Repository: woocommerce/woocommerce
Pull request #57674: Prevent checkout without paying for shipping when hiding core fields
Author: login: opr
Labels: name: plugin: woocommerce
description: Issues related to the WooCommerce Core plugin.
name: cherry pick to trunk
description: Dev only - Part of CFE, PRR flows
name: cherry pick to frozen release
description: Dev only - Part of PRR flow
Created: 2025-05-01T16:21:03Z | Merged: 2025-05-09T14:33:14Z

### Changes proposed in this Pull Request:

- This PR semi reverts https://github.com/woocommerce/woocommerce/pull/55804
- Adds a new `$cart_context` property to the WC cart, this lets us specify routes through legacy code depending on the source of the `WC_Cart` object. In WC Blocks, the context is set to `store-api` in other contexts it is set to `shortcode`.
- We use this to determine which code path to take in `has_full_shipping_address` - we need to support certain hooks in shortcode, but not in WC Blocks (we use locale only in WC Blocks, whereas in Shortcode we use `woocommerce_shipping_fields` because the list of required shipping fields is retrieved from `get_address_fields()`
- Expands on the show_shipping test case to test filtering functionality around this.

Closes WOOPLUG-4037

(For Bug Fixes) Bug introduced in PR https://github.com/woocommerce/woocommerce/pull/55804 and https://github.com/woocommerce/woocommerce/pull/53466

### Screenshots or screen recordings:

<!-- If this PR includes UI changes, please provide screenshots or a screen recording for clarity. -->
<!-- This section can be removed if not applicable. -->

[Note, in the below videos, I have made postcode optional using the ThemeHigh checkout field editor plugin](https://en-gb.wordpress.org/plugins/woo-checkout-field-editor-pro/) which we believe was a very common plugin installed on sites experiencing this issue.

| Before | After |
| ------ | ----- |
| https://www.loom.com/share/ed626d5a8e1c4f88b7b49154aebd69d9 | https://www.loom.com/share/67efeafb77e54af48f5ded0e8bab328b |


<!-- Begin testing instructions -->

### How to test the changes in this Pull Request:

<!-- Include detailed instructions on how these changes can be tested. Review and follow the guide for how to write high-quality testing instructions. -->

Using the [WooCommerce Testing Instructions Guide](https://github.com/woocommerce/woocommerce/wiki/Writing-high-quality-testing-instructions), include your detailed testing instructions:

#### Happy path testing 1

This happy path makes the following assumption:
 - Extensions modifying the address fields through `woocommerce_shipping_fields` and `woocommerce_billing_fields`  _without_ updating locale correctly would still make the frontend fields optional on the JS side. (This is what happens in the checkout field editor plugin this was tested against, there is some JS that removes the "required" classes from non-required fields).

1. Install [ThemeHigh checkout field editor plugin](https://en-gb.wordpress.org/plugins/woo-checkout-field-editor-pro/)  
2. Go to the settings (WC -> Checkout Fields) and make the billing and shipping postcode optional (i.e. not required)
<img width="964" alt="image" src="https://github.com/user-attachments/assets/224972ca-0bcf-4ed5-806f-47927f555a49" />

3. Set up shipping zones and ensure methods are set up too.
4. Go to WC -> Settings -> Shipping -> Shipping Settings. Enable "Hide shipping costs until an address is entered"
5. Add an item to your cart and go to the shortcode checkout.
6. Fill in your details, except postcode. Ensure shipping rates show up for the correct country.
7. Ensure you can check out and it charges the correct amount of shipping.

#### Developer only testing/happy path

This test covers only using locale to update the checkout fields. 

1. Disable the checkout field editor plugin from test 1.
2. Add the following snippet.

```php
// Make postcode optional in checkout fields.
add_filter( 'woocommerce_get_country_locale', 'make_postcode_optional' );
function make_postcode_optional( $locales ) {
	foreach( $locales as $country => $locale ) {
		$locales[$country]['postcode']['required'] = false;
	}
    return $locales;
}

add_filter( 'woocommerce_get_country_locale_default', 'make_postcode_optional_default' );

function make_postcode_optional_default( $locale ) {
	$locale['postcode']['required'] = false;
	$locale['postcode']['hidden'] = false;
    return $locale;
}
```
3. Go to WC -> Settings -> Shipping -> Shipping Settings. Enable "Hide shipping costs until an address is entered"
4. Add an item to your cart and go to the shortcode checkout.
5. Fill in your details, except postcode. Ensure shipping rates show up for the correct country.
6. Ensure you can check out and it charges the correct amount of shipping.

#### Other testing

- Test with "Hide shipping costs until an address is entered" disabled - rates should appear as soon as a country is selected.
- Test with virtual products.
- Test with all shipping methods disabled (checkout should not be possible)
- Test with NO shipping methods at all (all deleted), checkout should be possible.
- Test in the blocks checkout with various different zones, different local pickup setting, and "Hide shipping costs until an address is entered" on and off.

<!-- End testing instructions -->

### Testing that has already taken place:

All of the above was tested already, Mike, Nadir, and I spent sync time together working through the problem before settling on the approach here of separating blocks and shortcode logic.

### Other notes

The below was part of the original testing instructions, however we removed the code responsible for blocking orders without posted shipping. The risk of blocking legit orders was too great and we would prefer to allow orders to be placed in this edge case without shipping, rather than blocking them altogether.

Note that this edge case occurs even as far back as WC 7.0, I didn't test earlier versions, but this clearly isn't a common use case pattern. This edge case would occur when editing the checkout fields incorrectly, and _not_ updating the fields on the front-end to not be required. When fields are marked as required, and without values, the new address values are not sent to the server.

For a merchant to face this, they would have to be content with an "optional" field still being presented as required on the front end.

##### Edge case 1

This edge case occurs when plugins "do it wrong" by updating the address fields through `woocommerce_shipping_fields` and `woocommerce_billing_fields`  _without_ updating locale correctly and without removing the classes that style a field as required on the front-end.

The postcode field will be made optional by the snippet below, but it will still have classes that make it required on the front end. Because it is still "required" the partial address updates will not be pushed to the server, meaning shipping rates will not update.

I have put a mechanism in place to stop orders going through with no shipping, but it doesn't really give shoppers a way to remedy it, unless they realise they need to enter a postcode.

Given this is an edge case, and a result of plugins "doing it wrong" I don't think it is worth spending too much time trying to work around this.

1. Disable the checkout field editor plugin from test 1.
2. Add the following snippet
```php
add_filter( 'woocommerce_checkout_fields', function( $fields ) {
	$fields['billing']['billing_postcode']['required'] = false;
	$fields['shipping']['shipping_postcode']['required'] = false;
	return $fields;
} );
add_filter( 'woocommerce_shipping_fields', function( $fields ) {
	$fields['shipping_postcode']['required'] = false;
	return $fields;
} );
add_filter( 'woocommerce_billing_fields', function( $fields ) {
	$fields['billing_postcode']['required'] = false;
	return $fields;
} );
```

3. Go to WC -> Settings -> Shipping -> Shipping Settings. Enable "Hide shipping costs until an address is entered"
4. Add an item to the cart and go to the shortcode checkout.
5. Fill in all fields except postcode.
7. Notice that, despite postcode looking like it is required, you can still check out without it.
8. Ensure that an error appears preventing checkout.
9. Reload the page.
10. Ensure rates appear and you can check out.

### Changelog entry

<!-- You can optionally choose to enter a changelog entry by checking the box below and supplying data. -->
<!-- It will trigger the 'Add changelog to PR' CI job to create and push the entry into the branch. -->

<!-- Due to org permissions, the job might fail for PRs crated from a fork under GitHub organizations. Possible solutions: -->
<!-- * Create entry manually with `pnpm --filter='@woocommerce/plugin-woocommerce' changelog add` and push it into the branch (replace `@woocommerce/plugin-woocommerce` with package name from nearest `package.json` file) -->
<!-- * Create entry from supplied PR data and push it automatically `pnpm utils changefile pr-number-here -o github-org-name-here` -->

-   [ ] Automatically create a changelog entry from the details below.

<!-- If no changelog entry is required for this PR, you can specify that below and provide a comment explaining why. This cannot be used if you selected the option to automatically create a changelog entry above. -->

-   [ ] This Pull Request does not require a changelog entry. (Comment required below)

<details>

<summary>Changelog Entry Details</summary>

#### Significance

<!-- Choose only one -->

-   [ ] Patch
-   [ ] Minor
-   [ ] Major

#### Type

<!-- Choose only one -->

-   [ ] Fix - Fixes an existing bug
-   [ ] Add - Adds functionality
-   [ ] Update - Update existing functionality
-   [ ] Dev - Development related task
-   [ ] Tweak - A minor adjustment to the codebase
-   [ ] Performance - Address performance issues
-   [ ] Enhancement - Improvement to existing functionality

#### Message <!-- Add a changelog message here -->

</details>

<details>

<summary>Changelog Entry Comment</summary>

#### Comment <!-- If your Pull Request doesn't require a changelog entry, a comment explaining why is required instead -->

</details>



Diff:
diff --git a/plugins/woocommerce/changelog/fix-shortcode-shipping-release-branch b/plugins/woocommerce/changelog/fix-shortcode-shipping-release-branch
new file mode 100644
index 0000000000000..15ee7859f7eb2
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-shortcode-shipping-release-branch
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Prevent shipping rates remaining hidden when checkout fields filtered to be optional are not filled on classic checkout.
diff --git a/plugins/woocommerce/includes/class-wc-cart.php b/plugins/woocommerce/includes/class-wc-cart.php
index da6a47c1218c4..68d9235f73fae 100644
--- a/plugins/woocommerce/includes/class-wc-cart.php
+++ b/plugins/woocommerce/includes/class-wc-cart.php
@@ -25,6 +25,14 @@
  */
 class WC_Cart extends WC_Legacy_Cart {
 
+	/**
+	 * Cart context, used to determine if the cart is being used in a StoreAPI or shortcode context. This should only
+	 * be used internally.
+	 *
+	 * @var string shortcode|store-api
+	 */
+	public $cart_context = 'shortcode';
+
 	/**
 	 * Contains an array of cart items.
 	 *
@@ -1621,10 +1629,50 @@ public function show_shipping() {
 		}
 
 		if ( 'yes' === get_option( 'woocommerce_shipping_cost_requires_address' ) ) {
-			$customer = $this->get_customer();
+			if ( 'store-api' === $this->cart_context ) {
+				$customer = $this->get_customer();
 
-			if ( ! $customer instanceof \WC_Customer || ! $customer->has_full_shipping_address() ) {
-				return false;
+				if ( ! $customer instanceof \WC_Customer || ! $customer->has_full_shipping_address() ) {
+					return false;
+				}
+			} else {
+				$country = $this->get_customer()->get_shipping_country();
+				if ( ! $country ) {
+					return false;
+				}
+				$country_fields  = WC()->countries->get_address_fields( $country, 'shipping_' );
+				$checkout_fields = WC()->checkout()->get_checkout_fields();
+
+				/**
+				 * Filter to not require shipping state for shipping calculation, even if it is required at checkout.
+				 * This can be used to allow shipping calculations to be done without a state.
+				 *
+				 * @since 8.4.0
+				 *
+				 * @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'];
+				// 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 ) {
+					return false;
+				}
+				/**
+				 * Filter to not require shipping postcode for shipping calculation, even if it is required at checkout.
+				 * This can be used to allow shipping calculations to be done without a postcode.
+				 *
+				 * @since 8.4.0
+				 *
+				 * @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'];
+				// 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 ) {
+					return false;
+				}
 			}
 		}
 
diff --git a/plugins/woocommerce/includes/class-wc-customer.php b/plugins/woocommerce/includes/class-wc-customer.php
index e88d9d90f78b3..13ce5afe53447 100644
--- a/plugins/woocommerce/includes/class-wc-customer.php
+++ b/plugins/woocommerce/includes/class-wc-customer.php
@@ -273,55 +273,26 @@ public function has_shipping_address() {
 
 	/**
 	 * Checks whether the address is "full" in the sense that it contains all required fields to calculate shipping rates.
+	 * This method uses the current country's locale to determine if a field is required, or falls back to the default
+	 * locale if there's no country-specific setting for that field.
+	 *
+	 * This method is only used internally by StoreAPI, and not by the classic/shortcode checkout.
 	 *
 	 * @since 9.8.0
-	 * @return bool Whether the customer has a full shipping address (address_1, city, state, postcode, country).
-	 * Only required fields are checked.
+	 * @return bool Whether the customer has a full shipping address (city, state, postcode, country).
+	 * Only required fields are checked based on the country locale.
 	 */
 	public function has_full_shipping_address() {
 		// These are the important fields required to get the shipping rates. Note that while we're respecting the filters
 		// for the shipping calculator below (city, postcode, state), we're not respecting the filter for the country field.
 		// The country field is always required as a bare minimum for shipping.
 		$shipping_address = array(
-			'country' => $this->get_shipping_country(),
+			'country'  => $this->get_shipping_country(),
+			'city'     => $this->get_shipping_city(),
+			'state'    => $this->get_shipping_state(),
+			'postcode' => $this->get_shipping_postcode(),
 		);
 
-		/**
-		 * Filter to not require shipping city for shipping calculation, even if it is required at checkout.
-		 * This can be used to allow shipping calculations to be done without a city.
-		 *
-		 * @since 8.4.0
-		 *
-		 * @param bool $show_city Whether to use the city field. Default true.
-		 */
-		if ( apply_filters( 'woocommerce_shipping_calculator_enable_city', true ) ) {
-			$shipping_address['city'] = $this->get_shipping_city();
-		}
-
-		/**
-		 * Filter to not require shipping state for shipping calculation, even if it is required at checkout.
-		 * This can be used to allow shipping calculations to be done without a state.
-		 *
-		 * @since 8.4.0
-		 *
-		 * @param bool $show_state Whether to use the state field. Default true.
-		 */
-		if ( apply_filters( 'woocommerce_shipping_calculator_enable_state', true ) ) {
-			$shipping_address['state'] = $this->get_shipping_state();
-		}
-
-		/**
-		 * Filter to not require shipping postcode for shipping calculation, even if it is required at checkout.
-		 * This can be used to allow shipping calculations to be done without a postcode.
-		 *
-		 * @since 8.4.0
-		 *
-		 * @param bool $show_postcode Whether to use the postcode field. Default true.
-		 */
-		if ( apply_filters( 'woocommerce_shipping_calculator_enable_postcode', true ) ) {
-			$shipping_address['postcode'] = $this->get_shipping_postcode();
-		}
-
 		$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'];
diff --git a/plugins/woocommerce/src/Blocks/Shipping/ShippingController.php b/plugins/woocommerce/src/Blocks/Shipping/ShippingController.php
index a9a00baf86070..23f09446b968e 100644
--- a/plugins/woocommerce/src/Blocks/Shipping/ShippingController.php
+++ b/plugins/woocommerce/src/Blocks/Shipping/ShippingController.php
@@ -445,6 +445,10 @@ function ( $rate ) {
 	 * @return array
 	 */
 	public function remove_shipping_if_no_address( $packages ) {
+		if ( 'shortcode' === WC()->cart->cart_context ) {
+			return $packages;
+		}
+
 		$shipping_cost_requires_address = wc_string_to_bool( get_option( 'woocommerce_shipping_cost_requires_address', 'no' ) );
 
 		// Return early here for a small performance gain if we don't need to hide shipping costs until an address is entered.
diff --git a/plugins/woocommerce/src/StoreApi/Utilities/CartController.php b/plugins/woocommerce/src/StoreApi/Utilities/CartController.php
index 8534cf80b76df..9936c342136f8 100644
--- a/plugins/woocommerce/src/StoreApi/Utilities/CartController.php
+++ b/plugins/woocommerce/src/StoreApi/Utilities/CartController.php
@@ -36,7 +36,8 @@ public function load_cart() {
 		wc_load_cart();
 
 		// Load cart from session.
-		$cart = $this->get_cart_instance();
+		$cart               = $this->get_cart_instance();
+		$cart->cart_context = 'store-api';
 		$cart->get_cart();
 	}
 
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 9cb140969ceec..ee87a58c720e5 100644
--- a/plugins/woocommerce/tests/php/includes/class-wc-cart-test.php
+++ b/plugins/woocommerce/tests/php/includes/class-wc-cart-test.php
@@ -232,7 +232,97 @@ public function test_show_shipping() {
 		WC()->cart->get_customer()->set_shipping_postcode( '12345' );
 		$this->assertTrue( WC()->cart->show_shipping() );
 
+		// Remove postcode while it is still required, validate shipping is hidden again.
+		WC()->cart->get_customer()->set_shipping_postcode( '' );
+		$this->assertFalse( WC()->cart->show_shipping() );
+
+		/**
+		 * Make shipping fields postcode optional.
+		 * @param array $fields Shipping fields.
+		 *
+		 * @return array
+		 */
+		function make_shipping_fields_postcode_optional( $fields ) {
+			$fields['shipping_postcode']['required'] = 0;
+			return $fields;
+		}
+		add_filter(
+			'woocommerce_shipping_fields',
+			'make_shipping_fields_postcode_optional'
+		);
+		$this->assertTrue( WC()->cart->show_shipping() );
+		// Check shipping still shows when postcode is optional and set.
+		WC()->cart->get_customer()->set_shipping_postcode( '12345' );
+		$this->assertTrue( WC()->cart->show_shipping() );
+
+		remove_all_filters( 'woocommerce_shipping_fields' );
+		$this->assertTrue( WC()->cart->show_shipping() );
+		WC()->cart->get_customer()->set_shipping_postcode( '' );
+		$this->assertFalse( WC()->cart->show_shipping() );
+
+		/**
+		 * Make locale postcode optional.
+		 * @param array $locales Locales.
+		 *
+		 * @return array
+		 */
+		function make_locale_postcode_optional( $locales ) {
+			foreach ( $locales as $country => $locale ) {
+				$locales[ $country ]['postcode']['required'] = false;
+				$locales[ $country ]['postcode']['hidden']   = true;
+			}
+			return $locales;
+		}
+		add_filter( 'woocommerce_get_country_locale', 'make_locale_postcode_optional' );
+
+		// Reset locales so they are regenerated with the new postcode optional.
+		WC()->countries->locale = null;
+		$this->assertTrue( WC()->cart->show_shipping() );
+		// Check shipping still shows when postcode is optional and set.
+		WC()->cart->get_customer()->set_shipping_postcode( '12345' );
+		$this->assertTrue( WC()->cart->show_shipping() );
+
+		// Check that both fields and locale filter work when both are in use together.
+		add_filter(
+			'woocommerce_shipping_fields',
+			'make_shipping_fields_postcode_optional'
+		);
+		WC()->cart->get_customer()->set_shipping_postcode( '' );
+		$this->assertTrue( WC()->cart->show_shipping() );
+
+		// Check shipping still shows when postcode is optional and set.
+		WC()->cart->get_customer()->set_shipping_postcode( '12345' );
+		$this->assertTrue( WC()->cart->show_shipping() );
+
 		// Reset.
+		remove_all_filters( 'woocommerce_shipping_fields' );
+		remove_all_filters( 'woocommerce_get_country_locale' );
+
+		/**
+		 * Remove unwanted fields from checkout page.
+		 *
+		 * @param array $fields of checkout fields.
+		 *
+		 * @return mixed
+		 */
+		function remove_unwanted_fields_from_checkout_page( $fields ) {
+			unset( $fields['shipping']['shipping_company'] );
+			unset( $fields['shipping']['shipping_city'] );
+			unset( $fields['shipping']['shipping_postcode'] );
+			unset( $fields['shipping']['shipping_address_2'] );
+			return $fields;
+		}
+		add_filter( 'woocommerce_checkout_fields', 'remove_unwanted_fields_from_checkout_page' );
+
+		WC()->cart->get_customer()->set_shipping_postcode( '' );
+		WC()->cart->get_customer()->set_shipping_city( '' );
+		$this->assertTrue( WC()->cart->show_shipping() );
+		WC()->cart->get_customer()->set_shipping_postcode( '12345' );
+		WC()->cart->get_customer()->set_shipping_city( 'San Francisco' );
+		$this->assertTrue( WC()->cart->show_shipping() );
+
+		remove_filter( 'woocommerce_checkout_fields', 'remove_unwanted_fields_from_checkout_page' );
+
 		update_option( 'woocommerce_shipping_cost_requires_address', $default_shipping_cost_requires_address );
 		$product->delete( true );
 		WC()->cart->get_customer()->set_shipping_country( 'GB' );
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 40bb25180610e..dd7e9d1054ab4 100644
--- a/plugins/woocommerce/tests/php/includes/class-wc-checkout-test.php
+++ b/plugins/woocommerce/tests/php/includes/class-wc-checkout-test.php
@@ -222,6 +222,7 @@ function () {
 			$expect_we_dont_ship_error ? 'Unfortunately <strong>we do not ship to JP</strong>. Please enter an alternative shipping address.' : '',
 			$errors->get_error_message( 'shipping' )
 		);
+		remove_all_filters( 'woocommerce_countries_allowed_countries' );
 	}
 
 	/**


Reviews:
user: login: opr
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-05T13:35:10Z
user: login: opr
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-05T13:42:18Z
user: login: opr
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-05T13:45:20Z
user: login: opr
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-05T14:14:14Z
user: login: opr
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-06T13:28:34Z
user: login: opr
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-06T14:29:25Z
user: login: mikejolley
body: - Was the pnpm lock change intentional? You can use `pnpm install --frozen-lockfile` to leave it alone when building from the release branch.
- This PR needs syncing with the release branch

**Developer only testing/happy path**
- Step 3 requires local pickup to be disabled otherwise you can't toggle the setting.
- Tested the snippets and some variations of settings including forcing to ship to billing address
- Edge case 1 no longer needed (order goes through)
- Tested all shipping methods disabled and confirmed "No shipping method has been selected. Please double check your address, or contact us if you need any help." with and without edge case snippets in place.
- As above with methods enabled in everywhere zone, but none in active zone
- Tested successful checkout via Blocks with snippets in place

I think this does its job and ensures the `has_full_shipping_address` logic is confined to blocks, while restoring shortcode to how it was previously. Its sad we need to include both paths separately, and that its so easy for someone to break the experience through hooks so it might be good to log a future task to deprecate and/or consolidate some of the logic but for now I'm happy for this to proceed. 

Added non-blocking feedback so approving.
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-06T16:16:51Z
user: login: tjcafferkey
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-09T11:21:44Z
user: login: tjcafferkey
body: I've followed the testing steps and it does work as expected. I'm aware this is a fragile area, and my experience in it is limited so it's difficult to be in the mindset of "_how might this break_". That said, looks like @mikejolley has approved and gone beyond the testing instructions which is good, @senadir has also tested it and if you're also confident in this change then I am happy.
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-09T11:34:44Z
user: login: opr
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-09T11:55:11Z
user: login: senadir
body: I have not retested this beyond our initial review but the changes has been minimal. Thank you for working on this Thomas 
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-09T13:30:52Z

Review comments:
diff_hunk: @@ -25,6 +25,13 @@
  */
 class WC_Cart extends WC_Legacy_Cart {
 
+	/**
+	 * Cart context, used to determine if the cart is being used in a StoreAPI or shortcode context.
+	 *
+	 * @var string shortcode|store-api
+	 */
+	public $cart_context = 'shortcode';
path: plugins/woocommerce/includes/class-wc-cart.php
user: login: opr
body: ```suggestion
	public $_cart_context = 'shortcode';
```
created_at: 2025-05-05T13:35:10Z
author_association: CONTRIBUTOR
diff_hunk: @@ -835,6 +835,20 @@ public function get_posted_data() {
 	 * @param  WP_Error $errors Validation error.
 	 */
 	protected function validate_posted_data( &$data, &$errors ) {
+
path: plugins/woocommerce/includes/class-wc-checkout.php
user: login: opr
body: Move to validate_checkout
created_at: 2025-05-05T13:42:17Z
author_association: CONTRIBUTOR
diff_hunk: @@ -835,6 +835,20 @@ public function get_posted_data() {
 	 * @param  WP_Error $errors Validation error.
 	 */
 	protected function validate_posted_data( &$data, &$errors ) {
+
+		// Verify shipping was posted if needed.
+		if ( WC()->cart->needs_shipping() && WC()->cart->show_shipping() ) {
+			if ( empty( $data['shipping_method'] ) ) {
path: plugins/woocommerce/includes/class-wc-checkout.php
user: login: opr
body: Merge ifs, remove show_shipping
created_at: 2025-05-05T13:45:20Z
author_association: CONTRIBUTOR
diff_hunk: @@ -232,8 +232,99 @@ public function test_show_shipping() {
 		WC()->cart->get_customer()->set_shipping_postcode( '12345' );
 		$this->assertTrue( WC()->cart->show_shipping() );
 
+		// Remove postcode while it is still required, validate shipping is hidden again.
+		WC()->cart->get_customer()->set_shipping_postcode( '' );
+		$this->assertFalse( WC()->cart->show_shipping() );
+
+		/**
+		 * Make shipping fields postcode optional.
+		 * @param array $fields Shipping fields.
+		 *
+		 * @return array
+		 */
+		function make_shipping_fields_postcode_optional( $fields ) {
+			$fields['shipping_postcode']['required'] = 0;
+			return $fields;
+		}
+		add_filter(
+			'woocommerce_shipping_fields',
+			'make_shipping_fields_postcode_optional'
+		);
+		$this->assertTrue( WC()->cart->show_shipping() );
+		// Check shipping still shows when postcode is optional and set.
+		WC()->cart->get_customer()->set_shipping_postcode( '12345' );
+		$this->assertTrue( WC()->cart->show_shipping() );
+
+		remove_all_filters( 'woocommerce_shipping_fields' );
+		$this->assertTrue( WC()->cart->show_shipping() );
+		WC()->cart->get_customer()->set_shipping_postcode( '' );
+		$this->assertFalse( WC()->cart->show_shipping() );
+
+		/**
+		 * Make locale postcode optional.
+		 * @param array $locales Locales.
+		 *
+		 * @return array
+		 */
+		function make_locale_postcode_optional( $locales ) {
+			foreach ( $locales as $country => $locale ) {
+				$locales[ $country ]['postcode']['required'] = false;
+				$locales[ $country ]['postcode']['hidden']   = true;
+			}
+			return $locales;
+		}
+		add_filter( 'woocommerce_get_country_locale', 'make_locale_postcode_optional' );
+
+		// Reset locales so they are regenerated with the new postcode optional.
+		WC()->countries->locale = null;
+		$this->assertTrue( WC()->cart->show_shipping() );
+		// Check shipping still shows when postcode is optional and set.
+		WC()->cart->get_customer()->set_shipping_postcode( '12345' );
+		$this->assertTrue( WC()->cart->show_shipping() );
+
+		// Check that both fields and locale filter work when both are in use together.
+		add_filter(
+			'woocommerce_shipping_fields',
+			'make_shipping_fields_postcode_optional'
+		);
+		WC()->cart->get_customer()->set_shipping_postcode( '' );
+		$this->assertTrue( WC()->cart->show_shipping() );
+
+		// Check shipping still shows when postcode is optional and set.
+		WC()->cart->get_customer()->set_shipping_postcode( '12345' );
+		$this->assertTrue( WC()->cart->show_shipping() );
+
 		// Reset.
+		remove_all_filters( 'woocommerce_shipping_fields' );
+		remove_all_filters( 'woocommerce_get_country_locale' );
+
+		/**
+		 * Remove unwanted fields from checkout page.
+		 *
+		 * @param array $fields of checkout fields.
+		 *
+		 * @return mixed
+		 */
+		function remove_unwanted_fields_from_checkout_page( $fields ) {
+			unset( $fields['shipping']['shipping_company'] );
+			unset( $fields['shipping']['shipping_city'] );
+			unset( $fields['shipping']['shipping_postcode'] );
+			unset( $fields['shipping']['shipping_address_2'] );
+			return $fields;
+		}
+		add_filter( 'woocommerce_checkout_fields', 'remove_unwanted_fields_from_checkout_page' );
+
+		WC()->cart->get_customer()->set_shipping_postcode( '' );
+		WC()->cart->get_customer()->set_shipping_city( '' );
+		$this->assertTrue( WC()->cart->show_shipping() );
+		WC()->cart->get_customer()->set_shipping_postcode( '12345' );
+		WC()->cart->get_customer()->set_shipping_city( 'San Francisco' );
+		$this->assertTrue( WC()->cart->show_shipping() );
+
+		remove_filter( 'woocommerce_checkout_fields', 'remove_unwanted_fields_from_checkout_page' );
+
 		update_option( 'woocommerce_shipping_cost_requires_address', $default_shipping_cost_requires_address );
+		add_filter( 'woocommerce_get_country_locale', 'make_locale_postcode_optional' );
path: plugins/woocommerce/tests/php/includes/class-wc-cart-test.php
user: login: opr
body: Remove
created_at: 2025-05-05T14:14:14Z
author_association: CONTRIBUTOR
diff_hunk: @@ -70,6 +70,68 @@ function () {
 		);
 	}
 
+	/**
+	 * @testdox 'validate_posted_data' adds errors for non-posted shipping method.
path: plugins/woocommerce/tests/php/includes/class-wc-checkout-test.php
user: login: opr
body: update
created_at: 2025-05-06T13:28:34Z
author_association: CONTRIBUTOR
diff_hunk: @@ -25,6 +25,13 @@
  */
 class WC_Cart extends WC_Legacy_Cart {
 
+	/**
+	 * Cart context, used to determine if the cart is being used in a StoreAPI or shortcode context.
+	 *
+	 * @var string shortcode|store-api
+	 */
+	public $cart_context = 'shortcode';
path: plugins/woocommerce/includes/class-wc-cart.php
user: login: opr
body: prefixing with _ is deprecated
created_at: 2025-05-06T14:29:25Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Prevent shipping rates remaining hidden when checkout fields filtered to be optional are not filled
path: plugins/woocommerce/changelog/fix-shortcode-shipping-release-branch
user: login: mikejolley
body: ```suggestion
Prevent shipping rates remaining hidden when checkout fields filtered to be optional are not filled on classic checkout.
```
created_at: 2025-05-06T15:37:26Z
author_association: MEMBER
diff_hunk: @@ -1621,10 +1629,50 @@ public function show_shipping() {
 		}
 
 		if ( 'yes' === get_option( 'woocommerce_shipping_cost_requires_address' ) ) {
-			$customer = $this->get_customer();
+			if ( 'store-api' === $this->cart_context ) {
+				$customer = $this->get_customer();
 
-			if ( ! $customer instanceof \WC_Customer || ! $customer->has_full_shipping_address() ) {
-				return false;
+				if ( ! $customer instanceof \WC_Customer || ! $customer->has_full_shipping_address() ) {
+					return false;
+				}
+			} else {
+				$country = $this->get_customer()->get_shipping_country();
+				if ( ! $country ) {
+					return false;
+				}
+				$country_fields  = WC()->countries->get_address_fields( $country, 'shipping_' );
+				$checkout_fields = WC()->checkout()->get_checkout_fields();
+
+				/**
+				 * Filter to not require shipping state for shipping calculation, even if it is required at checkout.
+				 * This can be used to allow shipping calculations to be done without a state.
+				 *
+				 * @since 8.4.0
+				 *
+				 * @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'];
+				// 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 ) {
+					return false;
+				}
+				/**
+				 * Filter to not require shipping postcode for shipping calculation, even if it is required at checkout.
+				 * This can be used to allow shipping calculations to be done without a postcode.
+				 *
+				 * @since 8.4.0
+				 *
+				 * @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'];
+				// 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 ) {
+					return false;
+				}
path: plugins/woocommerce/includes/class-wc-cart.php
user: login: mikejolley
body: 👍🏻 
created_at: 2025-05-06T15:38:59Z
author_association: MEMBER
diff_hunk: @@ -273,55 +273,26 @@ public function has_shipping_address() {
 
 	/**
 	 * Checks whether the address is "full" in the sense that it contains all required fields to calculate shipping rates.
+	 * This method uses the current country's locale to determine if a field is required, or falls back to the default
+	 * locale if there's no country-specific setting for that field.
+	 *
+	 * This method is only used internally by StoreAPI, and not by the classic/shortcode checkout.
 	 *
 	 * @since 9.8.0
-	 * @return bool Whether the customer has a full shipping address (address_1, city, state, postcode, country).
-	 * Only required fields are checked.
+	 * @return bool Whether the customer has a full shipping address (city, state, postcode, country).
+	 * Only required (according to locale) fields are checked.
path: plugins/woocommerce/includes/class-wc-customer.php
user: login: mikejolley
body: ```suggestion
	 * Only required fields are checked based on the country locale.
```
created_at: 2025-05-06T15:39:52Z
author_association: MEMBER
diff_hunk: @@ -1621,10 +1629,50 @@ public function show_shipping() {
 		}
 
 		if ( 'yes' === get_option( 'woocommerce_shipping_cost_requires_address' ) ) {
-			$customer = $this->get_customer();
+			if ( 'store-api' === $this->cart_context ) {
+				$customer = $this->get_customer();
 
-			if ( ! $customer instanceof \WC_Customer || ! $customer->has_full_shipping_address() ) {
-				return false;
+				if ( ! $customer instanceof \WC_Customer || ! $customer->has_full_shipping_address() ) {
+					return false;
+				}
+			} else {
+				$country = $this->get_customer()->get_shipping_country();
+				if ( ! $country ) {
+					return false;
+				}
+				$country_fields  = WC()->countries->get_address_fields( $country, 'shipping_' );
+				$checkout_fields = WC()->checkout()->get_checkout_fields();
+
+				/**
+				 * Filter to not require shipping state for shipping calculation, even if it is required at checkout.
+				 * This can be used to allow shipping calculations to be done without a state.
+				 *
+				 * @since 8.4.0
+				 *
+				 * @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'];
+				// 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 ) {
path: plugins/woocommerce/includes/class-wc-cart.php
user: login: tjcafferkey
body: Just noting here that we check:

`! $this->get_customer()->get_shipping_state()`

But down where we check `get_shipping_postcode()` we do a different check:

`'' === $this->get_customer()->get_shipping_postcode()`

Not saying that there's anything wrong with it but I noticed it because it feels inconsistent.
created_at: 2025-05-09T11:21:44Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1621,10 +1629,50 @@ public function show_shipping() {
 		}
 
 		if ( 'yes' === get_option( 'woocommerce_shipping_cost_requires_address' ) ) {
-			$customer = $this->get_customer();
+			if ( 'store-api' === $this->cart_context ) {
+				$customer = $this->get_customer();
 
-			if ( ! $customer instanceof \WC_Customer || ! $customer->has_full_shipping_address() ) {
-				return false;
+				if ( ! $customer instanceof \WC_Customer || ! $customer->has_full_shipping_address() ) {
+					return false;
+				}
+			} else {
+				$country = $this->get_customer()->get_shipping_country();
+				if ( ! $country ) {
+					return false;
+				}
+				$country_fields  = WC()->countries->get_address_fields( $country, 'shipping_' );
+				$checkout_fields = WC()->checkout()->get_checkout_fields();
+
+				/**
+				 * Filter to not require shipping state for shipping calculation, even if it is required at checkout.
+				 * This can be used to allow shipping calculations to be done without a state.
+				 *
+				 * @since 8.4.0
+				 *
+				 * @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'];
+				// 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 ) {
path: plugins/woocommerce/includes/class-wc-cart.php
user: login: opr
body: That is because a postcode of `0` would pass. PR introducing this: https://github.com/woocommerce/woocommerce/pull/51623
created_at: 2025-05-09T11:55:11Z
author_association: CONTRIBUTOR

PR comments:
user: login: github-actions[bot]
created_at: 2025-05-02T11:32:11Z
body: ## Testing Guidelines
Hi @senadir @tjcafferkey ,

Apart from reviewing the code changes, please make sure to **review the testing instructions** ([Guide](https://github.com/woocommerce/woocommerce/wiki/Writing-high-quality-testing-instructions)) and verify that relevant tests (E2E, Unit, Integration, etc.) have been added or updated as needed.

**Reminder:** PR reviewers are required to document testing performed. This includes:
- 🖼️ **Screenshots or screen recordings**.
- 📝 **List of functionality tested / steps followed**.
- 🌐 **Site details** (environment attributes such as hosting type, plugins, theme, store size, store age, and relevant settings).
- 🔍 **Any analysis performed**, such as assessing potential impacts on environment attributes and other plugins, conducting performance profiling, or using LLM/AI-based analysis.

⚠️ Within the testing details you provide, please ensure that no sensitive information (such as API keys, passwords, user data, etc.) is included in this public issue.
author_association: CONTRIBUTOR
user: login: github-actions[bot]
created_at: 2025-05-02T11:38:44Z
body: 
## Test using WordPress Playground
The changes in this pull request can be previewed and tested using a [WordPress Playground](https://developer.wordpress.org/playground/) instance.
[WordPress Playground](https://developer.wordpress.org/playground/) is an experimental project that creates a full WordPress instance entirely within the browser.

[Test this pull request with WordPress Playground](https://playground.wordpress.net/#{"landingPage":"/wp-admin/admin.php?page=wc-admin","preferredVersions":{"php":"8.0","wp":"latest"},"phpExtensionBundles":["kitchen-sink"],"features":{"networking":true},"steps":[{"step":"installPlugin","pluginZipFile":{"resource":"url","url":"https://playground.wordpress.net/plugin-proxy.php?org=woocommerce&repo=woocommerce&workflow=Build%20Live%20Branch&artifact=plugins-14925690382&pr=57674"},"options":{"activate":true}},{"step":"installPlugin","pluginZipFile":{"resource":"url","url":"https://github-proxy.com/https://github.com/woocommerce/woocommerce/releases/download/wc-beta-tester-2.3.1/woocommerce-beta-tester.zip"},"options":{"activate":true}},{"step":"login","username":"admin","password":"password"}],"plugins":[]}).

Note that this URL is valid for 30 days from when this comment was last updated. You can update it by closing/reopening the PR or pushing a new commit.

author_association: CONTRIBUTOR
user: login: opr
created_at: 2025-05-06T16:42:38Z
body: > Was the pnpm lock change intentional? You can use pnpm install --frozen-lockfile to leave it alone when building from the release branch.

No, removed, thanks for catching.

> This PR needs syncing with the release branch

Thanks, done!

> Step 3 requires local pickup to be disabled otherwise you can't toggle the setting.

Updated

> Edge case 1 no longer needed (order goes through)

Moved to "other notes"

> it might be good to log a future task to deprecate and/or consolidate some of the logic but for now I'm happy for this to proceed.

I'll add this as a follow up.

author_association: CONTRIBUTOR
user: login: jorgeatorres
created_at: 2025-05-09T09:13:51Z
body: @opr: CI should be passing now if you rebase. If this doesn't need additional reviews could you please merge (after you rebase)? Or if this is indeed waiting for another approval, can we try to get it by EOD today? Thanks!
author_association: MEMBER
user: login: opr
created_at: 2025-05-09T09:18:40Z
body: @jorgeatorres thanks, rebased. I have asked Tom to review.
author_association: CONTRIBUTOR