Commit 71ee24c0d1 for woocommerce

commit 71ee24c0d1aa94e0540489068236ed2afcaf9540
Author: Łukasz Strączyński <8038404+straku@users.noreply.github.com>
Date:   Fri Feb 20 13:35:29 2026 +0100

    Fix tax rounding error in v4 refunds API (#63046)

    Round extracted taxes to display precision (wc_get_price_decimals)
    before calculating the refund base amount. This prevents 1 cent
    discrepancies when multiple tax rates with decimal percentages
    are extracted from an inclusive total.

diff --git a/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Refunds/DataUtils.php b/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Refunds/DataUtils.php
index 9ec1615f1a..9363214569 100644
--- a/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Refunds/DataUtils.php
+++ b/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Refunds/DataUtils.php
@@ -9,6 +9,7 @@ namespace Automattic\WooCommerce\Internal\RestApi\Routes\V4\Refunds;

 defined( 'ABSPATH' ) || exit;

+use Automattic\WooCommerce\Utilities\NumberUtil;
 use WP_Error;
 use WC_Order;
 use WC_Tax;
@@ -69,13 +70,23 @@ class DataUtils {
 							$tax_rates
 						);

+						// Round extracted taxes to display precision to match how original taxes were stored.
+						// This prevents rounding errors where internal precision (6DP) differs from storage precision (2DP).
+						$price_decimals   = wc_get_price_decimals();
+						$calculated_taxes = array_map(
+							function ( $tax ) use ( $price_decimals ) {
+								return NumberUtil::round( $tax, $price_decimals );
+							},
+							$calculated_taxes
+						);
+
 						$line_item['refund_tax'] = $this->convert_proportional_taxes_to_schema_format(
 							$calculated_taxes
 						);

 						// Subtract extracted tax from refund_total to get the amount excluding tax.
 						$total_tax                 = array_sum( $calculated_taxes );
-						$line_item['refund_total'] = $line_item['refund_total'] - $total_tax;
+						$line_item['refund_total'] = NumberUtil::round( $line_item['refund_total'] - $total_tax, $price_decimals );
 					}
 				}
 			}
diff --git a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version4/Refunds/class-wc-rest-refunds-v4-controller-tests.php b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version4/Refunds/class-wc-rest-refunds-v4-controller-tests.php
index f325195b4c..738f48ffe7 100644
--- a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version4/Refunds/class-wc-rest-refunds-v4-controller-tests.php
+++ b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version4/Refunds/class-wc-rest-refunds-v4-controller-tests.php
@@ -1101,4 +1101,213 @@ class WC_REST_Refunds_V4_Controller_Tests extends WC_REST_Unit_Test_Case {
 		// Track for cleanup.
 		$this->created_refunds[] = $response_data['id'];
 	}
