Commit 01a29ece26f for woocommerce
commit 01a29ece26fc35e2f0e58b12ffa252fa64ea5b41
Author: Miguel Gasca <miguel.gasca@automattic.com>
Date: Wed Sep 16 11:18:00 2026 +0200
Fix Store API checkout failing after the gateway took payment
The Store API checkout treated every failure during a place-order request
as "the order was not placed". Once the gateway has run that is no longer
true. A post-payment integration dying during the status transition left
an order created, paid and processing while the shopper saw a retry
message. The cart was never emptied and a retry cannot resume a paid
order, so every retry produced another paid order.
When the order has moved past awaiting payment, recover instead of
reporting failure: log the error, note it on the order, return success
with the order-received redirect in both redirect_url and
payment_details, and empty the cart when it still belongs to the order.
diff --git a/plugins/woocommerce/changelog/42223-fix-store-api-failure-on-paid-order b/plugins/woocommerce/changelog/42223-fix-store-api-failure-on-paid-order
new file mode 100644
index 00000000000..23fc6fcf196
--- /dev/null
+++ b/plugins/woocommerce/changelog/42223-fix-store-api-failure-on-paid-order
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Store API: report the checkout as successful when a failure happens after the gateway has taken payment, so shoppers are no longer told to retry an order that already succeeded.
diff --git a/plugins/woocommerce/client/blocks/docs/third-party-developers/extensibility/hooks/filters.md b/plugins/woocommerce/client/blocks/docs/third-party-developers/extensibility/hooks/filters.md
index 254b3643d37..986c567f31a 100644
--- a/plugins/woocommerce/client/blocks/docs/third-party-developers/extensibility/hooks/filters.md
+++ b/plugins/woocommerce/client/blocks/docs/third-party-developers/extensibility/hooks/filters.md
@@ -78,6 +78,7 @@
- [woocommerce_thankyou_order_failed_text](#woocommerce_thankyou_order_failed_text)
- [woocommerce_thankyou_order_received_title](#woocommerce_thankyou_order_received_title)
- [woocommerce_use_block_notices_in_classic_theme](#woocommerce_use_block_notices_in_classic_theme)
+- [woocommerce_valid_order_statuses_for_payment](#woocommerce_valid_order_statuses_for_payment)
- [woocommerce_variation_option_name](#woocommerce_variation_option_name)
---
@@ -1954,6 +1955,32 @@ apply_filters( 'woocommerce_use_block_notices_in_classic_theme', bool $use_block
---
+## woocommerce_valid_order_statuses_for_payment
+
+
+Filter the valid order statuses for payment.
+
+```php
+apply_filters( 'woocommerce_valid_order_statuses_for_payment', array $valid_order_statuses, \WC_Order $order )
+```
+
+### Description
+
+The same filter WC_Order::needs_payment() applies. A status a site declares payable counts as awaiting payment here too, so a failure while the order is in it is reported rather than recovered as one that took payment.
+
+### Parameters
+
+| Argument | Type | Description |
+| -------- | ---- | ----------- |
+| $valid_order_statuses | array | Array of valid order statuses for payment. |
+| $order | \WC_Order | Order object. |
+
+### Source
+
+- [StoreApi/Utilities/CheckoutTrait.php](../../../../../../src/StoreApi/Utilities/CheckoutTrait.php)
+
+---
+
## woocommerce_variation_option_name
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index a8882966d95..633efd3278d 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -67213,12 +67213,6 @@ parameters:
count: 1
path: src/StoreApi/Legacy.php
- -
- message: '#^Parameter \#1 \$arr1 of function array_merge expects array, string given\.$#'
- identifier: argument.type
- count: 1
- path: src/StoreApi/Legacy.php
-
-
message: '#^Access to an undefined property WooCommerce\:\:\$payment_gateways\.$#'
identifier: property.notFound
@@ -67279,24 +67273,6 @@ parameters:
count: 1
path: src/StoreApi/Payments/PaymentResult.php
- -
- message: '#^Parameter \#1 \$url of function esc_url_raw expects string, array given\.$#'
- identifier: argument.type
- count: 1
- path: src/StoreApi/Payments/PaymentResult.php
-
- -
- message: '#^Property Automattic\\WooCommerce\\StoreApi\\Payments\\PaymentResult\:\:\$payment_details \(string\) does not accept array\.$#'
- identifier: assign.propertyType
- count: 1
- path: src/StoreApi/Payments/PaymentResult.php
-
- -
- message: '#^Property Automattic\\WooCommerce\\StoreApi\\Payments\\PaymentResult\:\:\$payment_details \(string\) does not accept default value of type array\.$#'
- identifier: property.defaultValue
- count: 1
- path: src/StoreApi/Payments/PaymentResult.php
-
-
message: '#^Expected 2 @param tags, found 3\.$#'
identifier: paramTag.count
@@ -68011,12 +67987,6 @@ parameters:
count: 1
path: src/StoreApi/Routes/V1/Checkout.php
- -
- message: '#^Parameter \#1 \$redirect_url of method Automattic\\WooCommerce\\StoreApi\\Payments\\PaymentResult\:\:set_redirect_url\(\) expects array, string given\.$#'
- identifier: argument.type
- count: 1
- path: src/StoreApi/Routes/V1/Checkout.php
-
-
message: '#^Parameter \#1 \$text of function esc_html expects string, int given\.$#'
identifier: argument.type
@@ -68215,12 +68185,6 @@ parameters:
count: 1
path: src/StoreApi/Routes/V1/CheckoutOrder.php
- -
- message: '#^Parameter \#1 \$redirect_url of method Automattic\\WooCommerce\\StoreApi\\Payments\\PaymentResult\:\:set_redirect_url\(\) expects array, string given\.$#'
- identifier: argument.type
- count: 1
- path: src/StoreApi/Routes/V1/CheckoutOrder.php
-
-
message: '#^Parameter \#1 \$text of function esc_html expects string, array\|string given\.$#'
identifier: argument.type
@@ -68827,12 +68791,6 @@ parameters:
count: 1
path: src/StoreApi/Schemas/V1/CheckoutSchema.php
- -
- message: '#^Parameter \#1 \$payment_details of method Automattic\\WooCommerce\\StoreApi\\Schemas\\V1\\CheckoutSchema\:\:prepare_payment_details_for_response\(\) expects array, string given\.$#'
- identifier: argument.type
- count: 1
- path: src/StoreApi/Schemas/V1/CheckoutSchema.php
-
-
message: '#^Property Automattic\\WooCommerce\\StoreApi\\Schemas\\V1\\CheckoutSchema\:\:\$billing_address_schema \(Automattic\\WooCommerce\\StoreApi\\Schemas\\V1\\BillingAddressSchema\) does not accept Automattic\\WooCommerce\\StoreApi\\Schemas\\V1\\AbstractSchema\.$#'
identifier: assign.propertyType
diff --git a/plugins/woocommerce/src/StoreApi/Payments/PaymentResult.php b/plugins/woocommerce/src/StoreApi/Payments/PaymentResult.php
index 02ff5f04672..08b155867f8 100644
--- a/plugins/woocommerce/src/StoreApi/Payments/PaymentResult.php
+++ b/plugins/woocommerce/src/StoreApi/Payments/PaymentResult.php
@@ -22,7 +22,7 @@ class PaymentResult {
/**
* Array of details about the payment.
*
- * @var string
+ * @var array
*/
protected $payment_details = [];
@@ -66,6 +66,26 @@ class PaymentResult {
return $this->status;
}
+ /**
+ * Get the redirect URL.
+ *
+ * @since 11.2.0
+ * @return string URL the customer is sent to after checkout, empty when none was set.
+ */
+ public function get_redirect_url(): string {
+ return $this->redirect_url;
+ }
+
+ /**
+ * Get the payment details.
+ *
+ * @since 11.2.0
+ * @return array Key value pairs the gateway returned, empty when none were set.
+ */
+ public function get_payment_details(): array {
+ return $this->payment_details;
+ }
+
/**
* Set payment status.
*
@@ -96,9 +116,9 @@ class PaymentResult {
/**
* Set redirect URL.
*
- * @param array $redirect_url URL to redirect the customer to after checkout.
+ * @param string $redirect_url URL to redirect the customer to after checkout.
*/
- public function set_redirect_url( $redirect_url = [] ) {
+ public function set_redirect_url( $redirect_url = '' ) {
$this->redirect_url = esc_url_raw( $redirect_url );
}
}
diff --git a/plugins/woocommerce/src/StoreApi/Routes/V1/Checkout.php b/plugins/woocommerce/src/StoreApi/Routes/V1/Checkout.php
index 3b01357619b..0010020a7b5 100644
--- a/plugins/woocommerce/src/StoreApi/Routes/V1/Checkout.php
+++ b/plugins/woocommerce/src/StoreApi/Routes/V1/Checkout.php
@@ -181,10 +181,18 @@ class Checkout extends AbstractCartRoute {
if ( is_wp_error( $response ) ) {
$response = $this->error_to_response( $response );
- // If we encountered an exception, free up stock and release held coupons.
- if ( $this->order ) {
- wc_release_stock_for_order( $this->order );
- wc_release_coupons_for_order( $this->order );
+ // If we encountered an exception, free up stock and release held coupons. Only an order
+ // that moved past awaiting payment keeps both: its stock is committed and its coupons
+ // are spent. Everything else still needs the cleanup, a fully discounted order and one
+ // that never left checkout-draft included.
+ // Re-read the order first, since a gateway that advanced it may have done so on its own
+ // instance, leaving the one held here reporting a stale status.
+ $order = $this->order ? wc_get_order( $this->order->get_id() ) : null;
+ $order = $order instanceof \WC_Order ? $order : $this->order;
+
+ if ( $order && ! $this->order_moved_past_payment( $order ) ) {
+ wc_release_stock_for_order( $order );
+ wc_release_coupons_for_order( $order );
}
if ( $request->get_method() === \WP_REST_Server::CREATABLE ) {
diff --git a/plugins/woocommerce/src/StoreApi/Utilities/CheckoutTrait.php b/plugins/woocommerce/src/StoreApi/Utilities/CheckoutTrait.php
index 2a4da72c0bb..b2cf9d6ca57 100644
--- a/plugins/woocommerce/src/StoreApi/Utilities/CheckoutTrait.php
+++ b/plugins/woocommerce/src/StoreApi/Utilities/CheckoutTrait.php
@@ -7,6 +7,7 @@ use Automattic\WooCommerce\StoreApi\Exceptions\RouteException;
use Automattic\WooCommerce\StoreApi\Payments\PaymentContext;
use Automattic\WooCommerce\StoreApi\Payments\PaymentResult;
use Automattic\WooCommerce\Blocks\Domain\Services\CheckoutFieldsSchema\DocumentObject;
+use Automattic\WooCommerce\Enums\OrderStatus;
use WC_Customer;
/**
@@ -61,6 +62,9 @@ trait CheckoutTrait {
/**
* For orders which do not require payment, just update status.
*
+ * Deliberately no recovery of the kind process_payment() does: nothing was charged, so a
+ * failure costs the shopper only a retry, and claiming success would be the worse outcome.
+ *
* @throws RouteException If the order is missing.
*
* @param \WP_REST_Request $request Request object.
@@ -80,6 +84,7 @@ trait CheckoutTrait {
* Fires an action hook instructing active payment gateways to process the payment for an order and provide a result.
*
* @throws RouteException If the order is missing, or on payment error.
+ * @throws \Throwable If a gateway raised an Error before payment was taken.
*
* @param \WP_REST_Request $request Request object.
* @param PaymentResult $payment_result Payment result object.
@@ -94,6 +99,13 @@ trait CheckoutTrait {
$session->save_data();
}
+ /*
+ * The hook below takes the result by reference and can replace it, with anything at all.
+ * The caller keeps this instance either way, and it is the one serialised into the
+ * response, so recovery has to write onto it rather than onto whatever the hook left.
+ */
+ $result_for_recovery = $payment_result;
+
try {
// Prepare the payment context object to pass through payment hooks.
$context = new PaymentContext();
@@ -116,7 +128,26 @@ trait CheckoutTrait {
if ( ! $payment_result instanceof PaymentResult ) {
throw new RouteException( 'woocommerce_rest_checkout_invalid_payment_result', __( 'Invalid payment result received from payment method.', 'woocommerce' ), 500 );
}
- } catch ( \Exception $e ) {
+ } catch ( \Throwable $e ) {
+ /*
+ * The gateway may already have taken payment, for example when a post-payment
+ * integration throws during the status transition. Reporting a failure would send the
+ * shopper back to place the order again, and every retry pays for another order.
+ *
+ * Re-read first: a gateway that advanced the order may have done so on its own
+ * instance, leaving the one held above reporting a stale status.
+ */
+ $order = $this->refresh_order( $order );
+
+ if ( $this->order_moved_past_payment( $order ) ) {
+ $this->recover_order_that_took_payment( $order, $e, $result_for_recovery );
+ return;
+ }
+
+ if ( ! $e instanceof \Exception ) {
+ throw $e;
+ }
+
$additional_data = [];
// phpcs:disable WooCommerce.Commenting.CommentHooks.MissingSinceComment
@@ -135,6 +166,132 @@ trait CheckoutTrait {
}
}
+ /**
+ * Re-reads the order so its status reflects whatever the gateway persisted, falling back
+ * to the in-memory order when it can no longer be read.
+ *
+ * @param \WC_Order $order Order object.
+ * @return \WC_Order
+ */
+ private function refresh_order( \WC_Order $order ): \WC_Order {
+ $refreshed_order = wc_get_order( $order->get_id() );
+
+ return $refreshed_order instanceof \WC_Order ? $refreshed_order : $order;
+ }
+
+ /**
+ * Whether the order has moved beyond the point of awaiting payment.
+ *
+ * An order reaches a gateway awaiting payment or as a draft, so any other status means
+ * something moved it on. This is about the status, not the money: an order parked on-hold
+ * for review counts, because sending the shopper back to place it again would be wrong.
+ *
+ * The statuses a site declares payable count as awaiting payment, so a custom status an
+ * extension parks the order in before the gateway runs is not mistaken for a paid one.
+ * Not needs_payment(): that folds in the order total, and a fully discounted order that
+ * failed would then keep its stock and coupon holds.
+ *
+ * @param \WC_Order $order Order object.
+ * @return bool
+ */
+ private function order_moved_past_payment( \WC_Order $order ): bool {
+ /**
+ * Filter the valid order statuses for payment.
+ *
+ * The same filter WC_Order::needs_payment() applies. A status a site declares payable
+ * counts as awaiting payment here too, so a failure while the order is in it is reported
+ * rather than recovered as one that took payment.
+ *
+ * @since 11.2.0
+ *
+ * @param array $valid_order_statuses Array of valid order statuses for payment.
+ * @param \WC_Order $order Order object.
+ */
+ $payable_statuses = apply_filters( 'woocommerce_valid_order_statuses_for_payment', array( OrderStatus::PENDING, OrderStatus::FAILED ), $order );
+
+ return ! $order->has_status(
+ array_merge(
+ (array) $payable_statuses,
+ array(
+ OrderStatus::CHECKOUT_DRAFT,
+ OrderStatus::CANCELLED,
+ OrderStatus::REFUNDED,
+ OrderStatus::TRASH,
+ )
+ )
+ );
+ }
+
+ /**
+ * Records a failure that happened after payment was taken and reports the checkout as
+ * successful, so the shopper is sent to the order confirmation rather than back to retry.
+ *
+ * @param \WC_Order $order Order object.
+ * @param \Throwable $error The failure raised after payment was taken.
+ * @param PaymentResult $payment_result Payment result object.
+ */
+ private function recover_order_that_took_payment( \WC_Order $order, \Throwable $error, PaymentResult $payment_result ): void {
+ /*
+ * The failure goes in the message, not the context: the file handler renders context with
+ * wp_json_encode(), and neither WC_Order nor Throwable exposes public properties, so the
+ * objects alone would write an empty {}. This is now the only log of the error.
+ */
+ wc_get_logger()->error(
+ sprintf(
+ 'Checkout for order #%1$d failed after payment was taken: %2$s: %3$s in %4$s:%5$d',
+ $order->get_id(),
+ get_class( $error ),
+ $error->getMessage(),
+ $error->getFile(),
+ $error->getLine()
+ ),
+ array( 'source' => 'store-api' )
+ );
+
+ $order->add_order_note(
+ sprintf(
+ /* translators: %s: the error that was raised after payment was taken. */
+ __( 'Checkout could not be completed after payment was taken: %s', 'woocommerce' ),
+ wp_strip_all_tags( $error->getMessage() )
+ )
+ );
+
+ $payment_result->set_status( 'success' );
+
+ /*
+ * Keep a redirect the gateway set before it threw. An order can reach here still needing
+ * the shopper to act, say one parked on-hold awaiting a 3DS challenge, and overwriting it
+ * would walk them past that step. Order confirmation is the fallback.
+ */
+ if ( '' === $payment_result->get_redirect_url() ) {
+ $payment_result->set_redirect_url( $order->get_checkout_order_received_url() );
+ }
+
+ /*
+ * Keep both redirect fields in sync, as Legacy::process_legacy_payment does on the normal
+ * path. Setting only one leaves a success result core would never otherwise emit, and
+ * gateway client code reads the redirect from the payment details rather than from
+ * redirect_url: WooPayments and WooCommerce Stripe both call String.match() on it
+ * unguarded, stranding the shopper on the checkout of an order that was in fact paid.
+ */
+ $payment_details = $payment_result->get_payment_details();
+
+ if ( ! isset( $payment_details['redirect'] ) ) {
+ $payment_details['redirect'] = $payment_result->get_redirect_url();
+ $payment_result->set_payment_details( $payment_details );
+ }
+
+ /*
+ * The gateway never reached the point where it empties the cart, so do it here: otherwise
+ * the shopper lands on the confirmation with the order still in their cart and can place
+ * it again. Only when the cart still belongs to this order, since pay-for-order shares
+ * this trait with an unrelated cart. Same guard as wc_clear_cart_after_payment().
+ */
+ if ( WC()->cart && $order->has_cart_hash( WC()->cart->get_cart_hash() ) ) {
+ WC()->cart->empty_cart();
+ }
+ }
+
/**
* Gets the chosen payment method ID from the request.
*
diff --git a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/Checkout.php b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/Checkout.php
index ff1c42db37d..0cd9cd36ded 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/Checkout.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/Checkout.php
@@ -17,6 +17,7 @@ use Automattic\WooCommerce\StoreApi\Routes\V1\CheckoutOrder as CheckoutOrderRout
use Automattic\WooCommerce\StoreApi\SchemaController;
use Automattic\WooCommerce\Blocks\Package;
use Automattic\WooCommerce\Blocks\Domain\Services\CheckoutFields;
+use Automattic\WooCommerce\Enums\OrderStatus;
use Automattic\WooCommerce\Enums\ProductStockStatus;
use Mockery\Adapter\Phpunit\MockeryPHPUnitIntegration;
use WC_Gateway_BACS;
@@ -3407,4 +3408,594 @@ class Checkout extends \WP_Test_REST_TestCase {
$this->assertSame( 500, $response->get_status(), 'A cart session failure should return a Store API error response.' );
$this->assertSame( 'woocommerce_rest_unknown_server_error', $response->get_data()['code'] );
}
+
+ /**
+ * Adds a payment participant that takes payment and then throws, simulating a
+ * post-payment integration (transactional email, CRM, fulfilment) failing after
+ * the money has already changed hands.
+ *
+ * Priority 998 runs before Legacy::process_legacy_payment (999), so the cart is
+ * left populated exactly as it is when a gateway dies part-way through.
+ */
+ private function fail_after_payment_is_taken(): void {
+ add_action(
+ 'woocommerce_rest_checkout_process_payment_with_context',
+ function ( $context ) {
+ $context->order->payment_complete();
+ throw new \Exception( 'Transactional email integration failed.' );
+ },
+ 998
+ );
+ }
+
+ /**
+ * Re-registers the checkout route so the next dispatch behaves like a separate
+ * HTTP request. Without this the route object, and the order it is holding, is
+ * carried over from the previous dispatch.
+ */
+ private function simulate_fresh_request(): void {
+ $schema_controller = new SchemaController( $this->mock_extend );
+ $route = new CheckoutRoute( $schema_controller, $schema_controller->get( 'checkout' ) );
+ register_rest_route( $route->get_namespace(), $route->get_path(), $route->get_args(), true );
+ }
+
+ /**
+ * Builds a valid place-order request for the seeded cart.
+ */
+ private function build_checkout_post_request(): \WP_REST_Request {
+ $request = new \WP_REST_Request( 'POST', '/wc/store/v1/checkout' );
+ $request->set_header( 'Nonce', wp_create_nonce( 'wc_store_api' ) );
+ $request->set_body_params(
+ array(
+ 'billing_address' => (object) array(
+ 'first_name' => 'test',
+ 'last_name' => 'test',
+ 'company' => '',
+ 'address_1' => 'test',
+ 'address_2' => '',
+ 'city' => 'test',
+ 'state' => '',
+ 'postcode' => 'cb241ab',
+ 'country' => 'GB',
+ 'phone' => '',
+ 'email' => 'testaccount@test.com',
+ ),
+ 'shipping_address' => (object) array(
+ 'first_name' => 'test',
+ 'last_name' => 'test',
+ 'company' => '',
+ 'address_1' => 'test',
+ 'address_2' => '',
+ 'city' => 'test',
+ 'state' => '',
+ 'postcode' => 'cb241ab',
+ 'country' => 'GB',
+ 'phone' => '',
+ ),
+ 'payment_method' => WC_Gateway_BACS::ID,
+ )
+ );
+
+ return $request;
+ }
+
+ /**
+ * @testdox A failure raised after the gateway took payment is not reported to the shopper as a failed order.
+ */
+ public function test_failure_after_payment_is_taken_is_not_reported_as_a_failed_order() {
+ $this->fail_after_payment_is_taken();
+
+ $response = rest_get_server()->dispatch( $this->build_checkout_post_request() );
+ $data = $response->get_data();
+
+ $this->assertEquals(
+ 200,
+ $response->get_status(),
+ 'A failure after payment was taken must not be reported as a failed checkout: ' . print_r( $data, true )
+ );
+
+ // A 200 on its own proves little: the route returns one whenever process_payment() does not
+ // throw, so the payload is what shows the shopper was actually sent to their order.
+ $this->assertArrayHasKey( 'payment_result', $data );
+ $this->assertSame( 'success', $data['payment_result']['payment_status'], 'Recovery must report the payment as successful.' );
+ $this->assertSame(
+ wc_get_order( $data['order_id'] )->get_checkout_order_received_url(),
+ $data['payment_result']['redirect_url'],
+ 'Recovery must redirect the shopper to the order they just paid for.'
+ );
+ }
+
+ /**
+ * @testdox A single attempt that fails after payment leaves one order, and the shopper is sent to it.
+ */
+ public function test_failure_after_payment_is_taken_leaves_the_order_paid() {
+ $this->fail_after_payment_is_taken();
+
+ $response = rest_get_server()->dispatch( $this->build_checkout_post_request() );
+
+ $orders = wc_get_orders(
+ array(
+ 'limit' => -1,
+ 'status' => 'any',
+ )
+ );
+ $this->assertCount( 1, $orders, 'Exactly one order should exist after a single place-order attempt.' );
+ $this->assertFalse( $orders[0]->needs_payment(), 'The order took payment, so it must not be left awaiting payment.' );
+
+ // The helper pays the order itself, so the assertion above holds with or without recovery.
+ // Tying the response to that same order is what shows recovery ran.
+ $this->assertSame(
+ $orders[0]->get_id(),
+ $response->get_data()['order_id'],
+ 'The shopper must be sent to the order that took payment, not told to place another.'
+ );
+ }
+
+ /**
+ * @testdox Retrying after a failure raised post-payment does not place a duplicate paid order.
+ */
+ public function test_retry_after_failure_post_payment_does_not_duplicate_the_order() {
+ $this->fail_after_payment_is_taken();
+
+ rest_get_server()->dispatch( $this->build_checkout_post_request() );
+ $order_ids_after_first_attempt = wc_get_orders(
+ array(
+ 'limit' => -1,
+ 'status' => 'any',
+ 'return' => 'ids',
+ )
+ );
+
+ $this->simulate_fresh_request();
+ rest_get_server()->dispatch( $this->build_checkout_post_request() );
+ $order_ids_after_retry = wc_get_orders(
+ array(
+ 'limit' => -1,
+ 'status' => 'any',
+ 'return' => 'ids',
+ )
+ );
+
+ $this->assertSame(
+ $order_ids_after_first_attempt,
+ $order_ids_after_retry,
+ 'Retrying after a post-payment failure must not create a second paid order.'
+ );
+ }
+
+ /**
+ * @testdox A recovered checkout empties the cart the gateway never got to.
+ */
+ public function test_recovered_checkout_empties_the_cart() {
+ $this->fail_after_payment_is_taken();
+
+ rest_get_server()->dispatch( $this->build_checkout_post_request() );
+
+ $this->assertTrue( WC()->cart->is_empty(), 'Recovery should leave the shopper with an empty cart, as a successful checkout does.' );
+ }
+
+ /**
+ * @testdox A recovered checkout leaves a cart that no longer belongs to the order alone.
+ */
+ public function test_recovered_checkout_keeps_a_cart_that_no_longer_matches_the_order() {
+ // Pay-for-order runs through the same trait with a cart unrelated to the order, and a
+ // shopper checking out in a second tab has moved the cart on. Model that by changing the
+ // cart after the order was built from it, so the hashes no longer agree.
+ add_action(
+ 'woocommerce_rest_checkout_process_payment_with_context',
+ function ( $context ) {
+ $context->order->payment_complete();
+ WC()->cart->add_to_cart( $this->products[0]->get_id(), 1 );
+ throw new \Exception( 'Transactional email integration failed.' );
+ },
+ 998
+ );
+
+ rest_get_server()->dispatch( $this->build_checkout_post_request() );
+
+ $this->assertFalse( WC()->cart->is_empty(), 'A cart that no longer matches the order must survive recovery.' );
+ }
+
+ /**
+ * @testdox A failure raised after the gateway has run is recovered too.
+ */
+ public function test_failure_raised_after_the_gateway_ran_is_recovered() {
+ // Priority 1000 runs after Legacy::process_legacy_payment (999), so BACS has really
+ // processed the payment and moved the order on before this throws.
+ add_action(
+ 'woocommerce_rest_checkout_process_payment_with_context',
+ function () {
+ throw new \Exception( 'Transactional email integration failed.' );
+ },
+ 1000
+ );
+
+ $response = rest_get_server()->dispatch( $this->build_checkout_post_request() );
+
+ $this->assertEquals(
+ 200,
+ $response->get_status(),
+ 'A failure after the gateway ran must not be reported as a failed checkout: ' . print_r( $response->get_data(), true )
+ );
+
+ $orders = wc_get_orders(
+ array(
+ 'limit' => -1,
+ 'status' => 'any',
+ )
+ );
+ $this->assertCount( 1, $orders, 'Exactly one order should exist.' );
+ $this->assertFalse( $orders[0]->needs_payment(), 'BACS moved the order on, so it must not be left awaiting payment.' );
+ }
+
+ /**
+ * @testdox A recovered checkout records what went wrong on the order.
+ */
+ public function test_recovered_checkout_records_the_failure_on_the_order() {
+ $this->fail_after_payment_is_taken();
+
+ rest_get_server()->dispatch( $this->build_checkout_post_request() );
+
+ $orders = wc_get_orders(
+ array(
+ 'limit' => -1,
+ 'status' => 'any',
+ )
+ );
+ $notes = wc_get_order_notes( array( 'order_id' => $orders[0]->get_id() ) );
+ $notes = wp_list_pluck( $notes, 'content' );
+
+ $this->assertNotEmpty(
+ array_filter(
+ $notes,
+ function ( $note ) {
+ return false !== strpos( $note, 'Transactional email integration failed.' );
+ }
+ ),
+ 'The merchant needs a trace of the failure on the order, since the shopper was told the checkout succeeded. Notes: ' . print_r( $notes, true )
+ );
+ }
+
+ /**
+ * @testdox A failure while the order is still a draft releases the stock it had reserved.
+ */
+ public function test_failure_before_the_order_leaves_draft_releases_held_stock() {
+ // Its own product rather than a class fixture, so enabling stock management here cannot
+ // leak into the other tests in this class.
+ $product = \WC_Helper_Product::create_simple_product();
+ $product->set_manage_stock( true );
+ $product->set_stock_quantity( 10 );
+ $product->set_backorders( 'no' );
+ $product->save();
+
+ update_option( 'woocommerce_manage_stock', 'yes' );
+ update_option( 'woocommerce_hold_stock_minutes', 60 );
+
+ WC()->cart->empty_cart();
+ WC()->cart->add_to_cart( $product->get_id(), 2 );
+
+ // The only extension hooks between wc_reserve_stock_for_order() and update_status( 'pending' )
+ // are deprecated ones, and that window is exactly what this test covers, so the notice is
+ // expected rather than a signal to hook something else.
+ $this->setExpectedDeprecated( 'woocommerce_blocks_checkout_order_processed' );
+
+ // Fires while the order is still checkout-draft and holding stock.
+ $state_at_failure = null;
+ add_action(
+ 'woocommerce_blocks_checkout_order_processed',
+ function () use ( &$state_at_failure, $product ) {
+ $draft_ids = wc_get_orders(
+ array(
+ 'limit' => 1,
+ 'status' => 'checkout-draft',
+ 'return' => 'ids',
+ )
+ );
+
+ $state_at_failure = array(
+ 'held' => (int) wc_get_held_stock_quantity( wc_get_product( $product->get_id() ) ),
+ 'status' => $draft_ids ? wc_get_order( $draft_ids[0] )->get_status() : 'none',
+ );
+ throw new \Exception( 'Extension failed while the order was still a draft.' );
+ }
+ );
+
+ $response = rest_get_server()->dispatch( $this->build_checkout_post_request() );
+
+ $this->assertEquals( 500, $response->get_status(), 'The checkout should still be reported as failed: ' . print_r( $response->get_data(), true ) );
+
+ // Without these the test could pass for the wrong reason: no hold placed, or the order
+ // already past checkout-draft by the time the failure lands.
+ $this->assertSame( 2, $state_at_failure['held'], 'The order must actually be holding stock when the failure happens, or this test proves nothing.' );
+ $this->assertSame( 'checkout-draft', $state_at_failure['status'], 'The failure must land while the order is still a draft, which is the window this test covers.' );
+
+ $this->assertSame(
+ 0,
+ (int) wc_get_held_stock_quantity( wc_get_product( $product->get_id() ) ),
+ 'A draft order that never took payment must not keep its stock hold when the checkout fails.'
+ );
+ }
+
+ /**
+ * @testdox An Error raised before any payment was taken still surfaces instead of being swallowed.
+ */
+ public function test_error_raised_before_payment_is_not_converted_into_a_successful_checkout() {
+ // No payment_complete() here: the order is still awaiting payment when this lands, so the
+ // recovery path must not claim it, and an Error must keep behaving as it did before.
+ add_action(
+ 'woocommerce_rest_checkout_process_payment_with_context',
+ function () {
+ // Raises Error: call to a member function on null.
+ $integration = null;
+ $integration->push_order();
+ },
+ 998
+ );
+
+ $caught = null;
+ try {
+ rest_get_server()->dispatch( $this->build_checkout_post_request() );
+ } catch ( \Throwable $error ) {
+ $caught = $error;
+ }
+
+ $this->assertInstanceOf( \Error::class, $caught, 'An Error with no payment taken must surface rather than be reported as a successful checkout.' );
+
+ $orders = wc_get_orders(
+ array(
+ 'limit' => -1,
+ 'status' => 'any',
+ )
+ );
+ $this->assertCount( 1, $orders, 'Exactly one order should exist.' );
+ $this->assertTrue( $orders[0]->has_status( OrderStatus::PENDING ), 'The order never took payment, so it must be left awaiting payment.' );
+ }
+
+ /**
+ * @testdox An Error raised inside a status transition after the gateway moved the order on is recovered.
+ */
+ public function test_error_raised_inside_a_status_transition_after_the_gateway_ran_is_recovered() {
+ // Core catches Exception, not Error, around status-transition hooks, so an integration
+ // dying there is the failure that escapes the gateway and reaches the route. BACS moves
+ // the order on-hold before it empties the cart, so this lands between the two, as the
+ // report did. This is the test that fails if the catch narrows back to \Exception.
+ add_action(
+ 'woocommerce_order_status_on-hold',
+ function () {
+ // Raises Error: call to a member function on null.
+ $integration = null;
+ $integration->push_order();
+ }
+ );
+
+ $response = rest_get_server()->dispatch( $this->build_checkout_post_request() );
+ $data = $response->get_data();
+
+ $this->assertEquals( 200, $response->get_status(), 'An Error after the gateway moved the order on must be recovered, not surfaced: ' . print_r( $data, true ) );
+ $this->assertSame( 'success', $data['payment_result']['payment_status'], 'Recovery must report the payment as successful.' );
+
+ $order = wc_get_order( $data['order_id'] );
+ $this->assertTrue( $order->has_status( OrderStatus::ON_HOLD ), 'The gateway had already moved the order on when the Error was raised.' );
+ $this->assertTrue( WC()->cart->is_empty(), 'The gateway never reached empty_cart(), so recovery must empty the cart.' );
+
+ $notes = wp_list_pluck( wc_get_order_notes( array( 'order_id' => $order->get_id() ) ), 'content' );
+ $this->assertNotEmpty(
+ array_filter(
+ $notes,
+ function ( $note ) {
+ return false !== strpos( $note, 'push_order() on null' );
+ }
+ ),
+ 'The Error must be recorded on the order. Notes: ' . print_r( $notes, true )
+ );
+ }
+
+ /**
+ * @testdox A failure while the order sits in a custom status the site declares payable is reported, not recovered.
+ */
+ public function test_failure_in_a_custom_payable_status_is_not_recovered() {
+ // Short on purpose: the status column holds twenty characters, prefix included.
+ $status_name = 'awaiting_review';
+ $this->register_custom_order_status( $status_name, true );
+
+ // A gateway that parks the order in the custom status and then declines. No payment was
+ // taken, so the status must read as awaiting payment rather than as moved past it.
+ add_action(
+ 'woocommerce_rest_checkout_process_payment_with_context',
+ function ( $context ) use ( $status_name ) {
+ $context->order->update_status( $status_name );
+ throw new \Exception( 'Your card was declined.' );
+ },
+ 998
+ );
+
+ $response = rest_get_server()->dispatch( $this->build_checkout_post_request() );
+
+ $this->assertEquals( 400, $response->get_status(), 'A decline while the order is awaiting payment must be reported: ' . print_r( $response->get_data(), true ) );
+ $this->assertSame( 'woocommerce_rest_checkout_process_payment_error', $response->get_data()['code'] );
+ $this->assertFalse( WC()->cart->is_empty(), 'The cart must survive so the shopper can retry.' );
+ }
+
+ /**
+ * Gateways read the redirect back out of payment_details on the client rather than out of
+ * redirect_url, so a recovered result has to carry it in both. WooPayments reads
+ * paymentDetails.redirect and calls String.match() on it, which throws on undefined and
+ * strands the shopper on the checkout with a JavaScript error even though the recovery
+ * itself succeeded.
+ */
+ public function test_recovery_reports_the_redirect_in_the_payment_details() {
+ $this->fail_after_payment_is_taken();
+
+ $response = rest_get_server()->dispatch( $this->build_checkout_post_request() );
+ $data = $response->get_data();
+
+ $this->assertEquals( 200, $response->get_status(), print_r( $data, true ) );
+ $this->assertSame( 'success', $data['payment_result']['payment_status'] );
+
+ $details = wp_list_pluck( $data['payment_result']['payment_details'], 'value', 'key' );
+
+ $this->assertArrayHasKey(
+ 'redirect',
+ $details,
+ 'A success result without a redirect in payment_details breaks gateway client code that reads it from there.'
+ );
+ $this->assertSame(
+ $data['payment_result']['redirect_url'],
+ $details['redirect'],
+ 'The redirect reported in payment_details must match the one in redirect_url.'
+ );
+ }
+
+ /**
+ * A redirect the gateway set before failing is kept in payment_details too, rather than
+ * being replaced with the order confirmation.
+ */
+ public function test_recovery_reports_a_gateway_set_redirect_in_the_payment_details() {
+ $gateway_redirect = 'https://example.com/3ds-challenge';
+
+ add_action(
+ 'woocommerce_rest_checkout_process_payment_with_context',
+ function ( $context, &$payment_result ) use ( $gateway_redirect ) {
+ $context->order->update_status( OrderStatus::ON_HOLD );
+ $payment_result->set_redirect_url( $gateway_redirect );
+ throw new \Exception( 'Gateway failed after parking the order for authentication.' );
+ },
+ 998,
+ 2
+ );
+
+ $response = rest_get_server()->dispatch( $this->build_checkout_post_request() );
+ $data = $response->get_data();
+ $details = wp_list_pluck( $data['payment_result']['payment_details'], 'value', 'key' );
+
+ $this->assertSame( $gateway_redirect, $details['redirect'] );
+ }
+
+ /**
+ * @testdox Recovery keeps a redirect the gateway set before it failed.
+ */
+ public function test_recovery_keeps_a_redirect_the_gateway_already_set() {
+ $gateway_redirect = 'https://example.com/3ds-challenge';
+
+ // Parks the order on-hold with an authentication step still outstanding, sets the redirect
+ // that step needs, then fails. The shopper still has to complete the challenge.
+ add_action(
+ 'woocommerce_rest_checkout_process_payment_with_context',
+ function ( $context, &$payment_result ) use ( $gateway_redirect ) {
+ $context->order->update_status( OrderStatus::ON_HOLD );
+ $payment_result->set_redirect_url( $gateway_redirect );
+ throw new \Exception( 'Gateway failed after parking the order for authentication.' );
+ },
+ 998,
+ 2
+ );
+
+ $response = rest_get_server()->dispatch( $this->build_checkout_post_request() );
+ $data = $response->get_data();
+
+ $this->assertEquals( 200, $response->get_status(), 'The order moved past awaiting payment, so the checkout is recovered: ' . print_r( $data, true ) );
+ $this->assertSame( 'success', $data['payment_result']['payment_status'] );
+ $this->assertSame(
+ $gateway_redirect,
+ $data['payment_result']['redirect_url'],
+ 'Overwriting the gateway redirect would walk the shopper past the step it was pointing at.'
+ );
+ }
+
+ /**
+ * @testdox A failure on a fully discounted order still releases its stock and coupon holds.
+ */
+ public function test_failure_on_a_zero_total_order_releases_held_stock_and_coupons() {
+ $product = \WC_Helper_Product::create_simple_product();
+ $product->set_regular_price( '10' );
+ $product->set_virtual( true );
+ $product->set_manage_stock( true );
+ $product->set_stock_quantity( 10 );
+ $product->set_backorders( 'no' );
+ $product->save();
+
+ $coupon = new \WC_Coupon();
+ $coupon->set_code( 'zero_total_coupon' );
+ $coupon->set_discount_type( 'percent' );
+ $coupon->set_amount( 100 );
+ $coupon->set_usage_limit( 1 );
+ $coupon->save();
+
+ update_option( 'woocommerce_manage_stock', 'yes' );
+ update_option( 'woocommerce_hold_stock_minutes', 60 );
+
+ WC()->cart->empty_cart();
+ WC()->cart->add_to_cart( $product->get_id(), 2 );
+ WC()->cart->apply_coupon( 'zero_total_coupon' );
+
+ $state_at_failure = null;
+ add_action(
+ 'woocommerce_store_api_checkout_order_processed',
+ function ( $order ) use ( &$state_at_failure, $product ) {
+ $state_at_failure = array(
+ 'total' => (float) $order->get_total(),
+ 'status' => $order->get_status(),
+ 'held' => (int) wc_get_held_stock_quantity( wc_get_product( $product->get_id() ) ),
+ 'coupon' => $order->get_meta( '_coupon_held_keys' ),
+ );
+ throw new \Exception( 'Extension failed on a fully discounted order.' );
+ }
+ );
+
+ $response = rest_get_server()->dispatch( $this->build_checkout_post_request() );
+
+ $this->assertEquals( 500, $response->get_status(), 'The checkout should be reported as failed: ' . print_r( $response->get_data(), true ) );
+
+ // Preconditions: without these the release assertions could pass for the wrong reason.
+ $this->assertSame( 0.0, $state_at_failure['total'], 'The coupon must take the order to a zero total, which is the case this test covers.' );
+ $this->assertSame( 2, $state_at_failure['held'], 'The order must actually be holding stock when the failure happens.' );
+ $this->assertNotEmpty( $state_at_failure['coupon'], 'The order must actually be holding the coupon when the failure happens.' );
+
+ $this->assertSame(
+ 0,
+ (int) wc_get_held_stock_quantity( wc_get_product( $product->get_id() ) ),
+ 'A zero-total order that never took payment must not keep its stock hold when the checkout fails.'
+ );
+
+ $orders = wc_get_orders(
+ array(
+ 'limit' => 1,
+ 'status' => 'pending',
+ 'return' => 'ids',
+ )
+ );
+ $this->assertNotEmpty( $orders, 'The failed order should still exist to inspect.' );
+ $this->assertEmpty(
+ wc_get_order( $orders[0] )->get_meta( '_coupon_held_keys' ),
+ 'A zero-total order that never took payment must release its coupon hold, or the shopper cannot retry with the same coupon.'
+ );
+ }
+
+ /**
+ * @testdox Recovery survives a gateway that replaces the payment result with an invalid value.
+ */
+ public function test_recovery_handles_a_gateway_that_discards_the_payment_result() {
+ // Takes payment, then hands back something that is not a PaymentResult. The route rejects
+ // the result, and recovery has to cope with the replacement rather than fatal on it.
+ add_action(
+ 'woocommerce_rest_checkout_process_payment_with_context',
+ function ( $context, &$payment_result ) {
+ $context->order->payment_complete();
+ $payment_result = null;
+ },
+ 998,
+ 2
+ );
+
+ $response = rest_get_server()->dispatch( $this->build_checkout_post_request() );
+ $data = $response->get_data();
+
+ $this->assertEquals(
+ 200,
+ $response->get_status(),
+ 'A discarded payment result must not turn a paid order into a fatal: ' . print_r( $data, true )
+ );
+ $this->assertArrayHasKey( 'payment_result', $data );
+ $this->assertSame( 'success', $data['payment_result']['payment_status'], 'Recovery must still report success on the result the response uses.' );
+ }
}