Commit 843858af9d6 for woocommerce
commit 843858af9d6188f270c5d0eb2245e1123e4a0381
Author: Samuel Urbanowicz <samiuelson@gmail.com>
Date: Thu Jun 11 15:36:04 2026 +0200
[2/2] Add refund preview endpoint (POST /wc/v4/refunds/preview) (#65335)
* 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
* 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.
* 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 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>
* 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.
* Fix test isolation: guard wp_insert_user and wrap stub restore in finally
---------
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/65335-woomob-2684-refund-preview-endpoint b/plugins/woocommerce/changelog/65335-woomob-2684-refund-preview-endpoint
new file mode 100644
index 00000000000..b36f1a6814f
--- /dev/null
+++ b/plugins/woocommerce/changelog/65335-woomob-2684-refund-preview-endpoint
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add refund preview endpoint (POST /wc/v4/refunds/preview) for server-side refund calculation
\ No newline at end of file
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index ac24ee76727..cdd3f68a954 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -66895,18 +66895,6 @@ parameters:
count: 1
path: src/Internal/RestApi/Routes/V4/Refunds/Controller.php
- -
- message: '#^Call to method get_item_response\(\) on an unknown class Automattic\\WooCommerce\\Internal\\RestApi\\Routes\\V4\\Refunds\\OrderSchema\.$#'
- identifier: class.notFound
- count: 1
- path: src/Internal/RestApi/Routes/V4/Refunds/Controller.php
-
- -
- message: '#^Call to method get_item_schema\(\) on an unknown class Automattic\\WooCommerce\\Internal\\RestApi\\Routes\\V4\\Refunds\\OrderSchema\.$#'
- identifier: class.notFound
- count: 1
- path: src/Internal/RestApi/Routes/V4/Refunds/Controller.php
-
-
message: '#^Cannot call method delete\(\) on WC_Order\|WC_Order_Refund\|false\.$#'
identifier: method.nonObject
@@ -67021,18 +67009,6 @@ parameters:
count: 1
path: src/Internal/RestApi/Routes/V4/Refunds/Controller.php
- -
- message: '#^Property Automattic\\WooCommerce\\Internal\\RestApi\\Routes\\V4\\Refunds\\Controller\:\:\$item_schema \(Automattic\\WooCommerce\\Internal\\RestApi\\Routes\\V4\\Refunds\\OrderSchema\) does not accept Automattic\\WooCommerce\\Internal\\RestApi\\Routes\\V4\\Refunds\\Schema\\RefundSchema\.$#'
- identifier: assign.propertyType
- count: 1
- path: src/Internal/RestApi/Routes/V4/Refunds/Controller.php
-
- -
- message: '#^Property Automattic\\WooCommerce\\Internal\\RestApi\\Routes\\V4\\Refunds\\Controller\:\:\$item_schema has unknown class Automattic\\WooCommerce\\Internal\\RestApi\\Routes\\V4\\Refunds\\OrderSchema as its type\.$#'
- identifier: class.notFound
- 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
@@ -67069,90 +67045,24 @@ parameters:
count: 1
path: src/Internal/RestApi/Routes/V4/Refunds/DataUtils.php
- -
- message: '#^Call to method get_amount\(\) on an unknown class Automattic\\WooCommerce\\Internal\\RestApi\\Routes\\V4\\Refunds\\Schema\\WC_Order_Refund\.$#'
- identifier: class.notFound
- count: 1
- path: src/Internal/RestApi/Routes/V4/Refunds/Schema/RefundSchema.php
-
- -
- message: '#^Call to method get_cogs_total_value\(\) on an unknown class Automattic\\WooCommerce\\Internal\\RestApi\\Routes\\V4\\Refunds\\Schema\\WC_Order_Refund\.$#'
- identifier: class.notFound
- count: 1
- path: src/Internal/RestApi/Routes/V4/Refunds/Schema/RefundSchema.php
-
- -
- message: '#^Call to method get_currency\(\) on an unknown class Automattic\\WooCommerce\\Internal\\RestApi\\Routes\\V4\\Refunds\\Schema\\WC_Order_Refund\.$#'
- identifier: class.notFound
- count: 2
- path: src/Internal/RestApi/Routes/V4/Refunds/Schema/RefundSchema.php
-
- -
- message: '#^Call to method get_date_created\(\) on an unknown class Automattic\\WooCommerce\\Internal\\RestApi\\Routes\\V4\\Refunds\\Schema\\WC_Order_Refund\.$#'
- identifier: class.notFound
- count: 2
- path: src/Internal/RestApi/Routes/V4/Refunds/Schema/RefundSchema.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
count: 1
path: src/Internal/RestApi/Routes/V4/Refunds/Schema/RefundSchema.php
- -
- message: '#^Call to method get_id\(\) on an unknown class Automattic\\WooCommerce\\Internal\\RestApi\\Routes\\V4\\Refunds\\Schema\\WC_Order_Refund\.$#'
- identifier: class.notFound
- count: 1
- path: src/Internal/RestApi/Routes/V4/Refunds/Schema/RefundSchema.php
-
- -
- message: '#^Call to method get_items\(\) on an unknown class Automattic\\WooCommerce\\Internal\\RestApi\\Routes\\V4\\Refunds\\Schema\\WC_Order_Refund\.$#'
- identifier: class.notFound
- count: 3
- path: src/Internal/RestApi/Routes/V4/Refunds/Schema/RefundSchema.php
-
-
message: '#^Call to method get_meta\(\) on an unknown class Automattic\\WooCommerce\\Internal\\RestApi\\Routes\\V4\\Refunds\\Schema\\WC_Order_Item\.$#'
identifier: class.notFound
count: 1
path: src/Internal/RestApi/Routes/V4/Refunds/Schema/RefundSchema.php
- -
- message: '#^Call to method get_meta_data\(\) on an unknown class Automattic\\WooCommerce\\Internal\\RestApi\\Routes\\V4\\Refunds\\Schema\\WC_Order_Refund\.$#'
- identifier: class.notFound
- count: 1
- path: src/Internal/RestApi/Routes/V4/Refunds/Schema/RefundSchema.php
-
- -
- message: '#^Call to method get_parent_id\(\) on an unknown class Automattic\\WooCommerce\\Internal\\RestApi\\Routes\\V4\\Refunds\\Schema\\WC_Order_Refund\.$#'
- identifier: class.notFound
- count: 1
- path: src/Internal/RestApi/Routes/V4/Refunds/Schema/RefundSchema.php
-
-
message: '#^Call to method get_quantity\(\) on an unknown class Automattic\\WooCommerce\\Internal\\RestApi\\Routes\\V4\\Refunds\\Schema\\WC_Order_Item\.$#'
identifier: class.notFound
count: 1
path: src/Internal/RestApi/Routes/V4/Refunds/Schema/RefundSchema.php
- -
- message: '#^Call to method get_reason\(\) on an unknown class Automattic\\WooCommerce\\Internal\\RestApi\\Routes\\V4\\Refunds\\Schema\\WC_Order_Refund\.$#'
- identifier: class.notFound
- count: 1
- path: src/Internal/RestApi/Routes/V4/Refunds/Schema/RefundSchema.php
-
- -
- message: '#^Call to method get_refunded_by\(\) on an unknown class Automattic\\WooCommerce\\Internal\\RestApi\\Routes\\V4\\Refunds\\Schema\\WC_Order_Refund\.$#'
- identifier: class.notFound
- count: 1
- path: src/Internal/RestApi/Routes/V4/Refunds/Schema/RefundSchema.php
-
- -
- message: '#^Call to method get_refunded_payment\(\) on an unknown class Automattic\\WooCommerce\\Internal\\RestApi\\Routes\\V4\\Refunds\\Schema\\WC_Order_Refund\.$#'
- identifier: class.notFound
- count: 1
- path: src/Internal/RestApi/Routes/V4/Refunds/Schema/RefundSchema.php
-
-
message: '#^Call to method get_taxes\(\) on an unknown class Automattic\\WooCommerce\\Internal\\RestApi\\Routes\\V4\\Refunds\\Schema\\WC_Order_Item\.$#'
identifier: class.notFound
@@ -67195,12 +67105,6 @@ parameters:
count: 1
path: src/Internal/RestApi/Routes/V4/Refunds/Schema/RefundSchema.php
- -
- message: '#^Parameter \$refund of method Automattic\\WooCommerce\\Internal\\RestApi\\Routes\\V4\\Refunds\\Schema\\RefundSchema\:\:get_item_response\(\) has invalid type Automattic\\WooCommerce\\Internal\\RestApi\\Routes\\V4\\Refunds\\Schema\\WC_Order_Refund\.$#'
- identifier: class.notFound
- count: 1
- path: src/Internal/RestApi/Routes/V4/Refunds/Schema/RefundSchema.php
-
-
message: '#^Property Automattic\\WooCommerce\\Internal\\RestApi\\Routes\\V4\\Refunds\\Schema\\RefundSchema\:\:\$order_fee_schema is never read, only written\.$#'
identifier: property.onlyWritten
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 37a8ce3d5ca..ffc800b718d 100644
--- a/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Refunds/Controller.php
+++ b/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Refunds/Controller.php
@@ -15,11 +15,13 @@ defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Internal\RestApi\Routes\V4\AbstractController;
use Automattic\WooCommerce\StoreApi\Utilities\Pagination;
+use Automattic\WooCommerce\Internal\RestApi\Routes\V4\Refunds\Schema\RefundPreviewSchema;
use Automattic\WooCommerce\Internal\RestApi\Routes\V4\Refunds\Schema\RefundSchema;
use Automattic\WooCommerce\Utilities\MetaDataUtil;
use Automattic\WooCommerce\Utilities\NumberUtil;
use WP_Http;
use WP_Error;
+use WC_Order;
use WC_Order_Refund;
use WP_REST_Request;
use WP_REST_Response;
@@ -46,10 +48,17 @@ class Controller extends AbstractController {
/**
* Schema class for this route.
*
- * @var OrderSchema
+ * @var RefundSchema
*/
protected $item_schema;
+ /**
+ * Schema class for preview responses.
+ *
+ * @var RefundPreviewSchema
+ */
+ protected $preview_schema;
+
/**
* Collection query class.
*
@@ -67,13 +76,16 @@ class Controller extends AbstractController {
/**
* Initialize the controller.
*
- * @param RefundSchema $item_schema Refund schema class.
- * @param CollectionQuery $collection_query Collection query class.
- * @param DataUtils $data_utils Data utils class.
* @internal
+ *
+ * @param RefundSchema $item_schema Refund schema class.
+ * @param RefundPreviewSchema $preview_schema Preview schema class.
+ * @param CollectionQuery $collection_query Collection query class.
+ * @param DataUtils $data_utils Data utils class.
*/
- final public function init( RefundSchema $item_schema, CollectionQuery $collection_query, DataUtils $data_utils ) {
+ final public function init( RefundSchema $item_schema, RefundPreviewSchema $preview_schema, CollectionQuery $collection_query, DataUtils $data_utils ) {
$this->item_schema = $item_schema;
+ $this->preview_schema = $preview_schema;
$this->collection_query = $collection_query;
$this->data_utils = $data_utils;
}
@@ -155,6 +167,56 @@ class Controller extends AbstractController {
)
);
+ register_rest_route(
+ $this->namespace,
+ '/' . $this->rest_base . '/preview',
+ array(
+ // permission_callback below intentionally uses the create-refund capability:
+ // preview is read-only but logically part of the refund-creation flow, so it
+ // requires the same capability. This prevents read-only-API clients from
+ // probing refund state on orders they cannot act on.
+ array(
+ 'methods' => WP_REST_Server::CREATABLE,
+ 'callback' => array( $this, 'preview_item' ),
+ 'permission_callback' => array( $this, 'create_item_permissions_check' ),
+ 'args' => array(
+ 'order_id' => array(
+ 'description' => __( 'The ID of the order to preview a refund for.', 'woocommerce' ),
+ 'type' => 'integer',
+ 'required' => true,
+ 'minimum' => 1,
+ 'validate_callback' => 'rest_validate_request_arg',
+ ),
+ 'line_items' => array(
+ 'description' => __( 'Line items to include in the refund preview.', 'woocommerce' ),
+ 'type' => 'array',
+ 'required' => true,
+ 'minItems' => 1,
+ 'validate_callback' => 'rest_validate_request_arg',
+ 'items' => array(
+ 'type' => 'object',
+ 'required' => array( 'line_item_id', 'quantity' ),
+ 'additionalProperties' => false,
+ 'properties' => array(
+ 'line_item_id' => array(
+ 'description' => __( 'ID of the original order line item.', 'woocommerce' ),
+ 'type' => 'integer',
+ 'minimum' => 1,
+ ),
+ 'quantity' => array(
+ 'description' => __( 'Quantity to refund.', 'woocommerce' ),
+ 'type' => 'integer',
+ 'minimum' => 1,
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ 'schema' => array( $this, 'get_public_preview_schema' ),
+ )
+ );
+
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/(?P<id>[\d]+)',
@@ -371,6 +433,93 @@ class Controller extends AbstractController {
}
}
+ /**
+ * Preview a refund without creating it.
+ *
+ * @param WP_REST_Request<array<string, mixed>> $request Full details about the request.
+ * @return WP_REST_Response|WP_Error
+ *
+ * @since 10.9.0
+ */
+ public function preview_item( $request ) {
+ $order = wc_get_order( $request['order_id'] );
+
+ // wc_get_order returns WC_Order|WC_Order_Refund|false; only a WC_Order
+ // (shop_order) is previewable here — refunds and missing IDs are rejected.
+ if ( ! $order instanceof WC_Order ) {
+ return $this->get_route_error_by_code( self::INVALID_ID );
+ }
+
+ $validation_error = $this->data_utils->validate_preview_line_items( $request['line_items'], $order );
+
+ if ( is_wp_error( $validation_error ) ) {
+ $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 );
+ }
+
+ try {
+ $preview = $this->data_utils->build_refund_preview( $order, $request['line_items'] );
+ } catch ( \InvalidArgumentException $e ) {
+ // validate_preview_line_items above should have caught any bad input.
+ // If build_refund_preview still throws InvalidArgumentException, treat
+ // it as a server-side invariant violation, log for observability, and
+ // return a generic message (do not leak internal IDs to clients).
+ wc_get_logger()->error(
+ sprintf( 'Refund preview invariant violation on order %d: %s', $order->get_id(), $e->getMessage() ),
+ array( 'source' => 'wc-v4-refunds' )
+ );
+ return $this->get_route_error_response(
+ 'invalid_preview_request',
+ __( 'The refund preview could not be generated due to an unexpected error.', 'woocommerce' ),
+ WP_Http::INTERNAL_SERVER_ERROR
+ );
+ } catch ( \Throwable $e ) {
+ wc_get_logger()->error(
+ sprintf( 'Refund preview unexpected error on order %d: %s', $order->get_id(), $e->getMessage() ),
+ array( 'source' => 'wc-v4-refunds' )
+ );
+ return $this->get_route_error_response(
+ 'unexpected_preview_error',
+ __( 'An unexpected error occurred while generating the refund preview.', 'woocommerce' ),
+ WP_Http::INTERNAL_SERVER_ERROR
+ );
+ }
+
+ // 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).
+ // Reject up-front so the eventual create call doesn't fail with the
+ // generic 'cannot_create_refund' error from wc_create_refund.
+ // `total` is already tax-inclusive; compare directly against max_refundable.
+ $preview_total_with_tax = abs( (float) $preview['total'] );
+ if ( $preview_total_with_tax > (float) $preview['max_refundable'] ) {
+ return $this->get_route_error_response(
+ 'preview_exceeds_max_refundable',
+ sprintf(
+ /* translators: 1: requested preview total including tax, 2: remaining refundable */
+ __( 'Requested refund preview (%1$s) exceeds the remaining refundable amount (%2$s).', 'woocommerce' ),
+ wc_format_decimal( $preview_total_with_tax, wc_get_price_decimals() ),
+ $preview['max_refundable']
+ ),
+ WP_Http::UNPROCESSABLE_ENTITY
+ );
+ }
+
+ return rest_ensure_response( $preview );
+ }
+
+ /**
+ * Get the public schema for the preview endpoint.
+ *
+ * @since 10.8.0
+ *
+ * @return array
+ */
+ public function get_public_preview_schema(): array {
+ return $this->preview_schema->get_item_schema();
+ }
+
/**
* Delete a single item.
*
@@ -386,7 +535,7 @@ class Controller extends AbstractController {
$request->set_param( 'context', 'edit' );
- $response = new WP_REST_Response( null, 204 );
+ $response = new WP_REST_Response( null, WP_Http::NO_CONTENT );
$result = $refund->delete( true );
if ( ! $result ) {
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 fb0376f79b8..dfba93811af 100644
--- a/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Refunds/DataUtils.php
+++ b/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Refunds/DataUtils.php
@@ -448,9 +448,9 @@ class DataUtils {
$item_data['name'] = $item->get_name();
if ( $item instanceof WC_Order_Item_Product ) {
- $item_data['product_id'] = $item->get_product_id();
- $item_data['variation_id'] = $item->get_variation_id();
- $section_key = 'products';
+ $variation_id = $item->get_variation_id();
+ $item_data['product_id'] = $variation_id > 0 ? $variation_id : $item->get_product_id();
+ $section_key = 'products';
} elseif ( $item instanceof WC_Order_Item_Shipping ) {
$section_key = 'shipping';
} else {
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
new file mode 100644
index 00000000000..197afa7eb72
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Refunds/Schema/RefundPreviewSchema.php
@@ -0,0 +1,207 @@
+<?php
+/**
+ * RefundPreviewSchema class.
+ *
+ * @package WooCommerce\RestApi
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Internal\RestApi\Routes\V4\Refunds\Schema;
+
+defined( 'ABSPATH' ) || exit;
+
+use Automattic\WooCommerce\Internal\RestApi\Routes\V4\AbstractSchema;
+use WP_REST_Request;
+
+/**
+ * Schema for the refund preview response.
+ *
+ * @since 10.9.0
+ */
+class RefundPreviewSchema extends AbstractSchema {
+
+ /**
+ * The schema item identifier.
+ *
+ * @var string
+ */
+ const IDENTIFIER = 'refund-preview';
+
+ // The next method always throws so its return type can never be reached.
+ // phpcs:disable Squiz.Commenting.FunctionComment.InvalidNoReturn
+ /**
+ * Not used. The refund preview controller bypasses prepare_item_for_response
+ * and returns the raw data array directly via rest_ensure_response, so this
+ * method must never be invoked. The `: array` return type is required to
+ * satisfy AbstractSchema::get_item_response, but the body always throws.
+ *
+ * @param mixed $item Item data.
+ * @param WP_REST_Request $request Request object.
+ * @param array $include_fields Fields to include.
+ *
+ * @phpstan-param WP_REST_Request<array<string, mixed>> $request
+ *
+ * @return array
+ * @throws \LogicException Always — this method should never be called for the preview route.
+ *
+ * @since 10.9.0
+ */
+ public function get_item_response( $item, WP_REST_Request $request, array $include_fields = array() ): array {
+ throw new \LogicException(
+ 'RefundPreviewSchema::get_item_response() should not be called; the preview controller bypasses prepare_item_for_response().'
+ );
+ }
+ // phpcs:enable Squiz.Commenting.FunctionComment.InvalidNoReturn
+
+ /**
+ * Return all properties for the item schema.
+ *
+ * @return array
+ *
+ * @since 10.9.0
+ */
+ public function get_item_schema_properties(): array {
+ return array(
+ 'breakdown' => array(
+ 'description' => __( 'Refund breakdown by item type.', 'woocommerce' ),
+ 'type' => 'object',
+ 'context' => self::VIEW_EDIT_EMBED_CONTEXT,
+ 'readonly' => true,
+ 'properties' => array(
+ 'products' => $this->get_section_schema( 'products' ),
+ 'shipping' => $this->get_section_schema( 'shipping' ),
+ 'fees' => $this->get_section_schema( 'fees' ),
+ ),
+ ),
+ 'subtotal' => array(
+ 'description' => __( 'Grand subtotal of the refund preview (excluding tax).', 'woocommerce' ),
+ 'type' => 'string',
+ 'context' => self::VIEW_EDIT_EMBED_CONTEXT,
+ 'readonly' => true,
+ ),
+ 'tax' => array(
+ 'description' => __( 'Grand tax total of the refund preview.', 'woocommerce' ),
+ 'type' => 'string',
+ 'context' => self::VIEW_EDIT_EMBED_CONTEXT,
+ 'readonly' => true,
+ ),
+ 'total' => array(
+ 'description' => __( 'Grand total of the refund preview (tax-inclusive).', 'woocommerce' ),
+ 'type' => 'string',
+ 'context' => self::VIEW_EDIT_EMBED_CONTEXT,
+ 'readonly' => true,
+ ),
+ 'max_refundable' => array(
+ 'description' => __( 'Maximum refundable amount remaining on the order.', 'woocommerce' ),
+ 'type' => 'string',
+ 'context' => self::VIEW_EDIT_EMBED_CONTEXT,
+ 'readonly' => true,
+ ),
+ );
+ }
+
+ /**
+ * Schema for one section of the breakdown (products, shipping, or fees).
+ *
+ * @param string $section_key One of 'products', 'shipping', 'fees'. Determines which item schema variant is used.
+ * @return array
+ */
+ private function get_section_schema( string $section_key ): array {
+ return array(
+ 'type' => 'object',
+ 'properties' => array(
+ 'items' => array(
+ 'description' => __( 'Line items in this section.', 'woocommerce' ),
+ 'type' => 'array',
+ 'items' => 'products' === $section_key ? $this->get_product_item_schema() : $this->get_base_item_schema(),
+ 'context' => self::VIEW_EDIT_EMBED_CONTEXT,
+ 'readonly' => true,
+ ),
+ 'subtotal' => array(
+ 'description' => __( 'Section subtotal (excluding tax).', 'woocommerce' ),
+ 'type' => 'string',
+ 'context' => self::VIEW_EDIT_EMBED_CONTEXT,
+ 'readonly' => true,
+ ),
+ 'tax' => array(
+ 'description' => __( 'Section tax total.', 'woocommerce' ),
+ 'type' => 'string',
+ 'context' => self::VIEW_EDIT_EMBED_CONTEXT,
+ 'readonly' => true,
+ ),
+ 'total' => array(
+ 'description' => __( 'Section total (tax-inclusive).', 'woocommerce' ),
+ 'type' => 'string',
+ 'context' => self::VIEW_EDIT_EMBED_CONTEXT,
+ 'readonly' => true,
+ ),
+ ),
+ );
+ }
+
+ /**
+ * Schema for an item entry in the shipping or fees sections.
+ *
+ * @return array
+ */
+ private function get_base_item_schema(): array {
+ return array(
+ 'type' => 'object',
+ 'properties' => array(
+ 'id' => array(
+ 'description' => __( 'The original order line item ID.', 'woocommerce' ),
+ 'type' => 'integer',
+ 'context' => self::VIEW_EDIT_EMBED_CONTEXT,
+ 'readonly' => true,
+ ),
+ 'name' => array(
+ 'description' => __( 'The line item name.', 'woocommerce' ),
+ 'type' => 'string',
+ 'context' => self::VIEW_EDIT_EMBED_CONTEXT,
+ 'readonly' => true,
+ ),
+ 'quantity' => array(
+ 'description' => __( 'The quantity being refunded.', 'woocommerce' ),
+ 'type' => 'integer',
+ 'context' => self::VIEW_EDIT_EMBED_CONTEXT,
+ 'readonly' => true,
+ ),
+ 'subtotal' => array(
+ 'description' => __( 'The refund subtotal for this item (excluding tax).', 'woocommerce' ),
+ 'type' => 'string',
+ 'context' => self::VIEW_EDIT_EMBED_CONTEXT,
+ 'readonly' => true,
+ ),
+ 'tax' => array(
+ 'description' => __( 'The tax amount for this item.', 'woocommerce' ),
+ 'type' => 'string',
+ 'context' => self::VIEW_EDIT_EMBED_CONTEXT,
+ 'readonly' => true,
+ ),
+ 'total' => array(
+ 'description' => __( 'The refund total for this item (tax-inclusive).', 'woocommerce' ),
+ 'type' => 'string',
+ 'context' => self::VIEW_EDIT_EMBED_CONTEXT,
+ 'readonly' => true,
+ ),
+ ),
+ );
+ }
+
+ /**
+ * Schema for an item entry in the products section (extends the base with product_id).
+ *
+ * @return array
+ */
+ private function get_product_item_schema(): array {
+ $schema = $this->get_base_item_schema();
+ $schema['properties']['product_id'] = array(
+ 'description' => __( 'Product or variation ID.', 'woocommerce' ),
+ 'type' => 'integer',
+ 'context' => self::VIEW_EDIT_EMBED_CONTEXT,
+ 'readonly' => true,
+ );
+ return $schema;
+ }
+}
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 95745186ce3..c0ebc231d94 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
@@ -19,6 +19,7 @@ use Automattic\WooCommerce\Internal\RestApi\Routes\V4\Orders\Schema\OrderItemSch
use Automattic\WooCommerce\Internal\RestApi\Routes\V4\Orders\Schema\OrderFeeSchema;
use Automattic\WooCommerce\Internal\RestApi\Routes\V4\Orders\Schema\OrderTaxSchema;
use Automattic\WooCommerce\Internal\RestApi\Routes\V4\Orders\Schema\OrderShippingSchema;
+use WC_Order_Refund;
use WP_REST_Request;
/**
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 5b68c8f1fd5..044a2f77766 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
@@ -5,6 +5,7 @@ use Automattic\WooCommerce\Enums\OrderStatus;
use Automattic\WooCommerce\RestApi\UnitTests\HPOSToggleTrait;
use Automattic\WooCommerce\Tests\Helpers\MetaDataAssertionTrait;
use Automattic\WooCommerce\Internal\RestApi\Routes\V4\Refunds\Controller as RefundsController;
+use Automattic\WooCommerce\Internal\RestApi\Routes\V4\Refunds\Schema\RefundPreviewSchema;
use Automattic\WooCommerce\Internal\RestApi\Routes\V4\Refunds\Schema\RefundSchema;
use Automattic\WooCommerce\Internal\RestApi\Routes\V4\Refunds\CollectionQuery;
use Automattic\WooCommerce\Internal\RestApi\Routes\V4\Refunds\DataUtils;
@@ -119,13 +120,14 @@ class WC_REST_Refunds_V4_Controller_Tests extends WC_REST_Unit_Test_Case {
// Create schema instances with dependency injection.
$this->refund_schema = new RefundSchema();
+ $preview_schema = new RefundPreviewSchema();
// Create utils instances.
$collection_query = new CollectionQuery();
$data_utils = new DataUtils();
$this->endpoint = new RefundsController();
- $this->endpoint->init( $this->refund_schema, $collection_query, $data_utils );
+ $this->endpoint->init( $this->refund_schema, $preview_schema, $collection_query, $data_utils );
$this->user_id = wp_insert_user(
array(
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
new file mode 100644
index 00000000000..c7b7581bbb9
--- /dev/null
+++ b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version4/Refunds/class-wc-rest-refunds-v4-preview-tests.php
@@ -0,0 +1,1234 @@
+<?php
+declare( strict_types=1 );
+
+use Automattic\WooCommerce\Enums\OrderStatus;
+
+/**
+ * Integration tests for the POST /wc/v4/refunds/preview endpoint.
+ *
+ * @group refund-preview-tests
+ */
+class WC_REST_Refunds_V4_Preview_Tests extends WC_REST_Unit_Test_Case {
+
+ /**
+ * Shared admin user ID. Created once per class to avoid the wp_insert_user cost
+ * on every test (this suite has 25+ cases).
+ *
+ * @var int
+ */
+ protected static $user_id;
+
+ /**
+ * Collection of created orders for cleanup.
+ *
+ * @var array
+ */
+ private $created_orders = array();
+
+ /**
+ * Enable the REST API v4 feature.
+ */
+ public static function enable_rest_api_v4_feature() {
+ add_filter(
+ 'woocommerce_admin_features',
+ function ( $features ) {
+ $features[] = 'rest-api-v4';
+ return $features;
+ },
+ );
+ }
+
+ /**
+ * Disable the REST API v4 feature.
+ */
+ public static function disable_rest_api_v4_feature() {
+ add_filter(
+ 'woocommerce_admin_features',
+ function ( $features ) {
+ $features = array_diff( $features, array( 'rest-api-v4' ) );
+ return $features;
+ }
+ );
+ }
+
+ /**
+ * Create the shared admin user once per class.
+ */
+ public static function setUpBeforeClass(): void {
+ parent::setUpBeforeClass();
+
+ self::$user_id = wp_insert_user(
+ array(
+ 'user_login' => 'preview_admin_' . wp_generate_password( 6, false ),
+ 'user_email' => 'preview_admin_' . wp_generate_password( 6, false ) . '@example.com',
+ 'user_pass' => 'password',
+ 'role' => 'administrator',
+ )
+ );
+ if ( is_wp_error( self::$user_id ) ) {
+ self::fail( 'Could not create test admin user: ' . self::$user_id->get_error_message() );
+ }
+ self::$user_id = (int) self::$user_id;
+ }
+
+ /**
+ * Delete the shared admin user once per class.
+ */
+ public static function tearDownAfterClass(): void {
+ if ( self::$user_id ) {
+ wp_delete_user( self::$user_id );
+ self::$user_id = 0;
+ }
+ parent::tearDownAfterClass();
+ }
+
+ /**
+ * Setup our test server, endpoints, and user info.
+ */
+ public function setUp(): void {
+ $this->enable_rest_api_v4_feature();
+ parent::setUp();
+
+ wp_set_current_user( self::$user_id );
+ }
+
+ /**
+ * Runs after each test.
+ */
+ public function tearDown(): void {
+ foreach ( $this->created_orders as $order_id ) {
+ $order = wc_get_order( $order_id );
+ if ( $order ) {
+ foreach ( $order->get_refunds() as $refund ) {
+ $refund->delete( true );
+ }
+ $order->delete( true );
+ }
+ }
+ $this->created_orders = array();
+
+ global $wpdb;
+ $wpdb->query( "DELETE FROM {$wpdb->prefix}woocommerce_tax_rate_locations" );
+ $wpdb->query( "DELETE FROM {$wpdb->prefix}woocommerce_tax_rates" );
+
+ parent::tearDown();
+ $this->disable_rest_api_v4_feature();
+ }
+
+ /**
+ * @testdox P1: Preview a single full line item with no tax returns correct totals.
+ */
+ public function test_preview_single_line_item_no_tax(): void {
+ $order = $this->create_order_with_product( 50.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,
+ ),
+ )
+ );
+
+ $this->assertEquals( 200, $response->get_status() );
+ $data = $response->get_data();
+
+ $this->assertEquals( '100.00', $data['subtotal'] );
+ $this->assertEquals( '0.00', $data['tax'] );
+ $this->assertEquals( '100.00', $data['total'] );
+ $this->assertCount( 1, $data['breakdown']['products']['items'] );
+ $this->assertEquals( 2, $data['breakdown']['products']['items'][0]['quantity'] );
+ }
+
+ /**
+ * @testdox P2: Preview a single line item with 10% tax extracts tax correctly.
+ */
+ public function test_preview_single_line_item_with_tax(): 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 );
+
+ $response = $this->do_preview_request(
+ $order->get_id(),
+ array(
+ array(
+ 'line_item_id' => $item_id,
+ 'quantity' => 1,
+ ),
+ )
+ );
+
+ $this->assertEquals( 200, $response->get_status() );
+ $data = $response->get_data();
+
+ $this->assertEquals( '100.00', $data['subtotal'] );
+ $this->assertEquals( '10.00', $data['tax'] );
+ $this->assertEquals( '110.00', $data['total'] );
+ }
+
+ /**
+ * @testdox P3: Preview partial quantity returns proportional totals.
+ */
+ public function test_preview_partial_quantity(): void {
+ $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,
+ ),
+ )
+ );
+
+ $this->assertEquals( 200, $response->get_status() );
+ $data = $response->get_data();
+
+ $this->assertEquals( '20.00', $data['total'], 'Partial refund of 2 of 5 at $10 each should be $20' );
+ $this->assertEquals( 2, $data['breakdown']['products']['items'][0]['quantity'] );
+ }
+
+ /**
+ * @testdox P4: Preview multiple line items returns aggregated totals.
+ */
+ public function test_preview_multiple_line_items(): void {
+ $product_a = WC_Helper_Product::create_simple_product();
+ $product_a->set_regular_price( 20.00 );
+ $product_a->save();
+
+ $product_b = WC_Helper_Product::create_simple_product();
+ $product_b->set_regular_price( 30.00 );
+ $product_b->save();
+
+ $order = wc_create_order();
+ $item_a = new WC_Order_Item_Product();
+ $item_a->set_props(
+ array(
+ 'product' => $product_a,
+ 'quantity' => 2,
+ 'subtotal' => 40.00,
+ 'total' => 40.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' => 30.00,
+ 'total' => 30.00,
+ )
+ );
+ $item_b->save();
+ $order->add_item( $item_b );
+
+ $order->set_total( 70.00 );
+ $order->set_status( OrderStatus::COMPLETED );
+ $order->save();
+ $this->created_orders[] = $order->get_id();
+
+ $response = $this->do_preview_request(
+ $order->get_id(),
+ array(
+ array(
+ 'line_item_id' => $item_a->get_id(),
+ 'quantity' => 1,
+ ),
+ array(
+ 'line_item_id' => $item_b->get_id(),
+ 'quantity' => 1,
+ ),
+ )
+ );
+
+ $this->assertEquals( 200, $response->get_status() );
+ $data = $response->get_data();
+
+ $this->assertEquals( '50.00', $data['total'], '20 + 30 = 50' );
+ $this->assertCount( 2, $data['breakdown']['products']['items'] );
+
+ $product_a->delete( true );
+ $product_b->delete( true );
+ }
+
+ /**
+ * @testdox P7: Preview with quantity exceeding refundable returns error.
+ */
+ public function test_preview_quantity_exceeds_refundable(): void {
+ // Create order with qty=2 so a partial refund leaves remaining amount.
+ $order = $this->create_order_with_product( 25.00, 2 );
+ $item_id = $this->get_first_line_item_id( $order );
+
+ // Refund 1 unit (leaves 1 remaining and $25 remaining amount).
+ wc_create_refund(
+ array(
+ 'order_id' => $order->get_id(),
+ 'amount' => 25.00,
+ 'line_items' => array(
+ $item_id => array(
+ 'qty' => 1,
+ 'refund_total' => 25.00,
+ 'refund_tax' => array(),
+ ),
+ ),
+ )
+ );
+
+ // Try to refund 2, but only 1 remains.
+ $response = $this->do_preview_request(
+ $order->get_id(),
+ array(
+ array(
+ 'line_item_id' => $item_id,
+ 'quantity' => 2,
+ ),
+ )
+ );
+
+ $this->assertEquals( 422, $response->get_status() );
+ $data = $response->get_data();
+ $this->assertEquals( 'quantity_exceeds_refundable', $data['code'] );
+ }
+
+ /**
+ * @testdox P8: Preview with invalid line item ID returns line_item_not_found.
+ */
+ public function test_preview_invalid_line_item(): void {
+ $order = $this->create_order_with_product( 50.00, 1 );
+ $existing_item_id = $this->get_first_line_item_id( $order );
+ $nonexistent_id = $existing_item_id + 999;
+
+ $response = $this->do_preview_request(
+ $order->get_id(),
+ array(
+ array(
+ 'line_item_id' => $nonexistent_id,
+ 'quantity' => 1,
+ ),
+ )
+ );
+
+ $this->assertEquals( 404, $response->get_status() );
+ $data = $response->get_data();
+ $this->assertEquals( 'line_item_not_found', $data['code'] );
+ }
+
+ /**
+ * @testdox Preview returns 422 preview_exceeds_max_refundable when the computed total exceeds the order's remaining refundable amount.
+ *
+ * An amount-only partial refund (no line items attached) drops
+ * `get_remaining_refund_amount()` but leaves per-line quantities intact,
+ * so the per-line validation can still let a preview through that would
+ * over-refund in aggregate. The endpoint's grand-total guard catches it.
+ *
+ * Setup: 2 × $100 order ($200 refundable) → $50 amount-only refund applied
+ * → remaining = $150. Previewing qty 2 would compute total $200, exceeding
+ * the $150 remaining → 422 `preview_exceeds_max_refundable`.
+ */
+ public function test_preview_returns_422_when_total_exceeds_max_refundable(): void {
+ $order = $this->create_order_with_product( 100.00, 2 );
+ $item_id = $this->get_first_line_item_id( $order );
+
+ // Amount-only partial refund — drops remaining refundable to $150
+ // without consuming any specific units of the line item.
+ wc_create_refund(
+ array(
+ 'order_id' => $order->get_id(),
+ 'amount' => 50.00,
+ )
+ );
+
+ $response = $this->do_preview_request(
+ $order->get_id(),
+ array(
+ array(
+ 'line_item_id' => $item_id,
+ 'quantity' => 2,
+ ),
+ )
+ );
+
+ $this->assertEquals( 422, $response->get_status() );
+ $data = $response->get_data();
+ $this->assertEquals( 'preview_exceeds_max_refundable', $data['code'] );
+ }
+
+ /**
+ * @testdox P9: Preview on fully refunded order returns error.
+ */
+ public function test_preview_fully_refunded_order(): void {
+ $order = $this->create_order_with_product( 50.00, 1 );
+ $item_id = $this->get_first_line_item_id( $order );
+
+ wc_create_refund(
+ array(
+ 'order_id' => $order->get_id(),
+ 'amount' => 50.00,
+ )
+ );
+
+ $response = $this->do_preview_request(
+ $order->get_id(),
+ array(
+ array(
+ 'line_item_id' => $item_id,
+ 'quantity' => 1,
+ ),
+ )
+ );
+
+ $this->assertEquals( 422, $response->get_status() );
+ $data = $response->get_data();
+ $this->assertEquals( 'order_not_refundable', $data['code'] );
+ }
+
+ /**
+ * @testdox P11: Preview with empty line_items array is rejected by schema validation.
+ *
+ * REST schema validation (minItems: 1) rejects the request before it reaches
+ * the controller, so the framework's generic 'rest_invalid_param' code wins
+ * over DataUtils's curated 'missing_line_items'. The HTTP contract still
+ * delivers a 400 with an actionable message.
+ */
+ public function test_preview_empty_line_items(): void {
+ $order = $this->create_order_with_product( 50.00, 1 );
+
+ $response = $this->do_preview_request( $order->get_id(), array() );
+
+ $this->assertEquals( 400, $response->get_status() );
+ $data = $response->get_data();
+ $this->assertEquals( 'rest_invalid_param', $data['code'] );
+ }
+
+ /**
+ * @testdox Preview rejects invalid quantity values (zero, negative, missing, non-integer).
+ *
+ * @dataProvider invalid_quantity_provider
+ *
+ * @param array $line_item_overrides Overrides merged into the line item entry (after line_item_id).
+ * @param array $expected_codes Acceptable response error codes (REST framework or DataUtils).
+ */
+ public function test_preview_invalid_quantity( array $line_item_overrides, array $expected_codes ): void {
+ $order = $this->create_order_with_product( 50.00, 1 );
+ $item_id = $this->get_first_line_item_id( $order );
+
+ $line_item = array_merge( array( 'line_item_id' => $item_id ), $line_item_overrides );
+ $response = $this->do_preview_request( $order->get_id(), array( $line_item ) );
+
+ $this->assertEquals( 400, $response->get_status() );
+ $data = $response->get_data();
+ $this->assertContains( $data['code'], $expected_codes, 'Got code ' . $data['code'] );
+ }
+
+ /**
+ * 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.
+ *
+ * @return array<string, array<int, mixed>>
+ */
+ public function invalid_quantity_provider(): array {
+ 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' ) ),
+ 'string' => array( array( 'quantity' => 'abc' ), array( 'rest_invalid_param', 'invalid_quantity' ) ),
+ 'float' => array( array( 'quantity' => 1.5 ), array( 'rest_invalid_param', 'invalid_quantity' ) ),
+ );
+ }
+
+ /**
+ * @testdox Preview rejects malformed line_items payload at REST validation boundary.
+ */
+ public function test_preview_invalid_payload_shape(): void {
+ $order = $this->create_order_with_product( 50.00, 1 );
+
+ $response = $this->do_preview_request(
+ $order->get_id(),
+ array(
+ array(
+ 'line_item_id' => 'not-an-int',
+ 'quantity' => 'also-not-an-int',
+ ),
+ )
+ );
+
+ $this->assertEquals( 400, $response->get_status() );
+ $data = $response->get_data();
+ $this->assertEquals( 'rest_invalid_param', $data['code'] );
+ }
+
+ /**
+ * @testdox Preview returns INVALID_ID for an order ID belonging to a non-shop_order post type.
+ */
+ public function test_preview_non_shop_order_returns_invalid_id(): void {
+ // Create a refund directly — wc_get_order() will return it but get_type() is shop_order_refund.
+ $order = $this->create_order_with_product( 50.00, 1 );
+ $refund = wc_create_refund(
+ array(
+ 'order_id' => $order->get_id(),
+ 'amount' => 10.00,
+ )
+ );
+ $this->assertNotInstanceOf( \WP_Error::class, $refund );
+
+ $response = $this->do_preview_request(
+ $refund->get_id(),
+ array(
+ array(
+ 'line_item_id' => $this->get_first_line_item_id( $order ),
+ 'quantity' => 1,
+ ),
+ )
+ );
+
+ $this->assertEquals( 404, $response->get_status() );
+ }
+
+ /**
+ * @testdox Preview rejects unauthorized users (read-only / customer role).
+ */
+ public function test_preview_read_only_user_returns_forbidden(): void {
+ $customer_id = wp_insert_user(
+ array(
+ 'user_login' => 'preview_customer_' . wp_generate_password( 6, false ),
+ 'user_email' => 'customer_' . wp_generate_password( 6, false ) . '@example.com',
+ 'user_pass' => 'password',
+ 'role' => 'customer',
+ )
+ );
+ if ( is_wp_error( $customer_id ) ) {
+ $this->fail( 'Could not create test customer: ' . $customer_id->get_error_message() );
+ }
+ $customer_id = (int) $customer_id;
+ wp_set_current_user( $customer_id );
+
+ $order = $this->create_order_with_product( 50.00, 1 );
+
+ $response = $this->do_preview_request(
+ $order->get_id(),
+ array(
+ array(
+ 'line_item_id' => $this->get_first_line_item_id( $order ),
+ 'quantity' => 1,
+ ),
+ )
+ );
+
+ $this->assertContains( $response->get_status(), array( 401, 403 ) );
+
+ // Restore admin user for teardown.
+ wp_set_current_user( self::$user_id );
+ wp_delete_user( $customer_id );
+ }
+
+ /**
+ * @testdox Response shape matches the published schema (keys-only parity, recursive).
+ */
+ public function test_schema_matches_response_shape(): void {
+ // Build a mixed-section order so every section's items[] has at least one entry to walk.
+ $order = $this->create_order_with_product( 50.00, 1 );
+ $item_id = $this->get_first_line_item_id( $order );
+
+ $shipping = new \WC_Order_Item_Shipping();
+ $shipping->set_props(
+ array(
+ 'method_title' => 'Flat Rate',
+ 'total' => 10.00,
+ )
+ );
+ $shipping->save();
+ $order->add_item( $shipping );
+
+ $fee = new \WC_Order_Item_Fee();
+ $fee->set_props(
+ array(
+ 'name' => 'Service fee',
+ 'total' => 5.00,
+ )
+ );
+ $fee->save();
+ $order->add_item( $fee );
+
+ $order->set_total( 65.00 );
+ $order->save();
+
+ $response = $this->do_preview_request(
+ $order->get_id(),
+ array(
+ array(
+ 'line_item_id' => $item_id,
+ 'quantity' => 1,
+ ),
+ array(
+ 'line_item_id' => $shipping->get_id(),
+ 'quantity' => 1,
+ ),
+ array(
+ 'line_item_id' => $fee->get_id(),
+ 'quantity' => 1,
+ ),
+ )
+ );
+
+ $this->assertEquals( 200, $response->get_status() );
+ $data = $response->get_data();
+
+ $schema_properties = wc_get_container()
+ ->get( \Automattic\WooCommerce\Internal\RestApi\Routes\V4\Refunds\Schema\RefundPreviewSchema::class )
+ ->get_item_schema_properties();
+
+ $this->assertSchemaKeysMatchData( $schema_properties, $data, 'root' );
+ }
+
+ /**
+ * Assert that every key present in $data is declared in the schema and vice
+ * versa for object subtrees. Skips assertion at array-of-objects boundaries
+ * (the items[] array) and instead recurses into the first element's shape
+ * against the items.items schema. Optional fields (e.g. product_id only on
+ * the products section) are tolerated when absent from $data.
+ *
+ * @param array $schema Schema fragment (an associative array of property name => spec, or a single-property spec).
+ * @param mixed $data Data fragment at the same path.
+ * @param string $path Dot path for assertion messages.
+ */
+ private function assertSchemaKeysMatchData( array $schema, $data, string $path ): void {
+ // Treat each entry as a property descriptor.
+ foreach ( $schema as $name => $spec ) {
+ if ( ! is_array( $spec ) ) {
+ continue;
+ }
+ $type = $spec['type'] ?? null;
+ if ( 'object' === $type && isset( $spec['properties'] ) ) {
+ if ( ! array_key_exists( $name, $data ) ) {
+ $this->fail( "Schema declares object '{$path}.{$name}' but response is missing it" );
+ }
+ $this->assertSchemaKeysMatchData( $spec['properties'], $data[ $name ], "{$path}.{$name}" );
+ } elseif ( 'array' === $type && isset( $spec['items']['properties'] ) ) {
+ if ( ! array_key_exists( $name, $data ) ) {
+ $this->fail( "Schema declares array '{$path}.{$name}' but response is missing it" );
+ }
+ if ( ! empty( $data[ $name ] ) ) {
+ $this->assertSchemaKeysMatchData( $spec['items']['properties'], $data[ $name ][0], "{$path}.{$name}[0]" );
+ }
+ } elseif ( ! array_key_exists( $name, $data ) ) {
+ // Scalar field missing from data is OK. The products-only `product_id` field is
+ // legitimately absent on shipping/fees sections.
+ continue;
+ }
+ }
+
+ // Inverse check: every key in $data should be declared in the schema.
+ if ( is_array( $data ) && array_keys( $data ) !== range( 0, count( $data ) - 1 ) ) {
+ foreach ( array_keys( $data ) as $key ) {
+ if ( is_string( $key ) ) {
+ $this->assertArrayHasKey(
+ $key,
+ $schema,
+ "Response key '{$path}.{$key}' is not declared in the schema"
+ );
+ }
+ }
+ }
+ }
+
+ /**
+ * @testdox Preview returns 500 with invalid_preview_request when build_refund_preview throws an invariant violation.
+ */
+ public function test_preview_invariant_violation_returns_500(): void {
+ $order = $this->create_order_with_product( 50.00, 1 );
+ $item_id = $this->get_first_line_item_id( $order );
+
+ // Stub DataUtils so validate_preview_line_items passes but build_refund_preview throws.
+ $stub = new class() extends \Automattic\WooCommerce\Internal\RestApi\Routes\V4\Refunds\DataUtils {
+ /**
+ * Validation is forced to pass so the controller reaches the build step.
+ *
+ * @param array $line_items Ignored.
+ * @param \WC_Order $order Ignored.
+ * @return bool
+ */
+ public function validate_preview_line_items( array $line_items, \WC_Order $order ) {
+ return true;
+ }
+ // Stub always throws; the : array return type is never reached.
+ // phpcs:disable Squiz.Commenting.FunctionComment.InvalidNoReturn
+ /**
+ * Always throws to exercise the controller's InvalidArgumentException catch arm.
+ *
+ * @param \WC_Order $order Ignored.
+ * @param array $line_items Ignored.
+ * @return array
+ * @throws \InvalidArgumentException Always.
+ */
+ public function build_refund_preview( \WC_Order $order, array $line_items ): array {
+ throw new \InvalidArgumentException( 'simulated invariant violation' );
+ }
+ // phpcs:enable Squiz.Commenting.FunctionComment.InvalidNoReturn
+ };
+ wc_get_container()->get( \Automattic\WooCommerce\Internal\RestApi\Routes\V4\Refunds\Controller::class )
+ ->init(
+ wc_get_container()->get( \Automattic\WooCommerce\Internal\RestApi\Routes\V4\Refunds\Schema\RefundSchema::class ),
+ wc_get_container()->get( \Automattic\WooCommerce\Internal\RestApi\Routes\V4\Refunds\Schema\RefundPreviewSchema::class ),
+ wc_get_container()->get( \Automattic\WooCommerce\Internal\RestApi\Routes\V4\Refunds\CollectionQuery::class ),
+ $stub
+ );
+
+ try {
+ $response = $this->do_preview_request(
+ $order->get_id(),
+ array(
+ array(
+ 'line_item_id' => $item_id,
+ 'quantity' => 1,
+ ),
+ )
+ );
+
+ $this->assertEquals( 500, $response->get_status() );
+ $data = $response->get_data();
+ $this->assertEquals( 'invalid_preview_request', $data['code'] );
+ } finally {
+ // Restore the real DataUtils for subsequent tests in this run.
+ wc_get_container()->get( \Automattic\WooCommerce\Internal\RestApi\Routes\V4\Refunds\Controller::class )
+ ->init(
+ wc_get_container()->get( \Automattic\WooCommerce\Internal\RestApi\Routes\V4\Refunds\Schema\RefundSchema::class ),
+ wc_get_container()->get( \Automattic\WooCommerce\Internal\RestApi\Routes\V4\Refunds\Schema\RefundPreviewSchema::class ),
+ wc_get_container()->get( \Automattic\WooCommerce\Internal\RestApi\Routes\V4\Refunds\CollectionQuery::class ),
+ wc_get_container()->get( \Automattic\WooCommerce\Internal\RestApi\Routes\V4\Refunds\DataUtils::class )
+ );
+ }
+ }
+
+ /**
+ * @testdox Preview on order with shipping-only line returns populated shipping section.
+ */
+ public function test_preview_shipping_line(): void {
+ $order = $this->create_order_with_shipping( 10.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(),
+ 'quantity' => 1,
+ ),
+ )
+ );
+
+ $this->assertEquals( 200, $response->get_status() );
+ $data = $response->get_data();
+ $this->assertCount( 1, $data['breakdown']['shipping']['items'] );
+ $this->assertEmpty( $data['breakdown']['products']['items'] );
+ $this->assertEmpty( $data['breakdown']['fees']['items'] );
+ $this->assertEquals( '10.00', $data['breakdown']['shipping']['total'] );
+ $this->assertEquals( '10.00', $data['total'] );
+ }
+
+ /**
+ * @testdox Preview on order with fee-only line returns populated fees section.
+ */
+ public function test_preview_fee_line(): void {
+ $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(),
+ 'quantity' => 1,
+ ),
+ )
+ );
+
+ $this->assertEquals( 200, $response->get_status() );
+ $data = $response->get_data();
+ $this->assertCount( 1, $data['breakdown']['fees']['items'] );
+ $this->assertEmpty( $data['breakdown']['products']['items'] );
+ $this->assertEmpty( $data['breakdown']['shipping']['items'] );
+ $this->assertEquals( '20.00', $data['breakdown']['fees']['total'] );
+ $this->assertEquals( '20.00', $data['total'] );
+ }
+
+ /**
+ * @testdox Preview on mixed order aggregates products, shipping, and fees sections correctly.
+ */
+ public function test_preview_mixed_sections(): void {
+ $order = $this->create_order_with_product( 50.00, 1 );
+ $item_id = $this->get_first_line_item_id( $order );
+
+ $shipping = new \WC_Order_Item_Shipping();
+ $shipping->set_props(
+ array(
+ 'method_title' => 'Flat Rate',
+ 'total' => 10.00,
+ )
+ );
+ $shipping->save();
+ $order->add_item( $shipping );
+
+ $fee = new \WC_Order_Item_Fee();
+ $fee->set_props(
+ array(
+ 'name' => 'Service fee',
+ 'total' => 5.00,
+ )
+ );
+ $fee->save();
+ $order->add_item( $fee );
+
+ $order->set_total( 65.00 );
+ $order->save();
+
+ $response = $this->do_preview_request(
+ $order->get_id(),
+ array(
+ array(
+ 'line_item_id' => $item_id,
+ 'quantity' => 1,
+ ),
+ array(
+ 'line_item_id' => $shipping->get_id(),
+ 'quantity' => 1,
+ ),
+ array(
+ 'line_item_id' => $fee->get_id(),
+ 'quantity' => 1,
+ ),
+ )
+ );
+
+ $this->assertEquals( 200, $response->get_status() );
+ $data = $response->get_data();
+ $this->assertEquals( '50.00', $data['breakdown']['products']['total'] );
+ $this->assertEquals( '10.00', $data['breakdown']['shipping']['total'] );
+ $this->assertEquals( '5.00', $data['breakdown']['fees']['total'] );
+ $this->assertEquals( '65.00', $data['total'] );
+ }
+
+ /**
+ * @testdox P15: Preview without authentication returns 401.
+ */
+ public function test_preview_unauthenticated(): void {
+ $order = $this->create_order_with_product( 50.00, 1 );
+ wp_set_current_user( 0 );
+
+ $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,
+ ),
+ )
+ );
+
+ $this->assertContains( $response->get_status(), array( 401, 403 ) );
+ }
+
+ /**
+ * @testdox P17: Preview does NOT create a refund record.
+ */
+ public function test_preview_does_not_create_refund(): void {
+ $order = $this->create_order_with_product( 50.00, 1 );
+ $item_id = $this->get_first_line_item_id( $order );
+
+ $refunds_before = $order->get_refunds();
+
+ $response = $this->do_preview_request(
+ $order->get_id(),
+ array(
+ array(
+ 'line_item_id' => $item_id,
+ 'quantity' => 1,
+ ),
+ )
+ );
+
+ $this->assertEquals( 200, $response->get_status() );
+
+ // Reload the order and check refunds.
+ $order = wc_get_order( $order->get_id() );
+ $refunds_after = $order->get_refunds();
+
+ $this->assertCount( count( $refunds_before ), $refunds_after, 'Preview should not create any refund records' );
+ }
+
+ /**
+ * @testdox P19: Preview response total matches subsequent create response total for same inputs.
+ */
+ public function test_preview_matches_create(): 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 );
+
+ // Get preview.
+ $preview_response = $this->do_preview_request(
+ $order->get_id(),
+ array(
+ array(
+ 'line_item_id' => $item_id,
+ 'quantity' => 1,
+ ),
+ )
+ );
+ $this->assertEquals( 200, $preview_response->get_status() );
+ $preview_data = $preview_response->get_data();
+
+ // Create the actual refund. Drive refund_total from the preview total so a divergence
+ // between preview and create produces an actual mismatch rather than passing by coincidence.
+ // Both preview `total` and create `refund_total` are tax-inclusive.
+ $preview_total_with_tax = (float) $preview_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' => array(
+ array(
+ 'line_item_id' => $item_id,
+ 'quantity' => 1,
+ 'refund_total' => $preview_total_with_tax,
+ ),
+ ),
+ )
+ );
+ $create_response = $this->server->dispatch( $create_request );
+ $this->assertEquals( 201, $create_response->get_status() );
+ $create_data = $create_response->get_data();
+
+ $this->assertEquals(
+ wc_format_decimal( $preview_total_with_tax, wc_get_price_decimals() ),
+ $create_data['amount'],
+ 'Preview total + tax must match create refund amount exactly'
+ );
+ }
+
+ /**
+ * @testdox Preview response includes product metadata (name, product_id).
+ */
+ public function test_preview_includes_product_metadata(): void {
+ $product = WC_Helper_Product::create_simple_product();
+ $product->set_regular_price( 50.00 );
+ $product->save();
+
+ $order = wc_create_order();
+ $item = new WC_Order_Item_Product();
+ $item->set_props(
+ array(
+ 'product' => $product,
+ 'quantity' => 1,
+ 'subtotal' => 50.00,
+ 'total' => 50.00,
+ )
+ );
+ $item->save();
+ $order->add_item( $item );
+ $order->set_total( 50.00 );
+ $order->set_status( OrderStatus::COMPLETED );
+ $order->save();
+ $this->created_orders[] = $order->get_id();
+
+ $response = $this->do_preview_request(
+ $order->get_id(),
+ array(
+ array(
+ 'line_item_id' => $item->get_id(),
+ 'quantity' => 1,
+ ),
+ )
+ );
+
+ $this->assertEquals( 200, $response->get_status() );
+ $data = $response->get_data();
+
+ $product_item = $data['breakdown']['products']['items'][0];
+ $this->assertArrayHasKey( 'name', $product_item );
+ $this->assertArrayHasKey( 'product_id', $product_item );
+ $this->assertArrayNotHasKey( 'variation_id', $product_item );
+ $this->assertNotEmpty( $product_item['name'] );
+ $this->assertEquals( $product->get_id(), $product_item['product_id'] );
+
+ $product->delete( true );
+ }
+
+ /**
+ * @testdox Preview on cancelled order returns order_not_refundable error.
+ */
+ public function test_preview_cancelled_order(): void {
+ $product = WC_Helper_Product::create_simple_product();
+ $product->set_regular_price( 50.00 );
+ $product->save();
+
+ $order = wc_create_order();
+ $item = new WC_Order_Item_Product();
+ $item->set_props(
+ array(
+ 'product' => $product,
+ 'quantity' => 1,
+ 'subtotal' => 50.00,
+ 'total' => 50.00,
+ )
+ );
+ $item->save();
+ $order->add_item( $item );
+ $order->set_total( 50.00 );
+ $order->set_status( OrderStatus::CANCELLED );
+ $order->save();
+ $this->created_orders[] = $order->get_id();
+
+ $response = $this->do_preview_request(
+ $order->get_id(),
+ array(
+ array(
+ 'line_item_id' => $item->get_id(),
+ 'quantity' => 1,
+ ),
+ )
+ );
+
+ $this->assertEquals( 422, $response->get_status() );
+ $data = $response->get_data();
+ $this->assertEquals( 'order_not_refundable', $data['code'] );
+
+ $product->delete( true );
+ }
+
+ /**
+ * @testdox Preview includes max_refundable amount.
+ */
+ public function test_preview_includes_max_refundable(): void {
+ $order = $this->create_order_with_product( 100.00, 2 );
+ $item_id = $this->get_first_line_item_id( $order );
+
+ // Partially refund $50.
+ wc_create_refund(
+ array(
+ 'order_id' => $order->get_id(),
+ 'amount' => 50.00,
+ )
+ );
+
+ $response = $this->do_preview_request(
+ $order->get_id(),
+ array(
+ array(
+ 'line_item_id' => $item_id,
+ 'quantity' => 1,
+ ),
+ )
+ );
+
+ $this->assertEquals( 200, $response->get_status() );
+ $data = $response->get_data();
+
+ $this->assertEquals( '150.00', $data['max_refundable'], 'Max refundable should be original total minus already refunded' );
+ }
+
+ // -- Helper methods --
+
+ /**
+ * Create an order with a product line item.
+ *
+ * @param float $unit_price Product price per unit.
+ * @param int $quantity Quantity.
+ * @return WC_Order
+ */
+ private function create_order_with_product( float $unit_price, int $quantity ): WC_Order {
+ $product = WC_Helper_Product::create_simple_product();
+ $product->set_regular_price( $unit_price );
+ $product->save();
+
+ $order = wc_create_order();
+ $item = new WC_Order_Item_Product();
+ $item->set_props(
+ array(
+ 'product' => $product,
+ 'quantity' => $quantity,
+ 'subtotal' => $unit_price * $quantity,
+ 'total' => $unit_price * $quantity,
+ )
+ );
+ $item->save();
+ $order->add_item( $item );
+ $order->set_total( $unit_price * $quantity );
+ $order->set_status( OrderStatus::COMPLETED );
+ $order->save();
+
+ $this->created_orders[] = $order->get_id();
+ $product->delete( true );
+
+ return $order;
+ }
+
+ /**
+ * Create a completed order with a single shipping line.
+ *
+ * @param float $total Shipping total.
+ * @return WC_Order
+ */
+ private function create_order_with_shipping( float $total ): WC_Order {
+ $order = wc_create_order();
+ $shipping = new \WC_Order_Item_Shipping();
+ $shipping->set_props(
+ array(
+ 'method_title' => 'Flat Rate',
+ 'total' => $total,
+ )
+ );
+ $shipping->save();
+ $order->add_item( $shipping );
+ $order->set_total( $total );
+ $order->set_status( OrderStatus::COMPLETED );
+ $order->save();
+
+ $this->created_orders[] = $order->get_id();
+
+ return $order;
+ }
+
+ /**
+ * Create a completed order with a single fee line.
+ *
+ * @param float $total Fee total.
+ * @return WC_Order
+ */
+ private function create_order_with_fee( float $total ): WC_Order {
+ $order = wc_create_order();
+ $fee = new \WC_Order_Item_Fee();
+ $fee->set_props(
+ array(
+ 'name' => 'Service fee',
+ 'total' => $total,
+ )
+ );
+ $fee->save();
+ $order->add_item( $fee );
+ $order->set_total( $total );
+ $order->set_status( OrderStatus::COMPLETED );
+ $order->save();
+
+ $this->created_orders[] = $order->get_id();
+
+ return $order;
+ }
+
+ /**
+ * Create an order with a product and tax.
+ *
+ * @param float $product_price Product price.
+ * @param int $quantity Quantity.
+ * @param int $tax_rate_id Tax rate ID.
+ * @param float $tax_amount Tax amount.
+ * @return WC_Order
+ */
+ private function create_order_with_product_and_tax( float $product_price, int $quantity, int $tax_rate_id, float $tax_amount ): WC_Order {
+ $product = WC_Helper_Product::create_simple_product();
+ $product->set_regular_price( $product_price );
+ $product->set_tax_status( 'taxable' );
+ $product->save();
+
+ $total = $product_price * $quantity;
+ $order = wc_create_order();
+ $item = new WC_Order_Item_Product();
+ $item->set_props(
+ array(
+ 'product' => $product,
+ 'quantity' => $quantity,
+ 'subtotal' => $total,
+ 'total' => $total,
+ )
+ );
+ $item->set_taxes(
+ array(
+ 'total' => array( $tax_rate_id => $tax_amount ),
+ 'subtotal' => array( $tax_rate_id => $tax_amount ),
+ )
+ );
+ $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( $tax_amount );
+ $tax_item->save();
+ $order->add_item( $tax_item );
+
+ $order->set_billing_country( 'US' );
+ $order->set_total( $total + $tax_amount );
+ $order->set_status( OrderStatus::COMPLETED );
+ $order->save();
+
+ $this->created_orders[] = $order->get_id();
+ $product->delete( true );
+
+ return $order;
+ }
+
+ /**
+ * Create a tax rate.
+ *
+ * @param float $rate Tax rate percentage.
+ * @return int Tax rate ID.
+ */
+ private function create_tax_rate( float $rate ): int {
+ return WC_Tax::_insert_tax_rate(
+ array(
+ 'tax_rate_country' => 'US',
+ 'tax_rate_state' => '',
+ 'tax_rate' => number_format( $rate, 4 ),
+ 'tax_rate_name' => 'Tax',
+ 'tax_rate_priority' => '1',
+ 'tax_rate_compound' => '0',
+ 'tax_rate_shipping' => '1',
+ 'tax_rate_order' => '1',
+ 'tax_rate_class' => '',
+ )
+ );
+ }
+
+ /**
+ * Get the first line item ID from an order.
+ *
+ * @param WC_Order $order Order instance.
+ * @return int Line item ID.
+ */
+ private function get_first_line_item_id( WC_Order $order ): int {
+ $items = $order->get_items( 'line_item' );
+ $item = reset( $items );
+ return $item->get_id();
+ }
+
+ /**
+ * Send a preview request and return the response.
+ *
+ * @param int $order_id Order ID.
+ * @param array $line_items Line items array.
+ * @return WP_REST_Response
+ */
+ private function do_preview_request( int $order_id, array $line_items ): WP_REST_Response {
+ $request = new WP_REST_Request( 'POST', '/wc/v4/refunds/preview' );
+ $request->set_body_params(
+ array(
+ 'order_id' => $order_id,
+ 'line_items' => $line_items,
+ )
+ );
+ return $this->server->dispatch( $request );
+ }
+}
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 d37f19147f1..e2079ba37e9 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
@@ -1321,6 +1321,61 @@ class DataUtilsTest extends WC_Unit_Test_Case {
$this->assertCount( 1, $result['breakdown']['products']['items'] );
$this->assertArrayHasKey( 'name', $result['breakdown']['products']['items'][0] );
$this->assertArrayHasKey( 'product_id', $result['breakdown']['products']['items'][0] );
+ $this->assertArrayHasKey( 'subtotal', $result['breakdown']['products']['items'][0] );
+ $this->assertArrayHasKey( 'tax', $result['breakdown']['products']['items'][0] );
+ $this->assertArrayHasKey( 'total', $result['breakdown']['products']['items'][0] );
+ $this->assertEquals( '100.00', $result['breakdown']['products']['items'][0]['subtotal'] );
+ $this->assertEquals( '10.00', $result['breakdown']['products']['items'][0]['tax'] );
+ $this->assertEquals( '110.00', $result['breakdown']['products']['items'][0]['total'] );
+ $this->assertArrayHasKey( 'subtotal', $result['breakdown']['products'] );
+ $this->assertArrayHasKey( 'tax', $result['breakdown']['products'] );
+ $this->assertArrayHasKey( 'total', $result['breakdown']['products'] );
+ $this->assertEquals( '100.00', $result['breakdown']['products']['subtotal'] );
+ $this->assertEquals( '10.00', $result['breakdown']['products']['tax'] );
+ $this->assertEquals( '110.00', $result['breakdown']['products']['total'] );
+ }
+
+ /**
+ * @testdox build_refund_preview should set product_id to the variation ID for variation line items.
+ */
+ public function test_build_refund_preview_product_id_is_variation_id_for_variations(): void {
+ $variable_product = WC_Helper_Product::create_variation_product();
+ $variation_ids = $variable_product->get_children();
+ $this->assertNotEmpty( $variation_ids, 'Variable product fixture should expose at least one variation.' );
+ $variation_id = (int) $variation_ids[0];
+
+ $order = wc_create_order();
+ $item = new WC_Order_Item_Product();
+ $item->set_props(
+ array(
+ 'product_id' => $variable_product->get_id(),
+ 'variation_id' => $variation_id,
+ 'quantity' => 1,
+ 'subtotal' => 10.00,
+ 'total' => 10.00,
+ )
+ );
+ $item->save();
+ $order->add_item( $item );
+ $order->save();
+
+ $result = $this->data_utils->build_refund_preview(
+ $order,
+ array(
+ array(
+ 'line_item_id' => $item->get_id(),
+ 'quantity' => 1,
+ ),
+ )
+ );
+
+ $product_item = $result['breakdown']['products']['items'][0];
+ $this->assertArrayHasKey( 'product_id', $product_item );
+ $this->assertArrayNotHasKey( 'variation_id', $product_item );
+ $this->assertSame( $variation_id, $product_item['product_id'] );
+
+ $variable_product->delete( true );
+ $order->delete( true );
}
/**