Commit 029e3b2ff4e for woocommerce
commit 029e3b2ff4e406bf80544064e1f67e221d643d6d
Author: Mike Jolley <mike.jolley@me.com>
Date: Tue Oct 27 10:49:16 2026 +0000
Fix REST API orders ignoring the order address for tax-inclusive prices (#70142)
* Fix REST order item prices ignoring the order tax location
* Add changelog entry for REST order tax location fix
* Set REST order items after other props, with shipping lines first
diff --git a/plugins/woocommerce/changelog/fix-30730-rest-order-non-base-location-prices b/plugins/woocommerce/changelog/fix-30730-rest-order-non-base-location-prices
new file mode 100644
index 00000000000..4e4fa264fa5
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-30730-rest-order-non-base-location-prices
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Keep tax-inclusive prices fixed for orders created through the REST API when non-base location price adjustment is turned off.
diff --git a/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-orders-v2-controller.php b/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-orders-v2-controller.php
index 6b33cee4dae..d749d815708 100644
--- a/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-orders-v2-controller.php
+++ b/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-orders-v2-controller.php
@@ -64,6 +64,13 @@ class WC_REST_Orders_V2_Controller extends WC_REST_CRUD_Controller {
*/
protected $request = array();
+ /**
+ * Order whose item set_item() is preparing, so new line items are priced at the order's tax location.
+ *
+ * @var WC_Order|null
+ */
+ private $order_for_item = null;
+
/**
* Register the routes for orders.
*/
@@ -729,8 +736,10 @@ class WC_REST_Orders_V2_Controller extends WC_REST_CRUD_Controller {
$schema = $this->get_item_schema();
$data_keys = array_keys( array_filter( $schema['properties'], array( $this, 'filter_writable_props' ) ) );
+ $item_types = array( 'shipping_lines', 'line_items', 'fee_lines', 'coupon_lines' );
+
// Handle all writable props.
- foreach ( $data_keys as $key ) {
+ foreach ( array_diff( $data_keys, $item_types ) as $key ) {
$value = $request[ $key ];
if ( ! is_null( $value ) ) {
@@ -742,22 +751,6 @@ class WC_REST_Orders_V2_Controller extends WC_REST_CRUD_Controller {
case 'shipping':
$this->update_address( $order, $value, $key );
break;
- case 'line_items':
- case 'shipping_lines':
- case 'fee_lines':
- case 'coupon_lines':
- if ( is_array( $value ) ) {
- foreach ( $value as $item ) {
- if ( is_array( $item ) ) {
- if ( $this->item_is_null( $item ) || ( isset( $item['quantity'] ) && 0 === $item['quantity'] ) ) {
- $order->remove_item( $item['id'] );
- } else {
- $this->set_item( $order, $key, $item );
- }
- }
- }
- }
- break;
case 'meta_data':
if ( is_array( $value ) ) {
foreach ( $value as $meta ) {
@@ -774,6 +767,23 @@ class WC_REST_Orders_V2_Controller extends WC_REST_CRUD_Controller {
}
}
+ // Set items once the address is known, shipping lines first, so new line items are priced at the order's tax location (the base address for local pickup).
+ foreach ( array_intersect( $item_types, $data_keys ) as $key ) {
+ if ( ! is_array( $request[ $key ] ) ) {
+ continue;
+ }
+
+ foreach ( $request[ $key ] as $item ) {
+ if ( is_array( $item ) ) {
+ if ( $this->item_is_null( $item ) || ( isset( $item['quantity'] ) && 0 === $item['quantity'] ) ) {
+ $order->remove_item( $item['id'] );
+ } else {
+ $this->set_item( $order, $key, $item );
+ }
+ }
+ }
+ }
+
/**
* Filters an object before it is inserted via the REST API.
*
@@ -996,7 +1006,15 @@ class WC_REST_Orders_V2_Controller extends WC_REST_CRUD_Controller {
if ( 'create' === $action ) {
$quantity = isset( $posted['quantity'] ) ? $posted['quantity'] : 1;
- $total = wc_get_price_excluding_tax( $product, array( 'qty' => $quantity ) );
+ $order = $this->order_for_item;
+ $total = wc_get_price_excluding_tax(
+ $product,
+ array(
+ 'qty' => $quantity,
+ 'order' => $order,
+ 'tax_location' => $order ? $order->get_taxable_location() : array(),
+ )
+ );
$item->set_total( $total );
$item->set_subtotal( $total );
}
@@ -1124,7 +1142,12 @@ class WC_REST_Orders_V2_Controller extends WC_REST_CRUD_Controller {
}
// Prepare item data.
- $item = $this->$method( $posted, $action, $item );
+ $this->order_for_item = $order;
+ try {
+ $item = $this->$method( $posted, $action, $item );
+ } finally {
+ $this->order_for_item = null;
+ }
/**
* Allow extensions be notified before the item is saved.
diff --git a/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-orders-controller.php b/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-orders-controller.php
index 6e8f49172ce..239be85a839 100644
--- a/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-orders-controller.php
+++ b/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-orders-controller.php
@@ -115,8 +115,10 @@ class WC_REST_Orders_Controller extends WC_REST_Orders_V2_Controller {
$schema = $this->get_item_schema();
$data_keys = array_keys( array_filter( $schema['properties'], array( $this, 'filter_writable_props' ) ) );
+ $item_types = array( 'shipping_lines', 'line_items', 'fee_lines' );
+
// Handle all writable props.
- foreach ( $data_keys as $key ) {
+ foreach ( array_diff( $data_keys, $item_types ) as $key ) {
$value = $request[ $key ];
if ( ! is_null( $value ) ) {
@@ -135,21 +137,6 @@ class WC_REST_Orders_Controller extends WC_REST_Orders_V2_Controller {
case 'shipping':
$this->update_address( $order, $value, $key );
break;
- case 'line_items':
- case 'shipping_lines':
- case 'fee_lines':
- if ( is_array( $value ) ) {
- foreach ( $value as $item ) {
- if ( is_array( $item ) ) {
- if ( $this->item_is_null( $item ) || ( isset( $item['quantity'] ) && 0 === $item['quantity'] ) ) {
- $this->remove_item( $order, $key, $item['id'] );
- } else {
- $this->set_item( $order, $key, $item );
- }
- }
- }
- }
- break;
case 'meta_data':
MetaDataUtil::update( $value, $order );
break;
@@ -162,6 +149,23 @@ class WC_REST_Orders_Controller extends WC_REST_Orders_V2_Controller {
}
}
+ // Set items once the address is known, shipping lines first, so new line items are priced at the order's tax location (the base address for local pickup).
+ foreach ( array_intersect( $item_types, $data_keys ) as $key ) {
+ if ( ! is_array( $request[ $key ] ) ) {
+ continue;
+ }
+
+ foreach ( $request[ $key ] as $item ) {
+ if ( is_array( $item ) ) {
+ if ( $this->item_is_null( $item ) || ( isset( $item['quantity'] ) && 0 === $item['quantity'] ) ) {
+ $this->remove_item( $order, $key, $item['id'] );
+ } else {
+ $this->set_item( $order, $key, $item );
+ }
+ }
+ }
+ }
+
/**
* Filters an object before it is inserted via the REST API.
*
diff --git a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-orders-controller-tests.php b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-orders-controller-tests.php
index 7be45c27e4c..77f2fa1e75c 100644
--- a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-orders-controller-tests.php
+++ b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-orders-controller-tests.php
@@ -304,6 +304,98 @@ class WC_REST_Orders_Controller_Tests extends WC_REST_Unit_Test_Case {
}
}
+ /**
+ * @testdox Creating an order keeps the entered tax-inclusive price when non-base price adjustment is off.
+ * @dataProvider orders_create_non_base_location_cases
+ *
+ * @param array $extra_params Extra order params to send.
+ * @param bool $has_customer Whether the order has a customer with a saved base-country address.
+ * @param float $expected_rate Tax rate the line price should exclude.
+ */
+ public function test_orders_create_keeps_tax_inclusive_price_when_non_base_prices_are_not_adjusted( array $extra_params, bool $has_customer, float $expected_rate ): void {
+ 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', 'billing' );
+ add_filter( 'woocommerce_adjust_non_base_location_prices', '__return_false' );
+
+ $rates = array(
+ 'BE' => '6.0000',
+ 'NL' => '9.0000',
+ );
+ foreach ( $rates as $country => $rate ) {
+ WC_Tax::_insert_tax_rate(
+ array(
+ 'tax_rate_country' => $country,
+ 'tax_rate' => $rate,
+ 'tax_rate_name' => "$country VAT",
+ 'tax_rate_priority' => 1,
+ 'tax_rate_class' => '',
+ )
+ );
+ }
+
+ $product = WC_Helper_Product::create_simple_product(
+ true,
+ array(
+ 'regular_price' => '24',
+ 'price' => '24',
+ )
+ );
+ $order_params = array(
+ 'billing' => array( 'country' => 'NL' ),
+ 'line_items' => array(
+ array(
+ 'product_id' => $product->get_id(),
+ 'quantity' => 1,
+ ),
+ ),
+ ) + $extra_params;
+
+ if ( $has_customer ) {
+ $customer = WC_Helper_Customer::create_customer( 'rest-tax-location-customer', wp_generate_password(), 'rest-tax-location@example.com' );
+ $customer->set_billing_country( 'BE' );
+ $customer->save();
+ $order_params['customer_id'] = $customer->get_id();
+ }
+
+ $request = new WP_REST_Request( 'POST', '/wc/v3/orders' );
+ $request->set_body_params( $order_params );
+ $response = $this->server->dispatch( $request );
+
+ $this->assertSame( 201, $response->get_status(), 'Creating the order should return HTTP 201.' );
+
+ $order = wc_get_order( $response->get_data()['id'] );
+ $item = current( $order->get_items() );
+ $this->assertEqualsWithDelta( 24 / ( 1 + $expected_rate ), (float) $item->get_subtotal(), 0.0001, 'The line price should exclude the tax rate for the order location.' );
+ $this->assertEquals( 24, (float) $order->get_total(), 'The order total should match the entered tax-inclusive price.' );
+ }
+
+ /**
+ * Cases for the non-base location price test. Billing is NL (9%), the store base is BE (6%).
+ *
+ * @return array
+ */
+ public function orders_create_non_base_location_cases(): array {
+ return array(
+ 'guest' => array( array(), false, 0.09 ),
+ 'customer saved in BE' => array( array(), true, 0.09 ),
+ 'local pickup uses base' => array(
+ array(
+ 'shipping_lines' => array(
+ array(
+ 'method_id' => 'local_pickup',
+ 'method_title' => 'Local pickup',
+ 'total' => '0',
+ ),
+ ),
+ ),
+ false,
+ 0.06,
+ ),
+ );
+ }
+
/**
* Order statuses accepted when creating through the V3 route.
*