Commit b348fa6d110 for woocommerce
commit b348fa6d1100c774ecbed6f7814c3f4ada114d7f
Author: malinajirka <malinajirka@gmail.com>
Date: Thu Jul 9 08:39:49 2026 +0200
Store API: restore billing address fallback when checkout omits the shipping address (#66409)
Store API: restore billing address fallback for omitted shipping address
`update_customer_from_request()` intended to fall back to the billing
address when a checkout request omits `shipping_address` and the cart
needs shipping. The `(array)` cast binds tighter than `??`, so the null
coalesce operated on the already-cast empty array and the fallback never
ran. The order was then persisted without a shipping address, and
pre-payment validation rejected the request with
`woocommerce_rest_invalid_address`.
The fallback was explicit and intentional when introduced (woocommerce-
blocks#6050) and was lost in #55570 when the block was reworked to use
contextual additional fields. PHPStan had flagged it all along:
"Expression on left side of ?? is not nullable", baselined for this file.
Fixing it drops that baseline count from 3 to 2.
Adds three route-level tests covering all three branches: omitted
shipping address falls back to billing, a provided shipping address is
used as-is, and the shipping address is ignored when the cart does not
need shipping.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
diff --git a/plugins/woocommerce/changelog/fix-store-api-checkout-shipping-address-fallback b/plugins/woocommerce/changelog/fix-store-api-checkout-shipping-address-fallback
new file mode 100644
index 00000000000..1a97fa6d90c
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-store-api-checkout-shipping-address-fallback
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Store API: fall back to the billing address when a checkout request omits the shipping address and the cart needs shipping.
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index e3eda14b1e6..2dc6f35ac63 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -69903,7 +69903,7 @@ parameters:
-
message: '#^Expression on left side of \?\? is not nullable\.$#'
identifier: nullCoalesce.expr
- count: 3
+ count: 2
path: src/StoreApi/Routes/V1/Checkout.php
-
diff --git a/plugins/woocommerce/src/StoreApi/Routes/V1/Checkout.php b/plugins/woocommerce/src/StoreApi/Routes/V1/Checkout.php
index d8baf18b91b..ea3a6c15039 100644
--- a/plugins/woocommerce/src/StoreApi/Routes/V1/Checkout.php
+++ b/plugins/woocommerce/src/StoreApi/Routes/V1/Checkout.php
@@ -902,11 +902,9 @@ class Checkout extends AbstractCartRoute {
$additional_fields = $this->additional_fields_controller->get_contextual_fields_for_location( $context_data['location'], $document_object );
if ( 'shipping_address' === $context_data['param'] ) {
- $field_values = (array) $request['shipping_address'] ?? ( $request['billing_address'] ?? [] );
-
- if ( ! WC()->cart->needs_shipping() ) {
- $field_values = $request['billing_address'] ?? [];
- }
+ $field_values = WC()->cart->needs_shipping()
+ ? (array) ( $request['shipping_address'] ?? $request['billing_address'] ?? [] )
+ : (array) ( $request['billing_address'] ?? [] );
} else {
$field_values = (array) $request[ $context_data['param'] ] ?? [];
}
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 0ed4ac56e70..e95f92d79b4 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/Checkout.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/Checkout.php
@@ -213,6 +213,135 @@ class Checkout extends MockeryTestCase {
$this->assertEquals( 200, $response->get_status(), print_r( $response->get_data(), true ) );
}
+ /**
+ * Billing address used by the shipping address fallback tests.
+ *
+ * @return array
+ */
+ private function get_fallback_billing_address() {
+ return array(
+ 'first_name' => 'Billing',
+ 'last_name' => 'Person',
+ 'company' => '',
+ 'address_1' => '10 Billing Street',
+ 'address_2' => '',
+ 'city' => 'Cambridge',
+ 'state' => '',
+ 'postcode' => 'cb241ab',
+ 'country' => 'GB',
+ 'phone' => '01223 000000',
+ 'email' => 'testaccount@test.com',
+ );
+ }
+
+ /**
+ * Shipping address used by the shipping address fallback tests.
+ *
+ * @return array
+ */
+ private function get_fallback_shipping_address() {
+ return array(
+ 'first_name' => 'Shipping',
+ 'last_name' => 'Person',
+ 'company' => '',
+ 'address_1' => '20 Shipping Street',
+ 'address_2' => '',
+ 'city' => 'Oxford',
+ 'state' => '',
+ 'postcode' => 'ox11aa',
+ 'country' => 'GB',
+ 'phone' => '01865 000000',
+ );
+ }
+
+ /**
+ * When the cart needs shipping and the request omits the shipping address, the billing address is used as the
+ * shipping address.
+ *
+ * Regression test: the fallback never ran because `(array)` binds tighter than `??`, so the null coalesce
+ * operated on an already-cast empty array instead of on the missing `shipping_address` param. That left the
+ * order without a shipping address, which then failed pre-payment validation.
+ */
+ public function test_omitted_shipping_address_falls_back_to_billing_address() {
+ $this->assertTrue( WC()->cart->needs_shipping(), 'Test cart is expected to need shipping.' );
+
+ $request = new \WP_REST_Request( 'POST', '/wc/store/v1/checkout' );
+ $request->set_header( 'Nonce', wp_create_nonce( 'wc_store_api' ) );
+ $request->set_body_params(
+ array(
+ 'billing_address' => (object) $this->get_fallback_billing_address(),
+ 'payment_method' => WC_Gateway_BACS::ID,
+ )
+ );
+
+ $response = rest_get_server()->dispatch( $request );
+ $this->assertEquals( 200, $response->get_status(), print_r( $response->get_data(), true ) );
+
+ $order = wc_get_order( $response->get_data()['order_id'] );
+ $this->assertInstanceOf( \WC_Order::class, $order );
+ $this->assertEquals( 'Billing', $order->get_shipping_first_name() );
+ $this->assertEquals( 'Person', $order->get_shipping_last_name() );
+ $this->assertEquals( '10 Billing Street', $order->get_shipping_address_1() );
+ $this->assertEquals( 'Cambridge', $order->get_shipping_city() );
+ $this->assertEquals( 'CB24 1AB', $order->get_shipping_postcode() );
+ $this->assertEquals( 'GB', $order->get_shipping_country() );
+ }
+
+ /**
+ * When the cart needs shipping and a shipping address is provided, it is used as-is rather than being overwritten
+ * by the billing address fallback.
+ */
+ public function test_provided_shipping_address_is_used_when_cart_needs_shipping() {
+ $this->assertTrue( WC()->cart->needs_shipping(), 'Test cart is expected to need shipping.' );
+
+ $request = new \WP_REST_Request( 'POST', '/wc/store/v1/checkout' );
+ $request->set_header( 'Nonce', wp_create_nonce( 'wc_store_api' ) );
+ $request->set_body_params(
+ array(
+ 'billing_address' => (object) $this->get_fallback_billing_address(),
+ 'shipping_address' => (object) $this->get_fallback_shipping_address(),
+ 'payment_method' => WC_Gateway_BACS::ID,
+ )
+ );
+
+ $response = rest_get_server()->dispatch( $request );
+ $this->assertEquals( 200, $response->get_status(), print_r( $response->get_data(), true ) );
+
+ $order = wc_get_order( $response->get_data()['order_id'] );
+ $this->assertInstanceOf( \WC_Order::class, $order );
+ $this->assertEquals( 'Shipping', $order->get_shipping_first_name() );
+ $this->assertEquals( '20 Shipping Street', $order->get_shipping_address_1() );
+ $this->assertEquals( 'Oxford', $order->get_shipping_city() );
+ }
+
+ /**
+ * When the cart does not need shipping, the provided shipping address is ignored in favour of the billing address.
+ */
+ public function test_shipping_address_is_ignored_when_cart_does_not_need_shipping() {
+ wc_empty_cart();
+ WC()->cart->add_to_cart( $this->products[2]->get_id(), 1 );
+ $this->assertFalse( WC()->cart->needs_shipping(), 'Test cart is expected to not need shipping.' );
+
+ $request = new \WP_REST_Request( 'POST', '/wc/store/v1/checkout' );
+ $request->set_header( 'Nonce', wp_create_nonce( 'wc_store_api' ) );
+ $request->set_body_params(
+ array(
+ 'billing_address' => (object) $this->get_fallback_billing_address(),
+ 'shipping_address' => (object) $this->get_fallback_shipping_address(),
+ 'payment_method' => WC_Gateway_BACS::ID,
+ )
+ );
+
+ $response = rest_get_server()->dispatch( $request );
+ $this->assertEquals( 200, $response->get_status(), print_r( $response->get_data(), true ) );
+
+ $order = wc_get_order( $response->get_data()['order_id'] );
+ $this->assertInstanceOf( \WC_Order::class, $order );
+ $this->assertEquals( 'Billing', $order->get_shipping_first_name() );
+ $this->assertEquals( '10 Billing Street', $order->get_shipping_address_1() );
+ $this->assertEquals( 'Cambridge', $order->get_shipping_city() );
+ }
+
/**
* Ensure that orders can be placed with virtual products.
*/