Commit b217606fe1f for woocommerce

commit b217606fe1fcdc18d75d2bebd6210f50b50e8b33
Author: Mike Jolley <mike.jolley@me.com>
Date:   Thu May 14 10:57:11 2026 +0100

    Fix Store API checkout duplicate orders on failed-payment retry (#64806)

    * Fix Store API checkout duplicate orders on failed-payment retry

    Restores the session-fallback line in create_or_update_draft_order that was deleted to silence a PHPStan nullCoalesce.property warning. Without it, every retry POST after a failed payment created a new order and orphaned the session's pending one (issue #64792).

    The property's @var \WC_Order docblock claimed non-null but the property is initialised to null — that mismatch is what triggered the original warning. Correct the docblock to \WC_Order|null and add a get_order_or_throw() helper on CheckoutTrait so callers can pick up a narrowed \WC_Order or throw if the order hasn't been materialised yet. Also restores the session-first precedence for payment_method in CheckoutSchema::get_draft_response so a logged-in shopper's PATCH selection isn't overwritten by their saved default.

    * Add changelog for Store API checkout duplicate orders fix

    * Address PR review: validate session payment method, document PATCH branches

    - CheckoutSchema::get_draft_response now reconciles the session-stored
      chosen_payment_method against the list of enabled gateways before
      returning it. Covers two cases flagged by @prettyboymp: classic-checkout
      storing an empty string, and a slug surviving after its gateway is
      disabled or its plugin deactivated. Falls back to
      PaymentUtils::get_default_payment_method() when the session value is
      empty or stale.
    - Expand the docblock on Checkout::get_route_update_response to explain
      the PATCH branching (failed-payment retry vs fresh form interaction)
      and the deferred draft order creation, per @ralucaStan's suggestion.

diff --git a/.ai/skills/woocommerce-backend-dev/type-annotations.md b/.ai/skills/woocommerce-backend-dev/type-annotations.md
index 87c2302d4bd..7cac6e7b4f7 100644
--- a/.ai/skills/woocommerce-backend-dev/type-annotations.md
+++ b/.ai/skills/woocommerce-backend-dev/type-annotations.md
@@ -157,5 +157,89 @@ Common ignore identifiers:
 - `return.type` - Return type mismatch
 - `argument.type` - Argument type mismatch
 - `method.nonObject` - Method call on potentially non-object
+- `nullCoalesce.property` - `??` on a property typed as non-nullable

 **Important:** Only use ignores when the code is genuinely correct. Prefer fixing the type annotations or code when possible.
+
+## NEVER Delete Code to Silence a Warning
+
+When a PHPStan warning points at a specific expression, the fix is **never** to delete the expression as a shortcut. Always:
+
+1. **Understand the warning.** What is PHPStan claiming about the code?
+2. **Verify the claim.** Is the type annotation misleading PHPStan, or is the code genuinely unsafe?
+3. **Fix the root cause:**
+   - Wrong `@var` / `@param` / `@return`? Fix the annotation.
+   - Unsafe code (e.g. method call on possibly-null)? Add a guard, narrow with `instanceof`, or assert.
+   - PHPStan is genuinely mistaken? Use `@phpstan-ignore-next-line <identifier>` with a comment explaining *why* PHPStan is wrong.
+4. **Verify the warning is gone and tests still pass.**
+
+**Forbidden shortcut: removing the offending line.** This often silently breaks runtime behaviour that tests don't cover — especially with null-coalescing fallbacks (`??`), defensive guards, and "redundant"-looking session/cache reads.
+
+### Real example (PR #64155 → issue #64792)
+
+A PHPStan `nullCoalesce.property` warning was reported on:
+
+```php
+$this->order = $this->order ?? $this->get_draft_order();
+```
+
+The PR "fixed" the warning by deleting the line. Tests passed. But the line was the **load-bearing failed-payment retry fallback** — without it, every retry POST created a duplicate order. The actual root cause was that `$this->order` was declared `@var \WC_Order` (non-null) but initialised to `null`, so PHPStan thought the `??` was unnecessary.
+
+**Correct fix: make the type honest, then handle null at the points that read the property.**
+
+```php
+// 1. Fix the property docblock to match reality.
+/**
+ * @var \WC_Order|null
+ */
+private $order = null;
+
+// 2. Restore the load-bearing fallback. With the docblock correct, no warning.
+$this->order = $this->order ?? $this->get_draft_order();
+
+// 3. Declare the post-condition on the method that materialises the value, so
+//    PHPStan can narrow `$this->order` in the *direct caller's* scope.
+/**
+ * @phpstan-assert \WC_Order $this->order
+ */
+private function create_or_update_draft_order( \WP_REST_Request $request ) {
+    // ... method already throws if `$this->order` ends up null ...
+}
+
+// 4. For helper methods that read `$this->order` but live in their own method
+//    scope (so they don't benefit from a caller-side assertion), extract a
+//    small helper that throws if null and returns the narrowed order. Callers
+//    use the returned local variable rather than `$this->order` directly. Do
+//    not change method signatures to thread the order through.
+private function get_order_or_throw(): \WC_Order {
+    if ( ! $this->order instanceof \WC_Order ) {
+        throw new RouteException(
+            'woocommerce_rest_checkout_missing_order',
+            esc_html__( 'Unable to create order', 'woocommerce' ),
+            500
+        );
+    }
+    return $this->order;
+}
+
+private function process_payment( \WP_REST_Request $request, PaymentResult $payment_result ) {
+    $order = $this->get_order_or_throw();
+    // ... rest of method uses $order, which PHPStan knows is non-null ...
+}
+```
+
+The return-value pattern (rather than `@phpstan-assert \WC_Order $this->order` on the helper) is the safer choice when the helper lives in a trait used by multiple classes whose `$this->order` property may be typed differently. `@phpstan-assert` can fail with `assert.alreadyNarrowedType` in a consuming class whose property is already non-null per its (possibly inaccurate) docblock.
+
+Fixing the type honestly often surfaces other latent type-unsafety. Resolve it inside each affected method (null-check + throw, then narrowing), not by changing method signatures to thread the value through. Do not add the new errors to `phpstan-baseline.neon` — the baseline must only shrink.
+
+A single-line `@phpstan-ignore-next-line` with an explanatory comment is acceptable only when PHPStan is genuinely mistaken about a correct piece of code (e.g. it can't see through a runtime invariant). It is *not* a substitute for fixing a wrong type annotation.
+
+### Sanity-check questions before deleting any line to fix a warning
+
+Answer all three out loud:
+
+1. What does this line do at runtime?
+2. In what scenario does it matter (success path, failure path, retry path, edge case)?
+3. Which tests cover that scenario?
+
+If any answer is "I don't know," **do not delete the line.** Add an inline suppression with a comment, or fix the underlying annotation.
diff --git a/plugins/woocommerce/changelog/64792-fix-store-api-checkout-duplicate-orders-on-retry b/plugins/woocommerce/changelog/64792-fix-store-api-checkout-duplicate-orders-on-retry
new file mode 100644
index 00000000000..5712c769349
--- /dev/null
+++ b/plugins/woocommerce/changelog/64792-fix-store-api-checkout-duplicate-orders-on-retry
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Store API: reuse the session's pending/failed order on checkout POST retry instead of creating a duplicate. Also restore session precedence for `payment_method` in the deferred-draft response so a shopper's PATCH-time selection isn't overwritten by their saved default.
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index e4bc5e40ff0..762bf98b1e8 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -71718,24 +71718,6 @@ parameters:
 			count: 1
 			path: src/StoreApi/Routes/V1/Agentic/CheckoutSessionsComplete.php

-		-
-			message: '#^Cannot call method get_checkout_order_received_url\(\) on WC_Order\|null\.$#'
-			identifier: method.nonObject
-			count: 1
-			path: src/StoreApi/Routes/V1/Agentic/CheckoutSessionsComplete.php
-
-		-
-			message: '#^Cannot call method get_customer_id\(\) on WC_Order\|null\.$#'
-			identifier: method.nonObject
-			count: 2
-			path: src/StoreApi/Routes/V1/Agentic/CheckoutSessionsComplete.php
-
-		-
-			message: '#^Cannot call method payment_complete\(\) on WC_Order\|null\.$#'
-			identifier: method.nonObject
-			count: 1
-			path: src/StoreApi/Routes/V1/Agentic/CheckoutSessionsComplete.php
-
 		-
 			message: '#^Instanceof between Automattic\\WooCommerce\\StoreApi\\Payments\\PaymentResult and Automattic\\WooCommerce\\StoreApi\\Payments\\PaymentResult will always evaluate to true\.$#'
 			identifier: instanceof.alwaysTrue
@@ -71862,30 +71844,12 @@ parameters:
 			count: 1
 			path: src/StoreApi/Routes/V1/Agentic/CheckoutSessionsComplete.php

-		-
-			message: '#^Parameter \#1 \$order of method Automattic\\WooCommerce\\Blocks\\Domain\\Services\\CheckoutFields\:\:sync_customer_additional_fields_with_order\(\) expects WC_Order, WC_Order\|null given\.$#'
-			identifier: argument.type
-			count: 1
-			path: src/StoreApi/Routes/V1/Agentic/CheckoutSessionsComplete.php
-
-		-
-			message: '#^Parameter \#1 \$order of method Automattic\\WooCommerce\\StoreApi\\Payments\\PaymentContext\:\:set_order\(\) expects WC_Order, WC_Order\|null given\.$#'
-			identifier: argument.type
-			count: 1
-			path: src/StoreApi/Routes/V1/Agentic/CheckoutSessionsComplete.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/Agentic/CheckoutSessionsComplete.php

-		-
-			message: '#^Parameter \#3 \$order of method Automattic\\WooCommerce\\Blocks\\Domain\\Services\\CheckoutFields\:\:persist_field_for_order\(\) expects WC_Order, WC_Order\|null given\.$#'
-			identifier: argument.type
-			count: 1
-			path: src/StoreApi/Routes/V1/Agentic/CheckoutSessionsComplete.php
-
 		-
 			message: '#^Call to method to_rest_response\(\) on an unknown class Automattic\\WooCommerce\\StoreApi\\Routes\\V1\\Agentic\\Errors\\Error\.$#'
 			identifier: class.notFound
diff --git a/plugins/woocommerce/src/StoreApi/Routes/V1/Checkout.php b/plugins/woocommerce/src/StoreApi/Routes/V1/Checkout.php
index a6905aa0df4..575a73d1dbe 100644
--- a/plugins/woocommerce/src/StoreApi/Routes/V1/Checkout.php
+++ b/plugins/woocommerce/src/StoreApi/Routes/V1/Checkout.php
@@ -31,9 +31,11 @@ class Checkout extends AbstractCartRoute {
 	const SCHEMA_TYPE = 'checkout';

 	/**
-	 * Holds the current order being processed.
+	 * Holds the current order being processed. Null until `create_or_update_draft_order()`
+	 * materialises it (either by reusing the session's pending/failed order or by creating
+	 * a new one from the cart).
 	 *
-	 * @var \WC_Order
+	 * @var \WC_Order|null
 	 */
 	private $order = null;

@@ -349,7 +351,22 @@ class Checkout extends AbstractCartRoute {
 	}

 	/**
-	 * Get route response for PUT requests.
+	 * Get route response for PUT/PATCH requests.
+	 *
+	 * Branches on whether a pending/failed order already exists in the customer's
+	 * session:
+	 *
+	 * - Order in session (failed-payment retry): update the existing order via
+	 *   `create_or_update_draft_order()` + `update_order_from_request()`. Same
+	 *   shape as the POST flow.
+	 * - No order in session (fresh checkout form interaction): persist request
+	 *   state to the customer session via `update_session_from_request()` and
+	 *   return a no-order response built from cart + customer + request.
+	 *
+	 * Draft order creation is deferred to POST (place-order time) to avoid
+	 * orphaned `wc-checkout-draft` rows from form interactions that never
+	 * complete. POSTs do not flow through this method — see
+	 * `get_route_post_response()`.
 	 *
 	 * @param \WP_REST_Request $request Request object.
 	 * @throws RouteException On error.
@@ -745,10 +762,15 @@ class Checkout extends AbstractCartRoute {
 	/**
 	 * Create or update a draft order based on the cart.
 	 *
+	 * @phpstan-assert \WC_Order $this->order
+	 *
 	 * @param \WP_REST_Request $request Full details about the request.
 	 * @throws RouteException On error.
 	 */
 	private function create_or_update_draft_order( \WP_REST_Request $request ) {
+		// Reuse the failed/pending order from the customer's session if one exists; otherwise the POST flow would orphan it by creating a fresh order on every retry.
+		$this->order = $this->order ?? $this->get_draft_order();
+
 		if ( ! $this->order ) {
 			$this->order = $this->order_controller->create_order_from_cart();
 			wc_log_order_step( '[Store API #4::create_or_update_draft_order] Created order from cart', array( 'order_object' => $this->order ) );
@@ -958,6 +980,8 @@ class Checkout extends AbstractCartRoute {
 	 * @param \WP_REST_Request $request Request object.
 	 */
 	private function process_customer( \WP_REST_Request $request ) {
+		$order = $this->get_order_or_throw();
+
 		if ( $this->should_create_customer_account( $request ) ) {
 			$customer_id = wc_create_new_customer(
 				$request['billing_address']['email'],
@@ -979,7 +1003,7 @@ class Checkout extends AbstractCartRoute {
 			}

 			// Associate customer with the order.
-			$this->order->set_customer_id( $customer_id );
+			$order->set_customer_id( $customer_id );

 			// Set the customer auth cookie.
 			wc_set_customer_auth_cookie( $customer_id );
@@ -987,8 +1011,8 @@ class Checkout extends AbstractCartRoute {
 		}

 		// Persist customer address data to account.
-		$this->order_controller->sync_customer_data_with_order( $this->order );
-		wc_log_order_step( '[Store API #6::process_customer] Synced customer data from order', array( 'customer_id' => $this->order->get_customer_id() ) );
+		$this->order_controller->sync_customer_data_with_order( $order );
+		wc_log_order_step( '[Store API #6::process_customer] Synced customer data from order', array( 'customer_id' => $order->get_customer_id() ) );
 	}

 	/**
diff --git a/plugins/woocommerce/src/StoreApi/Schemas/V1/CheckoutSchema.php b/plugins/woocommerce/src/StoreApi/Schemas/V1/CheckoutSchema.php
index 9551bda36ec..9b8b85c4b9b 100644
--- a/plugins/woocommerce/src/StoreApi/Schemas/V1/CheckoutSchema.php
+++ b/plugins/woocommerce/src/StoreApi/Schemas/V1/CheckoutSchema.php
@@ -261,6 +261,13 @@ class CheckoutSchema extends AbstractSchema {
 	 * @return array
 	 */
 	public function get_draft_response( \WC_Cart $cart, \WC_Customer $customer ) {
+		// Use the shopper's session-stored selection so a PATCH-time choice survives the next render — but only if the gateway is still enabled, since the slug can outlive the gateway (admin disables it, payment plugin deactivated).
+		$session_payment_method = (string) WC()->session->get( 'chosen_payment_method' );
+		$enabled_gateways       = PaymentUtils::get_enabled_payment_gateways();
+		$payment_method         = ( '' !== $session_payment_method && isset( $enabled_gateways[ $session_payment_method ] ) )
+			? $session_payment_method
+			: (string) PaymentUtils::get_default_payment_method();
+
 		return [
 			'order_id'           => 0,
 			'status'             => 'checkout-draft',
@@ -270,7 +277,7 @@ class CheckoutSchema extends AbstractSchema {
 			'customer_id'        => $customer->get_id(),
 			'billing_address'    => (object) $this->billing_address_schema->get_item_response( $customer ),
 			'shipping_address'   => (object) $this->shipping_address_schema->get_item_response( $customer ),
-			'payment_method'     => (string) PaymentUtils::get_default_payment_method(),
+			'payment_method'     => $payment_method,
 			'payment_result'     => null,
 			'additional_fields'  => (object) $this->get_additional_fields_response( $customer ),
 			'__experimentalCart' => (object) $this->cart_schema->get_item_response( $cart ),
diff --git a/plugins/woocommerce/src/StoreApi/Utilities/CheckoutTrait.php b/plugins/woocommerce/src/StoreApi/Utilities/CheckoutTrait.php
index c224f30cd56..d876951ab6e 100644
--- a/plugins/woocommerce/src/StoreApi/Utilities/CheckoutTrait.php
+++ b/plugins/woocommerce/src/StoreApi/Utilities/CheckoutTrait.php
@@ -39,35 +39,61 @@ trait CheckoutTrait {
 		return $response;
 	}

+	/**
+	 * Returns the order being processed, throwing if it hasn't been materialised yet.
+	 *
+	 * Use the returned `WC_Order` (rather than `$this->order`) for type-safe access in
+	 * the rest of the calling method.
+	 *
+	 * @throws RouteException If `$this->order` is null.
+	 * @return \WC_Order
+	 */
+	private function get_order_or_throw(): \WC_Order {
+		if ( ! $this->order instanceof \WC_Order ) {
+			throw new RouteException(
+				'woocommerce_rest_checkout_missing_order',
+				esc_html__( 'Unable to create order', 'woocommerce' ),
+				500
+			);
+		}
+		return $this->order;
+	}
+
 	/**
 	 * For orders which do not require payment, just update status.
 	 *
+	 * @throws RouteException If the order is missing.
+	 *
 	 * @param \WP_REST_Request $request Request object.
 	 * @param PaymentResult    $payment_result Payment result object.
 	 */
 	private function process_without_payment( \WP_REST_Request $request, PaymentResult $payment_result ) {
-		$this->order->payment_complete();
+		$order = $this->get_order_or_throw();
+
+		$order->payment_complete();

 		// Mark the payment as successful.
 		$payment_result->set_status( 'success' );
-		$payment_result->set_redirect_url( $this->order->get_checkout_order_received_url() );
+		$payment_result->set_redirect_url( $order->get_checkout_order_received_url() );
 	}

 	/**
 	 * Fires an action hook instructing active payment gateways to process the payment for an order and provide a result.
 	 *
-	 * @throws RouteException On error.
+	 * @throws RouteException If the order is missing, or on payment error.
 	 *
 	 * @param \WP_REST_Request $request Request object.
 	 * @param PaymentResult    $payment_result Payment result object.
 	 */
 	private function process_payment( \WP_REST_Request $request, PaymentResult $payment_result ) {
+		$order = $this->get_order_or_throw();
+
 		try {
 			// Prepare the payment context object to pass through payment hooks.
 			$context = new PaymentContext();
 			$context->set_payment_method( $this->get_request_payment_method_id( $request ) );
 			$context->set_payment_data( $this->get_request_payment_data( $request ) );
-			$context->set_order( $this->order );
+			$context->set_order( $order );

 			/**
 			 * Process payment with context.
@@ -148,22 +174,16 @@ trait CheckoutTrait {
 	 * @throws RouteException If the order is missing, or if the order requires a payment method on POST and none was supplied.
 	 */
 	private function update_order_from_request( \WP_REST_Request $request, bool $persist = true ) {
-		if ( ! $this->order instanceof \WC_Order ) {
-			throw new RouteException(
-				'woocommerce_rest_checkout_missing_order',
-				esc_html__( 'Unable to create order', 'woocommerce' ),
-				500
-			);
-		}
+		$order = $this->get_order_or_throw();

-		$this->order->set_customer_note( wc_sanitize_textarea( $request['customer_note'] ) ?? '' );
+		$order->set_customer_note( wc_sanitize_textarea( $request['customer_note'] ) ?? '' );
 		$payment_method = $this->get_request_payment_method( $request );
 		if ( null !== $payment_method ) {
 			WC()->session->set( 'chosen_payment_method', $payment_method->id );
-			$this->order->set_payment_method( $payment_method->id );
-			$this->order->set_payment_method_title( $payment_method->title );
+			$order->set_payment_method( $payment_method->id );
+			$order->set_payment_method_title( $payment_method->title );
 		} else {
-			$order_needs_payment = $this->order->needs_payment();
+			$order_needs_payment = $order->needs_payment();
 			if ( $order_needs_payment && 'POST' === $request->get_method() ) {
 				throw new RouteException(
 					'woocommerce_rest_checkout_missing_payment_method',
@@ -172,29 +192,29 @@ trait CheckoutTrait {
 				);
 			}
 			if ( ! $order_needs_payment ) {
-				$this->order->set_payment_method( '' );
+				$order->set_payment_method( '' );
 			}
 		}
 		wc_log_order_step(
 			'[Store API #5::update_order_from_request] Set customer note and payment method',
 			array(
-				'order_id' => $this->order->get_id(),
-				'payment'  => $this->order->get_payment_method_title(),
+				'order_id' => $order->get_id(),
+				'payment'  => $order->get_payment_method_title(),
 			)
 		);
 		$this->persist_additional_fields_for_order( $request );
 		wc_log_order_step(
 			'[Store API #5::update_order_from_request] Persisted additional fields',
 			array(
-				'order_id' => $this->order->get_id(),
-				'payment'  => $this->order->get_payment_method_title(),
+				'order_id' => $order->get_id(),
+				'payment'  => $order->get_payment_method_title(),
 			)
 		);

 		wc_do_deprecated_action(
 			'__experimental_woocommerce_blocks_checkout_update_order_from_request',
 			array(
-				$this->order,
+				$order,
 				$request,
 			),
 			'6.3.0',
@@ -205,7 +225,7 @@ trait CheckoutTrait {
 		wc_do_deprecated_action(
 			'woocommerce_blocks_checkout_update_order_from_request',
 			array(
-				$this->order,
+				$order,
 				$request,
 			),
 			'7.2.0',
@@ -224,10 +244,10 @@ trait CheckoutTrait {
 		 * @param \WC_Order $order Order object.
 		 * @param \WP_REST_Request $request Full details about the request.
 		 */
-		do_action( 'woocommerce_store_api_checkout_update_order_from_request', $this->order, $request );
+		do_action( 'woocommerce_store_api_checkout_update_order_from_request', $order, $request );

 		if ( $persist ) {
-			$this->order->save();
+			$order->save();
 		}
 	}

@@ -246,18 +266,23 @@ trait CheckoutTrait {
 	/**
 	 * Persist additional fields for the order after validating them.
 	 *
+	 * @throws RouteException If the order is missing.
+	 *
 	 * @param \WP_REST_Request $request Full details about the request.
 	 */
 	private function persist_additional_fields_for_order( \WP_REST_Request $request ) {
+		// Local alias so the closure and downstream calls keep a non-null `WC_Order`.
+		$order = $this->get_order_or_throw();
+
 		$this->resolve_and_persist_additional_fields(
 			$request,
-			function ( string $key, $value ) {
-				$this->additional_fields_controller->persist_field_for_order( $key, $value, $this->order, 'other', false );
+			function ( string $key, $value ) use ( $order ) {
+				$this->additional_fields_controller->persist_field_for_order( $key, $value, $order, 'other', false );
 			}
 		);

-		if ( 0 !== $this->order->get_customer_id() && get_current_user_id() === $this->order->get_customer_id() ) {
-			$this->additional_fields_controller->sync_customer_additional_fields_with_order( $this->order, wc()->customer );
+		if ( 0 !== $order->get_customer_id() && get_current_user_id() === $order->get_customer_id() ) {
+			$this->additional_fields_controller->sync_customer_additional_fields_with_order( $order, wc()->customer );
 		}
 	}

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 40c9cce6500..b3bd5733a28 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/Checkout.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/Checkout.php
@@ -2210,6 +2210,54 @@ class Checkout extends MockeryTestCase {
 		$this->assertSame( 'placed-directly', $order->get_meta( '_sample_ext_no_patch_meta' ) );
 	}

+	/**
+	 * Regression test for https://github.com/woocommerce/woocommerce/issues/64792.
+	 *
+	 * After a failed payment, the customer's session holds a pointer to the pending
+	 * order. A second POST on the same session must reuse that order — otherwise the
+	 * session pointer is overwritten by `set_draft_order_id()` and the first order is
+	 * orphaned. Prior to the fix, `create_or_update_draft_order()` did not consult the
+	 * session and unconditionally created a new order on every POST.
+	 */
+	public function test_post_reuses_pending_order_from_session_on_retry() {
+		// Force the first POST to fail at the order-processed hook, mirroring the
+		// real failed-payment shape from issue #64792. The throw happens after the
+		// order has been created and the session pointer set, but before the cart-
+		// clear that would normally follow a successful checkout.
+		$fail_hook = function () {
+			throw new \Automattic\WooCommerce\StoreApi\Exceptions\RouteException(
+				'woocommerce_rest_checkout_payment_failed',
+				'Forced failure for issue #64792 repro',
+				400
+			);
+		};
+		add_action( 'woocommerce_store_api_checkout_order_processed', $fail_hook, 999 );
+
+		$first_response = rest_get_server()->dispatch( $this->build_valid_post_request() );
+		$this->assertEquals( 400, $first_response->get_status(), 'First POST should fail per the forced-failure hook.' );
+
+		$first_order_id = (int) WC()->session->get( 'store_api_draft_order' );
+		$this->assertGreaterThan( 0, $first_order_id, 'Session should hold the failed order id after a failed POST.' );
+
+		$first_order = wc_get_order( $first_order_id );
+		$this->assertInstanceOf( \WC_Order::class, $first_order );
+		$this->assertTrue( $first_order->has_status( 'pending' ), 'First order should be left in pending status after payment failure.' );
+
+		remove_action( 'woocommerce_store_api_checkout_order_processed', $fail_hook, 999 );
+
+		// Second POST on the same session should reuse the existing pending order.
+		$second_response = rest_get_server()->dispatch( $this->build_valid_post_request() );
+		$this->assertEquals( 200, $second_response->get_status(), print_r( $second_response->get_data(), true ) );
+
+		$second_order_id = (int) $second_response->get_data()['order_id'];
+		$this->assertSame(
+			$first_order_id,
+			$second_order_id,
+			'Second POST must reuse the existing pending order, not create a new one (regression: issue #64792).'
+		);
+		$this->assertSame( $first_order_id, (int) WC()->session->get( 'store_api_draft_order' ), 'Session pointer should still reference the reused order.' );
+	}
+
 	/**
 	 * Build a valid checkout POST request body for use by the sample-extension tests.
 	 *