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