Commit 2d6f2e0da11 for woocommerce
commit 2d6f2e0da11eeb15becf173df829d83f4b897ce3
Author: Samuel Urbanowicz <samiuelson@gmail.com>
Date: Thu Jun 18 13:55:53 2026 +0200
Simplify v4 refund creation: make per-line refund_total optional (#65439)
* Add DataUtils helpers for refund preview endpoint
Adds three new public methods to Refunds\DataUtils to support an
upcoming refund preview endpoint (POST /wc/v4/refunds/preview):
- compute_line_item_refund_total(): tax-inclusive refund total for
a given line item at a requested quantity.
- build_refund_preview(): returns the structured refund breakdown
(products/shipping/fees with per-section subtotal/tax/total plus
top-level subtotal/tax/total/max_refundable).
- validate_preview_line_items(): validates a preview request against
the order — checks status (REFUNDABLE_STATUSES), remaining refundable
amount, line item existence, and remaining refundable quantity/total.
Reuses the same tax extraction path (WC_Tax::calc_inclusive_tax) as
the create endpoint to guarantee preview/create equivalence.
Relaxes visibility (private -> protected) on three existing helpers
(build_tax_rates_array, convert_line_item_taxes_to_internal_format,
convert_proportional_taxes_to_schema_format) so the new methods and
tests can reuse them.
Part of WOOMOB-2684. The endpoint that consumes these helpers will
follow in a separate PR.
* Add changefile(s) from automation for the following project(s): woocommerce
* Tighten validate_preview_line_items input validation
- Reject missing/non-int/non-positive quantity with new code
invalid_quantity. Previously `quantity = $line_item['quantity'] ?? 0`
silently passed for missing/string/float input, then downstream
consumers saw 0 or a coerced value.
- Require quantity === 1 for shipping/fee items (they aren't
quantity-divisible — the remaining-total branch was already not
scaled by quantity).
- Switch shipping/fee remaining-total math to abs()-based so
legitimately negative-total fees (discount-as-fee pattern) aren't
rejected as "fully refunded".
- Replace the catch-all invalid_line_item code with 4 distinct codes
so clients can distinguish failure modes:
invalid_line_item (empty array) -> missing_line_items
invalid_line_item (missing id) -> missing_line_item_id
invalid_line_item (not found) -> line_item_not_found
invalid_line_item (unsupported) -> unsupported_item_type
Addresses review issues #1, #3, and the lower-priority error-code
split from the PR #65334 review.
* Throw on missing item in build_refund_preview
Replace silent `continue` with InvalidArgumentException so callers
can't get a successful-looking empty preview when a line_item_id is
invalid (e.g. typo, race with delete, validation bypassed).
Document precondition in the docblock: callers must invoke
validate_preview_line_items() first.
Addresses review issue #2.
* Accumulate raw floats in build_refund_preview section sums
Previously the per-section subtotal/tax/total were accumulated by
casting already-formatted decimal strings back to floats via
`(float) $item['subtotal']`, which loses precision and can produce
a 1-cent drift between `breakdown.products.total` and the sum of
`breakdown.products.items[].total` on multi-line refunds.
Refactor: keep running raw-float totals per section during the
per-item loop, format once at section level. Item-level strings are
unchanged.
Addresses review issue #5.
* Log malformed tax data and zero-quantity branches
- compute_line_item_refund_total: emit a warning before returning 0.0
when a product item has zero original quantity. Indicates corrupted
order data that would otherwise silently produce a $0 preview.
- build_refund_preview: emit a warning when an item's taxes array is
non-empty but all entries are filtered out by the
is_numeric && > 0 check. Surfaces malformed tax metadata for ops
without changing user-visible behavior.
Both warnings use wc_get_logger() with source 'wc-v4-refunds'.
Addresses review issues #6 and #8.
* Add preconditions to compute_line_item_refund_total
Guard $quantity >= 1 with an InvalidArgumentException at method entry.
Document the precondition in the docblock plus a note that shipping
and fee items ignore quantity, and that the return value can be
negative for negative-total items (discount fees).
The validator catches bad input at the request boundary; this guard
protects direct callers since the method is public on an Internal\*
class that may be reused by the create endpoint.
Addresses review issue #7.
* Expand DataUtils unit tests, drop reflection tests
Delete the two reflection-based test_build_tax_rates_array_* tests.
build_tax_rates_array is exercised indirectly by
test_convert_line_items_extracts_tax_automatically and
test_build_refund_preview_with_tax; the project convention is to test
through public interfaces (see tests/php/src/CLAUDE.md).
Add 19 unit tests for the helpers introduced in this PR:
- compute_line_item_refund_total:
* zero-original-quantity branch returns 0.0
* shipping item (full total + tax, quantity ignored)
* fee item with positive total
* fee item with negative total (sign preserved)
* InvalidArgumentException for quantity < 1 (data provider)
- build_refund_preview:
* shipping-only order (products + fees sections empty)
* fee-only order (products + shipping sections empty)
* mixed sections (products + shipping + fees aggregate correctly)
* multi-item fractional-price aggregation (no drift between
section total and sum of item totals)
* InvalidArgumentException for missing line_item_id
- validate_preview_line_items:
* empty array -> missing_line_items
* order with no remaining refund amount -> order_not_refundable
* missing line_item_id key -> missing_line_item_id
* cross-order line_item_id -> line_item_not_found
* unsupported item type (tax line) -> unsupported_item_type
* invalid quantity values (data provider) -> invalid_quantity
* shipping with quantity \!= 1 -> invalid_quantity
* shipping fully refunded -> order_not_refundable
* negative-total fee passes validation
* Fix PHPCS issues in DataUtils.php (escape exception output, align assignments)
* Apply PHPCBF auto-fixes to DataUtilsTest
* Remove unused @var docblock to satisfy lint
* Restore @var docblock with short description for PHPStan type narrowing
* Populate HTTP status data on validate_preview_line_items WP_Errors
Each WP_Error now carries a per-code 'status' key in its error
data, so the REST controller can map to the right HTTP status
instead of flattening everything to 400:
missing_line_items -> 400 Bad Request
missing_line_item_id -> 400 Bad Request
invalid_quantity -> 400 Bad Request
line_item_not_found -> 404 Not Found
order_not_refundable -> 422 Unprocessable Entity
unsupported_item_type -> 422 Unprocessable Entity
quantity_exceeds_refundable -> 422 Unprocessable Entity
The controller-side switch to get_route_error_response_from_object
landed in the endpoint PR (#65335); this commit activates it by
populating the data the helper reads.
* Make per-line refund_total optional on POST /wc/v4/refunds
When a client sends a line item without `refund_total`, the backend
now computes the tax-inclusive total from the order line item's unit
price × quantity, via the existing
DataUtils::compute_line_item_refund_total() helper (introduced in
PR #65334 for the preview endpoint).
This is the third v4 enhancement from the POS Refunds API spec
(WOOMOB-2685). It eliminates the need for mobile POS clients to
duplicate the backend's tax/rounding logic just to assemble a refund
request.
Changes:
- DataUtils::fill_missing_refund_totals() — new helper that fills
refund_total for any line item that omits it. Items that can't be
resolved (missing id, item not on order, bad quantity, unsupported
type) are left untouched; the existing validator surfaces the right
error.
- Refunds\Controller::create_item() — calls fill_missing_refund_totals
after order resolution and before validation, so all downstream
code (validator, converter, calculator) sees fully-populated input.
- RefundSchema — removes the `default: 0` on line_items[].refund_total
so "missing" is detectable downstream. Updates the field
description to document the new optional behavior.
Backward compatibility is preserved: clients that send refund_total
explicitly continue to work unchanged, and clients that send the
top-level `amount` continue to work unchanged. The existing under-
refund check (amount must not be less than the line items' total)
still runs against the auto-computed value.
* Add tests for simplified refund creation
Unit tests (DataUtilsTest) for fill_missing_refund_totals:
- fills product line item from unit price × quantity
- preserves explicit refund_total when present
- skips items with line_item_id not on the order
- skips bad quantity (data provider: null, 0, -1, "abc", 1.5)
- fills shipping line item (full total, quantity ignored)
- processes a mixed array (some with, some without refund_total)
Integration tests (v4 refunds controller):
- test_refunds_create_simplified_form_no_tax — auto-computed amount
for a single product with no tax
- test_refunds_create_simplified_form_with_tax — tax extraction works
on the auto-computed total; per-line refund_tax is populated
- test_refunds_create_simplified_matches_explicit — sending the
computed refund_total explicitly produces the same amount as
omitting it
- test_refunds_create_mixed_with_and_without_refund_total — mixed
request: one item auto-computed, one explicit
- test_refunds_create_simplified_form_rejects_over_quantity — over-
quantity is still rejected even when refund_total is auto-computed
* Apply phpcbf auto-fixes to new tests
* Fix inline comment punctuation in test_fill_missing_refund_totals_mixed
* Add changefile(s) from automation for the following project(s): woocommerce
* Reject missing/non-positive quantity with a specific error
When a client sent {line_item_id: X} (no quantity), validate_line_items
silently passed the existing checks (PHP's `int < null` evaluates to
false) and the request cascaded into a misleading "Refund total must
be greater than zero" error. The new method's docblock claimed "the
downstream validator surfaces the right error" — until now, that
claim was false for missing quantity.
Add an explicit precondition in validate_line_items: quantity must be
set, an integer, and >= 1. The error code stays the same
(invalid_line_item) but the message names the actual problem.
Also guards the existing $line_item['refund_total'] comparison with
isset() so the simplified-form path (where refund_total may legitimately
be absent at validation time if fill_missing_refund_totals skipped) no
longer raises an undefined-index notice.
Adds:
- Unit test: validate_line_items rejects all bad quantity shapes
(missing, 0, -1, string, float) via data provider
- Integration test: POST to /wc/v4/refunds with missing quantity
returns 400 invalid_line_item, not the misleading
invalid_refund_amount cascade
Addresses review issue #1 (the only critical) from the PR #65439 audit.
* Treat refund_total null the same as missing; document zero semantics
fill_missing_refund_totals now uses array_key_exists + null check
instead of isset, so a client sending refund_total: null gets the
same auto-computed value as omitting the field entirely. This
removes a subtle behavioural split that was undocumented and easy
to break in future refactors.
Explicit refund_total: 0 is left untouched as before — calculate_
refund_amount treats 0 as "no contribution to the sum" via its
existing \!empty() check, which may trip the under-refund validation
if the total amount is greater. The schema description now
documents both behaviours explicitly.
Adds:
- Unit test asserting null is treated as missing
- Unit test asserting explicit 0 is preserved
Addresses review issue #2 from the PR #65439 audit.
* Defensive InvalidArgumentException catch in create_item
fill_missing_refund_totals pre-checks quantity before calling
compute_line_item_refund_total, so the latter's
InvalidArgumentException should be unreachable in normal flow.
If a future refactor breaks that invariant the throw would
bubble as a fatal 500 with no log entry.
Mirror the preview_item pattern from PR #65335: catch
InvalidArgumentException, log via wc_get_logger() with source
'wc-v4-refunds' and the order id, return 500 with code
'invalid_refund_request' and a generic user message (do not
leak the exception message to clients).
Addresses review issue #3 from the PR #65439 audit.
* Sync $request['line_items'] after fill so hooks see augmented data
Pre-PR behaviour populated refund_total: 0 on every line item via
the schema default. With the default removed, third-party listeners
on the 'woocommerce_rest_api_v4_refunds_created' hook reading
$request['line_items'] would see entries without refund_total when
the original client request used the simplified form — a silent
semantic change.
Mirror the augmented array back onto the request with
$request->set_param('line_items', $line_items) after the fill, so
all downstream readers (the hook payload, any future code that
inspects the request) see normalised data with refund_total
populated.
Adds an integration test that registers a listener on the 'created'
hook and asserts the captured request['line_items'] contains the
auto-computed refund_total.
Addresses review issue #4 from the PR #65439 audit.
* Type-design polish: PHPDoc array shape, tax-inclusive convention comment
- fill_missing_refund_totals docblock now expresses the line_items
array shape as a PHPDoc generic
(list<array{line_item_id?, quantity?, refund_total?, refund_tax?}>)
on both @param and @return. Zero runtime cost; PHPStan now narrows
the type at the single call site in Controller::create_item.
- Expand the docblock prose to document the explicit-0 vs missing/null
semantics introduced in the previous commit, and the tax-inclusive
convention shared with compute_line_item_refund_total.
- Add a code comment in Controller::create_item explaining that
auto-computed and explicit refund_total values use the same
(tax-inclusive) convention, so summing across mixed entries is safe.
Addresses review issue #5 (type-design wins) from the PR #65439 audit.
* Add integration tests for fee auto-compute (positive and negative)
- test_refunds_create_simplified_form_fee_line: POSTs the simplified
form for a positive-total fee, asserts the auto-computed amount
equals the full fee total. Mirrors the existing shipping integration
test (which had no equivalent for fees).
- test_refunds_create_simplified_form_negative_fee: discount-as-fee
scenario from the spec. Asserts that whatever the platform's
behaviour for negative-fee refunds, the result is NOT a misleading
invalid_refund_amount cascade. Locks the contract regardless of
whether wc_create_refund accepts the negative.
Addresses review issue #6 (fee auto-compute + negative-fee end-to-end)
from the PR #65439 audit.
* Lint fixes: docblock formatting + comment style
* Review fixes: zero-qty source error + narrowed exception catch
Address three issues from the 3-agent review:
1. CRITICAL (silent failure): when a product line on the source order has
quantity=0, fill_missing_refund_totals now skips it instead of letting
compute_line_item_refund_total return 0.0 silently. validate_line_items
surfaces a specific 'invalid_line_item' error telling the client to
provide an explicit refund_total, replacing the misleading
"must be greater than zero" cascade.
2. HIGH (broad catch): \InvalidArgumentException is now caught only around
the fill_missing_refund_totals call rather than the whole create_item
try block, so genuine exceptions from wc_create_refund, MetaDataUtil,
prepare_item_for_response, or third-party 'created' hook listeners are
no longer swallowed. The order resolution check is tightened to
instanceof WC_Order (rejecting WC_Order_Refund).
3. Tests: add unit + integration coverage for the zero-qty source path,
a tax-inclusive store (prices_include_tax=yes) integration test, and
a cross-order line_item_id integration test. Also fix mis-indented
trailing comments around lines 844-865 (PHPCS).
* Fix CI: stale PHPStan baseline + flaky integration tests
PHPStan baseline:
- Remove 2 stale entries for DataUtils::convert_line_items_to_internal_format
and DataUtils::validate_line_items 'expects WC_Order, got WC_Order|WC_Order_Refund'.
The previous commit's instanceof WC_Order check in create_item resolved both.
Test fixes (4 pre-existing flaky tests surfacing as CI failures):
- test_refunds_create_simplified_form_with_tax: replace calculate_totals(false)
with explicit set_total(110.00). The former does not reliably populate the
order total in the test environment, so wc_create_refund rejected the
refund with 'cannot_create_refund: Invalid refund amount'.
- test_refunds_create_simplified_matches_explicit + hook test:
use set_regular_price() instead of set_price(). set_price() only updates
the in-memory derived price, so the order created via the REST API used
the WC_Helper default ($10) instead of the test's $25.
- test_refunds_create_simplified_form_negative_fee: tighten assertion to
reflect actual platform behavior (negative-fee refunds inevitably trip
the 0 > refund_amount guard and surface invalid_refund_amount). The
previous assertion claimed this code must NOT appear, which was unrealistic.
* Address Copilot review: schema null, comment indentation, dupe changelog
- RefundSchema: refund_total now accepts ['number', 'null'] and drops the
sanitize_text_field that was stripping null values. Aligns the actual
schema with the documented behavior ('omitted OR null triggers
backend computation').
- Reformat four mis-aligned inline comments inside line_items arrays in
the integration tests (PHPCS-correct + readable).
- Remove duplicate changelog file 65439-woomob-2685-simplify-refund-creation;
the woomob-2685-simplify-refund-creation entry is the canonical one.
* Add changefile(s) from automation for the following project(s): woocommerce
* Preserve legacy explicit-refund_total path without quantity
The PR's strict quantity check in validate_line_items was meant to guard
the new auto-compute path but unintentionally rejected the legacy v3-style
request shape `{line_item_id, refund_total}` (no quantity) — which POS
clients will rely on when the v4 features port to v3.
Backend:
- validate_line_items: gate the positive-integer quantity check on
refund_total being absent. When refund_total is provided explicitly,
quantity stays optional / informational (legacy behavior).
- Gate the over-quantity check on quantity being set.
Schema:
- Drop `default: 0` on quantity (removes the misleading default — the
field is genuinely optional now, conditional on refund_total).
- Document the conditional requirement in the field description.
Tests:
- New integration test: POST /wc/v4/refunds with
{line_item_id, refund_total: 50} and no quantity returns 201.
- New unit test: validate_line_items accepts missing/zero quantity when
refund_total is provided.
* Add changefile(s) from automation for the following project(s): woocommerce
* Fix silent-failure paths: line-item drop + explicit-zero in calculate
Address two silent-failure paths surfaced by the re-review hunter:
B (CRITICAL) — Legacy {line_item_id, refund_total} (no quantity) was
silently dropped from the refund record. The validator accepted the
shape but convert_line_items_to_internal_format required all three
keys, so the line was skipped and wc_create_refund got an empty
line_items array — the refund had the right dollar amount but no
line attribution, leaving per-unit accounting broken for that line.
Fix: relax the converter to require line_item_id + (quantity OR
refund_total) and default qty=0 when quantity is missing. Matches
v3 semantics ("refunded $X of this line without consuming specific
units"). Dollar accounting via get_remaining_refund_amount still
gates over-refunding regardless of the per-unit looseness.
A (HIGH) — calculate_refund_amount used !empty() which treats an
explicit refund_total of 0 as missing, silently dropping the line
from the sum. Replace with isset() + is_numeric() so the explicit-
zero contract documented in the schema is honored.
Tests added/augmented:
- test_refunds_create_legacy_form_no_quantity_with_explicit_refund_total
now asserts the refund line item is attached (count=1) with
qty=0 and a -$30 total, AND that a follow-up simplified-form
refund exceeding remaining dollars is correctly rejected by
wc_create_refund.
- test_refunds_create_legacy_form_api_restock_does_not_restock pins
the no-restock semantic: qty=0 means no units to add back, so
api_restock=true is a no-op for legacy refunds.
- test_calculate_refund_amount_includes_explicit_zero (unit) —
regression guard against the !empty() reintroduction.
- test_convert_line_items_legacy_no_quantity_defaults_qty_zero (unit)
— pins the converter's new behavior.
Also: guard tax extraction in the converter with isset(refund_total)
to avoid silently coercing missing refund_total to 0 via float cast.
* Close the test-analyzer follow-ups
- Add test_refunds_create_simplified_matches_explicit_tax_inclusive:
simplified-vs-explicit equivalence on a tax-inclusive store (10% / $110).
The previous equivalence test used an untaxed product, so a regression
that yielded a tax-exclusive auto-computed value would have gone
undetected. The new test asserts both top-level `amount` and per-line
refund_total / refund_tax round-trip identically between the two paths.
- Add test_refunds_create_invariant_violation_returns_500: partial-mock
DataUtils so fill_missing_refund_totals throws InvalidArgumentException,
inject into the DI-resolved RefundsController, dispatch a refund POST,
and assert 500 + invalid_refund_request. Pins the scoped catch's
response shape so a future refactor that broadens the catch (e.g. to
\Throwable) or re-narrows fill's pre-check is caught.
- Tighten test_refunds_create_simplified_form_negative_fee: replace the
permissive assertLessThan(500, status) + assertArrayHasKey('code') with
explicit assertEquals(400) + assertEquals('invalid_refund_amount').
Pins the current platform behavior (the controller's `0 > $refund_amount`
guard fires for any negative auto-computed total) so a behavior change
is loud rather than silent.
* Address 3rd-pass review findings (test-side)
Test-analyzer findings, in priority order:
(#1, merge-blocker) Option leakage — two tests mutated
woocommerce_calc_taxes / woocommerce_prices_include_tax without
restoration. tearDown does not reset these, so any test running
after them in the same process would see polluted state. Wrap
both in try/finally that captures and restores the original
values, mirroring the pattern already used in
test_refunds_create_simplified_form_tax_inclusive_store.
(#4) test_refunds_create_simplified_matches_explicit_tax_inclusive
was misnamed — it set prices_include_tax = no. Fix it to actually
exercise a tax-inclusive store (set to yes; product entered with
tax baked into regular_price). The name now matches reality.
(#2) Augment
test_refunds_create_legacy_form_no_quantity_with_explicit_refund_total
with a Step 3: after the $30 initial refund and the rejected $100
follow-up, refund $40 — this MUST succeed and bring total_refunded
to $70 / remaining $30. Guards against a regression where the
first refund silently consumed the full $100 budget.
(#3) New test_refunds_create_legacy_form_tax_inclusive_store —
exercises the converter's tax-extraction block on the legacy
{line_item_id, refund_total} (no quantity) path under tax-inclusive
prices. This combination is the one POS clients will actually
exercise after the v3 port, but no other test reaches it.
(#5) New test_refunds_create_three_way_mixed_shapes — single create
call with auto-compute + explicit-with-quantity + legacy-no-quantity
entries. Verifies the controller's fill/convert pass handles all
three shapes coexisting, and asserts each refund line item is
attached with the expected qty (refunds store quantities negative,
so request qty=1 lands as -1; legacy no-quantity lands as 0).
(#6) New test_refunds_create_hook_sees_explicit_refund_total_unchanged
— pins that the request-mirroring step (set_param after
fill_missing_refund_totals) does not overwrite client-supplied
refund_total. Uses a deliberately different value from the
auto-computed one so an overwrite regression would surface.
* Address review comments
CodeRabbit + codex review findings closed:
(A) Per-line cap for partially-refunded shipping/fees in
validate_preview_line_items. Previously only fully-refunded lines
were rejected, so a $10 shipping line partially refunded by $5
would pass validation and let build_refund_preview return $10 even
though only $5 was refundable. The new check computes the requested
refund via compute_line_item_refund_total() and rejects with
'quantity_exceeds_refundable' (422) when it exceeds remaining.
(B) Preserve the signed tax split for negative-tax discount fees.
The tax-extraction filter in build_refund_preview previously dropped
tax IDs where `amount > 0`, so a -$10 fee with -$1 stored tax
previewed as subtotal -$11 / tax $0 — losing the breakdown.
Switch the filter to `amount != 0` so non-zero (positive or
negative) tax amounts are kept and WC_Tax::calc_inclusive_tax
propagates the sign correctly.
(C) @since 10.8.0 → @since 10.9.0 on all new public methods, and
add @since 10.9.0 to the newly-protected helpers
(convert_line_item_taxes_to_internal_format,
convert_proportional_taxes_to_schema_format, build_tax_rates_array).
Per .ai/skills/woocommerce-backend-dev/code-entities.md, @since
is moved to the last line of each docblock.
(D) Remove the duplicate changelog file
65334-woomob-2684-refund-preview-helpers — the unprefixed
woomob-2684-refund-preview-helpers entry is the canonical one.
(E) Fix the @testdox on test_validate_preview_line_items_shipping_fully_refunded
to match the assertion (order_not_refundable). The order-level
remaining-amount guard fires first when shipping is fully refunded.
Tests added:
- test_validate_preview_line_items_shipping_partial_remaining —
pins the new per-line cap. Order has a $10 shipping line + $50
product; $5 of shipping is pre-refunded; previewing shipping at
qty=1 must return quantity_exceeds_refundable rather than
passing through to an oversized total.
- test_build_refund_preview_negative_fee_with_negative_tax —
pins the negative-tax breakdown fix. -$10 fee + -$1 stored tax
must preview as subtotal -$10 / tax -$1 / total -$11.
* Add changefile(s) from automation for the following project(s): woocommerce
* Address review comments
Codex findings closed:
(#1, CRITICAL) Cap simplified-form refunds against the line's REMAINING
refundable quantity rather than the original. validate_line_items
previously compared $line_item['quantity'] to $item->get_quantity()
(the unchanged original count), so once item A was refunded for the
first time, a second simplified {line_item_id: A, quantity: 1}
request would pass the per-line check and only be bounded by the
order's dollar balance — if item B still had room, item A would be
silently refunded twice. Hoist compute_refunded_quantities_and_totals
outside the loop and cap product items by
$item->get_quantity() + ($refund_data['qtys'][id] ?? 0), matching
the pattern already used in validate_preview_line_items. Shipping/
fees keep the original simpler check (they don't track per-unit
refund history).
(#2, HIGH) Reject the ambiguous "omitted refund_total + explicit
refund_tax" combination. fill_missing_refund_totals would have
written a tax-inclusive refund_total (110 for a $100 item with $10
tax) and convert_line_items_to_internal_format would then skip tax
extraction because refund_tax was already present —
calculate_refund_amount summed both and emitted amount=120
(overstated by the tax amount). fill_missing_refund_totals now
skips items with explicit refund_tax, and validate_line_items
rejects the combination with 'invalid_line_item: refund_tax cannot
be combined with auto-computed refund_total'.
(#3) Remove the duplicate changelog file
65439-woomob-2685-simplify-refund-creation — the unprefixed
woomob-2685-simplify-refund-creation entry is the canonical one.
(#4) @since 10.8.0 → @since 10.9.0 on fill_missing_refund_totals;
@since moved to the last docblock line per
.ai/skills/woocommerce-backend-dev/code-entities.md. The other
@since annotations on this file belong to methods introduced by
#65334 and were already updated on that branch (644f7c0cd9);
they'll re-flow to this branch via merge.
Tests added:
- test_refunds_create_simplified_form_rejects_already_refunded_product
pins the new remaining-qty cap. Two-line order (A + B, each $50);
refund A once → 201; refund A again → 400 invalid_line_item with
"remaining refundable quantity" in the message.
- test_refunds_create_rejects_auto_compute_with_explicit_refund_tax
pins the rejection. $100 product with $10 stored tax; request
with no refund_total + explicit refund_tax → 400 invalid_line_item
with "refund_tax cannot be combined" in the message. Wrapped in
try/finally for tax-option restoration.
* Add changefile(s) from automation for the following project(s): woocommerce
* Address review comments on validate_preview_line_items
- Compare shipping/fee refunds on a tax-inclusive basis. The previous
code pitted the tax-inclusive $requested_total against a tax-exclusive
$remaining_total — for a $10 shipping line with $1.50 tax, the
comparison was 11.50 > 10.00 and rejected a legitimate full refund.
compute_refunded_quantities_and_totals() now records fee/shipping
totals tax-inclusive so the validator can compare like-for-like.
- Replace the hardcoded `'status' => 422` literals in
validate_preview_line_items with WP_Http::UNPROCESSABLE_ENTITY,
matching the convention used elsewhere in V4 routes.
- Add a regression test (shipping line with tax, no prior refund) to
lock in the tax-inclusive comparison.
- Delete the duplicate changelog entry; keep the PR-prefixed one auto-
generated by CI.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Align tax filter in convert_line_items_to_internal_format with preview side
The creation-side filter dropped tax IDs whose stored amount was <= 0,
so a negative-fee discount line (e.g. a -$10 fee with -$1 stored tax) had
its tax breakdown stripped on save — refund_total stayed at -$11 and
refund_tax was emitted as []. The preview side (build_refund_preview)
keeps any non-zero stored tax and renders the signed split correctly, so
a refund moving from preview to create lost the tax breakdown.
Match the preview rule (non-zero, not strictly positive). Add a
regression test that converts a negative fee with negative stored tax
and verifies the signed split survives.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Remove duplicate slug-only changelog entry
The CI auto-added 65439-woomob-2685-simplify-refund-creation with a
more detailed body. Keep the PR-prefixed file as the canonical entry.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Add parity test: create amount matches build_refund_preview total
Regression guard against the create vs preview drift mikejolley flagged
on PRs #65334 and #65335. Builds an order with a product carrying 10%
tax, calls build_refund_preview directly to capture the authoritative
grand total, then posts the same line items (quantity only) to the
create endpoint. The resulting refund amount must equal preview total
exactly. A future change that subtly diverges create's auto compute from
the preview calculation would fail this assertion.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Lock fee/shipping quantity-ignored behavior in compute_line_item_refund_total
The existing tests for shipping and positive fees pass quantity = 1, which
does not prove the quantity argument is ignored. Add two short tests that
pass quantity = 5 and assert the same line total + tax is returned. Catches
a future refactor that wrongly applies unit_price * quantity to shipping
or fee items.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Improve `refund_total` description text
Co-authored-by: Mike Jolley <mike.jolley@me.com>
* Add multi-quantity rounding tests and fix refund total comparison
* Fix duplicate refunds for fees and shipping
* Remove refunds PHPStan baseline suppressions
---------
Co-authored-by: woocommercebot <woocommercebot@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Mike Jolley <mike.jolley@me.com>
diff --git a/plugins/woocommerce/changelog/65439-woomob-2685-simplify-refund-creation b/plugins/woocommerce/changelog/65439-woomob-2685-simplify-refund-creation
new file mode 100644
index 00000000000..a12d8dc60f1
--- /dev/null
+++ b/plugins/woocommerce/changelog/65439-woomob-2685-simplify-refund-creation
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Make per-line `refund_total` optional on POST /wc/v4/refunds. When omitted, the backend computes the tax-inclusive refund total from the order line item's unit price × quantity. The legacy explicit form (refund_total without quantity) is preserved for v3-style callers.
\ No newline at end of file
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index 8c071eedfce..9470f1528e9 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -66925,18 +66925,6 @@ parameters:
count: 1
path: src/Internal/RestApi/Routes/V4/Refunds/Controller.php
- -
- message: '#^Parameter \#2 \$order of method Automattic\\WooCommerce\\Internal\\RestApi\\Routes\\V4\\Refunds\\DataUtils\:\:convert_line_items_to_internal_format\(\) expects WC_Order, WC_Order\|WC_Order_Refund given\.$#'
- identifier: argument.type
- count: 1
- path: src/Internal/RestApi/Routes/V4/Refunds/Controller.php
-
- -
- message: '#^Parameter \#2 \$order of method Automattic\\WooCommerce\\Internal\\RestApi\\Routes\\V4\\Refunds\\DataUtils\:\:validate_line_items\(\) expects WC_Order, WC_Order\|WC_Order_Refund given\.$#'
- identifier: argument.type
- count: 1
- path: src/Internal/RestApi/Routes/V4/Refunds/Controller.php
-
-
message: '#^Binary operation "\+" between string and string results in an error\.$#'
identifier: binaryOp.invalid
diff --git a/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Refunds/Controller.php b/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Refunds/Controller.php
index ffc800b718d..d078c69e47d 100644
--- a/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Refunds/Controller.php
+++ b/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Refunds/Controller.php
@@ -348,23 +348,59 @@ class Controller extends AbstractController {
return $this->get_route_error_by_code( self::RESOURCE_EXISTS );
}
+ $order = wc_get_order( $request['order_id'] );
+
+ // wc_get_order can return a WC_Order_Refund for refund IDs — reject those
+ // here since refunds are not refundable themselves.
+ if ( ! $order instanceof \WC_Order ) {
+ return $this->get_route_error_by_code( self::INVALID_ID );
+ }
+
+ // Fill in refund_total for any line items that omit it. The simplified
+ // request form sends only {line_item_id, quantity}; the backend derives
+ // the tax-inclusive total from the order's unit price × quantity.
+ // Scoped try: compute_line_item_refund_total throws InvalidArgumentException
+ // on quantity < 1, but fill_missing_refund_totals pre-checks that condition,
+ // so this branch is defensive against a future invariant break only.
try {
- $order = wc_get_order( $request['order_id'] );
+ $line_items = $this->data_utils->fill_missing_refund_totals( $request['line_items'] ?? array(), $order );
+ } catch ( \InvalidArgumentException $e ) {
+ wc_get_logger()->error(
+ sprintf(
+ 'Refund creation invariant violation on order %d (%s): %s',
+ $order->get_id(),
+ get_class( $e ),
+ $e->getMessage()
+ ),
+ array( 'source' => 'wc-v4-refunds' )
+ );
+ return $this->get_route_error_response(
+ 'invalid_refund_request',
+ __( 'The refund could not be created due to an unexpected error.', 'woocommerce' ),
+ WP_Http::INTERNAL_SERVER_ERROR
+ );
+ }
- if ( ! $order ) {
- return $this->get_route_error_by_code( self::INVALID_ID );
- }
+ // Mirror the augmented array back onto the request so the 'created' hook
+ // and any other downstream readers of $request['line_items'] see
+ // normalised data with refund_total populated.
+ $request->set_param( 'line_items', $line_items );
+ try {
// Validate request line_items before proceeding against the order being refunded.
- $validation_error = $this->data_utils->validate_line_items( $request['line_items'], $order );
+ $validation_error = $this->data_utils->validate_line_items( $line_items, $order );
if ( is_wp_error( $validation_error ) ) {
return $this->get_route_error_response( $validation_error->get_error_code(), $validation_error->get_error_message() );
}
- // Convert line items to internal format.
- $line_item_data = $this->data_utils->convert_line_items_to_internal_format( $request['line_items'], $order );
- $calculated_total = ! empty( $request['line_items'] ) ? $this->data_utils->calculate_refund_amount( $request['line_items'] ) : 0;
+ // Convert line items to internal format. Note: refund_total is tax-inclusive
+ // for both auto-computed values (from compute_line_item_refund_total) and
+ // explicit client values — the converter then extracts the tax portion via
+ // WC_Tax::calc_inclusive_tax. Summing across mixed (auto + explicit) entries
+ // in calculate_refund_amount is therefore well-defined.
+ $line_item_data = $this->data_utils->convert_line_items_to_internal_format( $line_items, $order );
+ $calculated_total = ! empty( $line_items ) ? $this->data_utils->calculate_refund_amount( $line_items ) : 0;
$refund_amount = ! empty( $request['amount'] ) ? $request['amount'] : $calculated_total;
if ( 0 > $refund_amount || ! $refund_amount ) {
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 dfba93811af..f99756265c7 100644
--- a/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Refunds/DataUtils.php
+++ b/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Refunds/DataUtils.php
@@ -58,12 +58,24 @@ class DataUtils {
$prepared_line_items = array();
foreach ( $line_items as $line_item ) {
- if ( ! isset( $line_item['line_item_id'], $line_item['quantity'], $line_item['refund_total'] ) ) {
+ // A line item is processable when it has an ID and at least one of
+ // quantity or refund_total. The legacy v3-style form may omit
+ // quantity entirely; in that case qty=0 is recorded on the refund,
+ // matching v3 semantics ("refunded $X of this line without consuming
+ // specific units"). Dollar accounting via get_remaining_refund_amount
+ // still bounds subsequent refunds, so per-unit looseness here does
+ // not enable over-refunding.
+ if ( ! isset( $line_item['line_item_id'] ) ) {
+ continue;
+ }
+ if ( ! isset( $line_item['quantity'] ) && ! isset( $line_item['refund_total'] ) ) {
continue;
}
- // If no explicit refund_tax provided, extract tax from refund_total using WC_Tax.
- if ( ! isset( $line_item['refund_tax'] ) ) {
+ // If no explicit refund_tax provided, extract tax from refund_total
+ // using WC_Tax. Skip when refund_total is also missing — there's
+ // nothing to extract tax from.
+ if ( ! isset( $line_item['refund_tax'] ) && isset( $line_item['refund_total'] ) ) {
$original_item = $order->get_item( $line_item['line_item_id'] );
if ( $original_item ) {
$original_taxes = $original_item->get_taxes();
@@ -110,9 +122,12 @@ class DataUtils {
}
}
+ // Default qty=0 when quantity was omitted (legacy v3-style explicit
+ // refund_total path). Default refund_total=0 defensively; in practice
+ // validate_line_items ensures one of them is set by this point.
$prepared_line_items[ $line_item['line_item_id'] ] = array(
- 'qty' => $line_item['quantity'],
- 'refund_total' => $line_item['refund_total'],
+ 'qty' => $line_item['quantity'] ?? 0,
+ 'refund_total' => $line_item['refund_total'] ?? 0,
'refund_tax' => $this->convert_line_item_taxes_to_internal_format( $line_item['refund_tax'] ?? array() ),
);
}
@@ -155,13 +170,16 @@ class DataUtils {
$amount = 0;
foreach ( $line_items as $line_item ) {
- if ( ! empty( $line_item['refund_total'] ) && is_numeric( $line_item['refund_total'] ) ) {
+ // is_numeric() (not !empty) — an explicit refund_total of 0 is a valid
+ // "zero refund for this line" value and must round-trip cleanly, not
+ // be silently dropped from the sum.
+ if ( isset( $line_item['refund_total'] ) && is_numeric( $line_item['refund_total'] ) ) {
$amount += $line_item['refund_total'];
}
if ( ! empty( $line_item['refund_tax'] ) && is_array( $line_item['refund_tax'] ) ) {
foreach ( $line_item['refund_tax'] as $tax ) {
- if ( ! empty( $tax['refund_total'] ) && is_numeric( $tax['refund_total'] ) ) {
+ if ( isset( $tax['refund_total'] ) && is_numeric( $tax['refund_total'] ) ) {
$amount += $tax['refund_total'];
}
}
@@ -179,6 +197,10 @@ class DataUtils {
* @return boolean|WP_Error
*/
public function validate_line_items( $line_items, WC_Order $order ) {
+ // Precompute refunded quantities/totals once so the over-refund check
+ // below caps against remaining refundable quantity, not the original.
+ $refund_data = $this->compute_refunded_quantities_and_totals( $order );
+
foreach ( $line_items as $line_item ) {
$line_item_id = $line_item['line_item_id'] ?? null;
@@ -197,15 +219,108 @@ class DataUtils {
return new WP_Error( 'invalid_line_item', __( 'Line item is not a product, fee, or shipping line.', 'woocommerce' ) );
}
- // Validate item quantity is not greater than the item quantity.
- if ( $item->get_quantity() < $line_item['quantity'] ) {
- /* translators: %s: item quantity */
- return new WP_Error( 'invalid_line_item', sprintf( __( 'Line item quantity cannot be greater than the item quantity (%s).', 'woocommerce' ), $item->get_quantity() ) );
+ // Quantity is required only when the client omits refund_total — the
+ // auto-compute path needs a real quantity to derive the unit price.
+ // When refund_total is provided explicitly (legacy v3-style path),
+ // quantity is informational and can be missing/zero, matching the
+ // original v4 schema's `default: 0` behavior.
+ $refund_total_missing = ! array_key_exists( 'refund_total', $line_item ) || null === $line_item['refund_total'];
+
+ // Reject the ambiguous "auto-computed refund_total + explicit refund_tax"
+ // combination. Auto-compute writes a tax-inclusive value; the
+ // converter then skips tax extraction because refund_tax is set,
+ // and calculate_refund_amount double-counts the tax. The client
+ // must either supply refund_total explicitly (and may then supply
+ // refund_tax to override the auto-extracted split) or let the
+ // server handle taxes (omit both).
+ if ( $refund_total_missing && isset( $line_item['refund_tax'] ) ) {
+ return new WP_Error(
+ 'invalid_line_item',
+ __( 'refund_tax cannot be combined with an auto-computed refund_total. Provide refund_total explicitly when supplying refund_tax.', 'woocommerce' )
+ );
+ }
+
+ if ( $refund_total_missing && ( ! isset( $line_item['quantity'] ) || ! is_int( $line_item['quantity'] ) || $line_item['quantity'] < 1 ) ) {
+ return new WP_Error(
+ 'invalid_line_item',
+ __( 'Line item quantity must be a positive integer when refund_total is omitted.', 'woocommerce' )
+ );
+ }
+
+ // Auto-compute requires a non-zero source quantity to derive the unit
+ // price from. If the client omitted refund_total (or sent null) and the
+ // source product has zero quantity, surface a clear error rather than
+ // letting the request slip into the misleading "must be greater than
+ // zero" branch downstream.
+ if ( $refund_total_missing && $item instanceof \WC_Order_Item_Product && 0 === $item->get_quantity() ) {
+ return new WP_Error(
+ 'invalid_line_item',
+ sprintf(
+ /* translators: %d: line item id */
+ __( 'Cannot auto-compute refund for line item %d: source quantity is zero. Provide an explicit refund_total.', 'woocommerce' ),
+ (int) $line_item_id
+ )
+ );
+ }
+
+ // Validate refund quantity does not exceed remaining refundable
+ // quantity for this line. compute_refunded_quantities_and_totals
+ // returns negative values for already-refunded units (matches the
+ // convention used by validate_preview_line_items), so adding to
+ // $item->get_quantity() yields the remaining count.
+ // Only fires when a quantity was provided — the legacy
+ // explicit-refund_total path may omit it.
+ if ( isset( $line_item['quantity'] ) && $item instanceof \WC_Order_Item_Product ) {
+ $remaining_qty = $item->get_quantity() + ( $refund_data['qtys'][ $line_item_id ] ?? 0 );
+ if ( $line_item['quantity'] > $remaining_qty ) {
+ return new WP_Error(
+ 'invalid_line_item',
+ sprintf(
+ /* translators: %d: remaining refundable quantity */
+ __( 'Line item quantity cannot be greater than the remaining refundable quantity (%d).', 'woocommerce' ),
+ $remaining_qty
+ )
+ );
+ }
+ } elseif ( isset( $line_item['quantity'] ) ) {
+ if ( $item->get_quantity() < $line_item['quantity'] ) {
+ /* translators: %s: item quantity */
+ return new WP_Error( 'invalid_line_item', sprintf( __( 'Line item quantity cannot be greater than the item quantity (%s).', 'woocommerce' ), $item->get_quantity() ) );
+ }
+
+ $price_decimals = wc_get_price_decimals();
+ $item_total_with_tax = abs( (float) $item->get_total() + (float) $item->get_total_tax() );
+ $refunded_total = abs( (float) ( $refund_data['totals'][ $line_item_id ] ?? 0.0 ) );
+ $remaining_total = $item_total_with_tax - $refunded_total;
+ $requested_total = isset( $line_item['refund_total'] )
+ ? abs( (float) $line_item['refund_total'] )
+ : abs( $this->compute_line_item_refund_total( $item, $line_item['quantity'] ) );
+
+ if ( $remaining_total <= 0 ) {
+ return new WP_Error(
+ 'invalid_line_item',
+ __( 'This line item has already been fully refunded.', 'woocommerce' )
+ );
+ }
+
+ if ( $requested_total > NumberUtil::round( $remaining_total, $price_decimals ) ) {
+ return new WP_Error(
+ 'invalid_line_item',
+ sprintf(
+ /* translators: %s: remaining refundable amount */
+ __( 'Line item refund total cannot be greater than the remaining refundable amount (%s).', 'woocommerce' ),
+ wc_format_decimal( $remaining_total, $price_decimals )
+ )
+ );
+ }
}
// Validate refund total is not greater than the item total (including tax).
+ // Round both sides to price decimals before comparing — the raw float sum
+ // (e.g. 29.97 + 2.66) can land a hair below the rounded refund_total and
+ // spuriously reject full-line refunds, including auto-computed totals.
$item_total_with_tax = $item->get_total() + $item->get_total_tax();
- if ( $item_total_with_tax < $line_item['refund_total'] ) {
+ if ( isset( $line_item['refund_total'] ) && NumberUtil::round( (float) $item_total_with_tax, wc_get_price_decimals() ) < NumberUtil::round( (float) $line_item['refund_total'], wc_get_price_decimals() ) ) {
return new WP_Error(
'invalid_refund_amount',
sprintf(
@@ -347,6 +462,77 @@ class DataUtils {
return NumberUtil::round( (float) $item->get_total() + (float) $item->get_total_tax(), $price_decimals );
}
+ /**
+ * 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().
+ *
+ * Items that already have refund_total (including an explicit 0) are left
+ * untouched, so existing v3-style clients keep working. Items where refund_total
+ * is omitted OR is explicitly null are treated as "compute it for me". Items
+ * that can't be resolved (missing line_item_id, item not on order, invalid
+ * quantity, unsupported item type, product with zero source quantity) are
+ * also left untouched — validate_line_items surfaces the right error for
+ * those cases.
+ *
+ * Auto-computed values are tax-inclusive, matching the convention enforced by
+ * the existing converter (convert_line_items_to_internal_format extracts tax
+ * from a tax-inclusive refund_total).
+ *
+ * @param array $line_items Line items from the request (schema format).
+ * Each item: array{line_item_id?: int, quantity?: int,
+ * refund_total?: float|int|null, refund_tax?: array<int, mixed>}.
+ * @param WC_Order $order The order being refunded.
+ * @return array The line items with refund_total populated where possible (same shape as input).
+ *
+ * @since 10.9.0
+ */
+ public function fill_missing_refund_totals( array $line_items, WC_Order $order ): array {
+ 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 treated as a zero refund for
+ // that line (existing behaviour: calculate_refund_amount skips it from
+ // the sum, the under-refund check may then trip).
+ if ( array_key_exists( 'refund_total', $line_item ) && null !== $line_item['refund_total'] ) {
+ continue;
+ }
+
+ // Skip auto-compute when the client also supplied an explicit
+ // refund_tax. Auto-compute writes a tax-inclusive refund_total, but
+ // the converter then skips tax extraction whenever refund_tax is
+ // already present — and calculate_refund_amount would add both,
+ // inflating the total by the tax amount. Leave refund_total unset;
+ // validate_line_items rejects this ambiguous combination with a
+ // clear error.
+ if ( isset( $line_item['refund_tax'] ) ) {
+ continue;
+ }
+
+ $line_item_id = $line_item['line_item_id'] ?? null;
+ $quantity = $line_item['quantity'] ?? null;
+ if ( ! $line_item_id || ! is_int( $quantity ) || $quantity < 1 ) {
+ continue;
+ }
+
+ $item = $order->get_item( $line_item_id );
+ if ( ! $item || ! ( $item instanceof WC_Order_Item_Product || $item instanceof WC_Order_Item_Shipping || $item instanceof WC_Order_Item_Fee ) ) {
+ continue;
+ }
+
+ // A product whose source line has zero quantity has no unit price to
+ // derive a refund from. Skip so validate_line_items surfaces a clear
+ // 'invalid_line_item' error to the API consumer instead of letting a
+ // silent 0.0 propagate into the misleading "must be greater than zero"
+ // branch downstream.
+ if ( $item instanceof WC_Order_Item_Product && 0 === $item->get_quantity() ) {
+ continue;
+ }
+
+ $line_items[ $key ]['refund_total'] = $this->compute_line_item_refund_total( $item, $quantity );
+ }
+
+ return $line_items;
+ }
+
/**
* Build a refund preview showing authoritative totals and breakdowns.
*
diff --git a/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Refunds/Schema/RefundSchema.php b/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Refunds/Schema/RefundSchema.php
index c0ebc231d94..61dc35a86d9 100644
--- a/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Refunds/Schema/RefundSchema.php
+++ b/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Refunds/Schema/RefundSchema.php
@@ -229,19 +229,16 @@ class RefundSchema extends AbstractSchema {
'validate_callback' => 'rest_validate_request_arg',
),
'quantity' => array(
- 'description' => __( 'Quantity refunded.', 'woocommerce' ),
+ 'description' => __( 'Quantity refunded. Required when refund_total is omitted (the backend computes the total from unit price × quantity); optional when refund_total is provided explicitly.', 'woocommerce' ),
'type' => 'integer',
'context' => self::VIEW_EDIT_EMBED_CONTEXT,
- 'default' => 0,
'sanitize_callback' => 'wc_stock_amount',
'validate_callback' => 'rest_validate_request_arg',
),
'refund_total' => array(
- 'description' => __( 'Total refunded for this item.', 'woocommerce' ),
- 'type' => 'number',
+ 'description' => __( 'Total amount refunded for this item (including tax). If omitted or set to null, the backend computes it from the order line item\'s unit price multiplied by quantity. An explicit 0 is treated as a zero refund for this line item.', 'woocommerce' ),
+ 'type' => array( 'number', 'null' ),
'context' => self::VIEW_EDIT_EMBED_CONTEXT,
- 'default' => 0,
- 'sanitize_callback' => 'sanitize_text_field',
'validate_callback' => 'rest_validate_request_arg',
),
'refund_tax' => array(
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 044a2f77766..2bf3bf06f2e 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
@@ -560,7 +560,8 @@ class WC_REST_Refunds_V4_Controller_Tests extends WC_REST_Unit_Test_Case {
array(
'line_item_id' => $item->get_id(),
'quantity' => 1,
- 'refund_total' => 128.00, // Includes 23.00 + 5.00 tax.
+ // Includes 23.00 + 5.00 tax.
+ 'refund_total' => 128.00,
),
),
);
@@ -707,7 +708,8 @@ class WC_REST_Refunds_V4_Controller_Tests extends WC_REST_Unit_Test_Case {
array(
'line_item_id' => $item->get_id(),
'quantity' => 1,
- 'refund_total' => 115.50, // Includes 10.00 + 5.50 compound tax.
+ // Includes 10.00 + 5.50 compound tax.
+ 'refund_total' => 115.50,
),
),
);
@@ -839,6 +841,7 @@ class WC_REST_Refunds_V4_Controller_Tests extends WC_REST_Unit_Test_Case {
// Create partial refund with explicit refund_tax array (legacy backward compatibility).
// Refunding 30.00 out of 50.00 subtotal (30.00 + 6.90 + 1.50 = 38.40).
// Don't specify amount - let it auto-calculate from line items.
+ // refund_total values exclude tax; refund_tax entries are 23% and 5% of 30.00.
$refund_data = array(
'order_id' => $order->get_id(),
'reason' => 'Testing explicit tax array (legacy format)',
@@ -846,15 +849,15 @@ class WC_REST_Refunds_V4_Controller_Tests extends WC_REST_Unit_Test_Case {
array(
'line_item_id' => $item->get_id(),
'quantity' => 1,
- 'refund_total' => 30.00, // Excluding tax.
+ 'refund_total' => 30.00,
'refund_tax' => array(
array(
'id' => $tax_rate_id_1,
- 'refund_total' => 6.90, // 23% of 30.00.
+ 'refund_total' => 6.90,
),
array(
'id' => $tax_rate_id_2,
- 'refund_total' => 1.50, // 5% of 30.00.
+ 'refund_total' => 1.50,
),
),
),
@@ -960,7 +963,8 @@ class WC_REST_Refunds_V4_Controller_Tests extends WC_REST_Unit_Test_Case {
array(
'line_item_id' => $item->get_id(),
'quantity' => 1,
- 'refund_total' => 500.00, // Exceeds 110.00 (item total with tax).
+ // Exceeds 110.00 (item total with tax) to trigger the over-refund check.
+ 'refund_total' => 500.00,
),
),
);
@@ -1050,7 +1054,8 @@ class WC_REST_Refunds_V4_Controller_Tests extends WC_REST_Unit_Test_Case {
array(
'line_item_id' => $item->get_id(),
'quantity' => 1,
- 'refund_total' => 110.00, // Line items total is 110.00.
+ // Line items total is 110.00.
+ 'refund_total' => 110.00,
),
),
);
@@ -1230,7 +1235,8 @@ class WC_REST_Refunds_V4_Controller_Tests extends WC_REST_Unit_Test_Case {
$order->set_billing_country( 'US' );
$order->set_billing_state( 'CA' );
- $order->set_total( 55.26 ); // 50.00 + 0.50 + 1.63 + 3.13.
+ $order->set_total( 55.26 );
+ // 50.00 + 0.50 + 1.63 + 3.13.
$order->save();
$this->created_orders[] = $order->get_id();
@@ -1245,7 +1251,8 @@ class WC_REST_Refunds_V4_Controller_Tests extends WC_REST_Unit_Test_Case {
array(
'line_item_id' => $item->get_id(),
'quantity' => 1,
- 'refund_total' => 55.26, // Includes all taxes.
+ // Includes all taxes.
+ 'refund_total' => 55.26,
),
),
);
@@ -1315,6 +1322,2316 @@ class WC_REST_Refunds_V4_Controller_Tests extends WC_REST_Unit_Test_Case {
$product->delete( true );
}
+ /**
+ * @testdox Refund creation auto-computes refund_total from the order line item when omitted.
+ */
+ public function test_refunds_create_simplified_form_no_tax(): void {
+ // Two-quantity product at $10 each = $20 order total.
+ $product = WC_Helper_Product::create_simple_product();
+ $product->set_price( 10.00 );
+ $product->save();
+
+ $order = $this->create_test_order(
+ array(
+ 'line_items' => array(
+ array(
+ 'product_id' => $product->get_id(),
+ 'quantity' => 2,
+ ),
+ ),
+ )
+ );
+ $items = $order->get_items();
+ $line_item = reset( $items );
+
+ // Refund 1 of 2 — refund_total OMITTED.
+ $request = new WP_REST_Request( 'POST', '/wc/v4/refunds' );
+ $request->set_body_params(
+ array(
+ 'order_id' => $order->get_id(),
+ 'line_items' => array(
+ array(
+ 'line_item_id' => $line_item->get_id(),
+ 'quantity' => 1,
+ ),
+ ),
+ )
+ );
+ $response = $this->server->dispatch( $request );
+
+ $this->assertEquals( 201, $response->get_status() );
+ $data = $response->get_data();
+ $this->assertEquals( '10.00', $data['amount'], 'Auto-computed amount should be unit price × quantity' );
+
+ $this->created_refunds[] = $data['id'];
+ $product->delete( true );
+ }
+
+ /**
+ * @testdox Refund creation with omitted refund_total extracts tax correctly.
+ */
+ public function test_refunds_create_simplified_form_with_tax(): void {
+ $tax_rate_id = WC_Tax::_insert_tax_rate(
+ array(
+ 'tax_rate_country' => 'US',
+ 'tax_rate_state' => '',
+ 'tax_rate' => '10.0000',
+ 'tax_rate_name' => 'VAT',
+ 'tax_rate_priority' => '1',
+ 'tax_rate_compound' => '0',
+ 'tax_rate_shipping' => '1',
+ 'tax_rate_order' => '1',
+ 'tax_rate_class' => '',
+ )
+ );
+
+ // Capture original option values so we can restore them in finally —
+ // tearDown doesn't reset these globally and leakage breaks subsequent
+ // tests that assume the default tax config.
+ $original_calc_taxes = get_option( 'woocommerce_calc_taxes', 'no' );
+ $original_prices_include_tax = get_option( 'woocommerce_prices_include_tax', 'no' );
+ update_option( 'woocommerce_calc_taxes', 'yes' );
+ update_option( 'woocommerce_prices_include_tax', 'no' );
+
+ try {
+ $product = WC_Helper_Product::create_simple_product();
+ $product->set_regular_price( 100.00 );
+ $product->set_tax_status( 'taxable' );
+ $product->save();
+
+ $order = wc_create_order();
+ $item = new WC_Order_Item_Product();
+ $item->set_props(
+ array(
+ 'product' => $product,
+ 'quantity' => 1,
+ 'subtotal' => 100.00,
+ 'total' => 100.00,
+ )
+ );
+ $item->set_taxes(
+ array(
+ 'total' => array( $tax_rate_id => 10.00 ),
+ 'subtotal' => array( $tax_rate_id => 10.00 ),
+ )
+ );
+ $item->save();
+ $order->add_item( $item );
+
+ $tax_item = new \WC_Order_Item_Tax();
+ $tax_item->set_rate( $tax_rate_id );
+ $tax_item->set_tax_total( 10.00 );
+ $tax_item->save();
+ $order->add_item( $tax_item );
+
+ $order->set_billing_country( 'US' );
+ // calculate_totals( false ) is not reliable in the test environment when
+ // taxes are involved — set_total() explicitly so get_remaining_refund_amount()
+ // matches the line + tax sum.
+ $order->set_total( 110.00 );
+ $order->set_status( OrderStatus::COMPLETED );
+ $order->save();
+ $this->created_orders[] = $order->get_id();
+
+ // Refund the line — refund_total OMITTED.
+ $request = new WP_REST_Request( 'POST', '/wc/v4/refunds' );
+ $request->set_body_params(
+ array(
+ 'order_id' => $order->get_id(),
+ 'line_items' => array(
+ array(
+ 'line_item_id' => $item->get_id(),
+ 'quantity' => 1,
+ ),
+ ),
+ )
+ );
+ $response = $this->server->dispatch( $request );
+
+ $this->assertEquals( 201, $response->get_status() );
+ $data = $response->get_data();
+ $this->assertEquals( '110.00', $data['amount'], 'Auto-computed amount should include tax ($100 + 10% = $110)' );
+
+ // Verify the per-line refund_tax was extracted (not 0).
+ $this->assertNotEmpty( $data['line_items'] );
+ $line_item_response = $data['line_items'][0];
+ $this->assertEquals( '100.00', $line_item_response['refund_total'], 'Per-line refund_total should be tax-exclusive after extraction' );
+ $this->assertNotEmpty( $line_item_response['refund_tax'], 'refund_tax should be populated from extraction' );
+ $this->assertEquals( '10.00', $line_item_response['refund_tax'][0]['refund_total'] );
+
+ $this->created_refunds[] = $data['id'];
+ $product->delete( true );
+ } finally {
+ update_option( 'woocommerce_calc_taxes', $original_calc_taxes );
+ update_option( 'woocommerce_prices_include_tax', $original_prices_include_tax );
+ }
+ }
+
+ /**
+ * @testdox Simplified form (no refund_total) produces the same amount as explicit refund_total.
+ */
+ public function test_refunds_create_simplified_matches_explicit(): void {
+ $product = WC_Helper_Product::create_simple_product();
+ // set_regular_price() persists to product meta so the REST order-creation flow
+ // picks it up. set_price() only updates the in-memory derived price and gets
+ // overwritten when the product is reloaded inside the order controller.
+ $product->set_regular_price( 25.00 );
+ $product->save();
+
+ // Order A: refunded via simplified form.
+ $order_a = $this->create_test_order(
+ array(
+ 'line_items' => array(
+ array(
+ 'product_id' => $product->get_id(),
+ 'quantity' => 4,
+ ),
+ ),
+ )
+ );
+ $items_a = $order_a->get_items();
+ $item_a = reset( $items_a );
+ $request_a = new WP_REST_Request( 'POST', '/wc/v4/refunds' );
+ $request_a->set_body_params(
+ array(
+ 'order_id' => $order_a->get_id(),
+ 'line_items' => array(
+ array(
+ 'line_item_id' => $item_a->get_id(),
+ 'quantity' => 2,
+ ),
+ ),
+ )
+ );
+ $response_a = $this->server->dispatch( $request_a );
+ $this->assertEquals( 201, $response_a->get_status() );
+ $amount_a = $response_a->get_data()['amount'];
+ $this->created_refunds[] = $response_a->get_data()['id'];
+
+ // Order B: same shape but with explicit refund_total computed by the client.
+ $order_b = $this->create_test_order(
+ array(
+ 'line_items' => array(
+ array(
+ 'product_id' => $product->get_id(),
+ 'quantity' => 4,
+ ),
+ ),
+ )
+ );
+ $items_b = $order_b->get_items();
+ $item_b = reset( $items_b );
+ $request_b = new WP_REST_Request( 'POST', '/wc/v4/refunds' );
+ $request_b->set_body_params(
+ array(
+ 'order_id' => $order_b->get_id(),
+ 'line_items' => array(
+ array(
+ 'line_item_id' => $item_b->get_id(),
+ 'quantity' => 2,
+ 'refund_total' => 50.00,
+ ),
+ ),
+ )
+ );
+ $response_b = $this->server->dispatch( $request_b );
+ $this->assertEquals( 201, $response_b->get_status() );
+ $amount_b = $response_b->get_data()['amount'];
+ $this->created_refunds[] = $response_b->get_data()['id'];
+
+ $this->assertEquals( $amount_b, $amount_a, 'Simplified form should produce the same amount as the explicit form.' );
+
+ $product->delete( true );
+ }
+
+ /**
+ * @testdox Simplified form produces the same amount as explicit refund_total on a tax-inclusive store.
+ *
+ * The no-tax equivalence test is trivial — compute_line_item_refund_total
+ * returns the raw line total. The interesting regression risk is the
+ * tax round-trip: auto-compute returns a tax-inclusive value, the converter
+ * runs WC_Tax::calc_inclusive_tax to split it. A future refactor that
+ * yielded a tax-exclusive auto-computed value would diverge from the
+ * explicit-form total by the tax delta with no other test catching it.
+ */
+ public function test_refunds_create_simplified_matches_explicit_tax_inclusive(): void {
+ $tax_rate_id = WC_Tax::_insert_tax_rate(
+ array(
+ 'tax_rate_country' => 'US',
+ 'tax_rate_state' => '',
+ 'tax_rate' => '10.0000',
+ 'tax_rate_name' => 'VAT',
+ 'tax_rate_priority' => '1',
+ 'tax_rate_compound' => '0',
+ 'tax_rate_shipping' => '1',
+ 'tax_rate_order' => '1',
+ 'tax_rate_class' => '',
+ )
+ );
+
+ $original_calc_taxes = get_option( 'woocommerce_calc_taxes', 'no' );
+ $original_prices_include_tax = get_option( 'woocommerce_prices_include_tax', 'no' );
+ update_option( 'woocommerce_calc_taxes', 'yes' );
+ // Actually exercise a tax-inclusive store (the test name now matches reality).
+ update_option( 'woocommerce_prices_include_tax', 'yes' );
+
+ try {
+ $dispatch_refund = function ( array $line_item_overrides ) use ( $tax_rate_id ): array {
+ $product = WC_Helper_Product::create_simple_product();
+ // Tax-inclusive store: regular_price entered with tax baked in.
+ $product->set_regular_price( 110.00 );
+ $product->set_tax_status( 'taxable' );
+ $product->save();
+
+ $order = wc_create_order();
+ $item = new WC_Order_Item_Product();
+ $item->set_props(
+ array(
+ 'product' => $product,
+ 'quantity' => 1,
+ 'subtotal' => 100.00,
+ 'total' => 100.00,
+ )
+ );
+ $item->set_taxes(
+ array(
+ 'total' => array( $tax_rate_id => 10.00 ),
+ 'subtotal' => array( $tax_rate_id => 10.00 ),
+ )
+ );
+ $item->save();
+ $order->add_item( $item );
+
+ $tax_item = new \WC_Order_Item_Tax();
+ $tax_item->set_rate( $tax_rate_id );
+ $tax_item->set_tax_total( 10.00 );
+ $tax_item->save();
+ $order->add_item( $tax_item );
+
+ $order->set_billing_country( 'US' );
+ $order->set_total( 110.00 );
+ $order->set_status( OrderStatus::COMPLETED );
+ $order->save();
+ $this->created_orders[] = $order->get_id();
+
+ $request = new WP_REST_Request( 'POST', '/wc/v4/refunds' );
+ $request->set_body_params(
+ array(
+ 'order_id' => $order->get_id(),
+ 'line_items' => array(
+ array_merge( array( 'line_item_id' => $item->get_id() ), $line_item_overrides ),
+ ),
+ )
+ );
+ $response = $this->server->dispatch( $request );
+
+ $this->assertEquals( 201, $response->get_status() );
+ $data = $response->get_data();
+ $this->created_refunds[] = $data['id'];
+ $product->delete( true );
+
+ return $data;
+ };
+
+ // Path A: simplified form — no refund_total, backend auto-computes via compute_line_item_refund_total.
+ $data_simplified = $dispatch_refund( array( 'quantity' => 1 ) );
+ // Path B: explicit form — client supplies the tax-inclusive refund_total.
+ $data_explicit = $dispatch_refund(
+ array(
+ 'quantity' => 1,
+ 'refund_total' => 110.00,
+ )
+ );
+
+ $this->assertEquals(
+ $data_explicit['amount'],
+ $data_simplified['amount'],
+ 'Tax-inclusive store: simplified and explicit forms must produce the same amount.'
+ );
+ $this->assertEquals( '110.00', $data_simplified['amount'] );
+
+ // The per-line refund_total / refund_tax must round-trip identically too.
+ $this->assertEquals(
+ $data_explicit['line_items'][0]['refund_total'],
+ $data_simplified['line_items'][0]['refund_total'],
+ 'Per-line refund_total must match (tax-exclusive after extraction).'
+ );
+ $this->assertEquals( '100.00', $data_simplified['line_items'][0]['refund_total'] );
+
+ $this->assertNotEmpty( $data_simplified['line_items'][0]['refund_tax'] );
+ $this->assertEquals(
+ $data_explicit['line_items'][0]['refund_tax'][0]['refund_total'],
+ $data_simplified['line_items'][0]['refund_tax'][0]['refund_total'],
+ 'Extracted refund_tax must match between paths.'
+ );
+ $this->assertEquals( '10.00', $data_simplified['line_items'][0]['refund_tax'][0]['refund_total'] );
+ } finally {
+ update_option( 'woocommerce_calc_taxes', $original_calc_taxes );
+ update_option( 'woocommerce_prices_include_tax', $original_prices_include_tax );
+ }
+ }
+
+ /**
+ * @testdox Refund creation supports mixing items with and without refund_total in the same request.
+ */
+ public function test_refunds_create_mixed_with_and_without_refund_total(): void {
+ $product_a = WC_Helper_Product::create_simple_product();
+ $product_a->set_price( 10.00 );
+ $product_a->save();
+ $product_b = WC_Helper_Product::create_simple_product();
+ $product_b->set_price( 20.00 );
+ $product_b->save();
+
+ $order = wc_create_order();
+ $item_a = new WC_Order_Item_Product();
+ $item_a->set_props(
+ array(
+ 'product' => $product_a,
+ 'quantity' => 1,
+ 'subtotal' => 10.00,
+ 'total' => 10.00,
+ )
+ );
+ $item_a->save();
+ $order->add_item( $item_a );
+ $item_b = new WC_Order_Item_Product();
+ $item_b->set_props(
+ array(
+ 'product' => $product_b,
+ 'quantity' => 1,
+ 'subtotal' => 20.00,
+ 'total' => 20.00,
+ )
+ );
+ $item_b->save();
+ $order->add_item( $item_b );
+ $order->set_total( 30.00 );
+ $order->set_status( OrderStatus::COMPLETED );
+ $order->save();
+ $this->created_orders[] = $order->get_id();
+
+ $request = new WP_REST_Request( 'POST', '/wc/v4/refunds' );
+ $request->set_body_params(
+ array(
+ 'order_id' => $order->get_id(),
+ 'line_items' => array(
+ // Item A — no refund_total, will be auto-computed to 10.00.
+ array(
+ 'line_item_id' => $item_a->get_id(),
+ 'quantity' => 1,
+ ),
+ // Item B — explicit refund_total (less than item total — over-refund allowed for B).
+ array(
+ 'line_item_id' => $item_b->get_id(),
+ 'quantity' => 1,
+ 'refund_total' => 15.00,
+ ),
+ ),
+ )
+ );
+ $response = $this->server->dispatch( $request );
+
+ $this->assertEquals( 201, $response->get_status() );
+ $data = $response->get_data();
+ $this->assertEquals( '25.00', $data['amount'], 'Total = 10 (auto) + 15 (explicit) = 25' );
+
+ $this->created_refunds[] = $data['id'];
+ $product_a->delete( true );
+ $product_b->delete( true );
+ }
+
+ /**
+ * @testdox Refund creation supports all three request shapes mixed into a single create call.
+ *
+ * The controller normalises every line item through fill_missing_refund_totals
+ * and then convert_line_items_to_internal_format in one pass. An ordering bug
+ * (e.g. a stateful helper, or the converter depending on uniform shape) would
+ * only surface when all three forms coexist:
+ * - auto-compute (quantity, no refund_total)
+ * - explicit-with-quantity (quantity + refund_total)
+ * - legacy explicit-no-quantity (refund_total only)
+ */
+ public function test_refunds_create_three_way_mixed_shapes(): void {
+ $product_a = WC_Helper_Product::create_simple_product();
+ $product_a->set_price( 10.00 );
+ $product_a->save();
+ $product_b = WC_Helper_Product::create_simple_product();
+ $product_b->set_price( 20.00 );
+ $product_b->save();
+ $product_c = WC_Helper_Product::create_simple_product();
+ $product_c->set_price( 30.00 );
+ $product_c->save();
+
+ $order = wc_create_order();
+ $item_a = new WC_Order_Item_Product();
+ $item_a->set_props(
+ array(
+ 'product' => $product_a,
+ 'quantity' => 1,
+ 'subtotal' => 10.00,
+ 'total' => 10.00,
+ )
+ );
+ $item_a->save();
+ $order->add_item( $item_a );
+ $item_b = new WC_Order_Item_Product();
+ $item_b->set_props(
+ array(
+ 'product' => $product_b,
+ 'quantity' => 1,
+ 'subtotal' => 20.00,
+ 'total' => 20.00,
+ )
+ );
+ $item_b->save();
+ $order->add_item( $item_b );
+ $item_c = new WC_Order_Item_Product();
+ $item_c->set_props(
+ array(
+ 'product' => $product_c,
+ 'quantity' => 1,
+ 'subtotal' => 30.00,
+ 'total' => 30.00,
+ )
+ );
+ $item_c->save();
+ $order->add_item( $item_c );
+ $order->set_total( 60.00 );
+ $order->set_status( OrderStatus::COMPLETED );
+ $order->save();
+ $this->created_orders[] = $order->get_id();
+
+ $request = new WP_REST_Request( 'POST', '/wc/v4/refunds' );
+ $request->set_body_params(
+ array(
+ 'order_id' => $order->get_id(),
+ 'line_items' => array(
+ // Shape 1: auto-compute (quantity only).
+ array(
+ 'line_item_id' => $item_a->get_id(),
+ 'quantity' => 1,
+ ),
+ // Shape 2: explicit-with-quantity.
+ array(
+ 'line_item_id' => $item_b->get_id(),
+ 'quantity' => 1,
+ 'refund_total' => 15.00,
+ ),
+ // Shape 3: legacy explicit-no-quantity (qty=0 on the refund record).
+ array(
+ 'line_item_id' => $item_c->get_id(),
+ 'refund_total' => 25.00,
+ ),
+ ),
+ )
+ );
+ $response = $this->server->dispatch( $request );
+
+ $this->assertEquals( 201, $response->get_status() );
+ $data = $response->get_data();
+ $this->assertEquals( '50.00', $data['amount'], 'Total = 10 (auto) + 15 (explicit) + 25 (legacy) = 50' );
+ $this->created_refunds[] = $data['id'];
+
+ // Verify all three lines are attached and carry the expected qty.
+ // WC stores refund quantities as negative (refunded amount), so the
+ // request quantity N becomes -N on the refund line item.
+ $refund = wc_get_order( $data['id'] );
+ $refund_items = $refund->get_items( 'line_item' );
+ $this->assertCount( 3, $refund_items, 'All three line items must be attached to the refund record.' );
+
+ $qty_by_original_id = array();
+ foreach ( $refund_items as $refund_item ) {
+ $qty_by_original_id[ absint( $refund_item->get_meta( '_refunded_item_id' ) ) ] = $refund_item->get_quantity();
+ }
+ $this->assertSame( -1, $qty_by_original_id[ $item_a->get_id() ], 'Auto-compute path records qty=-1 (refund of 1 unit).' );
+ $this->assertSame( -1, $qty_by_original_id[ $item_b->get_id() ], 'Explicit-with-quantity path records qty=-1 (refund of 1 unit).' );
+ $this->assertSame( 0, $qty_by_original_id[ $item_c->get_id() ], 'Legacy no-quantity path records qty=0 (no units consumed).' );
+
+ $product_a->delete( true );
+ $product_b->delete( true );
+ $product_c->delete( true );
+ }
+
+ /**
+ * @testdox Simplified form preserves existing quantity validation: over-quantity is still rejected.
+ */
+ public function test_refunds_create_simplified_form_rejects_over_quantity(): void {
+ $product = WC_Helper_Product::create_simple_product();
+ $product->set_price( 10.00 );
+ $product->save();
+
+ $order = $this->create_test_order(
+ array(
+ 'line_items' => array(
+ array(
+ 'product_id' => $product->get_id(),
+ 'quantity' => 1,
+ ),
+ ),
+ )
+ );
+ $items = $order->get_items();
+ $line_item = reset( $items );
+
+ // Request refund_total omitted AND quantity > original.
+ $request = new WP_REST_Request( 'POST', '/wc/v4/refunds' );
+ $request->set_body_params(
+ array(
+ 'order_id' => $order->get_id(),
+ 'line_items' => array(
+ array(
+ 'line_item_id' => $line_item->get_id(),
+ 'quantity' => 99,
+ ),
+ ),
+ )
+ );
+ $response = $this->server->dispatch( $request );
+
+ $this->assertEquals( 400, $response->get_status(), 'Over-quantity must still be rejected even when refund_total is auto-computed.' );
+
+ $product->delete( true );
+ }
+
+ /**
+ * @testdox Simplified form auto-computes refund_total for a positive-total fee line.
+ */
+ public function test_refunds_create_simplified_form_fee_line(): void {
+ $product = WC_Helper_Product::create_simple_product();
+ $product->set_price( 10.00 );
+ $product->save();
+
+ $order = wc_create_order();
+ $item = new WC_Order_Item_Product();
+ $item->set_props(
+ array(
+ 'product' => $product,
+ 'quantity' => 1,
+ 'subtotal' => 10.00,
+ 'total' => 10.00,
+ )
+ );
+ $item->save();
+ $order->add_item( $item );
+
+ $fee = new \WC_Order_Item_Fee();
+ $fee->set_props(
+ array(
+ 'name' => 'Service fee',
+ 'total' => 7.50,
+ )
+ );
+ $fee->save();
+ $order->add_item( $fee );
+
+ $order->set_total( 17.50 );
+ $order->set_status( OrderStatus::COMPLETED );
+ $order->save();
+ $this->created_orders[] = $order->get_id();
+
+ // Refund the fee line via the simplified form.
+ $request = new WP_REST_Request( 'POST', '/wc/v4/refunds' );
+ $request->set_body_params(
+ array(
+ 'order_id' => $order->get_id(),
+ 'line_items' => array(
+ array(
+ 'line_item_id' => $fee->get_id(),
+ 'quantity' => 1,
+ ),
+ ),
+ )
+ );
+ $response = $this->server->dispatch( $request );
+
+ $this->assertEquals( 201, $response->get_status() );
+ $data = $response->get_data();
+ $this->assertEquals( '7.50', $data['amount'], 'Auto-computed fee refund should equal the full fee total' );
+
+ $this->created_refunds[] = $data['id'];
+ $product->delete( true );
+ }
+
+ /**
+ * @testdox Simplified form does not silently break on a negative-total fee (discount-as-fee).
+ *
+ * The compute helper preserves the sign of negative fees, but the existing
+ * validate_line_items has an item_total_with_tax < refund_total check that
+ * normally guards over-refunds. For a negative fee (e.g. total: -10), the
+ * auto-computed refund_total is also -10. The validator's comparison
+ * (-10 < -10) is false, so the request passes. The downstream
+ * wc_create_refund() call is what ultimately accepts or rejects the
+ * negative refund — assert the request reaches that point without an
+ * earlier silent failure.
+ */
+ public function test_refunds_create_simplified_form_negative_fee(): void {
+ $product = WC_Helper_Product::create_simple_product();
+ $product->set_price( 10.00 );
+ $product->save();
+
+ $order = wc_create_order();
+ $item = new WC_Order_Item_Product();
+ $item->set_props(
+ array(
+ 'product' => $product,
+ 'quantity' => 1,
+ 'subtotal' => 10.00,
+ 'total' => 10.00,
+ )
+ );
+ $item->save();
+ $order->add_item( $item );
+
+ $fee = new \WC_Order_Item_Fee();
+ $fee->set_props(
+ array(
+ 'name' => 'Discount',
+ 'total' => -3.00,
+ )
+ );
+ $fee->save();
+ $order->add_item( $fee );
+
+ $order->set_total( 7.00 );
+ $order->set_status( OrderStatus::COMPLETED );
+ $order->save();
+ $this->created_orders[] = $order->get_id();
+
+ $request = new WP_REST_Request( 'POST', '/wc/v4/refunds' );
+ $request->set_body_params(
+ array(
+ 'order_id' => $order->get_id(),
+ 'line_items' => array(
+ array(
+ 'line_item_id' => $fee->get_id(),
+ 'quantity' => 1,
+ ),
+ ),
+ )
+ );
+ $response = $this->server->dispatch( $request );
+
+ // Current platform behaviour: the controller's `0 > $refund_amount`
+ // guard fires for any negative auto-computed total and surfaces
+ // `invalid_refund_amount`. Pin the exact response so a future change
+ // (e.g. platform support for negative-fee refunds, or a different
+ // rejection code) is loud rather than silent. If the platform later
+ // allows negative refunds, this test will fail and force the
+ // conversation about whether to update it to assert 201 + `-3.00`.
+ $this->assertEquals( 400, $response->get_status() );
+ $this->assertEquals( 'invalid_refund_amount', $response->get_data()['code'] );
+
+ $product->delete( true );
+ }
+
+ /**
+ * @testdox Simplified form on a tax-inclusive store ($prices_include_tax = yes) produces the correct tax-inclusive amount.
+ */
+ public function test_refunds_create_simplified_form_tax_inclusive_store(): void {
+ $tax_rate_id = WC_Tax::_insert_tax_rate(
+ array(
+ 'tax_rate_country' => 'US',
+ 'tax_rate_state' => '',
+ 'tax_rate' => '10.0000',
+ 'tax_rate_name' => 'VAT',
+ 'tax_rate_priority' => '1',
+ 'tax_rate_compound' => '0',
+ 'tax_rate_shipping' => '1',
+ 'tax_rate_order' => '1',
+ 'tax_rate_class' => '',
+ )
+ );
+
+ $original_prices_include_tax = get_option( 'woocommerce_prices_include_tax', 'no' );
+ update_option( 'woocommerce_calc_taxes', 'yes' );
+ update_option( 'woocommerce_prices_include_tax', 'yes' );
+
+ try {
+ // Product price $110 entered tax-inclusive; tax-exclusive total is $100, tax is $10.
+ $product = WC_Helper_Product::create_simple_product();
+ $product->set_regular_price( 110.00 );
+ $product->set_tax_status( 'taxable' );
+ $product->save();
+
+ $order = wc_create_order();
+ $item = new WC_Order_Item_Product();
+ $item->set_props(
+ array(
+ 'product' => $product,
+ 'quantity' => 1,
+ 'subtotal' => 100.00,
+ 'total' => 100.00,
+ )
+ );
+ $item->set_taxes(
+ array(
+ 'total' => array( $tax_rate_id => 10.00 ),
+ 'subtotal' => array( $tax_rate_id => 10.00 ),
+ )
+ );
+ $item->save();
+ $order->add_item( $item );
+
+ $tax_item = new \WC_Order_Item_Tax();
+ $tax_item->set_rate( $tax_rate_id );
+ $tax_item->set_tax_total( 10.00 );
+ $tax_item->save();
+ $order->add_item( $tax_item );
+
+ $order->set_billing_country( 'US' );
+ $order->set_total( 110.00 );
+ $order->set_status( OrderStatus::COMPLETED );
+ $order->save();
+ $this->created_orders[] = $order->get_id();
+
+ // Refund via the simplified form (no refund_total).
+ $request = new WP_REST_Request( 'POST', '/wc/v4/refunds' );
+ $request->set_body_params(
+ array(
+ 'order_id' => $order->get_id(),
+ 'line_items' => array(
+ array(
+ 'line_item_id' => $item->get_id(),
+ 'quantity' => 1,
+ ),
+ ),
+ )
+ );
+ $response = $this->server->dispatch( $request );
+
+ $this->assertEquals( 201, $response->get_status() );
+ $data = $response->get_data();
+ // Tax-inclusive store: refund amount must still be $110 ($100 + $10 tax),
+ // confirming the auto-compute round-trip works under prices_include_tax=yes.
+ $this->assertEquals( '110.00', $data['amount'], 'Tax-inclusive store: auto-computed amount must equal the tax-inclusive line total.' );
+
+ $this->assertNotEmpty( $data['line_items'] );
+ $line_item_response = $data['line_items'][0];
+ $this->assertEquals( '100.00', $line_item_response['refund_total'], 'Per-line refund_total should be tax-exclusive after extraction.' );
+ $this->assertNotEmpty( $line_item_response['refund_tax'] );
+ $this->assertEquals( '10.00', $line_item_response['refund_tax'][0]['refund_total'] );
+
+ $this->created_refunds[] = $data['id'];
+ $product->delete( true );
+ } finally {
+ // Restore the option so a failing assertion above can't leak state into other tests.
+ update_option( 'woocommerce_prices_include_tax', $original_prices_include_tax );
+ }
+ }
+
+ /**
+ * @testdox Legacy v3-style path: explicit refund_total with no quantity still works (201).
+ *
+ * The PR added a strict quantity check in validate_line_items because the
+ * new auto-compute path needs a real quantity, but that check must NOT
+ * affect requests that supply refund_total directly — those are the
+ * pre-existing v4 contract and POS clients integrating against v3 will
+ * eventually depend on it too.
+ */
+ public function test_refunds_create_legacy_form_no_quantity_with_explicit_refund_total(): 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' => 2,
+ 'subtotal' => 100.00,
+ 'total' => 100.00,
+ )
+ );
+ $item->save();
+ $order->add_item( $item );
+ $order->set_total( 100.00 );
+ $order->set_status( OrderStatus::COMPLETED );
+ $order->save();
+ $this->created_orders[] = $order->get_id();
+
+ // Step 1: legacy explicit form — refund_total provided, no quantity.
+ $request = new WP_REST_Request( 'POST', '/wc/v4/refunds' );
+ $request->set_body_params(
+ array(
+ 'order_id' => $order->get_id(),
+ 'line_items' => array(
+ array(
+ 'line_item_id' => $item->get_id(),
+ 'refund_total' => 30.00,
+ ),
+ ),
+ )
+ );
+ $response = $this->server->dispatch( $request );
+
+ $this->assertEquals( 201, $response->get_status() );
+ $data = $response->get_data();
+ $this->assertEquals( '30.00', $data['amount'] );
+ $this->created_refunds[] = $data['id'];
+
+ // The line item must be attached to the refund record (B regression guard).
+ // qty=0 matches v3 semantics — refund_total recorded without consuming specific units.
+ $refund = wc_get_order( $data['id'] );
+ $refund_items = $refund->get_items( 'line_item' );
+ $this->assertCount( 1, $refund_items, 'Refund record must have the line item attached, not an empty array.' );
+ $refund_item = reset( $refund_items );
+ $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: dollar accounting still gates subsequent refunds.
+ // Remaining refundable = 100 - 30 = 70. A simplified-form request for the
+ // full remaining 2 units would compute 100 (2 * $50), which exceeds 70,
+ // so wc_create_refund must reject it.
+ $request2 = new WP_REST_Request( 'POST', '/wc/v4/refunds' );
+ $request2->set_body_params(
+ array(
+ 'order_id' => $order->get_id(),
+ 'line_items' => array(
+ array(
+ 'line_item_id' => $item->get_id(),
+ 'quantity' => 2,
+ ),
+ ),
+ )
+ );
+ $response2 = $this->server->dispatch( $request2 );
+
+ $this->assertEquals( 400, $response2->get_status(), 'Follow-up refund exceeding remaining dollars must be rejected.' );
+ $this->assertEquals( 'cannot_create_refund', $response2->get_data()['code'] );
+
+ // Step 3: a follow-up that fits within remaining ($40 of $70) must succeed.
+ // Guards against a regression where the first refund silently consumed
+ // the full $100 budget — that would surface here as a 400, not 201.
+ $request3 = new WP_REST_Request( 'POST', '/wc/v4/refunds' );
+ $request3->set_body_params(
+ array(
+ 'order_id' => $order->get_id(),
+ 'line_items' => array(
+ array(
+ 'line_item_id' => $item->get_id(),
+ 'refund_total' => 40.00,
+ ),
+ ),
+ )
+ );
+ $response3 = $this->server->dispatch( $request3 );
+
+ $this->assertEquals( 201, $response3->get_status(), 'Follow-up refund within remaining dollars must succeed.' );
+ $data3 = $response3->get_data();
+ $this->assertEquals( '40.00', $data3['amount'] );
+ $this->created_refunds[] = $data3['id'];
+
+ // And after $30 + $40 = $70 refunded, total refunded equals 70, remaining = 30.
+ $order_after = wc_get_order( $order->get_id() );
+ $this->assertEquals( 70.00, (float) $order_after->get_total_refunded() );
+ $this->assertEquals( 30.00, (float) $order_after->get_remaining_refund_amount() );
+
+ $product->delete( true );
+ }
+
+ /**
+ * @testdox Legacy form with api_restock=true does not restock anything (qty=0 semantics).
+ *
+ * When no quantity is provided, qty defaults to 0 on the refund line item.
+ * api_restock therefore has no units to add back to inventory. Pin that
+ * behavior so future contract changes don't silently start restocking
+ * a guessed unit count.
+ */
+ public function test_refunds_create_legacy_form_api_restock_does_not_restock(): void {
+ $product = WC_Helper_Product::create_simple_product();
+ $product->set_regular_price( 50.00 );
+ $product->set_manage_stock( true );
+ $product->set_stock_quantity( 5 );
+ $product->save();
+
+ $order = wc_create_order();
+ $item = new WC_Order_Item_Product();
+ $item->set_props(
+ array(
+ 'product' => $product,
+ 'quantity' => 2,
+ 'subtotal' => 100.00,
+ 'total' => 100.00,
+ )
+ );
+ $item->save();
+ $order->add_item( $item );
+ $order->set_total( 100.00 );
+ $order->set_status( OrderStatus::COMPLETED );
+ $order->save();
+ $this->created_orders[] = $order->get_id();
+
+ // Capture stock after order completion (the order may or may not have
+ // reduced stock depending on settings) — what matters is the refund
+ // step does not change it.
+ $stock_before_refund = wc_get_product( $product->get_id() )->get_stock_quantity();
+
+ $request = new WP_REST_Request( 'POST', '/wc/v4/refunds' );
+ $request->set_body_params(
+ array(
+ 'order_id' => $order->get_id(),
+ 'api_restock' => true,
+ 'line_items' => array(
+ array(
+ 'line_item_id' => $item->get_id(),
+ 'refund_total' => 30.00,
+ ),
+ ),
+ )
+ );
+ $response = $this->server->dispatch( $request );
+
+ $this->assertEquals( 201, $response->get_status() );
+ $this->created_refunds[] = $response->get_data()['id'];
+
+ $stock_after_refund = wc_get_product( $product->get_id() )->get_stock_quantity();
+ $this->assertSame(
+ $stock_before_refund,
+ $stock_after_refund,
+ 'Legacy form (no quantity) + api_restock must not restock — qty=0 means no units to put back.'
+ );
+
+ $product->delete( true );
+ }
+
+ /**
+ * @testdox Legacy form (refund_total without quantity) on a tax-inclusive store extracts the right tax.
+ *
+ * The legacy v3-style path hits a different converter branch than the
+ * simplified form (qty defaults to 0; refund_total is supplied directly).
+ * On a tax-inclusive store this combination is the one POS clients will
+ * actually exercise after the v3 port, so a converter regression in the
+ * tax-extraction block would only surface here.
+ */
+ public function test_refunds_create_legacy_form_tax_inclusive_store(): void {
+ $tax_rate_id = WC_Tax::_insert_tax_rate(
+ array(
+ 'tax_rate_country' => 'US',
+ 'tax_rate_state' => '',
+ 'tax_rate' => '10.0000',
+ 'tax_rate_name' => 'VAT',
+ 'tax_rate_priority' => '1',
+ 'tax_rate_compound' => '0',
+ 'tax_rate_shipping' => '1',
+ 'tax_rate_order' => '1',
+ 'tax_rate_class' => '',
+ )
+ );
+
+ $original_calc_taxes = get_option( 'woocommerce_calc_taxes', 'no' );
+ $original_prices_include_tax = get_option( 'woocommerce_prices_include_tax', 'no' );
+ update_option( 'woocommerce_calc_taxes', 'yes' );
+ update_option( 'woocommerce_prices_include_tax', 'yes' );
+
+ try {
+ $product = WC_Helper_Product::create_simple_product();
+ $product->set_regular_price( 110.00 );
+ $product->set_tax_status( 'taxable' );
+ $product->save();
+
+ $order = wc_create_order();
+ $item = new WC_Order_Item_Product();
+ $item->set_props(
+ array(
+ 'product' => $product,
+ 'quantity' => 1,
+ 'subtotal' => 100.00,
+ 'total' => 100.00,
+ )
+ );
+ $item->set_taxes(
+ array(
+ 'total' => array( $tax_rate_id => 10.00 ),
+ 'subtotal' => array( $tax_rate_id => 10.00 ),
+ )
+ );
+ $item->save();
+ $order->add_item( $item );
+
+ $tax_item = new \WC_Order_Item_Tax();
+ $tax_item->set_rate( $tax_rate_id );
+ $tax_item->set_tax_total( 10.00 );
+ $tax_item->save();
+ $order->add_item( $tax_item );
+
+ $order->set_billing_country( 'US' );
+ $order->set_total( 110.00 );
+ $order->set_status( OrderStatus::COMPLETED );
+ $order->save();
+ $this->created_orders[] = $order->get_id();
+
+ // Legacy form: client supplies the tax-inclusive refund_total ($110)
+ // and omits quantity. Converter must extract the $10 tax portion the
+ // same way it does for the simplified/explicit-with-quantity paths.
+ $request = new WP_REST_Request( 'POST', '/wc/v4/refunds' );
+ $request->set_body_params(
+ array(
+ 'order_id' => $order->get_id(),
+ 'line_items' => array(
+ array(
+ 'line_item_id' => $item->get_id(),
+ 'refund_total' => 110.00,
+ ),
+ ),
+ )
+ );
+ $response = $this->server->dispatch( $request );
+
+ $this->assertEquals( 201, $response->get_status() );
+ $data = $response->get_data();
+ $this->created_refunds[] = $data['id'];
+
+ $this->assertEquals( '110.00', $data['amount'] );
+ $this->assertNotEmpty( $data['line_items'] );
+ $this->assertEquals( '100.00', $data['line_items'][0]['refund_total'], 'Per-line refund_total should be tax-exclusive after extraction.' );
+ $this->assertNotEmpty( $data['line_items'][0]['refund_tax'], 'refund_tax must be extracted on the tax-inclusive legacy path.' );
+ $this->assertEquals( '10.00', $data['line_items'][0]['refund_tax'][0]['refund_total'] );
+
+ $product->delete( true );
+ } finally {
+ update_option( 'woocommerce_calc_taxes', $original_calc_taxes );
+ update_option( 'woocommerce_prices_include_tax', $original_prices_include_tax );
+ }
+ }
+
+ /**
+ * @testdox Simplified form rejects a line_item_id that belongs to a different order with invalid_line_item.
+ */
+ public function test_refunds_create_simplified_form_rejects_cross_order_line_item_id(): void {
+ $product = WC_Helper_Product::create_simple_product();
+ $product->set_price( 10.00 );
+ $product->save();
+
+ // Order A: target of the refund request.
+ $order_a = $this->create_test_order(
+ array(
+ 'line_items' => array(
+ array(
+ 'product_id' => $product->get_id(),
+ 'quantity' => 1,
+ ),
+ ),
+ )
+ );
+
+ // Order B: holds the line_item the client will mistakenly reference.
+ $order_b = $this->create_test_order(
+ array(
+ 'line_items' => array(
+ array(
+ 'product_id' => $product->get_id(),
+ 'quantity' => 1,
+ ),
+ ),
+ )
+ );
+ $order_b_items = $order_b->get_items();
+ $order_b_item = reset( $order_b_items );
+
+ $request = new WP_REST_Request( 'POST', '/wc/v4/refunds' );
+ $request->set_body_params(
+ array(
+ 'order_id' => $order_a->get_id(),
+ 'line_items' => array(
+ array(
+ 'line_item_id' => $order_b_item->get_id(),
+ 'quantity' => 1,
+ ),
+ ),
+ )
+ );
+ $response = $this->server->dispatch( $request );
+
+ $this->assertEquals( 400, $response->get_status(), 'Cross-order line_item_id must be rejected, not silently auto-computed.' );
+ $data = $response->get_data();
+ $this->assertEquals( 'invalid_line_item', $data['code'] );
+
+ $product->delete( true );
+ }
+
+ /**
+ * @testdox Simplified form surfaces a specific error when the source product line has zero original quantity.
+ *
+ * Without explicit handling, fill_missing_refund_totals would compute 0.0 from a divide-by-zero
+ * scenario and the request would fall through to the misleading "Refund total must be greater
+ * than zero" cascade. Lock in the clear error.
+ */
+ public function test_refunds_create_simplified_form_zero_source_quantity(): void {
+ $order = wc_create_order();
+ $item = new WC_Order_Item_Product();
+ $item->set_props(
+ array(
+ 'quantity' => 0,
+ 'subtotal' => 0,
+ 'total' => 0,
+ )
+ );
+ $item->save();
+ $order->add_item( $item );
+ $order->set_status( OrderStatus::COMPLETED );
+ // A non-zero order total is needed so the order is not considered fully refunded.
+ $order->set_total( 10.00 );
+ $order->save();
+ $this->created_orders[] = $order->get_id();
+
+ $request = new WP_REST_Request( 'POST', '/wc/v4/refunds' );
+ $request->set_body_params(
+ array(
+ 'order_id' => $order->get_id(),
+ 'line_items' => array(
+ array(
+ 'line_item_id' => $item->get_id(),
+ 'quantity' => 1,
+ ),
+ ),
+ )
+ );
+ $response = $this->server->dispatch( $request );
+
+ $this->assertEquals( 400, $response->get_status() );
+ $data = $response->get_data();
+ $this->assertEquals( 'invalid_line_item', $data['code'] );
+ $this->assertStringContainsString( 'source quantity is zero', $data['message'] );
+ }
+
+ /**
+ * @testdox Simplified form rejects a second refund of an already-fully-refunded product line.
+ *
+ * Codex regression guard: a previous implementation compared the request's
+ * quantity to `$item->get_quantity()` (the ORIGINAL count) rather than the
+ * remaining-after-prior-refunds count. On a multi-line order, refunding
+ * item A once would leave it look unrefunded to the validator on the next
+ * request — and if item B left enough order-level dollar room, the second
+ * `{line_item_id: A, quantity: 1}` request would be accepted and refund
+ * item A twice. The fix uses compute_refunded_quantities_and_totals to
+ * cap against remaining qty.
+ */
+ public function test_refunds_create_simplified_form_rejects_already_refunded_product(): void {
+ $product_a = WC_Helper_Product::create_simple_product();
+ $product_a->set_price( 50.00 );
+ $product_a->save();
+ $product_b = WC_Helper_Product::create_simple_product();
+ $product_b->set_price( 50.00 );
+ $product_b->save();
+
+ $order = wc_create_order();
+ $item_a = new WC_Order_Item_Product();
+ $item_a->set_props(
+ array(
+ 'product' => $product_a,
+ 'quantity' => 1,
+ 'subtotal' => 50.00,
+ 'total' => 50.00,
+ )
+ );
+ $item_a->save();
+ $order->add_item( $item_a );
+
+ $item_b = new WC_Order_Item_Product();
+ $item_b->set_props(
+ array(
+ 'product' => $product_b,
+ 'quantity' => 1,
+ 'subtotal' => 50.00,
+ 'total' => 50.00,
+ )
+ );
+ $item_b->save();
+ $order->add_item( $item_b );
+
+ $order->set_total( 100.00 );
+ $order->set_status( OrderStatus::COMPLETED );
+ $order->save();
+ $this->created_orders[] = $order->get_id();
+
+ // First simplified refund of item A — must succeed.
+ $request1 = new WP_REST_Request( 'POST', '/wc/v4/refunds' );
+ $request1->set_body_params(
+ array(
+ 'order_id' => $order->get_id(),
+ 'line_items' => array(
+ array(
+ 'line_item_id' => $item_a->get_id(),
+ 'quantity' => 1,
+ ),
+ ),
+ )
+ );
+ $response1 = $this->server->dispatch( $request1 );
+ $this->assertEquals( 201, $response1->get_status() );
+ $this->created_refunds[] = $response1->get_data()['id'];
+
+ // Second simplified refund of item A — must be rejected by the
+ // remaining-qty check (item A is fully refunded). Without the fix,
+ // item B's dollar room would let this through.
+ $request2 = new WP_REST_Request( 'POST', '/wc/v4/refunds' );
+ $request2->set_body_params(
+ array(
+ 'order_id' => $order->get_id(),
+ 'line_items' => array(
+ array(
+ 'line_item_id' => $item_a->get_id(),
+ 'quantity' => 1,
+ ),
+ ),
+ )
+ );
+ $response2 = $this->server->dispatch( $request2 );
+
+ $this->assertEquals( 400, $response2->get_status() );
+ $data2 = $response2->get_data();
+ $this->assertEquals( 'invalid_line_item', $data2['code'] );
+ $this->assertStringContainsString( 'remaining refundable quantity', $data2['message'] );
+
+ $product_a->delete( true );
+ $product_b->delete( true );
+ }
+
+ /**
+ * @testdox Simplified form rejects a second refund of already-fully-refunded fee and shipping lines.
+ */
+ public function test_refunds_create_simplified_form_rejects_already_refunded_fee_and_shipping(): void {
+ $product = WC_Helper_Product::create_simple_product();
+ $product->set_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' => 'Service fee',
+ 'total' => 7.50,
+ )
+ );
+ $fee->save();
+ $order->add_item( $fee );
+
+ $shipping = new \WC_Order_Item_Shipping();
+ $shipping->set_props(
+ array(
+ 'method_title' => 'Flat rate',
+ 'method_id' => 'flat_rate',
+ 'total' => 5.00,
+ )
+ );
+ $shipping->save();
+ $order->add_item( $shipping );
+
+ $order->set_total( 62.50 );
+ $order->set_status( OrderStatus::COMPLETED );
+ $order->save();
+ $this->created_orders[] = $order->get_id();
+
+ foreach ( array( $fee, $shipping ) as $non_product_item ) {
+ $first_response = $this->dispatch_refund_request(
+ $order->get_id(),
+ array(
+ array(
+ 'line_item_id' => $non_product_item->get_id(),
+ 'quantity' => 1,
+ ),
+ )
+ );
+ $this->assertEquals( 201, $first_response->get_status() );
+ $this->created_refunds[] = $first_response->get_data()['id'];
+
+ $second_response = $this->dispatch_refund_request(
+ $order->get_id(),
+ array(
+ array(
+ 'line_item_id' => $non_product_item->get_id(),
+ 'quantity' => 1,
+ ),
+ )
+ );
+ if ( 201 === $second_response->get_status() ) {
+ $this->created_refunds[] = $second_response->get_data()['id'];
+ }
+
+ $this->assertEquals( 400, $second_response->get_status(), 'A fee or shipping line must not be refunded twice using other order lines remaining balance.' );
+ $data = $second_response->get_data();
+ $this->assertEquals( 'invalid_line_item', $data['code'] );
+ $this->assertStringContainsString( 'already been fully refunded', $data['message'] );
+ }
+
+ $product->delete( true );
+ }
+
+ /**
+ * @testdox Simplified form rejects auto-computed refund_total combined with explicit refund_tax.
+ *
+ * Codex regression guard: with refund_total omitted and refund_tax
+ * supplied, fill_missing_refund_totals would have written a tax-inclusive
+ * refund_total (110 for a $100 item with $10 tax) and the converter would
+ * then skip tax extraction because refund_tax was already present —
+ * calculate_refund_amount summed both and emitted amount=120 (overstated
+ * by the tax). The combination is now rejected up-front.
+ */
+ public function test_refunds_create_rejects_auto_compute_with_explicit_refund_tax(): void {
+ $tax_rate_id = WC_Tax::_insert_tax_rate(
+ array(
+ 'tax_rate_country' => 'US',
+ 'tax_rate_state' => '',
+ 'tax_rate' => '10.0000',
+ 'tax_rate_name' => 'VAT',
+ 'tax_rate_priority' => '1',
+ 'tax_rate_compound' => '0',
+ 'tax_rate_shipping' => '1',
+ 'tax_rate_order' => '1',
+ 'tax_rate_class' => '',
+ )
+ );
+
+ $original_calc_taxes = get_option( 'woocommerce_calc_taxes', 'no' );
+ $original_prices_include_tax = get_option( 'woocommerce_prices_include_tax', 'no' );
+ update_option( 'woocommerce_calc_taxes', 'yes' );
+ update_option( 'woocommerce_prices_include_tax', 'no' );
+
+ try {
+ $product = WC_Helper_Product::create_simple_product();
+ $product->set_regular_price( 100.00 );
+ $product->set_tax_status( 'taxable' );
+ $product->save();
+
+ $order = wc_create_order();
+ $item = new WC_Order_Item_Product();
+ $item->set_props(
+ array(
+ 'product' => $product,
+ 'quantity' => 1,
+ 'subtotal' => 100.00,
+ 'total' => 100.00,
+ )
+ );
+ $item->set_taxes(
+ array(
+ 'total' => array( $tax_rate_id => 10.00 ),
+ 'subtotal' => array( $tax_rate_id => 10.00 ),
+ )
+ );
+ $item->save();
+ $order->add_item( $item );
+
+ $tax_item = new \WC_Order_Item_Tax();
+ $tax_item->set_rate( $tax_rate_id );
+ $tax_item->set_tax_total( 10.00 );
+ $tax_item->save();
+ $order->add_item( $tax_item );
+
+ $order->set_billing_country( 'US' );
+ $order->set_total( 110.00 );
+ $order->set_status( OrderStatus::COMPLETED );
+ $order->save();
+ $this->created_orders[] = $order->get_id();
+
+ // Auto-compute (no refund_total) + explicit refund_tax.
+ $request = new WP_REST_Request( 'POST', '/wc/v4/refunds' );
+ $request->set_body_params(
+ array(
+ 'order_id' => $order->get_id(),
+ 'line_items' => array(
+ array(
+ 'line_item_id' => $item->get_id(),
+ 'quantity' => 1,
+ 'refund_tax' => array(
+ array(
+ 'id' => $tax_rate_id,
+ 'refund_total' => 10.00,
+ ),
+ ),
+ ),
+ ),
+ )
+ );
+ $response = $this->server->dispatch( $request );
+
+ $this->assertEquals( 400, $response->get_status() );
+ $data = $response->get_data();
+ $this->assertEquals( 'invalid_line_item', $data['code'] );
+ $this->assertStringContainsString( 'refund_tax cannot be combined', $data['message'] );
+
+ $product->delete( true );
+ } finally {
+ update_option( 'woocommerce_calc_taxes', $original_calc_taxes );
+ update_option( 'woocommerce_prices_include_tax', $original_prices_include_tax );
+ }
+ }
+
+ /**
+ * @testdox Scoped catch around fill_missing_refund_totals returns a 500 with invalid_refund_request when the helper throws.
+ *
+ * The catch is defensive — fill_missing_refund_totals pre-checks the
+ * invariant that compute_line_item_refund_total cares about, so the
+ * throw is unreachable from public input. Locking in the response shape
+ * here means a future refactor that broadens the catch (e.g. to
+ * \Throwable) or accidentally re-narrows fill's pre-check is caught.
+ */
+ public function test_refunds_create_invariant_violation_returns_500(): 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->set_status( OrderStatus::COMPLETED );
+ $order->save();
+ $this->created_orders[] = $order->get_id();
+
+ // Inject a DataUtils stub that throws on fill_missing_refund_totals
+ // into the *DI-resolved* RefundsController instance — that's the one
+ // the REST server dispatches against. $this->endpoint in setUp is a
+ // separate instance and mutating it would not affect dispatch.
+ $throwing_utils = $this->getMockBuilder( DataUtils::class )
+ ->onlyMethods( array( 'fill_missing_refund_totals' ) )
+ ->getMock();
+ $throwing_utils->method( 'fill_missing_refund_totals' )
+ ->willThrowException( new \InvalidArgumentException( 'simulated invariant violation' ) );
+
+ $container = wc_get_container();
+ $dispatch_target = $container->get( RefundsController::class );
+ $dispatch_target->init( $this->refund_schema, new RefundPreviewSchema(), new CollectionQuery(), $throwing_utils );
+
+ try {
+ $request = new WP_REST_Request( 'POST', '/wc/v4/refunds' );
+ $request->set_body_params(
+ array(
+ 'order_id' => $order->get_id(),
+ 'line_items' => array(
+ array(
+ 'line_item_id' => $item->get_id(),
+ 'quantity' => 1,
+ ),
+ ),
+ )
+ );
+ $response = $this->server->dispatch( $request );
+
+ $this->assertEquals( 500, $response->get_status() );
+ $data = $response->get_data();
+ $this->assertEquals( 'invalid_refund_request', $data['code'] );
+ } finally {
+ // Restore the real data_utils on the dispatch-target controller
+ // so the rest of the suite is unaffected.
+ $dispatch_target->init( $this->refund_schema, new RefundPreviewSchema(), new CollectionQuery(), new DataUtils() );
+ $product->delete( true );
+ }
+ }
+
+ /**
+ * @testdox The 'created' hook receives a request whose line_items include the auto-computed refund_total.
+ */
+ public function test_refunds_create_hook_sees_normalised_line_items(): void {
+ $product = WC_Helper_Product::create_simple_product();
+ // See test_refunds_create_simplified_matches_explicit for why set_regular_price()
+ // is required when the order is created via the REST API.
+ $product->set_regular_price( 25.00 );
+ $product->save();
+
+ $order = $this->create_test_order(
+ array(
+ 'line_items' => array(
+ array(
+ 'product_id' => $product->get_id(),
+ 'quantity' => 1,
+ ),
+ ),
+ )
+ );
+ $items = $order->get_items();
+ $line_item = reset( $items );
+
+ $captured_line_items = null;
+ $hook = 'woocommerce_rest_api_v4_refunds_created';
+ $listener = function ( $refund, $captured_request ) use ( &$captured_line_items ) {
+ $captured_line_items = $captured_request['line_items'];
+ };
+ add_action( $hook, $listener, 10, 2 );
+
+ try {
+ $request = new WP_REST_Request( 'POST', '/wc/v4/refunds' );
+ $request->set_body_params(
+ array(
+ 'order_id' => $order->get_id(),
+ 'line_items' => array(
+ array(
+ 'line_item_id' => $line_item->get_id(),
+ 'quantity' => 1,
+ ),
+ ),
+ )
+ );
+ $response = $this->server->dispatch( $request );
+
+ $this->assertEquals( 201, $response->get_status() );
+ $this->created_refunds[] = $response->get_data()['id'];
+
+ $this->assertIsArray( $captured_line_items, 'Hook should have fired and captured the request line_items' );
+ $this->assertNotEmpty( $captured_line_items );
+ $this->assertArrayHasKey( 'refund_total', $captured_line_items[0], 'Hook listener should see the auto-computed refund_total on the request' );
+ $this->assertSame( 25.00, (float) $captured_line_items[0]['refund_total'] );
+ } finally {
+ remove_action( $hook, $listener, 10 );
+ $product->delete( true );
+ }
+ }
+
+ /**
+ * @testdox The 'created' hook sees client-supplied refund_total unchanged on the explicit form.
+ *
+ * Guards against a future bug where the request-mirroring step (set_param
+ * after fill_missing_refund_totals) accidentally overwrites client-supplied
+ * refund_total values.
+ */
+ public function test_refunds_create_hook_sees_explicit_refund_total_unchanged(): void {
+ $product = WC_Helper_Product::create_simple_product();
+ $product->set_regular_price( 25.00 );
+ $product->save();
+
+ $order = $this->create_test_order(
+ array(
+ 'line_items' => array(
+ array(
+ 'product_id' => $product->get_id(),
+ 'quantity' => 1,
+ ),
+ ),
+ )
+ );
+ $items = $order->get_items();
+ $line_item = reset( $items );
+
+ $captured_line_items = null;
+ $hook = 'woocommerce_rest_api_v4_refunds_created';
+ $listener = function ( $refund, $captured_request ) use ( &$captured_line_items ) {
+ $captured_line_items = $captured_request['line_items'];
+ };
+ add_action( $hook, $listener, 10, 2 );
+
+ try {
+ $request = new WP_REST_Request( 'POST', '/wc/v4/refunds' );
+ $request->set_body_params(
+ array(
+ 'order_id' => $order->get_id(),
+ 'line_items' => array(
+ array(
+ 'line_item_id' => $line_item->get_id(),
+ 'quantity' => 1,
+ // Deliberately different from the auto-computed value
+ // so an accidental overwrite would be detectable.
+ 'refund_total' => 7.50,
+ ),
+ ),
+ )
+ );
+ $response = $this->server->dispatch( $request );
+
+ $this->assertEquals( 201, $response->get_status() );
+ $this->created_refunds[] = $response->get_data()['id'];
+
+ $this->assertIsArray( $captured_line_items );
+ $this->assertArrayHasKey( 'refund_total', $captured_line_items[0] );
+ $this->assertSame( 7.50, (float) $captured_line_items[0]['refund_total'], 'Hook listener must see the client-supplied refund_total unchanged.' );
+ } finally {
+ remove_action( $hook, $listener, 10 );
+ $product->delete( true );
+ }
+ }
+
+ /**
+ * @testdox Refund creation with missing quantity returns a clear invalid_line_item error (not the misleading "amount > 0" cascade).
+ */
+ public function test_refunds_create_missing_quantity_returns_clear_error(): void {
+ $product = WC_Helper_Product::create_simple_product();
+ $product->set_price( 10.00 );
+ $product->save();
+
+ $order = $this->create_test_order(
+ array(
+ 'line_items' => array(
+ array(
+ 'product_id' => $product->get_id(),
+ 'quantity' => 1,
+ ),
+ ),
+ )
+ );
+ $items = $order->get_items();
+ $line_item = reset( $items );
+
+ // Send a line item with NO quantity and NO refund_total — both required for auto-compute.
+ $request = new WP_REST_Request( 'POST', '/wc/v4/refunds' );
+ $request->set_body_params(
+ array(
+ 'order_id' => $order->get_id(),
+ 'line_items' => array(
+ array(
+ 'line_item_id' => $line_item->get_id(),
+ ),
+ ),
+ )
+ );
+ $response = $this->server->dispatch( $request );
+
+ $this->assertEquals( 400, $response->get_status() );
+ $data = $response->get_data();
+ $this->assertEquals( 'invalid_line_item', $data['code'], 'Should fail validation with a specific quantity error, not cascade to invalid_refund_amount.' );
+ $this->assertStringContainsString( 'positive integer', $data['message'] );
+
+ $product->delete( true );
+ }
+
+ /**
+ * @testdox The create endpoint's auto-computed amount matches build_refund_preview's grand total for the same line items.
+ *
+ * Regression guard for create vs preview drift. Calls `build_refund_preview()`
+ * directly to capture the authoritative total, then posts the same line items
+ * (quantity only, no `refund_total`) to the create endpoint. The resulting
+ * refund amount must equal the preview total exactly. A future change that
+ * subtly diverges create's auto-compute from the preview-side calculation
+ * would fail this assertion.
+ */
+ public function test_refunds_create_auto_compute_matches_build_refund_preview(): void {
+ $tax_rate_id = WC_Tax::_insert_tax_rate(
+ array(
+ 'tax_rate_country' => 'US',
+ 'tax_rate_state' => '',
+ 'tax_rate' => '10.0000',
+ 'tax_rate_name' => 'VAT',
+ 'tax_rate_priority' => '1',
+ 'tax_rate_compound' => '0',
+ 'tax_rate_shipping' => '1',
+ 'tax_rate_order' => '1',
+ 'tax_rate_class' => '',
+ )
+ );
+
+ $original_calc_taxes = get_option( 'woocommerce_calc_taxes', 'no' );
+ $original_prices_include_tax = get_option( 'woocommerce_prices_include_tax', 'no' );
+ update_option( 'woocommerce_calc_taxes', 'yes' );
+ update_option( 'woocommerce_prices_include_tax', 'no' );
+
+ try {
+ $product = WC_Helper_Product::create_simple_product();
+ $product->set_regular_price( 100.00 );
+ $product->set_tax_status( 'taxable' );
+ $product->save();
+
+ $order = wc_create_order();
+ $item = new WC_Order_Item_Product();
+ $item->set_props(
+ array(
+ 'product' => $product,
+ 'quantity' => 2,
+ 'subtotal' => 200.00,
+ 'total' => 200.00,
+ )
+ );
+ $item->set_taxes(
+ array(
+ 'total' => array( $tax_rate_id => 20.00 ),
+ 'subtotal' => array( $tax_rate_id => 20.00 ),
+ )
+ );
+ $item->save();
+ $order->add_item( $item );
+
+ $order->set_total( 220.00 );
+ $order->set_status( OrderStatus::COMPLETED );
+ $order->save();
+ $this->created_orders[] = $order->get_id();
+
+ $line_items = array(
+ array(
+ 'line_item_id' => $item->get_id(),
+ 'quantity' => 1,
+ ),
+ );
+
+ $data_utils = wc_get_container()->get( DataUtils::class );
+ $preview = $data_utils->build_refund_preview( $order, $line_items );
+
+ $request = new \WP_REST_Request( 'POST', '/wc/v4/refunds' );
+ $request->set_body_params(
+ array(
+ 'order_id' => $order->get_id(),
+ 'line_items' => $line_items,
+ )
+ );
+ $response = $this->server->dispatch( $request );
+
+ $this->assertEquals( 201, $response->get_status() );
+ $create_data = $response->get_data();
+ $this->created_refunds[] = $create_data['id'];
+
+ $this->assertEquals(
+ $preview['total'],
+ $create_data['amount'],
+ 'Create amount must match build_refund_preview total exactly.'
+ );
+
+ $product->delete( true );
+ } finally {
+ update_option( 'woocommerce_calc_taxes', $original_calc_taxes );
+ update_option( 'woocommerce_prices_include_tax', $original_prices_include_tax );
+ }
+ }
+
+ /**
+ * Helper to create an order containing one product line item with exact totals.
+ *
+ * Builds the order directly (no REST round-trip) so line totals that do not
+ * divide evenly by quantity can be set verbatim — the rounding tests below
+ * need unit prices like 11.00/3 that cannot be produced via a product price.
+ *
+ * @param int $quantity Line item quantity.
+ * @param float $subtotal Line subtotal (tax-exclusive, pre-discount).
+ * @param float $total Line total (tax-exclusive).
+ * @param float $order_total Order grand total.
+ * @param array $taxes Optional map of tax_rate_id => tax amount for the line.
+ * @return array{0: WC_Order, 1: WC_Order_Item_Product} The order and its line item.
+ */
+ private function create_order_with_exact_line( int $quantity, float $subtotal, float $total, float $order_total, array $taxes = array() ): array {
+ $product = WC_Helper_Product::create_simple_product();
+ $product->set_regular_price( 10.00 );
+ $product->save();
+
+ $order = wc_create_order();
+ $item = new WC_Order_Item_Product();
+ $item->set_props(
+ array(
+ 'product' => $product,
+ 'quantity' => $quantity,
+ 'subtotal' => $subtotal,
+ 'total' => $total,
+ )
+ );
+ if ( ! empty( $taxes ) ) {
+ $item->set_taxes(
+ array(
+ 'total' => $taxes,
+ 'subtotal' => $taxes,
+ )
+ );
+ }
+ $item->save();
+ $order->add_item( $item );
+
+ foreach ( $taxes as $rate_id => $tax_total ) {
+ $tax_item = new \WC_Order_Item_Tax();
+ $tax_item->set_rate( $rate_id );
+ $tax_item->set_tax_total( $tax_total );
+ $tax_item->save();
+ $order->add_item( $tax_item );
+ }
+
+ $order->set_billing_country( 'US' );
+ $order->set_total( $order_total );
+ $order->set_status( OrderStatus::COMPLETED );
+ $order->save();
+ $this->created_orders[] = $order->get_id();
+
+ $product->delete( true );
+
+ return array( $order, $item );
+ }
+
+ /**
+ * Helper to POST a refund request for an order and return the response.
+ *
+ * @param int $order_id Order ID.
+ * @param array $line_items Request line items.
+ * @return WP_REST_Response
+ */
+ private function dispatch_refund_request( int $order_id, array $line_items ): WP_REST_Response {
+ $request = new WP_REST_Request( 'POST', '/wc/v4/refunds' );
+ $request->set_body_params(
+ array(
+ 'order_id' => $order_id,
+ 'line_items' => $line_items,
+ )
+ );
+ return $this->server->dispatch( $request );
+ }
+
+ /**
+ * @testdox Sequential single-unit auto-computed refunds that round above the remaining balance are rejected; an explicit refund_total recovers the remainder.
+ *
+ * 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 wc_create_refund's
+ * remaining-amount guard. 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 );
+
+ $response = $this->dispatch_refund_request(
+ $one_shot_order->get_id(),
+ array(
+ array(
+ 'line_item_id' => $one_shot_item->get_id(),
+ 'quantity' => 3,
+ ),
+ )
+ );
+ $this->assertEquals( 201, $response->get_status() );
+ $this->assertEqualsWithDelta( 11.00, (float) $response->get_data()['amount'], 0.001, 'One-shot qty-3 refund should equal the full line total' );
+ $this->created_refunds[] = $response->get_data()['id'];
+
+ list( $order, $item ) = $this->create_order_with_exact_line( 3, 11.00, 11.00, 11.00 );
+
+ $unit_refund = array(
+ array(
+ 'line_item_id' => $item->get_id(),
+ 'quantity' => 1,
+ ),
+ );
+
+ foreach ( array( 1, 2 ) as $refund_number ) {
+ $response = $this->dispatch_refund_request( $order->get_id(), $unit_refund );
+ $this->assertEquals( 201, $response->get_status(), "Single-unit refund {$refund_number} should succeed" );
+ $this->assertEqualsWithDelta( 3.67, (float) $response->get_data()['amount'], 0.001, 'Each single-unit refund rounds 11.00/3 up to 3.67' );
+ $this->created_refunds[] = $response->get_data()['id'];
+ }
+
+ $order = wc_get_order( $order->get_id() );
+ $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( 400, $response->get_status(), 'Third auto-computed 3.67 exceeds the 3.66 remaining and must be rejected' );
+ $this->assertEquals( 'cannot_create_refund', $response->get_data()['code'] );
+
+ $response = $this->dispatch_refund_request(
+ $order->get_id(),
+ array(
+ 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->created_refunds[] = $response->get_data()['id'];
+ }
+
+ /**
+ * @testdox Auto-compute follows the store's zero-decimal price setting and repeated unit refunds strand one currency unit.
+ */
+ public function test_refunds_create_auto_compute_zero_decimal_currency(): void {
+ $original_decimals = get_option( 'woocommerce_price_num_decimals', '2' );
+ update_option( 'woocommerce_price_num_decimals', '0' );
+
+ try {
+ list( $order, $item ) = $this->create_order_with_exact_line( 3, 1000.00, 1000.00, 1000.00 );
+
+ $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( 667.0, (float) $response->get_data()['amount'], 0.001, 'Qty-2 refund of a 1000/3 line rounds to 667 at zero decimals' );
+ $this->created_refunds[] = $response->get_data()['id'];
+
+ $response = $this->dispatch_refund_request(
+ $order->get_id(),
+ array(
+ array(
+ 'line_item_id' => $item->get_id(),
+ 'quantity' => 1,
+ ),
+ )
+ );
+ $this->assertEquals( 201, $response->get_status() );
+ $this->assertEqualsWithDelta( 333.0, (float) $response->get_data()['amount'], 0.001, '667 + 333 consumes the 1000 line exactly' );
+ $this->created_refunds[] = $response->get_data()['id'];
+
+ list( $order_b, $item_b ) = $this->create_order_with_exact_line( 3, 1000.00, 1000.00, 1000.00 );
+
+ $unit_refund = array(
+ array(
+ 'line_item_id' => $item_b->get_id(),
+ 'quantity' => 1,
+ ),
+ );
+ for ( $i = 0; $i < 3; $i++ ) {
+ $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()['amount'], 0.001, 'Each single-unit refund rounds 1000/3 down to 333' );
+ $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(),
+ 'amount' => 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'];
+ } finally {
+ update_option( 'woocommerce_price_num_decimals', $original_decimals );
+ }
+ }
+
+ /**
+ * @testdox Multi-quantity auto-compute with a fractional tax rate reassembles net + tax and consumes the line exactly.
+ */
+ public function test_refunds_create_auto_compute_multi_qty_fractional_tax_rate(): void {
+ $tax_rate_id = WC_Tax::_insert_tax_rate(
+ array(
+ 'tax_rate_country' => 'US',
+ 'tax_rate_state' => '',
+ 'tax_rate' => '8.8750',
+ 'tax_rate_name' => 'NYC',
+ 'tax_rate_priority' => '1',
+ 'tax_rate_compound' => '0',
+ 'tax_rate_shipping' => '1',
+ 'tax_rate_order' => '1',
+ 'tax_rate_class' => '',
+ )
+ );
+
+ $original_calc_taxes = get_option( 'woocommerce_calc_taxes', 'no' );
+ $original_prices_include_tax = get_option( 'woocommerce_prices_include_tax', 'no' );
+ update_option( 'woocommerce_calc_taxes', 'yes' );
+ update_option( 'woocommerce_prices_include_tax', 'no' );
+
+ try {
+ // 3 × 9.99 = 29.97, tax at 8.875% = 2.66, grand total 32.63.
+ list( $one_shot_order, $one_shot_item ) = $this->create_order_with_exact_line( 3, 29.97, 29.97, 32.63, array( $tax_rate_id => 2.66 ) );
+
+ $response = $this->dispatch_refund_request(
+ $one_shot_order->get_id(),
+ array(
+ array(
+ 'line_item_id' => $one_shot_item->get_id(),
+ 'quantity' => 3,
+ ),
+ )
+ );
+ $this->assertEquals( 201, $response->get_status() );
+ $this->assertEqualsWithDelta( 32.63, (float) $response->get_data()['amount'], 0.001, 'Full-quantity refund must equal line total + line tax exactly' );
+ $this->created_refunds[] = $response->get_data()['id'];
+
+ list( $order, $item ) = $this->create_order_with_exact_line( 3, 29.97, 29.97, 32.63, array( $tax_rate_id => 2.66 ) );
+
+ $response = $this->dispatch_refund_request(
+ $order->get_id(),
+ array(
+ array(
+ 'line_item_id' => $item->get_id(),
+ 'quantity' => 2,
+ ),
+ )
+ );
+ $this->assertEquals( 201, $response->get_status() );
+ $data = $response->get_data();
+ $this->assertEqualsWithDelta( 21.75, (float) $data['amount'], 0.001, 'Qty-2 refund of the 32.63 line rounds 21.7533 to 21.75' );
+ $this->created_refunds[] = $data['id'];
+
+ $line = $data['line_items'][0];
+ $tax_sum = 0.0;
+ foreach ( $line['refund_tax'] as $tax ) {
+ $tax_sum += (float) $tax['refund_total'];
+ }
+ $this->assertEqualsWithDelta( 21.75, (float) $line['refund_total'] + $tax_sum, 0.001, 'Extracted net + tax must reassemble the tax-inclusive amount' );
+
+ $response = $this->dispatch_refund_request(
+ $order->get_id(),
+ array(
+ array(
+ 'line_item_id' => $item->get_id(),
+ 'quantity' => 1,
+ ),
+ )
+ );
+ $this->assertEquals( 201, $response->get_status() );
+ $this->assertEqualsWithDelta( 10.88, (float) $response->get_data()['amount'], 0.001, '21.75 + 10.88 consumes the 32.63 line exactly' );
+ $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, 'Qty-2 then qty-1 must leave nothing unrefunded' );
+ } finally {
+ update_option( 'woocommerce_calc_taxes', $original_calc_taxes );
+ update_option( 'woocommerce_prices_include_tax', $original_prices_include_tax );
+ }
+ }
+
+ /**
+ * @testdox Multi-quantity auto-compute on a tax-inclusive store returns quantity × displayed price.
+ */
+ public function test_refunds_create_auto_compute_multi_qty_prices_include_tax(): void {
+ $tax_rate_id = WC_Tax::_insert_tax_rate(
+ array(
+ 'tax_rate_country' => 'US',
+ 'tax_rate_state' => '',
+ 'tax_rate' => '23.0000',
+ 'tax_rate_name' => 'VAT',
+ 'tax_rate_priority' => '1',
+ 'tax_rate_compound' => '0',
+ 'tax_rate_shipping' => '1',
+ 'tax_rate_order' => '1',
+ 'tax_rate_class' => '',
+ )
+ );
+
+ $original_calc_taxes = get_option( 'woocommerce_calc_taxes', 'no' );
+ $original_prices_include_tax = get_option( 'woocommerce_prices_include_tax', 'no' );
+ update_option( 'woocommerce_calc_taxes', 'yes' );
+ update_option( 'woocommerce_prices_include_tax', 'yes' );
+
+ try {
+ // 5 × 9.99 displayed (tax-inclusive) = 49.95; stored net 40.61 + 9.34 tax.
+ list( $order, $item ) = $this->create_order_with_exact_line( 5, 40.61, 40.61, 49.95, array( $tax_rate_id => 9.34 ) );
+
+ $response = $this->dispatch_refund_request(
+ $order->get_id(),
+ array(
+ array(
+ 'line_item_id' => $item->get_id(),
+ 'quantity' => 5,
+ ),
+ )
+ );
+ $this->assertEquals( 201, $response->get_status() );
+ $this->assertEqualsWithDelta( 49.95, (float) $response->get_data()['amount'], 0.001, 'Full-quantity refund must equal 5 × the displayed 9.99 price' );
+ $this->created_refunds[] = $response->get_data()['id'];
+
+ list( $order_b, $item_b ) = $this->create_order_with_exact_line( 5, 40.61, 40.61, 49.95, array( $tax_rate_id => 9.34 ) );
+
+ $response = $this->dispatch_refund_request(
+ $order_b->get_id(),
+ array(
+ array(
+ 'line_item_id' => $item_b->get_id(),
+ 'quantity' => 2,
+ ),
+ )
+ );
+ $this->assertEquals( 201, $response->get_status() );
+ $this->assertEqualsWithDelta( 19.98, (float) $response->get_data()['amount'], 0.001, 'Qty-2 refund must equal 2 × the displayed 9.99 price' );
+ $this->created_refunds[] = $response->get_data()['id'];
+ } finally {
+ update_option( 'woocommerce_calc_taxes', $original_calc_taxes );
+ update_option( 'woocommerce_prices_include_tax', $original_prices_include_tax );
+ }
+ }
+
+ /**
+ * @testdox Multi-quantity auto-compute with compound taxes matches the preview total and reassembles per-rate taxes.
+ */
+ public function test_refunds_create_auto_compute_multi_qty_compound_taxes(): void {
+ $gst_rate_id = WC_Tax::_insert_tax_rate(
+ array(
+ 'tax_rate_country' => 'US',
+ 'tax_rate_state' => '',
+ 'tax_rate' => '5.0000',
+ 'tax_rate_name' => 'GST',
+ 'tax_rate_priority' => '1',
+ 'tax_rate_compound' => '0',
+ 'tax_rate_shipping' => '1',
+ 'tax_rate_order' => '1',
+ 'tax_rate_class' => '',
+ )
+ );
+ $pst_rate_id = WC_Tax::_insert_tax_rate(
+ array(
+ 'tax_rate_country' => 'US',
+ 'tax_rate_state' => '',
+ 'tax_rate' => '7.0000',
+ 'tax_rate_name' => 'PST',
+ 'tax_rate_priority' => '2',
+ 'tax_rate_compound' => '1',
+ 'tax_rate_shipping' => '1',
+ 'tax_rate_order' => '2',
+ 'tax_rate_class' => '',
+ )
+ );
+
+ $original_calc_taxes = get_option( 'woocommerce_calc_taxes', 'no' );
+ $original_prices_include_tax = get_option( 'woocommerce_prices_include_tax', 'no' );
+ update_option( 'woocommerce_calc_taxes', 'yes' );
+ update_option( 'woocommerce_prices_include_tax', 'no' );
+
+ try {
+ // 3 × 50 = 150; GST 5% = 7.50; compound PST 7% of 157.50 = 11.03; grand total 168.53.
+ list( $order, $item ) = $this->create_order_with_exact_line(
+ 3,
+ 150.00,
+ 150.00,
+ 168.53,
+ array(
+ $gst_rate_id => 7.50,
+ $pst_rate_id => 11.03,
+ )
+ );
+
+ $line_items = array(
+ array(
+ 'line_item_id' => $item->get_id(),
+ 'quantity' => 2,
+ ),
+ );
+
+ $data_utils = wc_get_container()->get( DataUtils::class );
+ $preview = $data_utils->build_refund_preview( $order, $line_items );
+
+ $response = $this->dispatch_refund_request( $order->get_id(), $line_items );
+ $this->assertEquals( 201, $response->get_status() );
+ $data = $response->get_data();
+ $this->assertEqualsWithDelta( 112.35, (float) $data['amount'], 0.001, 'Qty-2 refund of the 168.53 line rounds 112.3533 to 112.35' );
+ $this->assertEquals( $preview['total'], $data['amount'], 'Create amount must match build_refund_preview total exactly' );
+ $this->created_refunds[] = $data['id'];
+
+ $line = $data['line_items'][0];
+ $tax_sum = 0.0;
+ foreach ( $line['refund_tax'] as $tax ) {
+ $tax_sum += (float) $tax['refund_total'];
+ }
+ $this->assertEqualsWithDelta( 112.35, (float) $line['refund_total'] + $tax_sum, 0.001, 'Net + per-rate taxes must reassemble the tax-inclusive amount' );
+ } finally {
+ update_option( 'woocommerce_calc_taxes', $original_calc_taxes );
+ update_option( 'woocommerce_prices_include_tax', $original_prices_include_tax );
+ }
+ }
+
+ /**
+ * @testdox Auto-compute uses the discounted line total, not the pre-discount subtotal.
+ */
+ public function test_refunds_create_auto_compute_uses_discounted_total(): void {
+ // 3 × 10.00 with a 10% discount applied: subtotal 30.00, total 27.00.
+ list( $order, $item ) = $this->create_order_with_exact_line( 3, 30.00, 27.00, 27.00 );
+
+ $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( 18.00, (float) $response->get_data()['amount'], 0.001, 'Qty-2 refund must use the discounted 9.00 unit price, not the 10.00 subtotal price' );
+ $this->created_refunds[] = $response->get_data()['id'];
+ }
+
+ /**
+ * @testdox Fee and shipping lines reject quantity above 1 and auto-compute their full total at quantity 1.
+ */
+ public function test_refunds_create_fee_and_shipping_quantity_is_informational(): void {
+ $product = WC_Helper_Product::create_simple_product();
+ $product->set_price( 10.00 );
+ $product->save();
+
+ $order = wc_create_order();
+ $item = new WC_Order_Item_Product();
+ $item->set_props(
+ array(
+ 'product' => $product,
+ 'quantity' => 1,
+ 'subtotal' => 10.00,
+ 'total' => 10.00,
+ )
+ );
+ $item->save();
+ $order->add_item( $item );
+
+ $fee = new \WC_Order_Item_Fee();
+ $fee->set_props(
+ array(
+ 'name' => 'Service fee',
+ 'total' => 7.50,
+ )
+ );
+ $fee->save();
+ $order->add_item( $fee );
+
+ $shipping = new \WC_Order_Item_Shipping();
+ $shipping->set_props(
+ array(
+ 'method_title' => 'Flat rate',
+ 'method_id' => 'flat_rate',
+ 'total' => 5.00,
+ )
+ );
+ $shipping->save();
+ $order->add_item( $shipping );
+
+ $order->set_total( 22.50 );
+ $order->set_status( OrderStatus::COMPLETED );
+ $order->save();
+ $this->created_orders[] = $order->get_id();
+
+ foreach ( array( $fee, $shipping ) as $non_product_item ) {
+ $response = $this->dispatch_refund_request(
+ $order->get_id(),
+ array(
+ array(
+ 'line_item_id' => $non_product_item->get_id(),
+ 'quantity' => 3,
+ ),
+ )
+ );
+ $this->assertEquals( 400, $response->get_status(), 'Fee/shipping items have quantity 1; requesting 3 must be rejected' );
+ $this->assertEquals( 'invalid_line_item', $response->get_data()['code'] );
+ }
+
+ $response = $this->dispatch_refund_request(
+ $order->get_id(),
+ array(
+ array(
+ 'line_item_id' => $fee->get_id(),
+ 'quantity' => 1,
+ ),
+ array(
+ 'line_item_id' => $shipping->get_id(),
+ 'quantity' => 1,
+ ),
+ )
+ );
+ $this->assertEquals( 201, $response->get_status() );
+ $this->assertEqualsWithDelta( 12.50, (float) $response->get_data()['amount'], 0.001, 'Quantity 1 refunds each non-product line at its full total, exactly once' );
+ $this->created_refunds[] = $response->get_data()['id'];
+
+ $product->delete( true );
+ }
+
/**
* @testdox Creating a V4 refund with incomplete meta_data entries does not cause errors.
*/
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 e2079ba37e9..2b9330eb9aa 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
@@ -335,6 +335,68 @@ class DataUtilsTest extends WC_Unit_Test_Case {
$this->assertNull( $this->data_utils->calculate_refund_amount( array() ) );
}
+ /**
+ * @testdox calculate_refund_amount treats explicit refund_total: 0 as a valid zero contribution, not as missing.
+ *
+ * Regression guard: a previous implementation used `!empty($line_item['refund_total'])`
+ * which is `true` for `0` / `0.0` / `"0"`. A mixed request like
+ * `[{refund_total: 50}, {refund_total: 0}]` therefore summed to 50 with the
+ * second line silently absent. The current implementation uses `isset() && is_numeric()`,
+ * which preserves the explicit-zero contract documented in the schema.
+ */
+ public function test_calculate_refund_amount_includes_explicit_zero(): void {
+ $line_items = array(
+ array(
+ 'line_item_id' => 1,
+ 'quantity' => 1,
+ 'refund_total' => 50.00,
+ ),
+ array(
+ 'line_item_id' => 2,
+ 'quantity' => 1,
+ 'refund_total' => 0,
+ ),
+ );
+
+ $result = $this->data_utils->calculate_refund_amount( $line_items );
+
+ $this->assertSame( 50.0, $result, 'Explicit-zero line contributes 0; total stays 50.' );
+ }
+
+ /**
+ * @testdox convert_line_items_to_internal_format accepts the legacy v3-style shape (refund_total without quantity) and records qty=0.
+ */
+ public function test_convert_line_items_legacy_no_quantity_defaults_qty_zero(): void {
+ $order = wc_create_order();
+ $item = new WC_Order_Item_Product();
+ $item->set_props(
+ array(
+ 'quantity' => 2,
+ 'subtotal' => 100.00,
+ 'total' => 100.00,
+ )
+ );
+ $item->save();
+ $order->add_item( $item );
+ $order->save();
+
+ $result = $this->data_utils->convert_line_items_to_internal_format(
+ array(
+ array(
+ 'line_item_id' => $item->get_id(),
+ 'refund_total' => 30.00,
+ ),
+ ),
+ $order
+ );
+
+ $this->assertArrayHasKey( $item->get_id(), $result, 'Line item must be attached, not silently dropped.' );
+ $this->assertSame( 0, $result[ $item->get_id() ]['qty'] );
+ $this->assertSame( 30.00, $result[ $item->get_id() ]['refund_total'] );
+
+ $order->delete( true );
+ }
+
/**
* @testdox Should compute line item refund total for a product based on unit price and quantity.
*/
@@ -482,6 +544,27 @@ class DataUtilsTest extends WC_Unit_Test_Case {
$this->assertSame( 11.50, $this->data_utils->compute_line_item_refund_total( $shipping, 1 ) );
}
+ /**
+ * @testdox compute_line_item_refund_total returns the same total regardless of the quantity argument for shipping items.
+ *
+ * Behavior lock. Shipping lines refund as a whole; the quantity argument
+ * must not multiply the result. A future refactor that wrongly applied
+ * unit_price * quantity to shipping would fail this assertion.
+ */
+ public function test_compute_line_item_refund_total_shipping_ignores_quantity(): void {
+ $shipping = new WC_Order_Item_Shipping();
+ $shipping->set_props(
+ array(
+ 'method_title' => 'Flat Rate',
+ 'total' => 10.00,
+ )
+ );
+ $shipping->set_taxes( array( 'total' => array( 1 => 1.50 ) ) );
+ $shipping->save();
+
+ $this->assertSame( 11.50, $this->data_utils->compute_line_item_refund_total( $shipping, 5 ) );
+ }
+
/**
* @testdox Should return full item total + tax for fee items.
*/
@@ -499,6 +582,26 @@ class DataUtilsTest extends WC_Unit_Test_Case {
$this->assertSame( 23.00, $this->data_utils->compute_line_item_refund_total( $fee, 1 ) );
}
+ /**
+ * @testdox compute_line_item_refund_total returns the same total regardless of the quantity argument for fee items.
+ *
+ * Behavior lock matching the shipping case. Fees refund as a whole; quantity
+ * must not multiply the result.
+ */
+ public function test_compute_line_item_refund_total_fee_ignores_quantity(): void {
+ $fee = new WC_Order_Item_Fee();
+ $fee->set_props(
+ array(
+ 'name' => 'Handling',
+ 'total' => 20.00,
+ )
+ );
+ $fee->set_taxes( array( 'total' => array( 1 => 3.00 ) ) );
+ $fee->save();
+
+ $this->assertSame( 23.00, $this->data_utils->compute_line_item_refund_total( $fee, 5 ) );
+ }
+
/**
* @testdox Should preserve negative sign for negative-total fee items (discount fees).
*/
@@ -1279,6 +1382,496 @@ class DataUtilsTest extends WC_Unit_Test_Case {
$order->delete( true );
}
+ /**
+ * @testdox validate_line_items rejects missing or non-positive quantity with a clear invalid_line_item error.
+ *
+ * @dataProvider provider_invalid_quantities_for_validate_line_items
+ *
+ * @param mixed $quantity The quantity value to test (or null to omit the key).
+ */
+ public function test_validate_line_items_rejects_missing_quantity( $quantity ): void {
+ $product = WC_Helper_Product::create_simple_product();
+ $product->save();
+
+ $order = wc_create_order();
+ $item = new WC_Order_Item_Product();
+ $item->set_props(
+ array(
+ 'product' => $product,
+ 'quantity' => 2,
+ 'subtotal' => 20.00,
+ 'total' => 20.00,
+ )
+ );
+ $item->save();
+ $order->add_item( $item );
+ $order->set_status( OrderStatus::COMPLETED );
+ $order->save();
+
+ $line_item = array( 'line_item_id' => $item->get_id() );
+ if ( null !== $quantity ) {
+ $line_item['quantity'] = $quantity;
+ }
+
+ $result = $this->data_utils->validate_line_items( array( $line_item ), $order );
+
+ $this->assertInstanceOf( \WP_Error::class, $result );
+ $this->assertEquals( 'invalid_line_item', $result->get_error_code() );
+ $this->assertStringContainsString( 'positive integer', $result->get_error_message() );
+
+ $product->delete( true );
+ $order->delete( true );
+ }
+
+ /**
+ * @return array<string, array<int, mixed>>
+ */
+ public function provider_invalid_quantities_for_validate_line_items(): array {
+ return array(
+ 'missing' => array( null ),
+ 'zero' => array( 0 ),
+ 'negative' => array( -1 ),
+ 'string' => array( '2' ),
+ 'float' => array( 1.5 ),
+ );
+ }
+
+ /**
+ * @testdox validate_line_items accepts missing/zero quantity when refund_total is provided explicitly (legacy v3-style path).
+ *
+ * @dataProvider provider_loose_quantities_with_explicit_refund_total
+ *
+ * @param mixed $quantity The quantity value to test (or null to omit the key).
+ */
+ public function test_validate_line_items_accepts_loose_quantity_with_explicit_refund_total( $quantity ): void {
+ $product = WC_Helper_Product::create_simple_product();
+ $product->save();
+
+ $order = wc_create_order();
+ $item = new WC_Order_Item_Product();
+ $item->set_props(
+ array(
+ 'product' => $product,
+ 'quantity' => 2,
+ 'subtotal' => 20.00,
+ 'total' => 20.00,
+ )
+ );
+ $item->save();
+ $order->add_item( $item );
+ $order->set_status( OrderStatus::COMPLETED );
+ $order->save();
+
+ $line_item = array(
+ 'line_item_id' => $item->get_id(),
+ 'refund_total' => 10.00,
+ );
+ if ( null !== $quantity ) {
+ $line_item['quantity'] = $quantity;
+ }
+
+ $result = $this->data_utils->validate_line_items( array( $line_item ), $order );
+
+ $this->assertTrue( $result, 'Legacy explicit-refund_total path should accept missing/zero quantity.' );
+
+ $product->delete( true );
+ $order->delete( true );
+ }
+
+ /**
+ * @return array<string, array<int, mixed>>
+ */
+ public function provider_loose_quantities_with_explicit_refund_total(): array {
+ return array(
+ 'missing' => array( null ),
+ 'zero' => array( 0 ),
+ );
+ }
+
+ /**
+ * @testdox fill_missing_refund_totals computes refund_total for a product line item when missing.
+ */
+ public function test_fill_missing_refund_totals_product(): void {
+ $product = WC_Helper_Product::create_simple_product();
+ $product->set_regular_price( 25.00 );
+ $product->save();
+
+ $order = wc_create_order();
+ $item = new WC_Order_Item_Product();
+ $item->set_props(
+ array(
+ 'product' => $product,
+ 'quantity' => 4,
+ 'subtotal' => 100.00,
+ 'total' => 100.00,
+ )
+ );
+ $item->save();
+ $order->add_item( $item );
+ $order->save();
+
+ $result = $this->data_utils->fill_missing_refund_totals(
+ array(
+ array(
+ 'line_item_id' => $item->get_id(),
+ 'quantity' => 2,
+ ),
+ ),
+ $order
+ );
+
+ $this->assertArrayHasKey( 'refund_total', $result[0] );
+ $this->assertSame( 50.00, $result[0]['refund_total'], '2 × $25 unit price = $50' );
+
+ $product->delete( true );
+ $order->delete( true );
+ }
+
+ /**
+ * @testdox fill_missing_refund_totals treats refund_total: null the same as a missing key (computes it).
+ */
+ public function test_fill_missing_refund_totals_treats_null_as_missing(): void {
+ $product = WC_Helper_Product::create_simple_product();
+ $product->set_regular_price( 15.00 );
+ $product->save();
+
+ $order = wc_create_order();
+ $item = new WC_Order_Item_Product();
+ $item->set_props(
+ array(
+ 'product' => $product,
+ 'quantity' => 2,
+ 'subtotal' => 30.00,
+ 'total' => 30.00,
+ )
+ );
+ $item->save();
+ $order->add_item( $item );
+ $order->save();
+
+ $result = $this->data_utils->fill_missing_refund_totals(
+ array(
+ array(
+ 'line_item_id' => $item->get_id(),
+ 'quantity' => 1,
+ 'refund_total' => null,
+ ),
+ ),
+ $order
+ );
+
+ $this->assertArrayHasKey( 'refund_total', $result[0] );
+ $this->assertSame( 15.00, $result[0]['refund_total'], 'null should be treated the same as omitted — auto-computed' );
+
+ $product->delete( true );
+ $order->delete( true );
+ }
+
+ /**
+ * @testdox fill_missing_refund_totals leaves explicit refund_total: 0 untouched.
+ */
+ public function test_fill_missing_refund_totals_leaves_explicit_zero_untouched(): void {
+ $product = WC_Helper_Product::create_simple_product();
+ $product->set_regular_price( 10.00 );
+ $product->save();
+
+ $order = wc_create_order();
+ $item = new WC_Order_Item_Product();
+ $item->set_props(
+ array(
+ 'product' => $product,
+ 'quantity' => 1,
+ 'subtotal' => 10.00,
+ 'total' => 10.00,
+ )
+ );
+ $item->save();
+ $order->add_item( $item );
+ $order->save();
+
+ $result = $this->data_utils->fill_missing_refund_totals(
+ array(
+ array(
+ 'line_item_id' => $item->get_id(),
+ 'quantity' => 1,
+ 'refund_total' => 0,
+ ),
+ ),
+ $order
+ );
+
+ $this->assertSame( 0, $result[0]['refund_total'], 'Explicit zero must not be replaced by the auto-computed value' );
+
+ $product->delete( true );
+ $order->delete( true );
+ }
+
+ /**
+ * @testdox fill_missing_refund_totals leaves explicit refund_total untouched.
+ */
+ public function test_fill_missing_refund_totals_preserves_explicit(): void {
+ $product = WC_Helper_Product::create_simple_product();
+ $product->set_regular_price( 10.00 );
+ $product->save();
+
+ $order = wc_create_order();
+ $item = new WC_Order_Item_Product();
+ $item->set_props(
+ array(
+ 'product' => $product,
+ 'quantity' => 1,
+ 'subtotal' => 10.00,
+ 'total' => 10.00,
+ )
+ );
+ $item->save();
+ $order->add_item( $item );
+ $order->save();
+
+ $result = $this->data_utils->fill_missing_refund_totals(
+ array(
+ array(
+ 'line_item_id' => $item->get_id(),
+ 'quantity' => 1,
+ 'refund_total' => 7.50,
+ ),
+ ),
+ $order
+ );
+
+ $this->assertSame( 7.50, $result[0]['refund_total'], 'Explicit refund_total must not be overwritten' );
+
+ $product->delete( true );
+ $order->delete( true );
+ }
+
+ /**
+ * @testdox fill_missing_refund_totals leaves the item alone when line_item_id does not resolve.
+ */
+ public function test_fill_missing_refund_totals_skips_unknown_item(): void {
+ $order = wc_create_order();
+ $order->save();
+
+ $result = $this->data_utils->fill_missing_refund_totals(
+ array(
+ array(
+ 'line_item_id' => 999999,
+ 'quantity' => 1,
+ ),
+ ),
+ $order
+ );
+
+ $this->assertArrayNotHasKey( 'refund_total', $result[0] );
+
+ $order->delete( true );
+ }
+
+ /**
+ * @testdox fill_missing_refund_totals skips items with bad or missing quantity.
+ *
+ * @dataProvider provider_bad_quantities_for_fill
+ *
+ * @param mixed $quantity The quantity value to test.
+ */
+ public function test_fill_missing_refund_totals_skips_bad_quantity( $quantity ): void {
+ $product = WC_Helper_Product::create_simple_product();
+ $product->save();
+ $order = wc_create_order();
+ $item = new WC_Order_Item_Product();
+ $item->set_props(
+ array(
+ 'product' => $product,
+ 'quantity' => 2,
+ 'subtotal' => 20.00,
+ 'total' => 20.00,
+ )
+ );
+ $item->save();
+ $order->add_item( $item );
+ $order->save();
+
+ $line_item = array( 'line_item_id' => $item->get_id() );
+ if ( null !== $quantity ) {
+ $line_item['quantity'] = $quantity;
+ }
+
+ $result = $this->data_utils->fill_missing_refund_totals( array( $line_item ), $order );
+
+ $this->assertArrayNotHasKey( 'refund_total', $result[0] );
+
+ $product->delete( true );
+ $order->delete( true );
+ }
+
+ /**
+ * @return array<string, array<int, mixed>>
+ */
+ public function provider_bad_quantities_for_fill(): array {
+ return array(
+ 'missing' => array( null ),
+ 'zero' => array( 0 ),
+ 'negative' => array( -1 ),
+ 'string' => array( 'abc' ),
+ 'float' => array( 1.5 ),
+ );
+ }
+
+ /**
+ * @testdox fill_missing_refund_totals leaves refund_total unset for product items whose source has zero quantity.
+ */
+ public function test_fill_missing_refund_totals_skips_zero_source_quantity_product(): void {
+ $order = wc_create_order();
+ $item = new WC_Order_Item_Product();
+ $item->set_props(
+ array(
+ 'quantity' => 0,
+ 'subtotal' => 0,
+ 'total' => 0,
+ )
+ );
+ $item->save();
+ $order->add_item( $item );
+ $order->save();
+
+ $result = $this->data_utils->fill_missing_refund_totals(
+ array(
+ array(
+ 'line_item_id' => $item->get_id(),
+ 'quantity' => 1,
+ ),
+ ),
+ $order
+ );
+
+ $this->assertArrayNotHasKey( 'refund_total', $result[0], 'Helper must leave refund_total unset so validate_line_items can surface a specific error.' );
+
+ $order->delete( true );
+ }
+
+ /**
+ * @testdox validate_line_items returns a specific error when refund_total is omitted and source product has zero quantity.
+ */
+ public function test_validate_line_items_zero_source_quantity_with_missing_refund_total(): void {
+ $order = wc_create_order();
+ $item = new WC_Order_Item_Product();
+ $item->set_props(
+ array(
+ 'quantity' => 0,
+ 'subtotal' => 0,
+ 'total' => 0,
+ )
+ );
+ $item->save();
+ $order->add_item( $item );
+ $order->save();
+
+ $result = $this->data_utils->validate_line_items(
+ array(
+ array(
+ 'line_item_id' => $item->get_id(),
+ 'quantity' => 1,
+ ),
+ ),
+ $order
+ );
+
+ $this->assertInstanceOf( \WP_Error::class, $result );
+ $this->assertEquals( 'invalid_line_item', $result->get_error_code() );
+ $this->assertStringContainsString( 'source quantity is zero', $result->get_error_message() );
+
+ $order->delete( true );
+ }
+
+ /**
+ * @testdox fill_missing_refund_totals returns full item total for shipping items, ignoring quantity.
+ */
+ public function test_fill_missing_refund_totals_shipping(): void {
+ $order = wc_create_order();
+ $shipping = new WC_Order_Item_Shipping();
+ $shipping->set_props(
+ array(
+ 'method_title' => 'Flat Rate',
+ 'total' => 12.50,
+ )
+ );
+ $shipping->save();
+ $order->add_item( $shipping );
+ $order->save();
+
+ $result = $this->data_utils->fill_missing_refund_totals(
+ array(
+ array(
+ 'line_item_id' => $shipping->get_id(),
+ 'quantity' => 1,
+ ),
+ ),
+ $order
+ );
+
+ $this->assertSame( 12.50, $result[0]['refund_total'] );
+
+ $order->delete( true );
+ }
+
+ /**
+ * @testdox fill_missing_refund_totals processes a mixed array (some items with, some without refund_total).
+ */
+ public function test_fill_missing_refund_totals_mixed(): void {
+ $product = WC_Helper_Product::create_simple_product();
+ $product->set_regular_price( 10.00 );
+ $product->save();
+
+ $order = wc_create_order();
+ $item_a = new WC_Order_Item_Product();
+ $item_a->set_props(
+ array(
+ 'product' => $product,
+ 'quantity' => 1,
+ 'subtotal' => 10.00,
+ 'total' => 10.00,
+ )
+ );
+ $item_a->save();
+ $order->add_item( $item_a );
+
+ $item_b = new WC_Order_Item_Product();
+ $item_b->set_props(
+ array(
+ 'product' => $product,
+ 'quantity' => 1,
+ 'subtotal' => 10.00,
+ 'total' => 10.00,
+ )
+ );
+ $item_b->save();
+ $order->add_item( $item_b );
+ $order->save();
+
+ $result = $this->data_utils->fill_missing_refund_totals(
+ array(
+ array(
+ 'line_item_id' => $item_a->get_id(),
+ 'quantity' => 1,
+ ),
+ // Item A above has no refund_total, expected to be filled with 10.00.
+ array(
+ 'line_item_id' => $item_b->get_id(),
+ 'quantity' => 1,
+ 'refund_total' => 7.0,
+ ),
+ // Item B has explicit refund_total 7.0, expected to be preserved.
+ ),
+ $order
+ );
+
+ $this->assertSame( 10.00, $result[0]['refund_total'] );
+ $this->assertSame( 7.0, $result[1]['refund_total'] );
+
+ $product->delete( true );
+ $order->delete( true );
+ }
+
/**
* @testdox Should build refund preview with correct tax extraction.
*/