Skip to content

Update/payments 2 0#1

Draft
vladolaru wants to merge 7 commits into
trunkfrom
update/payments-2-0
Draft

Update/payments 2 0#1
vladolaru wants to merge 7 commits into
trunkfrom
update/payments-2-0

Conversation

@vladolaru

Copy link
Copy Markdown
Owner

Submission Review Guidelines:

Changes proposed in this Pull Request:

Closes # .

(For Bug Fixes) Bug introduced in PR # .

Screenshots or screen recordings:

Before After

How to test the changes in this Pull Request:

Using the WooCommerce Testing Instructions Guide, include your detailed testing instructions:

Testing that has already taken place:

Changelog entry

  • Automatically create a changelog entry from the details below.
  • This Pull Request does not require a changelog entry. (Comment required below)
Changelog Entry Details

Significance

  • Patch
  • Minor
  • Major

Type

  • Fix - Fixes an existing bug
  • Add - Adds functionality
  • Update - Update existing functionality
  • Dev - Development related task
  • Tweak - A minor adjustment to the codebase
  • Performance - Address performance issues
  • Enhancement - Improvement to existing functionality

Message

Changelog Entry Comment

Comment

@vladolaru vladolaru self-assigned this Jun 25, 2025
vladolaru pushed a commit that referenced this pull request Jul 6, 2026
…commerce#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, woocommerce#3, and the lower-priority error-code
split from the PR woocommerce#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 woocommerce#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 woocommerce#6 and woocommerce#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 woocommerce#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 (woocommerce#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 woocommerce#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 woocommerce#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 woocommerce#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 woocommerce#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 woocommerce#3 from the PR woocommerce#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 woocommerce#4 from the PR woocommerce#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 woocommerce#5 (type-design wins) from the PR woocommerce#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 woocommerce#6 (fee auto-compute + negative-fee end-to-end)
from the PR woocommerce#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.

