Commit ab359ecbc7e for woocommerce

commit ab359ecbc7e6be78ad1fb613d762a7a8747f4dfd
Author: Tom Cafferkey <tjcafferkey@gmail.com>
Date:   Thu Oct 8 14:05:11 2026 +0100

    Fix manual order product tax using unsaved address (#69518)

    * Fix manual order product tax using unsaved address

    * Add changelog entry for manual order tax fix

    * Fix manual order tax test product price

    * Restore matched tax rates filter for customer orders

    * Simplify AJAX order tax test cleanup

    * Cover customer tax fallback without editor country

diff --git a/plugins/woocommerce/changelog/fix-wooplug-6422-unsaved-order-tax-location b/plugins/woocommerce/changelog/fix-wooplug-6422-unsaved-order-tax-location
new file mode 100644
index 00000000000..330933a8755
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-wooplug-6422-unsaved-order-tax-location
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Use the address entered in the order editor when calculating tax for products added to manual orders.
diff --git a/plugins/woocommerce/client/legacy/js/admin/meta-boxes-order.js b/plugins/woocommerce/client/legacy/js/admin/meta-boxes-order.js
index c730ff4e815..81e2f0afcce 100644
--- a/plugins/woocommerce/client/legacy/js/admin/meta-boxes-order.js
+++ b/plugins/woocommerce/client/legacy/js/admin/meta-boxes-order.js
@@ -1702,12 +1702,12 @@ jQuery( function ( $ ) {
 			add_items: function( add_items ) {
 				wc_meta_boxes_order_items.block();

-				var data = {
+				var data = $.extend( {}, wc_meta_boxes_order_items.get_taxable_address(), {
 					action   : 'woocommerce_add_order_item',
 					order_id : woocommerce_admin_meta_boxes.post_id,
 					security : woocommerce_admin_meta_boxes.order_item_nonce,
 					data     : add_items
-				};
+				} );

 				// Check if items have changed, if so pass them through so we can save them before adding a new item.
 				if ( 'true' === $( 'button.cancel-action' ).attr( 'data-reload' ) ) {
diff --git a/plugins/woocommerce/includes/abstracts/abstract-wc-order.php b/plugins/woocommerce/includes/abstracts/abstract-wc-order.php
index a9ec774cc37..1acff6b4ade 100644
--- a/plugins/woocommerce/includes/abstracts/abstract-wc-order.php
+++ b/plugins/woocommerce/includes/abstracts/abstract-wc-order.php
@@ -2087,7 +2087,7 @@ abstract class WC_Abstract_Order extends WC_Abstract_Legacy_Order {
 	 *
 	 * @param  WC_Product $product Product object.
 	 * @param  int        $qty Quantity to add.
-	 * @param  array      $args Args for the added product.
+	 * @param  array      $args Args for the added product, including an optional tax location for its price.
 	 * @return int
 	 */
 	public function add_product( $product, $qty = 1, $args = array() ) {
@@ -2096,8 +2096,9 @@ abstract class WC_Abstract_Order extends WC_Abstract_Legacy_Order {
 			$total = wc_get_price_excluding_tax(
 				$product,
 				array(
-					'qty'   => $qty,
-					'order' => $order,
+					'qty'          => $qty,
+					'order'        => $order,
+					'tax_location' => ArrayUtil::get_value_or_default( $args, 'tax_location' ),
 				)
 			);

diff --git a/plugins/woocommerce/includes/class-wc-ajax.php b/plugins/woocommerce/includes/class-wc-ajax.php
index a56a0b95afa..e54da4f5c7f 100644
--- a/plugins/woocommerce/includes/class-wc-ajax.php
+++ b/plugins/woocommerce/includes/class-wc-ajax.php
@@ -1215,9 +1215,13 @@ class WC_AJAX {
 		$items = ( ! empty( $_POST['items'] ) ) ? wp_unslash( $_POST['items'] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized

 		$items_to_add = isset( $_POST['data'] ) ? array_filter( wp_unslash( (array) $_POST['data'] ) ) : array(); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
+		$tax_location = array();
+		foreach ( array( 'country', 'state', 'postcode', 'city' ) as $key ) {
+			$tax_location[ $key ] = isset( $_POST[ $key ] ) && is_string( $_POST[ $key ] ) ? wc_strtoupper( sanitize_text_field( wp_unslash( $_POST[ $key ] ) ) ) : '';
+		}

 		try {
-			$response = self::maybe_add_order_item( $order_id, $items, $items_to_add );
+			$response = self::maybe_add_order_item( $order_id, $items, $items_to_add, $tax_location );
 		} catch ( Exception $e ) {
 			wp_send_json_error( array( 'error' => $e->getMessage() ) );
 		}
@@ -1230,11 +1234,12 @@ class WC_AJAX {
 	 * @param int          $order_id     ID of order to add items to.
 	 * @param string|array $items        Existing items in order. Empty string if no items to add.
 	 * @param array        $items_to_add Array of items to add.
+	 * @param array        $tax_location Tax location entered in the order editor, if available.
 	 *
 	 * @return array     Fragments to render and notes HTML.
 	 * @throws Exception When unable to add item.
 	 */
-	private static function maybe_add_order_item( $order_id, $items, $items_to_add ) {
+	private static function maybe_add_order_item( $order_id, $items, $items_to_add, $tax_location = array() ) {
 		try {
 			$order = wc_get_order( $order_id );

@@ -1287,7 +1292,14 @@ class WC_AJAX {
 					// The message is shown in a JS alert, not rendered as HTML.
 					throw new Exception( wp_strip_all_tags( html_entity_decode( $message, ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML401 ) ) );
 				}
-				$item_id                 = $order->add_product( $product, $qty, array( 'order' => $order ) );
+				$item_id                 = $order->add_product(
+					$product,
+					$qty,
+					array(
+						'order'        => $order,
+						'tax_location' => $tax_location,
+					)
+				);
 				$item                    = apply_filters( 'woocommerce_ajax_order_item', $order->get_item( $item_id ), $item_id, $order, $product );
 				$added_items[ $item_id ] = $item;
 				$order_notes[ $item_id ] = $product->get_formatted_name();
diff --git a/plugins/woocommerce/includes/wc-product-functions.php b/plugins/woocommerce/includes/wc-product-functions.php
index cf28ab211ff..76b581f09a1 100644
--- a/plugins/woocommerce/includes/wc-product-functions.php
+++ b/plugins/woocommerce/includes/wc-product-functions.php
@@ -1614,7 +1614,7 @@ function wc_get_price_including_tax( $product, $args = array() ) {
  *
  * @since  3.0.0
  * @param  WC_Product $product WC_Product object.
- * @param  array      $args Optional arguments to pass product quantity and price.
+ * @param  array      $args Optional quantity, price, order, and tax location arguments.
  * @return float|string Price with tax excluded, or an empty string if price calculation failed.
  */
 function wc_get_price_excluding_tax( $product, $args = array() ) {
@@ -1646,21 +1646,30 @@ function wc_get_price_excluding_tax( $product, $args = array() ) {

 		if ( apply_filters( 'woocommerce_adjust_non_base_location_prices', true ) ) {
 			$tax_rates = WC_Tax::get_base_tax_rates( $product->get_tax_class( 'unfiltered' ) );
-		} elseif ( $customer_id ) {
+		} elseif ( $customer_id && empty( $args['tax_location']['country'] ) ) {
 			$customer  = wc_get_container()->get( LegacyProxy::class )->get_instance_of( WC_Customer::class, $customer_id );
 			$tax_rates = WC_Tax::get_rates( $product->get_tax_class(), $customer );
 		} elseif ( is_object( $order ) && method_exists( $order, 'get_taxable_location' ) ) {
-			$tax_location = $order->get_taxable_location();
+			$tax_location = $order->get_taxable_location( ! empty( $args['tax_location']['country'] ) ? $args['tax_location'] : array() );
 			if ( is_array( $tax_location ) && isset( $tax_location['country'] ) ) {
-				$tax_rates = WC_Tax::find_rates(
-					array(
-						'country'   => $tax_location['country'],
-						'state'     => $tax_location['state'] ?? '',
-						'postcode'  => $tax_location['postcode'] ?? '',
-						'city'      => $tax_location['city'] ?? '',
-						'tax_class' => $product->get_tax_class(),
-					)
-				);
+				if ( $customer_id ) {
+					$customer  = wc_get_container()->get( LegacyProxy::class )->get_instance_of( WC_Customer::class, $customer_id );
+					$tax_rates = WC_Tax::get_rates_from_location(
+						$product->get_tax_class(),
+						array( $tax_location['country'], $tax_location['state'] ?? '', $tax_location['postcode'] ?? '', $tax_location['city'] ?? '' ),
+						$customer
+					);
+				} else {
+					$tax_rates = WC_Tax::find_rates(
+						array(
+							'country'   => $tax_location['country'],
+							'state'     => $tax_location['state'] ?? '',
+							'postcode'  => $tax_location['postcode'] ?? '',
+							'city'      => $tax_location['city'] ?? '',
+							'tax_class' => $product->get_tax_class(),
+						)
+					);
+				}
 			}
 		}

diff --git a/plugins/woocommerce/tests/php/includes/class-wc-ajax-test.php b/plugins/woocommerce/tests/php/includes/class-wc-ajax-test.php
index 103e26c5350..c0f5712c61d 100644
--- a/plugins/woocommerce/tests/php/includes/class-wc-ajax-test.php
+++ b/plugins/woocommerce/tests/php/includes/class-wc-ajax-test.php
@@ -2624,6 +2624,160 @@ class WC_AJAX_Test extends \WP_Ajax_UnitTestCase {
 		$this->assertCount( $initial_item_count + 1, $order->get_items() );
 	}

+	/**
+	 * @testdox Adding a product uses the unsaved editor tax location without saving the order address.
+	 * @dataProvider add_order_item_editor_tax_location_cases
+	 *
+	 * @param string $tax_based_on Whether taxes use the billing or shipping address.
+	 * @param bool   $has_customer Whether the order has a customer with a saved base-country address.
+	 */
+	public function test_add_order_item_uses_unsaved_tax_location( string $tax_based_on, bool $has_customer ): void {
+		$this->_setRole( 'administrator' );
+
+		update_option( 'woocommerce_calc_taxes', 'yes' );
+		update_option( 'woocommerce_prices_include_tax', 'yes' );
+		update_option( 'woocommerce_default_country', 'BE' );
+		update_option( 'woocommerce_tax_based_on', $tax_based_on );
+		add_filter( 'woocommerce_adjust_non_base_location_prices', '__return_false' );
+
+		WC_Tax::_insert_tax_rate(
+			array(
+				'tax_rate_country'  => 'BE',
+				'tax_rate'          => '6.0000',
+				'tax_rate_name'     => 'Belgian VAT',
+				'tax_rate_priority' => 1,
+				'tax_rate_class'    => '',
+			)
+		);
+		WC_Tax::_insert_tax_rate(
+			array(
+				'tax_rate_country'  => 'NL',
+				'tax_rate'          => '9.0000',
+				'tax_rate_name'     => 'Dutch VAT',
+				'tax_rate_priority' => 1,
+				'tax_rate_class'    => '',
+			)
+		);
+
+		$product = WC_Helper_Product::create_simple_product(
+			true,
+			array(
+				'regular_price' => '24',
+				'price'         => '24',
+			)
+		);
+
+		$order = wc_create_order();
+		if ( $has_customer ) {
+			$customer = WC_Helper_Customer::create_customer( 'tax-location-customer', wp_generate_password(), 'tax-location@example.com' );
+			$customer->set_billing_country( 'BE' );
+			$customer->save();
+			$order->set_customer_id( $customer->get_id() );
+			$order->save();
+		}
+
+		$_POST['order_id'] = $order->get_id();
+		$_POST['security'] = wp_create_nonce( 'order-item' );
+		$_POST['data']     = array(
+			array(
+				'id'  => $product->get_id(),
+				'qty' => 1,
+			),
+		);
+		$_POST['country']  = 'NL';
+		$_POST['state']    = '';
+		$_POST['postcode'] = '';
+		$_POST['city']     = '';
+
+		$response = $this->do_ajax( 'woocommerce_add_order_item' );
+
+		$this->assertTrue( $response['success'], 'The product should be added successfully.' );
+		$stored_order = wc_get_order( $order->get_id() );
+		$this->assertCount( 1, $stored_order->get_items(), 'The product should appear on the order.' );
+		$item = current( $stored_order->get_items() );
+		$this->assertEqualsWithDelta( 24 / 1.09, (float) $item->get_total(), 0.0001, 'The line price should exclude the editor location tax rate.' );
+		$this->assertSame( '', $stored_order->get_billing_country(), 'The unsaved billing country must not be persisted.' );
+		$this->assertSame( '', $stored_order->get_shipping_country(), 'The unsaved shipping country must not be persisted.' );
+	}
+
+	/**
+	 * Editor address cases for the Add product request.
+	 *
+	 * @return array
+	 */
+	public function add_order_item_editor_tax_location_cases(): array {
+		return array(
+			'billing guest'    => array( 'billing', false ),
+			'billing customer' => array( 'billing', true ),
+			'shipping guest'   => array( 'shipping', false ),
+		);
+	}
+
+	/**
+	 * @testdox Adding a product with no editor country uses the assigned customer's tax location.
+	 */
+	public function test_add_order_item_without_editor_country_uses_customer_tax_location(): void {
+		$this->_setRole( 'administrator' );
+
+		update_option( 'woocommerce_calc_taxes', 'yes' );
+		update_option( 'woocommerce_prices_include_tax', 'yes' );
+		update_option( 'woocommerce_default_country', 'NL' );
+		update_option( 'woocommerce_tax_based_on', 'billing' );
+		add_filter( 'woocommerce_adjust_non_base_location_prices', '__return_false' );
+
+		WC_Tax::_insert_tax_rate(
+			array(
+				'tax_rate_country'  => 'BE',
+				'tax_rate'          => '6.0000',
+				'tax_rate_name'     => 'Belgian VAT',
+				'tax_rate_priority' => 1,
+				'tax_rate_class'    => '',
+			)
+		);
+		WC_Tax::_insert_tax_rate(
+			array(
+				'tax_rate_country'  => 'NL',
+				'tax_rate'          => '9.0000',
+				'tax_rate_name'     => 'Dutch VAT',
+				'tax_rate_priority' => 1,
+				'tax_rate_class'    => '',
+			)
+		);
+
+		$product  = WC_Helper_Product::create_simple_product(
+			true,
+			array(
+				'regular_price' => '24',
+				'price'         => '24',
+			)
+		);
+		$customer = WC_Helper_Customer::create_customer( 'tax-fallback-customer', wp_generate_password(), 'tax-fallback@example.com' );
+		$customer->set_billing_country( 'BE' );
+		$customer->save();
+
+		$order = wc_create_order();
+		$order->set_customer_id( $customer->get_id() );
+		$order->save();
+
+		$_POST['order_id'] = $order->get_id();
+		$_POST['security'] = wp_create_nonce( 'order-item' );
+		$_POST['data']     = array(
+			array(
+				'id'  => $product->get_id(),
+				'qty' => 1,
+			),
+		);
+		$_POST['country']  = '';
+
+		$response = $this->do_ajax( 'woocommerce_add_order_item' );
+
+		$this->assertTrue( $response['success'], 'The product should be added successfully.' );
+		$stored_order = wc_get_order( $order->get_id() );
+		$this->assertCount( 1, $stored_order->get_items(), 'The product should appear on the order.' );
+		$item = current( $stored_order->get_items() );
+		$this->assertEqualsWithDelta( 24 / 1.06, (float) $item->get_total(), 0.0001, 'The net price should use the customer’s Belgian rate rather than the Dutch base rate.' );
+	}
+
 	/**
 	 * @testdox save_order_items rejects a negative quantity and leaves the stored item untouched.
 	 */
diff --git a/plugins/woocommerce/tests/php/includes/wc-product-functions-test.php b/plugins/woocommerce/tests/php/includes/wc-product-functions-test.php
index d04e2235d81..da19eb8041c 100644
--- a/plugins/woocommerce/tests/php/includes/wc-product-functions-test.php
+++ b/plugins/woocommerce/tests/php/includes/wc-product-functions-test.php
@@ -1471,6 +1471,80 @@ class WC_Product_Functions_Tests extends \WC_Unit_Test_Case {
 		}
 	}

+	/**
+	 * @testdox Assigned-customer orders apply matched rates for the editor tax location and pass the order customer to the filter.
+	 */
+	public function test_wc_get_price_excluding_tax_editor_location_filters_customer_rates(): void {
+		$original_calc_taxes         = get_option( 'woocommerce_calc_taxes' );
+		$original_prices_include_tax = get_option( 'woocommerce_prices_include_tax' );
+		update_option( 'woocommerce_calc_taxes', 'yes' );
+		update_option( 'woocommerce_prices_include_tax', 'yes' );
+
+		$customer = WC_Helper_Customer::create_customer();
+		$order    = wc_create_order();
+		$order->set_customer_id( $customer->get_id() );
+		$order->set_billing_country( 'DE' );
+		$order->save();
+
+		$product = new WC_Product_Simple();
+		$product->set_price( 100 );
+		$product->set_tax_status( 'taxable' );
+
+		$french_tax_rate_id = WC_Tax::_insert_tax_rate(
+			array(
+				'tax_rate_country'  => 'FR',
+				'tax_rate'          => '20.0000',
+				'tax_rate_name'     => 'French VAT',
+				'tax_rate_priority' => 1,
+				'tax_rate_compound' => 0,
+				'tax_rate_shipping' => 1,
+				'tax_rate_order'    => 1,
+				'tax_rate_class'    => '',
+			)
+		);
+
+		$filter_calls = array();
+		$filter       = function ( $rates, $tax_class, $filtered_customer ) use ( &$filter_calls ) {
+			$filter_calls[] = array( $rates, $tax_class, $filtered_customer );
+			foreach ( $rates as &$rate ) {
+				$rate['rate'] = 25.0;
+			}
+			return $rates;
+		};
+		add_filter( 'woocommerce_adjust_non_base_location_prices', '__return_false' );
+		add_filter( 'woocommerce_matched_rates', $filter, 10, 3 );
+
+		try {
+			$net_price = wc_get_price_excluding_tax(
+				$product,
+				array(
+					'order'        => $order,
+					'tax_location' => array(
+						'country'  => 'FR',
+						'state'    => '',
+						'postcode' => '75001',
+						'city'     => 'Paris',
+					),
+				)
+			);
+
+			$this->assertCount( 1, $filter_calls, 'The matched rates filter should run for an assigned-customer order.' );
+			$this->assertSame( '', $filter_calls[0][1], 'The filter should receive the product tax class.' );
+			$this->assertInstanceOf( WC_Customer::class, $filter_calls[0][2] );
+			$this->assertSame( $customer->get_id(), $filter_calls[0][2]->get_id(), 'The filter should receive the order customer.' );
+			$this->assertArrayHasKey( $french_tax_rate_id, $filter_calls[0][0], 'The editor location should determine the matched rate.' );
+			$this->assertEquals( 80, $net_price, 'The filtered 25% rate should determine the net price.' );
+		} finally {
+			remove_filter( 'woocommerce_matched_rates', $filter );
+			remove_filter( 'woocommerce_adjust_non_base_location_prices', '__return_false' );
+			WC_Tax::_delete_tax_rate( $french_tax_rate_id );
+			$order->delete( true );
+			wp_delete_user( $customer->get_id() );
+			update_option( 'woocommerce_calc_taxes', $original_calc_taxes );
+			update_option( 'woocommerce_prices_include_tax', $original_prices_include_tax );
+		}
+	}
+
 	/**
 	 * @testDox Test 'wc_get_related_products' with actual related products.
 	 */