+
+	/**
+	 * Test refund creation with automatic tax extraction handles rounding correctly.
+	 *
+	 * This tests a specific scenario where multiple tax rates with decimals can cause
+	 * a 1 cent rounding error when extracting taxes from an inclusive total.
+	 *
+	 * Scenario: $50.00 item with California-style tax rates:
+	 * - County Tax: 1% = $0.50
+	 * - Special Tax: 3.25% = $1.625 → $1.63 (rounded)
+	 * - State Sales Tax: 6.25% = $3.125 → $3.13 (rounded)
+	 * - Total tax: $5.26
+	 * - Total with tax: $55.26
+	 *
+	 * The bug: When extracting taxes from $55.26 using calc_inclusive_tax(),
+	 * the internal precision (6DP) extraction gives different results than
+	 * the 2DP rounded values that were used to build the original total.
+	 * This causes the base to be calculated as $50.01 instead of $50.00.
+	 *
+	 * @link https://github.com/woocommerce/woocommerce/issues/XXXXX
+	 */
+	public function test_refunds_create_with_automatic_tax_extraction_rounding_precision(): void {
+		// Create three non-compound tax rates matching California-style setup.
+		// Priority 1 = all applied to base price (not compound).
+		$tax_rate_county = WC_Tax::_insert_tax_rate(
+			array(
+				'tax_rate_country'  => 'US',
+				'tax_rate_state'    => 'CA',
+				'tax_rate'          => '1.0000',
+				'tax_rate_name'     => 'County Tax',
+				'tax_rate_priority' => '1',
+				'tax_rate_compound' => '0',
+				'tax_rate_shipping' => '1',
+				'tax_rate_order'    => '1',
+				'tax_rate_class'    => '',
+			)
+		);
+
+		$tax_rate_special = WC_Tax::_insert_tax_rate(
+			array(
+				'tax_rate_country'  => 'US',
+				'tax_rate_state'    => 'CA',
+				'tax_rate'          => '3.2500',
+				'tax_rate_name'     => 'Special Tax',
+				'tax_rate_priority' => '1',
+				'tax_rate_compound' => '0',
+				'tax_rate_shipping' => '1',
+				'tax_rate_order'    => '2',
+				'tax_rate_class'    => '',
+			)
+		);
+
+		$tax_rate_state = WC_Tax::_insert_tax_rate(
+			array(
+				'tax_rate_country'  => 'US',
+				'tax_rate_state'    => 'CA',
+				'tax_rate'          => '6.2500',
+				'tax_rate_name'     => 'State Sales Tax',
+				'tax_rate_priority' => '1',
+				'tax_rate_compound' => '0',
+				'tax_rate_shipping' => '1',
+				'tax_rate_order'    => '3',
+				'tax_rate_class'    => '',
+			)
+		);
+
+		// Create order with $50.00 product.
+		$product = WC_Helper_Product::create_simple_product();
+		$product->set_regular_price( 50.00 );
+		$product->set_tax_status( 'taxable' );
+		$product->save();
+
+		$order = wc_create_order();
+		$item  = new WC_Order_Item_Product();
+		$item->set_props(
+			array(
+				'product'  => $product,
+				'quantity' => 1,
+				'subtotal' => 50.00,
+				'total'    => 50.00,
+			)
+		);
+
+		// Set taxes as they would be calculated by WooCommerce (forward calculation).
+		// County: 50 × 0.01 = 0.50.
+		// Special: 50 × 0.0325 = 1.625 → stored/displayed as 1.63.
+		// State: 50 × 0.0625 = 3.125 → stored/displayed as 3.13.
+		$item->set_taxes(
+			array(
+				'total'    => array(
+					$tax_rate_county  => '0.50',
+					$tax_rate_special => '1.63',
+					$tax_rate_state   => '3.13',
+				),
+				'subtotal' => array(
+					$tax_rate_county  => '0.50',
+					$tax_rate_special => '1.63',
+					$tax_rate_state   => '3.13',
+				),
+			)
+		);
+		$item->save();
+		$order->add_item( $item );
+
+		// Add tax items to the order.
+		$tax_item_county = new WC_Order_Item_Tax();
+		$tax_item_county->set_rate( $tax_rate_county );
+		$tax_item_county->set_tax_total( 0.50 );
+		$tax_item_county->save();
+		$order->add_item( $tax_item_county );
+
+		$tax_item_special = new WC_Order_Item_Tax();
+		$tax_item_special->set_rate( $tax_rate_special );
+		$tax_item_special->set_tax_total( 1.63 );
+		$tax_item_special->save();
+		$order->add_item( $tax_item_special );
+
+		$tax_item_state = new WC_Order_Item_Tax();
+		$tax_item_state->set_rate( $tax_rate_state );
+		$tax_item_state->set_tax_total( 3.13 );
+		$tax_item_state->save();
+		$order->add_item( $tax_item_state );
+
+		$order->set_billing_country( 'US' );
+		$order->set_billing_state( 'CA' );
+		$order->set_total( 55.26 ); // 50.00 + 0.50 + 1.63 + 3.13.
+		$order->save();
+
+		$this->created_orders[] = $order->get_id();
+
+		// Create full refund with just refund_total (should extract taxes automatically).
+		// The v4 API should extract taxes from the inclusive amount and get the correct base.
+		$refund_data = array(
+			'order_id'   => $order->get_id(),
+			'amount'     => 55.26,
+			'reason'     => 'Testing rounding precision with multiple tax rates',
+			'line_items' => array(
+				array(
+					'line_item_id' => $item->get_id(),
+					'quantity'     => 1,
+					'refund_total' => 55.26, // Includes all taxes.
+				),
+			),
+		);
+
+		$request = new WP_REST_Request( 'POST', '/wc/v4/refunds' );
+		$request->set_body_params( $refund_data );
+		$response = $this->server->dispatch( $request );
+
+		$this->assertEquals( 201, $response->get_status(), 'Refund should be created successfully' );
+		$response_data = $response->get_data();
+
+		// Verify the refund was created.
+		$this->assertIsArray( $response_data );
+		$this->assertArrayHasKey( 'id', $response_data );
+		$this->assertEquals( $order->get_id(), $response_data['order_id'] );
+
+		// Total refund amount should be 55.26.
+		$this->assertEquals( '55.26', $response_data['amount'], 'Refund amount should be 55.26' );
+
+		// Get the actual refund object to check line item details.
+		$refund           = wc_get_order( $response_data['id'] );
+		$refund_items     = $refund->get_items( 'line_item' );
+		$refund_line_item = reset( $refund_items );
+
+		// CRITICAL: Line item total should be exactly -50.00, not -50.01.
+		// This is the core of the rounding bug being tested.
+		$this->assertEquals(
+			-50.00,
+			(float) $refund_line_item->get_total(),
+			'Line item total should be exactly -50.00 (not -50.01 due to rounding error)'
+		);
+
+		// Verify extracted taxes match the original order taxes.
+		$refund_taxes = $refund_line_item->get_taxes();
+		$this->assertEquals(
+			-0.50,
+			(float) $refund_taxes['total'][ $tax_rate_county ],
+			'County Tax refund should be -0.50'
+		);
+		$this->assertEquals(
+			-1.63,
+			(float) $refund_taxes['total'][ $tax_rate_special ],
+			'Special Tax refund should be -1.63'
+		);
+		$this->assertEquals(
+			-3.13,
+			(float) $refund_taxes['total'][ $tax_rate_state ],
+			'State Sales Tax refund should be -3.13'
+		);
+
+		// Verify the math adds up: base + all taxes = total refund amount.
+		$calculated_total = abs( (float) $refund_line_item->get_total() )
+			+ abs( (float) $refund_taxes['total'][ $tax_rate_county ] )
+			+ abs( (float) $refund_taxes['total'][ $tax_rate_special ] )
+			+ abs( (float) $refund_taxes['total'][ $tax_rate_state ] );
+		$this->assertEqualsWithDelta(
+			55.26,
+			$calculated_total,
+			0.001,
+			'Sum of base and taxes should equal the refund amount'
+		);
+
+		// Track for cleanup.
+		$this->created_refunds[] = $response_data['id'];
+
+		// Clean up product.
+		$product->delete( true );
+	}
 }