(woocommerce#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.

(woocommerce#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.

(woocommerce#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).

(woocommerce#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'.

(woocommerce#3) Remove the duplicate changelog file
65439-woomob-2685-simplify-refund-creation — the unprefixed
woomob-2685-simplify-refund-creation entry is the canonical one.

(woocommerce#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
woocommerce#65334 and were already updated on that branch (644f7c0);
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 woocommerce#65334 and woocommerce#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>
vladolaru pushed a commit that referenced this pull request Jul 6, 2026
…5645)

* 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 refund preview endpoint (POST /wc/v4/refunds/preview)

Wires the v4 refund preview endpoint that returns authoritative
totals/breakdowns for a proposed refund without writing any data.

Request:
- order_id + line_items[].line_item_id/quantity

Response:
- breakdown.{products, shipping, fees} (items + subtotal/tax/total)
- top-level subtotal, tax, total, max_refundable

The calculation, validation, and tax extraction live in
Refunds\DataUtils (added in the helpers PR) — this PR is the HTTP
surface: route registration, schema, controller handler, and
integration tests.

Key behaviors:
- Read-only: no refund record, no stock reservation, no writes
- Enforces REFUNDABLE_STATUSES order gate via the validation helper
- Uses the same tax-extraction path as the create endpoint
  (WC_Tax::calc_inclusive_tax) to guarantee preview/create equivalence
- Returns standard WP_Error responses (invalid_line_item,
  quantity_exceeds_refundable, order_not_refundable)
- Gated behind the existing rest-api-v4 feature flag

Part of WOOMOB-2684. Depends on the DataUtils helpers 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

* Catch InvalidArgumentException in preview_item; add integration tests

Controller:
- preview_item now wraps build_refund_preview() in a try/catch for
  \InvalidArgumentException. The validator above should have rejected
  bad input, so any throw here is an invariant violation — surface as
  invalid_preview_request rather than letting it bubble as a fatal.

Integration tests:
- Update test_preview_invalid_line_item assertion to the new
  line_item_not_found error code (was the catch-all invalid_line_item).
- Tighten test_preview_empty_line_items to assert missing_line_items.
- Add test_preview_invalid_quantity_zero (asserts invalid_quantity).
- Add test_preview_shipping_line (shipping-only order, breakdown.shipping).
- Add test_preview_fee_line (fee-only order, breakdown.fees).
- Add test_preview_mixed_sections (products + shipping + fees aggregate).
- Add create_order_with_shipping / create_order_with_fee helpers.

* Apply PHPCBF auto-fixes to PR2 files

* Restore @var docblock with short description for PHPStan type narrowing

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

* Tighten preview endpoint request validation

- Add validate_callback to the top-level line_items arg so REST
  framework runs rest_validate_value_from_schema on the nested
  items[] (previously inert: the nested validate_callback /
  sanitize_callback keys on items.properties are only honored at
  the top level).
- Drop absint sanitize_callbacks from order_id, line_item_id,
  quantity. absint silently rewrites negative or float inputs;
  use 'minimum' => 1 with rest_validate_request_arg to reject
  invalid input at the framework boundary.
- Add 'minItems' => 1 to line_items so an empty array is rejected
  by the REST framework instead of bouncing through DataUtils.
- Add 'additionalProperties' => false and explicit 'required'
  list to the items schema for stricter shape enforcement.
- Reject non-shop_order post types (e.g. shop_subscription) in
  preview_item — previously only WC_Order_Refund was rejected, so
  a subscription ID would pass through and either compute nonsense
  totals or trigger an opaque InvalidArgumentException.
- Add a code comment justifying create_item_permissions_check on
  a read-only endpoint (preview is part of refund-create flow).

Addresses review issues #1 (line_items validation), #3 (order
type gate), and minor #6 (absint masking) / #8 (minItems) from
the PR #65335 audit.

* Preserve WP_Error status and broaden preview_item catch list

Validation errors: Switch from get_route_error_response (hardcoded
400) to get_route_error_response_from_object, reading the status
data from the WP_Error so per-code statuses are honored
(line_item_not_found can be 404, order_not_refundable can be 422,
etc.). When the WP_Error has no status data, defaults to 400 — same
behavior as before. The DataUtils-side change to populate status
data per code lands in the helpers PR.

Exception handling: Match create_item's catch list:
- \WC_Data_Exception and \WC_REST_Exception now caught with the
  exception's own error code preserved.
- \InvalidArgumentException now logged via wc_get_logger() with
  source 'wc-v4-refunds' + order_id context, returns HTTP 500 (was
  400) since the code comment already identified this as a
  server-side invariant violation. The exception message is no
  longer leaked to the client — generic message returned instead.
- Final \Throwable arm catches anything else (PHP TypeError,
  RuntimeException, etc.), logs it, returns 500 with code
  unexpected_preview_error.

Addresses review issues #2 (status preservation), #5
(InvalidArgumentException as 500), #6 (broader catch list) from
the PR #65335 audit.

* Schema: split product/base item shapes; throw on unused stub

- Split the single $item_schema (which advertised product_id and
  variation_id on every section) into get_base_item_schema()
  (id/name/quantity/subtotal/tax/total) and
  get_product_item_schema() (extends base with
  product_id/variation_id). The breakdown.products section uses the
  product variant; shipping and fees use the base variant. The
  public schema document now accurately reflects which fields
  appear in which section.
- get_item_response() now throws \LogicException instead of being
  a silent no-op stub. The preview controller bypasses
  prepare_item_for_response and returns the data array directly,
  so this method should never be invoked. Throwing surfaces any
  accidental future call site immediately rather than silently
  returning incomplete or wrong-shaped data.

Addresses review issues #4 (schema declares product fields on
shipping/fees) and #7 (silent get_item_response stub).

* Add integration tests for new validation, type gate, and invariant catch

- test_preview_invalid_quantity (data provider) replaces the
  single test_preview_invalid_quantity_zero. Covers zero,
  negative, missing key, string, and float. Accepts either
  rest_invalid_param (when the REST framework rejects pre-handler
  via the new minimum/type constraints) or invalid_quantity (when
  DataUtils rejects), so the test documents the actual HTTP-level
  behavior without coupling to which layer rejects first.
- test_preview_invalid_payload_shape — POSTs a malformed object
  (string line_item_id / quantity) and asserts rest_invalid_param.
  Locks the new rest_validate_request_arg on line_items.
- test_preview_non_shop_order_returns_invalid_id — passes a refund
  ID to the preview endpoint and asserts 404. Locks the
  shop_order type gate.
- test_preview_read_only_user_returns_forbidden — creates a
  customer-role user and asserts 401/403. Locks the
  create_item_permissions_check gate against accidental loosening.
- test_preview_invariant_violation_returns_500 — replaces the
  controller's DataUtils dependency with an anonymous stub that
  validates true but throws on build. Asserts 500 with code
  invalid_preview_request. Locks the controller's
  InvalidArgumentException catch arm, which is otherwise dead code
  in normal flow.

* Add schema/response parity test for refund preview

test_schema_matches_response_shape walks the declared
RefundPreviewSchema properties recursively against an actual
preview response built from a mixed (product + shipping + fee)
order. Asserts that every object/array section declared in the
schema is present in the response, and every key in the response
is declared in the schema.

Catches future drift where new keys are added to
build_refund_preview() but not to the schema (or vice versa) —
which would silently mislead clients reading the autodoc at
/wp-json/wc/v4/refunds/preview.

* Tighten preview integration tests (quality nits)

- Replace assertSame(array(), ...) with assertEmpty() in shipping
  and fee section assertions. The empty-array check still holds
  but is no longer brittle to a future schema change that might
  return null or omit the key.
- test_preview_matches_create (P19): derive create's refund_total
  from $preview_data['total'] instead of hardcoding 110.00. A
  divergence between preview and create now produces an actual
  mismatch rather than passing by coincidence — which was the
  whole point of this regression guard.
- Replace hardcoded 999999 invalid-id with $existing_item_id + 999
  so the test is principled rather than statistically safe.
- Move wp_insert_user from per-test setUp to setUpBeforeClass
  (using self::$user_id). Saves ~25 user inserts per run.

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

* Fix PHPCS warnings in PR2 changes

* More PHPCS fixes: docblocks, elseif, ignore comments

* Move phpcs:disable outside docblock so it applies

* 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: PHPStan errors + stale baseline + preview test status codes

PHPStan code fixes:
- Add use WC_Order_Refund to RefundSchema so docblock @param resolves to the
  global WC_Order_Refund instead of the unknown Schema\WC_Order_Refund.
- Add use WC_Order to Controller and tighten preview_item's order check to
  instanceof WC_Order, so the WC_Order_Refund branch from wc_get_order is
  rejected with INVALID_ID instead of leaking into validate_preview_line_items
  / build_refund_preview (both expect WC_Order).
- Remove dead WC_Data_Exception and WC_REST_Exception catches around
  build_refund_preview; only InvalidArgumentException and Throwable can fire.
- Add a @phpstan-param annotation on RefundPreviewSchema::get_item_response to
  satisfy missingType.generics without breaking PHPCS (the latter rejects
  generics in @param).

PHPStan baseline cleanup:
- Remove 14 stale entries: 4 referencing the long-renamed Refunds\OrderSchema,
  10 referencing Refunds\Schema\WC_Order_Refund that became stale once the
  WC_Order_Refund use was added to RefundSchema.

Preview test status codes:
- validate_preview_line_items emits status: 422 for order_not_refundable /
  quantity_exceeds_refundable / unsupported_item_type and status: 404 for
  line_item_not_found, but five tests still asserted 400. Align them.
- test_preview_empty_line_items: the schema-level minItems check fires before
  the controller runs, so the response carries rest_invalid_param (not
  missing_line_items). Update the assertion and document why.

* 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 review findings closed:

(G) Endpoint-level grand-total guard. Even when per-line validation
passes, the aggregate preview total can still exceed the order's
remaining refundable amount — typical case: an amount-only partial
refund applied earlier doesn't consume any per-line quantity, so
per-line checks pass while the preview overstates remaining dollars.
After build_refund_preview returns, compare preview.total against
preview.max_refundable; return 422 preview_exceeds_max_refundable
when exceeded. abs() lets negative-fee scenarios compare correctly.

(H) @since 10.8.0 → @since 10.9.0 on:
- Controller::preview_item
- RefundPreviewSchema class
- RefundPreviewSchema::get_item_response (was missing)
- RefundPreviewSchema::get_item_schema_properties (was missing)
Per .ai/skills/woocommerce-backend-dev/code-entities.md, @since is
moved to the last line of each docblock.

(I) Remove the duplicate changelog file
65335-woomob-2684-refund-preview-endpoint — the unprefixed
woomob-2684-refund-preview-endpoint entry is the canonical one.

Test added:
test_preview_returns_422_when_total_exceeds_max_refundable pins the
new endpoint guard. 2 × $100 order with a $50 amount-only refund
applied → previewing qty 2 must return 422
preview_exceeds_max_refundable rather than a $200 total with
$150 max_refundable in the response body.

* 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>

* Use WP_Http constants in Refunds Controller

Replace the 422 literal in preview_item with WP_Http::UNPROCESSABLE_ENTITY,
and the 204 literal in delete_item with WP_Http::NO_CONTENT. Matches the
convention already used elsewhere in V4 routes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Rename refund preview totals to total + total_tax

Match the V4 Orders convention. Items, sections, and the grand total
block now expose `total` (excluding tax) and `total_tax` only. The
including-tax figure is consumer-computed as `total + total_tax`.

Controller's preview-exceeds-max-refundable check now compares the
inclusive sum (`total + total_tax`) against `max_refundable`, which is
still the order-side inclusive remaining amount.

Tests cover the new shape positively and assert the dropped `subtotal`
and `tax` keys are absent. The preview-to-create round trip in the
integration test now feeds `total + total_tax` into `POST /wc/v4/refunds`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Collapse product_id and variation_id on the preview product item schema

Drop the separate variation_id field. product_id now carries the
variation ID when present and the product ID otherwise, matching
OrderItemSchema.php:181 (V4 Orders convention). Description text matches
OrderItemSchema as "Product or variation ID.".

Integration test asserts variation_id is absent. New unit test exercises
the variation branch so product_id === variation_id is locked in.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Update negative-fee preview test to new total + total_tax shape

The test inherited from the helpers merge was written against the old
preview shape (subtotal + tax + including-tax total). Update assertions
to match the new shape: section total = -10.00 (excluding tax),
total_tax = -1.00. Item entries no longer carry subtotal or bare tax.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Remove duplicate slug-only changelog entry

The CI auto-added 65335-woomob-2684-refund-preview-endpoint with the
same content. Keep the PR-prefixed file.

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>

* Add partial-amount form to POST /wc/v4/refunds/preview

* Add changelog for partial-amount preview form

* Fix partial-refund validation gaps found in review

* Align preview schema, controller, and tests with subtotal/tax/total

Schema fields renamed to subtotal (ex-tax), tax, and total (tax-inclusive)
at item, section, and grand levels. Collapse variation_id into product_id
for variation line items. Update controller guard and test assertions to match.

* Update partial-amount preview tests to subtotal/tax/total field names

* Fix test isolation: guard wp_insert_user and wrap stub restore in finally

* Reject non-positive refund_total in preview with invalid_refund_total

A present-but-zero refund_total was treated as absent by
validate_preview_line_items (which then validated the quantity path)
while build_refund_preview used the explicit 0 via isset(), so a
request like {quantity: 2, refund_total: 0} returned a 200 preview
with a $0.00 total. Validation now rejects any present non-positive
refund_total with a dedicated invalid_refund_total error (400), and
build_refund_preview only takes the explicit value when positive.

Also aligns the get_public_preview_schema @since tag with the rest of
the preview code (10.8.0 -> 10.9.0).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Align v4 refund preview with create path and harden tax split

Split tax-inclusive amounts by the line's stored total/tax ratio in a shared
helper used by both build_refund_preview and the create converter, and normalize
caller-supplied refund_total to currency precision in both flows, so a previewed
amount matches the created refund to the cent (and charged tax is never dropped
on zero-rate or non-proportional lines).

Harden validation across both endpoints: reject duplicate line items, reject a
refund_total whose sign is wrong for the line, cap each line against its remaining
refundable amount, return 400 for bad line references on both paths, and reject an
order-level over-refund up front with a clear error. Clamp the tax split when a
line's stored total and tax nearly cancel.

Add tests for partial multi-tax-ID distribution, rounding parity, non-2-decimal
currencies, normalize_refund_totals, and the new guards.

* Refine changelog wording for partial-amount refund preview

* Align v4 refund create validation and errors with preview path

Make the create endpoint reject the same invalid inputs the preview
endpoint already rejects, with matching codes and HTTP status:

- Reject non-refundable order statuses (order_not_refundable, 422).
- Reject a refund_total that rounds to zero (invalid_refund_total).
- Return 422 with distinct codes for per-line over-refunds
  (refund_total_exceeds_line, line_item_already_refunded,
  refund_total_exceeds_remaining) instead of a flattened 400.
- Propagate the WP_Error status from validate_line_items.

Also cap explicit refund_tax against the remaining per-tax-id amount
by tracking already-refunded tax in compute_refunded_quantities_and_totals,
preventing sequential refunds from over-refunding a single tax bucket.

* Align v4 refund create errors with preview and add partial-amount tests

Give validate_line_items granular error codes and HTTP statuses (missing_line_item_id,
duplicate_line_item, line_item_not_found, unsupported_item_type,
missing_quantity_or_refund_total, quantity_exceeds_refundable, invalid_refund_total) so
create and preview reject the same input identically, reject a fully-refunded order up
front, and round both sides of the per-tax-id cap to currency precision. Refresh the
stale tax-split comment in the controller.

Add tests:
- preview/create breakdown parity for the explicit refund_total form (per-line net + tax)
- two sequential partial-amount refunds summing to the line total, then rejection
- direct unit coverage of split_inclusive_by_stored_ratio (rounding remainder, negative
  discount fee, zero-tax line, degenerate-ratio clamp)

Remove the duplicate 65439-woomob-2685 changelog entry; woomob-3176 covers it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Align v4 refund preview validation with create

validate_preview_line_items() was more lenient than validate_line_items(),
letting a preview return 200 for inputs create then rejects:

- Validate a supplied quantity even when refund_total is present, so
  {quantity: 2, refund_total: 1} on a 1-unit line is rejected, not previewed.
- Cap the quantity-derived amount against the remaining line amount for
  products too (not just shipping/fees), so a product with a prior amount-only
  refund can no longer preview an over-refund.
- Enforce refund_total sign parity: accept a negative refund_total on a
  discount-fee line, reject a positive one on a negative line, keep rejecting
  zero. build_refund_preview() now honors a non-zero explicit refund_total
  instead of falling through to the quantity path.

Add 5 DataUtils unit tests and 3 preview/create parity endpoint tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Make per-tax-id refund_tax cap sign-aware for negative tax buckets

validate_line_items() compared the remaining tax bucket with a signed `<`,
so for a discount fee with stored tax -1.00 a valid partial refund_tax of
-0.50 was rejected while an over-refund of -2.00 passed.

Cap on absolute magnitudes (stored bucket minus prior refunds, which are
already accumulated as positive magnitudes) and reject a refund_tax whose
sign is opposite the bucket, mirroring the refund_total caps.

Add 3 DataUtils tests: partial negative refund_tax accepted, negative
over-refund rejected, positive-on-negative wrong-sign rejected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Reject non-positive aggregate total in v4 refund preview

preview_item() only guarded abs(total) > max_refundable, so a preview of
only a negative discount line, or a product plus discount that nets to zero,
returned 200 while create_item() rejects it with invalid_refund_amount.

Add the same non-positive guard to preview (total <= 0 -> invalid_refund_amount),
plus two preview/create parity endpoint tests (negative-only and zero-net).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Cap gross line refund and document refund_total/refund_tax contract

refund_tax presence is the discriminator: without it, refund_total is
tax-inclusive and the server splits tax; with it, refund_total is the
tax-exclusive subtotal and the gross line refund is refund_total + refund_tax
(core Woo semantics). The schema and a controller comment wrongly called the
explicit-tax refund_total tax-inclusive.

- validate_line_items now caps the GROSS (refund_total + sum(refund_tax))
  against the line's tax-inclusive total, so a client cannot push the overage
  into refund_tax and over-refund the line. With no refund_tax the gross equals
  refund_total, so the inclusive form and preview parity are unchanged.
- Clarify the dual-mode contract in RefundSchema, the create_item comment,
  convert_line_items_to_internal_format, and calculate_refund_amount.

Add 2 DataUtils tests (gross over line rejected, gross within line accepted).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Correct v4 refund preview refund_total schema doc for signed amounts

The preview request schema described refund_total as "Must be greater than
zero" and the comment claimed negatives return invalid_refund_total, but
validate_preview_line_items() accepts a negative refund_total for a discount or
credit line (in a mixed refund) and rejects only zero and wrong-sign values.
The stale schema could steer clients away from valid mixed refunds.

Doc/comment only; behavior is unchanged and already covered by tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Address CodeRabbit review: quantity validation, schema docs, test hygiene

- validate_line_items: reject a negative or non-integer quantity supplied
  alongside refund_total (it would be stored verbatim as the refund line qty).
  An explicit 0 stays accepted as the dollars-only form, matching the existing
  loose-quantity contract; only negative/fractional values are rejected.
- RefundSchema: correct refund_total ("a value that rounds to 0 is rejected",
  not "0 is a zero refund") and refund_tax (auto-split uses the line's stored
  total/tax ratio, not the order's current tax rates).
- Fix a stale "explicit 0 is allowed" comment in validate_line_items.
- Fix a preview testdox that said "returns 400" for a 422 case.
- Reset woocommerce_calc_taxes/prices_include_tax in the controller test
  tearDown so a test that toggles them can't bleed into the suite.
- Wrap an expected-exception preview test in try/finally so the order is
  always cleaned up.

Add a data-provider test for the negative/non-integer quantity rejection.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Fix tax-only explicit refunds

* Remove stale PHPStan baseline entries

* Align refund preview amount-cap errors

* Restore refund auto-compute regression tests

* Fix refund CI failures

* Align v4 Orders can_be_refunded for fee/shipping with the refund validator

WOOPLUG-6829: OrderSchema computed fee/shipping can_be_refunded as
( get_total() + get_total_tax() - refunded ) > 0, omitting the abs() and
rounding the Refunds validator uses. So a discount (negative-total) fee
reported can_be_refunded=false even though the refund endpoints accept it,
and sub-cent float residue could report a fully-refunded line as refundable.

Compare on a tax-inclusive absolute basis and round to currency precision,
mirroring Refunds\DataUtils. Add a regression test for the negative-fee case;
existing fully-refunded/partial tests guard the rounding path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: woocommercebot <woocommercebot@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant