Commit aa60492fba1 for woocommerce

commit aa60492fba1b30550004974eaec21cc140582d17
Author: Miguel Gasca <miguel.gasca@automattic.com>
Date:   Tue Nov 3 19:26:51 2026 +0300

    Fix duplicate orders when a shopper retries checkout after a post-payment failure (#68749)

    A gateway can move an order on and then fail inside that status change,
    for example when a mail plugin dies in woocommerce_mail_callback. The
    shopper sees an error with the cart still full, and submitting again
    created and charged a second order.

    Both checkouts now send that retry to the order already placed. The
    shortcode checkout recovers the first attempt as the Store API does, a
    new order_sent_to_gateway session key survives payment_complete(), and
    a repeat submit is answered before validation, so its hooks no longer
    run. The shared code lives in Internal\Checkout\PaymentRecovery.

diff --git a/plugins/woocommerce/changelog/fix-42223-classic-checkout-repeat-submit b/plugins/woocommerce/changelog/fix-42223-classic-checkout-repeat-submit
new file mode 100644
index 00000000000..8cc9c79d4c1
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-42223-classic-checkout-repeat-submit
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Stop creating a duplicate order when a customer retries a checkout that already went through but showed an error.
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 66485a369f1..d1e6e08b2ca 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
@@ -2046,7 +2046,7 @@ apply_filters( 'woocommerce_valid_order_statuses_for_payment', array $valid_orde

 ### 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.
+The same filter WC_Order::needs_payment() applies, so a zero-total order in a status the site declares payable can be resumed.

 ### Parameters

@@ -2057,7 +2057,6 @@ The same filter WC_Order::needs_payment() applies. A status a site declares paya

 ### Source

-- [StoreApi/Utilities/CheckoutTrait.php](../../../../../../src/StoreApi/Utilities/CheckoutTrait.php)
 - [StoreApi/Utilities/DraftOrderTrait.php](../../../../../../src/StoreApi/Utilities/DraftOrderTrait.php)

 ---
diff --git a/plugins/woocommerce/includes/class-wc-cart-session.php b/plugins/woocommerce/includes/class-wc-cart-session.php
index df2ba47ea56..18642a0f997 100644
--- a/plugins/woocommerce/includes/class-wc-cart-session.php
+++ b/plugins/woocommerce/includes/class-wc-cart-session.php
@@ -8,6 +8,7 @@

 use Automattic\WooCommerce\Enums\OrderStatus;
 use Automattic\WooCommerce\Enums\ProductType;
+use Automattic\WooCommerce\Internal\Checkout\PaymentRecovery;

 if ( ! defined( 'ABSPATH' ) ) {
 	exit;
@@ -379,6 +380,7 @@ final class WC_Cart_Session {
 		$wc_session->set( 'coupon_discount_tax_totals', null );
 		$wc_session->set( 'removed_cart_contents', null );
 		$wc_session->set( 'order_awaiting_payment', null );
+		$wc_session->set( PaymentRecovery::ORDER_SENT_TO_GATEWAY, null );
 		$wc_session->set( 'store_api_draft_order', null );
 		$wc_session->set( 'shipping_method_counts', null );
 		$wc_session->set( 'previous_shipping_methods', null );
diff --git a/plugins/woocommerce/includes/class-wc-checkout.php b/plugins/woocommerce/includes/class-wc-checkout.php
index 7db68b20216..64c9c291eb0 100644
--- a/plugins/woocommerce/includes/class-wc-checkout.php
+++ b/plugins/woocommerce/includes/class-wc-checkout.php
@@ -12,6 +12,7 @@ use Automattic\WooCommerce\Enums\OrderItemType;
 use Automattic\WooCommerce\Enums\OrderStatus;
 use Automattic\WooCommerce\Enums\ProductTaxStatus;
 use Automattic\WooCommerce\Enums\ProductType;
+use Automattic\WooCommerce\Internal\Checkout\PaymentRecovery;
 use Automattic\WooCommerce\Internal\CostOfGoodsSold\CogsAwareTrait;
 use Automattic\WooCommerce\Internal\ProductVariations\SelectedVariationName;
 use Automattic\WooCommerce\Internal\Tax\TaxRateDataStore;
@@ -36,6 +37,13 @@ class WC_Checkout {
 		'shipping_tax',
 	);

+	/**
+	 * Error code create_order() returns when the submit repeats one for an order that moved past payment.
+	 *
+	 * @var string
+	 */
+	private const ORDER_ALREADY_PLACED_ERROR = 'checkout-order-already-placed';
+
 	/**
 	 * The single instance of the class.
 	 *
@@ -444,6 +452,17 @@ class WC_Checkout {
 			$available_gateways = WC()->payment_gateways->get_available_payment_gateways();
 			$order              = $order_id ? wc_get_order( $order_id ) : null;

+			// The gateway moved this order on but something died before the cart was
+			// emptied, so this submit is a repeat: a new order would charge the shopper twice.
+			$session_order = PaymentRecovery::get_session_order_moved_past_payment( $order_id );
+			if ( $session_order ) {
+				return new WP_Error(
+					self::ORDER_ALREADY_PLACED_ERROR,
+					__( 'This order has already been placed.', 'woocommerce' ),
+					array( 'order_id' => $session_order->get_id() )
+				);
+			}
+
 			/**
 			 * If there is an order pending payment, we can resume it here so
 			 * long as it has not changed. If the order has changed, i.e.
@@ -1412,6 +1431,7 @@ class WC_Checkout {

 		// Store Order ID in session, so it can be re-used after payment failure.
 		WC()->session->set( 'order_awaiting_payment', $order_id );
+		PaymentRecovery::remember_order_sent_to_gateway( (int) $order_id );

 		// We save the session early because if the payment gateway hangs
 		// the request will never finish, thus the session data will never be saved,
@@ -1494,6 +1514,77 @@ class WC_Checkout {
 		);
 	}

+	/**
+	 * Answer a repeat submit for an order that already moved past payment.
+	 *
+	 * The shopper is sent to the order received page, where wc_clear_cart_after_payment()
+	 * empties the cart the gateway left behind, instead of being charged for a second order.
+	 *
+	 * @since 11.4.0
+	 * @param WC_Order $order The order the session was awaiting payment for.
+	 */
+	protected function send_repeat_submit_response( WC_Order $order ): void {
+		PaymentRecovery::record_repeat_submit( $order );
+
+		wc_log_order_step(
+			'[Shortcode #6C] Repeat submit for an order that moved past payment, sending to the order received page',
+			array(
+				'order_object' => $order,
+				'redirected'   => ! wp_doing_ajax() ? 'yes' : 'no',
+			),
+			true
+		);
+
+		$this->send_order_received_response( $order );
+	}
+
+	/**
+	 * Record a failure raised after the gateway moved the order on and send the shopper to the order received page.
+	 *
+	 * Reporting a failure would send them back to place the order again, and every retry
+	 * pays for another order. PaymentRecovery empties the cart rather than leaving it to the
+	 * received page: after payment_complete() nothing in the session points at the order, so
+	 * if the shopper never lands there, a submit from the still-open form would build a new one.
+	 *
+	 * @since 11.4.0
+	 * @param WC_Order  $order The order, re-read after the failure.
+	 * @param Throwable $error The failure raised after the gateway ran.
+	 */
+	protected function recover_order_that_moved_past_payment( WC_Order $order, Throwable $error ): void {
+		PaymentRecovery::record_failure_after_payment( $order, $error, 'checkout' );
+
+		wc_log_order_step(
+			'[Shortcode #6D] Order moved past payment before a failure, sending to the order received page',
+			array(
+				'order_object' => $order,
+				'error'        => get_class( $error ) . ': ' . $error->getMessage(),
+				'redirected'   => ! wp_doing_ajax() ? 'yes' : 'no',
+			),
+			true
+		);
+
+		$this->send_order_received_response( $order );
+	}
+
+	/**
+	 * End the request by sending the shopper to the order received page: a redirect for the form post, JSON for the checkout JS.
+	 *
+	 * @param WC_Order $order Order object.
+	 */
+	private function send_order_received_response( WC_Order $order ): void {
+		if ( ! wp_doing_ajax() ) {
+			wp_safe_redirect( $order->get_checkout_order_received_url() );
+			exit;
+		}
+
+		wp_send_json(
+			array(
+				'result'   => 'success',
+				'redirect' => $order->get_checkout_order_received_url(),
+			)
+		);
+	}
+
 	/**
 	 * Create a new customer account if needed.
 	 *
@@ -1620,6 +1711,7 @@ class WC_Checkout {
 	 * Process the checkout after the confirm order button is pressed.
 	 *
 	 * @throws Exception When validation fails.
+	 * @throws Throwable When a gateway fails before it moved the order on; only an Error escapes, as before, since an Exception is caught below.
 	 */
 	public function process_checkout() {
 		try {
@@ -1668,6 +1760,13 @@ class WC_Checkout {

 			wc_log_order_step( '[Shortcode #2] Session updated with checkout data and totals calculated' );

+			// Answer a repeat submit before validation: the first order may have taken the last
+			// units, and the stock check would then turn the shopper away from an order they placed.
+			$session_order = PaymentRecovery::get_session_order_moved_past_payment( absint( WC()->session->get( 'order_awaiting_payment' ) ) );
+			if ( $session_order ) {
+				$this->send_repeat_submit_response( $session_order );
+			}
+
 			// Validate posted data and cart items before proceeding.
 			$this->validate_checkout( $posted_data, $errors );

@@ -1687,6 +1786,14 @@ class WC_Checkout {
 				$order_id = $this->create_order( $posted_data );
 				$order    = wc_get_order( $order_id );

+				if ( is_wp_error( $order_id ) && self::ORDER_ALREADY_PLACED_ERROR === $order_id->get_error_code() ) {
+					$session_order = wc_get_order( $order_id->get_error_data()['order_id'] ?? 0 );
+
+					if ( $session_order instanceof WC_Order ) {
+						$this->send_repeat_submit_response( $session_order );
+					}
+				}
+
 				if ( is_wp_error( $order_id ) ) {
 					throw new Exception( $order_id->get_error_message() );
 				}
@@ -1730,8 +1837,23 @@ class WC_Checkout {
 				 */

 				if ( apply_filters( 'woocommerce_cart_needs_payment', $order->needs_payment(), WC()->cart ) ) {
-					$this->process_order_payment( $order_id, $posted_data['payment_method'] );
+					try {
+						$this->process_order_payment( $order_id, $posted_data['payment_method'] );
+					} catch ( Throwable $e ) {
+						// The gateway may have moved the order on already, say when a post-payment
+						// integration throws inside the status transition. Re-read it: the gateway
+						// changed the status on its own instance, so $order is stale.
+						$refreshed_order = wc_get_order( $order_id );
+
+						if ( ! $refreshed_order instanceof WC_Order || ! PaymentRecovery::order_moved_past_payment( $refreshed_order ) ) {
+							throw $e;
+						}
+
+						$this->recover_order_that_moved_past_payment( $refreshed_order, $e );
+					}
 				} else {
+					// No gateway runs, but payment_complete() still runs the status change a plugin can die in.
+					PaymentRecovery::remember_order_sent_to_gateway( (int) $order_id );
 					$this->process_order_without_payment( $order_id );
 				}
 			}
diff --git a/plugins/woocommerce/includes/wc-cart-functions.php b/plugins/woocommerce/includes/wc-cart-functions.php
index dedd1d234ac..b13e195e84c 100644
--- a/plugins/woocommerce/includes/wc-cart-functions.php
+++ b/plugins/woocommerce/includes/wc-cart-functions.php
@@ -12,6 +12,7 @@ use Automattic\Jetpack\Constants;
 use Automattic\WooCommerce\Blocks\Utils\CartCheckoutUtils;
 use Automattic\WooCommerce\Enums\OrderStatus;
 use Automattic\WooCommerce\Enums\ProductType;
+use Automattic\WooCommerce\Internal\Checkout\PaymentRecovery;
 use Automattic\WooCommerce\StoreApi\Utilities\LocalPickupUtils;

 defined( 'ABSPATH' ) || exit;
@@ -203,8 +204,16 @@ function wc_clear_cart_after_payment() {
 	}

 	// If the order is awaiting payment, and we haven't already decided to clear the cart, check the order status.
-	if ( is_object( WC()->session ) && WC()->session->order_awaiting_payment > 0 && ! $should_clear_cart_after_payment ) {
-		$order = wc_get_order( WC()->session->order_awaiting_payment );
+	$session_order_id = 0;
+	if ( is_object( WC()->session ) ) {
+		// The order sent to the gateway comes first: payment_complete() clears order_awaiting_payment
+		// before the status change, so after a request dies there it is the only pointer left.
+		$session_order_id = absint( WC()->session->get( PaymentRecovery::ORDER_SENT_TO_GATEWAY ) );
+		$session_order_id = $session_order_id ? $session_order_id : absint( WC()->session->order_awaiting_payment );
+	}
+
+	if ( $session_order_id > 0 && ! $should_clear_cart_after_payment ) {
+		$order = wc_get_order( $session_order_id );

 		if ( $order instanceof WC_Order && $order->get_id() > 0 ) {
 			// If the order status is neither pending, failed, nor cancelled, the order must have gone through.
diff --git a/plugins/woocommerce/src/Internal/Checkout/PaymentRecovery.php b/plugins/woocommerce/src/Internal/Checkout/PaymentRecovery.php
new file mode 100644
index 00000000000..9166bb9b2da
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Checkout/PaymentRecovery.php
@@ -0,0 +1,190 @@
+<?php
+/**
+ * PaymentRecovery class file.
+ */
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\Checkout;
+
+use Automattic\WooCommerce\Enums\OrderStatus;
+use Throwable;
+use WC_Order;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * What both checkouts need when a failure follows the gateway: whether the order it left behind
+ * moved past payment, and how to record the failure without sending the shopper back to pay again.
+ *
+ * The shortcode checkout (WC_Checkout) and the Store API checkout answer such a failure
+ * differently, one with a redirect or JSON and the other with a PaymentResult, but they judge the
+ * order and record the failure the same way, so that part lives here.
+ *
+ * @internal For WooCommerce core use only.
+ */
+final class PaymentRecovery {
+
+	/**
+	 * Session key holding the order last handed to a gateway, or completed without one when it was free.
+	 *
+	 * Unlike order_awaiting_payment, which WC_Order::payment_complete() clears before the status
+	 * change, this survives a request that dies inside that change, so a repeat submit can still
+	 * find the order. Emptying the cart clears it.
+	 *
+	 * @var string
+	 */
+	public const ORDER_SENT_TO_GATEWAY = 'order_sent_to_gateway';
+
+	/**
+	 * Remembers the order a checkout is about to hand to the gateway.
+	 *
+	 * @since 11.4.0
+	 *
+	 * @param int $order_id Order ID.
+	 */
+	public static function remember_order_sent_to_gateway( int $order_id ): void {
+		if ( WC()->session ) {
+			WC()->session->set( self::ORDER_SENT_TO_GATEWAY, $order_id );
+		}
+	}
+
+	/**
+	 * The session's order, when it moved past payment and the cart still holds it.
+	 *
+	 * That combination means a submit repeats one the gateway already took: the gateway moved the
+	 * order on but the request died before the cart was emptied.
+	 *
+	 * @since 11.4.0
+	 *
+	 * @param int $fallback_order_id Order to use when nothing was remembered, as in a session written before the key existed.
+	 * @return WC_Order|null
+	 */
+	public static function get_session_order_moved_past_payment( int $fallback_order_id = 0 ): ?WC_Order {
+		if ( ! WC()->session || ! WC()->cart ) {
+			return null;
+		}
+
+		$order_id = absint( WC()->session->get( self::ORDER_SENT_TO_GATEWAY ) );
+		$order    = wc_get_order( $order_id ? $order_id : $fallback_order_id );
+
+		if ( ! $order instanceof WC_Order || ! $order->has_cart_hash( WC()->cart->get_cart_hash() ) ) {
+			return null;
+		}
+
+		return self::order_moved_past_payment( $order ) ? $order : null;
+	}
+
+	/**
+	 * Notes on the order that a repeat submit was answered with its order received page.
+	 *
+	 * @since 11.4.0
+	 *
+	 * @param WC_Order $order The order the gateway already moved past payment.
+	 */
+	public static function record_repeat_submit( WC_Order $order ): void {
+		$order->add_order_note( __( 'The checkout form was submitted again for this order after the payment step. No second order was created; the customer was sent to the order received page.', 'woocommerce' ) );
+	}
+
+	/**
+	 * Reads the order again, so a status the gateway set on its own instance is seen.
+	 *
+	 * @since 11.4.0
+	 *
+	 * @param WC_Order $order Order object, possibly stale.
+	 * @return WC_Order The stored order, or the given one when it can no longer be read.
+	 */
+	public static 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.
+	 *
+	 * @since 11.4.0
+	 *
+	 * @param WC_Order $order Order object.
+	 * @return bool
+	 */
+	public static function order_moved_past_payment( WC_Order $order ): bool {
+		/**
+		 * This filter is documented in woocommerce/includes/class-wc-order.php
+		 *
+		 * @since 2.7.0
+		 */
+		$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 raised after the gateway moved the order on, and clears the cart it left.
+	 *
+	 * The caller still has to answer the shopper with the order received page: this only writes
+	 * down what happened, since neither checkout reports the failure to the shopper any more.
+	 *
+	 * @since 11.4.0
+	 *
+	 * @param WC_Order  $order      Order object.
+	 * @param Throwable $error      The failure raised after the gateway moved the order on.
+	 * @param string    $log_source Log source to file the failure under.
+	 */
+	public static function record_failure_after_payment( WC_Order $order, Throwable $error, string $log_source ): 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 it moved past the payment step: %2$s: %3$s in %4$s:%5$d',
+				$order->get_id(),
+				get_class( $error ),
+				$error->getMessage(),
+				$error->getFile(),
+				$error->getLine()
+			),
+			array( 'source' => $log_source )
+		);
+
+		$order->add_order_note(
+			sprintf(
+				/* translators: %s: the error that was raised after the order moved past the payment step. */
+				__( 'Checkout could not be completed after the order moved past the payment step. The order keeps the status the gateway set. Error: %s', 'woocommerce' ),
+				wp_strip_all_tags( $error->getMessage() )
+			)
+		);
+
+		/*
+		 * 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 and a
+		 * second tab both reach here 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();
+		}
+	}
+}
diff --git a/plugins/woocommerce/src/StoreApi/Routes/V1/Checkout.php b/plugins/woocommerce/src/StoreApi/Routes/V1/Checkout.php
index a6e424d1e78..dd7ea7a01c5 100644
--- a/plugins/woocommerce/src/StoreApi/Routes/V1/Checkout.php
+++ b/plugins/woocommerce/src/StoreApi/Routes/V1/Checkout.php
@@ -9,6 +9,7 @@ use Automattic\WooCommerce\StoreApi\Exceptions\RouteException;
 use Automattic\WooCommerce\StoreApi\Utilities\DraftOrderTrait;
 use Automattic\WooCommerce\Checkout\Helpers\ReserveStockException;
 use Automattic\WooCommerce\StoreApi\Utilities\CheckoutTrait;
+use Automattic\WooCommerce\Internal\Checkout\PaymentRecovery;

 /**
  * Checkout class.
@@ -190,10 +191,9 @@ class Checkout extends AbstractCartRoute {
 			// 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;
+			$order = $this->order instanceof \WC_Order ? PaymentRecovery::refresh_order( $this->order ) : null;

-			if ( $order && ! $this->order_moved_past_payment( $order ) ) {
+			if ( $order && ! PaymentRecovery::order_moved_past_payment( $order ) ) {
 				wc_release_stock_for_order( $order );
 				wc_release_coupons_for_order( $order );
 			}
@@ -572,6 +572,16 @@ class Checkout extends AbstractCartRoute {
 		$this->cart_controller->validate_cart_not_empty();
 		wc_log_order_step( '[Store API #2] Cart validated' );

+		/**
+		 * Answer a repeat submit before the cart and stock checks: the gateway already moved this
+		 * order on and the request died before the cart was emptied, so a new order would charge
+		 * the shopper twice, and the first one may have taken the last units.
+		 */
+		$session_order = PaymentRecovery::get_session_order_moved_past_payment( (int) $this->get_draft_order_id() );
+		if ( $session_order ) {
+			return $this->get_repeat_submit_response( $session_order, $request );
+		}
+
 		/**
 		 * Persist customer session data from the request first so that OrderController::update_addresses_from_cart
 		 * uses the up-to-date customer address.
@@ -737,6 +747,38 @@ class Checkout extends AbstractCartRoute {
 		);
 	}

+	/**
+	 * Answers a submit that repeats one the gateway already took, with that order's received page.
+	 *
+	 * Both redirect fields are set, as on the normal success path: gateway client code reads the
+	 * redirect from the payment details rather than from redirect_url.
+	 *
+	 * @param \WC_Order                              $order   The order the gateway moved past payment.
+	 * @param \WP_REST_Request<array<string, mixed>> $request Request object.
+	 * @return \WP_REST_Response
+	 */
+	private function get_repeat_submit_response( \WC_Order $order, \WP_REST_Request $request ) { // phpcs:ignore Squiz.Commenting.FunctionComment.IncorrectTypeHint
+		PaymentRecovery::record_repeat_submit( $order );
+
+		$payment_result = new PaymentResult( 'success' );
+		$payment_result->set_redirect_url( $order->get_checkout_order_received_url() );
+		$payment_result->set_payment_details( array( 'redirect' => $payment_result->get_redirect_url() ) );
+
+		wc_log_order_step(
+			'[Store API #2A] Repeat submit for an order that moved past payment, sending to the order received page',
+			array( 'order_object' => $order ),
+			true
+		);
+
+		return $this->prepare_item_for_response(
+			(object) [
+				'order'          => $order,
+				'payment_result' => $payment_result,
+			],
+			$request
+		);
+	}
+
 	/**
 	 * Get route response when something went wrong.
 	 *
diff --git a/plugins/woocommerce/src/StoreApi/Utilities/CheckoutTrait.php b/plugins/woocommerce/src/StoreApi/Utilities/CheckoutTrait.php
index 3234cf4b447..42342f2c999 100644
--- a/plugins/woocommerce/src/StoreApi/Utilities/CheckoutTrait.php
+++ b/plugins/woocommerce/src/StoreApi/Utilities/CheckoutTrait.php
@@ -9,6 +9,7 @@ use Automattic\WooCommerce\StoreApi\Payments\PaymentResult;
 use Automattic\WooCommerce\Blocks\Domain\Services\CheckoutFieldsSchema\DocumentObject;
 use Automattic\WooCommerce\Admin\Features\Features;
 use Automattic\WooCommerce\Enums\OrderStatus;
+use Automattic\WooCommerce\Internal\Checkout\PaymentRecovery;
 use WC_Customer;

 /**
@@ -104,6 +105,7 @@ trait CheckoutTrait {

 		$session = WC()->session;
 		$session->set( 'order_awaiting_payment', $order->get_id() );
+		PaymentRecovery::remember_order_sent_to_gateway( $order->get_id() );
 		// Persist before invoking gateways because redirects or stalled requests may prevent the session from being saved on shutdown.
 		if ( is_callable( array( $session, 'save_data' ) ) ) {
 			$session->save_data();
@@ -147,9 +149,9 @@ trait CheckoutTrait {
 			 * 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 );
+			$order = PaymentRecovery::refresh_order( $order );

-			if ( $this->order_moved_past_payment( $order ) ) {
+			if ( PaymentRecovery::order_moved_past_payment( $order ) ) {
 				$this->recover_order_that_took_payment( $order, $e, $result_for_recovery );
 				return;
 			}
@@ -176,62 +178,6 @@ 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.
@@ -241,30 +187,7 @@ trait CheckoutTrait {
 	 * @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() )
-			)
-		);
+		PaymentRecovery::record_failure_after_payment( $order, $error, 'store-api' );

 		$payment_result->set_status( 'success' );

@@ -290,16 +213,6 @@ trait CheckoutTrait {
 			$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();
-		}
 	}

 	/**
diff --git a/plugins/woocommerce/tests/php/includes/class-wc-cart-test.php b/plugins/woocommerce/tests/php/includes/class-wc-cart-test.php
index b21257ee273..3eeb423a6f8 100644
--- a/plugins/woocommerce/tests/php/includes/class-wc-cart-test.php
+++ b/plugins/woocommerce/tests/php/includes/class-wc-cart-test.php
@@ -7,6 +7,7 @@

 use Automattic\WooCommerce\Checkout\Helpers\ReserveStock;
 use Automattic\WooCommerce\Enums\OrderStatus;
+use Automattic\WooCommerce\Internal\Checkout\PaymentRecovery;
 use Automattic\WooCommerce\Tests\Blocks\Helpers\FixtureData;

 /**
@@ -2162,6 +2163,26 @@ class WC_Cart_Test extends \WC_Unit_Test_Case {
 		}
 	}

+	/**
+	 * @testdox Should clear the cart after payment_complete() cleared order_awaiting_payment, when the order sent to the gateway moved past payment.
+	 */
+	public function test_clear_cart_after_payment_reads_the_order_sent_to_gateway(): void {
+		$product = WC_Helper_Product::create_simple_product();
+		WC()->cart->add_to_cart( $product->get_id(), 1 );
+
+		$order = WC_Helper_Order::create_order( 1, $product, array( 'status' => OrderStatus::PENDING ) );
+		$order->set_cart_hash( WC()->cart->get_cart_hash() );
+		$order->save();
+
+		PaymentRecovery::remember_order_sent_to_gateway( $order->get_id() );
+		WC()->session->set( 'order_awaiting_payment', $order->get_id() );
+		$order->payment_complete( 'txn_died_in_transition' );
+
+		wc_clear_cart_after_payment();
+
+		$this->assertTrue( WC()->cart->is_empty(), 'The next page load should empty the cart of an order a card gateway already charged, so a retry cannot place it again.' );
+	}
+
 	/**
 	 * @testdox Should allow woocommerce_should_clear_cart_after_payment to override the final clear cart value.
 	 */
diff --git a/plugins/woocommerce/tests/php/includes/class-wc-checkout-test.php b/plugins/woocommerce/tests/php/includes/class-wc-checkout-test.php
index 6e8dda0e6a6..3c31cd1921a 100644
--- a/plugins/woocommerce/tests/php/includes/class-wc-checkout-test.php
+++ b/plugins/woocommerce/tests/php/includes/class-wc-checkout-test.php
@@ -6,12 +6,23 @@
  */

 use Automattic\WooCommerce\Enums\OrderStatus;
+use Automattic\WooCommerce\Internal\Checkout\PaymentRecovery;
+use Automattic\WooCommerce\RestApi\UnitTests\LoggerSpyTrait;
 use Automattic\WooCommerce\Testing\Tools\CodeHacking\Hacks\FunctionsMockerHack;

 /**
  * Class WC_Checkout
  */
 class WC_Checkout_Test extends \WC_Unit_Test_Case {
+	use LoggerSpyTrait;
+
+	/**
+	 * The least create_order() needs from a posted classic checkout form, paying by cash on delivery.
+	 */
+	private const COD_POSTED_DATA = array(
+		'payment_method' => WC_Gateway_COD::ID,
+		'billing_email'  => 'customer@example.com',
+	);

 	/**
 	 * @var object The system under test.
@@ -23,6 +34,16 @@ class WC_Checkout_Test extends \WC_Unit_Test_Case {
 	 */
 	private $extra_field_filters = array();

+	/**
+	 * @var WC_Session|null The session the test base installed, put back after a test swapped in a real handler.
+	 */
+	private $original_session;
+
+	/**
+	 * @var WC_Customer|null The customer the test base installed, put back after a test ran a full checkout into a fresh one.
+	 */
+	private $original_session_customer;
+
 	/**
 	 * @var array The customer's location and VAT exemption before each test, restored on tear down.
 	 */
@@ -87,6 +108,13 @@ class WC_Checkout_Test extends \WC_Unit_Test_Case {

 		$this->extra_field_filters = array();

+		if ( $this->original_session ) {
+			WC()->session                    = $this->original_session;
+			WC()->customer                   = $this->original_session_customer;
+			$this->original_session          = null;
+			$this->original_session_customer = null;
+		}
+
 		$customer = WC()->customer;
 		$customer->set_billing_location( $this->original_customer[0], $this->original_customer[1] );
 		$customer->set_shipping_location( $this->original_customer[2], $this->original_customer[3] );
@@ -1941,6 +1969,525 @@ class WC_Checkout_Test extends \WC_Unit_Test_Case {
 		$this->assertStringContainsString( 'Order items could not be saved', $result->get_error_message(), 'Error message should surface the defense-in-depth guard message.' );
 	}

+	/**
+	 * @testdox create_order() decides a repeat submit by the session order's status: "$status" leads to "$expected".
+	 *
+	 * @testWith ["processing", "refuse"]
+	 *           ["completed", "refuse"]
+	 *           ["on-hold", "refuse"]
+	 *           ["pending", "resume"]
+	 *           ["failed", "resume"]
+	 *           ["cancelled", "new-order"]
+	 *           ["refunded", "new-order"]
+	 *
+	 * @param string $status   Status the session order is in when the form is submitted again.
+	 * @param string $expected What create_order() does with it: refuse, resume or new-order.
+	 */
+	public function test_create_order_decides_a_repeat_submit_by_the_session_order_status( string $status, string $expected ): void {
+		$first_order_id = $this->leave_session_order_in_status( $status );
+
+		$result = $this->sut->create_order( self::COD_POSTED_DATA );
+
+		switch ( $expected ) {
+			case 'refuse':
+				$this->assertWPError( $result, 'A repeat submit for an order that went through must not build a new order.' );
+				$this->assertSame( 'checkout-order-already-placed', $result->get_error_code() );
+				$this->assertSame( array( 'order_id' => $first_order_id ), $result->get_error_data() );
+				$this->assertSame( array( $first_order_id ), $this->order_ids(), 'No second order should exist.' );
+				break;
+			case 'resume':
+				$this->assertSame( $first_order_id, $result, 'The order awaiting payment should be resumed.' );
+				break;
+			case 'new-order':
+				$this->assertNotWPError( $result );
+				$this->assertNotSame( $first_order_id, $result, 'A new order should be built.' );
+				break;
+			default:
+				$this->fail( "Unknown expectation '{$expected}'." );
+		}
+	}
+
+	/**
+	 * @testdox create_order() does not treat a status the site declares payable as a repeat submit.
+	 */
+	public function test_create_order_does_not_treat_a_site_declared_payable_status_as_a_repeat_submit(): void {
+		add_filter(
+			'woocommerce_valid_order_statuses_for_payment',
+			function ( $statuses ) {
+				$statuses[] = OrderStatus::ON_HOLD;
+
+				return $statuses;
+			}
+		);
+		$this->leave_session_order_in_status( OrderStatus::ON_HOLD );
+
+		$result = $this->sut->create_order( self::COD_POSTED_DATA );
+
+		$this->assertNotWPError( $result, 'An on-hold order the site still lets the shopper pay for is not a repeat submit.' );
+	}
+
+	/**
+	 * Leave the session the way a gateway that died right after its status change does: the order it points at
+	 * has the cart's hash and the given status, and the cart was never emptied.
+	 *
+	 * @param string $status Status to put the order in.
+	 * @return int The order ID.
+	 */
+	private function leave_session_order_in_status( string $status ): int {
+		$product = WC_Helper_Product::create_simple_product( true, array( 'virtual' => true ) );
+		WC()->cart->add_to_cart( $product->get_id() );
+		WC()->cart->calculate_totals();
+
+		$order_id = $this->sut->create_order( self::COD_POSTED_DATA );
+		$this->assertNotWPError( $order_id );
+		WC()->session->set( 'order_awaiting_payment', $order_id );
+		wc_get_order( $order_id )->update_status( $status );
+
+		return $order_id;
+	}
+
+	/**
+	 * @testdox process_checkout() answers a repeat submit with the order-received redirect when the session order already moved past payment.
+	 */
+	public function test_process_checkout_answers_a_repeat_submit_with_the_order_received_redirect(): void {
+		$this->use_real_session();
+		$this->make_gateway_available( WC_Gateway_COD::ID );
+		// What a failure the checkout could not recover leaves behind: the session order moved on, the cart was never emptied.
+		$order = wc_get_order( $this->leave_session_order_in_status( OrderStatus::PROCESSING ) );
+		$this->post_checkout_form( WC_Gateway_COD::ID );
+
+		$response = $this->submit_checkout_over_ajax();
+
+		$this->assertSame( 'success', $response['result'] ?? null, wp_json_encode( $response ) );
+		$this->assertSame( $order->get_checkout_order_received_url(), $response['redirect'] ?? null );
+		$this->assertSame( array( $order->get_id() ), $this->order_ids(), 'The retry must not build a second order.' );
+		$this->assertStringContainsString( 'No second order was created', $this->latest_note( $order ), 'The merchant should see the repeat submit on the order.' );
+	}
+
+	/**
+	 * @testdox process_checkout() answers a repeat submit after a card gateway's payment_complete() cleared order_awaiting_payment and the request died.
+	 */
+	public function test_process_checkout_answers_a_repeat_submit_after_payment_complete_cleared_the_session(): void {
+		$this->use_real_session();
+		$this->make_gateway_available( WC_Gateway_COD::ID );
+		// What an exit inside a card gateway's status change leaves: the order paid, the older pointer cleared, the cart full.
+		$order = wc_get_order( $this->leave_session_order_in_status( OrderStatus::PENDING ) );
+		PaymentRecovery::remember_order_sent_to_gateway( $order->get_id() );
+		$order->payment_complete( 'txn_died_in_transition' );
+		$this->post_checkout_form( WC_Gateway_COD::ID );
+
+		$response = $this->submit_checkout_over_ajax();
+
+		$this->assertSame( 'success', $response['result'] ?? null, wp_json_encode( $response ) );
+		$this->assertSame( $order->get_checkout_order_received_url(), $response['redirect'] ?? null );
+		$this->assertSame( array( $order->get_id() ), $this->order_ids(), 'The retry must not build and charge a second order.' );
+	}
+
+	/**
+	 * @testdox process_checkout() answers a repeat submit before the stock check, when the first order took the last unit.
+	 */
+	public function test_process_checkout_answers_a_repeat_submit_before_the_stock_check(): void {
+		$this->use_real_session();
+		$this->make_gateway_available( WC_Gateway_COD::ID );
+		$product = WC_Helper_Product::create_simple_product(
+			true,
+			array(
+				'virtual'        => true,
+				'manage_stock'   => true,
+				'stock_quantity' => 1,
+			)
+		);
+		WC()->cart->add_to_cart( $product->get_id() );
+		WC()->cart->calculate_totals();
+		$order = wc_get_order( $this->sut->create_order( self::COD_POSTED_DATA ) );
+		PaymentRecovery::remember_order_sent_to_gateway( $order->get_id() );
+		$order->payment_complete( 'txn_took_the_last_unit' );
+		// The retry is a new request, so its cart holds the product as it is now: out of stock.
+		WC()->cart->get_cart_from_session();
+		$this->post_checkout_form( WC_Gateway_COD::ID );
+
+		$response = $this->submit_checkout_over_ajax();
+
+		$this->assertSame( 0, wc_get_product( $product->get_id() )->get_stock_quantity(), 'The first order took the last unit.' );
+		$this->assertSame( 'success', $response['result'] ?? null, 'The shopper should be sent to the order, not told the product sold out: ' . wp_json_encode( $response ) );
+		$this->assertSame( $order->get_checkout_order_received_url(), $response['redirect'] ?? null );
+	}
+
+	/**
+	 * @testdox process_checkout() answers a repeat submit for a free order whose request died after it moved past payment.
+	 */
+	public function test_process_checkout_answers_a_repeat_submit_for_a_free_order(): void {
+		$this->use_real_session();
+		$product = WC_Helper_Product::create_simple_product(
+			true,
+			array(
+				'virtual'       => true,
+				'regular_price' => 0,
+				'price'         => 0,
+			)
+		);
+		WC()->cart->add_to_cart( $product->get_id() );
+		$this->post_checkout_form( '' );
+		// A free order skips the gateway, but payment_complete() still runs its status change; a plugin dying there is the reported shape.
+		$die_in_transition = function () {
+			throw new Error( 'Call to a member function push_order() on null' );
+		};
+		add_action( 'woocommerce_order_status_processing', $die_in_transition );
+
+		try {
+			$this->submit_checkout_over_ajax();
+			$this->fail( 'A free order is not recovered on the first attempt, so the Error should escape.' );
+		} catch ( Error $e ) {
+			$this->assertSame( 'Call to a member function push_order() on null', $e->getMessage() );
+		}
+		remove_action( 'woocommerce_order_status_processing', $die_in_transition );
+		$order = wc_get_order( $this->order_ids()[0] ?? 0 );
+		$this->post_checkout_form( '' );
+
+		$response = $this->submit_checkout_over_ajax();
+
+		$this->assertInstanceOf( WC_Order::class, $order );
+		$this->assertTrue( $order->has_status( OrderStatus::PROCESSING ), 'The first submit completed the free order before the request died.' );
+		$this->assertSame( 'success', $response['result'] ?? null, wp_json_encode( $response ) );
+		$this->assertSame( $order->get_checkout_order_received_url(), $response['redirect'] ?? null );
+		$this->assertSame( array( $order->get_id() ), $this->order_ids(), 'The retry must not build a second free order.' );
+	}
+
+	/**
+	 * @testdox process_checkout() recovers an order the gateway moved on when an Error is raised inside the status transition.
+	 */
+	public function test_process_checkout_recovers_an_order_the_gateway_moved_on_when_the_transition_throws(): void {
+		$this->use_real_session();
+		$this->make_gateway_available( WC_Gateway_BACS::ID );
+		WC()->cart->add_to_cart( WC_Helper_Product::create_simple_product( true, array( 'virtual' => true ) )->get_id() );
+		$this->post_checkout_form( WC_Gateway_BACS::ID );
+		// BACS moves the order on-hold and only then empties the cart; a plugin dying in between is the reported shape.
+		add_action(
+			'woocommerce_order_status_on-hold',
+			function () {
+				throw new Error( 'Call to a member function push_order() on null' );
+			}
+		);
+
+		$response = $this->submit_checkout_over_ajax();
+
+		$order = wc_get_order( $this->order_ids()[0] ?? 0 );
+		$this->assertInstanceOf( WC_Order::class, $order );
+		$this->assertSame( 'success', $response['result'] ?? null, wp_json_encode( $response ) );
+		$this->assertSame( $order->get_checkout_order_received_url(), $response['redirect'] ?? null );
+		$this->assertTrue( $order->has_status( OrderStatus::ON_HOLD ), 'The status the gateway set must stand.' );
+		$this->assertTrue( WC()->cart->is_empty(), 'The cart the gateway never got to empty should be emptied.' );
+		$this->assertStringContainsString( 'push_order() on null', $this->latest_note( $order ), 'The merchant should see the failure on the order.' );
+		$this->assertLogged( 'error', 'push_order() on null' );
+		$this->assertLogged( 'error', __FILE__ );
+	}
+
+	/**
+	 * @testdox process_checkout() recovers when a gateway that calls payment_complete() dies inside the resulting status transition.
+	 */
+	public function test_process_checkout_recovers_when_a_payment_complete_gateway_dies_in_the_transition(): void {
+		$this->use_real_session();
+		$this->make_gateway_available(
+			$this->create_gateway(
+				'card_like',
+				function ( WC_Order $order ) {
+					$order->payment_complete( 'txn_1' );
+				}
+			)
+		);
+		WC()->cart->add_to_cart( WC_Helper_Product::create_simple_product( true, array( 'virtual' => true ) )->get_id() );
+		$this->post_checkout_form( 'card_like' );
+		add_action(
+			'woocommerce_order_status_processing',
+			function () {
+				throw new Error( 'Call to a member function push_order() on null' );
+			}
+		);
+
+		$response = $this->submit_checkout_over_ajax();
+
+		$order = wc_get_order( $this->order_ids()[0] ?? 0 );
+		$this->assertInstanceOf( WC_Order::class, $order );
+		$this->assertSame( 'success', $response['result'] ?? null, wp_json_encode( $response ) );
+		$this->assertSame( $order->get_checkout_order_received_url(), $response['redirect'] ?? null );
+		$this->assertTrue( $order->has_status( OrderStatus::PROCESSING ) );
+		$this->assertTrue( WC()->cart->is_empty() );
+	}
+
+	/**
+	 * @testdox process_checkout() recovers when an Exception follows a successful payment result.
+	 */
+	public function test_process_checkout_recovers_when_an_exception_follows_payment(): void {
+		$this->use_real_session();
+		$this->make_gateway_available( WC_Gateway_BACS::ID );
+		WC()->cart->add_to_cart( WC_Helper_Product::create_simple_product( true, array( 'virtual' => true ) )->get_id() );
+		$this->post_checkout_form( WC_Gateway_BACS::ID );
+		add_filter(
+			'woocommerce_payment_successful_result',
+			function () {
+				throw new Exception( 'Analytics integration timed out' );
+			}
+		);
+
+		$response = $this->submit_checkout_over_ajax();
+
+		$order = wc_get_order( $this->order_ids()[0] ?? 0 );
+		$this->assertInstanceOf( WC_Order::class, $order );
+		$this->assertSame( 'success', $response['result'] ?? null, wp_json_encode( $response ) );
+		$this->assertTrue( $order->has_status( OrderStatus::ON_HOLD ) );
+		$this->assertStringContainsString( 'Analytics integration timed out', $this->latest_note( $order ) );
+	}
+
+	/**
+	 * @testdox process_checkout() lets an Error raised before the gateway moved the order on escape unchanged.
+	 */
+	public function test_process_checkout_lets_an_error_raised_before_payment_escape(): void {
+		$this->use_real_session();
+		$this->make_gateway_available(
+			$this->create_gateway(
+				'exploding',
+				function () {
+					throw new Error( 'Gateway SDK not loaded' );
+				}
+			)
+		);
+		WC()->cart->add_to_cart( WC_Helper_Product::create_simple_product( true, array( 'virtual' => true ) )->get_id() );
+		$this->post_checkout_form( 'exploding' );
+
+		try {
+			$this->submit_checkout_over_ajax();
+			$this->fail( 'An Error raised before payment should still reach the fatal handler.' );
+		} catch ( Error $e ) {
+			$this->assertSame( 'Gateway SDK not loaded', $e->getMessage() );
+		}
+
+		$order = wc_get_order( $this->order_ids()[0] ?? 0 );
+		$this->assertInstanceOf( WC_Order::class, $order );
+		$this->assertTrue( $order->has_status( OrderStatus::PENDING ), 'Nothing moved the order on, so nothing should be recovered.' );
+		$this->assertFalse( WC()->cart->is_empty() );
+		$this->assertStringNotContainsString( 'after payment', $this->latest_note( $order ) );
+	}
+
+	/**
+	 * @testdox process_checkout() reports an Exception raised before the gateway moved the order on as a failure, as before.
+	 */
+	public function test_process_checkout_reports_an_exception_raised_before_payment_as_a_failure(): void {
+		$this->use_real_session();
+		$this->make_gateway_available(
+			$this->create_gateway(
+				'declining',
+				function () {
+					throw new Exception( 'Your card was declined.' );
+				}
+			)
+		);
+		WC()->cart->add_to_cart( WC_Helper_Product::create_simple_product( true, array( 'virtual' => true ) )->get_id() );
+		$this->post_checkout_form( 'declining' );
+
+		$response = $this->submit_checkout_over_ajax();
+
+		$order = wc_get_order( $this->order_ids()[0] ?? 0 );
+		$this->assertInstanceOf( WC_Order::class, $order );
+		$this->assertSame( 'failure', $response['result'] ?? null, wp_json_encode( $response ) );
+		$this->assertStringContainsString( 'Your card was declined.', $response['messages'] ?? '' );
+		$this->assertTrue( $order->has_status( OrderStatus::PENDING ) );
+		$this->assertFalse( WC()->cart->is_empty(), 'The shopper should be able to try again with the same cart.' );
+	}
+
+	/**
+	 * @testdox process_checkout() remembers the order it hands to the gateway before the gateway runs.
+	 */
+	public function test_process_checkout_remembers_the_order_sent_to_gateway(): void {
+		$this->use_real_session();
+
+		$remembered_during_payment = null;
+		$this->make_gateway_available(
+			$this->create_gateway(
+				'recording',
+				function () use ( &$remembered_during_payment ) {
+					$remembered_during_payment = WC()->session->get( 'order_sent_to_gateway' );
+					throw new Exception( 'Stop after recording.' );
+				}
+			)
+		);
+		WC()->cart->add_to_cart( WC_Helper_Product::create_simple_product( true, array( 'virtual' => true ) )->get_id() );
+		$this->post_checkout_form( 'recording' );
+
+		$this->submit_checkout_over_ajax();
+
+		$this->assertSame( $this->order_ids()[0] ?? null, $remembered_during_payment, 'The gateway should run with its order already remembered, so a request that dies inside it can be traced back.' );
+	}
+
+	/**
+	 * IDs of every order in the database, oldest first.
+	 *
+	 * @return int[]
+	 */
+	private function order_ids(): array {
+		return wc_get_orders(
+			array(
+				'return'  => 'ids',
+				'limit'   => -1,
+				'orderby' => 'ID',
+				'order'   => 'ASC',
+			)
+		);
+	}
+
+	/**
+	 * Content of the most recent note on an order, or an empty string.
+	 *
+	 * @param WC_Order $order Order object.
+	 * @return string
+	 */
+	private function latest_note( WC_Order $order ): string {
+		$notes = wc_get_order_notes(
+			array(
+				'order_id' => $order->get_id(),
+				'limit'    => 1,
+			)
+		);
+
+		return $notes[0]->content ?? '';
+	}
+
+	/**
+	 * Build a throwaway gateway whose process_payment() runs the given callback with the order, then reports success.
+	 *
+	 * @param string   $id              Gateway ID.
+	 * @param callable $process_payment Called with the WC_Order; throw from it to simulate a gateway failure.
+	 * @return WC_Payment_Gateway
+	 */
+	private function create_gateway( string $id, callable $process_payment ): WC_Payment_Gateway {
+		// phpcs:disable Generic.CodeAnalysis, Squiz.Commenting
+		return new class( $id, $process_payment ) extends WC_Payment_Gateway {
+			private $process_payment_callback;
+
+			public function __construct( string $id, callable $process_payment ) {
+				$this->id                       = $id;
+				$this->title                    = $id;
+				$this->enabled                  = 'yes';
+				$this->process_payment_callback = $process_payment;
+			}
+
+			public function process_payment( $order_id ) {
+				$order = wc_get_order( $order_id );
+				( $this->process_payment_callback )( $order );
+
+				return array(
+					'result'   => 'success',
+					'redirect' => $this->get_return_url( $order ),
+				);
+			}
+		};
+		// phpcs:enable Generic.CodeAnalysis, Squiz.Commenting
+	}
+
+	/**
+	 * Swap the test base's in-memory session for a real handler, which is what process_order_payment() needs (it calls save_data()).
+	 *
+	 * The customer is swapped too, because a full checkout writes the posted address into it and nothing else resets that singleton.
+	 */
+	private function use_real_session(): void {
+		$this->original_session          = WC()->session;
+		$this->original_session_customer = WC()->customer;
+
+		WC()->session = new WC_Session_Handler();
+		WC()->session->init();
+		WC()->session->set_customer_session_cookie( true );
+		WC()->customer = new WC_Customer( 0, true );
+	}
+
+	/**
+	 * Make a gateway available at checkout without touching stored settings: a registered one by ID, or an instance.
+	 *
+	 * @param string|WC_Payment_Gateway $gateway Gateway ID or gateway object.
+	 */
+	private function make_gateway_available( $gateway ): void {
+		$gateway = $gateway instanceof WC_Payment_Gateway ? $gateway : WC()->payment_gateways()->payment_gateways()[ $gateway ];
+
+		add_filter(
+			'woocommerce_available_payment_gateways',
+			function ( $gateways ) use ( $gateway ) {
+				$gateways[ $gateway->id ] = $gateway;
+
+				return $gateways;
+			}
+		);
+	}
+
+	/**
+	 * Fill the request the way the classic checkout form posts it, for a guest buying a virtual product.
+	 *
+	 * Call it after use_real_session(): the nonce takes its logged-out user ID from the session, so one created
+	 * before the swap fails verification after it.
+	 *
+	 * @param string $payment_method Gateway ID.
+	 */
+	private function post_checkout_form( string $payment_method ): void {
+		update_option( 'woocommerce_enable_guest_checkout', 'yes' );
+
+		$_POST    = array(
+			'woocommerce-process-checkout-nonce' => wp_create_nonce( 'woocommerce-process_checkout' ),
+			'payment_method'                     => $payment_method,
+			'billing_first_name'                 => 'Ada',
+			'billing_last_name'                  => 'Lovelace',
+			'billing_address_1'                  => '1 Analytical Engine Way',
+			'billing_city'                       => 'Los Angeles',
+			'billing_state'                      => 'CA',
+			'billing_postcode'                   => '90210',
+			'billing_country'                    => 'US',
+			'billing_email'                      => 'customer@example.com',
+			'billing_phone'                      => '555-555-5555',
+		);
+		$_REQUEST = $_POST; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Test fixture; the request carries the nonce created just above.
+	}
+
+	/**
+	 * Run process_checkout() as the checkout JS does, returning the JSON it answers with.
+	 *
+	 * Anything other than the wp_send_json() exit, such as an Error a hook raises, propagates to the caller.
+	 * The exit is an Error rather than an Exception because process_checkout() catches Exception itself.
+	 *
+	 * Cannot drive a gateway that returns success: its exit happens inside process_order_payment(), so
+	 * process_checkout() catches it and recovers it as a post-payment failure, and two JSON bodies come back.
+	 * Make the gateway throw before that point, or take the repeat-submit path, which exits outside the catch.
+	 *
+	 * @return array Decoded JSON response.
+	 */
+	private function submit_checkout_over_ajax(): array {
+		$throw_instead_of_dying = function () {
+			return function () {
+				throw new Error( 'wp_die' );
+			};
+		};
+		add_filter( 'wp_doing_ajax', '__return_true' );
+		add_filter( 'wp_die_ajax_handler', $throw_instead_of_dying );
+		$outer_buffer_level = ob_get_level();
+		ob_start();
+
+		try {
+			$this->sut->process_checkout();
+			$this->fail( 'process_checkout() should end the request with wp_send_json().' );
+		} catch ( Error $e ) {
+			if ( 'wp_die' !== $e->getMessage() ) {
+				throw $e;
+			}
+			$output = (string) ob_get_clean();
+		} finally {
+			while ( ob_get_level() > $outer_buffer_level ) {
+				ob_end_clean();
+			}
+			remove_filter( 'wp_doing_ajax', '__return_true' );
+			remove_filter( 'wp_die_ajax_handler', $throw_instead_of_dying );
+		}
+
+		$decoded = json_decode( $output, true );
+		$this->assertIsArray( $decoded, $output );
+
+		return $decoded;
+	}
+
 	/**
 	 * @testdox Checkout tolerates non-array cart variation data when building contextual item names.
 	 */
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 99f02f0ff42..559d12fd4a5 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/Checkout.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/Checkout.php
@@ -3874,16 +3874,25 @@ class Checkout extends \WP_Test_REST_TestCase {
 	}

 	/**
-	 * @testdox Checkout does not reuse a zero-total order from the session once it has moved past awaiting payment.
+	 * @testdox Checkout answers with the zero-total order from the session, without placing another, once that order has moved past awaiting payment.
 	 */
-	public function test_post_does_not_reuse_completed_zero_total_order_from_session(): void {
+	public function test_post_answers_with_completed_zero_total_order_from_session(): void {
 		$this->apply_full_discount_coupon();
 		$order = $this->create_session_order_from_cart( OrderStatus::COMPLETED );

 		$response = rest_get_server()->dispatch( $this->build_valid_post_request() );

+		$order_ids = wc_get_orders(
+			array(
+				'limit'  => -1,
+				'status' => 'any',
+				'return' => 'ids',
+			)
+		);
+
 		$this->assertEquals( 200, $response->get_status(), print_r( $response->get_data(), true ) );
-		$this->assertNotSame( $order->get_id(), (int) $response->get_data()['order_id'], 'A completed order must not be placed again.' );
+		$this->assertSame( array( $order->get_id() ), $order_ids, 'A completed order must not be placed again.' );
+		$this->assertSame( $order->get_checkout_order_received_url(), $response->get_data()['payment_result']['redirect_url'], 'The shopper should be sent to the order received page of the completed order.' );
 		$this->assertTrue( wc_get_order( $order->get_id() )->has_status( OrderStatus::COMPLETED ), 'The completed order should be left as it was.' );
 	}

@@ -3969,6 +3978,29 @@ class Checkout extends \WP_Test_REST_TestCase {
 		$this->assertSame( $order_id, (int) $persisted_session_data['order_awaiting_payment'], 'Redirect payments should leave the persisted order linked to the shopper session.' );
 	}

+	/**
+	 * @testdox Store API checkout remembers the order it hands to the gateway before the gateway runs.
+	 */
+	public function test_post_remembers_the_order_sent_to_gateway(): void {
+		$remembered_during_payment = null;
+		$payment_handler           = function ( $context, $payment_result ) use ( &$remembered_during_payment ) {
+			unset( $context );
+			$remembered_during_payment = WC()->session->get( 'order_sent_to_gateway' );
+			$payment_result->set_status( 'success' );
+		};
+
+		add_action( 'woocommerce_rest_checkout_process_payment_with_context', $payment_handler, 1, 2 );
+
+		try {
+			$response = rest_get_server()->dispatch( $this->build_valid_post_request() );
+		} finally {
+			remove_action( 'woocommerce_rest_checkout_process_payment_with_context', $payment_handler, 1 );
+		}
+
+		$this->assertEquals( 200, $response->get_status(), print_r( $response->get_data(), true ) );
+		$this->assertSame( (int) $response->get_data()['order_id'], $remembered_during_payment, 'The gateway should run with its order already remembered, so a request that dies inside it can be traced back.' );
+	}
+
 	/**
 	 * @testdox Each checkout request handled in the same PHP request receives its own payment data, as Store API batch subrequests do.
 	 */
@@ -4339,6 +4371,106 @@ class Checkout extends \WP_Test_REST_TestCase {
 		);
 	}

+	/**
+	 * @testdox Retrying after the request died between payment_complete() and the cart being emptied does not place a second paid order.
+	 */
+	public function test_retry_after_the_request_died_post_payment_does_not_duplicate_the_order() {
+		// PHPUnit cannot survive a real exit, so arrange the state one leaves behind: the first
+		// POST stops before payment, as an exit inside the transition would stop before the
+		// catch, and the gateway's payment_complete() is then run on the order directly.
+		$stop_before_payment = function () {
+			throw new \Automattic\WooCommerce\StoreApi\Exceptions\RouteException(
+				'woocommerce_rest_checkout_payment_failed',
+				'Stands in for the request dying after payment_complete()',
+				400
+			);
+		};
+		add_action( 'woocommerce_store_api_checkout_order_processed', $stop_before_payment, 999 );
+
+		try {
+			rest_get_server()->dispatch( $this->build_checkout_post_request() );
+		} finally {
+			remove_action( 'woocommerce_store_api_checkout_order_processed', $stop_before_payment, 999 );
+		}
+
+		$paid_order_id = (int) WC()->session->get( 'store_api_draft_order' );
+		$this->assertGreaterThan( 0, $paid_order_id, 'The session should point at the order the first attempt created.' );
+
+		$paid_order = wc_get_order( $paid_order_id );
+		$paid_order->payment_complete( 'txn_died_after_payment' );
+
+		$this->assertFalse( wc_get_order( $paid_order_id )->needs_payment(), 'The gateway took payment before the request died.' );
+		$this->assertFalse( WC()->cart->is_empty(), 'The request died before anything emptied the cart.' );
+		$this->assertSame( $paid_order_id, (int) WC()->session->get( 'store_api_draft_order' ), 'payment_complete() leaves the Store API pointer in place.' );
+
+		$this->simulate_fresh_request();
+		$retry = 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(
+			array( $paid_order_id ),
+			$order_ids_after_retry,
+			'Retrying after the request died post-payment must not create a second paid order. Retry answered: ' . print_r( $retry->get_data(), true )
+		);
+		$this->assertSame( $paid_order_id, $retry->get_data()['order_id'], 'The retry should answer with the order that was already paid.' );
+		$this->assertSame( 'success', $retry->get_data()['payment_result']['payment_status'] );
+		$this->assertSame( wc_get_order( $paid_order_id )->get_checkout_order_received_url(), $retry->get_data()['payment_result']['redirect_url'], 'The shopper should be sent to the order received page of the paid order.' );
+	}
+
+	/**
+	 * @testdox Retrying after the request died post-payment is answered with the paid order, not a stock error, when that order took the last unit.
+	 */
+	public function test_retry_after_the_paid_order_took_the_last_unit_is_answered_with_that_order() {
+		// 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( 1 );
+		$product->set_backorders( 'no' );
+		$product->save();
+		update_option( 'woocommerce_manage_stock', 'yes' );
+
+		WC()->cart->empty_cart();
+		WC()->cart->add_to_cart( $product->get_id(), 1 );
+
+		// Same arrangement as the test above: the first POST stops before payment, then the gateway's
+		// payment_complete() runs on the order directly, which takes the last unit.
+		$stop_before_payment = function () {
+			throw new \Automattic\WooCommerce\StoreApi\Exceptions\RouteException(
+				'woocommerce_rest_checkout_payment_failed',
+				'Stands in for the request dying after payment_complete()',
+				400
+			);
+		};
+		add_action( 'woocommerce_store_api_checkout_order_processed', $stop_before_payment, 999 );
+
+		try {
+			rest_get_server()->dispatch( $this->build_checkout_post_request() );
+		} finally {
+			remove_action( 'woocommerce_store_api_checkout_order_processed', $stop_before_payment, 999 );
+		}
+
+		$paid_order_id = (int) WC()->session->get( 'store_api_draft_order' );
+		wc_get_order( $paid_order_id )->payment_complete( 'txn_took_the_last_unit' );
+		$this->assertSame( 0, wc_get_product( $product->get_id() )->get_stock_quantity(), 'The paid order took the last unit.' );
+
+		// The retry is a new request, so its cart holds the product as it is now: out of stock.
+		$this->simulate_fresh_request();
+		WC()->cart->get_cart_from_session();
+		$retry = rest_get_server()->dispatch( $this->build_checkout_post_request() );
+
+		$this->assertEquals( 200, $retry->get_status(), 'The shopper should be sent to the order, not told the product sold out: ' . print_r( $retry->get_data(), true ) );
+		$this->assertSame( $paid_order_id, $retry->get_data()['order_id'], 'The retry should answer with the order that took the last unit.' );
+		$this->assertSame( 'success', $retry->get_data()['payment_result']['payment_status'] );
+	}
+
 	/**
 	 * @testdox A recovered checkout empties the cart the gateway never got to.
 	 */
diff --git a/plugins/woocommerce/tests/php/src/Internal/Checkout/PaymentRecoveryTest.php b/plugins/woocommerce/tests/php/src/Internal/Checkout/PaymentRecoveryTest.php
new file mode 100644
index 00000000000..57dda1f5f1a
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Checkout/PaymentRecoveryTest.php
@@ -0,0 +1,263 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\Checkout;
+
+use Automattic\WooCommerce\Enums\OrderStatus;
+use Automattic\WooCommerce\Internal\Checkout\PaymentRecovery;
+use Automattic\WooCommerce\RestApi\UnitTests\LoggerSpyTrait;
+use WC_Helper_Product;
+use WC_Order;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for the PaymentRecovery class.
+ */
+class PaymentRecoveryTest extends WC_Unit_Test_Case {
+
+	use LoggerSpyTrait;
+
+	/**
+	 * @testdox Should treat every status a gateway cannot leave an order awaiting payment in as past payment.
+	 *
+	 * @testWith ["processing", true]
+	 *           ["completed", true]
+	 *           ["on-hold", true]
+	 *           ["pending", false]
+	 *           ["failed", false]
+	 *           ["checkout-draft", false]
+	 *           ["cancelled", false]
+	 *           ["refunded", false]
+	 *
+	 * @param string $status   Status to leave the order in.
+	 * @param bool   $expected Whether the order counts as moved past payment.
+	 */
+	public function test_order_moved_past_payment_decides_by_status( string $status, bool $expected ): void {
+		$order = new WC_Order();
+		$order->set_status( $status );
+		$order->save();
+
+		$this->assertSame(
+			$expected,
+			PaymentRecovery::order_moved_past_payment( $order ),
+			sprintf( 'An order in %s should%s count as moved past payment.', $status, $expected ? '' : ' not' )
+		);
+	}
+
+	/**
+	 * @testdox Should leave an order awaiting payment when the site declares its status payable.
+	 */
+	public function test_order_moved_past_payment_respects_the_payable_statuses_filter(): void {
+		$order = new WC_Order();
+		$order->set_status( OrderStatus::ON_HOLD );
+		$order->save();
+
+		$declare_on_hold_payable = function ( $statuses ) {
+			$statuses[] = OrderStatus::ON_HOLD;
+			return $statuses;
+		};
+
+		add_filter( 'woocommerce_valid_order_statuses_for_payment', $declare_on_hold_payable );
+
+		try {
+			$this->assertFalse(
+				PaymentRecovery::order_moved_past_payment( $order ),
+				'A status the site declares payable is still awaiting payment.'
+			);
+		} finally {
+			remove_filter( 'woocommerce_valid_order_statuses_for_payment', $declare_on_hold_payable );
+		}
+	}
+
+	/**
+	 * @testdox Should read the order again so a status the gateway set on its own instance is seen.
+	 */
+	public function test_refresh_order_returns_the_stored_order(): void {
+		$order = new WC_Order();
+		$order->set_status( OrderStatus::PENDING );
+		$order->save();
+
+		$stale = wc_get_order( $order->get_id() );
+		$order->set_status( OrderStatus::PROCESSING );
+		$order->save();
+
+		$refreshed = PaymentRecovery::refresh_order( $stale );
+
+		$this->assertSame( OrderStatus::PROCESSING, $refreshed->get_status(), 'The refreshed order carries the stored status.' );
+	}
+
+	/**
+	 * @testdox Should hand back the order it was given when the order can no longer be read.
+	 */
+	public function test_refresh_order_falls_back_to_the_given_order(): void {
+		$order = new WC_Order();
+		$order->set_status( OrderStatus::PROCESSING );
+		$order->save();
+
+		$order->delete( true );
+
+		$this->assertSame( $order, PaymentRecovery::refresh_order( $order ), 'A deleted order is handed back unchanged.' );
+	}
+
+	/**
+	 * @testdox Should remember the order sent to the gateway under a key payment_complete() leaves alone.
+	 */
+	public function test_the_order_sent_to_gateway_outlives_payment_complete(): void {
+		$order = new WC_Order();
+		$order->set_status( OrderStatus::PENDING );
+		$order->save();
+		WC()->session->set( 'order_awaiting_payment', $order->get_id() );
+
+		PaymentRecovery::remember_order_sent_to_gateway( $order->get_id() );
+		$order->payment_complete( 'txn_1' );
+
+		$this->assertFalse( WC()->session->get( 'order_awaiting_payment' ), 'payment_complete() clears the older pointer, which is the gap this key closes.' );
+		$this->assertSame( $order->get_id(), WC()->session->get( 'order_sent_to_gateway' ), 'The order sent to the gateway is still known after payment_complete().' );
+	}
+
+	/**
+	 * @testdox Should forget the order sent to the gateway once the cart is emptied.
+	 */
+	public function test_emptying_the_cart_forgets_the_order_sent_to_gateway(): void {
+		$order = new WC_Order();
+		$order->save();
+		PaymentRecovery::remember_order_sent_to_gateway( $order->get_id() );
+
+		WC()->cart->empty_cart();
+
+		$this->assertEmpty( WC()->session->get( 'order_sent_to_gateway' ), 'An emptied cart has nothing left to place twice.' );
+	}
+
+	/**
+	 * @testdox Should find the order sent to the gateway only once it moved past payment.
+	 *
+	 * @testWith ["processing", true]
+	 *           ["on-hold", true]
+	 *           ["pending", false]
+	 *           ["failed", false]
+	 *
+	 * @param string $status   Status the order is left in.
+	 * @param bool   $is_found Whether the order should be found as moved past payment.
+	 */
+	public function test_session_order_moved_past_payment_decides_by_status( string $status, bool $is_found ): void {
+		$order = $this->order_for_the_current_cart( $status );
+		PaymentRecovery::remember_order_sent_to_gateway( $order->get_id() );
+
+		$found = PaymentRecovery::get_session_order_moved_past_payment();
+
+		$this->assertSame( $is_found ? $order->get_id() : null, $found ? $found->get_id() : null, sprintf( 'An order in %s should%s be found.', $status, $is_found ? '' : ' not' ) );
+	}
+
+	/**
+	 * @testdox Should not find the order sent to the gateway once the cart holds something else.
+	 */
+	public function test_session_order_moved_past_payment_ignores_a_different_cart(): void {
+		$order = $this->order_for_the_current_cart( OrderStatus::PROCESSING );
+		PaymentRecovery::remember_order_sent_to_gateway( $order->get_id() );
+
+		WC()->cart->add_to_cart( WC_Helper_Product::create_simple_product()->get_id() );
+
+		$this->assertNull( PaymentRecovery::get_session_order_moved_past_payment(), 'A changed cart is a new purchase, not a repeat of the old one.' );
+	}
+
+	/**
+	 * @testdox Should fall back to the order the caller names when nothing was remembered.
+	 */
+	public function test_session_order_moved_past_payment_falls_back_to_the_given_order(): void {
+		$order = $this->order_for_the_current_cart( OrderStatus::PROCESSING );
+
+		$found = PaymentRecovery::get_session_order_moved_past_payment( $order->get_id() );
+
+		$this->assertSame( $order->get_id(), $found ? $found->get_id() : null, 'A session written before the key existed still points at the order.' );
+	}
+
+	/**
+	 * @testdox Should note on the order that a repeat submit was answered without a second order.
+	 */
+	public function test_record_repeat_submit_notes_the_order(): void {
+		$order = new WC_Order();
+		$order->set_status( OrderStatus::PROCESSING );
+		$order->save();
+
+		PaymentRecovery::record_repeat_submit( $order );
+
+		$notes = wc_get_order_notes( array( 'order_id' => $order->get_id() ) );
+		$this->assertStringContainsString( 'No second order was created', $notes[0]->content, 'The merchant should see that the shopper submitted again and was not charged twice.' );
+	}
+
+	/**
+	 * @testdox Should log the failure under the caller's source and note it on the order.
+	 */
+	public function test_record_failure_after_payment_logs_and_notes_the_failure(): void {
+		$order = new WC_Order();
+		$order->set_status( OrderStatus::PROCESSING );
+		$order->save();
+
+		$error = new \Error( 'Call to a member function push_order() on null' );
+
+		PaymentRecovery::record_failure_after_payment( $order, $error, 'checkout' );
+
+		$this->assertLogged( 'error', sprintf( 'Checkout for order #%d failed after it moved past the payment step', $order->get_id() ) );
+		$this->assertLogged( 'error', 'push_order() on null' );
+		$this->assertLogged( 'error', __FILE__ );
+
+		$notes = wc_get_order_notes( array( 'order_id' => $order->get_id() ) );
+
+		$this->assertStringContainsString(
+			'Checkout could not be completed after the order moved past the payment step. The order keeps the status the gateway set. Error: Call to a member function push_order() on null',
+			$notes[0]->content,
+			'The merchant is told what failed and why the order kept its status.'
+		);
+	}
+
+	/**
+	 * @testdox Should empty the cart the gateway left behind when it still holds this order.
+	 */
+	public function test_record_failure_after_payment_empties_a_cart_that_matches_the_order(): void {
+		$product = WC_Helper_Product::create_simple_product();
+		WC()->cart->add_to_cart( $product->get_id() );
+
+		$order = new WC_Order();
+		$order->set_status( OrderStatus::PROCESSING );
+		$order->set_cart_hash( WC()->cart->get_cart_hash() );
+		$order->save();
+
+		PaymentRecovery::record_failure_after_payment( $order, new \Error( 'boom' ), 'checkout' );
+
+		$this->assertTrue( WC()->cart->is_empty(), 'The cart that produced the order is emptied.' );
+	}
+
+	/**
+	 * @testdox Should leave a cart holding something else alone, as a second tab would.
+	 */
+	public function test_record_failure_after_payment_leaves_an_unrelated_cart_alone(): void {
+		$product = WC_Helper_Product::create_simple_product();
+		WC()->cart->add_to_cart( $product->get_id() );
+
+		$order = new WC_Order();
+		$order->set_status( OrderStatus::PROCESSING );
+		$order->set_cart_hash( 'a-different-cart' );
+		$order->save();
+
+		PaymentRecovery::record_failure_after_payment( $order, new \Error( 'boom' ), 'checkout' );
+
+		$this->assertFalse( WC()->cart->is_empty(), 'A cart holding another order is left as it is.' );
+	}
+
+	/**
+	 * Fills the cart with one product and saves an order in the given status that the cart produced.
+	 *
+	 * @param string $status Status to save the order in.
+	 * @return WC_Order
+	 */
+	private function order_for_the_current_cart( string $status ): WC_Order {
+		WC()->cart->add_to_cart( WC_Helper_Product::create_simple_product()->get_id() );
+
+		$order = new WC_Order();
+		$order->set_status( $status );
+		$order->set_cart_hash( WC()->cart->get_cart_hash() );
+		$order->save();
+
+		return $order;
+	}
+}