Commit 88b632bb4a7 for woocommerce

commit 88b632bb4a726d54ef5475c3a0dc3f884123b67e
Author: Samuel Urbanowicz <samiuelson@gmail.com>
Date:   Tue Aug 11 11:22:44 2026 +0200

    [1/2] Add a refund preview endpoint to the wc/v3 REST API (#67042)

    * Add refund preview endpoint to the v3 order refunds controller

    * Add changelog entry for the v3 refund preview endpoint

    * Remove baseline entry resolved by the inherited register_routes docblock

    * Move the preview filter since annotation below its param tags

    * Prefix the preview endpoint error codes with woocommerce_rest_

    The shared validation engine emits unprefixed codes, which is the wc/v4
    convention. Review feedback on the preview endpoint pointed out that a
    wc/v3 route mixing bare and prefixed codes follows neither convention, so
    the codes are now renamed at the v3 boundary: controller-level codes are
    prefixed at the source, and errors returned by the shared engine pass
    through a helper that prepends the prefix and keeps message, data, and
    HTTP status. wc/v4 keeps its own convention and shipped codes unchanged.

    Nothing has shipped with the bare codes on v3, so the rename carries no
    compatibility cost, and doing it in the PR that introduces the endpoint
    means the contract is right from its first release.

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

    * [2/2] Add an opt-in compute_totals mode to v3 refund creation (#67043)

    * Add opt-in compute_totals mode to the v3 refund creation endpoint

    * Add changelog entry for the v3 compute_totals refund mode

    * Validate compute_totals line item value types before computation

    * Cover malformed compute_totals payloads and mask PHP 7.4 notices in BC tests

    * Reject fractional line item and tax ids in compute_totals requests

    * Reject duplicate tax IDs in refund creation line items

    * Declare refund_total as nullable in the v3 refund schema

    * Prefix the compute_totals error codes with woocommerce_rest_

    Follows the preview endpoint's rename: controller-level codes are
    prefixed at the source and shared-engine validation errors pass through
    the prefixing boundary, so every error the wc/v3 refund endpoints emit
    carries the v3 convention's prefix. The legacy create path is untouched
    and keeps its pre-existing codes.

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

    * Apply review follow-ups to the wc/v3 refund preview endpoint (#67399)

    * Apply review follow-ups to the refund preview endpoint

    Non-contract improvements from the review of the preview endpoint PR:

    - Drop the @since tags from the register_routes override and a private
      helper; the route's own @since documents when it appeared, and private
      methods do not carry them.
    - Run add_additional_fields_schema() in the public preview schema getter
      like the sibling v3 getters, so register_rest_field() additions appear
      in OPTIONS.
    - Document the two deliberate differences from the create endpoint in the
      arg schema docblock: line_item_id vs id keying, and the tax-inclusive
      refund_total vs the classic create's net refund_total.

    New tests for the review's coverage gaps: a negative refund_total on a
    discount line netting against a product line, rejection of unknown line
    item keys via additionalProperties, the wrong-HTTP-method behavior (WP
    answers GET with rest_no_route, the same code clients treat as "endpoint
    missing"), and the max_refundable value on a success response after a
    prior partial refund.

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

    * Address second-pass review on the preview follow-ups

    - Populate fields registered for order_refund_preview into the POST
      response. The schema advertised them but nothing ran their
      get_callback; the stock add_additional_fields_to_object() resolves the
      controller's own object type (order_refund), so a small helper fetches
      the preview type's fields explicitly. Covered by a response-level test
      that also asserts the schema advertisement.
    - Restore @since 11.1.0 on register_routes(): the override is new in
      11.1.0 even though the parent method is not, and the convention
      requires the tag on public methods. The private helper's drop stands.
    - Remove bin/build-zip-dev.sh, which entered the branch by mistake and
      duplicates build-zip.sh's PLUGIN_SLUG support.
    - Trim the trailing blank line the changelog entry carried at EOF.

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

    * Type the preview field helpers for PHPStan and honor _fields

    The additional-fields helper lacked the WP_REST_Request generic the
    project's PHPStan level requires, and it executed every registered
    callback regardless of _fields, so extension callbacks ran even for
    responses that would not carry their field. The helper now skips
    callbacks for excluded fields, mirroring core's behavior against the
    preview schema, with a test asserting the callback does not execute
    when _fields excludes it.

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

    * Include schema-less registered fields in the preview response

    register_rest_field() permits a callback without a schema, and core
    deliberately includes such fields for backward compatibility. The
    preview's field resolver derived its allowlist from the published schema
    only, so a schema-less field's callback never ran, even without _fields.
    The resolver now adds registered fields whose schema is null, mirroring
    core's get_fields_for_response(), with a test using a schema-less
    callback.

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

    * Share the refund preview pipeline between wc/v3 and wc/v4 (#67414)

    * Share the refund preview pipeline between wc/v3 and wc/v4

    The v3 preview handler and its line_items arg schema were copies of the
    v4 controller's, so a fix to the guard chain or a schema change had to
    land twice or the versions would drift. Both now live in the shared
    DataUtils engine: compute_refund_preview_or_error() runs normalize,
    validate, build, and the two aggregate guards, and
    get_preview_line_items_arg_schema() is the one accepted payload shape.

    Each controller keeps only its surface concerns: v3 prefixes the engine's
    codes at its boundary and applies its response filter; v4 wraps them in
    its error envelope. Log sources stay per caller. No behavior change; the
    v3 and v4 preview suites pass unchanged.

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

    * Trim the trailing blank line from the changelog entry

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

    ---------

    Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

    ---------

    Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
    (cherry picked from commit a87c8778cd08cd3e044784a90decbb1646a96500)

    * Apply review fixes to the v3 compute_totals creation path

    - Align wc_create_refund failure with the legacy path: code
      woocommerce_rest_cannot_create_order_refund, status 500, same message.
    - Restore the readonly line_items refund_total schema: the parent line_items
      property is readonly, so the earlier mutation only changed the public
      response schema. Input semantics are documented on compute_totals and in
      the REST API docs instead.
    - Rename normalize_line_item_types to normalize_line_item, validate whole
      numbers via rest_is_integer, and move the id to line_item_id mapping into
      the method.
    - Trim the compute_totals schema description to two sentences.
    - Rename the data_utils getter to get_data_utils.
    - Fix the stale docblock claiming compute_totals error codes are unprefixed.
    - Rescope the duplicate-tax-id changelog to the wc/v4 endpoint and the wc/v3
      compute_totals path; the default v3 path still resolves duplicates by
      overwrite in RestApiParameterUtil and is fixed separately.
    - Add tests: duplicate line_item_id rejection, nonexistent and refund-id
      order ids, pre_insert filter request shape, create-failure parity.

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

    * Deduplicate refund creation preparation into DataUtils

    The wc/v3 compute_totals path duplicated the wc/v4 creation pipeline: fill
    missing refund totals, validate against refund history, convert to internal
    format, resolve the amount, and the three aggregate guards. That pipeline now
    lives in DataUtils::prepare_refund_creation_or_error and both controllers call
    it, mirroring how compute_refund_preview_or_error is shared for previews.

    The engine keeps emitting unprefixed codes with the HTTP status in the error
    data; wc/v3 prefixes them at its boundary and wc/v4 wraps them in its error
    envelope. Request mutation, hooks, metadata, and the wc_create_refund call
    stay controller-side. No behavior change: all four endpoint suites and the
    DataUtils unit suite pass unchanged.

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

    * Document the v3 refund preview endpoint and compute_totals mode

    Adds REST API documentation for the two WooCommerce 11.1 additions to the
    order refunds API: the server-computed creation mode (compute_totals) and the
    refund preview endpoint. Includes the capability detection contract and a
    warning that stores below 11.1 silently drop the unknown flag, where a
    quantity-only request creates a 0.00 refund.

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

    * Pass the response object through the preview filter and sanitize the args

    - woocommerce_rest_prepare_order_refund_preview now receives the
      WP_REST_Response, matching the contract its woocommerce_rest_prepare_*
      name promises, before the hook freezes with 11.1. The docblock states
      that the preview is advisory: the create path re-validates independently,
      so filtered values cannot bypass the creation guards.
    - The shared preview line_items arg schema gains a sanitize_callback:
      hand-registered args get no default sanitizer, so validated numeric
      strings previously reached the engine uncoerced. Covers the wc/v3 and
      wc/v4 preview routes.
    - Tests: the filter test asserts the response-object contract and the
      order argument; a new test pins numeric-string payloads computing
      correctly.

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

    * Align assignments in the preview filter test per phpcs

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

    * Reject conflicting line identifiers and fix the preview docs table

    A compute_totals line item carrying both the documented id and the
    engine's line_item_id previously let line_item_id win silently, so a
    payload could refund and restock a different line than the client
    intended. Either key alone stays accepted; both together return 400
    woocommerce_rest_invalid_line_item. Tests cover the rejection and pin
    the line_item_id-only form.

    The preview docs table advertised refund_tax, which the preview arg
    schema does not accept (additionalProperties is false); the row is
    removed.

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

    ---------

    Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

    ---------

    Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

diff --git a/docs/apis/rest-api/v3/order-refunds.mdx b/docs/apis/rest-api/v3/order-refunds.mdx
index d70eb87931e..c2508950f98 100644
--- a/docs/apis/rest-api/v3/order-refunds.mdx
+++ b/docs/apis/rest-api/v3/order-refunds.mdx
@@ -28,6 +28,7 @@ The order refunds API allows you to create, view, and delete individual refunds,
 | `fee_lines`        | array     | Fee lines data. See [Order refund - Fee lines properties](#order-refund---fee-lines-properties)                                                                                 |
 | `api_refund`       | boolean   | When true, the payment gateway API is used to generate the refund. Default is `true`. `WRITE-ONLY`                                              |
 | `api_restock`      | boolean   | When true, the selected line items are restocked Default is `true`. `WRITE-ONLY`                                                                |
+| `compute_totals`   | boolean   | When true, the server computes per-line refund amounts from quantities and validates the request against the order's refund history. Available since WooCommerce 11.1. See [Server-computed refunds](#server-computed-refunds-compute_totals). Default is `false`. `WRITE-ONLY` |

 ### Order refund - Meta data properties

@@ -285,6 +286,162 @@ woocommerce.post("orders/723/refunds", data).parsed_response
 | `id`           | integer | The ID of the tax rate.                         |
 | `refund_total` | number  | The amount of tax to refund for this line item. |

+### Server-computed refunds (compute_totals)
+
+Available since WooCommerce 11.1.
+
+Set `compute_totals` to `true` to have the server compute per-line refund amounts instead of supplying them. Line items may then send only `id` and `quantity`: the server derives each line's refund amount from the order's stored unit prices and taxes, caps it to the line's remaining refundable amount, and validates the whole request against the order's refund history. The refund `amount` is derived from the line items unless supplied explicitly.
+
+<Tabs>
+  <TabItem value="curl" label="cURL">
+
+```shell
+curl -X POST https://example.com/wp-json/wc/v3/orders/723/refunds \
+	-u consumer_key:consumer_secret \
+	-H "Content-Type: application/json" \
+	-d '{
+  "compute_totals": true,
+  "line_items": [
+    {
+      "id": 111,
+      "quantity": 1
+    }
+  ]
+}'
+```
+
+  </TabItem>
+  <TabItem value="response" label="JSON Response">
+
+```json
+{
+	"id": 726,
+	"date_created": "2026-08-06T17:07:11",
+	"date_created_gmt": "2026-08-06T20:07:11",
+	"amount": "110.00",
+	"reason": "",
+	"refunded_by": 1,
+	"refunded_payment": false,
+	"meta_data": [],
+	"line_items": [ ]
+}
+```
+
+  </TabItem>
+</Tabs>
+
+#### Line item parameters with compute_totals
+
+| Parameter      | Type    | Description                                                                                                                                                            |
+| -------------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `id`           | integer | The ID of the line item in the order. Each line item may appear only once per request.                                                                                 |
+| `quantity`     | integer | The number of units to refund. The server computes the amount for the quantity.                                                                                        |
+| `refund_total` | number  | Optional explicit amount for this line. Tax-inclusive when `refund_tax` is omitted (the server splits the tax portion out); tax-exclusive when `refund_tax` is supplied. |
+| `refund_tax`   | array   | Optional explicit tax refunds. See [Refund tax parameters](#refund-tax-parameters)                                                                                       |
+
+When `amount` is supplied together with `compute_totals`, it must be at least the computed line items total and no more than the order's remaining refundable amount. Requests that exceed what is still refundable are rejected with HTTP 400 or 422 and error codes such as `woocommerce_rest_invalid_refund_amount`, `woocommerce_rest_refund_exceeds_remaining`, `woocommerce_rest_quantity_exceeds_refundable`, and `woocommerce_rest_duplicate_line_item`.
+
+::::warning Older stores silently ignore compute_totals
+
+Stores running WooCommerce below 11.1 drop the unknown `compute_totals` parameter and process the request with the classic behavior. A quantity-only request then creates a refund of `0.00` instead of the intended amount. Before sending computed-form requests, verify that the store supports the flag:
+
+- Send `OPTIONS /wp-json/wc/v3/orders/<id>/refunds` and check that `compute_totals` is listed in the endpoint arguments, or
+- Probe `POST /wp-json/wc/v3/orders/<id>/refunds/preview`, which returns `rest_no_route` with HTTP 404 on stores without support.
+
+::::
+
+## Preview a refund
+
+Available since WooCommerce 11.1.
+
+This API computes the totals a refund would have, without creating it. The preview uses the same calculation engine as refund creation with `compute_totals`, so clients do not have to replicate tax, rounding, and currency-precision logic. It requires the same capability as creating a refund.
+
+```http
+POST /wp-json/wc/v3/orders/<id>/refunds/preview
+```
+
+<Tabs>
+  <TabItem value="curl" label="cURL">
+
+```shell
+curl -X POST https://example.com/wp-json/wc/v3/orders/723/refunds/preview \
+	-u consumer_key:consumer_secret \
+	-H "Content-Type: application/json" \
+	-d '{
+  "line_items": [
+    {
+      "line_item_id": 111,
+      "quantity": 1
+    }
+  ]
+}'
+```
+
+  </TabItem>
+  <TabItem value="response" label="JSON Response">
+
+```json
+{
+	"breakdown": {
+		"products": {
+			"items": [
+				{
+					"id": 111,
+					"name": "T-Shirt",
+					"quantity": 1,
+					"subtotal": "100.00",
+					"tax": "10.00",
+					"total": "110.00",
+					"product_id": 93
+				}
+			],
+			"subtotal": "100.00",
+			"tax": "10.00",
+			"total": "110.00"
+		},
+		"shipping": {
+			"items": [],
+			"subtotal": "0.00",
+			"tax": "0.00",
+			"total": "0.00"
+		},
+		"fees": {
+			"items": [],
+			"subtotal": "0.00",
+			"tax": "0.00",
+			"total": "0.00"
+		}
+	},
+	"subtotal": "100.00",
+	"tax": "10.00",
+	"total": "110.00",
+	"max_refundable": "110.00"
+}
+```
+
+  </TabItem>
+</Tabs>
+
+#### Preview line item parameters
+
+| Parameter      | Type    | Description                                                                                                          |
+| -------------- | ------- | --------------------------------------------------------------------------------------------------------------------- |
+| `line_item_id` | integer | The ID of the line item in the order. Note that the preview keys lines by `line_item_id` where the create endpoint uses `id`. |
+| `quantity`     | integer | The number of units to preview. Shipping and fee lines must use a quantity of 1.                                     |
+| `refund_total` | number  | Optional explicit tax-inclusive amount for this line.                                                                |
+
+#### Preview response properties
+
+| Attribute        | Type   | Description                                                                                             |
+| ---------------- | ------ | -------------------------------------------------------------------------------------------------------- |
+| `breakdown`      | object | Refund breakdown by item type: `products`, `shipping`, and `fees`, each with `items`, `subtotal`, `tax`, and `total`. `READ-ONLY` |
+| `subtotal`       | string | Grand subtotal of the refund preview, excluding tax. `READ-ONLY`                                        |
+| `tax`            | string | Grand tax total of the refund preview. `READ-ONLY`                                                      |
+| `total`          | string | Grand total of the refund preview, tax-inclusive. `READ-ONLY`                                           |
+| `max_refundable` | string | Maximum refundable amount remaining on the order. `READ-ONLY`                                           |
+
+Invalid requests return the same errors as creation with `compute_totals`, for example `woocommerce_rest_invalid_refund_amount` for a non-positive total and `woocommerce_rest_preview_exceeds_max_refundable` with HTTP 422 when the preview exceeds the remaining refundable amount.
+
 ## Retrieve a refund

 This API lets you retrieve and view a specific refund from an order.
diff --git a/plugins/woocommerce/changelog/add-v3-order-refunds-preview-endpoint b/plugins/woocommerce/changelog/add-v3-order-refunds-preview-endpoint
new file mode 100644
index 00000000000..ea677616314
--- /dev/null
+++ b/plugins/woocommerce/changelog/add-v3-order-refunds-preview-endpoint
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add a refund preview endpoint (POST /wc/v3/orders/{id}/refunds/preview) that returns server-computed refund totals and per-line breakdowns without creating a refund.
diff --git a/plugins/woocommerce/changelog/add-v3-refunds-compute-totals b/plugins/woocommerce/changelog/add-v3-refunds-compute-totals
new file mode 100644
index 00000000000..f0b2b362c77
--- /dev/null
+++ b/plugins/woocommerce/changelog/add-v3-refunds-compute-totals
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add an opt-in compute_totals mode to POST /wc/v3/orders/{id}/refunds that computes per-line refund amounts server-side from quantities, validated against the order's refund history.
diff --git a/plugins/woocommerce/changelog/fix-refunds-duplicate-tax-ids b/plugins/woocommerce/changelog/fix-refunds-duplicate-tax-ids
new file mode 100644
index 00000000000..897087451f3
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-refunds-duplicate-tax-ids
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Reject duplicate tax IDs in refund line items on the wc/v4 refunds endpoint and the wc/v3 compute_totals path, which previously created refunds whose stored line taxes did not add up to the refund amount.
diff --git a/plugins/woocommerce/changelog/v3-refunds-preview-review-fixes b/plugins/woocommerce/changelog/v3-refunds-preview-review-fixes
new file mode 100644
index 00000000000..6d5e931d8a6
--- /dev/null
+++ b/plugins/woocommerce/changelog/v3-refunds-preview-review-fixes
@@ -0,0 +1,3 @@
+Significance: patch
+Type: dev
+Comment: Review follow-ups for the unreleased wc/v3 refund preview endpoint: schema/doc polish and additional test coverage. No user-facing change.
diff --git a/plugins/woocommerce/changelog/v3-refunds-shared-preview-pipeline b/plugins/woocommerce/changelog/v3-refunds-shared-preview-pipeline
new file mode 100644
index 00000000000..bafd3f99728
--- /dev/null
+++ b/plugins/woocommerce/changelog/v3-refunds-shared-preview-pipeline
@@ -0,0 +1,3 @@
+Significance: patch
+Type: dev
+Comment: Deduplicate the refund preview pipeline and arg schema between the wc/v3 and wc/v4 endpoints into the shared DataUtils engine. No behavior change.
diff --git a/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-order-refunds-controller.php b/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-order-refunds-controller.php
index b43ff4c7c2f..6ab3b62eb3f 100644
--- a/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-order-refunds-controller.php
+++ b/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-order-refunds-controller.php
@@ -12,6 +12,8 @@ defined( 'ABSPATH' ) || exit;

 use Automattic\WooCommerce\Internal\RestApiParameterUtil;
 use Automattic\WooCommerce\Internal\CostOfGoodsSold\CogsAwareTrait;
+use Automattic\WooCommerce\Internal\RestApi\Routes\V4\Refunds\DataUtils;
+use Automattic\WooCommerce\Internal\RestApi\Routes\V4\Refunds\Schema\RefundPreviewSchema;
 use Automattic\WooCommerce\Utilities\MetaDataUtil;

 /**
@@ -30,6 +32,338 @@ class WC_REST_Order_Refunds_Controller extends WC_REST_Order_Refunds_V2_Controll
 	 */
 	protected $namespace = 'wc/v3';

+	/**
+	 * Register the routes for order refunds, including the refund preview route.
+	 *
+	 * The override is new in 11.1.0 even though the parent method is not, hence
+	 * the tag per the convention for public methods.
+	 *
+	 * @return void
+	 *
+	 * @since 11.1.0
+	 */
+	public function register_routes() {
+		parent::register_routes();
+
+		register_rest_route(
+			$this->namespace,
+			'/' . $this->rest_base . '/preview',
+			array(
+				'args'   => array(
+					'order_id' => array(
+						'description' => __( 'The order ID.', 'woocommerce' ),
+						'type'        => 'integer',
+					),
+				),
+				// permission_callback below intentionally uses the create-refund capability:
+				// preview is read-only but logically part of the refund-creation flow, so it
+				// requires the same capability. This prevents read-only-API clients from
+				// probing refund state on orders they cannot act on.
+				array(
+					'methods'             => WP_REST_Server::CREATABLE,
+					'callback'            => array( $this, 'preview_refund' ),
+					'permission_callback' => array( $this, 'create_item_permissions_check' ),
+					'args'                => array(
+						'line_items' => $this->get_preview_line_items_arg_schema(),
+					),
+				),
+				'schema' => array( $this, 'get_public_preview_schema' ),
+			)
+		);
+	}
+
+	/**
+	 * Preview a refund without creating it.
+	 *
+	 * Returns server-computed refund totals and per-line breakdowns for the
+	 * requested line items, using the same calculation engine as the wc/v4
+	 * refunds endpoints, so clients do not have to replicate tax, rounding,
+	 * and currency-precision logic.
+	 *
+	 * @param WP_REST_Request $request Full details about the request.
+	 * @return WP_REST_Response|WP_Error
+	 *
+	 * @phpstan-param WP_REST_Request<array<string, mixed>> $request
+	 *
+	 * @since 11.1.0
+	 */
+	public function preview_refund( $request ) {
+		$order = wc_get_order( (int) $request['order_id'] );
+
+		// wc_get_order returns WC_Order|WC_Order_Refund|false; only a WC_Order
+		// (shop_order) is previewable here — refunds and missing IDs are rejected.
+		if ( ! $order instanceof WC_Order ) {
+			return new WP_Error( 'woocommerce_rest_invalid_order_id', __( 'Invalid order ID.', 'woocommerce' ), array( 'status' => 404 ) );
+		}
+
+		// The shared engine runs the whole pipeline: normalize, validate, build,
+		// and the aggregate guards. Its WP_Errors carry their HTTP status in the
+		// error data and use unprefixed codes (the engine is convention neutral);
+		// they are prefixed here at the v3 boundary so this endpoint follows the
+		// `woocommerce_rest_*` convention of the rest of the v3 surface.
+		$preview = $this->get_data_utils()->compute_refund_preview_or_error( $order, $request['line_items'], 'wc-rest-refunds' );
+
+		if ( is_wp_error( $preview ) ) {
+			return $this->prefix_error_code( $preview );
+		}
+
+		$preview = $this->add_preview_additional_fields( $preview, $request );
+
+		$response = rest_ensure_response( $preview );
+
+		/**
+		 * Filters the refund preview response before it is returned, following the
+		 * `woocommerce_rest_prepare_*` family contract. The preview is advisory:
+		 * the create path re-validates independently, so filtered values cannot
+		 * bypass the creation guards.
+		 *
+		 * @param WP_REST_Response $response The preview response. Its data carries
+		 *                                   breakdown, subtotal, tax, total, max_refundable.
+		 * @param WC_Order         $order    The order the refund preview was computed for.
+		 * @param WP_REST_Request  $request  The request.
+		 *
+		 * @since 11.1.0
+		 */
+		return apply_filters( 'woocommerce_rest_prepare_order_refund_preview', $response, $order, $request );
+	}
+
+	/**
+	 * Populate fields registered for the preview object type into a response.
+	 *
+	 * The stock add_additional_fields_to_object() resolves the object type from
+	 * this controller's item schema (`order_refund`), so it would populate the
+	 * wrong field set; the preview publishes its schema as
+	 * `order_refund_preview` and must populate the fields registered for that
+	 * type. Mirrors core's `_fields` handling: callbacks for fields the request
+	 * excludes are not executed, so extension callbacks do not run for
+	 * responses that will not carry their field. Runs before the response
+	 * filter so filters see the complete payload.
+	 *
+	 * @param array           $preview Preview response data.
+	 * @param WP_REST_Request $request The request.
+	 *
+	 * @return array
+	 *
+	 * @phpstan-param WP_REST_Request<array<string, mixed>> $request
+	 */
+	private function add_preview_additional_fields( array $preview, $request ): array {
+		$additional_fields = $this->get_additional_fields( 'order_refund_preview' );
+
+		if ( empty( $additional_fields ) ) {
+			return $preview;
+		}
+
+		$fields_for_response = $this->get_preview_fields_for_response( $request );
+
+		foreach ( $additional_fields as $field_name => $field_options ) {
+			if ( empty( $field_options['get_callback'] ) || ! is_callable( $field_options['get_callback'] ) ) {
+				continue;
+			}
+
+			if ( ! in_array( $field_name, $fields_for_response, true ) ) {
+				continue;
+			}
+
+			$preview[ $field_name ] = call_user_func( $field_options['get_callback'], $preview, $field_name, $request, 'order_refund_preview' );
+		}
+
+		return $preview;
+	}
+
+	/**
+	 * Get the preview fields a request asks for, mirroring core's
+	 * get_fields_for_response() against the preview schema instead of the
+	 * controller's item schema.
+	 *
+	 * @param WP_REST_Request $request The request.
+	 *
+	 * @return string[]
+	 *
+	 * @phpstan-param WP_REST_Request<array<string, mixed>> $request
+	 */
+	private function get_preview_fields_for_response( $request ): array {
+		$schema     = $this->get_public_preview_schema();
+		$properties = isset( $schema['properties'] ) && is_array( $schema['properties'] ) ? $schema['properties'] : array();
+
+		// For back-compat, include any registered field with an empty schema, as
+		// core's get_fields_for_response() does: without a schema the field never
+		// reaches the published properties, but its callback must still run.
+		foreach ( $this->get_additional_fields( 'order_refund_preview' ) as $field_name => $field_options ) {
+			if ( is_null( $field_options['schema'] ) ) {
+				$properties[ $field_name ] = $field_options;
+			}
+		}
+
+		$fields = array_map( 'strval', array_keys( $properties ) );
+
+		if ( ! isset( $request['_fields'] ) || empty( $request['_fields'] ) ) {
+			return $fields;
+		}
+
+		$requested_fields = array_map(
+			static function ( $field ): string {
+				return trim( (string) $field );
+			},
+			wp_parse_list( $request['_fields'] )
+		);
+
+		if ( 0 === count( $requested_fields ) ) {
+			return $fields;
+		}
+
+		return array_values(
+			array_filter(
+				$fields,
+				function ( string $field ) use ( $requested_fields ): bool {
+					return rest_is_field_included( $field, $requested_fields );
+				}
+			)
+		);
+	}
+
+	/**
+	 * Get the public schema for the refund preview endpoint.
+	 *
+	 * @return array
+	 *
+	 * @since 11.1.0
+	 */
+	public function get_public_preview_schema() {
+		$schema          = wc_get_container()->get( RefundPreviewSchema::class )->get_item_schema();
+		$schema['title'] = 'order_refund_preview';
+
+		// Like the sibling v3 schema getters: fields registered via
+		// register_rest_field() must appear in the published schema.
+		return $this->add_additional_fields_schema( $schema );
+	}
+
+	/**
+	 * Get the argument schema for the preview route's line_items parameter.
+	 *
+	 * Shared with the wc/v4 preview endpoint (including the line_item_id key
+	 * naming) so clients can send the same payload to both API versions and
+	 * the accepted shape cannot drift between them.
+	 *
+	 * Note the two deliberate differences from this controller's create
+	 * endpoint: the preview keys lines by `line_item_id` where the create
+	 * uses `id`, and the preview's `refund_total` is tax-inclusive where the
+	 * create's classic `refund_total` is net with taxes supplied separately
+	 * via `refund_tax` (the compute_totals create shares the preview's
+	 * tax-inclusive semantics).
+	 *
+	 * @return array
+	 */
+	private function get_preview_line_items_arg_schema() {
+		return $this->get_data_utils()->get_preview_line_items_arg_schema();
+	}
+
+	/**
+	 * Normalize one compute_totals line item to the shared engine's shape.
+	 *
+	 * Maps the create endpoint's public `id` key to the engine's `line_item_id`
+	 * and validates/normalizes the scalar types. The REST schema cannot validate
+	 * the line_items subtree (the property is readonly for backward
+	 * compatibility), so without this check malformed values such as an array
+	 * refund_total would reach the calculation engine and fail with a TypeError
+	 * instead of a 400 response. Uses the same error codes as the engine's own
+	 * validation, and casts numeric strings to their proper types.
+	 *
+	 * @param array $line_item Line item in the public request shape (id keys).
+	 * @return array|WP_Error The normalized line item, or WP_Error on an invalid type.
+	 *
+	 * @since 11.1.0
+	 */
+	private function normalize_line_item( array $line_item ) {
+		// The create endpoint documents `id`; the shared engine and the preview
+		// endpoint key lines by `line_item_id`, and either form is accepted here.
+		// A payload carrying both is rejected: silently preferring one could
+		// refund and restock a different line than the client intended.
+		if ( isset( $line_item['id'], $line_item['line_item_id'] ) ) {
+			return new WP_Error( 'woocommerce_rest_invalid_line_item', __( 'Specify the line item with either id or line_item_id, not both.', 'woocommerce' ), array( 'status' => 400 ) );
+		}
+
+		if ( isset( $line_item['id'] ) ) {
+			$line_item['line_item_id'] = $line_item['id'];
+			unset( $line_item['id'] );
+		}
+
+		// IDs must be whole numbers (rest_is_integer): silently truncating a
+		// fractional id such as 123.5 to 123 would target a different line or
+		// tax bucket than requested.
+		if ( isset( $line_item['line_item_id'] ) ) {
+			if ( ! rest_is_integer( $line_item['line_item_id'] ) ) {
+				return new WP_Error( 'woocommerce_rest_invalid_line_item', __( 'Line item id must be an integer.', 'woocommerce' ), array( 'status' => 400 ) );
+			}
+			$line_item['line_item_id'] = (int) $line_item['line_item_id'];
+		}
+
+		if ( isset( $line_item['quantity'] ) ) {
+			if ( ! rest_is_integer( $line_item['quantity'] ) ) {
+				return new WP_Error( 'woocommerce_rest_invalid_quantity', __( 'Quantity must be a whole number.', 'woocommerce' ), array( 'status' => 400 ) );
+			}
+			$line_item['quantity'] = (int) $line_item['quantity'];
+		}
+
+		if ( isset( $line_item['refund_total'] ) ) {
+			if ( ! is_numeric( $line_item['refund_total'] ) ) {
+				return new WP_Error( 'woocommerce_rest_invalid_refund_total', __( 'refund_total must be a number.', 'woocommerce' ), array( 'status' => 400 ) );
+			}
+			$line_item['refund_total'] = (float) $line_item['refund_total'];
+		}
+
+		if ( isset( $line_item['refund_tax'] ) ) {
+			if ( ! is_array( $line_item['refund_tax'] ) ) {
+				return new WP_Error( 'woocommerce_rest_invalid_line_item', __( 'refund_tax must be an array of objects with id and refund_total.', 'woocommerce' ), array( 'status' => 400 ) );
+			}
+			foreach ( $line_item['refund_tax'] as $index => $tax ) {
+				if ( ! is_array( $tax ) || ! isset( $tax['id'], $tax['refund_total'] ) || ! rest_is_integer( $tax['id'] ) || ! is_numeric( $tax['refund_total'] ) ) {
+					return new WP_Error( 'woocommerce_rest_invalid_line_item', __( 'refund_tax entries must be objects with an integer id and a numeric refund_total.', 'woocommerce' ), array( 'status' => 400 ) );
+				}
+				$line_item['refund_tax'][ $index ] = array(
+					'id'           => (int) $tax['id'],
+					'refund_total' => (float) $tax['refund_total'],
+				);
+			}
+		}
+
+		return $line_item;
+	}
+
+	/**
+	 * Get the shared refund calculation engine.
+	 *
+	 * DataUtils is the calculation/validation engine shared with the wc/v4
+	 * refunds endpoints (the V4 segment in its namespace is historical); using
+	 * it here keeps wc/v3 and wc/v4 refund math identical.
+	 *
+	 * @return DataUtils
+	 */
+	private function get_data_utils(): DataUtils {
+		return wc_get_container()->get( DataUtils::class );
+	}
+
+	/**
+	 * Prefix a shared-engine error code with `woocommerce_rest_`.
+	 *
+	 * DataUtils emits unprefixed codes (the wc/v4 convention). The wc/v3 surface
+	 * uses `woocommerce_rest_*`, so errors crossing into a v3 response are
+	 * renamed at this boundary. Codes that already carry the prefix pass through
+	 * unchanged, and the message and data (including the HTTP status) are kept.
+	 *
+	 * @param WP_Error $error The error whose code should be prefixed.
+	 *
+	 * @return WP_Error
+	 */
+	private function prefix_error_code( WP_Error $error ): WP_Error {
+		$code = (string) $error->get_error_code();
+
+		if ( str_starts_with( $code, 'woocommerce_rest_' ) ) {
+			return $error;
+		}
+
+		return new WP_Error( 'woocommerce_rest_' . $code, $error->get_error_message(), $error->get_error_data() );
+	}
+
 	/**
 	 * Prepares one object for create or update operation.
 	 *
@@ -39,6 +373,16 @@ class WC_REST_Order_Refunds_Controller extends WC_REST_Order_Refunds_V2_Controll
 	 * @return WP_Error|WC_Data The prepared item, or WP_Error object on failure.
 	 */
 	protected function prepare_object_for_database( $request, $creating = false ) {
+		// The opt-in compute_totals mode routes through the shared wc/v4 refund
+		// calculation pipeline. It is a separate path so that requests without the
+		// flag behave exactly as before, including degenerate forms such as
+		// quantity-only line items producing a 0.00 refund. The schema declares
+		// compute_totals as boolean with a false default, so the REST layer has
+		// already sanitized the value by the time this runs.
+		if ( $creating && true === $request['compute_totals'] ) {
+			return $this->create_refund_with_computed_totals( $request );
+		}
+
 		RestApiParameterUtil::adjust_create_refund_request_parameters( $request );

 		$order = wc_get_order( (int) $request['order_id'] );
@@ -89,6 +433,120 @@ class WC_REST_Order_Refunds_Controller extends WC_REST_Order_Refunds_V2_Controll
 		return apply_filters( "woocommerce_rest_pre_insert_{$this->post_type}_object", $refund, $request, $creating );
 	}

+	/**
+	 * Create a refund with server-computed per-line totals (compute_totals mode).
+	 *
+	 * Mirrors the wc/v4 refund creation pipeline: line items may omit refund_total
+	 * (computed from quantity at the order's stored unit price, tax-inclusive,
+	 * clamped to the remaining refundable amount), input is validated against the
+	 * order's refund history, and the refund amount is derived from the line items
+	 * unless an explicit amount override is supplied. Validation follows the same
+	 * rules as the wc/v4 creation endpoint; error codes are prefixed with
+	 * `woocommerce_rest_` at this v3 boundary like the rest of the v3 surface.
+	 *
+	 * @param WP_REST_Request $request Request object.
+	 * @return WP_Error|WC_Data The created refund, or WP_Error object on failure.
+	 *
+	 * @phpstan-param WP_REST_Request<array<string, mixed>> $request
+	 *
+	 * @since 11.1.0
+	 */
+	private function create_refund_with_computed_totals( $request ) {
+		$order = wc_get_order( (int) $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 new WP_Error( 'woocommerce_rest_invalid_order_id', __( 'Invalid order ID.', 'woocommerce' ), array( 'status' => 404 ) );
+		}
+
+		// Normalize each line to the engine's schema shape and validate value
+		// types here: the REST layer cannot, because the line_items schema
+		// property is readonly for backward compatibility, so its args are not
+		// registered.
+		$line_items = array();
+		foreach ( (array) ( $request['line_items'] ?? array() ) as $line_item ) {
+			if ( ! is_array( $line_item ) ) {
+				return new WP_Error( 'woocommerce_rest_invalid_line_item', __( 'Each line item must be an object.', 'woocommerce' ), array( 'status' => 400 ) );
+			}
+
+			$line_item = $this->normalize_line_item( $line_item );
+			if ( is_wp_error( $line_item ) ) {
+				return $line_item;
+			}
+
+			$line_items[] = $line_item;
+		}
+
+		// The shared engine runs the whole creation preparation: fill missing
+		// refund totals, validate against the order's refund history, convert to
+		// the internal wc_create_refund() format, resolve the amount, and apply
+		// the aggregate guards. Its WP_Errors carry their HTTP status in the
+		// error data and use unprefixed codes; they are prefixed here at the v3
+		// boundary like every other error the endpoint returns.
+		$prepared = $this->get_data_utils()->prepare_refund_creation_or_error(
+			$order,
+			$line_items,
+			$request->has_param( 'amount' ),
+			$request['amount'],
+			'wc-rest-refunds'
+		);
+
+		if ( is_wp_error( $prepared ) ) {
+			return $this->prefix_error_code( $prepared );
+		}
+
+		$line_item_data = $prepared['line_items'];
+		$refund_amount  = $prepared['amount'];
+
+		// Mirror the resolved values back onto the request so the pre_insert filter
+		// below and any other downstream readers see the same internal-format
+		// line_items and amount the legacy path exposes after
+		// RestApiParameterUtil::adjust_create_refund_request_parameters().
+		$request->set_param( 'line_items', $line_item_data );
+		$request->set_param( 'amount', strval( $refund_amount ) );
+
+		$refund = wc_create_refund(
+			array(
+				'order_id'       => $order->get_id(),
+				'amount'         => $refund_amount,
+				'reason'         => empty( $request['reason'] ) ? null : $request['reason'],
+				'line_items'     => $line_item_data,
+				'refund_payment' => is_bool( $request['api_refund'] ) ? $request['api_refund'] : true,
+				'restock_items'  => is_bool( $request['api_restock'] ) ? $request['api_restock'] : true,
+			)
+		);
+
+		// Same code and status as the legacy path above so a wc_create_refund
+		// failure looks identical to clients regardless of the compute_totals flag.
+		if ( is_wp_error( $refund ) ) {
+			return new WP_Error( 'woocommerce_rest_cannot_create_order_refund', $refund->get_error_message(), array( 'status' => 500 ) );
+		}
+
+		if ( ! $refund ) {
+			return new WP_Error( 'woocommerce_rest_cannot_create_order_refund', __( 'Cannot create order refund, please try again.', 'woocommerce' ), array( 'status' => 500 ) );
+		}
+
+		if ( ! empty( $request['meta_data'] ) ) {
+			MetaDataUtil::update( $request['meta_data'], $refund );
+			$refund->save_meta_data();
+		}
+
+		/**
+		 * Filters an object before it is inserted via the REST API.
+		 *
+		 * The dynamic portion of the hook name, `$this->post_type`,
+		 * refers to the object type slug.
+		 *
+		 * @param WC_Data         $refund   Object object.
+		 * @param WP_REST_Request $request  Request object.
+		 * @param bool            $creating If is creating a new object.
+		 *
+		 * @since 3.0.0
+		 */
+		return apply_filters( "woocommerce_rest_pre_insert_{$this->post_type}_object", $refund, $request, true );
+	}
+
 	/**
 	 * Get formatted item data.
 	 * Invokes parents and then adds the proper Cost of Goods Sold information.
@@ -150,6 +608,13 @@ class WC_REST_Order_Refunds_Controller extends WC_REST_Order_Refunds_V2_Controll
 			'default'     => true,
 		);

+		$schema['properties']['compute_totals'] = array(
+			'description' => __( 'When true, the server computes per-line refund amounts from quantities using the order\'s stored prices and taxes, validating the request against the order\'s refund history. Defaults to false, which preserves the pre-existing behavior of this endpoint.', 'woocommerce' ),
+			'type'        => 'boolean',
+			'context'     => array( 'edit' ),
+			'default'     => false,
+		);
+
 		if ( $this->cogs_is_enabled() ) {
 			$schema = $this->add_cogs_related_schema( $schema );
 		}
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index 612401aae71..0f0608f5c73 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -30309,12 +30309,6 @@ parameters:
 			count: 1
 			path: includes/rest-api/Controllers/Version3/class-wc-rest-refunds-controller.php

-		-
-			message: '#^Method WC_REST_Refunds_Controller\:\:register_routes\(\) has no return type specified\.$#'
-			identifier: missingType.return
-			count: 1
-			path: includes/rest-api/Controllers/Version3/class-wc-rest-refunds-controller.php
-
 		-
 			message: '#^PHPDoc tag @extends has invalid value \(WC_REST_Order_Refunds_Controller\)\: Unexpected token "\\n ", expected ''\<'' at offset 127 on line 5$#'
 			identifier: phpDoc.parseError
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 a04fdc714cf..c6a8bde69c2 100644
--- a/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Refunds/Controller.php
+++ b/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Refunds/Controller.php
@@ -18,7 +18,6 @@ use Automattic\WooCommerce\StoreApi\Utilities\Pagination;
 use Automattic\WooCommerce\Internal\RestApi\Routes\V4\Refunds\Schema\RefundPreviewSchema;
 use Automattic\WooCommerce\Internal\RestApi\Routes\V4\Refunds\Schema\RefundSchema;
 use Automattic\WooCommerce\Utilities\MetaDataUtil;
-use Automattic\WooCommerce\Utilities\NumberUtil;
 use WP_Http;
 use WP_Error;
 use WC_Order;
@@ -187,41 +186,9 @@ class Controller extends AbstractController {
 							'minimum'           => 1,
 							'validate_callback' => 'rest_validate_request_arg',
 						),
-						'line_items' => array(
-							'description'       => __( 'Line items to include in the refund preview.', 'woocommerce' ),
-							'type'              => 'array',
-							'required'          => true,
-							'minItems'          => 1,
-							'validate_callback' => 'rest_validate_request_arg',
-							'items'             => array(
-								'type'                 => 'object',
-								'required'             => array( 'line_item_id' ),
-								'additionalProperties' => false,
-								'properties'           => array(
-									'line_item_id' => array(
-										'description' => __( 'ID of the original order line item.', 'woocommerce' ),
-										'type'        => 'integer',
-										'minimum'     => 1,
-									),
-									'quantity'     => array(
-										'description' => __( 'Quantity to refund. Required when refund_total is omitted.', 'woocommerce' ),
-										'type'        => 'integer',
-										'minimum'     => 1,
-									),
-									'refund_total' => array(
-										// No `minimum` here on purpose: validate_preview_line_items() owns
-										// the sign rule and returns the actionable `invalid_refund_total`
-										// code. A refund_total must be non-zero and match the line's sign —
-										// negative is valid for a discount/credit line, positive for a normal
-										// line; zero and wrong-sign values are rejected. A schema `minimum`
-										// would wrongly forbid the negative form, and a generic
-										// `rest_invalid_param` is less useful to clients.
-										'description' => __( 'Tax-inclusive amount to refund for this line item. Must be non-zero and match the line\'s sign (negative for discount or credit lines, positive otherwise). Required when quantity is omitted.', 'woocommerce' ),
-										'type'        => array( 'number', 'null' ),
-									),
-								),
-							),
-						),
+						// Shared with the wc/v3 preview endpoint so the accepted
+						// payload cannot drift between API versions.
+						'line_items' => $this->data_utils->get_preview_line_items_arg_schema(),
 					),
 				),
 				'schema' => array( $this, 'get_public_preview_schema' ),
@@ -381,104 +348,37 @@ class Controller extends AbstractController {
 			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 {
-			$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
-			);
-		}
-
-		// 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( $line_items, $order );
-
-			if ( is_wp_error( $validation_error ) ) {
-				// Preserve any status carried on the WP_Error so create and preview
-				// return the same HTTP code for the same invalid input (e.g. 422 for
-				// over-refund / non-refundable order). Falls back to 400 otherwise.
-				$error_data = $validation_error->get_error_data();
-				$status     = is_array( $error_data ) && isset( $error_data['status'] ) ? (int) $error_data['status'] : WP_Http::BAD_REQUEST;
-				return $this->get_route_error_response_from_object( $validation_error, $status );
-			}
+		// The shared engine runs the whole creation preparation: fill missing
+		// refund totals, validate against the order's refund history, convert to
+		// the internal wc_create_refund() format, resolve the amount, and apply
+		// the aggregate guards. Shared with the wc/v3 compute_totals path so a
+		// fix lands once.
+		$prepared = $this->data_utils->prepare_refund_creation_or_error(
+			$order,
+			$request['line_items'] ?? array(),
+			$request->has_param( 'total' ),
+			$request['total'],
+			'wc-v4-refunds'
+		);

-			// Convert line items to internal format. refund_total is tax-inclusive when no
-			// explicit refund_tax is supplied (auto-computed values, or client values) — the
-			// converter splits the tax portion out via the line's stored total/tax ratio
-			// (DataUtils::split_inclusive_by_stored_ratio(), the same method the preview uses).
-			// When the client supplies an explicit refund_tax breakdown, refund_total is the
-			// tax-exclusive subtotal and the tax is added on top (core Woo semantics). Either
-			// way calculate_refund_amount sums refund_total + refund_tax to the gross line
-			// amount, so mixing the two forms across line items is well-defined.
-			$line_item_data   = $this->data_utils->convert_line_items_to_internal_format( $line_items, $order );
-			$calculated_total = ! empty( $line_items ) ? $this->data_utils->calculate_refund_amount( $line_items ) : 0;
-
-			// has_param() distinguishes an omitted total from an explicitly supplied one —
-			// the schema declares no default, so the param exists only when the client sent
-			// it. An explicit zero (including string forms like "0.00", which are truthy)
-			// must be rejected rather than silently falling back to the calculated amount:
-			// a request meaning "refund nothing" must never refund the full computed total.
-			$has_total     = $request->has_param( 'total' );
-			$refund_amount = $has_total ? $request['total'] : $calculated_total;
-
-			if ( (float) $refund_amount <= 0 ) {
-				return $this->get_route_error_response( 'invalid_refund_amount', __( 'Refund total must be greater than zero.', 'woocommerce' ) );
-			}
+		if ( is_wp_error( $prepared ) ) {
+			// Preserve any status carried on the WP_Error so create and preview
+			// return the same HTTP code for the same invalid input (e.g. 422 for
+			// over-refund / non-refundable order). Falls back to 400 otherwise.
+			$error_data = $prepared->get_error_data();
+			$status     = is_array( $error_data ) && isset( $error_data['status'] ) ? (int) $error_data['status'] : WP_Http::BAD_REQUEST;
+			return $this->get_route_error_response_from_object( $prepared, $status );
+		}

-			// Prevent under-refunding: total cannot be less than calculated line items total.
-			// Over-refunding is allowed for goodwill/compensation scenarios.
-			if ( $has_total && $calculated_total > 0 && NumberUtil::round( (float) $refund_amount, wc_get_price_decimals() ) < NumberUtil::round( $calculated_total, wc_get_price_decimals() ) ) {
-				return $this->get_route_error_response(
-					'invalid_refund_amount',
-					sprintf(
-						/* translators: %1$s: refund amount, %2$s: calculated total from line items */
-						__( 'Refund amount (%1$s) cannot be less than the total of line items (%2$s).', 'woocommerce' ),
-						wc_format_decimal( $refund_amount, wc_get_price_decimals() ),
-						wc_format_decimal( $calculated_total, wc_get_price_decimals() )
-					)
-				);
-			}
+		// Mirror the filled schema-format 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', $prepared['schema_line_items'] );

-			// Over-refunding line items is allowed (goodwill), but the amount can never
-			// exceed the order's remaining refundable amount. Reject up-front with a clear
-			// 422 rather than relying on wc_create_refund's generic failure, mirroring the
-			// preview endpoint's preview_exceeds_max_refundable guard.
-			$remaining_refundable = (float) $order->get_remaining_refund_amount();
-			if ( NumberUtil::round( (float) $refund_amount, wc_get_price_decimals() ) > NumberUtil::round( $remaining_refundable, wc_get_price_decimals() ) ) {
-				return $this->get_route_error_response(
-					'refund_exceeds_remaining',
-					sprintf(
-						/* translators: %1$s: requested refund amount, %2$s: remaining refundable amount */
-						__( 'Refund amount (%1$s) exceeds the remaining refundable amount (%2$s).', 'woocommerce' ),
-						wc_format_decimal( $refund_amount, wc_get_price_decimals() ),
-						wc_format_decimal( $remaining_refundable, wc_get_price_decimals() )
-					),
-					WP_Http::UNPROCESSABLE_ENTITY
-				);
-			}
+		$line_item_data = $prepared['line_items'];
+		$refund_amount  = $prepared['amount'];

+		try {
 			$refund = wc_create_refund(
 				array(
 					'order_id'       => $order->get_id(),
@@ -544,76 +444,16 @@ class Controller extends AbstractController {
 			return $this->get_route_error_by_code( self::INVALID_ID );
 		}

-		// Round caller-supplied refund_total values once, up front, so validation and
-		// the computed preview use the same precision the create flow stores. Reused
-		// for both validate and build below.
-		$line_items = $this->data_utils->normalize_refund_totals( $request['line_items'] );
-
-		$validation_error = $this->data_utils->validate_preview_line_items( $line_items, $order );
+		// The shared engine runs the whole pipeline: normalize, validate, build,
+		// and the aggregate guards. Its WP_Errors carry their HTTP status in the
+		// error data; wrap them in this controller's error envelope, as the
+		// validation branch always has.
+		$preview = $this->data_utils->compute_refund_preview_or_error( $order, $request['line_items'], 'wc-v4-refunds' );

-		if ( is_wp_error( $validation_error ) ) {
-			$error_data = $validation_error->get_error_data();
+		if ( is_wp_error( $preview ) ) {
+			$error_data = $preview->get_error_data();
 			$status     = is_array( $error_data ) && isset( $error_data['status'] ) ? (int) $error_data['status'] : WP_Http::BAD_REQUEST;
-			return $this->get_route_error_response_from_object( $validation_error, $status );
-		}
-
-		try {
-			$preview = $this->data_utils->build_refund_preview( $order, $line_items );
-		} catch ( \InvalidArgumentException $e ) {
-			// validate_preview_line_items above should have caught any bad input.
-			// If build_refund_preview still throws InvalidArgumentException, treat
-			// it as a server-side invariant violation, log for observability, and
-			// return a generic message (do not leak internal IDs to clients).
-			wc_get_logger()->error(
-				sprintf( 'Refund preview invariant violation on order %d: %s', $order->get_id(), $e->getMessage() ),
-				array( 'source' => 'wc-v4-refunds' )
-			);
-			return $this->get_route_error_response(
-				'invalid_preview_request',
-				__( 'The refund preview could not be generated due to an unexpected error.', 'woocommerce' ),
-				WP_Http::INTERNAL_SERVER_ERROR
-			);
-		} catch ( \Throwable $e ) {
-			wc_get_logger()->error(
-				sprintf( 'Refund preview unexpected error on order %d: %s', $order->get_id(), $e->getMessage() ),
-				array( 'source' => 'wc-v4-refunds' )
-			);
-			return $this->get_route_error_response(
-				'unexpected_preview_error',
-				__( 'An unexpected error occurred while generating the refund preview.', 'woocommerce' ),
-				WP_Http::INTERNAL_SERVER_ERROR
-			);
-		}
-
-		// Reject a non-positive aggregate total up front, mirroring create_item()'s
-		// `0 > $refund_amount || ! $refund_amount` guard. A refund of only a negative
-		// discount line, or a product plus discount that nets to zero, would otherwise
-		// preview successfully and then fail at create with 'invalid_refund_amount'.
-		if ( (float) $preview['total'] <= 0 ) {
-			return $this->get_route_error_response(
-				'invalid_refund_amount',
-				__( 'Refund total must be greater than zero.', 'woocommerce' )
-			);
-		}
-
-		// Final guard: even when per-line validation passes, the aggregate
-		// preview total can still exceed the order's remaining refundable
-		// amount (e.g. an amount-only partial refund applied previously).
-		// Reject up-front so the eventual create call doesn't fail with the
-		// generic 'cannot_create_refund' error from wc_create_refund.
-		// `total` is already tax-inclusive; compare directly against max_refundable.
-		$preview_total_with_tax = abs( (float) $preview['total'] );
-		if ( $preview_total_with_tax > (float) $preview['max_refundable'] ) {
-			return $this->get_route_error_response(
-				'preview_exceeds_max_refundable',
-				sprintf(
-					/* translators: 1: requested preview total including tax, 2: remaining refundable */
-					__( 'Requested refund preview (%1$s) exceeds the remaining refundable amount (%2$s).', 'woocommerce' ),
-					wc_format_decimal( $preview_total_with_tax, wc_get_price_decimals() ),
-					$preview['max_refundable']
-				),
-				WP_Http::UNPROCESSABLE_ENTITY
-			);
+			return $this->get_route_error_response_from_object( $preview, $status );
 		}

 		return rest_ensure_response( $preview );
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 f01e8fa6aab..f73dc537dc3 100644
--- a/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Refunds/DataUtils.php
+++ b/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Refunds/DataUtils.php
@@ -365,7 +365,23 @@ class DataUtils {
 				// equivalent for the inclusive form.
 				$line_refund_gross = (float) $line_item['refund_total'];
 				if ( ! empty( $line_item['refund_tax'] ) && is_array( $line_item['refund_tax'] ) ) {
+					$seen_tax_ids = array();
 					foreach ( $line_item['refund_tax'] as $tax ) {
+						// Reject duplicate tax IDs within a line: the gross sum here and
+						// calculate_refund_amount() count every entry, but the internal
+						// conversion keys taxes by ID, so a duplicate would silently
+						// overwrite its sibling and store less than the refund amount.
+						$tax_id = $tax['id'] ?? null;
+						if ( null !== $tax_id && isset( $seen_tax_ids[ $tax_id ] ) ) {
+							return new WP_Error(
+								'duplicate_tax_id',
+								__( 'Each tax can appear at most once per line item in refund_tax.', 'woocommerce' ),
+								array( 'status' => WP_Http::BAD_REQUEST )
+							);
+						}
+						if ( null !== $tax_id ) {
+							$seen_tax_ids[ $tax_id ] = true;
+						}
 						$line_refund_gross += (float) ( $tax['refund_total'] ?? 0 );
 					}
 				}
@@ -1209,6 +1225,268 @@ class DataUtils {
 		return true;
 	}

+	/**
+	 * Run the full refund preview pipeline for one request.
+	 *
+	 * Normalizes the caller-supplied refund_total values, validates the line
+	 * items, builds the preview, and applies the two aggregate guards (a
+	 * non-positive total, and a total exceeding the order's remaining
+	 * refundable amount). Shared by the wc/v3 and wc/v4 preview endpoints so
+	 * a fix lands once; error codes are emitted unprefixed and each caller
+	 * applies its own surface convention (wc/v3 prefixes with
+	 * `woocommerce_rest_`, wc/v4 wraps in its error envelope).
+	 *
+	 * @param WC_Order $order      The order the preview is computed for.
+	 * @param array    $line_items Line items in schema format (line_item_id keyed).
+	 * @param string   $log_source Log source for invariant violations, per caller.
+	 * @return array|WP_Error Preview data, or WP_Error carrying its HTTP status in the error data.
+	 *
+	 * @since 11.1.0
+	 */
+	public function compute_refund_preview_or_error( WC_Order $order, array $line_items, string $log_source ) {
+		// Round caller-supplied refund_total values once, up front, so validation and
+		// the computed preview use the same precision the create flow stores. Reused
+		// for both validate and build below.
+		$line_items = $this->normalize_refund_totals( $line_items );
+
+		$validation_error = $this->validate_preview_line_items( $line_items, $order );
+
+		if ( is_wp_error( $validation_error ) ) {
+			return $validation_error;
+		}
+
+		try {
+			$preview = $this->build_refund_preview( $order, $line_items );
+		} catch ( \InvalidArgumentException $e ) {
+			// validate_preview_line_items above should have caught any bad input.
+			// If build_refund_preview still throws InvalidArgumentException, treat
+			// it as a server-side invariant violation, log for observability, and
+			// return a generic message (do not leak internal IDs to clients).
+			wc_get_logger()->error(
+				sprintf( 'Refund preview invariant violation on order %d: %s', $order->get_id(), $e->getMessage() ),
+				array( 'source' => $log_source )
+			);
+			return new WP_Error(
+				'invalid_preview_request',
+				__( 'The refund preview could not be generated due to an unexpected error.', 'woocommerce' ),
+				array( 'status' => WP_Http::INTERNAL_SERVER_ERROR )
+			);
+		} catch ( \Throwable $e ) {
+			wc_get_logger()->error(
+				sprintf( 'Refund preview unexpected error on order %d: %s', $order->get_id(), $e->getMessage() ),
+				array( 'source' => $log_source )
+			);
+			return new WP_Error(
+				'unexpected_preview_error',
+				__( 'An unexpected error occurred while generating the refund preview.', 'woocommerce' ),
+				array( 'status' => WP_Http::INTERNAL_SERVER_ERROR )
+			);
+		}
+
+		// Reject a non-positive aggregate total up front. A refund of only a negative
+		// discount line, or a product plus discount that nets to zero, would otherwise
+		// preview successfully and then fail at create time.
+		if ( (float) $preview['total'] <= 0 ) {
+			return new WP_Error(
+				'invalid_refund_amount',
+				__( 'Refund total must be greater than zero.', 'woocommerce' ),
+				array( 'status' => WP_Http::BAD_REQUEST )
+			);
+		}
+
+		// Final guard: even when per-line validation passes, the aggregate
+		// preview total can still exceed the order's remaining refundable
+		// amount (e.g. an amount-only partial refund applied previously).
+		// `total` is already tax-inclusive; compare directly against max_refundable.
+		$preview_total_with_tax = abs( (float) $preview['total'] );
+		if ( $preview_total_with_tax > (float) $preview['max_refundable'] ) {
+			return new WP_Error(
+				'preview_exceeds_max_refundable',
+				sprintf(
+					/* translators: 1: requested preview total including tax, 2: remaining refundable */
+					__( 'Requested refund preview (%1$s) exceeds the remaining refundable amount (%2$s).', 'woocommerce' ),
+					wc_format_decimal( $preview_total_with_tax, wc_get_price_decimals() ),
+					$preview['max_refundable']
+				),
+				array( 'status' => WP_Http::UNPROCESSABLE_ENTITY )
+			);
+		}
+
+		return $preview;
+	}
+
+	/**
+	 * Run the shared refund creation preparation pipeline for one request.
+	 *
+	 * Fills missing refund_total values from quantities, validates the line
+	 * items against the order's refund history, converts them to the internal
+	 * wc_create_refund() format, resolves the refund amount, and applies the
+	 * three aggregate guards (non-positive amount, an override below the line
+	 * items total, and an amount exceeding the order's remaining refundable
+	 * amount). Shared by the wc/v3 compute_totals path and the wc/v4 creation
+	 * endpoint so a fix lands once; error codes are emitted unprefixed with
+	 * their HTTP status in the error data, and each caller applies its own
+	 * surface convention. Request mutation, hooks, metadata, and the
+	 * wc_create_refund() call itself stay with the callers.
+	 *
+	 * The amount override is passed as a flag plus raw value because an
+	 * explicitly supplied zero or null must be rejected, never silently
+	 * replaced with the calculated total: a request meaning "refund nothing"
+	 * must not refund the full computed amount.
+	 *
+	 * @param WC_Order $order               The order the refund is prepared for.
+	 * @param array    $line_items          Line items in schema format (line_item_id keyed).
+	 * @param bool     $has_amount_override Whether the client explicitly supplied an amount.
+	 * @param mixed    $amount_override     The raw client-supplied amount, when $has_amount_override.
+	 * @param string   $log_source          Log source for invariant violations, per caller.
+	 * @return array|WP_Error Array with internal-format `line_items`, filled schema-format
+	 *                        `schema_line_items`, and the resolved raw `amount`, or WP_Error
+	 *                        carrying its HTTP status in the error data.
+	 *
+	 * @since 11.1.0
+	 */
+	public function prepare_refund_creation_or_error( WC_Order $order, array $line_items, bool $has_amount_override, $amount_override, string $log_source ) {
+		// 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 {
+			$line_items = $this->fill_missing_refund_totals( $line_items, $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' => $log_source )
+			);
+			return new WP_Error(
+				'invalid_refund_request',
+				__( 'The refund could not be created due to an unexpected error.', 'woocommerce' ),
+				array( 'status' => WP_Http::INTERNAL_SERVER_ERROR )
+			);
+		}
+
+		// The WP_Error already carries its HTTP status (400/422) in the error
+		// data, so create and preview return the same code for the same invalid
+		// input.
+		$validation_error = $this->validate_line_items( $line_items, $order );
+		if ( is_wp_error( $validation_error ) ) {
+			return $validation_error;
+		}
+
+		// Convert line items to internal format. refund_total is tax-inclusive when no
+		// explicit refund_tax is supplied (auto-computed values, or client values) — the
+		// converter splits the tax portion out via the line's stored total/tax ratio
+		// (split_inclusive_by_stored_ratio(), the same method the preview uses). When
+		// the client supplies an explicit refund_tax breakdown, refund_total is the
+		// tax-exclusive subtotal and the tax is added on top (core Woo semantics).
+		// Either way calculate_refund_amount sums refund_total + refund_tax to the
+		// gross line amount, so mixing the two forms across line items is well-defined.
+		$internal_line_items = $this->convert_line_items_to_internal_format( $line_items, $order );
+		$calculated_total    = ! empty( $line_items ) ? $this->calculate_refund_amount( $line_items ) : 0;
+
+		$amount = $has_amount_override ? $amount_override : $calculated_total;
+
+		if ( (float) $amount <= 0 ) {
+			return new WP_Error(
+				'invalid_refund_amount',
+				__( 'Refund total must be greater than zero.', 'woocommerce' ),
+				array( 'status' => WP_Http::BAD_REQUEST )
+			);
+		}
+
+		// Prevent under-refunding: the amount cannot be less than the calculated line
+		// items total. Over-refunding is allowed for goodwill/compensation scenarios.
+		if ( $has_amount_override && $calculated_total > 0 && NumberUtil::round( (float) $amount, wc_get_price_decimals() ) < NumberUtil::round( $calculated_total, wc_get_price_decimals() ) ) {
+			return new WP_Error(
+				'invalid_refund_amount',
+				sprintf(
+					/* translators: %1$s: refund amount, %2$s: calculated total from line items */
+					__( 'Refund amount (%1$s) cannot be less than the total of line items (%2$s).', 'woocommerce' ),
+					wc_format_decimal( $amount, wc_get_price_decimals() ),
+					wc_format_decimal( $calculated_total, wc_get_price_decimals() )
+				),
+				array( 'status' => WP_Http::BAD_REQUEST )
+			);
+		}
+
+		// Over-refunding line items is allowed (goodwill), but the amount can never
+		// exceed the order's remaining refundable amount. Reject up-front with a clear
+		// 422 rather than relying on wc_create_refund's generic failure, mirroring the
+		// preview pipeline's preview_exceeds_max_refundable guard.
+		$remaining_refundable = (float) $order->get_remaining_refund_amount();
+		if ( NumberUtil::round( (float) $amount, wc_get_price_decimals() ) > NumberUtil::round( $remaining_refundable, wc_get_price_decimals() ) ) {
+			return new WP_Error(
+				'refund_exceeds_remaining',
+				sprintf(
+					/* translators: %1$s: requested refund amount, %2$s: remaining refundable amount */
+					__( 'Refund amount (%1$s) exceeds the remaining refundable amount (%2$s).', 'woocommerce' ),
+					wc_format_decimal( $amount, wc_get_price_decimals() ),
+					wc_format_decimal( $remaining_refundable, wc_get_price_decimals() )
+				),
+				array( 'status' => WP_Http::UNPROCESSABLE_ENTITY )
+			);
+		}
+
+		return array(
+			'line_items'        => $internal_line_items,
+			'schema_line_items' => $line_items,
+			'amount'            => $amount,
+		);
+	}
+
+	/**
+	 * Get the REST argument schema for a preview request's line_items parameter.
+	 *
+	 * Shared by the wc/v3 and wc/v4 preview endpoints so the accepted payload
+	 * cannot drift between versions.
+	 *
+	 * @return array
+	 *
+	 * @since 11.1.0
+	 */
+	public function get_preview_line_items_arg_schema(): array {
+		return array(
+			'description'       => __( 'Line items to include in the refund preview.', 'woocommerce' ),
+			'type'              => 'array',
+			'required'          => true,
+			'minItems'          => 1,
+			'validate_callback' => 'rest_validate_request_arg',
+			// Hand-registered args get no default sanitizer; this coerces validated
+			// values to their schema types (numeric strings become numbers) like the
+			// args generated from an item schema.
+			'sanitize_callback' => 'rest_sanitize_request_arg',
+			'items'             => array(
+				'type'                 => 'object',
+				'required'             => array( 'line_item_id' ),
+				'additionalProperties' => false,
+				'properties'           => array(
+					'line_item_id' => array(
+						'description' => __( 'ID of the original order line item.', 'woocommerce' ),
+						'type'        => 'integer',
+						'minimum'     => 1,
+					),
+					'quantity'     => array(
+						'description' => __( 'Quantity to refund. Required when refund_total is omitted.', 'woocommerce' ),
+						'type'        => 'integer',
+						'minimum'     => 1,
+					),
+					'refund_total' => array(
+						// No `minimum` here on purpose: validate_preview_line_items() owns
+						// the sign rule and returns the actionable `invalid_refund_total`
+						// code. A refund_total must be non-zero and match the line's sign —
+						// negative is valid for a discount/credit line, positive for a normal
+						// line; zero and wrong-sign values are rejected. A schema `minimum`
+						// would wrongly forbid the negative form, and a generic
+						// `rest_invalid_param` is less useful to clients.
+						'description' => __( 'Tax-inclusive amount to refund for this line item. Must be non-zero and match the line\'s sign (negative for discount or credit lines, positive otherwise). Required when quantity is omitted.', 'woocommerce' ),
+						'type'        => array( 'number', 'null' ),
+					),
+				),
+			),
+		);
+	}
+
 	/**
 	 * Pre-compute refund data for all line items in an order.
 	 *
diff --git a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-order-refunds-computed-totals-test.php b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-order-refunds-computed-totals-test.php
new file mode 100644
index 00000000000..9ac38484dcc
--- /dev/null
+++ b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-order-refunds-computed-totals-test.php
@@ -0,0 +1,1280 @@
+<?php
+declare( strict_types=1 );
+
+use Automattic\WooCommerce\Enums\OrderStatus;
+
+/**
+ * Integration tests for the opt-in compute_totals mode of POST /wc/v3/orders/<order_id>/refunds,
+ * including backward-compatibility lock-in tests for the default (unflagged) path.
+ *
+ * @group order-refunds-computed-totals
+ */
+class WC_REST_Order_Refunds_Computed_Totals_Test extends WC_REST_Unit_Test_Case {
+
+	/**
+	 * Shared admin user ID. Created once per class to avoid the wp_insert_user cost
+	 * on every test.
+	 *
+	 * @var int
+	 */
+	protected static $user_id;
+
+	/**
+	 * Create the shared admin user once per class.
+	 */
+	public static function setUpBeforeClass(): void {
+		parent::setUpBeforeClass();
+
+		self::$user_id = wp_insert_user(
+			array(
+				'user_login' => 'v3_totals_admin_' . wp_generate_password( 6, false ),
+				'user_email' => 'v3_totals_admin_' . wp_generate_password( 6, false ) . '@example.com',
+				'user_pass'  => 'password',
+				'role'       => 'administrator',
+			)
+		);
+		if ( is_wp_error( self::$user_id ) ) {
+			self::fail( 'Could not create test admin user: ' . self::$user_id->get_error_message() );
+		}
+		self::$user_id = (int) self::$user_id;
+	}
+
+	/**
+	 * Delete the shared admin user once per class.
+	 */
+	public static function tearDownAfterClass(): void {
+		if ( self::$user_id ) {
+			wp_delete_user( self::$user_id );
+			self::$user_id = 0;
+		}
+		parent::tearDownAfterClass();
+	}
+
+	/**
+	 * Setup our test server, endpoints, and user info.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+
+		wp_set_current_user( self::$user_id );
+	}
+
+	/**
+	 * @testdox A quantity-only line item computes the refund amount server-side.
+	 */
+	public function test_quantity_only_computes_amount(): void {
+		$order   = $this->create_order_with_product( 25.00, 2 );
+		$item_id = $this->get_first_line_item_id( $order );
+
+		$response = $this->do_create_request(
+			$order->get_id(),
+			array(
+				'compute_totals' => true,
+				'line_items'     => array(
+					array(
+						'id'       => $item_id,
+						'quantity' => 1,
+					),
+				),
+			)
+		);
+
+		$this->assertEquals( 201, $response->get_status() );
+		$this->assertEquals( '25.00', $response->get_data()['amount'] );
+	}
+
+	/**
+	 * @testdox A quantity-only line item on a taxed order computes a tax-inclusive amount with a correct tax split.
+	 */
+	public function test_quantity_only_computes_amount_with_tax(): void {
+		$tax_rate_id = $this->create_tax_rate( 10.0 );
+		$order       = $this->create_order_with_product_and_tax( 100.00, 1, $tax_rate_id, 10.00 );
+		$item_id     = $this->get_first_line_item_id( $order );
+
+		$response = $this->do_create_request(
+			$order->get_id(),
+			array(
+				'compute_totals' => true,
+				'line_items'     => array(
+					array(
+						'id'       => $item_id,
+						'quantity' => 1,
+					),
+				),
+			)
+		);
+
+		$this->assertEquals( 201, $response->get_status() );
+		$this->assertEquals( '110.00', $response->get_data()['amount'], 'Computed amount should be tax-inclusive.' );
+
+		$refunds = wc_get_order( $order->get_id() )->get_refunds();
+		$this->assertCount( 1, $refunds );
+		$this->assertEquals( '10.00', wc_format_decimal( abs( (float) $refunds[0]->get_total_tax() ), 2 ), 'The stored tax portion should be split out of the inclusive amount.' );
+	}
+
+	/**
+	 * @testdox A partial quantity computes a proportional amount.
+	 */
+	public function test_multi_quantity_partial(): void {
+		$order   = $this->create_order_with_product( 10.00, 5 );
+		$item_id = $this->get_first_line_item_id( $order );
+
+		$response = $this->do_create_request(
+			$order->get_id(),
+			array(
+				'compute_totals' => true,
+				'line_items'     => array(
+					array(
+						'id'       => $item_id,
+						'quantity' => 2,
+					),
+				),
+			)
+		);
+
+		$this->assertEquals( 201, $response->get_status() );
+		$this->assertEquals( '20.00', $response->get_data()['amount'] );
+	}
+
+	/**
+	 * @testdox Computed and explicit-amount line items can be mixed in one request.
+	 */
+	public function test_mixed_computed_and_explicit_lines(): void {
+		$order = $this->create_order_with_product_and_fee( 30.00, 10.00 );
+		$items = $order->get_items( array( 'line_item', 'fee' ) );
+
+		$line_items = array();
+		foreach ( $items as $item_id => $item ) {
+			if ( $item instanceof WC_Order_Item_Fee ) {
+				$line_items[] = array(
+					'id'           => $item_id,
+					'refund_total' => 10.00,
+				);
+			} else {
+				$line_items[] = array(
+					'id'       => $item_id,
+					'quantity' => 1,
+				);
+			}
+		}
+
+		$response = $this->do_create_request(
+			$order->get_id(),
+			array(
+				'compute_totals' => true,
+				'line_items'     => $line_items,
+			)
+		);
+
+		$this->assertEquals( 201, $response->get_status() );
+		$this->assertEquals( '40.00', $response->get_data()['amount'], 'Computed product line (30) plus explicit fee (10) should total 40.' );
+	}
+
+	/**
+	 * @testdox In compute_totals mode a refund_total without refund_tax is treated as tax-inclusive.
+	 */
+	public function test_refund_total_without_refund_tax_is_tax_inclusive(): void {
+		$tax_rate_id = $this->create_tax_rate( 10.0 );
+		$order       = $this->create_order_with_product_and_tax( 100.00, 1, $tax_rate_id, 10.00 );
+		$item_id     = $this->get_first_line_item_id( $order );
+
+		$response = $this->do_create_request(
+			$order->get_id(),
+			array(
+				'compute_totals' => true,
+				'line_items'     => array(
+					array(
+						'id'           => $item_id,
+						'refund_total' => 55.00,
+					),
+				),
+			)
+		);
+
+		$this->assertEquals( 201, $response->get_status() );
+		$this->assertEquals( '55.00', $response->get_data()['amount'] );
+
+		$refunds = wc_get_order( $order->get_id() )->get_refunds();
+		$this->assertEquals( '5.00', wc_format_decimal( abs( (float) $refunds[0]->get_total_tax() ), 2 ), 'Half of the stored $10 tax should be split out of the inclusive 55.00.' );
+	}
+
+	/**
+	 * @testdox In compute_totals mode an explicit refund_tax keeps tax-exclusive refund_total semantics.
+	 */
+	public function test_explicit_refund_tax_keeps_net_semantics(): void {
+		$tax_rate_id = $this->create_tax_rate( 10.0 );
+		$order       = $this->create_order_with_product_and_tax( 100.00, 1, $tax_rate_id, 10.00 );
+		$item_id     = $this->get_first_line_item_id( $order );
+
+		$response = $this->do_create_request(
+			$order->get_id(),
+			array(
+				'compute_totals' => true,
+				'line_items'     => array(
+					array(
+						'id'           => $item_id,
+						'refund_total' => 50.00,
+						'refund_tax'   => array(
+							array(
+								'id'           => $tax_rate_id,
+								'refund_total' => 5.00,
+							),
+						),
+					),
+				),
+			)
+		);
+
+		$this->assertEquals( 201, $response->get_status() );
+		$this->assertEquals( '55.00', $response->get_data()['amount'], 'Net 50.00 plus explicit tax 5.00 should total 55.00.' );
+	}
+
+	/**
+	 * @testdox Duplicate tax IDs within a line return 400 duplicate_tax_id and create no refund.
+	 */
+	public function test_duplicate_refund_tax_ids_return_400(): void {
+		$tax_rate_id = $this->create_tax_rate( 10.0 );
+		$order       = $this->create_order_with_product_and_tax( 100.00, 1, $tax_rate_id, 10.00 );
+		$item_id     = $this->get_first_line_item_id( $order );
+
+		$response = $this->do_create_request(
+			$order->get_id(),
+			array(
+				'compute_totals' => true,
+				'line_items'     => array(
+					array(
+						'id'           => $item_id,
+						'refund_total' => 50.00,
+						'refund_tax'   => array(
+							array(
+								'id'           => $tax_rate_id,
+								'refund_total' => 5.00,
+							),
+							array(
+								'id'           => $tax_rate_id,
+								'refund_total' => 5.00,
+							),
+						),
+					),
+				),
+			)
+		);
+
+		$this->assertEquals( 400, $response->get_status() );
+		$this->assertEquals( 'woocommerce_rest_duplicate_tax_id', $response->get_data()['code'] );
+		$this->assertCount( 0, wc_get_order( $order->get_id() )->get_refunds(), 'A refund with inconsistent tax accounting must never be created.' );
+	}
+
+	/**
+	 * @testdox A quantity-form refund after a partial amount refund is clamped to the line's remaining refundable amount.
+	 */
+	public function test_quantity_refund_clamped_to_remaining(): void {
+		$order   = $this->create_order_with_product( 50.00, 2 );
+		$item_id = $this->get_first_line_item_id( $order );
+
+		wc_create_refund(
+			array(
+				'order_id'   => $order->get_id(),
+				'amount'     => 30.00,
+				'line_items' => array(
+					$item_id => array(
+						'qty'          => 0,
+						'refund_total' => 30.00,
+					),
+				),
+			)
+		);
+
+		$response = $this->do_create_request(
+			$order->get_id(),
+			array(
+				'compute_totals' => true,
+				'line_items'     => array(
+					array(
+						'id'       => $item_id,
+						'quantity' => 2,
+					),
+				),
+			)
+		);
+
+		$this->assertEquals( 201, $response->get_status() );
+		$this->assertEquals( '70.00', $response->get_data()['amount'], 'The 100.00 quantity amount should be clamped to the 70.00 remaining on the line.' );
+	}
+
+	/**
+	 * @testdox An amount-only request works in compute_totals mode.
+	 */
+	public function test_amount_only_refund(): void {
+		$order = $this->create_order_with_product( 50.00, 1 );
+
+		$response = $this->do_create_request(
+			$order->get_id(),
+			array(
+				'compute_totals' => true,
+				'amount'         => '10.00',
+			)
+		);
+
+		$this->assertEquals( 201, $response->get_status() );
+		$this->assertEquals( '10.00', $response->get_data()['amount'] );
+	}
+
+	/**
+	 * @testdox A quantity exceeding the refundable units returns 422 quantity_exceeds_refundable.
+	 */
+	public function test_over_quantity_returns_422(): void {
+		$order   = $this->create_order_with_product( 10.00, 2 );
+		$item_id = $this->get_first_line_item_id( $order );
+
+		$response = $this->do_create_request(
+			$order->get_id(),
+			array(
+				'compute_totals' => true,
+				'line_items'     => array(
+					array(
+						'id'       => $item_id,
+						'quantity' => 3,
+					),
+				),
+			)
+		);
+
+		$this->assertEquals( 422, $response->get_status() );
+		$this->assertEquals( 'woocommerce_rest_quantity_exceeds_refundable', $response->get_data()['code'] );
+	}
+
+	/**
+	 * @testdox A refund_total above the line total returns 422 refund_total_exceeds_line.
+	 */
+	public function test_refund_total_exceeds_line_returns_422(): void {
+		$order   = $this->create_order_with_product( 50.00, 1 );
+		$item_id = $this->get_first_line_item_id( $order );
+
+		$response = $this->do_create_request(
+			$order->get_id(),
+			array(
+				'compute_totals' => true,
+				'line_items'     => array(
+					array(
+						'id'           => $item_id,
+						'refund_total' => 60.00,
+					),
+				),
+			)
+		);
+
+		$this->assertEquals( 422, $response->get_status() );
+		$this->assertEquals( 'woocommerce_rest_refund_total_exceeds_line', $response->get_data()['code'] );
+	}
+
+	/**
+	 * @testdox A non-refundable order status returns 422 order_not_refundable.
+	 */
+	public function test_non_refundable_status_returns_422(): void {
+		$order   = $this->create_order_with_product( 10.00, 1 );
+		$item_id = $this->get_first_line_item_id( $order );
+		$order->set_status( OrderStatus::PENDING );
+		$order->save();
+
+		$response = $this->do_create_request(
+			$order->get_id(),
+			array(
+				'compute_totals' => true,
+				'line_items'     => array(
+					array(
+						'id'       => $item_id,
+						'quantity' => 1,
+					),
+				),
+			)
+		);
+
+		$this->assertEquals( 422, $response->get_status() );
+		$this->assertEquals( 'woocommerce_rest_order_not_refundable', $response->get_data()['code'] );
+	}
+
+	/**
+	 * @testdox An amount below the computed line total returns 400 invalid_refund_amount.
+	 */
+	public function test_amount_below_computed_total_returns_400(): void {
+		$order   = $this->create_order_with_product( 50.00, 1 );
+		$item_id = $this->get_first_line_item_id( $order );
+
+		$response = $this->do_create_request(
+			$order->get_id(),
+			array(
+				'compute_totals' => true,
+				'amount'         => '30.00',
+				'line_items'     => array(
+					array(
+						'id'       => $item_id,
+						'quantity' => 1,
+					),
+				),
+			)
+		);
+
+		$this->assertEquals( 400, $response->get_status() );
+		$this->assertEquals( 'woocommerce_rest_invalid_refund_amount', $response->get_data()['code'] );
+	}
+
+	/**
+	 * @testdox An amount above the order's remaining refundable amount returns 422 refund_exceeds_remaining.
+	 */
+	public function test_amount_above_remaining_returns_422(): void {
+		$order = $this->create_order_with_product( 50.00, 1 );
+
+		$response = $this->do_create_request(
+			$order->get_id(),
+			array(
+				'compute_totals' => true,
+				'amount'         => '80.00',
+			)
+		);
+
+		$this->assertEquals( 422, $response->get_status() );
+		$this->assertEquals( 'woocommerce_rest_refund_exceeds_remaining', $response->get_data()['code'] );
+	}
+
+	/**
+	 * @testdox An explicit zero amount returns 400 invalid_refund_amount instead of falling back to the computed total.
+	 */
+	public function test_explicit_zero_amount_returns_400(): void {
+		$order   = $this->create_order_with_product( 50.00, 1 );
+		$item_id = $this->get_first_line_item_id( $order );
+
+		$response = $this->do_create_request(
+			$order->get_id(),
+			array(
+				'compute_totals' => true,
+				'amount'         => '0.00',
+				'line_items'     => array(
+					array(
+						'id'       => $item_id,
+						'quantity' => 1,
+					),
+				),
+			)
+		);
+
+		$this->assertEquals( 400, $response->get_status() );
+		$this->assertEquals( 'woocommerce_rest_invalid_refund_amount', $response->get_data()['code'] );
+		$this->assertCount( 0, wc_get_order( $order->get_id() )->get_refunds(), 'No refund may be created for an explicit zero amount.' );
+	}
+
+	/**
+	 * @testdox A compute_totals refund matches the totals of the preview endpoint for the same input.
+	 */
+	public function test_matches_preview(): void {
+		$tax_rate_id = $this->create_tax_rate( 10.0 );
+		$order       = $this->create_order_with_product_and_tax( 100.00, 1, $tax_rate_id, 10.00 );
+		$item_id     = $this->get_first_line_item_id( $order );
+
+		$preview_request = new WP_REST_Request( 'POST', '/wc/v3/orders/' . $order->get_id() . '/refunds/preview' );
+		$preview_request->set_body_params(
+			array(
+				'line_items' => array(
+					array(
+						'line_item_id' => $item_id,
+						'quantity'     => 1,
+					),
+				),
+			)
+		);
+		$preview_response = $this->server->dispatch( $preview_request );
+		$this->assertEquals( 200, $preview_response->get_status() );
+
+		$create_response = $this->do_create_request(
+			$order->get_id(),
+			array(
+				'compute_totals' => true,
+				'line_items'     => array(
+					array(
+						'id'       => $item_id,
+						'quantity' => 1,
+					),
+				),
+			)
+		);
+		$this->assertEquals( 201, $create_response->get_status() );
+
+		$this->assertEquals(
+			$preview_response->get_data()['total'],
+			$create_response->get_data()['amount'],
+			'Preview total and computed create amount must match for identical input.'
+		);
+	}
+
+	/**
+	 * @testdox Computed amounts round to a whole unit in a zero-decimal currency.
+	 */
+	public function test_zero_decimal_currency(): void {
+		add_filter( 'wc_get_price_decimals', '__return_zero' );
+
+		try {
+			$order   = $this->create_order_with_product( 100.00, 1 );
+			$item_id = $this->get_first_line_item_id( $order );
+
+			$response = $this->do_create_request(
+				$order->get_id(),
+				array(
+					'compute_totals' => true,
+					'line_items'     => array(
+						array(
+							'id'       => $item_id,
+							'quantity' => 1,
+						),
+					),
+				)
+			);
+
+			$this->assertEquals( 201, $response->get_status() );
+			$this->assertEquals( '100', $response->get_data()['amount'] );
+		} finally {
+			remove_filter( 'wc_get_price_decimals', '__return_zero' );
+		}
+	}
+
+	/**
+	 * @testdox BC: without compute_totals a quantity-only request still creates a 0.00 refund.
+	 */
+	public function test_unflagged_quantity_only_still_creates_zero_refund(): void {
+		$order   = $this->create_order_with_product( 25.00, 2 );
+		$item_id = $this->get_first_line_item_id( $order );
+
+		// In production this request path emits a PHP warning (undefined refund_total
+		// array key in wc_create_refund; an E_NOTICE on PHP 7.4) and continues.
+		// PHPUnit converts it into an exception, which would make wc_create_refund
+		// fail with a 500 that does not happen outside the test runner — mask both
+		// error levels to test the real behavior.
+		// phpcs:ignore WordPress.PHP.DevelopmentFunctions.prevent_path_disclosure_error_reporting, WordPress.PHP.DiscouragedPHPFunctions.runtime_configuration_error_reporting -- scoped mask so PHPUnit does not convert a production-only PHP warning into an exception.
+		$error_reporting = error_reporting( E_ALL & ~E_WARNING & ~E_NOTICE );
+		try {
+			$response = $this->do_create_request(
+				$order->get_id(),
+				array(
+					'line_items' => array(
+						array(
+							'id'       => $item_id,
+							'quantity' => 1,
+						),
+					),
+				)
+			);
+		} finally {
+			// phpcs:ignore WordPress.PHP.DevelopmentFunctions.prevent_path_disclosure_error_reporting, WordPress.PHP.DiscouragedPHPFunctions.runtime_configuration_error_reporting -- restore the previous error_reporting level.
+			error_reporting( $error_reporting );
+		}
+
+		$this->assertEquals( 201, $response->get_status() );
+		$this->assertEquals( '', $response->get_data()['amount'], 'The pre-existing ghost zero-amount refund behavior (amount serialized as an empty string) must be preserved when the flag is absent.' );
+
+		$refunds = wc_get_order( $order->get_id() )->get_refunds();
+		$this->assertCount( 1, $refunds );
+		$this->assertEquals( 0.0, (float) $refunds[0]->get_amount() );
+	}
+
+	/**
+	 * @testdox BC: compute_totals explicitly false behaves the same as an absent flag.
+	 */
+	public function test_compute_totals_false_same_as_absent(): void {
+		$order   = $this->create_order_with_product( 25.00, 2 );
+		$item_id = $this->get_first_line_item_id( $order );
+
+		// See test_unflagged_quantity_only_still_creates_zero_refund for why the
+		// PHP warning this legacy path emits must be masked under PHPUnit.
+		// phpcs:ignore WordPress.PHP.DevelopmentFunctions.prevent_path_disclosure_error_reporting, WordPress.PHP.DiscouragedPHPFunctions.runtime_configuration_error_reporting -- scoped mask so PHPUnit does not convert a production-only PHP warning into an exception.
+		$error_reporting = error_reporting( E_ALL & ~E_WARNING & ~E_NOTICE );
+		try {
+			$response = $this->do_create_request(
+				$order->get_id(),
+				array(
+					'compute_totals' => false,
+					'line_items'     => array(
+						array(
+							'id'       => $item_id,
+							'quantity' => 1,
+						),
+					),
+				)
+			);
+		} finally {
+			// phpcs:ignore WordPress.PHP.DevelopmentFunctions.prevent_path_disclosure_error_reporting, WordPress.PHP.DiscouragedPHPFunctions.runtime_configuration_error_reporting -- restore the previous error_reporting level.
+			error_reporting( $error_reporting );
+		}
+
+		$this->assertEquals( 201, $response->get_status() );
+		$this->assertEquals( '', $response->get_data()['amount'] );
+	}
+
+	/**
+	 * @testdox BC: without compute_totals a refund_total without refund_tax keeps tax-exclusive semantics.
+	 */
+	public function test_unflagged_refund_total_stays_tax_exclusive(): void {
+		$tax_rate_id = $this->create_tax_rate( 10.0 );
+		$order       = $this->create_order_with_product_and_tax( 100.00, 1, $tax_rate_id, 10.00 );
+		$item_id     = $this->get_first_line_item_id( $order );
+
+		$response = $this->do_create_request(
+			$order->get_id(),
+			array(
+				'line_items' => array(
+					array(
+						'id'           => $item_id,
+						'refund_total' => 55.00,
+					),
+				),
+			)
+		);
+
+		$this->assertEquals( 201, $response->get_status() );
+		$this->assertEquals( '55.00', $response->get_data()['amount'] );
+
+		$refunds = wc_get_order( $order->get_id() )->get_refunds();
+		$this->assertEquals( 0.0, (float) $refunds[0]->get_total_tax(), 'Without the flag no tax may be split out of refund_total.' );
+	}
+
+	/**
+	 * @testdox BC: without compute_totals an explicit refund_total plus refund_tax sums to the amount as before.
+	 */
+	public function test_unflagged_explicit_net_plus_tax_sums_amount(): void {
+		$tax_rate_id = $this->create_tax_rate( 10.0 );
+		$order       = $this->create_order_with_product_and_tax( 100.00, 1, $tax_rate_id, 10.00 );
+		$item_id     = $this->get_first_line_item_id( $order );
+
+		$response = $this->do_create_request(
+			$order->get_id(),
+			array(
+				'line_items' => array(
+					array(
+						'id'           => $item_id,
+						'quantity'     => 1,
+						'refund_total' => 100.00,
+						'refund_tax'   => array(
+							array(
+								'id'           => $tax_rate_id,
+								'refund_total' => 10.00,
+							),
+						),
+					),
+				),
+			)
+		);
+
+		$this->assertEquals( 201, $response->get_status() );
+		$this->assertEquals( '110.00', $response->get_data()['amount'] );
+	}
+
+	/**
+	 * @testdox The compute_totals parameter is declared in the create schema with a false default.
+	 */
+	public function test_compute_totals_declared_in_schema(): void {
+		$order = $this->create_order_with_product( 10.00, 1 );
+
+		$request  = new WP_REST_Request( 'OPTIONS', '/wc/v3/orders/' . $order->get_id() . '/refunds' );
+		$response = $this->server->dispatch( $request );
+		$schema   = $response->get_data()['schema'];
+
+		$this->assertArrayHasKey( 'compute_totals', $schema['properties'] );
+		$this->assertFalse( $schema['properties']['compute_totals']['default'] );
+	}
+
+	/**
+	 * @testdox An array refund_total returns 400 invalid_refund_total instead of a TypeError.
+	 */
+	public function test_array_refund_total_returns_400(): void {
+		$order   = $this->create_order_with_product( 25.00, 1 );
+		$item_id = $this->get_first_line_item_id( $order );
+
+		$response = $this->do_create_request(
+			$order->get_id(),
+			array(
+				'compute_totals' => true,
+				'line_items'     => array(
+					array(
+						'id'           => $item_id,
+						'refund_total' => array( 10.00 ),
+					),
+				),
+			)
+		);
+
+		$this->assertEquals( 400, $response->get_status() );
+		$this->assertEquals( 'woocommerce_rest_invalid_refund_total', $response->get_data()['code'] );
+		$this->assertCount( 0, wc_get_order( $order->get_id() )->get_refunds() );
+	}
+
+	/**
+	 * @testdox A non-numeric quantity returns 400 invalid_quantity.
+	 */
+	public function test_non_numeric_quantity_returns_400(): void {
+		$order   = $this->create_order_with_product( 25.00, 1 );
+		$item_id = $this->get_first_line_item_id( $order );
+
+		$response = $this->do_create_request(
+			$order->get_id(),
+			array(
+				'compute_totals' => true,
+				'line_items'     => array(
+					array(
+						'id'       => $item_id,
+						'quantity' => 'two',
+					),
+				),
+			)
+		);
+
+		$this->assertEquals( 400, $response->get_status() );
+		$this->assertEquals( 'woocommerce_rest_invalid_quantity', $response->get_data()['code'] );
+	}
+
+	/**
+	 * @testdox A fractional quantity returns 400 invalid_quantity.
+	 */
+	public function test_fractional_quantity_returns_400(): void {
+		$order   = $this->create_order_with_product( 25.00, 2 );
+		$item_id = $this->get_first_line_item_id( $order );
+
+		$response = $this->do_create_request(
+			$order->get_id(),
+			array(
+				'compute_totals' => true,
+				'line_items'     => array(
+					array(
+						'id'       => $item_id,
+						'quantity' => 1.5,
+					),
+				),
+			)
+		);
+
+		$this->assertEquals( 400, $response->get_status() );
+		$this->assertEquals( 'woocommerce_rest_invalid_quantity', $response->get_data()['code'] );
+	}
+
+	/**
+	 * @testdox Numeric-string quantity and refund_total values are accepted and cast.
+	 */
+	public function test_numeric_string_values_are_accepted(): void {
+		$order   = $this->create_order_with_product( 25.00, 2 );
+		$item_id = $this->get_first_line_item_id( $order );
+
+		$response = $this->do_create_request(
+			$order->get_id(),
+			array(
+				'compute_totals' => true,
+				'line_items'     => array(
+					array(
+						'id'       => (string) $item_id,
+						'quantity' => '2',
+					),
+				),
+			)
+		);
+
+		$this->assertEquals( 201, $response->get_status() );
+		$this->assertEquals( '50.00', $response->get_data()['amount'] );
+	}
+
+	/**
+	 * @testdox A non-array refund_tax returns 400 invalid_line_item.
+	 */
+	public function test_non_array_refund_tax_returns_400(): void {
+		$order   = $this->create_order_with_product( 25.00, 1 );
+		$item_id = $this->get_first_line_item_id( $order );
+
+		$response = $this->do_create_request(
+			$order->get_id(),
+			array(
+				'compute_totals' => true,
+				'line_items'     => array(
+					array(
+						'id'           => $item_id,
+						'refund_total' => 10.00,
+						'refund_tax'   => 'nope',
+					),
+				),
+			)
+		);
+
+		$this->assertEquals( 400, $response->get_status() );
+		$this->assertEquals( 'woocommerce_rest_invalid_line_item', $response->get_data()['code'] );
+	}
+
+	/**
+	 * @testdox A refund_tax entry with a non-numeric refund_total returns 400 invalid_line_item.
+	 */
+	public function test_malformed_refund_tax_entry_returns_400(): void {
+		$order   = $this->create_order_with_product( 25.00, 1 );
+		$item_id = $this->get_first_line_item_id( $order );
+
+		$response = $this->do_create_request(
+			$order->get_id(),
+			array(
+				'compute_totals' => true,
+				'line_items'     => array(
+					array(
+						'id'           => $item_id,
+						'refund_total' => 10.00,
+						'refund_tax'   => array(
+							array(
+								'id'           => 1,
+								'refund_total' => array( 5.00 ),
+							),
+						),
+					),
+				),
+			)
+		);
+
+		$this->assertEquals( 400, $response->get_status() );
+		$this->assertEquals( 'woocommerce_rest_invalid_line_item', $response->get_data()['code'] );
+	}
+
+	/**
+	 * @testdox An array line item id returns 400 invalid_line_item.
+	 */
+	public function test_array_line_item_id_returns_400(): void {
+		$order = $this->create_order_with_product( 25.00, 1 );
+
+		$response = $this->do_create_request(
+			$order->get_id(),
+			array(
+				'compute_totals' => true,
+				'line_items'     => array(
+					array(
+						'id'       => array( 1 ),
+						'quantity' => 1,
+					),
+				),
+			)
+		);
+
+		$this->assertEquals( 400, $response->get_status() );
+		$this->assertEquals( 'woocommerce_rest_invalid_line_item', $response->get_data()['code'] );
+	}
+
+	/**
+	 * @testdox A fractional line item id returns 400 invalid_line_item instead of refunding a truncated id.
+	 */
+	public function test_fractional_line_item_id_returns_400(): void {
+		$order   = $this->create_order_with_product( 25.00, 1 );
+		$item_id = $this->get_first_line_item_id( $order );
+
+		$response = $this->do_create_request(
+			$order->get_id(),
+			array(
+				'compute_totals' => true,
+				'line_items'     => array(
+					array(
+						'id'       => $item_id + 0.5,
+						'quantity' => 1,
+					),
+				),
+			)
+		);
+
+		$this->assertEquals( 400, $response->get_status() );
+		$this->assertEquals( 'woocommerce_rest_invalid_line_item', $response->get_data()['code'] );
+		$this->assertCount( 0, wc_get_order( $order->get_id() )->get_refunds(), 'A fractional id must never be truncated into a refund of a different line.' );
+	}
+
+	/**
+	 * @testdox A fractional refund_tax id returns 400 invalid_line_item instead of refunding a truncated tax bucket.
+	 */
+	public function test_fractional_refund_tax_id_returns_400(): void {
+		$tax_rate_id = $this->create_tax_rate( 10.0 );
+		$order       = $this->create_order_with_product_and_tax( 100.00, 1, $tax_rate_id, 10.00 );
+		$item_id     = $this->get_first_line_item_id( $order );
+
+		$response = $this->do_create_request(
+			$order->get_id(),
+			array(
+				'compute_totals' => true,
+				'line_items'     => array(
+					array(
+						'id'           => $item_id,
+						'refund_total' => 50.00,
+						'refund_tax'   => array(
+							array(
+								'id'           => $tax_rate_id + 0.5,
+								'refund_total' => 5.00,
+							),
+						),
+					),
+				),
+			)
+		);
+
+		$this->assertEquals( 400, $response->get_status() );
+		$this->assertEquals( 'woocommerce_rest_invalid_line_item', $response->get_data()['code'] );
+		$this->assertCount( 0, wc_get_order( $order->get_id() )->get_refunds() );
+	}
+
+	/**
+	 * @testdox A scalar line_items entry returns 400 invalid_line_item.
+	 */
+	public function test_scalar_line_items_entry_returns_400(): void {
+		$order = $this->create_order_with_product( 25.00, 1 );
+
+		$response = $this->do_create_request(
+			$order->get_id(),
+			array(
+				'compute_totals' => true,
+				'line_items'     => array( 'not-an-object' ),
+			)
+		);
+
+		$this->assertEquals( 400, $response->get_status() );
+		$this->assertEquals( 'woocommerce_rest_invalid_line_item', $response->get_data()['code'] );
+	}
+
+	/**
+	 * @testdox Repeating the same line item id in one compute_totals request is rejected.
+	 */
+	public function test_duplicate_line_item_ids_rejected(): void {
+		$order   = $this->create_order_with_product( 10.00, 4 );
+		$item_id = $this->get_first_line_item_id( $order );
+
+		$response = $this->do_create_request(
+			$order->get_id(),
+			array(
+				'compute_totals' => true,
+				'line_items'     => array(
+					array(
+						'id'       => $item_id,
+						'quantity' => 1,
+					),
+					array(
+						'id'       => $item_id,
+						'quantity' => 1,
+					),
+				),
+			)
+		);
+
+		$this->assertEquals( 400, $response->get_status() );
+		$this->assertEquals( 'woocommerce_rest_duplicate_line_item', $response->get_data()['code'] );
+	}
+
+	/**
+	 * @testdox A compute_totals request against a nonexistent order id, or a refund id used as the order id, returns a 404.
+	 */
+	public function test_invalid_order_ids_return_404(): void {
+		$body = array(
+			'compute_totals' => true,
+			'line_items'     => array(),
+		);
+
+		$response = $this->do_create_request( 999999999, $body );
+		$this->assertEquals( 404, $response->get_status() );
+		$this->assertEquals( 'woocommerce_rest_invalid_order_id', $response->get_data()['code'] );
+
+		// A refund id resolves through wc_get_order to a WC_Order_Refund, which
+		// must be rejected the same way as a missing order.
+		$order  = $this->create_order_with_product( 10.00, 1 );
+		$refund = wc_create_refund(
+			array(
+				'order_id' => $order->get_id(),
+				'amount'   => 5.00,
+			)
+		);
+		$this->assertNotWPError( $refund );
+
+		$response = $this->do_create_request( $refund->get_id(), $body );
+		$this->assertEquals( 404, $response->get_status() );
+		$this->assertEquals( 'woocommerce_rest_invalid_order_id', $response->get_data()['code'] );
+	}
+
+	/**
+	 * @testdox The pre_insert filter receives internal-format line items and the resolved amount, matching the legacy path's request shape.
+	 */
+	public function test_pre_insert_filter_receives_internal_format(): void {
+		$order   = $this->create_order_with_product( 25.00, 2 );
+		$item_id = $this->get_first_line_item_id( $order );
+
+		$captured = null;
+		$capture  = function ( $refund, $request, $creating ) use ( &$captured ) {
+			$captured = array(
+				'line_items' => $request['line_items'],
+				'amount'     => $request['amount'],
+				'creating'   => $creating,
+			);
+			return $refund;
+		};
+		add_filter( 'woocommerce_rest_pre_insert_shop_order_refund_object', $capture, 10, 3 );
+
+		try {
+			$response = $this->do_create_request(
+				$order->get_id(),
+				array(
+					'compute_totals' => true,
+					'line_items'     => array(
+						array(
+							'id'       => $item_id,
+							'quantity' => 1,
+						),
+					),
+				)
+			);
+		} finally {
+			remove_filter( 'woocommerce_rest_pre_insert_shop_order_refund_object', $capture );
+		}
+
+		$this->assertEquals( 201, $response->get_status() );
+		$this->assertNotNull( $captured, 'The pre_insert filter should run for compute_totals requests.' );
+		$this->assertTrue( $captured['creating'] );
+		$this->assertSame( '25', $captured['amount'], 'The resolved amount should be mirrored onto the request.' );
+		$this->assertSame( array( $item_id ), array_keys( $captured['line_items'] ), 'line_items should be keyed by item id (internal format).' );
+		$this->assertSame( 1, $captured['line_items'][ $item_id ]['qty'] );
+		$this->assertEquals( 25.00, $captured['line_items'][ $item_id ]['refund_total'] );
+	}
+
+	/**
+	 * @testdox A wc_create_refund failure returns the same error code and status with and without compute_totals.
+	 */
+	public function test_create_failure_code_and_status_match_legacy(): void {
+		$order   = $this->create_order_with_product( 10.00, 1 );
+		$item_id = $this->get_first_line_item_id( $order );
+
+		$fail = function () {
+			throw new Exception( 'Simulated create failure.' );
+		};
+		add_action( 'woocommerce_create_refund', $fail );
+
+		try {
+			$computed = $this->do_create_request(
+				$order->get_id(),
+				array(
+					'compute_totals' => true,
+					'line_items'     => array(
+						array(
+							'id'       => $item_id,
+							'quantity' => 1,
+						),
+					),
+				)
+			);
+
+			$legacy = $this->do_create_request( $order->get_id(), array( 'amount' => '5.00' ) );
+		} finally {
+			remove_action( 'woocommerce_create_refund', $fail );
+		}
+
+		$this->assertEquals( 500, $computed->get_status() );
+		$this->assertEquals( 'woocommerce_rest_cannot_create_order_refund', $computed->get_data()['code'] );
+		$this->assertEquals( $legacy->get_status(), $computed->get_status(), 'Both paths should fail with the same HTTP status.' );
+		$this->assertEquals( $legacy->get_data()['code'], $computed->get_data()['code'], 'Both paths should fail with the same error code.' );
+	}
+
+	/**
+	 * @testdox A line item carrying both id and line_item_id is rejected as ambiguous.
+	 */
+	public function test_conflicting_line_identifiers_rejected(): void {
+		$order   = $this->create_order_with_product( 10.00, 2 );
+		$item_id = $this->get_first_line_item_id( $order );
+
+		$response = $this->do_create_request(
+			$order->get_id(),
+			array(
+				'compute_totals' => true,
+				'line_items'     => array(
+					array(
+						'id'           => $item_id,
+						'line_item_id' => $item_id + 1,
+						'quantity'     => 1,
+					),
+				),
+			)
+		);
+
+		$this->assertEquals( 400, $response->get_status() );
+		$this->assertEquals( 'woocommerce_rest_invalid_line_item', $response->get_data()['code'] );
+		$this->assertCount( 0, wc_get_order( $order->get_id() )->get_refunds(), 'No refund should be created from an ambiguous line identifier.' );
+	}
+
+	/**
+	 * @testdox A line item keyed by line_item_id alone is accepted, matching the preview payload shape.
+	 */
+	public function test_line_item_id_key_accepted(): void {
+		$order   = $this->create_order_with_product( 25.00, 2 );
+		$item_id = $this->get_first_line_item_id( $order );
+
+		$response = $this->do_create_request(
+			$order->get_id(),
+			array(
+				'compute_totals' => true,
+				'line_items'     => array(
+					array(
+						'line_item_id' => $item_id,
+						'quantity'     => 1,
+					),
+				),
+			)
+		);
+
+		$this->assertEquals( 201, $response->get_status() );
+		$this->assertEquals( '25.00', $response->get_data()['amount'] );
+	}
+
+	/**
+	 * Create a completed order with a single product line item.
+	 *
+	 * @param float $unit_price Unit price.
+	 * @param int   $quantity   Quantity.
+	 * @return WC_Order
+	 */
+	private function create_order_with_product( float $unit_price, int $quantity ): WC_Order {
+		$product = WC_Helper_Product::create_simple_product();
+		$product->set_regular_price( $unit_price );
+		$product->save();
+
+		$order = wc_create_order();
+		$item  = new WC_Order_Item_Product();
+		$item->set_props(
+			array(
+				'product'  => $product,
+				'quantity' => $quantity,
+				'subtotal' => $unit_price * $quantity,
+				'total'    => $unit_price * $quantity,
+			)
+		);
+		$item->save();
+		$order->add_item( $item );
+		$order->set_total( $unit_price * $quantity );
+		$order->set_status( OrderStatus::COMPLETED );
+		$order->save();
+
+		$product->delete( true );
+
+		return $order;
+	}
+
+	/**
+	 * Create a completed order with one product line item and one fee line.
+	 *
+	 * @param float $product_price Product price.
+	 * @param float $fee_total     Fee total.
+	 * @return WC_Order
+	 */
+	private function create_order_with_product_and_fee( float $product_price, float $fee_total ): WC_Order {
+		$order = $this->create_order_with_product( $product_price, 1 );
+
+		$fee = new WC_Order_Item_Fee();
+		$fee->set_props(
+			array(
+				'name'  => 'Service fee',
+				'total' => $fee_total,
+			)
+		);
+		$fee->save();
+		$order->add_item( $fee );
+		$order->set_total( $product_price + $fee_total );
+		$order->save();
+
+		return $order;
+	}
+
+	/**
+	 * Create an order with a product and tax.
+	 *
+	 * @param float $product_price Product price.
+	 * @param int   $quantity      Quantity.
+	 * @param int   $tax_rate_id   Tax rate ID.
+	 * @param float $tax_amount    Tax amount.
+	 * @return WC_Order
+	 */
+	private function create_order_with_product_and_tax( float $product_price, int $quantity, int $tax_rate_id, float $tax_amount ): WC_Order {
+		$product = WC_Helper_Product::create_simple_product();
+		$product->set_regular_price( $product_price );
+		$product->set_tax_status( 'taxable' );
+		$product->save();
+
+		$total = $product_price * $quantity;
+		$order = wc_create_order();
+		$item  = new WC_Order_Item_Product();
+		$item->set_props(
+			array(
+				'product'  => $product,
+				'quantity' => $quantity,
+				'subtotal' => $total,
+				'total'    => $total,
+			)
+		);
+		$item->set_taxes(
+			array(
+				'total'    => array( $tax_rate_id => $tax_amount ),
+				'subtotal' => array( $tax_rate_id => $tax_amount ),
+			)
+		);
+		$item->save();
+		$order->add_item( $item );
+
+		$tax_item = new WC_Order_Item_Tax();
+		$tax_item->set_rate( $tax_rate_id );
+		$tax_item->set_tax_total( $tax_amount );
+		$tax_item->save();
+		$order->add_item( $tax_item );
+
+		$order->set_billing_country( 'US' );
+		$order->set_total( $total + $tax_amount );
+		$order->set_status( OrderStatus::COMPLETED );
+		$order->save();
+
+		$product->delete( true );
+
+		return $order;
+	}
+
+	/**
+	 * Create a tax rate.
+	 *
+	 * @param float $rate Tax rate percentage.
+	 * @return int Tax rate ID.
+	 */
+	private function create_tax_rate( float $rate ): int {
+		return WC_Tax::_insert_tax_rate(
+			array(
+				'tax_rate_country'  => 'US',
+				'tax_rate_state'    => '',
+				'tax_rate'          => number_format( $rate, 4 ),
+				'tax_rate_name'     => 'Tax',
+				'tax_rate_priority' => '1',
+				'tax_rate_compound' => '0',
+				'tax_rate_shipping' => '1',
+				'tax_rate_order'    => '1',
+				'tax_rate_class'    => '',
+			)
+		);
+	}
+
+	/**
+	 * Get the first line item ID from an order.
+	 *
+	 * @param WC_Order $order Order instance.
+	 * @return int Line item ID.
+	 */
+	private function get_first_line_item_id( WC_Order $order ): int {
+		$items = $order->get_items( 'line_item' );
+		$item  = reset( $items );
+		return $item->get_id();
+	}
+
+	/**
+	 * Send a refund creation request and return the response.
+	 *
+	 * api_refund is forced to false unless the test supplies it: the test orders
+	 * have no payment gateway, so the v3 default of true would make wc_refund_payment
+	 * fail and delete the refund.
+	 *
+	 * @param int   $order_id Order ID.
+	 * @param array $body     Request body parameters.
+	 * @return WP_REST_Response
+	 */
+	private function do_create_request( int $order_id, array $body ): WP_REST_Response {
+		$request = new WP_REST_Request( 'POST', '/wc/v3/orders/' . $order_id . '/refunds' );
+		$request->set_body_params( array_merge( array( 'api_refund' => false ), $body ) );
+		return $this->server->dispatch( $request );
+	}
+}
diff --git a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-order-refunds-preview-test.php b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-order-refunds-preview-test.php
new file mode 100644
index 00000000000..b0f34692b8a
--- /dev/null
+++ b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-order-refunds-preview-test.php
@@ -0,0 +1,1337 @@
+<?php
+declare( strict_types=1 );
+
+use Automattic\WooCommerce\Enums\OrderStatus;
+
+/**
+ * Integration tests for the POST /wc/v3/orders/<order_id>/refunds/preview endpoint.
+ *
+ * @group order-refunds-preview
+ */
+class WC_REST_Order_Refunds_Preview_Test extends WC_REST_Unit_Test_Case {
+
+	/**
+	 * Shared admin user ID. Created once per class to avoid the wp_insert_user cost
+	 * on every test.
+	 *
+	 * @var int
+	 */
+	protected static $user_id;
+
+	/**
+	 * Create the shared admin user once per class.
+	 */
+	public static function setUpBeforeClass(): void {
+		parent::setUpBeforeClass();
+
+		self::$user_id = wp_insert_user(
+			array(
+				'user_login' => 'v3_preview_admin_' . wp_generate_password( 6, false ),
+				'user_email' => 'v3_preview_admin_' . wp_generate_password( 6, false ) . '@example.com',
+				'user_pass'  => 'password',
+				'role'       => 'administrator',
+			)
+		);
+		if ( is_wp_error( self::$user_id ) ) {
+			self::fail( 'Could not create test admin user: ' . self::$user_id->get_error_message() );
+		}
+		self::$user_id = (int) self::$user_id;
+	}
+
+	/**
+	 * Delete the shared admin user once per class.
+	 */
+	public static function tearDownAfterClass(): void {
+		if ( self::$user_id ) {
+			wp_delete_user( self::$user_id );
+			self::$user_id = 0;
+		}
+		parent::tearDownAfterClass();
+	}
+
+	/**
+	 * Setup our test server, endpoints, and user info.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+
+		wp_set_current_user( self::$user_id );
+	}
+
+	/**
+	 * Runs after each test.
+	 */
+	public function tearDown(): void {
+		remove_all_filters( 'woocommerce_rest_prepare_order_refund_preview' );
+
+		global $wp_rest_additional_fields;
+		unset( $wp_rest_additional_fields['order_refund_preview'] );
+
+		parent::tearDown();
+	}
+
+	/**
+	 * @testdox The preview route is registered under wc/v3.
+	 */
+	public function test_preview_route_is_registered(): void {
+		$routes = $this->server->get_routes();
+
+		$this->assertArrayHasKey( '/wc/v3/orders/(?P<order_id>[\d]+)/refunds/preview', $routes );
+	}
+
+	/**
+	 * @testdox Preview a single full line item with no tax returns correct totals.
+	 */
+	public function test_preview_single_line_item_no_tax(): void {
+		$order   = $this->create_order_with_product( 50.00, 2 );
+		$item_id = $this->get_first_line_item_id( $order );
+
+		$response = $this->do_preview_request(
+			$order->get_id(),
+			array(
+				array(
+					'line_item_id' => $item_id,
+					'quantity'     => 2,
+				),
+			)
+		);
+
+		$this->assertEquals( 200, $response->get_status() );
+		$data = $response->get_data();
+
+		$this->assertEquals( '100.00', $data['subtotal'] );
+		$this->assertEquals( '0.00', $data['tax'] );
+		$this->assertEquals( '100.00', $data['total'] );
+		$this->assertCount( 1, $data['breakdown']['products']['items'] );
+		$this->assertEquals( 2, $data['breakdown']['products']['items'][0]['quantity'] );
+	}
+
+	/**
+	 * @testdox Preview a single line item with 10% tax extracts tax correctly.
+	 */
+	public function test_preview_single_line_item_with_tax(): void {
+		$tax_rate_id = $this->create_tax_rate( 10.0 );
+		$order       = $this->create_order_with_product_and_tax( 100.00, 1, $tax_rate_id, 10.00 );
+		$item_id     = $this->get_first_line_item_id( $order );
+
+		$response = $this->do_preview_request(
+			$order->get_id(),
+			array(
+				array(
+					'line_item_id' => $item_id,
+					'quantity'     => 1,
+				),
+			)
+		);
+
+		$this->assertEquals( 200, $response->get_status() );
+		$data = $response->get_data();
+
+		$this->assertEquals( '100.00', $data['subtotal'] );
+		$this->assertEquals( '10.00', $data['tax'] );
+		$this->assertEquals( '110.00', $data['total'] );
+	}
+
+	/**
+	 * @testdox Preview partial quantity returns proportional totals.
+	 */
+	public function test_preview_partial_quantity(): void {
+		$order   = $this->create_order_with_product( 10.00, 5 );
+		$item_id = $this->get_first_line_item_id( $order );
+
+		$response = $this->do_preview_request(
+			$order->get_id(),
+			array(
+				array(
+					'line_item_id' => $item_id,
+					'quantity'     => 2,
+				),
+			)
+		);
+
+		$this->assertEquals( 200, $response->get_status() );
+		$data = $response->get_data();
+
+		$this->assertEquals( '20.00', $data['total'], 'Partial refund of 2 of 5 at $10 each should be $20' );
+		$this->assertEquals( 2, $data['breakdown']['products']['items'][0]['quantity'] );
+	}
+
+	/**
+	 * @testdox Preview multiple line items returns aggregated totals.
+	 */
+	public function test_preview_multiple_line_items(): void {
+		$order = $this->create_order_with_two_products( 20.00, 30.00 );
+		$items = array_values( $order->get_items( 'line_item' ) );
+
+		$response = $this->do_preview_request(
+			$order->get_id(),
+			array(
+				array(
+					'line_item_id' => $items[0]->get_id(),
+					'quantity'     => 1,
+				),
+				array(
+					'line_item_id' => $items[1]->get_id(),
+					'quantity'     => 1,
+				),
+			)
+		);
+
+		$this->assertEquals( 200, $response->get_status() );
+		$data = $response->get_data();
+
+		$this->assertEquals( '50.00', $data['total'], '20 + 30 = 50' );
+		$this->assertCount( 2, $data['breakdown']['products']['items'] );
+	}
+
+	/**
+	 * @testdox Preview a shipping line returns the shipping breakdown section.
+	 */
+	public function test_preview_shipping_line(): void {
+		$order = $this->create_order_with_shipping( 15.00 );
+		$items = $order->get_items( 'shipping' );
+		$item  = reset( $items );
+
+		$response = $this->do_preview_request(
+			$order->get_id(),
+			array(
+				array(
+					'line_item_id' => $item->get_id(),
+					'quantity'     => 1,
+				),
+			)
+		);
+
+		$this->assertEquals( 200, $response->get_status() );
+		$data = $response->get_data();
+
+		$this->assertEquals( '15.00', $data['total'] );
+		$this->assertCount( 1, $data['breakdown']['shipping']['items'] );
+		$this->assertCount( 0, $data['breakdown']['products']['items'] );
+	}
+
+	/**
+	 * @testdox Preview a fee line returns the fees breakdown section.
+	 */
+	public function test_preview_fee_line(): void {
+		$order = $this->create_order_with_fee( 7.50 );
+		$items = $order->get_items( 'fee' );
+		$item  = reset( $items );
+
+		$response = $this->do_preview_request(
+			$order->get_id(),
+			array(
+				array(
+					'line_item_id' => $item->get_id(),
+					'quantity'     => 1,
+				),
+			)
+		);
+
+		$this->assertEquals( 200, $response->get_status() );
+		$data = $response->get_data();
+
+		$this->assertEquals( '7.50', $data['total'] );
+		$this->assertCount( 1, $data['breakdown']['fees']['items'] );
+	}
+
+	/**
+	 * @testdox An explicit refund_total overrides the quantity for the refunded amount.
+	 */
+	public function test_preview_refund_total_overrides_quantity(): void {
+		$order   = $this->create_order_with_product( 50.00, 2 );
+		$item_id = $this->get_first_line_item_id( $order );
+
+		$response = $this->do_preview_request(
+			$order->get_id(),
+			array(
+				array(
+					'line_item_id' => $item_id,
+					'quantity'     => 2,
+					'refund_total' => 30.00,
+				),
+			)
+		);
+
+		$this->assertEquals( 200, $response->get_status() );
+		$data = $response->get_data();
+
+		$this->assertEquals( '30.00', $data['total'], 'refund_total should override the quantity-derived amount' );
+	}
+
+	/**
+	 * @testdox A partial-amount preview splits tax so subtotal + tax reconstitutes the total.
+	 */
+	public function test_preview_partial_amount_tax_split_reconstitutes_total(): void {
+		$tax_rate_id = $this->create_tax_rate( 10.0 );
+		// $100 net + $10 tax = $110 incl. Refund $55 (half): expect 50.00 net + 5.00 tax.
+		$order   = $this->create_order_with_product_and_tax( 100.00, 1, $tax_rate_id, 10.00 );
+		$item_id = $this->get_first_line_item_id( $order );
+
+		$response = $this->do_preview_request(
+			$order->get_id(),
+			array(
+				array(
+					'line_item_id' => $item_id,
+					'refund_total' => 55.00,
+				),
+			)
+		);
+
+		$this->assertEquals( 200, $response->get_status() );
+		$data = $response->get_data();
+		$item = $data['breakdown']['products']['items'][0];
+
+		$this->assertEquals( '50.00', $item['subtotal'], 'Net subtotal should be half of the $100 net.' );
+		$this->assertEquals( '5.00', $item['tax'], 'Tax should be half of the $10 stored tax.' );
+		$this->assertEquals( '55.00', $item['total'], 'Total should equal the requested refund_total.' );
+		$this->assertEquals(
+			$item['total'],
+			wc_format_decimal( (float) $item['subtotal'] + (float) $item['tax'], wc_get_price_decimals() ),
+			'subtotal + tax must reconstitute the total to the cent.'
+		);
+	}
+
+	/**
+	 * @testdox Preview with quantity exceeding refundable units returns quantity_exceeds_refundable.
+	 */
+	public function test_preview_quantity_exceeds_refundable(): void {
+		$order   = $this->create_order_with_product( 25.00, 2 );
+		$item_id = $this->get_first_line_item_id( $order );
+
+		wc_create_refund(
+			array(
+				'order_id'   => $order->get_id(),
+				'amount'     => 25.00,
+				'line_items' => array(
+					$item_id => array(
+						'qty'          => 1,
+						'refund_total' => 25.00,
+					),
+				),
+			)
+		);
+
+		$response = $this->do_preview_request(
+			$order->get_id(),
+			array(
+				array(
+					'line_item_id' => $item_id,
+					'quantity'     => 2,
+				),
+			)
+		);
+
+		$this->assertEquals( 422, $response->get_status() );
+		$this->assertEquals( 'woocommerce_rest_quantity_exceeds_refundable', $response->get_data()['code'] );
+	}
+
+	/**
+	 * @testdox Preview returns 422 when the total exceeds the remaining refundable amount.
+	 */
+	public function test_preview_returns_422_when_total_exceeds_max_refundable(): void {
+		$order   = $this->create_order_with_product( 50.00, 2 );
+		$item_id = $this->get_first_line_item_id( $order );
+
+		// Amount-only partial refund leaves the line untouched but shrinks the
+		// order's remaining refundable amount to $40.
+		wc_create_refund(
+			array(
+				'order_id' => $order->get_id(),
+				'amount'   => 60.00,
+			)
+		);
+
+		$response = $this->do_preview_request(
+			$order->get_id(),
+			array(
+				array(
+					'line_item_id' => $item_id,
+					'quantity'     => 2,
+				),
+			)
+		);
+
+		$this->assertEquals( 422, $response->get_status() );
+		$this->assertEquals( 'woocommerce_rest_preview_exceeds_max_refundable', $response->get_data()['code'] );
+	}
+
+	/**
+	 * @testdox Preview on a fully refunded order returns order_not_refundable.
+	 */
+	public function test_preview_fully_refunded_order(): void {
+		$order   = $this->create_order_with_product( 30.00, 1 );
+		$item_id = $this->get_first_line_item_id( $order );
+
+		wc_create_refund(
+			array(
+				'order_id'   => $order->get_id(),
+				'amount'     => 30.00,
+				'line_items' => array(
+					$item_id => array(
+						'qty'          => 1,
+						'refund_total' => 30.00,
+					),
+				),
+			)
+		);
+
+		$response = $this->do_preview_request(
+			$order->get_id(),
+			array(
+				array(
+					'line_item_id' => $item_id,
+					'quantity'     => 1,
+				),
+			)
+		);
+
+		$this->assertEquals( 422, $response->get_status() );
+		$this->assertEquals( 'woocommerce_rest_order_not_refundable', $response->get_data()['code'] );
+	}
+
+	/**
+	 * @testdox An amount-form preview of an already fully refunded line returns line_item_already_refunded.
+	 */
+	public function test_preview_fully_refunded_line(): void {
+		$order = $this->create_order_with_two_products( 20.00, 30.00 );
+		$items = array_values( $order->get_items( 'line_item' ) );
+
+		wc_create_refund(
+			array(
+				'order_id'   => $order->get_id(),
+				'amount'     => 20.00,
+				'line_items' => array(
+					$items[0]->get_id() => array(
+						'qty'          => 1,
+						'refund_total' => 20.00,
+					),
+				),
+			)
+		);
+
+		$response = $this->do_preview_request(
+			$order->get_id(),
+			array(
+				array(
+					'line_item_id' => $items[0]->get_id(),
+					'refund_total' => 5.00,
+				),
+			)
+		);
+
+		$this->assertEquals( 422, $response->get_status() );
+		$this->assertEquals( 'woocommerce_rest_line_item_already_refunded', $response->get_data()['code'] );
+	}
+
+	/**
+	 * @testdox Preview with an empty line_items array is rejected by the schema.
+	 */
+	public function test_preview_empty_line_items(): void {
+		$order = $this->create_order_with_product( 10.00, 1 );
+
+		$response = $this->do_preview_request( $order->get_id(), array() );
+
+		$this->assertEquals( 400, $response->get_status() );
+		$this->assertEquals( 'rest_invalid_param', $response->get_data()['code'] );
+	}
+
+	/**
+	 * @testdox Preview with the same line item twice returns duplicate_line_item.
+	 */
+	public function test_preview_duplicate_line_item(): void {
+		$order   = $this->create_order_with_product( 10.00, 2 );
+		$item_id = $this->get_first_line_item_id( $order );
+
+		$response = $this->do_preview_request(
+			$order->get_id(),
+			array(
+				array(
+					'line_item_id' => $item_id,
+					'quantity'     => 1,
+				),
+				array(
+					'line_item_id' => $item_id,
+					'quantity'     => 1,
+				),
+			)
+		);
+
+		$this->assertEquals( 400, $response->get_status() );
+		$this->assertEquals( 'woocommerce_rest_duplicate_line_item', $response->get_data()['code'] );
+	}
+
+	/**
+	 * @testdox Preview with a line item from a different order returns line_item_not_found.
+	 */
+	public function test_preview_cross_order_line_item(): void {
+		$order       = $this->create_order_with_product( 10.00, 1 );
+		$other_order = $this->create_order_with_product( 10.00, 1 );
+
+		$response = $this->do_preview_request(
+			$order->get_id(),
+			array(
+				array(
+					'line_item_id' => $this->get_first_line_item_id( $other_order ),
+					'quantity'     => 1,
+				),
+			)
+		);
+
+		$this->assertEquals( 400, $response->get_status() );
+		$this->assertEquals( 'woocommerce_rest_line_item_not_found', $response->get_data()['code'] );
+	}
+
+	/**
+	 * @testdox Preview with a zero refund_total returns invalid_refund_total.
+	 */
+	public function test_preview_zero_refund_total(): void {
+		$order   = $this->create_order_with_product( 10.00, 1 );
+		$item_id = $this->get_first_line_item_id( $order );
+
+		$response = $this->do_preview_request(
+			$order->get_id(),
+			array(
+				array(
+					'line_item_id' => $item_id,
+					'refund_total' => 0,
+				),
+			)
+		);
+
+		$this->assertEquals( 400, $response->get_status() );
+		$this->assertEquals( 'woocommerce_rest_invalid_refund_total', $response->get_data()['code'] );
+	}
+
+	/**
+	 * @testdox Preview with a null refund_total and a quantity falls back to the quantity amount.
+	 */
+	public function test_preview_null_refund_total_with_quantity_uses_quantity(): void {
+		$order   = $this->create_order_with_product( 25.00, 2 );
+		$item_id = $this->get_first_line_item_id( $order );
+
+		$response = $this->do_preview_request(
+			$order->get_id(),
+			array(
+				array(
+					'line_item_id' => $item_id,
+					'quantity'     => 1,
+					'refund_total' => null,
+				),
+			)
+		);
+
+		$this->assertEquals( 200, $response->get_status() );
+		$this->assertEquals( '25.00', $response->get_data()['total'] );
+	}
+
+	/**
+	 * @testdox Preview with neither quantity nor refund_total returns missing_quantity_or_refund_total.
+	 */
+	public function test_preview_missing_quantity_and_refund_total(): void {
+		$order   = $this->create_order_with_product( 10.00, 1 );
+		$item_id = $this->get_first_line_item_id( $order );
+
+		$response = $this->do_preview_request(
+			$order->get_id(),
+			array(
+				array(
+					'line_item_id' => $item_id,
+				),
+			)
+		);
+
+		$this->assertEquals( 400, $response->get_status() );
+		$this->assertEquals( 'woocommerce_rest_missing_quantity_or_refund_total', $response->get_data()['code'] );
+	}
+
+	/**
+	 * @testdox Preview with an unknown order ID returns woocommerce_rest_invalid_order_id.
+	 */
+	public function test_preview_unknown_order_id(): void {
+		$response = $this->do_preview_request(
+			999999999,
+			array(
+				array(
+					'line_item_id' => 1,
+					'quantity'     => 1,
+				),
+			)
+		);
+
+		$this->assertEquals( 404, $response->get_status() );
+		$this->assertEquals( 'woocommerce_rest_invalid_order_id', $response->get_data()['code'] );
+	}
+
+	/**
+	 * @testdox Preview with a refund ID as the order ID is rejected with a 404.
+	 */
+	public function test_preview_refund_id_rejected(): void {
+		$order   = $this->create_order_with_product( 30.00, 1 );
+		$item_id = $this->get_first_line_item_id( $order );
+
+		$refund = wc_create_refund(
+			array(
+				'order_id' => $order->get_id(),
+				'amount'   => 10.00,
+			)
+		);
+
+		$response = $this->do_preview_request(
+			$refund->get_id(),
+			array(
+				array(
+					'line_item_id' => $item_id,
+					'quantity'     => 1,
+				),
+			)
+		);
+
+		$this->assertEquals( 404, $response->get_status() );
+		$this->assertEquals( 'woocommerce_rest_invalid_order_id', $response->get_data()['code'] );
+	}
+
+	/**
+	 * @testdox Preview requires authentication.
+	 */
+	public function test_preview_unauthenticated(): void {
+		$order   = $this->create_order_with_product( 10.00, 1 );
+		$item_id = $this->get_first_line_item_id( $order );
+
+		wp_set_current_user( 0 );
+
+		$response = $this->do_preview_request(
+			$order->get_id(),
+			array(
+				array(
+					'line_item_id' => $item_id,
+					'quantity'     => 1,
+				),
+			)
+		);
+
+		$this->assertEquals( 401, $response->get_status() );
+	}
+
+	/**
+	 * @testdox Preview is forbidden for users without the create-refund capability.
+	 */
+	public function test_preview_forbidden_for_customer(): void {
+		$order   = $this->create_order_with_product( 10.00, 1 );
+		$item_id = $this->get_first_line_item_id( $order );
+
+		$customer_id = wp_insert_user(
+			array(
+				'user_login' => 'v3_preview_customer_' . wp_generate_password( 6, false ),
+				'user_email' => 'v3_preview_customer_' . wp_generate_password( 6, false ) . '@example.com',
+				'user_pass'  => 'password',
+				'role'       => 'customer',
+			)
+		);
+		wp_set_current_user( $customer_id );
+
+		$response = $this->do_preview_request(
+			$order->get_id(),
+			array(
+				array(
+					'line_item_id' => $item_id,
+					'quantity'     => 1,
+				),
+			)
+		);
+
+		$this->assertEquals( 403, $response->get_status() );
+
+		wp_delete_user( $customer_id );
+	}
+
+	/**
+	 * @testdox Preview does not create a refund.
+	 */
+	public function test_preview_does_not_create_refund(): void {
+		$order   = $this->create_order_with_product( 10.00, 1 );
+		$item_id = $this->get_first_line_item_id( $order );
+
+		$this->do_preview_request(
+			$order->get_id(),
+			array(
+				array(
+					'line_item_id' => $item_id,
+					'quantity'     => 1,
+				),
+			)
+		);
+
+		$order = wc_get_order( $order->get_id() );
+		$this->assertCount( 0, $order->get_refunds(), 'Preview must not create a refund' );
+	}
+
+	/**
+	 * @testdox Preview totals match a refund subsequently created through the v3 create endpoint.
+	 */
+	public function test_preview_matches_create(): void {
+		$tax_rate_id = $this->create_tax_rate( 10.0 );
+		$order       = $this->create_order_with_product_and_tax( 100.00, 1, $tax_rate_id, 10.00 );
+		$item_id     = $this->get_first_line_item_id( $order );
+
+		$preview_response = $this->do_preview_request(
+			$order->get_id(),
+			array(
+				array(
+					'line_item_id' => $item_id,
+					'quantity'     => 1,
+				),
+			)
+		);
+		$this->assertEquals( 200, $preview_response->get_status() );
+		$preview      = $preview_response->get_data();
+		$preview_item = $preview['breakdown']['products']['items'][0];
+
+		// Drive the create request from the preview breakdown so a divergence between
+		// preview and create produces an actual mismatch rather than passing by
+		// coincidence. v3 create semantics: refund_total is net, refund_tax explicit.
+		$create_request = new WP_REST_Request( 'POST', '/wc/v3/orders/' . $order->get_id() . '/refunds' );
+		$create_request->set_body_params(
+			array(
+				'line_items' => array(
+					array(
+						'id'           => $item_id,
+						'quantity'     => 1,
+						'refund_total' => (float) $preview_item['subtotal'],
+						'refund_tax'   => array(
+							array(
+								'id'           => $tax_rate_id,
+								'refund_total' => (float) $preview_item['tax'],
+							),
+						),
+					),
+				),
+				'api_refund' => false,
+			)
+		);
+		$create_response = $this->server->dispatch( $create_request );
+		$this->assertEquals( 201, $create_response->get_status() );
+
+		$this->assertEquals(
+			$preview['total'],
+			$create_response->get_data()['amount'],
+			'Preview total must match the created refund amount exactly'
+		);
+	}
+
+	/**
+	 * @testdox Preview rounds to a whole unit in a zero-decimal currency.
+	 */
+	public function test_preview_zero_decimal_currency(): void {
+		$tax_rate_id = $this->create_tax_rate( 10.0 );
+		// $100 net + $10 tax = $110 incl, stored at 2dp before the currency switch.
+		$order   = $this->create_order_with_product_and_tax( 100.00, 1, $tax_rate_id, 10.00 );
+		$item_id = $this->get_first_line_item_id( $order );
+
+		add_filter( 'wc_get_price_decimals', '__return_zero' );
+
+		try {
+			$response = $this->do_preview_request(
+				$order->get_id(),
+				array(
+					array(
+						'line_item_id' => $item_id,
+						'refund_total' => 55.4,
+					),
+				)
+			);
+
+			$this->assertEquals( 200, $response->get_status() );
+			$item = $response->get_data()['breakdown']['products']['items'][0];
+
+			// 55.4 rounds to 55 at 0dp; the 10% split gives whole-unit 50 net / 5 tax.
+			$this->assertEquals( '55', $item['total'], 'Total should round to a whole unit.' );
+			$this->assertEquals( '5', $item['tax'] );
+			$this->assertEquals( '50', $item['subtotal'] );
+		} finally {
+			remove_filter( 'wc_get_price_decimals', '__return_zero' );
+		}
+	}
+
+	/**
+	 * @testdox Preview keeps three decimal places in a three-decimal currency.
+	 */
+	public function test_preview_three_decimal_currency(): void {
+		$three_decimals = function () {
+			return 3;
+		};
+		add_filter( 'wc_get_price_decimals', $three_decimals );
+
+		try {
+			$order   = $this->create_order_with_product( 10.555, 1 );
+			$item_id = $this->get_first_line_item_id( $order );
+
+			$response = $this->do_preview_request(
+				$order->get_id(),
+				array(
+					array(
+						'line_item_id' => $item_id,
+						'quantity'     => 1,
+					),
+				)
+			);
+
+			$this->assertEquals( 200, $response->get_status() );
+			$this->assertEquals( '10.555', $response->get_data()['total'], 'Three-decimal precision should be preserved.' );
+		} finally {
+			remove_filter( 'wc_get_price_decimals', $three_decimals );
+		}
+	}
+
+	/**
+	 * @testdox A field registered for order_refund_preview is populated in the response and advertised in the schema.
+	 */
+	public function test_preview_registered_rest_field_in_response_and_schema(): void {
+		register_rest_field(
+			'order_refund_preview',
+			'registered_field',
+			array(
+				'get_callback' => function () {
+					return 'registered_value';
+				},
+				'schema'       => array(
+					'description' => 'Test field.',
+					'type'        => 'string',
+				),
+			)
+		);
+
+		$order   = $this->create_order_with_product( 10.00, 1 );
+		$item_id = $this->get_first_line_item_id( $order );
+
+		$response = $this->do_preview_request(
+			$order->get_id(),
+			array(
+				array(
+					'line_item_id' => $item_id,
+					'quantity'     => 1,
+				),
+			)
+		);
+
+		$this->assertEquals( 200, $response->get_status() );
+		$this->assertEquals( 'registered_value', $response->get_data()['registered_field'] );
+
+		$options = new WP_REST_Request( 'OPTIONS', '/wc/v3/orders/' . $order->get_id() . '/refunds/preview' );
+		$schema  = $this->server->dispatch( $options )->get_data()['schema'];
+		$this->assertArrayHasKey( 'registered_field', $schema['properties'] );
+	}
+
+	/**
+	 * @testdox A registered field without a schema is still populated, matching core back-compat.
+	 */
+	public function test_preview_schema_less_registered_field_is_populated(): void {
+		register_rest_field(
+			'order_refund_preview',
+			'schema_less_field',
+			array(
+				'get_callback' => function () {
+					return 'schema_less_value';
+				},
+			)
+		);
+
+		$order   = $this->create_order_with_product( 10.00, 1 );
+		$item_id = $this->get_first_line_item_id( $order );
+
+		$response = $this->do_preview_request(
+			$order->get_id(),
+			array(
+				array(
+					'line_item_id' => $item_id,
+					'quantity'     => 1,
+				),
+			)
+		);
+
+		// Core deliberately includes fields registered without a schema; a
+		// schema-derived allowlist alone would silently drop them.
+		$this->assertEquals( 200, $response->get_status() );
+		$this->assertEquals( 'schema_less_value', $response->get_data()['schema_less_field'] );
+	}
+
+	/**
+	 * @testdox A registered field's callback does not run when _fields excludes it.
+	 */
+	public function test_preview_registered_field_callback_skipped_when_not_requested(): void {
+		$executed = false;
+		register_rest_field(
+			'order_refund_preview',
+			'expensive_field',
+			array(
+				'get_callback' => function () use ( &$executed ) {
+					$executed = true;
+					return 'expensive_value';
+				},
+				'schema'       => array(
+					'description' => 'Test field.',
+					'type'        => 'string',
+				),
+			)
+		);
+
+		$order   = $this->create_order_with_product( 10.00, 1 );
+		$item_id = $this->get_first_line_item_id( $order );
+
+		$request = new WP_REST_Request( 'POST', '/wc/v3/orders/' . $order->get_id() . '/refunds/preview' );
+		$request->set_body_params(
+			array(
+				'line_items' => array(
+					array(
+						'line_item_id' => $item_id,
+						'quantity'     => 1,
+					),
+				),
+			)
+		);
+		$request->set_param( '_fields', 'total' );
+		$response = $this->server->dispatch( $request );
+
+		$this->assertEquals( 200, $response->get_status() );
+		$this->assertFalse( $executed, 'The excluded field callback must not execute.' );
+		$data = rest_filter_response_fields( $response, $this->server, $request )->get_data();
+		$this->assertArrayNotHasKey( 'expensive_field', $data );
+		$this->assertArrayHasKey( 'total', $data );
+	}
+
+	/**
+	 * @testdox The woocommerce_rest_prepare_order_refund_preview filter receives the response object and can mutate it.
+	 */
+	public function test_preview_filter_can_mutate_response(): void {
+		$order   = $this->create_order_with_product( 10.00, 1 );
+		$item_id = $this->get_first_line_item_id( $order );
+
+		$received_order = null;
+		add_filter(
+			'woocommerce_rest_prepare_order_refund_preview',
+			function ( $response, $filter_order ) use ( &$received_order ) {
+				$this->assertInstanceOf( WP_REST_Response::class, $response, 'The filter should receive the response object, per the woocommerce_rest_prepare_* family contract.' );
+				$received_order       = $filter_order;
+				$data                 = $response->get_data();
+				$data['custom_field'] = 'custom_value';
+				$response->set_data( $data );
+				return $response;
+			},
+			10,
+			2
+		);
+
+		$response = $this->do_preview_request(
+			$order->get_id(),
+			array(
+				array(
+					'line_item_id' => $item_id,
+					'quantity'     => 1,
+				),
+			)
+		);
+
+		$this->assertEquals( 200, $response->get_status() );
+		$this->assertEquals( 'custom_value', $response->get_data()['custom_field'] );
+		$this->assertInstanceOf( WC_Order::class, $received_order );
+		$this->assertEquals( $order->get_id(), $received_order->get_id() );
+	}
+
+	/**
+	 * @testdox Numeric strings in the preview payload are sanitized to their schema types before computation.
+	 */
+	public function test_preview_numeric_strings_are_sanitized(): void {
+		$order   = $this->create_order_with_product( 25.00, 2 );
+		$item_id = $this->get_first_line_item_id( $order );
+
+		$response = $this->do_preview_request(
+			$order->get_id(),
+			array(
+				array(
+					'line_item_id' => (string) $item_id,
+					'quantity'     => '2',
+				),
+			)
+		);
+
+		$this->assertEquals( 200, $response->get_status() );
+		$this->assertEquals( '50.00', $response->get_data()['total'] );
+	}
+
+	/**
+	 * @testdox The preview schema is exposed with the order_refund_preview title and expected properties.
+	 */
+	public function test_preview_schema_shape(): void {
+		$order = $this->create_order_with_product( 10.00, 1 );
+
+		$request  = new WP_REST_Request( 'OPTIONS', '/wc/v3/orders/' . $order->get_id() . '/refunds/preview' );
+		$response = $this->server->dispatch( $request );
+		$data     = $response->get_data();
+
+		$this->assertEquals( 'order_refund_preview', $data['schema']['title'] );
+		foreach ( array( 'breakdown', 'subtotal', 'tax', 'total', 'max_refundable' ) as $property ) {
+			$this->assertArrayHasKey( $property, $data['schema']['properties'], "Schema should declare the {$property} property." );
+		}
+	}
+
+	/**
+	 * @testdox A negative refund_total is accepted for a discount line when the aggregate stays positive.
+	 */
+	public function test_preview_negative_refund_total_on_discount_line(): void {
+		$order = $this->create_order_with_product_and_discount( 50.00, 1, -10.00 );
+
+		$product_item_id  = $this->get_first_line_item_id( $order );
+		$discount_item_id = 0;
+		foreach ( $order->get_items( 'fee' ) as $item ) {
+			$discount_item_id = $item->get_id();
+		}
+		$this->assertGreaterThan( 0, $discount_item_id, 'Order should carry a discount fee line.' );
+
+		$response = $this->do_preview_request(
+			$order->get_id(),
+			array(
+				array(
+					'line_item_id' => $product_item_id,
+					'quantity'     => 1,
+				),
+				array(
+					'line_item_id' => $discount_item_id,
+					'refund_total' => -10.00,
+				),
+			)
+		);
+
+		// The discount line's negative amount nets against the product: 50 - 10 = 40.
+		// A discount-only preview would be rejected by the aggregate guard by design;
+		// this pairing is where the per-line sign rule and that guard interact.
+		$this->assertEquals( 200, $response->get_status() );
+		$data = $response->get_data();
+		$this->assertEquals( '40.00', $data['total'] );
+	}
+
+	/**
+	 * @testdox An unknown key inside a line item is rejected by the argument schema.
+	 */
+	public function test_preview_unknown_line_item_key_rejected(): void {
+		$order   = $this->create_order_with_product( 10.00, 1 );
+		$item_id = $this->get_first_line_item_id( $order );
+
+		$response = $this->do_preview_request(
+			$order->get_id(),
+			array(
+				array(
+					'line_item_id' => $item_id,
+					'quantity'     => 1,
+					'unexpected'   => 'value',
+				),
+			)
+		);
+
+		// additionalProperties => false: clients relying on strict payloads (the
+		// mobile apps send exactly the declared keys) get an explicit rejection
+		// rather than silently ignored input.
+		$this->assertEquals( 400, $response->get_status() );
+		$this->assertEquals( 'rest_invalid_param', $response->get_data()['code'] );
+	}
+
+	/**
+	 * @testdox GET on the preview route is rejected; only POST is registered.
+	 */
+	public function test_preview_get_method_rejected(): void {
+		$order = $this->create_order_with_product( 10.00, 1 );
+
+		$request  = new WP_REST_Request( 'GET', '/wc/v3/orders/' . $order->get_id() . '/refunds/preview' );
+		$response = $this->server->dispatch( $request );
+
+		// WP answers a wrong method on this route with `rest_no_route` — the same
+		// code clients use as the "endpoint missing" signal, so a client mistakenly
+		// issuing GET would look like an ineligible store rather than a client bug.
+		$this->assertEquals( 404, $response->get_status() );
+		$this->assertEquals( 'rest_no_route', $response->get_data()['code'] );
+	}
+
+	/**
+	 * @testdox max_refundable reflects the remaining refundable amount after a prior partial refund.
+	 */
+	public function test_preview_max_refundable_value_after_partial_refund(): void {
+		$order   = $this->create_order_with_product( 50.00, 2 );
+		$item_id = $this->get_first_line_item_id( $order );
+
+		$refund = wc_create_refund(
+			array(
+				'order_id' => $order->get_id(),
+				'amount'   => 30.00,
+				'reason'   => 'Partial refund',
+			)
+		);
+		$this->assertNotWPError( $refund );
+
+		$response = $this->do_preview_request(
+			$order->get_id(),
+			array(
+				array(
+					'line_item_id' => $item_id,
+					'quantity'     => 1,
+				),
+			)
+		);
+
+		$this->assertEquals( 200, $response->get_status() );
+		$data = $response->get_data();
+		$this->assertEquals( '50.00', $data['total'] );
+		$this->assertEquals( '70.00', $data['max_refundable'] );
+	}
+
+	/**
+	 * Create a completed order with a single product line item.
+	 *
+	 * @param float $unit_price Unit price.
+	 * @param int   $quantity   Quantity.
+	 * @return WC_Order
+	 */
+	private function create_order_with_product( float $unit_price, int $quantity ): WC_Order {
+		$product = WC_Helper_Product::create_simple_product();
+		$product->set_regular_price( $unit_price );
+		$product->save();
+
+		$order = wc_create_order();
+		$item  = new WC_Order_Item_Product();
+		$item->set_props(
+			array(
+				'product'  => $product,
+				'quantity' => $quantity,
+				'subtotal' => $unit_price * $quantity,
+				'total'    => $unit_price * $quantity,
+			)
+		);
+		$item->save();
+		$order->add_item( $item );
+		$order->set_total( $unit_price * $quantity );
+		$order->set_status( OrderStatus::COMPLETED );
+		$order->save();
+
+		$product->delete( true );
+
+		return $order;
+	}
+
+	/**
+	 * Create a completed order with two single-quantity product line items.
+	 *
+	 * @param float $price_a Price of the first product.
+	 * @param float $price_b Price of the second product.
+	 * @return WC_Order
+	 */
+	private function create_order_with_two_products( float $price_a, float $price_b ): WC_Order {
+		$order = wc_create_order();
+
+		foreach ( array( $price_a, $price_b ) as $price ) {
+			$product = WC_Helper_Product::create_simple_product();
+			$product->set_regular_price( $price );
+			$product->save();
+
+			$item = new WC_Order_Item_Product();
+			$item->set_props(
+				array(
+					'product'  => $product,
+					'quantity' => 1,
+					'subtotal' => $price,
+					'total'    => $price,
+				)
+			);
+			$item->save();
+			$order->add_item( $item );
+
+			$product->delete( true );
+		}
+
+		$order->set_total( $price_a + $price_b );
+		$order->set_status( OrderStatus::COMPLETED );
+		$order->save();
+
+		return $order;
+	}
+
+	/**
+	 * Create a completed order with a single shipping line.
+	 *
+	 * @param float $total Shipping total.
+	 * @return WC_Order
+	 */
+	private function create_order_with_shipping( float $total ): WC_Order {
+		$order    = wc_create_order();
+		$shipping = new WC_Order_Item_Shipping();
+		$shipping->set_props(
+			array(
+				'method_title' => 'Flat Rate',
+				'total'        => $total,
+			)
+		);
+		$shipping->save();
+		$order->add_item( $shipping );
+		$order->set_total( $total );
+		$order->set_status( OrderStatus::COMPLETED );
+		$order->save();
+
+		return $order;
+	}
+
+	/**
+	 * Create a completed order with a single fee line.
+	 *
+	 * @param float $total Fee total.
+	 * @return WC_Order
+	 */
+	private function create_order_with_fee( float $total ): WC_Order {
+		$order = wc_create_order();
+		$fee   = new WC_Order_Item_Fee();
+		$fee->set_props(
+			array(
+				'name'  => 'Service fee',
+				'total' => $total,
+			)
+		);
+		$fee->save();
+		$order->add_item( $fee );
+		$order->set_total( $total );
+		$order->set_status( OrderStatus::COMPLETED );
+		$order->save();
+
+		return $order;
+	}
+
+	/**
+	 * Create a completed order with a product line and a negative (discount) fee line.
+	 *
+	 * @param float $unit_price Product unit price.
+	 * @param int   $quantity   Product quantity.
+	 * @param float $discount   Negative fee total representing the discount.
+	 * @return WC_Order
+	 */
+	private function create_order_with_product_and_discount( float $unit_price, int $quantity, float $discount ): WC_Order {
+		$product = WC_Helper_Product::create_simple_product();
+		$product->set_regular_price( $unit_price );
+		$product->save();
+
+		$order = wc_create_order();
+		$order->add_product( $product, $quantity );
+
+		$fee = new WC_Order_Item_Fee();
+		$fee->set_props(
+			array(
+				'name'  => 'Discount',
+				'total' => $discount,
+			)
+		);
+		$fee->save();
+		$order->add_item( $fee );
+
+		$order->calculate_totals();
+		$order->set_status( OrderStatus::COMPLETED );
+		$order->save();
+
+		return $order;
+	}
+
+	/**
+	 * Create an order with a product and tax.
+	 *
+	 * @param float $product_price Product price.
+	 * @param int   $quantity      Quantity.
+	 * @param int   $tax_rate_id   Tax rate ID.
+	 * @param float $tax_amount    Tax amount.
+	 * @return WC_Order
+	 */
+	private function create_order_with_product_and_tax( float $product_price, int $quantity, int $tax_rate_id, float $tax_amount ): WC_Order {
+		$product = WC_Helper_Product::create_simple_product();
+		$product->set_regular_price( $product_price );
+		$product->set_tax_status( 'taxable' );
+		$product->save();
+
+		$total = $product_price * $quantity;
+		$order = wc_create_order();
+		$item  = new WC_Order_Item_Product();
+		$item->set_props(
+			array(
+				'product'  => $product,
+				'quantity' => $quantity,
+				'subtotal' => $total,
+				'total'    => $total,
+			)
+		);
+		$item->set_taxes(
+			array(
+				'total'    => array( $tax_rate_id => $tax_amount ),
+				'subtotal' => array( $tax_rate_id => $tax_amount ),
+			)
+		);
+		$item->save();
+		$order->add_item( $item );
+
+		$tax_item = new WC_Order_Item_Tax();
+		$tax_item->set_rate( $tax_rate_id );
+		$tax_item->set_tax_total( $tax_amount );
+		$tax_item->save();
+		$order->add_item( $tax_item );
+
+		$order->set_billing_country( 'US' );
+		$order->set_total( $total + $tax_amount );
+		$order->set_status( OrderStatus::COMPLETED );
+		$order->save();
+
+		$product->delete( true );
+
+		return $order;
+	}
+
+	/**
+	 * Create a tax rate.
+	 *
+	 * @param float $rate Tax rate percentage.
+	 * @return int Tax rate ID.
+	 */
+	private function create_tax_rate( float $rate ): int {
+		return WC_Tax::_insert_tax_rate(
+			array(
+				'tax_rate_country'  => 'US',
+				'tax_rate_state'    => '',
+				'tax_rate'          => number_format( $rate, 4 ),
+				'tax_rate_name'     => 'Tax',
+				'tax_rate_priority' => '1',
+				'tax_rate_compound' => '0',
+				'tax_rate_shipping' => '1',
+				'tax_rate_order'    => '1',
+				'tax_rate_class'    => '',
+			)
+		);
+	}
+
+	/**
+	 * Get the first line item ID from an order.
+	 *
+	 * @param WC_Order $order Order instance.
+	 * @return int Line item ID.
+	 */
+	private function get_first_line_item_id( WC_Order $order ): int {
+		$items = $order->get_items( 'line_item' );
+		$item  = reset( $items );
+		return $item->get_id();
+	}
+
+	/**
+	 * Send a preview request and return the response.
+	 *
+	 * @param int   $order_id   Order ID.
+	 * @param array $line_items Line items array.
+	 * @return WP_REST_Response
+	 */
+	private function do_preview_request( int $order_id, array $line_items ): WP_REST_Response {
+		$request = new WP_REST_Request( 'POST', '/wc/v3/orders/' . $order_id . '/refunds/preview' );
+		$request->set_body_params(
+			array(
+				'line_items' => $line_items,
+			)
+		);
+		return $this->server->dispatch( $request );
+	}
+}
diff --git a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version4/Refunds/class-wc-rest-refunds-v4-controller-tests.php b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version4/Refunds/class-wc-rest-refunds-v4-controller-tests.php
index 80c3026b1f6..f8a0509f8a5 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
@@ -3659,6 +3659,55 @@ class WC_REST_Refunds_V4_Controller_Tests extends WC_REST_Unit_Test_Case {
 		return $this->server->dispatch( $request );
 	}

+	/**
+	 * @testdox Duplicate tax IDs within a line return 400 duplicate_tax_id and create no refund.
+	 */
+	public function test_refunds_create_rejects_duplicate_tax_ids(): void {
+		$tax_rate_id = WC_Tax::_insert_tax_rate(
+			array(
+				'tax_rate_country'  => 'US',
+				'tax_rate_state'    => '',
+				'tax_rate'          => '10.0000',
+				'tax_rate_name'     => 'VAT',
+				'tax_rate_priority' => '1',
+				'tax_rate_compound' => '0',
+				'tax_rate_shipping' => '1',
+				'tax_rate_order'    => '1',
+				'tax_rate_class'    => '',
+			)
+		);
+
+		// $100 net + $10 tax = $110 line total.
+		list( $order, $item ) = $this->create_order_with_exact_line( 1, 100.00, 100.00, 110.00, array( $tax_rate_id => 10.00 ) );
+
+		// Validation and the amount calculation sum both entries (gross 60), but the
+		// internal conversion keys taxes by ID, so one entry would silently overwrite
+		// the other and store only 55. The request must be rejected instead.
+		$response = $this->dispatch_refund_request(
+			$order->get_id(),
+			array(
+				array(
+					'line_item_id' => $item->get_id(),
+					'refund_total' => 50.00,
+					'refund_tax'   => array(
+						array(
+							'id'           => $tax_rate_id,
+							'refund_total' => 5.00,
+						),
+						array(
+							'id'           => $tax_rate_id,
+							'refund_total' => 5.00,
+						),
+					),
+				),
+			)
+		);
+
+		$this->assertEquals( 400, $response->get_status() );
+		$this->assertEquals( 'duplicate_tax_id', $response->get_data()['code'] );
+		$this->assertCount( 0, wc_get_order( $order->get_id() )->get_refunds(), 'A refund with inconsistent tax accounting must never be created.' );
+	}
+
 	/**
 	 * @testdox Simplified form rejects a second refund of already-fully-refunded fee and shipping lines.
 	 */
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 43cb7f9eb8b..d69b9b893d0 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
@@ -649,6 +649,59 @@ class DataUtilsTest extends WC_Unit_Test_Case {
 		$this->assertEquals( 'invalid_refund_amount', $result->get_error_code() );
 	}

+	/**
+	 * @testdox validate_line_items rejects duplicate tax IDs within a line's refund_tax.
+	 */
+	public function test_validate_line_items_rejects_duplicate_tax_ids(): void {
+		$tax_rate_id = WC_Tax::_insert_tax_rate(
+			array(
+				'tax_rate_country'  => 'US',
+				'tax_rate_state'    => '',
+				'tax_rate'          => '10.0000',
+				'tax_rate_name'     => 'VAT',
+				'tax_rate_priority' => '1',
+				'tax_rate_compound' => '0',
+				'tax_rate_shipping' => '1',
+				'tax_rate_order'    => '1',
+				'tax_rate_class'    => '',
+			)
+		);
+
+		// $100 net + $10 tax (rate VAT) = $110 line total.
+		$order = $this->create_order_with_taxes( array( $tax_rate_id ), 100.00 );
+		$order->set_status( OrderStatus::COMPLETED );
+		$order->save();
+
+		$items = $order->get_items( 'line_item' );
+		$item  = reset( $items );
+
+		// Both validation and the amount calculation sum every entry, but the internal
+		// conversion keys taxes by ID, so a duplicate would silently drop one entry and
+		// store less than the refund amount. It must be rejected instead.
+		$result = $this->data_utils->validate_line_items(
+			array(
+				array(
+					'line_item_id' => $item->get_id(),
+					'refund_total' => 50.00,
+					'refund_tax'   => array(
+						array(
+							'id'           => $tax_rate_id,
+							'refund_total' => 5.00,
+						),
+						array(
+							'id'           => $tax_rate_id,
+							'refund_total' => 5.00,
+						),
+					),
+				),
+			),
+			$order
+		);
+
+		$this->assertInstanceOf( \WP_Error::class, $result, 'Duplicate tax IDs within a line must be rejected.' );
+		$this->assertEquals( 'duplicate_tax_id', $result->get_error_code() );
+	}
+
 	/**
 	 * Build a completed order with a positive product line and a discount fee that carries a
 	 * negative stored tax bucket, for the negative-tax refund_tax cap tests.