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 );
+	}
+}