Commit fad009ba610 for woocommerce

commit fad009ba61090891b6ee4d2e5374d26b218ebd67
Author: Samuel Urbanowicz <samiuelson@gmail.com>
Date:   Thu Jul 23 17:08:55 2026 +0200

    Cap quantity-form v4 refund amounts to the line's remaining refundable amount (#66884)

    * Rename amount to total in v4 refunds creation endpoint

    * Add changelog entry for v4 refunds amount rename

    * Add changefile(s) from automation for the following project(s): woocommerce

    * Reject the legacy amount field in v4 refund creation

    * Remove bot-generated duplicate changelog entry

    * Add changefile(s) from automation for the following project(s): woocommerce

    * Reject an explicitly supplied zero total in v4 refund creation

    * Remove bot-generated duplicate changelog entry

    * Add changefile(s) from automation for the following project(s): woocommerce

    * Reject an explicit null legacy amount and drop the regenerated bot changelog

    * Cap quantity-form v4 refund amounts to the line's remaining refundable amount

    * Gate quantity-form top-up on consistent refund history and share one refund snapshot

    * Preserve original DataUtils signatures via additive snapshot-aware entry points

    * Route controller dispatch through the original DataUtils methods to preserve overrides

    ---------

    Co-authored-by: woocommercebot <woocommercebot@users.noreply.github.com>

diff --git a/plugins/woocommerce/changelog/fix-v4-refunds-clamp-quantity-refund-to-remaining b/plugins/woocommerce/changelog/fix-v4-refunds-clamp-quantity-refund-to-remaining
new file mode 100644
index 00000000000..e5a966b916c
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-v4-refunds-clamp-quantity-refund-to-remaining
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Cap quantity-form v4 refund amounts to the line's remaining refundable amount so sequential partial refunds can always refund the remaining items instead of failing on rounding drift.
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 5ada37e4219..f01e8fa6aab 100644
--- a/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Refunds/DataUtils.php
+++ b/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Refunds/DataUtils.php
@@ -197,6 +197,9 @@ class DataUtils {

 		// Precompute refunded quantities/totals once so the over-refund check
 		// below caps against remaining refundable quantity, not the original.
+		// Loaded here rather than passed in: the controller dispatches through this
+		// method so subclass overrides keep working, and WC_Order::get_refunds()
+		// serves repeat loads within the request from the object cache.
 		$refund_data = $this->compute_refunded_quantities_and_totals( $order );

 		$seen_ids = array();
@@ -397,8 +400,11 @@ class DataUtils {
 					);
 				}

+				// Remaining is rounded to currency precision before both checks, so a
+				// sub-cent residue left by rounding drift counts as fully refunded
+				// rather than producing a "cannot exceed 0.00" rejection.
 				$refunded_total  = abs( (float) ( $refund_data['totals'][ $line_item_id ] ?? 0.0 ) );
