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