Commit 78c81c18bd6 for woocommerce
commit 78c81c18bd64157b74321af6d0c150b375cc1443
Author: Andrew Matia <73502748+drewmt@users.noreply.github.com>
Date: Fri Oct 9 16:24:52 2026 +0300
Fix order coupon discounts for VAT-exempt orders (#69441)
* Fix order coupon discounts for VAT-exempt orders
* Add changelog entry for VAT-exempt order discounts
* Format coupon regression fixture arguments
* Move VAT-exempt coupon regression to the main test suite
* Refactor order VAT exemption and avoid redundant recalculation
diff --git a/plugins/woocommerce/changelog/fix-vat-exempt-order-coupon-discounts b/plugins/woocommerce/changelog/fix-vat-exempt-order-coupon-discounts
new file mode 100644
index 00000000000..3edbea4fb27
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-vat-exempt-order-coupon-discounts
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Apply the full coupon discount and no discount tax to VAT-exempt orders.
diff --git a/plugins/woocommerce/includes/abstracts/abstract-wc-order.php b/plugins/woocommerce/includes/abstracts/abstract-wc-order.php
index 1acff6b4ade..f24bac99c94 100644
--- a/plugins/woocommerce/includes/abstracts/abstract-wc-order.php
+++ b/plugins/woocommerce/includes/abstracts/abstract-wc-order.php
@@ -1993,15 +1993,17 @@ abstract class WC_Abstract_Order extends WC_Abstract_Legacy_Order {
*/
protected function set_item_discount_amounts( $discounts ) {
$item_discounts = $discounts->get_discounts_by_item();
- $tax_location = $this->get_tax_location();
- $tax_location = array( $tax_location['country'], $tax_location['state'], $tax_location['postcode'], $tax_location['city'] );
+ $is_vat_exempt = $this->is_vat_exempt();
+
+ $tax_location = $this->get_tax_location();
+ $tax_location = array( $tax_location['country'], $tax_location['state'], $tax_location['postcode'], $tax_location['city'] );
if ( $item_discounts ) {
foreach ( $item_discounts as $item_id => $amount ) {
$item = $this->get_item( $item_id, false );
// If the prices include tax, discounts should be taken off the tax inclusive prices like in the cart.
- if ( $this->get_prices_include_tax() && wc_tax_enabled() && ProductTaxStatus::TAXABLE === $item->get_tax_status() ) {
+ if ( ! $is_vat_exempt && $this->get_prices_include_tax() && wc_tax_enabled() && ProductTaxStatus::TAXABLE === $item->get_tax_status() ) {
$taxes = WC_Tax::calc_tax( $amount, $this->get_tax_rates( $item->get_tax_class(), $tax_location ), true );
// Use unrounded taxes so totals will be re-calculated accurately, like in cart.
@@ -2025,8 +2027,10 @@ abstract class WC_Abstract_Order extends WC_Abstract_Legacy_Order {
$coupon_code_to_id = wc_list_pluck( $coupons, 'get_id', 'get_code' );
$all_discounts = $discounts->get_discounts();
$coupon_discounts = $discounts->get_discounts_by_coupon();
- $tax_location = $this->get_tax_location();
- $tax_location = array(
+ $is_vat_exempt = $this->is_vat_exempt();
+
+ $tax_location = $this->get_tax_location();
+ $tax_location = array(
$tax_location['country'],
$tax_location['state'],
$tax_location['postcode'],
@@ -2057,7 +2061,7 @@ abstract class WC_Abstract_Order extends WC_Abstract_Legacy_Order {
foreach ( $all_discounts[ $coupon_code ] as $item_id => $item_discount_amount ) {
$item = $this->get_item( $item_id, false );
- if ( ProductTaxStatus::TAXABLE !== $item->get_tax_status() || ! wc_tax_enabled() ) {
+ if ( $is_vat_exempt || ProductTaxStatus::TAXABLE !== $item->get_tax_status() || ! wc_tax_enabled() ) {
continue;
}
@@ -2313,6 +2317,24 @@ abstract class WC_Abstract_Order extends WC_Abstract_Legacy_Order {
return WC_Tax::get_rates_from_location( $tax_class, $tax_location, $customer );
}
+ /**
+ * Get the filtered VAT exemption for this order.
+ *
+ * @return bool Whether the order is exempt from VAT.
+ */
+ private function is_vat_exempt(): bool {
+ /**
+ * Filters whether the order is exempt from VAT.
+ *
+ * @since 3.3.0
+ * @since 11.3.0 Also applied to discounts.
+ *
+ * @param bool $is_vat_exempt Whether the order is exempt from VAT.
+ * @param WC_Abstract_Order $order Order instance.
+ */
+ return (bool) apply_filters( 'woocommerce_order_is_vat_exempt', 'yes' === $this->get_meta( 'is_vat_exempt' ), $this );
+ }
+
/**
* Calculate taxes for all line items and shipping, and store the totals and tax rows.
*
@@ -2340,7 +2362,7 @@ 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 );
+ $is_vat_exempt = $this->is_vat_exempt();
// Trigger tax recalculation for all items.
foreach ( $this->get_items( array( 'line_item', 'fee' ) ) as $item_id => $item ) {
diff --git a/plugins/woocommerce/tests/php/includes/abstracts/class-wc-abstract-order-test.php b/plugins/woocommerce/tests/php/includes/abstracts/class-wc-abstract-order-test.php
index 7b728aeac58..2421c84648e 100644
--- a/plugins/woocommerce/tests/php/includes/abstracts/class-wc-abstract-order-test.php
+++ b/plugins/woocommerce/tests/php/includes/abstracts/class-wc-abstract-order-test.php
@@ -25,6 +25,89 @@ class WC_Abstract_Order_Test extends WC_Unit_Test_Case {
*/
private $registered_order_statuses = array();
+ /**
+ * @testdox Order coupon amounts and totals respect the filtered VAT exemption when applied and recalculated.
+ * @testWith ["yes", "yes", null, true]
+ * ["no", "yes", null, true]
+ * ["yes", "no", true, true]
+ * ["no", "no", true, true]
+ * ["yes", "yes", false, false]
+ * ["no", "yes", false, false]
+ * ["yes", "no", null, false]
+ * ["no", "no", null, false]
+ *
+ * @param string $prices_include_tax Whether store prices include tax.
+ * @param string $exemption_meta Stored order VAT exemption.
+ * @param bool|null $filtered_exemption Optional filter override.
+ * @param bool $is_exempt Expected effective exemption.
+ */
+ public function test_coupon_amounts_respect_order_vat_exemption( string $prices_include_tax, string $exemption_meta, ?bool $filtered_exemption, bool $is_exempt ): void {
+ update_option( 'woocommerce_prices_include_tax', $prices_include_tax );
+ update_option( 'woocommerce_calc_taxes', 'yes' );
+ update_option( 'woocommerce_tax_based_on', 'base' );
+
+ WC_Tax::_insert_tax_rate(
+ array(
+ 'tax_rate_country' => '',
+ 'tax_rate_state' => '',
+ 'tax_rate' => '20.0000',
+ 'tax_rate_name' => 'VAT',
+ 'tax_rate_priority' => '1',
+ 'tax_rate_compound' => '0',
+ 'tax_rate_shipping' => '1',
+ 'tax_rate_order' => '1',
+ 'tax_rate_class' => '',
+ )
+ );
+
+ $product = WC_Helper_Product::create_simple_product();
+ $order = wc_create_order();
+ $order->update_meta_data( 'is_vat_exempt', $exemption_meta );
+ $order->add_product(
+ $product,
+ 1,
+ array(
+ 'subtotal' => 100,
+ 'total' => 100,
+ )
+ );
+
+ if ( null !== $filtered_exemption ) {
+ add_filter(
+ 'woocommerce_order_is_vat_exempt',
+ static function ( $exempt, $filtered_order ) use ( $order, $filtered_exemption ) {
+ return $filtered_order->get_id() === $order->get_id() ? $filtered_exemption : $exempt;
+ },
+ 10,
+ 2
+ );
+ }
+
+ $order->calculate_totals();
+ $coupon = new WC_Coupon();
+ $coupon->set_code( 'vat-exemption-coupon' );
+ $coupon->set_discount_type( 'percent' );
+ $coupon->set_amount( 20 );
+ $coupon->save();
+
+ $this->assertTrue( $order->apply_coupon( $coupon ), 'The percentage coupon should apply successfully.' );
+
+ foreach ( array( 'applied', 'recalculated' ) as $operation ) {
+ if ( 'recalculated' === $operation ) {
+ $order = wc_get_order( $order->get_id() );
+ $order->recalculate_coupons();
+ }
+
+ $coupon_items = $order->get_items( 'coupon' );
+ $coupon_item = reset( $coupon_items );
+ $this->assertEquals( 20, $coupon_item->get_discount(), "The $operation coupon should discount the net subtotal by 20%." );
+ $this->assertEquals( $is_exempt ? 0 : 4, $coupon_item->get_discount_tax(), "The $operation coupon tax should respect the effective exemption." );
+ $this->assertEquals( 20, $order->get_discount_total(), "The $operation order should retain the complete discount." );
+ $this->assertEquals( $is_exempt ? 0 : 4, $order->get_discount_tax(), "The $operation order discount tax should match the coupon." );
+ $this->assertEquals( $is_exempt ? 80 : 96, $order->get_total(), "The $operation order should have the correct discounted total." );
+ }
+ }
+
/**
* @testdox Order and refund defaults capture the store tax mode without pending changes.
* @dataProvider provide_order_tax_defaults