Commit 99be813a6a for woocommerce
commit 99be813a6aaf1e72e60bdc2bfd63152af35a6001
Author: Eric Binnion <ericbinnion@gmail.com>
Date: Tue Dec 9 09:18:57 2025 -0600
Tax: Fix fixed price tax regardless of location for manual orders (#62271)
* Tax: Fix bug with manual order creation and price inclusive tax
* Add tests
* First pass at fixing recalculate in manual order
* Add changefile(s) from automation for the following project(s): woocommerce
* Do not make unnecessary utility change
* More defensive on non-existent order
* Preserve coupon ratio
* Use get_taxable_location for proper tax location handling
Respects woocommerce_tax_based_on setting (shipping/billing/base)
instead of only checking billing address.
* Revert TaxesController recalculate line item subtotals
This functionality needs a better home, likely on the order itself.
* Simplify tests to single end-to-end test
* Address PHPStan
* Add defensive validation for get_taxable_location return value
Validates that get_taxable_location() returns an array with expected
keys before accessing them, preventing PHP warnings from custom order
implementations that may return unexpected structures.
* Use sentinel value for tax_rates fallback check
Using isset() fails when the assigned value is null (e.g., from mocked
methods in tests). Use false as a sentinel value and strict comparison
to reliably detect when no tax rates were determined.
---------
Co-authored-by: github-actions <github-actions@github.com>
diff --git a/plugins/woocommerce/changelog/62271-update-adjust-non-base-locatio-for-guest-purchases b/plugins/woocommerce/changelog/62271-update-adjust-non-base-locatio-for-guest-purchases
new file mode 100644
index 0000000000..88b7012fea
--- /dev/null
+++ b/plugins/woocommerce/changelog/62271-update-adjust-non-base-locatio-for-guest-purchases
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Fix tax calculation for manual orders when using fixed total price mode (woocommerce_adjust_non_base_location_prices filter).
\ No newline at end of file
diff --git a/plugins/woocommerce/includes/wc-product-functions.php b/plugins/woocommerce/includes/wc-product-functions.php
index 6166d6c524..0a444db402 100644
--- a/plugins/woocommerce/includes/wc-product-functions.php
+++ b/plugins/woocommerce/includes/wc-product-functions.php
@@ -1291,12 +1291,33 @@ function wc_get_price_excluding_tax( $product, $args = array() ) {
if ( $product->is_taxable() && wc_prices_include_tax() ) {
$order = ArrayUtil::get_value_or_default( $args, 'order' );
$customer_id = $order ? $order->get_customer_id() : 0;
+ $tax_rates = false;
+
if ( apply_filters( 'woocommerce_adjust_non_base_location_prices', true ) ) {
$tax_rates = WC_Tax::get_base_tax_rates( $product->get_tax_class( 'unfiltered' ) );
- } else {
- $customer = $customer_id ? wc_get_container()->get( LegacyProxy::class )->get_instance_of( WC_Customer::class, $customer_id ) : null;
+ } elseif ( $customer_id ) {
+ $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();
+ 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(),
+ )
+ );
+ }
}
+
+ // Fallback if no tax rates were determined.
+ if ( false === $tax_rates ) {
+ $tax_rates = WC_Tax::get_rates( $product->get_tax_class(), null );
+ }
+
$remove_taxes = WC_Tax::calc_tax( $line_price, $tax_rates, true );
$return_price = $line_price - array_sum( $remove_taxes ); // Unrounded since we're dealing with tax inclusive prices. Matches logic in cart-totals class. @see adjust_non_base_location_price.
} else {
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 8d2f58921f..32e0985a84 100644
--- a/plugins/woocommerce/tests/php/includes/wc-product-functions-test.php
+++ b/plugins/woocommerce/tests/php/includes/wc-product-functions-test.php
@@ -241,6 +241,96 @@ class WC_Product_Functions_Tests extends \WC_Unit_Test_Case {
$this->assertEquals( 100, wc_get_product( $product->get_id() )->get_price() );
}
+ /**
+ * @testdox Guest order uses billing address tax rate when woocommerce_adjust_non_base_location_prices is false.
+ */
+ public function test_wc_get_price_excluding_tax_guest_order_uses_billing_address() {
+ // Enable taxes.
+ $wc_tax_enabled = wc_tax_enabled();
+ if ( ! $wc_tax_enabled ) {
+ update_option( 'woocommerce_calc_taxes', 'yes' );
+ }
+
+ // Set prices to include tax.
+ $original_prices_include_tax = get_option( 'woocommerce_prices_include_tax' );
+ update_option( 'woocommerce_prices_include_tax', 'yes' );
+
+ // Set base country to Germany.
+ $original_base_country = get_option( 'woocommerce_default_country' );
+ update_option( 'woocommerce_default_country', 'DE' );
+
+ // Create German tax rate (19%) - this is the base/shop rate.
+ $german_tax_rate_id = WC_Tax::_insert_tax_rate(
+ array(
+ 'tax_rate_country' => 'DE',
+ 'tax_rate_state' => '',
+ 'tax_rate' => '19.0000',
+ 'tax_rate_name' => 'German VAT',
+ 'tax_rate_priority' => '1',
+ 'tax_rate_compound' => '0',
+ 'tax_rate_shipping' => '1',
+ 'tax_rate_order' => '1',
+ 'tax_rate_class' => '',
+ )
+ );
+
+ // Create French tax rate (20%) - this is where the customer is.
+ $french_tax_rate_id = WC_Tax::_insert_tax_rate(
+ array(
+ 'tax_rate_country' => 'FR',
+ 'tax_rate_state' => '',
+ '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' => '',
+ )
+ );
+
+ // Create a product priced at 100 (including tax).
+ $product = WC_Helper_Product::create_simple_product();
+ $product->set_price( 100 );
+ $product->set_tax_status( 'taxable' );
+ $product->save();
+
+ // Create a guest order with French billing address.
+ $order = wc_create_order();
+ $order->set_customer_id( 0 ); // Guest order.
+ $order->set_billing_country( 'FR' );
+ $order->set_billing_city( 'Paris' );
+ $order->set_billing_postcode( '75001' );
+ $order->save();
+
+ // Enable "same price everywhere" mode.
+ add_filter( 'woocommerce_adjust_non_base_location_prices', '__return_false' );
+
+ // Calculate the price excluding tax.
+ $price_excluding_tax = wc_get_price_excluding_tax( $product, array( 'order' => $order ) );
+
+ // With filter=false and French customer (20% VAT):
+ // €100 / 1.20 = €83.33 (net price)
+ // Later: €83.33 * 1.20 = €100 (customer pays €100).
+ //
+ // If the bug were present (using base rate instead):
+ // €100 / 1.19 = €84.03 (wrong net price)
+ // Later: €84.03 * 1.20 = €100.84 (customer pays more than €100).
+ $this->assertEquals( 83.33, round( $price_excluding_tax, 2 ), 'Price should use French tax rate (20%) to calculate net, not German base rate (19%)' );
+
+ // Clean up.
+ remove_filter( 'woocommerce_adjust_non_base_location_prices', '__return_false' );
+ WC_Tax::_delete_tax_rate( $german_tax_rate_id );
+ WC_Tax::_delete_tax_rate( $french_tax_rate_id );
+ WC_Helper_Product::delete_product( $product->get_id() );
+ $order->delete( true );
+ update_option( 'woocommerce_prices_include_tax', $original_prices_include_tax );
+ update_option( 'woocommerce_default_country', $original_base_country );
+ if ( ! $wc_tax_enabled ) {
+ update_option( 'woocommerce_calc_taxes', 'no' );
+ }
+ }
+
/**
* @testDox Test 'wc_get_related_products' with actual related products.
*/