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