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