Commit 7d708435e00 for woocommerce

commit 7d708435e000daceac4d7599326131a0ec631c01
Author: Michal Iwanow <4765119+mcliwanow@users.noreply.github.com>
Date:   Wed Nov 4 13:38:40 2026 +0100

    Reset Store API checkout request state between requests (#70625)

    The checkout route kept the order of the previous request on the route instance. Clear it at the start of each request so every checkout looks up its own order.

    Co-authored-by: Raluca Stan <1628454+ralucaStan@users.noreply.github.com>

diff --git a/plugins/woocommerce/changelog/store-api-checkout-request-state b/plugins/woocommerce/changelog/store-api-checkout-request-state
new file mode 100644
index 00000000000..d06c9f7f0d6
--- /dev/null
+++ b/plugins/woocommerce/changelog/store-api-checkout-request-state
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Start each Store API checkout request with its own order and payment data.
diff --git a/plugins/woocommerce/src/StoreApi/Routes/V1/Checkout.php b/plugins/woocommerce/src/StoreApi/Routes/V1/Checkout.php
index dd7ea7a01c5..109d5a400f1 100644
--- a/plugins/woocommerce/src/StoreApi/Routes/V1/Checkout.php
+++ b/plugins/woocommerce/src/StoreApi/Routes/V1/Checkout.php
@@ -147,6 +147,14 @@ class Checkout extends AbstractCartRoute {
 		];
 	}

