Commit 1b320629104 for woocommerce
commit 1b320629104564c935f014fdc4a1ca6779c481a9
Author: Anuj Singh <80690679+Anuj-Rathore24@users.noreply.github.com>
Date: Thu Jun 4 20:17:03 2026 +0530
Fix: `woocommerce_adjust_non_base_location_prices` not respected for manual backend orders (#63744)
Co-authored-by: Nestor Soriano <konamiman@konamiman.com>
diff --git a/plugins/woocommerce/changelog/63744-fix-adjust-non-base-location-prices-manual-orders b/plugins/woocommerce/changelog/63744-fix-adjust-non-base-location-prices-manual-orders
new file mode 100644
index 00000000000..9907ef17814
--- /dev/null
+++ b/plugins/woocommerce/changelog/63744-fix-adjust-non-base-location-prices-manual-orders
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Fix: woocommerce_adjust_non_base_location_prices not respected for manual backend orders
\ No newline at end of file
diff --git a/plugins/woocommerce/includes/abstracts/abstract-wc-order.php b/plugins/woocommerce/includes/abstracts/abstract-wc-order.php
index aa23accb00a..37130e5a108 100644
--- a/plugins/woocommerce/includes/abstracts/abstract-wc-order.php
+++ b/plugins/woocommerce/includes/abstracts/abstract-wc-order.php
@@ -1634,13 +1634,22 @@ abstract class WC_Abstract_Order extends WC_Abstract_Legacy_Order {
public function add_product( $product, $qty = 1, $args = array() ) {
if ( $product ) {
$order = ArrayUtil::get_value_or_default( $args, 'order' );
- $total = wc_get_price_excluding_tax(
- $product,
- array(
- 'qty' => $qty,
- 'order' => $order,
- )
- );
+
+ if ( $this->has_fixed_end_prices() ) {
+ // Note: storing inclusive price as-is relies on the filter being a
+ // code-level constant. If the filter changes at runtime, existing
+ // line totals will be misinterpreted on recalculate since the gross
+ // price is reused without re-deriving from the product.
+ $total = (float) $product->get_price() * $qty;
+ } else {
+ $total = wc_get_price_excluding_tax(
+ $product,
+ array(
+ 'qty' => $qty,
+ 'order' => $order,
+ )
+ );
+ }
$default_args = array(
'name' => $product->get_name(),
@@ -1877,7 +1886,9 @@ abstract class WC_Abstract_Order extends WC_Abstract_Legacy_Order {
$is_vat_exempt = apply_filters( 'woocommerce_order_is_vat_exempt', 'yes' === $this->get_meta( 'is_vat_exempt' ), $this );
- // Trigger tax recalculation for all items.
+ if ( $this->has_fixed_end_prices() ) {
+ $calculate_tax_for['prices_include_tax'] = true;
+ }
foreach ( $this->get_items( array( 'line_item', 'fee' ) ) as $item_id => $item ) {
if ( ! $is_vat_exempt ) {
$item->calculate_taxes( $calculate_tax_for );
@@ -2003,6 +2014,30 @@ abstract class WC_Abstract_Order extends WC_Abstract_Legacy_Order {
);
}
+ /**
+ * Whether this store uses fixed end-prices across tax jurisdictions.
+ * True when prices include tax and woocommerce_adjust_non_base_location_prices is false.
+ *
+ * @return bool
+ */
+ private function has_fixed_end_prices(): bool {
+ /**
+ * Filters if taxes should be removed from locations outside the store base location.
+ *
+ * The woocommerce_adjust_non_base_location_prices filter can stop base taxes being taken off when dealing
+ * with out of base locations. e.g. If a product costs 10 including tax, all users will pay 10
+ * regardless of location and taxes.
+ *
+ * @since 2.4.7
+ *
+ * @param bool $adjust_non_base_location_prices True by default.
+ */
+ $adjust_non_base_location_prices = apply_filters( 'woocommerce_adjust_non_base_location_prices', true );
+
+ return 'yes' === get_option( 'woocommerce_prices_include_tax' )
+ && ! $adjust_non_base_location_prices;
+ }
+
/**
* Calculate totals by looking at the contents of the order. Stores the totals and returns the orders final total.
*
@@ -2048,6 +2083,11 @@ abstract class WC_Abstract_Order extends WC_Abstract_Legacy_Order {
$this->calculate_taxes();
}
+ // Re-read cart totals after calculate_taxes().
+ // Negative fees may have been capped while calculating totals.
+ $cart_subtotal = $this->get_cart_subtotal_for_order();
+ $cart_total = (float) $this->get_cart_total_for_order();
+
// Sum taxes again so we can work out how much tax was discounted. This uses original values, not those possibly rounded to 2dp.
foreach ( $this->get_items() as $item ) {
$taxes = $item->get_taxes();
@@ -2061,6 +2101,12 @@ abstract class WC_Abstract_Order extends WC_Abstract_Legacy_Order {
}
}
+ // Fixed end-price orders keep inclusive item totals; compare net values and add tax back below.
+ if ( $this->has_fixed_end_prices() ) {
+ $cart_subtotal = $cart_subtotal - $cart_subtotal_tax;
+ $cart_total = $cart_total - $cart_total_tax;
+ }
+
$this->set_discount_total( NumberUtil::round( $cart_subtotal - $cart_total, $price_decimals ) );
$this->set_discount_tax( wc_round_tax_total( $cart_subtotal_tax - $cart_total_tax ) );
$this->set_total( NumberUtil::round( $cart_total + $fees_total + (float) $this->get_shipping_total() + (float) $this->get_cart_tax() + (float) $this->get_shipping_tax(), $price_decimals ) );
@@ -2100,6 +2146,37 @@ abstract class WC_Abstract_Order extends WC_Abstract_Legacy_Order {
return apply_filters( 'woocommerce_order_amount_item_subtotal', $subtotal, $this, $item, $inc_tax, $round );
}
+ /**
+ * Get the items subtotal amount to display in the admin order screen.
+ *
+ * For stores with fixed end-prices (prices entered including tax with the
+ * woocommerce_adjust_non_base_location_prices adjustment disabled), the cart
+ * tax is removed so the displayed subtotal matches the ex-tax cart display.
+ *
+ * @return float
+ */
+ public function get_subtotal_amount_to_display() {
+ return $this->has_fixed_end_prices()
+ ? (float) $this->get_subtotal() - (float) $this->get_cart_tax()
+ : (float) $this->get_subtotal();
+ }
+
+ /**
+ * Get the per-unit item subtotal amount to display in the admin order screen.
+ *
+ * For stores with fixed end-prices (prices entered including tax with the
+ * woocommerce_adjust_non_base_location_prices adjustment disabled), the item
+ * tax is removed so the displayed amount matches the ex-tax cart display.
+ *
+ * @param object $item Item to get the subtotal from.
+ * @return float
+ */
+ public function get_item_subtotal_to_display( $item ) {
+ return ( $this->has_fixed_end_prices() && $item instanceof WC_Order_Item_Product && $item->get_quantity() )
+ ? NumberUtil::round( ( (float) $item->get_subtotal() - (float) $item->get_subtotal_tax() ) / $item->get_quantity(), wc_get_price_decimals() )
+ : $this->get_item_subtotal( $item, false, true );
+ }
+
/**
* Get line subtotal - this is the cost before discount.
*
diff --git a/plugins/woocommerce/includes/admin/meta-boxes/views/html-order-item.php b/plugins/woocommerce/includes/admin/meta-boxes/views/html-order-item.php
index 88383ec4cbc..5348d3e88fc 100644
--- a/plugins/woocommerce/includes/admin/meta-boxes/views/html-order-item.php
+++ b/plugins/woocommerce/includes/admin/meta-boxes/views/html-order-item.php
@@ -80,10 +80,10 @@ $item_name = apply_filters( 'woocommerce_order_item_name', $item->get_name(), $i
</div>
</td>
<?php endif; ?>
- <td class="item_cost" width="1%" data-sort-value="<?php echo esc_attr( $order->get_item_subtotal( $item, false, true ) ); ?>">
+ <td class="item_cost" width="1%" data-sort-value="<?php echo esc_attr( $order->get_item_subtotal_to_display( $item ) ); ?>">
<div class="view">
<?php
- echo wc_price( $order->get_item_subtotal( $item, false, true ), $wc_price_arg ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
+ echo wc_price( $order->get_item_subtotal_to_display( $item ), $wc_price_arg ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
?>
</div>
</td>
diff --git a/plugins/woocommerce/includes/admin/meta-boxes/views/html-order-items.php b/plugins/woocommerce/includes/admin/meta-boxes/views/html-order-items.php
index b39f119d40e..1c2160da276 100644
--- a/plugins/woocommerce/includes/admin/meta-boxes/views/html-order-items.php
+++ b/plugins/woocommerce/includes/admin/meta-boxes/views/html-order-items.php
@@ -171,7 +171,7 @@ if ( wc_tax_enabled() ) {
<td class="label"><?php esc_html_e( 'Items Subtotal:', 'woocommerce' ); ?></td>
<td width="1%"></td>
<td class="total">
- <?php echo wc_price( $order->get_subtotal(), array( 'currency' => $order->get_currency() ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
+ <?php echo wc_price( $order->get_subtotal_amount_to_display(), array( 'currency' => $order->get_currency() ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
</td>
</tr>
<?php if ( 0 < $order->get_total_discount() ) : ?>
diff --git a/plugins/woocommerce/includes/class-wc-order-item-fee.php b/plugins/woocommerce/includes/class-wc-order-item-fee.php
index a49f5de03ca..ecbbfc927b1 100644
--- a/plugins/woocommerce/includes/class-wc-order-item-fee.php
+++ b/plugins/woocommerce/includes/class-wc-order-item-fee.php
@@ -93,6 +93,7 @@ class WC_Order_Item_Fee extends WC_Order_Item {
}
// Use regular calculation unless the fee is negative.
if ( 0 <= $this->get_total() ) {
+ unset( $calculate_tax_for['prices_include_tax'] );
return parent::calculate_taxes( $calculate_tax_for );
}
@@ -111,7 +112,7 @@ class WC_Order_Item_Fee extends WC_Order_Item {
$cart_discount_proportion = $this->get_total() * $proportion;
$calculate_tax_for['tax_class'] = $tax_class;
$tax_rates = WC_Tax::find_rates( $calculate_tax_for );
- $discount_taxes = wc_array_merge_recursive_numeric( $discount_taxes, WC_Tax::calc_tax( $cart_discount_proportion, $tax_rates ) );
+ $discount_taxes = wc_array_merge_recursive_numeric( $discount_taxes, WC_Tax::calc_tax( $cart_discount_proportion, $tax_rates, ! empty( $calculate_tax_for['prices_include_tax'] ) ) );
}
}
$this->set_taxes( array( 'total' => $discount_taxes ) );
diff --git a/plugins/woocommerce/includes/class-wc-order-item.php b/plugins/woocommerce/includes/class-wc-order-item.php
index 22858fcd59a..3ad6b58ce9b 100644
--- a/plugins/woocommerce/includes/class-wc-order-item.php
+++ b/plugins/woocommerce/includes/class-wc-order-item.php
@@ -283,19 +283,23 @@ class WC_Order_Item extends WC_Data implements ArrayAccess {
if ( ! isset( $calculate_tax_for['country'], $calculate_tax_for['state'], $calculate_tax_for['postcode'], $calculate_tax_for['city'] ) ) {
return false;
}
+
+ $inc_tax = ! empty( $calculate_tax_for['prices_include_tax'] );
+
if ( '0' !== $this->get_tax_class() && ProductTaxStatus::TAXABLE === $this->get_tax_status() && wc_tax_enabled() ) {
$calculate_tax_for['tax_class'] = $this->get_tax_class();
$tax_rates = WC_Tax::find_rates( $calculate_tax_for );
- $taxes = WC_Tax::calc_tax( $this->get_total(), $tax_rates, false );
+ $taxes = WC_Tax::calc_tax( $this->get_total(), $tax_rates, $inc_tax );
if ( method_exists( $this, 'get_subtotal' ) ) {
- $subtotal_taxes = WC_Tax::calc_tax( $this->get_subtotal(), $tax_rates, false );
+ $subtotal_taxes = WC_Tax::calc_tax( $this->get_subtotal(), $tax_rates, $inc_tax );
$this->set_taxes(
array(
'total' => $taxes,
'subtotal' => $subtotal_taxes,
)
);
+
} else {
$this->set_taxes( array( 'total' => $taxes ) );
}
diff --git a/plugins/woocommerce/tests/legacy/unit-tests/order/class-wc-tests-crud-orders.php b/plugins/woocommerce/tests/legacy/unit-tests/order/class-wc-tests-crud-orders.php
index dbd21a31a3d..c4a46a9a868 100644
--- a/plugins/woocommerce/tests/legacy/unit-tests/order/class-wc-tests-crud-orders.php
+++ b/plugins/woocommerce/tests/legacy/unit-tests/order/class-wc-tests-crud-orders.php
@@ -2175,4 +2175,295 @@ class WC_Tests_CRUD_Orders extends WC_Unit_Test_Case {
$this->assertEquals( 20.50, $order->get_total_fees() );
}
+
+ /**
+ * @testdox calculate_taxes() respects woocommerce_adjust_non_base_location_prices
+ * for manual backend orders.
+ */
+ public function test_calculate_taxes_fixed_end_price_non_base_country(): void {
+ $context = $this->create_manual_order_tax_context();
+ add_filter( 'woocommerce_adjust_non_base_location_prices', '__return_false' );
+ $order = $this->create_manual_order_for_tax_context( $context, '24' );
+
+ try {
+ $order->calculate_totals();
+
+ $this->assertEquals( 24.00, round( (float) $order->get_total(), 2 ) );
+ $this->assertEquals( 0.00, round( (float) $order->get_discount_total(), 2 ) );
+
+ $item = $this->get_first_order_item( $order );
+ $this->assertEquals(
+ 1.98,
+ round( (float) $item->get_subtotal_tax(), 2 ),
+ 'Line subtotal tax must be 24/1.09*0.09 from the customer NL rate.'
+ );
+ } finally {
+ $this->cleanup_manual_order_tax_context( $context, $order );
+ }
+ }
+
+ /**
+ * @testdox calculate_taxes() without the filter still adjusts price for non-base
+ * country (default WooCommerce behaviour must be preserved).
+ */
+ public function test_calculate_taxes_adjusts_price_without_filter(): void {
+ $context = $this->create_manual_order_tax_context();
+ $order = $this->create_manual_order_for_tax_context( $context );
+
+ try {
+ $order->calculate_totals();
+
+ $this->assertGreaterThan( 24.00, (float) $order->get_total() );
+ $this->assertEquals(
+ 24.68,
+ round( (float) $order->get_total(), 2 ),
+ 'Without the filter, NL total should be ~24.68.'
+ );
+ } finally {
+ $this->cleanup_manual_order_tax_context( $context, $order );
+ }
+ }
+
+ /**
+ * @testdox calculate_taxes() with adjust_non_base_location_prices false is a
+ * no-op for prices-exclusive-of-tax stores.
+ */
+ public function test_calculate_taxes_fixed_price_filter_noop_for_excl_tax_store(): void {
+ $context = $this->create_manual_order_tax_context( 'no' );
+ add_filter( 'woocommerce_adjust_non_base_location_prices', '__return_false' );
+ $order = $this->create_manual_order_for_tax_context( $context );
+
+ try {
+ $order->calculate_totals();
+
+ $this->assertEquals(
+ 26.16,
+ round( (float) $order->get_total(), 2 ),
+ 'For excl-tax stores the filter must be a no-op; NL 9% applies on top of 24.'
+ );
+ } finally {
+ $this->cleanup_manual_order_tax_context( $context, $order );
+ }
+ }
+
+ /**
+ * @testdox calculate_totals() is idempotent, calling it twice produces
+ * the same result when adjust_non_base_location_prices is false.
+ */
+ public function test_calculate_taxes_fixed_price_idempotent(): void {
+ $context = $this->create_manual_order_tax_context();
+ add_filter( 'woocommerce_adjust_non_base_location_prices', '__return_false' );
+ $order = $this->create_manual_order_for_tax_context( $context, '24' );
+
+ try {
+ $order->calculate_totals();
+ $total_first = $order->get_total();
+
+ $order->calculate_totals();
+ $total_second = $order->get_total();
+
+ $this->assertEquals(
+ round( (float) $total_first, 2 ),
+ round( (float) $total_second, 2 ),
+ 'Calling calculate_totals() twice must produce the same total.'
+ );
+ $this->assertEquals( 24.00, round( (float) $total_second, 2 ) );
+ } finally {
+ $this->cleanup_manual_order_tax_context( $context, $order );
+ }
+ }
+
+ /**
+ * @testdox calculate_totals() keeps positive fees tax-exclusive in fixed end-price mode.
+ */
+ public function test_calculate_taxes_fixed_price_positive_fee_remains_tax_exclusive(): void {
+ $context = $this->create_manual_order_tax_context();
+ add_filter( 'woocommerce_adjust_non_base_location_prices', '__return_false' );
+ $order = $this->create_manual_order_for_tax_context( $context, '24' );
+
+ $fee = new WC_Order_Item_Fee();
+ $fee->set_props(
+ array(
+ 'name' => 'Handling',
+ 'tax_status' => ProductTaxStatus::TAXABLE,
+ 'tax_class' => $context['tax_class_slug'],
+ 'total' => 10,
+ )
+ );
+ $order->add_item( $fee );
+
+ try {
+ $order->calculate_totals();
+
+ $this->assertEquals( 34.90, round( (float) $order->get_total(), 2 ) );
+ $this->assertEquals( 0.90, round( (float) $fee->get_total_tax(), 2 ) );
+ } finally {
+ $this->cleanup_manual_order_tax_context( $context, $order );
+ }
+ }
+
+ /**
+ * @testdox calculate_totals() extracts tax from negative fees in fixed end-price mode.
+ */
+ public function test_calculate_taxes_fixed_price_negative_fee_uses_inclusive_tax(): void {
+ $context = $this->create_manual_order_tax_context();
+ add_filter( 'woocommerce_adjust_non_base_location_prices', '__return_false' );
+ $order = $this->create_manual_order_for_tax_context( $context, '24' );
+
+ $fee = new WC_Order_Item_Fee();
+ $fee->set_props(
+ array(
+ 'name' => 'Manual discount',
+ 'tax_status' => ProductTaxStatus::TAXABLE,
+ 'tax_class' => $context['tax_class_slug'],
+ 'total' => -10,
+ )
+ );
+ $order->add_item( $fee );
+
+ try {
+ $order->calculate_totals();
+
+ $this->assertEquals( -0.83, round( (float) $fee->get_total_tax(), 2 ) );
+ $this->assertEquals( 1.15, round( (float) $order->get_cart_tax(), 2 ) );
+ $this->assertEquals( 13.17, round( (float) $order->get_total(), 2 ) );
+ } finally {
+ $this->cleanup_manual_order_tax_context( $context, $order );
+ }
+ }
+
+ /**
+ * Creates tax rates and a product for manual order tax tests.
+ *
+ * @param string $prices_include_tax Whether prices include tax.
+ * @return array<string,mixed>
+ */
+ private function create_manual_order_tax_context( string $prices_include_tax = 'yes' ): array {
+ $tax_class_slug = 'books-vat';
+ $tax_class_created = false;
+
+ $context = array(
+ 'original_prices_include_tax' => get_option( 'woocommerce_prices_include_tax' ),
+ 'original_calc_taxes' => get_option( 'woocommerce_calc_taxes' ),
+ 'original_tax_based_on' => get_option( 'woocommerce_tax_based_on' ),
+ 'original_base_country' => get_option( 'woocommerce_default_country' ),
+ 'tax_class_slug' => $tax_class_slug,
+ );
+
+ update_option( 'woocommerce_prices_include_tax', $prices_include_tax );
+ update_option( 'woocommerce_calc_taxes', 'yes' );
+ update_option( 'woocommerce_tax_based_on', 'billing' );
+ update_option( 'woocommerce_default_country', 'BE' );
+
+ if ( ! in_array( $tax_class_slug, WC_Tax::get_tax_class_slugs(), true ) ) {
+ WC_Tax::create_tax_class( 'Books VAT', $tax_class_slug );
+ $tax_class_created = true;
+ }
+
+ $context['tax_class_created'] = $tax_class_created;
+ $context['be_rate_id'] = $this->insert_manual_order_tax_rate( 'BE', '6.0000', 'Belgium VAT', $tax_class_slug );
+ $context['nl_rate_id'] = $this->insert_manual_order_tax_rate( 'NL', '9.0000', 'Netherlands VAT', $tax_class_slug );
+
+ WC_Cache_Helper::invalidate_cache_group( 'taxes' );
+
+ $product = WC_Helper_Product::create_simple_product();
+ $product->set_price( '24' );
+ $product->set_regular_price( '24' );
+ $product->set_tax_status( ProductTaxStatus::TAXABLE );
+ $product->set_tax_class( $tax_class_slug );
+ $product->save();
+
+ $context['product'] = $product;
+
+ return $context;
+ }
+
+ /**
+ * Inserts a tax rate for manual order tax tests.
+ *
+ * @param string $country Tax rate country.
+ * @param string $rate Tax rate percentage.
+ * @param string $name Tax rate name.
+ * @param string $tax_class_slug Tax class slug.
+ * @return int
+ */
+ private function insert_manual_order_tax_rate( string $country, string $rate, string $name, string $tax_class_slug ): int {
+ return WC_Tax::_insert_tax_rate(
+ array(
+ 'tax_rate_country' => $country,
+ 'tax_rate_state' => '',
+ 'tax_rate' => $rate,
+ 'tax_rate_name' => $name,
+ 'tax_rate_priority' => '1',
+ 'tax_rate_compound' => '0',
+ 'tax_rate_shipping' => '1',
+ 'tax_rate_order' => '1',
+ 'tax_rate_class' => $tax_class_slug,
+ )
+ );
+ }
+
+ /**
+ * Creates a manual order for the tax context.
+ *
+ * @param array<string,mixed> $context Tax test context.
+ * @param string|null $line_total Optional line total override.
+ * @return WC_Order
+ */
+ private function create_manual_order_for_tax_context( array $context, ?string $line_total = null ): WC_Order {
+ $order = wc_create_order();
+ $order->set_billing_country( 'NL' );
+ $order->set_shipping_country( 'NL' );
+ $item_id = $order->add_product( $context['product'], 1 );
+
+ if ( null !== $line_total ) {
+ $item = $order->get_item( $item_id );
+ $item->set_subtotal( $line_total );
+ $item->set_total( $line_total );
+ $item->save();
+ }
+
+ $order->save();
+
+ return $order;
+ }
+
+ /**
+ * Cleans up manual order tax test data.
+ *
+ * @param array<string,mixed> $context Tax test context.
+ * @param WC_Order|null $order Order to delete.
+ */
+ private function cleanup_manual_order_tax_context( array $context, ?WC_Order $order = null ): void {
+ remove_filter( 'woocommerce_adjust_non_base_location_prices', '__return_false' );
+ WC_Tax::_delete_tax_rate( $context['be_rate_id'] );
+ WC_Tax::_delete_tax_rate( $context['nl_rate_id'] );
+
+ if ( $context['tax_class_created'] ) {
+ WC_Tax::delete_tax_class_by( 'slug', $context['tax_class_slug'] );
+ }
+
+ WC_Cache_Helper::invalidate_cache_group( 'taxes' );
+ WC_Helper_Product::delete_product( $context['product']->get_id() );
+
+ if ( $order ) {
+ $order->delete( true );
+ }
+
+ update_option( 'woocommerce_prices_include_tax', $context['original_prices_include_tax'] );
+ update_option( 'woocommerce_calc_taxes', $context['original_calc_taxes'] );
+ update_option( 'woocommerce_tax_based_on', $context['original_tax_based_on'] );
+ update_option( 'woocommerce_default_country', $context['original_base_country'] );
+ }
+
+ /**
+ * Gets the first item from an order.
+ *
+ * @param WC_Order $order Order object.
+ * @return WC_Order_Item
+ */
+ private function get_first_order_item( WC_Order $order ): WC_Order_Item {
+ $items = $order->get_items();
+ return reset( $items );
+ }
}