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;
+ }
}