-				$remaining_total = $item_total_with_tax - $refunded_total;
+				$remaining_total = NumberUtil::round( $item_total_with_tax - $refunded_total, $price_decimals );
 				if ( $remaining_total <= 0 ) {
 					return new WP_Error(
 						'line_item_already_refunded',
@@ -406,7 +412,7 @@ class DataUtils {
 						array( 'status' => WP_Http::UNPROCESSABLE_ENTITY )
 					);
 				}
-				if ( $abs_refund_total > NumberUtil::round( $remaining_total, $price_decimals ) ) {
+				if ( $abs_refund_total > $remaining_total ) {
 					return new WP_Error(
 						'refund_total_exceeds_remaining',
 						sprintf(
@@ -646,6 +652,106 @@ class DataUtils {
 		return NumberUtil::round( (float) $item->get_total() + (float) $item->get_total_tax(), $price_decimals );
 	}

+	/**
+	 * Compute the tax-inclusive refund total for a quantity-form line item, capped to
+	 * the line's remaining refundable amount.
+	 *
+	 * {@see compute_line_item_refund_total()} rounds each request independently, so a
+	 * sequence of partial quantity refunds can drift from the stored line gross by up
+	 * to a cent per request — round(unit × 2) + round(unit × 4) may exceed the line
+	 * total that round(unit × 6) would produce. The quantity form is "server, compute
+	 * the amount for me", so rather than rejecting its own arithmetic the server caps
+	 * the result:
+	 *
+	 * - The unit-derived amount is always clamped down to the remaining amount, so
+	 *   rounding drift can never push it over the remaining-amount cap the validators
+	 *   enforce. Shipping and fee lines carry a single unit whose derived amount is the
+	 *   full line total, so partially-refunded ones always resolve to their remainder
+	 *   through this clamp.
+	 * - A product quantity that consumes the line's remaining refundable units is
+	 *   topped up to the exact remaining amount — closing the line at currency
+	 *   precision with no stranded cents — but only when every prior refund on the
+	 *   line matches its own quantity-derived amount, i.e. the shortfall is provably
+	 *   accumulated rounding drift. An off-schedule prior refund (an explicit partial
+	 *   amount, or a dollar-only refund with no units) means the residue was
+	 *   deliberately withheld, and the quantity form must never silently pay it back
+	 *   out; the residue stays refundable through an explicit refund_total.
+	 *
+	 * When no refundable amount remains, the unclamped amount is returned as-is:
+	 * clamping to zero would trip the zero-refund guard with a misleading error, while
+	 * the validators reject the line with line_item_already_refunded. Explicit
+	 * client-supplied refund_total values are never capped — those stay strictly
+	 * validated.
+	 *
+	 * @param WC_Order_Item_Product|WC_Order_Item_Shipping|WC_Order_Item_Fee $item        The order item.
+	 * @param int                                                            $quantity    The quantity to refund (>= 1).
+	 * @param array                                                          $refund_data Refund-history snapshot from {@see compute_refunded_quantities_and_totals()} (see its return shape).
+	 * @return float The tax-inclusive refund total, carrying the line's sign.
+	 *
+	 * @since 11.1.0
+	 */
+	private function compute_quantity_refund_total( $item, int $quantity, array $refund_data ): float {
+		$computed          = $this->compute_line_item_refund_total( $item, $quantity );
+		$price_decimals    = wc_get_price_decimals();
+		$signed_line_total = (float) $item->get_total() + (float) $item->get_total_tax();
+		$refunded_total    = abs( (float) ( $refund_data['totals'][ $item->get_id() ] ?? 0.0 ) );
+		$remaining_total   = NumberUtil::round( abs( $signed_line_total ) - $refunded_total, $price_decimals );
+
+		if ( $remaining_total <= 0 ) {
+			return $computed;
+		}
+
+		$sign         = $signed_line_total < 0 ? -1.0 : 1.0;
+		$abs_computed = abs( $computed );
+
+		if ( $abs_computed > $remaining_total ) {
+			return $sign * $remaining_total;
+		}
+
+		if ( $item instanceof WC_Order_Item_Product && $abs_computed < $remaining_total ) {
+			$remaining_qty = $item->get_quantity() + ( $refund_data['qtys'][ $item->get_id() ] ?? 0 );
+			if (
+				$quantity >= $remaining_qty &&
+				$this->line_refund_history_matches_quantities( $item, $refund_data['line_refunds'][ $item->get_id() ] ?? array() )
+			) {
+				return $sign * $remaining_total;
+			}
+		}
+
+		return $computed;
+	}
+
+	/**
+	 * Whether every prior refund line for a product matches its quantity-derived amount.
+	 *
+	 * True means the difference between the line's refunded total and the sum of
+	 * unit-price amounts is pure rounding drift, so the final-chunk top-up in
+	 * {@see compute_quantity_refund_total()} can safely reconcile it. Gross values are
+	 * compared as formatted decimals at currency precision — never raw float
+	 * equality. A refund line with no units (qty 0, the dollar-only form) is
+	 * off-schedule by definition.
+	 *
+	 * @param WC_Order_Item_Product $item         The original order line.
+	 * @param array                 $line_refunds Prior refund lines as list<array{qty: int, gross: float}> (positive magnitudes).
+	 * @return bool
+	 */
+	private function line_refund_history_matches_quantities( WC_Order_Item_Product $item, array $line_refunds ): bool {
+		$price_decimals = wc_get_price_decimals();
+
+		foreach ( $line_refunds as $refund_line ) {
+			if ( $refund_line['qty'] <= 0 ) {
+				return false;
+			}
+
+			$expected = abs( $this->compute_line_item_refund_total( $item, $refund_line['qty'] ) );
+			if ( wc_format_decimal( $refund_line['gross'], $price_decimals ) !== wc_format_decimal( $expected, $price_decimals ) ) {
+				return false;
+			}
+		}
+
+		return true;
+	}
+
 	/**
 	 * Round every caller-supplied refund_total to currency precision.
 	 *
@@ -672,7 +778,8 @@ class DataUtils {

 	/**
 	 * Fill in refund_total for any line item that omits it, computing the value from
-	 * the order item's unit price × quantity via compute_line_item_refund_total().
+	 * the order item's unit price × quantity via compute_quantity_refund_total(),
+	 * which caps the result to the line's remaining refundable amount.
 	 *
 	 * Items that already have refund_total (including an explicit 0) are left
 	 * untouched so validation can decide whether the explicit amount is valid.
@@ -697,9 +804,13 @@ class DataUtils {
 	public function fill_missing_refund_totals( array $line_items, WC_Order $order ): array {
 		// Round caller-supplied amounts up front so explicit values are stored at the
 		// same precision the preview validated and showed. Computed values below are
-		// already rounded by compute_line_item_refund_total().
+		// already rounded by compute_quantity_refund_total().
 		$line_items = $this->normalize_refund_totals( $line_items );

+		// Loaded lazily: only requests with at least one auto-computed line pay for
+		// the refund-history scan the remaining-amount cap needs.
+		$refund_data = null;
+
 		foreach ( $line_items as $key => $line_item ) {
 			// Treat a missing key and an explicit `null` value the same — both mean
 			// "compute it for me". An explicit `0` is caller-supplied input, so leave
@@ -739,7 +850,11 @@ class DataUtils {
 				continue;
 			}

-			$line_items[ $key ]['refund_total'] = $this->compute_line_item_refund_total( $item, $quantity );
+			if ( null === $refund_data ) {
+				$refund_data = $this->compute_refunded_quantities_and_totals( $order );
+			}
+
+			$line_items[ $key ]['refund_total'] = $this->compute_quantity_refund_total( $item, $quantity, $refund_data );
 		}

 		return $line_items;
@@ -754,7 +869,9 @@ class DataUtils {
 	 * Each line item must have 'line_item_id' and at least one of 'quantity'
 	 * (positive int) or 'refund_total' (positive tax-inclusive float). When
 	 * 'refund_total' is present and positive it is used directly; otherwise the
-	 * total is computed from quantity via {@see compute_line_item_refund_total()}.
+	 * total is computed from quantity via {@see compute_quantity_refund_total()},
+	 * capped to the line's remaining refundable amount — the same computation the
+	 * create flow stores, so the previewed amounts always match the created refund.
 	 *
 	 * @param WC_Order $order      The order being previewed for refund.
 	 * @param array    $line_items Line items. Each: array{line_item_id: int, quantity?: int, refund_total?: float}.
@@ -765,6 +882,7 @@ class DataUtils {
 	 */
 	public function build_refund_preview( WC_Order $order, array $line_items ): array {
 		$price_decimals = wc_get_price_decimals();
+		$refund_data    = $this->compute_refunded_quantities_and_totals( $order );
 		$sections       = array(
 			'products' => array(
 				'items'    => array(),
@@ -800,14 +918,16 @@ class DataUtils {
 			 * @var WC_Order_Item_Product|WC_Order_Item_Shipping|WC_Order_Item_Fee $item
 			 */
 			// When the caller provides an explicit refund_total (partial-amount form) use it
-			// directly. The quantity-based form computes the tax-inclusive total from unit price.
+			// directly. The quantity-based form computes the tax-inclusive total from unit price,
+			// capped to the line's remaining refundable amount — the same computation
+			// fill_missing_refund_totals() feeds the create flow.
 			// A non-zero check (not > 0) mirrors validate_preview_line_items(), which accepts a
 			// negative refund_total for a negative discount line and rejects a present-but-zero
 			// one before this method runs — so a signed value is honoured rather than falling
 			// through to the (possibly absent) quantity.
 			$refund_total_with_tax = isset( $line_item['refund_total'] ) && is_numeric( $line_item['refund_total'] ) && 0.0 !== (float) $line_item['refund_total']
 				? NumberUtil::round( (float) $line_item['refund_total'], $price_decimals )
-				: $this->compute_line_item_refund_total( $item, (int) $line_item['quantity'] );
+				: $this->compute_quantity_refund_total( $item, (int) $line_item['quantity'], $refund_data );

 			// Split by the line's own stored total/tax ratio so the preview reflects what
 			// was actually charged and matches the split create stores (both call this).
@@ -1013,9 +1133,11 @@ class DataUtils {

 				// Cap against the remaining refundable amount for this line.
 				// compute_refunded_quantities_and_totals() tracks tax-inclusive totals
-				// for all item types so the comparison is consistent.
+				// for all item types so the comparison is consistent. Remaining is
+				// rounded to currency precision before both checks, matching
+				// validate_line_items(), so a sub-cent residue counts as fully refunded.
 				$refunded_total  = abs( (float) ( $refund_data['totals'][ $line_item_id ] ?? 0.0 ) );
-				$remaining_total = $item_total_with_tax - $refunded_total;
+				$remaining_total = NumberUtil::round( $item_total_with_tax - $refunded_total, $price_decimals );
 				if ( $remaining_total <= 0 ) {
 					return new WP_Error(
 						'line_item_already_refunded',
@@ -1023,7 +1145,7 @@ class DataUtils {
 						array( 'status' => WP_Http::UNPROCESSABLE_ENTITY )
 					);
 				}
-				if ( $abs_refund_total > NumberUtil::round( $remaining_total, $price_decimals ) ) {
+				if ( $abs_refund_total > $remaining_total ) {
 					return new WP_Error(
 						'refund_total_exceeds_remaining',
 						sprintf(
@@ -1065,14 +1187,15 @@ class DataUtils {
 				}
 			}

-			// Amount-from-quantity cap: when the amount is derived from quantity (no explicit
-			// refund_total), cap the computed tax-inclusive amount against the remaining line
-			// amount for every item type. Mirrors create, which auto-fills refund_total from
-			// quantity and then applies the same cap — so a product with prior amount-only
-			// refunds (units still uncounted) can no longer preview an over-refund.
+			// Amount-from-quantity: the server derives the amount itself via
+			// compute_quantity_refund_total(), which caps it to the remaining line amount,
+			// so the only invalid state left to reject is a line with nothing refundable
+			// remaining. Rounded to currency precision so a sub-cent residue left by
+			// rounding drift counts as fully refunded. Create fills refund_total through
+			// the same capped computation, so preview and create accept identical input.
 			if ( $has_quantity && ! $has_refund_total ) {
 				$refunded_total  = abs( (float) ( $refund_data['totals'][ $line_item_id ] ?? 0.0 ) );
-				$remaining_total = abs( $signed_line_total ) - $refunded_total;
+				$remaining_total = NumberUtil::round( abs( $signed_line_total ) - $refunded_total, $price_decimals );
 				if ( $remaining_total <= 0 ) {
 					return new WP_Error(
 						'line_item_already_refunded',
@@ -1080,19 +1203,6 @@ class DataUtils {
 						array( 'status' => WP_Http::UNPROCESSABLE_ENTITY )
 					);
 				}
-
-				$requested_total = abs( $this->compute_line_item_refund_total( $item, $line_item['quantity'] ) );
-				if ( $requested_total > NumberUtil::round( $remaining_total, $price_decimals ) ) {
-					return new WP_Error(
-						'refund_total_exceeds_remaining',
-						sprintf(
-							/* translators: %s: remaining refundable amount */
-							__( 'refund_total cannot exceed the remaining refundable amount for this line item (%s).', 'woocommerce' ),
-							wc_format_decimal( $remaining_total, $price_decimals )
-						),
-						array( 'status' => WP_Http::UNPROCESSABLE_ENTITY )
-					);
-				}
 			}
 		}

@@ -1106,13 +1216,18 @@ class DataUtils {
 	 * avoiding repeated get_refunds() calls during serialization. Fee and shipping totals are
 	 * tax-inclusive so they can be compared directly against {@see compute_line_item_refund_total()}.
 	 *
+	 * line_refunds records each product refund line individually — quantity and tax-inclusive
+	 * gross, both as positive magnitudes — so the quantity-form top-up can verify that prior
+	 * refunds match their quantity-derived amounts before reconciling rounding drift.
+	 *
 	 * @param WC_Order $order Order instance.
-	 * @return array{qtys: array<int, int>, totals: array<int, float>, tax_totals: array<int, array<int, float>>}
+	 * @return array{qtys: array<int, int>, totals: array<int, float>, tax_totals: array<int, array<int, float>>, line_refunds: array<int, list<array{qty: int, gross: float}>>}
 	 */
 	public function compute_refunded_quantities_and_totals( WC_Order $order ): array {
-		$qtys       = array();
-		$totals     = array();
-		$tax_totals = array();
+		$qtys         = array();
+		$totals       = array();
+		$tax_totals   = array();
+		$line_refunds = array();

 		// Accumulate the already-refunded tax per original item, keyed by tax rate
 		// id, as a positive amount. Refund line items store taxes as negatives, so
@@ -1132,9 +1247,13 @@ class DataUtils {
 			 */
 			$refunded_line_items = $refund->get_items( 'line_item' );
 			foreach ( $refunded_line_items as $refunded_item ) {
-				$original_id            = absint( $refunded_item->get_meta( '_refunded_item_id' ) );
-				$qtys[ $original_id ]   = ( $qtys[ $original_id ] ?? 0 ) + $refunded_item->get_quantity();
-				$totals[ $original_id ] = ( $totals[ $original_id ] ?? 0.0 ) + ( (float) $refunded_item->get_total() + (float) $refunded_item->get_total_tax() ) * -1;
+				$original_id                    = absint( $refunded_item->get_meta( '_refunded_item_id' ) );
+				$qtys[ $original_id ]           = ( $qtys[ $original_id ] ?? 0 ) + $refunded_item->get_quantity();
+				$totals[ $original_id ]         = ( $totals[ $original_id ] ?? 0.0 ) + ( (float) $refunded_item->get_total() + (float) $refunded_item->get_total_tax() ) * -1;
+				$line_refunds[ $original_id ][] = array(
+					'qty'   => absint( $refunded_item->get_quantity() ),
+					'gross' => abs( (float) $refunded_item->get_total() + (float) $refunded_item->get_total_tax() ),
+				);
 				$add_refunded_taxes( $refunded_item, $original_id );
 			}
 			/**
@@ -1162,9 +1281,10 @@ class DataUtils {
 		}

 		return array(
-			'qtys'       => $qtys,
-			'totals'     => $totals,
-			'tax_totals' => $tax_totals,
+			'qtys'         => $qtys,
+			'totals'       => $totals,
+			'tax_totals'   => $tax_totals,
+			'line_refunds' => $line_refunds,
 		);
 	}
 }
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 ce366c272db..80c3026b1f6 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
@@ -2747,12 +2747,13 @@ class WC_REST_Refunds_V4_Controller_Tests extends WC_REST_Unit_Test_Case {
 		$this->assertSame( 0, $refund_item->get_quantity(), 'qty=0 expected for legacy-no-quantity path.' );
 		$this->assertEquals( -30.00, (float) $refund_item->get_total(), 'Refund line item total should be -30.00.' );

-		// Step 2: the per-line remaining-amount cap gates subsequent refunds.
-		// Remaining refundable on the line = 100 - 30 = 70. A simplified-form request
-		// for the full 2 units would compute 100 (2 * $50), which exceeds the remaining
-		// 70, so validate_line_items rejects it with refund_total_exceeds_remaining — the
-		// same code (and 422 status) the preview endpoint applies, before the request
-		// ever reaches wc_create_refund.
+		// Step 2: the per-line remaining-amount cap gates subsequent explicit refunds.
+		// Remaining refundable on the line = 100 - 30 = 70. An explicit refund_total of
+		// 100 exceeds the remaining 70, so validate_line_items rejects it with
+		// refund_total_exceeds_remaining — the same code (and 422 status) the preview
+		// endpoint applies, before the request ever reaches wc_create_refund. Only
+		// explicit amounts are gated this way: the simplified quantity form derives its
+		// amount server-side, capped to the remaining 70.
 		$request2 = new WP_REST_Request( 'POST', '/wc/v4/refunds' );
 		$request2->set_body_params(
 			array(
@@ -2760,7 +2761,7 @@ class WC_REST_Refunds_V4_Controller_Tests extends WC_REST_Unit_Test_Case {
 				'line_items' => array(
 					array(
 						'line_item_id' => $item->get_id(),
-						'quantity'     => 2,
+						'refund_total' => 100.00,
 					),
 				),
 			)
@@ -3741,12 +3742,14 @@ class WC_REST_Refunds_V4_Controller_Tests extends WC_REST_Unit_Test_Case {
 	}

 	/**
-	 * @testdox Sequential single-unit auto-computed refunds that round above the remaining balance are rejected; an explicit refund_total recovers the remainder.
+	 * @testdox Sequential single-unit auto-computed refunds absorb rounding drift into the final chunk and consume the line exactly.
 	 *
 	 * A 3-quantity line totalling 11.00 has a repeating unit price (3.6667), so
 	 * each single-unit refund rounds up to 3.67. After two such refunds only 3.66
-	 * remains and the third auto-computed 3.67 is rejected by the remaining-amount
-	 * guard. A one-shot qty-3 refund rounds once and consumes the line exactly.
+	 * remains; the third quantity refund consumes the line's last unit, so the
+	 * server refunds exactly the 3.66 remainder instead of rejecting its own
+	 * 3.67 computation. A one-shot qty-3 refund rounds once and consumes the
+	 * line exactly.
 	 */
 	public function test_refunds_create_sequential_unit_refunds_with_repeating_unit_price(): void {
 		list( $one_shot_order, $one_shot_item ) = $this->create_order_with_exact_line( 3, 11.00, 11.00, 11.00 );
@@ -3784,8 +3787,83 @@ class WC_REST_Refunds_V4_Controller_Tests extends WC_REST_Unit_Test_Case {
 		$this->assertEqualsWithDelta( 3.66, (float) $order->get_remaining_refund_amount(), 0.001, 'Two 3.67 refunds leave 3.66 of the 11.00 line' );

 		$response = $this->dispatch_refund_request( $order->get_id(), $unit_refund );
-		$this->assertEquals( 422, $response->get_status(), 'Third auto-computed 3.67 exceeds the 3.66 remaining and must be rejected' );
-		$this->assertEquals( 'refund_total_exceeds_remaining', $response->get_data()['code'] );
+		$this->assertEquals( 201, $response->get_status(), 'The final unit refund must succeed at the remaining amount' );
+		$this->assertEqualsWithDelta( 3.66, (float) $response->get_data()['total'], 0.001, 'The last unit refunds the 3.66 remainder, not the unit-derived 3.67' );
+		$this->created_refunds[] = $response->get_data()['id'];
+
+		$order = wc_get_order( $order->get_id() );
+		$this->assertEqualsWithDelta( 0.0, (float) $order->get_remaining_refund_amount(), 0.001, 'Three unit refunds consume the 11.00 line exactly' );
+	}
+
+	/**
+	 * @testdox Sequential quantity refunds close a line whose stored total carries sub-cent skew instead of rejecting the final chunk.
+	 *
+	 * A 6-quantity line stored at 77.8425 has unit price 12.97375, so refunding
+	 * 2 units rounds up to 25.95 and the remaining 4 units compute to 51.90 — a
+	 * cent above the 51.89 actually remaining. The final chunk consumes the
+	 * line's remaining units, so the server refunds exactly the remainder
+	 * instead of rejecting its own computation with refund_total_exceeds_remaining.
+	 * Mirrors a tax-inclusive store report where "refund the rest" was blocked.
+	 */
+	public function test_refunds_create_sequential_quantity_refunds_with_subcent_line_skew(): void {
+		list( $order, $item ) = $this->create_order_with_exact_line( 6, 77.8425, 77.8425, 77.8425 );
+
+		$response = $this->dispatch_refund_request(
+			$order->get_id(),
+			array(
+				array(
+					'line_item_id' => $item->get_id(),
+					'quantity'     => 2,
+				),
+			)
+		);
+		$this->assertEquals( 201, $response->get_status() );
+		$this->assertEqualsWithDelta( 25.95, (float) $response->get_data()['total'], 0.001, 'Qty-2 refund of the skewed line rounds 2 × 12.97375 up to 25.95' );
+		$this->created_refunds[] = $response->get_data()['id'];
+
+		$response = $this->dispatch_refund_request(
+			$order->get_id(),
+			array(
+				array(
+					'line_item_id' => $item->get_id(),
+					'quantity'     => 4,
+				),
+			)
+		);
+		$this->assertEquals( 201, $response->get_status(), 'Refunding the remaining 4 units must succeed' );
+		$this->assertEqualsWithDelta( 51.89, (float) $response->get_data()['total'], 0.001, 'The final chunk refunds the 51.89 remainder, not the unit-derived 51.90' );
+		$this->created_refunds[] = $response->get_data()['id'];
+
+		$order = wc_get_order( $order->get_id() );
+		$this->assertEqualsWithDelta( 0.0, (float) $order->get_remaining_refund_amount(), 0.005, 'The line closes at currency precision with no stranded cents' );
+	}
+
+	/**
+	 * @testdox The final-unit quantity refund does not absorb an amount deliberately withheld by a prior explicit refund, and the residue stays refundable explicitly.
+	 *
+	 * A two-unit, $200 line ($100/unit): one unit is deliberately under-refunded
+	 * at an explicit $50, leaving a withheld $50 on that unit. Refunding the last
+	 * unit through the quantity form must pay the unit's $100 value — not the
+	 * $150 line remainder, which would silently reverse the earlier decision.
+	 * The withheld $50 then remains refundable through the explicit amount form,
+	 * proving the residue is usable rather than merely visible.
+	 */
+	public function test_refunds_create_final_unit_does_not_absorb_withheld_amount(): void {
+		list( $order, $item ) = $this->create_order_with_exact_line( 2, 200.00, 200.00, 200.00 );
+
+		$response = $this->dispatch_refund_request(
+			$order->get_id(),
+			array(
+				array(
+					'line_item_id' => $item->get_id(),
+					'quantity'     => 1,
+					'refund_total' => 50.00,
+				),
+			)
+		);
+		$this->assertEquals( 201, $response->get_status() );
+		$this->assertEqualsWithDelta( 50.00, (float) $response->get_data()['total'], 0.001, 'One unit is under-refunded at an explicit 50.00' );
+		$this->created_refunds[] = $response->get_data()['id'];

 		$response = $this->dispatch_refund_request(
 			$order->get_id(),
@@ -3793,16 +3871,32 @@ class WC_REST_Refunds_V4_Controller_Tests extends WC_REST_Unit_Test_Case {
 				array(
 					'line_item_id' => $item->get_id(),
 					'quantity'     => 1,
-					'refund_total' => 3.66,
 				),
 			)
 		);
-		$this->assertEquals( 201, $response->get_status(), 'Explicit refund_total recovers the rounding remainder' );
+		$this->assertEquals( 201, $response->get_status() );
+		$this->assertEqualsWithDelta( 100.00, (float) $response->get_data()['total'], 0.001, 'The final unit refunds its 100.00 value, not the 150.00 line remainder' );
 		$this->created_refunds[] = $response->get_data()['id'];
+
+		$response = $this->dispatch_refund_request(
+			$order->get_id(),
+			array(
+				array(
+					'line_item_id' => $item->get_id(),
+					'refund_total' => 50.00,
+				),
+			)
+		);
+		$this->assertEquals( 201, $response->get_status(), 'The withheld 50.00 must remain refundable through the explicit amount form' );
+		$this->assertEqualsWithDelta( 50.00, (float) $response->get_data()['total'], 0.001 );
+		$this->created_refunds[] = $response->get_data()['id'];
+
+		$order = wc_get_order( $order->get_id() );
+		$this->assertEqualsWithDelta( 0.0, (float) $order->get_remaining_refund_amount(), 0.001, 'Explicitly refunding the residue closes the order' );
 	}

 	/**
-	 * @testdox Auto-compute follows the store's zero-decimal price setting and repeated unit refunds strand one currency unit.
+	 * @testdox Auto-compute follows the store's zero-decimal price setting and the final unit refund absorbs the rounding remainder.
 	 */
 	public function test_refunds_create_auto_compute_zero_decimal_currency(): void {
 		$original_decimals = get_option( 'woocommerce_price_num_decimals', '2' );
@@ -3845,26 +3939,15 @@ class WC_REST_Refunds_V4_Controller_Tests extends WC_REST_Unit_Test_Case {
 					'quantity'     => 1,
 				),
 			);
-			for ( $i = 0; $i < 3; $i++ ) {
+			foreach ( array( 333.0, 333.0, 334.0 ) as $refund_number => $expected_total ) {
 				$response = $this->dispatch_refund_request( $order_b->get_id(), $unit_refund );
 				$this->assertEquals( 201, $response->get_status() );
-				$this->assertEqualsWithDelta( 333.0, (float) $response->get_data()['total'], 0.001, 'Each single-unit refund rounds 1000/3 down to 333' );
+				$this->assertEqualsWithDelta( $expected_total, (float) $response->get_data()['total'], 0.001, "Unit refund {$refund_number} of the 1000/3 line: the final unit absorbs the rounding remainder" );
 				$this->created_refunds[] = $response->get_data()['id'];
 			}

 			$order_b = wc_get_order( $order_b->get_id() );
-			$this->assertEqualsWithDelta( 1.0, (float) $order_b->get_remaining_refund_amount(), 0.001, 'Three 333 refunds strand 1 currency unit of the 1000 line' );
-
-			$request = new WP_REST_Request( 'POST', '/wc/v4/refunds' );
-			$request->set_body_params(
-				array(
-					'order_id' => $order_b->get_id(),
-					'total'    => 1,
-				)
-			);
-			$response = $this->server->dispatch( $request );
-			$this->assertEquals( 201, $response->get_status(), 'The stranded unit stays refundable via an order-level amount' );
-			$this->created_refunds[] = $response->get_data()['id'];
+			$this->assertEqualsWithDelta( 0.0, (float) $order_b->get_remaining_refund_amount(), 0.001, 'The final 334 refund consumes the 1000 line exactly — no stranded currency unit' );
 		} finally {
 			update_option( 'woocommerce_price_num_decimals', $original_decimals );
 		}
diff --git a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version4/Refunds/class-wc-rest-refunds-v4-preview-tests.php b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version4/Refunds/class-wc-rest-refunds-v4-preview-tests.php
index dcb4c773ba9..64c20e27d35 100644
--- a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version4/Refunds/class-wc-rest-refunds-v4-preview-tests.php
+++ b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version4/Refunds/class-wc-rest-refunds-v4-preview-tests.php
@@ -1673,7 +1673,7 @@ class WC_REST_Refunds_V4_Preview_Tests extends WC_REST_Unit_Test_Case {
 	}

 	/**
-	 * @testdox Preview rejects a product quantity refund that exceeds the remaining line amount after a prior amount-only refund, matching create.
+	 * @testdox Preview caps a product quantity refund to the remaining line amount after a prior amount-only refund, matching what create stores.
 	 */
 	public function test_preview_product_quantity_after_amount_refund_matches_create(): void {
 		$order   = $this->create_order_with_product( 100.00, 2 );
@@ -1701,10 +1701,11 @@ class WC_REST_Refunds_V4_Preview_Tests extends WC_REST_Unit_Test_Case {
 			),
 		);

-		// Preview must not return 200 while create rejects the same auto-filled $200 over-refund.
+		// The unit-derived $200 exceeds the $50 remaining; the quantity form caps to the
+		// remainder, and preview must show the same amount create stores.
 		$preview_response = $this->do_preview_request( $order->get_id(), $line_items );
-		$this->assertEquals( 422, $preview_response->get_status() );
-		$this->assertEquals( 'refund_total_exceeds_remaining', $preview_response->get_data()['code'] );
+		$this->assertEquals( 200, $preview_response->get_status() );
+		$this->assertEqualsWithDelta( 50.00, (float) $preview_response->get_data()['total'], 0.001, 'Preview caps the quantity refund to the 50.00 remaining on the line' );

 		$create_request = new WP_REST_Request( 'POST', '/wc/v4/refunds' );
 		$create_request->set_body_params(
@@ -1714,8 +1715,76 @@ class WC_REST_Refunds_V4_Preview_Tests extends WC_REST_Unit_Test_Case {
 			)
 		);
 		$create_response = $this->server->dispatch( $create_request );
-		$this->assertEquals( 422, $create_response->get_status() );
-		$this->assertEquals( 'refund_total_exceeds_remaining', $create_response->get_data()['code'] );
+		$this->assertEquals( 201, $create_response->get_status() );
+		$this->assertEqualsWithDelta( 50.00, (float) $create_response->get_data()['total'], 0.001, 'Create stores the same capped amount the preview showed' );
+	}
+
+	/**
+	 * @testdox Preview of the final quantity chunk on a sub-cent-skewed line shows the capped remainder create will store.
+	 *
+	 * 6 × 12.97375 stores a 77.8425 line. Refunding 2 units rounds up to 25.95,
+	 * so the remaining 4 units compute to 51.90 — a cent above the 51.89 actually
+	 * remaining. Preview must return the capped 51.89 (not 422, and not an amount
+	 * create would then disagree with).
+	 */
+	public function test_preview_final_quantity_chunk_on_subcent_skewed_line_shows_remainder(): void {
+		$order   = $this->create_order_with_product( 12.97375, 6 );
+		$item_id = $this->get_first_line_item_id( $order );
+
+		$preview_response = $this->do_preview_request(
+			$order->get_id(),
+			array(
+				array(
+					'line_item_id' => $item_id,
+					'quantity'     => 2,
+				),
+			)
+		);
+		$this->assertEquals( 200, $preview_response->get_status() );
+		$this->assertEqualsWithDelta( 25.95, (float) $preview_response->get_data()['total'], 0.001, 'Qty-2 preview rounds 2 × 12.97375 up to 25.95' );
+
+		$create_request = new WP_REST_Request( 'POST', '/wc/v4/refunds' );
+		$create_request->set_body_params(
+			array(
+				'order_id'   => $order->get_id(),
+				'line_items' => array(
+					array(
+						'line_item_id' => $item_id,
+						'quantity'     => 2,
+					),
+				),
+			)
+		);
+		$create_response = $this->server->dispatch( $create_request );
+		$this->assertEquals( 201, $create_response->get_status() );
+
+		$preview_response = $this->do_preview_request(
+			$order->get_id(),
+			array(
+				array(
+					'line_item_id' => $item_id,
+					'quantity'     => 4,
+				),
+			)
+		);
+		$this->assertEquals( 200, $preview_response->get_status(), 'Previewing the remaining units must not be rejected' );
+		$this->assertEqualsWithDelta( 51.89, (float) $preview_response->get_data()['total'], 0.001, 'Preview caps the unit-derived 51.90 to the 51.89 remaining' );
+
+		$create_request = new WP_REST_Request( 'POST', '/wc/v4/refunds' );
+		$create_request->set_body_params(
+			array(
+				'order_id'   => $order->get_id(),
+				'line_items' => array(
+					array(
+						'line_item_id' => $item_id,
+						'quantity'     => 4,
+					),
+				),
+			)
+		);
+		$create_response = $this->server->dispatch( $create_request );
+		$this->assertEquals( 201, $create_response->get_status(), 'Refunding the remaining units must succeed' );
+		$this->assertEqualsWithDelta( 51.89, (float) $create_response->get_data()['total'], 0.001, 'Create stores the same capped remainder the preview showed' );
 	}

 	/**
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 2443679d0f6..43cb7f9eb8b 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
@@ -1548,14 +1548,13 @@ class DataUtilsTest extends WC_Unit_Test_Case {
 	}

 	/**
-	 * @testdox Should return refund_total_exceeds_remaining when a partially-refunded shipping line cannot fit a full preview at its original total.
+	 * @testdox Should cap a quantity preview of a partially-refunded shipping line to its remaining amount.
 	 *
 	 * Order has a $10 shipping line + a $50 product line so the order is still
-	 * refundable after a $5 partial shipping refund. Previewing the shipping
-	 * line at qty=1 would refund the full $10 — exceeds the $5 remaining on
-	 * that line — so validation must reject with `refund_total_exceeds_remaining`.
-	 * Without the per-line cap, validate would pass and `build_refund_preview`
-	 * would return an oversized total.
+	 * refundable after a $5 partial shipping refund. A shipping line carries a
+	 * single refundable unit, so previewing it at qty=1 refunds the remainder:
+	 * validation passes and the capped computation returns the $5 remaining,
+	 * not the original $10 total.
 	 */
 	public function test_validate_preview_line_items_shipping_partial_remaining(): void {
 		$product = WC_Helper_Product::create_simple_product();
@@ -1615,8 +1614,23 @@ class DataUtilsTest extends WC_Unit_Test_Case {
 			$order
 		);

-		$this->assertInstanceOf( \WP_Error::class, $result );
-		$this->assertEquals( 'refund_total_exceeds_remaining', $result->get_error_code() );
+		$this->assertTrue( $result );
+
+		$filled = $this->data_utils->fill_missing_refund_totals(
+			array(
+				array(
+					'line_item_id' => $shipping->get_id(),
+					'quantity'     => 1,
+				),
+			),
+			$order
+		);
+		$this->assertEqualsWithDelta(
+			5.00,
+			$filled[0]['refund_total'],
+			0.001,
+			'The shipping line refunds its $5 remainder, not the original $10 total'
+		);

 		$product->delete( true );
 		$order->delete( true );
@@ -1848,11 +1862,12 @@ class DataUtilsTest extends WC_Unit_Test_Case {
 	}

 	/**
-	 * @testdox Preview caps a product quantity refund against the remaining line amount, not just units (matches create).
+	 * @testdox Preview accepts a product quantity refund after a prior amount-only refund and caps it to the remaining line amount (matches create).
 	 *
-	 * Regression guard: an amount-only prior refund leaves all units "available" by
-	 * count, so the units-only check passed, but create auto-fills refund_total and
-	 * rejects the over-refund. Preview must reject it too.
+	 * An amount-only prior refund leaves all units "available" by count while
+	 * consuming line dollars. The quantity form derives its amount server-side,
+	 * capped to the remaining line amount, so validation passes and both preview
+	 * and create resolve the same capped value — never an over-refund.
 	 */
 	public function test_validate_preview_line_items_product_quantity_respects_prior_amount_refund(): void {
 		$product = WC_Helper_Product::create_simple_product();
@@ -1901,13 +1916,434 @@ class DataUtilsTest extends WC_Unit_Test_Case {
 			$order
 		);

-		$this->assertInstanceOf( \WP_Error::class, $result );
-		$this->assertEquals( 'refund_total_exceeds_remaining', $result->get_error_code() );
+		$this->assertTrue( $result );
+
+		$filled = $this->data_utils->fill_missing_refund_totals(
+			array(
+				array(
+					'line_item_id' => $item->get_id(),
+					'quantity'     => 2,
+				),
+			),
+			$order
+		);
+		$this->assertEqualsWithDelta(
+			50.00,
+			$filled[0]['refund_total'],
+			0.001,
+			'Both units consume the line, so the refund caps to the $50 remaining after the prior $150 amount-only refund'
+		);
+
+		$product->delete( true );
+		$order->delete( true );
+	}
+
+	/**
+	 * @testdox fill_missing_refund_totals clamps a partial quantity to the remaining amount when prior refunds consumed line dollars without units.
+	 */
+	public function test_fill_missing_refund_totals_clamps_partial_quantity_to_remaining(): void {
+		$product = WC_Helper_Product::create_simple_product();
+		$product->set_regular_price( 100.00 );
+		$product->save();
+
+		$order = wc_create_order();
+		$item  = new WC_Order_Item_Product();
+		$item->set_props(
+			array(
+				'product'  => $product,
+				'quantity' => 2,
+				'subtotal' => 200.00,
+				'total'    => 200.00,
+			)
+		);
+		$item->save();
+		$order->add_item( $item );
+		$order->set_total( 200.00 );
+		$order->save();
+
+		// Amount-only refund of $150: no units consumed, $50 of line amount left.
+		wc_create_refund(
+			array(
+				'order_id'   => $order->get_id(),
+				'amount'     => 150.00,
+				'line_items' => array(
+					$item->get_id() => array(
+						'qty'          => 0,
+						'refund_total' => 150.00,
+						'refund_tax'   => array(),
+					),
+				),
+			)
+		);
+
+		$filled = $this->data_utils->fill_missing_refund_totals(
+			array(
+				array(
+					'line_item_id' => $item->get_id(),
+					'quantity'     => 1,
+				),
+			),
+			$order
+		);
+
+		$this->assertEqualsWithDelta(
+			50.00,
+			$filled[0]['refund_total'],
+			0.001,
+			'One of two units derives $100, clamped to the $50 remaining on the line'
+		);

 		$product->delete( true );
 		$order->delete( true );
 	}

+	/**
+	 * @testdox fill_missing_refund_totals refunds the exact remainder when the quantity consumes the line's remaining units after quantity-derived refunds.
+	 */
+	public function test_fill_missing_refund_totals_full_consumption_returns_remainder(): void {
+		$product = WC_Helper_Product::create_simple_product();
+		$product->set_regular_price( 12.97375 );
+		$product->save();
+
+		$order = wc_create_order();
+		$item  = new WC_Order_Item_Product();
+		$item->set_props(
+			array(
+				'product'  => $product,
+				'quantity' => 6,
+				'subtotal' => 77.8425,
+				'total'    => 77.8425,
+			)
+		);
+		$item->save();
+		$order->add_item( $item );
+		$order->set_total( 77.8425 );
+		$order->save();
+
+		// Prior qty-2 refund at the unit-derived (rounded-up) 25.95.
+		wc_create_refund(
+			array(
+				'order_id'   => $order->get_id(),
+				'amount'     => 25.95,
+				'line_items' => array(
+					$item->get_id() => array(
+						'qty'          => 2,
+						'refund_total' => 25.95,
+						'refund_tax'   => array(),
+					),
+				),
+			)
+		);
+
+		$filled = $this->data_utils->fill_missing_refund_totals(
+			array(
+				array(
+					'line_item_id' => $item->get_id(),
+					'quantity'     => 4,
+				),
+			),
+			$order
+		);
+
+		$this->assertEqualsWithDelta(
+			51.89,
+			$filled[0]['refund_total'],
+			0.001,
+			'The remaining 4 units refund the 51.89 remainder, not the unit-derived 51.90'
+		);
+
+		$product->delete( true );
+		$order->delete( true );
+	}
+
+	/**
+	 * @testdox fill_missing_refund_totals returns the unclamped amount for a fully-refunded line so validators reject it with the right error.
+	 */
+	public function test_fill_missing_refund_totals_fully_refunded_line_returns_unclamped(): void {
+		$product = WC_Helper_Product::create_simple_product();
+		$product->set_regular_price( 50.00 );
+		$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,
+			)
+		);
+		$item->save();
+		$order->add_item( $item );
+		$order->set_total( 50.00 );
+		$order->save();
+
+		wc_create_refund(
+			array(
+				'order_id'   => $order->get_id(),
+				'amount'     => 50.00,
+				'line_items' => array(
+					$item->get_id() => array(
+						'qty'          => 1,
+						'refund_total' => 50.00,
+						'refund_tax'   => array(),
+					),
+				),
+			)
+		);
+
+		$filled = $this->data_utils->fill_missing_refund_totals(
+			array(
+				array(
+					'line_item_id' => $item->get_id(),
+					'quantity'     => 1,
+				),
+			),
+			$order
+		);
+
+		$this->assertEqualsWithDelta(
+			50.00,
+			$filled[0]['refund_total'],
+			0.001,
+			'Nothing remains: the unclamped unit-derived amount comes back so validation rejects with line_item_already_refunded, not a zero-refund error'
+		);
+
+		$product->delete( true );
+		$order->delete( true );
+	}
+
+	/**
+	 * @testdox fill_missing_refund_totals preserves the line sign when capping a partially-refunded discount fee.
+	 */
+	public function test_fill_missing_refund_totals_negative_fee_keeps_sign(): void {
+		$product = WC_Helper_Product::create_simple_product();
+		$product->set_regular_price( 50.00 );
+		$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,
+			)
+		);
+		$item->save();
+		$order->add_item( $item );
+
+		$fee = new WC_Order_Item_Fee();
+		$fee->set_props(
+			array(
+				'name'  => 'Discount',
+				'total' => -10.00,
+			)
+		);
+		$fee->save();
+		$order->add_item( $fee );
+		$order->set_total( 40.00 );
+		$order->save();
+
+		// Prior mixed refund: $10 of the product line and -$4 of the discount, net $6.
+		wc_create_refund(
+			array(
+				'order_id'   => $order->get_id(),
+				'amount'     => 6.00,
+				'line_items' => array(
+					$item->get_id() => array(
+						'qty'          => 0,
+						'refund_total' => 10.00,
+						'refund_tax'   => array(),
+					),
+					$fee->get_id()  => array(
+						'qty'          => 1,
+						'refund_total' => -4.00,
+						'refund_tax'   => array(),
+					),
+				),
+			)
+		);
+
+		$filled = $this->data_utils->fill_missing_refund_totals(
+			array(
+				array(
+					'line_item_id' => $fee->get_id(),
+					'quantity'     => 1,
+				),
+			),
+			$order
+		);
+
+		$this->assertEqualsWithDelta(
+			-6.00,
+			$filled[0]['refund_total'],
+			0.001,
+			'The discount fee refunds its -$6 remainder, keeping the negative sign'
+		);
+
+		$product->delete( true );
+		$order->delete( true );
+	}
+
+	/**
+	 * @testdox fill_missing_refund_totals tops up the final unit to the exact remainder when every prior refund matches its quantity-derived amount.
+	 */
+	public function test_fill_missing_refund_totals_tops_up_final_unit_after_consistent_refunds(): void {
+		$product = WC_Helper_Product::create_simple_product();
+		$product->set_regular_price( 5.005 );
+		$product->save();
+
+		$order = wc_create_order();
+		$item  = new WC_Order_Item_Product();
+		$item->set_props(
+			array(
+				'product'  => $product,
+				'quantity' => 2,
+				'subtotal' => 10.01,
+				'total'    => 10.01,
+			)
+		);
+		$item->save();
+		$order->add_item( $item );
+		$order->set_total( 10.01 );
+		$order->save();
+
+		// A 10.01 line over 2 units derives 5.00 per unit (5.005 rounds down), so a
+		// prior quantity refund stored exactly that value. The shortfall against the
+		// line is pure rounding drift.
+		wc_create_refund(
+			array(
+				'order_id'   => $order->get_id(),
+				'amount'     => 5.00,
+				'line_items' => array(
+					$item->get_id() => array(
+						'qty'          => 1,
+						'refund_total' => 5.00,
+						'refund_tax'   => array(),
+					),
+				),
+			)
+		);
+
+		$filled = $this->data_utils->fill_missing_refund_totals(
+			array(
+				array(
+					'line_item_id' => $item->get_id(),
+					'quantity'     => 1,
+				),
+			),
+			$order
+		);
+
+		$this->assertEqualsWithDelta(
+			5.01,
+			$filled[0]['refund_total'],
+			0.001,
+			'The final unit absorbs the drift and refunds the 5.01 remainder, closing the 10.01 line exactly'
+		);
+
+		$product->delete( true );
+		$order->delete( true );
+	}
+
+	/**
+	 * @testdox fill_missing_refund_totals does not pay out a deliberately withheld residue when refunding the final unit.
+	 */
+	public function test_fill_missing_refund_totals_final_unit_keeps_unit_amount_after_offschedule_refund(): void {
+		$product = WC_Helper_Product::create_simple_product();
+		$product->set_regular_price( 100.00 );
+		$product->save();
+
+		$order = wc_create_order();
+		$item  = new WC_Order_Item_Product();
+		$item->set_props(
+			array(
+				'product'  => $product,
+				'quantity' => 2,
+				'subtotal' => 200.00,
+				'total'    => 200.00,
+			)
+		);
+		$item->save();
+		$order->add_item( $item );
+		$order->set_total( 200.00 );
+		$order->save();
+
+		// A two-unit, $200 line ($100/unit): one unit was deliberately under-refunded
+		// at $50, an off-schedule amount. The withheld $50 must not be silently paid
+		// out by the final unit's quantity refund.
+		wc_create_refund(
+			array(
+				'order_id'   => $order->get_id(),
+				'amount'     => 50.00,
+				'line_items' => array(
+					$item->get_id() => array(
+						'qty'          => 1,
+						'refund_total' => 50.00,
+						'refund_tax'   => array(),
+					),
+				),
+			)
+		);
+
+		$filled = $this->data_utils->fill_missing_refund_totals(
+			array(
+				array(
+					'line_item_id' => $item->get_id(),
+					'quantity'     => 1,
+				),
+			),
+			$order
+		);
+
+		$this->assertEqualsWithDelta(
+			100.00,
+			$filled[0]['refund_total'],
+			0.001,
+			'The final unit refunds its $100 value, not the $150 line remainder'
+		);
+
+		$product->delete( true );
+		$order->delete( true );
+	}
+
+	/**
+	 * @testdox Subclasses overriding the public method signatures stay loadable and keep intercepting the controller flow.
+	 *
+	 * The controller dispatches through these exact methods, so subclass
+	 * overrides keep affecting REST behavior, and each method loads its own
+	 * refund-history snapshot rather than receiving it as a parameter — a
+	 * parent gaining an optional parameter would fatal any subclass still
+	 * declaring the old signature. Declaring such a subclass here pins both
+	 * guarantees.
+	 */
+	public function test_original_public_method_signatures_remain_overridable(): void {
+		// phpcs:disable Squiz.Commenting.FunctionComment.Missing, Generic.CodeAnalysis.UselessOverridingMethod.Found -- pass-through overrides ARE the fixture: they pin the parent signatures.
+		$subclass = new class() extends DataUtils {
+			public function validate_line_items( $line_items, WC_Order $order ) {
+				return parent::validate_line_items( $line_items, $order );
+			}
+
+			public function fill_missing_refund_totals( array $line_items, WC_Order $order ): array {
+				return parent::fill_missing_refund_totals( $line_items, $order );
+			}
+
+			public function build_refund_preview( WC_Order $order, array $line_items ): array {
+				return parent::build_refund_preview( $order, $line_items );
+			}
+
+			public function validate_preview_line_items( array $line_items, WC_Order $order ) {
+				return parent::validate_preview_line_items( $line_items, $order );
+			}
+		};
+		// phpcs:enable Squiz.Commenting.FunctionComment.Missing, Generic.CodeAnalysis.UselessOverridingMethod.Found
+
+		$this->assertInstanceOf( DataUtils::class, $subclass );
+	}
+
 	/**
 	 * @testdox Preview accepts an explicit negative refund_total on a discount-fee line (matches create).
 	 */