Commit fdaa3b2a4d3 for woocommerce

commit fdaa3b2a4d3f698d52f37bef3828bc24656df1ba
Author: Liam Sarsfield <43409125+LiamSarsfield@users.noreply.github.com>
Date:   Wed Jul 29 13:39:15 2026 +0100

    Fix fatal error validating checkout fields outside the billing and shipping fieldsets (#67100)

diff --git a/plugins/woocommerce/changelog/fix-67083-checkout-fieldset-country-fatal b/plugins/woocommerce/changelog/fix-67083-checkout-fieldset-country-fatal
new file mode 100644
index 00000000000..3c184afbbf6
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-67083-checkout-fieldset-country-fatal
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Fix a fatal error on the shortcode checkout when a phone, postcode, or state validated field is registered outside the billing and shipping fieldsets.
diff --git a/plugins/woocommerce/includes/class-wc-checkout.php b/plugins/woocommerce/includes/class-wc-checkout.php
index 15c50e5ee71..6e5102b5eed 100644
--- a/plugins/woocommerce/includes/class-wc-checkout.php
+++ b/plugins/woocommerce/includes/class-wc-checkout.php
@@ -858,6 +858,37 @@ class WC_Checkout {
 		return apply_filters( 'woocommerce_checkout_posted_data', $data );
 	}

+	/**
+	 * Get the country to validate a fieldset's fields against.
+	 *
+	 * Uses the posted country when the fieldset has one. Only 'billing' and 'shipping' have a customer
+	 * country to fall back on, and only once the customer object is set up, so every other fieldset,
+	 * including those registered through the 'woocommerce_checkout_fields' filter, is validated without
+	 * country specific rules instead of fataling on an undefined method.
+	 *
+	 * @param  string $fieldset_key Fieldset key.
+	 * @param  array  $data         An array of posted data.
+	 * @return string Country code, or an empty string when it can't be determined.
+	 */
+	private function get_fieldset_country( $fieldset_key, $data ) {
+		if ( isset( $data[ $fieldset_key . '_country' ] ) ) {
+			return $data[ $fieldset_key . '_country' ];
+		}
+
+		if ( ! WC()->customer instanceof WC_Customer ) {
+			return '';
+		}
+
+		switch ( $fieldset_key ) {
+			case 'shipping':
+				return WC()->customer->get_shipping_country();
+			case 'billing':
+				return WC()->customer->get_billing_country();
+			default:
+				return '';
+		}
+	}
+
 	/**
 	 * Validates the posted checkout data based on field properties.
 	 *
@@ -899,7 +930,7 @@ class WC_Checkout {
 				}

 				if ( in_array( 'postcode', $format, true ) ) {
-					$country      = isset( $data[ $fieldset_key . '_country' ] ) ? $data[ $fieldset_key . '_country' ] : WC()->customer->{"get_{$fieldset_key}_country"}();
+					$country      = $this->get_fieldset_country( $fieldset_key, $data );
 					$data[ $key ] = wc_format_postcode( $data[ $key ], $country );

 					if ( $validate_fieldset && '' !== $data[ $key ] && ! WC_Validation::is_postcode( $data[ $key ], $country ) ) {
@@ -917,7 +948,7 @@ class WC_Checkout {
 				}

 				if ( in_array( 'phone', $format, true ) ) {
-					$country = $data[ $fieldset_key . '_country' ] ?? WC()->customer->{"get_{$fieldset_key}_country"}();
+					$country = $this->get_fieldset_country( $fieldset_key, $data );
 					// This is a safe sanitize to prevent copy-paste issues with invisible chars. Won't ensure validation.
 					$data[ $key ] = wc_remove_non_displayable_chars( $data[ $key ] );

@@ -939,7 +970,7 @@ class WC_Checkout {
 				}

 				if ( '' !== $data[ $key ] && in_array( 'state', $format, true ) ) {
-					$country      = isset( $data[ $fieldset_key . '_country' ] ) ? $data[ $fieldset_key . '_country' ] : WC()->customer->{"get_{$fieldset_key}_country"}();
+					$country      = $this->get_fieldset_country( $fieldset_key, $data );
 					$valid_states = WC()->countries->get_states( $country );

 					if ( ! empty( $valid_states ) && is_array( $valid_states ) && count( $valid_states ) > 0 ) {
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 1b3c1833dfa..63efd1b575e 100644
--- a/plugins/woocommerce/tests/php/includes/class-wc-checkout-test.php
+++ b/plugins/woocommerce/tests/php/includes/class-wc-checkout-test.php
@@ -17,6 +17,11 @@ class WC_Checkout_Test extends \WC_Unit_Test_Case {
 	 */
 	private $sut;

+	/**
+	 * @var callable[] Callbacks registering extra checkout fields, all removed on tear down.
+	 */
+	private $extra_field_filters = array();
+
 	/**
 	 * Runs before each test.
 	 */
@@ -47,9 +52,36 @@ class WC_Checkout_Test extends \WC_Unit_Test_Case {
 		remove_filter( 'woocommerce_checkout_registration_enabled', '__return_true' );
 		delete_option( 'woocommerce_calc_taxes' );

+		foreach ( $this->extra_field_filters as $extra_field_filter ) {
+			remove_filter( 'woocommerce_checkout_fields', $extra_field_filter );
+		}
+
+		$this->extra_field_filters = array();
+
 		parent::tearDown();
 	}

+	/**
+	 * Register an extra checkout field in an arbitrary fieldset, the way checkout field editor plugins do.
+	 *
+	 * Can be called more than once per test: each callback is tracked and removed on tear down.
+	 *
+	 * @param string $fieldset_key Fieldset the field belongs to.
+	 * @param string $key          Field key.
+	 * @param array  $field        Field definition.
+	 */
+	private function register_extra_checkout_field( $fieldset_key, $key, $field ) {
+		$extra_field_filter = function ( $fields ) use ( $fieldset_key, $key, $field ) {
+			$fields[ $fieldset_key ][ $key ] = $field;
+
+			return $fields;
+		};
+
+		$this->extra_field_filters[] = $extra_field_filter;
+
+		add_filter( 'woocommerce_checkout_fields', $extra_field_filter );
+	}
+
 	/**
 	 * @testdox 'validate_posted_data' adds errors for non-existing billing/shipping countries.
 	 *
@@ -183,6 +215,273 @@ class WC_Checkout_Test extends \WC_Unit_Test_Case {
 		$this->assertEmpty( $errors->get_error_message( 'shipping_country_validation' ) );
 	}

+	/**
+	 * @testdox 'validate_posted_data' reports a required field error, and doesn't throw, for an empty phone field in a fieldset without a country.
+	 *
+	 * @testWith ["order"]
+	 *           ["custom"]
+	 *
+	 * @param string $fieldset_key The fieldset the phone field belongs to.
+	 */
+	public function test_validate_posted_data_does_not_throw_for_empty_phone_field_without_a_country( $fieldset_key ) {
+		$this->register_extra_checkout_field(
+			$fieldset_key,
+			'extra_phone',
+			array(
+				'label'    => 'Phone number',
+				'validate' => array( 'phone' ),
+				'required' => true,
+			)
+		);
+
+		$data = array(
+			'ship_to_different_address' => false,
+			'extra_phone'               => '',
+		);
+
+		$errors = new WP_Error();
+
+		$this->sut->validate_posted_data( $data, $errors );
+
+		$this->assertEquals(
+			'<strong>Phone number</strong> is a required field.',
+			$errors->get_error_message( 'extra_phone_required' ),
+			'An empty required phone field should be reported as missing, not blow up while resolving the country.'
+		);
+		$this->assertEmpty( $errors->get_error_message( 'extra_phone_validation' ) );
+	}
+
+	/**
+	 * @testdox 'validate_posted_data' validates a phone field in a fieldset without a country, without country specific rules.
+	 *
+	 * @testWith ["not a phone number", true]
+	 *           ["+34 600 000 000", false]
+	 *
+	 * @param string $phone        The posted phone number.
+	 * @param bool   $expect_error True to expect a validation error for the phone field.
+	 */
+	public function test_validate_posted_data_validates_phone_field_in_order_fieldset( $phone, $expect_error ) {
+		$this->register_extra_checkout_field(
+			'order',
+			'order_phone',
+			array(
+				'label'    => 'Phone number',
+				'validate' => array( 'phone' ),
+			)
+		);
+
+		$data = array(
+			'ship_to_different_address' => false,
+			'order_phone'               => $phone,
+		);
+
+		$errors = new WP_Error();
+
+		$this->sut->validate_posted_data( $data, $errors );
+
+		$this->assertEquals(
+			$expect_error ? '<strong>Phone number</strong> is not a valid phone number.' : '',
+			$errors->get_error_message( 'order_phone_validation' )
+		);
+	}
+
+	/**
+	 * @testdox 'validate_posted_data' validates a postcode field in a fieldset without a country, without country specific rules.
+	 *
+	 * 'ABCDE' is deliberately valid only without a country: it passes the country agnostic check but fails the
+	 * store's own base country rules, so this fails if the fieldset ever resolves to a real country.
+	 *
+	 * @testWith ["INVALID!", true]
+	 *           ["ABCDE", false]
+	 *
+	 * @param string $postcode     The posted postcode.
+	 * @param bool   $expect_error True to expect a validation error for the postcode field.
+	 */
+	public function test_validate_posted_data_validates_postcode_field_in_order_fieldset( $postcode, $expect_error ) {
+		$this->register_extra_checkout_field(
+			'order',
+			'order_postcode',
+			array(
+				'label'    => 'Delivery postcode',
+				'validate' => array( 'postcode' ),
+			)
+		);
+
+		$data = array(
+			'ship_to_different_address' => false,
+			'order_postcode'            => $postcode,
+		);
+
+		$errors = new WP_Error();
+
+		$this->sut->validate_posted_data( $data, $errors );
+
+		$this->assertEquals(
+			$expect_error ? '<strong>Delivery postcode</strong> is not a valid postcode / ZIP.' : '',
+			$errors->get_error_message( 'order_postcode_validation' )
+		);
+	}
+
+	/**
+	 * @testdox 'validate_posted_data' skips state validation, and doesn't throw, for a state field in a fieldset without a country.
+	 */
+	public function test_validate_posted_data_does_not_throw_for_state_field_in_order_fieldset() {
+		$this->register_extra_checkout_field(
+			'order',
+			'order_state',
+			array(
+				'label'    => 'Delivery state',
+				'validate' => array( 'state' ),
+			)
+		);
+
+		$data = array(
+			'ship_to_different_address' => false,
+			'order_state'               => 'Not a real state',
+		);
+
+		$errors = new WP_Error();
+
+		$this->sut->validate_posted_data( $data, $errors );
+
+		$this->assertEmpty( $errors->get_error_message( 'order_state_validation' ) );
+	}
+
+	/**
+	 * @testdox 'validate_posted_data' ignores customer getters that only match a fieldset key by accident.
+	 *
+	 * 'WC_Customer::get_default_country()' is public, so looking the getter up from the fieldset key made a
+	 * fieldset named 'default' validate against the store's base country and emit a deprecation notice.
+	 * 'ABCDE' passes the country agnostic postcode check but fails the store's base country rules.
+	 */
+	public function test_validate_posted_data_ignores_incidentally_matching_customer_getters() {
+		$this->register_extra_checkout_field(
+			'default',
+			'default_postcode',
+			array(
+				'label'    => 'Default postcode',
+				'validate' => array( 'postcode' ),
+			)
+		);
+
+		$data = array(
+			'ship_to_different_address' => false,
+			'default_postcode'          => 'ABCDE',
+		);
+
+		$errors = new WP_Error();
+
+		$this->sut->validate_posted_data( $data, $errors );
+
+		$this->assertEmpty(
+			$errors->get_error_message( 'default_postcode_validation' ),
+			"Only billing and shipping have a country, so the 'default' fieldset must not resolve to one."
+		);
+	}
+
+	/**
+	 * @testdox 'validate_posted_data' doesn't throw when the customer object isn't set up yet.
+	 *
+	 * 'WC()->customer' is null until 'WC_Woocommerce::initialize_cart()' runs, and 'validate_posted_data'
+	 * has to survive that. 'ABCDE' passes the country agnostic postcode check but fails the store's base
+	 * country rules, so this also pins that no country is assumed when the customer can't supply one.
+	 */
+	public function test_validate_posted_data_does_not_throw_without_a_customer_object() {
+		$original_customer = WC()->customer;
+
+		WC()->customer = null;
+
+		$data = array(
+			'ship_to_different_address' => false,
+			'billing_postcode'          => 'ABCDE',
+		);
+
+		$errors = new WP_Error();
+
+		try {
+			$this->sut->validate_posted_data( $data, $errors );
+		} finally {
+			WC()->customer = $original_customer;
+		}
+
+		$this->assertEmpty(
+			$errors->get_error_message( 'billing_postcode_validation' ),
+			'Without a customer object there is no country to validate against.'
+		);
+	}
+
+	/**
+	 * @testdox 'validate_posted_data' prefers the posted country over the one stored on the customer.
+	 */
+	public function test_validate_posted_data_prefers_the_posted_country() {
+		$original_billing_country = WC()->customer->get_billing_country();
+
+		WC()->customer->set_billing_country( 'GB' );
+
+		$data = array(
+			'ship_to_different_address' => false,
+			'billing_country'           => 'US',
+			'billing_postcode'          => '12345',
+		);
+
+		$errors = new WP_Error();
+
+		try {
+			$this->sut->validate_posted_data( $data, $errors );
+		} finally {
+			WC()->customer->set_billing_country( $original_billing_country );
+		}
+
+		$this->assertEmpty(
+			$errors->get_error_message( 'billing_postcode_validation' ),
+			"'12345' is a valid US postcode, so the posted country must win over the customer's GB country."
+		);
+	}
+
+	/**
+	 * @testdox 'validate_posted_data' still falls back to the customer country for billing and shipping fields.
+	 *
+	 * @testWith ["billing", "GB", true]
+	 *           ["billing", "US", false]
+	 *           ["shipping", "GB", true]
+	 *           ["shipping", "US", false]
+	 *
+	 * @param string $fieldset_key     The fieldset holding the postcode field.
+	 * @param string $customer_country The country stored on the customer object.
+	 * @param bool   $expect_error     True to expect a validation error for the postcode field.
+	 */
+	public function test_validate_posted_data_falls_back_to_the_customer_country( $fieldset_key, $customer_country, $expect_error ) {
+		add_filter( 'woocommerce_cart_needs_shipping_address', '__return_true' );
+
+		$original_billing_country  = WC()->customer->get_billing_country();
+		$original_shipping_country = WC()->customer->get_shipping_country();
+
+		WC()->customer->set_billing_country( $customer_country );
+		WC()->customer->set_shipping_country( $customer_country );
+
+		// The country is deliberately not posted, so it has to be resolved from the customer object.
+		$data = array(
+			'ship_to_different_address' => true,
+			$fieldset_key . '_postcode' => '12345',
+		);
+
+		$errors = new WP_Error();
+
+		try {
+			$this->sut->validate_posted_data( $data, $errors );
+		} finally {
+			WC()->customer->set_billing_country( $original_billing_country );
+			WC()->customer->set_shipping_country( $original_shipping_country );
+			remove_filter( 'woocommerce_cart_needs_shipping_address', '__return_true' );
+		}
+
+		$this->assertEquals(
+			$expect_error,
+			! empty( $errors->get_error_message( $fieldset_key . '_postcode_validation' ) ),
+			"'12345' should " . ( $expect_error ? 'not ' : '' ) . "be accepted as a {$customer_country} postcode."
+		);
+	}
+
 	/**
 	 * @testdox 'get_posted_data' respects the selected shipping address.
 	 *