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 {