Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions changelog/fix-1195-pending-refund
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Significance: minor
Type: fix

Handle pending refunds properly
7 changes: 4 additions & 3 deletions includes/class-wc-payment-gateway-wcpay.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
use WCPay\Constants\Intent_Status;
use WCPay\Constants\Payment_Type;
use WCPay\Constants\Payment_Method;
use WCPay\Constants\Refund_Status;
use WCPay\Exceptions\{Add_Payment_Method_Exception,
Amount_Too_Small_Exception,
API_Merchant_Exception,
Expand Down Expand Up @@ -2310,8 +2311,8 @@ static function ( $refund ) use ( $refund_amount ) {
// translators: %1$: order id.
return new WP_Error( 'wcpay_edit_order_refund_not_found', sprintf( __( 'A refund cannot be found for order: %1$s', 'woocommerce-payments' ), $order->get_id() ) );
}
// If the refund was successful, add a note to the order and update the refund status.
$this->order_service->add_note_and_metadata_for_refund( $order, $wc_refund, $refund['id'], $refund['balance_transaction'] ?? null );
// There is no error. Refund status can be either pending or succeeded, add a note to the order and update the refund status.
$this->order_service->add_note_and_metadata_for_created_refund( $order, $wc_refund, $refund['id'], $refund['balance_transaction'] ?? null, Refund_Status::PENDING === $refund['status'] );

return true;
}
Expand All @@ -2323,7 +2324,7 @@ static function ( $refund ) use ( $refund_amount ) {
* @return boolean
*/
public function has_refund_failed( $order ) {
return 'failed' === $this->order_service->get_wcpay_refund_status_for_order( $order );
return Refund_Status::FAILED === $this->order_service->get_wcpay_refund_status_for_order( $order );
}

/**
Expand Down
94 changes: 78 additions & 16 deletions includes/class-wc-payments-order-service.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
use WCPay\Constants\Order_Status;
use WCPay\Constants\Intent_Status;
use WCPay\Constants\Payment_Method;
use WCPay\Constants\Refund_Status;
use WCPay\Exceptions\Order_Not_Found_Exception;
use WCPay\Fraud_Prevention\Models\Rule;
use WCPay\Logger;
Expand Down Expand Up @@ -1446,18 +1447,19 @@ public function create_refund_for_order( WC_Order $order, float $amount, string
* @param WC_Order_Refund $wc_refund The WC refund object.
* @param string $refund_id The refund ID.
* @param string|null $refund_balance_transaction_id The balance transaction ID of the refund.
* @param bool $is_pending Created refund status can be either pending or succeeded. Default false, i.e. succeeded.
* @throws Order_Not_Found_Exception
* @throws Exception
*/
public function add_note_and_metadata_for_refund( WC_Order $order, WC_Order_Refund $wc_refund, string $refund_id, ?string $refund_balance_transaction_id ): void {
$note = $this->generate_payment_refunded_note( $wc_refund->get_amount(), $wc_refund->get_currency(), $refund_id, $wc_refund->get_reason(), $order );
public function add_note_and_metadata_for_created_refund( WC_Order $order, WC_Order_Refund $wc_refund, string $refund_id, ?string $refund_balance_transaction_id, bool $is_pending = false ): void {
$note = $this->generate_payment_created_refund_note( $wc_refund->get_amount(), $wc_refund->get_currency(), $refund_id, $wc_refund->get_reason(), $order, $is_pending );

if ( ! $this->order_note_exists( $order, $note ) ) {
$order->add_order_note( $note );
}

// Set refund metadata.
$this->set_wcpay_refund_status_for_order( $order, 'successful' );
// Use `successful` to maintain the backward compatibility with the previous WooPayments versions.
$this->set_wcpay_refund_status_for_order( $order, $is_pending ? Refund_Status::PENDING : 'successful' );
$this->set_wcpay_refund_id_for_refund( $wc_refund, $refund_id );
if ( isset( $refund_balance_transaction_id ) ) {
$this->set_wcpay_refund_transaction_id_for_order( $wc_refund, $refund_balance_transaction_id );
Expand All @@ -1466,6 +1468,55 @@ public function add_note_and_metadata_for_refund( WC_Order $order, WC_Order_Refu
$order->save();
}

/**
* Handle a failed refund by adding a note, updating metadata, and optionally deleting the refund.
*
* @param WC_Order $order The order to add the note to.
* @param string $refund_id The ID of the failed refund.
* @param int $amount The refund amount in cents.
* @param string $currency The currency code.
* @param WC_Order_Refund|null $wc_refund The WC refund object to delete if provided.
* @param bool $is_cancelled Whether this is a cancellation rather than a failure. Default false.
* @return void
*/
public function handle_failed_refund( WC_Order $order, string $refund_id, int $amount, string $currency, ?WC_Order_Refund $wc_refund = null, bool $is_cancelled = false ): void {
// Delete the refund if it exists.
if ( $wc_refund ) {
$wc_refund->delete();
}

$note = sprintf(
WC_Payments_Utils::esc_interpolated_html(
/* translators: %1: the refund amount, %2: WooPayments, %3: ID of the refund */
__( 'A refund of %1$s was <strong>%4$s</strong> using %2$s (<code>%3$s</code>).', 'woocommerce-payments' ),
[
'strong' => '<strong>',
'code' => '<code>',
]
),
WC_Payments_Explicit_Price_Formatter::get_explicit_price(
wc_price( WC_Payments_Utils::interpret_stripe_amount( $amount, $currency ), [ 'currency' => strtoupper( $currency ) ] ),
$order
),
'WooPayments',
$refund_id,
$is_cancelled ? __( 'cancelled', 'woocommerce-payments' ) : __( 'unsuccessful', 'woocommerce-payments' )
);

if ( $this->order_note_exists( $order, $note ) ) {
return;
}

// If order has been fully refunded.
if ( Order_Status::REFUNDED === $order->get_status() ) {
$order->update_status( Order_Status::FAILED );
}

$order->add_order_note( $note );
$this->set_wcpay_refund_status_for_order( $order, Refund_Status::FAILED );
$order->save();
}

/**
* Get content for the success order note.
*
Expand Down Expand Up @@ -1872,43 +1923,54 @@ private function generate_dispute_closed_note( $charge_id, $status, $is_inquiry
/**
* Generates the HTML note for a refunded payment.
*
* @param float $refunded_amount Amount refunded.
* @param string $refunded_currency Refund currency.
* @param string $wcpay_refund_id WCPay Refund ID.
* @param string $refund_reason Refund reason.
* @param WC_Order $order Order object.
* @param float $refunded_amount Amount refunded.
* @param string $refunded_currency Refund currency.
* @param string $wcpay_refund_id WCPay Refund ID.
* @param string $refund_reason Refund reason.
* @param WC_Order $order Order object.
* @param bool $is_pending Created refund status can be either pending or succeeded. Default false, i.e. succeeded.
*
* @return string HTML note.
*/
private function generate_payment_refunded_note( float $refunded_amount, string $refunded_currency, string $wcpay_refund_id, string $refund_reason, WC_Order $order ): string {
private function generate_payment_created_refund_note( float $refunded_amount, string $refunded_currency, string $wcpay_refund_id, string $refund_reason, WC_Order $order, bool $is_pending ): string {
$multi_currency_instance = WC_Payments_Multi_Currency();
$formatted_price = WC_Payments_Explicit_Price_Formatter::get_explicit_price( $multi_currency_instance->get_backend_formatted_wc_price( $refunded_amount, [ 'currency' => strtoupper( $refunded_currency ) ] ), $order );

$status_text = $is_pending ?
sprintf(
'<a href="https://woocommerce.com/document/woopayments/managing-money/#pending-refunds" target="_blank" rel="noopener noreferrer">%1$s</a>',
__( 'is pending', 'woocommerce-payments' )
)
: __( 'was successfully processed', 'woocommerce-payments' );

if ( empty( $refund_reason ) ) {
$note = sprintf(
WC_Payments_Utils::esc_interpolated_html(
/* translators: %1: the refund amount, %2: WooPayments, %3: ID of the refund */
__( 'A refund of %1$s was successfully processed using %2$s (<code>%3$s</code>).', 'woocommerce-payments' ),
/* translators: %1: the refund amount, %2: WooPayments, %3: ID of the refund, %4: status text */
__( 'A refund of %1$s %4$s using %2$s (<code>%3$s</code>).', 'woocommerce-payments' ),
[
'code' => '<code>',
]
),
$formatted_price,
'WooPayments',
$wcpay_refund_id
$wcpay_refund_id,
$status_text
);
} else {
$note = sprintf(
WC_Payments_Utils::esc_interpolated_html(
/* translators: %1: the successfully charged amount, %2: WooPayments, %3: reason, %4: refund id */
__( 'A refund of %1$s was successfully processed using %2$s. Reason: %3$s. (<code>%4$s</code>)', 'woocommerce-payments' ),
/* translators: %1: the refund amount, %2: WooPayments, %3: reason, %4: refund id, %5: status text */
__( 'A refund of %1$s %5$s using %2$s. Reason: %3$s. (<code>%4$s</code>)', 'woocommerce-payments' ),
[
'code' => '<code>',
]
),
$formatted_price,
'WooPayments',
$refund_reason,
$wcpay_refund_id
$wcpay_refund_id,
$status_text
);
}

Expand Down
90 changes: 34 additions & 56 deletions includes/class-wc-payments-webhook-processing-service.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
use WCPay\Exceptions\Order_Not_Found_Exception;
use WCPay\Exceptions\Rest_Request_Exception;
use WCPay\Logger;
use WCPay\Constants\Refund_Status;

if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly.
Expand Down Expand Up @@ -248,17 +249,15 @@ private function process_webhook_refund_updated( $event_body ) {
$event_data = $this->read_webhook_property( $event_body, 'data' );
$event_object = $this->read_webhook_property( $event_data, 'object' );

// First, check the reason for the update. We're only interested in a status of failed.
$status = $this->read_webhook_property( $event_object, 'status' );
if ( 'failed' !== $status ) {
return;
}

// Fetch the details of the failed refund so that we can find the associated order and write a note.
$charge_id = $this->read_webhook_property( $event_object, 'charge' );
$refund_id = $this->read_webhook_property( $event_object, 'id' );
$amount = $this->read_webhook_property( $event_object, 'amount' );
$currency = $this->read_webhook_property( $event_object, 'currency' );
$charge_id = $this->read_webhook_property( $event_object, 'charge' );
$refund_id = $this->read_webhook_property( $event_object, 'id' );
$amount = $this->read_webhook_property( $event_object, 'amount' );
$currency = $this->read_webhook_property( $event_object, 'currency' );
$status = $this->read_webhook_property( $event_object, 'status' );
$balance_transaction = $this->has_webhook_property( $event_object, 'balance_transaction' )
? $this->read_webhook_property( $event_object, 'balance_transaction' )
: null;

// Look up the order related to this charge.
$order = $this->wcpay_db->order_from_charge_id( $charge_id );
Expand All @@ -273,29 +272,9 @@ private function process_webhook_refund_updated( $event_body ) {
);
}

$note = sprintf(
WC_Payments_Utils::esc_interpolated_html(
/* translators: %1: the refund amount, %2: WooPayments, %3: ID of the refund */
__( 'A refund of %1$s was <strong>unsuccessful</strong> using %2$s (<code>%3$s</code>).', 'woocommerce-payments' ),
[
'strong' => '<strong>',
'code' => '<code>',
]
),
WC_Payments_Explicit_Price_Formatter::get_explicit_price(
wc_price( WC_Payments_Utils::interpret_stripe_amount( $amount, $currency ), [ 'currency' => strtoupper( $currency ) ] ),
$order
),
'WooPayments',
$refund_id
);

if ( $this->order_service->order_note_exists( $order, $note ) ) {
return;
}

$matched_wc_refund = null;
/**
* Get refunds from order and delete refund if matches wcpay refund id.
* Get the WC_Refund from the WCPay refund ID.
*
* @var $wc_refunds WC_Order_Refund[]
* */
Expand All @@ -304,34 +283,33 @@ private function process_webhook_refund_updated( $event_body ) {
foreach ( $wc_refunds as $wc_refund ) {
$wcpay_refund_id = $this->order_service->get_wcpay_refund_id_for_order( $wc_refund );
if ( $refund_id === $wcpay_refund_id ) {
// Delete WC Refund.
$wc_refund->delete();
$matched_wc_refund = $wc_refund;
break;
}
}
}

// Update order status if order is fully refunded.
$current_order_status = $order->get_status();
if ( Order_Status::REFUNDED === $current_order_status ) {
$order->update_status( Order_Status::FAILED );
}

$order->add_order_note( $note );
$this->order_service->set_wcpay_refund_status_for_order( $order, 'failed' );
$order->save();

try {
$failure_reason = $this->read_webhook_property( $event_object, 'failure_reason' );

if ( 'insufficient_funds' === $failure_reason ) {
$this->order_service->handle_insufficient_balance_for_refund(
$order,
$amount
);
}
} catch ( Exception $e ) {
Logger::debug( 'Failed to handle insufficient balance for refund: ' . $e->getMessage() );
// Refund update webhook events can be either failed, cancelled (basically it's also a failure but triggered by the merchant), succeeded only.
switch ( $status ) {
case Refund_Status::FAILED:
$this->order_service->handle_failed_refund( $order, $refund_id, $amount, $currency, $matched_wc_refund );
if (
$this->has_webhook_property( $event_object, 'failure_reason' )
&& 'insufficient_funds' === $this->read_webhook_property( $event_object, 'failure_reason' )
) {
$this->order_service->handle_insufficient_balance_for_refund( $order, $amount );
}
break;
case Refund_Status::CANCELED:
$this->order_service->handle_failed_refund( $order, $refund_id, $amount, $currency, $matched_wc_refund, true );
break;
case Refund_Status::SUCCEEDED:
if ( $matched_wc_refund ) {
$this->order_service->add_note_and_metadata_for_created_refund( $order, $matched_wc_refund, $refund_id, $balance_transaction ?? null );
}
break;
default:
throw new Invalid_Webhook_Data_Exception( 'Invalid refund update status: ' . $status );
}
}

Expand Down Expand Up @@ -883,7 +861,7 @@ private function process_webhook_refund_triggered_externally( array $event_body

$wc_refund = $this->order_service->create_refund_for_order( $order, $refunded_amount, $refund_reason, ( ! $is_partial_refund ? $order->get_items() : [] ) );
// Process the refund in the order service.
$this->order_service->add_note_and_metadata_for_refund( $order, $wc_refund, $refund_id, $refund_balance_transaction_id );
$this->order_service->add_note_and_metadata_for_created_refund( $order, $wc_refund, $refund_id, $refund_balance_transaction_id, Refund_Status::PENDING === $refund['status'] );
}

/**
Expand Down
1 change: 1 addition & 0 deletions includes/class-wc-payments.php
Original file line number Diff line number Diff line change
Expand Up @@ -481,6 +481,7 @@ public static function init() {
include_once __DIR__ . '/constants/class-payment-type.php';
include_once __DIR__ . '/constants/class-payment-initiated-by.php';
include_once __DIR__ . '/constants/class-intent-status.php';
include_once __DIR__ . '/constants/class-refund-status.php';
include_once __DIR__ . '/constants/class-payment-intent-status.php';
include_once __DIR__ . '/constants/class-payment-capture-type.php';
include_once __DIR__ . '/constants/class-payment-method.php';
Expand Down
24 changes: 24 additions & 0 deletions includes/constants/class-refund-status.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?php
/**
* Class Refund_Status
*
* @package WooCommerce\Payments
*/

namespace WCPay\Constants;

if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly.
}

/**
* This class gives a list of all the possible WCPay refund status constants.
*
* @psalm-immutable
*/
class Refund_Status extends Base_Constant {
const PENDING = 'pending';
const SUCCEEDED = 'succeeded';
const FAILED = 'failed';
const CANCELED = 'canceled';
}
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,7 @@ public function test_process_refund_should_work_without_payment_method_id_meta()
'id' => 're_123456789',
'amount' => $amount,
'currency' => 'usd',
'status' => 'succeeded',
]
);
$request = $this->mock_wcpay_request( Refund_Charge::class );
Expand Down
Loading
Loading