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.' );
+	}
 }