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 );
 	}

 	/**