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