Commit 81c0b130d11 for woocommerce

commit 81c0b130d11b3d4f113ff17bc6535ec7f2537bea
Author: Mike Jolley <mike.jolley@me.com>
Date:   Thu Oct 22 16:47:17 2026 +0100

    Show shipping when forced-billing stores remove billing address fields (#69930)

    * Check billing fields when hiding shipping for forced-billing stores

    * Add changelog entry for the forced-billing hidden shipping fix

diff --git a/plugins/woocommerce/changelog/69737-forced-billing-removed-address-fields b/plugins/woocommerce/changelog/69737-forced-billing-removed-address-fields
new file mode 100644
index 00000000000..e69352c2d71
--- /dev/null
+++ b/plugins/woocommerce/changelog/69737-forced-billing-removed-address-fields
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Charge shipping at the classic checkout when shipping is forced to billing and the store removed billing address fields.
diff --git a/plugins/woocommerce/includes/class-wc-cart.php b/plugins/woocommerce/includes/class-wc-cart.php
index cdfdaf08b08..76f7327a2d1 100644
--- a/plugins/woocommerce/includes/class-wc-cart.php
+++ b/plugins/woocommerce/includes/class-wc-cart.php
@@ -1917,7 +1917,10 @@ class WC_Cart extends WC_Legacy_Cart {
 				$state_enabled  = apply_filters( 'woocommerce_shipping_calculator_enable_state', true );
 				$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'] );
+				// When shipping is forced to the billing address the shipping fields are never shown, so only a field the shopper
+				// can actually fill in (the billing one) should keep shipping hidden.
+				$address_fieldset            = wc_ship_to_billing_address_only() ? 'billing' : 'shipping';
+				$checkout_state_field_exists = isset( $checkout_fields[ $address_fieldset ][ $address_fieldset . '_state' ] );
 				if ( $state_enabled && $state_required && ! $this->get_customer()->get_shipping_state() && $checkout_state_field_exists ) {
 					return false;
 				}
@@ -1932,7 +1935,7 @@ class WC_Cart extends WC_Legacy_Cart {
 				$postcode_enabled  = apply_filters( 'woocommerce_shipping_calculator_enable_postcode', true );
 				$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'] );
+				$checkout_postcode_field_exists = isset( $checkout_fields[ $address_fieldset ][ $address_fieldset . '_postcode' ] );
 				if ( $postcode_enabled && $postcode_required && '' === $this->get_customer()->get_shipping_postcode() && $checkout_postcode_field_exists ) {
 					return false;
 				}
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 af77d9ef44b..8b77a9b45df 100644
--- a/plugins/woocommerce/tests/php/includes/class-wc-cart-test.php
+++ b/plugins/woocommerce/tests/php/includes/class-wc-cart-test.php
@@ -1096,6 +1096,40 @@ class WC_Cart_Test extends \WC_Unit_Test_Case {
 		WC()->cart->get_customer()->set_shipping_postcode( '' );
 	}

+	/**
+	 * @testdox show_shipping() checks the billing fields when shipping is forced to the billing address, so a removed billing postcode no longer hides shipping.
+	 *
+	 * @testWith [ "billing_only", true ]
+	 *           [ "billing", false ]
+	 *           [ "shipping", false ]
+	 *
+	 * @param string $ship_to_destination Value of the woocommerce_ship_to_destination option.
+	 * @param bool   $expected            Expected show_shipping() result with an empty postcode.
+	 */
+	public function test_show_shipping_reads_billing_fields_when_shipping_is_forced_to_billing( string $ship_to_destination, bool $expected ): void {
+		$remove_billing_address = static function ( $fields ) {
+			unset( $fields['billing']['billing_city'], $fields['billing']['billing_address_1'], $fields['billing']['billing_postcode'] );
+			return $fields;
+		};
+		add_filter( 'woocommerce_checkout_fields', $remove_billing_address );
+		update_option( 'woocommerce_shipping_cost_requires_address', 'yes' );
+		update_option( 'woocommerce_ship_to_destination', $ship_to_destination );
+
+		$product = WC_Helper_Product::create_simple_product();
+		WC()->cart->add_to_cart( $product->get_id(), 1 );
+		WC()->customer->set_shipping_location( 'US', 'NY', '', '' );
+
+		try {
+			$this->assertSame( $expected, WC()->cart->show_shipping(), 'Shipping should only stay hidden while the shopper has a postcode field to fill in.' );
+
+			WC()->customer->set_shipping_location( 'US', '', '', '' );
+			$this->assertFalse( WC()->cart->show_shipping(), 'A removed postcode must not stop the state from being required.' );
+		} finally {
+			remove_filter( 'woocommerce_checkout_fields', $remove_billing_address );
+			WC()->customer->set_shipping_location( '', '', '', '' );
+		}
+	}
+
 	/**
 	 * Test that show_shipping returns true when Local Pickup is enabled,
 	 * even when "Hide shipping costs until an address is entered" is enabled
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 2d251848e52..d23bf0dd035 100644
--- a/plugins/woocommerce/tests/php/includes/class-wc-checkout-test.php
+++ b/plugins/woocommerce/tests/php/includes/class-wc-checkout-test.php
@@ -786,6 +786,59 @@ class WC_Checkout_Test extends \WC_Unit_Test_Case {
 		);
 	}

+	/**
+	 * @testdox 'validate_checkout' charges shipping when shipping is forced to billing and the removed billing fields are the only missing address parts.
+	 *
+	 * @testWith [ "10001", "O'Fallon" ]
+	 *           [ "", "" ]
+	 *
+	 * @param string $calculator_postcode Postcode entered in the cart shipping calculator, or empty when the shopper went straight to checkout.
+	 * @param string $calculator_city     City entered in the cart shipping calculator.
+	 */
+	public function test_validate_checkout_accepts_forced_billing_order_when_removed_billing_fields_are_missing( string $calculator_postcode, string $calculator_city ): void {
+		$remove_billing_address = static function ( $fields ) {
+			unset( $fields['billing']['billing_city'], $fields['billing']['billing_address_1'], $fields['billing']['billing_postcode'] );
+			return $fields;
+		};
+		add_filter( 'woocommerce_checkout_fields', $remove_billing_address );
+		update_option( 'woocommerce_shipping_cost_requires_address', 'yes' );
+		update_option( 'woocommerce_ship_to_destination', 'billing_only' );
+		WC_Helper_Shipping::create_simple_flat_rate( 8 );
+
+		$product = WC_Helper_Product::create_simple_product();
+		WC()->cart->add_to_cart( $product->get_id(), 1 );
+		WC()->customer->set_shipping_location( 'US', 'NY', $calculator_postcode, $calculator_city );
+		WC()->cart->calculate_totals();
+
+		$original_post = $_POST; // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Restored in the finally block.
+		$_POST         = array(
+			'billing_first_name' => 'Test',
+			'billing_last_name'  => 'Shopper',
+			'billing_phone'      => '5555555555',
+			'billing_email'      => 'shopper@example.com',
+			'billing_country'    => 'US',
+			'billing_state'      => 'NY',
+			'shipping_method'    => array( 'flat_rate' ),
+			'payment_method'     => 'cod',
+		);
+
+		try {
+			$data = $this->sut->get_posted_data();
+			$this->sut->update_session( $data );
+
+			$errors = new WP_Error();
+			$this->sut->validate_checkout( $data, $errors );
+		} finally {
+			$_POST = $original_post;
+			remove_filter( 'woocommerce_checkout_fields', $remove_billing_address );
+			WC_Helper_Shipping::delete_simple_flat_rate();
+		}
+
+		$this->assertTrue( WC()->cart->has_calculated_shipping(), 'Shipping should be calculated from the country and state the shopper was asked for.' );
+		$this->assertSame( '', $errors->get_error_message( 'shipping' ), 'The order must not be blocked for an address field the store does not show.' );
+		$this->assertSame( 8.0, (float) WC()->cart->get_shipping_total(), 'The order must be charged the shipping rate.' );
+	}
+
 	/**
 	 * @testdox 'validate_checkout' still accepts an order when a plugin deliberately turns off shipping calculation.
 	 */