Commit 9966df35335 for woocommerce
commit 9966df353350a44b141f6ad053f9a69d2558cd5f
Author: Michal Iwanow <4765119+mcliwanow@users.noreply.github.com>
Date: Wed Nov 4 13:52:08 2026 +0100
Use a chosen shipping method only when its rate was offered (#70629)
Checkout, the cart AJAX handlers and the Store API now keep a chosen shipping method only when WooCommerce calculated its rate for the cart package. The shipping rate cache key includes the customer tax address.
Co-authored-by: Ayush Pahwa <8526215+ayushpahwa@users.noreply.github.com>
diff --git a/plugins/woocommerce/changelog/fix-chosen-shipping-method-offered b/plugins/woocommerce/changelog/fix-chosen-shipping-method-offered
new file mode 100644
index 00000000000..9a689a9d83e
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-chosen-shipping-method-offered
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Use a chosen shipping method only when its rate was calculated for the cart.
diff --git a/plugins/woocommerce/includes/class-wc-ajax.php b/plugins/woocommerce/includes/class-wc-ajax.php
index 3d25d949022..4d796913170 100644
--- a/plugins/woocommerce/includes/class-wc-ajax.php
+++ b/plugins/woocommerce/includes/class-wc-ajax.php
@@ -23,6 +23,7 @@ use Automattic\WooCommerce\Proxies\LegacyProxy;
use Automattic\WooCommerce\Utilities\ArrayUtil;
use Automattic\WooCommerce\Utilities\NumberUtil;
use Automattic\WooCommerce\Utilities\OrderUtil;
+use Automattic\WooCommerce\Utilities\ShippingUtil;
use Automattic\WooCommerce\Utilities\StringUtil;
defined( 'ABSPATH' ) || exit;
@@ -345,7 +346,7 @@ class WC_AJAX {
if ( is_array( $posted_shipping_methods ) ) {
foreach ( $posted_shipping_methods as $i => $value ) {
- if ( ! is_string( $value ) ) {
+ if ( ! is_string( $value ) || ! ShippingUtil::is_shipping_rate_listed( $i, $value ) ) {
continue;
}
$chosen_shipping_methods[ $i ] = $value;
@@ -428,7 +429,7 @@ class WC_AJAX {
if ( is_array( $posted_shipping_methods ) ) {
foreach ( $posted_shipping_methods as $i => $value ) {
- if ( ! is_string( $value ) ) {
+ if ( ! is_string( $value ) || ! ShippingUtil::is_shipping_rate_listed( $i, $value ) ) {
continue;
}
$chosen_shipping_methods[ $i ] = $value;
diff --git a/plugins/woocommerce/includes/class-wc-checkout.php b/plugins/woocommerce/includes/class-wc-checkout.php
index 261a38dcc6b..29bb2f15fd2 100644
--- a/plugins/woocommerce/includes/class-wc-checkout.php
+++ b/plugins/woocommerce/includes/class-wc-checkout.php
@@ -16,6 +16,7 @@ use Automattic\WooCommerce\Internal\Checkout\PaymentRecovery;
use Automattic\WooCommerce\Internal\CostOfGoodsSold\CogsAwareTrait;
use Automattic\WooCommerce\Internal\ProductVariations\SelectedVariationName;
use Automattic\WooCommerce\Internal\Tax\TaxRateDataStore;
+use Automattic\WooCommerce\Utilities\ShippingUtil;
use Automattic\WooCommerce\StoreApi\Utilities\CartController;
defined( 'ABSPATH' ) || exit;
@@ -1414,12 +1415,15 @@ class WC_Checkout {
array_walk( $address_fields, array( $this, 'set_customer_address_fields' ), $data );
WC()->customer->save();
+ // Calculate the rates for the posted address, so the posted choices are checked against them.
+ WC()->cart->calculate_shipping();
+
// Update customer shipping and payment method to posted method.
$chosen_shipping_methods = WC()->session->get( 'chosen_shipping_methods' );
if ( is_array( $data['shipping_method'] ) ) {
foreach ( $data['shipping_method'] as $i => $value ) {
- if ( ! is_string( $value ) ) {
+ if ( ! is_string( $value ) || ! ShippingUtil::is_shipping_rate_listed( $i, $value ) ) {
continue;
}
$chosen_shipping_methods[ $i ] = $value;
@@ -1645,6 +1649,11 @@ class WC_Checkout {
throw new Exception( $customer_id->get_error_message() ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
}
+ // Save the posted choices, because the login loads the session again from the database.
+ if ( is_callable( array( WC()->session, 'save_data' ) ) ) {
+ WC()->session->save_data();
+ }
+
wc_set_customer_auth_cookie( $customer_id );
// As we are now logged in, checkout will need to refresh to show logged in data.
diff --git a/plugins/woocommerce/includes/class-wc-shipping.php b/plugins/woocommerce/includes/class-wc-shipping.php
index 38659a62754..b1764788d87 100644
--- a/plugins/woocommerce/includes/class-wc-shipping.php
+++ b/plugins/woocommerce/includes/class-wc-shipping.php
@@ -417,6 +417,11 @@ class WC_Shipping {
* @return string
*/
private function get_package_hash( array $package_to_hash ): string {
+ // The rate taxes depend on the tax address, so a new tax address must not reuse saved rates.
+ if ( wc_tax_enabled() && WC()->customer instanceof WC_Customer ) {
+ $package_to_hash['tax_location'] = WC()->customer->get_taxable_address();
+ }
+
/**
* Filters package fields that should not affect the shipping-rate cache hash.
*
diff --git a/plugins/woocommerce/includes/wc-cart-functions.php b/plugins/woocommerce/includes/wc-cart-functions.php
index b13e195e84c..2c5f71bc6e9 100644
--- a/plugins/woocommerce/includes/wc-cart-functions.php
+++ b/plugins/woocommerce/includes/wc-cart-functions.php
@@ -14,6 +14,7 @@ use Automattic\WooCommerce\Enums\OrderStatus;
use Automattic\WooCommerce\Enums\ProductType;
use Automattic\WooCommerce\Internal\Checkout\PaymentRecovery;
use Automattic\WooCommerce\StoreApi\Utilities\LocalPickupUtils;
+use Automattic\WooCommerce\Utilities\ShippingUtil;
defined( 'ABSPATH' ) || exit;
@@ -435,6 +436,8 @@ function wc_cart_round_discount( $value, $precision ) {
/**
* Gets chosen shipping method IDs from chosen_shipping_methods session, without instance IDs.
*
+ * Only choices whose rate was listed for the cart are returned, see ShippingUtil::filter_chosen_shipping_methods().
+ *
* @since 2.6.2
* @return string[]
*/
@@ -443,7 +446,7 @@ function wc_get_chosen_shipping_method_ids() {
return array();
}
- $chosen_methods = WC()->session->get( 'chosen_shipping_methods', array() );
+ $chosen_methods = ShippingUtil::filter_chosen_shipping_methods( WC()->session->get( 'chosen_shipping_methods', array() ) );
$method_ids = array();
foreach ( $chosen_methods as $chosen_method ) {
diff --git a/plugins/woocommerce/src/Blocks/Shipping/ShippingController.php b/plugins/woocommerce/src/Blocks/Shipping/ShippingController.php
index cf0edf5ca46..5ef6d915e83 100644
--- a/plugins/woocommerce/src/Blocks/Shipping/ShippingController.php
+++ b/plugins/woocommerce/src/Blocks/Shipping/ShippingController.php
@@ -8,6 +8,7 @@ use Automattic\WooCommerce\Enums\ProductTaxStatus;
use Automattic\WooCommerce\Enums\TaxDisplayMode;
use Automattic\WooCommerce\StoreApi\Utilities\LocalPickupUtils;
use Automattic\WooCommerce\Utilities\ArrayUtil;
+use Automattic\WooCommerce\Utilities\ShippingUtil;
use WC_Customer;
use WC_Shipping_Rate;
@@ -446,7 +447,12 @@ class ShippingController {
return $address;
}
// We only need to select from the first package, since pickup_location only supports a single package.
- $chosen_method = current( WC()->session->get( 'chosen_shipping_methods', array() ) ) ?? '';
+ // The first entry is used only when its rate was listed for the cart.
+ $chosen_methods = WC()->session->get( 'chosen_shipping_methods', array() );
+ $first_key = is_array( $chosen_methods ) && ! empty( $chosen_methods ) ? array_key_first( $chosen_methods ) : null;
+ $listed_methods = null !== $first_key ? ShippingUtil::filter_chosen_shipping_methods( $chosen_methods ) : array();
+ $chosen_method = null !== $first_key && isset( $listed_methods[ $first_key ] ) ? (string) $listed_methods[ $first_key ] : '';
+
$chosen_method_id = explode( ':', $chosen_method )[0];
$chosen_method_instance = explode( ':', $chosen_method )[1] ?? 0;
diff --git a/plugins/woocommerce/src/StoreApi/Utilities/CartController.php b/plugins/woocommerce/src/StoreApi/Utilities/CartController.php
index 51523691ded..742a382fdf2 100644
--- a/plugins/woocommerce/src/StoreApi/Utilities/CartController.php
+++ b/plugins/woocommerce/src/StoreApi/Utilities/CartController.php
@@ -14,6 +14,7 @@ use Automattic\WooCommerce\StoreApi\Utilities\ArrayUtils;
use Automattic\WooCommerce\StoreApi\Utilities\DraftOrderTrait;
use Automattic\WooCommerce\StoreApi\Utilities\NoticeHandler;
use Automattic\WooCommerce\StoreApi\Utilities\QuantityLimits;
+use Automattic\WooCommerce\Utilities\ShippingUtil;
use WP_Error;
/**
@@ -936,11 +937,13 @@ class CartController {
/**
* Selects a shipping rate.
*
+ * The rate must be listed for the package. Other choices are ignored.
+ *
* @param int|string $package_id ID of the package to choose a rate for.
* @param string $rate_id ID of the rate being chosen.
*/
public function select_shipping_rate( $package_id, $rate_id ) {
- if ( ! is_string( $rate_id ) ) {
+ if ( ! is_string( $rate_id ) || ! ShippingUtil::is_shipping_rate_listed( $package_id, $rate_id ) ) {
return;
}
$cart = $this->get_cart_instance();
diff --git a/plugins/woocommerce/src/Utilities/ShippingUtil.php b/plugins/woocommerce/src/Utilities/ShippingUtil.php
index 2bd89656c7d..9a8685ad09d 100644
--- a/plugins/woocommerce/src/Utilities/ShippingUtil.php
+++ b/plugins/woocommerce/src/Utilities/ShippingUtil.php
@@ -11,6 +11,13 @@ namespace Automattic\WooCommerce\Utilities;
*/
class ShippingUtil {
+ /**
+ * The package keys of the cart from the last build, with the cart state they belong to.
+ *
+ * @var array{state: string, keys: array<int|string, true>|null}|null
+ */
+ private static $cart_package_keys = null;
+
/**
* Get the selected shipping rates from the packages.
*
@@ -31,4 +38,125 @@ class ShippingUtil {
)
);
}
+
+ /**
+ * Check that a rate was listed for a package key the last time shipping was calculated.
+ *
+ * A rate id in the `chosen_shipping_methods` session value is used only when the `previous_shipping_methods`
+ * session value lists it for the same key.
+ *
+ * @since 11.2.2
+ *
+ * @param int|string $package_key Package key, as used in `chosen_shipping_methods`.
+ * @param mixed $rate_id Rate id to check. Only strings can match.
+ * @return bool
+ */
+ public static function is_shipping_rate_listed( $package_key, $rate_id ): bool {
+ if ( ! is_string( $rate_id ) || ( ! is_int( $package_key ) && ! is_string( $package_key ) ) || ! is_callable( array( WC()->session, 'get' ) ) ) {
+ return false;
+ }
+
+ $listed_rates = WC()->session->get( 'previous_shipping_methods' );
+ $rate_ids = is_array( $listed_rates ) ? ( $listed_rates[ $package_key ] ?? null ) : null;
+
+ // Compare as strings: array_keys() turns a numeric rate id into an int.
+ return is_array( $rate_ids ) && in_array( $rate_id, array_map( 'strval', array_filter( $rate_ids, 'is_scalar' ) ), true );
+ }
+
+ /**
+ * Keep only the chosen methods whose rates were listed for the current cart packages.
+ *
+ * Each rate must be listed for its key. Key must be a shipping package of
+ * the cart at the time of request. The session can keep keys from earlier requests and keys
+ * that other code adds for carts other than this one.
+ *
+ * @since 11.2.2
+ *
+ * @param mixed $chosen_methods Chosen methods keyed by package key, usually the `chosen_shipping_methods` session value.
+ * @return array The listed entries with their keys kept. Empty when the input is not an array.
+ */
+ public static function filter_chosen_shipping_methods( $chosen_methods ): array {
+ if ( ! is_array( $chosen_methods ) || ! is_callable( array( WC()->session, 'get' ) ) ) {
+ return array();
+ }
+
+ $listed_rates = WC()->session->get( 'previous_shipping_methods' );
+ $listed_rates = is_array( $listed_rates ) ? $listed_rates : array();
+ $chosen_methods = array_filter(
+ $chosen_methods,
+ function ( $rate_id, $package_key ) use ( $listed_rates ) {
+ $rate_ids = $listed_rates[ $package_key ] ?? null;
+
+ // Compare as strings: array_keys() turns a numeric rate id into an int.
+ return is_string( $rate_id ) && is_array( $rate_ids ) && in_array( $rate_id, array_map( 'strval', array_filter( $rate_ids, 'is_scalar' ) ), true );
+ },
+ ARRAY_FILTER_USE_BOTH
+ );
+
+ // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment -- Documented in WC_Abstract_Order::get_tax_location().
+ $pickup_method_ids = apply_filters( 'woocommerce_local_pickup_methods', array( 'legacy_local_pickup', 'local_pickup' ) );
+ $pickup_method_ids = is_array( $pickup_method_ids ) ? $pickup_method_ids : array();
+ $package_keys = null;
+
+ foreach ( $chosen_methods as $package_key => $rate_id ) {
+ if ( ! in_array( explode( ':', $rate_id )[0], $pickup_method_ids, true ) ) {
+ continue;
+ }
+
+ // This runs on every tax rate lookup, so build the keys again only when the cart state changes.
+ // The totals counters change for each Subscriptions recurring cart, which uses other keys.
+ if ( null === $package_keys ) {
+ $cart = WC()->cart;
+ if ( ! $cart instanceof \WC_Cart ) {
+ $package_keys = array();
+ } else {
+ $customer = WC()->customer;
+ $state = md5(
+ (string) wp_json_encode(
+ array(
+ spl_object_id( $cart ),
+ wp_list_pluck( $cart->get_cart_contents(), 'quantity' ),
+ $customer instanceof \WC_Customer ? array(
+ $customer->get_shipping_country(),
+ $customer->get_shipping_state(),
+ $customer->get_shipping_postcode(),
+ $customer->get_shipping_city(),
+ $customer->get_shipping_address_1(),
+ $customer->get_shipping_address_2(),
+ ) : array(),
+ did_action( 'woocommerce_before_calculate_totals' ),
+ did_action( 'woocommerce_after_calculate_totals' ),
+ )
+ )
+ );
+
+ if ( null === self::$cart_package_keys || self::$cart_package_keys['state'] !== $state ) {
+ // Save the state first: a nested call from the package filter sees null keys, keeps the pickup and does not build the packages again.
+ self::$cart_package_keys = array(
+ 'state' => $state,
+ 'keys' => null,
+ );
+ try {
+ self::$cart_package_keys['keys'] = array_fill_keys( array_keys( $cart->get_shipping_packages() ), true );
+ } finally {
+ // A build that did not complete leaves the keys for the current state null.
+ if ( null === ( self::$cart_package_keys['keys'] ?? null ) ) {
+ self::$cart_package_keys = null;
+ }
+ }
+ }
+ $package_keys = self::$cart_package_keys['keys'] ?? null;
+ if ( null === $package_keys ) {
+ break;
+ }
+ }
+ }
+
+ if ( ! isset( $package_keys[ $package_key ] ) ) {
+ unset( $chosen_methods[ $package_key ] );
+ }
+ }
+
+ return $chosen_methods;
+ }
}
diff --git a/plugins/woocommerce/tests/legacy/unit-tests/customer/customer.php b/plugins/woocommerce/tests/legacy/unit-tests/customer/customer.php
index de73d7ccdaa..3b6cf0a7166 100644
--- a/plugins/woocommerce/tests/legacy/unit-tests/customer/customer.php
+++ b/plugins/woocommerce/tests/legacy/unit-tests/customer/customer.php
@@ -23,6 +23,7 @@ class WC_Tests_Customer extends WC_Unit_Test_Case {
// Create dummy product, and add the product to the cart.
$product = WC_Helper_Product::create_simple_product();
WC()->cart->add_to_cart( $product->get_id(), 1 );
+ WC()->session->set( 'previous_shipping_methods', array( array( 'local_pickup', 'free_shipping' ) ) );
// Customer is going with the Local Pickup option, and the store calculates tax based on the store location.
WC_Helper_Customer::set_chosen_shipping_methods( array( 'local_pickup' ) );
@@ -69,6 +70,7 @@ class WC_Tests_Customer extends WC_Unit_Test_Case {
$product = WC_Helper_Product::create_simple_product();
WC()->cart->add_to_cart( $product->get_id(), 1 );
+ WC()->session->set( 'previous_shipping_methods', array( array( 'local_pickup', 'free_shipping' ) ) );
// Customer is going with the Local Pickup option, and the store calculates tax based on the store location.
WC_Helper_Customer::set_chosen_shipping_methods( array( 'local_pickup' ) );
diff --git a/plugins/woocommerce/tests/php/includes/admin/class-wc-admin-reports-customers-controller-test.php b/plugins/woocommerce/tests/php/includes/admin/class-wc-admin-reports-customers-controller-test.php
index 6eb559d0448..e6f8ee961cf 100644
--- a/plugins/woocommerce/tests/php/includes/admin/class-wc-admin-reports-customers-controller-test.php
+++ b/plugins/woocommerce/tests/php/includes/admin/class-wc-admin-reports-customers-controller-test.php
@@ -182,6 +182,7 @@ class WC_Admin_Reports_Customers_Controller_Test extends WC_Unit_Test_Case {
public static function wpTearDownAfterClass() {
remove_action( 'action_scheduler_stored_action', array( self::class, 'track_import_action' ) );
WC_Helper_Reports::reset_stats_dbs();
+ WC_Helper_Shipping::delete_simple_flat_rate();
$action_store = ActionScheduler_Store::instance();
foreach ( self::$import_action_ids as $action_id ) {
$action_store->delete_action( $action_id );
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 8c4b266a18a..9e5f51a0c9b 100644
--- a/plugins/woocommerce/tests/php/includes/class-wc-checkout-test.php
+++ b/plugins/woocommerce/tests/php/includes/class-wc-checkout-test.php
@@ -910,6 +910,264 @@ class WC_Checkout_Test extends \WC_Unit_Test_Case {
$this->assertSame( 8.0, (float) WC()->cart->get_shipping_total(), 'The order must be charged the shipping rate.' );
}
+ /**
+ * @testdox 'update_session' keeps only posted shipping rates that are listed for their package key.
+ */
+ public function test_update_session_ignores_posted_shipping_rates_that_are_not_listed(): void {
+ WC_Helper_Shipping::create_simple_flat_rate( 8 );
+
+ $product = WC_Helper_Product::create_simple_product();
+ WC()->cart->add_to_cart( $product->get_id(), 1 );
+
+ $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',
+ 'billing_postcode' => '10001',
+ 'billing_city' => 'New York',
+ 'billing_address_1' => '1 Main St',
+ 'shipping_method' => array(
+ 0 => 'local_pickup',
+ 'extra' => 'local_pickup',
+ ),
+ 'payment_method' => 'cod',
+ );
+
+ try {
+ $this->sut->update_session( $this->sut->get_posted_data() );
+ } finally {
+ $_POST = $original_post;
+ WC_Helper_Shipping::delete_simple_flat_rate();
+ }
+
+ $chosen_methods = WC()->session->get( 'chosen_shipping_methods' );
+
+ $this->assertArrayNotHasKey( 'extra', $chosen_methods, 'A posted choice for a key with no listed rates must not be saved.' );
+ $this->assertSame( 'legacy_flat_rate', $chosen_methods[0], 'A posted rate that is not listed must not replace the listed rate.' );
+ }
+
+ /**
+ * @testdox 'update_session' taxes the posted shipping rate at the posted address when a pickup rate was chosen before and the item taxes do not change with the address.
+ */
+ public function test_update_session_taxes_posted_shipping_rate_at_posted_address(): void {
+ update_option( 'woocommerce_calc_taxes', 'yes' );
+ update_option( 'woocommerce_tax_based_on', 'shipping' );
+ update_option( 'woocommerce_default_country', 'US:CA' );
+ update_option( 'woocommerce_shipping_tax_class', '' );
+
+ foreach ( array( array( 'US', 'CA', '10.0000', 'CA tax' ), array( 'GB', '', '20.0000', 'GB tax' ) ) as list( $country, $state, $rate, $name ) ) {
+ WC_Tax::_insert_tax_rate(
+ array(
+ 'tax_rate_country' => $country,
+ 'tax_rate_state' => $state,
+ 'tax_rate' => $rate,
+ 'tax_rate_name' => $name,
+ 'tax_rate_priority' => '1',
+ 'tax_rate_compound' => '0',
+ 'tax_rate_shipping' => '1',
+ 'tax_rate_order' => '1',
+ 'tax_rate_class' => '',
+ )
+ );
+ }
+
+ $base_zone = new WC_Shipping_Zone();
+ $base_zone->set_zone_name( 'Store base' );
+ $base_zone->save();
+ $base_zone->add_location( 'US:CA', 'state' );
+ $base_zone->save();
+ $pickup_id = $base_zone->add_shipping_method( 'local_pickup' );
+
+ $delivery_zone = new WC_Shipping_Zone();
+ $delivery_zone->set_zone_name( 'Delivery' );
+ $delivery_zone->save();
+ $delivery_zone->add_location( 'GB', 'country' );
+ $delivery_zone->save();
+ $flat_rate_id = $delivery_zone->add_shipping_method( 'flat_rate' );
+ update_option(
+ "woocommerce_flat_rate_{$flat_rate_id}_settings",
+ array(
+ 'title' => 'Flat rate',
+ 'tax_status' => 'taxable',
+ 'cost' => '5',
+ )
+ );
+ WC_Cache_Helper::get_transient_version( 'shipping', true );
+
+ $product = WC_Helper_Product::create_simple_product( true, array( 'tax_status' => 'none' ) );
+ WC()->cart->add_to_cart( $product->get_id(), 1 );
+ WC()->customer->set_is_vat_exempt( false );
+ WC()->customer->set_billing_location( 'US', 'CA', '90001', 'Los Angeles' );
+ WC()->customer->set_shipping_location( 'US', 'CA', '90001', 'Los Angeles' );
+ WC()->cart->calculate_totals();
+ WC()->session->set( 'chosen_shipping_methods', array( "local_pickup:{$pickup_id}" ) );
+ WC()->cart->calculate_totals();
+
+ $this->assertSame( array( "local_pickup:{$pickup_id}" ), WC()->session->get( 'chosen_shipping_methods' ), 'The pickup rate must be the chosen rate at the store address.' );
+
+ $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' => 'GB',
+ 'billing_postcode' => 'SW1A 1AA',
+ 'billing_city' => 'London',
+ 'billing_address_1' => '1 Main St',
+ 'shipping_method' => array( "flat_rate:{$flat_rate_id}" ),
+ 'payment_method' => 'cod',
+ );
+
+ try {
+ $this->sut->update_session( $this->sut->get_posted_data() );
+ } finally {
+ $_POST = $original_post;
+ }
+
+ $this->assertSame( array( "flat_rate:{$flat_rate_id}" ), WC()->session->get( 'chosen_shipping_methods' ), 'The posted rate must be the chosen rate.' );
+ $this->assertSame( 5.0, (float) WC()->cart->get_shipping_total(), 'The cart must be charged the posted rate.' );
+ $this->assertSame( 1.0, (float) WC()->cart->get_shipping_tax(), 'The shipping tax must be the tax of the posted address.' );
+ }
+
+ /**
+ * @testdox 'process_customer' keeps the posted shipping rate, taxed at the posted address, when the checkout creates an account.
+ *
+ * @testWith ["GB", "", "SW1A 1AA", "London", 5.0, 1.0]
+ * ["US", "CA", "90001", "Los Angeles", 7.0, 0.7]
+ *
+ * @param string $country Posted billing country.
+ * @param string $state Posted billing state.
+ * @param string $postcode Posted billing postcode.
+ * @param string $city Posted billing city.
+ * @param float $expected_shipping Cost of the posted flat rate.
+ * @param float $expected_shipping_tax Tax of the posted flat rate at the posted address.
+ */
+ public function test_process_customer_keeps_posted_shipping_rate_when_creating_an_account( string $country, string $state, string $postcode, string $city, float $expected_shipping, float $expected_shipping_tax ): void {
+ update_option( 'woocommerce_calc_taxes', 'yes' );
+ update_option( 'woocommerce_tax_based_on', 'shipping' );
+ update_option( 'woocommerce_default_country', 'US:CA' );
+ update_option( 'woocommerce_shipping_tax_class', '' );
+ update_option( 'woocommerce_enable_signup_and_login_from_checkout', 'yes' );
+
+ foreach ( array( array( 'US', 'CA', '10.0000', 'CA tax' ), array( 'GB', '', '20.0000', 'GB tax' ) ) as list( $rate_country, $rate_state, $rate, $name ) ) {
+ WC_Tax::_insert_tax_rate(
+ array(
+ 'tax_rate_country' => $rate_country,
+ 'tax_rate_state' => $rate_state,
+ 'tax_rate' => $rate,
+ 'tax_rate_name' => $name,
+ 'tax_rate_priority' => '1',
+ 'tax_rate_compound' => '0',
+ 'tax_rate_shipping' => '1',
+ 'tax_rate_order' => '1',
+ 'tax_rate_class' => '',
+ )
+ );
+ }
+
+ $base_zone = new WC_Shipping_Zone();
+ $base_zone->set_zone_name( 'Store base' );
+ $base_zone->save();
+ $base_zone->add_location( 'US:CA', 'state' );
+ $base_zone->save();
+ $pickup_id = $base_zone->add_shipping_method( 'local_pickup' );
+
+ $delivery_zone = new WC_Shipping_Zone();
+ $delivery_zone->set_zone_name( 'Delivery' );
+ $delivery_zone->save();
+ $delivery_zone->add_location( 'GB', 'country' );
+ $delivery_zone->save();
+
+ $flat_rate_ids = array(
+ 'US' => $base_zone->add_shipping_method( 'flat_rate' ),
+ 'GB' => $delivery_zone->add_shipping_method( 'flat_rate' ),
+ );
+ $flat_rate_costs = array(
+ 'US' => '7',
+ 'GB' => '5',
+ );
+ foreach ( $flat_rate_costs as $zone_country => $cost ) {
+ update_option(
+ "woocommerce_flat_rate_{$flat_rate_ids[ $zone_country ]}_settings",
+ array(
+ 'title' => 'Flat rate',
+ 'tax_status' => 'taxable',
+ 'cost' => $cost,
+ )
+ );
+ }
+ WC_Cache_Helper::get_transient_version( 'shipping', true );
+
+ // Keep the session cookie in $_COOKIE, where the login reads it, instead of sending a header.
+ $cookie_name = '';
+ $store_cookie = function ( $enabled, $name, $value ) use ( &$cookie_name ) {
+ $cookie_name = $name;
+ $_COOKIE[ $cookie_name ] = $value;
+ return false;
+ };
+
+ $posted_rate = "flat_rate:{$flat_rate_ids[ $country ]}";
+ $original_session = WC()->session;
+ $original_post = $_POST; // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Restored in the finally block.
+
+ try {
+ wp_set_current_user( 0 );
+ add_filter( 'woocommerce_set_cookie_enabled', $store_cookie, 10, 3 );
+ WC()->session = new WC_Session_Handler();
+ WC()->session->init_session_cookie();
+ WC()->session->set_customer_session_cookie( true );
+
+ $product = WC_Helper_Product::create_simple_product( true, array( 'tax_status' => 'none' ) );
+ WC()->cart->add_to_cart( $product->get_id(), 1 );
+ WC()->customer->set_is_vat_exempt( false );
+ WC()->customer->set_billing_location( 'US', 'CA', '90001', 'Los Angeles' );
+ WC()->customer->set_shipping_location( 'US', 'CA', '90001', 'Los Angeles' );
+ WC()->cart->calculate_totals();
+ WC()->session->set( 'chosen_shipping_methods', array( "local_pickup:{$pickup_id}" ) );
+ WC()->cart->calculate_totals();
+ WC()->session->save_data();
+
+ $this->assertSame( array( "local_pickup:{$pickup_id}" ), WC()->session->get( 'chosen_shipping_methods' ), 'The pickup rate must be the chosen rate at the store address.' );
+
+ $_POST = array(
+ 'billing_first_name' => 'Test',
+ 'billing_last_name' => 'Shopper',
+ 'billing_phone' => '5555555555',
+ 'billing_email' => 'new-shopper@example.com',
+ 'billing_country' => $country,
+ 'billing_state' => $state,
+ 'billing_postcode' => $postcode,
+ 'billing_city' => $city,
+ 'billing_address_1' => '1 Main St',
+ 'shipping_method' => array( $posted_rate ),
+ 'payment_method' => 'cod',
+ 'createaccount' => '1',
+ );
+
+ $data = $this->sut->get_posted_data();
+ $this->sut->update_session( $data );
+ $this->sut->process_customer( $data );
+
+ $this->assertTrue( is_user_logged_in(), 'The checkout must create the account and log the customer in.' );
+ $this->assertSame( array( $posted_rate ), WC()->session->get( 'chosen_shipping_methods' ), 'The posted rate must still be the chosen rate after the login.' );
+ $this->assertSame( $expected_shipping, (float) WC()->cart->get_shipping_total(), 'The cart must be charged the posted rate.' );
+ $this->assertSame( $expected_shipping_tax, (float) WC()->cart->get_shipping_tax(), 'The shipping tax must be the tax of the posted address.' );
+ } finally {
+ remove_filter( 'woocommerce_set_cookie_enabled', $store_cookie, 10 );
+ if ( '' !== $cookie_name ) {
+ unset( $_COOKIE[ $cookie_name ] );
+ }
+ wp_set_current_user( 0 );
+ WC()->session = $original_session;
+ $_POST = $original_post;
+ }
+ }
+
/**
* @testdox 'validate_checkout' still accepts an order when a plugin deliberately turns off shipping calculation.
*/
diff --git a/plugins/woocommerce/tests/php/includes/class-wc-customer-test.php b/plugins/woocommerce/tests/php/includes/class-wc-customer-test.php
index 1c26cbd8eea..8b3545e83fe 100644
--- a/plugins/woocommerce/tests/php/includes/class-wc-customer-test.php
+++ b/plugins/woocommerce/tests/php/includes/class-wc-customer-test.php
@@ -155,6 +155,31 @@ class WC_Customer_Test extends \WC_Unit_Test_Case {
$this->assertFalse( $nested, 'The outermost nested check should still treat a missing postcode as incomplete.' );
}
+ /**
+ * @testdox get_taxable_address uses the store address only for a pickup rate listed for a cart package.
+ *
+ * @testWith [ { "0": "local_pickup:2" }, { "0": [ "local_pickup:2", "flat_rate:1" ] }, "US" ]
+ * [ { "0": "local_pickup:2" }, { "0": [ "flat_rate:1" ] }, "GB" ]
+ * [ { "0": "flat_rate:1", "other": "local_pickup:2" }, { "0": [ "flat_rate:1" ], "other": [ "local_pickup:2" ] }, "GB" ]
+ *
+ * @param array $chosen_methods The `chosen_shipping_methods` session value.
+ * @param array $listed_rates The `previous_shipping_methods` session value.
+ * @param string $expected_country The expected taxable country.
+ */
+ public function test_get_taxable_address_checks_chosen_pickup( array $chosen_methods, array $listed_rates, string $expected_country ): void {
+ update_option( 'woocommerce_default_country', 'US:CA' );
+ update_option( 'woocommerce_tax_based_on', 'shipping' );
+ WC()->session->set( 'chosen_shipping_methods', $chosen_methods );
+ WC()->session->set( 'previous_shipping_methods', $listed_rates );
+
+ $customer = new WC_Customer();
+ $customer->set_shipping_country( 'GB' );
+ $customer->set_shipping_postcode( 'SW1A 2AA' );
+ $customer->set_shipping_city( 'London' );
+
+ $this->assertSame( $expected_country, $customer->get_taxable_address()[0] );
+ }
+
/**
* Get a customer whose US shipping address has everything but a postcode.
*
diff --git a/plugins/woocommerce/tests/php/includes/class-wc-shipping-test.php b/plugins/woocommerce/tests/php/includes/class-wc-shipping-test.php
index 0ef2b133c72..d96f3a39c6b 100644
--- a/plugins/woocommerce/tests/php/includes/class-wc-shipping-test.php
+++ b/plugins/woocommerce/tests/php/includes/class-wc-shipping-test.php
@@ -12,6 +12,11 @@ class WC_Shipping_Test extends WC_Unit_Test_Case {
*/
private $sut;
+ /**
+ * @var array The customer shipping address before the test.
+ */
+ private $previous_shipping_address = array();
+
/**
* Set up test
*
@@ -22,12 +27,19 @@ class WC_Shipping_Test extends WC_Unit_Test_Case {
$this->sut = new WC_Shipping();
update_option( 'woocommerce_shipping_debug_mode', 'yes' );
+
+ // Tests set the shipping address on WC()->customer, which outlives a test.
+ $this->previous_shipping_address = WC()->customer->get_shipping( 'edit' );
}
/**
* Restore options.
*/
public function tearDown(): void {
+ foreach ( $this->previous_shipping_address as $key => $value ) {
+ WC()->customer->{"set_shipping_{$key}"}( $value );
+ }
+
parent::tearDown();
update_option( 'woocommerce_shipping_debug_mode', 'no' );
@@ -317,6 +329,68 @@ class WC_Shipping_Test extends WC_Unit_Test_Case {
remove_filter( 'woocommerce_package_rates', $filter, 10 );
}
+ /**
+ * @testdox a new customer tax address invalidates cached shipping rates for the same package only while taxes are enabled
+ *
+ * @testWith ["yes", 2]
+ * ["no", 1]
+ *
+ * @param string $calc_taxes Value of the woocommerce_calc_taxes option.
+ * @param int $expected_calculations Number of rate calculations for the two tax addresses.
+ */
+ public function test_calculate_shipping_for_package_invalidates_cache_for_new_tax_address( string $calc_taxes, int $expected_calculations ) {
+ update_option( 'woocommerce_shipping_debug_mode', 'no' );
+ update_option( 'woocommerce_calc_taxes', $calc_taxes );
+ WC()->session->__unset( 'shipping_for_package_0' );
+
+ $filter_calls = 0;
+ $filter = $this->get_package_rates_counter( $filter_calls );
+ $package = $this->get_package_hash_test_package();
+
+ add_filter( 'woocommerce_package_rates', $filter, 10 );
+
+ $this->set_customer_tax_location( 'US', 'CA' );
+ $this->sut->calculate_shipping_for_package( $package );
+
+ $this->set_customer_tax_location( 'GB', '' );
+ $this->sut->calculate_shipping_for_package( $package );
+
+ $this->assertSame( $expected_calculations, $filter_calls );
+
+ remove_filter( 'woocommerce_package_rates', $filter, 10 );
+ }
+
+ /**
+ * @testdox extensions can ignore the customer tax address for the shipping-rate cache hash
+ */
+ public function test_calculate_shipping_for_package_allows_extensions_to_ignore_tax_address() {
+ update_option( 'woocommerce_shipping_debug_mode', 'no' );
+ update_option( 'woocommerce_calc_taxes', 'yes' );
+ WC()->session->__unset( 'shipping_for_package_0' );
+
+ $filter_calls = 0;
+ $filter = $this->get_package_rates_counter( $filter_calls );
+ $ignored_fields_filter = function ( array $ignored_fields ): array {
+ $ignored_fields[] = 'tax_location';
+ return $ignored_fields;
+ };
+ $package = $this->get_package_hash_test_package();
+
+ add_filter( 'woocommerce_package_rates', $filter, 10 );
+ add_filter( 'woocommerce_shipping_package_hash_ignored_fields', $ignored_fields_filter );
+
+ $this->set_customer_tax_location( 'US', 'CA' );
+ $this->sut->calculate_shipping_for_package( $package );
+
+ $this->set_customer_tax_location( 'GB', '' );
+ $this->sut->calculate_shipping_for_package( $package );
+
+ $this->assertSame( 1, $filter_calls );
+
+ remove_filter( 'woocommerce_shipping_package_hash_ignored_fields', $ignored_fields_filter );
+ remove_filter( 'woocommerce_package_rates', $filter, 10 );
+ }
+
/**
* Data provider for test_package_rates_filter_error_handling.
*
@@ -400,6 +474,17 @@ class WC_Shipping_Test extends WC_Unit_Test_Case {
);
}
+ /**
+ * Give the customer a shipping address that taxes apply to.
+ *
+ * @param string $country Country code.
+ * @param string $state State code.
+ */
+ private function set_customer_tax_location( string $country, string $state ): void {
+ update_option( 'woocommerce_tax_based_on', 'shipping' );
+ WC()->customer->set_shipping_location( $country, $state, '00000', 'Test City' );
+ }
+
/**
* Get a package rates filter that counts recalculations.
*
diff --git a/plugins/woocommerce/tests/php/src/Admin/API/Reports/Orders/Stats/DataStoreFilterQueriesTest.php b/plugins/woocommerce/tests/php/src/Admin/API/Reports/Orders/Stats/DataStoreFilterQueriesTest.php
index f864244e06a..0c154337e01 100644
--- a/plugins/woocommerce/tests/php/src/Admin/API/Reports/Orders/Stats/DataStoreFilterQueriesTest.php
+++ b/plugins/woocommerce/tests/php/src/Admin/API/Reports/Orders/Stats/DataStoreFilterQueriesTest.php
@@ -11,6 +11,7 @@ use WC_Helper_Customer;
use WC_Helper_Order;
use WC_Helper_Queue;
use WC_Helper_Reports;
+use WC_Helper_Shipping;
use WC_Order_Item_Product;
use WC_Product_Simple;
@@ -216,6 +217,7 @@ class DataStoreFilterQueriesTest extends OrdersStatsTestCase {
self::$primary_product_ids = array();
WC_Helper_Reports::reset_stats_dbs();
+ WC_Helper_Shipping::delete_simple_flat_rate();
}
/**
diff --git a/plugins/woocommerce/tests/php/src/Admin/API/Reports/Orders/Stats/DataStoreZeroFillTest.php b/plugins/woocommerce/tests/php/src/Admin/API/Reports/Orders/Stats/DataStoreZeroFillTest.php
index 83ba28256cc..91c34d9c793 100644
--- a/plugins/woocommerce/tests/php/src/Admin/API/Reports/Orders/Stats/DataStoreZeroFillTest.php
+++ b/plugins/woocommerce/tests/php/src/Admin/API/Reports/Orders/Stats/DataStoreZeroFillTest.php
@@ -10,6 +10,7 @@ use WC_Helper_Customer;
use WC_Helper_Order;
use WC_Helper_Queue;
use WC_Helper_Reports;
+use WC_Helper_Shipping;
use WC_Product_Simple;
/**
@@ -127,6 +128,7 @@ class DataStoreZeroFillTest extends OrdersStatsTestCase {
}
WC_Helper_Reports::reset_stats_dbs();
+ WC_Helper_Shipping::delete_simple_flat_rate();
}
/**
diff --git a/plugins/woocommerce/tests/php/src/Blocks/Shipping/ShippingControllerTest.php b/plugins/woocommerce/tests/php/src/Blocks/Shipping/ShippingControllerTest.php
index d882fd06d79..80bfea5a4d9 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/Shipping/ShippingControllerTest.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/Shipping/ShippingControllerTest.php
@@ -334,6 +334,7 @@ class ShippingControllerTest extends \WC_Unit_Test_Case {
public function test_filter_taxable_address_returns_chosen_pickup_location_address(): void {
$this->save_pickup_locations();
WC()->session->set( 'chosen_shipping_methods', array( 'pickup_location:1' ) );
+ WC()->session->set( 'previous_shipping_methods', array( array( 'pickup_location:1' ) ) );
$this->assertSame(
array( 'US', 'NY', '10001', 'New York' ),
@@ -361,6 +362,7 @@ class ShippingControllerTest extends \WC_Unit_Test_Case {
)
);
WC()->session->set( 'chosen_shipping_methods', array( 'pickup_location:0' ) );
+ WC()->session->set( 'previous_shipping_methods', array( array( 'pickup_location:0' ) ) );
$this->assertSame(
array( 'FR', '', '75001', 'Paris' ),
diff --git a/plugins/woocommerce/tests/php/src/Utilities/ShippingUtilTest.php b/plugins/woocommerce/tests/php/src/Utilities/ShippingUtilTest.php
new file mode 100644
index 00000000000..f84ed24c232
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Utilities/ShippingUtilTest.php
@@ -0,0 +1,454 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Utilities;
+
+use Automattic\WooCommerce\Utilities\ShippingUtil;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for the ShippingUtil class.
+ */
+class ShippingUtilTest extends WC_Unit_Test_Case {
+
+ /**
+ * Most runs of the package filter before a test stops the code it runs from the filter.
+ */
+ private const PACKAGE_FILTER_RUN_CAP = 5;
+
+ /**
+ * Set up test fixtures.
+ */
+ public function setUp(): void {
+ parent::setUp();
+ $this->reset_cart_package_keys();
+ WC()->shipping()->unregister_shipping_methods();
+ foreach ( array( 'flat_rate', 'free_shipping', 'international_delivery', 'local_delivery', 'local_pickup' ) as $legacy_method ) {
+ delete_option( 'woocommerce_' . $legacy_method . '_settings' );
+ }
+ \WC_Cache_Helper::get_transient_version( 'shipping', true );
+ }
+
+ /**
+ * Tear down test fixtures.
+ */
+ public function tearDown(): void {
+ try {
+ $this->reset_cart_package_keys();
+ } finally {
+ parent::tearDown();
+ }
+ }
+
+ /**
+ * Forget the package keys kept from the last build, so each test starts without them.
+ */
+ private function reset_cart_package_keys(): void {
+ $property = new \ReflectionProperty( ShippingUtil::class, 'cart_package_keys' );
+ $property->setAccessible( true );
+ $property->setValue( null, null );
+ }
+
+ /**
+ * Count the runs of the `woocommerce_cart_shipping_packages` filter after all other callbacks.
+ *
+ * @param int $calls Variable that receives the number of runs.
+ */
+ private function count_package_builds( int &$calls ): void {
+ add_filter(
+ 'woocommerce_cart_shipping_packages',
+ function ( $packages ) use ( &$calls ) {
+ ++$calls;
+ return $packages;
+ },
+ PHP_INT_MAX
+ );
+ }
+
+ /**
+ * Run a callable from the `woocommerce_cart_shipping_packages` filter and count the runs.
+ *
+ * The callable does not run after the cap, so a build that repeats itself ends in a failed
+ * assertion instead of an exhausted stack.
+ *
+ * @param callable $inner Code to run from the filter.
+ * @param int $calls Variable that receives the number of filter runs.
+ */
+ private function run_in_package_filter( callable $inner, int &$calls ): void {
+ add_filter(
+ 'woocommerce_cart_shipping_packages',
+ function ( $packages ) use ( $inner, &$calls ) {
+ ++$calls;
+ if ( $calls < self::PACKAGE_FILTER_RUN_CAP ) {
+ $inner();
+ }
+ return $packages;
+ }
+ );
+ }
+
+ /**
+ * @testdox is_shipping_rate_listed only accepts a rate listed for the same package key.
+ *
+ * @testWith [ 0, "flat_rate:1", true ]
+ * [ 0, "local_pickup:2", false ]
+ * [ 1, "flat_rate:1", false ]
+ * [ 0, "123", true ]
+ * [ 0, 123, false ]
+ *
+ * @param int|string $package_key Package key to check.
+ * @param mixed $rate_id Rate id to check.
+ * @param bool $expected Expected result.
+ */
+ public function test_is_shipping_rate_listed( $package_key, $rate_id, bool $expected ): void {
+ WC()->session->set( 'previous_shipping_methods', array( 0 => array( 'flat_rate:1', 123 ) ) );
+
+ $this->assertSame( $expected, ShippingUtil::is_shipping_rate_listed( $package_key, $rate_id ) );
+ }
+
+ /**
+ * @testdox is_shipping_rate_listed returns false when no rates were listed.
+ */
+ public function test_is_shipping_rate_listed_without_listed_rates(): void {
+ WC()->session->set( 'previous_shipping_methods', null );
+
+ $this->assertFalse( ShippingUtil::is_shipping_rate_listed( 0, 'flat_rate:1' ) );
+ }
+
+ /**
+ * @testdox filter_chosen_shipping_methods drops choices whose rate was not listed for their key.
+ */
+ public function test_filter_chosen_shipping_methods_drops_unlisted_rates(): void {
+ WC()->session->set( 'previous_shipping_methods', array( 0 => array( 'flat_rate:1' ) ) );
+
+ $this->assertSame(
+ array( 0 => 'flat_rate:1' ),
+ ShippingUtil::filter_chosen_shipping_methods(
+ array(
+ 0 => 'flat_rate:1',
+ 'extra' => 'flat_rate:1',
+ )
+ ),
+ 'A key with no listed rates must be dropped.'
+ );
+ $this->assertSame(
+ array(),
+ ShippingUtil::filter_chosen_shipping_methods( array( 0 => 'local_pickup:2' ) ),
+ 'A rate that was not listed for the key must be dropped.'
+ );
+ }
+
+ /**
+ * @testdox filter_chosen_shipping_methods keeps a listed pickup rate only for a shipping package of the cart.
+ */
+ public function test_filter_chosen_shipping_methods_checks_package_key_for_pickup(): void {
+ WC()->session->set(
+ 'previous_shipping_methods',
+ array(
+ 0 => array( 'local_pickup:2' ),
+ 'other' => array( 'local_pickup:2', 'flat_rate:1' ),
+ )
+ );
+
+ $this->assertSame(
+ array( 0 => 'local_pickup:2' ),
+ ShippingUtil::filter_chosen_shipping_methods(
+ array(
+ 0 => 'local_pickup:2',
+ 'other' => 'local_pickup:2',
+ )
+ ),
+ 'A pickup rate for a key that is not a cart package must be dropped.'
+ );
+ $this->assertSame(
+ array( 'other' => 'flat_rate:1' ),
+ ShippingUtil::filter_chosen_shipping_methods( array( 'other' => 'flat_rate:1' ) ),
+ 'A listed rate that is not pickup does not need a cart package.'
+ );
+ }
+
+ /**
+ * @testdox filter_chosen_shipping_methods builds the cart packages one time for the same cart state.
+ */
+ public function test_filter_chosen_shipping_methods_builds_packages_once_for_same_cart_state(): void {
+ WC()->session->set( 'previous_shipping_methods', array( 0 => array( 'local_pickup:2' ) ) );
+ $chosen = array(
+ 0 => 'local_pickup:2',
+ 'other' => 'local_pickup:2',
+ );
+ $calls = 0;
+ $this->count_package_builds( $calls );
+
+ for ( $i = 0; $i < 3; $i++ ) {
+ $this->assertSame( array( 0 => 'local_pickup:2' ), ShippingUtil::filter_chosen_shipping_methods( $chosen ) );
+ }
+
+ $this->assertSame( 1, $calls, 'The packages must be built one time for three calls with the same cart state.' );
+ }
+
+ /**
+ * @testdox filter_chosen_shipping_methods builds the cart packages one time when a package filter reads the taxable address.
+ */
+ public function test_filter_chosen_shipping_methods_builds_packages_once_when_package_filter_reads_taxable_address(): void {
+ update_option( 'woocommerce_default_country', 'US:CA' );
+ update_option( 'woocommerce_tax_based_on', 'shipping' );
+ WC()->customer->set_shipping_country( 'GB' );
+ WC()->session->set( 'previous_shipping_methods', array( 0 => array( 'local_pickup:2' ) ) );
+ WC()->session->set( 'chosen_shipping_methods', array( 0 => 'local_pickup:2' ) );
+ $calls = 0;
+ $address = null;
+ $this->run_in_package_filter(
+ function () use ( &$address ) {
+ $address = WC()->customer->get_taxable_address();
+ },
+ $calls
+ );
+
+ $result = ShippingUtil::filter_chosen_shipping_methods( array( 0 => 'local_pickup:2' ) );
+
+ $this->assertSame( 1, $calls, 'The packages must be built one time when a package filter reads the taxable address.' );
+ $this->assertSame( array( 0 => 'local_pickup:2' ), $result, 'The listed pickup rate must be kept.' );
+ $this->assertSame( 'US', $address[0], 'While the packages are built, the chosen pickup must still give the store address.' );
+ }
+
+ /**
+ * @testdox filter_chosen_shipping_methods builds the cart packages one time when a package filter reads a taxed price.
+ */
+ public function test_filter_chosen_shipping_methods_builds_packages_once_when_package_filter_reads_taxed_price(): void {
+ update_option( 'woocommerce_calc_taxes', 'yes' );
+ update_option( 'woocommerce_default_country', 'US:CA' );
+ update_option( 'woocommerce_tax_based_on', 'shipping' );
+ WC()->customer->set_shipping_country( 'GB' );
+ \WC_Tax::_insert_tax_rate(
+ array(
+ 'tax_rate_country' => 'US',
+ 'tax_rate' => '10.0000',
+ 'tax_rate_name' => 'US',
+ )
+ );
+ \WC_Tax::_insert_tax_rate(
+ array(
+ 'tax_rate_country' => 'GB',
+ 'tax_rate' => '20.0000',
+ 'tax_rate_name' => 'GB',
+ )
+ );
+ $product = \WC_Helper_Product::create_simple_product( true, array( 'regular_price' => 10 ) );
+ WC()->cart->add_to_cart( $product->get_id() );
+ WC()->session->set( 'previous_shipping_methods', array( 0 => array( 'local_pickup:2' ) ) );
+ WC()->session->set( 'chosen_shipping_methods', array( 0 => 'local_pickup:2' ) );
+ $calls = 0;
+ $price = null;
+ $this->run_in_package_filter(
+ function () use ( $product, &$price ) {
+ $price = wc_get_price_including_tax( $product );
+ },
+ $calls
+ );
+
+ $result = ShippingUtil::filter_chosen_shipping_methods( array( 0 => 'local_pickup:2' ) );
+
+ $this->assertSame( 1, $calls, 'The packages must be built one time when a package filter reads a taxed price.' );
+ $this->assertSame( array( 0 => 'local_pickup:2' ), $result, 'The listed pickup rate must be kept.' );
+ $this->assertSame( 11.0, $price, 'While the packages are built, the chosen pickup must still give the store tax rate.' );
+ }
+
+ /**
+ * @testdox filter_chosen_shipping_methods checks the package key again once the cart packages are built.
+ */
+ public function test_filter_chosen_shipping_methods_checks_package_key_once_packages_are_built(): void {
+ WC()->session->set(
+ 'previous_shipping_methods',
+ array(
+ 0 => array( 'local_pickup:2' ),
+ 'other' => array( 'local_pickup:2' ),
+ )
+ );
+ $chosen = array(
+ 0 => 'local_pickup:2',
+ 'other' => 'local_pickup:2',
+ );
+ $calls = 0;
+ $nested = null;
+ $this->run_in_package_filter(
+ function () use ( $chosen, &$nested ) {
+ $nested = ShippingUtil::filter_chosen_shipping_methods( $chosen );
+ },
+ $calls
+ );
+
+ $result = ShippingUtil::filter_chosen_shipping_methods( $chosen );
+
+ $this->assertSame( 1, $calls );
+ $this->assertSame( $chosen, $nested, 'While the packages are built, every listed pickup must be kept.' );
+ $this->assertSame( array( 0 => 'local_pickup:2' ), $result, 'The outer call must drop the pickup for a key that is not a cart package.' );
+ $this->assertSame( array( 0 => 'local_pickup:2' ), ShippingUtil::filter_chosen_shipping_methods( $chosen ), 'A later call must drop the pickup for a key that is not a cart package.' );
+ }
+
+ /**
+ * @testdox filter_chosen_shipping_methods forgets the cart state when the package build throws.
+ */
+ public function test_filter_chosen_shipping_methods_forgets_cart_state_when_package_build_throws(): void {
+ WC()->session->set(
+ 'previous_shipping_methods',
+ array(
+ 0 => array( 'local_pickup:2' ),
+ 'other' => array( 'local_pickup:2' ),
+ )
+ );
+ $chosen = array(
+ 0 => 'local_pickup:2',
+ 'other' => 'local_pickup:2',
+ );
+ $fail = true;
+ add_filter(
+ 'woocommerce_cart_shipping_packages',
+ function ( $packages ) use ( &$fail ) {
+ if ( $fail ) {
+ throw new \RuntimeException( 'Package filter failed.' );
+ }
+ return $packages;
+ }
+ );
+
+ $caught = null;
+ try {
+ ShippingUtil::filter_chosen_shipping_methods( $chosen );
+ } catch ( \RuntimeException $e ) {
+ $caught = $e;
+ }
+
+ $this->assertInstanceOf( \RuntimeException::class, $caught, 'The exception from the package filter must reach the caller.' );
+ $property = new \ReflectionProperty( ShippingUtil::class, 'cart_package_keys' );
+ $property->setAccessible( true );
+ $this->assertNull( $property->getValue(), 'A failed build must not leave an entry for the cart state.' );
+
+ $fail = false;
+ $this->assertSame( array( 0 => 'local_pickup:2' ), ShippingUtil::filter_chosen_shipping_methods( $chosen ), 'The next call must build the packages and check the package key.' );
+ }
+
+ /**
+ * @testdox filter_chosen_shipping_methods builds the cart packages again when the cart items change.
+ */
+ public function test_filter_chosen_shipping_methods_rebuilds_packages_when_cart_items_change(): void {
+ WC()->session->set( 'previous_shipping_methods', array( 0 => array( 'local_pickup:2' ) ) );
+ $chosen = array( 0 => 'local_pickup:2' );
+ $calls = 0;
+ $this->count_package_builds( $calls );
+
+ ShippingUtil::filter_chosen_shipping_methods( $chosen );
+ $this->assertSame( 1, $calls );
+
+ $product = \WC_Helper_Product::create_simple_product();
+ $item_key = WC()->cart->add_to_cart( $product->get_id() );
+ $calls = 0;
+ $this->assertSame( $chosen, ShippingUtil::filter_chosen_shipping_methods( $chosen ) );
+ $this->assertSame( 1, $calls, 'The packages must be built again after an item is added.' );
+ ShippingUtil::filter_chosen_shipping_methods( $chosen );
+ $this->assertSame( 1, $calls, 'The packages must not be built again for the same items.' );
+
+ WC()->cart->set_quantity( $item_key, 2, false );
+ $calls = 0;
+ $this->assertSame( $chosen, ShippingUtil::filter_chosen_shipping_methods( $chosen ) );
+ $this->assertSame( 1, $calls, 'The packages must be built again after a quantity changes.' );
+ }
+
+ /**
+ * @testdox filter_chosen_shipping_methods builds the cart packages again when the shipping address changes.
+ */
+ public function test_filter_chosen_shipping_methods_rebuilds_packages_when_shipping_address_changes(): void {
+ // Other test classes leave their postcode on the shared customer, so start from a known one.
+ WC()->customer->set_shipping_postcode( '90001' );
+ WC()->session->set( 'previous_shipping_methods', array( 0 => array( 'local_pickup:2' ) ) );
+ $chosen = array( 0 => 'local_pickup:2' );
+ $calls = 0;
+ $this->count_package_builds( $calls );
+
+ ShippingUtil::filter_chosen_shipping_methods( $chosen );
+ $this->assertSame( 1, $calls );
+
+ WC()->customer->set_shipping_postcode( '90210' );
+ $calls = 0;
+ $this->assertSame( $chosen, ShippingUtil::filter_chosen_shipping_methods( $chosen ) );
+ $this->assertSame( 1, $calls, 'The packages must be built again after the shipping address changes.' );
+ ShippingUtil::filter_chosen_shipping_methods( $chosen );
+ $this->assertSame( 1, $calls, 'The packages must not be built again for the same address.' );
+ }
+
+ /**
+ * @testdox filter_chosen_shipping_methods builds the cart packages again for another cart object.
+ */
+ public function test_filter_chosen_shipping_methods_rebuilds_packages_for_another_cart_object(): void {
+ WC()->session->set( 'previous_shipping_methods', array( 0 => array( 'local_pickup:2' ) ) );
+ $chosen = array( 0 => 'local_pickup:2' );
+ $calls = 0;
+ $this->count_package_builds( $calls );
+
+ ShippingUtil::filter_chosen_shipping_methods( $chosen );
+ $this->assertSame( 1, $calls );
+
+ $original_cart = WC()->cart;
+ try {
+ WC()->cart = clone $original_cart;
+ $calls = 0;
+ $this->assertSame( $chosen, ShippingUtil::filter_chosen_shipping_methods( $chosen ) );
+ $this->assertSame( 1, $calls, 'The packages must be built again for another cart object.' );
+ } finally {
+ WC()->cart = $original_cart;
+ }
+ }
+
+ /**
+ * @testdox filter_chosen_shipping_methods builds the cart packages again for each totals calculation.
+ */
+ public function test_filter_chosen_shipping_methods_rebuilds_packages_for_each_totals_calculation(): void {
+ $recurring_key = '2026_11_30_monthly_0';
+ $use_recurring = false;
+ add_filter(
+ 'woocommerce_cart_shipping_packages',
+ function ( $packages ) use ( &$use_recurring, $recurring_key ) {
+ return $use_recurring ? array( $recurring_key => $packages[0] ) : $packages;
+ }
+ );
+ $listed = array(
+ 0 => array( 'local_pickup:2' ),
+ $recurring_key => array( 'local_pickup:2' ),
+ );
+ $chosen = array(
+ 0 => 'local_pickup:2',
+ $recurring_key => 'local_pickup:2',
+ );
+ WC()->session->set( 'previous_shipping_methods', $listed );
+
+ $this->assertSame(
+ array( 0 => 'local_pickup:2' ),
+ ShippingUtil::filter_chosen_shipping_methods( $chosen ),
+ 'Before the recurring calculation, only the main package key must be kept.'
+ );
+
+ $use_recurring = true;
+ do_action( 'woocommerce_before_calculate_totals', WC()->cart );
+ $this->assertSame(
+ array( $recurring_key => 'local_pickup:2' ),
+ ShippingUtil::filter_chosen_shipping_methods( $chosen ),
+ 'During the recurring calculation, only the recurring package key must be kept.'
+ );
+
+ $use_recurring = false;
+ do_action( 'woocommerce_after_calculate_totals', WC()->cart );
+ // WC_Cart_Session::set_session() runs on this action and clears the listed rates for a cart without shippable items.
+ WC()->session->set( 'previous_shipping_methods', $listed );
+ $this->assertSame(
+ array( 0 => 'local_pickup:2' ),
+ ShippingUtil::filter_chosen_shipping_methods( $chosen ),
+ 'After the recurring calculation, only the main package key must be kept.'
+ );
+ }
+
+ /**
+ * @testdox filter_chosen_shipping_methods returns an empty array for a value that is not an array.
+ */
+ public function test_filter_chosen_shipping_methods_with_invalid_input(): void {
+ $this->assertSame( array(), ShippingUtil::filter_chosen_shipping_methods( 'local_pickup:2' ) );
+ }
+}