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.
*/