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.
*