Commit 2b62cfd7eb9 for woocommerce

commit 2b62cfd7eb927632469f31b4aaf0b0552328662e
Author: Mike Jolley <mike.jolley@me.com>
Date:   Fri May 8 13:35:48 2026 +0100

    Defer Store API checkout draft order creation to place-order time (#64155)

    * WC_Abstract_Order: skip data-store delete for unsaved orders

    remove_order_items() unconditionally calls $this->data_store->delete_items()
    which issues DELETE queries against the order's id. For an unsaved order
    (id 0) no rows can match, making the call wasted work. This runs on every
    create_order_from_cart() first-populate (5 item-type hash sections), so
    the guard saves ~5 DELETE round-trips per new order across all checkout
    flows — legacy shortcode, Store API POST, and any extension building
    orders from the cart.

    Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

    * Store API: defer checkout draft order creation to place-order time

    Fresh-checkout GET and PATCH requests no longer create or update a
    wc-checkout-draft order row. The response is built directly from the
    cart, customer session, and request via CheckoutSchema::get_draft_response.
    Failed-payment retry flows (where a pending/failed order exists in the
    session) fall through to the existing real-order path unchanged.

    New action woocommerce_store_api_checkout_update_draft fires per
    fresh-session PATCH for extensions needing live checkout observation.
    Existing update_order_meta and update_order_from_request actions
    continue to fire only against real, persisted orders.

    New action woocommerce_store_api_checkout_order_created fires once
    when the draft order is first materialised at POST.

    Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

    * Checkout block: gate on error code instead of hasOrder

    The checkout block gated render on hasOrder (!! orderId). Under deferred
    draft creation the initial GET returns order_id: 0, so the gate failed
    and shoppers saw the error screen. Replace with a check for an error
    code in the hydrated checkoutData — the only legitimate reason to show
    the error screen.

    Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

    * Fix PHPStan errors: add return type to persist_additional_fields_for_customer, update baseline counts

    Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

    * Store API: persist customer note to session in deferred-draft PATCH path

    Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

    * Store API: eliminate double get_draft_order call, guard partial PATCH note overwrite, simplify payment method read

    Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

    * Store API: move session reads into CheckoutSchema::get_draft_response

    Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

    * Fix PHPStan nullCoalesce.property error in create_or_update_draft_order

    Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

    * Fix PHPStan baseline

    ---------

    Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

diff --git a/plugins/woocommerce/changelog/experiment-remove-store-api-draft-order b/plugins/woocommerce/changelog/experiment-remove-store-api-draft-order
new file mode 100644
index 00000000000..f172b00e0b2
--- /dev/null
+++ b/plugins/woocommerce/changelog/experiment-remove-store-api-draft-order
@@ -0,0 +1,4 @@
+Significance: minor
+Type: enhancement
+
+Store API: defer checkout draft order creation to place-order time, eliminating orphaned `wc-checkout-draft` rows from page views and form interactions. Adds `woocommerce_store_api_checkout_update_draft` action for PATCH-time observation and `woocommerce_store_api_checkout_order_created` action when the order is first materialised.
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/checkout/block.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/checkout/block.tsx
index 6d9d295366d..33cd7ceb4b1 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/checkout/block.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/checkout/block.tsx
@@ -17,6 +17,7 @@ import { SlotFillProvider } from '@woocommerce/blocks-checkout';
 import withScrollToTop from '@woocommerce/base-hocs/with-scroll-to-top';
 import { useDispatch, useSelect } from '@wordpress/data';
 import { checkoutStore, validationStore } from '@woocommerce/block-data';
+import type { CheckoutResponse } from '@woocommerce/types';

 /**
  * Internal dependencies
@@ -29,6 +30,13 @@ import type { Attributes } from './types';
 import { CheckoutBlockContext } from './context';
 import { IncompatibleExtensionsFrontendNotice } from '../cart-checkout-shared/incompatible-extensions-notice';

+// Show the error screen only when the hydrated checkout GET response carried an error code.
+const preloadedCheckoutData = getSetting< Partial< CheckoutResponse > >(
+	'checkoutData',
+	{}
+);
+const hasCheckoutError = !! preloadedCheckoutData.code;
+
 const MustLoginPrompt = () => {
 	return (
 		<div className="wc-block-must-login-prompt">
@@ -47,10 +55,9 @@ const Checkout = ( {
 	attributes: Attributes;
 	children: React.ReactChildren;
 } ): JSX.Element => {
-	const { hasOrder, customerId } = useSelect( ( select ) => {
+	const { customerId } = useSelect( ( select ) => {
 		const store = select( checkoutStore );
 		return {
-			hasOrder: store.hasOrder(),
 			customerId: store.getCustomerId(),
 		};
 	} );
@@ -62,7 +69,7 @@ const Checkout = ( {
 		return <EmptyCart />;
 	}

-	if ( ! hasOrder ) {
+	if ( hasCheckoutError ) {
 		return <CheckoutOrderError />;
 	}

diff --git a/plugins/woocommerce/includes/abstracts/abstract-wc-order.php b/plugins/woocommerce/includes/abstracts/abstract-wc-order.php
index fd3bdc3db75..aa23accb00a 100644
--- a/plugins/woocommerce/includes/abstracts/abstract-wc-order.php
+++ b/plugins/woocommerce/includes/abstracts/abstract-wc-order.php
@@ -883,8 +883,14 @@ abstract class WC_Abstract_Order extends WC_Abstract_Legacy_Order {
 		 * @since 7.8.0
 		 */
 		do_action( 'woocommerce_remove_order_items', $this, $type );
+
+		// Unsaved orders (id 0) have no persisted items — skip the data store round-trip.
+		$has_persisted_items = $this->get_id() > 0;
+
 		if ( ! empty( $type ) ) {
-			$this->data_store->delete_items( $this, $type );
+			if ( $has_persisted_items ) {
+				$this->data_store->delete_items( $this, $type );
+			}

 			$group = $this->type_to_group( $type );

@@ -892,7 +898,9 @@ abstract class WC_Abstract_Order extends WC_Abstract_Legacy_Order {
 				unset( $this->items[ $group ] );
 			}
 		} else {
-			$this->data_store->delete_items( $this );
+			if ( $has_persisted_items ) {
+				$this->data_store->delete_items( $this );
+			}
 			$this->items = array();
 		}
 		/**
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index c90a3ebe0cc..70754430db2 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -71754,12 +71754,6 @@ parameters:
 			count: 1
 			path: src/StoreApi/Routes/V1/Agentic/CheckoutSessions.php

-		-
-			message: '#^@param WC_Order \$order does not accept actual type of parameter\: WC_Order\|null\.$#'
-			identifier: parameter.phpDocType
-			count: 1
-			path: src/StoreApi/Routes/V1/Agentic/CheckoutSessionsComplete.php
-
 		-
 			message: '#^Access to protected property Automattic\\WooCommerce\\StoreApi\\Payments\\PaymentResult\:\:\$status\.$#'
 			identifier: property.protected
@@ -71790,60 +71784,12 @@ parameters:
 			count: 2
 			path: src/StoreApi/Routes/V1/Agentic/CheckoutSessionsComplete.php

-		-
-			message: '#^Cannot call method get_id\(\) on WC_Order\|null\.$#'
-			identifier: method.nonObject
-			count: 2
-			path: src/StoreApi/Routes/V1/Agentic/CheckoutSessionsComplete.php
-
-		-
-			message: '#^Cannot call method get_payment_method_title\(\) on WC_Order\|null\.$#'
-			identifier: method.nonObject
-			count: 2
-			path: src/StoreApi/Routes/V1/Agentic/CheckoutSessionsComplete.php
-
-		-
-			message: '#^Cannot call method needs_payment\(\) on WC_Order\|null\.$#'
-			identifier: method.nonObject
-			count: 1
-			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: '#^Cannot call method save\(\) on WC_Order\|null\.$#'
-			identifier: method.nonObject
-			count: 1
-			path: src/StoreApi/Routes/V1/Agentic/CheckoutSessionsComplete.php
-
-		-
-			message: '#^Cannot call method set_customer_note\(\) on WC_Order\|null\.$#'
-			identifier: method.nonObject
-			count: 1
-			path: src/StoreApi/Routes/V1/Agentic/CheckoutSessionsComplete.php
-
-		-
-			message: '#^Cannot call method set_payment_method\(\) on WC_Order\|null\.$#'
-			identifier: method.nonObject
-			count: 2
-			path: src/StoreApi/Routes/V1/Agentic/CheckoutSessionsComplete.php
-
-		-
-			message: '#^Cannot call method set_payment_method_title\(\) on WC_Order\|null\.$#'
-			identifier: method.nonObject
-			count: 1
-			path: src/StoreApi/Routes/V1/Agentic/CheckoutSessionsComplete.php
-
-		-
-			message: '#^Expression on left side of \?\? is not nullable\.$#'
-			identifier: nullCoalesce.expr
-			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
@@ -71991,7 +71937,7 @@ parameters:
 		-
 			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: 2
+			count: 1
 			path: src/StoreApi/Routes/V1/Agentic/CheckoutSessionsComplete.php

 		-
@@ -72303,7 +72249,7 @@ parameters:
 		-
 			message: '#^Expression on left side of \?\? is not nullable\.$#'
 			identifier: nullCoalesce.expr
-			count: 4
+			count: 3
 			path: src/StoreApi/Routes/V1/Checkout.php

 		-
@@ -72594,12 +72540,6 @@ parameters:
 			count: 1
 			path: src/StoreApi/Routes/V1/CheckoutOrder.php

-		-
-			message: '#^Expression on left side of \?\? is not nullable\.$#'
-			identifier: nullCoalesce.expr
-			count: 1
-			path: src/StoreApi/Routes/V1/CheckoutOrder.php
-
 		-
 			message: '#^Instanceof between Automattic\\WooCommerce\\StoreApi\\Payments\\PaymentResult and Automattic\\WooCommerce\\StoreApi\\Payments\\PaymentResult will always evaluate to true\.$#'
 			identifier: instanceof.alwaysTrue
diff --git a/plugins/woocommerce/src/StoreApi/Routes/V1/Checkout.php b/plugins/woocommerce/src/StoreApi/Routes/V1/Checkout.php
index f02982ffba9..a6905aa0df4 100644
--- a/plugins/woocommerce/src/StoreApi/Routes/V1/Checkout.php
+++ b/plugins/woocommerce/src/StoreApi/Routes/V1/Checkout.php
@@ -191,21 +191,32 @@ class Checkout extends AbstractCartRoute {
 	}

 	/**
-	 * Convert the cart into a new draft order, or update an existing draft order, and return an updated cart response.
+	 * Return a checkout response for GET requests.
+	 *
+	 * If a `pending`/`failed` order from a previous payment attempt is in the customer
+	 * session, reuse it (the failed-payment retry path). Otherwise build a no-order
+	 * response directly from cart + customer + request.
 	 *
 	 * @throws RouteException On error.
 	 * @param \WP_REST_Request $request Request object.
 	 * @return \WP_REST_Response
 	 */
 	protected function get_route_response( \WP_REST_Request $request ) {
-		$this->create_or_update_draft_order( $request );
-		return $this->prepare_item_for_response(
-			(object) [
-				'order'          => $this->order,
-				'payment_result' => new PaymentResult(),
-			],
-			$request
-		);
+		$this->order = $this->get_draft_order();
+
+		if ( $this->order ) {
+			$this->create_or_update_draft_order( $request );
+
+			return $this->prepare_item_for_response(
+				(object) [
+					'order'          => $this->order,
+					'payment_result' => new PaymentResult(),
+				],
+				$request
+			);
+		}
+
+		return $this->build_draft_route_response( $request );
 	}

 	/**
@@ -354,14 +365,20 @@ class Checkout extends AbstractCartRoute {
 		/**
 		 * Create (or update) Draft Order and process request data.
 		 */
-		$this->create_or_update_draft_order( $request );
-		// Order save-point: 1.
+		$this->order = $this->get_draft_order();

-		/**
-		 * Persist additional fields, order notes and payment method for order.
-		 */
-		$this->update_order_from_request( $request );
-		// Order save-point: 2.
+		if ( $this->order ) {
+			$this->create_or_update_draft_order( $request );
+			// Order save-point: 1.
+
+			/**
+			 * Persist additional fields, order notes and payment method for order.
+			 */
+			$this->update_order_from_request( $request );
+			// Order save-point: 2.
+		} else {
+			$this->update_session_from_request( $request );
+		}

 		if ( $request->get_param( '__experimental_calc_totals' ) ) {
 			/**
@@ -380,12 +397,83 @@ class Checkout extends AbstractCartRoute {
 			$this->cart_controller->validate_cart();
 		}

-		return $this->prepare_item_for_response(
-			(object) [
-				'order' => wc_get_order( $this->order ),
-				'cart'  => $this->cart_controller->get_cart_instance(),
-			],
-			$request
+		if ( $this->order ) {
+			return $this->prepare_item_for_response(
+				(object) [
+					'order' => wc_get_order( $this->order ),
+					'cart'  => $this->cart_controller->get_cart_instance(),
+				],
+				$request
+			);
+		}
+
+		/**
+		 * Fires after a Store API checkout PATCH request has been validated and live
+		 * customer/session state has been updated, before the response is returned.
+		 *
+		 * Hook this action when an extension needs to observe live checkout state — e.g.
+		 * abandoned-cart trackers, side-panel previews, conditional shipping or payment
+		 * validators, or anything else that needs to react to every customer interaction
+		 * with the form.
+		 *
+		 * No `WC_Order` exists at this point under deferred draft order creation. Read
+		 * checkout state from `WC()->cart`, `WC()->customer`, and the supplied
+		 * `$request`, and persist any extension-owned state to `WC()->session`. To
+		 * apply that state to the real order at place-order time, hook
+		 * `woocommerce_store_api_checkout_update_order_meta` or
+		 * `woocommerce_store_api_checkout_update_order_from_request` — both fire
+		 * against the real, persisted order at POST exactly as they always have.
+		 *
+		 * @since 10.8.0
+		 *
+		 * @param \WP_REST_Request $request The current PATCH request.
+		 */
+		do_action( 'woocommerce_store_api_checkout_update_draft', $request );
+
+		return $this->build_draft_route_response( $request );
+	}
+
+	/**
+	 * Persist the PATCH request's payment method and additional fields to the customer
+	 * session. Counterpart to `update_order_from_request` for the no-order PATCH path.
+	 *
+	 * @phpstan-param \WP_REST_Request<array<string, mixed>> $request
+	 *
+	 * @param \WP_REST_Request $request Request object.
+	 * @throws RouteException If the supplied payment method id is unknown or disabled.
+	 */
+	private function update_session_from_request( \WP_REST_Request $request ): void {
+		$payment_method = $this->get_request_payment_method( $request );
+		if ( null !== $payment_method ) {
+			WC()->session->set( 'chosen_payment_method', $payment_method->id );
+		}
+		if ( isset( $request['order_notes'] ) ) {
+			WC()->session->set( 'store_api_customer_note', wc_sanitize_textarea( $request['order_notes'] ) );
+		}
+		$this->persist_additional_fields_for_customer( $request );
+	}
+
+	/**
+	 * Build a checkout response for a session with no order in flight.
+	 *
+	 * @phpstan-param \WP_REST_Request<array<string, mixed>> $request
+	 *
+	 * @param \WP_REST_Request $request Request object.
+	 * @return \WP_REST_Response
+	 */
+	private function build_draft_route_response( \WP_REST_Request $request ) {
+		/**
+		 * Narrow the parent-declared schema property to the checkout subclass for phpstan.
+		 *
+		 * @var \Automattic\WooCommerce\StoreApi\Schemas\V1\CheckoutSchema $schema
+		 */
+		$schema = $this->schema;
+
+		return new \WP_REST_Response(
+			$schema->get_draft_response(
+				$this->cart_controller->get_cart_instance(),
+				wc()->customer
+			)
 		);
 	}

@@ -661,11 +749,24 @@ class Checkout extends AbstractCartRoute {
 	 * @throws RouteException On error.
 	 */
 	private function create_or_update_draft_order( \WP_REST_Request $request ) {
-		$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 ) );
+
+			/**
+			 * Fires once when the Store API checkout draft order is first materialised.
+			 *
+			 * Use this hook for first-touch logic that should only run when the draft
+			 * order is initially created (e.g. analytics, abandoned-cart trackers). As
+			 * of WooCommerce 10.8.0 the Store API defers draft order creation to
+			 * place-order time, so this action fires once at POST rather than on the
+			 * first PATCH.
+			 *
+			 * @since 10.8.0
+			 *
+			 * @param \WC_Order $order Order object.
+			 */
+			do_action( 'woocommerce_store_api_checkout_order_created', $this->order );
 		} else {
 			$this->order_controller->update_order_from_cart( $this->order, true );
 			wc_log_order_step( '[Store API #4::create_or_update_draft_order] Updated order from cart', array( 'order_object' => $this->order ) );
@@ -825,17 +926,8 @@ class Checkout extends AbstractCartRoute {
 	private function get_request_payment_method( \WP_REST_Request $request ) {
 		$available_gateways     = WC()->payment_gateways->get_available_payment_gateways();
 		$request_payment_method = wc_clean( wp_unslash( $request['payment_method'] ?? '' ) );
-		// For PUT requests, the order never requires payment, only POST does.
-		$requires_payment_method = $this->order->needs_payment() && 'POST' === $request->get_method();

 		if ( empty( $request_payment_method ) ) {
-			if ( $requires_payment_method ) {
-				throw new RouteException(
-					'woocommerce_rest_checkout_missing_payment_method',
-					esc_html__( 'No payment method provided.', 'woocommerce' ),
-					400
-				);
-			}
 			return null;
 		}

diff --git a/plugins/woocommerce/src/StoreApi/Schemas/V1/CheckoutSchema.php b/plugins/woocommerce/src/StoreApi/Schemas/V1/CheckoutSchema.php
index f53874ade40..9551bda36ec 100644
--- a/plugins/woocommerce/src/StoreApi/Schemas/V1/CheckoutSchema.php
+++ b/plugins/woocommerce/src/StoreApi/Schemas/V1/CheckoutSchema.php
@@ -10,6 +10,7 @@ use Automattic\WooCommerce\Blocks\Domain\Services\CheckoutFields;
 use Automattic\WooCommerce\Blocks\Package;
 use Automattic\WooCommerce\StoreApi\Utilities\SanitizationUtils;
 use Automattic\WooCommerce\StoreApi\Schemas\V1\CartSchema;
+use Automattic\WooCommerce\StoreApi\Utilities\PaymentUtils;

 /**
  * CheckoutSchema class.
@@ -249,6 +250,34 @@ class CheckoutSchema extends AbstractSchema {
 		];
 	}

+	/**
+	 * Build a checkout response for a session with no persisted order.
+	 *
+	 * Session-owned values (payment method, customer note) are read internally
+	 * so the caller doesn't need to forward them.
+	 *
+	 * @param \WC_Cart     $cart     Cart object.
+	 * @param \WC_Customer $customer Customer object (typically `wc()->customer`).
+	 * @return array
+	 */
+	public function get_draft_response( \WC_Cart $cart, \WC_Customer $customer ) {
+		return [
+			'order_id'           => 0,
+			'status'             => 'checkout-draft',
+			'order_key'          => '',
+			'order_number'       => '0',
+			'customer_note'      => (string) ( WC()->session->get( 'store_api_customer_note' ) ?? '' ),
+			'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_result'     => null,
+			'additional_fields'  => (object) $this->get_additional_fields_response( $customer ),
+			'__experimentalCart' => (object) $this->cart_schema->get_item_response( $cart ),
+			self::EXTENDING_KEY  => $this->get_extended_data( self::IDENTIFIER ),
+		];
+	}
+
 	/**
 	 * This prepares the payment details for the response so it's following the
 	 * schema where it's an array of objects.
@@ -274,14 +303,19 @@ class CheckoutSchema extends AbstractSchema {
 	/**
 	 * Get the additional fields response.
 	 *
-	 * @param \WC_Order $order Order object.
+	 * For an order the response falls back to customer-mirrored values; for a
+	 * customer it reads the customer meta directly.
+	 *
+	 * @param \WC_Order|\WC_Customer $wc_object Order or customer to read fields from.
 	 * @return array
 	 */
-	protected function get_additional_fields_response( \WC_Order $order ) {
-		$fields = wp_parse_args(
-			$this->additional_fields_controller->get_all_fields_from_object( $order, 'other' ),
-			$this->additional_fields_controller->get_all_fields_from_object( wc()->customer, 'other' )
-		);
+	protected function get_additional_fields_response( \WC_Data $wc_object ) {
+		$fields = $wc_object instanceof \WC_Order
+			? wp_parse_args(
+				$this->additional_fields_controller->get_all_fields_from_object( $wc_object, 'other' ),
+				$this->additional_fields_controller->get_all_fields_from_object( wc()->customer, 'other' )
+			)
+			: $this->additional_fields_controller->get_all_fields_from_object( $wc_object, 'other' );

 		$additional_field_schema = $this->get_additional_fields_schema();
 		foreach ( $fields as $key => $value ) {
diff --git a/plugins/woocommerce/src/StoreApi/Utilities/CheckoutTrait.php b/plugins/woocommerce/src/StoreApi/Utilities/CheckoutTrait.php
index ca93d3c22ab..c224f30cd56 100644
--- a/plugins/woocommerce/src/StoreApi/Utilities/CheckoutTrait.php
+++ b/plugins/woocommerce/src/StoreApi/Utilities/CheckoutTrait.php
@@ -138,18 +138,42 @@ trait CheckoutTrait {
 	/**
 	 * Update the current order using the posted values from the request.
 	 *
+	 * Called only with a real, persisted order — either the place-order POST flow or
+	 * the rare failed-payment PATCH retry flow where `get_draft_order()` resolved to
+	 * an existing `pending`/`failed` order from the customer's session. Fresh-session
+	 * PATCHes never call this method; they go through the no-order draft path.
+	 *
 	 * @param \WP_REST_Request $request Full details about the request.
 	 * @param bool             $persist Whether to persist the changes right away (defaults to true).
+	 * @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
+			);
+		}
+
 		$this->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 );
-		} elseif ( ! $this->order->needs_payment() ) {
-			$this->order->set_payment_method( '' );
+		} else {
+			$order_needs_payment = $this->order->needs_payment();
+			if ( $order_needs_payment && 'POST' === $request->get_method() ) {
+				throw new RouteException(
+					'woocommerce_rest_checkout_missing_payment_method',
+					esc_html__( 'No payment method provided.', 'woocommerce' ),
+					400
+				);
+			}
+			if ( ! $order_needs_payment ) {
+				$this->order->set_payment_method( '' );
+			}
 		}
 		wc_log_order_step(
 			'[Store API #5::update_order_from_request] Set customer note and payment method',
@@ -225,39 +249,80 @@ trait CheckoutTrait {
 	 * @param \WP_REST_Request $request Full details about the request.
 	 */
 	private function persist_additional_fields_for_order( \WP_REST_Request $request ) {
+		$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 );
+			}
+		);
+
+		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 );
+		}
+	}
+
+	/**
+	 * Persist additional fields for the customer session.
+	 *
+	 * Counterpart to `persist_additional_fields_for_order` for routes that operate
+	 * without a persisted order (e.g. the deferred-draft PATCH path).
+	 *
+	 * @phpstan-param \WP_REST_Request<array<string, mixed>> $request
+	 *
+	 * @param \WP_REST_Request $request Full details about the request.
+	 */
+	private function persist_additional_fields_for_customer( \WP_REST_Request $request ): void {
+		$customer = wc()->customer;
+
+		$this->resolve_and_persist_additional_fields(
+			$request,
+			function ( string $key, $value ) use ( $customer ) {
+				$this->additional_fields_controller->persist_field_for_customer( $key, $value, $customer, 'other' );
+			}
+		);
+
+		$customer->save();
+	}
+
+	/**
+	 * Resolve the additional checkout fields from the request and persist each one
+	 * via the supplied callback. Fields hidden by conditional logic that were still
+	 * posted are cleared (passed with an empty value).
+	 *
+	 * @phpstan-param \WP_REST_Request<array<string, mixed>> $request
+	 *
+	 * @param \WP_REST_Request $request Full details about the request.
+	 * @param callable         $persist Callback invoked as `$persist( string $key, mixed $value )` for each field.
+	 */
+	private function resolve_and_persist_additional_fields( \WP_REST_Request $request, callable $persist ): void {
 		if ( Features::is_enabled( 'experimental-blocks' ) ) {
 			$document_object = $this->get_document_object_from_rest_request( $request );
 			$document_object->set_context( 'order' );
-			$additional_fields_order   = $this->additional_fields_controller->get_contextual_fields_for_location( 'order', $document_object );
-			$additional_fields_contact = $this->additional_fields_controller->get_contextual_fields_for_location( 'contact', $document_object );
-			$additional_fields         = array_merge( $additional_fields_order, $additional_fields_contact );
+			$additional_fields = array_merge(
+				$this->additional_fields_controller->get_contextual_fields_for_location( 'order', $document_object ),
+				$this->additional_fields_controller->get_contextual_fields_for_location( 'contact', $document_object )
+			);
 		} else {
-			$additional_fields_order   = $this->additional_fields_controller->get_fields_for_location( 'order' );
-			$additional_fields_contact = $this->additional_fields_controller->get_fields_for_location( 'contact' );
-			$additional_fields         = array_merge( $additional_fields_order, $additional_fields_contact );
+			$additional_fields = array_merge(
+				$this->additional_fields_controller->get_fields_for_location( 'order' ),
+				$this->additional_fields_controller->get_fields_for_location( 'contact' )
+			);
 		}

-		$field_values = (array) $request['additional_fields'] ?? [];
+		$field_values = isset( $request['additional_fields'] ) ? (array) $request['additional_fields'] : array();

 		foreach ( $additional_fields as $key => $field ) {
 			if ( isset( $field_values[ $key ] ) ) {
-				$this->additional_fields_controller->persist_field_for_order( $key, $field_values[ $key ], $this->order, 'other', false );
+				$persist( $key, $field_values[ $key ] );
 			}
 		}

-		// The above logic sets visible fields, but not hidden fields. Unset the hidden fields here.
-		$other_posted_field_values = array_diff_key( $field_values, $additional_fields );
-
-		foreach ( $other_posted_field_values as $key => $value ) {
+		$hidden_posted_field_values = array_diff_key( $field_values, $additional_fields );
+		foreach ( $hidden_posted_field_values as $key => $value ) {
 			if ( $this->additional_fields_controller->is_field( $key ) ) {
-				$this->additional_fields_controller->persist_field_for_order( $key, '', $this->order, 'other', false );
+				$persist( $key, '' );
 			}
 		}
-
-		// We need to sync the customer additional fields with the order otherwise they will be overwritten on next page load.
-		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 );
-		}
 	}

 	/**
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 1e2f930fcfa..40c9cce6500 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/Checkout.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/Checkout.php
@@ -207,7 +207,7 @@ class Checkout extends MockeryTestCase {
 			)
 		);
 		$response = rest_get_server()->dispatch( $request );
-		$this->assertEquals( 200, $response->get_status() );
+		$this->assertEquals( 200, $response->get_status(), print_r( $response->get_data(), true ) );
 	}

 	/**
@@ -1317,9 +1317,11 @@ class Checkout extends MockeryTestCase {
 		);
 		array_map( 'woocommerce_register_additional_checkout_field', $fields );

-		$request = new \WP_REST_Request( 'PUT', '/wc/store/v1/checkout' );
-		$request->set_header( 'Nonce', wp_create_nonce( 'wc_store_api' ) );
-		$request->set_body_params(
+		// PATCH the checkout with the additional fields. Under deferred draft creation,
+		// PATCH does not materialise an order — values are captured to the field store.
+		$put_request = new \WP_REST_Request( 'PUT', '/wc/store/v1/checkout' );
+		$put_request->set_header( 'Nonce', wp_create_nonce( 'wc_store_api' ) );
+		$put_request->set_body_params(
 			array(
 				'additional_fields' => array(
 					'plugin-namespace/student-id'     => '1234567890',
@@ -1331,10 +1333,56 @@ class Checkout extends MockeryTestCase {
 			)
 		);

-		$response = rest_get_server()->dispatch( $request );
-		$this->assertEquals( 200, $response->get_status() );
+		$put_response = rest_get_server()->dispatch( $put_request );
+		$this->assertEquals( 200, $put_response->get_status() );
+		$this->assertSame( 0, $put_response->get_data()['order_id'] );
+
+		// POST to materialise the real order. The replayed field store should round-trip
+		// the contact and order additional fields onto the persisted order.
+		$post_request = new \WP_REST_Request( 'POST', '/wc/store/v1/checkout' );
+		$post_request->set_header( 'Nonce', wp_create_nonce( 'wc_store_api' ) );
+		$post_request->set_body_params(
+			array(
+				'billing_address'   => array(
+					'first_name'                  => 'Test',
+					'last_name'                   => 'User',
+					'company'                     => '',
+					'address_1'                   => '123 Test St',
+					'address_2'                   => '',
+					'city'                        => 'Test City',
+					'state'                       => 'CA',
+					'postcode'                    => '94016',
+					'country'                     => 'US',
+					'email'                       => 'testaccount@test.com',
+					'phone'                       => '5555555555',
+					'plugin-namespace/student-id' => '1234567890',
+				),
+				'shipping_address'  => array(
+					'first_name'                  => 'Test',
+					'last_name'                   => 'User',
+					'company'                     => '',
+					'address_1'                   => '123 Test St',
+					'address_2'                   => '',
+					'city'                        => 'Test City',
+					'state'                       => 'CA',
+					'postcode'                    => '94016',
+					'country'                     => 'US',
+					'phone'                       => '5555555555',
+					'plugin-namespace/student-id' => '1234567890',
+				),
+				'payment_method'    => 'bacs',
+				'additional_fields' => array(
+					'plugin-namespace/job-function'   => 'engineering',
+					'plugin-namespace/leave-on-porch' => true,
+				),
+			)
+		);

-		$order = wc_get_order( $response->get_data()['order_id'] );
+		$post_response = rest_get_server()->dispatch( $post_request );
+		$this->assertEquals( 200, $post_response->get_status(), print_r( $post_response->get_data(), true ) );
+
+		$order = wc_get_order( $post_response->get_data()['order_id'] );
+		$this->assertInstanceOf( \WC_Order::class, $order );

 		$checkout_fields           = Package::container()->get( CheckoutFields::class );
 		$additional_fields_address = $checkout_fields->get_order_additional_fields_with_values( $order, 'address', 'other', 'view' );
@@ -1345,6 +1393,13 @@ class Checkout extends MockeryTestCase {
 		$this->assertArrayNotHasKey( 'plugin-namespace/student-id', $additional_fields_address );
 		$this->assertEquals( 'engineering', $additional_fields_contact['plugin-namespace/job-function']['value'] );
 		$this->assertEquals( true, $additional_fields_order['plugin-namespace/leave-on-porch']['value'] );
+
+		// Deregister the fields we registered above so later tests start from a clean
+		// global state. Without this, subsequent tests would fail address validation
+		// because `student-id` is required on every address submission.
+		foreach ( $fields as $field ) {
+			$checkout_fields->deregister_checkout_field( $field['id'] );
+		}
 	}

 	/**
@@ -1788,4 +1843,411 @@ class Checkout extends MockeryTestCase {
 		// Order shouldn't stay in custom status, instead we let payment gateway set the correct status.
 		$this->assertEquals( 'on-hold', $order->get_status(), 'Order status should be controlled by the payment gateway, not remain custom.' );
 	}
+
+	/**
+	 * GET /wc/store/v1/checkout against a populated cart must not create a draft order.
+	 *
+	 * Prior to 10.8.0, simply rendering the checkout block triggered a draft order creation
+	 * which was the largest source of orphaned `wc-checkout-draft` rows.
+	 */
+	public function test_get_does_not_create_draft_order() {
+		$drafts_before = wc_get_orders(
+			array(
+				'status' => 'checkout-draft',
+				'limit'  => -1,
+				'return' => 'ids',
+			)
+		);
+
+		$request = new \WP_REST_Request( 'GET', '/wc/store/v1/checkout' );
+		$request->set_header( 'Nonce', wp_create_nonce( 'wc_store_api' ) );
+
+		$response = rest_get_server()->dispatch( $request );
+		$data     = $response->get_data();
+
+		$this->assertEquals( 200, $response->get_status() );
+		$this->assertSame( 0, $data['order_id'], 'GET response should report order_id 0 — no draft order materialised.' );
+
+		$drafts_after = wc_get_orders(
+			array(
+				'status' => 'checkout-draft',
+				'limit'  => -1,
+				'return' => 'ids',
+			)
+		);
+
+		$this->assertCount(
+			count( $drafts_before ),
+			$drafts_after,
+			'GET should not create any wc-checkout-draft rows.'
+		);
+
+		// Session should not hold a draft order id.
+		$this->assertEmpty( WC()->session->get( 'store_api_draft_order' ) );
+	}
+
+	/**
+	 * GET should not fire `woocommerce_store_api_checkout_update_order_meta` (only PATCH/POST do).
+	 */
+	public function test_get_does_not_fire_update_order_meta_action() {
+		$fired = false;
+		add_action(
+			'woocommerce_store_api_checkout_update_order_meta',
+			function () use ( &$fired ) {
+				$fired = true;
+			}
+		);
+
+		$request = new \WP_REST_Request( 'GET', '/wc/store/v1/checkout' );
+		$request->set_header( 'Nonce', wp_create_nonce( 'wc_store_api' ) );
+		rest_get_server()->dispatch( $request );
+
+		remove_all_actions( 'woocommerce_store_api_checkout_update_order_meta' );
+
+		$this->assertFalse( $fired, 'update_order_meta should not fire on GET — it should only fire when the draft is materialised.' );
+	}
+
+	/**
+	 * Phase 2: POST is the only place that materialises a draft order, and the
+	 * `woocommerce_store_api_checkout_order_created` action fires once at that point.
+	 */
+	public function test_post_creates_order_and_fires_order_created_action() {
+		$created_order_ids = array();
+		add_action(
+			'woocommerce_store_api_checkout_order_created',
+			function ( $order ) use ( &$created_order_ids ) {
+				$created_order_ids[] = $order->get_id();
+			}
+		);
+
+		$post_request = new \WP_REST_Request( 'POST', '/wc/store/v1/checkout' );
+		$post_request->set_header( 'Nonce', wp_create_nonce( 'wc_store_api' ) );
+		$post_request->set_body_params(
+			array(
+				'billing_address'  => array(
+					'first_name' => 'Phase',
+					'last_name'  => 'Two',
+					'company'    => '',
+					'address_1'  => '123 Test St',
+					'address_2'  => '',
+					'city'       => 'Test City',
+					'state'      => 'CA',
+					'postcode'   => '94016',
+					'country'    => 'US',
+					'email'      => 'phasetwo@example.com',
+					'phone'      => '5555555555',
+				),
+				'shipping_address' => array(
+					'first_name' => 'Phase',
+					'last_name'  => 'Two',
+					'company'    => '',
+					'address_1'  => '123 Test St',
+					'address_2'  => '',
+					'city'       => 'Test City',
+					'state'      => 'CA',
+					'postcode'   => '94016',
+					'country'    => 'US',
+					'phone'      => '5555555555',
+				),
+				'payment_method'   => WC_Gateway_BACS::ID,
+			)
+		);
+
+		$post_response = rest_get_server()->dispatch( $post_request );
+
+		remove_all_actions( 'woocommerce_store_api_checkout_order_created' );
+
+		$this->assertEquals( 200, $post_response->get_status(), print_r( $post_response->get_data(), true ) );
+
+		$order_id = $post_response->get_data()['order_id'];
+		$this->assertGreaterThan( 0, $order_id );
+
+		$this->assertCount( 1, $created_order_ids );
+		$this->assertSame( $order_id, $created_order_ids[0] );
+	}
+
+	/**
+	 * Phase 2: PATCH should not create a draft order row; the order is materialised at POST.
+	 */
+	public function test_put_does_not_create_draft_order() {
+		$drafts_before = wc_get_orders(
+			array(
+				'status' => 'checkout-draft',
+				'limit'  => -1,
+				'return' => 'ids',
+			)
+		);
+
+		$request = new \WP_REST_Request( 'PUT', '/wc/store/v1/checkout' );
+		$request->set_header( 'Nonce', wp_create_nonce( 'wc_store_api' ) );
+		$request->set_body_params(
+			array(
+				'payment_method' => WC_Gateway_BACS::ID,
+			)
+		);
+
+		$response = rest_get_server()->dispatch( $request );
+
+		$this->assertEquals( 200, $response->get_status(), print_r( $response->get_data(), true ) );
+		$this->assertSame( 0, $response->get_data()['order_id'], 'PATCH should report order_id 0 — order is now deferred to POST.' );
+
+		$drafts_after = wc_get_orders(
+			array(
+				'status' => 'checkout-draft',
+				'limit'  => -1,
+				'return' => 'ids',
+			)
+		);
+
+		$this->assertCount( count( $drafts_before ), $drafts_after, 'PATCH should not create any wc-checkout-draft rows.' );
+		$this->assertEmpty( WC()->session->get( 'store_api_draft_order' ), 'No session draft id should be set after PATCH under Phase 2.' );
+	}
+
+	/**
+	 * The persisted-order hooks (`update_order_meta`, `update_order_from_request`)
+	 * MUST NOT fire on PATCH — they are reserved for the real order materialised at
+	 * place-order time.
+	 */
+	public function test_put_does_not_fire_persisted_order_hooks() {
+		$update_meta_orders         = array();
+		$update_from_request_orders = array();
+
+		add_action(
+			'woocommerce_store_api_checkout_update_order_meta',
+			function ( $order ) use ( &$update_meta_orders ) {
+				$update_meta_orders[] = $order->get_id();
+			}
+		);
+		add_action(
+			'woocommerce_store_api_checkout_update_order_from_request',
+			function ( $order ) use ( &$update_from_request_orders ) {
+				$update_from_request_orders[] = $order->get_id();
+			}
+		);
+
+		$request = new \WP_REST_Request( 'PUT', '/wc/store/v1/checkout' );
+		$request->set_header( 'Nonce', wp_create_nonce( 'wc_store_api' ) );
+		$request->set_body_params(
+			array(
+				'payment_method' => WC_Gateway_BACS::ID,
+			)
+		);
+		$response = rest_get_server()->dispatch( $request );
+
+		remove_all_actions( 'woocommerce_store_api_checkout_update_order_meta' );
+		remove_all_actions( 'woocommerce_store_api_checkout_update_order_from_request' );
+
+		$this->assertEquals( 200, $response->get_status() );
+		$this->assertSame( array(), $update_meta_orders, 'update_order_meta must not fire on PATCH.' );
+		$this->assertSame( array(), $update_from_request_orders, 'update_order_from_request must not fire on PATCH.' );
+	}
+
+	/**
+	 * The new `woocommerce_store_api_checkout_update_draft` action fires once per PATCH
+	 * with the request, and is the only hook extensions should subscribe to for live
+	 * PATCH-time observation. No `WC_Order` is constructed.
+	 */
+	public function test_put_fires_update_draft_action() {
+		$received_requests = array();
+
+		add_action(
+			'woocommerce_store_api_checkout_update_draft',
+			function ( $request ) use ( &$received_requests ) {
+				$received_requests[] = $request;
+			}
+		);
+
+		$request = new \WP_REST_Request( 'PUT', '/wc/store/v1/checkout' );
+		$request->set_header( 'Nonce', wp_create_nonce( 'wc_store_api' ) );
+		$request->set_body_params(
+			array(
+				'payment_method' => WC_Gateway_BACS::ID,
+			)
+		);
+		$response = rest_get_server()->dispatch( $request );
+
+		remove_all_actions( 'woocommerce_store_api_checkout_update_draft' );
+
+		$this->assertEquals( 200, $response->get_status(), print_r( $response->get_data(), true ) );
+		$this->assertCount( 1, $received_requests, 'update_draft should fire exactly once per PATCH.' );
+		$this->assertInstanceOf( \WP_REST_Request::class, $received_requests[0] );
+
+		// The PATCH'd payment method should be reflected in the response and the session.
+		$this->assertSame( WC_Gateway_BACS::ID, $response->get_data()['payment_method'] );
+		$this->assertSame( WC_Gateway_BACS::ID, WC()->session->get( 'chosen_payment_method' ) );
+	}
+
+	/**
+	 * On POST the persisted-order hooks fire against a real, persisted order — exactly
+	 * as they did before deferred draft creation. The new draft action does NOT fire
+	 * on POST.
+	 */
+	public function test_post_fires_persisted_hooks_against_real_order_only() {
+		$meta_ids          = array();
+		$from_request_ids  = array();
+		$draft_request_ids = array();
+
+		add_action(
+			'woocommerce_store_api_checkout_update_order_meta',
+			function ( $order ) use ( &$meta_ids ) {
+				$meta_ids[] = $order->get_id();
+			}
+		);
+		add_action(
+			'woocommerce_store_api_checkout_update_order_from_request',
+			function ( $order ) use ( &$from_request_ids ) {
+				$from_request_ids[] = $order->get_id();
+			}
+		);
+		add_action(
+			'woocommerce_store_api_checkout_update_draft',
+			function ( $request ) use ( &$draft_request_ids ) {
+				$draft_request_ids[] = $request->get_method();
+			}
+		);
+
+		$post_response = rest_get_server()->dispatch( $this->build_valid_post_request() );
+
+		remove_all_actions( 'woocommerce_store_api_checkout_update_order_meta' );
+		remove_all_actions( 'woocommerce_store_api_checkout_update_order_from_request' );
+		remove_all_actions( 'woocommerce_store_api_checkout_update_draft' );
+
+		$this->assertEquals( 200, $post_response->get_status(), print_r( $post_response->get_data(), true ) );
+		$order_id = $post_response->get_data()['order_id'];
+		$this->assertGreaterThan( 0, $order_id );
+
+		$this->assertSame( array( $order_id ), $meta_ids, 'update_order_meta must fire exactly once on POST against the real order.' );
+		$this->assertSame( array( $order_id ), $from_request_ids, 'update_order_from_request must fire exactly once on POST against the real order.' );
+		$this->assertSame( array(), $draft_request_ids, 'update_draft must NOT fire on POST.' );
+	}
+
+	/**
+	 * Sample-extension round-trip: an extension uses `update_draft` on PATCH to persist
+	 * live state to its own session bucket, then uses `update_order_meta` on POST to
+	 * apply that state to the real order. State observed during PATCH lands on the
+	 * persisted order. This is the documented pattern under deferred draft creation.
+	 */
+	public function test_extension_round_trip_via_update_draft_and_update_order_meta() {
+		$session_key = 'sample_ext_pending_meta';
+
+		// PATCH handler — capture live state from cart/customer/request into session.
+		add_action(
+			'woocommerce_store_api_checkout_update_draft',
+			function ( $request ) use ( $session_key ) {
+				WC()->session->set(
+					$session_key,
+					array(
+						'_sample_ext_payment_method' => (string) $request['payment_method'],
+						'_sample_ext_observed_total' => (string) WC()->cart->get_total( 'edit' ),
+					)
+				);
+			}
+		);
+
+		// POST handler — apply session-stored state to the real order.
+		add_action(
+			'woocommerce_store_api_checkout_update_order_meta',
+			function ( $order ) use ( $session_key ) {
+				$pending = WC()->session->get( $session_key );
+				if ( ! is_array( $pending ) ) {
+					return;
+				}
+				foreach ( $pending as $key => $value ) {
+					$order->update_meta_data( $key, $value );
+				}
+				WC()->session->__unset( $session_key );
+			}
+		);
+
+		$put_request = new \WP_REST_Request( 'PUT', '/wc/store/v1/checkout' );
+		$put_request->set_header( 'Nonce', wp_create_nonce( 'wc_store_api' ) );
+		$put_request->set_body_params(
+			array(
+				'payment_method' => WC_Gateway_BACS::ID,
+			)
+		);
+		$put_response = rest_get_server()->dispatch( $put_request );
+		$this->assertEquals( 200, $put_response->get_status(), print_r( $put_response->get_data(), true ) );
+		$this->assertSame( 0, $put_response->get_data()['order_id'] );
+		$this->assertIsArray( WC()->session->get( $session_key ) );
+
+		$post_response = rest_get_server()->dispatch( $this->build_valid_post_request() );
+
+		remove_all_actions( 'woocommerce_store_api_checkout_update_draft' );
+		remove_all_actions( 'woocommerce_store_api_checkout_update_order_meta' );
+
+		$this->assertEquals( 200, $post_response->get_status(), print_r( $post_response->get_data(), true ) );
+		$order_id = $post_response->get_data()['order_id'];
+		$order    = wc_get_order( $order_id );
+		$this->assertInstanceOf( \WC_Order::class, $order );
+
+		$this->assertSame( WC_Gateway_BACS::ID, $order->get_meta( '_sample_ext_payment_method' ) );
+		$this->assertNotEmpty( $order->get_meta( '_sample_ext_observed_total' ) );
+		$this->assertEmpty( WC()->session->get( $session_key ) );
+	}
+
+	/**
+	 * Sample-extension immediate-place-order case: a logged-in shopper lands on
+	 * checkout with defaults populated and clicks Place Order without any PATCH first.
+	 * The extension's POST handler still runs against the real order (no PATCH state
+	 * to round-trip) and can write meta directly. This validates the user concern that
+	 * a no-PATCH POST is fully supported.
+	 */
+	public function test_extension_immediate_post_without_patch() {
+		add_action(
+			'woocommerce_store_api_checkout_update_order_meta',
+			function ( $order ) {
+				$order->update_meta_data( '_sample_ext_no_patch_meta', 'placed-directly' );
+			}
+		);
+
+		$post_response = rest_get_server()->dispatch( $this->build_valid_post_request() );
+
+		remove_all_actions( 'woocommerce_store_api_checkout_update_order_meta' );
+
+		$this->assertEquals( 200, $post_response->get_status(), print_r( $post_response->get_data(), true ) );
+		$order = wc_get_order( $post_response->get_data()['order_id'] );
+		$this->assertSame( 'placed-directly', $order->get_meta( '_sample_ext_no_patch_meta' ) );
+	}
+
+	/**
+	 * Build a valid checkout POST request body for use by the sample-extension tests.
+	 *
+	 * @return \WP_REST_Request
+	 */
+	private function build_valid_post_request() {
+		$request = new \WP_REST_Request( 'POST', '/wc/store/v1/checkout' );
+		$request->set_header( 'Nonce', wp_create_nonce( 'wc_store_api' ) );
+		$request->set_body_params(
+			array(
+				'billing_address'  => array(
+					'first_name' => 'Sample',
+					'last_name'  => 'Extension',
+					'company'    => '',
+					'address_1'  => '123 Test St',
+					'address_2'  => '',
+					'city'       => 'Test City',
+					'state'      => 'CA',
+					'postcode'   => '94016',
+					'country'    => 'US',
+					'email'      => 'sample-ext@example.com',
+					'phone'      => '5555555555',
+				),
+				'shipping_address' => array(
+					'first_name' => 'Sample',
+					'last_name'  => 'Extension',
+					'company'    => '',
+					'address_1'  => '123 Test St',
+					'address_2'  => '',
+					'city'       => 'Test City',
+					'state'      => 'CA',
+					'postcode'   => '94016',
+					'country'    => 'US',
+					'phone'      => '5555555555',
+				),
+				'payment_method'   => WC_Gateway_BACS::ID,
+			)
+		);
+		return $request;
+	}
 }