Commit e8eeff9cd11 for woocommerce
commit e8eeff9cd1119acb60b56b32d492b7860e33495b
Author: Samuel Urbanowicz <samiuelson@gmail.com>
Date: Fri Jun 19 09:59:31 2026 +0200
Add partial-amount form to POST /wc/v4/refunds/preview (#65645)
* 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>
diff --git a/plugins/woocommerce/changelog/woomob-3176-partial-refunds-by-amount b/plugins/woocommerce/changelog/woomob-3176-partial-refunds-by-amount
new file mode 100644
index 00000000000..ec21f37b633
--- /dev/null
+++ b/plugins/woocommerce/changelog/woomob-3176-partial-refunds-by-amount
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+POST /wc/v4/refunds/preview: accept refund_total per line item for partial-amount previews (quantity now optional when refund_total is provided). For the quantity and tax-inclusive refund_total forms, preview and create split tax by the line's stored total/tax ratio and round amounts identically, so a previewed amount matches the created refund to the cent.
diff --git a/plugins/woocommerce/changelog/wooplug-6829-can-be-refunded-fee-shipping-parity b/plugins/woocommerce/changelog/wooplug-6829-can-be-refunded-fee-shipping-parity
new file mode 100644
index 00000000000..82570ba745a
--- /dev/null
+++ b/plugins/woocommerce/changelog/wooplug-6829-can-be-refunded-fee-shipping-parity
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+v4 Orders API: align fee/shipping `can_be_refunded` with the refund validator — compare on a tax-inclusive absolute basis (so discount/negative fee lines report refundable) and round to currency precision (so a fully-refunded line is not reported refundable on a sub-cent float residue).
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index 742d0897472..831cbda12d2 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -66907,12 +66907,6 @@ parameters:
count: 1
path: src/Internal/RestApi/Routes/V4/Refunds/Controller.php
- -
- message: '#^Parameter \#1 \$error_code of method Automattic\\WooCommerce\\Internal\\RestApi\\Routes\\V4\\AbstractController\:\:get_route_error_response\(\) expects string, int\|string given\.$#'
- identifier: argument.type
- count: 1
- path: src/Internal/RestApi/Routes/V4/Refunds/Controller.php
-
-
message: '#^Parameter \#1 \$refund of method Automattic\\WooCommerce\\Internal\\RestApi\\Routes\\V4\\Refunds\\Controller\:\:is_valid_refund_for_request\(\) expects WC_Order_Refund, WC_Order\|WC_Order_Refund\|false given\.$#'
identifier: argument.type
@@ -66925,42 +66919,6 @@ parameters:
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
- count: 1
- path: src/Internal/RestApi/Routes/V4/Refunds/DataUtils.php
-
- -
- message: '#^Call to an undefined method WC_Order_Item\:\:get_label\(\)\.$#'
- identifier: method.notFound
- count: 1
- path: src/Internal/RestApi/Routes/V4/Refunds/DataUtils.php
-
- -
- message: '#^Call to an undefined method WC_Order_Item\:\:get_rate_id\(\)\.$#'
- identifier: method.notFound
- count: 1
- path: src/Internal/RestApi/Routes/V4/Refunds/DataUtils.php
-
- -
- message: '#^Call to an undefined method WC_Order_Item\:\:get_rate_percent\(\)\.$#'
- identifier: method.notFound
- count: 1
- path: src/Internal/RestApi/Routes/V4/Refunds/DataUtils.php
-
- -
- message: '#^Call to an undefined method WC_Order_Item\:\:get_taxes\(\)\.$#'
- identifier: method.notFound
- count: 1
- path: src/Internal/RestApi/Routes/V4/Refunds/DataUtils.php
-
- -
- message: '#^Call to an undefined method WC_Order_Item\:\:is_compound\(\)\.$#'
- identifier: method.notFound
- count: 1
- path: src/Internal/RestApi/Routes/V4/Refunds/DataUtils.php
-
-
message: '#^Call to method get_id\(\) on an unknown class Automattic\\WooCommerce\\Internal\\RestApi\\Routes\\V4\\Refunds\\Schema\\WC_Order_Item\.$#'
identifier: class.notFound
diff --git a/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Orders/Schema/OrderSchema.php b/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Orders/Schema/OrderSchema.php
index 0ae3cc336ec..e196518b23f 100644
--- a/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Orders/Schema/OrderSchema.php
+++ b/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Orders/Schema/OrderSchema.php
@@ -16,6 +16,7 @@ use Automattic\WooCommerce\Enums\OrderItemType;
use Automattic\WooCommerce\Enums\OrderStatus;
use Automattic\WooCommerce\Internal\CostOfGoodsSold\CogsAwareTrait;
use Automattic\WooCommerce\Internal\RestApi\Routes\V4\Refunds\DataUtils;
+use Automattic\WooCommerce\Utilities\NumberUtil;
use Automattic\WooCommerce\Utilities\OrderUtil;
use WC_Order;
use WP_REST_Request;
@@ -708,9 +709,13 @@ class OrderSchema extends AbstractSchema {
$data['shipping_lines'] = array();
foreach ( $shipping_lines as $shipping_line ) {
$item_data = $this->order_shipping_schema->get_item_response( $shipping_line, $request );
- $refunded = $refund_data['totals'][ $shipping_line->get_id() ] ?? 0.0;
+ $refunded = abs( (float) ( $refund_data['totals'][ $shipping_line->get_id() ] ?? 0.0 ) );
- $item_data['can_be_refunded'] = ( (float) $shipping_line->get_total() + (float) $shipping_line->get_total_tax() - $refunded ) > 0;
+ // Mirror Refunds\DataUtils validation: compare on a tax-inclusive, absolute basis
+ // (so discount/negative lines are handled) and round to currency precision (so a
+ // fully-refunded line is not reported refundable on a sub-cent float residue).
+ $remaining = abs( (float) $shipping_line->get_total() + (float) $shipping_line->get_total_tax() ) - $refunded;
+ $item_data['can_be_refunded'] = NumberUtil::round( $remaining, wc_get_price_decimals() ) > 0;
$data['shipping_lines'][] = $item_data;
}
@@ -734,9 +739,13 @@ class OrderSchema extends AbstractSchema {
$data['fee_lines'] = array();
foreach ( $fee_lines as $fee_line ) {
$item_data = $this->order_fee_schema->get_item_response( $fee_line, $request );
- $refunded = $refund_data['totals'][ $fee_line->get_id() ] ?? 0.0;
+ $refunded = abs( (float) ( $refund_data['totals'][ $fee_line->get_id() ] ?? 0.0 ) );
- $item_data['can_be_refunded'] = ( (float) $fee_line->get_total() + (float) $fee_line->get_total_tax() - $refunded ) > 0;
+ // Mirror Refunds\DataUtils validation: compare on a tax-inclusive, absolute basis
+ // (so discount/negative fee lines are handled) and round to currency precision (so a
+ // fully-refunded line is not reported refundable on a sub-cent float residue).
+ $remaining = abs( (float) $fee_line->get_total() + (float) $fee_line->get_total_tax() ) - $refunded;
+ $item_data['can_be_refunded'] = NumberUtil::round( $remaining, wc_get_price_decimals() ) > 0;
$data['fee_lines'][] = $item_data;
}
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 d078c69e47d..0d879fc8def 100644
--- a/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Refunds/Controller.php
+++ b/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Refunds/Controller.php
@@ -195,7 +195,7 @@ class Controller extends AbstractController {
'validate_callback' => 'rest_validate_request_arg',
'items' => array(
'type' => 'object',
- 'required' => array( 'line_item_id', 'quantity' ),
+ 'required' => array( 'line_item_id' ),
'additionalProperties' => false,
'properties' => array(
'line_item_id' => array(
@@ -204,10 +204,21 @@ class Controller extends AbstractController {
'minimum' => 1,
),
'quantity' => array(
- 'description' => __( 'Quantity to refund.', 'woocommerce' ),
+ 'description' => __( 'Quantity to refund. Required when refund_total is omitted.', 'woocommerce' ),
'type' => 'integer',
'minimum' => 1,
),
+ 'refund_total' => array(
+ // No `minimum` here on purpose: validate_preview_line_items() owns
+ // the sign rule and returns the actionable `invalid_refund_total`
+ // code. A refund_total must be non-zero and match the line's sign —
+ // negative is valid for a discount/credit line, positive for a normal
+ // line; zero and wrong-sign values are rejected. A schema `minimum`
+ // would wrongly forbid the negative form, and a generic
+ // `rest_invalid_param` is less useful to clients.
+ 'description' => __( 'Tax-inclusive amount to refund for this line item. Must be non-zero and match the line\'s sign (negative for discount or credit lines, positive otherwise). Required when quantity is omitted.', 'woocommerce' ),
+ 'type' => array( 'number', 'null' ),
+ ),
),
),
),
@@ -391,14 +402,22 @@ class Controller extends AbstractController {
$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() );
+ // Preserve any status carried on the WP_Error so create and preview
+ // return the same HTTP code for the same invalid input (e.g. 422 for
+ // over-refund / non-refundable order). Falls back to 400 otherwise.
+ $error_data = $validation_error->get_error_data();
+ $status = is_array( $error_data ) && isset( $error_data['status'] ) ? (int) $error_data['status'] : WP_Http::BAD_REQUEST;
+ return $this->get_route_error_response_from_object( $validation_error, $status );
}
- // 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.
+ // Convert line items to internal format. refund_total is tax-inclusive when no
+ // explicit refund_tax is supplied (auto-computed values, or client values) — the
+ // converter splits the tax portion out via the line's stored total/tax ratio
+ // (DataUtils::split_inclusive_by_stored_ratio(), the same method the preview uses).
+ // When the client supplies an explicit refund_tax breakdown, refund_total is the
+ // tax-exclusive subtotal and the tax is added on top (core Woo semantics). Either
+ // way calculate_refund_amount sums refund_total + refund_tax to the gross line
+ // amount, so mixing the two forms across line items is 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;
@@ -421,6 +440,24 @@ class Controller extends AbstractController {
);
}
+ // Over-refunding line items is allowed (goodwill), but the amount can never
+ // exceed the order's remaining refundable amount. Reject up-front with a clear
+ // 422 rather than relying on wc_create_refund's generic failure, mirroring the
+ // preview endpoint's preview_exceeds_max_refundable guard.
+ $remaining_refundable = (float) $order->get_remaining_refund_amount();
+ if ( NumberUtil::round( (float) $refund_amount, wc_get_price_decimals() ) > NumberUtil::round( $remaining_refundable, wc_get_price_decimals() ) ) {
+ return $this->get_route_error_response(
+ 'refund_exceeds_remaining',
+ sprintf(
+ /* translators: %1$s: requested refund amount, %2$s: remaining refundable amount */
+ __( 'Refund amount (%1$s) exceeds the remaining refundable amount (%2$s).', 'woocommerce' ),
+ wc_format_decimal( $refund_amount, wc_get_price_decimals() ),
+ wc_format_decimal( $remaining_refundable, wc_get_price_decimals() )
+ ),
+ WP_Http::UNPROCESSABLE_ENTITY
+ );
+ }
+
$refund = wc_create_refund(
array(
'order_id' => $order->get_id(),
@@ -486,7 +523,12 @@ class Controller extends AbstractController {
return $this->get_route_error_by_code( self::INVALID_ID );
}
- $validation_error = $this->data_utils->validate_preview_line_items( $request['line_items'], $order );
+ // Round caller-supplied refund_total values once, up front, so validation and
+ // the computed preview use the same precision the create flow stores. Reused
+ // for both validate and build below.
+ $line_items = $this->data_utils->normalize_refund_totals( $request['line_items'] );
+
+ $validation_error = $this->data_utils->validate_preview_line_items( $line_items, $order );
if ( is_wp_error( $validation_error ) ) {
$error_data = $validation_error->get_error_data();
@@ -495,7 +537,7 @@ class Controller extends AbstractController {
}
try {
- $preview = $this->data_utils->build_refund_preview( $order, $request['line_items'] );
+ $preview = $this->data_utils->build_refund_preview( $order, $line_items );
} catch ( \InvalidArgumentException $e ) {
// validate_preview_line_items above should have caught any bad input.
// If build_refund_preview still throws InvalidArgumentException, treat
@@ -522,6 +564,17 @@ class Controller extends AbstractController {
);
}
+ // Reject a non-positive aggregate total up front, mirroring create_item()'s
+ // `0 > $refund_amount || ! $refund_amount` guard. A refund of only a negative
+ // discount line, or a product plus discount that nets to zero, would otherwise
+ // preview successfully and then fail at create with 'invalid_refund_amount'.
+ if ( (float) $preview['total'] <= 0 ) {
+ return $this->get_route_error_response(
+ 'invalid_refund_amount',
+ __( 'Refund total must be greater than zero.', 'woocommerce' )
+ );
+ }
+
// Final guard: even when per-line validation passes, the aggregate
// preview total can still exceed the order's remaining refundable
// amount (e.g. an amount-only partial refund applied previously).
@@ -548,7 +601,7 @@ class Controller extends AbstractController {
/**
* Get the public schema for the preview endpoint.
*
- * @since 10.8.0
+ * @since 10.9.0
*
* @return array
*/
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 f99756265c7..5ada37e4219 100644
--- a/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Refunds/DataUtils.php
+++ b/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Refunds/DataUtils.php
@@ -9,14 +9,12 @@ namespace Automattic\WooCommerce\Internal\RestApi\Routes\V4\Refunds;
defined( 'ABSPATH' ) || exit;
-use Automattic\WooCommerce\Enums\OrderItemType;
use Automattic\WooCommerce\Enums\OrderStatus;
use Automattic\WooCommerce\Utilities\NumberUtil;
use WC_Order;
use WC_Order_Item_Fee;
use WC_Order_Item_Product;
use WC_Order_Item_Shipping;
-use WC_Tax;
use WP_Error;
use WP_Http;
@@ -72,52 +70,26 @@ class DataUtils {
continue;
}
- // 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.
+ // refund_tax presence is the discriminator for how refund_total is interpreted:
+ // when refund_tax is absent, refund_total is tax-inclusive and the tax portion is
+ // split out below; when refund_tax is present, refund_total is the tax-exclusive
+ // subtotal and is stored as-is, with the supplied taxes added on top.
+ //
+ // If no explicit refund_tax provided, extract tax from the tax-inclusive
+ // refund_total. Skip when refund_total is also missing — there's nothing
+ // to extract tax from. The split is by the line's own stored total/tax
+ // ratio via split_inclusive_by_stored_ratio(), the same method the preview
+ // uses, so the stored refund matches what build_refund_preview() showed.
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();
- // Keep any non-zero stored tax (positive or negative). Negative-tax
- // discount fees (e.g. a -$10 fee with -$1 stored tax) must retain
- // their tax breakdown so the create side matches the preview side
- // in build_refund_preview() — filtering on `> 0` previously dropped
- // them and emitted refund_total=$line_total / refund_tax=[].
- $tax_totals = array_filter(
- $original_taxes['total'] ?? array(),
- function ( $amount ) {
- return is_numeric( $amount ) && 0.0 !== (float) $amount;
- }
- );
- $tax_ids = array_keys( $tax_totals );
-
- if ( ! empty( $tax_ids ) ) {
- $tax_rates = $this->build_tax_rates_array( $order, $tax_ids );
-
- // Always assume refund_total includes tax - extract it using WC_Tax.
- $calculated_taxes = WC_Tax::calc_inclusive_tax(
- (float) $line_item['refund_total'],
- $tax_rates
- );
-
- // Round extracted taxes to display precision to match how original taxes were stored.
- // This prevents rounding errors where internal precision (6DP) differs from storage precision (2DP).
- $price_decimals = wc_get_price_decimals();
- $calculated_taxes = array_map(
- function ( $tax ) use ( $price_decimals ) {
- return NumberUtil::round( $tax, $price_decimals );
- },
- $calculated_taxes
- );
-
- $line_item['refund_tax'] = $this->convert_proportional_taxes_to_schema_format(
- $calculated_taxes
- );
-
- // Subtract extracted tax from refund_total to get the amount excluding tax.
- $total_tax = array_sum( $calculated_taxes );
- $line_item['refund_total'] = NumberUtil::round( $line_item['refund_total'] - $total_tax, $price_decimals );
+ if ( $original_item instanceof WC_Order_Item_Product || $original_item instanceof WC_Order_Item_Shipping || $original_item instanceof WC_Order_Item_Fee ) {
+ $split = $this->split_inclusive_by_stored_ratio( (float) $line_item['refund_total'], $original_item, wc_get_price_decimals() );
+
+ // Leave a tax-free line untouched: refund_total stays the full
+ // (tax-exclusive == tax-inclusive) amount and no refund_tax is set.
+ if ( ! empty( $split['taxes'] ) ) {
+ $line_item['refund_tax'] = $this->convert_proportional_taxes_to_schema_format( $split['taxes'] );
+ $line_item['refund_total'] = $split['subtotal'];
}
}
}
@@ -157,7 +129,12 @@ class DataUtils {
}
/**
- * Calculate the refund amount from line items.
+ * Calculate the gross refund amount from line items (schema format).
+ *
+ * Sums refund_total plus any explicit refund_tax. This yields the tax-inclusive gross
+ * for both forms: when refund_tax is omitted, refund_total is already tax-inclusive (and
+ * there is no refund_tax to add); when refund_tax is supplied, refund_total is the
+ * tax-exclusive subtotal and the taxes are added on top.
*
* @param array $line_items The line items to calculate the refund amount from.
* @return float|null The refund amount, or null if it can't be calculated.
@@ -170,9 +147,8 @@ class DataUtils {
$amount = 0;
foreach ( $line_items as $line_item ) {
- // 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.
+ // is_numeric() (not !empty) — an explicit refund_total of 0 can be part
+ // of a valid tax-only refund and must be included in the gross sum.
if ( isset( $line_item['refund_total'] ) && is_numeric( $line_item['refund_total'] ) ) {
$amount += $line_item['refund_total'];
}
@@ -197,26 +173,73 @@ class DataUtils {
* @return boolean|WP_Error
*/
public function validate_line_items( $line_items, WC_Order $order ) {
+ // Reject non-refundable order statuses up front, mirroring the preview path
+ // so create and preview agree on which orders accept refunds.
+ if ( ! in_array( $order->get_status(), self::REFUNDABLE_STATUSES, true ) ) {
+ return new WP_Error(
+ 'order_not_refundable',
+ __( 'This order cannot be refunded.', 'woocommerce' ),
+ array( 'status' => WP_Http::UNPROCESSABLE_ENTITY )
+ );
+ }
+
+ // Reject a fully-refunded order up front with the same code/status the
+ // preview path returns, so a fully-refunded order is rejected identically
+ // by both endpoints rather than via the controller's later
+ // refund_exceeds_remaining guard.
+ if ( (float) $order->get_remaining_refund_amount() <= 0 ) {
+ return new WP_Error(
+ 'order_not_refundable',
+ __( 'This order has already been fully refunded.', 'woocommerce' ),
+ array( 'status' => WP_Http::UNPROCESSABLE_ENTITY )
+ );
+ }
+
// 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 );
+ $seen_ids = array();
foreach ( $line_items as $line_item ) {
$line_item_id = $line_item['line_item_id'] ?? null;
if ( ! $line_item_id ) {
- return new WP_Error( 'invalid_line_item', __( 'Line item ID is required.', 'woocommerce' ) );
+ return new WP_Error(
+ 'missing_line_item_id',
+ __( 'Line item ID is required.', 'woocommerce' ),
+ array( 'status' => WP_Http::BAD_REQUEST )
+ );
}
+ // Reject duplicate line items: each is validated against the same remaining
+ // snapshot, so repeating an ID would let the per-line cap pass twice for the
+ // same line. Callers must combine a line into a single entry.
+ if ( isset( $seen_ids[ $line_item_id ] ) ) {
+ return new WP_Error(
+ 'duplicate_line_item',
+ __( 'Each line item may appear only once per request.', 'woocommerce' ),
+ array( 'status' => WP_Http::BAD_REQUEST )
+ );
+ }
+ $seen_ids[ $line_item_id ] = true;
+
$item = $order->get_item( $line_item_id );
// Validate item exists and belongs to the order.
if ( ! $item || $item->get_order_id() !== $order->get_id() ) {
- return new WP_Error( 'invalid_line_item', __( 'Line item not found.', 'woocommerce' ) );
+ return new WP_Error(
+ 'line_item_not_found',
+ __( 'Line item not found.', 'woocommerce' ),
+ array( 'status' => WP_Http::BAD_REQUEST )
+ );
}
if ( ! $item instanceof \WC_Order_Item_Product && ! $item instanceof \WC_Order_Item_Fee && ! $item instanceof \WC_Order_Item_Shipping ) {
- return new WP_Error( 'invalid_line_item', __( 'Line item is not a product, fee, or shipping line.', 'woocommerce' ) );
+ return new WP_Error(
+ 'unsupported_item_type',
+ __( 'Line item is not a product, fee, or shipping line.', 'woocommerce' ),
+ array( 'status' => WP_Http::BAD_REQUEST )
+ );
}
// Quantity is required only when the client omits refund_total — the
@@ -242,8 +265,22 @@ class DataUtils {
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' )
+ 'missing_quantity_or_refund_total',
+ __( 'Line item quantity must be a positive integer when refund_total is omitted.', 'woocommerce' ),
+ array( 'status' => WP_Http::BAD_REQUEST )
+ );
+ }
+
+ // When refund_total is provided, a supplied quantity is informational, but it must
+ // still be a non-negative integer so it round-trips cleanly onto the refund line —
+ // a negative or fractional value would be stored verbatim as the line qty. 0 (or an
+ // omitted quantity) means "dollars only". This mirrors the integer/range checks the
+ // preview path applies before branching on item type.
+ if ( ! $refund_total_missing && isset( $line_item['quantity'] ) && ( ! is_int( $line_item['quantity'] ) || $line_item['quantity'] < 0 ) ) {
+ return new WP_Error(
+ 'invalid_quantity',
+ __( 'Line item quantity must be a non-negative integer.', 'woocommerce' ),
+ array( 'status' => WP_Http::BAD_REQUEST )
);
}
@@ -274,63 +311,114 @@ class DataUtils {
$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',
+ 'quantity_exceeds_refundable',
sprintf(
/* translators: %d: remaining refundable quantity */
__( 'Line item quantity cannot be greater than the remaining refundable quantity (%d).', 'woocommerce' ),
$remaining_qty
- )
+ ),
+ array( 'status' => WP_Http::UNPROCESSABLE_ENTITY )
);
}
- } 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() ) );
+ } elseif ( isset( $line_item['quantity'] ) && $line_item['quantity'] > 1 ) {
+ return new WP_Error(
+ 'invalid_quantity',
+ __( 'Shipping and fee line items must be refunded with quantity of 1.', 'woocommerce' ),
+ array( 'status' => WP_Http::BAD_REQUEST )
+ );
+ }
+
+ // Validate refund total against the remaining refundable amount for this
+ // line (including tax), subtracting any prior partial refunds. Rounds both
+ // sides to currency precision and uses abs() so the cap matches
+ // validate_preview_line_items() exactly — a previewed amount that is
+ // accepted (or rejected) there behaves the same way here.
+ if ( isset( $line_item['refund_total'] ) ) {
+ $price_decimals = wc_get_price_decimals();
+ $signed_line_total = (float) $item->get_total() + (float) $item->get_total_tax();
+
+ // Reject a refund_total whose sign is opposite the line: you cannot refund
+ // a positive amount from a discount line, or a negative amount from a normal
+ // line. Without this, abs() in the cap below would let a wrong-sign value
+ // pass and be stored (e.g. a negative refund_total on a positive line in a
+ // mixed-line request whose total stays positive). A gross line refund that
+ // rounds to 0 is rejected below, so create and preview stay aligned for the
+ // tax-inclusive form while explicit tax-only create requests remain valid.
+ if ( (float) $line_item['refund_total'] * $signed_line_total < 0 ) {
+ return new WP_Error(
+ 'invalid_refund_total',
+ __( 'Refund total has the wrong sign for this line item.', 'woocommerce' ),
+ array( 'status' => WP_Http::BAD_REQUEST )
+ );
}
- $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'] ) );
+ // Cap and zero-check the GROSS line refund against the line's tax-inclusive
+ // total. When an explicit refund_tax breakdown is supplied, refund_total is
+ // the tax-exclusive (net) subtotal and the tax is added on top (core Woo
+ // semantics — see RefundSchema); without it, refund_total is already
+ // tax-inclusive, so the gross equals refund_total. Capping the net alone
+ // would let a client push the overage into refund_tax and over-refund the
+ // line. Preview has no refund_tax field, so its (refund_total-only) cap stays
+ // equivalent for the inclusive form.
+ $line_refund_gross = (float) $line_item['refund_total'];
+ if ( ! empty( $line_item['refund_tax'] ) && is_array( $line_item['refund_tax'] ) ) {
+ foreach ( $line_item['refund_tax'] as $tax ) {
+ $line_refund_gross += (float) ( $tax['refund_total'] ?? 0 );
+ }
+ }
- if ( $remaining_total <= 0 ) {
+ // Reject a gross line refund that rounds to zero. A zero line refund is a
+ // no-op that would otherwise be stored as an empty qty:0 refund line.
+ if ( 0.0 === (float) NumberUtil::round( $line_refund_gross, $price_decimals ) ) {
return new WP_Error(
- 'invalid_line_item',
- __( 'This line item has already been fully refunded.', 'woocommerce' )
+ 'invalid_refund_total',
+ __( 'refund_total must be a number greater than zero.', 'woocommerce' ),
+ array( 'status' => WP_Http::BAD_REQUEST )
);
}
- if ( $requested_total > NumberUtil::round( $remaining_total, $price_decimals ) ) {
+ $item_total_with_tax = abs( $signed_line_total );
+ $abs_refund_total = abs( $line_refund_gross );
+
+ // Mirror the preview path's three distinct over-refund errors (same
+ // codes, messages, and 422 status) so create and preview reject the
+ // same input identically. An over-refund is a well-formed but
+ // unprocessable request, so 422 — not 400 — is the correct status,
+ // matching the order-level cap the controller already returns.
+ if ( $abs_refund_total > NumberUtil::round( $item_total_with_tax, $price_decimals ) ) {
return new WP_Error(
- 'invalid_line_item',
+ 'refund_total_exceeds_line',
+ sprintf(
+ /* translators: %s: line item total including tax */
+ __( 'refund_total cannot exceed the line item total including tax (%s).', 'woocommerce' ),
+ wc_format_decimal( $item_total_with_tax, $price_decimals )
+ ),
+ array( 'status' => WP_Http::UNPROCESSABLE_ENTITY )
+ );
+ }
+
+ $refunded_total = abs( (float) ( $refund_data['totals'][ $line_item_id ] ?? 0.0 ) );
+ $remaining_total = $item_total_with_tax - $refunded_total;
+ if ( $remaining_total <= 0 ) {
+ return new WP_Error(
+ 'line_item_already_refunded',
+ __( 'This line item has already been fully refunded.', 'woocommerce' ),
+ array( 'status' => WP_Http::UNPROCESSABLE_ENTITY )
+ );
+ }
+ if ( $abs_refund_total > NumberUtil::round( $remaining_total, $price_decimals ) ) {
+ return new WP_Error(
+ 'refund_total_exceeds_remaining',
sprintf(
/* translators: %s: remaining refundable amount */
- __( 'Line item refund total cannot be greater than the remaining refundable amount (%s).', 'woocommerce' ),
+ __( 'refund_total cannot exceed the remaining refundable amount for this line item (%s).', 'woocommerce' ),
wc_format_decimal( $remaining_total, $price_decimals )
- )
+ ),
+ array( 'status' => WP_Http::UNPROCESSABLE_ENTITY )
);
}
}
- // 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 ( 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(
- /* translators: %s: item total with tax */
- __( 'Refund total cannot be greater than the line item total including tax (%s).', 'woocommerce' ),
- $item_total_with_tax
- )
- );
- }
-
if ( isset( $line_item['refund_tax'] ) ) {
$item_taxes = $item->get_taxes();
@@ -355,13 +443,42 @@ class DataUtils {
);
}
- if ( $item_taxes['total'][ $tax_id ] < $tax_refund_total ) {
+ $price_decimals = wc_get_price_decimals();
+ $stored_tax = (float) $item_taxes['total'][ $tax_id ];
+ $requested_tax = (float) $tax_refund_total;
+
+ // Reject a refund_tax whose sign is opposite the stored tax bucket: you
+ // cannot refund a positive tax from a negative (discount) bucket or vice
+ // versa. Mirrors the refund_total wrong-sign guard. Compare on absolute
+ // magnitudes below so a negative bucket is capped the same way a positive
+ // one is — a signed `<` admits an over-refund of a negative bucket and
+ // rejects a valid partial one. An explicit 0 is allowed (a no-op).
+ if ( $requested_tax * $stored_tax < 0 ) {
+ return new WP_Error(
+ 'invalid_refund_amount',
+ __( 'Refund tax total has the wrong sign for this line item.', 'woocommerce' ),
+ array( 'status' => WP_Http::BAD_REQUEST )
+ );
+ }
+
+ // Cap against the remaining tax for this bucket, subtracting any tax
+ // already refunded for this tax id on prior refunds — not the original
+ // line tax — so sequential refunds cannot over-refund a single bucket.
+ // $already_refunded_tax is accumulated as a positive magnitude
+ // (compute_refunded_quantities_and_totals() uses abs()), so compare it
+ // against the stored bucket's magnitude. Round both sides to currency
+ // precision: the accumulator is built from repeated float additions, so
+ // an unrounded compare could reject or admit an exactly-correct amount by
+ // a sub-cent residue.
+ $already_refunded_tax = (float) ( $refund_data['tax_totals'][ $line_item_id ][ $tax_id ] ?? 0.0 );
+ $remaining_tax = abs( $stored_tax ) - $already_refunded_tax;
+ if ( abs( $requested_tax ) > NumberUtil::round( $remaining_tax, $price_decimals ) ) {
return new WP_Error(
'invalid_refund_amount',
sprintf(
- /* translators: %s: tax total */
- __( 'Refund tax total cannot be greater than the line item tax total (%s).', 'woocommerce' ),
- $item_taxes['total'][ $tax_id ]
+ /* translators: %s: remaining refundable tax total */
+ __( 'Refund tax total cannot be greater than the remaining refundable tax for this line item (%s).', 'woocommerce' ),
+ wc_format_decimal( $remaining_tax, $price_decimals )
)
);
}
@@ -393,32 +510,99 @@ class DataUtils {
}
/**
- * Build tax rate array from order tax items for use with WC_Tax calculations.
+ * Split a tax-inclusive amount into net subtotal and per-tax-ID tax amounts using
+ * the line item's own stored total/tax ratio.
*
- * @param WC_Order $order The order.
- * @param array $tax_ids Array of tax rate IDs that apply to an item.
- * @return array Tax rates array formatted for WC_Tax::calc_*_tax() methods.
+ * Splitting by the line's actual stored proportion — rather than re-deriving tax
+ * from the order tax item's rate percent — returns exactly what was charged. It
+ * stays correct when the stored tax is not an exact rate% of net (manually edited
+ * tax, or a rate that changed after the order) and when a taxed line's rate
+ * resolves to zero. Preview ({@see build_refund_preview()}) and create
+ * ({@see convert_line_items_to_internal_format()}) share this method so a previewed
+ * split always matches the split stored on the created refund.
+ *
+ * Per-ID amounts are rounded and the subtotal is derived as amount - sum(tax), so
+ * the invariant subtotal + total_tax == amount holds exactly at $dp precision.
+ *
+ * @param float $amount Tax-inclusive amount to split. Rounded to $dp before splitting.
+ * @param WC_Order_Item_Product|WC_Order_Item_Shipping|WC_Order_Item_Fee $item Order item supplying the stored total/tax ratio.
+ * @param int $dp Price decimal places.
+ * @return array{subtotal: float, total_tax: float, taxes: array<int, float>} Net subtotal, summed tax, and per-tax-ID amounts.
*
* @since 10.9.0
*/
- protected function build_tax_rates_array( WC_Order $order, array $tax_ids ): array {
- $tax_rates = array();
- $tax_items = $order->get_items( OrderItemType::TAX );
-
- foreach ( $tax_ids as $tax_id ) {
- foreach ( $tax_items as $tax_item ) {
- if ( $tax_item->get_rate_id() === (int) $tax_id ) {
- $tax_rates[ $tax_id ] = array(
- 'rate' => $tax_item->get_rate_percent(),
- 'label' => $tax_item->get_label(),
- 'compound' => $tax_item->is_compound() ? 'yes' : 'no',
- );
- break;
- }
+ protected function split_inclusive_by_stored_ratio( float $amount, $item, int $dp ): array {
+ $amount = NumberUtil::round( $amount, $dp );
+ $stored_total = (float) $item->get_total();
+
+ // Keep only non-zero numeric stored taxes (positive or negative). A negative-tax
+ // discount fee must retain its breakdown; a zero entry contributes nothing.
+ $stored_taxes = array_filter(
+ $item->get_taxes()['total'] ?? array(),
+ function ( $t ) {
+ return is_numeric( $t ) && 0.0 !== (float) $t;
}
+ );
+
+ $stored_tax_total = array_sum( array_map( 'floatval', $stored_taxes ) );
+ $stored_with_tax = $stored_total + $stored_tax_total;
+
+ // Fallback used whenever the stored data can't yield a sane proportional split:
+ // treat the whole amount as net (no tax) and log for observability.
+ $unsplittable = function ( string $reason ) use ( $amount, $item ) {
+ wc_get_logger()->warning(
+ sprintf(
+ 'Refund tax split: cannot split tax for item %d on order %d (%s).',
+ (int) $item->get_id(),
+ (int) $item->get_order_id(),
+ $reason
+ ),
+ array( 'source' => 'wc-v4-refunds' )
+ );
+ return array(
+ 'subtotal' => $amount,
+ 'total_tax' => 0.0,
+ 'taxes' => array(),
+ );
+ };
+
+ // No tax on the line: the whole amount is net (not an error, no log).
+ if ( empty( $stored_taxes ) ) {
+ return array(
+ 'subtotal' => $amount,
+ 'total_tax' => 0.0,
+ 'taxes' => array(),
+ );
+ }
+
+ // A zero-value line (stored total nets to zero while a tax was charged) can't be
+ // split proportionally — avoid division by zero.
+ if ( 0.0 === (float) $stored_with_tax ) {
+ return $unsplittable( 'stored total incl. tax is zero' );
+ }
+
+ // Scale each stored tax by the share of the line being refunded.
+ $taxes = array();
+ foreach ( $stored_taxes as $tax_id => $stored_tax ) {
+ $taxes[ (int) $tax_id ] = NumberUtil::round( $amount * ( (float) $stored_tax / $stored_with_tax ), $dp );
+ }
+ $total_tax = NumberUtil::round( array_sum( $taxes ), $dp );
+
+ // Sanity clamp: the tax portion of a tax-inclusive amount can never exceed the
+ // amount itself. A larger value means the stored total/tax nearly cancel (e.g. a
+ // near-zero inclusive total from manually edited data), which would explode the
+ // ratio. Fall back rather than emit a nonsensical negative subtotal.
+ if ( abs( $total_tax ) > abs( $amount ) ) {
+ return $unsplittable( 'stored total and tax nearly cancel' );
}
- return $tax_rates;
+ $subtotal = NumberUtil::round( $amount - $total_tax, $dp );
+
+ return array(
+ 'subtotal' => $subtotal,
+ 'total_tax' => $total_tax,
+ 'taxes' => $taxes,
+ );
}
/**
@@ -462,17 +646,41 @@ class DataUtils {
return NumberUtil::round( (float) $item->get_total() + (float) $item->get_total_tax(), $price_decimals );
}
+ /**
+ * Round every caller-supplied refund_total to currency precision.
+ *
+ * Applied at the entry of both the preview and create flows so a value the client
+ * sends is validated, summed, split, and stored at the same precision. A previewed
+ * amount therefore always matches the created refund to the cent. A missing or null
+ * refund_total (the auto-compute form) is left untouched — those are computed later
+ * and already rounded by {@see compute_line_item_refund_total()}.
+ *
+ * @param array $line_items Line items in schema format.
+ * @return array Line items with numeric refund_total values rounded to wc_get_price_decimals().
+ *
+ * @since 10.9.0
+ */
+ public function normalize_refund_totals( array $line_items ): array {
+ $price_decimals = wc_get_price_decimals();
+ foreach ( $line_items as $key => $line_item ) {
+ if ( isset( $line_item['refund_total'] ) && is_numeric( $line_item['refund_total'] ) ) {
+ $line_items[ $key ]['refund_total'] = NumberUtil::round( (float) $line_item['refund_total'], $price_decimals );
+ }
+ }
+ return $line_items;
+ }
+
/**
* 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.
+ * untouched so validation can decide whether the explicit amount is valid.
+ * 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
@@ -487,11 +695,15 @@ class DataUtils {
* @since 10.9.0
*/
public function fill_missing_refund_totals( array $line_items, WC_Order $order ): array {
+ // Round caller-supplied amounts up front so explicit values are stored at the
+ // same precision the preview validated and showed. Computed values below are
+ // already rounded by compute_line_item_refund_total().
+ $line_items = $this->normalize_refund_totals( $line_items );
+
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).
+ // "compute it for me". An explicit `0` is caller-supplied input, so leave
+ // it untouched and let validation decide whether the gross line refund is valid.
if ( array_key_exists( 'refund_total', $line_item ) && null !== $line_item['refund_total'] ) {
continue;
}
@@ -539,8 +751,13 @@ class DataUtils {
* Callers must invoke {@see validate_preview_line_items()} first — this
* method assumes inputs have been validated and throws on missing items.
*
+ * Each line item must have 'line_item_id' and at least one of 'quantity'
+ * (positive int) or 'refund_total' (positive tax-inclusive float). When
+ * 'refund_total' is present and positive it is used directly; otherwise the
+ * total is computed from quantity via {@see compute_line_item_refund_total()}.
+ *
* @param WC_Order $order The order being previewed for refund.
- * @param array $line_items Array of line items with 'line_item_id' and 'quantity' keys.
+ * @param array $line_items Line items. Each: array{line_item_id: int, quantity?: int, refund_total?: float}.
* @return array The structured preview response.
* @throws \InvalidArgumentException When a line_item_id does not resolve to an item on the order.
*
@@ -582,50 +799,25 @@ class DataUtils {
*
* @var WC_Order_Item_Product|WC_Order_Item_Shipping|WC_Order_Item_Fee $item
*/
- $refund_total_with_tax = $this->compute_line_item_refund_total( $item, $line_item['quantity'] );
- $subtotal = $refund_total_with_tax;
- $tax = 0.0;
-
- $original_taxes = $item->get_taxes();
- // Keep any non-zero stored tax (positive or negative). Negative-tax
- // discount fees (e.g. a -$10 fee with -$1 stored tax) must retain
- // their tax breakdown — filtering on `> 0` previously dropped them
- // and emitted subtotal=$line_total / tax=0 instead of the correct
- // signed split.
- $tax_totals = array_filter(
- $original_taxes['total'] ?? array(),
- function ( $amount ) {
- return is_numeric( $amount ) && 0.0 !== (float) $amount;
- }
- );
-
- if ( ! empty( $original_taxes['total'] ?? array() ) && empty( $tax_totals ) ) {
- wc_get_logger()->warning(
- sprintf(
- 'Refund preview: tax totals filtered to empty for item %d on order %d (non-numeric or zero values).',
- (int) $line_item['line_item_id'],
- $order->get_id()
- ),
- array( 'source' => 'wc-v4-refunds' )
- );
- }
-
- if ( ! empty( $tax_totals ) ) {
- $tax_rates = $this->build_tax_rates_array( $order, array_keys( $tax_totals ) );
- $calculated_taxes = WC_Tax::calc_inclusive_tax( $refund_total_with_tax, $tax_rates );
- $calculated_taxes = array_map(
- function ( $t ) use ( $price_decimals ) {
- return NumberUtil::round( $t, $price_decimals );
- },
- $calculated_taxes
- );
- $tax = NumberUtil::round( array_sum( $calculated_taxes ), $price_decimals );
- $subtotal = NumberUtil::round( $refund_total_with_tax - $tax, $price_decimals );
- }
+ // When the caller provides an explicit refund_total (partial-amount form) use it
+ // directly. The quantity-based form computes the tax-inclusive total from unit price.
+ // A non-zero check (not > 0) mirrors validate_preview_line_items(), which accepts a
+ // negative refund_total for a negative discount line and rejects a present-but-zero
+ // one before this method runs — so a signed value is honoured rather than falling
+ // through to the (possibly absent) quantity.
+ $refund_total_with_tax = isset( $line_item['refund_total'] ) && is_numeric( $line_item['refund_total'] ) && 0.0 !== (float) $line_item['refund_total']
+ ? NumberUtil::round( (float) $line_item['refund_total'], $price_decimals )
+ : $this->compute_line_item_refund_total( $item, (int) $line_item['quantity'] );
+
+ // Split by the line's own stored total/tax ratio so the preview reflects what
+ // was actually charged and matches the split create stores (both call this).
+ $split = $this->split_inclusive_by_stored_ratio( $refund_total_with_tax, $item, $price_decimals );
+ $subtotal = $split['subtotal'];
+ $tax = $split['total_tax'];
$item_data = array(
'id' => $line_item['line_item_id'],
- 'quantity' => $line_item['quantity'],
+ 'quantity' => $line_item['quantity'] ?? null,
'subtotal' => wc_format_decimal( $subtotal, $price_decimals ),
'tax' => wc_format_decimal( $tax, $price_decimals ),
'total' => wc_format_decimal( $refund_total_with_tax, $price_decimals ),
@@ -711,6 +903,7 @@ class DataUtils {
$refund_data = $this->compute_refunded_quantities_and_totals( $order );
+ $seen_ids = array();
foreach ( $line_items as $line_item ) {
$line_item_id = $line_item['line_item_id'] ?? null;
if ( ! $line_item_id ) {
@@ -721,12 +914,27 @@ class DataUtils {
);
}
+ // Reject duplicate line items: each is validated against the same remaining
+ // snapshot, so repeating an ID would let the per-line cap pass twice for the
+ // same line and double-count it in the preview breakdown.
+ if ( isset( $seen_ids[ $line_item_id ] ) ) {
+ return new WP_Error(
+ 'duplicate_line_item',
+ __( 'Each line item may appear only once per request.', 'woocommerce' ),
+ array( 'status' => WP_Http::BAD_REQUEST )
+ );
+ }
+ $seen_ids[ $line_item_id ] = true;
+
+ // A bad line_item_id reference (not on the order, or an unsupported type) is a
+ // malformed request, returned as 400 to match the create endpoint's handling
+ // of the same conditions.
$item = $order->get_item( $line_item_id );
if ( ! $item || $item->get_order_id() !== $order->get_id() ) {
return new WP_Error(
'line_item_not_found',
__( 'Line item not found.', 'woocommerce' ),
- array( 'status' => WP_Http::NOT_FOUND )
+ array( 'status' => WP_Http::BAD_REQUEST )
);
}
@@ -734,70 +942,153 @@ class DataUtils {
return new WP_Error(
'unsupported_item_type',
__( 'Line item is not a product, fee, or shipping line.', 'woocommerce' ),
- array( 'status' => WP_Http::UNPROCESSABLE_ENTITY )
+ array( 'status' => WP_Http::BAD_REQUEST )
);
}
- if ( ! isset( $line_item['quantity'] ) || ! is_int( $line_item['quantity'] ) || $line_item['quantity'] < 1 ) {
+ // A present refund_total may be negative (a discount/credit line) but must be a
+ // non-zero number with the same sign as the line — validated below, mirroring
+ // validate_line_items() so create and preview accept and reject the same input.
+ // A null refund_total means "use the quantity form" (isset() is false for null).
+ $has_refund_total = isset( $line_item['refund_total'] );
+ if ( $has_refund_total && ! is_numeric( $line_item['refund_total'] ) ) {
return new WP_Error(
- 'invalid_quantity',
- __( 'Quantity must be a positive integer.', 'woocommerce' ),
+ 'invalid_refund_total',
+ __( 'refund_total must be a number greater than zero.', 'woocommerce' ),
array( 'status' => WP_Http::BAD_REQUEST )
);
}
- $quantity = $line_item['quantity'];
- if ( $item instanceof WC_Order_Item_Product ) {
- $remaining_qty = $item->get_quantity() + ( $refund_data['qtys'][ $line_item_id ] ?? 0 );
- if ( $quantity > $remaining_qty ) {
+ $has_quantity = isset( $line_item['quantity'] ) && is_int( $line_item['quantity'] ) && $line_item['quantity'] >= 1;
+
+ if ( ! $has_quantity && ! $has_refund_total ) {
+ return new WP_Error(
+ 'missing_quantity_or_refund_total',
+ __( 'Either a positive integer quantity or a numeric refund_total is required.', 'woocommerce' ),
+ array( 'status' => WP_Http::BAD_REQUEST )
+ );
+ }
+
+ $price_decimals = wc_get_price_decimals();
+ $signed_line_total = (float) $item->get_total() + (float) $item->get_total_tax();
+
+ // Validate an explicit refund_total. Mirrors validate_line_items() exactly (sign,
+ // zero, and the three over-refund caps) so a previewed amount that is accepted or
+ // rejected here behaves identically at create.
+ if ( $has_refund_total ) {
+ $refund_total = (float) $line_item['refund_total'];
+
+ // Reject a refund_total whose sign is opposite the line: you cannot refund a
+ // positive amount from a discount line, or a negative amount from a normal line.
+ if ( $refund_total * $signed_line_total < 0 ) {
return new WP_Error(
- 'quantity_exceeds_refundable',
+ 'invalid_refund_total',
+ __( 'Refund total has the wrong sign for this line item.', 'woocommerce' ),
+ array( 'status' => WP_Http::BAD_REQUEST )
+ );
+ }
+
+ // Reject a refund_total that rounds to zero — a no-op the create path also rejects.
+ if ( 0.0 === (float) NumberUtil::round( $refund_total, $price_decimals ) ) {
+ return new WP_Error(
+ 'invalid_refund_total',
+ __( 'refund_total must be a number greater than zero.', 'woocommerce' ),
+ array( 'status' => WP_Http::BAD_REQUEST )
+ );
+ }
+
+ $item_total_with_tax = abs( $signed_line_total );
+ $abs_refund_total = abs( $refund_total );
+ if ( $abs_refund_total > NumberUtil::round( $item_total_with_tax, $price_decimals ) ) {
+ return new WP_Error(
+ 'refund_total_exceeds_line',
sprintf(
- /* translators: %d: remaining refundable quantity */
- __( 'Requested quantity exceeds remaining refundable quantity (%d).', 'woocommerce' ),
- $remaining_qty
+ /* translators: %s: line item total including tax */
+ __( 'refund_total cannot exceed the line item total including tax (%s).', 'woocommerce' ),
+ wc_format_decimal( $item_total_with_tax, $price_decimals )
+ ),
+ array( 'status' => WP_Http::UNPROCESSABLE_ENTITY )
+ );
+ }
+
+ // Cap against the remaining refundable amount for this line.
+ // compute_refunded_quantities_and_totals() tracks tax-inclusive totals
+ // for all item types so the comparison is consistent.
+ $refunded_total = abs( (float) ( $refund_data['totals'][ $line_item_id ] ?? 0.0 ) );
+ $remaining_total = $item_total_with_tax - $refunded_total;
+ if ( $remaining_total <= 0 ) {
+ return new WP_Error(
+ 'line_item_already_refunded',
+ __( 'This line item has already been fully refunded.', 'woocommerce' ),
+ array( 'status' => WP_Http::UNPROCESSABLE_ENTITY )
+ );
+ }
+ if ( $abs_refund_total > NumberUtil::round( $remaining_total, $price_decimals ) ) {
+ return new WP_Error(
+ 'refund_total_exceeds_remaining',
+ sprintf(
+ /* translators: %s: remaining refundable amount */
+ __( 'refund_total cannot exceed the remaining refundable amount for this line item (%s).', 'woocommerce' ),
+ wc_format_decimal( $remaining_total, $price_decimals )
),
array( 'status' => WP_Http::UNPROCESSABLE_ENTITY )
);
}
}
- if ( $item instanceof WC_Order_Item_Shipping || $item instanceof WC_Order_Item_Fee ) {
- if ( 1 !== $quantity ) {
+ // Validate a supplied quantity whenever present — even alongside refund_total —
+ // matching validate_line_items(). Skipping this when refund_total was given let a
+ // preview accept a quantity the create path then rejects.
+ if ( $has_quantity ) {
+ $quantity = $line_item['quantity'];
+
+ if ( $item instanceof WC_Order_Item_Product ) {
+ $remaining_qty = $item->get_quantity() + ( $refund_data['qtys'][ $line_item_id ] ?? 0 );
+ if ( $quantity > $remaining_qty ) {
+ return new WP_Error(
+ 'quantity_exceeds_refundable',
+ sprintf(
+ /* translators: %d: remaining refundable quantity */
+ __( 'Requested quantity exceeds remaining refundable quantity (%d).', 'woocommerce' ),
+ $remaining_qty
+ ),
+ array( 'status' => WP_Http::UNPROCESSABLE_ENTITY )
+ );
+ }
+ } elseif ( 1 !== $quantity ) {
+ // Shipping and fee lines carry a single refundable unit.
return new WP_Error(
'invalid_quantity',
__( 'Shipping and fee line items must be refunded with quantity of 1.', 'woocommerce' ),
array( 'status' => WP_Http::BAD_REQUEST )
);
}
+ }
- // Compare on a tax-inclusive basis: compute_line_item_refund_total() (and
- // therefore $requested_total below) already includes tax, and
- // compute_refunded_quantities_and_totals() also returns tax-inclusive
- // fee/shipping totals.
+ // Amount-from-quantity cap: when the amount is derived from quantity (no explicit
+ // refund_total), cap the computed tax-inclusive amount against the remaining line
+ // amount for every item type. Mirrors create, which auto-fills refund_total from
+ // quantity and then applies the same cap — so a product with prior amount-only
+ // refunds (units still uncounted) can no longer preview an over-refund.
+ if ( $has_quantity && ! $has_refund_total ) {
$refunded_total = abs( (float) ( $refund_data['totals'][ $line_item_id ] ?? 0.0 ) );
- $remaining_total = abs( (float) $item->get_total() + (float) $item->get_total_tax() ) - $refunded_total;
+ $remaining_total = abs( $signed_line_total ) - $refunded_total;
if ( $remaining_total <= 0 ) {
return new WP_Error(
- 'quantity_exceeds_refundable',
+ 'line_item_already_refunded',
__( 'This line item has already been fully refunded.', 'woocommerce' ),
array( 'status' => WP_Http::UNPROCESSABLE_ENTITY )
);
}
- // Cap against the line's remaining refundable amount. The preview
- // shape only takes quantity, so a fee/shipping line that's been
- // partially refunded cannot be previewed again at the full original
- // total — the request would over-refund and the eventual create
- // call would fail. Reject up-front with a clear error.
- $requested_total = abs( $this->compute_line_item_refund_total( $item, $quantity ) );
- if ( $requested_total > NumberUtil::round( $remaining_total, wc_get_price_decimals() ) ) {
+ $requested_total = abs( $this->compute_line_item_refund_total( $item, $line_item['quantity'] ) );
+ if ( $requested_total > NumberUtil::round( $remaining_total, $price_decimals ) ) {
return new WP_Error(
- 'quantity_exceeds_refundable',
+ 'refund_total_exceeds_remaining',
sprintf(
/* translators: %s: remaining refundable amount */
- __( 'Requested refund exceeds the remaining refundable amount for this line item (%s).', 'woocommerce' ),
- wc_format_decimal( $remaining_total, wc_get_price_decimals() )
+ __( 'refund_total cannot exceed the remaining refundable amount for this line item (%s).', 'woocommerce' ),
+ wc_format_decimal( $remaining_total, $price_decimals )
),
array( 'status' => WP_Http::UNPROCESSABLE_ENTITY )
);
@@ -816,11 +1107,22 @@ class DataUtils {
* tax-inclusive so they can be compared directly against {@see compute_line_item_refund_total()}.
*
* @param WC_Order $order Order instance.
- * @return array{qtys: array<int, int>, totals: array<int, float>}
+ * @return array{qtys: array<int, int>, totals: array<int, float>, tax_totals: array<int, array<int, float>>}
*/
public function compute_refunded_quantities_and_totals( WC_Order $order ): array {
- $qtys = array();
- $totals = array();
+ $qtys = array();
+ $totals = array();
+ $tax_totals = array();
+
+ // Accumulate the already-refunded tax per original item, keyed by tax rate
+ // id, as a positive amount. Refund line items store taxes as negatives, so
+ // flip the sign. Lets the per-tax-id cap subtract prior refunds.
+ $add_refunded_taxes = function ( $refunded_item, int $original_id ) use ( &$tax_totals ) {
+ $taxes = $refunded_item->get_taxes();
+ foreach ( (array) ( $taxes['total'] ?? array() ) as $tax_id => $amount ) {
+ $tax_totals[ $original_id ][ $tax_id ] = ( $tax_totals[ $original_id ][ $tax_id ] ?? 0.0 ) + abs( (float) $amount );
+ }
+ };
foreach ( $order->get_refunds() as $refund ) {
/**
@@ -830,8 +1132,10 @@ class DataUtils {
*/
$refunded_line_items = $refund->get_items( 'line_item' );
foreach ( $refunded_line_items as $refunded_item ) {
- $original_id = absint( $refunded_item->get_meta( '_refunded_item_id' ) );
- $qtys[ $original_id ] = ( $qtys[ $original_id ] ?? 0 ) + $refunded_item->get_quantity();
+ $original_id = absint( $refunded_item->get_meta( '_refunded_item_id' ) );
+ $qtys[ $original_id ] = ( $qtys[ $original_id ] ?? 0 ) + $refunded_item->get_quantity();
+ $totals[ $original_id ] = ( $totals[ $original_id ] ?? 0.0 ) + ( (float) $refunded_item->get_total() + (float) $refunded_item->get_total_tax() ) * -1;
+ $add_refunded_taxes( $refunded_item, $original_id );
}
/**
* Refunded fee items.
@@ -842,6 +1146,7 @@ class DataUtils {
foreach ( $refunded_fees as $refunded_item ) {
$original_id = absint( $refunded_item->get_meta( '_refunded_item_id' ) );
$totals[ $original_id ] = ( $totals[ $original_id ] ?? 0.0 ) + ( (float) $refunded_item->get_total() + (float) $refunded_item->get_total_tax() ) * -1;
+ $add_refunded_taxes( $refunded_item, $original_id );
}
/**
* Refunded shipping items.
@@ -852,12 +1157,14 @@ class DataUtils {
foreach ( $refunded_shipping as $refunded_item ) {
$original_id = absint( $refunded_item->get_meta( '_refunded_item_id' ) );
$totals[ $original_id ] = ( $totals[ $original_id ] ?? 0.0 ) + ( (float) $refunded_item->get_total() + (float) $refunded_item->get_total_tax() ) * -1;
+ $add_refunded_taxes( $refunded_item, $original_id );
}
}
return array(
- 'qtys' => $qtys,
- 'totals' => $totals,
+ 'qtys' => $qtys,
+ 'totals' => $totals,
+ 'tax_totals' => $tax_totals,
);
}
}
diff --git a/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Refunds/Schema/RefundPreviewSchema.php b/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Refunds/Schema/RefundPreviewSchema.php
index 197afa7eb72..6e4188f7e7d 100644
--- a/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Refunds/Schema/RefundPreviewSchema.php
+++ b/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Refunds/Schema/RefundPreviewSchema.php
@@ -162,8 +162,8 @@ class RefundPreviewSchema extends AbstractSchema {
'readonly' => true,
),
'quantity' => array(
- 'description' => __( 'The quantity being refunded.', 'woocommerce' ),
- 'type' => 'integer',
+ 'description' => __( 'The quantity being refunded. Null when the refund was specified by amount only.', 'woocommerce' ),
+ 'type' => array( 'integer', 'null' ),
'context' => self::VIEW_EDIT_EMBED_CONTEXT,
'readonly' => true,
),
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 61dc35a86d9..7a436a3e04c 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
@@ -236,13 +236,13 @@ class RefundSchema extends AbstractSchema {
'validate_callback' => 'rest_validate_request_arg',
),
'refund_total' => array(
- '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' ),
+ 'description' => __( 'Optional: Total refunded for this item. Tax-inclusive when refund_tax is omitted (the backend splits out tax automatically); tax-exclusive (net subtotal) when refund_tax is supplied, in which case the line\'s total refund is refund_total + refund_tax. If omitted or set to null, the backend computes it from the order line item\'s unit price multiplied by quantity. A gross line refund that rounds to 0 is rejected.', 'woocommerce' ),
'type' => array( 'number', 'null' ),
'context' => self::VIEW_EDIT_EMBED_CONTEXT,
'validate_callback' => 'rest_validate_request_arg',
),
'refund_tax' => array(
- 'description' => __( 'Optional: Taxes refunded for this item. If not provided, tax will be automatically extracted from refund_total using the order\'s tax rates.', 'woocommerce' ),
+ 'description' => __( 'Optional: Taxes refunded for this item. If not provided, tax is automatically split out of the tax-inclusive refund_total using the line\'s own stored total-to-tax ratio (what was charged), not the order\'s current tax rates. When provided, refund_total is treated as the tax-exclusive subtotal and these taxes are added on top.', 'woocommerce' ),
'type' => 'array',
'context' => self::VIEW_EDIT_EMBED_CONTEXT,
'default' => array(),
diff --git a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version4/Orders/class-wc-rest-orders-v4-can-be-refunded-test.php b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version4/Orders/class-wc-rest-orders-v4-can-be-refunded-test.php
index 83698fdad48..d11d69251cc 100644
--- a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version4/Orders/class-wc-rest-orders-v4-can-be-refunded-test.php
+++ b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version4/Orders/class-wc-rest-orders-v4-can-be-refunded-test.php
@@ -662,4 +662,50 @@ class WC_REST_Orders_V4_Can_Be_Refunded_Test extends WC_REST_Unit_Test_Case {
'Zero-priced item with remaining quantity should be refundable'
);
}
+
+ /**
+ * @testdox Discount (negative-total) fee line with no prior refund reports can_be_refunded true.
+ *
+ * Regression for WOOPLUG-6829: the comparison omitted abs(), so a negative
+ * discount fee (e.g. a loyalty credit) reported can_be_refunded=false even
+ * though the refund validator accepts it as part of a mixed refund.
+ */
+ public function test_negative_fee_line_can_be_refunded(): void {
+ $product = WC_Helper_Product::create_simple_product( true, array( 'regular_price' => '50.00' ) );
+ $order = wc_create_order( array( 'customer_id' => $this->user_id ) );
+
+ $item = new WC_Order_Item_Product();
+ $item->set_props(
+ array(
+ 'product' => $product,
+ 'quantity' => 1,
+ 'subtotal' => 50,
+ 'total' => 50,
+ )
+ );
+ $item->save();
+ $order->add_item( $item );
+
+ $fee = new WC_Order_Item_Fee();
+ $fee->set_props(
+ array(
+ 'name' => 'Loyalty discount',
+ 'total' => '-10.00',
+ )
+ );
+ $fee->save();
+ $order->add_item( $fee );
+
+ $order->set_status( 'completed' );
+ $order->save();
+ $order->calculate_totals( false );
+
+ $data = $this->get_order_response( $order->get_id() );
+
+ $this->assertNotEmpty( $data['fee_lines'], 'Order should have fee lines' );
+ $this->assertTrue(
+ $data['fee_lines'][0]['can_be_refunded'],
+ 'A discount (negative) fee with no prior refund should be refundable.'
+ );
+ }
}
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 2bf3bf06f2e..9f25cda990f 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
@@ -81,6 +81,11 @@ class WC_REST_Refunds_V4_Controller_Tests extends WC_REST_Unit_Test_Case {
$wpdb->query( "DELETE FROM {$wpdb->prefix}woocommerce_tax_rate_locations" );
$wpdb->query( "DELETE FROM {$wpdb->prefix}woocommerce_tax_rates" );
+ // Reset tax-calculation options to their defaults. Several tests toggle these and
+ // not all restore them individually; resetting here keeps the suite order-independent.
+ update_option( 'woocommerce_calc_taxes', 'no' );
+ update_option( 'woocommerce_prices_include_tax', 'no' );
+
parent::tearDown();
$this->disable_rest_api_v4_feature();
}
@@ -547,6 +552,7 @@ class WC_REST_Refunds_V4_Controller_Tests extends WC_REST_Unit_Test_Case {
$order->set_billing_country( 'US' );
$order->set_total( 128.00 );
+ $order->set_status( OrderStatus::COMPLETED );
$order->save();
$this->created_orders[] = $order->get_id();
@@ -695,6 +701,7 @@ 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( 115.50 );
+ $order->set_status( OrderStatus::COMPLETED );
$order->save();
$this->created_orders[] = $order->get_id();
@@ -834,6 +841,7 @@ class WC_REST_Refunds_V4_Controller_Tests extends WC_REST_Unit_Test_Case {
$order->set_billing_country( 'US' );
$order->set_total( 64.00 );
+ $order->set_status( OrderStatus::COMPLETED );
$order->save();
$this->created_orders[] = $order->get_id();
@@ -898,6 +906,96 @@ class WC_REST_Refunds_V4_Controller_Tests extends WC_REST_Unit_Test_Case {
$product->delete( true );
}
+ /**
+ * @testdox Refund creation accepts a tax-only explicit tax array.
+ */
+ public function test_refunds_create_with_tax_only_explicit_tax_array(): 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' => '',
+ )
+ );
+
+ $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();
+
+ $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' => 0.00,
+ 'refund_tax' => array(
+ array(
+ 'id' => $tax_rate_id,
+ 'refund_total' => 10.00,
+ ),
+ ),
+ ),
+ ),
+ )
+ );
+ $response = $this->server->dispatch( $request );
+
+ $this->assertEquals( 201, $response->get_status(), 'Tax-only explicit refunds should be accepted.' );
+ $response_data = $response->get_data();
+ $this->assertEquals( '10.00', $response_data['amount'], 'Refund amount should include the explicit tax.' );
+
+ $refund = wc_get_order( $response_data['id'] );
+ $refund_items = $refund->get_items( 'line_item' );
+ $refund_line_item = reset( $refund_items );
+ $refund_taxes = $refund_line_item->get_taxes();
+
+ $this->assertEquals( 0.00, (float) $refund_line_item->get_total(), 'Line item total should stay zero for a tax-only refund.' );
+ $this->assertEquals( -10.00, (float) $refund_taxes['total'][ $tax_rate_id ], 'Explicit tax should be stored on the refund line.' );
+
+ $this->created_refunds[] = $response_data['id'];
+ $product->delete( true );
+ }
+
/**
* Test refund creation fails when refund_total exceeds line item total.
*/
@@ -950,6 +1048,7 @@ class WC_REST_Refunds_V4_Controller_Tests extends WC_REST_Unit_Test_Case {
$order->set_billing_country( 'US' );
$order->set_total( 110.00 );
+ $order->set_status( OrderStatus::COMPLETED );
$order->save();
$this->created_orders[] = $order->get_id();
@@ -973,18 +1072,52 @@ class WC_REST_Refunds_V4_Controller_Tests extends WC_REST_Unit_Test_Case {
$request->set_body_params( $refund_data );
$response = $this->server->dispatch( $request );
- // Should return 400 Bad Request.
- $this->assertEquals( 400, $response->get_status(), 'Refund should fail with 400 status' );
+ // A refund_total exceeding the line total is a well-formed but unprocessable
+ // request, so it returns 422 with the same code the preview endpoint uses.
+ $this->assertEquals( 422, $response->get_status() );
$response_data = $response->get_data();
$this->assertArrayHasKey( 'code', $response_data );
- $this->assertEquals( 'invalid_refund_amount', $response_data['code'] );
- $this->assertStringContainsString( 'cannot be greater than the line item total including tax', $response_data['message'] );
+ $this->assertEquals( 'refund_total_exceeds_line', $response_data['code'] );
+ $this->assertStringContainsString( 'cannot exceed the line item total including tax', $response_data['message'] );
// Clean up product.
$product->delete( true );
}
+ /**
+ * @testdox Refund creation rejects a request that lists the same line item more than once.
+ */
+ public function test_refunds_create_duplicate_line_item_returns_error(): void {
+ $order = $this->create_test_order();
+ $items = $order->get_items( 'line_item' );
+ $item = reset( $items );
+ $item_id = $item->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_id,
+ 'refund_total' => 5.00,
+ ),
+ array(
+ 'line_item_id' => $item_id,
+ 'refund_total' => 5.00,
+ ),
+ ),
+ )
+ );
+ $response = $this->server->dispatch( $request );
+
+ $this->assertEquals( 400, $response->get_status() );
+ $data = $response->get_data();
+ $this->assertEquals( 'duplicate_line_item', $data['code'], 'Create must use the same duplicate_line_item code as the preview path.' );
+ $this->assertStringContainsString( 'only once', $data['message'] );
+ }
+
/**
* Test refund creation fails when amount is less than line items total (under-refunding).
*/
@@ -1040,6 +1173,7 @@ class WC_REST_Refunds_V4_Controller_Tests extends WC_REST_Unit_Test_Case {
$order->set_billing_country( 'US' );
$order->set_total( 110.00 );
+ $order->set_status( OrderStatus::COMPLETED );
$order->save();
$this->created_orders[] = $order->get_id();
@@ -1237,6 +1371,7 @@ class WC_REST_Refunds_V4_Controller_Tests extends WC_REST_Unit_Test_Case {
$order->set_billing_state( 'CA' );
$order->set_total( 55.26 );
// 50.00 + 0.50 + 1.63 + 3.13.
+ $order->set_status( OrderStatus::COMPLETED );
$order->save();
$this->created_orders[] = $order->get_id();
@@ -1323,35 +1458,128 @@ class WC_REST_Refunds_V4_Controller_Tests extends WC_REST_Unit_Test_Case {
}
/**
- * @testdox Refund creation auto-computes refund_total from the order line item when omitted.
+ * @testdox A partial refund on a line with multiple tax IDs distributes tax per ID by stored share.
+ *
+ * Full-refund tests exercise the proportional split only at ratio 1.0 (the identity).
+ * This refunds exactly half a $55 line ($50 net + $0.50 county + $4.50 state) so the
+ * per-ID distribution and the subtotal-as-remainder math actually run with a non-trivial
+ * ratio, and asserts each stored per-tax-ID amount.
*/
- public function test_refunds_create_simplified_form_no_tax(): void {
- // Two-quantity product at $10 each = $20 order total.
+ public function test_refunds_create_partial_multi_tax_id_distributes_per_id(): void {
+ $tax_rate_county = WC_Tax::_insert_tax_rate(
+ array(
+ 'tax_rate_country' => 'US',
+ 'tax_rate' => '1.0000',
+ 'tax_rate_name' => 'County',
+ 'tax_rate_priority' => '1',
+ 'tax_rate_order' => '1',
+ )
+ );
+ $tax_rate_state = WC_Tax::_insert_tax_rate(
+ array(
+ 'tax_rate_country' => 'US',
+ 'tax_rate' => '9.0000',
+ 'tax_rate_name' => 'State',
+ 'tax_rate_priority' => '2',
+ 'tax_rate_order' => '2',
+ )
+ );
+
$product = WC_Helper_Product::create_simple_product();
- $product->set_price( 10.00 );
+ $product->set_regular_price( 50.00 );
+ $product->set_tax_status( 'taxable' );
$product->save();
- $order = $this->create_test_order(
+ $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->set_taxes(
+ array(
+ 'total' => array(
+ $tax_rate_county => '0.50',
+ $tax_rate_state => '4.50',
+ ),
+ 'subtotal' => array(
+ $tax_rate_county => '0.50',
+ $tax_rate_state => '4.50',
+ ),
+ )
+ );
+ $item->save();
+ $order->add_item( $item );
+
+ $tax_totals_by_rate = array(
+ $tax_rate_county => 0.50,
+ $tax_rate_state => 4.50,
+ );
+ foreach ( $tax_totals_by_rate 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( 55.00 );
+ $order->set_status( OrderStatus::COMPLETED );
+ $order->save();
+ $this->created_orders[] = $order->get_id();
+
+ // Refund half the tax-inclusive line: $27.50 → $25.00 net, $0.25 county, $2.25 state.
+ $request = new WP_REST_Request( 'POST', '/wc/v4/refunds' );
+ $request->set_body_params(
array(
+ 'order_id' => $order->get_id(),
'line_items' => array(
array(
- 'product_id' => $product->get_id(),
- 'quantity' => 2,
+ 'line_item_id' => $item->get_id(),
+ 'refund_total' => 27.50,
),
),
)
);
- $items = $order->get_items();
- $line_item = reset( $items );
+ $response = $this->server->dispatch( $request );
+ $this->assertEquals( 201, $response->get_status() );
+ $this->created_refunds[] = $response->get_data()['id'];
+
+ $refund = wc_get_order( $response->get_data()['id'] );
+ $refund_items = $refund->get_items( 'line_item' );
+ $refund_line_item = reset( $refund_items );
+ $refund_taxes = $refund_line_item->get_taxes();
+
+ $this->assertEquals( -25.00, (float) $refund_line_item->get_total(), 'Net subtotal should be half of $50.' );
+ $this->assertEquals( -0.25, (float) $refund_taxes['total'][ $tax_rate_county ], 'County tax should be half of $0.50.' );
+ $this->assertEquals( -2.25, (float) $refund_taxes['total'][ $tax_rate_state ], 'State tax should be half of $4.50.' );
+
+ $product->delete( true );
+ }
+
+ /**
+ * @testdox Refund creation on a non-refundable order returns 422 order_not_refundable, matching the preview endpoint.
+ */
+ public function test_refunds_create_order_not_refundable_returns_422(): void {
+ $order = $this->create_test_order();
+ $order->set_status( OrderStatus::CANCELLED );
+ $order->save();
+
+ $items = $order->get_items( 'line_item' );
+ $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(),
+ 'line_item_id' => $item->get_id(),
'quantity' => 1,
),
),
@@ -1359,47 +1587,295 @@ class WC_REST_Refunds_V4_Controller_Tests extends WC_REST_Unit_Test_Case {
);
$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 );
+ $this->assertEquals( 422, $response->get_status() );
+ $this->assertEquals( 'order_not_refundable', $response->get_data()['code'] );
}
/**
- * @testdox Refund creation with omitted refund_total extracts tax correctly.
+ * @testdox Refund creation on an already-fully-refunded line returns 422 line_item_already_refunded with a clear message.
+ *
+ * Uses a two-line order so fully refunding one line leaves the order itself
+ * refundable — otherwise a full order refund flips the order to the refunded
+ * status and the order-level guard fires first.
*/
- public function test_refunds_create_simplified_form_with_tax(): void {
- $tax_rate_id = WC_Tax::_insert_tax_rate(
+ public function test_refunds_create_fully_refunded_line_returns_422(): 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(
- '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' => '',
+ 'product' => $product_a,
+ 'quantity' => 1,
+ 'subtotal' => 10.00,
+ 'total' => 10.00,
)
);
+ $item_a->save();
+ $order->add_item( $item_a );
- // 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' );
+ $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 );
- try {
- $product = WC_Helper_Product::create_simple_product();
- $product->set_regular_price( 100.00 );
- $product->set_tax_status( 'taxable' );
- $product->save();
+ $order->set_total( 30.00 );
+ $order->set_status( OrderStatus::COMPLETED );
+ $order->save();
+ $this->created_orders[] = $order->get_id();
- $order = wc_create_order();
+ $first = new WP_REST_Request( 'POST', '/wc/v4/refunds' );
+ $first->set_body_params(
+ array(
+ 'order_id' => $order->get_id(),
+ 'line_items' => array(
+ array(
+ 'line_item_id' => $item_a->get_id(),
+ 'refund_total' => 10.00,
+ ),
+ ),
+ )
+ );
+ $first_response = $this->server->dispatch( $first );
+ $this->assertEquals( 201, $first_response->get_status(), 'First full-line refund should succeed.' );
+ $this->created_refunds[] = $first_response->get_data()['id'];
+
+ $second = new WP_REST_Request( 'POST', '/wc/v4/refunds' );
+ $second->set_body_params(
+ array(
+ 'order_id' => $order->get_id(),
+ 'line_items' => array(
+ array(
+ 'line_item_id' => $item_a->get_id(),
+ 'refund_total' => 5.00,
+ ),
+ ),
+ )
+ );
+ $second_response = $this->server->dispatch( $second );
+
+ $this->assertEquals( 422, $second_response->get_status() );
+ $this->assertEquals( 'line_item_already_refunded', $second_response->get_data()['code'] );
+ $this->assertStringContainsString( 'already been fully refunded', $second_response->get_data()['message'] );
+
+ $product_a->delete( true );
+ $product_b->delete( true );
+ }
+
+ /**
+ * @testdox Refund creation rejects a zero refund_total in a mixed request and stores no refund, matching preview.
+ */
+ public function test_refunds_create_zero_refund_total_in_mixed_request_returns_error(): 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(
+ array(
+ 'line_item_id' => $item_a->get_id(),
+ 'refund_total' => 0,
+ ),
+ array(
+ 'line_item_id' => $item_b->get_id(),
+ 'refund_total' => 10.00,
+ ),
+ ),
+ )
+ );
+ $response = $this->server->dispatch( $request );
+
+ $this->assertEquals( 400, $response->get_status() );
+ $this->assertEquals( 'invalid_refund_total', $response->get_data()['code'] );
+ $this->assertCount( 0, $order->get_refunds(), 'No refund should be stored when any line is rejected.' );
+
+ $product_a->delete( true );
+ $product_b->delete( true );
+ }
+
+ /**
+ * @testdox Refund creation rejects a negative refund_total on a positive line item.
+ */
+ public function test_refunds_create_wrong_sign_refund_total_returns_error(): void {
+ $order = $this->create_test_order();
+ $items = $order->get_items( 'line_item' );
+ $item = reset( $items );
+
+ $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' => -5.00,
+ ),
+ ),
+ )
+ );
+ $response = $this->server->dispatch( $request );
+
+ $this->assertEquals( 400, $response->get_status() );
+ $data = $response->get_data();
+ $this->assertEquals( 'invalid_refund_total', $data['code'], 'Create must use the same invalid_refund_total code as the preview path for a non-positive refund_total.' );
+ $this->assertStringContainsString( 'wrong sign', $data['message'] );
+ }
+
+ /**
+ * @testdox Refund creation rejects an amount exceeding the order's remaining refundable amount.
+ */
+ public function test_refunds_create_amount_exceeds_order_remaining_returns_422(): void {
+ // $10 order. A goodwill over-refund of the line is allowed, but the amount
+ // cannot exceed what remains refundable on the order.
+ $order = $this->create_test_order();
+ $items = $order->get_items( 'line_item' );
+ $item = reset( $items );
+
+ $request = new WP_REST_Request( 'POST', '/wc/v4/refunds' );
+ $request->set_body_params(
+ array(
+ 'order_id' => $order->get_id(),
+ 'amount' => 15.00,
+ 'line_items' => array(
+ array(
+ 'line_item_id' => $item->get_id(),
+ 'refund_total' => 10.00,
+ ),
+ ),
+ )
+ );
+ $response = $this->server->dispatch( $request );
+
+ $this->assertEquals( 422, $response->get_status() );
+ $this->assertEquals( 'refund_exceeds_remaining', $response->get_data()['code'] );
+ }
+
+ /**
+ * @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(
@@ -1888,7 +2364,8 @@ class WC_REST_Refunds_V4_Controller_Tests extends WC_REST_Unit_Test_Case {
);
$response = $this->server->dispatch( $request );
- $this->assertEquals( 400, $response->get_status(), 'Over-quantity must still be rejected even when refund_total is auto-computed.' );
+ $this->assertEquals( 422, $response->get_status(), 'Over-quantity must still be rejected even when refund_total is auto-computed.' );
+ $this->assertEquals( 'quantity_exceeds_refundable', $response->get_data()['code'], 'Create must use the same quantity_exceeds_refundable code as the preview path.' );
$product->delete( true );
}
@@ -2179,10 +2656,12 @@ class WC_REST_Refunds_V4_Controller_Tests extends WC_REST_Unit_Test_Case {
$this->assertSame( 0, $refund_item->get_quantity(), 'qty=0 expected for legacy-no-quantity path.' );
$this->assertEquals( -30.00, (float) $refund_item->get_total(), 'Refund line item total should be -30.00.' );
- // Step 2: 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.
+ // Step 2: the per-line remaining-amount cap gates subsequent refunds.
+ // Remaining refundable on the line = 100 - 30 = 70. A simplified-form request
+ // for the full 2 units would compute 100 (2 * $50), which exceeds the remaining
+ // 70, so validate_line_items rejects it with refund_total_exceeds_remaining — the
+ // same code (and 422 status) the preview endpoint applies, before the request
+ // ever reaches wc_create_refund.
$request2 = new WP_REST_Request( 'POST', '/wc/v4/refunds' );
$request2->set_body_params(
array(
@@ -2197,8 +2676,8 @@ class WC_REST_Refunds_V4_Controller_Tests extends WC_REST_Unit_Test_Case {
);
$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'] );
+ $this->assertEquals( 422, $response2->get_status(), 'Follow-up refund exceeding remaining dollars must be rejected.' );
+ $this->assertEquals( 'refund_total_exceeds_remaining', $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
@@ -2445,7 +2924,7 @@ class WC_REST_Refunds_V4_Controller_Tests extends WC_REST_Unit_Test_Case {
$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'] );
+ $this->assertEquals( 'line_item_not_found', $data['code'], 'Create must use the same line_item_not_found code as the preview path.' );
$product->delete( true );
}
@@ -2579,9 +3058,9 @@ class WC_REST_Refunds_V4_Controller_Tests extends WC_REST_Unit_Test_Case {
);
$response2 = $this->server->dispatch( $request2 );
- $this->assertEquals( 400, $response2->get_status() );
+ $this->assertEquals( 422, $response2->get_status(), 'Over-refunding quantity is unprocessable (422), matching the preview path.' );
$data2 = $response2->get_data();
- $this->assertEquals( 'invalid_line_item', $data2['code'] );
+ $this->assertEquals( 'quantity_exceeds_refundable', $data2['code'], 'Create must use the same quantity_exceeds_refundable code as the preview path.' );
$this->assertStringContainsString( 'remaining refundable quantity', $data2['message'] );
$product_a->delete( true );
@@ -2589,116 +3068,34 @@ class WC_REST_Refunds_V4_Controller_Tests extends WC_REST_Unit_Test_Case {
}
/**
- * @testdox Simplified form rejects a second refund of already-fully-refunded fee and shipping lines.
+ * @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_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(
+ public function test_refunds_create_rejects_auto_compute_with_explicit_refund_tax(): void {
+ $tax_rate_id = WC_Tax::_insert_tax_rate(
array(
- 'product' => $product,
- 'quantity' => 1,
- 'subtotal' => 50.00,
- 'total' => 50.00,
+ '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' => '',
)
);
- $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' );
+ $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();
@@ -2998,7 +3395,7 @@ class WC_REST_Refunds_V4_Controller_Tests extends WC_REST_Unit_Test_Case {
$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->assertEquals( 'missing_quantity_or_refund_total', $data['code'], 'Create must use the same missing_quantity_or_refund_total code as the preview path, not cascade to invalid_refund_amount.' );
$this->assertStringContainsString( 'positive integer', $data['message'] );
$product->delete( true );
@@ -3029,75 +3426,68 @@ class WC_REST_Refunds_V4_Controller_Tests extends WC_REST_Unit_Test_Case {
)
);
- $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();
+ $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 = 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();
+ $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,
- ),
- );
+ $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 );
+ $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 );
+ $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( 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.'
- );
+ $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 );
- }
+ $product->delete( true );
}
/**
@@ -3177,14 +3567,95 @@ class WC_REST_Refunds_V4_Controller_Tests extends WC_REST_Unit_Test_Case {
return $this->server->dispatch( $request );
}
+ /**
+ * @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( 422, $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( 'line_item_already_refunded', $data['code'] );
+ $this->assertStringContainsString( 'already been fully refunded', $data['message'] );
+ }
+
+ $product->delete( true );
+ }
+
/**
* @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.
+ * remains and the third auto-computed 3.67 is rejected by the remaining-amount
+ * guard. A one-shot qty-3 refund rounds once and consumes the line exactly.
*/
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 );
@@ -3222,8 +3693,8 @@ class WC_REST_Refunds_V4_Controller_Tests extends WC_REST_Unit_Test_Case {
$this->assertEqualsWithDelta( 3.66, (float) $order->get_remaining_refund_amount(), 0.001, 'Two 3.67 refunds leave 3.66 of the 11.00 line' );
$response = $this->dispatch_refund_request( $order->get_id(), $unit_refund );
- $this->assertEquals( 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'] );
+ $this->assertEquals( 422, $response->get_status(), 'Third auto-computed 3.67 exceeds the 3.66 remaining and must be rejected' );
+ $this->assertEquals( 'refund_total_exceeds_remaining', $response->get_data()['code'] );
$response = $this->dispatch_refund_request(
$order->get_id(),
@@ -3609,7 +4080,7 @@ class WC_REST_Refunds_V4_Controller_Tests extends WC_REST_Unit_Test_Case {
)
);
$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'] );
+ $this->assertEquals( 'invalid_quantity', $response->get_data()['code'] );
}
$response = $this->dispatch_refund_request(
@@ -3655,4 +4126,693 @@ class WC_REST_Refunds_V4_Controller_Tests extends WC_REST_Unit_Test_Case {
$this->assert_incomplete_meta_data_handled_correctly( $refund );
}
+
+ /**
+ * @testdox Create splits a single-tax partial refund_total into the stored net total and tax, independently of the preview path.
+ */
+ public function test_refunds_create_partial_amount_single_tax_split_stored(): void {
+ $tax_rate_id = WC_Tax::_insert_tax_rate(
+ array(
+ 'tax_rate_country' => 'US',
+ 'tax_rate' => '10.0000',
+ 'tax_rate_name' => 'VAT',
+ 'tax_rate_priority' => '1',
+ 'tax_rate_order' => '1',
+ )
+ );
+
+ $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();
+
+ // Refund $55 of the $110 tax-inclusive line → $50 net, $5 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(),
+ 'refund_total' => 55.00,
+ ),
+ ),
+ )
+ );
+ $response = $this->server->dispatch( $request );
+
+ $this->assertEquals( 201, $response->get_status() );
+ $data = $response->get_data();
+ $this->created_refunds[] = $data['id'];
+
+ $this->assertEquals( '55.00', $data['amount'], 'Refund amount must equal the tax-inclusive partial total.' );
+
+ $refund = wc_get_order( $data['id'] );
+ $refund_items = $refund->get_items( 'line_item' );
+ $refund_line_item = reset( $refund_items );
+ $refund_taxes = $refund_line_item->get_taxes();
+
+ $this->assertEquals( -50.00, (float) $refund_line_item->get_total(), 'Stored net total should be half of $100.' );
+ $this->assertEquals( -5.00, (float) $refund_taxes['total'][ $tax_rate_id ], 'Stored tax should be half of $10.' );
+
+ $product->delete( true );
+ }
+
+ /**
+ * @testdox Create uses an explicit refund_total over quantity for the money split on a multi-quantity line, while storing the requested quantity.
+ */
+ public function test_refunds_create_partial_amount_multi_quantity_line(): void {
+ $tax_rate_id = WC_Tax::_insert_tax_rate(
+ array(
+ 'tax_rate_country' => 'US',
+ 'tax_rate' => '10.0000',
+ 'tax_rate_name' => 'VAT',
+ 'tax_rate_priority' => '1',
+ 'tax_rate_order' => '1',
+ )
+ );
+
+ $product = WC_Helper_Product::create_simple_product();
+ $product->set_regular_price( 10.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' => 3,
+ 'subtotal' => 30.00,
+ 'total' => 30.00,
+ )
+ );
+ $item->set_taxes(
+ array(
+ 'total' => array( $tax_rate_id => 3.00 ),
+ 'subtotal' => array( $tax_rate_id => 3.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( 3.00 );
+ $tax_item->save();
+ $order->add_item( $tax_item );
+
+ $order->set_billing_country( 'US' );
+ $order->set_total( 33.00 );
+ $order->set_status( OrderStatus::COMPLETED );
+ $order->save();
+ $this->created_orders[] = $order->get_id();
+
+ // Refund half the $33 tax-inclusive line ($16.50) while passing quantity 3:
+ // the money split follows refund_total ($15 net, $1.50 tax), quantity is stored as-is.
+ $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' => 3,
+ 'refund_total' => 16.50,
+ ),
+ ),
+ )
+ );
+ $response = $this->server->dispatch( $request );
+
+ $this->assertEquals( 201, $response->get_status() );
+ $data = $response->get_data();
+ $this->created_refunds[] = $data['id'];
+
+ $this->assertEquals( '16.50', $data['amount'], 'Refund amount must follow refund_total, not the full quantity total.' );
+
+ $refund = wc_get_order( $data['id'] );
+ $refund_items = $refund->get_items( 'line_item' );
+ $refund_line_item = reset( $refund_items );
+ $refund_taxes = $refund_line_item->get_taxes();
+
+ $this->assertEquals( -15.00, (float) $refund_line_item->get_total(), 'Stored net total should be half of $30.' );
+ $this->assertEquals( -1.50, (float) $refund_taxes['total'][ $tax_rate_id ], 'Stored tax should be half of $3.' );
+ // Refund line items store quantity as a negative, mirroring the negative totals.
+ $this->assertEquals( -3, $refund_line_item->get_quantity(), 'The requested quantity is stored even when refund_total drives the amount.' );
+
+ $product->delete( true );
+ }
+
+ /**
+ * @testdox Create rounds a partial refund_total split to a zero-decimal currency precision.
+ */
+ public function test_refunds_create_partial_amount_zero_decimal_currency(): void {
+ $tax_rate_id = WC_Tax::_insert_tax_rate(
+ array(
+ 'tax_rate_country' => 'US',
+ 'tax_rate' => '10.0000',
+ 'tax_rate_name' => 'VAT',
+ 'tax_rate_priority' => '1',
+ 'tax_rate_order' => '1',
+ )
+ );
+
+ // Force whole-number currency precision deterministically. update_option on
+ // woocommerce_price_num_decimals does not reliably propagate to
+ // wc_get_price_decimals() within a single request in the test environment.
+ $zero_decimals = static function () {
+ return 0;
+ };
+ add_filter( 'wc_get_price_decimals', $zero_decimals );
+
+ try {
+ $product = WC_Helper_Product::create_simple_product();
+ $product->set_regular_price( 1000.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' => 1000.00,
+ 'total' => 1000.00,
+ )
+ );
+ $item->set_taxes(
+ array(
+ 'total' => array( $tax_rate_id => 100.00 ),
+ 'subtotal' => array( $tax_rate_id => 100.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( 100.00 );
+ $tax_item->save();
+ $order->add_item( $tax_item );
+
+ $order->set_billing_country( 'US' );
+ $order->set_total( 1100.00 );
+ $order->set_status( OrderStatus::COMPLETED );
+ $order->save();
+ $this->created_orders[] = $order->get_id();
+
+ // Refund 549 of the 1100 tax-inclusive line. The stored ratio gives
+ // 549 * 100/1100 = 49.909..., which rounds to 50 at zero decimals (it would
+ // be 49.91 at two), and the net subtotal becomes 549 - 50 = 499. Asserting
+ // these whole numbers proves the split honours the currency precision.
+ $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' => 549,
+ ),
+ ),
+ )
+ );
+ $response = $this->server->dispatch( $request );
+
+ $this->assertEquals( 201, $response->get_status() );
+ $data = $response->get_data();
+ $this->created_refunds[] = $data['id'];
+
+ $refund = wc_get_order( $data['id'] );
+ $refund_items = $refund->get_items( 'line_item' );
+ $refund_line_item = reset( $refund_items );
+ $refund_taxes = $refund_line_item->get_taxes();
+
+ $this->assertEquals( -499.0, (float) $refund_line_item->get_total(), 'Net subtotal rounds to a whole number at zero decimals.' );
+ $this->assertEquals( -50.0, (float) $refund_taxes['total'][ $tax_rate_id ], 'Tax rounds to a whole number at zero decimals.' );
+ $this->assertEquals( 549.0, (float) $data['amount'], 'Net + tax must reconstitute the requested amount.' );
+
+ $product->delete( true );
+ } finally {
+ remove_filter( 'wc_get_price_decimals', $zero_decimals );
+ }
+ }
+
+ /**
+ * @testdox Create absorbs the rounding remainder into the net subtotal when a partial refund_total does not split into clean cents.
+ */
+ public function test_refunds_create_partial_amount_rounding_remainder(): void {
+ $tax_rate_id = WC_Tax::_insert_tax_rate(
+ array(
+ 'tax_rate_country' => 'US',
+ 'tax_rate' => '15.0000',
+ 'tax_rate_name' => 'VAT',
+ 'tax_rate_priority' => '1',
+ 'tax_rate_order' => '1',
+ )
+ );
+
+ $product = WC_Helper_Product::create_simple_product();
+ $product->set_regular_price( 10.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' => 10.00,
+ 'total' => 10.00,
+ )
+ );
+ $item->set_taxes(
+ array(
+ 'total' => array( $tax_rate_id => 1.50 ),
+ 'subtotal' => array( $tax_rate_id => 1.50 ),
+ )
+ );
+ $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( 1.50 );
+ $tax_item->save();
+ $order->add_item( $tax_item );
+
+ $order->set_billing_country( 'US' );
+ $order->set_total( 11.50 );
+ $order->set_status( OrderStatus::COMPLETED );
+ $order->save();
+ $this->created_orders[] = $order->get_id();
+
+ // Refund $3.33 of the $11.50 tax-inclusive line. tax = round(3.33 * 1.50 / 11.50, 2) = 0.43,
+ // and the net subtotal absorbs the remainder: 3.33 - 0.43 = 2.90, so subtotal + tax == amount.
+ $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' => 3.33,
+ ),
+ ),
+ )
+ );
+ $response = $this->server->dispatch( $request );
+
+ $this->assertEquals( 201, $response->get_status() );
+ $data = $response->get_data();
+ $this->created_refunds[] = $data['id'];
+
+ $refund = wc_get_order( $data['id'] );
+ $refund_items = $refund->get_items( 'line_item' );
+ $refund_line_item = reset( $refund_items );
+ $refund_taxes = $refund_line_item->get_taxes();
+
+ $this->assertEquals( -0.43, (float) $refund_taxes['total'][ $tax_rate_id ], 'Tax rounds to the nearest cent.' );
+ $this->assertEquals( -2.90, (float) $refund_line_item->get_total(), 'Net subtotal absorbs the rounding remainder.' );
+ $this->assertEquals( '3.33', $data['amount'], 'Net + tax must reconstitute the requested amount to the cent.' );
+
+ $product->delete( true );
+ }
+
+ /**
+ * @testdox Create rejects a partial refund_total that exceeds the remaining refundable amount on a fee line.
+ */
+ public function test_refunds_create_partial_amount_fee_exceeds_remaining_returns_422(): void {
+ $order = wc_create_order();
+ $fee = new WC_Order_Item_Fee();
+ $fee->set_props(
+ array(
+ 'name' => 'Handling',
+ 'total' => 20.00,
+ )
+ );
+ $fee->save();
+ $order->add_item( $fee );
+ $order->set_total( 20.00 );
+ $order->set_status( OrderStatus::COMPLETED );
+ $order->save();
+ $this->created_orders[] = $order->get_id();
+
+ // First refund $15 of the $20 fee, leaving $5 remaining.
+ $first_request = new WP_REST_Request( 'POST', '/wc/v4/refunds' );
+ $first_request->set_body_params(
+ array(
+ 'order_id' => $order->get_id(),
+ 'line_items' => array(
+ array(
+ 'line_item_id' => $fee->get_id(),
+ 'refund_total' => 15.00,
+ ),
+ ),
+ )
+ );
+ $first_response = $this->server->dispatch( $first_request );
+ $this->assertEquals( 201, $first_response->get_status(), 'First partial fee refund should succeed.' );
+ $this->created_refunds[] = $first_response->get_data()['id'];
+
+ // Second refund of $10 exceeds the $5 remaining on the fee line.
+ $second_request = new WP_REST_Request( 'POST', '/wc/v4/refunds' );
+ $second_request->set_body_params(
+ array(
+ 'order_id' => $order->get_id(),
+ 'line_items' => array(
+ array(
+ 'line_item_id' => $fee->get_id(),
+ 'refund_total' => 10.00,
+ ),
+ ),
+ )
+ );
+ $second_response = $this->server->dispatch( $second_request );
+
+ $this->assertEquals( 422, $second_response->get_status() );
+ $this->assertEquals( 'refund_total_exceeds_remaining', $second_response->get_data()['code'] );
+ $this->assertStringContainsString( 'remaining refundable amount', $second_response->get_data()['message'] );
+ }
+
+ /**
+ * @testdox Create splits a partial refund_total by the stored ratio under a tax-inclusive store, without double-extracting tax.
+ */
+ public function test_refunds_create_partial_amount_tax_inclusive_store(): void {
+ $tax_rate_id = WC_Tax::_insert_tax_rate(
+ array(
+ 'tax_rate_country' => 'US',
+ 'tax_rate' => '10.0000',
+ 'tax_rate_name' => 'VAT',
+ 'tax_rate_priority' => '1',
+ 'tax_rate_order' => '1',
+ )
+ );
+
+ $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();
+
+ // Refund $55 of the $110 tax-inclusive line → $50 net, $5 tax, same split as a tax-exclusive store.
+ $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' => 55.00,
+ ),
+ ),
+ )
+ );
+ $response = $this->server->dispatch( $request );
+
+ $this->assertEquals( 201, $response->get_status() );
+ $data = $response->get_data();
+ $this->created_refunds[] = $data['id'];
+
+ $this->assertEquals( '55.00', $data['amount'], 'Tax-inclusive store: amount equals the requested tax-inclusive partial.' );
+
+ $refund = wc_get_order( $data['id'] );
+ $refund_items = $refund->get_items( 'line_item' );
+ $refund_line_item = reset( $refund_items );
+ $refund_taxes = $refund_line_item->get_taxes();
+
+ $this->assertEquals( -50.00, (float) $refund_line_item->get_total(), 'Stored net total should be half of $100.' );
+ $this->assertEquals( -5.00, (float) $refund_taxes['total'][ $tax_rate_id ], 'Stored tax should be half of $10, not re-extracted.' );
+
+ $product->delete( true );
+ } finally {
+ update_option( 'woocommerce_prices_include_tax', $original_prices_include_tax );
+ }
+ }
+
+ /**
+ * @testdox The created refund's per-line split matches build_refund_preview's breakdown for the explicit refund_total form.
+ *
+ * Guards the headline guarantee of the partial-amount feature: a previewed
+ * refund_total matches the created refund to the cent, including the per-tax
+ * split, not just the grand total.
+ */
+ public function test_refunds_create_partial_amount_matches_build_refund_preview_breakdown(): void {
+ $tax_rate_id = WC_Tax::_insert_tax_rate(
+ array(
+ 'tax_rate_country' => 'US',
+ 'tax_rate' => '10.0000',
+ 'tax_rate_name' => 'VAT',
+ 'tax_rate_priority' => '1',
+ 'tax_rate_order' => '1',
+ )
+ );
+
+ $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();
+
+ // Partial-amount form: refund $55 of the $110 tax-inclusive line.
+ $line_items = array(
+ array(
+ 'line_item_id' => $item->get_id(),
+ 'refund_total' => 55.00,
+ ),
+ );
+
+ $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() );
+ $data = $response->get_data();
+ $this->created_refunds[] = $data['id'];
+
+ $preview_item = $preview['breakdown']['products']['items'][0];
+
+ // Grand-total parity.
+ $this->assertEquals( $preview['total'], $data['amount'], 'Create amount must match the preview total.' );
+
+ // Per-line split parity: stored refund values are negative, the preview is positive.
+ $refund = wc_get_order( $data['id'] );
+ $refund_items = $refund->get_items( 'line_item' );
+ $refund_line_item = reset( $refund_items );
+ $refund_taxes = $refund_line_item->get_taxes();
+
+ $this->assertEquals(
+ -1 * (float) $preview_item['subtotal'],
+ (float) $refund_line_item->get_total(),
+ 'Stored net total must match the previewed subtotal.'
+ );
+ $this->assertEquals(
+ -1 * (float) $preview_item['tax'],
+ (float) $refund_taxes['total'][ $tax_rate_id ],
+ 'Stored tax must match the previewed tax.'
+ );
+
+ $product->delete( true );
+ }
+
+ /**
+ * @testdox Two sequential partial-amount refunds that sum to the line total both succeed; a further refund is rejected.
+ *
+ * Exercises the rounded remaining-amount boundary in validate_line_items for a
+ * product line: the second refund hits the exact remaining amount and must be
+ * accepted, after which the order is fully refunded.
+ */
+ public function test_refunds_create_partial_amount_product_sequential_to_full(): void {
+ $tax_rate_id = WC_Tax::_insert_tax_rate(
+ array(
+ 'tax_rate_country' => 'US',
+ 'tax_rate' => '10.0000',
+ 'tax_rate_name' => 'VAT',
+ 'tax_rate_priority' => '1',
+ 'tax_rate_order' => '1',
+ )
+ );
+
+ $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();
+
+ $refund_line = function ( float $amount ) use ( $order, $item ) {
+ $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' => $amount,
+ ),
+ ),
+ )
+ );
+ return $this->server->dispatch( $request );
+ };
+
+ // First refund: $40 of the $110 tax-inclusive line.
+ $first = $refund_line( 40.00 );
+ $this->assertEquals( 201, $first->get_status(), 'First partial refund should succeed.' );
+ $this->created_refunds[] = $first->get_data()['id'];
+
+ // Second refund: exactly the $70 remaining — the boundary must be accepted.
+ $second = $refund_line( 70.00 );
+ $this->assertEquals( 201, $second->get_status(), 'Second partial refund at the exact remaining amount should succeed.' );
+ $this->created_refunds[] = $second->get_data()['id'];
+
+ $this->assertEquals(
+ 0.0,
+ (float) wc_get_order( $order->get_id() )->get_remaining_refund_amount(),
+ 'Order should be fully refunded after both partials.'
+ );
+
+ // A further refund on the now fully-refunded order is rejected.
+ $third = $refund_line( 0.01 );
+ $this->assertEquals( 422, $third->get_status() );
+ $this->assertEquals( 'order_not_refundable', $third->get_data()['code'] );
+
+ $product->delete( true );
+ }
}
diff --git a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version4/Refunds/class-wc-rest-refunds-v4-preview-tests.php b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version4/Refunds/class-wc-rest-refunds-v4-preview-tests.php
index c7b7581bbb9..cdda235f5b4 100644
--- a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version4/Refunds/class-wc-rest-refunds-v4-preview-tests.php
+++ b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version4/Refunds/class-wc-rest-refunds-v4-preview-tests.php
@@ -315,7 +315,8 @@ class WC_REST_Refunds_V4_Preview_Tests extends WC_REST_Unit_Test_Case {
)
);
- $this->assertEquals( 404, $response->get_status() );
+ // A bad line_item_id reference is a 400 (malformed request), matching the create endpoint.
+ $this->assertEquals( 400, $response->get_status() );
$data = $response->get_data();
$this->assertEquals( 'line_item_not_found', $data['code'] );
}
@@ -431,9 +432,9 @@ class WC_REST_Refunds_V4_Preview_Tests extends WC_REST_Unit_Test_Case {
* Quantity scenarios that should all be rejected at the HTTP boundary.
*
* Some inputs are rejected by the REST framework (`rest_invalid_param`) and
- * others by DataUtils::validate_preview_line_items (`invalid_quantity`).
- * The test accepts either so it documents the actual observable behaviour
- * without coupling to which layer rejects first.
+ * others by DataUtils::validate_preview_line_items (`invalid_quantity` or
+ * `missing_quantity_or_refund_total`). The test accepts any from the set so it
+ * documents the actual observable behaviour without coupling to which layer rejects.
*
* @return array<string, array<int, mixed>>
*/
@@ -441,7 +442,7 @@ class WC_REST_Refunds_V4_Preview_Tests extends WC_REST_Unit_Test_Case {
return array(
'zero' => array( array( 'quantity' => 0 ), array( 'rest_invalid_param', 'invalid_quantity' ) ),
'negative' => array( array( 'quantity' => -1 ), array( 'rest_invalid_param', 'invalid_quantity' ) ),
- 'missing key' => array( array(), array( 'rest_invalid_param', 'missing_line_item_id', 'invalid_quantity' ) ),
+ 'missing key' => array( array(), array( 'rest_invalid_param', 'missing_line_item_id', 'missing_quantity_or_refund_total' ) ),
'string' => array( array( 'quantity' => 'abc' ), array( 'rest_invalid_param', 'invalid_quantity' ) ),
'float' => array( array( 'quantity' => 1.5 ), array( 'rest_invalid_param', 'invalid_quantity' ) ),
);
@@ -1040,6 +1041,831 @@ class WC_REST_Refunds_V4_Preview_Tests extends WC_REST_Unit_Test_Case {
$this->assertEquals( '150.00', $data['max_refundable'], 'Max refundable should be original total minus already refunded' );
}
+ /**
+ * @testdox Partial amount preview on a product line returns the tax split for the requested amount.
+ */
+ public function test_preview_partial_amount_product_line(): void {
+ $tax_rate_id = $this->create_tax_rate( 10.0 );
+ // $100 product + $10 tax = $110 total. Request a partial $55 refund.
+ $order = $this->create_order_with_product_and_tax( 100.00, 1, $tax_rate_id, 10.00 );
+ $item_id = $this->get_first_line_item_id( $order );
+
+ $response = $this->do_preview_request(
+ $order->get_id(),
+ array(
+ array(
+ 'line_item_id' => $item_id,
+ 'refund_total' => 55.00,
+ ),
+ )
+ );
+
+ $this->assertEquals( 200, $response->get_status() );
+ $data = $response->get_data();
+
+ // refund_total = 55 (tax-inclusive). Tax extracted from 55 at 10%: ~5.00.
+ $this->assertEquals( '55.00', $data['total'] );
+ $this->assertGreaterThan( 0.0, (float) $data['tax'] );
+ // quantity is null because the caller did not supply it.
+ $this->assertNull( $data['breakdown']['products']['items'][0]['quantity'] );
+ }
+
+ /**
+ * @testdox Partial amount preview on a product line with both quantity and refund_total uses refund_total.
+ */
+ public function test_preview_partial_amount_overrides_quantity_for_product(): void {
+ // $10/unit × 5 units = $50 total. quantity=2 would compute $20, but refund_total=30 wins.
+ $order = $this->create_order_with_product( 10.00, 5 );
+ $item_id = $this->get_first_line_item_id( $order );
+
+ $response = $this->do_preview_request(
+ $order->get_id(),
+ array(
+ array(
+ 'line_item_id' => $item_id,
+ 'quantity' => 2,
+ 'refund_total' => 30.00,
+ ),
+ )
+ );
+
+ $this->assertEquals( 200, $response->get_status() );
+ $data = $response->get_data();
+
+ // refund_total wins: total = 30.
+ $this->assertEquals( '30.00', $data['total'] );
+ // quantity is echoed back from the request.
+ $this->assertEquals( 2, $data['breakdown']['products']['items'][0]['quantity'] );
+ }
+
+ /**
+ * @testdox Partial amount preview on a fee line returns correct tax split.
+ */
+ public function test_preview_partial_amount_fee_line(): void {
+ // $20 fee, no tax.
+ $order = $this->create_order_with_fee( 20.00 );
+ $items = $order->get_items( 'fee' );
+ $item = reset( $items );
+
+ $response = $this->do_preview_request(
+ $order->get_id(),
+ array(
+ array(
+ 'line_item_id' => $item->get_id(),
+ 'refund_total' => 8.00,
+ ),
+ )
+ );
+
+ $this->assertEquals( 200, $response->get_status() );
+ $data = $response->get_data();
+
+ $this->assertEquals( '8.00', $data['total'] );
+ $this->assertEquals( '0.00', $data['tax'] );
+ $this->assertNull( $data['breakdown']['fees']['items'][0]['quantity'] );
+ }
+
+ /**
+ * @testdox Partial amount preview on a shipping line returns correct total.
+ */
+ public function test_preview_partial_amount_shipping_line(): void {
+ $order = $this->create_order_with_shipping( 15.00 );
+ $items = $order->get_items( 'shipping' );
+ $item = reset( $items );
+
+ $response = $this->do_preview_request(
+ $order->get_id(),
+ array(
+ array(
+ 'line_item_id' => $item->get_id(),
+ 'refund_total' => 6.00,
+ ),
+ )
+ );
+
+ $this->assertEquals( 200, $response->get_status() );
+ $data = $response->get_data();
+
+ $this->assertEquals( '6.00', $data['total'] );
+ $this->assertNull( $data['breakdown']['shipping']['items'][0]['quantity'] );
+ }
+
+ /**
+ * @testdox Partial amount preview returns 422 when refund_total exceeds line item total.
+ */
+ public function test_preview_partial_amount_exceeds_line_total_returns_422(): void {
+ $order = $this->create_order_with_product( 20.00, 1 );
+ $item_id = $this->get_first_line_item_id( $order );
+
+ $response = $this->do_preview_request(
+ $order->get_id(),
+ array(
+ array(
+ 'line_item_id' => $item_id,
+ 'refund_total' => 25.00,
+ ),
+ )
+ );
+
+ $this->assertEquals( 422, $response->get_status() );
+ $data = $response->get_data();
+ $this->assertEquals( 'refund_total_exceeds_line', $data['code'] );
+ }
+
+ /**
+ * @testdox Partial amount preview returns 400 when neither quantity nor refund_total is provided.
+ */
+ public function test_preview_missing_quantity_and_refund_total_returns_400(): void {
+ $order = $this->create_order_with_product( 20.00, 1 );
+ $item_id = $this->get_first_line_item_id( $order );
+
+ $response = $this->do_preview_request(
+ $order->get_id(),
+ array(
+ array(
+ 'line_item_id' => $item_id,
+ ),
+ )
+ );
+
+ $this->assertEquals( 400, $response->get_status() );
+ $data = $response->get_data();
+ $this->assertEquals( 'missing_quantity_or_refund_total', $data['code'] );
+ }
+
+ /**
+ * @testdox Partial amount preview on fee returns 422 when refund_total exceeds remaining after prior partial refund.
+ */
+ public function test_preview_partial_amount_fee_exceeds_remaining_returns_422(): void {
+ $order = $this->create_order_with_fee( 20.00 );
+ $items = $order->get_items( 'fee' );
+ $item = reset( $items );
+
+ // First partial refund: $12 of the $20 fee.
+ wc_create_refund(
+ array(
+ 'order_id' => $order->get_id(),
+ 'amount' => 12.00,
+ 'line_items' => array(
+ $item->get_id() => array(
+ 'qty' => 0,
+ 'refund_total' => 12.00,
+ 'refund_tax' => array(),
+ ),
+ ),
+ )
+ );
+
+ // Try to refund $15, but only $8 remains.
+ $response = $this->do_preview_request(
+ $order->get_id(),
+ array(
+ array(
+ 'line_item_id' => $item->get_id(),
+ 'refund_total' => 15.00,
+ ),
+ )
+ );
+
+ $this->assertEquals( 422, $response->get_status() );
+ $data = $response->get_data();
+ $this->assertEquals( 'refund_total_exceeds_remaining', $data['code'] );
+ }
+
+ /**
+ * @testdox Partial amount preview returns 400 invalid_refund_total when refund_total is zero.
+ */
+ public function test_preview_partial_amount_zero_refund_total_returns_400(): void {
+ $order = $this->create_order_with_product( 20.00, 1 );
+ $item_id = $this->get_first_line_item_id( $order );
+
+ $response = $this->do_preview_request(
+ $order->get_id(),
+ array(
+ array(
+ 'line_item_id' => $item_id,
+ 'refund_total' => 0,
+ ),
+ )
+ );
+
+ $this->assertEquals( 400, $response->get_status() );
+ $data = $response->get_data();
+ $this->assertEquals( 'invalid_refund_total', $data['code'] );
+ }
+
+ /**
+ * @testdox Partial amount preview returns 400 invalid_refund_total when refund_total is zero even if quantity is provided.
+ */
+ public function test_preview_zero_refund_total_with_quantity_returns_400(): void {
+ // Regression: a zero refund_total used to be treated as absent by
+ // validation (which then validated the quantity path) while
+ // build_refund_preview() used the explicit 0, producing a 200
+ // response with a $0.00 total. The combination must be rejected.
+ $order = $this->create_order_with_product( 20.00, 2 );
+ $item_id = $this->get_first_line_item_id( $order );
+
+ $response = $this->do_preview_request(
+ $order->get_id(),
+ array(
+ array(
+ 'line_item_id' => $item_id,
+ 'quantity' => 2,
+ 'refund_total' => 0,
+ ),
+ )
+ );
+
+ $this->assertEquals( 400, $response->get_status() );
+ $data = $response->get_data();
+ $this->assertEquals( 'invalid_refund_total', $data['code'] );
+ }
+
+ /**
+ * @testdox Partial amount preview treats an explicit null refund_total as the quantity form.
+ */
+ public function test_preview_null_refund_total_with_quantity_uses_quantity(): void {
+ // null means "use the quantity form" — mirrors the create endpoint,
+ // where null means "compute the total for me".
+ $order = $this->create_order_with_product( 20.00, 2 );
+ $item_id = $this->get_first_line_item_id( $order );
+
+ $response = $this->do_preview_request(
+ $order->get_id(),
+ array(
+ array(
+ 'line_item_id' => $item_id,
+ 'quantity' => 1,
+ 'refund_total' => null,
+ ),
+ )
+ );
+
+ $this->assertEquals( 200, $response->get_status() );
+ $data = $response->get_data();
+ $this->assertEquals( '20.00', $data['total'] );
+ $this->assertSame( 1, $data['breakdown']['products']['items'][0]['quantity'] );
+ }
+
+ /**
+ * @testdox Partial amount preview on a product returns 422 when refund_total exceeds remaining after prior partial refund.
+ */
+ public function test_preview_partial_amount_product_exceeds_remaining_returns_422(): void {
+ $order = $this->create_order_with_product( 50.00, 1 );
+ $item_id = $this->get_first_line_item_id( $order );
+
+ // First partial refund: $30 of the $50 product.
+ wc_create_refund(
+ array(
+ 'order_id' => $order->get_id(),
+ 'amount' => 30.00,
+ 'line_items' => array(
+ $item_id => array(
+ 'qty' => 0,
+ 'refund_total' => 30.00,
+ 'refund_tax' => array(),
+ ),
+ ),
+ )
+ );
+
+ // Try to refund $25, but only $20 remains.
+ $response = $this->do_preview_request(
+ $order->get_id(),
+ array(
+ array(
+ 'line_item_id' => $item_id,
+ 'refund_total' => 25.00,
+ ),
+ )
+ );
+
+ $this->assertEquals( 422, $response->get_status() );
+ $data = $response->get_data();
+ $this->assertEquals( 'refund_total_exceeds_remaining', $data['code'] );
+ }
+
+ /**
+ * @testdox Partial amount preview splits tax so subtotal + tax equals the requested total exactly.
+ */
+ public function test_preview_partial_amount_tax_split_reconstitutes_total(): void {
+ $tax_rate_id = $this->create_tax_rate( 10.0 );
+ // $100 net + $10 tax = $110 incl. Refund $55 (half): expect 50.00 net + 5.00 tax.
+ $order = $this->create_order_with_product_and_tax( 100.00, 1, $tax_rate_id, 10.00 );
+ $item_id = $this->get_first_line_item_id( $order );
+
+ $response = $this->do_preview_request(
+ $order->get_id(),
+ array(
+ array(
+ 'line_item_id' => $item_id,
+ 'refund_total' => 55.00,
+ ),
+ )
+ );
+
+ $this->assertEquals( 200, $response->get_status() );
+ $data = $response->get_data();
+ $item = $data['breakdown']['products']['items'][0];
+
+ $this->assertEquals( '50.00', $item['subtotal'], 'Net subtotal should be half of the $100 net.' );
+ $this->assertEquals( '5.00', $item['tax'], 'Tax should be half of the $10 stored tax.' );
+ $this->assertEquals( '55.00', $item['total'], 'Total should equal the requested refund_total.' );
+ $this->assertEquals(
+ $item['total'],
+ wc_format_decimal( (float) $item['subtotal'] + (float) $item['tax'], wc_get_price_decimals() ),
+ 'subtotal + tax must reconstitute the total to the cent.'
+ );
+ }
+
+ /**
+ * @testdox Partial amount preview splits tax by the line's stored ratio, not the tax rate percent.
+ */
+ public function test_preview_partial_amount_non_proportional_stored_tax(): void {
+ // Tax rate is labelled 10% but the stored line tax is $8 on a $100 net ($108 incl).
+ // A rate-based split of a $54 refund would extract 54 - 54/1.10 = $4.91; the correct
+ // proportional split is 54 * 8/108 = $4.00.
+ $tax_rate_id = $this->create_tax_rate( 10.0 );
+ $order = $this->create_order_with_product_and_tax( 100.00, 1, $tax_rate_id, 8.00 );
+ $item_id = $this->get_first_line_item_id( $order );
+
+ $response = $this->do_preview_request(
+ $order->get_id(),
+ array(
+ array(
+ 'line_item_id' => $item_id,
+ 'refund_total' => 54.00,
+ ),
+ )
+ );
+
+ $this->assertEquals( 200, $response->get_status() );
+ $item = $response->get_data()['breakdown']['products']['items'][0];
+
+ $this->assertEquals( '4.00', $item['tax'], 'Tax must be split by the stored 8/108 ratio, not the 10% rate.' );
+ $this->assertEquals( '50.00', $item['subtotal'] );
+ }
+
+ /**
+ * @testdox Partial amount preview keeps charged tax even when the order tax rate resolves to zero.
+ */
+ public function test_preview_partial_amount_zero_rate_taxed_line(): void {
+ // A line that was charged $10 tax but whose order tax item has a 0% rate. A rate-based
+ // split would zero the tax out; the proportional split keeps it ($55 * 10/110 = $5.00).
+ $tax_rate_id = $this->create_tax_rate( 0.0 );
+ $order = $this->create_order_with_product_and_tax( 100.00, 1, $tax_rate_id, 10.00 );
+ $item_id = $this->get_first_line_item_id( $order );
+
+ $response = $this->do_preview_request(
+ $order->get_id(),
+ array(
+ array(
+ 'line_item_id' => $item_id,
+ 'refund_total' => 55.00,
+ ),
+ )
+ );
+
+ $this->assertEquals( 200, $response->get_status() );
+ $item = $response->get_data()['breakdown']['products']['items'][0];
+
+ $this->assertEquals( '5.00', $item['tax'], 'Charged tax must not be dropped when the rate is zero.' );
+ $this->assertEquals( '50.00', $item['subtotal'] );
+ }
+
+ /**
+ * @testdox A partial-amount preview matches the line totals stored on the created refund.
+ */
+ public function test_preview_amount_matches_created_refund(): void {
+ $tax_rate_id = $this->create_tax_rate( 10.0 );
+ $order = $this->create_order_with_product_and_tax( 100.00, 1, $tax_rate_id, 10.00 );
+ $item_id = $this->get_first_line_item_id( $order );
+
+ $preview_response = $this->do_preview_request(
+ $order->get_id(),
+ array(
+ array(
+ 'line_item_id' => $item_id,
+ 'refund_total' => 55.00,
+ ),
+ )
+ );
+ $this->assertEquals( 200, $preview_response->get_status() );
+ $preview_item = $preview_response->get_data()['breakdown']['products']['items'][0];
+
+ $create_request = new WP_REST_Request( 'POST', '/wc/v4/refunds' );
+ $create_request->set_body_params(
+ array(
+ 'order_id' => $order->get_id(),
+ 'line_items' => array(
+ array(
+ 'line_item_id' => $item_id,
+ 'refund_total' => 55.00,
+ ),
+ ),
+ )
+ );
+ $create_response = $this->server->dispatch( $create_request );
+ $this->assertEquals( 201, $create_response->get_status() );
+
+ $refund = wc_get_order( $create_response->get_data()['id'] );
+ $refund_items = $refund->get_items();
+ $refund_item = reset( $refund_items );
+ $dp = wc_get_price_decimals();
+
+ $this->assertEquals(
+ $preview_item['subtotal'],
+ wc_format_decimal( abs( (float) $refund_item->get_total() ), $dp ),
+ 'Created refund line net total must match the previewed subtotal.'
+ );
+ $this->assertEquals(
+ $preview_item['tax'],
+ wc_format_decimal( abs( (float) $refund_item->get_total_tax() ), $dp ),
+ 'Created refund line tax must match the previewed tax.'
+ );
+ }
+
+ /**
+ * @testdox A sub-cent refund_total is rounded to currency precision identically in preview and create.
+ */
+ public function test_preview_partial_amount_rounds_to_currency_precision(): void {
+ $order = $this->create_order_with_product( 100.00, 1 );
+ $item_id = $this->get_first_line_item_id( $order );
+
+ $preview_response = $this->do_preview_request(
+ $order->get_id(),
+ array(
+ array(
+ 'line_item_id' => $item_id,
+ 'refund_total' => 33.337,
+ ),
+ )
+ );
+ $this->assertEquals( 200, $preview_response->get_status() );
+ $this->assertEquals( '33.34', $preview_response->get_data()['total'], 'Preview total should round to currency precision.' );
+
+ $create_request = new WP_REST_Request( 'POST', '/wc/v4/refunds' );
+ $create_request->set_body_params(
+ array(
+ 'order_id' => $order->get_id(),
+ 'line_items' => array(
+ array(
+ 'line_item_id' => $item_id,
+ 'refund_total' => 33.337,
+ ),
+ ),
+ )
+ );
+ $create_response = $this->server->dispatch( $create_request );
+ $this->assertEquals( 201, $create_response->get_status() );
+ $this->assertEquals( '33.34', $create_response->get_data()['amount'], 'Create amount must round identically to the preview.' );
+ }
+
+ /**
+ * @testdox Partial amount preview succeeds when refund_total exactly equals the line item total.
+ */
+ public function test_preview_partial_amount_equal_to_line_total_succeeds(): void {
+ $order = $this->create_order_with_product( 20.00, 1 );
+ $item_id = $this->get_first_line_item_id( $order );
+
+ $response = $this->do_preview_request(
+ $order->get_id(),
+ array(
+ array(
+ 'line_item_id' => $item_id,
+ 'refund_total' => 20.00,
+ ),
+ )
+ );
+
+ $this->assertEquals( 200, $response->get_status(), 'A refund_total equal to the line total must be accepted.' );
+ $this->assertEquals( '20.00', $response->get_data()['total'] );
+ }
+
+ /**
+ * @testdox Partial amount preview returns 422 line_item_already_refunded when the line is fully refunded.
+ */
+ public function test_preview_partial_amount_fully_refunded_line_returns_422_already_refunded(): void {
+ // Two-line order so the order itself stays refundable (the fee remains) while the
+ // product line is fully refunded — otherwise the order-level guard fires first.
+ $order = $this->create_order_with_product( 50.00, 1 );
+ $item_id = $this->get_first_line_item_id( $order );
+
+ $fee = new \WC_Order_Item_Fee();
+ $fee->set_props(
+ array(
+ 'name' => 'Service fee',
+ 'total' => 10.00,
+ )
+ );
+ $fee->save();
+ $order->add_item( $fee );
+ $order->set_total( 60.00 );
+ $order->save();
+
+ wc_create_refund(
+ array(
+ 'order_id' => $order->get_id(),
+ 'amount' => 50.00,
+ 'line_items' => array(
+ $item_id => array(
+ 'qty' => 1,
+ 'refund_total' => 50.00,
+ 'refund_tax' => array(),
+ ),
+ ),
+ )
+ );
+
+ $response = $this->do_preview_request(
+ $order->get_id(),
+ array(
+ array(
+ 'line_item_id' => $item_id,
+ 'refund_total' => 5.00,
+ ),
+ )
+ );
+
+ $this->assertEquals( 422, $response->get_status() );
+ $this->assertEquals( 'line_item_already_refunded', $response->get_data()['code'] );
+ }
+
+ /**
+ * @testdox Preview returns 400 duplicate_line_item when the same line item appears more than once.
+ */
+ public function test_preview_duplicate_line_item_returns_400(): void {
+ // Without dedup, each entry validates against the same remaining snapshot, so two
+ // $8 entries on a $10 line would each pass the per-line cap and double-count.
+ $order = $this->create_order_with_fee( 10.00 );
+ $items = $order->get_items( 'fee' );
+ $item = reset( $items );
+
+ $response = $this->do_preview_request(
+ $order->get_id(),
+ array(
+ array(
+ 'line_item_id' => $item->get_id(),
+ 'refund_total' => 8.00,
+ ),
+ array(
+ 'line_item_id' => $item->get_id(),
+ 'refund_total' => 8.00,
+ ),
+ )
+ );
+
+ $this->assertEquals( 400, $response->get_status() );
+ $this->assertEquals( 'duplicate_line_item', $response->get_data()['code'] );
+ }
+
+ /**
+ * @testdox Partial amount preview rounds to currency precision on a zero-decimal currency.
+ */
+ public function test_preview_partial_amount_zero_decimal_currency(): void {
+ $tax_rate_id = $this->create_tax_rate( 10.0 );
+ // $100 net + $10 tax = $110 incl, stored at 2dp before the currency switch.
+ $order = $this->create_order_with_product_and_tax( 100.00, 1, $tax_rate_id, 10.00 );
+ $item_id = $this->get_first_line_item_id( $order );
+
+ // Switch to a zero-decimal currency for the request only.
+ add_filter( 'wc_get_price_decimals', '__return_zero' );
+
+ try {
+ $response = $this->do_preview_request(
+ $order->get_id(),
+ array(
+ array(
+ 'line_item_id' => $item_id,
+ 'refund_total' => 55.4,
+ ),
+ )
+ );
+
+ $this->assertEquals( 200, $response->get_status() );
+ $item = $response->get_data()['breakdown']['products']['items'][0];
+
+ // 55.4 rounds to 55 at 0dp; the 10% split gives whole-unit 50 net / 5 tax.
+ $this->assertEquals( '55', $item['total'], 'Total should round to a whole unit.' );
+ $this->assertEquals( '5', $item['tax'] );
+ $this->assertEquals( '50', $item['subtotal'] );
+ } finally {
+ remove_filter( 'wc_get_price_decimals', '__return_zero' );
+ }
+ }
+
+ /**
+ * @testdox Preview rejects a quantity exceeding refundable units even when refund_total is supplied, matching create.
+ */
+ public function test_preview_quantity_with_refund_total_exceeding_units_matches_create(): void {
+ $order = $this->create_order_with_product( 10.00, 1 );
+ $item_id = $this->get_first_line_item_id( $order );
+
+ $line_items = array(
+ array(
+ 'line_item_id' => $item_id,
+ 'quantity' => 2,
+ 'refund_total' => 1.00,
+ ),
+ );
+
+ $preview_response = $this->do_preview_request( $order->get_id(), $line_items );
+ $this->assertEquals( 422, $preview_response->get_status(), 'Preview must reject a quantity over the refundable units.' );
+ $this->assertEquals( 'quantity_exceeds_refundable', $preview_response->get_data()['code'] );
+
+ $create_request = new WP_REST_Request( 'POST', '/wc/v4/refunds' );
+ $create_request->set_body_params(
+ array(
+ 'order_id' => $order->get_id(),
+ 'line_items' => $line_items,
+ )
+ );
+ $create_response = $this->server->dispatch( $create_request );
+ $this->assertEquals( 422, $create_response->get_status(), 'Create rejects the same input, so preview must not return 200.' );
+ $this->assertEquals( 'quantity_exceeds_refundable', $create_response->get_data()['code'] );
+ }
+
+ /**
+ * @testdox Preview rejects a product quantity refund that exceeds the remaining line amount after a prior amount-only refund, matching create.
+ */
+ public function test_preview_product_quantity_after_amount_refund_matches_create(): void {
+ $order = $this->create_order_with_product( 100.00, 2 );
+ $item_id = $this->get_first_line_item_id( $order );
+
+ // Prior amount-only refund of $150 on the line (no units consumed: qty 0).
+ wc_create_refund(
+ array(
+ 'order_id' => $order->get_id(),
+ 'amount' => 150.00,
+ 'line_items' => array(
+ $item_id => array(
+ 'qty' => 0,
+ 'refund_total' => 150.00,
+ 'refund_tax' => array(),
+ ),
+ ),
+ )
+ );
+
+ $line_items = array(
+ array(
+ 'line_item_id' => $item_id,
+ 'quantity' => 2,
+ ),
+ );
+
+ // Preview must not return 200 while create rejects the same auto-filled $200 over-refund.
+ $preview_response = $this->do_preview_request( $order->get_id(), $line_items );
+ $this->assertEquals( 422, $preview_response->get_status() );
+ $this->assertEquals( 'refund_total_exceeds_remaining', $preview_response->get_data()['code'] );
+
+ $create_request = new WP_REST_Request( 'POST', '/wc/v4/refunds' );
+ $create_request->set_body_params(
+ array(
+ 'order_id' => $order->get_id(),
+ 'line_items' => $line_items,
+ )
+ );
+ $create_response = $this->server->dispatch( $create_request );
+ $this->assertEquals( 422, $create_response->get_status() );
+ $this->assertEquals( 'refund_total_exceeds_remaining', $create_response->get_data()['code'] );
+ }
+
+ /**
+ * @testdox Preview accepts a mixed refund with a negative discount-fee line, matching create.
+ */
+ public function test_preview_partial_amount_negative_fee_matches_create(): void {
+ $order = $this->create_order_with_product( 50.00, 1 );
+ $item_id = $this->get_first_line_item_id( $order );
+
+ $fee = new \WC_Order_Item_Fee();
+ $fee->set_props(
+ array(
+ 'name' => 'Discount',
+ 'total' => -10.00,
+ )
+ );
+ $fee->save();
+ $order->add_item( $fee );
+ $order->set_total( 40.00 );
+ $order->save();
+
+ // Refund the full product line and the full discount: net $40, the order total.
+ $line_items = array(
+ array(
+ 'line_item_id' => $item_id,
+ 'refund_total' => 50.00,
+ ),
+ array(
+ 'line_item_id' => $fee->get_id(),
+ 'refund_total' => -10.00,
+ ),
+ );
+
+ $preview_response = $this->do_preview_request( $order->get_id(), $line_items );
+ $this->assertEquals( 200, $preview_response->get_status(), 'Preview must accept the negative discount-fee line.' );
+ $this->assertEquals( '40.00', $preview_response->get_data()['total'] );
+
+ $create_request = new WP_REST_Request( 'POST', '/wc/v4/refunds' );
+ $create_request->set_body_params(
+ array(
+ 'order_id' => $order->get_id(),
+ 'line_items' => $line_items,
+ )
+ );
+ $create_response = $this->server->dispatch( $create_request );
+ $this->assertEquals( 201, $create_response->get_status(), 'Create accepts the same mixed request.' );
+ $this->assertEquals( '40.00', $create_response->get_data()['amount'], 'Create amount must match the preview total.' );
+ }
+
+ /**
+ * @testdox Preview rejects a refund whose aggregate total is negative, matching create.
+ */
+ public function test_preview_negative_only_total_rejected_matches_create(): void {
+ $order = $this->create_order_with_product( 50.00, 1 );
+ $item_id = $this->get_first_line_item_id( $order );
+
+ $fee = new \WC_Order_Item_Fee();
+ $fee->set_props(
+ array(
+ 'name' => 'Discount',
+ 'total' => -10.00,
+ )
+ );
+ $fee->save();
+ $order->add_item( $fee );
+ $order->set_total( 40.00 );
+ $order->save();
+
+ // A refund of only the negative discount line nets -$5.
+ $line_items = array(
+ array(
+ 'line_item_id' => $fee->get_id(),
+ 'refund_total' => -5.00,
+ ),
+ );
+
+ $preview_response = $this->do_preview_request( $order->get_id(), $line_items );
+ $this->assertNotEquals( 200, $preview_response->get_status(), 'Preview must not accept a non-positive aggregate total.' );
+ $this->assertEquals( 'invalid_refund_amount', $preview_response->get_data()['code'] );
+
+ $create_request = new WP_REST_Request( 'POST', '/wc/v4/refunds' );
+ $create_request->set_body_params(
+ array(
+ 'order_id' => $order->get_id(),
+ 'line_items' => $line_items,
+ )
+ );
+ $create_response = $this->server->dispatch( $create_request );
+ $this->assertEquals( 'invalid_refund_amount', $create_response->get_data()['code'], 'Create rejects the same input.' );
+ }
+
+ /**
+ * @testdox Preview rejects a refund whose aggregate total nets to zero, matching create.
+ */
+ public function test_preview_zero_net_total_rejected_matches_create(): void {
+ // $20 product less a $10 discount: order stays refundable ($10), but the refund nets $0.
+ $order = $this->create_order_with_product( 20.00, 1 );
+ $item_id = $this->get_first_line_item_id( $order );
+
+ $fee = new \WC_Order_Item_Fee();
+ $fee->set_props(
+ array(
+ 'name' => 'Discount',
+ 'total' => -10.00,
+ )
+ );
+ $fee->save();
+ $order->add_item( $fee );
+ $order->set_total( 10.00 );
+ $order->save();
+
+ $line_items = array(
+ array(
+ 'line_item_id' => $item_id,
+ 'refund_total' => 10.00,
+ ),
+ array(
+ 'line_item_id' => $fee->get_id(),
+ 'refund_total' => -10.00,
+ ),
+ );
+
+ $preview_response = $this->do_preview_request( $order->get_id(), $line_items );
+ $this->assertNotEquals( 200, $preview_response->get_status(), 'Preview must not accept a zero aggregate total.' );
+ $this->assertEquals( 'invalid_refund_amount', $preview_response->get_data()['code'] );
+
+ $create_request = new WP_REST_Request( 'POST', '/wc/v4/refunds' );
+ $create_request->set_body_params(
+ array(
+ 'order_id' => $order->get_id(),
+ 'line_items' => $line_items,
+ )
+ );
+ $create_response = $this->server->dispatch( $create_request );
+ $this->assertEquals( 'invalid_refund_amount', $create_response->get_data()['code'], 'Create rejects the same input.' );
+ }
+
// -- Helper methods --
/**
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 2b9330eb9aa..2443679d0f6 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
@@ -505,6 +505,392 @@ class DataUtilsTest extends WC_Unit_Test_Case {
$this->assertEquals( 'order_not_refundable', $result->get_error_code() );
}
+ /**
+ * @testdox validate_line_items rejects an order whose status is not refundable, mirroring the preview path.
+ *
+ * @dataProvider provider_non_refundable_statuses
+ *
+ * @param string $status Non-refundable order status.
+ */
+ public function test_validate_line_items_order_not_refundable( string $status ): void {
+ $order = $this->create_order_with_taxes( array(), 50.00 );
+ $order->set_status( $status );
+ $order->save();
+
+ $items = $order->get_items( 'line_item' );
+ $item = reset( $items );
+
+ $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( 'order_not_refundable', $result->get_error_code() );
+ }
+
+ /**
+ * @return array<string, array<int, string>>
+ */
+ public function provider_non_refundable_statuses(): array {
+ return array(
+ 'cancelled' => array( OrderStatus::CANCELLED ),
+ 'pending' => array( OrderStatus::PENDING ),
+ 'failed' => array( OrderStatus::FAILED ),
+ 'refunded' => array( OrderStatus::REFUNDED ),
+ );
+ }
+
+ /**
+ * @testdox validate_line_items rejects an explicit refund_total of zero, matching the preview path.
+ *
+ * @dataProvider provider_zero_refund_totals
+ *
+ * @param mixed $refund_total The zero-equivalent refund_total to test.
+ */
+ public function test_validate_line_items_rejects_zero_refund_total( $refund_total ): void {
+ $order = $this->create_order_with_taxes( array(), 50.00 );
+ $order->set_status( OrderStatus::COMPLETED );
+ $order->save();
+
+ $items = $order->get_items( 'line_item' );
+ $item = reset( $items );
+
+ $result = $this->data_utils->validate_line_items(
+ array(
+ array(
+ 'line_item_id' => $item->get_id(),
+ 'refund_total' => $refund_total,
+ ),
+ ),
+ $order
+ );
+
+ $this->assertInstanceOf( \WP_Error::class, $result );
+ $this->assertEquals( 'invalid_refund_total', $result->get_error_code() );
+ }
+
+ /**
+ * @return array<string, array<int, mixed>>
+ */
+ public function provider_zero_refund_totals(): array {
+ return array(
+ 'int zero' => array( 0 ),
+ 'float zero' => array( 0.0 ),
+ 'rounds to zero' => array( 0.001 ),
+ );
+ }
+
+ /**
+ * @testdox validate_line_items caps explicit refund_tax against the remaining per-tax-id amount, not the original line tax.
+ */
+ public function test_validate_line_items_refund_tax_capped_against_remaining_per_tax_id(): 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' => '',
+ )
+ );
+
+ // $100 net + $10 tax (rate VAT) = $110 line total.
+ $order = $this->create_order_with_taxes( array( $tax_rate_id ), 100.00 );
+ $order->set_status( OrderStatus::COMPLETED );
+ $order->save();
+
+ $items = $order->get_items( 'line_item' );
+ $item = reset( $items );
+ $item_id = $item->get_id();
+
+ // Prior refund consumes $8 of the $10 tax bucket, leaving $2 remaining.
+ wc_create_refund(
+ array(
+ 'order_id' => $order->get_id(),
+ 'amount' => 88.00,
+ 'line_items' => array(
+ $item_id => array(
+ 'qty' => 0,
+ 'refund_total' => 80.00,
+ 'refund_tax' => array( $tax_rate_id => 8.00 ),
+ ),
+ ),
+ )
+ );
+
+ // A second refund claiming $5 of the same tax bucket exceeds the $2 remaining.
+ $result = $this->data_utils->validate_line_items(
+ array(
+ array(
+ 'line_item_id' => $item_id,
+ 'refund_total' => 5.00,
+ 'refund_tax' => array(
+ array(
+ 'id' => $tax_rate_id,
+ 'refund_total' => 5.00,
+ ),
+ ),
+ ),
+ ),
+ $order
+ );
+
+ $this->assertInstanceOf( \WP_Error::class, $result, 'Refund tax exceeding the remaining bucket must be rejected.' );
+ $this->assertEquals( 'invalid_refund_amount', $result->get_error_code() );
+ }
+
+ /**
+ * Build a completed order with a positive product line and a discount fee that carries a
+ * negative stored tax bucket, for the negative-tax refund_tax cap tests.
+ *
+ * @param int $tax_rate_id Tax rate id used for the fee's stored tax bucket.
+ * @return array{0: WC_Order, 1: int} The order and the fee line item id.
+ */
+ private function create_order_with_negative_tax_fee( int $tax_rate_id ): array {
+ $product = WC_Helper_Product::create_simple_product();
+ $product->set_regular_price( 50.00 );
+ $product->save();
+
+ $order = wc_create_order();
+ $item = new WC_Order_Item_Product();
+ $item->set_props(
+ array(
+ 'product' => $product,
+ 'quantity' => 1,
+ 'subtotal' => 50.00,
+ 'total' => 50.00,
+ )
+ );
+ $item->save();
+ $order->add_item( $item );
+
+ $fee = new WC_Order_Item_Fee();
+ $fee->set_props(
+ array(
+ 'name' => 'Loyalty discount',
+ 'total' => -10.00,
+ )
+ );
+ $fee->set_taxes( array( 'total' => array( $tax_rate_id => -1.00 ) ) );
+ $fee->save();
+ $order->add_item( $fee );
+
+ $order->set_total( 39.00 );
+ $order->set_status( OrderStatus::COMPLETED );
+ $order->save();
+
+ $product->delete( true );
+
+ return array( $order, $fee->get_id() );
+ }
+
+ /**
+ * @testdox validate_line_items accepts a partial negative refund_tax within a negative stored tax bucket.
+ */
+ public function test_validate_line_items_negative_tax_bucket_partial_refund_passes(): void {
+ $tax_rate_id = WC_Tax::_insert_tax_rate(
+ array(
+ 'tax_rate_country' => 'US',
+ 'tax_rate' => '10.0000',
+ 'tax_rate_name' => 'VAT',
+ 'tax_rate_order' => '1',
+ )
+ );
+ list( $order, $fee_id ) = $this->create_order_with_negative_tax_fee( $tax_rate_id );
+
+ // Refund half of the -$1.00 tax bucket. Same sign, within the magnitude cap.
+ $result = $this->data_utils->validate_line_items(
+ array(
+ array(
+ 'line_item_id' => $fee_id,
+ 'refund_total' => -5.00,
+ 'refund_tax' => array(
+ array(
+ 'id' => $tax_rate_id,
+ 'refund_total' => -0.50,
+ ),
+ ),
+ ),
+ ),
+ $order
+ );
+
+ $this->assertTrue( $result, 'A partial negative refund_tax within the bucket must be accepted.' );
+
+ $order->delete( true );
+ }
+
+ /**
+ * @testdox validate_line_items rejects a negative refund_tax that exceeds the negative stored tax bucket magnitude.
+ */
+ public function test_validate_line_items_negative_tax_bucket_over_refund_rejected(): void {
+ $tax_rate_id = WC_Tax::_insert_tax_rate(
+ array(
+ 'tax_rate_country' => 'US',
+ 'tax_rate' => '10.0000',
+ 'tax_rate_name' => 'VAT',
+ 'tax_rate_order' => '1',
+ )
+ );
+ list( $order, $fee_id ) = $this->create_order_with_negative_tax_fee( $tax_rate_id );
+
+ // -$2.00 exceeds the -$1.00 bucket magnitude.
+ $result = $this->data_utils->validate_line_items(
+ array(
+ array(
+ 'line_item_id' => $fee_id,
+ 'refund_total' => -5.00,
+ 'refund_tax' => array(
+ array(
+ 'id' => $tax_rate_id,
+ 'refund_total' => -2.00,
+ ),
+ ),
+ ),
+ ),
+ $order
+ );
+
+ $this->assertInstanceOf( \WP_Error::class, $result, 'A negative refund_tax over the bucket magnitude must be rejected.' );
+ $this->assertEquals( 'invalid_refund_amount', $result->get_error_code() );
+
+ $order->delete( true );
+ }
+
+ /**
+ * @testdox validate_line_items rejects a positive refund_tax against a negative stored tax bucket (wrong sign).
+ */
+ public function test_validate_line_items_wrong_sign_tax_refund_rejected(): void {
+ $tax_rate_id = WC_Tax::_insert_tax_rate(
+ array(
+ 'tax_rate_country' => 'US',
+ 'tax_rate' => '10.0000',
+ 'tax_rate_name' => 'VAT',
+ 'tax_rate_order' => '1',
+ )
+ );
+ list( $order, $fee_id ) = $this->create_order_with_negative_tax_fee( $tax_rate_id );
+
+ $result = $this->data_utils->validate_line_items(
+ array(
+ array(
+ 'line_item_id' => $fee_id,
+ 'refund_total' => -5.00,
+ 'refund_tax' => array(
+ array(
+ 'id' => $tax_rate_id,
+ 'refund_total' => 0.50,
+ ),
+ ),
+ ),
+ ),
+ $order
+ );
+
+ $this->assertInstanceOf( \WP_Error::class, $result, 'A positive refund_tax on a negative bucket must be rejected.' );
+ $this->assertEquals( 'invalid_refund_amount', $result->get_error_code() );
+
+ $order->delete( true );
+ }
+
+ /**
+ * @testdox validate_line_items caps the gross (refund_total + explicit refund_tax) against the line total.
+ *
+ * With an explicit refund_tax breakdown, refund_total is the tax-exclusive subtotal
+ * and the tax is added on top. A refund_total within the line that pushes the gross
+ * over the line total via refund_tax must be rejected.
+ */
+ public function test_validate_line_items_gross_with_explicit_tax_exceeds_line_rejected(): void {
+ $tax_rate_id = WC_Tax::_insert_tax_rate(
+ array(
+ 'tax_rate_country' => 'US',
+ 'tax_rate' => '10.0000',
+ 'tax_rate_name' => 'VAT',
+ 'tax_rate_order' => '1',
+ )
+ );
+
+ // $50 net + $5 tax = $55 tax-inclusive line.
+ $order = $this->create_order_with_taxes( array( $tax_rate_id ), 50.00 );
+ $order->set_status( OrderStatus::COMPLETED );
+ $order->save();
+ $items = $order->get_items( 'line_item' );
+ $item = reset( $items );
+
+ // Net 51 + tax 5 = gross 56 > 55.
+ $result = $this->data_utils->validate_line_items(
+ array(
+ array(
+ 'line_item_id' => $item->get_id(),
+ 'refund_total' => 51.00,
+ 'refund_tax' => array(
+ array(
+ 'id' => $tax_rate_id,
+ 'refund_total' => 5.00,
+ ),
+ ),
+ ),
+ ),
+ $order
+ );
+
+ $this->assertInstanceOf( \WP_Error::class, $result, 'Gross refund over the line total must be rejected.' );
+ $this->assertEquals( 'refund_total_exceeds_line', $result->get_error_code() );
+
+ $order->delete( true );
+ }
+
+ /**
+ * @testdox validate_line_items accepts a gross (refund_total + explicit refund_tax) equal to the line total.
+ */
+ public function test_validate_line_items_gross_with_explicit_tax_within_line_passes(): void {
+ $tax_rate_id = WC_Tax::_insert_tax_rate(
+ array(
+ 'tax_rate_country' => 'US',
+ 'tax_rate' => '10.0000',
+ 'tax_rate_name' => 'VAT',
+ 'tax_rate_order' => '1',
+ )
+ );
+
+ $order = $this->create_order_with_taxes( array( $tax_rate_id ), 50.00 );
+ $order->set_status( OrderStatus::COMPLETED );
+ $order->save();
+ $items = $order->get_items( 'line_item' );
+ $item = reset( $items );
+
+ // Net 50 + tax 5 = gross 55 == line total.
+ $result = $this->data_utils->validate_line_items(
+ array(
+ array(
+ 'line_item_id' => $item->get_id(),
+ 'refund_total' => 50.00,
+ 'refund_tax' => array(
+ array(
+ 'id' => $tax_rate_id,
+ 'refund_total' => 5.00,
+ ),
+ ),
+ ),
+ ),
+ $order
+ );
+
+ $this->assertTrue( $result, 'A gross equal to the line total must be accepted.' );
+
+ $order->delete( true );
+ }
+
/**
* @testdox Should return 0.0 for product line item with zero original quantity.
*/
@@ -849,17 +1235,19 @@ class DataUtilsTest extends WC_Unit_Test_Case {
$order->save();
$this->expectException( \InvalidArgumentException::class );
- $this->data_utils->build_refund_preview(
- $order,
- array(
+ try {
+ $this->data_utils->build_refund_preview(
+ $order,
array(
- 'line_item_id' => 999999,
- 'quantity' => 1,
- ),
- )
- );
-
- $order->delete( true );
+ array(
+ 'line_item_id' => 999999,
+ 'quantity' => 1,
+ ),
+ )
+ );
+ } finally {
+ $order->delete( true );
+ }
}
/**
@@ -996,7 +1384,7 @@ class DataUtilsTest extends WC_Unit_Test_Case {
}
/**
- * @testdox Should return invalid_quantity for missing, zero, negative, string, or float quantity values.
+ * @testdox Should return missing_quantity_or_refund_total when neither a valid quantity nor refund_total is provided.
*
* @dataProvider provider_invalid_quantities_for_validate
*
@@ -1014,7 +1402,7 @@ class DataUtilsTest extends WC_Unit_Test_Case {
$result = $this->data_utils->validate_preview_line_items( array( $line_item ), $order );
$this->assertInstanceOf( \WP_Error::class, $result );
- $this->assertEquals( 'invalid_quantity', $result->get_error_code() );
+ $this->assertEquals( 'missing_quantity_or_refund_total', $result->get_error_code() );
}
/**
@@ -1031,6 +1419,51 @@ class DataUtilsTest extends WC_Unit_Test_Case {
);
}
+ /**
+ * @testdox Should return invalid_refund_total when refund_total is present but not a positive number.
+ *
+ * @dataProvider provider_invalid_refund_totals_for_validate
+ *
+ * @param array<string, mixed> $line_item_overrides Keys to merge into the test line item.
+ */
+ public function test_validate_preview_line_items_invalid_refund_total( array $line_item_overrides ): void {
+ $order = $this->create_order_with_taxes( array(), 50.00 );
+ $order->set_status( OrderStatus::COMPLETED );
+ $order->save();
+ $items = $order->get_items( 'line_item' );
+ $item = reset( $items );
+
+ $line_item = array_merge( array( 'line_item_id' => $item->get_id() ), $line_item_overrides );
+
+ $result = $this->data_utils->validate_preview_line_items( array( $line_item ), $order );
+
+ $this->assertInstanceOf( \WP_Error::class, $result );
+ $this->assertEquals( 'invalid_refund_total', $result->get_error_code() );
+ }
+
+ /**
+ * @return array<string, array<array<string, mixed>>>
+ */
+ public function provider_invalid_refund_totals_for_validate(): array {
+ return array(
+ 'zero' => array( array( 'refund_total' => 0 ) ),
+ 'zero with quantity' => array(
+ array(
+ 'quantity' => 1,
+ 'refund_total' => 0,
+ ),
+ ),
+ 'negative' => array( array( 'refund_total' => -5.00 ) ),
+ 'negative with quantity' => array(
+ array(
+ 'quantity' => 1,
+ 'refund_total' => -5.00,
+ ),
+ ),
+ 'non-numeric string' => array( array( 'refund_total' => 'abc' ) ),
+ );
+ }
+
/**
* @testdox Should reject shipping/fee items with quantity other than 1.
*/
@@ -1115,12 +1548,12 @@ class DataUtilsTest extends WC_Unit_Test_Case {
}
/**
- * @testdox Should return quantity_exceeds_refundable when a partially-refunded shipping line cannot fit a full preview at its original total.
+ * @testdox Should return refund_total_exceeds_remaining when a partially-refunded shipping line cannot fit a full preview at its original total.
*
* Order has a $10 shipping line + a $50 product line so the order is still
* refundable after a $5 partial shipping refund. Previewing the shipping
* line at qty=1 would refund the full $10 — exceeds the $5 remaining on
- * that line — so validation must reject with `quantity_exceeds_refundable`.
+ * that line — so validation must reject with `refund_total_exceeds_remaining`.
* Without the per-line cap, validate would pass and `build_refund_preview`
* would return an oversized total.
*/
@@ -1183,7 +1616,7 @@ class DataUtilsTest extends WC_Unit_Test_Case {
);
$this->assertInstanceOf( \WP_Error::class, $result );
- $this->assertEquals( 'quantity_exceeds_refundable', $result->get_error_code() );
+ $this->assertEquals( 'refund_total_exceeds_remaining', $result->get_error_code() );
$product->delete( true );
$order->delete( true );
@@ -1229,64 +1662,366 @@ class DataUtilsTest extends WC_Unit_Test_Case {
$item->save();
$order->add_item( $item );
- $shipping = new WC_Order_Item_Shipping();
- $shipping->set_props(
+ $shipping = new WC_Order_Item_Shipping();
+ $shipping->set_props(
+ array(
+ 'method_title' => 'Flat Rate',
+ 'total' => 10.00,
+ )
+ );
+ $shipping->set_taxes( array( 'total' => array( $tax_rate_id => 1.50 ) ) );
+ $shipping->save();
+ $order->add_item( $shipping );
+
+ $tax_item = new \WC_Order_Item_Tax();
+ $tax_item->set_rate( $tax_rate_id );
+ $tax_item->set_shipping_tax_total( 1.50 );
+ $tax_item->save();
+ $order->add_item( $tax_item );
+
+ $order->set_total( 61.50 );
+ $order->set_status( OrderStatus::COMPLETED );
+ $order->save();
+
+ $result = $this->data_utils->validate_preview_line_items(
+ array(
+ array(
+ 'line_item_id' => $shipping->get_id(),
+ 'quantity' => 1,
+ ),
+ ),
+ $order
+ );
+
+ $this->assertTrue( $result, 'Full shipping refund covering line total + tax with no prior refund should pass validation.' );
+
+ $product->delete( true );
+ $order->delete( true );
+ }
+
+ /**
+ * @testdox build_refund_preview preserves the negative tax split on a fee with a negative stored tax.
+ *
+ * Regression guard: a previous implementation filtered tax IDs by `amount > 0`,
+ * which dropped negative tax entries entirely and emitted `tax: 0.00` on
+ * negative-fee discount lines. The fix keeps any non-zero stored tax so the
+ * preview returns the signed split.
+ */
+ public function test_build_refund_preview_negative_fee_with_negative_tax(): void {
+ // A 10% rate is needed so WC_Tax::calc_inclusive_tax can split a tax-inclusive total.
+ $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' => '0',
+ 'tax_rate_order' => '1',
+ 'tax_rate_class' => '',
+ )
+ );
+
+ $order = wc_create_order();
+ $fee = new WC_Order_Item_Fee();
+ $fee->set_props(
+ array(
+ 'name' => 'Loyalty discount',
+ 'total' => -10.00,
+ )
+ );
+ $fee->set_taxes( array( 'total' => array( $tax_rate_id => -1.00 ) ) );
+ $fee->save();
+ $order->add_item( $fee );
+
+ $tax_item = new \WC_Order_Item_Tax();
+ $tax_item->set_rate( $tax_rate_id );
+ $tax_item->set_tax_total( -1.00 );
+ $tax_item->save();
+ $order->add_item( $tax_item );
+
+ $order->save();
+
+ $result = $this->data_utils->build_refund_preview(
+ $order,
+ array(
+ array(
+ 'line_item_id' => $fee->get_id(),
+ 'quantity' => 1,
+ ),
+ )
+ );
+
+ // Total stays at the tax-inclusive -$11. The split between subtotal
+ // (-$10) and tax (-$1) must be preserved on the fee item entry.
+ $this->assertSame( '-11.00', $result['breakdown']['fees']['total'] );
+ $this->assertCount( 1, $result['breakdown']['fees']['items'] );
+ $this->assertEquals( '-10.00', $result['breakdown']['fees']['items'][0]['subtotal'] );
+ $this->assertEquals( '-1.00', $result['breakdown']['fees']['items'][0]['tax'] );
+ $this->assertEquals( '-11.00', $result['breakdown']['fees']['items'][0]['total'] );
+
+ $order->delete( true );
+ }
+
+ /**
+ * @testdox Should allow validating a negative-total fee (discount fee) that has no prior refund.
+ */
+ public function test_validate_preview_line_items_negative_fee_passes(): void {
+ $product = WC_Helper_Product::create_simple_product();
+ $product->set_regular_price( 50.00 );
+ $product->save();
+
+ $order = wc_create_order();
+ $item = new WC_Order_Item_Product();
+ $item->set_props(
+ array(
+ 'product' => $product,
+ 'quantity' => 1,
+ 'subtotal' => 50.00,
+ 'total' => 50.00,
+ )
+ );
+ $item->save();
+ $order->add_item( $item );
+
+ $fee = new WC_Order_Item_Fee();
+ $fee->set_props(
+ array(
+ 'name' => 'Discount',
+ 'total' => -10.00,
+ )
+ );
+ $fee->save();
+ $order->add_item( $fee );
+
+ $order->set_total( 40.00 );
+ $order->set_status( OrderStatus::COMPLETED );
+ $order->save();
+
+ $result = $this->data_utils->validate_preview_line_items(
+ array(
+ array(
+ 'line_item_id' => $fee->get_id(),
+ 'quantity' => 1,
+ ),
+ ),
+ $order
+ );
+
+ $this->assertTrue( $result, 'Negative-total fee with no prior refund should pass validation.' );
+
+ $product->delete( true );
+ $order->delete( true );
+ }
+
+ /**
+ * @testdox Preview validates a supplied quantity even when refund_total is also present (matches create).
+ *
+ * Regression guard: preview previously skipped quantity validation whenever a
+ * refund_total was supplied, so { quantity: 2, refund_total: 1 } on a 1-unit
+ * line previewed successfully but failed at create.
+ */
+ public function test_validate_preview_line_items_quantity_with_refund_total_still_validated(): void {
+ // 1-unit, no-tax product line.
+ $order = $this->create_order_with_taxes( array(), 50.00 );
+ $order->set_status( OrderStatus::COMPLETED );
+ $order->save();
+ $items = $order->get_items( 'line_item' );
+ $item = reset( $items );
+
+ $result = $this->data_utils->validate_preview_line_items(
+ array(
+ array(
+ 'line_item_id' => $item->get_id(),
+ 'quantity' => 2,
+ 'refund_total' => 1.00,
+ ),
+ ),
+ $order
+ );
+
+ $this->assertInstanceOf( \WP_Error::class, $result );
+ $this->assertEquals( 'quantity_exceeds_refundable', $result->get_error_code() );
+
+ $order->delete( true );
+ }
+
+ /**
+ * @testdox Preview caps a product quantity refund against the remaining line amount, not just units (matches create).
+ *
+ * Regression guard: an amount-only prior refund leaves all units "available" by
+ * count, so the units-only check passed, but create auto-fills refund_total and
+ * rejects the over-refund. Preview must reject it too.
+ */
+ public function test_validate_preview_line_items_product_quantity_respects_prior_amount_refund(): void {
+ $product = WC_Helper_Product::create_simple_product();
+ $product->set_regular_price( 100.00 );
+ $product->save();
+
+ $order = wc_create_order();
+ $item = new WC_Order_Item_Product();
+ $item->set_props(
+ array(
+ 'product' => $product,
+ 'quantity' => 2,
+ 'subtotal' => 200.00,
+ 'total' => 200.00,
+ )
+ );
+ $item->save();
+ $order->add_item( $item );
+ $order->set_total( 200.00 );
+ $order->set_status( OrderStatus::COMPLETED );
+ $order->save();
+
+ // Prior amount-only refund of $150 on the line (no units consumed: qty 0),
+ // leaving $50 of line amount but both units still uncounted.
+ wc_create_refund(
+ array(
+ 'order_id' => $order->get_id(),
+ 'amount' => 150.00,
+ 'line_items' => array(
+ $item->get_id() => array(
+ 'qty' => 0,
+ 'refund_total' => 150.00,
+ 'refund_tax' => array(),
+ ),
+ ),
+ )
+ );
+
+ $result = $this->data_utils->validate_preview_line_items(
+ array(
+ array(
+ 'line_item_id' => $item->get_id(),
+ 'quantity' => 2,
+ ),
+ ),
+ $order
+ );
+
+ $this->assertInstanceOf( \WP_Error::class, $result );
+ $this->assertEquals( 'refund_total_exceeds_remaining', $result->get_error_code() );
+
+ $product->delete( true );
+ $order->delete( true );
+ }
+
+ /**
+ * @testdox Preview accepts an explicit negative refund_total on a discount-fee line (matches create).
+ */
+ public function test_validate_preview_line_items_negative_fee_explicit_refund_total_passes(): void {
+ $product = WC_Helper_Product::create_simple_product();
+ $product->set_regular_price( 50.00 );
+ $product->save();
+
+ $order = wc_create_order();
+ $item = new WC_Order_Item_Product();
+ $item->set_props(
+ array(
+ 'product' => $product,
+ 'quantity' => 1,
+ 'subtotal' => 50.00,
+ 'total' => 50.00,
+ )
+ );
+ $item->save();
+ $order->add_item( $item );
+
+ $fee = new WC_Order_Item_Fee();
+ $fee->set_props(
+ array(
+ 'name' => 'Discount',
+ 'total' => -10.00,
+ )
+ );
+ $fee->save();
+ $order->add_item( $fee );
+
+ $order->set_total( 40.00 );
+ $order->set_status( OrderStatus::COMPLETED );
+ $order->save();
+
+ $result = $this->data_utils->validate_preview_line_items(
+ array(
+ array(
+ 'line_item_id' => $fee->get_id(),
+ 'refund_total' => -5.00,
+ ),
+ ),
+ $order
+ );
+
+ $this->assertTrue( $result, 'A negative refund_total on a negative line should be accepted.' );
+
+ $product->delete( true );
+ $order->delete( true );
+ }
+
+ /**
+ * @testdox Preview rejects a positive refund_total against a negative discount-fee line (matches create).
+ */
+ public function test_validate_preview_line_items_positive_refund_total_on_negative_line_rejected(): void {
+ $product = WC_Helper_Product::create_simple_product();
+ $product->set_regular_price( 50.00 );
+ $product->save();
+
+ $order = wc_create_order();
+ $item = new WC_Order_Item_Product();
+ $item->set_props(
+ array(
+ 'product' => $product,
+ 'quantity' => 1,
+ 'subtotal' => 50.00,
+ 'total' => 50.00,
+ )
+ );
+ $item->save();
+ $order->add_item( $item );
+
+ $fee = new WC_Order_Item_Fee();
+ $fee->set_props(
array(
- 'method_title' => 'Flat Rate',
- 'total' => 10.00,
+ 'name' => 'Discount',
+ 'total' => -10.00,
)
);
- $shipping->set_taxes( array( 'total' => array( $tax_rate_id => 1.50 ) ) );
- $shipping->save();
- $order->add_item( $shipping );
-
- $tax_item = new \WC_Order_Item_Tax();
- $tax_item->set_rate( $tax_rate_id );
- $tax_item->set_shipping_tax_total( 1.50 );
- $tax_item->save();
- $order->add_item( $tax_item );
+ $fee->save();
+ $order->add_item( $fee );
- $order->set_total( 61.50 );
+ $order->set_total( 40.00 );
$order->set_status( OrderStatus::COMPLETED );
$order->save();
$result = $this->data_utils->validate_preview_line_items(
array(
array(
- 'line_item_id' => $shipping->get_id(),
- 'quantity' => 1,
+ 'line_item_id' => $fee->get_id(),
+ 'refund_total' => 5.00,
),
),
$order
);
- $this->assertTrue( $result, 'Full shipping refund covering line total + tax with no prior refund should pass validation.' );
+ $this->assertInstanceOf( \WP_Error::class, $result );
+ $this->assertEquals( 'invalid_refund_total', $result->get_error_code() );
$product->delete( true );
$order->delete( true );
}
/**
- * @testdox build_refund_preview preserves the negative tax split on a fee with a negative stored tax.
- *
- * Regression guard: a previous implementation filtered tax IDs by `amount > 0`,
- * which dropped negative tax entries entirely and emitted `tax: 0.00` on
- * negative-fee discount lines. The fix keeps any non-zero stored tax so the
- * preview returns the signed split.
+ * @testdox build_refund_preview honors an explicit negative refund_total on a discount-fee line.
*/
- public function test_build_refund_preview_negative_fee_with_negative_tax(): void {
- // A 10% rate is needed so WC_Tax::calc_inclusive_tax can split a tax-inclusive total.
+ public function test_build_refund_preview_explicit_negative_refund_total(): 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' => '0',
'tax_rate_order' => '1',
- 'tax_rate_class' => '',
)
);
@@ -1307,7 +2042,6 @@ class DataUtilsTest extends WC_Unit_Test_Case {
$tax_item->set_tax_total( -1.00 );
$tax_item->save();
$order->add_item( $tax_item );
-
$order->save();
$result = $this->data_utils->build_refund_preview(
@@ -1315,70 +2049,21 @@ class DataUtilsTest extends WC_Unit_Test_Case {
array(
array(
'line_item_id' => $fee->get_id(),
- 'quantity' => 1,
+ 'refund_total' => -5.00,
),
)
);
- // Total stays at the tax-inclusive -$11. The split between subtotal
- // (-$10) and tax (-$1) must be preserved on the fee item entry.
- $this->assertSame( '-11.00', $result['breakdown']['fees']['total'] );
- $this->assertCount( 1, $result['breakdown']['fees']['items'] );
- $this->assertEquals( '-10.00', $result['breakdown']['fees']['items'][0]['subtotal'] );
- $this->assertEquals( '-1.00', $result['breakdown']['fees']['items'][0]['tax'] );
- $this->assertEquals( '-11.00', $result['breakdown']['fees']['items'][0]['total'] );
-
- $order->delete( true );
- }
-
- /**
- * @testdox Should allow validating a negative-total fee (discount fee) that has no prior refund.
- */
- public function test_validate_preview_line_items_negative_fee_passes(): void {
- $product = WC_Helper_Product::create_simple_product();
- $product->set_regular_price( 50.00 );
- $product->save();
-
- $order = wc_create_order();
- $item = new WC_Order_Item_Product();
- $item->set_props(
- array(
- 'product' => $product,
- 'quantity' => 1,
- 'subtotal' => 50.00,
- 'total' => 50.00,
- )
- );
- $item->save();
- $order->add_item( $item );
-
- $fee = new WC_Order_Item_Fee();
- $fee->set_props(
- array(
- 'name' => 'Discount',
- 'total' => -10.00,
- )
- );
- $fee->save();
- $order->add_item( $fee );
-
- $order->set_total( 40.00 );
- $order->set_status( OrderStatus::COMPLETED );
- $order->save();
-
- $result = $this->data_utils->validate_preview_line_items(
- array(
- array(
- 'line_item_id' => $fee->get_id(),
- 'quantity' => 1,
- ),
- ),
- $order
+ // The explicit -$5 is used directly, not recomputed from a (missing) quantity.
+ $this->assertSame( '-5.00', $result['breakdown']['fees']['total'] );
+ $item_data = $result['breakdown']['fees']['items'][0];
+ $this->assertEqualsWithDelta(
+ -5.00,
+ (float) $item_data['subtotal'] + (float) $item_data['tax'],
+ 0.0001,
+ 'Subtotal + tax must reconstitute the requested negative amount.'
);
- $this->assertTrue( $result, 'Negative-total fee with no prior refund should pass validation.' );
-
- $product->delete( true );
$order->delete( true );
}
@@ -1405,6 +2090,7 @@ class DataUtilsTest extends WC_Unit_Test_Case {
);
$item->save();
$order->add_item( $item );
+ $order->set_total( 20.00 );
$order->set_status( OrderStatus::COMPLETED );
$order->save();
@@ -1416,7 +2102,7 @@ class DataUtilsTest extends WC_Unit_Test_Case {
$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->assertEquals( 'missing_quantity_or_refund_total', $result->get_error_code() );
$this->assertStringContainsString( 'positive integer', $result->get_error_message() );
$product->delete( true );
@@ -1459,6 +2145,7 @@ class DataUtilsTest extends WC_Unit_Test_Case {
);
$item->save();
$order->add_item( $item );
+ $order->set_total( 20.00 );
$order->set_status( OrderStatus::COMPLETED );
$order->save();
@@ -1488,6 +2175,65 @@ class DataUtilsTest extends WC_Unit_Test_Case {
);
}
+ /**
+ * @testdox validate_line_items rejects a negative or non-integer quantity supplied alongside refund_total.
+ *
+ * A missing or zero quantity is the accepted dollars-only form, but a negative or
+ * fractional quantity would be stored verbatim on the refund line, so it is rejected
+ * — matching the integer/range checks the preview path performs.
+ *
+ * @dataProvider provider_invalid_loose_quantities
+ *
+ * @param mixed $quantity The quantity value to test.
+ */
+ public function test_validate_line_items_rejects_invalid_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_total( 20.00 );
+ $order->set_status( OrderStatus::COMPLETED );
+ $order->save();
+
+ $result = $this->data_utils->validate_line_items(
+ array(
+ array(
+ 'line_item_id' => $item->get_id(),
+ 'refund_total' => 10.00,
+ 'quantity' => $quantity,
+ ),
+ ),
+ $order
+ );
+
+ $this->assertInstanceOf( \WP_Error::class, $result, 'A negative or non-integer quantity must be rejected.' );
+ $this->assertEquals( 'invalid_quantity', $result->get_error_code() );
+
+ $product->delete( true );
+ $order->delete( true );
+ }
+
+ /**
+ * @return array<string, array<int, mixed>>
+ */
+ public function provider_invalid_loose_quantities(): array {
+ return array(
+ 'negative' => array( -1 ),
+ 'fractional' => array( 1.5 ),
+ );
+ }
+
/**
* @testdox fill_missing_refund_totals computes refund_total for a product line item when missing.
*/
@@ -1600,7 +2346,9 @@ class DataUtilsTest extends WC_Unit_Test_Case {
$order
);
- $this->assertSame( 0, $result[0]['refund_total'], 'Explicit zero must not be replaced by the auto-computed value' );
+ // normalize_refund_totals() rounds every explicit value to a float, so an
+ // explicit 0 is preserved as 0.0 (not replaced by the auto-computed $10).
+ $this->assertSame( 0.0, $result[0]['refund_total'], 'Explicit zero must not be replaced by the auto-computed value' );
$product->delete( true );
$order->delete( true );
@@ -1764,6 +2512,10 @@ class DataUtilsTest extends WC_Unit_Test_Case {
);
$item->save();
$order->add_item( $item );
+ // A non-zero order total keeps the order from looking fully refunded so the
+ // zero-source-quantity branch is what surfaces.
+ $order->set_total( 10.00 );
+ $order->set_status( OrderStatus::COMPLETED );
$order->save();
$result = $this->data_utils->validate_line_items(
@@ -1971,6 +2723,192 @@ class DataUtilsTest extends WC_Unit_Test_Case {
$order->delete( true );
}
+ /**
+ * @testdox normalize_refund_totals rounds a numeric refund_total to currency precision (and coerces ints to float).
+ * @dataProvider provider_normalize_refund_totals_numeric
+ *
+ * @param int|float $input Provided refund_total.
+ * @param float $expected Rounded float result.
+ */
+ public function test_normalize_refund_totals_rounds_numeric( $input, float $expected ): void {
+ $result = $this->data_utils->normalize_refund_totals( array( array( 'refund_total' => $input ) ) );
+
+ $this->assertSame( $expected, $result[0]['refund_total'] );
+ }
+
+ /**
+ * @return array<string, array{0: int|float, 1: float}>
+ */
+ public function provider_normalize_refund_totals_numeric(): array {
+ return array(
+ 'integer coerced to float' => array( 30, 30.0 ),
+ 'rounds to two decimals' => array( 30.999, 31.0 ),
+ 'explicit zero' => array( 0, 0.0 ),
+ );
+ }
+
+ /**
+ * @testdox normalize_refund_totals leaves null, non-numeric, and missing refund_total untouched.
+ */
+ public function test_normalize_refund_totals_leaves_non_numeric_untouched(): void {
+ $result = $this->data_utils->normalize_refund_totals(
+ array(
+ array( 'refund_total' => null ),
+ array( 'refund_total' => 'abc' ),
+ array( 'line_item_id' => 7 ),
+ )
+ );
+
+ $this->assertNull( $result[0]['refund_total'], 'null means "auto-compute" and must be preserved.' );
+ $this->assertSame( 'abc', $result[1]['refund_total'], 'Non-numeric values are left for downstream validation.' );
+ $this->assertArrayNotHasKey( 'refund_total', $result[2], 'A missing key stays missing.' );
+ }
+
+ /**
+ * @testdox build_refund_preview falls back to zero tax when a line's stored total and tax nearly cancel.
+ *
+ * A line with total 100 and stored tax -99.99 has a near-zero inclusive total; splitting by
+ * the stored ratio would explode the tax. The sanity clamp must fall back to all-net.
+ */
+ public function test_build_refund_preview_clamps_degenerate_stored_ratio(): void {
+ $product = WC_Helper_Product::create_simple_product();
+ $product->set_regular_price( 100.00 );
+ $product->save();
+
+ $order = wc_create_order();
+ $item = new WC_Order_Item_Product();
+ $item->set_props(
+ array(
+ 'product' => $product,
+ 'quantity' => 1,
+ 'subtotal' => 100.00,
+ 'total' => 100.00,
+ )
+ );
+ $item->set_taxes(
+ array(
+ 'total' => array( 1 => -99.99 ),
+ 'subtotal' => array( 1 => -99.99 ),
+ )
+ );
+ $item->save();
+ $order->add_item( $item );
+ $order->set_total( 0.01 );
+ $order->set_status( OrderStatus::COMPLETED );
+ $order->save();
+
+ $preview = $this->data_utils->build_refund_preview(
+ $order,
+ array(
+ array(
+ 'line_item_id' => $item->get_id(),
+ 'quantity' => 1,
+ ),
+ )
+ );
+
+ $item_data = $preview['breakdown']['products']['items'][0];
+ $this->assertEquals( '0.00', $item_data['tax'], 'Degenerate ratio must clamp tax to zero rather than explode.' );
+ $this->assertEquals( '0.01', $item_data['subtotal'] );
+
+ $product->delete( true );
+ $order->delete( true );
+ }
+
+ /**
+ * Invoke the protected split_inclusive_by_stored_ratio() via reflection.
+ *
+ * @param float $amount Tax-inclusive amount to split.
+ * @param mixed $item Order item supplying the stored total/tax ratio.
+ * @param int $dp Price decimal places.
+ * @return array{subtotal: float, total_tax: float, taxes: array<int, float>}
+ */
+ private function invoke_split_inclusive( float $amount, $item, int $dp = 2 ): array {
+ $method = ( new \ReflectionClass( DataUtils::class ) )->getMethod( 'split_inclusive_by_stored_ratio' );
+ $method->setAccessible( true );
+ return $method->invoke( $this->data_utils, $amount, $item, $dp );
+ }
+
+ /**
+ * @testdox split_inclusive_by_stored_ratio rounds per-tax-id amounts and derives the subtotal as the remainder so the invariant holds.
+ */
+ public function test_split_inclusive_two_rate_rounding_remainder(): void {
+ $fee = new WC_Order_Item_Fee();
+ $fee->set_total( 100.00 );
+ $fee->set_taxes(
+ array(
+ // Two rates: County 1% and State 9%.
+ 'total' => array(
+ 1 => 1.00,
+ 2 => 9.00,
+ ),
+ )
+ );
+
+ // Split $33.33 of the $110 tax-inclusive line: forces sub-cent per-id rounding.
+ $result = $this->invoke_split_inclusive( 33.33, $fee );
+
+ $this->assertEqualsWithDelta( 0.30, $result['taxes'][1], 0.0001 );
+ $this->assertEqualsWithDelta( 2.73, $result['taxes'][2], 0.0001 );
+ $this->assertEqualsWithDelta( 3.03, $result['total_tax'], 0.0001 );
+ $this->assertEqualsWithDelta( 30.30, $result['subtotal'], 0.0001 );
+ // Invariant: subtotal + total_tax reconstitutes the requested amount exactly.
+ $this->assertEqualsWithDelta( 33.33, $result['subtotal'] + $result['total_tax'], 0.0001 );
+ }
+
+ /**
+ * @testdox split_inclusive_by_stored_ratio preserves negative signs for a discount fee with negative tax.
+ */
+ public function test_split_inclusive_negative_discount_fee(): void {
+ $fee = new WC_Order_Item_Fee();
+ $fee->set_total( -10.00 );
+ $fee->set_taxes(
+ array(
+ 'total' => array( 1 => -1.00 ),
+ )
+ );
+
+ // Refund half of the -$11 tax-inclusive discount line.
+ $result = $this->invoke_split_inclusive( -5.50, $fee );
+
+ $this->assertEqualsWithDelta( -0.50, $result['taxes'][1], 0.0001 );
+ $this->assertEqualsWithDelta( -0.50, $result['total_tax'], 0.0001 );
+ $this->assertEqualsWithDelta( -5.00, $result['subtotal'], 0.0001 );
+ }
+
+ /**
+ * @testdox split_inclusive_by_stored_ratio treats a line with no stored tax as fully net.
+ */
+ public function test_split_inclusive_zero_tax_line(): void {
+ $fee = new WC_Order_Item_Fee();
+ $fee->set_total( 50.00 );
+
+ $result = $this->invoke_split_inclusive( 25.00, $fee );
+
+ $this->assertSame( array(), $result['taxes'] );
+ $this->assertEqualsWithDelta( 0.0, $result['total_tax'], 0.0001 );
+ $this->assertEqualsWithDelta( 25.00, $result['subtotal'], 0.0001 );
+ }
+
+ /**
+ * @testdox split_inclusive_by_stored_ratio clamps to net-only when the stored total and tax nearly cancel.
+ */
+ public function test_split_inclusive_degenerate_ratio_clamps(): void {
+ $fee = new WC_Order_Item_Fee();
+ $fee->set_total( 100.00 );
+ $fee->set_taxes(
+ array(
+ 'total' => array( 1 => -99.99 ),
+ )
+ );
+
+ $result = $this->invoke_split_inclusive( 0.01, $fee );
+
+ $this->assertSame( array(), $result['taxes'] );
+ $this->assertEqualsWithDelta( 0.0, $result['total_tax'], 0.0001 );
+ $this->assertEqualsWithDelta( 0.01, $result['subtotal'], 0.0001 );
+ }
+
/**
* Helper: Create an order with shipping that has tax rate IDs but zero tax amounts.
*