Commit cf5f05c987b for woocommerce

commit cf5f05c987bc0987da147316617d917c1fb4c55b
Author: Vlad Olaru <vlad.olaru@automattic.com>
Date:   Mon Sep 28 15:34:33 2026 +0300

    Charge shipping in the classic checkout when a hidden address field is required (#69139)

diff --git a/plugins/woocommerce/changelog/fix-show-shipping-hidden-address-fields b/plugins/woocommerce/changelog/fix-show-shipping-hidden-address-fields
new file mode 100644
index 00000000000..f1c9c971405
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-show-shipping-hidden-address-fields
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Charge shipping in the classic checkout when an extension hides a required state or postcode field.
diff --git a/plugins/woocommerce/includes/class-wc-cart.php b/plugins/woocommerce/includes/class-wc-cart.php
index 4e8ff09e292..2403b92af51 100644
--- a/plugins/woocommerce/includes/class-wc-cart.php
+++ b/plugins/woocommerce/includes/class-wc-cart.php
@@ -1889,7 +1889,7 @@ class WC_Cart extends WC_Legacy_Cart {
 				 * @param bool $show_state Whether to use the state field. Default true.
 				 */
 				$state_enabled  = apply_filters( 'woocommerce_shipping_calculator_enable_state', true );
-				$state_required = isset( $country_fields['shipping_state'] ) && $country_fields['shipping_state']['required'];
+				$state_required = isset( $country_fields['shipping_state'] ) && $country_fields['shipping_state']['required'] && true !== ( $country_fields['shipping_state']['hidden'] ?? false );
 				// Takes care of late unsetting of checkout fields via hooks (woocommerce_checkout_fields, woocommerce_shipping_fields).
 				$checkout_state_field_exists = isset( $checkout_fields['shipping']['shipping_state'] );
 				if ( $state_enabled && $state_required && ! $this->get_customer()->get_shipping_state() && $checkout_state_field_exists ) {
@@ -1904,7 +1904,7 @@ class WC_Cart extends WC_Legacy_Cart {
 				 * @param bool $show_postcode Whether to use the postcode field. Default true.
 				 */
 				$postcode_enabled  = apply_filters( 'woocommerce_shipping_calculator_enable_postcode', true );
-				$postcode_required = isset( $country_fields['shipping_postcode'] ) && $country_fields['shipping_postcode']['required'];
+				$postcode_required = isset( $country_fields['shipping_postcode'] ) && $country_fields['shipping_postcode']['required'] && true !== ( $country_fields['shipping_postcode']['hidden'] ?? false );
 				// Takes care of late unsetting of checkout fields via hooks (woocommerce_checkout_fields, woocommerce_shipping_fields).
 				$checkout_postcode_field_exists = isset( $checkout_fields['shipping']['shipping_postcode'] );
 				if ( $postcode_enabled && $postcode_required && '' === $this->get_customer()->get_shipping_postcode() && $checkout_postcode_field_exists ) {
diff --git a/plugins/woocommerce/includes/class-wc-install.php b/plugins/woocommerce/includes/class-wc-install.php
index d1b9a29a63e..ba02c2b8ea7 100644
--- a/plugins/woocommerce/includes/class-wc-install.php
+++ b/plugins/woocommerce/includes/class-wc-install.php
@@ -350,9 +350,7 @@ class WC_Install {
 		'11.1.0-1' => array(
 			'wc_update_11101_remove_deprecated_variation_gallery_option',
 		),
-		'11.2.0-1' => array(
-			// Run again to cancel all pattern fetch jobs now that scheduling is disabled.
-			'wc_update_1040_cleanup_legacy_ptk_patterns_fetching',
+		'11.2.0'   => array(
 			'wc_update_1120_remove_abandoned_cart_recovery',
 			'wc_update_1120_migrate_stock_notifications_alpha_constant',
 			'wc_update_1120_delete_surface_cart_checkout_note',
@@ -362,6 +360,10 @@ class WC_Install {
 			'wc_update_11202_reset_refund_returning_customer_markers',
 			'wc_update_11203_normalize_stock_notification_emails',
 		),
+		'11.2.0-1' => array(
+			// Run again to cancel all pattern fetch jobs now that scheduling is disabled.
+			'wc_update_1040_cleanup_legacy_ptk_patterns_fetching',
+		),
 		'11.3.0'   => array(
 			'wc_update_1130_set_legacy_variation_price_hash_option',
 			'wc_update_1130_delete_unpublished_variation_lookup_rows',
diff --git a/plugins/woocommerce/tests/php/includes/class-wc-cart-test.php b/plugins/woocommerce/tests/php/includes/class-wc-cart-test.php
index d8f8c35fc24..82d2e772dc1 100644
--- a/plugins/woocommerce/tests/php/includes/class-wc-cart-test.php
+++ b/plugins/woocommerce/tests/php/includes/class-wc-cart-test.php
@@ -21,6 +21,13 @@ class WC_Cart_Test extends \WC_Unit_Test_Case {
 	 */
 	protected $add_to_cart_quantity_filter_args = array();

+	/**
+	 * Customer shipping address to restore after a test that changes it, keyed by prop name.
+	 *
+	 * @var array<string, string>|null
+	 */
+	private $original_shipping_address = null;
+
 	/**
 	 * Called before every test.
 	 */
@@ -39,6 +46,11 @@ class WC_Cart_Test extends \WC_Unit_Test_Case {
 		WC()->customer->set_is_vat_exempt( false );
 		WC()->session->set( 'wc_notices', null );

+		if ( null !== $this->original_shipping_address ) {
+			WC()->customer->set_props( $this->original_shipping_address );
+			$this->original_shipping_address = null;
+		}
+
 		// The parent teardown only clears chosen_shipping_methods, through
 		// WC_Shipping::reset_shipping(). Planted shipping_for_package_* rates survive and
 		// make later shipping calculations fail on a missing package hash, so clear them
@@ -968,6 +980,82 @@ class WC_Cart_Test extends \WC_Unit_Test_Case {
 		WC()->cart->cart_context = 'shortcode'; // Reset to default.
 	}

+	/**
+	 * @testdox show_shipping() in the classic cart does not wait for an address field that the country locale hides.
+	 *
+	 * @testWith [ "postcode" ]
+	 *           [ "state" ]
+	 *
+	 * @param string $field Address field the locale hides while it stays required.
+	 */
+	public function test_show_shipping_skips_an_address_field_the_locale_hides( string $field ): void {
+		$this->hide_us_address_field_in_locale( $field, true );
+		$this->add_product_for_a_us_address_missing( $field );
+
+		WC()->cart->calculate_totals();
+
+		$this->assertTrue( WC()->cart->show_shipping(), "A hidden {$field} should not hold back shipping." );
+		$this->assertTrue( WC()->cart->has_calculated_shipping(), "A hidden {$field} should let shipping be calculated." );
+		$this->assertGreaterThan( 0.0, (float) WC()->cart->get_shipping_total(), "A hidden {$field} should still charge the flat rate." );
+	}
+
+	/**
+	 * @testdox show_shipping() in the classic cart still requires an address field whose hidden flag is not exactly true.
+	 *
+	 * @testWith [ "postcode", "yes" ]
+	 *           [ "postcode", 1 ]
+	 *           [ "state", "yes" ]
+	 *           [ "state", 1 ]
+	 *
+	 * @param string $field  Address field the locale flags as hidden.
+	 * @param mixed  $hidden Hidden flag the locale sets on the field.
+	 */
+	public function test_show_shipping_requires_a_field_whose_hidden_flag_is_not_exactly_true( string $field, $hidden ): void {
+		$this->hide_us_address_field_in_locale( $field, $hidden );
+		$this->add_product_for_a_us_address_missing( $field );
+
+		$this->assertFalse( WC()->cart->show_shipping(), "The classic checkout only treats hidden => true as hidden, so the {$field} stays required." );
+	}
+
+	/**
+	 * Hide a US address field in the country locale while leaving it required.
+	 *
+	 * @param string $field  Address field key, without the type prefix.
+	 * @param mixed  $hidden Value for the hidden flag.
+	 */
+	private function hide_us_address_field_in_locale( string $field, $hidden ): void {
+		add_filter(
+			'woocommerce_get_country_locale',
+			function ( $locale ) use ( $field, $hidden ) {
+				$locale['US'][ $field ]['hidden'] = $hidden;
+				return $locale;
+			}
+		);
+		WC()->countries->locale = array();
+	}
+
+	/**
+	 * Add a product to the cart and give the customer a US shipping address without the given field, with shipping costs hidden until an address is entered.
+	 *
+	 * @param string $missing_field Address field to leave empty: 'postcode' or 'state'.
+	 */
+	private function add_product_for_a_us_address_missing( string $missing_field ): void {
+		update_option( 'woocommerce_shipping_cost_requires_address', 'yes' );
+		$product = WC_Helper_Product::create_simple_product();
+		WC()->cart->add_to_cart( $product->get_id(), 1 );
+		$customer                        = WC()->cart->get_customer();
+		$this->original_shipping_address = array(
+			'shipping_country'  => $customer->get_shipping_country(),
+			'shipping_state'    => $customer->get_shipping_state(),
+			'shipping_city'     => $customer->get_shipping_city(),
+			'shipping_postcode' => $customer->get_shipping_postcode(),
+		);
+		$customer->set_shipping_country( 'US' );
+		$customer->set_shipping_city( 'New York' );
+		$customer->set_shipping_state( 'state' === $missing_field ? '' : 'NY' );
+		$customer->set_shipping_postcode( 'postcode' === $missing_field ? '' : '10001' );
+	}
+
 	/**
 	 * Test show_shipping for countries with various state/postcode requirement.
 	 */