Commit d4630850fd4 for woocommerce
commit d4630850fd4ad31cbcb6fbe0c18757515cdcae4a
Author: Vlad Olaru <vlad.olaru@automattic.com>
Date: Fri Sep 25 20:58:42 2026 +0300
Fix incomplete addresses passing the full shipping address check (#69108)
* fix(customer): require default address fields without a default locale
WC_Customer::has_full_shipping_address() read the default entry of the
country locale settings without checking it. Since #68573, a lookup
made while those settings are being built returns an empty array, so a
locale filter callback that checks the shipping address raised an
undefined-key warning. The default entry can also be dropped or
replaced by a locale filter or the public locale property. Either way
the default locale became null, every field counted as optional, and
an address missing its postcode read as complete, so shipping rates
were calculated for it.
Fall back to the unfiltered default address fields when the entry is
missing or not an array, the same guard #68573 added to the Store API
address validation in OrderController.
Refs WOOAIRR-303
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(customer): stop the default fields fallback from recursing
The fallback added to has_full_shipping_address() loads the default
address fields when the locale has no usable default entry, which
fires woocommerce_default_address_fields. A callback on that filter
that checks the shipping address, for example through
WC()->cart->show_shipping(), ran while the locale was being built. The
nested check found the in-progress locale empty, took the fallback
again and fired the filter again, until the process was killed.
A static flag now marks the outer load. A nested call that sees it
skips the filter and applies no rules, returning true as it did before
the fallback, so the loop ends after one level. The outer call still
requires the default fields.
The comment now also says country rules are unavailable during the
first locale build, so the default fields apply to every country
until it finishes.
Refs WOOAIRR-303
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
diff --git a/plugins/woocommerce/changelog/fix-wooairr-303-customer-default-locale b/plugins/woocommerce/changelog/fix-wooairr-303-customer-default-locale
new file mode 100644
index 00000000000..4ca7941a573
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-wooairr-303-customer-default-locale
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Treat a shipping address missing required fields as incomplete, even when an extension changes the default address settings.
diff --git a/plugins/woocommerce/includes/class-wc-customer.php b/plugins/woocommerce/includes/class-wc-customer.php
index 8b0d2f7f13f..1800cc4e2ca 100644
--- a/plugins/woocommerce/includes/class-wc-customer.php
+++ b/plugins/woocommerce/includes/class-wc-customer.php
@@ -73,6 +73,13 @@ class WC_Customer extends WC_Legacy_Customer {
*/
protected $is_vat_exempt = false;
+ /**
+ * Whether has_full_shipping_address() is loading the default address fields, so a nested call from a filter callback does not load them again.
+ *
+ * @var bool
+ */
+ private static $loading_default_address_fields = false;
+
/**
* Stores if user has calculated shipping in this session.
*
@@ -297,9 +304,25 @@ class WC_Customer extends WC_Legacy_Customer {
$address_fields = WC()->countries->get_country_locale();
$locale_key = ! empty( $shipping_address['country'] ) && array_key_exists( $shipping_address['country'], $address_fields ) ? $shipping_address['country'] : 'default';
- $default_locale = $address_fields['default'];
+ $default_locale = $address_fields['default'] ?? null;
$country_locale = $address_fields[ $locale_key ] ?? array();
+ // The default entry is missing while the locale settings are first built, and a locale filter callback can drop or replace it.
+ // Country rules are not available during that build, so the default fields apply to every country until it finishes.
+ if ( ! is_array( $default_locale ) ) {
+ $default_locale = array();
+
+ // A woocommerce_default_address_fields callback can call this method again, so the nested call skips the filter and applies no rules.
+ if ( ! self::$loading_default_address_fields ) {
+ self::$loading_default_address_fields = true;
+ try {
+ $default_locale = WC()->countries->get_default_address_fields();
+ } finally {
+ self::$loading_default_address_fields = false;
+ }
+ }
+ }
+
/**
* Checks all shipping address fields against the country's locale settings.
*
diff --git a/plugins/woocommerce/tests/php/includes/class-wc-customer-test.php b/plugins/woocommerce/tests/php/includes/class-wc-customer-test.php
index 39717ad937b..1c26cbd8eea 100644
--- a/plugins/woocommerce/tests/php/includes/class-wc-customer-test.php
+++ b/plugins/woocommerce/tests/php/includes/class-wc-customer-test.php
@@ -75,4 +75,98 @@ class WC_Customer_Test extends \WC_Unit_Test_Case {
$this->assertInstanceOf( 'WC_Customer', $re_fetched_customer );
}
+
+ /**
+ * @testdox has_full_shipping_address() still requires the default fields when the default locale entry is missing or not an array.
+ *
+ * @dataProvider provide_broken_default_locale_entries
+ *
+ * @param mixed $default_entry Value to put in the default locale entry, or null to remove the entry.
+ */
+ public function test_has_full_shipping_address_requires_default_fields_without_a_default_locale_entry( $default_entry ): void {
+ $countries = WC()->countries;
+ $countries->get_country_locale();
+ if ( null === $default_entry ) {
+ unset( $countries->locale['default'] );
+ } else {
+ $countries->locale['default'] = $default_entry;
+ }
+ $sut = $this->get_customer_without_shipping_postcode();
+
+ $this->assertFalse( $sut->has_full_shipping_address(), 'A missing postcode should still make the shipping address incomplete.' );
+ }
+
+ /**
+ * Broken values for the default entry of the country locale settings.
+ *
+ * @return array<string, array<mixed>>
+ */
+ public function provide_broken_default_locale_entries(): array {
+ return array(
+ 'entry removed' => array( null ),
+ 'entry not an array' => array( false ),
+ );
+ }
+
+ /**
+ * @testdox has_full_shipping_address() still requires the default fields when it runs while the country locale settings are being built.
+ */
+ public function test_has_full_shipping_address_requires_default_fields_while_country_locale_is_built(): void {
+ $sut = $this->get_customer_without_shipping_postcode();
+ $nested = null;
+ add_filter(
+ 'woocommerce_get_country_locale',
+ function ( $locale ) use ( $sut, &$nested ) {
+ $nested = $sut->has_full_shipping_address();
+ return $locale;
+ }
+ );
+ WC()->countries->locale = array();
+
+ WC()->countries->get_country_locale();
+
+ $this->assertFalse( $nested, 'A check made while the locale settings are built should still treat a missing postcode as incomplete.' );
+ }
+
+ /**
+ * @testdox has_full_shipping_address() does not recurse when a default address fields callback calls it while the locale settings are being built.
+ */
+ public function test_has_full_shipping_address_does_not_recurse_through_default_address_fields_callback(): void {
+ $sut = $this->get_customer_without_shipping_postcode();
+ $calls = 0;
+ $nested = null;
+ add_filter(
+ 'woocommerce_default_address_fields',
+ function ( $fields ) use ( $sut, &$calls, &$nested ) {
+ ++$calls;
+ // Stop a runaway recursion so a regression fails the assertion below instead of exhausting the process.
+ if ( $calls <= 10 ) {
+ // The outermost check finishes last, so its result is the one kept.
+ $nested = $sut->has_full_shipping_address();
+ }
+ return $fields;
+ }
+ );
+ WC()->countries->locale = array();
+
+ WC()->countries->get_country_locale();
+
+ $this->assertSame( 2, $calls, 'The default address fields filter should run once for the locale build and once for the nested shipping address check.' );
+ $this->assertFalse( $nested, 'The outermost nested check should still treat a missing postcode as incomplete.' );
+ }
+
+ /**
+ * Get a customer whose US shipping address has everything but a postcode.
+ *
+ * @return WC_Customer
+ */
+ private function get_customer_without_shipping_postcode(): WC_Customer {
+ $customer = new WC_Customer();
+ $customer->set_shipping_country( 'US' );
+ $customer->set_shipping_state( 'CA' );
+ $customer->set_shipping_city( 'San Francisco' );
+ $customer->set_shipping_postcode( '' );
+
+ return $customer;
+ }
}