Commit 15b5312464b for woocommerce

commit 15b5312464b2cb1ba746e2d57facdb1f6454571a
Author: Mike Jolley <mike.jolley@me.com>
Date:   Mon Mar 16 11:16:39 2026 +0000

    Fix floating point precision error in REST API v4 refund amount validation (#63667)

    * Fix floating point precision error in REST API v4 refund amount validation

    PHP floating point arithmetic (e.g. 43.20 + 19.20 = 62.400000000000006)
    caused the under-refund validation to falsely reject valid refund amounts.
    Round calculated totals using NumberUtil::round() to match store decimal
    precision before comparison.

    Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

    * Improve refund validation error message to include both amounts

    Show both the refund amount and the calculated line items total in the
    error message so the caller can see exactly where the mismatch is.

    Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

    * Add changelog entry for refund float comparison fix

    Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

    * Use wc_get_price_decimals() in messages from API

    ---------

    Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>

diff --git a/plugins/woocommerce/changelog/fix-refund-float-compare b/plugins/woocommerce/changelog/fix-refund-float-compare
new file mode 100644
index 00000000000..1e117c6d40c
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-refund-float-compare
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Fix floating point precision error in REST API v4 refund amount validation that caused valid refunds to be rejected.
diff --git a/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Refunds/Controller.php b/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Refunds/Controller.php
index 2c84977244c..01b6a786419 100644
--- a/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Refunds/Controller.php
+++ b/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Refunds/Controller.php
@@ -16,6 +16,7 @@ defined( 'ABSPATH' ) || exit;
 use Automattic\WooCommerce\Internal\RestApi\Routes\V4\AbstractController;
 use Automattic\WooCommerce\StoreApi\Utilities\Pagination;
 use Automattic\WooCommerce\Internal\RestApi\Routes\V4\Refunds\Schema\RefundSchema;
+use Automattic\WooCommerce\Utilities\NumberUtil;
 use WP_Http;
 use WP_Error;
 use WC_Order_Refund;
@@ -309,13 +310,14 @@ class Controller extends AbstractController {

 			// Prevent under-refunding: amount cannot be less than calculated line items total.
 			// Over-refunding is allowed for goodwill/compensation scenarios.
-			if ( ! empty( $request['amount'] ) && $calculated_total > 0 && $refund_amount < $calculated_total ) {
+			if ( ! empty( $request['amount'] ) && $calculated_total > 0 && NumberUtil::round( (float) $refund_amount, wc_get_price_decimals() ) < NumberUtil::round( $calculated_total, wc_get_price_decimals() ) ) {
 				return $this->get_route_error_response(
 					'invalid_refund_amount',
 					sprintf(
-						/* translators: %s: calculated total from line items */
-						__( 'Refund amount cannot be less than the total of line items (%s).', 'woocommerce' ),
-						wc_format_decimal( $calculated_total, 2 )
+						/* translators: %1$s: refund amount, %2$s: calculated total from line items */
+						__( 'Refund amount (%1$s) cannot be less than the total of line items (%2$s).', 'woocommerce' ),
+						wc_format_decimal( $refund_amount, wc_get_price_decimals() ),
+						wc_format_decimal( $calculated_total, wc_get_price_decimals() )
 					)
 				);
 			}
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 93632145697..f247783996f 100644
--- a/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Refunds/DataUtils.php
+++ b/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Refunds/DataUtils.php
@@ -147,7 +147,7 @@ class DataUtils {
 			}
 		}

-		return $amount;
+		return (float) NumberUtil::round( $amount, wc_get_price_decimals() );
 	}

 	/**
diff --git a/plugins/woocommerce/tests/php/src/Internal/RestApi/Routes/V4/Refunds/DataUtilsTest.php b/plugins/woocommerce/tests/php/src/Internal/RestApi/Routes/V4/Refunds/DataUtilsTest.php
index cc83fb58eed..4759e733eab 100644
--- a/plugins/woocommerce/tests/php/src/Internal/RestApi/Routes/V4/Refunds/DataUtilsTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/RestApi/Routes/V4/Refunds/DataUtilsTest.php
@@ -301,6 +301,64 @@ class DataUtilsTest extends WC_Unit_Test_Case {
 		$this->assertEmpty( $result[ $shipping_item->get_id() ]['refund_tax'] );
 	}

+	/**
+	 * Test that calculate_refund_amount handles floating point precision correctly.
+	 *
+	 * Values like 43.20 + 19.20 can produce 62.400000000000006 in PHP due to IEEE 754
+	 * floating point representation. The method should round the result to avoid false
+	 * positives in under-refund validation.
+	 */
+	public function test_calculate_refund_amount_avoids_floating_point_errors() {
+		$line_items = array(
+			array(
+				'line_item_id' => '62',
+				'quantity'     => 2,
+				'refund_total' => '43.20',
+			),
+			array(
+				'line_item_id' => '63',
+				'quantity'     => 1,
+				'refund_total' => '19.20',
+			),
+		);
+
+		$result = $this->data_utils->calculate_refund_amount( $line_items );
+
+		// Without rounding, 43.20 + 19.20 = 62.400000000000006 in PHP.
+		// The method should return exactly 62.40.
+		$this->assertSame( 62.40, $result );
+	}
+
+	/**
+	 * Test that calculate_refund_amount includes tax totals.
+	 */
+	public function test_calculate_refund_amount_includes_tax() {
+		$line_items = array(
+			array(
+				'line_item_id' => '1',
+				'quantity'     => 1,
+				'refund_total' => '10.00',
+				'refund_tax'   => array(
+					array(
+						'id'           => 1,
+						'refund_total' => '1.50',
+					),
+				),
+			),
+		);
+
+		$result = $this->data_utils->calculate_refund_amount( $line_items );
+
+		$this->assertSame( 11.50, $result );
+	}
+
+	/**
+	 * Test that calculate_refund_amount returns null for empty line items.
+	 */
+	public function test_calculate_refund_amount_returns_null_for_empty() {
+		$this->assertNull( $this->data_utils->calculate_refund_amount( array() ) );
+	}
+
 	/**
 	 * Helper: Create an order with shipping that has tax rate IDs but zero tax amounts.
 	 *