Commit 2d6f2e0da11 for woocommerce

commit 2d6f2e0da11eeb15becf173df829d83f4b897ce3
Author: Samuel Urbanowicz <samiuelson@gmail.com>
Date:   Thu Jun 18 13:55:53 2026 +0200

    Simplify v4 refund creation: make per-line refund_total optional (#65439)

    * Add DataUtils helpers for refund preview endpoint

    Adds three new public methods to Refunds\DataUtils to support an
    upcoming refund preview endpoint (POST /wc/v4/refunds/preview):

    - compute_line_item_refund_total(): tax-inclusive refund total for
      a given line item at a requested quantity.
    - build_refund_preview(): returns the structured refund breakdown
      (products/shipping/fees with per-section subtotal/tax/total plus
      top-level subtotal/tax/total/max_refundable).
    - validate_preview_line_items(): validates a preview request against
      the order — checks status (REFUNDABLE_STATUSES), remaining refundable
      amount, line item existence, and remaining refundable quantity/total.

    Reuses the same tax extraction path (WC_Tax::calc_inclusive_tax) as
    the create endpoint to guarantee preview/create equivalence.

    Relaxes visibility (private -> protected) on three existing helpers
    (build_tax_rates_array, convert_line_item_taxes_to_internal_format,
    convert_proportional_taxes_to_schema_format) so the new methods and
    tests can reuse them.

    Part of WOOMOB-2684. The endpoint that consumes these helpers will
    follow in a separate PR.

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

    * Tighten validate_preview_line_items input validation

    - Reject missing/non-int/non-positive quantity with new code
      invalid_quantity. Previously `quantity = $line_item['quantity'] ?? 0`
      silently passed for missing/string/float input, then downstream
      consumers saw 0 or a coerced value.
    - Require quantity === 1 for shipping/fee items (they aren't
      quantity-divisible — the remaining-total branch was already not
      scaled by quantity).
    - Switch shipping/fee remaining-total math to abs()-based so
      legitimately negative-total fees (discount-as-fee pattern) aren't
      rejected as "fully refunded".
    - Replace the catch-all invalid_line_item code with 4 distinct codes
      so clients can distinguish failure modes:
        invalid_line_item (empty array)    -> missing_line_items
        invalid_line_item (missing id)     -> missing_line_item_id
        invalid_line_item (not found)      -> line_item_not_found
        invalid_line_item (unsupported)    -> unsupported_item_type

    Addresses review issues #1, #3, and the lower-priority error-code
    split from the PR #65334 review.

    * Throw on missing item in build_refund_preview

    Replace silent `continue` with InvalidArgumentException so callers
    can't get a successful-looking empty preview when a line_item_id is
    invalid (e.g. typo, race with delete, validation bypassed).

    Document precondition in the docblock: callers must invoke
    validate_preview_line_items() first.

    Addresses review issue #2.

    * Accumulate raw floats in build_refund_preview section sums

    Previously the per-section subtotal/tax/total were accumulated by
    casting already-formatted decimal strings back to floats via
    `(float) $item['subtotal']`, which loses precision and can produce
    a 1-cent drift between `breakdown.products.total` and the sum of
    `breakdown.products.items[].total` on multi-line refunds.

    Refactor: keep running raw-float totals per section during the
    per-item loop, format once at section level. Item-level strings are
    unchanged.

    Addresses review issue #5.

    * Log malformed tax data and zero-quantity branches

    - compute_line_item_refund_total: emit a warning before returning 0.0
      when a product item has zero original quantity. Indicates corrupted
      order data that would otherwise silently produce a $0 preview.
    - build_refund_preview: emit a warning when an item's taxes array is
      non-empty but all entries are filtered out by the
      is_numeric && > 0 check. Surfaces malformed tax metadata for ops
      without changing user-visible behavior.

    Both warnings use wc_get_logger() with source 'wc-v4-refunds'.

    Addresses review issues #6 and #8.

    * Add preconditions to compute_line_item_refund_total

    Guard $quantity >= 1 with an InvalidArgumentException at method entry.
    Document the precondition in the docblock plus a note that shipping
    and fee items ignore quantity, and that the return value can be
    negative for negative-total items (discount fees).

    The validator catches bad input at the request boundary; this guard
    protects direct callers since the method is public on an Internal\*
    class that may be reused by the create endpoint.

    Addresses review issue #7.

    * Expand DataUtils unit tests, drop reflection tests

    Delete the two reflection-based test_build_tax_rates_array_* tests.
    build_tax_rates_array is exercised indirectly by
    test_convert_line_items_extracts_tax_automatically and
    test_build_refund_preview_with_tax; the project convention is to test
    through public interfaces (see tests/php/src/CLAUDE.md).

    Add 19 unit tests for the helpers introduced in this PR:

    - compute_line_item_refund_total:
      * zero-original-quantity branch returns 0.0
      * shipping item (full total + tax, quantity ignored)
      * fee item with positive total
      * fee item with negative total (sign preserved)
      * InvalidArgumentException for quantity < 1 (data provider)

    - build_refund_preview:
      * shipping-only order (products + fees sections empty)
      * fee-only order (products + shipping sections empty)
      * mixed sections (products + shipping + fees aggregate correctly)
      * multi-item fractional-price aggregation (no drift between
        section total and sum of item totals)
      * InvalidArgumentException for missing line_item_id

    - validate_preview_line_items:
      * empty array -> missing_line_items
      * order with no remaining refund amount -> order_not_refundable
      * missing line_item_id key -> missing_line_item_id
      * cross-order line_item_id -> line_item_not_found
      * unsupported item type (tax line) -> unsupported_item_type
      * invalid quantity values (data provider) -> invalid_quantity
      * shipping with quantity \!= 1 -> invalid_quantity
      * shipping fully refunded -> order_not_refundable
      * negative-total fee passes validation

    * Fix PHPCS issues in DataUtils.php (escape exception output, align assignments)

    * Apply PHPCBF auto-fixes to DataUtilsTest

    * Remove unused @var docblock to satisfy lint

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

    * Populate HTTP status data on validate_preview_line_items WP_Errors

    Each WP_Error now carries a per-code 'status' key in its error
    data, so the REST controller can map to the right HTTP status
    instead of flattening everything to 400:

      missing_line_items           -> 400 Bad Request
      missing_line_item_id         -> 400 Bad Request
      invalid_quantity             -> 400 Bad Request
      line_item_not_found          -> 404 Not Found
      order_not_refundable         -> 422 Unprocessable Entity
      unsupported_item_type        -> 422 Unprocessable Entity
      quantity_exceeds_refundable  -> 422 Unprocessable Entity

    The controller-side switch to get_route_error_response_from_object
    landed in the endpoint PR (#65335); this commit activates it by
    populating the data the helper reads.

    * Make per-line refund_total optional on POST /wc/v4/refunds

    When a client sends a line item without `refund_total`, the backend
    now computes the tax-inclusive total from the order line item's unit
    price × quantity, via the existing
    DataUtils::compute_line_item_refund_total() helper (introduced in
    PR #65334 for the preview endpoint).

    This is the third v4 enhancement from the POS Refunds API spec
    (WOOMOB-2685). It eliminates the need for mobile POS clients to
    duplicate the backend's tax/rounding logic just to assemble a refund
    request.

    Changes:
    - DataUtils::fill_missing_refund_totals() — new helper that fills
      refund_total for any line item that omits it. Items that can't be
      resolved (missing id, item not on order, bad quantity, unsupported
      type) are left untouched; the existing validator surfaces the right
      error.
    - Refunds\Controller::create_item() — calls fill_missing_refund_totals
      after order resolution and before validation, so all downstream
      code (validator, converter, calculator) sees fully-populated input.
    - RefundSchema — removes the `default: 0` on line_items[].refund_total
      so "missing" is detectable downstream. Updates the field
      description to document the new optional behavior.

    Backward compatibility is preserved: clients that send refund_total
    explicitly continue to work unchanged, and clients that send the
    top-level `amount` continue to work unchanged. The existing under-
    refund check (amount must not be less than the line items' total)
    still runs against the auto-computed value.

    * Add tests for simplified refund creation

    Unit tests (DataUtilsTest) for fill_missing_refund_totals:
    - fills product line item from unit price × quantity
    - preserves explicit refund_total when present
    - skips items with line_item_id not on the order
    - skips bad quantity (data provider: null, 0, -1, "abc", 1.5)
    - fills shipping line item (full total, quantity ignored)
    - processes a mixed array (some with, some without refund_total)

    Integration tests (v4 refunds controller):
    - test_refunds_create_simplified_form_no_tax — auto-computed amount
      for a single product with no tax
    - test_refunds_create_simplified_form_with_tax — tax extraction works
      on the auto-computed total; per-line refund_tax is populated
    - test_refunds_create_simplified_matches_explicit — sending the
      computed refund_total explicitly produces the same amount as
      omitting it
    - test_refunds_create_mixed_with_and_without_refund_total — mixed
      request: one item auto-computed, one explicit
    - test_refunds_create_simplified_form_rejects_over_quantity — over-
      quantity is still rejected even when refund_total is auto-computed

    * Apply phpcbf auto-fixes to new tests

    * Fix inline comment punctuation in test_fill_missing_refund_totals_mixed

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

    * Reject missing/non-positive quantity with a specific error

    When a client sent {line_item_id: X} (no quantity), validate_line_items
    silently passed the existing checks (PHP's `int < null` evaluates to
    false) and the request cascaded into a misleading "Refund total must
    be greater than zero" error. The new method's docblock claimed "the
    downstream validator surfaces the right error" — until now, that
    claim was false for missing quantity.

    Add an explicit precondition in validate_line_items: quantity must be
    set, an integer, and >= 1. The error code stays the same
    (invalid_line_item) but the message names the actual problem.

    Also guards the existing $line_item['refund_total'] comparison with
    isset() so the simplified-form path (where refund_total may legitimately
    be absent at validation time if fill_missing_refund_totals skipped) no
    longer raises an undefined-index notice.

    Adds:
    - Unit test: validate_line_items rejects all bad quantity shapes
      (missing, 0, -1, string, float) via data provider
    - Integration test: POST to /wc/v4/refunds with missing quantity
      returns 400 invalid_line_item, not the misleading
      invalid_refund_amount cascade

    Addresses review issue #1 (the only critical) from the PR #65439 audit.

    * Treat refund_total null the same as missing; document zero semantics

    fill_missing_refund_totals now uses array_key_exists + null check
    instead of isset, so a client sending refund_total: null gets the
    same auto-computed value as omitting the field entirely. This
    removes a subtle behavioural split that was undocumented and easy
    to break in future refactors.

    Explicit refund_total: 0 is left untouched as before — calculate_
    refund_amount treats 0 as "no contribution to the sum" via its
    existing \!empty() check, which may trip the under-refund validation
    if the total amount is greater. The schema description now
    documents both behaviours explicitly.

    Adds:
    - Unit test asserting null is treated as missing
    - Unit test asserting explicit 0 is preserved

    Addresses review issue #2 from the PR #65439 audit.

    * Defensive InvalidArgumentException catch in create_item

    fill_missing_refund_totals pre-checks quantity before calling
    compute_line_item_refund_total, so the latter's
    InvalidArgumentException should be unreachable in normal flow.
    If a future refactor breaks that invariant the throw would
    bubble as a fatal 500 with no log entry.

    Mirror the preview_item pattern from PR #65335: catch
    InvalidArgumentException, log via wc_get_logger() with source
    'wc-v4-refunds' and the order id, return 500 with code
    'invalid_refund_request' and a generic user message (do not
    leak the exception message to clients).

    Addresses review issue #3 from the PR #65439 audit.

    * Sync $request['line_items'] after fill so hooks see augmented data

    Pre-PR behaviour populated refund_total: 0 on every line item via
    the schema default. With the default removed, third-party listeners
    on the 'woocommerce_rest_api_v4_refunds_created' hook reading
    $request['line_items'] would see entries without refund_total when
    the original client request used the simplified form — a silent
    semantic change.

    Mirror the augmented array back onto the request with
    $request->set_param('line_items', $line_items) after the fill, so
    all downstream readers (the hook payload, any future code that
    inspects the request) see normalised data with refund_total
    populated.

    Adds an integration test that registers a listener on the 'created'
    hook and asserts the captured request['line_items'] contains the
    auto-computed refund_total.

    Addresses review issue #4 from the PR #65439 audit.

    * Type-design polish: PHPDoc array shape, tax-inclusive convention comment

    - fill_missing_refund_totals docblock now expresses the line_items
      array shape as a PHPDoc generic
      (list<array{line_item_id?, quantity?, refund_total?, refund_tax?}>)
      on both @param and @return. Zero runtime cost; PHPStan now narrows
      the type at the single call site in Controller::create_item.
    - Expand the docblock prose to document the explicit-0 vs missing/null
      semantics introduced in the previous commit, and the tax-inclusive
      convention shared with compute_line_item_refund_total.
    - Add a code comment in Controller::create_item explaining that
      auto-computed and explicit refund_total values use the same
      (tax-inclusive) convention, so summing across mixed entries is safe.

    Addresses review issue #5 (type-design wins) from the PR #65439 audit.

    * Add integration tests for fee auto-compute (positive and negative)

    - test_refunds_create_simplified_form_fee_line: POSTs the simplified
      form for a positive-total fee, asserts the auto-computed amount
      equals the full fee total. Mirrors the existing shipping integration
      test (which had no equivalent for fees).
    - test_refunds_create_simplified_form_negative_fee: discount-as-fee
      scenario from the spec. Asserts that whatever the platform's
      behaviour for negative-fee refunds, the result is NOT a misleading
      invalid_refund_amount cascade. Locks the contract regardless of
      whether wc_create_refund accepts the negative.

    Addresses review issue #6 (fee auto-compute + negative-fee end-to-end)
    from the PR #65439 audit.

    * Lint fixes: docblock formatting + comment style

    * Review fixes: zero-qty source error + narrowed exception catch

    Address three issues from the 3-agent review:

    1. CRITICAL (silent failure): when a product line on the source order has
       quantity=0, fill_missing_refund_totals now skips it instead of letting
       compute_line_item_refund_total return 0.0 silently. validate_line_items
       surfaces a specific 'invalid_line_item' error telling the client to
       provide an explicit refund_total, replacing the misleading
       "must be greater than zero" cascade.

    2. HIGH (broad catch): \InvalidArgumentException is now caught only around
       the fill_missing_refund_totals call rather than the whole create_item
       try block, so genuine exceptions from wc_create_refund, MetaDataUtil,
       prepare_item_for_response, or third-party 'created' hook listeners are
       no longer swallowed. The order resolution check is tightened to
       instanceof WC_Order (rejecting WC_Order_Refund).

    3. Tests: add unit + integration coverage for the zero-qty source path,
       a tax-inclusive store (prices_include_tax=yes) integration test, and
       a cross-order line_item_id integration test. Also fix mis-indented
       trailing comments around lines 844-865 (PHPCS).

    * Fix CI: stale PHPStan baseline + flaky integration tests

    PHPStan baseline:
    - Remove 2 stale entries for DataUtils::convert_line_items_to_internal_format
      and DataUtils::validate_line_items 'expects WC_Order, got WC_Order|WC_Order_Refund'.
      The previous commit's instanceof WC_Order check in create_item resolved both.

    Test fixes (4 pre-existing flaky tests surfacing as CI failures):
    - test_refunds_create_simplified_form_with_tax: replace calculate_totals(false)
      with explicit set_total(110.00). The former does not reliably populate the
      order total in the test environment, so wc_create_refund rejected the
      refund with 'cannot_create_refund: Invalid refund amount'.
    - test_refunds_create_simplified_matches_explicit + hook test:
      use set_regular_price() instead of set_price(). set_price() only updates
      the in-memory derived price, so the order created via the REST API used
      the WC_Helper default ($10) instead of the test's $25.
    - test_refunds_create_simplified_form_negative_fee: tighten assertion to
      reflect actual platform behavior (negative-fee refunds inevitably trip
      the 0 > refund_amount guard and surface invalid_refund_amount). The
      previous assertion claimed this code must NOT appear, which was unrealistic.

    * Address Copilot review: schema null, comment indentation, dupe changelog

    - RefundSchema: refund_total now accepts ['number', 'null'] and drops the
      sanitize_text_field that was stripping null values. Aligns the actual
      schema with the documented behavior ('omitted OR null triggers
      backend computation').
    - Reformat four mis-aligned inline comments inside line_items arrays in
      the integration tests (PHPCS-correct + readable).
    - Remove duplicate changelog file 65439-woomob-2685-simplify-refund-creation;
      the woomob-2685-simplify-refund-creation entry is the canonical one.

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

    * Preserve legacy explicit-refund_total path without quantity

    The PR's strict quantity check in validate_line_items was meant to guard
    the new auto-compute path but unintentionally rejected the legacy v3-style
    request shape `{line_item_id, refund_total}` (no quantity) — which POS
    clients will rely on when the v4 features port to v3.

    Backend:
    - validate_line_items: gate the positive-integer quantity check on
      refund_total being absent. When refund_total is provided explicitly,
      quantity stays optional / informational (legacy behavior).
    - Gate the over-quantity check on quantity being set.

    Schema:
    - Drop `default: 0` on quantity (removes the misleading default — the
      field is genuinely optional now, conditional on refund_total).
    - Document the conditional requirement in the field description.

    Tests:
    - New integration test: POST /wc/v4/refunds with
      {line_item_id, refund_total: 50} and no quantity returns 201.
    - New unit test: validate_line_items accepts missing/zero quantity when
      refund_total is provided.

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

    * Fix silent-failure paths: line-item drop + explicit-zero in calculate

    Address two silent-failure paths surfaced by the re-review hunter:

    B (CRITICAL) — Legacy {line_item_id, refund_total} (no quantity) was
    silently dropped from the refund record. The validator accepted the
    shape but convert_line_items_to_internal_format required all three
    keys, so the line was skipped and wc_create_refund got an empty
    line_items array — the refund had the right dollar amount but no
    line attribution, leaving per-unit accounting broken for that line.

    Fix: relax the converter to require line_item_id + (quantity OR
    refund_total) and default qty=0 when quantity is missing. Matches
    v3 semantics ("refunded $X of this line without consuming specific
    units"). Dollar accounting via get_remaining_refund_amount still
    gates over-refunding regardless of the per-unit looseness.

    A (HIGH) — calculate_refund_amount used !empty() which treats an
    explicit refund_total of 0 as missing, silently dropping the line
    from the sum. Replace with isset() + is_numeric() so the explicit-
    zero contract documented in the schema is honored.

    Tests added/augmented:
    - test_refunds_create_legacy_form_no_quantity_with_explicit_refund_total
      now asserts the refund line item is attached (count=1) with
      qty=0 and a -$30 total, AND that a follow-up simplified-form
      refund exceeding remaining dollars is correctly rejected by
      wc_create_refund.
    - test_refunds_create_legacy_form_api_restock_does_not_restock pins
      the no-restock semantic: qty=0 means no units to add back, so
      api_restock=true is a no-op for legacy refunds.
    - test_calculate_refund_amount_includes_explicit_zero (unit) —
      regression guard against the !empty() reintroduction.
    - test_convert_line_items_legacy_no_quantity_defaults_qty_zero (unit)
      — pins the converter's new behavior.

    Also: guard tax extraction in the converter with isset(refund_total)
    to avoid silently coercing missing refund_total to 0 via float cast.

    * Close the test-analyzer follow-ups

    - Add test_refunds_create_simplified_matches_explicit_tax_inclusive:
      simplified-vs-explicit equivalence on a tax-inclusive store (10% / $110).
      The previous equivalence test used an untaxed product, so a regression
      that yielded a tax-exclusive auto-computed value would have gone
      undetected. The new test asserts both top-level `amount` and per-line
      refund_total / refund_tax round-trip identically between the two paths.

    - Add test_refunds_create_invariant_violation_returns_500: partial-mock
      DataUtils so fill_missing_refund_totals throws InvalidArgumentException,
      inject into the DI-resolved RefundsController, dispatch a refund POST,
      and assert 500 + invalid_refund_request. Pins the scoped catch's
      response shape so a future refactor that broadens the catch (e.g. to
      \Throwable) or re-narrows fill's pre-check is caught.

    - Tighten test_refunds_create_simplified_form_negative_fee: replace the
      permissive assertLessThan(500, status) + assertArrayHasKey('code') with
      explicit assertEquals(400) + assertEquals('invalid_refund_amount').
      Pins the current platform behavior (the controller's `0 > $refund_amount`
      guard fires for any negative auto-computed total) so a behavior change
      is loud rather than silent.

    * Address 3rd-pass review findings (test-side)

    Test-analyzer findings, in priority order:

    (#1, merge-blocker) Option leakage — two tests mutated
    woocommerce_calc_taxes / woocommerce_prices_include_tax without
    restoration. tearDown does not reset these, so any test running
    after them in the same process would see polluted state. Wrap
    both in try/finally that captures and restores the original
    values, mirroring the pattern already used in
    test_refunds_create_simplified_form_tax_inclusive_store.

    (#4) test_refunds_create_simplified_matches_explicit_tax_inclusive
    was misnamed — it set prices_include_tax = no. Fix it to actually
    exercise a tax-inclusive store (set to yes; product entered with
    tax baked into regular_price). The name now matches reality.

    (#2) Augment
    test_refunds_create_legacy_form_no_quantity_with_explicit_refund_total
    with a Step 3: after the $30 initial refund and the rejected $100
    follow-up, refund $40 — this MUST succeed and bring total_refunded
    to $70 / remaining $30. Guards against a regression where the
    first refund silently consumed the full $100 budget.

    (#3) New test_refunds_create_legacy_form_tax_inclusive_store —
    exercises the converter's tax-extraction block on the legacy
    {line_item_id, refund_total} (no quantity) path under tax-inclusive
    prices. This combination is the one POS clients will actually
    exercise after the v3 port, but no other test reaches it.

    (#5) New test_refunds_create_three_way_mixed_shapes — single create
    call with auto-compute + explicit-with-quantity + legacy-no-quantity
    entries. Verifies the controller's fill/convert pass handles all
    three shapes coexisting, and asserts each refund line item is
    attached with the expected qty (refunds store quantities negative,
    so request qty=1 lands as -1; legacy no-quantity lands as 0).

    (#6) New test_refunds_create_hook_sees_explicit_refund_total_unchanged
    — pins that the request-mirroring step (set_param after
    fill_missing_refund_totals) does not overwrite client-supplied
    refund_total. Uses a deliberately different value from the
    auto-computed one so an overwrite regression would surface.

    * Address review comments

    CodeRabbit + codex review findings closed:

    (A) Per-line cap for partially-refunded shipping/fees in
    validate_preview_line_items. Previously only fully-refunded lines
    were rejected, so a $10 shipping line partially refunded by $5
    would pass validation and let build_refund_preview return $10 even
    though only $5 was refundable. The new check computes the requested
    refund via compute_line_item_refund_total() and rejects with
    'quantity_exceeds_refundable' (422) when it exceeds remaining.

    (B) Preserve the signed tax split for negative-tax discount fees.
    The tax-extraction filter in build_refund_preview previously dropped
    tax IDs where `amount > 0`, so a -$10 fee with -$1 stored tax
    previewed as subtotal -$11 / tax $0 — losing the breakdown.
    Switch the filter to `amount != 0` so non-zero (positive or
    negative) tax amounts are kept and WC_Tax::calc_inclusive_tax
    propagates the sign correctly.

    (C) @since 10.8.0 → @since 10.9.0 on all new public methods, and
    add @since 10.9.0 to the newly-protected helpers
    (convert_line_item_taxes_to_internal_format,
    convert_proportional_taxes_to_schema_format, build_tax_rates_array).
    Per .ai/skills/woocommerce-backend-dev/code-entities.md, @since
    is moved to the last line of each docblock.

    (D) Remove the duplicate changelog file
    65334-woomob-2684-refund-preview-helpers — the unprefixed
    woomob-2684-refund-preview-helpers entry is the canonical one.

    (E) Fix the @testdox on test_validate_preview_line_items_shipping_fully_refunded
    to match the assertion (order_not_refundable). The order-level
    remaining-amount guard fires first when shipping is fully refunded.

    Tests added:
    - test_validate_preview_line_items_shipping_partial_remaining —
      pins the new per-line cap. Order has a $10 shipping line + $50
      product; $5 of shipping is pre-refunded; previewing shipping at
      qty=1 must return quantity_exceeds_refundable rather than
      passing through to an oversized total.
    - test_build_refund_preview_negative_fee_with_negative_tax —
      pins the negative-tax breakdown fix. -$10 fee + -$1 stored tax
      must preview as subtotal -$10 / tax -$1 / total -$11.

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

    * Address review comments

    Codex findings closed:

    (#1, CRITICAL) Cap simplified-form refunds against the line's REMAINING
    refundable quantity rather than the original. validate_line_items
    previously compared $line_item['quantity'] to $item->get_quantity()
    (the unchanged original count), so once item A was refunded for the
    first time, a second simplified {line_item_id: A, quantity: 1}
    request would pass the per-line check and only be bounded by the
    order's dollar balance — if item B still had room, item A would be
    silently refunded twice. Hoist compute_refunded_quantities_and_totals
    outside the loop and cap product items by
    $item->get_quantity() + ($refund_data['qtys'][id] ?? 0), matching
    the pattern already used in validate_preview_line_items. Shipping/
    fees keep the original simpler check (they don't track per-unit
    refund history).

    (#2, HIGH) Reject the ambiguous "omitted refund_total + explicit
    refund_tax" combination. fill_missing_refund_totals would have
    written a tax-inclusive refund_total (110 for a $100 item with $10
    tax) and convert_line_items_to_internal_format would then skip tax
    extraction because refund_tax was already present —
    calculate_refund_amount summed both and emitted amount=120
    (overstated by the tax amount). fill_missing_refund_totals now
    skips items with explicit refund_tax, and validate_line_items
    rejects the combination with 'invalid_line_item: refund_tax cannot
    be combined with auto-computed refund_total'.

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

    (#4) @since 10.8.0 → @since 10.9.0 on fill_missing_refund_totals;
    @since moved to the last docblock line per
    .ai/skills/woocommerce-backend-dev/code-entities.md. The other
    @since annotations on this file belong to methods introduced by
    #65334 and were already updated on that branch (644f7c0cd9);
    they'll re-flow to this branch via merge.

    Tests added:
    - test_refunds_create_simplified_form_rejects_already_refunded_product
      pins the new remaining-qty cap. Two-line order (A + B, each $50);
      refund A once → 201; refund A again → 400 invalid_line_item with
      "remaining refundable quantity" in the message.
    - test_refunds_create_rejects_auto_compute_with_explicit_refund_tax
      pins the rejection. $100 product with $10 stored tax; request
      with no refund_total + explicit refund_tax → 400 invalid_line_item
      with "refund_tax cannot be combined" in the message. Wrapped in
      try/finally for tax-option restoration.

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

    * Address review comments on validate_preview_line_items

    - Compare shipping/fee refunds on a tax-inclusive basis. The previous
      code pitted the tax-inclusive $requested_total against a tax-exclusive
      $remaining_total — for a $10 shipping line with $1.50 tax, the
      comparison was 11.50 > 10.00 and rejected a legitimate full refund.
      compute_refunded_quantities_and_totals() now records fee/shipping
      totals tax-inclusive so the validator can compare like-for-like.
    - Replace the hardcoded `'status' => 422` literals in
      validate_preview_line_items with WP_Http::UNPROCESSABLE_ENTITY,
      matching the convention used elsewhere in V4 routes.
    - Add a regression test (shipping line with tax, no prior refund) to
      lock in the tax-inclusive comparison.
    - Delete the duplicate changelog entry; keep the PR-prefixed one auto-
      generated by CI.

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

    * Align tax filter in convert_line_items_to_internal_format with preview side

    The creation-side filter dropped tax IDs whose stored amount was <= 0,
    so a negative-fee discount line (e.g. a -$10 fee with -$1 stored tax) had
    its tax breakdown stripped on save — refund_total stayed at -$11 and
    refund_tax was emitted as []. The preview side (build_refund_preview)
    keeps any non-zero stored tax and renders the signed split correctly, so
    a refund moving from preview to create lost the tax breakdown.

    Match the preview rule (non-zero, not strictly positive). Add a
    regression test that converts a negative fee with negative stored tax
    and verifies the signed split survives.

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

    * Remove duplicate slug-only changelog entry

    The CI auto-added 65439-woomob-2685-simplify-refund-creation with a
    more detailed body. Keep the PR-prefixed file as the canonical entry.

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

    * Add parity test: create amount matches build_refund_preview total

    Regression guard against the create vs preview drift mikejolley flagged
    on PRs #65334 and #65335. Builds an order with a product carrying 10%
    tax, calls build_refund_preview directly to capture the authoritative
    grand total, then posts the same line items (quantity only) to the
    create endpoint. The resulting refund amount must equal preview total
    exactly. A future change that subtly diverges create's auto compute from
    the preview calculation would fail this assertion.

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

    * Lock fee/shipping quantity-ignored behavior in compute_line_item_refund_total

    The existing tests for shipping and positive fees pass quantity = 1, which
    does not prove the quantity argument is ignored. Add two short tests that
    pass quantity = 5 and assert the same line total + tax is returned. Catches
    a future refactor that wrongly applies unit_price * quantity to shipping
    or fee items.

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

    * Improve `refund_total` description text

    Co-authored-by: Mike Jolley <mike.jolley@me.com>

    * Add multi-quantity rounding tests and fix refund total comparison

    * Fix duplicate refunds for fees and shipping

    * Remove refunds PHPStan baseline suppressions

    ---------

    Co-authored-by: woocommercebot <woocommercebot@users.noreply.github.com>
    Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
    Co-authored-by: Mike Jolley <mike.jolley@me.com>

diff --git a/plugins/woocommerce/changelog/65439-woomob-2685-simplify-refund-creation b/plugins/woocommerce/changelog/65439-woomob-2685-simplify-refund-creation
new file mode 100644
index 00000000000..a12d8dc60f1
--- /dev/null
+++ b/plugins/woocommerce/changelog/65439-woomob-2685-simplify-refund-creation
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Make per-line `refund_total` optional on POST /wc/v4/refunds. When omitted, the backend computes the tax-inclusive refund total from the order line item's unit price × quantity. The legacy explicit form (refund_total without quantity) is preserved for v3-style callers.
\ No newline at end of file
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index 8c071eedfce..9470f1528e9 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -66925,18 +66925,6 @@ parameters:
 			count: 1
 			path: src/Internal/RestApi/Routes/V4/Refunds/Controller.php

-		-
-			message: '#^Parameter \#2 \$order of method Automattic\\WooCommerce\\Internal\\RestApi\\Routes\\V4\\Refunds\\DataUtils\:\:convert_line_items_to_internal_format\(\) expects WC_Order, WC_Order\|WC_Order_Refund given\.$#'
-			identifier: argument.type
-			count: 1
-			path: src/Internal/RestApi/Routes/V4/Refunds/Controller.php
-
-		-
-			message: '#^Parameter \#2 \$order of method Automattic\\WooCommerce\\Internal\\RestApi\\Routes\\V4\\Refunds\\DataUtils\:\:validate_line_items\(\) expects WC_Order, WC_Order\|WC_Order_Refund given\.$#'
-			identifier: argument.type
-			count: 1
-			path: src/Internal/RestApi/Routes/V4/Refunds/Controller.php
-
 		-
 			message: '#^Binary operation "\+" between string and string results in an error\.$#'
 			identifier: binaryOp.invalid
diff --git a/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Refunds/Controller.php b/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Refunds/Controller.php
index ffc800b718d..d078c69e47d 100644
--- a/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Refunds/Controller.php
+++ b/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Refunds/Controller.php
@@ -348,23 +348,59 @@ class Controller extends AbstractController {
 			return $this->get_route_error_by_code( self::RESOURCE_EXISTS );
 		}

+		$order = wc_get_order( $request['order_id'] );
+
+		// wc_get_order can return a WC_Order_Refund for refund IDs — reject those
+		// here since refunds are not refundable themselves.
+		if ( ! $order instanceof \WC_Order ) {
+			return $this->get_route_error_by_code( self::INVALID_ID );
+		}
+
+		// Fill in refund_total for any line items that omit it. The simplified
+		// request form sends only {line_item_id, quantity}; the backend derives
+		// the tax-inclusive total from the order's unit price × quantity.
+		// Scoped try: compute_line_item_refund_total throws InvalidArgumentException
+		// on quantity < 1, but fill_missing_refund_totals pre-checks that condition,
+		// so this branch is defensive against a future invariant break only.
 		try {
-			$order = wc_get_order( $request['order_id'] );
+			$line_items = $this->data_utils->fill_missing_refund_totals( $request['line_items'] ?? array(), $order );
+		} catch ( \InvalidArgumentException $e ) {
+			wc_get_logger()->error(
+				sprintf(
+					'Refund creation invariant violation on order %d (%s): %s',
+					$order->get_id(),
+					get_class( $e ),
+					$e->getMessage()
+				),
+				array( 'source' => 'wc-v4-refunds' )
+			);
+			return $this->get_route_error_response(
+				'invalid_refund_request',
+				__( 'The refund could not be created due to an unexpected error.', 'woocommerce' ),
+				WP_Http::INTERNAL_SERVER_ERROR
+			);
+		}

-			if ( ! $order ) {
-				return $this->get_route_error_by_code( self::INVALID_ID );
-			}
+		// Mirror the augmented array back onto the request so the 'created' hook
+		// and any other downstream readers of $request['line_items'] see
+		// normalised data with refund_total populated.
+		$request->set_param( 'line_items', $line_items );

+		try {
 			// Validate request line_items before proceeding against the order being refunded.
-			$validation_error = $this->data_utils->validate_line_items( $request['line_items'], $order );
+			$validation_error = $this->data_utils->validate_line_items( $line_items, $order );

 			if ( is_wp_error( $validation_error ) ) {
 				return $this->get_route_error_response( $validation_error->get_error_code(), $validation_error->get_error_message() );
 			}

-			// Convert line items to internal format.
-			$line_item_data   = $this->data_utils->convert_line_items_to_internal_format( $request['line_items'], $order );
-			$calculated_total = ! empty( $request['line_items'] ) ? $this->data_utils->calculate_refund_amount( $request['line_items'] ) : 0;
+			// Convert line items to internal format. Note: refund_total is tax-inclusive
+			// for both auto-computed values (from compute_line_item_refund_total) and
+			// explicit client values — the converter then extracts the tax portion via
+			// WC_Tax::calc_inclusive_tax. Summing across mixed (auto + explicit) entries
+			// in calculate_refund_amount is therefore well-defined.
+			$line_item_data   = $this->data_utils->convert_line_items_to_internal_format( $line_items, $order );
+			$calculated_total = ! empty( $line_items ) ? $this->data_utils->calculate_refund_amount( $line_items ) : 0;
 			$refund_amount    = ! empty( $request['amount'] ) ? $request['amount'] : $calculated_total;

 			if ( 0 > $refund_amount || ! $refund_amount ) {
diff --git a/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Refunds/DataUtils.php b/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Refunds/DataUtils.php
index dfba93811af..f99756265c7 100644
--- a/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Refunds/DataUtils.php
+++ b/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Refunds/DataUtils.php
@@ -58,12 +58,24 @@ class DataUtils {
 		$prepared_line_items = array();

 		foreach ( $line_items as $line_item ) {
-			if ( ! isset( $line_item['line_item_id'], $line_item['quantity'], $line_item['refund_total'] ) ) {
+			// A line item is processable when it has an ID and at least one of
+			// quantity or refund_total. The legacy v3-style form may omit
+			// quantity entirely; in that case qty=0 is recorded on the refund,
+			// matching v3 semantics ("refunded $X of this line without consuming
+			// specific units"). Dollar accounting via get_remaining_refund_amount
+			// still bounds subsequent refunds, so per-unit looseness here does
+			// not enable over-refunding.
+			if ( ! isset( $line_item['line_item_id'] ) ) {
+				continue;
+			}
+			if ( ! isset( $line_item['quantity'] ) && ! isset( $line_item['refund_total'] ) ) {
 				continue;
 			}

-			// If no explicit refund_tax provided, extract tax from refund_total using WC_Tax.
-			if ( ! isset( $line_item['refund_tax'] ) ) {
+			// If no explicit refund_tax provided, extract tax from refund_total
+			// using WC_Tax. Skip when refund_total is also missing — there's
+			// nothing to extract tax from.
+			if ( ! isset( $line_item['refund_tax'] ) && isset( $line_item['refund_total'] ) ) {
 				$original_item = $order->get_item( $line_item['line_item_id'] );
 				if ( $original_item ) {
 					$original_taxes = $original_item->get_taxes();
@@ -110,9 +122,12 @@ class DataUtils {
 				}
 			}

+			// Default qty=0 when quantity was omitted (legacy v3-style explicit
+			// refund_total path). Default refund_total=0 defensively; in practice
+			// validate_line_items ensures one of them is set by this point.
 			$prepared_line_items[ $line_item['line_item_id'] ] = array(
-				'qty'          => $line_item['quantity'],
-				'refund_total' => $line_item['refund_total'],
+				'qty'          => $line_item['quantity'] ?? 0,
+				'refund_total' => $line_item['refund_total'] ?? 0,
 				'refund_tax'   => $this->convert_line_item_taxes_to_internal_format( $line_item['refund_tax'] ?? array() ),
 			);
 		}
@@ -155,13 +170,16 @@ class DataUtils {
 		$amount = 0;

 		foreach ( $line_items as $line_item ) {
-			if ( ! empty( $line_item['refund_total'] ) && is_numeric( $line_item['refund_total'] ) ) {
+			// is_numeric() (not !empty) — an explicit refund_total of 0 is a valid
+			// "zero refund for this line" value and must round-trip cleanly, not
+			// be silently dropped from the sum.
+			if ( isset( $line_item['refund_total'] ) && is_numeric( $line_item['refund_total'] ) ) {
 				$amount += $line_item['refund_total'];
 			}

 			if ( ! empty( $line_item['refund_tax'] ) && is_array( $line_item['refund_tax'] ) ) {
 				foreach ( $line_item['refund_tax'] as $tax ) {
-					if ( ! empty( $tax['refund_total'] ) && is_numeric( $tax['refund_total'] ) ) {
+					if ( isset( $tax['refund_total'] ) && is_numeric( $tax['refund_total'] ) ) {
 						$amount += $tax['refund_total'];
 					}
 				}
@@ -179,6 +197,10 @@ class DataUtils {
 	 * @return boolean|WP_Error
 	 */
 	public function validate_line_items( $line_items, WC_Order $order ) {
+		// Precompute refunded quantities/totals once so the over-refund check
+		// below caps against remaining refundable quantity, not the original.
+		$refund_data = $this->compute_refunded_quantities_and_totals( $order );
+
 		foreach ( $line_items as $line_item ) {
 			$line_item_id = $line_item['line_item_id'] ?? null;

@@ -197,15 +219,108 @@ class DataUtils {
 				return new WP_Error( 'invalid_line_item', __( 'Line item is not a product, fee, or shipping line.', 'woocommerce' ) );
 			}

-			// Validate item quantity is not greater than the item quantity.
-			if ( $item->get_quantity() < $line_item['quantity'] ) {
-				/* translators: %s: item quantity */
-				return new WP_Error( 'invalid_line_item', sprintf( __( 'Line item quantity cannot be greater than the item quantity (%s).', 'woocommerce' ), $item->get_quantity() ) );
+			// Quantity is required only when the client omits refund_total — the
+			// auto-compute path needs a real quantity to derive the unit price.
+			// When refund_total is provided explicitly (legacy v3-style path),
+			// quantity is informational and can be missing/zero, matching the
+			// original v4 schema's `default: 0` behavior.
+			$refund_total_missing = ! array_key_exists( 'refund_total', $line_item ) || null === $line_item['refund_total'];
+
+			// Reject the ambiguous "auto-computed refund_total + explicit refund_tax"
+			// combination. Auto-compute writes a tax-inclusive value; the
+			// converter then skips tax extraction because refund_tax is set,
+			// and calculate_refund_amount double-counts the tax. The client
+			// must either supply refund_total explicitly (and may then supply
+			// refund_tax to override the auto-extracted split) or let the
+			// server handle taxes (omit both).
+			if ( $refund_total_missing && isset( $line_item['refund_tax'] ) ) {
+				return new WP_Error(
+					'invalid_line_item',
+					__( 'refund_tax cannot be combined with an auto-computed refund_total. Provide refund_total explicitly when supplying refund_tax.', 'woocommerce' )
+				);
+			}
+
+			if ( $refund_total_missing && ( ! isset( $line_item['quantity'] ) || ! is_int( $line_item['quantity'] ) || $line_item['quantity'] < 1 ) ) {
+				return new WP_Error(
+					'invalid_line_item',
+					__( 'Line item quantity must be a positive integer when refund_total is omitted.', 'woocommerce' )
+				);
+			}
+
+			// Auto-compute requires a non-zero source quantity to derive the unit
+			// price from. If the client omitted refund_total (or sent null) and the
+			// source product has zero quantity, surface a clear error rather than
+			// letting the request slip into the misleading "must be greater than
+			// zero" branch downstream.
+			if ( $refund_total_missing && $item instanceof \WC_Order_Item_Product && 0 === $item->get_quantity() ) {
+				return new WP_Error(
+					'invalid_line_item',
+					sprintf(
+						/* translators: %d: line item id */
+						__( 'Cannot auto-compute refund for line item %d: source quantity is zero. Provide an explicit refund_total.', 'woocommerce' ),
+						(int) $line_item_id
+					)
+				);
+			}
+
+			// Validate refund quantity does not exceed remaining refundable
+			// quantity for this line. compute_refunded_quantities_and_totals
+			// returns negative values for already-refunded units (matches the
+			// convention used by validate_preview_line_items), so adding to
+			// $item->get_quantity() yields the remaining count.
+			// Only fires when a quantity was provided — the legacy
+			// explicit-refund_total path may omit it.
+			if ( isset( $line_item['quantity'] ) && $item instanceof \WC_Order_Item_Product ) {
+				$remaining_qty = $item->get_quantity() + ( $refund_data['qtys'][ $line_item_id ] ?? 0 );
+				if ( $line_item['quantity'] > $remaining_qty ) {
+					return new WP_Error(
+						'invalid_line_item',
+						sprintf(
+							/* translators: %d: remaining refundable quantity */
+							__( 'Line item quantity cannot be greater than the remaining refundable quantity (%d).', 'woocommerce' ),
+							$remaining_qty
+						)
+					);
+				}
+			} elseif ( isset( $line_item['quantity'] ) ) {
+				if ( $item->get_quantity() < $line_item['quantity'] ) {
+					/* translators: %s: item quantity */
+					return new WP_Error( 'invalid_line_item', sprintf( __( 'Line item quantity cannot be greater than the item quantity (%s).', 'woocommerce' ), $item->get_quantity() ) );
+				}
+
+				$price_decimals      = wc_get_price_decimals();
+				$item_total_with_tax = abs( (float) $item->get_total() + (float) $item->get_total_tax() );
+				$refunded_total      = abs( (float) ( $refund_data['totals'][ $line_item_id ] ?? 0.0 ) );
+				$remaining_total     = $item_total_with_tax - $refunded_total;
+				$requested_total     = isset( $line_item['refund_total'] )
+					? abs( (float) $line_item['refund_total'] )
+					: abs( $this->compute_line_item_refund_total( $item, $line_item['quantity'] ) );
+
+				if ( $remaining_total <= 0 ) {
+					return new WP_Error(
+						'invalid_line_item',
+						__( 'This line item has already been fully refunded.', 'woocommerce' )
+					);
+				}
+
+				if ( $requested_total > NumberUtil::round( $remaining_total, $price_decimals ) ) {
+					return new WP_Error(
+						'invalid_line_item',
+						sprintf(
+							/* translators: %s: remaining refundable amount */
+							__( 'Line item refund total cannot be greater than the remaining refundable amount (%s).', 'woocommerce' ),
+							wc_format_decimal( $remaining_total, $price_decimals )
+						)
+					);
+				}
 			}

 			// Validate refund total is not greater than the item total (including tax).
+			// Round both sides to price decimals before comparing — the raw float sum
+			// (e.g. 29.97 + 2.66) can land a hair below the rounded refund_total and
+			// spuriously reject full-line refunds, including auto-computed totals.
 			$item_total_with_tax = $item->get_total() + $item->get_total_tax();
-			if ( $item_total_with_tax < $line_item['refund_total'] ) {
+			if ( isset( $line_item['refund_total'] ) && NumberUtil::round( (float) $item_total_with_tax, wc_get_price_decimals() ) < NumberUtil::round( (float) $line_item['refund_total'], wc_get_price_decimals() ) ) {
 				return new WP_Error(
 					'invalid_refund_amount',
 					sprintf(
@@ -347,6 +462,77 @@ class DataUtils {
 		return NumberUtil::round( (float) $item->get_total() + (float) $item->get_total_tax(), $price_decimals );
 	}

+	/**
+	 * Fill in refund_total for any line item that omits it, computing the value from
+	 * the order item's unit price × quantity via compute_line_item_refund_total().
+	 *
+	 * Items that already have refund_total (including an explicit 0) are left
+	 * untouched, so existing v3-style clients keep working. Items where refund_total
+	 * is omitted OR is explicitly null are treated as "compute it for me". Items
+	 * that can't be resolved (missing line_item_id, item not on order, invalid
+	 * quantity, unsupported item type, product with zero source quantity) are
+	 * also left untouched — validate_line_items surfaces the right error for
+	 * those cases.
+	 *
+	 * Auto-computed values are tax-inclusive, matching the convention enforced by
+	 * the existing converter (convert_line_items_to_internal_format extracts tax
+	 * from a tax-inclusive refund_total).
+	 *
+	 * @param array    $line_items Line items from the request (schema format).
+	 *                             Each item: array{line_item_id?: int, quantity?: int,
+	 *                             refund_total?: float|int|null, refund_tax?: array<int, mixed>}.
+	 * @param WC_Order $order      The order being refunded.
+	 * @return array The line items with refund_total populated where possible (same shape as input).
+	 *
+	 * @since 10.9.0
+	 */
+	public function fill_missing_refund_totals( array $line_items, WC_Order $order ): array {
+		foreach ( $line_items as $key => $line_item ) {
+			// Treat a missing key and an explicit `null` value the same — both mean
+			// "compute it for me". An explicit `0` is treated as a zero refund for
+			// that line (existing behaviour: calculate_refund_amount skips it from
+			// the sum, the under-refund check may then trip).
+			if ( array_key_exists( 'refund_total', $line_item ) && null !== $line_item['refund_total'] ) {
+				continue;
+			}
+
+			// Skip auto-compute when the client also supplied an explicit
+			// refund_tax. Auto-compute writes a tax-inclusive refund_total, but
+			// the converter then skips tax extraction whenever refund_tax is
+			// already present — and calculate_refund_amount would add both,
+			// inflating the total by the tax amount. Leave refund_total unset;
+			// validate_line_items rejects this ambiguous combination with a
+			// clear error.
+			if ( isset( $line_item['refund_tax'] ) ) {
+				continue;
+			}
+
+			$line_item_id = $line_item['line_item_id'] ?? null;
+			$quantity     = $line_item['quantity'] ?? null;
+			if ( ! $line_item_id || ! is_int( $quantity ) || $quantity < 1 ) {
+				continue;
+			}
+
+			$item = $order->get_item( $line_item_id );
+			if ( ! $item || ! ( $item instanceof WC_Order_Item_Product || $item instanceof WC_Order_Item_Shipping || $item instanceof WC_Order_Item_Fee ) ) {
+				continue;
+			}
+
+			// A product whose source line has zero quantity has no unit price to
+			// derive a refund from. Skip so validate_line_items surfaces a clear
+			// 'invalid_line_item' error to the API consumer instead of letting a
+			// silent 0.0 propagate into the misleading "must be greater than zero"
+			// branch downstream.
+			if ( $item instanceof WC_Order_Item_Product && 0 === $item->get_quantity() ) {
+				continue;
+			}
+
+			$line_items[ $key ]['refund_total'] = $this->compute_line_item_refund_total( $item, $quantity );
+		}
+
+		return $line_items;
+	}
+
 	/**
 	 * Build a refund preview showing authoritative totals and breakdowns.
 	 *
diff --git a/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Refunds/Schema/RefundSchema.php b/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Refunds/Schema/RefundSchema.php
index c0ebc231d94..61dc35a86d9 100644
--- a/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Refunds/Schema/RefundSchema.php
+++ b/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Refunds/Schema/RefundSchema.php
@@ -229,19 +229,16 @@ class RefundSchema extends AbstractSchema {
 							'validate_callback' => 'rest_validate_request_arg',
 						),
 						'quantity'     => array(
-							'description'       => __( 'Quantity refunded.', 'woocommerce' ),
+							'description'       => __( 'Quantity refunded. Required when refund_total is omitted (the backend computes the total from unit price × quantity); optional when refund_total is provided explicitly.', 'woocommerce' ),
 							'type'              => 'integer',
 							'context'           => self::VIEW_EDIT_EMBED_CONTEXT,
-							'default'           => 0,
 							'sanitize_callback' => 'wc_stock_amount',
 							'validate_callback' => 'rest_validate_request_arg',
 						),
 						'refund_total' => array(
-							'description'       => __( 'Total refunded for this item.', 'woocommerce' ),
-							'type'              => 'number',
+							'description'       => __( 'Total amount refunded for this item (including tax). If omitted or set to null, the backend computes it from the order line item\'s unit price multiplied by quantity. An explicit 0 is treated as a zero refund for this line item.', 'woocommerce' ),
+							'type'              => array( 'number', 'null' ),
 							'context'           => self::VIEW_EDIT_EMBED_CONTEXT,
-							'default'           => 0,
-							'sanitize_callback' => 'sanitize_text_field',
 							'validate_callback' => 'rest_validate_request_arg',
 						),
 						'refund_tax'   => array(
diff --git a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version4/Refunds/class-wc-rest-refunds-v4-controller-tests.php b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version4/Refunds/class-wc-rest-refunds-v4-controller-tests.php
index 044a2f77766..2bf3bf06f2e 100644
--- a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version4/Refunds/class-wc-rest-refunds-v4-controller-tests.php
+++ b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version4/Refunds/class-wc-rest-refunds-v4-controller-tests.php
@@ -560,7 +560,8 @@ class WC_REST_Refunds_V4_Controller_Tests extends WC_REST_Unit_Test_Case {
 				array(
 					'line_item_id' => $item->get_id(),
 					'quantity'     => 1,
-					'refund_total' => 128.00, // Includes 23.00 + 5.00 tax.
+					// Includes 23.00 + 5.00 tax.
+					'refund_total' => 128.00,
 				),
 			),
 		);
@@ -707,7 +708,8 @@ class WC_REST_Refunds_V4_Controller_Tests extends WC_REST_Unit_Test_Case {
 				array(
 					'line_item_id' => $item->get_id(),
 					'quantity'     => 1,
-					'refund_total' => 115.50, // Includes 10.00 + 5.50 compound tax.
+					// Includes 10.00 + 5.50 compound tax.
+					'refund_total' => 115.50,
 				),
 			),
 		);
@@ -839,6 +841,7 @@ class WC_REST_Refunds_V4_Controller_Tests extends WC_REST_Unit_Test_Case {
 		// Create partial refund with explicit refund_tax array (legacy backward compatibility).
 		// Refunding 30.00 out of 50.00 subtotal (30.00 + 6.90 + 1.50 = 38.40).
 		// Don't specify amount - let it auto-calculate from line items.
+		// refund_total values exclude tax; refund_tax entries are 23% and 5% of 30.00.
 		$refund_data = array(
 			'order_id'   => $order->get_id(),
 			'reason'     => 'Testing explicit tax array (legacy format)',
@@ -846,15 +849,15 @@ class WC_REST_Refunds_V4_Controller_Tests extends WC_REST_Unit_Test_Case {
 				array(
 					'line_item_id' => $item->get_id(),
 					'quantity'     => 1,
-					'refund_total' => 30.00, // Excluding tax.
+					'refund_total' => 30.00,
 					'refund_tax'   => array(
 						array(
 							'id'           => $tax_rate_id_1,
-							'refund_total' => 6.90, // 23% of 30.00.
+							'refund_total' => 6.90,
 						),
 						array(
 							'id'           => $tax_rate_id_2,
-							'refund_total' => 1.50, // 5% of 30.00.
+							'refund_total' => 1.50,
 						),
 					),
 				),
@@ -960,7 +963,8 @@ class WC_REST_Refunds_V4_Controller_Tests extends WC_REST_Unit_Test_Case {
 				array(
 					'line_item_id' => $item->get_id(),
 					'quantity'     => 1,
-					'refund_total' => 500.00, // Exceeds 110.00 (item total with tax).
+					// Exceeds 110.00 (item total with tax) to trigger the over-refund check.
+					'refund_total' => 500.00,
 				),
 			),
 		);
@@ -1050,7 +1054,8 @@ class WC_REST_Refunds_V4_Controller_Tests extends WC_REST_Unit_Test_Case {
 				array(
 					'line_item_id' => $item->get_id(),
 					'quantity'     => 1,
-					'refund_total' => 110.00, // Line items total is 110.00.
+					// Line items total is 110.00.
+					'refund_total' => 110.00,
 				),
 			),
 		);
@@ -1230,7 +1235,8 @@ class WC_REST_Refunds_V4_Controller_Tests extends WC_REST_Unit_Test_Case {

 		$order->set_billing_country( 'US' );
 		$order->set_billing_state( 'CA' );
-		$order->set_total( 55.26 ); // 50.00 + 0.50 + 1.63 + 3.13.
+		$order->set_total( 55.26 );
+		// 50.00 + 0.50 + 1.63 + 3.13.
 		$order->save();

 		$this->created_orders[] = $order->get_id();
@@ -1245,7 +1251,8 @@ class WC_REST_Refunds_V4_Controller_Tests extends WC_REST_Unit_Test_Case {
 				array(
 					'line_item_id' => $item->get_id(),
 					'quantity'     => 1,
-					'refund_total' => 55.26, // Includes all taxes.
+					// Includes all taxes.
+					'refund_total' => 55.26,
 				),
 			),
 		);
@@ -1315,6 +1322,2316 @@ class WC_REST_Refunds_V4_Controller_Tests extends WC_REST_Unit_Test_Case {
 		$product->delete( true );
 	}

+	/**
+	 * @testdox Refund creation auto-computes refund_total from the order line item when omitted.
+	 */
+	public function test_refunds_create_simplified_form_no_tax(): void {
+		// Two-quantity product at $10 each = $20 order total.
+		$product = WC_Helper_Product::create_simple_product();
+		$product->set_price( 10.00 );
+		$product->save();
+
+		$order     = $this->create_test_order(
+			array(
+				'line_items' => array(
+					array(
+						'product_id' => $product->get_id(),
+						'quantity'   => 2,
+					),
+				),
+			)
+		);
+		$items     = $order->get_items();
+		$line_item = reset( $items );
+
+		// Refund 1 of 2 — refund_total OMITTED.
+		$request = new WP_REST_Request( 'POST', '/wc/v4/refunds' );
+		$request->set_body_params(
+			array(
+				'order_id'   => $order->get_id(),
+				'line_items' => array(
+					array(
+						'line_item_id' => $line_item->get_id(),
+						'quantity'     => 1,
+					),
+				),
+			)
+		);
+		$response = $this->server->dispatch( $request );
+
+		$this->assertEquals( 201, $response->get_status() );
+		$data = $response->get_data();
+		$this->assertEquals( '10.00', $data['amount'], 'Auto-computed amount should be unit price × quantity' );
+
+		$this->created_refunds[] = $data['id'];
+		$product->delete( true );
+	}
+
+	/**
+	 * @testdox Refund creation with omitted refund_total extracts tax correctly.
+	 */
+	public function test_refunds_create_simplified_form_with_tax(): void {
+		$tax_rate_id = WC_Tax::_insert_tax_rate(
+			array(
+				'tax_rate_country'  => 'US',
+				'tax_rate_state'    => '',
+				'tax_rate'          => '10.0000',
+				'tax_rate_name'     => 'VAT',
+				'tax_rate_priority' => '1',
+				'tax_rate_compound' => '0',
+				'tax_rate_shipping' => '1',
+				'tax_rate_order'    => '1',
+				'tax_rate_class'    => '',
+			)
+		);
+
+		// Capture original option values so we can restore them in finally —
+		// tearDown doesn't reset these globally and leakage breaks subsequent
+		// tests that assume the default tax config.
+		$original_calc_taxes         = get_option( 'woocommerce_calc_taxes', 'no' );
+		$original_prices_include_tax = get_option( 'woocommerce_prices_include_tax', 'no' );
+		update_option( 'woocommerce_calc_taxes', 'yes' );
+		update_option( 'woocommerce_prices_include_tax', 'no' );
+
+		try {
+			$product = WC_Helper_Product::create_simple_product();
+			$product->set_regular_price( 100.00 );
+			$product->set_tax_status( 'taxable' );
+			$product->save();
+
+			$order = wc_create_order();
+			$item  = new WC_Order_Item_Product();
+			$item->set_props(
+				array(
+					'product'  => $product,
+					'quantity' => 1,
+					'subtotal' => 100.00,
+					'total'    => 100.00,
+				)
+			);
+			$item->set_taxes(
+				array(
+					'total'    => array( $tax_rate_id => 10.00 ),
+					'subtotal' => array( $tax_rate_id => 10.00 ),
+				)
+			);
+			$item->save();
+			$order->add_item( $item );
+
+			$tax_item = new \WC_Order_Item_Tax();
+			$tax_item->set_rate( $tax_rate_id );
+			$tax_item->set_tax_total( 10.00 );
+			$tax_item->save();
+			$order->add_item( $tax_item );
+
+			$order->set_billing_country( 'US' );
+			// calculate_totals( false ) is not reliable in the test environment when
+			// taxes are involved — set_total() explicitly so get_remaining_refund_amount()
+			// matches the line + tax sum.
+			$order->set_total( 110.00 );
+			$order->set_status( OrderStatus::COMPLETED );
+			$order->save();
+			$this->created_orders[] = $order->get_id();
+
+			// Refund the line — refund_total OMITTED.
+			$request = new WP_REST_Request( 'POST', '/wc/v4/refunds' );
+			$request->set_body_params(
+				array(
+					'order_id'   => $order->get_id(),
+					'line_items' => array(
+						array(
+							'line_item_id' => $item->get_id(),
+							'quantity'     => 1,
+						),
+					),
+				)
+			);
+			$response = $this->server->dispatch( $request );
+
+			$this->assertEquals( 201, $response->get_status() );
+			$data = $response->get_data();
+			$this->assertEquals( '110.00', $data['amount'], 'Auto-computed amount should include tax ($100 + 10% = $110)' );
+
+			// Verify the per-line refund_tax was extracted (not 0).
+			$this->assertNotEmpty( $data['line_items'] );
+			$line_item_response = $data['line_items'][0];
+			$this->assertEquals( '100.00', $line_item_response['refund_total'], 'Per-line refund_total should be tax-exclusive after extraction' );
+			$this->assertNotEmpty( $line_item_response['refund_tax'], 'refund_tax should be populated from extraction' );
+			$this->assertEquals( '10.00', $line_item_response['refund_tax'][0]['refund_total'] );
+
+			$this->created_refunds[] = $data['id'];
+			$product->delete( true );
+		} finally {
+			update_option( 'woocommerce_calc_taxes', $original_calc_taxes );
+			update_option( 'woocommerce_prices_include_tax', $original_prices_include_tax );
+		}
+	}
+
+	/**
+	 * @testdox Simplified form (no refund_total) produces the same amount as explicit refund_total.
+	 */
+	public function test_refunds_create_simplified_matches_explicit(): void {
+		$product = WC_Helper_Product::create_simple_product();
+		// set_regular_price() persists to product meta so the REST order-creation flow
+		// picks it up. set_price() only updates the in-memory derived price and gets
+		// overwritten when the product is reloaded inside the order controller.
+		$product->set_regular_price( 25.00 );
+		$product->save();
+
+		// Order A: refunded via simplified form.
+		$order_a   = $this->create_test_order(
+			array(
+				'line_items' => array(
+					array(
+						'product_id' => $product->get_id(),
+						'quantity'   => 4,
+					),
+				),
+			)
+		);
+		$items_a   = $order_a->get_items();
+		$item_a    = reset( $items_a );
+		$request_a = new WP_REST_Request( 'POST', '/wc/v4/refunds' );
+		$request_a->set_body_params(
+			array(
+				'order_id'   => $order_a->get_id(),
+				'line_items' => array(
+					array(
+						'line_item_id' => $item_a->get_id(),
+						'quantity'     => 2,
+					),
+				),
+			)
+		);
+		$response_a = $this->server->dispatch( $request_a );
+		$this->assertEquals( 201, $response_a->get_status() );
+		$amount_a                = $response_a->get_data()['amount'];
+		$this->created_refunds[] = $response_a->get_data()['id'];
+
+		// Order B: same shape but with explicit refund_total computed by the client.
+		$order_b   = $this->create_test_order(
+			array(
+				'line_items' => array(
+					array(
+						'product_id' => $product->get_id(),
+						'quantity'   => 4,
+					),
+				),
+			)
+		);
+		$items_b   = $order_b->get_items();
+		$item_b    = reset( $items_b );
+		$request_b = new WP_REST_Request( 'POST', '/wc/v4/refunds' );
+		$request_b->set_body_params(
+			array(
+				'order_id'   => $order_b->get_id(),
+				'line_items' => array(
+					array(
+						'line_item_id' => $item_b->get_id(),
+						'quantity'     => 2,
+						'refund_total' => 50.00,
+					),
+				),
+			)
+		);
+		$response_b = $this->server->dispatch( $request_b );
+		$this->assertEquals( 201, $response_b->get_status() );
+		$amount_b                = $response_b->get_data()['amount'];
+		$this->created_refunds[] = $response_b->get_data()['id'];
+
+		$this->assertEquals( $amount_b, $amount_a, 'Simplified form should produce the same amount as the explicit form.' );
+
+		$product->delete( true );
+	}
+
+	/**
+	 * @testdox Simplified form produces the same amount as explicit refund_total on a tax-inclusive store.
+	 *
+	 * The no-tax equivalence test is trivial — compute_line_item_refund_total
+	 * returns the raw line total. The interesting regression risk is the
+	 * tax round-trip: auto-compute returns a tax-inclusive value, the converter
+	 * runs WC_Tax::calc_inclusive_tax to split it. A future refactor that
+	 * yielded a tax-exclusive auto-computed value would diverge from the
+	 * explicit-form total by the tax delta with no other test catching it.
+	 */
+	public function test_refunds_create_simplified_matches_explicit_tax_inclusive(): void {
+		$tax_rate_id = WC_Tax::_insert_tax_rate(
+			array(
+				'tax_rate_country'  => 'US',
+				'tax_rate_state'    => '',
+				'tax_rate'          => '10.0000',
+				'tax_rate_name'     => 'VAT',
+				'tax_rate_priority' => '1',
+				'tax_rate_compound' => '0',
+				'tax_rate_shipping' => '1',
+				'tax_rate_order'    => '1',
+				'tax_rate_class'    => '',
+			)
+		);
+
+		$original_calc_taxes         = get_option( 'woocommerce_calc_taxes', 'no' );
+		$original_prices_include_tax = get_option( 'woocommerce_prices_include_tax', 'no' );
+		update_option( 'woocommerce_calc_taxes', 'yes' );
+		// Actually exercise a tax-inclusive store (the test name now matches reality).
+		update_option( 'woocommerce_prices_include_tax', 'yes' );
+
+		try {
+			$dispatch_refund = function ( array $line_item_overrides ) use ( $tax_rate_id ): array {
+				$product = WC_Helper_Product::create_simple_product();
+				// Tax-inclusive store: regular_price entered with tax baked in.
+				$product->set_regular_price( 110.00 );
+				$product->set_tax_status( 'taxable' );
+				$product->save();
+
+				$order = wc_create_order();
+				$item  = new WC_Order_Item_Product();
+				$item->set_props(
+					array(
+						'product'  => $product,
+						'quantity' => 1,
+						'subtotal' => 100.00,
+						'total'    => 100.00,
+					)
+				);
+				$item->set_taxes(
+					array(
+						'total'    => array( $tax_rate_id => 10.00 ),
+						'subtotal' => array( $tax_rate_id => 10.00 ),
+					)
+				);
+				$item->save();
+				$order->add_item( $item );
+
+				$tax_item = new \WC_Order_Item_Tax();
+				$tax_item->set_rate( $tax_rate_id );
+				$tax_item->set_tax_total( 10.00 );
+				$tax_item->save();
+				$order->add_item( $tax_item );
+
+				$order->set_billing_country( 'US' );
+				$order->set_total( 110.00 );
+				$order->set_status( OrderStatus::COMPLETED );
+				$order->save();
+				$this->created_orders[] = $order->get_id();
+
+				$request = new WP_REST_Request( 'POST', '/wc/v4/refunds' );
+				$request->set_body_params(
+					array(
+						'order_id'   => $order->get_id(),
+						'line_items' => array(
+							array_merge( array( 'line_item_id' => $item->get_id() ), $line_item_overrides ),
+						),
+					)
+				);
+				$response = $this->server->dispatch( $request );
+
+				$this->assertEquals( 201, $response->get_status() );
+				$data                    = $response->get_data();
+				$this->created_refunds[] = $data['id'];
+				$product->delete( true );
+
+				return $data;
+			};
+
+			// Path A: simplified form — no refund_total, backend auto-computes via compute_line_item_refund_total.
+			$data_simplified = $dispatch_refund( array( 'quantity' => 1 ) );
+			// Path B: explicit form — client supplies the tax-inclusive refund_total.
+			$data_explicit = $dispatch_refund(
+				array(
+					'quantity'     => 1,
+					'refund_total' => 110.00,
+				)
+			);
+
+			$this->assertEquals(
+				$data_explicit['amount'],
+				$data_simplified['amount'],
+				'Tax-inclusive store: simplified and explicit forms must produce the same amount.'
+			);
+			$this->assertEquals( '110.00', $data_simplified['amount'] );
+
+			// The per-line refund_total / refund_tax must round-trip identically too.
+			$this->assertEquals(
+				$data_explicit['line_items'][0]['refund_total'],
+				$data_simplified['line_items'][0]['refund_total'],
+				'Per-line refund_total must match (tax-exclusive after extraction).'
+			);
+			$this->assertEquals( '100.00', $data_simplified['line_items'][0]['refund_total'] );
+
+			$this->assertNotEmpty( $data_simplified['line_items'][0]['refund_tax'] );
+			$this->assertEquals(
+				$data_explicit['line_items'][0]['refund_tax'][0]['refund_total'],
+				$data_simplified['line_items'][0]['refund_tax'][0]['refund_total'],
+				'Extracted refund_tax must match between paths.'
+			);
+			$this->assertEquals( '10.00', $data_simplified['line_items'][0]['refund_tax'][0]['refund_total'] );
+		} finally {
+			update_option( 'woocommerce_calc_taxes', $original_calc_taxes );
+			update_option( 'woocommerce_prices_include_tax', $original_prices_include_tax );
+		}
+	}
+
+	/**
+	 * @testdox Refund creation supports mixing items with and without refund_total in the same request.
+	 */
+	public function test_refunds_create_mixed_with_and_without_refund_total(): void {
+		$product_a = WC_Helper_Product::create_simple_product();
+		$product_a->set_price( 10.00 );
+		$product_a->save();
+		$product_b = WC_Helper_Product::create_simple_product();
+		$product_b->set_price( 20.00 );
+		$product_b->save();
+
+		$order  = wc_create_order();
+		$item_a = new WC_Order_Item_Product();
+		$item_a->set_props(
+			array(
+				'product'  => $product_a,
+				'quantity' => 1,
+				'subtotal' => 10.00,
+				'total'    => 10.00,
+			)
+		);
+		$item_a->save();
+		$order->add_item( $item_a );
+		$item_b = new WC_Order_Item_Product();
+		$item_b->set_props(
+			array(
+				'product'  => $product_b,
+				'quantity' => 1,
+				'subtotal' => 20.00,
+				'total'    => 20.00,
+			)
+		);
+		$item_b->save();
+		$order->add_item( $item_b );
+		$order->set_total( 30.00 );
+		$order->set_status( OrderStatus::COMPLETED );
+		$order->save();
+		$this->created_orders[] = $order->get_id();
+
+		$request = new WP_REST_Request( 'POST', '/wc/v4/refunds' );
+		$request->set_body_params(
+			array(
+				'order_id'   => $order->get_id(),
+				'line_items' => array(
+					// Item A — no refund_total, will be auto-computed to 10.00.
+					array(
+						'line_item_id' => $item_a->get_id(),
+						'quantity'     => 1,
+					),
+					// Item B — explicit refund_total (less than item total — over-refund allowed for B).
+					array(
+						'line_item_id' => $item_b->get_id(),
+						'quantity'     => 1,
+						'refund_total' => 15.00,
+					),
+				),
+			)
+		);
+		$response = $this->server->dispatch( $request );
+
+		$this->assertEquals( 201, $response->get_status() );
+		$data = $response->get_data();
+		$this->assertEquals( '25.00', $data['amount'], 'Total = 10 (auto) + 15 (explicit) = 25' );
+
+		$this->created_refunds[] = $data['id'];
+		$product_a->delete( true );
+		$product_b->delete( true );
+	}
+
+	/**
+	 * @testdox Refund creation supports all three request shapes mixed into a single create call.
+	 *
+	 * The controller normalises every line item through fill_missing_refund_totals
+	 * and then convert_line_items_to_internal_format in one pass. An ordering bug
+	 * (e.g. a stateful helper, or the converter depending on uniform shape) would
+	 * only surface when all three forms coexist:
+	 *  - auto-compute (quantity, no refund_total)
+	 *  - explicit-with-quantity (quantity + refund_total)
+	 *  - legacy explicit-no-quantity (refund_total only)
+	 */
+	public function test_refunds_create_three_way_mixed_shapes(): void {
+		$product_a = WC_Helper_Product::create_simple_product();
+		$product_a->set_price( 10.00 );
+		$product_a->save();
+		$product_b = WC_Helper_Product::create_simple_product();
+		$product_b->set_price( 20.00 );
+		$product_b->save();
+		$product_c = WC_Helper_Product::create_simple_product();
+		$product_c->set_price( 30.00 );
+		$product_c->save();
+
+		$order  = wc_create_order();
+		$item_a = new WC_Order_Item_Product();
+		$item_a->set_props(
+			array(
+				'product'  => $product_a,
+				'quantity' => 1,
+				'subtotal' => 10.00,
+				'total'    => 10.00,
+			)
+		);
+		$item_a->save();
+		$order->add_item( $item_a );
+		$item_b = new WC_Order_Item_Product();
+		$item_b->set_props(
+			array(
+				'product'  => $product_b,
+				'quantity' => 1,
+				'subtotal' => 20.00,
+				'total'    => 20.00,
+			)
+		);
+		$item_b->save();
+		$order->add_item( $item_b );
+		$item_c = new WC_Order_Item_Product();
+		$item_c->set_props(
+			array(
+				'product'  => $product_c,
+				'quantity' => 1,
+				'subtotal' => 30.00,
+				'total'    => 30.00,
+			)
+		);
+		$item_c->save();
+		$order->add_item( $item_c );
+		$order->set_total( 60.00 );
+		$order->set_status( OrderStatus::COMPLETED );
+		$order->save();
+		$this->created_orders[] = $order->get_id();
+
+		$request = new WP_REST_Request( 'POST', '/wc/v4/refunds' );
+		$request->set_body_params(
+			array(
+				'order_id'   => $order->get_id(),
+				'line_items' => array(
+					// Shape 1: auto-compute (quantity only).
+					array(
+						'line_item_id' => $item_a->get_id(),
+						'quantity'     => 1,
+					),
+					// Shape 2: explicit-with-quantity.
+					array(
+						'line_item_id' => $item_b->get_id(),
+						'quantity'     => 1,
+						'refund_total' => 15.00,
+					),
+					// Shape 3: legacy explicit-no-quantity (qty=0 on the refund record).
+					array(
+						'line_item_id' => $item_c->get_id(),
+						'refund_total' => 25.00,
+					),
+				),
+			)
+		);
+		$response = $this->server->dispatch( $request );
+
+		$this->assertEquals( 201, $response->get_status() );
+		$data = $response->get_data();
+		$this->assertEquals( '50.00', $data['amount'], 'Total = 10 (auto) + 15 (explicit) + 25 (legacy) = 50' );
+		$this->created_refunds[] = $data['id'];
+
+		// Verify all three lines are attached and carry the expected qty.
+		// WC stores refund quantities as negative (refunded amount), so the
+		// request quantity N becomes -N on the refund line item.
+		$refund       = wc_get_order( $data['id'] );
+		$refund_items = $refund->get_items( 'line_item' );
+		$this->assertCount( 3, $refund_items, 'All three line items must be attached to the refund record.' );
+
+		$qty_by_original_id = array();
+		foreach ( $refund_items as $refund_item ) {
+			$qty_by_original_id[ absint( $refund_item->get_meta( '_refunded_item_id' ) ) ] = $refund_item->get_quantity();
+		}
+		$this->assertSame( -1, $qty_by_original_id[ $item_a->get_id() ], 'Auto-compute path records qty=-1 (refund of 1 unit).' );
+		$this->assertSame( -1, $qty_by_original_id[ $item_b->get_id() ], 'Explicit-with-quantity path records qty=-1 (refund of 1 unit).' );
+		$this->assertSame( 0, $qty_by_original_id[ $item_c->get_id() ], 'Legacy no-quantity path records qty=0 (no units consumed).' );
+
+		$product_a->delete( true );
+		$product_b->delete( true );
+		$product_c->delete( true );
+	}
+
+	/**
+	 * @testdox Simplified form preserves existing quantity validation: over-quantity is still rejected.
+	 */
+	public function test_refunds_create_simplified_form_rejects_over_quantity(): void {
+		$product = WC_Helper_Product::create_simple_product();
+		$product->set_price( 10.00 );
+		$product->save();
+
+		$order     = $this->create_test_order(
+			array(
+				'line_items' => array(
+					array(
+						'product_id' => $product->get_id(),
+						'quantity'   => 1,
+					),
+				),
+			)
+		);
+		$items     = $order->get_items();
+		$line_item = reset( $items );
+
+		// Request refund_total omitted AND quantity > original.
+		$request = new WP_REST_Request( 'POST', '/wc/v4/refunds' );
+		$request->set_body_params(
+			array(
+				'order_id'   => $order->get_id(),
+				'line_items' => array(
+					array(
+						'line_item_id' => $line_item->get_id(),
+						'quantity'     => 99,
+					),
+				),
+			)
+		);
+		$response = $this->server->dispatch( $request );
+
+		$this->assertEquals( 400, $response->get_status(), 'Over-quantity must still be rejected even when refund_total is auto-computed.' );
+
+		$product->delete( true );
+	}
+
+	/**
+	 * @testdox Simplified form auto-computes refund_total for a positive-total fee line.
+	 */
+	public function test_refunds_create_simplified_form_fee_line(): void {
+		$product = WC_Helper_Product::create_simple_product();
+		$product->set_price( 10.00 );
+		$product->save();
+
+		$order = wc_create_order();
+		$item  = new WC_Order_Item_Product();
+		$item->set_props(
+			array(
+				'product'  => $product,
+				'quantity' => 1,
+				'subtotal' => 10.00,
+				'total'    => 10.00,
+			)
+		);
+		$item->save();
+		$order->add_item( $item );
+
+		$fee = new \WC_Order_Item_Fee();
+		$fee->set_props(
+			array(
+				'name'  => 'Service fee',
+				'total' => 7.50,
+			)
+		);
+		$fee->save();
+		$order->add_item( $fee );
+
+		$order->set_total( 17.50 );
+		$order->set_status( OrderStatus::COMPLETED );
+		$order->save();
+		$this->created_orders[] = $order->get_id();
+
+		// Refund the fee line via the simplified form.
+		$request = new WP_REST_Request( 'POST', '/wc/v4/refunds' );
+		$request->set_body_params(
+			array(
+				'order_id'   => $order->get_id(),
+				'line_items' => array(
+					array(
+						'line_item_id' => $fee->get_id(),
+						'quantity'     => 1,
+					),
+				),
+			)
+		);
+		$response = $this->server->dispatch( $request );
+
+		$this->assertEquals( 201, $response->get_status() );
+		$data = $response->get_data();
+		$this->assertEquals( '7.50', $data['amount'], 'Auto-computed fee refund should equal the full fee total' );
+
+		$this->created_refunds[] = $data['id'];
+		$product->delete( true );
+	}
+
+	/**
+	 * @testdox Simplified form does not silently break on a negative-total fee (discount-as-fee).
+	 *
+	 * The compute helper preserves the sign of negative fees, but the existing
+	 * validate_line_items has an item_total_with_tax < refund_total check that
+	 * normally guards over-refunds. For a negative fee (e.g. total: -10), the
+	 * auto-computed refund_total is also -10. The validator's comparison
+	 * (-10 < -10) is false, so the request passes. The downstream
+	 * wc_create_refund() call is what ultimately accepts or rejects the
+	 * negative refund — assert the request reaches that point without an
+	 * earlier silent failure.
+	 */
+	public function test_refunds_create_simplified_form_negative_fee(): void {
+		$product = WC_Helper_Product::create_simple_product();
+		$product->set_price( 10.00 );
+		$product->save();
+
+		$order = wc_create_order();
+		$item  = new WC_Order_Item_Product();
+		$item->set_props(
+			array(
+				'product'  => $product,
+				'quantity' => 1,
+				'subtotal' => 10.00,
+				'total'    => 10.00,
+			)
+		);
+		$item->save();
+		$order->add_item( $item );
+
+		$fee = new \WC_Order_Item_Fee();
+		$fee->set_props(
+			array(
+				'name'  => 'Discount',
+				'total' => -3.00,
+			)
+		);
+		$fee->save();
+		$order->add_item( $fee );
+
+		$order->set_total( 7.00 );
+		$order->set_status( OrderStatus::COMPLETED );
+		$order->save();
+		$this->created_orders[] = $order->get_id();
+
+		$request = new WP_REST_Request( 'POST', '/wc/v4/refunds' );
+		$request->set_body_params(
+			array(
+				'order_id'   => $order->get_id(),
+				'line_items' => array(
+					array(
+						'line_item_id' => $fee->get_id(),
+						'quantity'     => 1,
+					),
+				),
+			)
+		);
+		$response = $this->server->dispatch( $request );
+
+		// Current platform behaviour: the controller's `0 > $refund_amount`
+		// guard fires for any negative auto-computed total and surfaces
+		// `invalid_refund_amount`. Pin the exact response so a future change
+		// (e.g. platform support for negative-fee refunds, or a different
+		// rejection code) is loud rather than silent. If the platform later
+		// allows negative refunds, this test will fail and force the
+		// conversation about whether to update it to assert 201 + `-3.00`.
+		$this->assertEquals( 400, $response->get_status() );
+		$this->assertEquals( 'invalid_refund_amount', $response->get_data()['code'] );
+
+		$product->delete( true );
+	}
+
+	/**
+	 * @testdox Simplified form on a tax-inclusive store ($prices_include_tax = yes) produces the correct tax-inclusive amount.
+	 */
+	public function test_refunds_create_simplified_form_tax_inclusive_store(): void {
+		$tax_rate_id = WC_Tax::_insert_tax_rate(
+			array(
+				'tax_rate_country'  => 'US',
+				'tax_rate_state'    => '',
+				'tax_rate'          => '10.0000',
+				'tax_rate_name'     => 'VAT',
+				'tax_rate_priority' => '1',
+				'tax_rate_compound' => '0',
+				'tax_rate_shipping' => '1',
+				'tax_rate_order'    => '1',
+				'tax_rate_class'    => '',
+			)
+		);
+
+		$original_prices_include_tax = get_option( 'woocommerce_prices_include_tax', 'no' );
+		update_option( 'woocommerce_calc_taxes', 'yes' );
+		update_option( 'woocommerce_prices_include_tax', 'yes' );
+
+		try {
+			// Product price $110 entered tax-inclusive; tax-exclusive total is $100, tax is $10.
+			$product = WC_Helper_Product::create_simple_product();
+			$product->set_regular_price( 110.00 );
+			$product->set_tax_status( 'taxable' );
+			$product->save();
+
+			$order = wc_create_order();
+			$item  = new WC_Order_Item_Product();
+			$item->set_props(
+				array(
+					'product'  => $product,
+					'quantity' => 1,
+					'subtotal' => 100.00,
+					'total'    => 100.00,
+				)
+			);
+			$item->set_taxes(
+				array(
+					'total'    => array( $tax_rate_id => 10.00 ),
+					'subtotal' => array( $tax_rate_id => 10.00 ),
+				)
+			);
+			$item->save();
+			$order->add_item( $item );
+
+			$tax_item = new \WC_Order_Item_Tax();
+			$tax_item->set_rate( $tax_rate_id );
+			$tax_item->set_tax_total( 10.00 );
+			$tax_item->save();
+			$order->add_item( $tax_item );
+
+			$order->set_billing_country( 'US' );
+			$order->set_total( 110.00 );
+			$order->set_status( OrderStatus::COMPLETED );
+			$order->save();
+			$this->created_orders[] = $order->get_id();
+
+			// Refund via the simplified form (no refund_total).
+			$request = new WP_REST_Request( 'POST', '/wc/v4/refunds' );
+			$request->set_body_params(
+				array(
+					'order_id'   => $order->get_id(),
+					'line_items' => array(
+						array(
+							'line_item_id' => $item->get_id(),
+							'quantity'     => 1,
+						),
+					),
+				)
+			);
+			$response = $this->server->dispatch( $request );
+
+			$this->assertEquals( 201, $response->get_status() );
+			$data = $response->get_data();
+			// Tax-inclusive store: refund amount must still be $110 ($100 + $10 tax),
+			// confirming the auto-compute round-trip works under prices_include_tax=yes.
+			$this->assertEquals( '110.00', $data['amount'], 'Tax-inclusive store: auto-computed amount must equal the tax-inclusive line total.' );
+
+			$this->assertNotEmpty( $data['line_items'] );
+			$line_item_response = $data['line_items'][0];
+			$this->assertEquals( '100.00', $line_item_response['refund_total'], 'Per-line refund_total should be tax-exclusive after extraction.' );
+			$this->assertNotEmpty( $line_item_response['refund_tax'] );
+			$this->assertEquals( '10.00', $line_item_response['refund_tax'][0]['refund_total'] );
+
+			$this->created_refunds[] = $data['id'];
+			$product->delete( true );
+		} finally {
+			// Restore the option so a failing assertion above can't leak state into other tests.
+			update_option( 'woocommerce_prices_include_tax', $original_prices_include_tax );
+		}
+	}
+
+	/**
+	 * @testdox Legacy v3-style path: explicit refund_total with no quantity still works (201).
+	 *
+	 * The PR added a strict quantity check in validate_line_items because the
+	 * new auto-compute path needs a real quantity, but that check must NOT
+	 * affect requests that supply refund_total directly — those are the
+	 * pre-existing v4 contract and POS clients integrating against v3 will
+	 * eventually depend on it too.
+	 */
+	public function test_refunds_create_legacy_form_no_quantity_with_explicit_refund_total(): void {
+		$product = WC_Helper_Product::create_simple_product();
+		$product->set_regular_price( 50.00 );
+		$product->save();
+
+		$order = wc_create_order();
+		$item  = new WC_Order_Item_Product();
+		$item->set_props(
+			array(
+				'product'  => $product,
+				'quantity' => 2,
+				'subtotal' => 100.00,
+				'total'    => 100.00,
+			)
+		);
+		$item->save();
+		$order->add_item( $item );
+		$order->set_total( 100.00 );
+		$order->set_status( OrderStatus::COMPLETED );
+		$order->save();
+		$this->created_orders[] = $order->get_id();
+
+		// Step 1: legacy explicit form — refund_total provided, no quantity.
+		$request = new WP_REST_Request( 'POST', '/wc/v4/refunds' );
+		$request->set_body_params(
+			array(
+				'order_id'   => $order->get_id(),
+				'line_items' => array(
+					array(
+						'line_item_id' => $item->get_id(),
+						'refund_total' => 30.00,
+					),
+				),
+			)
+		);
+		$response = $this->server->dispatch( $request );
+
+		$this->assertEquals( 201, $response->get_status() );
+		$data = $response->get_data();
+		$this->assertEquals( '30.00', $data['amount'] );
+		$this->created_refunds[] = $data['id'];
+
+		// The line item must be attached to the refund record (B regression guard).
+		// qty=0 matches v3 semantics — refund_total recorded without consuming specific units.
+		$refund       = wc_get_order( $data['id'] );
+		$refund_items = $refund->get_items( 'line_item' );
+		$this->assertCount( 1, $refund_items, 'Refund record must have the line item attached, not an empty array.' );
+		$refund_item = reset( $refund_items );
+		$this->assertSame( 0, $refund_item->get_quantity(), 'qty=0 expected for legacy-no-quantity path.' );
+		$this->assertEquals( -30.00, (float) $refund_item->get_total(), 'Refund line item total should be -30.00.' );
+
+		// Step 2: dollar accounting still gates subsequent refunds.
+		// Remaining refundable = 100 - 30 = 70. A simplified-form request for the
+		// full remaining 2 units would compute 100 (2 * $50), which exceeds 70,
+		// so wc_create_refund must reject it.
+		$request2 = new WP_REST_Request( 'POST', '/wc/v4/refunds' );
+		$request2->set_body_params(
+			array(
+				'order_id'   => $order->get_id(),
+				'line_items' => array(
+					array(
+						'line_item_id' => $item->get_id(),
+						'quantity'     => 2,
+					),
+				),
+			)
+		);
+		$response2 = $this->server->dispatch( $request2 );
+
+		$this->assertEquals( 400, $response2->get_status(), 'Follow-up refund exceeding remaining dollars must be rejected.' );
+		$this->assertEquals( 'cannot_create_refund', $response2->get_data()['code'] );
+
+		// Step 3: a follow-up that fits within remaining ($40 of $70) must succeed.
+		// Guards against a regression where the first refund silently consumed
+		// the full $100 budget — that would surface here as a 400, not 201.
+		$request3 = new WP_REST_Request( 'POST', '/wc/v4/refunds' );
+		$request3->set_body_params(
+			array(
+				'order_id'   => $order->get_id(),
+				'line_items' => array(
+					array(
+						'line_item_id' => $item->get_id(),
+						'refund_total' => 40.00,
+					),
+				),
+			)
+		);
+		$response3 = $this->server->dispatch( $request3 );
+
+		$this->assertEquals( 201, $response3->get_status(), 'Follow-up refund within remaining dollars must succeed.' );
+		$data3 = $response3->get_data();
+		$this->assertEquals( '40.00', $data3['amount'] );
+		$this->created_refunds[] = $data3['id'];
+
+		// And after $30 + $40 = $70 refunded, total refunded equals 70, remaining = 30.
+		$order_after = wc_get_order( $order->get_id() );
+		$this->assertEquals( 70.00, (float) $order_after->get_total_refunded() );
+		$this->assertEquals( 30.00, (float) $order_after->get_remaining_refund_amount() );
+
+		$product->delete( true );
+	}
+
+	/**
+	 * @testdox Legacy form with api_restock=true does not restock anything (qty=0 semantics).
+	 *
+	 * When no quantity is provided, qty defaults to 0 on the refund line item.
+	 * api_restock therefore has no units to add back to inventory. Pin that
+	 * behavior so future contract changes don't silently start restocking
+	 * a guessed unit count.
+	 */
+	public function test_refunds_create_legacy_form_api_restock_does_not_restock(): void {
+		$product = WC_Helper_Product::create_simple_product();
+		$product->set_regular_price( 50.00 );
+		$product->set_manage_stock( true );
+		$product->set_stock_quantity( 5 );
+		$product->save();
+
+		$order = wc_create_order();
+		$item  = new WC_Order_Item_Product();
+		$item->set_props(
+			array(
+				'product'  => $product,
+				'quantity' => 2,
+				'subtotal' => 100.00,
+				'total'    => 100.00,
+			)
+		);
+		$item->save();
+		$order->add_item( $item );
+		$order->set_total( 100.00 );
+		$order->set_status( OrderStatus::COMPLETED );
+		$order->save();
+		$this->created_orders[] = $order->get_id();
+
+		// Capture stock after order completion (the order may or may not have
+		// reduced stock depending on settings) — what matters is the refund
+		// step does not change it.
+		$stock_before_refund = wc_get_product( $product->get_id() )->get_stock_quantity();
+
+		$request = new WP_REST_Request( 'POST', '/wc/v4/refunds' );
+		$request->set_body_params(
+			array(
+				'order_id'    => $order->get_id(),
+				'api_restock' => true,
+				'line_items'  => array(
+					array(
+						'line_item_id' => $item->get_id(),
+						'refund_total' => 30.00,
+					),
+				),
+			)
+		);
+		$response = $this->server->dispatch( $request );
+
+		$this->assertEquals( 201, $response->get_status() );
+		$this->created_refunds[] = $response->get_data()['id'];
+
+		$stock_after_refund = wc_get_product( $product->get_id() )->get_stock_quantity();
+		$this->assertSame(
+			$stock_before_refund,
+			$stock_after_refund,
+			'Legacy form (no quantity) + api_restock must not restock — qty=0 means no units to put back.'
+		);
+
+		$product->delete( true );
+	}
+
+	/**
+	 * @testdox Legacy form (refund_total without quantity) on a tax-inclusive store extracts the right tax.
+	 *
+	 * The legacy v3-style path hits a different converter branch than the
+	 * simplified form (qty defaults to 0; refund_total is supplied directly).
+	 * On a tax-inclusive store this combination is the one POS clients will
+	 * actually exercise after the v3 port, so a converter regression in the
+	 * tax-extraction block would only surface here.
+	 */
+	public function test_refunds_create_legacy_form_tax_inclusive_store(): void {
+		$tax_rate_id = WC_Tax::_insert_tax_rate(
+			array(
+				'tax_rate_country'  => 'US',
+				'tax_rate_state'    => '',
+				'tax_rate'          => '10.0000',
+				'tax_rate_name'     => 'VAT',
+				'tax_rate_priority' => '1',
+				'tax_rate_compound' => '0',
+				'tax_rate_shipping' => '1',
+				'tax_rate_order'    => '1',
+				'tax_rate_class'    => '',
+			)
+		);
+
+		$original_calc_taxes         = get_option( 'woocommerce_calc_taxes', 'no' );
+		$original_prices_include_tax = get_option( 'woocommerce_prices_include_tax', 'no' );
+		update_option( 'woocommerce_calc_taxes', 'yes' );
+		update_option( 'woocommerce_prices_include_tax', 'yes' );
+
+		try {
+			$product = WC_Helper_Product::create_simple_product();
+			$product->set_regular_price( 110.00 );
+			$product->set_tax_status( 'taxable' );
+			$product->save();
+
+			$order = wc_create_order();
+			$item  = new WC_Order_Item_Product();
+			$item->set_props(
+				array(
+					'product'  => $product,
+					'quantity' => 1,
+					'subtotal' => 100.00,
+					'total'    => 100.00,
+				)
+			);
+			$item->set_taxes(
+				array(
+					'total'    => array( $tax_rate_id => 10.00 ),
+					'subtotal' => array( $tax_rate_id => 10.00 ),
+				)
+			);
+			$item->save();
+			$order->add_item( $item );
+
+			$tax_item = new \WC_Order_Item_Tax();
+			$tax_item->set_rate( $tax_rate_id );
+			$tax_item->set_tax_total( 10.00 );
+			$tax_item->save();
+			$order->add_item( $tax_item );
+
+			$order->set_billing_country( 'US' );
+			$order->set_total( 110.00 );
+			$order->set_status( OrderStatus::COMPLETED );
+			$order->save();
+			$this->created_orders[] = $order->get_id();
+
+			// Legacy form: client supplies the tax-inclusive refund_total ($110)
+			// and omits quantity. Converter must extract the $10 tax portion the
+			// same way it does for the simplified/explicit-with-quantity paths.
+			$request = new WP_REST_Request( 'POST', '/wc/v4/refunds' );
+			$request->set_body_params(
+				array(
+					'order_id'   => $order->get_id(),
+					'line_items' => array(
+						array(
+							'line_item_id' => $item->get_id(),
+							'refund_total' => 110.00,
+						),
+					),
+				)
+			);
+			$response = $this->server->dispatch( $request );
+
+			$this->assertEquals( 201, $response->get_status() );
+			$data                    = $response->get_data();
+			$this->created_refunds[] = $data['id'];
+
+			$this->assertEquals( '110.00', $data['amount'] );
+			$this->assertNotEmpty( $data['line_items'] );
+			$this->assertEquals( '100.00', $data['line_items'][0]['refund_total'], 'Per-line refund_total should be tax-exclusive after extraction.' );
+			$this->assertNotEmpty( $data['line_items'][0]['refund_tax'], 'refund_tax must be extracted on the tax-inclusive legacy path.' );
+			$this->assertEquals( '10.00', $data['line_items'][0]['refund_tax'][0]['refund_total'] );
+
+			$product->delete( true );
+		} finally {
+			update_option( 'woocommerce_calc_taxes', $original_calc_taxes );
+			update_option( 'woocommerce_prices_include_tax', $original_prices_include_tax );
+		}
+	}
+
+	/**
+	 * @testdox Simplified form rejects a line_item_id that belongs to a different order with invalid_line_item.
+	 */
+	public function test_refunds_create_simplified_form_rejects_cross_order_line_item_id(): void {
+		$product = WC_Helper_Product::create_simple_product();
+		$product->set_price( 10.00 );
+		$product->save();
+
+		// Order A: target of the refund request.
+		$order_a = $this->create_test_order(
+			array(
+				'line_items' => array(
+					array(
+						'product_id' => $product->get_id(),
+						'quantity'   => 1,
+					),
+				),
+			)
+		);
+
+		// Order B: holds the line_item the client will mistakenly reference.
+		$order_b       = $this->create_test_order(
+			array(
+				'line_items' => array(
+					array(
+						'product_id' => $product->get_id(),
+						'quantity'   => 1,
+					),
+				),
+			)
+		);
+		$order_b_items = $order_b->get_items();
+		$order_b_item  = reset( $order_b_items );
+
+		$request = new WP_REST_Request( 'POST', '/wc/v4/refunds' );
+		$request->set_body_params(
+			array(
+				'order_id'   => $order_a->get_id(),
+				'line_items' => array(
+					array(
+						'line_item_id' => $order_b_item->get_id(),
+						'quantity'     => 1,
+					),
+				),
+			)
+		);
+		$response = $this->server->dispatch( $request );
+
+		$this->assertEquals( 400, $response->get_status(), 'Cross-order line_item_id must be rejected, not silently auto-computed.' );
+		$data = $response->get_data();
+		$this->assertEquals( 'invalid_line_item', $data['code'] );
+
+		$product->delete( true );
+	}
+
+	/**
+	 * @testdox Simplified form surfaces a specific error when the source product line has zero original quantity.
+	 *
+	 * Without explicit handling, fill_missing_refund_totals would compute 0.0 from a divide-by-zero
+	 * scenario and the request would fall through to the misleading "Refund total must be greater
+	 * than zero" cascade. Lock in the clear error.
+	 */
+	public function test_refunds_create_simplified_form_zero_source_quantity(): void {
+		$order = wc_create_order();
+		$item  = new WC_Order_Item_Product();
+		$item->set_props(
+			array(
+				'quantity' => 0,
+				'subtotal' => 0,
+				'total'    => 0,
+			)
+		);
+		$item->save();
+		$order->add_item( $item );
+		$order->set_status( OrderStatus::COMPLETED );
+		// A non-zero order total is needed so the order is not considered fully refunded.
+		$order->set_total( 10.00 );
+		$order->save();
+		$this->created_orders[] = $order->get_id();
+
+		$request = new WP_REST_Request( 'POST', '/wc/v4/refunds' );
+		$request->set_body_params(
+			array(
+				'order_id'   => $order->get_id(),
+				'line_items' => array(
+					array(
+						'line_item_id' => $item->get_id(),
+						'quantity'     => 1,
+					),
+				),
+			)
+		);
+		$response = $this->server->dispatch( $request );
+
+		$this->assertEquals( 400, $response->get_status() );
+		$data = $response->get_data();
+		$this->assertEquals( 'invalid_line_item', $data['code'] );
+		$this->assertStringContainsString( 'source quantity is zero', $data['message'] );
+	}
+
+	/**
+	 * @testdox Simplified form rejects a second refund of an already-fully-refunded product line.
+	 *
+	 * Codex regression guard: a previous implementation compared the request's
+	 * quantity to `$item->get_quantity()` (the ORIGINAL count) rather than the
+	 * remaining-after-prior-refunds count. On a multi-line order, refunding
+	 * item A once would leave it look unrefunded to the validator on the next
+	 * request — and if item B left enough order-level dollar room, the second
+	 * `{line_item_id: A, quantity: 1}` request would be accepted and refund
+	 * item A twice. The fix uses compute_refunded_quantities_and_totals to
+	 * cap against remaining qty.
+	 */
+	public function test_refunds_create_simplified_form_rejects_already_refunded_product(): void {
+		$product_a = WC_Helper_Product::create_simple_product();
+		$product_a->set_price( 50.00 );
+		$product_a->save();
+		$product_b = WC_Helper_Product::create_simple_product();
+		$product_b->set_price( 50.00 );
+		$product_b->save();
+
+		$order  = wc_create_order();
+		$item_a = new WC_Order_Item_Product();
+		$item_a->set_props(
+			array(
+				'product'  => $product_a,
+				'quantity' => 1,
+				'subtotal' => 50.00,
+				'total'    => 50.00,
+			)
+		);
+		$item_a->save();
+		$order->add_item( $item_a );
+
+		$item_b = new WC_Order_Item_Product();
+		$item_b->set_props(
+			array(
+				'product'  => $product_b,
+				'quantity' => 1,
+				'subtotal' => 50.00,
+				'total'    => 50.00,
+			)
+		);
+		$item_b->save();
+		$order->add_item( $item_b );
+
+		$order->set_total( 100.00 );
+		$order->set_status( OrderStatus::COMPLETED );
+		$order->save();
+		$this->created_orders[] = $order->get_id();
+
+		// First simplified refund of item A — must succeed.
+		$request1 = new WP_REST_Request( 'POST', '/wc/v4/refunds' );
+		$request1->set_body_params(
+			array(
+				'order_id'   => $order->get_id(),
+				'line_items' => array(
+					array(
+						'line_item_id' => $item_a->get_id(),
+						'quantity'     => 1,
+					),
+				),
+			)
+		);
+		$response1 = $this->server->dispatch( $request1 );
+		$this->assertEquals( 201, $response1->get_status() );
+		$this->created_refunds[] = $response1->get_data()['id'];
+
+		// Second simplified refund of item A — must be rejected by the
+		// remaining-qty check (item A is fully refunded). Without the fix,
+		// item B's dollar room would let this through.
+		$request2 = new WP_REST_Request( 'POST', '/wc/v4/refunds' );
+		$request2->set_body_params(
+			array(
+				'order_id'   => $order->get_id(),
+				'line_items' => array(
+					array(
+						'line_item_id' => $item_a->get_id(),
+						'quantity'     => 1,
+					),
+				),
+			)
+		);
+		$response2 = $this->server->dispatch( $request2 );
+
+		$this->assertEquals( 400, $response2->get_status() );
+		$data2 = $response2->get_data();
+		$this->assertEquals( 'invalid_line_item', $data2['code'] );
+		$this->assertStringContainsString( 'remaining refundable quantity', $data2['message'] );
+
+		$product_a->delete( true );
+		$product_b->delete( true );
+	}
+
+	/**
+	 * @testdox Simplified form rejects a second refund of already-fully-refunded fee and shipping lines.
+	 */
+	public function test_refunds_create_simplified_form_rejects_already_refunded_fee_and_shipping(): void {
+		$product = WC_Helper_Product::create_simple_product();
+		$product->set_price( 50.00 );
+		$product->save();
+
+		$order = wc_create_order();
+		$item  = new WC_Order_Item_Product();
+		$item->set_props(
+			array(
+				'product'  => $product,
+				'quantity' => 1,
+				'subtotal' => 50.00,
+				'total'    => 50.00,
+			)
+		);
+		$item->save();
+		$order->add_item( $item );
+
+		$fee = new \WC_Order_Item_Fee();
+		$fee->set_props(
+			array(
+				'name'  => 'Service fee',
+				'total' => 7.50,
+			)
+		);
+		$fee->save();
+		$order->add_item( $fee );
+
+		$shipping = new \WC_Order_Item_Shipping();
+		$shipping->set_props(
+			array(
+				'method_title' => 'Flat rate',
+				'method_id'    => 'flat_rate',
+				'total'        => 5.00,
+			)
+		);
+		$shipping->save();
+		$order->add_item( $shipping );
+
+		$order->set_total( 62.50 );
+		$order->set_status( OrderStatus::COMPLETED );
+		$order->save();
+		$this->created_orders[] = $order->get_id();
+
+		foreach ( array( $fee, $shipping ) as $non_product_item ) {
+			$first_response = $this->dispatch_refund_request(
+				$order->get_id(),
+				array(
+					array(
+						'line_item_id' => $non_product_item->get_id(),
+						'quantity'     => 1,
+					),
+				)
+			);
+			$this->assertEquals( 201, $first_response->get_status() );
+			$this->created_refunds[] = $first_response->get_data()['id'];
+
+			$second_response = $this->dispatch_refund_request(
+				$order->get_id(),
+				array(
+					array(
+						'line_item_id' => $non_product_item->get_id(),
+						'quantity'     => 1,
+					),
+				)
+			);
+			if ( 201 === $second_response->get_status() ) {
+				$this->created_refunds[] = $second_response->get_data()['id'];
+			}
+
+			$this->assertEquals( 400, $second_response->get_status(), 'A fee or shipping line must not be refunded twice using other order lines remaining balance.' );
+			$data = $second_response->get_data();
+			$this->assertEquals( 'invalid_line_item', $data['code'] );
+			$this->assertStringContainsString( 'already been fully refunded', $data['message'] );
+		}
+
+		$product->delete( true );
+	}
+
+	/**
+	 * @testdox Simplified form rejects auto-computed refund_total combined with explicit refund_tax.
+	 *
+	 * Codex regression guard: with refund_total omitted and refund_tax
+	 * supplied, fill_missing_refund_totals would have written a tax-inclusive
+	 * refund_total (110 for a $100 item with $10 tax) and the converter would
+	 * then skip tax extraction because refund_tax was already present —
+	 * calculate_refund_amount summed both and emitted amount=120 (overstated
+	 * by the tax). The combination is now rejected up-front.
+	 */
+	public function test_refunds_create_rejects_auto_compute_with_explicit_refund_tax(): void {
+		$tax_rate_id = WC_Tax::_insert_tax_rate(
+			array(
+				'tax_rate_country'  => 'US',
+				'tax_rate_state'    => '',
+				'tax_rate'          => '10.0000',
+				'tax_rate_name'     => 'VAT',
+				'tax_rate_priority' => '1',
+				'tax_rate_compound' => '0',
+				'tax_rate_shipping' => '1',
+				'tax_rate_order'    => '1',
+				'tax_rate_class'    => '',
+			)
+		);
+
+		$original_calc_taxes         = get_option( 'woocommerce_calc_taxes', 'no' );
+		$original_prices_include_tax = get_option( 'woocommerce_prices_include_tax', 'no' );
+		update_option( 'woocommerce_calc_taxes', 'yes' );
+		update_option( 'woocommerce_prices_include_tax', 'no' );
+
+		try {
+			$product = WC_Helper_Product::create_simple_product();
+			$product->set_regular_price( 100.00 );
+			$product->set_tax_status( 'taxable' );
+			$product->save();
+
+			$order = wc_create_order();
+			$item  = new WC_Order_Item_Product();
+			$item->set_props(
+				array(
+					'product'  => $product,
+					'quantity' => 1,
+					'subtotal' => 100.00,
+					'total'    => 100.00,
+				)
+			);
+			$item->set_taxes(
+				array(
+					'total'    => array( $tax_rate_id => 10.00 ),
+					'subtotal' => array( $tax_rate_id => 10.00 ),
+				)
+			);
+			$item->save();
+			$order->add_item( $item );
+
+			$tax_item = new \WC_Order_Item_Tax();
+			$tax_item->set_rate( $tax_rate_id );
+			$tax_item->set_tax_total( 10.00 );
+			$tax_item->save();
+			$order->add_item( $tax_item );
+
+			$order->set_billing_country( 'US' );
+			$order->set_total( 110.00 );
+			$order->set_status( OrderStatus::COMPLETED );
+			$order->save();
+			$this->created_orders[] = $order->get_id();
+
+			// Auto-compute (no refund_total) + explicit refund_tax.
+			$request = new WP_REST_Request( 'POST', '/wc/v4/refunds' );
+			$request->set_body_params(
+				array(
+					'order_id'   => $order->get_id(),
+					'line_items' => array(
+						array(
+							'line_item_id' => $item->get_id(),
+							'quantity'     => 1,
+							'refund_tax'   => array(
+								array(
+									'id'           => $tax_rate_id,
+									'refund_total' => 10.00,
+								),
+							),
+						),
+					),
+				)
+			);
+			$response = $this->server->dispatch( $request );
+
+			$this->assertEquals( 400, $response->get_status() );
+			$data = $response->get_data();
+			$this->assertEquals( 'invalid_line_item', $data['code'] );
+			$this->assertStringContainsString( 'refund_tax cannot be combined', $data['message'] );
+
+			$product->delete( true );
+		} finally {
+			update_option( 'woocommerce_calc_taxes', $original_calc_taxes );
+			update_option( 'woocommerce_prices_include_tax', $original_prices_include_tax );
+		}
+	}
+
+	/**
+	 * @testdox Scoped catch around fill_missing_refund_totals returns a 500 with invalid_refund_request when the helper throws.
+	 *
+	 * The catch is defensive — fill_missing_refund_totals pre-checks the
+	 * invariant that compute_line_item_refund_total cares about, so the
+	 * throw is unreachable from public input. Locking in the response shape
+	 * here means a future refactor that broadens the catch (e.g. to
+	 * \Throwable) or accidentally re-narrows fill's pre-check is caught.
+	 */
+	public function test_refunds_create_invariant_violation_returns_500(): void {
+		$product = WC_Helper_Product::create_simple_product();
+		$product->set_regular_price( 50.00 );
+		$product->save();
+
+		$order = wc_create_order();
+		$item  = new WC_Order_Item_Product();
+		$item->set_props(
+			array(
+				'product'  => $product,
+				'quantity' => 1,
+				'subtotal' => 50.00,
+				'total'    => 50.00,
+			)
+		);
+		$item->save();
+		$order->add_item( $item );
+		$order->set_total( 50.00 );
+		$order->set_status( OrderStatus::COMPLETED );
+		$order->save();
+		$this->created_orders[] = $order->get_id();
+
+		// Inject a DataUtils stub that throws on fill_missing_refund_totals
+		// into the *DI-resolved* RefundsController instance — that's the one
+		// the REST server dispatches against. $this->endpoint in setUp is a
+		// separate instance and mutating it would not affect dispatch.
+		$throwing_utils = $this->getMockBuilder( DataUtils::class )
+			->onlyMethods( array( 'fill_missing_refund_totals' ) )
+			->getMock();
+		$throwing_utils->method( 'fill_missing_refund_totals' )
+			->willThrowException( new \InvalidArgumentException( 'simulated invariant violation' ) );
+
+		$container       = wc_get_container();
+		$dispatch_target = $container->get( RefundsController::class );
+		$dispatch_target->init( $this->refund_schema, new RefundPreviewSchema(), new CollectionQuery(), $throwing_utils );
+
+		try {
+			$request = new WP_REST_Request( 'POST', '/wc/v4/refunds' );
+			$request->set_body_params(
+				array(
+					'order_id'   => $order->get_id(),
+					'line_items' => array(
+						array(
+							'line_item_id' => $item->get_id(),
+							'quantity'     => 1,
+						),
+					),
+				)
+			);
+			$response = $this->server->dispatch( $request );
+
+			$this->assertEquals( 500, $response->get_status() );
+			$data = $response->get_data();
+			$this->assertEquals( 'invalid_refund_request', $data['code'] );
+		} finally {
+			// Restore the real data_utils on the dispatch-target controller
+			// so the rest of the suite is unaffected.
+			$dispatch_target->init( $this->refund_schema, new RefundPreviewSchema(), new CollectionQuery(), new DataUtils() );
+			$product->delete( true );
+		}
+	}
+
+	/**
+	 * @testdox The 'created' hook receives a request whose line_items include the auto-computed refund_total.
+	 */
+	public function test_refunds_create_hook_sees_normalised_line_items(): void {
+		$product = WC_Helper_Product::create_simple_product();
+		// See test_refunds_create_simplified_matches_explicit for why set_regular_price()
+		// is required when the order is created via the REST API.
+		$product->set_regular_price( 25.00 );
+		$product->save();
+
+		$order     = $this->create_test_order(
+			array(
+				'line_items' => array(
+					array(
+						'product_id' => $product->get_id(),
+						'quantity'   => 1,
+					),
+				),
+			)
+		);
+		$items     = $order->get_items();
+		$line_item = reset( $items );
+
+		$captured_line_items = null;
+		$hook                = 'woocommerce_rest_api_v4_refunds_created';
+		$listener            = function ( $refund, $captured_request ) use ( &$captured_line_items ) {
+			$captured_line_items = $captured_request['line_items'];
+		};
+		add_action( $hook, $listener, 10, 2 );
+
+		try {
+			$request = new WP_REST_Request( 'POST', '/wc/v4/refunds' );
+			$request->set_body_params(
+				array(
+					'order_id'   => $order->get_id(),
+					'line_items' => array(
+						array(
+							'line_item_id' => $line_item->get_id(),
+							'quantity'     => 1,
+						),
+					),
+				)
+			);
+			$response = $this->server->dispatch( $request );
+
+			$this->assertEquals( 201, $response->get_status() );
+			$this->created_refunds[] = $response->get_data()['id'];
+
+			$this->assertIsArray( $captured_line_items, 'Hook should have fired and captured the request line_items' );
+			$this->assertNotEmpty( $captured_line_items );
+			$this->assertArrayHasKey( 'refund_total', $captured_line_items[0], 'Hook listener should see the auto-computed refund_total on the request' );
+			$this->assertSame( 25.00, (float) $captured_line_items[0]['refund_total'] );
+		} finally {
+			remove_action( $hook, $listener, 10 );
+			$product->delete( true );
+		}
+	}
+
+	/**
+	 * @testdox The 'created' hook sees client-supplied refund_total unchanged on the explicit form.
+	 *
+	 * Guards against a future bug where the request-mirroring step (set_param
+	 * after fill_missing_refund_totals) accidentally overwrites client-supplied
+	 * refund_total values.
+	 */
+	public function test_refunds_create_hook_sees_explicit_refund_total_unchanged(): void {
+		$product = WC_Helper_Product::create_simple_product();
+		$product->set_regular_price( 25.00 );
+		$product->save();
+
+		$order     = $this->create_test_order(
+			array(
+				'line_items' => array(
+					array(
+						'product_id' => $product->get_id(),
+						'quantity'   => 1,
+					),
+				),
+			)
+		);
+		$items     = $order->get_items();
+		$line_item = reset( $items );
+
+		$captured_line_items = null;
+		$hook                = 'woocommerce_rest_api_v4_refunds_created';
+		$listener            = function ( $refund, $captured_request ) use ( &$captured_line_items ) {
+			$captured_line_items = $captured_request['line_items'];
+		};
+		add_action( $hook, $listener, 10, 2 );
+
+		try {
+			$request = new WP_REST_Request( 'POST', '/wc/v4/refunds' );
+			$request->set_body_params(
+				array(
+					'order_id'   => $order->get_id(),
+					'line_items' => array(
+						array(
+							'line_item_id' => $line_item->get_id(),
+							'quantity'     => 1,
+							// Deliberately different from the auto-computed value
+							// so an accidental overwrite would be detectable.
+							'refund_total' => 7.50,
+						),
+					),
+				)
+			);
+			$response = $this->server->dispatch( $request );
+
+			$this->assertEquals( 201, $response->get_status() );
+			$this->created_refunds[] = $response->get_data()['id'];
+
+			$this->assertIsArray( $captured_line_items );
+			$this->assertArrayHasKey( 'refund_total', $captured_line_items[0] );
+			$this->assertSame( 7.50, (float) $captured_line_items[0]['refund_total'], 'Hook listener must see the client-supplied refund_total unchanged.' );
+		} finally {
+			remove_action( $hook, $listener, 10 );
+			$product->delete( true );
+		}
+	}
+
+	/**
+	 * @testdox Refund creation with missing quantity returns a clear invalid_line_item error (not the misleading "amount > 0" cascade).
+	 */
+	public function test_refunds_create_missing_quantity_returns_clear_error(): void {
+		$product = WC_Helper_Product::create_simple_product();
+		$product->set_price( 10.00 );
+		$product->save();
+
+		$order     = $this->create_test_order(
+			array(
+				'line_items' => array(
+					array(
+						'product_id' => $product->get_id(),
+						'quantity'   => 1,
+					),
+				),
+			)
+		);
+		$items     = $order->get_items();
+		$line_item = reset( $items );
+
+		// Send a line item with NO quantity and NO refund_total — both required for auto-compute.
+		$request = new WP_REST_Request( 'POST', '/wc/v4/refunds' );
+		$request->set_body_params(
+			array(
+				'order_id'   => $order->get_id(),
+				'line_items' => array(
+					array(
+						'line_item_id' => $line_item->get_id(),
+					),
+				),
+			)
+		);
+		$response = $this->server->dispatch( $request );
+
+		$this->assertEquals( 400, $response->get_status() );
+		$data = $response->get_data();
+		$this->assertEquals( 'invalid_line_item', $data['code'], 'Should fail validation with a specific quantity error, not cascade to invalid_refund_amount.' );
+		$this->assertStringContainsString( 'positive integer', $data['message'] );
+
+		$product->delete( true );
+	}
+
+	/**
+	 * @testdox The create endpoint's auto-computed amount matches build_refund_preview's grand total for the same line items.
+	 *
+	 * Regression guard for create vs preview drift. Calls `build_refund_preview()`
+	 * directly to capture the authoritative total, then posts the same line items
+	 * (quantity only, no `refund_total`) to the create endpoint. The resulting
+	 * refund amount must equal the preview total exactly. A future change that
+	 * subtly diverges create's auto-compute from the preview-side calculation
+	 * would fail this assertion.
+	 */
+	public function test_refunds_create_auto_compute_matches_build_refund_preview(): void {
+		$tax_rate_id = WC_Tax::_insert_tax_rate(
+			array(
+				'tax_rate_country'  => 'US',
+				'tax_rate_state'    => '',
+				'tax_rate'          => '10.0000',
+				'tax_rate_name'     => 'VAT',
+				'tax_rate_priority' => '1',
+				'tax_rate_compound' => '0',
+				'tax_rate_shipping' => '1',
+				'tax_rate_order'    => '1',
+				'tax_rate_class'    => '',
+			)
+		);
+
+		$original_calc_taxes         = get_option( 'woocommerce_calc_taxes', 'no' );
+		$original_prices_include_tax = get_option( 'woocommerce_prices_include_tax', 'no' );
+		update_option( 'woocommerce_calc_taxes', 'yes' );
+		update_option( 'woocommerce_prices_include_tax', 'no' );
+
+		try {
+			$product = WC_Helper_Product::create_simple_product();
+			$product->set_regular_price( 100.00 );
+			$product->set_tax_status( 'taxable' );
+			$product->save();
+
+			$order = wc_create_order();
+			$item  = new WC_Order_Item_Product();
+			$item->set_props(
+				array(
+					'product'  => $product,
+					'quantity' => 2,
+					'subtotal' => 200.00,
+					'total'    => 200.00,
+				)
+			);
+			$item->set_taxes(
+				array(
+					'total'    => array( $tax_rate_id => 20.00 ),
+					'subtotal' => array( $tax_rate_id => 20.00 ),
+				)
+			);
+			$item->save();
+			$order->add_item( $item );
+
+			$order->set_total( 220.00 );
+			$order->set_status( OrderStatus::COMPLETED );
+			$order->save();
+			$this->created_orders[] = $order->get_id();
+
+			$line_items = array(
+				array(
+					'line_item_id' => $item->get_id(),
+					'quantity'     => 1,
+				),
+			);
+
+			$data_utils = wc_get_container()->get( DataUtils::class );
+			$preview    = $data_utils->build_refund_preview( $order, $line_items );
+
+			$request = new \WP_REST_Request( 'POST', '/wc/v4/refunds' );
+			$request->set_body_params(
+				array(
+					'order_id'   => $order->get_id(),
+					'line_items' => $line_items,
+				)
+			);
+			$response = $this->server->dispatch( $request );
+
+			$this->assertEquals( 201, $response->get_status() );
+			$create_data             = $response->get_data();
+			$this->created_refunds[] = $create_data['id'];
+
+			$this->assertEquals(
+				$preview['total'],
+				$create_data['amount'],
+				'Create amount must match build_refund_preview total exactly.'
+			);
+
+			$product->delete( true );
+		} finally {
+			update_option( 'woocommerce_calc_taxes', $original_calc_taxes );
+			update_option( 'woocommerce_prices_include_tax', $original_prices_include_tax );
+		}
+	}
+
+	/**
+	 * Helper to create an order containing one product line item with exact totals.
+	 *
+	 * Builds the order directly (no REST round-trip) so line totals that do not
+	 * divide evenly by quantity can be set verbatim — the rounding tests below
+	 * need unit prices like 11.00/3 that cannot be produced via a product price.
+	 *
+	 * @param int   $quantity    Line item quantity.
+	 * @param float $subtotal    Line subtotal (tax-exclusive, pre-discount).
+	 * @param float $total       Line total (tax-exclusive).
+	 * @param float $order_total Order grand total.
+	 * @param array $taxes       Optional map of tax_rate_id => tax amount for the line.
+	 * @return array{0: WC_Order, 1: WC_Order_Item_Product} The order and its line item.
+	 */
+	private function create_order_with_exact_line( int $quantity, float $subtotal, float $total, float $order_total, array $taxes = array() ): array {
+		$product = WC_Helper_Product::create_simple_product();
+		$product->set_regular_price( 10.00 );
+		$product->save();
+
+		$order = wc_create_order();
+		$item  = new WC_Order_Item_Product();
+		$item->set_props(
+			array(
+				'product'  => $product,
+				'quantity' => $quantity,
+				'subtotal' => $subtotal,
+				'total'    => $total,
+			)
+		);
+		if ( ! empty( $taxes ) ) {
+			$item->set_taxes(
+				array(
+					'total'    => $taxes,
+					'subtotal' => $taxes,
+				)
+			);
+		}
+		$item->save();
+		$order->add_item( $item );
+
+		foreach ( $taxes as $rate_id => $tax_total ) {
+			$tax_item = new \WC_Order_Item_Tax();
+			$tax_item->set_rate( $rate_id );
+			$tax_item->set_tax_total( $tax_total );
+			$tax_item->save();
+			$order->add_item( $tax_item );
+		}
+
+		$order->set_billing_country( 'US' );
+		$order->set_total( $order_total );
+		$order->set_status( OrderStatus::COMPLETED );
+		$order->save();
+		$this->created_orders[] = $order->get_id();
+
+		$product->delete( true );
+
+		return array( $order, $item );
+	}
+
+	/**
+	 * Helper to POST a refund request for an order and return the response.
+	 *
+	 * @param int   $order_id   Order ID.
+	 * @param array $line_items Request line items.
+	 * @return WP_REST_Response
+	 */
+	private function dispatch_refund_request( int $order_id, array $line_items ): WP_REST_Response {
+		$request = new WP_REST_Request( 'POST', '/wc/v4/refunds' );
+		$request->set_body_params(
+			array(
+				'order_id'   => $order_id,
+				'line_items' => $line_items,
+			)
+		);
+		return $this->server->dispatch( $request );
+	}
+
+	/**
+	 * @testdox Sequential single-unit auto-computed refunds that round above the remaining balance are rejected; an explicit refund_total recovers the remainder.
+	 *
+	 * A 3-quantity line totalling 11.00 has a repeating unit price (3.6667), so
+	 * each single-unit refund rounds up to 3.67. After two such refunds only 3.66
+	 * remains and the third auto-computed 3.67 is rejected by wc_create_refund's
+	 * remaining-amount guard. A one-shot qty-3 refund rounds once and consumes
+	 * the line exactly.
+	 */
+	public function test_refunds_create_sequential_unit_refunds_with_repeating_unit_price(): void {
+		list( $one_shot_order, $one_shot_item ) = $this->create_order_with_exact_line( 3, 11.00, 11.00, 11.00 );
+
+		$response = $this->dispatch_refund_request(
+			$one_shot_order->get_id(),
+			array(
+				array(
+					'line_item_id' => $one_shot_item->get_id(),
+					'quantity'     => 3,
+				),
+			)
+		);
+		$this->assertEquals( 201, $response->get_status() );
+		$this->assertEqualsWithDelta( 11.00, (float) $response->get_data()['amount'], 0.001, 'One-shot qty-3 refund should equal the full line total' );
+		$this->created_refunds[] = $response->get_data()['id'];
+
+		list( $order, $item ) = $this->create_order_with_exact_line( 3, 11.00, 11.00, 11.00 );
+
+		$unit_refund = array(
+			array(
+				'line_item_id' => $item->get_id(),
+				'quantity'     => 1,
+			),
+		);
+
+		foreach ( array( 1, 2 ) as $refund_number ) {
+			$response = $this->dispatch_refund_request( $order->get_id(), $unit_refund );
+			$this->assertEquals( 201, $response->get_status(), "Single-unit refund {$refund_number} should succeed" );
+			$this->assertEqualsWithDelta( 3.67, (float) $response->get_data()['amount'], 0.001, 'Each single-unit refund rounds 11.00/3 up to 3.67' );
+			$this->created_refunds[] = $response->get_data()['id'];
+		}
+
+		$order = wc_get_order( $order->get_id() );
+		$this->assertEqualsWithDelta( 3.66, (float) $order->get_remaining_refund_amount(), 0.001, 'Two 3.67 refunds leave 3.66 of the 11.00 line' );
+
+		$response = $this->dispatch_refund_request( $order->get_id(), $unit_refund );
+		$this->assertEquals( 400, $response->get_status(), 'Third auto-computed 3.67 exceeds the 3.66 remaining and must be rejected' );
+		$this->assertEquals( 'cannot_create_refund', $response->get_data()['code'] );
+
+		$response = $this->dispatch_refund_request(
+			$order->get_id(),
+			array(
+				array(
+					'line_item_id' => $item->get_id(),
+					'quantity'     => 1,
+					'refund_total' => 3.66,
+				),
+			)
+		);
+		$this->assertEquals( 201, $response->get_status(), 'Explicit refund_total recovers the rounding remainder' );
+		$this->created_refunds[] = $response->get_data()['id'];
+	}
+
+	/**
+	 * @testdox Auto-compute follows the store's zero-decimal price setting and repeated unit refunds strand one currency unit.
+	 */
+	public function test_refunds_create_auto_compute_zero_decimal_currency(): void {
+		$original_decimals = get_option( 'woocommerce_price_num_decimals', '2' );
+		update_option( 'woocommerce_price_num_decimals', '0' );
+
+		try {
+			list( $order, $item ) = $this->create_order_with_exact_line( 3, 1000.00, 1000.00, 1000.00 );
+
+			$response = $this->dispatch_refund_request(
+				$order->get_id(),
+				array(
+					array(
+						'line_item_id' => $item->get_id(),
+						'quantity'     => 2,
+					),
+				)
+			);
+			$this->assertEquals( 201, $response->get_status() );
+			$this->assertEqualsWithDelta( 667.0, (float) $response->get_data()['amount'], 0.001, 'Qty-2 refund of a 1000/3 line rounds to 667 at zero decimals' );
+			$this->created_refunds[] = $response->get_data()['id'];
+
+			$response = $this->dispatch_refund_request(
+				$order->get_id(),
+				array(
+					array(
+						'line_item_id' => $item->get_id(),
+						'quantity'     => 1,
+					),
+				)
+			);
+			$this->assertEquals( 201, $response->get_status() );
+			$this->assertEqualsWithDelta( 333.0, (float) $response->get_data()['amount'], 0.001, '667 + 333 consumes the 1000 line exactly' );
+			$this->created_refunds[] = $response->get_data()['id'];
+
+			list( $order_b, $item_b ) = $this->create_order_with_exact_line( 3, 1000.00, 1000.00, 1000.00 );
+
+			$unit_refund = array(
+				array(
+					'line_item_id' => $item_b->get_id(),
+					'quantity'     => 1,
+				),
+			);
+			for ( $i = 0; $i < 3; $i++ ) {
+				$response = $this->dispatch_refund_request( $order_b->get_id(), $unit_refund );
+				$this->assertEquals( 201, $response->get_status() );
+				$this->assertEqualsWithDelta( 333.0, (float) $response->get_data()['amount'], 0.001, 'Each single-unit refund rounds 1000/3 down to 333' );
+				$this->created_refunds[] = $response->get_data()['id'];
+			}
+
+			$order_b = wc_get_order( $order_b->get_id() );
+			$this->assertEqualsWithDelta( 1.0, (float) $order_b->get_remaining_refund_amount(), 0.001, 'Three 333 refunds strand 1 currency unit of the 1000 line' );
+
+			$request = new WP_REST_Request( 'POST', '/wc/v4/refunds' );
+			$request->set_body_params(
+				array(
+					'order_id' => $order_b->get_id(),
+					'amount'   => 1,
+				)
+			);
+			$response = $this->server->dispatch( $request );
+			$this->assertEquals( 201, $response->get_status(), 'The stranded unit stays refundable via an order-level amount' );
+			$this->created_refunds[] = $response->get_data()['id'];
+		} finally {
+			update_option( 'woocommerce_price_num_decimals', $original_decimals );
+		}
+	}
+
+	/**
+	 * @testdox Multi-quantity auto-compute with a fractional tax rate reassembles net + tax and consumes the line exactly.
+	 */
+	public function test_refunds_create_auto_compute_multi_qty_fractional_tax_rate(): void {
+		$tax_rate_id = WC_Tax::_insert_tax_rate(
+			array(
+				'tax_rate_country'  => 'US',
+				'tax_rate_state'    => '',
+				'tax_rate'          => '8.8750',
+				'tax_rate_name'     => 'NYC',
+				'tax_rate_priority' => '1',
+				'tax_rate_compound' => '0',
+				'tax_rate_shipping' => '1',
+				'tax_rate_order'    => '1',
+				'tax_rate_class'    => '',
+			)
+		);
+
+		$original_calc_taxes         = get_option( 'woocommerce_calc_taxes', 'no' );
+		$original_prices_include_tax = get_option( 'woocommerce_prices_include_tax', 'no' );
+		update_option( 'woocommerce_calc_taxes', 'yes' );
+		update_option( 'woocommerce_prices_include_tax', 'no' );
+
+		try {
+			// 3 × 9.99 = 29.97, tax at 8.875% = 2.66, grand total 32.63.
+			list( $one_shot_order, $one_shot_item ) = $this->create_order_with_exact_line( 3, 29.97, 29.97, 32.63, array( $tax_rate_id => 2.66 ) );
+
+			$response = $this->dispatch_refund_request(
+				$one_shot_order->get_id(),
+				array(
+					array(
+						'line_item_id' => $one_shot_item->get_id(),
+						'quantity'     => 3,
+					),
+				)
+			);
+			$this->assertEquals( 201, $response->get_status() );
+			$this->assertEqualsWithDelta( 32.63, (float) $response->get_data()['amount'], 0.001, 'Full-quantity refund must equal line total + line tax exactly' );
+			$this->created_refunds[] = $response->get_data()['id'];
+
+			list( $order, $item ) = $this->create_order_with_exact_line( 3, 29.97, 29.97, 32.63, array( $tax_rate_id => 2.66 ) );
+
+			$response = $this->dispatch_refund_request(
+				$order->get_id(),
+				array(
+					array(
+						'line_item_id' => $item->get_id(),
+						'quantity'     => 2,
+					),
+				)
+			);
+			$this->assertEquals( 201, $response->get_status() );
+			$data = $response->get_data();
+			$this->assertEqualsWithDelta( 21.75, (float) $data['amount'], 0.001, 'Qty-2 refund of the 32.63 line rounds 21.7533 to 21.75' );
+			$this->created_refunds[] = $data['id'];
+
+			$line    = $data['line_items'][0];
+			$tax_sum = 0.0;
+			foreach ( $line['refund_tax'] as $tax ) {
+				$tax_sum += (float) $tax['refund_total'];
+			}
+			$this->assertEqualsWithDelta( 21.75, (float) $line['refund_total'] + $tax_sum, 0.001, 'Extracted net + tax must reassemble the tax-inclusive amount' );
+
+			$response = $this->dispatch_refund_request(
+				$order->get_id(),
+				array(
+					array(
+						'line_item_id' => $item->get_id(),
+						'quantity'     => 1,
+					),
+				)
+			);
+			$this->assertEquals( 201, $response->get_status() );
+			$this->assertEqualsWithDelta( 10.88, (float) $response->get_data()['amount'], 0.001, '21.75 + 10.88 consumes the 32.63 line exactly' );
+			$this->created_refunds[] = $response->get_data()['id'];
+
+			$order = wc_get_order( $order->get_id() );
+			$this->assertEqualsWithDelta( 0.0, (float) $order->get_remaining_refund_amount(), 0.001, 'Qty-2 then qty-1 must leave nothing unrefunded' );
+		} finally {
+			update_option( 'woocommerce_calc_taxes', $original_calc_taxes );
+			update_option( 'woocommerce_prices_include_tax', $original_prices_include_tax );
+		}
+	}
+
+	/**
+	 * @testdox Multi-quantity auto-compute on a tax-inclusive store returns quantity × displayed price.
+	 */
+	public function test_refunds_create_auto_compute_multi_qty_prices_include_tax(): void {
+		$tax_rate_id = WC_Tax::_insert_tax_rate(
+			array(
+				'tax_rate_country'  => 'US',
+				'tax_rate_state'    => '',
+				'tax_rate'          => '23.0000',
+				'tax_rate_name'     => 'VAT',
+				'tax_rate_priority' => '1',
+				'tax_rate_compound' => '0',
+				'tax_rate_shipping' => '1',
+				'tax_rate_order'    => '1',
+				'tax_rate_class'    => '',
+			)
+		);
+
+		$original_calc_taxes         = get_option( 'woocommerce_calc_taxes', 'no' );
+		$original_prices_include_tax = get_option( 'woocommerce_prices_include_tax', 'no' );
+		update_option( 'woocommerce_calc_taxes', 'yes' );
+		update_option( 'woocommerce_prices_include_tax', 'yes' );
+
+		try {
+			// 5 × 9.99 displayed (tax-inclusive) = 49.95; stored net 40.61 + 9.34 tax.
+			list( $order, $item ) = $this->create_order_with_exact_line( 5, 40.61, 40.61, 49.95, array( $tax_rate_id => 9.34 ) );
+
+			$response = $this->dispatch_refund_request(
+				$order->get_id(),
+				array(
+					array(
+						'line_item_id' => $item->get_id(),
+						'quantity'     => 5,
+					),
+				)
+			);
+			$this->assertEquals( 201, $response->get_status() );
+			$this->assertEqualsWithDelta( 49.95, (float) $response->get_data()['amount'], 0.001, 'Full-quantity refund must equal 5 × the displayed 9.99 price' );
+			$this->created_refunds[] = $response->get_data()['id'];
+
+			list( $order_b, $item_b ) = $this->create_order_with_exact_line( 5, 40.61, 40.61, 49.95, array( $tax_rate_id => 9.34 ) );
+
+			$response = $this->dispatch_refund_request(
+				$order_b->get_id(),
+				array(
+					array(
+						'line_item_id' => $item_b->get_id(),
+						'quantity'     => 2,
+					),
+				)
+			);
+			$this->assertEquals( 201, $response->get_status() );
+			$this->assertEqualsWithDelta( 19.98, (float) $response->get_data()['amount'], 0.001, 'Qty-2 refund must equal 2 × the displayed 9.99 price' );
+			$this->created_refunds[] = $response->get_data()['id'];
+		} finally {
+			update_option( 'woocommerce_calc_taxes', $original_calc_taxes );
+			update_option( 'woocommerce_prices_include_tax', $original_prices_include_tax );
+		}
+	}
+
+	/**
+	 * @testdox Multi-quantity auto-compute with compound taxes matches the preview total and reassembles per-rate taxes.
+	 */
+	public function test_refunds_create_auto_compute_multi_qty_compound_taxes(): void {
+		$gst_rate_id = WC_Tax::_insert_tax_rate(
+			array(
+				'tax_rate_country'  => 'US',
+				'tax_rate_state'    => '',
+				'tax_rate'          => '5.0000',
+				'tax_rate_name'     => 'GST',
+				'tax_rate_priority' => '1',
+				'tax_rate_compound' => '0',
+				'tax_rate_shipping' => '1',
+				'tax_rate_order'    => '1',
+				'tax_rate_class'    => '',
+			)
+		);
+		$pst_rate_id = WC_Tax::_insert_tax_rate(
+			array(
+				'tax_rate_country'  => 'US',
+				'tax_rate_state'    => '',
+				'tax_rate'          => '7.0000',
+				'tax_rate_name'     => 'PST',
+				'tax_rate_priority' => '2',
+				'tax_rate_compound' => '1',
+				'tax_rate_shipping' => '1',
+				'tax_rate_order'    => '2',
+				'tax_rate_class'    => '',
+			)
+		);
+
+		$original_calc_taxes         = get_option( 'woocommerce_calc_taxes', 'no' );
+		$original_prices_include_tax = get_option( 'woocommerce_prices_include_tax', 'no' );
+		update_option( 'woocommerce_calc_taxes', 'yes' );
+		update_option( 'woocommerce_prices_include_tax', 'no' );
+
+		try {
+			// 3 × 50 = 150; GST 5% = 7.50; compound PST 7% of 157.50 = 11.03; grand total 168.53.
+			list( $order, $item ) = $this->create_order_with_exact_line(
+				3,
+				150.00,
+				150.00,
+				168.53,
+				array(
+					$gst_rate_id => 7.50,
+					$pst_rate_id => 11.03,
+				)
+			);
+
+			$line_items = array(
+				array(
+					'line_item_id' => $item->get_id(),
+					'quantity'     => 2,
+				),
+			);
+
+			$data_utils = wc_get_container()->get( DataUtils::class );
+			$preview    = $data_utils->build_refund_preview( $order, $line_items );
+
+			$response = $this->dispatch_refund_request( $order->get_id(), $line_items );
+			$this->assertEquals( 201, $response->get_status() );
+			$data = $response->get_data();
+			$this->assertEqualsWithDelta( 112.35, (float) $data['amount'], 0.001, 'Qty-2 refund of the 168.53 line rounds 112.3533 to 112.35' );
+			$this->assertEquals( $preview['total'], $data['amount'], 'Create amount must match build_refund_preview total exactly' );
+			$this->created_refunds[] = $data['id'];
+
+			$line    = $data['line_items'][0];
+			$tax_sum = 0.0;
+			foreach ( $line['refund_tax'] as $tax ) {
+				$tax_sum += (float) $tax['refund_total'];
+			}
+			$this->assertEqualsWithDelta( 112.35, (float) $line['refund_total'] + $tax_sum, 0.001, 'Net + per-rate taxes must reassemble the tax-inclusive amount' );
+		} finally {
+			update_option( 'woocommerce_calc_taxes', $original_calc_taxes );
+			update_option( 'woocommerce_prices_include_tax', $original_prices_include_tax );
+		}
+	}
+
+	/**
+	 * @testdox Auto-compute uses the discounted line total, not the pre-discount subtotal.
+	 */
+	public function test_refunds_create_auto_compute_uses_discounted_total(): void {
+		// 3 × 10.00 with a 10% discount applied: subtotal 30.00, total 27.00.
+		list( $order, $item ) = $this->create_order_with_exact_line( 3, 30.00, 27.00, 27.00 );
+
+		$response = $this->dispatch_refund_request(
+			$order->get_id(),
+			array(
+				array(
+					'line_item_id' => $item->get_id(),
+					'quantity'     => 2,
+				),
+			)
+		);
+		$this->assertEquals( 201, $response->get_status() );
+		$this->assertEqualsWithDelta( 18.00, (float) $response->get_data()['amount'], 0.001, 'Qty-2 refund must use the discounted 9.00 unit price, not the 10.00 subtotal price' );
+		$this->created_refunds[] = $response->get_data()['id'];
+	}
+
+	/**
+	 * @testdox Fee and shipping lines reject quantity above 1 and auto-compute their full total at quantity 1.
+	 */
+	public function test_refunds_create_fee_and_shipping_quantity_is_informational(): void {
+		$product = WC_Helper_Product::create_simple_product();
+		$product->set_price( 10.00 );
+		$product->save();
+
+		$order = wc_create_order();
+		$item  = new WC_Order_Item_Product();
+		$item->set_props(
+			array(
+				'product'  => $product,
+				'quantity' => 1,
+				'subtotal' => 10.00,
+				'total'    => 10.00,
+			)
+		);
+		$item->save();
+		$order->add_item( $item );
+
+		$fee = new \WC_Order_Item_Fee();
+		$fee->set_props(
+			array(
+				'name'  => 'Service fee',
+				'total' => 7.50,
+			)
+		);
+		$fee->save();
+		$order->add_item( $fee );
+
+		$shipping = new \WC_Order_Item_Shipping();
+		$shipping->set_props(
+			array(
+				'method_title' => 'Flat rate',
+				'method_id'    => 'flat_rate',
+				'total'        => 5.00,
+			)
+		);
+		$shipping->save();
+		$order->add_item( $shipping );
+
+		$order->set_total( 22.50 );
+		$order->set_status( OrderStatus::COMPLETED );
+		$order->save();
+		$this->created_orders[] = $order->get_id();
+
+		foreach ( array( $fee, $shipping ) as $non_product_item ) {
+			$response = $this->dispatch_refund_request(
+				$order->get_id(),
+				array(
+					array(
+						'line_item_id' => $non_product_item->get_id(),
+						'quantity'     => 3,
+					),
+				)
+			);
+			$this->assertEquals( 400, $response->get_status(), 'Fee/shipping items have quantity 1; requesting 3 must be rejected' );
+			$this->assertEquals( 'invalid_line_item', $response->get_data()['code'] );
+		}
+
+		$response = $this->dispatch_refund_request(
+			$order->get_id(),
+			array(
+				array(
+					'line_item_id' => $fee->get_id(),
+					'quantity'     => 1,
+				),
+				array(
+					'line_item_id' => $shipping->get_id(),
+					'quantity'     => 1,
+				),
+			)
+		);
+		$this->assertEquals( 201, $response->get_status() );
+		$this->assertEqualsWithDelta( 12.50, (float) $response->get_data()['amount'], 0.001, 'Quantity 1 refunds each non-product line at its full total, exactly once' );
+		$this->created_refunds[] = $response->get_data()['id'];
+
+		$product->delete( true );
+	}
+
 	/**
 	 * @testdox Creating a V4 refund with incomplete meta_data entries does not cause errors.
 	 */
diff --git a/plugins/woocommerce/tests/php/src/Internal/RestApi/Routes/V4/Refunds/DataUtilsTest.php b/plugins/woocommerce/tests/php/src/Internal/RestApi/Routes/V4/Refunds/DataUtilsTest.php
index e2079ba37e9..2b9330eb9aa 100644
--- a/plugins/woocommerce/tests/php/src/Internal/RestApi/Routes/V4/Refunds/DataUtilsTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/RestApi/Routes/V4/Refunds/DataUtilsTest.php
@@ -335,6 +335,68 @@ class DataUtilsTest extends WC_Unit_Test_Case {
 		$this->assertNull( $this->data_utils->calculate_refund_amount( array() ) );
 	}

+	/**
+	 * @testdox calculate_refund_amount treats explicit refund_total: 0 as a valid zero contribution, not as missing.
+	 *
+	 * Regression guard: a previous implementation used `!empty($line_item['refund_total'])`
+	 * which is `true` for `0` / `0.0` / `"0"`. A mixed request like
+	 * `[{refund_total: 50}, {refund_total: 0}]` therefore summed to 50 with the
+	 * second line silently absent. The current implementation uses `isset() && is_numeric()`,
+	 * which preserves the explicit-zero contract documented in the schema.
+	 */
+	public function test_calculate_refund_amount_includes_explicit_zero(): void {
+		$line_items = array(
+			array(
+				'line_item_id' => 1,
+				'quantity'     => 1,
+				'refund_total' => 50.00,
+			),
+			array(
+				'line_item_id' => 2,
+				'quantity'     => 1,
+				'refund_total' => 0,
+			),
+		);
+
+		$result = $this->data_utils->calculate_refund_amount( $line_items );
+
+		$this->assertSame( 50.0, $result, 'Explicit-zero line contributes 0; total stays 50.' );
+	}
+
+	/**
+	 * @testdox convert_line_items_to_internal_format accepts the legacy v3-style shape (refund_total without quantity) and records qty=0.
+	 */
+	public function test_convert_line_items_legacy_no_quantity_defaults_qty_zero(): void {
+		$order = wc_create_order();
+		$item  = new WC_Order_Item_Product();
+		$item->set_props(
+			array(
+				'quantity' => 2,
+				'subtotal' => 100.00,
+				'total'    => 100.00,
+			)
+		);
+		$item->save();
+		$order->add_item( $item );
+		$order->save();
+
+		$result = $this->data_utils->convert_line_items_to_internal_format(
+			array(
+				array(
+					'line_item_id' => $item->get_id(),
+					'refund_total' => 30.00,
+				),
+			),
+			$order
+		);
+
+		$this->assertArrayHasKey( $item->get_id(), $result, 'Line item must be attached, not silently dropped.' );
+		$this->assertSame( 0, $result[ $item->get_id() ]['qty'] );
+		$this->assertSame( 30.00, $result[ $item->get_id() ]['refund_total'] );
+
+		$order->delete( true );
+	}
+
 	/**
 	 * @testdox Should compute line item refund total for a product based on unit price and quantity.
 	 */
@@ -482,6 +544,27 @@ class DataUtilsTest extends WC_Unit_Test_Case {
 		$this->assertSame( 11.50, $this->data_utils->compute_line_item_refund_total( $shipping, 1 ) );
 	}

+	/**
+	 * @testdox compute_line_item_refund_total returns the same total regardless of the quantity argument for shipping items.
+	 *
+	 * Behavior lock. Shipping lines refund as a whole; the quantity argument
+	 * must not multiply the result. A future refactor that wrongly applied
+	 * unit_price * quantity to shipping would fail this assertion.
+	 */
+	public function test_compute_line_item_refund_total_shipping_ignores_quantity(): void {
+		$shipping = new WC_Order_Item_Shipping();
+		$shipping->set_props(
+			array(
+				'method_title' => 'Flat Rate',
+				'total'        => 10.00,
+			)
+		);
+		$shipping->set_taxes( array( 'total' => array( 1 => 1.50 ) ) );
+		$shipping->save();
+
+		$this->assertSame( 11.50, $this->data_utils->compute_line_item_refund_total( $shipping, 5 ) );
+	}
+
 	/**
 	 * @testdox Should return full item total + tax for fee items.
 	 */
@@ -499,6 +582,26 @@ class DataUtilsTest extends WC_Unit_Test_Case {
 		$this->assertSame( 23.00, $this->data_utils->compute_line_item_refund_total( $fee, 1 ) );
 	}

+	/**
+	 * @testdox compute_line_item_refund_total returns the same total regardless of the quantity argument for fee items.
+	 *
+	 * Behavior lock matching the shipping case. Fees refund as a whole; quantity
+	 * must not multiply the result.
+	 */
+	public function test_compute_line_item_refund_total_fee_ignores_quantity(): void {
+		$fee = new WC_Order_Item_Fee();
+		$fee->set_props(
+			array(
+				'name'  => 'Handling',
+				'total' => 20.00,
+			)
+		);
+		$fee->set_taxes( array( 'total' => array( 1 => 3.00 ) ) );
+		$fee->save();
+
+		$this->assertSame( 23.00, $this->data_utils->compute_line_item_refund_total( $fee, 5 ) );
+	}
+
 	/**
 	 * @testdox Should preserve negative sign for negative-total fee items (discount fees).
 	 */
@@ -1279,6 +1382,496 @@ class DataUtilsTest extends WC_Unit_Test_Case {
 		$order->delete( true );
 	}

+	/**
+	 * @testdox validate_line_items rejects missing or non-positive quantity with a clear invalid_line_item error.
+	 *
+	 * @dataProvider provider_invalid_quantities_for_validate_line_items
+	 *
+	 * @param mixed $quantity The quantity value to test (or null to omit the key).
+	 */
+	public function test_validate_line_items_rejects_missing_quantity( $quantity ): void {
+		$product = WC_Helper_Product::create_simple_product();
+		$product->save();
+
+		$order = wc_create_order();
+		$item  = new WC_Order_Item_Product();
+		$item->set_props(
+			array(
+				'product'  => $product,
+				'quantity' => 2,
+				'subtotal' => 20.00,
+				'total'    => 20.00,
+			)
+		);
+		$item->save();
+		$order->add_item( $item );
+		$order->set_status( OrderStatus::COMPLETED );
+		$order->save();
+
+		$line_item = array( 'line_item_id' => $item->get_id() );
+		if ( null !== $quantity ) {
+			$line_item['quantity'] = $quantity;
+		}
+
+		$result = $this->data_utils->validate_line_items( array( $line_item ), $order );
+
+		$this->assertInstanceOf( \WP_Error::class, $result );
+		$this->assertEquals( 'invalid_line_item', $result->get_error_code() );
+		$this->assertStringContainsString( 'positive integer', $result->get_error_message() );
+
+		$product->delete( true );
+		$order->delete( true );
+	}
+
+	/**
+	 * @return array<string, array<int, mixed>>
+	 */
+	public function provider_invalid_quantities_for_validate_line_items(): array {
+		return array(
+			'missing'  => array( null ),
+			'zero'     => array( 0 ),
+			'negative' => array( -1 ),
+			'string'   => array( '2' ),
+			'float'    => array( 1.5 ),
+		);
+	}
+
+	/**
+	 * @testdox validate_line_items accepts missing/zero quantity when refund_total is provided explicitly (legacy v3-style path).
+	 *
+	 * @dataProvider provider_loose_quantities_with_explicit_refund_total
+	 *
+	 * @param mixed $quantity The quantity value to test (or null to omit the key).
+	 */
+	public function test_validate_line_items_accepts_loose_quantity_with_explicit_refund_total( $quantity ): void {
+		$product = WC_Helper_Product::create_simple_product();
+		$product->save();
+
+		$order = wc_create_order();
+		$item  = new WC_Order_Item_Product();
+		$item->set_props(
+			array(
+				'product'  => $product,
+				'quantity' => 2,
+				'subtotal' => 20.00,
+				'total'    => 20.00,
+			)
+		);
+		$item->save();
+		$order->add_item( $item );
+		$order->set_status( OrderStatus::COMPLETED );
+		$order->save();
+
+		$line_item = array(
+			'line_item_id' => $item->get_id(),
+			'refund_total' => 10.00,
+		);
+		if ( null !== $quantity ) {
+			$line_item['quantity'] = $quantity;
+		}
+
+		$result = $this->data_utils->validate_line_items( array( $line_item ), $order );
+
+		$this->assertTrue( $result, 'Legacy explicit-refund_total path should accept missing/zero quantity.' );
+
+		$product->delete( true );
+		$order->delete( true );
+	}
+
+	/**
+	 * @return array<string, array<int, mixed>>
+	 */
+	public function provider_loose_quantities_with_explicit_refund_total(): array {
+		return array(
+			'missing' => array( null ),
+			'zero'    => array( 0 ),
+		);
+	}
+
+	/**
+	 * @testdox fill_missing_refund_totals computes refund_total for a product line item when missing.
+	 */
+	public function test_fill_missing_refund_totals_product(): void {
+		$product = WC_Helper_Product::create_simple_product();
+		$product->set_regular_price( 25.00 );
+		$product->save();
+
+		$order = wc_create_order();
+		$item  = new WC_Order_Item_Product();
+		$item->set_props(
+			array(
+				'product'  => $product,
+				'quantity' => 4,
+				'subtotal' => 100.00,
+				'total'    => 100.00,
+			)
+		);
+		$item->save();
+		$order->add_item( $item );
+		$order->save();
+
+		$result = $this->data_utils->fill_missing_refund_totals(
+			array(
+				array(
+					'line_item_id' => $item->get_id(),
+					'quantity'     => 2,
+				),
+			),
+			$order
+		);
+
+		$this->assertArrayHasKey( 'refund_total', $result[0] );
+		$this->assertSame( 50.00, $result[0]['refund_total'], '2 × $25 unit price = $50' );
+
+		$product->delete( true );
+		$order->delete( true );
+	}
+
+	/**
+	 * @testdox fill_missing_refund_totals treats refund_total: null the same as a missing key (computes it).
+	 */
+	public function test_fill_missing_refund_totals_treats_null_as_missing(): void {
+		$product = WC_Helper_Product::create_simple_product();
+		$product->set_regular_price( 15.00 );
+		$product->save();
+
+		$order = wc_create_order();
+		$item  = new WC_Order_Item_Product();
+		$item->set_props(
+			array(
+				'product'  => $product,
+				'quantity' => 2,
+				'subtotal' => 30.00,
+				'total'    => 30.00,
+			)
+		);
+		$item->save();
+		$order->add_item( $item );
+		$order->save();
+
+		$result = $this->data_utils->fill_missing_refund_totals(
+			array(
+				array(
+					'line_item_id' => $item->get_id(),
+					'quantity'     => 1,
+					'refund_total' => null,
+				),
+			),
+			$order
+		);
+
+		$this->assertArrayHasKey( 'refund_total', $result[0] );
+		$this->assertSame( 15.00, $result[0]['refund_total'], 'null should be treated the same as omitted — auto-computed' );
+
+		$product->delete( true );
+		$order->delete( true );
+	}
+
+	/**
+	 * @testdox fill_missing_refund_totals leaves explicit refund_total: 0 untouched.
+	 */
+	public function test_fill_missing_refund_totals_leaves_explicit_zero_untouched(): void {
+		$product = WC_Helper_Product::create_simple_product();
+		$product->set_regular_price( 10.00 );
+		$product->save();
+
+		$order = wc_create_order();
+		$item  = new WC_Order_Item_Product();
+		$item->set_props(
+			array(
+				'product'  => $product,
+				'quantity' => 1,
+				'subtotal' => 10.00,
+				'total'    => 10.00,
+			)
+		);
+		$item->save();
+		$order->add_item( $item );
+		$order->save();
+
+		$result = $this->data_utils->fill_missing_refund_totals(
+			array(
+				array(
+					'line_item_id' => $item->get_id(),
+					'quantity'     => 1,
+					'refund_total' => 0,
+				),
+			),
+			$order
+		);
+
+		$this->assertSame( 0, $result[0]['refund_total'], 'Explicit zero must not be replaced by the auto-computed value' );
+
+		$product->delete( true );
+		$order->delete( true );
+	}
+
+	/**
+	 * @testdox fill_missing_refund_totals leaves explicit refund_total untouched.
+	 */
+	public function test_fill_missing_refund_totals_preserves_explicit(): void {
+		$product = WC_Helper_Product::create_simple_product();
+		$product->set_regular_price( 10.00 );
+		$product->save();
+
+		$order = wc_create_order();
+		$item  = new WC_Order_Item_Product();
+		$item->set_props(
+			array(
+				'product'  => $product,
+				'quantity' => 1,
+				'subtotal' => 10.00,
+				'total'    => 10.00,
+			)
+		);
+		$item->save();
+		$order->add_item( $item );
+		$order->save();
+
+		$result = $this->data_utils->fill_missing_refund_totals(
+			array(
+				array(
+					'line_item_id' => $item->get_id(),
+					'quantity'     => 1,
+					'refund_total' => 7.50,
+				),
+			),
+			$order
+		);
+
+		$this->assertSame( 7.50, $result[0]['refund_total'], 'Explicit refund_total must not be overwritten' );
+
+		$product->delete( true );
+		$order->delete( true );
+	}
+
+	/**
+	 * @testdox fill_missing_refund_totals leaves the item alone when line_item_id does not resolve.
+	 */
+	public function test_fill_missing_refund_totals_skips_unknown_item(): void {
+		$order = wc_create_order();
+		$order->save();
+
+		$result = $this->data_utils->fill_missing_refund_totals(
+			array(
+				array(
+					'line_item_id' => 999999,
+					'quantity'     => 1,
+				),
+			),
+			$order
+		);
+
+		$this->assertArrayNotHasKey( 'refund_total', $result[0] );
+
+		$order->delete( true );
+	}
+
+	/**
+	 * @testdox fill_missing_refund_totals skips items with bad or missing quantity.
+	 *
+	 * @dataProvider provider_bad_quantities_for_fill
+	 *
+	 * @param mixed $quantity The quantity value to test.
+	 */
+	public function test_fill_missing_refund_totals_skips_bad_quantity( $quantity ): void {
+		$product = WC_Helper_Product::create_simple_product();
+		$product->save();
+		$order = wc_create_order();
+		$item  = new WC_Order_Item_Product();
+		$item->set_props(
+			array(
+				'product'  => $product,
+				'quantity' => 2,
+				'subtotal' => 20.00,
+				'total'    => 20.00,
+			)
+		);
+		$item->save();
+		$order->add_item( $item );
+		$order->save();
+
+		$line_item = array( 'line_item_id' => $item->get_id() );
+		if ( null !== $quantity ) {
+			$line_item['quantity'] = $quantity;
+		}
+
+		$result = $this->data_utils->fill_missing_refund_totals( array( $line_item ), $order );
+
+		$this->assertArrayNotHasKey( 'refund_total', $result[0] );
+
+		$product->delete( true );
+		$order->delete( true );
+	}
+
+	/**
+	 * @return array<string, array<int, mixed>>
+	 */
+	public function provider_bad_quantities_for_fill(): array {
+		return array(
+			'missing'  => array( null ),
+			'zero'     => array( 0 ),
+			'negative' => array( -1 ),
+			'string'   => array( 'abc' ),
+			'float'    => array( 1.5 ),
+		);
+	}
+
+	/**
+	 * @testdox fill_missing_refund_totals leaves refund_total unset for product items whose source has zero quantity.
+	 */
+	public function test_fill_missing_refund_totals_skips_zero_source_quantity_product(): void {
+		$order = wc_create_order();
+		$item  = new WC_Order_Item_Product();
+		$item->set_props(
+			array(
+				'quantity' => 0,
+				'subtotal' => 0,
+				'total'    => 0,
+			)
+		);
+		$item->save();
+		$order->add_item( $item );
+		$order->save();
+
+		$result = $this->data_utils->fill_missing_refund_totals(
+			array(
+				array(
+					'line_item_id' => $item->get_id(),
+					'quantity'     => 1,
+				),
+			),
+			$order
+		);
+
+		$this->assertArrayNotHasKey( 'refund_total', $result[0], 'Helper must leave refund_total unset so validate_line_items can surface a specific error.' );
+
+		$order->delete( true );
+	}
+
+	/**
+	 * @testdox validate_line_items returns a specific error when refund_total is omitted and source product has zero quantity.
+	 */
+	public function test_validate_line_items_zero_source_quantity_with_missing_refund_total(): void {
+		$order = wc_create_order();
+		$item  = new WC_Order_Item_Product();
+		$item->set_props(
+			array(
+				'quantity' => 0,
+				'subtotal' => 0,
+				'total'    => 0,
+			)
+		);
+		$item->save();
+		$order->add_item( $item );
+		$order->save();
+
+		$result = $this->data_utils->validate_line_items(
+			array(
+				array(
+					'line_item_id' => $item->get_id(),
+					'quantity'     => 1,
+				),
+			),
+			$order
+		);
+
+		$this->assertInstanceOf( \WP_Error::class, $result );
+		$this->assertEquals( 'invalid_line_item', $result->get_error_code() );
+		$this->assertStringContainsString( 'source quantity is zero', $result->get_error_message() );
+
+		$order->delete( true );
+	}
+
+	/**
+	 * @testdox fill_missing_refund_totals returns full item total for shipping items, ignoring quantity.
+	 */
+	public function test_fill_missing_refund_totals_shipping(): void {
+		$order    = wc_create_order();
+		$shipping = new WC_Order_Item_Shipping();
+		$shipping->set_props(
+			array(
+				'method_title' => 'Flat Rate',
+				'total'        => 12.50,
+			)
+		);
+		$shipping->save();
+		$order->add_item( $shipping );
+		$order->save();
+
+		$result = $this->data_utils->fill_missing_refund_totals(
+			array(
+				array(
+					'line_item_id' => $shipping->get_id(),
+					'quantity'     => 1,
+				),
+			),
+			$order
+		);
+
+		$this->assertSame( 12.50, $result[0]['refund_total'] );
+
+		$order->delete( true );
+	}
+
+	/**
+	 * @testdox fill_missing_refund_totals processes a mixed array (some items with, some without refund_total).
+	 */
+	public function test_fill_missing_refund_totals_mixed(): void {
+		$product = WC_Helper_Product::create_simple_product();
+		$product->set_regular_price( 10.00 );
+		$product->save();
+
+		$order  = wc_create_order();
+		$item_a = new WC_Order_Item_Product();
+		$item_a->set_props(
+			array(
+				'product'  => $product,
+				'quantity' => 1,
+				'subtotal' => 10.00,
+				'total'    => 10.00,
+			)
+		);
+		$item_a->save();
+		$order->add_item( $item_a );
+
+		$item_b = new WC_Order_Item_Product();
+		$item_b->set_props(
+			array(
+				'product'  => $product,
+				'quantity' => 1,
+				'subtotal' => 10.00,
+				'total'    => 10.00,
+			)
+		);
+		$item_b->save();
+		$order->add_item( $item_b );
+		$order->save();
+
+		$result = $this->data_utils->fill_missing_refund_totals(
+			array(
+				array(
+					'line_item_id' => $item_a->get_id(),
+					'quantity'     => 1,
+				),
+				// Item A above has no refund_total, expected to be filled with 10.00.
+				array(
+					'line_item_id' => $item_b->get_id(),
+					'quantity'     => 1,
+					'refund_total' => 7.0,
+				),
+				// Item B has explicit refund_total 7.0, expected to be preserved.
+			),
+			$order
+		);
+
+		$this->assertSame( 10.00, $result[0]['refund_total'] );
+		$this->assertSame( 7.0, $result[1]['refund_total'] );
+
+		$product->delete( true );
+		$order->delete( true );
+	}
+
 	/**
 	 * @testdox Should build refund preview with correct tax extraction.
 	 */