Commit de89d235014 for woocommerce

commit de89d235014baa214f5dd972367cb228870f7335
Author: Francesco <frosso@users.noreply.github.com>
Date:   Tue Nov 3 18:16:01 2026 +0100

    fix: prefill classic checkout shipping fields from the saved address (#68157)

diff --git a/plugins/woocommerce/changelog/30258-fix-shipping-address-prefill b/plugins/woocommerce/changelog/30258-fix-shipping-address-prefill
new file mode 100644
index 00000000000..9a827fffcde
--- /dev/null
+++ b/plugins/woocommerce/changelog/30258-fix-shipping-address-prefill
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Prefill classic checkout shipping fields with the customer's saved shipping address, not their billing address.
diff --git a/plugins/woocommerce/includes/class-wc-checkout.php b/plugins/woocommerce/includes/class-wc-checkout.php
index 64c9c291eb0..261a38dcc6b 100644
--- a/plugins/woocommerce/includes/class-wc-checkout.php
+++ b/plugins/woocommerce/includes/class-wc-checkout.php
@@ -44,6 +44,14 @@ class WC_Checkout {
 	 */
 	private const ORDER_ALREADY_PLACED_ERROR = 'checkout-order-already-placed';

+	/**
+	 * Shipping address fields that checkout.js fills with the billing address while "Ship to a different address?" is
+	 * unticked. WC_AJAX::update_order_review() stores them on the session customer.
+	 *
+	 * @var string[]
+	 */
+	private const SHIPPING_FIELDS_COPIED_FROM_BILLING = array( 'country', 'state', 'postcode', 'city', 'address_1', 'address_2' );
+
 	/**
 	 * The single instance of the class.
 	 *
@@ -66,12 +74,23 @@ class WC_Checkout {
 	protected $legacy_posted_data = array();

 	/**
-	 * Caches customer object. @see get_value.
+	 * Caches customer object.
+	 *
+	 * @see WC_Checkout::get_value()
 	 *
 	 * @var WC_Customer
 	 */
 	private $logged_in_customer = null;

+	/**
+	 * Caches the logged-in customer as saved to their account, without session changes.
+	 *
+	 * @see WC_Checkout::get_saved_customer_for_shipping_field()
+	 *
+	 * @var WC_Customer|null
+	 */
+	private $saved_customer = null;
+
 	/**
 	 * Order item types that set_data_from_cart() creates from the cart.
 	 *
@@ -1900,19 +1919,15 @@ class WC_Checkout {
 			return $value;
 		}

-		/**
-		 * For logged in customers, pull data from their account rather than the session which may contain incomplete data.
-		 * Another reason is that WC sets shipping address to the billing address on the checkout updates unless the
-		 * "ship to another address" box is checked. @see issue #20975.
-		 */
 		$customer_object = false;

 		if ( is_user_logged_in() ) {
 			// Load customer object, but keep it cached to avoid reloading it multiple times.
 			if ( is_null( $this->logged_in_customer ) ) {
+				// Read through the session, so changes made earlier in the session and defaults such as the billing email prefill the form.
 				$this->logged_in_customer = new WC_Customer( get_current_user_id(), true );
 			}
-			$customer_object = $this->logged_in_customer;
+			$customer_object = $this->get_saved_customer_for_shipping_field( $input ) ?? $this->logged_in_customer;
 		}

 		if ( ! $customer_object ) {
@@ -1931,4 +1946,33 @@ class WC_Checkout {

 		return apply_filters( 'default_checkout_' . $input, $value, $input );
 	}
+
+	/**
+	 * Get the saved customer to prefill a shipping field from, when the session's shipping address is the billing copy.
+	 *
+	 * Prefilling from that copy would hide the shipping address saved to the account. Once the copy is detected, every
+	 * shipping field comes from the saved record, so the name and address shown belong together. A session address that
+	 * differs from billing still wins; one the customer typed to match billing looks the same as the copy.
+	 *
+	 * @param string $input Name of the input we want to grab data for, e.g. shipping_city.
+	 * @return WC_Customer|null The customer as saved to their account, or null to read the session.
+	 */
+	private function get_saved_customer_for_shipping_field( $input ): ?WC_Customer {
+		if ( 0 !== strpos( $input, 'shipping_' ) ) {
+			return null;
+		}
+
+		foreach ( self::SHIPPING_FIELDS_COPIED_FROM_BILLING as $field ) {
+			if ( $this->logged_in_customer->{"get_shipping_$field"}( 'edit' ) !== $this->logged_in_customer->{"get_billing_$field"}( 'edit' ) ) {
+				return null;
+			}
+		}
+
+		if ( is_null( $this->saved_customer ) ) {
+			$this->saved_customer = new WC_Customer( get_current_user_id() );
+		}
+
+		// A record without a country is not an address the customer can ship to, e.g. one holding only a name.
+		return '' !== $this->saved_customer->get_shipping_country( 'edit' ) ? $this->saved_customer : null;
+	}
 }
diff --git a/plugins/woocommerce/tests/php/includes/class-wc-checkout-test.php b/plugins/woocommerce/tests/php/includes/class-wc-checkout-test.php
index 3c31cd1921a..8c4b266a18a 100644
--- a/plugins/woocommerce/tests/php/includes/class-wc-checkout-test.php
+++ b/plugins/woocommerce/tests/php/includes/class-wc-checkout-test.php
@@ -24,6 +24,24 @@ class WC_Checkout_Test extends \WC_Unit_Test_Case {
 		'billing_email'  => 'customer@example.com',
 	);

+	/**
+	 * Shipping fields the get_value tests copy from billing and read back, without the 'shipping_' prefix.
+	 */
+	private const PREFILLED_SHIPPING_FIELDS = array( 'first_name', 'country', 'state', 'postcode', 'city', 'address_1', 'address_2' );
+
+	/**
+	 * A shipping address in New York for Jane, saved to the account.
+	 */
+	private const SAVED_SHIPPING_ADDRESS = array(
+		'shipping_first_name' => 'Jane',
+		'shipping_country'    => 'US',
+		'shipping_state'      => 'NY',
+		'shipping_postcode'   => '11201',
+		'shipping_city'       => 'Brooklyn',
+		'shipping_address_1'  => '2 Shipping Avenue',
+		'shipping_address_2'  => 'Apt 2',
+	);
+
 	/**
 	 * @var object The system under test.
 	 */
@@ -115,6 +133,10 @@ class WC_Checkout_Test extends \WC_Unit_Test_Case {
 			$this->original_session_customer = null;
 		}

+		wp_set_current_user( 0 );
+		WC()->session->set( 'customer', null );
+		WC()->customer = new WC_Customer( 0, true );
+
 		$customer = WC()->customer;
 		$customer->set_billing_location( $this->original_customer[0], $this->original_customer[1] );
 		$customer->set_shipping_location( $this->original_customer[2], $this->original_customer[3] );
@@ -936,6 +958,125 @@ class WC_Checkout_Test extends \WC_Unit_Test_Case {
 		WC()->customer = $orig_customer;
 	}

+	/**
+	 * Log in a customer named John with a billing address in California, and make them the session customer.
+	 *
+	 * @param array $saved_shipping Shipping props to save to the account.
+	 */
+	private function log_in_customer( array $saved_shipping ): void {
+		$customer = new WC_Customer( $this->factory->user->create( array( 'role' => 'customer' ) ) );
+		$customer->set_props(
+			array(
+				'billing_first_name' => 'John',
+				'billing_country'    => 'US',
+				'billing_state'      => 'CA',
+				'billing_postcode'   => '94103',
+				'billing_city'       => 'San Francisco',
+				'billing_address_1'  => '1 Billing Street',
+				'billing_address_2'  => 'Apt 1',
+			) + $saved_shipping
+		);
+		$customer->save();
+
+		wp_set_current_user( $customer->get_id() );
+		WC()->customer = new WC_Customer( $customer->get_id(), true );
+	}
+
+	/**
+	 * Copy the billing details over the session's shipping ones, as checkout does while "Ship to a different address?"
+	 * is unticked: checkout.js posts the billing address as the shipping one on every update, and submitting the form
+	 * copies the name too, even when validation then fails.
+	 */
+	private function copy_billing_to_shipping_in_session(): void {
+		foreach ( self::PREFILLED_SHIPPING_FIELDS as $field ) {
+			WC()->customer->{"set_shipping_$field"}( WC()->customer->{"get_billing_$field"}( 'edit' ) );
+		}
+
+		WC()->customer->save();
+	}
+
+	/**
+	 * Get the values checkout prefills the shipping fields with.
+	 *
+	 * @return array Values keyed by field name without the 'shipping_' prefix.
+	 */
+	private function get_shipping_prefill(): array {
+		$prefill = array();
+
+		foreach ( self::PREFILLED_SHIPPING_FIELDS as $field ) {
+			$prefill[ $field ] = $this->sut->get_value( "shipping_$field" );
+		}
+
+		return $prefill;
+	}
+
+	/**
+	 * @testdox 'get_value' prefills the saved shipping address when the session only holds the billing copy.
+	 */
+	public function test_get_value_prefills_saved_shipping_address_over_billing_copy(): void {
+		$this->log_in_customer( self::SAVED_SHIPPING_ADDRESS );
+		WC()->customer->set_billing_company( 'Session Company' );
+		$this->copy_billing_to_shipping_in_session();
+
+		$this->assertSame(
+			array(
+				'first_name' => 'Jane',
+				'country'    => 'US',
+				'state'      => 'NY',
+				'postcode'   => '11201',
+				'city'       => 'Brooklyn',
+				'address_1'  => '2 Shipping Avenue',
+				'address_2'  => 'Apt 2',
+			),
+			$this->get_shipping_prefill()
+		);
+		$this->assertSame( 'Session Company', $this->sut->get_value( 'billing_company' ), 'Billing fields should still come from the session.' );
+	}
+
+	/**
+	 * @testdox 'get_value' keeps a shipping address the customer entered during the session.
+	 */
+	public function test_get_value_keeps_shipping_address_entered_in_session(): void {
+		$this->log_in_customer( self::SAVED_SHIPPING_ADDRESS );
+		// What the cart shipping calculator stores for a customer with a billing name. It clears the street lines.
+		WC()->customer->set_shipping_location( 'US', 'WA', '98101', 'Seattle' );
+		WC()->customer->save();
+
+		$this->assertSame(
+			array(
+				'first_name' => 'Jane',
+				'country'    => 'US',
+				'state'      => 'WA',
+				'postcode'   => '98101',
+				'city'       => 'Seattle',
+				'address_1'  => null,
+				'address_2'  => null,
+			),
+			$this->get_shipping_prefill()
+		);
+	}
+
+	/**
+	 * @testdox 'get_value' keeps the billing copy when the customer has no saved shipping address.
+	 */
+	public function test_get_value_keeps_billing_copy_without_saved_shipping_address(): void {
+		$this->log_in_customer( array( 'shipping_first_name' => 'Jane' ) );
+		$this->copy_billing_to_shipping_in_session();
+
+		$this->assertSame(
+			array(
+				'first_name' => 'John',
+				'country'    => 'US',
+				'state'      => 'CA',
+				'postcode'   => '94103',
+				'city'       => 'San Francisco',
+				'address_1'  => '1 Billing Street',
+				'address_2'  => 'Apt 1',
+			),
+			$this->get_shipping_prefill()
+		);
+	}
+
 	/**
 	 * @testdox create_order_tax_lines sets rate_code, label, compound and rate_percent on order tax items.
 	 */