+	/**
+	 * Clear the order held from a previous request. One route instance can serve several requests
+	 * in the same PHP process, so each request must resolve its own order.
+	 */
+	private function reset_request_state(): void {
+		$this->order = null;
+	}
+
 	/**
 	 * Get the route response based on the type of request.
 	 *
@@ -155,6 +163,8 @@ class Checkout extends AbstractCartRoute {
 	 * @return \WP_REST_Response
 	 */
 	public function get_response( \WP_REST_Request $request ) {
+		$this->reset_request_state();
+
 		try {
 			$this->load_cart_session( $request );
 		} catch ( \Throwable $error ) {
@@ -844,7 +854,7 @@ class Checkout extends AbstractCartRoute {
 	 */
 	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();
+		$this->order = $this->get_draft_order();

 		if ( ! $this->order ) {
 			$this->order = $this->order_controller->create_order_from_cart();
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 559d12fd4a5..4b1f56fdfdd 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/Checkout.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/Checkout.php
@@ -891,6 +891,208 @@ class Checkout extends \WP_Test_REST_TestCase {
 		$this->assertEquals( 200, $response->get_status(), print_r( $response->get_data(), true ) );
 	}

+	/**
+	 * @testdox Each checkout served by the same route instance works on its own order.
+	 */
+	public function test_checkouts_on_same_route_instance_use_their_own_orders() {
+		$fixtures     = new FixtureData();
+		$free_product = $fixtures->get_simple_product(
+			array(
+				'name'          => 'Free Test Product',
+				'stock_status'  => ProductStockStatus::IN_STOCK,
+				'regular_price' => 0,
+				'virtual'       => true,
+			)
+		);
+
+		wc_empty_cart();
+		wc()->cart->add_to_cart( $free_product->get_id(), 1 );
+
+		$request = new \WP_REST_Request( 'POST', '/wc/store/v1/checkout' );
+		$request->set_header( 'Nonce', wp_create_nonce( 'wc_store_api' ) );
+		$request->set_body_params( array( 'billing_address' => (object) $this->get_fallback_billing_address() ) );
+		$response = rest_get_server()->dispatch( $request );
+		$this->assertSame( 200, $response->get_status(), print_r( $response->get_data(), true ) );
+		$first_order_id = $response->get_data()['order_id'];
+		$this->assertTrue( wc_get_order( $first_order_id )->is_paid() );
+
+		wc_empty_cart();
+		wc()->cart->add_to_cart( self::$product_ids[2], 5 );
+
+		// Both dispatches go through the same registered route instance, which is also how the batch endpoint serves its children.
+		$billing_address              = $this->get_fallback_billing_address();
+		$billing_address['last_name'] = '';
+		$request                      = new \WP_REST_Request( 'POST', '/wc/store/v1/checkout' );
+		$request->set_header( 'Nonce', wp_create_nonce( 'wc_store_api' ) );
+		$request->set_body_params( array( 'billing_address' => (object) $billing_address ) );
+		$response = rest_get_server()->dispatch( $request );
+		$this->assertSame( 400, $response->get_status(), print_r( $response->get_data(), true ) );
+
+		$first_order = wc_get_order( $first_order_id );
+		$this->assertSame( 0.0, (float) $first_order->get_total() );
+		$this->assertCount( 1, $first_order->get_items() );
+	}
+
+	/**
+	 * @testdox Each checkout hands the gateway the payment data from its own request.
+	 */
+	public function test_each_checkout_hands_gateway_its_own_payment_data() {
+		$seen_payment_data = array();
+		add_action(
+			'woocommerce_rest_checkout_process_payment_with_context',
+			function ( $context ) use ( &$seen_payment_data ) {
+				$seen_payment_data[] = $context->payment_data['token'] ?? null;
+			}
+		);
+
+		foreach ( array( 'first', 'second' ) as $token ) {
+			wc_empty_cart();
+			wc()->cart->add_to_cart( self::$product_ids[2], 1 );
+
+			$request = new \WP_REST_Request( 'POST', '/wc/store/v1/checkout' );
+			$request->set_header( 'Nonce', wp_create_nonce( 'wc_store_api' ) );
+			$request->set_body_params(
+				array(
+					'billing_address' => (object) $this->get_fallback_billing_address(),
+					'payment_method'  => WC_Gateway_BACS::ID,
+					'payment_data'    => array(
+						array(
+							'key'   => 'token',
+							'value' => $token,
+						),
+					),
+				)
+			);
+			$response = rest_get_server()->dispatch( $request );
+			$this->assertSame( 200, $response->get_status(), print_r( $response->get_data(), true ) );
+		}
+
+		$this->assertSame( array( 'first', 'second' ), $seen_payment_data );
+	}
+
+	/**
+	 * @testdox Checkouts in one batch request each work on their own order.
+	 */
+	public function test_batch_checkouts_use_their_own_orders() {
+		$fixtures     = new FixtureData();
+		$free_product = $fixtures->get_simple_product(
+			array(
+				'name'          => 'Free Test Product',
+				'stock_status'  => ProductStockStatus::IN_STOCK,
+				'regular_price' => 0,
+				'virtual'       => true,
+			)
+		);
+
+		wc_empty_cart();
+
+		$nonce        = wp_create_nonce( 'wc_store_api' );
+		$good_address = (object) $this->get_fallback_billing_address();
+		$bad_address  = $this->get_fallback_billing_address();
+		unset( $bad_address['last_name'] );
+		$bad_address = (object) array_merge( $bad_address, array( 'last_name' => '' ) );
+
+		$request = new \WP_REST_Request( 'POST', '/wc/store/v1/batch' );
+		$request->set_header( 'Nonce', $nonce );
+		$request->set_body_params(
+			array(
+				'requests' => array(
+					array(
+						'method'  => 'POST',
+						'path'    => '/wc/store/v1/cart/add-item',
+						'headers' => array( 'Nonce' => $nonce ),
+						'body'    => array(
+							'id'       => $free_product->get_id(),
+							'quantity' => 1,
+						),
+					),
+					array(
+						'method'  => 'POST',
+						'path'    => '/wc/store/v1/checkout',
+						'headers' => array( 'Nonce' => $nonce ),
+						'body'    => array( 'billing_address' => $good_address ),
+					),
+					array(
+						'method'  => 'POST',
+						'path'    => '/wc/store/v1/cart/add-item',
+						'headers' => array( 'Nonce' => $nonce ),
+						'body'    => array(
+							'id'       => self::$product_ids[2],
+							'quantity' => 5,
+						),
+					),
+					array(
+						'method'  => 'POST',
+						'path'    => '/wc/store/v1/checkout',
+						'headers' => array( 'Nonce' => $nonce ),
+						'body'    => array( 'billing_address' => $bad_address ),
+					),
+				),
+			)
+		);
+
+		$responses = rest_get_server()->dispatch( $request )->get_data()['responses'];
+
+		// The first checkout completes; the second is rejected.
+		$this->assertSame( 200, $responses[1]['status'], print_r( $responses[1]['body'], true ) );
+		$first_order_id = $responses[1]['body']['order_id'];
+		$this->assertSame( 400, $responses[3]['status'], print_r( $responses[3]['body'], true ) );
+
+		$first_order = wc_get_order( $first_order_id );
+		$this->assertTrue( $first_order->is_paid() );
+		$this->assertSame( 0.0, (float) $first_order->get_total() );
+		$this->assertCount( 1, $first_order->get_items() );
+	}
+
+	/**
+	 * @testdox A failed checkout request leaves other orders' reserved stock alone.
+	 */
+	public function test_failed_checkout_request_keeps_other_orders_reserved_stock() {
+		update_option( 'woocommerce_hold_stock_minutes', '60' );
+
+		$fixtures = new FixtureData();
+		$product  = $fixtures->get_simple_product(
+			array(
+				'name'          => 'Reserved Stock Product',
+				'stock_status'  => ProductStockStatus::IN_STOCK,
+				'regular_price' => 10,
+			)
+		);
+		$product->set_manage_stock( true );
+		$product->set_stock_quantity( 100 );
+		$product->save();
+
+		// First request: a pending order that holds reserved stock.
+		wc_empty_cart();
+		wc()->cart->add_to_cart( $product->get_id(), 3 );
+
+		$order = new \WC_Order();
+		$order->add_product( $product, 3 );
+		$order->set_status( OrderStatus::PENDING );
+		$order->set_cart_hash( wc()->cart->get_cart_hash() );
+		$order->calculate_totals();
+		$order->save();
+		wc_reserve_stock_for_order( $order );
+		wc()->session->set( 'store_api_draft_order', $order->get_id() );
+
+		$reserve = new \Automattic\WooCommerce\Checkout\Helpers\ReserveStock();
+		$this->assertSame( 3, $reserve->get_reserved_stock( $product, 0 ), 'Setup: the order should hold 3 reserved units.' );
+
+		$request = new \WP_REST_Request( 'GET', '/wc/store/v1/checkout' );
+		$request->set_header( 'Nonce', wp_create_nonce( 'wc_store_api' ) );
+		$this->assertSame( 200, rest_get_server()->dispatch( $request )->get_status() );
+
+		// Second request on the same route instance fails early.
+		wc_empty_cart();
+		$request = new \WP_REST_Request( 'POST', '/wc/store/v1/checkout' );
+		$request->set_header( 'Nonce', wp_create_nonce( 'wc_store_api' ) );
+		$request->set_body_params( array( 'billing_address' => (object) $this->get_fallback_billing_address() ) );
+		$this->assertSame( 'woocommerce_rest_cart_empty', rest_get_server()->dispatch( $request )->get_data()['code'] );
+
+		// The first order still holds its reserved stock.
+		$this->assertSame( 3, $reserve->get_reserved_stock( $product, 0 ) );
+	}
+
 	/**
 	 * Billing address used by the shipping address fallback tests.
 	 *
@@ -4393,15 +4595,15 @@ class Checkout extends \WP_Test_REST_TestCase {
 			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.' );
+		$first_order_id = (int) WC()->session->get( 'store_api_draft_order' );
+		$this->assertGreaterThan( 0, $first_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' );
+		$first_order = wc_get_order( $first_order_id );
+		$first_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_get_order( $first_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->assertSame( $first_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() );
@@ -4415,13 +4617,13 @@ class Checkout extends \WP_Test_REST_TestCase {
 		);

 		$this->assertSame(
-			array( $paid_order_id ),
+			array( $first_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( $first_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.' );
+		$this->assertSame( wc_get_order( $first_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.' );
 	}

 	/**
@@ -4457,8 +4659,8 @@ class Checkout extends \WP_Test_REST_TestCase {
 			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' );
+		$first_order_id = (int) WC()->session->get( 'store_api_draft_order' );
+		wc_get_order( $first_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.
@@ -4467,7 +4669,7 @@ class Checkout extends \WP_Test_REST_TestCase {
 		$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( $first_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'] );
 	}