Commit c5b9a5823c8 for woocommerce
commit c5b9a5823c8252d98a4b21627300284d9bf8c079
Author: Mike Jolley <mike.jolley@me.com>
Date: Mon Jun 15 10:39:24 2026 +0100
Resolve Store API local pickup tax location from the order (#65603)
* Store API: resolve local pickup tax location from the order, not a per-call filter
`OrderController::update_order_from_cart()` attached an anonymous closure to
`woocommerce_order_get_tax_location` on every call and never removed it, so the
filter chain grew across the request (coupon retries, repeated draft syncs) and
clobbered third-party tax hooks.
Replace it with a single order-scoped callback registered once in
`ShippingController::init()` (mirroring the existing cart-side
`filter_taxable_address`). The pickup location address is captured on the
shipping line item at purchase time, so the tax location is derived from the
order itself rather than the customer session.
Co-Authored-By: Abdalsalaam Halawa <19236737+Abdalsalaam@users.noreply.github.com>
* Improve local pickup order tax location resolution
Addresses Copilot review feedback on the order-side pickup tax filter:
- Detect local pickup using the canonical woocommerce_local_pickup_methods
list (the same one WC_Abstract_Order::get_tax_location() uses) instead of
LocalPickupUtils::get_local_pickup_method_ids(). The latter is built from
currently-registered methods and omits legacy_local_pickup, so existing
orders using legacy or deregistered methods were taxed at the store base
instead of the pickup location. Add a regression test for this case.
- Accept any WC_Abstract_Order (e.g. refunds), not just WC_Order, matching
the filter's documented argument type.
- Compute has_valid_pickup_location() once when building pickup rate meta.
* Hide hidden shipping rate meta in Store API
---------
Co-authored-by: Abdalsalaam Halawa <19236737+Abdalsalaam@users.noreply.github.com>
diff --git a/plugins/woocommerce/changelog/fix-storeapi-order-tax-location-from-order b/plugins/woocommerce/changelog/fix-storeapi-order-tax-location-from-order
new file mode 100644
index 00000000000..084f5f929be
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-storeapi-order-tax-location-from-order
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Resolve the local pickup tax location from the order's own shipping line instead of attaching a per-call closure to woocommerce_order_get_tax_location during cart sync.
diff --git a/plugins/woocommerce/src/Blocks/Shipping/PickupLocation.php b/plugins/woocommerce/src/Blocks/Shipping/PickupLocation.php
index dd6a175ab98..d526ae5f09e 100644
--- a/plugins/woocommerce/src/Blocks/Shipping/PickupLocation.php
+++ b/plugins/woocommerce/src/Blocks/Shipping/PickupLocation.php
@@ -99,6 +99,7 @@ class PickupLocation extends WC_Shipping_Method {
if ( ! $location['enabled'] ) {
continue;
}
+ $has_valid_address = $this->has_valid_pickup_location( $location['address'] );
$this->add_rate(
array(
'id' => $this->id . ':' . $index,
@@ -106,10 +107,13 @@ class PickupLocation extends WC_Shipping_Method {
'label' => wp_kses_post( $this->title . ' (' . $location['name'] . ')' ),
'package' => $package,
'cost' => $this->cost,
+ // `_pickup_location_address` is hidden (underscore-prefixed) structured address data, captured at
+ // purchase time, used to derive the order tax location. See ShippingController::filter_order_tax_location().
'meta_data' => array(
- 'pickup_location' => wp_kses_post( $location['name'] ),
- 'pickup_address' => $this->has_valid_pickup_location( $location['address'] ) ? wc()->countries->get_formatted_address( $location['address'], ', ' ) : '',
- 'pickup_details' => wp_kses_post( $location['details'] ),
+ 'pickup_location' => wp_kses_post( $location['name'] ),
+ 'pickup_address' => $has_valid_address ? wc()->countries->get_formatted_address( $location['address'], ', ' ) : '',
+ 'pickup_details' => wp_kses_post( $location['details'] ),
+ '_pickup_location_address' => $has_valid_address ? $location['address'] : array(),
),
)
);
diff --git a/plugins/woocommerce/src/Blocks/Shipping/ShippingController.php b/plugins/woocommerce/src/Blocks/Shipping/ShippingController.php
index 42f12cd30d8..3c71342bba1 100644
--- a/plugins/woocommerce/src/Blocks/Shipping/ShippingController.php
+++ b/plugins/woocommerce/src/Blocks/Shipping/ShippingController.php
@@ -76,6 +76,7 @@ class ShippingController {
add_filter( 'woocommerce_local_pickup_methods', array( $this, 'register_local_pickup_method' ) );
add_filter( 'woocommerce_order_hide_shipping_address', array( $this, 'hide_shipping_address_for_local_pickup' ), 10 );
add_filter( 'woocommerce_customer_taxable_address', array( $this, 'filter_taxable_address' ) );
+ add_filter( 'woocommerce_order_get_tax_location', array( $this, 'filter_order_tax_location' ), 10, 2 );
add_filter( 'woocommerce_shipping_settings', array( $this, 'remove_shipping_settings' ) );
add_filter( 'woocommerce_shipping_packages', array( $this, 'filter_shipping_packages' ) );
add_filter( 'pre_update_option_woocommerce_pickup_location_settings', array( $this, 'flush_cache' ) );
@@ -464,6 +465,61 @@ class ShippingController {
return $address;
}
+ /**
+ * Filter the tax location for an order so local pickup orders are taxed at the chosen pickup location's address.
+ *
+ * An order's stored shipping/billing address is not the correct tax location for local pickup: the customer
+ * collects from the store, so tax must be based on the pickup location. `WC_Abstract_Order::get_tax_location()`
+ * already forces the shop base address for local pickup orders; this filter refines that to the specific pickup
+ * location's address, which is captured on the shipping line item at purchase time.
+ *
+ * This is the order-side counterpart to {@see self::filter_taxable_address()}, which performs the equivalent
+ * override for the cart via the customer's taxable address. Resolving the address from the order (rather than the
+ * customer session) keeps the result correct for any order tax read, including admin recalculation, background
+ * jobs, and multiple orders handled within a single request.
+ *
+ * @since 11.0.0
+ *
+ * @param array $location Tax location with 'country', 'state', 'postcode' and 'city' keys.
+ * @param \WC_Abstract_Order $order Order the tax location is being resolved for.
+ * @return array
+ */
+ public function filter_order_tax_location( $location, $order ) {
+ if ( ! $order instanceof \WC_Abstract_Order ) {
+ return $location;
+ }
+
+ // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment
+ if ( true !== apply_filters( 'woocommerce_apply_base_tax_for_local_pickup', true ) ) {
+ return $location;
+ }
+
+ // Use the same canonical local pickup method list that WC_Abstract_Order::get_tax_location() uses to force
+ // the base address. Relying on the currently-registered methods (LocalPickupUtils::get_local_pickup_method_ids())
+ // would miss legacy or deregistered methods on existing orders, leaving them taxed at the store base instead.
+ // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment -- Documented in WC_Abstract_Order::get_tax_location().
+ $local_pickup_method_ids = apply_filters( 'woocommerce_local_pickup_methods', array( 'legacy_local_pickup', 'local_pickup' ) );
+
+ foreach ( $order->get_shipping_methods() as $shipping_method ) {
+ if ( ! in_array( $shipping_method->get_method_id(), $local_pickup_method_ids, true ) ) {
+ continue;
+ }
+
+ $pickup_address = $shipping_method->get_meta( '_pickup_location_address' );
+
+ if ( is_array( $pickup_address ) && ! empty( $pickup_address['country'] ) ) {
+ return array(
+ 'country' => $pickup_address['country'],
+ 'state' => $pickup_address['state'] ?? '',
+ 'postcode' => $pickup_address['postcode'] ?? '',
+ 'city' => $pickup_address['city'] ?? '',
+ );
+ }
+ }
+
+ return $location;
+ }
+
/**
* Local Pickup requires all packages to support local pickup. This is because the entire order must be picked up
* so that all packages get the same tax rates applied during checkout.
diff --git a/plugins/woocommerce/src/StoreApi/Schemas/V1/CartShippingRateSchema.php b/plugins/woocommerce/src/StoreApi/Schemas/V1/CartShippingRateSchema.php
index 4cc300707d9..678e9c60a24 100644
--- a/plugins/woocommerce/src/StoreApi/Schemas/V1/CartShippingRateSchema.php
+++ b/plugins/woocommerce/src/StoreApi/Schemas/V1/CartShippingRateSchema.php
@@ -342,6 +342,10 @@ class CartShippingRateSchema extends AbstractSchema {
return array_reduce(
array_keys( $meta_data ),
function( $return, $key ) use ( $meta_data ) {
+ if ( 0 === strpos( (string) $key, '_' ) ) {
+ return $return;
+ }
+
$return[] = [
'key' => $key,
'value' => $meta_data[ $key ],
diff --git a/plugins/woocommerce/src/StoreApi/Utilities/OrderController.php b/plugins/woocommerce/src/StoreApi/Utilities/OrderController.php
index 7d30f515219..349ff4a4204 100644
--- a/plugins/woocommerce/src/StoreApi/Utilities/OrderController.php
+++ b/plugins/woocommerce/src/StoreApi/Utilities/OrderController.php
@@ -69,41 +69,10 @@ class OrderController {
* @param boolean $update_totals Whether to update totals or not.
*/
public function update_order_from_cart( \WC_Order $order, $update_totals = true ) {
- /**
- * This filter ensures that local pickup locations are still used for order taxes by forcing the address used to
- * calculate tax for an order to match the current address of the customer.
- *
- * - The method `$customer->get_taxable_address()` runs the filter `woocommerce_customer_taxable_address`.
- * - While we have a session, our `ShippingController::filter_taxable_address` function uses this hook to set
- * the customer address to the pickup location address if local pickup is the chosen method.
- *
- * Without this code in place, `$customer->get_taxable_address()` is not used when order taxes are calculated,
- * resulting in the wrong taxes being applied with local pickup.
- *
- * The alternative would be to instead use `woocommerce_order_get_tax_location` to return the pickup location
- * address directly, however since we have the customer filter in place we don't need to duplicate effort.
- *
- * @see \WC_Abstract_Order::get_tax_location()
- */
- add_filter(
- 'woocommerce_order_get_tax_location',
- function ( $location ) {
-
- if ( ! is_null( wc()->customer ) ) {
-
- $taxable_address = wc()->customer->get_taxable_address();
-
- $location = array(
- 'country' => $taxable_address[0],
- 'state' => $taxable_address[1],
- 'postcode' => $taxable_address[2],
- 'city' => $taxable_address[3],
- );
- }
-
- return $location;
- }
- );
+ // Tax location for local pickup orders is handled by ShippingController::filter_order_tax_location(), which
+ // hooks woocommerce_order_get_tax_location once and derives the pickup location address from the order's own
+ // shipping line item. This avoids registering a per-call (and unremovable) closure on every sync.
+ // See \WC_Abstract_Order::get_tax_location().
// Ensure cart is current.
if ( $update_totals ) {
diff --git a/plugins/woocommerce/tests/php/src/Blocks/Shipping/ShippingControllerTest.php b/plugins/woocommerce/tests/php/src/Blocks/Shipping/ShippingControllerTest.php
index f10a2bc1a6a..313af0c695f 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/Shipping/ShippingControllerTest.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/Shipping/ShippingControllerTest.php
@@ -188,6 +188,132 @@ class ShippingControllerTest extends \WP_UnitTestCase {
$this->assertTrue( true, 'Method did not throw exceptions with missing WC object' );
}
+ /**
+ * @testdox filter_order_tax_location returns the chosen pickup location address for local pickup orders.
+ */
+ public function test_filter_order_tax_location_returns_pickup_location_address(): void {
+ $pickup_address = array(
+ 'country' => 'US',
+ 'state' => 'CA',
+ 'postcode' => '90210',
+ 'city' => 'Beverly Hills',
+ );
+
+ $order = new \WC_Order();
+ $shipping_item = new \WC_Order_Item_Shipping();
+ // 'local_pickup' is always recognised as a local pickup method id; in production the block 'pickup_location'
+ // method is what writes the _pickup_location_address meta onto the shipping line at purchase time.
+ $shipping_item->set_method_id( 'local_pickup' );
+ $shipping_item->set_method_title( 'Local pickup' );
+ $shipping_item->add_meta_data( '_pickup_location_address', $pickup_address );
+ $order->add_item( $shipping_item );
+ $order->save();
+
+ $order = wc_get_order( $order->get_id() );
+
+ $default_location = array(
+ 'country' => 'GB',
+ 'state' => '',
+ 'postcode' => 'PR1 4SS',
+ 'city' => 'Preston',
+ );
+
+ $location = $this->shipping_controller->filter_order_tax_location( $default_location, $order );
+
+ $this->assertSame( $pickup_address['country'], $location['country'], 'Tax country should match the pickup location.' );
+ $this->assertSame( $pickup_address['state'], $location['state'], 'Tax state should match the pickup location.' );
+ $this->assertSame( $pickup_address['postcode'], $location['postcode'], 'Tax postcode should match the pickup location.' );
+ $this->assertSame( $pickup_address['city'], $location['city'], 'Tax city should match the pickup location.' );
+ }
+
+ /**
+ * @testdox filter_order_tax_location resolves the pickup location for legacy_local_pickup orders even when the method is no longer registered.
+ */
+ public function test_filter_order_tax_location_returns_pickup_location_for_legacy_method(): void {
+ $pickup_address = array(
+ 'country' => 'US',
+ 'state' => 'CA',
+ 'postcode' => '90210',
+ 'city' => 'Beverly Hills',
+ );
+
+ $order = new \WC_Order();
+ $shipping_item = new \WC_Order_Item_Shipping();
+ // 'legacy_local_pickup' is in the canonical woocommerce_local_pickup_methods list but is not returned by
+ // LocalPickupUtils::get_local_pickup_method_ids(), so it stands in for any method no longer registered.
+ $shipping_item->set_method_id( 'legacy_local_pickup' );
+ $shipping_item->set_method_title( 'Local pickup' );
+ $shipping_item->add_meta_data( '_pickup_location_address', $pickup_address );
+ $order->add_item( $shipping_item );
+ $order->save();
+
+ $order = wc_get_order( $order->get_id() );
+
+ $default_location = array(
+ 'country' => 'GB',
+ 'state' => '',
+ 'postcode' => 'PR1 4SS',
+ 'city' => 'Preston',
+ );
+
+ $location = $this->shipping_controller->filter_order_tax_location( $default_location, $order );
+
+ $this->assertSame( $pickup_address['country'], $location['country'], 'Tax country should match the pickup location for legacy methods.' );
+ $this->assertSame( $pickup_address['state'], $location['state'], 'Tax state should match the pickup location for legacy methods.' );
+ $this->assertSame( $pickup_address['postcode'], $location['postcode'], 'Tax postcode should match the pickup location for legacy methods.' );
+ $this->assertSame( $pickup_address['city'], $location['city'], 'Tax city should match the pickup location for legacy methods.' );
+ }
+
+ /**
+ * @testdox filter_order_tax_location leaves the resolved location untouched for non local pickup orders.
+ */
+ public function test_filter_order_tax_location_ignores_non_local_pickup_orders(): void {
+ $order = new \WC_Order();
+ $shipping_item = new \WC_Order_Item_Shipping();
+ $shipping_item->set_method_id( 'flat_rate' );
+ $shipping_item->set_method_title( 'Flat rate' );
+ $order->add_item( $shipping_item );
+ $order->save();
+
+ $order = wc_get_order( $order->get_id() );
+
+ $default_location = array(
+ 'country' => 'GB',
+ 'state' => '',
+ 'postcode' => 'PR1 4SS',
+ 'city' => 'Preston',
+ );
+
+ $location = $this->shipping_controller->filter_order_tax_location( $default_location, $order );
+
+ $this->assertSame( $default_location, $location, 'Non local pickup orders should keep the resolved tax location.' );
+ }
+
+ /**
+ * @testdox filter_order_tax_location falls back to the resolved location when no pickup address is captured.
+ */
+ public function test_filter_order_tax_location_falls_back_without_pickup_address(): void {
+ $order = new \WC_Order();
+ $shipping_item = new \WC_Order_Item_Shipping();
+ $shipping_item->set_method_id( 'local_pickup' );
+ $shipping_item->set_method_title( 'Local pickup' );
+ $order->add_item( $shipping_item );
+ $order->save();
+
+ $order = wc_get_order( $order->get_id() );
+
+ $default_location = array(
+ 'country' => 'GB',
+ 'state' => '',
+ 'postcode' => 'PR1 4SS',
+ 'city' => 'Preston',
+ );
+
+ $location = $this->shipping_controller->filter_order_tax_location( $default_location, $order );
+
+ $this->assertSame( $default_location, $location, 'Without a captured pickup address the location should be unchanged.' );
+ }
+
/**
* Overrides the WC logger.
*
diff --git a/plugins/woocommerce/tests/php/src/StoreApi/Schemas/V1/CartShippingRateSchemaTest.php b/plugins/woocommerce/tests/php/src/StoreApi/Schemas/V1/CartShippingRateSchemaTest.php
new file mode 100644
index 00000000000..4da7e0e6d97
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/StoreApi/Schemas/V1/CartShippingRateSchemaTest.php
@@ -0,0 +1,89 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\StoreApi\Schemas\V1;
+
+use Automattic\WooCommerce\StoreApi\Formatters;
+use Automattic\WooCommerce\StoreApi\Formatters\CurrencyFormatter;
+use Automattic\WooCommerce\StoreApi\Formatters\HtmlFormatter;
+use Automattic\WooCommerce\StoreApi\Formatters\MoneyFormatter;
+use Automattic\WooCommerce\StoreApi\SchemaController;
+use Automattic\WooCommerce\StoreApi\Schemas\ExtendSchema;
+use Automattic\WooCommerce\StoreApi\Schemas\V1\CartShippingRateSchema;
+use ReflectionClass;
+use WC_Shipping_Rate;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for the CartShippingRateSchema class.
+ */
+class CartShippingRateSchemaTest extends WC_Unit_Test_Case {
+ /**
+ * The System Under Test.
+ *
+ * @var CartShippingRateSchema
+ */
+ private $sut;
+
+ /**
+ * Set up test fixtures.
+ */
+ public function setUp(): void {
+ parent::setUp();
+
+ $formatters = new Formatters();
+ $formatters->register( 'currency', CurrencyFormatter::class );
+ $formatters->register( 'html', HtmlFormatter::class );
+ $formatters->register( 'money', MoneyFormatter::class );
+
+ $extend = new ExtendSchema( $formatters );
+ $controller = new SchemaController( $extend );
+
+ $this->sut = $controller->get( CartShippingRateSchema::IDENTIFIER );
+ }
+
+ /**
+ * @testdox Should exclude hidden shipping rate meta from API response.
+ */
+ public function test_get_rate_response_excludes_hidden_meta_data(): void {
+ $rate = new WC_Shipping_Rate( 'pickup_location:0', 'Pickup', 0, array(), 'pickup_location' );
+ $rate->add_meta_data( 'pickup_location', 'Main store' );
+ $rate->add_meta_data(
+ '_pickup_location_address',
+ array(
+ 'country' => 'GB',
+ 'state' => '',
+ 'postcode' => 'PR1 4SS',
+ 'city' => 'Preston',
+ )
+ );
+ $rate->add_meta_data( '_private_note', 'Internal only' );
+
+ $response = $this->invoke_get_rate_response( $rate );
+
+ $this->assertSame(
+ array(
+ array(
+ 'key' => 'pickup_location',
+ 'value' => 'Main store',
+ ),
+ ),
+ $response['meta_data'],
+ 'Only public shipping rate meta should be exposed by the Store API.'
+ );
+ }
+
+ /**
+ * Invoke the protected get_rate_response method.
+ *
+ * @param WC_Shipping_Rate $rate Rate object.
+ * @return array
+ */
+ private function invoke_get_rate_response( WC_Shipping_Rate $rate ): array {
+ $reflection = new ReflectionClass( $this->sut );
+ $method = $reflection->getMethod( 'get_rate_response' );
+ $method->setAccessible( true );
+
+ return $method->invoke( $this->sut, $rate );
+ }
+}