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