diff --git a/changelog/fix-1195-pending-refund b/changelog/fix-1195-pending-refund
new file mode 100644
index 00000000000..4fa85762aff
--- /dev/null
+++ b/changelog/fix-1195-pending-refund
@@ -0,0 +1,4 @@
+Significance: minor
+Type: fix
+
+Handle pending refunds properly
diff --git a/includes/class-wc-payment-gateway-wcpay.php b/includes/class-wc-payment-gateway-wcpay.php
index 761dcae591a..7ebeb41ef7f 100644
--- a/includes/class-wc-payment-gateway-wcpay.php
+++ b/includes/class-wc-payment-gateway-wcpay.php
@@ -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,
@@ -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;
}
@@ -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 );
}
/**
diff --git a/includes/class-wc-payments-order-service.php b/includes/class-wc-payments-order-service.php
index 1a2dd532c63..23b6a9cc2a7 100644
--- a/includes/class-wc-payments-order-service.php
+++ b/includes/class-wc-payments-order-service.php
@@ -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;
@@ -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 );
@@ -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 %4$s using %2$s (%3$s
).', 'woocommerce-payments' ),
+ [
+ 'strong' => '',
+ '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.
*
@@ -1872,35 +1923,45 @@ 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(
+ '%1$s',
+ __( '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 (%3$s
).', '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 (%3$s
).', 'woocommerce-payments' ),
[
'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. (%4$s
)', '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. (%4$s
)', 'woocommerce-payments' ),
[
'code' => '',
]
@@ -1908,7 +1969,8 @@ private function generate_payment_refunded_note( float $refunded_amount, string
$formatted_price,
'WooPayments',
$refund_reason,
- $wcpay_refund_id
+ $wcpay_refund_id,
+ $status_text
);
}
diff --git a/includes/class-wc-payments-webhook-processing-service.php b/includes/class-wc-payments-webhook-processing-service.php
index 402a6593be2..3ce4258b21e 100644
--- a/includes/class-wc-payments-webhook-processing-service.php
+++ b/includes/class-wc-payments-webhook-processing-service.php
@@ -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.
@@ -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 );
@@ -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 unsuccessful using %2$s (%3$s
).', 'woocommerce-payments' ),
- [
- 'strong' => '',
- '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[]
* */
@@ -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 );
}
}
@@ -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'] );
}
/**
diff --git a/includes/class-wc-payments.php b/includes/class-wc-payments.php
index 41759213ddf..3e9ea9c6fba 100644
--- a/includes/class-wc-payments.php
+++ b/includes/class-wc-payments.php
@@ -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';
diff --git a/includes/constants/class-refund-status.php b/includes/constants/class-refund-status.php
new file mode 100644
index 00000000000..a0a16d8fa3c
--- /dev/null
+++ b/includes/constants/class-refund-status.php
@@ -0,0 +1,24 @@
+ 're_123456789',
'amount' => $amount,
'currency' => 'usd',
+ 'status' => 'succeeded',
]
);
$request = $this->mock_wcpay_request( Refund_Charge::class );
diff --git a/tests/unit/test-class-wc-payments-order-service.php b/tests/unit/test-class-wc-payments-order-service.php
index 1ab8593f879..3e507472fdf 100644
--- a/tests/unit/test-class-wc-payments-order-service.php
+++ b/tests/unit/test-class-wc-payments-order-service.php
@@ -10,6 +10,7 @@
use WCPay\Constants\Intent_Status;
use WCPay\Constants\Payment_Method;
use WCPay\Fraud_Prevention\Models\Rule;
+use WCPay\Constants\Refund_Status;
/**
* WC_Payments_Order_Service unit tests.
@@ -1394,7 +1395,7 @@ public function test_attach_transaction_fee_to_order_null_fee() {
$this->order_service->attach_transaction_fee_to_order( $mock_order, new WC_Payments_API_Charge( 'ch_mock', 1500, new DateTime(), null, null, null, null, null, [], [], 'eur' ) );
}
- public function test_add_note_and_metadata_for_refund_fully_refunded(): void {
+ public function test_add_note_and_metadata_for_created_refund_successful_fully_refunded(): void {
$order = WC_Helper_Order::create_order();
$order->save();
@@ -1405,12 +1406,13 @@ public function test_add_note_and_metadata_for_refund_fully_refunded(): void {
$wc_refund = $this->order_service->create_refund_for_order( $order, $refunded_amount, $refund_reason, $order->get_items() );
- $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 );
$order_note = wc_get_order_notes( [ 'order_id' => $order->get_id() ] )[0]->content;
$this->assertStringContainsString( $refunded_amount, $order_note, 'Order note does not contain expected refund amount' );
$this->assertStringContainsString( $refund_id, $order_note, 'Order note does not contain expected refund id' );
$this->assertStringContainsString( $refund_reason, $order_note, 'Order note does not contain expected refund reason' );
+ $this->assertStringContainsString( 'was successfully processed', $order_note, 'Order note should indicate successful processing' );
$this->assertSame( 'successful', $order->get_meta( WC_Payments_Order_Service::WCPAY_REFUND_STATUS_META_KEY, true ) );
$this->assertSame( $refund_id, $wc_refund->get_meta( WC_Payments_Order_Service::WCPAY_REFUND_ID_META_KEY, true ) );
@@ -1419,7 +1421,7 @@ public function test_add_note_and_metadata_for_refund_fully_refunded(): void {
WC_Helper_Order::delete_order( $order->get_id() );
}
- public function test_add_note_and_metadata_for_refund_partially_refunded(): void {
+ public function test_add_note_and_metadata_for_created_refund_successful_partially_refunded(): void {
$order = WC_Helper_Order::create_order();
$order->save();
@@ -1429,7 +1431,7 @@ public function test_add_note_and_metadata_for_refund_partially_refunded(): void
$refund_balance_transaction_id = 'txn_1J2a3B4c5D6e7F8g9H0';
$wc_refund = $this->order_service->create_refund_for_order( $order, $refunded_amount, $refund_reason, $order->get_items() );
- $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 );
$this->assertSame( Order_Status::PENDING, $order->get_status() );
@@ -1437,6 +1439,7 @@ public function test_add_note_and_metadata_for_refund_partially_refunded(): void
$this->assertStringContainsString( $refunded_amount, $order_note, 'Order note does not contain expected refund amount' );
$this->assertStringContainsString( $refund_id, $order_note, 'Order note does not contain expected refund id' );
$this->assertStringContainsString( $refund_reason, $order_note, 'Order note does not contain expected refund reason' );
+ $this->assertStringContainsString( 'was successfully processed', $order_note, 'Order note should indicate successful processing' );
$this->assertSame( 'successful', $order->get_meta( WC_Payments_Order_Service::WCPAY_REFUND_STATUS_META_KEY, true ) );
$this->assertSame( $refund_id, $wc_refund->get_meta( WC_Payments_Order_Service::WCPAY_REFUND_ID_META_KEY, true ) );
@@ -1445,6 +1448,57 @@ public function test_add_note_and_metadata_for_refund_partially_refunded(): void
WC_Helper_Order::delete_order( $order->get_id() );
}
+ public function test_add_note_and_metadata_for_created_refund_pending(): void {
+ $order = WC_Helper_Order::create_order();
+ $order->save();
+
+ $refunded_amount = 50;
+ $refund_id = 're_1J2a3B4c5D6e7F8g9H0';
+ $refund_reason = 'Test refund';
+ $refund_balance_transaction_id = 'txn_1J2a3B4c5D6e7F8g9H0';
+
+ $wc_refund = $this->order_service->create_refund_for_order( $order, $refunded_amount, $refund_reason, $order->get_items() );
+
+ $this->order_service->add_note_and_metadata_for_created_refund( $order, $wc_refund, $refund_id, $refund_balance_transaction_id, true );
+
+ $order_note = wc_get_order_notes( [ 'order_id' => $order->get_id() ] )[0]->content;
+ $this->assertStringContainsString( $refunded_amount, $order_note, 'Order note does not contain expected refund amount' );
+ $this->assertStringContainsString( $refund_id, $order_note, 'Order note does not contain expected refund id' );
+ $this->assertStringContainsString( $refund_reason, $order_note, 'Order note does not contain expected refund reason' );
+ $this->assertStringContainsString( 'is pending', $order_note, 'Order note should indicate pending status' );
+ $this->assertStringContainsString( 'https://woocommerce.com/document/woopayments/managing-money/#pending-refunds', $order_note, 'Order note should contain link to pending refunds documentation' );
+
+ $this->assertSame( Refund_Status::PENDING, $order->get_meta( WC_Payments_Order_Service::WCPAY_REFUND_STATUS_META_KEY, true ) );
+ $this->assertSame( $refund_id, $wc_refund->get_meta( WC_Payments_Order_Service::WCPAY_REFUND_ID_META_KEY, true ) );
+ $this->assertSame( $refund_balance_transaction_id, $order->get_refunds()[0]->get_meta( WC_Payments_Order_Service::WCPAY_REFUND_TRANSACTION_ID_META_KEY, true ) );
+
+ WC_Helper_Order::delete_order( $order->get_id() );
+ }
+
+ public function test_add_note_and_metadata_for_created_refund_no_duplicate_notes(): void {
+ $order = WC_Helper_Order::create_order();
+ $order->save();
+
+ $refunded_amount = 50;
+ $refund_id = 're_1J2a3B4c5D6e7F8g9H0';
+ $refund_reason = 'Test refund';
+ $refund_balance_transaction_id = 'txn_1J2a3B4c5D6e7F8g9H0';
+
+ $wc_refund = $this->order_service->create_refund_for_order( $order, $refunded_amount, $refund_reason, $order->get_items() );
+
+ // Add note first time.
+ $this->order_service->add_note_and_metadata_for_created_refund( $order, $wc_refund, $refund_id, $refund_balance_transaction_id );
+ $initial_notes_count = count( wc_get_order_notes( [ 'order_id' => $order->get_id() ] ) );
+
+ // Add note second time.
+ $this->order_service->add_note_and_metadata_for_created_refund( $order, $wc_refund, $refund_id, $refund_balance_transaction_id );
+ $final_notes_count = count( wc_get_order_notes( [ 'order_id' => $order->get_id() ] ) );
+
+ $this->assertSame( $initial_notes_count, $final_notes_count, 'Duplicate notes should not be added' );
+
+ WC_Helper_Order::delete_order( $order->get_id() );
+ }
+
public function test_process_captured_payment() {
$order = WC_Helper_Order::create_order();
$order->save();
@@ -1473,4 +1527,165 @@ public function test_process_captured_payment() {
$notes_2 = wc_get_order_notes( [ 'order_id' => $order->get_id() ] );
$this->assertEquals( count( $notes ), count( $notes_2 ) );
}
+
+ /**
+ * Tests handling of failed refunds.
+ *
+ * @dataProvider provider_handle_failed_refund
+ */
+ public function test_handle_failed_refund( string $initial_order_status, bool $has_refund, bool $expect_status_change ): void {
+ // Arrange: Create order and optionally add a refund.
+ $order = WC_Helper_Order::create_order();
+ $wc_refund = null;
+ if ( $has_refund ) {
+ $wc_refund = $this->order_service->create_refund_for_order( $order, $order->get_total(), 'Test refund reason', $order->get_items() );
+ }
+ $order->set_status( $initial_order_status );
+ $order->save();
+
+ $refund_id = 're_123456789';
+ $amount = 1000; // $10.00
+ $currency = 'usd';
+
+ // Act: Handle the failed refund.
+ $this->order_service->handle_failed_refund( $order, $refund_id, $amount, $currency, $wc_refund );
+
+ // Assert: Check order status was updated if needed.
+ if ( $expect_status_change ) {
+ $this->assertTrue( $order->has_status( Order_Status::FAILED ) );
+ } else {
+ $this->assertTrue( $order->has_status( $initial_order_status ) );
+ }
+
+ // Assert: Check refund status was set to failed.
+ $this->assertSame( Refund_Status::FAILED, $this->order_service->get_wcpay_refund_status_for_order( $order ) );
+
+ // Assert: Check order note was added.
+ $notes = wc_get_order_notes( [ 'order_id' => $order->get_id() ] );
+ $this->assertStringContainsString( 'unsuccessful', $notes[0]->content );
+ $this->assertStringContainsString( $refund_id, $notes[0]->content );
+
+ // Assert: If refund existed, it was deleted.
+ if ( $has_refund ) {
+ $this->assertEmpty( $order->get_refunds() );
+ }
+
+ WC_Helper_Order::delete_order( $order->get_id() );
+ }
+
+ public function provider_handle_failed_refund(): array {
+ return [
+ 'Order not refunded - no status change' => [
+ 'initial_order_status' => Order_Status::PROCESSING,
+ 'has_refund' => false,
+ 'expect_status_change' => false,
+ ],
+ 'Order fully refunded - status changes to failed' => [
+ 'initial_order_status' => Order_Status::REFUNDED,
+ 'has_refund' => true,
+ 'expect_status_change' => true,
+ ],
+ 'Order partially refunded - no status change' => [
+ 'initial_order_status' => Order_Status::PROCESSING,
+ 'has_refund' => true,
+ 'expect_status_change' => false,
+ ],
+ ];
+ }
+
+ /**
+ * Tests that handle_failed_refund doesn't add duplicate notes.
+ */
+ public function test_handle_failed_refund_no_duplicate_notes(): void {
+ // Arrange: Create order and handle failed refund twice.
+ $order = WC_Helper_Order::create_order();
+ $order->save();
+
+ $refund_id = 're_123456789';
+ $amount = 1000;
+ $currency = 'usd';
+
+ $this->order_service->handle_failed_refund( $order, $refund_id, $amount, $currency );
+ $initial_notes_count = count( wc_get_order_notes( [ 'order_id' => $order->get_id() ] ) );
+
+ $this->order_service->handle_failed_refund( $order, $refund_id, $amount, $currency );
+ $final_notes_count = count( wc_get_order_notes( [ 'order_id' => $order->get_id() ] ) );
+
+ // Assert: No duplicate notes were added.
+ $this->assertSame( $initial_notes_count, $final_notes_count );
+
+ WC_Helper_Order::delete_order( $order->get_id() );
+ }
+
+ /**
+ * Tests that handle_failed_refund adds the correct note for cancelled refunds.
+ */
+ public function test_handle_failed_refund_cancelled(): void {
+ // Arrange: Create order and handle cancelled refund.
+ $order = WC_Helper_Order::create_order();
+ $order->save();
+
+ $refund_id = 're_123456789';
+ $amount = 1000;
+ $currency = 'usd';
+
+ // Act: Handle the cancelled refund.
+ $this->order_service->handle_failed_refund( $order, $refund_id, $amount, $currency, null, true );
+
+ // Assert: Check order note was added with cancelled status.
+ $notes = wc_get_order_notes( [ 'order_id' => $order->get_id() ] );
+ $this->assertStringContainsString( 'cancelled', $notes[0]->content );
+ $this->assertStringContainsString( $refund_id, $notes[0]->content );
+
+ // Assert: Check refund status was set to failed.
+ $this->assertSame( Refund_Status::FAILED, $this->order_service->get_wcpay_refund_status_for_order( $order ) );
+
+ WC_Helper_Order::delete_order( $order->get_id() );
+ }
+
+ /**
+ * Tests that handle_failed_refund doesn't add duplicate notes for cancelled refunds.
+ */
+ public function test_handle_failed_refund_cancelled_no_duplicate_notes(): void {
+ // Arrange: Create order and handle cancelled refund twice.
+ $order = WC_Helper_Order::create_order();
+ $order->save();
+
+ $refund_id = 're_123456789';
+ $amount = 1000;
+ $currency = 'usd';
+
+ $this->order_service->handle_failed_refund( $order, $refund_id, $amount, $currency, null, true );
+ $initial_notes_count = count( wc_get_order_notes( [ 'order_id' => $order->get_id() ] ) );
+
+ $this->order_service->handle_failed_refund( $order, $refund_id, $amount, $currency, null, true );
+ $final_notes_count = count( wc_get_order_notes( [ 'order_id' => $order->get_id() ] ) );
+
+ // Assert: No duplicate notes were added.
+ $this->assertSame( $initial_notes_count, $final_notes_count );
+
+ WC_Helper_Order::delete_order( $order->get_id() );
+ }
+
+ /**
+ * Tests that handle_failed_refund updates order status to failed when fully refunded.
+ */
+ public function test_handle_failed_refund_cancelled_updates_order_status(): void {
+ // Arrange: Create order and set it to refunded status.
+ $order = WC_Helper_Order::create_order();
+ $order->set_status( Order_Status::REFUNDED );
+ $order->save();
+
+ $refund_id = 're_123456789';
+ $amount = 1000;
+ $currency = 'usd';
+
+ // Act: Handle the cancelled refund.
+ $this->order_service->handle_failed_refund( $order, $refund_id, $amount, $currency, null, true );
+
+ // Assert: Order status was updated to failed.
+ $this->assertTrue( $order->has_status( Order_Status::FAILED ) );
+
+ WC_Helper_Order::delete_order( $order->get_id() );
+ }
}
diff --git a/tests/unit/test-class-wc-payments-webhook-processing-service.php b/tests/unit/test-class-wc-payments-webhook-processing-service.php
index c9b21cde846..399e7d59173 100644
--- a/tests/unit/test-class-wc-payments-webhook-processing-service.php
+++ b/tests/unit/test-class-wc-payments-webhook-processing-service.php
@@ -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\Database_Cache;
use WCPay\Exceptions\Invalid_Payment_Method_Exception;
use WCPay\Exceptions\Invalid_Webhook_Data_Exception;
@@ -41,7 +42,7 @@ class WC_Payments_Webhook_Processing_Service_Test extends WCPAY_UnitTestCase {
private $mock_remote_note_service;
/**
- * @var WC_Payments_Order_Service
+ * @var WC_Payments_Order_Service|MockObject
*/
private $order_service;
@@ -98,7 +99,16 @@ public function set_up() {
$this->order_service = $this->getMockBuilder( 'WC_Payments_Order_Service' )
->setConstructorArgs( [ $this->createMock( WC_Payments_API_Client::class ) ] )
- ->setMethods( [ 'get_wcpay_refund_id_for_order', 'add_note_and_metadata_for_refund', 'create_refund_for_order', 'mark_terminal_payment_failed', 'handle_insufficient_balance_for_refund' ] )
+ ->setMethods(
+ [
+ 'get_wcpay_refund_id_for_order',
+ 'add_note_and_metadata_for_created_refund',
+ 'create_refund_for_order',
+ 'mark_terminal_payment_failed',
+ 'handle_insufficient_balance_for_refund',
+ 'handle_failed_refund',
+ ]
+ )
->getMock();
$this->mock_db_wrapper = $this->getMockBuilder( WC_Payments_DB::class )
@@ -142,7 +152,6 @@ public function set_up() {
->expects( $this->any() )
->method( 'get_id' )
->willReturn( 1234 );
-
WC_Payments::mode()->live();
}
@@ -259,9 +268,9 @@ public function test_webhook_with_no_data_property() {
}
/**
- * Test a valid refund sets failed meta.
+ * Test a failed refund without matched WC Refunds.
*/
- public function test_valid_failed_refund_webhook_sets_failed_meta() {
+ public function test_failed_refund_update_webhook_without_matched_wc_refund() {
// Setup test request data.
$this->event_body['type'] = 'charge.refund.updated';
$this->event_body['livemode'] = true;
@@ -273,36 +282,25 @@ public function test_valid_failed_refund_webhook_sets_failed_meta() {
'currency' => 'gbp',
];
- $this->mock_order->method( 'get_currency' )->willReturn( 'GBP' );
-
- $this->mock_order
- ->expects( $this->once() )
- ->method( 'add_order_note' )
- ->with(
- 'A refund of £9.99 was unsuccessful using WooPayments (test_refund_id
).'
- );
-
- // The expects condition here is the real test; we expect that the 'update_meta_data' function
- // is called with the appropriate values.
- $this->mock_order
- ->expects( $this->once() )
- ->method( 'update_meta_data' )
- ->with( '_wcpay_refund_status', 'failed' );
-
$this->mock_db_wrapper
->expects( $this->once() )
->method( 'order_from_charge_id' )
->with( 'test_charge_id' )
->willReturn( $this->mock_order );
+ $this->order_service
+ ->expects( $this->once() )
+ ->method( 'handle_failed_refund' )
+ ->with( $this->mock_order, 'test_refund_id', 999, 'gbp', null );
+
// Run the test.
$this->webhook_processing_service->process( $this->event_body );
}
/**
- * Test a vaild refund failure deletes WooCommerce Refund.
+ * Test a failed refund with matched WC Refunds.
*/
- public function test_valid_failed_refund_webhook_deletes_wc_refund() {
+ public function test_failed_refund_update_webhook_with_matched_wc_refund() {
// Setup test request data.
$this->event_body['type'] = 'charge.refund.updated';
$this->event_body['livemode'] = true;
@@ -340,130 +338,159 @@ public function test_valid_failed_refund_webhook_deletes_wc_refund() {
->with( 'test_charge_id' )
->willReturn( $this->mock_order );
- $mock_refund_1
- ->expects( $this->never() )
- ->method( 'delete' );
-
- $mock_refund_2
+ $this->order_service
->expects( $this->once() )
- ->method( 'delete' );
+ ->method( 'handle_failed_refund' )
+ ->with( $this->mock_order, 'test_refund_id', 999, 'usd', $mock_refund_2 );
// Run the test.
$this->webhook_processing_service->process( $this->event_body );
}
/**
- * Test a valid refund does not set failed meta.
+ * Test a refund update with status `cancelled`
*/
- public function test_non_failed_refund_update_webhook_does_not_set_failed_meta() {
+ public function test_cancelled_refund_update_webhook_with_matched_wc_refund() {
// Setup test request data.
$this->event_body['type'] = 'charge.refund.updated';
$this->event_body['livemode'] = true;
$this->event_body['data']['object'] = [
- 'status' => 'success',
+ 'status' => Refund_Status::CANCELED,
+ 'charge' => 'test_charge_id',
+ 'id' => 'test_refund_id',
+ 'amount' => 999,
+ 'currency' => 'usd',
];
+ $mock_refund_1 = $this->createMock( WC_Order_Refund::class );
+ $mock_refund_2 = $this->createMock( WC_Order_Refund::class );
+ $this->order_service
+ ->expects( $this->exactly( 2 ) )
+ ->method( 'get_wcpay_refund_id_for_order' )
+ ->withConsecutive(
+ [ $mock_refund_1 ],
+ [ $mock_refund_2 ]
+ )->willReturnOnConsecutiveCalls(
+ 'another_test_refund_id',
+ 'test_refund_id'
+ );
+
+ $this->mock_order->method( 'get_refunds' )->willReturn(
+ [
+ $mock_refund_1,
+ $mock_refund_2,
+ ]
+ );
+
$this->mock_db_wrapper
- ->expects( $this->never() )
- ->method( 'order_from_charge_id' );
+ ->expects( $this->once() )
+ ->method( 'order_from_charge_id' )
+ ->with( 'test_charge_id' )
+ ->willReturn( $this->mock_order );
- // The expects condition here is the real test; we expect that the 'update_meta_data' function
- // is never called to update the meta data.
- $this->mock_order
- ->expects( $this->never() )
- ->method( 'update_meta_data' );
+ $this->order_service
+ ->expects( $this->once() )
+ ->method( 'handle_failed_refund' )
+ ->with( $this->mock_order, 'test_refund_id', 999, 'usd', $mock_refund_2, true );
// Run the test.
$this->webhook_processing_service->process( $this->event_body );
}
/**
- * Test a valid failed refund update webhook.
+ * Test a failed refund update webhook with insufficient funds.
*/
- public function test_valid_failed_refund_update_webhook() {
+ public function test_failed_refund_update_webhook_with_insufficient_funds() {
// Setup test request data.
$this->event_body['type'] = 'charge.refund.updated';
$this->event_body['livemode'] = true;
$this->event_body['data']['object'] = [
- 'status' => 'failed',
- 'charge' => 'test_charge_id',
- 'id' => 'test_refund_id',
- 'amount' => 999,
- 'currency' => 'gbp',
+ 'status' => 'failed',
+ 'charge' => 'charge_id',
+ 'id' => 'test_refund_id',
+ 'amount' => 999,
+ 'currency' => 'gbp',
+ 'failure_reason' => 'insufficient_funds',
];
- $this->mock_order->method( 'get_currency' )->willReturn( 'GBP' );
-
- $this->mock_order
- ->expects( $this->once() )
- ->method( 'add_order_note' )
- ->with(
- 'A refund of £9.99 was unsuccessful using WooPayments (test_refund_id
).'
- );
-
$this->mock_db_wrapper
->expects( $this->once() )
->method( 'order_from_charge_id' )
- ->with( 'test_charge_id' )
+ ->with( 'charge_id' )
->willReturn( $this->mock_order );
- // Run the test.
+ $this->order_service
+ ->expects( $this->once() )
+ ->method( 'handle_insufficient_balance_for_refund' )
+ ->with( $this->mock_order, 999 );
+
$this->webhook_processing_service->process( $this->event_body );
}
/**
- * Test a valid failed refund update webhook for non-USD.
+ * Test a refund does not set failed meta.
*/
- public function test_valid_failed_refund_update_webhook_non_usd() {
+ public function test_succeeded_refund_update_webhook_without_matched_wc_refund() {
// Setup test request data.
$this->event_body['type'] = 'charge.refund.updated';
$this->event_body['livemode'] = true;
$this->event_body['data']['object'] = [
- 'status' => 'failed',
+ 'status' => Refund_Status::SUCCEEDED,
'charge' => 'test_charge_id',
'id' => 'test_refund_id',
'amount' => 999,
- 'currency' => 'eur',
+ 'currency' => 'usd',
];
- $this->mock_order->method( 'get_currency' )->willReturn( 'GBP' );
-
- $this->mock_order
- ->expects( $this->once() )
- ->method( 'add_order_note' )
- ->with( 'A refund of €9.99 was unsuccessful using WooPayments (test_refund_id
).' );
-
$this->mock_db_wrapper
->expects( $this->once() )
->method( 'order_from_charge_id' )
->with( 'test_charge_id' )
->willReturn( $this->mock_order );
+ $this->order_service
+ ->expects( $this->never() )
+ ->method( 'add_note_and_metadata_for_created_refund' );
+
// Run the test.
$this->webhook_processing_service->process( $this->event_body );
}
/**
- * Test a valid failed refund update webhook for zero decimal currency.
+ * Test a valid failed refund update webhook.
*/
- public function test_valid_failed_refund_update_webhook_zero_decimal_currency() {
+ public function test_succeeded_refund_update_webhook_with_matched_wc_refund() {
// Setup test request data.
$this->event_body['type'] = 'charge.refund.updated';
$this->event_body['livemode'] = true;
$this->event_body['data']['object'] = [
- 'status' => 'failed',
- 'charge' => 'test_charge_id',
- 'id' => 'test_refund_id',
- 'amount' => 999,
- 'currency' => 'jpy',
+ 'status' => 'succeeded',
+ 'charge' => 'test_charge_id',
+ 'id' => 'test_refund_id',
+ 'amount' => 999,
+ 'currency' => 'usd',
+ 'balance_transaction' => 'txn_balance_transaction',
];
- $this->mock_order->method( 'get_currency' )->willReturn( 'GBP' );
+ $mock_refund_1 = $this->createMock( WC_Order_Refund::class );
+ $mock_refund_2 = $this->createMock( WC_Order_Refund::class );
+ $this->order_service
+ ->expects( $this->exactly( 2 ) )
+ ->method( 'get_wcpay_refund_id_for_order' )
+ ->withConsecutive(
+ [ $mock_refund_1 ],
+ [ $mock_refund_2 ]
+ )->willReturnOnConsecutiveCalls(
+ 'another_test_refund_id',
+ 'test_refund_id'
+ );
- $this->mock_order
- ->expects( $this->once() )
- ->method( 'add_order_note' )
- ->with( 'A refund of ¥999.00 was unsuccessful using WooPayments (test_refund_id
).' );
+ $this->mock_order->method( 'get_refunds' )->willReturn(
+ [
+ $mock_refund_1,
+ $mock_refund_2,
+ ]
+ );
$this->mock_db_wrapper
->expects( $this->once() )
@@ -471,14 +498,19 @@ public function test_valid_failed_refund_update_webhook_zero_decimal_currency()
->with( 'test_charge_id' )
->willReturn( $this->mock_order );
+ $this->order_service
+ ->expects( $this->once() )
+ ->method( 'add_note_and_metadata_for_created_refund' )
+ ->with( $this->mock_order, $mock_refund_2, 'test_refund_id', 'txn_balance_transaction' );
+
// Run the test.
$this->webhook_processing_service->process( $this->event_body );
}
/**
- * Test a valid failed refund update webhook with an unknown charge ID.
+ * Test a failed refund update webhook with an unknown charge ID.
*/
- public function test_valid_failed_refund_update_webhook_with_unknown_charge_id() {
+ public function test_failed_refund_update_webhook_with_unknown_charge_id() {
// Setup test request data.
$this->event_body['type'] = 'charge.refund.updated';
$this->event_body['livemode'] = true;
@@ -504,53 +536,28 @@ public function test_valid_failed_refund_update_webhook_with_unknown_charge_id()
}
/**
- * Test a valid failed refund update webhook with insufficient funds.
+ * Test an invalid status refund update webhook
*/
- public function test_valid_failed_refund_update_webhook_with_insufficient_funds() {
+ public function test_invalid_status_refund_update_webhook_throws_exceptions() {
// Setup test request data.
$this->event_body['type'] = 'charge.refund.updated';
$this->event_body['livemode'] = true;
$this->event_body['data']['object'] = [
- 'status' => 'failed',
- 'charge' => 'charge_id',
- 'id' => 'test_refund_id',
- 'amount' => 999,
- 'currency' => 'gbp',
- 'failure_reason' => 'insufficient_funds',
+ 'status' => 'invalid_status',
+ 'charge' => 'test_charge_id',
+ 'id' => 'test_refund_id',
+ 'amount' => 999,
+ 'currency' => 'gbp',
];
$this->mock_db_wrapper
->expects( $this->once() )
->method( 'order_from_charge_id' )
- ->with( 'charge_id' )
+ ->with( 'test_charge_id' )
->willReturn( $this->mock_order );
- $this->order_service
- ->expects( $this->once() )
- ->method( 'handle_insufficient_balance_for_refund' )
- ->with( $this->mock_order, 999 );
-
- $this->webhook_processing_service->process( $this->event_body );
- }
-
-
- /**
- * Test a valid non-failed refund update webhook
- */
- public function test_non_failed_refund_update_webhook() {
- // Setup test request data.
- $this->event_body['type'] = 'charge.refund.updated';
- $this->event_body['livemode'] = true;
- $this->event_body['data']['object'] = [
- 'status' => 'updated',
- 'charge' => 'test_charge_id',
- 'id' => 'test_refund_id',
- 'amount' => 999,
- ];
-
- $this->mock_db_wrapper
- ->expects( $this->never() )
- ->method( 'order_from_charge_id' );
+ $this->expectException( Invalid_Webhook_Data_Exception::class );
+ $this->expectExceptionMessage( 'Invalid refund update status: invalid_status' );
// Run the test.
$this->webhook_processing_service->process( $this->event_body );
@@ -1579,6 +1586,7 @@ public function test_process_full_refund_succeeded(): void {
'data' => [
[
'id' => 'test_refund_id',
+ 'status' => Refund_Status::SUCCEEDED,
'amount' => 1800,
'currency' => 'usd',
'reason' => 'requested_by_customer',
@@ -1620,7 +1628,7 @@ public function test_process_full_refund_succeeded(): void {
$this->order_service
->expects( $this->once() )
- ->method( 'add_note_and_metadata_for_refund' )
+ ->method( 'add_note_and_metadata_for_created_refund' )
->with( $this->mock_order, $mock_refund, 'test_refund_id', 'txn_123' );
$this->webhook_processing_service->process( $this->event_body );
@@ -1635,6 +1643,7 @@ public function test_process_partial_refund_succeeded(): void {
'data' => [
[
'id' => 'test_refund_id',
+ 'status' => Refund_Status::SUCCEEDED,
'amount' => 900,
'currency' => 'usd',
'reason' => 'requested_by_customer',
@@ -1667,7 +1676,7 @@ public function test_process_partial_refund_succeeded(): void {
$this->order_service
->expects( $this->once() )
- ->method( 'add_note_and_metadata_for_refund' )
+ ->method( 'add_note_and_metadata_for_created_refund' )
->with( $this->mock_order, $mock_refund, 'test_refund_id', 'txn_123' );
$this->webhook_processing_service->process( $this->event_body );
@@ -1728,7 +1737,7 @@ public function test_process_refund_ignores_processed_event(): void {
$this->order_service
->expects( $this->never() )
- ->method( 'add_note_and_metadata_for_refund' );
+ ->method( 'add_note_and_metadata_for_created_refund' );
$this->webhook_processing_service->process( $this->event_body );
}
@@ -1742,7 +1751,7 @@ public function test_process_refund_ignores_event(): void {
$this->order_service
->expects( $this->never() )
- ->method( 'add_note_and_metadata_for_refund' );
+ ->method( 'add_note_and_metadata_for_created_refund' );
$this->webhook_processing_service->process( $this->event_body );
}
@@ -1773,7 +1782,7 @@ public function test_process_refund_ignores_failed_refund_event(): void {
$this->order_service
->expects( $this->never() )
- ->method( 'add_note_and_metadata_for_refund' );
+ ->method( 'add_note_and_metadata_for_created_refund' );
}
public function test_process_refund_throws_when_order_not_found(): void {