Commit 5fcc775f50c for woocommerce
commit 5fcc775f50c3e49634b60616ea6c12eab85a5cf7
Author: Cvetan Cvetanov <cvetan.cvetanov@automattic.com>
Date: Wed Jul 15 15:46:57 2026 +0300
Fix 0% tax rates not added to refund orders (#66615)
* Preserve 0% tax rates on refund orders
Refunding a line item taxed at 0% dropped the 0% tax line from the
refund order, so $refund->get_items('tax') held no entry for that rate.
0% is a valid rate (zero-rated VAT, tax-exempt classes), not "no tax",
and must be recorded for accurate tax reporting.
The admin refund UI posts each tax total via accounting.unformat() as
the numeric 0, and wc_format_decimal(0) returns the falsy string '0'.
Two cooperating array_filter() calls with no callback then stripped it:
WC_AJAX::refund_line_items() before handing off, and wc_create_refund()
again on its own input. With the entry gone, update_taxes() created no
WC_Order_Item_Tax for the rate.
Keep every numeric refund-tax entry (including 0) at both sites. In
wc_create_refund() the retained values are cast to float so the existing
array_map( 'wc_format_refund_total', ... ) calls, whose callback is
typed float, keep a stable parameter type.
Refs #27118
* Add changelog entry for 0% tax refund fix
* fix: skip refund line items whose refund tax is all zeros
The admin refund form posts a 0 total and 0 tax amount for every
untouched row (only qty fields are omitted when empty). The skip guard
in wc_create_refund() relied on the AJAX handler's callback-less
array_filter emptying refund_tax for those rows; once that filter was
changed to is_numeric to preserve 0% tax lines, an all-zero refund_tax
kept the guard from firing and every untouched item became a phantom
negative refund line item. That in turn revoked customer download
permissions for products that were never refunded.
Guard on the sum of absolute filtered tax amounts instead of raw-array
emptiness. A genuine 0% tax line still survives because its item is
refunded via qty or refund_total, which keep the guard open.
Refs #27118
* test: cover the refund_line_items AJAX path
The 0% tax fix and the untouched-item guard were only tested through
wc_create_refund() directly, while the code that runs when an admin
clicks Refund is WC_AJAX::refund_line_items() with its own
wc_format_decimal/is_numeric filter chain.
Exercise that handler end to end with the exact payload shape the admin
form serializes: a qty-driven refund of a 0% taxed item must carry the
0-rate tax line onto the refund order, and an amount-only refund on a
multi-item order must create no phantom line items and keep download
permissions for products that were not refunded. Both tests fail when
either the AJAX filter or the wc_create_refund() guard is reverted.
Refs #27118
* refactor: use array_filter idiom for refund tax filtering
Replace the init/isset/foreach block in wc_create_refund() with the
behaviorally identical one-line array_filter/is_numeric form already
used in the sibling AJAX fix, keeping the two call sites consistent.
Refs #27118
diff --git a/plugins/woocommerce/changelog/27118-fix-zero-rate-tax-refund b/plugins/woocommerce/changelog/27118-fix-zero-rate-tax-refund
new file mode 100644
index 00000000000..e5a8f0b15b2
--- /dev/null
+++ b/plugins/woocommerce/changelog/27118-fix-zero-rate-tax-refund
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Preserve 0% tax lines when refunding order line items, so zero-rated tax entries are recorded on the refund order instead of being dropped.
diff --git a/plugins/woocommerce/includes/class-wc-ajax.php b/plugins/woocommerce/includes/class-wc-ajax.php
index 3f41fd34875..6c1aba7e10c 100644
--- a/plugins/woocommerce/includes/class-wc-ajax.php
+++ b/plugins/woocommerce/includes/class-wc-ajax.php
@@ -2458,7 +2458,8 @@ class WC_AJAX {
$line_items[ $item_id ]['refund_total'] = wc_format_decimal( $total );
}
foreach ( $line_item_tax_totals as $item_id => $tax_totals ) {
- $line_items[ $item_id ]['refund_tax'] = array_filter( array_map( 'wc_format_decimal', $tax_totals ) );
+ // Use is_numeric so a 0% tax amount ('0') is preserved. A callback-less array_filter would drop it as falsy, losing the 0-rate tax line (0% is a valid rate, not "no tax"). See #27118.
+ $line_items[ $item_id ]['refund_tax'] = array_filter( array_map( 'wc_format_decimal', $tax_totals ), 'is_numeric' );
}
// Create the refund object.
diff --git a/plugins/woocommerce/includes/wc-order-functions.php b/plugins/woocommerce/includes/wc-order-functions.php
index 5ad099b157a..ce3ef477769 100644
--- a/plugins/woocommerce/includes/wc-order-functions.php
+++ b/plugins/woocommerce/includes/wc-order-functions.php
@@ -606,9 +606,16 @@ function wc_create_refund( $args = array() ) {
$qty = isset( $args['line_items'][ $item_id ]['qty'] ) ? $args['line_items'][ $item_id ]['qty'] : 0;
$refund_total = $args['line_items'][ $item_id ]['refund_total'];
- $refund_tax = isset( $args['line_items'][ $item_id ]['refund_tax'] ) ? array_filter( (array) $args['line_items'][ $item_id ]['refund_tax'] ) : array();
- if ( empty( $qty ) && empty( $refund_total ) && empty( $args['line_items'][ $item_id ]['refund_tax'] ) ) {
+ // Keep every numeric tax entry, including a 0% amount that a callback-less array_filter would drop as falsy. 0% is a valid rate, not "no tax". See #27118.
+ $refund_tax = isset( $args['line_items'][ $item_id ]['refund_tax'] ) ? array_map( 'floatval', array_filter( (array) $args['line_items'][ $item_id ]['refund_tax'], 'is_numeric' ) ) : array();
+
+ // The admin refund form posts a 0 total and 0 tax amounts for every untouched row, so an
+ // all-zero refund_tax alone must not create a refund line item (a genuine 0% tax line
+ // still survives, because its item is refunded via qty or refund_total).
+ $refund_tax_sum = array_sum( array_map( 'abs', $refund_tax ) );
+
+ if ( empty( $qty ) && empty( $refund_total ) && empty( $refund_tax_sum ) ) {
continue;
}
diff --git a/plugins/woocommerce/tests/legacy/unit-tests/order/class-wc-tests-order-functions.php b/plugins/woocommerce/tests/legacy/unit-tests/order/class-wc-tests-order-functions.php
index a52124336e7..9c1dc47c227 100644
--- a/plugins/woocommerce/tests/legacy/unit-tests/order/class-wc-tests-order-functions.php
+++ b/plugins/woocommerce/tests/legacy/unit-tests/order/class-wc-tests-order-functions.php
@@ -1501,6 +1501,243 @@ class WC_Tests_Order_Functions extends WC_Unit_Test_Case {
);
}
+ /**
+ * @testdox Refunding a 0% taxed line item preserves the 0-rate tax line on the refund order.
+ *
+ * @link https://github.com/woocommerce/woocommerce/issues/27118
+ */
+ public function test_wc_create_refund_preserves_zero_rate_tax_27118() {
+ update_option( 'woocommerce_calc_taxes', 'yes' );
+
+ $rate_id = WC_Tax::_insert_tax_rate(
+ array(
+ 'tax_rate_country' => '',
+ 'tax_rate_state' => '',
+ 'tax_rate' => '0.0000',
+ 'tax_rate_name' => 'Zero Rate',
+ '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();
+ $product->set_tax_status( ProductTaxStatus::TAXABLE );
+ $product->set_tax_class( '' );
+ $product->save();
+
+ $order = new WC_Order();
+ $order->add_product( $product, 1 );
+ $order->calculate_totals( true );
+ $order->save();
+
+ $line_items = $order->get_items( 'line_item' );
+ $item_id = array_keys( $line_items )[0];
+
+ // The admin refund UI posts the tax total as a numeric 0 (accounting.unformat),
+ // which wc_create_refund() must not silently drop as it would a "no tax" value.
+ $refund = wc_create_refund(
+ array(
+ 'amount' => $order->get_total(),
+ 'order_id' => $order->get_id(),
+ 'line_items' => array(
+ $item_id => array(
+ 'qty' => 1,
+ 'refund_total' => $order->get_total(),
+ 'refund_tax' => array( $rate_id => 0 ),
+ ),
+ ),
+ )
+ );
+
+ $this->assertNotWPError( $refund, 'The refund should be created successfully.' );
+
+ $refund_tax_items = $refund->get_items( 'tax' );
+ $this->assertCount( 1, $refund_tax_items, 'The 0% tax line must be carried over to the refund order.' );
+
+ $refund_tax_item = array_values( $refund_tax_items )[0];
+ $this->assertEquals( $rate_id, $refund_tax_item->get_rate_id(), 'The preserved tax line must reference the 0% rate.' );
+ $this->assertEquals( 0, $refund_tax_item->get_tax_total(), 'The preserved 0% tax line total must be 0.' );
+
+ $refunded_line_item = array_values( $refund->get_items( 'line_item' ) )[0];
+ $this->assertArrayHasKey(
+ $rate_id,
+ $refunded_line_item->get_taxes()['total'],
+ 'The refunded line item must retain the 0% rate in its tax map.'
+ );
+
+ WC_Tax::_delete_tax_rate( $rate_id );
+ update_option( 'woocommerce_calc_taxes', 'no' );
+ }
+
+ /**
+ * @testdox An amount-only refund whose line items carry only zero qty, zero total and zero tax creates no refund line items.
+ */
+ public function test_wc_create_refund_skips_untouched_items_with_zero_tax() {
+ $product_1 = WC_Helper_Product::create_simple_product();
+ $product_2 = WC_Helper_Product::create_simple_product();
+
+ $order = new WC_Order();
+ $order->add_product( $product_1, 1 );
+ $order->add_product( $product_2, 1 );
+ $order->calculate_totals();
+ $order->save();
+
+ $item_ids = array_keys( $order->get_items( 'line_item' ) );
+
+ // The admin refund form posts a 0 total and a 0 tax amount for every
+ // untouched row (only qty fields are omitted when empty), so an
+ // amount-only refund arrives with all-zero line item entries.
+ $line_items = array();
+ foreach ( $item_ids as $item_id ) {
+ $line_items[ $item_id ] = array(
+ 'refund_total' => 0,
+ 'refund_tax' => array( 1 => 0 ),
+ );
+ }
+
+ $refund = wc_create_refund(
+ array(
+ 'amount' => 5,
+ 'order_id' => $order->get_id(),
+ 'line_items' => $line_items,
+ )
+ );
+
+ $this->assertNotWPError( $refund, 'The refund should be created successfully.' );
+ $this->assertCount( 0, $refund->get_items( 'line_item' ), 'Untouched items must not become refund line items.' );
+ $this->assertCount( 0, $refund->get_items( 'tax' ), 'Untouched items must not produce refund tax items.' );
+ }
+
+ /**
+ * @testdox Partially refunding one item creates no refund line items for its untouched siblings.
+ */
+ public function test_wc_create_refund_partial_refund_ignores_untouched_siblings() {
+ $refunded_product = WC_Helper_Product::create_simple_product();
+ $untouched_product = WC_Helper_Product::create_simple_product();
+
+ $order = new WC_Order();
+ $order->add_product( $refunded_product, 1 );
+ $order->add_product( $untouched_product, 1 );
+ $order->calculate_totals();
+ $order->save();
+
+ $refunded_item_id = null;
+ $untouched_item_id = null;
+ foreach ( $order->get_items( 'line_item' ) as $item_id => $item ) {
+ if ( $item->get_product_id() === $refunded_product->get_id() ) {
+ $refunded_item_id = $item_id;
+ } else {
+ $untouched_item_id = $item_id;
+ }
+ }
+
+ $refunded_item_total = $order->get_items( 'line_item' )[ $refunded_item_id ]->get_total();
+
+ $refund = wc_create_refund(
+ array(
+ 'amount' => $refunded_item_total,
+ 'order_id' => $order->get_id(),
+ 'line_items' => array(
+ $refunded_item_id => array(
+ 'qty' => 1,
+ 'refund_total' => $refunded_item_total,
+ 'refund_tax' => array( 1 => 0 ),
+ ),
+ $untouched_item_id => array(
+ 'refund_total' => 0,
+ 'refund_tax' => array( 1 => 0 ),
+ ),
+ ),
+ )
+ );
+
+ $this->assertNotWPError( $refund, 'The refund should be created successfully.' );
+
+ $refund_items = $refund->get_items( 'line_item' );
+ $this->assertCount( 1, $refund_items, 'Only the explicitly refunded item must appear on the refund.' );
+
+ $refunded_item = array_values( $refund_items )[0];
+ $this->assertEquals(
+ $refunded_item_id,
+ $refunded_item->get_meta( '_refunded_item_id' ),
+ 'The refund line item must reference the refunded order item, not an untouched sibling.'
+ );
+ }
+
+ /**
+ * @testdox Partially refunding one item keeps download permissions for products that were not refunded.
+ */
+ public function test_wc_create_refund_keeps_downloads_of_unrefunded_products() {
+ $refunded_product = WC_Helper_Product::create_simple_product();
+
+ $downloadable_product = WC_Helper_Product::create_simple_product();
+ $downloadable_product->set_downloadable( true );
+ $downloadable_product->save();
+
+ $order = new WC_Order();
+ $order->add_product( $refunded_product, 1 );
+ $order->add_product( $downloadable_product, 1 );
+ $order->calculate_totals();
+ $order->save();
+
+ $download = new WC_Customer_Download();
+ $download->set_user_id( 1 );
+ $download->set_order_id( $order->get_id() );
+ $download->set_product_id( $downloadable_product->get_id() );
+ $download->set_download_id( wp_generate_uuid4() );
+ $download->save();
+
+ $refunded_item_id = null;
+ $downloadable_item_id = null;
+ foreach ( $order->get_items( 'line_item' ) as $item_id => $item ) {
+ if ( $item->get_product_id() === $refunded_product->get_id() ) {
+ $refunded_item_id = $item_id;
+ } else {
+ $downloadable_item_id = $item_id;
+ }
+ }
+
+ $refunded_item_total = $order->get_items( 'line_item' )[ $refunded_item_id ]->get_total();
+
+ // The untouched downloadable item still appears in the payload with
+ // zero total and zero tax, exactly as the admin refund form posts it.
+ $refund = wc_create_refund(
+ array(
+ 'amount' => $refunded_item_total,
+ 'order_id' => $order->get_id(),
+ 'line_items' => array(
+ $refunded_item_id => array(
+ 'qty' => 1,
+ 'refund_total' => $refunded_item_total,
+ 'refund_tax' => array( 1 => 0 ),
+ ),
+ $downloadable_item_id => array(
+ 'refund_total' => 0,
+ 'refund_tax' => array( 1 => 0 ),
+ ),
+ ),
+ )
+ );
+
+ $this->assertNotWPError( $refund, 'The refund should be created successfully.' );
+
+ $download_data_store = WC_Data_Store::load( 'customer-download' );
+ $remaining_downloads = $download_data_store->get_downloads(
+ array(
+ 'order_id' => $order->get_id(),
+ 'product_id' => $downloadable_product->get_id(),
+ )
+ );
+ $this->assertCount(
+ 1,
+ $remaining_downloads,
+ 'Download permissions for a product that was not refunded must be kept.'
+ );
+ }
+
/**
* Test wc_sanitize_order_id().
*/
diff --git a/plugins/woocommerce/tests/php/includes/class-wc-ajax-test.php b/plugins/woocommerce/tests/php/includes/class-wc-ajax-test.php
index 668cadf5284..cd2e4d28fbe 100644
--- a/plugins/woocommerce/tests/php/includes/class-wc-ajax-test.php
+++ b/plugins/woocommerce/tests/php/includes/class-wc-ajax-test.php
@@ -692,6 +692,136 @@ class WC_AJAX_Test extends \WP_Ajax_UnitTestCase {
);
}
+ /**
+ * @testdox Refunding a 0% taxed line item via the AJAX handler preserves the 0-rate tax line on the refund order.
+ */
+ public function test_refund_line_items_preserves_zero_rate_tax(): void {
+ update_option( 'woocommerce_calc_taxes', 'yes' );
+
+ $rate_id = WC_Tax::_insert_tax_rate(
+ array(
+ 'tax_rate_country' => '',
+ 'tax_rate_state' => '',
+ 'tax_rate' => '0.0000',
+ 'tax_rate_name' => 'Zero Rate',
+ '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 = new WC_Order();
+ $order->add_product( $product, 1 );
+ $order->calculate_totals( true );
+ $order->save();
+
+ $item_id = array_keys( $order->get_items( 'line_item' ) )[0];
+
+ $this->_setRole( 'administrator' );
+
+ // The exact payload shape the admin refund form serializes: the tax
+ // amount arrives as a numeric 0 (accounting.unformat of an empty field).
+ $_POST['security'] = wp_create_nonce( 'order-item' );
+ $_POST['order_id'] = $order->get_id();
+ $_POST['refund_amount'] = $order->get_total();
+ $_POST['refunded_amount'] = '0';
+ $_POST['refund_reason'] = '';
+ $_POST['line_item_qtys'] = wp_json_encode( array( $item_id => 1 ) );
+ $_POST['line_item_totals'] = wp_json_encode( array( $item_id => $order->get_total() ) );
+ $_POST['line_item_tax_totals'] = wp_json_encode( array( $item_id => array( $rate_id => 0 ) ) );
+ $_POST['api_refund'] = 'false';
+
+ $response = $this->do_ajax( 'woocommerce_refund_line_items' );
+
+ $this->assertTrue( $response['success'] ?? false, 'The AJAX refund request should succeed.' );
+
+ $refunds = wc_get_order( $order->get_id() )->get_refunds();
+ $this->assertCount( 1, $refunds, 'One refund should be created for the order.' );
+
+ $refund_tax_items = $refunds[0]->get_items( 'tax' );
+ $this->assertCount( 1, $refund_tax_items, 'The 0% tax line must be carried over to the refund order.' );
+ $this->assertEquals(
+ $rate_id,
+ array_values( $refund_tax_items )[0]->get_rate_id(),
+ 'The preserved tax line must reference the 0% rate.'
+ );
+
+ unset( $_POST['security'], $_POST['order_id'], $_POST['refund_amount'], $_POST['refunded_amount'], $_POST['refund_reason'], $_POST['line_item_qtys'], $_POST['line_item_totals'], $_POST['line_item_tax_totals'], $_POST['api_refund'] );
+ WC_Tax::_delete_tax_rate( $rate_id );
+ update_option( 'woocommerce_calc_taxes', 'no' );
+ }
+
+ /**
+ * @testdox An amount-only AJAX refund on a multi-item order creates no line items and keeps downloads of unrefunded products.
+ */
+ public function test_refund_line_items_amount_only_skips_untouched_items(): void {
+ $product = WC_Helper_Product::create_simple_product();
+
+ $downloadable_product = WC_Helper_Product::create_simple_product();
+ $downloadable_product->set_downloadable( true );
+ $downloadable_product->save();
+
+ $order = new WC_Order();
+ $order->add_product( $product, 1 );
+ $order->add_product( $downloadable_product, 1 );
+ $order->calculate_totals();
+ $order->save();
+
+ $download = new WC_Customer_Download();
+ $download->set_user_id( 1 );
+ $download->set_order_id( $order->get_id() );
+ $download->set_product_id( $downloadable_product->get_id() );
+ $download->set_download_id( wp_generate_uuid4() );
+ $download->save();
+
+ $item_ids = array_keys( $order->get_items( 'line_item' ) );
+
+ $this->_setRole( 'administrator' );
+
+ // An amount-only refund: the form posts no qtys, but a 0 total and a 0
+ // tax amount for every row in the order (those inputs are not gated).
+ $totals = array();
+ $tax_totals = array();
+ foreach ( $item_ids as $item_id ) {
+ $totals[ $item_id ] = 0;
+ $tax_totals[ $item_id ] = array( 1 => 0 );
+ }
+
+ $_POST['security'] = wp_create_nonce( 'order-item' );
+ $_POST['order_id'] = $order->get_id();
+ $_POST['refund_amount'] = '5';
+ $_POST['refunded_amount'] = '0';
+ $_POST['refund_reason'] = '';
+ $_POST['line_item_qtys'] = wp_json_encode( array() );
+ $_POST['line_item_totals'] = wp_json_encode( $totals );
+ $_POST['line_item_tax_totals'] = wp_json_encode( $tax_totals );
+ $_POST['api_refund'] = 'false';
+
+ $response = $this->do_ajax( 'woocommerce_refund_line_items' );
+
+ $this->assertTrue( $response['success'] ?? false, 'The AJAX refund request should succeed.' );
+
+ $refunds = wc_get_order( $order->get_id() )->get_refunds();
+ $this->assertCount( 1, $refunds, 'One refund should be created for the order.' );
+ $this->assertCount( 0, $refunds[0]->get_items( 'line_item' ), 'Untouched items must not become refund line items.' );
+ $this->assertCount( 0, $refunds[0]->get_items( 'tax' ), 'Untouched items must not produce refund tax items.' );
+
+ $download_data_store = WC_Data_Store::load( 'customer-download' );
+ $remaining_downloads = $download_data_store->get_downloads(
+ array(
+ 'order_id' => $order->get_id(),
+ 'product_id' => $downloadable_product->get_id(),
+ )
+ );
+ $this->assertCount( 1, $remaining_downloads, 'Download permissions for a product that was not refunded must be kept.' );
+
+ unset( $_POST['security'], $_POST['order_id'], $_POST['refund_amount'], $_POST['refunded_amount'], $_POST['refund_reason'], $_POST['line_item_qtys'], $_POST['line_item_totals'], $_POST['line_item_tax_totals'], $_POST['api_refund'] );
+ }
+
/**
* Does the 'hard work' of triggering an ajax endpoint and capturing the response.
*