Commit 3bc19f929ea for woocommerce

commit 3bc19f929ea647b1f880a210fff12cf8c70b2017
Author: Samuel Urbanowicz <samiuelson@gmail.com>
Date:   Mon Oct 26 18:12:24 2026 +0100

    Move the shared refund engine to a version-neutral namespace (#68733)

    * Move the shared refund engine to a version-neutral namespace

    * Add changelog entry for refund engine relocation

    * Simplify comments on the refund engine relocation files

    * Restore inherited AbstractSchema members on the old RefundPreviewSchema FQCN

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

    * Restore the AbstractSchema parent on the old RefundPreviewSchema FQCN

    * Update version tags and use the container in the refund schema shim

    * Remove the duplicate automated changelog entry

    * Update refund engine docs and comments, add shim tests

    * Log refund engine warnings under the caller's log source (#68819)

    ---------

    Co-authored-by: woocommercebot <woocommercebot@users.noreply.github.com>
    Co-authored-by: Seghir Nadir <nadir.seghir@gmail.com>

diff --git a/plugins/woocommerce/changelog/woomob-3805-relocate-refund-engine b/plugins/woocommerce/changelog/woomob-3805-relocate-refund-engine
new file mode 100644
index 00000000000..7bf913035f4
--- /dev/null
+++ b/plugins/woocommerce/changelog/woomob-3805-relocate-refund-engine
@@ -0,0 +1,4 @@
+Significance: patch
+Type: dev
+
+Move the shared refund calculation engine (DataUtils, RefundPreviewSchema) to the version-neutral Internal\RestApi\Refunds namespace. Backward compatibility shims keep the old class names working.
diff --git a/plugins/woocommerce/changelog/woomob-4112-refund-log-source b/plugins/woocommerce/changelog/woomob-4112-refund-log-source
new file mode 100644
index 00000000000..bbfa593711f
--- /dev/null
+++ b/plugins/woocommerce/changelog/woomob-4112-refund-log-source
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Log refund warnings from the wc/v3 refund endpoints under the wc-rest-refunds source instead of wc-v4-refunds.
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 c1543c2ee8e..86b8b04b95a 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,8 +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\Internal\RestApi\Refunds\DataUtils;
+use Automattic\WooCommerce\Internal\RestApi\Refunds\Schema\RefundPreviewSchema;
 use Automattic\WooCommerce\Utilities\MetaDataUtil;
 use Automattic\WooCommerce\Utilities\NumberUtil;

@@ -334,8 +334,8 @@ class WC_REST_Order_Refunds_Controller extends WC_REST_Order_Refunds_V2_Controll
 	 * 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.
+	 * refunds endpoints; using it here keeps wc/v3 and wc/v4 refund math
+	 * identical.
 	 *
 	 * @return DataUtils
 	 */
@@ -392,8 +392,8 @@ 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
+		// The opt-in compute_totals mode routes through the shared refund engine
+		// (DataUtils). 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
diff --git a/plugins/woocommerce/src/Internal/RestApi/README.md b/plugins/woocommerce/src/Internal/RestApi/README.md
index 831260d4331..ab6189de2bf 100644
--- a/plugins/woocommerce/src/Internal/RestApi/README.md
+++ b/plugins/woocommerce/src/Internal/RestApi/README.md
@@ -4,6 +4,13 @@

 This directory contains the implementation of WooCommerce's REST API v4, which represents a complete architectural rewrite of the REST API system.

+Only `Routes/V4/` is the experimental v4 API. Other code in this directory is shared with the production endpoints and must stay if the v4 routes are removed:
+
+- `Refunds/`: the version-neutral refund engine used by the wc/v3 refund endpoints and the v4 routes.
+- `ProductRequestPreparationTrait.php`: used by the wc/v2 and wc/v3 product endpoints.
+
+`Routes/V4/Orders/OrderLineMetaValidator.php` is also used by the wc/v2 and wc/v3 order endpoints. Move it out of `Routes/V4/` before removing the v4 routes.
+
 ---

 **Note**: This API is not ready for production use. Do not use in production environments.
diff --git a/plugins/woocommerce/src/Internal/RestApi/Refunds/DataUtils.php b/plugins/woocommerce/src/Internal/RestApi/Refunds/DataUtils.php
new file mode 100644
index 00000000000..cbc0feea335
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/RestApi/Refunds/DataUtils.php
@@ -0,0 +1,1640 @@
+<?php
+/**
+ * DataUtils class file.
+ */
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Internal\RestApi\Refunds;
+
+defined( 'ABSPATH' ) || exit;
+
+use Automattic\WooCommerce\Enums\OrderStatus;
+use Automattic\WooCommerce\Utilities\NumberUtil;
+use WC_Order;
+use WC_Order_Item_Fee;
+use WC_Order_Item_Product;
+use WC_Order_Item_Shipping;
+use WP_Error;
+use WP_Http;
+
+/**
+ * Helper methods for the REST API.
+ *
+ * Class DataUtils
+ *
+ * @since 11.3.0
+ * @package Automattic\WooCommerce\Internal\RestApi\Refunds
+ */
+class DataUtils {
+	/**
+	 * Order statuses that allow refunds.
+	 */
+	public const REFUNDABLE_STATUSES = array(
+		OrderStatus::COMPLETED,
+		OrderStatus::PROCESSING,
+		OrderStatus::ON_HOLD,
+	);
+
+	/**
+	 * Default log source, used outside the entry-point pipelines.
+	 */
+	private const DEFAULT_LOG_SOURCE = 'wc-v4-refunds';
+
+	/**
+	 * Log source for warnings emitted while an entry-point pipeline runs.
+	 *
+	 * Set from the caller's $log_source by compute_refund_preview_or_error() and
+	 * prepare_refund_creation_or_error(), restored to the default afterwards.
+	 * Carried as state rather than a parameter because the intermediate methods
+	 * are public and overridable, so their signatures must not change (see
+	 * DataUtilsTest::test_original_public_method_signatures_remain_overridable()).
+	 *
+	 * @var string
+	 */
+	private $log_source = self::DEFAULT_LOG_SOURCE;
+
+	/**
+	 * Convert line items (schema format) to internal format. This keys arrays by item ID and has some different naming
+	 * conventions.
+	 *
+	 * 111 => [
+	 *   "qty" => 1,
+	 *   "refund_total" => 123,
+	 *   "refund_tax" => [
+	 *     1 => 123,
+	 *     2 => 456,
+	 *   ],
+	 * ]
+	 *
+	 * @param array    $line_items The line items to convert.
+	 * @param WC_Order $order The order being refunded.
+	 * @return array The converted line items.
+	 */
+	public function convert_line_items_to_internal_format( $line_items, WC_Order $order ) {
+		$prepared_line_items = array();
+
+		foreach ( $line_items as $line_item ) {
+			// A line item is processable when it has an ID and at least one of
+			// quantity or refund_total. The legacy v3-style form may omit
+			// quantity entirely; in that case qty=0 is recorded on the refund,
+			// matching v3 semantics ("refunded $X of this line without consuming
+			// specific units"). Dollar accounting via get_remaining_refund_amount
+			// still bounds subsequent refunds, so per-unit looseness here does
+			// not enable over-refunding.
+			if ( ! isset( $line_item['line_item_id'] ) ) {
+				continue;
+			}
+			if ( ! isset( $line_item['quantity'] ) && ! isset( $line_item['refund_total'] ) ) {
+				continue;
+			}
+
+			// refund_tax presence is the discriminator for how refund_total is interpreted:
+			// when refund_tax is absent, refund_total is tax-inclusive and the tax portion is
+			// split out below; when refund_tax is present, refund_total is the tax-exclusive
+			// subtotal and is stored as-is, with the supplied taxes added on top.
+			//
+			// If no explicit refund_tax provided, extract tax from the tax-inclusive
+			// refund_total. Skip when refund_total is also missing — there's nothing
+			// to extract tax from. The split is by the line's own stored total/tax
+			// ratio via split_inclusive_by_stored_ratio(), the same method the preview
+			// uses, so the stored refund matches what build_refund_preview() showed.
+			if ( ! isset( $line_item['refund_tax'] ) && isset( $line_item['refund_total'] ) ) {
+				$original_item = $order->get_item( $line_item['line_item_id'] );
+				if ( $original_item instanceof WC_Order_Item_Product || $original_item instanceof WC_Order_Item_Shipping || $original_item instanceof WC_Order_Item_Fee ) {
+					$split = $this->split_inclusive_by_stored_ratio( (float) $line_item['refund_total'], $original_item, wc_get_price_decimals() );
+
+					// Leave a tax-free line untouched: refund_total stays the full
+					// (tax-exclusive == tax-inclusive) amount and no refund_tax is set.
+					if ( ! empty( $split['taxes'] ) ) {
+						$line_item['refund_tax']   = $this->convert_proportional_taxes_to_schema_format( $split['taxes'] );
+						$line_item['refund_total'] = $split['subtotal'];
+					}
+				}
+			}
+
+			// Default qty=0 when quantity was omitted (legacy v3-style explicit
+			// refund_total path). Default refund_total=0 defensively; in practice
+			// validate_line_items ensures one of them is set by this point.
+			$prepared_line_items[ $line_item['line_item_id'] ] = array(
+				'qty'          => $line_item['quantity'] ?? 0,
+				'refund_total' => $line_item['refund_total'] ?? 0,
+				'refund_tax'   => $this->convert_line_item_taxes_to_internal_format( $line_item['refund_tax'] ?? array() ),
+			);
+		}
+
+		return $prepared_line_items;
+	}
+
+	/**
+	 * Convert line item taxes (schema format) to internal format. This keys arrays by tax ID and has some different naming.
+	 *
+	 * @param array $line_item_taxes The taxes to convert.
+	 * @return array The converted taxes.
+	 *
+	 * @since 10.9.0
+	 */
+	protected function convert_line_item_taxes_to_internal_format( $line_item_taxes ) {
+		$prepared_taxes = array();
+
+		foreach ( $line_item_taxes as $line_item_tax ) {
+			if ( ! isset( $line_item_tax['id'], $line_item_tax['refund_total'] ) ) {
+				continue;
+			}
+			$prepared_taxes[ $line_item_tax['id'] ] = $line_item_tax['refund_total'];
+		}
+
+		return $prepared_taxes;
+	}
+
+	/**
+	 * Calculate the gross refund amount from line items (schema format).
+	 *
+	 * Sums refund_total plus any explicit refund_tax. This yields the tax-inclusive gross
+	 * for both forms: when refund_tax is omitted, refund_total is already tax-inclusive (and
+	 * there is no refund_tax to add); when refund_tax is supplied, refund_total is the
+	 * tax-exclusive subtotal and the taxes are added on top.
+	 *
+	 * @param array $line_items The line items to calculate the refund amount from.
+	 * @return float|null The refund amount, or null if it can't be calculated.
+	 */
+	public function calculate_refund_amount( array $line_items ): ?float {
+		if ( empty( $line_items ) ) {
+			return null;
+		}
+
+		$amount = 0;
+
+		foreach ( $line_items as $line_item ) {
+			// is_numeric() (not !empty) — an explicit refund_total of 0 can be part
+			// of a valid tax-only refund and must be included in the gross sum.
+			if ( isset( $line_item['refund_total'] ) && is_numeric( $line_item['refund_total'] ) ) {
+				$amount += $line_item['refund_total'];
+			}
+
+			if ( ! empty( $line_item['refund_tax'] ) && is_array( $line_item['refund_tax'] ) ) {
+				foreach ( $line_item['refund_tax'] as $tax ) {
+					if ( isset( $tax['refund_total'] ) && is_numeric( $tax['refund_total'] ) ) {
+						$amount += $tax['refund_total'];
+					}
+				}
+			}
+		}
+
+		return (float) NumberUtil::round( $amount, wc_get_price_decimals() );
+	}
+
+	/**
+	 * Validate line items (schema format) before conversion to internal format.
+	 *
+	 * @param array    $line_items The line items to validate.
+	 * @param WC_Order $order The order object.
+	 * @return boolean|WP_Error
+	 */
+	public function validate_line_items( $line_items, WC_Order $order ) {
+		// Reject non-refundable order statuses up front, mirroring the preview path
+		// so create and preview agree on which orders accept refunds.
+		if ( ! in_array( $order->get_status(), self::REFUNDABLE_STATUSES, true ) ) {
+			return new WP_Error(
+				'order_not_refundable',
+				__( 'This order cannot be refunded.', 'woocommerce' ),
+				array( 'status' => WP_Http::UNPROCESSABLE_ENTITY )
+			);
+		}
+
+		// Reject a fully-refunded order up front with the same code/status the
+		// preview path returns, so a fully-refunded order is rejected identically
+		// by both endpoints rather than via the controller's later
+		// refund_exceeds_remaining guard.
+		if ( (float) $order->get_remaining_refund_amount() <= 0 ) {
+			return new WP_Error(
+				'order_not_refundable',
+				__( 'This order has already been fully refunded.', 'woocommerce' ),
+				array( 'status' => WP_Http::UNPROCESSABLE_ENTITY )
+			);
+		}
+
+		// Precompute refunded quantities/totals once so the over-refund check
+		// below caps against remaining refundable quantity, not the original.
+		// Loaded here rather than passed in: the controller dispatches through this
+		// method so subclass overrides keep working, and WC_Order::get_refunds()
+		// serves repeat loads within the request from the object cache.
+		$refund_data = $this->compute_refunded_quantities_and_totals( $order );
+
+		$seen_ids = array();
+		foreach ( $line_items as $line_item ) {
+			$line_item_id = $line_item['line_item_id'] ?? null;
+
+			if ( ! $line_item_id ) {
+				return new WP_Error(
+					'missing_line_item_id',
+					__( 'Line item ID is required.', 'woocommerce' ),
+					array( 'status' => WP_Http::BAD_REQUEST )
+				);
+			}
+
+			// Reject duplicate line items: each is validated against the same remaining
+			// snapshot, so repeating an ID would let the per-line cap pass twice for the
+			// same line. Callers must combine a line into a single entry.
+			if ( isset( $seen_ids[ $line_item_id ] ) ) {
+				return new WP_Error(
+					'duplicate_line_item',
+					__( 'Each line item may appear only once per request.', 'woocommerce' ),
+					array( 'status' => WP_Http::BAD_REQUEST )
+				);
+			}
+			$seen_ids[ $line_item_id ] = true;
+
+			$item = $order->get_item( $line_item_id );
+
+			// Validate item exists and belongs to the order.
+			if ( ! $item || $item->get_order_id() !== $order->get_id() ) {
+				return new WP_Error(
+					'line_item_not_found',
+					__( 'Line item not found.', 'woocommerce' ),
+					array( 'status' => WP_Http::BAD_REQUEST )
+				);
+			}
+
+			if ( ! $item instanceof \WC_Order_Item_Product && ! $item instanceof \WC_Order_Item_Fee && ! $item instanceof \WC_Order_Item_Shipping ) {
+				return new WP_Error(
+					'unsupported_item_type',
+					__( 'Line item is not a product, fee, or shipping line.', 'woocommerce' ),
+					array( 'status' => WP_Http::BAD_REQUEST )
+				);
+			}
+
+			// Quantity is required only when the client omits refund_total — the
+			// auto-compute path needs a real quantity to derive the unit price.
+			// When refund_total is provided explicitly (legacy v3-style path),
+			// quantity is informational and can be missing/zero, matching the
+			// original v4 schema's `default: 0` behavior.
+			$refund_total_missing = ! array_key_exists( 'refund_total', $line_item ) || null === $line_item['refund_total'];
+
+			// Reject the ambiguous "auto-computed refund_total + explicit refund_tax"
+			// combination. Auto-compute writes a tax-inclusive value; the
+			// converter then skips tax extraction because refund_tax is set,
+			// and calculate_refund_amount double-counts the tax. The client
+			// must either supply refund_total explicitly (and may then supply
+			// refund_tax to override the auto-extracted split) or let the
+			// server handle taxes (omit both).
+			if ( $refund_total_missing && isset( $line_item['refund_tax'] ) ) {
+				return new WP_Error(
+					'invalid_line_item',
+					__( 'refund_tax cannot be combined with an auto-computed refund_total. Provide refund_total explicitly when supplying refund_tax.', 'woocommerce' ),
+					array( 'status' => WP_Http::BAD_REQUEST )
+				);
+			}
+
+			if ( $refund_total_missing && ( ! isset( $line_item['quantity'] ) || ! is_int( $line_item['quantity'] ) || $line_item['quantity'] < 1 ) ) {
+				return new WP_Error(
+					'missing_quantity_or_refund_total',
+					__( 'Line item quantity must be a positive integer when refund_total is omitted.', 'woocommerce' ),
+					array( 'status' => WP_Http::BAD_REQUEST )
+				);
+			}
+
+			// When refund_total is provided, a supplied quantity is informational, but it must
+			// still be a non-negative integer so it round-trips cleanly onto the refund line —
+			// a negative or fractional value would be stored verbatim as the line qty. 0 (or an
+			// omitted quantity) means "dollars only". This mirrors the integer/range checks the
+			// preview path applies before branching on item type.
+			if ( ! $refund_total_missing && isset( $line_item['quantity'] ) && ( ! is_int( $line_item['quantity'] ) || $line_item['quantity'] < 0 ) ) {
+				return new WP_Error(
+					'invalid_quantity',
+					__( 'Line item quantity must be a non-negative integer.', 'woocommerce' ),
+					array( 'status' => WP_Http::BAD_REQUEST )
+				);
+			}
+
+			// Auto-compute requires a non-zero source quantity to derive the unit
+			// price from. If the client omitted refund_total (or sent null) and the
+			// source product has zero quantity, surface a clear error rather than
+			// letting the request slip into the misleading "must be greater than
+			// zero" branch downstream.
+			if ( $refund_total_missing && $item instanceof \WC_Order_Item_Product && 0 === $item->get_quantity() ) {
+				return new WP_Error(
+					'invalid_line_item',
+					sprintf(
+						/* translators: %d: line item id */
+						__( 'Cannot auto-compute refund for line item %d: source quantity is zero. Provide an explicit refund_total.', 'woocommerce' ),
+						(int) $line_item_id
+					),
+					array( 'status' => WP_Http::BAD_REQUEST )
+				);
+			}
+
+			// Validate refund quantity does not exceed remaining refundable
+			// quantity for this line. compute_refunded_quantities_and_totals
+			// returns negative values for already-refunded units (matches the
+			// convention used by validate_preview_line_items), so adding to
+			// $item->get_quantity() yields the remaining count.
+			// Only fires when a quantity was provided — the legacy
+			// explicit-refund_total path may omit it.
+			if ( isset( $line_item['quantity'] ) && $item instanceof \WC_Order_Item_Product ) {
+				$remaining_qty = $item->get_quantity() + ( $refund_data['qtys'][ $line_item_id ] ?? 0 );
+				if ( $line_item['quantity'] > $remaining_qty ) {
+					return new WP_Error(
+						'quantity_exceeds_refundable',
+						sprintf(
+							/* translators: %d: remaining refundable quantity */
+							__( 'Line item quantity cannot be greater than the remaining refundable quantity (%d).', 'woocommerce' ),
+							$remaining_qty
+						),
+						array( 'status' => WP_Http::UNPROCESSABLE_ENTITY )
+					);
+				}
+			} elseif ( isset( $line_item['quantity'] ) && $line_item['quantity'] > 1 ) {
+				return new WP_Error(
+					'invalid_quantity',
+					__( 'Shipping and fee line items must be refunded with quantity of 1.', 'woocommerce' ),
+					array( 'status' => WP_Http::BAD_REQUEST )
+				);
+			}
+
+			// Validate refund total against the remaining refundable amount for this
+			// line (including tax), subtracting any prior partial refunds. Rounds both
+			// sides to currency precision and uses abs() so the cap matches
+			// validate_preview_line_items() exactly — a previewed amount that is
+			// accepted (or rejected) there behaves the same way here.
+			if ( isset( $line_item['refund_total'] ) ) {
+				$price_decimals    = wc_get_price_decimals();
+				$signed_line_total = (float) $item->get_total() + (float) $item->get_total_tax();
+
+				// Reject a refund_total whose sign is opposite the line: you cannot refund
+				// a positive amount from a discount line, or a negative amount from a normal
+				// line. Without this, abs() in the cap below would let a wrong-sign value
+				// pass and be stored (e.g. a negative refund_total on a positive line in a
+				// mixed-line request whose total stays positive). A gross line refund that
+				// rounds to 0 is rejected below, so create and preview stay aligned for the
+				// tax-inclusive form while explicit tax-only create requests remain valid.
+				if ( (float) $line_item['refund_total'] * $signed_line_total < 0 ) {
+					return new WP_Error(
+						'invalid_refund_total',
+						__( 'Refund total has the wrong sign for this line item.', 'woocommerce' ),
+						array( 'status' => WP_Http::BAD_REQUEST )
+					);
+				}
+
+				// Cap and zero-check the GROSS line refund against the line's tax-inclusive
+				// total. When an explicit refund_tax breakdown is supplied, refund_total is
+				// the tax-exclusive (net) subtotal and the tax is added on top (core Woo
+				// semantics — see RefundSchema); without it, refund_total is already
+				// tax-inclusive, so the gross equals refund_total. Capping the net alone
+				// would let a client push the overage into refund_tax and over-refund the
+				// line. Preview has no refund_tax field, so its (refund_total-only) cap stays
+				// equivalent for the inclusive form.
+				$line_refund_gross = (float) $line_item['refund_total'];
+				if ( ! empty( $line_item['refund_tax'] ) && is_array( $line_item['refund_tax'] ) ) {
+					$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 );
+					}
+				}
+
+				// Reject a gross line refund that rounds to zero. A zero line refund is a
+				// no-op that would otherwise be stored as an empty qty:0 refund line.
+				if ( 0.0 === (float) NumberUtil::round( $line_refund_gross, $price_decimals ) ) {
+					return new WP_Error(
+						'invalid_refund_total',
+						__( 'refund_total must be a number greater than zero.', 'woocommerce' ),
+						array( 'status' => WP_Http::BAD_REQUEST )
+					);
+				}
+
+				$refunded_total   = (float) ( $refund_data['totals'][ $line_item_id ] ?? 0.0 );
+				$validation_error = $this->validate_refund_line_total( $item, $line_refund_gross, $refunded_total );
+				if ( is_wp_error( $validation_error ) ) {
+					return $validation_error;
+				}
+			}
+
+			if ( isset( $line_item['refund_tax'] ) ) {
+				$item_taxes = $item->get_taxes();
+
+				if ( $item_taxes ) {
+					$allowed_tax_ids = array_keys( $item_taxes['total'] ?? array() );
+
+					foreach ( $line_item['refund_tax'] as $refund_tax ) {
+						if ( ! isset( $refund_tax['id'], $refund_tax['refund_total'] ) ) {
+							return new WP_Error( 'invalid_line_item', __( 'Tax id and refund_total are required.', 'woocommerce' ), array( 'status' => WP_Http::BAD_REQUEST ) );
+						}
+						$tax_id           = $refund_tax['id'];
+						$tax_refund_total = $refund_tax['refund_total'];
+
+						if ( ! in_array( $tax_id, $allowed_tax_ids, true ) ) {
+							return new WP_Error(
+								'invalid_line_item',
+								sprintf(
+								/* translators: %s: tax IDs */
+									__( 'Line item tax not found. Must be: %s.', 'woocommerce' ),
+									implode( ', ', $allowed_tax_ids )
+								),
+								array( 'status' => WP_Http::BAD_REQUEST )
+							);
+						}
+
+						$price_decimals = wc_get_price_decimals();
+						$stored_tax     = (float) $item_taxes['total'][ $tax_id ];
+						$requested_tax  = (float) $tax_refund_total;
+
+						// Reject a refund_tax whose sign is opposite the stored tax bucket: you
+						// cannot refund a positive tax from a negative (discount) bucket or vice
+						// versa. Mirrors the refund_total wrong-sign guard. Compare on absolute
+						// magnitudes below so a negative bucket is capped the same way a positive
+						// one is — a signed `<` admits an over-refund of a negative bucket and
+						// rejects a valid partial one. An explicit 0 is allowed (a no-op).
+						if ( $requested_tax * $stored_tax < 0 ) {
+							return new WP_Error(
+								'invalid_refund_amount',
+								__( 'Refund tax total has the wrong sign for this line item.', 'woocommerce' ),
+								array( 'status' => WP_Http::BAD_REQUEST )
+							);
+						}
+
+						// Cap against the remaining tax for this bucket, subtracting any tax
+						// already refunded for this tax id on prior refunds — not the original
+						// line tax — so sequential refunds cannot over-refund a single bucket.
+						// $already_refunded_tax is accumulated as a positive magnitude
+						// (compute_refunded_quantities_and_totals() uses abs()), so compare it
+						// against the stored bucket's magnitude. Round both sides to currency
+						// precision: the accumulator is built from repeated float additions, so
+						// an unrounded compare could reject or admit an exactly-correct amount by
+						// a sub-cent residue.
+						$already_refunded_tax = (float) ( $refund_data['tax_totals'][ $line_item_id ][ $tax_id ] ?? 0.0 );
+						$remaining_tax        = abs( $stored_tax ) - $already_refunded_tax;
+						if ( abs( $requested_tax ) > NumberUtil::round( $remaining_tax, $price_decimals ) ) {
+							// 400, not the 422 the over-refund caps above use: this is the
+							// status the released wc/v4 envelope already backfills for this
+							// error, so anything else would change a shipped response. It
+							// also keeps the code-to-status mapping one-to-one across this
+							// file — invalid_refund_amount is 400 at every site, sharing the
+							// code with the wrong-sign guard above, which is malformed
+							// input, while each 422 carries its own over-refund code.
+							return new WP_Error(
+								'invalid_refund_amount',
+								sprintf(
+								/* translators: %s: remaining refundable tax total */
+									__( 'Refund tax total cannot be greater than the remaining refundable tax for this line item (%s).', 'woocommerce' ),
+									wc_format_decimal( $remaining_tax, $price_decimals )
+								),
+								array( 'status' => WP_Http::BAD_REQUEST )
+							);
+						}
+					}
+				}
+			}
+		}
+
+		return true;
+	}
+
+	/**
+	 * Check a line refund against the original line and previous refunds.
+	 *
+	 * Both refund paths use this limit. The computed path passes a tax-inclusive
+	 * amount; the explicit wc/v3 path passes its tax-inclusive gross amount.
+	 *
+	 * @since 11.3.0
+	 *
+	 * @param WC_Order_Item_Product|WC_Order_Item_Fee|WC_Order_Item_Shipping $item                  Original order item.
+	 * @param float                                                          $refund_total          Requested line item refund amount.
+	 * @param float                                                          $already_refunded_total Prior refunds for this item, including tax.
+	 * @return true|WP_Error Validation result.
+	 */
+	public function validate_refund_line_total( $item, float $refund_total, float $already_refunded_total ) {
+		$price_decimals      = wc_get_price_decimals();
+		$signed_line_total   = (float) $item->get_total() + (float) $item->get_total_tax();
+		$item_total_with_tax = abs( $signed_line_total );
+		$abs_refund_total    = NumberUtil::round( abs( $refund_total ), $price_decimals );
+
+		if ( $refund_total * $signed_line_total < 0 ) {
+			return new WP_Error(
+				'invalid_refund_total',
+				__( 'Refund total has the wrong sign for this line item.', 'woocommerce' ),
+				array( 'status' => WP_Http::BAD_REQUEST )
+			);
+		}
+
+		// Mirror the preview path's three distinct over-refund errors (same
+		// codes, messages, and 422 status) so create and preview reject the
+		// same input identically. An over-refund is a well-formed but
+		// unprocessable request, so 422 — not 400 — is the correct status,
+		// matching the order-level cap the controller already returns.
+		if ( $abs_refund_total > NumberUtil::round( $item_total_with_tax, $price_decimals ) ) {
+			return new WP_Error(
+				'refund_total_exceeds_line',
+				sprintf(
+					/* translators: %s: line item total including tax */
+					__( 'refund_total cannot exceed the line item total including tax (%s).', 'woocommerce' ),
+					wc_format_decimal( $item_total_with_tax, $price_decimals )
+				),
+				array( 'status' => WP_Http::UNPROCESSABLE_ENTITY )
+			);
+		}
+
+		// Remaining is rounded to currency precision before both checks, so a
+		// sub-cent residue left by rounding drift counts as fully refunded
+		// rather than producing a "cannot exceed 0.00" rejection.
+		$remaining_total = NumberUtil::round( $item_total_with_tax - abs( $already_refunded_total ), $price_decimals );
+		if ( $remaining_total <= 0 ) {
+			return new WP_Error(
+				'line_item_already_refunded',
+				__( 'This line item has already been fully refunded.', 'woocommerce' ),
+				array( 'status' => WP_Http::UNPROCESSABLE_ENTITY )
+			);
+		}
+		if ( $abs_refund_total > $remaining_total ) {
+			return new WP_Error(
+				'refund_total_exceeds_remaining',
+				sprintf(
+					/* translators: %s: remaining refundable amount */
+					__( 'refund_total cannot exceed the remaining refundable amount for this line item (%s).', 'woocommerce' ),
+					wc_format_decimal( $remaining_total, $price_decimals )
+				),
+				array( 'status' => WP_Http::UNPROCESSABLE_ENTITY )
+			);
+		}
+
+		return true;
+	}
+
+	/**
+	 * Convert calculated taxes (internal format) to schema format.
+	 *
+	 * @param array $calculated_taxes Taxes keyed by tax ID with amounts.
+	 * @return array Schema format with id and refund_total keys.
+	 *
+	 * @since 10.9.0
+	 */
+	protected function convert_proportional_taxes_to_schema_format( array $calculated_taxes ): array {
+		$result = array();
+		foreach ( $calculated_taxes as $tax_id => $amount ) {
+			$result[] = array(
+				'id'           => (int) $tax_id,
+				'refund_total' => $amount,
+			);
+		}
+		return $result;
+	}
+
+	/**
+	 * Split a tax-inclusive amount into net subtotal and per-tax-ID tax amounts using
+	 * the line item's own stored total/tax ratio.
+	 *
+	 * Splitting by the line's actual stored proportion — rather than re-deriving tax
+	 * from the order tax item's rate percent — returns exactly what was charged. It
+	 * stays correct when the stored tax is not an exact rate% of net (manually edited
+	 * tax, or a rate that changed after the order) and when a taxed line's rate
+	 * resolves to zero. Preview ({@see build_refund_preview()}) and create
+	 * ({@see convert_line_items_to_internal_format()}) share this method so a previewed
+	 * split always matches the split stored on the created refund.
+	 *
+	 * Per-ID amounts are rounded and the subtotal is derived as amount - sum(tax), so
+	 * the invariant subtotal + total_tax == amount holds exactly at $dp precision.
+	 *
+	 * @param float                                                          $amount Tax-inclusive amount to split. Rounded to $dp before splitting.
+	 * @param WC_Order_Item_Product|WC_Order_Item_Shipping|WC_Order_Item_Fee $item   Order item supplying the stored total/tax ratio.
+	 * @param int                                                            $dp     Price decimal places.
+	 * @return array{subtotal: float, total_tax: float, taxes: array<int, float>} Net subtotal, summed tax, and per-tax-ID amounts.
+	 *
+	 * @since 10.9.0
+	 */
+	protected function split_inclusive_by_stored_ratio( float $amount, $item, int $dp ): array {
+		$amount       = NumberUtil::round( $amount, $dp );
+		$stored_total = (float) $item->get_total();
+
+		// Keep only non-zero numeric stored taxes (positive or negative). A negative-tax
+		// discount fee must retain its breakdown; a zero entry contributes nothing.
+		$stored_taxes = array_filter(
+			$item->get_taxes()['total'] ?? array(),
+			function ( $t ) {
+				return is_numeric( $t ) && 0.0 !== (float) $t;
+			}
+		);
+
+		$stored_tax_total = array_sum( array_map( 'floatval', $stored_taxes ) );
+		$stored_with_tax  = $stored_total + $stored_tax_total;
+
+		// Fallback used whenever the stored data can't yield a sane proportional split:
+		// treat the whole amount as net (no tax) and log for observability.
+		$unsplittable = function ( string $reason ) use ( $amount, $item ) {
+			wc_get_logger()->warning(
+				sprintf(
+					'Refund tax split: cannot split tax for item %d on order %d (%s).',
+					(int) $item->get_id(),
+					(int) $item->get_order_id(),
+					$reason
+				),
+				array( 'source' => $this->log_source )
+			);
+			return array(
+				'subtotal'  => $amount,
+				'total_tax' => 0.0,
+				'taxes'     => array(),
+			);
+		};
+
+		// No tax on the line: the whole amount is net (not an error, no log).
+		if ( empty( $stored_taxes ) ) {
+			return array(
+				'subtotal'  => $amount,
+				'total_tax' => 0.0,
+				'taxes'     => array(),
+			);
+		}
+
+		// A zero-value line (stored total nets to zero while a tax was charged) can't be
+		// split proportionally — avoid division by zero.
+		if ( 0.0 === (float) $stored_with_tax ) {
+			return $unsplittable( 'stored total incl. tax is zero' );
+		}
+
+		// Scale each stored tax by the share of the line being refunded.
+		$taxes = array();
+		foreach ( $stored_taxes as $tax_id => $stored_tax ) {
+			$taxes[ (int) $tax_id ] = NumberUtil::round( $amount * ( (float) $stored_tax / $stored_with_tax ), $dp );
+		}
+		$total_tax = NumberUtil::round( array_sum( $taxes ), $dp );
+
+		// Sanity clamp: the tax portion of a tax-inclusive amount can never exceed the
+		// amount itself. A larger value means the stored total/tax nearly cancel (e.g. a
+		// near-zero inclusive total from manually edited data), which would explode the
+		// ratio. Fall back rather than emit a nonsensical negative subtotal.
+		if ( abs( $total_tax ) > abs( $amount ) ) {
+			return $unsplittable( 'stored total and tax nearly cancel' );
+		}
+
+		$subtotal = NumberUtil::round( $amount - $total_tax, $dp );
+
+		return array(
+			'subtotal'  => $subtotal,
+			'total_tax' => $total_tax,
+			'taxes'     => $taxes,
+		);
+	}
+
+	/**
+	 * Compute the tax-inclusive refund total for a line item at a given quantity.
+	 *
+	 * Precondition: $item must be one of WC_Order_Item_Product, WC_Order_Item_Shipping,
+	 * WC_Order_Item_Fee, and $quantity must be a positive integer (>= 1). For
+	 * shipping and fee items the quantity is informational only — the full item
+	 * total is returned regardless. Callers using untrusted input should validate
+	 * via {@see validate_preview_line_items()} first.
+	 *
+	 * @param WC_Order_Item_Product|WC_Order_Item_Shipping|WC_Order_Item_Fee $item     The order item.
+	 * @param int                                                            $quantity The quantity to refund (>= 1).
+	 * @return float The tax-inclusive refund total. May be negative for items with negative totals (e.g. discount fees).
+	 * @throws \InvalidArgumentException When $quantity is less than 1.
+	 *
+	 * @since 10.9.0
+	 */
+	public function compute_line_item_refund_total( $item, int $quantity ): float {
+		if ( $quantity < 1 ) {
+			// Exception message is developer-facing only; the value is a typed int and the format is a literal string.
+			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
+			throw new \InvalidArgumentException( sprintf( 'Quantity must be >= 1, got %d.', (int) $quantity ) );
+		}
+
+		$price_decimals = wc_get_price_decimals();
+
+		if ( $item instanceof WC_Order_Item_Product ) {
+			$original_qty = $item->get_quantity();
+			if ( 0 === $original_qty ) {
+				wc_get_logger()->warning(
+					sprintf( 'Refund preview: product item %d has zero original quantity on order %d.', $item->get_id(), $item->get_order_id() ),
+					array( 'source' => $this->log_source )
+				);
+				return 0.0;
+			}
+			$unit_price_with_tax = ( (float) $item->get_total() + (float) $item->get_total_tax() ) / $original_qty;
+			return NumberUtil::round( $unit_price_with_tax * $quantity, $price_decimals );
+		}
+
+		return NumberUtil::round( (float) $item->get_total() + (float) $item->get_total_tax(), $price_decimals );
+	}
+
+	/**
+	 * Compute the tax-inclusive refund total for a quantity-form line item, capped to
+	 * the line's remaining refundable amount.
+	 *
+	 * {@see compute_line_item_refund_total()} rounds each request independently, so a
+	 * sequence of partial quantity refunds can drift from the stored line gross by up
+	 * to a cent per request — round(unit × 2) + round(unit × 4) may exceed the line
+	 * total that round(unit × 6) would produce. The quantity form is "server, compute
+	 * the amount for me", so rather than rejecting its own arithmetic the server caps
+	 * the result:
+	 *
+	 * - The unit-derived amount is always clamped down to the remaining amount, so
+	 *   rounding drift can never push it over the remaining-amount cap the validators
+	 *   enforce. Shipping and fee lines carry a single unit whose derived amount is the
+	 *   full line total, so partially-refunded ones always resolve to their remainder
+	 *   through this clamp.
+	 * - A product quantity that consumes the line's remaining refundable units is
+	 *   topped up to the exact remaining amount — closing the line at currency
+	 *   precision with no stranded cents — but only when every prior refund on the
+	 *   line matches its own quantity-derived amount, i.e. the shortfall is provably
+	 *   accumulated rounding drift. An off-schedule prior refund (an explicit partial
+	 *   amount, or a dollar-only refund with no units) means the residue was
+	 *   deliberately withheld, and the quantity form must never silently pay it back
+	 *   out; the residue stays refundable through an explicit refund_total.
+	 *
+	 * When no refundable amount remains, the unclamped amount is returned as-is:
+	 * clamping to zero would trip the zero-refund guard with a misleading error, while
+	 * the validators reject the line with line_item_already_refunded. Explicit
+	 * client-supplied refund_total values are never capped — those stay strictly
+	 * validated.
+	 *
+	 * @param WC_Order_Item_Product|WC_Order_Item_Shipping|WC_Order_Item_Fee $item        The order item.
+	 * @param int                                                            $quantity    The quantity to refund (>= 1).
+	 * @param array                                                          $refund_data Refund-history snapshot from {@see compute_refunded_quantities_and_totals()} (see its return shape).
+	 * @return float The tax-inclusive refund total, carrying the line's sign.
+	 *
+	 * @since 11.1.0
+	 */
+	private function compute_quantity_refund_total( $item, int $quantity, array $refund_data ): float {
+		$computed          = $this->compute_line_item_refund_total( $item, $quantity );
+		$price_decimals    = wc_get_price_decimals();
+		$signed_line_total = (float) $item->get_total() + (float) $item->get_total_tax();
+		$refunded_total    = abs( (float) ( $refund_data['totals'][ $item->get_id() ] ?? 0.0 ) );
+		$remaining_total   = NumberUtil::round( abs( $signed_line_total ) - $refunded_total, $price_decimals );
+
+		if ( $remaining_total <= 0 ) {
+			return $computed;
+		}
+
+		$sign         = $signed_line_total < 0 ? -1.0 : 1.0;
+		$abs_computed = abs( $computed );
+
+		if ( $abs_computed > $remaining_total ) {
+			return $sign * $remaining_total;
+		}
+
+		if ( $item instanceof WC_Order_Item_Product && $abs_computed < $remaining_total ) {
+			$remaining_qty = $item->get_quantity() + ( $refund_data['qtys'][ $item->get_id() ] ?? 0 );
+			if (
+				$quantity >= $remaining_qty &&
+				$this->line_refund_history_matches_quantities( $item, $refund_data['line_refunds'][ $item->get_id() ] ?? array() )
+			) {
+				return $sign * $remaining_total;
+			}
+		}
+
+		return $computed;
+	}
+
+	/**
+	 * Whether every prior refund line for a product matches its quantity-derived amount.
+	 *
+	 * True means the difference between the line's refunded total and the sum of
+	 * unit-price amounts is pure rounding drift, so the final-chunk top-up in
+	 * {@see compute_quantity_refund_total()} can safely reconcile it. Gross values are
+	 * compared as formatted decimals at currency precision — never raw float
+	 * equality. A refund line with no units (qty 0, the dollar-only form) is
+	 * off-schedule by definition.
+	 *
+	 * @param WC_Order_Item_Product $item         The original order line.
+	 * @param array                 $line_refunds Prior refund lines as list<array{qty: int, gross: float}> (positive magnitudes).
+	 * @return bool
+	 */
+	private function line_refund_history_matches_quantities( WC_Order_Item_Product $item, array $line_refunds ): bool {
+		$price_decimals = wc_get_price_decimals();
+
+		foreach ( $line_refunds as $refund_line ) {
+			if ( $refund_line['qty'] <= 0 ) {
+				return false;
+			}
+
+			$expected = abs( $this->compute_line_item_refund_total( $item, $refund_line['qty'] ) );
+			if ( wc_format_decimal( $refund_line['gross'], $price_decimals ) !== wc_format_decimal( $expected, $price_decimals ) ) {
+				return false;
+			}
+		}
+
+		return true;
+	}
+
+	/**
+	 * Round every caller-supplied refund_total to currency precision.
+	 *
+	 * Applied at the entry of both the preview and create flows so a value the client
+	 * sends is validated, summed, split, and stored at the same precision. A previewed
+	 * amount therefore always matches the created refund to the cent. A missing or null
+	 * refund_total (the auto-compute form) is left untouched — those are computed later
+	 * and already rounded by {@see compute_line_item_refund_total()}.
+	 *
+	 * @param array $line_items Line items in schema format.
+	 * @return array Line items with numeric refund_total values rounded to wc_get_price_decimals().
+	 *
+	 * @since 10.9.0
+	 */
+	public function normalize_refund_totals( array $line_items ): array {
+		$price_decimals = wc_get_price_decimals();
+		foreach ( $line_items as $key => $line_item ) {
+			if ( isset( $line_item['refund_total'] ) && is_numeric( $line_item['refund_total'] ) ) {
+				$line_items[ $key ]['refund_total'] = NumberUtil::round( (float) $line_item['refund_total'], $price_decimals );
+			}
+		}
+		return $line_items;
+	}
+
+	/**
+	 * Fill in refund_total for any line item that omits it, computing the value from
+	 * the order item's unit price × quantity via compute_quantity_refund_total(),
+	 * which caps the result to the line's remaining refundable amount.
+	 *
+	 * Items that already have refund_total (including an explicit 0) are left
+	 * untouched so validation can decide whether the explicit amount is valid.
+	 * Items where refund_total is omitted OR is explicitly null are treated as
+	 * "compute it for me". Items that can't be resolved (missing line_item_id,
+	 * item not on order, invalid quantity, unsupported item type, product with
+	 * zero source quantity) are also left untouched — validate_line_items surfaces
+	 * the right error for those cases.
+	 *
+	 * Auto-computed values are tax-inclusive, matching the convention enforced by
+	 * the existing converter (convert_line_items_to_internal_format extracts tax
+	 * from a tax-inclusive refund_total).
+	 *
+	 * @param array    $line_items Line items from the request (schema format).
+	 *                             Each item: array{line_item_id?: int, quantity?: int,
+	 *                             refund_total?: float|int|null, refund_tax?: array<int, mixed>}.
+	 * @param WC_Order $order      The order being refunded.
+	 * @return array The line items with refund_total populated where possible (same shape as input).
+	 *
+	 * @since 10.9.0
+	 */
+	public function fill_missing_refund_totals( array $line_items, WC_Order $order ): array {
+		// Round caller-supplied amounts up front so explicit values are stored at the
+		// same precision the preview validated and showed. Computed values below are
+		// already rounded by compute_quantity_refund_total().
+		$line_items = $this->normalize_refund_totals( $line_items );
+
+		// Loaded lazily: only requests with at least one auto-computed line pay for
+		// the refund-history scan the remaining-amount cap needs.
+		$refund_data = null;
+
+		foreach ( $line_items as $key => $line_item ) {
+			// Treat a missing key and an explicit `null` value the same — both mean
+			// "compute it for me". An explicit `0` is caller-supplied input, so leave
+			// it untouched and let validation decide whether the gross line refund is valid.
+			if ( array_key_exists( 'refund_total', $line_item ) && null !== $line_item['refund_total'] ) {
+				continue;
+			}
+
+			// Skip auto-compute when the client also supplied an explicit
+			// refund_tax. Auto-compute writes a tax-inclusive refund_total, but
+			// the converter then skips tax extraction whenever refund_tax is
+			// already present — and calculate_refund_amount would add both,
+			// inflating the total by the tax amount. Leave refund_total unset;
+			// validate_line_items rejects this ambiguous combination with a
+			// clear error.
+			if ( isset( $line_item['refund_tax'] ) ) {
+				continue;
+			}
+
+			$line_item_id = $line_item['line_item_id'] ?? null;
+			$quantity     = $line_item['quantity'] ?? null;
+			if ( ! $line_item_id || ! is_int( $quantity ) || $quantity < 1 ) {
+				continue;
+			}
+
+			$item = $order->get_item( $line_item_id );
+			if ( ! $item || ! ( $item instanceof WC_Order_Item_Product || $item instanceof WC_Order_Item_Shipping || $item instanceof WC_Order_Item_Fee ) ) {
+				continue;
+			}
+
+			// A product whose source line has zero quantity has no unit price to
+			// derive a refund from. Skip so validate_line_items surfaces a clear
+			// 'invalid_line_item' error to the API consumer instead of letting a
+			// silent 0.0 propagate into the misleading "must be greater than zero"
+			// branch downstream.
+			if ( $item instanceof WC_Order_Item_Product && 0 === $item->get_quantity() ) {
+				continue;
+			}
+
+			if ( null === $refund_data ) {
+				$refund_data = $this->compute_refunded_quantities_and_totals( $order );
+			}
+
+			$line_items[ $key ]['refund_total'] = $this->compute_quantity_refund_total( $item, $quantity, $refund_data );
+		}
+
+		return $line_items;
+	}
+
+	/**
+	 * Build a refund preview showing authoritative totals and breakdowns.
+	 *
+	 * Callers must invoke {@see validate_preview_line_items()} first — this
+	 * method assumes inputs have been validated and throws on missing items.
+	 *
+	 * Each line item must have 'line_item_id' and at least one of 'quantity'
+	 * (positive int) or 'refund_total' (positive tax-inclusive float). When
+	 * 'refund_total' is present and positive it is used directly; otherwise the
+	 * total is computed from quantity via {@see compute_quantity_refund_total()},
+	 * capped to the line's remaining refundable amount — the same computation the
+	 * create flow stores, so the previewed amounts always match the created refund.
+	 *
+	 * @param WC_Order $order      The order being previewed for refund.
+	 * @param array    $line_items Line items. Each: array{line_item_id: int, quantity?: int, refund_total?: float}.
+	 * @return array The structured preview response.
+	 * @throws \InvalidArgumentException When a line_item_id does not resolve to an item on the order.
+	 *
+	 * @since 10.9.0
+	 */
+	public function build_refund_preview( WC_Order $order, array $line_items ): array {
+		$price_decimals = wc_get_price_decimals();
+		$refund_data    = $this->compute_refunded_quantities_and_totals( $order );
+		$sections       = array(
+			'products' => array(
+				'items'    => array(),
+				'subtotal' => 0.0,
+				'tax'      => 0.0,
+				'total'    => 0.0,
+			),
+			'shipping' => array(
+				'items'    => array(),
+				'subtotal' => 0.0,
+				'tax'      => 0.0,
+				'total'    => 0.0,
+			),
+			'fees'     => array(
+				'items'    => array(),
+				'subtotal' => 0.0,
+				'tax'      => 0.0,
+				'total'    => 0.0,
+			),
+		);
+
+		foreach ( $line_items as $line_item ) {
+			$item = $order->get_item( $line_item['line_item_id'] );
+			if ( ! $item ) {
+				// Exception message is developer-facing only; both values are typed ints and the format is a literal string.
+				// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
+				throw new \InvalidArgumentException( sprintf( 'Line item %d not found on order %d.', (int) $line_item['line_item_id'], (int) $order->get_id() ) );
+			}
+
+			/**
+			 * Validated by validate_preview_line_items() upstream.
+			 *
+			 * @var WC_Order_Item_Product|WC_Order_Item_Shipping|WC_Order_Item_Fee $item
+			 */
+			// When the caller provides an explicit refund_total (partial-amount form) use it
+			// directly. The quantity-based form computes the tax-inclusive total from unit price,
+			// capped to the line's remaining refundable amount — the same computation
+			// fill_missing_refund_totals() feeds the create flow.
+			// A non-zero check (not > 0) mirrors validate_preview_line_items(), which accepts a
+			// negative refund_total for a negative discount line and rejects a present-but-zero
+			// one before this method runs — so a signed value is honoured rather than falling
+			// through to the (possibly absent) quantity.
+			$refund_total_with_tax = isset( $line_item['refund_total'] ) && is_numeric( $line_item['refund_total'] ) && 0.0 !== (float) $line_item['refund_total']
+				? NumberUtil::round( (float) $line_item['refund_total'], $price_decimals )
+				: $this->compute_quantity_refund_total( $item, (int) $line_item['quantity'], $refund_data );
+
+			// Split by the line's own stored total/tax ratio so the preview reflects what
+			// was actually charged and matches the split create stores (both call this).
+			$split    = $this->split_inclusive_by_stored_ratio( $refund_total_with_tax, $item, $price_decimals );
+			$subtotal = $split['subtotal'];
+			$tax      = $split['total_tax'];
+
+			$item_data = array(
+				'id'       => $line_item['line_item_id'],
+				'quantity' => $line_item['quantity'] ?? null,
+				'subtotal' => wc_format_decimal( $subtotal, $price_decimals ),
+				'tax'      => wc_format_decimal( $tax, $price_decimals ),
+				'total'    => wc_format_decimal( $refund_total_with_tax, $price_decimals ),
+			);
+
+			$item_data['name'] = $item->get_name();
+
+			if ( $item instanceof WC_Order_Item_Product ) {
+				$variation_id            = $item->get_variation_id();
+				$item_data['product_id'] = $variation_id > 0 ? $variation_id : $item->get_product_id();
+				$section_key             = 'products';
+			} elseif ( $item instanceof WC_Order_Item_Shipping ) {
+				$section_key = 'shipping';
+			} else {
+				$section_key = 'fees';
+			}
+
+			$sections[ $section_key ]['items'][]   = $item_data;
+			$sections[ $section_key ]['subtotal'] += $subtotal;
+			$sections[ $section_key ]['tax']      += $tax;
+			$sections[ $section_key ]['total']    += $refund_total_with_tax;
+		}
+
+		$format_section = function ( array $section ) use ( $price_decimals ): array {
+			return array(
+				'items'    => $section['items'],
+				'subtotal' => wc_format_decimal( $section['subtotal'], $price_decimals ),
+				'tax'      => wc_format_decimal( $section['tax'], $price_decimals ),
+				'total'    => wc_format_decimal( $section['total'], $price_decimals ),
+			);
+		};
+
+		$grand_subtotal = $sections['products']['subtotal'] + $sections['shipping']['subtotal'] + $sections['fees']['subtotal'];
+		$grand_tax      = $sections['products']['tax'] + $sections['shipping']['tax'] + $sections['fees']['tax'];
+		$grand_total    = $sections['products']['total'] + $sections['shipping']['total'] + $sections['fees']['total'];
+
+		return array(
+			'breakdown'      => array(
+				'products' => $format_section( $sections['products'] ),
+				'shipping' => $format_section( $sections['shipping'] ),
+				'fees'     => $format_section( $sections['fees'] ),
+			),
+			'subtotal'       => wc_format_decimal( $grand_subtotal, $price_decimals ),
+			'tax'            => wc_format_decimal( $grand_tax, $price_decimals ),
+			'total'          => wc_format_decimal( $grand_total, $price_decimals ),
+			'max_refundable' => wc_format_decimal( $order->get_remaining_refund_amount(), $price_decimals ),
+		);
+	}
+
+	/**
+	 * Validate line items for a preview request.
+	 *
+	 * @param array    $line_items The line items to validate.
+	 * @param WC_Order $order      The order object.
+	 * @return true|WP_Error True on success, WP_Error on failure.
+	 *
+	 * @since 10.9.0
+	 */
+	public function validate_preview_line_items( array $line_items, WC_Order $order ) {
+		if ( empty( $line_items ) ) {
+			return new WP_Error(
+				'missing_line_items',
+				__( 'At least one line item is required.', 'woocommerce' ),
+				array( 'status' => WP_Http::BAD_REQUEST )
+			);
+		}
+
+		if ( ! in_array( $order->get_status(), self::REFUNDABLE_STATUSES, true ) ) {
+			return new WP_Error(
+				'order_not_refundable',
+				__( 'This order cannot be refunded.', 'woocommerce' ),
+				array( 'status' => WP_Http::UNPROCESSABLE_ENTITY )
+			);
+		}
+
+		if ( (float) $order->get_remaining_refund_amount() <= 0 ) {
+			return new WP_Error(
+				'order_not_refundable',
+				__( 'This order has already been fully refunded.', 'woocommerce' ),
+				array( 'status' => WP_Http::UNPROCESSABLE_ENTITY )
+			);
+		}
+
+		$refund_data = $this->compute_refunded_quantities_and_totals( $order );
+
+		$seen_ids = array();
+		foreach ( $line_items as $line_item ) {
+			$line_item_id = $line_item['line_item_id'] ?? null;
+			if ( ! $line_item_id ) {
+				return new WP_Error(
+					'missing_line_item_id',
+					__( 'Line item ID is required.', 'woocommerce' ),
+					array( 'status' => WP_Http::BAD_REQUEST )
+				);
+			}
+
+			// Reject duplicate line items: each is validated against the same remaining
+			// snapshot, so repeating an ID would let the per-line cap pass twice for the
+			// same line and double-count it in the preview breakdown.
+			if ( isset( $seen_ids[ $line_item_id ] ) ) {
+				return new WP_Error(
+					'duplicate_line_item',
+					__( 'Each line item may appear only once per request.', 'woocommerce' ),
+					array( 'status' => WP_Http::BAD_REQUEST )
+				);
+			}
+			$seen_ids[ $line_item_id ] = true;
+
+			// A bad line_item_id reference (not on the order, or an unsupported type) is a
+			// malformed request, returned as 400 to match the create endpoint's handling
+			// of the same conditions.
+			$item = $order->get_item( $line_item_id );
+			if ( ! $item || $item->get_order_id() !== $order->get_id() ) {
+				return new WP_Error(
+					'line_item_not_found',
+					__( 'Line item not found.', 'woocommerce' ),
+					array( 'status' => WP_Http::BAD_REQUEST )
+				);
+			}
+
+			if ( ! $item instanceof WC_Order_Item_Product && ! $item instanceof WC_Order_Item_Fee && ! $item instanceof WC_Order_Item_Shipping ) {
+				return new WP_Error(
+					'unsupported_item_type',
+					__( 'Line item is not a product, fee, or shipping line.', 'woocommerce' ),
+					array( 'status' => WP_Http::BAD_REQUEST )
+				);
+			}
+
+			// A present refund_total may be negative (a discount/credit line) but must be a
+			// non-zero number with the same sign as the line — validated below, mirroring
+			// validate_line_items() so create and preview accept and reject the same input.
+			// A null refund_total means "use the quantity form" (isset() is false for null).
+			$has_refund_total = isset( $line_item['refund_total'] );
+			if ( $has_refund_total && ! is_numeric( $line_item['refund_total'] ) ) {
+				return new WP_Error(
+					'invalid_refund_total',
+					__( 'refund_total must be a number greater than zero.', 'woocommerce' ),
+					array( 'status' => WP_Http::BAD_REQUEST )
+				);
+			}
+
+			$has_quantity = isset( $line_item['quantity'] ) && is_int( $line_item['quantity'] ) && $line_item['quantity'] >= 1;
+
+			if ( ! $has_quantity && ! $has_refund_total ) {
+				return new WP_Error(
+					'missing_quantity_or_refund_total',
+					__( 'Either a positive integer quantity or a numeric refund_total is required.', 'woocommerce' ),
+					array( 'status' => WP_Http::BAD_REQUEST )
+				);
+			}
+
+			$price_decimals    = wc_get_price_decimals();
+			$signed_line_total = (float) $item->get_total() + (float) $item->get_total_tax();
+
+			// Validate an explicit refund_total. Mirrors validate_line_items() exactly (sign,
+			// zero, and the three over-refund caps) so a previewed amount that is accepted or
+			// rejected here behaves identically at create.
+			if ( $has_refund_total ) {
+				$refund_total = (float) $line_item['refund_total'];
+
+				// Reject a refund_total whose sign is opposite the line: you cannot refund a
+				// positive amount from a discount line, or a negative amount from a normal line.
+				if ( $refund_total * $signed_line_total < 0 ) {
+					return new WP_Error(
+						'invalid_refund_total',
+						__( 'Refund total has the wrong sign for this line item.', 'woocommerce' ),
+						array( 'status' => WP_Http::BAD_REQUEST )
+					);
+				}
+
+				// Reject a refund_total that rounds to zero — a no-op the create path also rejects.
+				if ( 0.0 === (float) NumberUtil::round( $refund_total, $price_decimals ) ) {
+					return new WP_Error(
+						'invalid_refund_total',
+						__( 'refund_total must be a number greater than zero.', 'woocommerce' ),
+						array( 'status' => WP_Http::BAD_REQUEST )
+					);
+				}
+
+				$item_total_with_tax = abs( $signed_line_total );
+				$abs_refund_total    = NumberUtil::round( abs( $refund_total ), $price_decimals );
+				if ( $abs_refund_total > NumberUtil::round( $item_total_with_tax, $price_decimals ) ) {
+					return new WP_Error(
+						'refund_total_exceeds_line',
+						sprintf(
+							/* translators: %s: line item total including tax */
+							__( 'refund_total cannot exceed the line item total including tax (%s).', 'woocommerce' ),
+							wc_format_decimal( $item_total_with_tax, $price_decimals )
+						),
+						array( 'status' => WP_Http::UNPROCESSABLE_ENTITY )
+					);
+				}
+
+				// Cap against the remaining refundable amount for this line.
+				// compute_refunded_quantities_and_totals() tracks tax-inclusive totals
+				// for all item types so the comparison is consistent. Remaining is
+				// rounded to currency precision before both checks, matching
+				// validate_line_items(), so a sub-cent residue counts as fully refunded.
+				$refunded_total  = abs( (float) ( $refund_data['totals'][ $line_item_id ] ?? 0.0 ) );
+				$remaining_total = NumberUtil::round( $item_total_with_tax - $refunded_total, $price_decimals );
+				if ( $remaining_total <= 0 ) {
+					return new WP_Error(
+						'line_item_already_refunded',
+						__( 'This line item has already been fully refunded.', 'woocommerce' ),
+						array( 'status' => WP_Http::UNPROCESSABLE_ENTITY )
+					);
+				}
+				if ( $abs_refund_total > $remaining_total ) {
+					return new WP_Error(
+						'refund_total_exceeds_remaining',
+						sprintf(
+							/* translators: %s: remaining refundable amount */
+							__( 'refund_total cannot exceed the remaining refundable amount for this line item (%s).', 'woocommerce' ),
+							wc_format_decimal( $remaining_total, $price_decimals )
+						),
+						array( 'status' => WP_Http::UNPROCESSABLE_ENTITY )
+					);
+				}
+			}
+
+			// Validate a supplied quantity whenever present — even alongside refund_total —
+			// matching validate_line_items(). Skipping this when refund_total was given let a
+			// preview accept a quantity the create path then rejects.
+			if ( $has_quantity ) {
+				$quantity = $line_item['quantity'];
+
+				if ( $item instanceof WC_Order_Item_Product ) {
+					$remaining_qty = $item->get_quantity() + ( $refund_data['qtys'][ $line_item_id ] ?? 0 );
+					if ( $quantity > $remaining_qty ) {
+						return new WP_Error(
+							'quantity_exceeds_refundable',
+							sprintf(
+								/* translators: %d: remaining refundable quantity */
+								__( 'Requested quantity exceeds remaining refundable quantity (%d).', 'woocommerce' ),
+								$remaining_qty
+							),
+							array( 'status' => WP_Http::UNPROCESSABLE_ENTITY )
+						);
+					}
+				} elseif ( 1 !== $quantity ) {
+					// Shipping and fee lines carry a single refundable unit.
+					return new WP_Error(
+						'invalid_quantity',
+						__( 'Shipping and fee line items must be refunded with quantity of 1.', 'woocommerce' ),
+						array( 'status' => WP_Http::BAD_REQUEST )
+					);
+				}
+			}
+
+			// Amount-from-quantity: the server derives the amount itself via
+			// compute_quantity_refund_total(), which caps it to the remaining line amount,
+			// so the only invalid state left to reject is a line with nothing refundable
+			// remaining. Rounded to currency precision so a sub-cent residue left by
+			// rounding drift counts as fully refunded. Create fills refund_total through
+			// the same capped computation, so preview and create accept identical input.
+			if ( $has_quantity && ! $has_refund_total ) {
+				$refunded_total  = abs( (float) ( $refund_data['totals'][ $line_item_id ] ?? 0.0 ) );
+				$remaining_total = NumberUtil::round( abs( $signed_line_total ) - $refunded_total, $price_decimals );
+				if ( $remaining_total <= 0 ) {
+					return new WP_Error(
+						'line_item_already_refunded',
+						__( 'This line item has already been fully refunded.', 'woocommerce' ),
+						array( 'status' => WP_Http::UNPROCESSABLE_ENTITY )
+					);
+				}
+			}
+		}
+
+		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 ) {
+		$this->log_source = $log_source;
+		try {
+			// 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;
+		} finally {
+			$this->log_source = self::DEFAULT_LOG_SOURCE;
+		}
+	}
+
+	/**
+	 * 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 ) {
+		$this->log_source = $log_source;
+		try {
+			// 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,
+			);
+		} finally {
+			$this->log_source = self::DEFAULT_LOG_SOURCE;
+		}
+	}
+
+	/**
+	 * 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.
+	 *
+	 * Loads refunds once and builds lookup maps for refunded quantities and totals per item ID,
+	 * avoiding repeated get_refunds() calls during serialization. Fee and shipping totals are
+	 * tax-inclusive so they can be compared directly against {@see compute_line_item_refund_total()}.
+	 *
+	 * line_refunds records each product refund line individually — quantity and tax-inclusive
+	 * gross, both as positive magnitudes — so the quantity-form top-up can verify that prior
+	 * refunds match their quantity-derived amounts before reconciling rounding drift.
+	 *
+	 * @param WC_Order $order Order instance.
+	 * @return array{qtys: array<int, int>, totals: array<int, float>, tax_totals: array<int, array<int, float>>, line_refunds: array<int, list<array{qty: int, gross: float}>>}
+	 */
+	public function compute_refunded_quantities_and_totals( WC_Order $order ): array {
+		$qtys         = array();
+		$totals       = array();
+		$tax_totals   = array();
+		$line_refunds = array();
+
+		// Accumulate the already-refunded tax per original item, keyed by tax rate
+		// id, as a positive amount. Refund line items store taxes as negatives, so
+		// flip the sign. Lets the per-tax-id cap subtract prior refunds.
+		$add_refunded_taxes = function ( $refunded_item, int $original_id ) use ( &$tax_totals ) {
+			$taxes = $refunded_item->get_taxes();
+			foreach ( (array) ( $taxes['total'] ?? array() ) as $tax_id => $amount ) {
+				$tax_totals[ $original_id ][ $tax_id ] = ( $tax_totals[ $original_id ][ $tax_id ] ?? 0.0 ) + abs( (float) $amount );
+			}
+		};
+
+		foreach ( $order->get_refunds() as $refund ) {
+			/**
+			 * Refunded product line items.
+			 *
+			 * @var \WC_Order_Item_Product[] $refunded_line_items
+			 */
+			$refunded_line_items = $refund->get_items( 'line_item' );
+			foreach ( $refunded_line_items as $refunded_item ) {
+				$original_id                    = absint( $refunded_item->get_meta( '_refunded_item_id' ) );
+				$qtys[ $original_id ]           = ( $qtys[ $original_id ] ?? 0 ) + $refunded_item->get_quantity();
+				$totals[ $original_id ]         = ( $totals[ $original_id ] ?? 0.0 ) + ( (float) $refunded_item->get_total() + (float) $refunded_item->get_total_tax() ) * -1;
+				$line_refunds[ $original_id ][] = array(
+					'qty'   => absint( $refunded_item->get_quantity() ),
+					'gross' => abs( (float) $refunded_item->get_total() + (float) $refunded_item->get_total_tax() ),
+				);
+				$add_refunded_taxes( $refunded_item, $original_id );
+			}
+			/**
+			 * Refunded fee items.
+			 *
+			 * @var \WC_Order_Item_Fee[] $refunded_fees
+			 */
+			$refunded_fees = $refund->get_items( 'fee' );
+			foreach ( $refunded_fees as $refunded_item ) {
+				$original_id            = absint( $refunded_item->get_meta( '_refunded_item_id' ) );
+				$totals[ $original_id ] = ( $totals[ $original_id ] ?? 0.0 ) + ( (float) $refunded_item->get_total() + (float) $refunded_item->get_total_tax() ) * -1;
+				$add_refunded_taxes( $refunded_item, $original_id );
+			}
+			/**
+			 * Refunded shipping items.
+			 *
+			 * @var \WC_Order_Item_Shipping[] $refunded_shipping
+			 */
+			$refunded_shipping = $refund->get_items( 'shipping' );
+			foreach ( $refunded_shipping as $refunded_item ) {
+				$original_id            = absint( $refunded_item->get_meta( '_refunded_item_id' ) );
+				$totals[ $original_id ] = ( $totals[ $original_id ] ?? 0.0 ) + ( (float) $refunded_item->get_total() + (float) $refunded_item->get_total_tax() ) * -1;
+				$add_refunded_taxes( $refunded_item, $original_id );
+			}
+		}
+
+		return array(
+			'qtys'         => $qtys,
+			'totals'       => $totals,
+			'tax_totals'   => $tax_totals,
+			'line_refunds' => $line_refunds,
+		);
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/RestApi/Refunds/Schema/RefundPreviewSchema.php b/plugins/woocommerce/src/Internal/RestApi/Refunds/Schema/RefundPreviewSchema.php
new file mode 100644
index 00000000000..e314bbc2704
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/RestApi/Refunds/Schema/RefundPreviewSchema.php
@@ -0,0 +1,201 @@
+<?php
+/**
+ * RefundPreviewSchema class.
+ *
+ * @package WooCommerce\RestApi
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Internal\RestApi\Refunds\Schema;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Schema for the refund preview response, shared by the wc/v3 and wc/v4 preview endpoints.
+ * Standalone (no V4 AbstractSchema parent) so wc/v3 does not depend on the v4 route tree.
+ *
+ * @since 11.3.0
+ */
+class RefundPreviewSchema {
+
+	/**
+	 * The schema item identifier.
+	 *
+	 * @var string
+	 */
+	const IDENTIFIER = 'refund-preview';
+
+	/**
+	 * Context for the item schema - view, edit, and embed.
+	 *
+	 * @var array
+	 */
+	const VIEW_EDIT_EMBED_CONTEXT = array( 'view', 'edit', 'embed' );
+
+	/**
+	 * Get the item schema.
+	 *
+	 * @return array The item schema.
+	 * @since 10.9.0
+	 */
+	public function get_item_schema(): array {
+		return array(
+			'$schema'    => 'http://json-schema.org/draft-04/schema#',
+			'title'      => static::IDENTIFIER,
+			'type'       => 'object',
+			'properties' => $this->get_item_schema_properties(),
+		);
+	}
+
+	/**
+	 * Return all properties for the item schema.
+	 *
+	 * @return array
+	 *
+	 * @since 10.9.0
+	 */
+	public function get_item_schema_properties(): array {
+		return array(
+			'breakdown'      => array(
+				'description' => __( 'Refund breakdown by item type.', 'woocommerce' ),
+				'type'        => 'object',
+				'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
+				'readonly'    => true,
+				'properties'  => array(
+					'products' => $this->get_section_schema( 'products' ),
+					'shipping' => $this->get_section_schema( 'shipping' ),
+					'fees'     => $this->get_section_schema( 'fees' ),
+				),
+			),
+			'subtotal'       => array(
+				'description' => __( 'Grand subtotal of the refund preview (excluding tax).', 'woocommerce' ),
+				'type'        => 'string',
+				'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
+				'readonly'    => true,
+			),
+			'tax'            => array(
+				'description' => __( 'Grand tax total of the refund preview.', 'woocommerce' ),
+				'type'        => 'string',
+				'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
+				'readonly'    => true,
+			),
+			'total'          => array(
+				'description' => __( 'Grand total of the refund preview (tax-inclusive).', 'woocommerce' ),
+				'type'        => 'string',
+				'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
+				'readonly'    => true,
+			),
+			'max_refundable' => array(
+				'description' => __( 'Maximum refundable amount remaining on the order.', 'woocommerce' ),
+				'type'        => 'string',
+				'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
+				'readonly'    => true,
+			),
+		);
+	}
+
+	/**
+	 * Schema for one section of the breakdown (products, shipping, or fees).
+	 *
+	 * @param string $section_key One of 'products', 'shipping', 'fees'. Determines which item schema variant is used.
+	 * @return array
+	 */
+	private function get_section_schema( string $section_key ): array {
+		return array(
+			'type'       => 'object',
+			'properties' => array(
+				'items'    => array(
+					'description' => __( 'Line items in this section.', 'woocommerce' ),
+					'type'        => 'array',
+					'items'       => 'products' === $section_key ? $this->get_product_item_schema() : $this->get_base_item_schema(),
+					'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
+					'readonly'    => true,
+				),
+				'subtotal' => array(
+					'description' => __( 'Section subtotal (excluding tax).', 'woocommerce' ),
+					'type'        => 'string',
+					'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
+					'readonly'    => true,
+				),
+				'tax'      => array(
+					'description' => __( 'Section tax total.', 'woocommerce' ),
+					'type'        => 'string',
+					'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
+					'readonly'    => true,
+				),
+				'total'    => array(
+					'description' => __( 'Section total (tax-inclusive).', 'woocommerce' ),
+					'type'        => 'string',
+					'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
+					'readonly'    => true,
+				),
+			),
+		);
+	}
+
+	/**
+	 * Schema for an item entry in the shipping or fees sections.
+	 *
+	 * @return array
+	 */
+	private function get_base_item_schema(): array {
+		return array(
+			'type'       => 'object',
+			'properties' => array(
+				'id'       => array(
+					'description' => __( 'The original order line item ID.', 'woocommerce' ),
+					'type'        => 'integer',
+					'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
+					'readonly'    => true,
+				),
+				'name'     => array(
+					'description' => __( 'The line item name.', 'woocommerce' ),
+					'type'        => 'string',
+					'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
+					'readonly'    => true,
+				),
+				'quantity' => array(
+					'description' => __( 'The quantity being refunded. Null when the refund was specified by amount only.', 'woocommerce' ),
+					'type'        => array( 'integer', 'null' ),
+					'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
+					'readonly'    => true,
+				),
+				'subtotal' => array(
+					'description' => __( 'The refund subtotal for this item (excluding tax).', 'woocommerce' ),
+					'type'        => 'string',
+					'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
+					'readonly'    => true,
+				),
+				'tax'      => array(
+					'description' => __( 'The tax amount for this item.', 'woocommerce' ),
+					'type'        => 'string',
+					'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
+					'readonly'    => true,
+				),
+				'total'    => array(
+					'description' => __( 'The refund total for this item (tax-inclusive).', 'woocommerce' ),
+					'type'        => 'string',
+					'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
+					'readonly'    => true,
+				),
+			),
+		);
+	}
+
+	/**
+	 * Schema for an item entry in the products section (extends the base with product_id).
+	 *
+	 * @return array
+	 */
+	private function get_product_item_schema(): array {
+		$schema                             = $this->get_base_item_schema();
+		$schema['properties']['product_id'] = array(
+			'description' => __( 'Product or variation ID.', 'woocommerce' ),
+			'type'        => 'integer',
+			'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
+			'readonly'    => true,
+		);
+		return $schema;
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Orders/Schema/OrderSchema.php b/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Orders/Schema/OrderSchema.php
index 4380a17436a..f7700698e97 100644
--- a/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Orders/Schema/OrderSchema.php
+++ b/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Orders/Schema/OrderSchema.php
@@ -16,7 +16,7 @@ use Automattic\WooCommerce\Enums\OrderItemType;
 use Automattic\WooCommerce\Enums\OrderStatus;
 use Automattic\WooCommerce\Internal\CostOfGoodsSold\CogsAwareTrait;
 use Automattic\WooCommerce\Internal\RestApi\Routes\V4\Orders\OrderLineMetaValidator;
-use Automattic\WooCommerce\Internal\RestApi\Routes\V4\Refunds\DataUtils;
+use Automattic\WooCommerce\Internal\RestApi\Refunds\DataUtils;
 use Automattic\WooCommerce\Utilities\NumberUtil;
 use Automattic\WooCommerce\Utilities\OrderUtil;
 use WC_Order;
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 c6a8bde69c2..75d61ccb6b8 100644
--- a/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Refunds/Controller.php
+++ b/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Refunds/Controller.php
@@ -13,9 +13,10 @@ namespace Automattic\WooCommerce\Internal\RestApi\Routes\V4\Refunds;

 defined( 'ABSPATH' ) || exit;

+use Automattic\WooCommerce\Internal\RestApi\Refunds\DataUtils;
+use Automattic\WooCommerce\Internal\RestApi\Refunds\Schema\RefundPreviewSchema;
 use Automattic\WooCommerce\Internal\RestApi\Routes\V4\AbstractController;
 use Automattic\WooCommerce\StoreApi\Utilities\Pagination;
-use Automattic\WooCommerce\Internal\RestApi\Routes\V4\Refunds\Schema\RefundPreviewSchema;
 use Automattic\WooCommerce\Internal\RestApi\Routes\V4\Refunds\Schema\RefundSchema;
 use Automattic\WooCommerce\Utilities\MetaDataUtil;
 use WP_Http;
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 966a7be93ee..dab631931ad 100644
--- a/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Refunds/DataUtils.php
+++ b/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Refunds/DataUtils.php
@@ -1,1611 +1,17 @@
 <?php
 /**
- * DataUtils class file.
- */
-
-declare(strict_types=1);
-
-namespace Automattic\WooCommerce\Internal\RestApi\Routes\V4\Refunds;
-
-defined( 'ABSPATH' ) || exit;
-
-use Automattic\WooCommerce\Enums\OrderStatus;
-use Automattic\WooCommerce\Utilities\NumberUtil;
-use WC_Order;
-use WC_Order_Item_Fee;
-use WC_Order_Item_Product;
-use WC_Order_Item_Shipping;
-use WP_Error;
-use WP_Http;
-
-/**
- * Helper methods for the REST API.
- *
- * Class DataUtils
+ * Backward compatibility shim: keeps the old DataUtils FQCN resolving after the class
+ * moved to the version-neutral Internal\RestApi\Refunds namespace.
  *
+ * @deprecated 11.3.0 Use Automattic\WooCommerce\Internal\RestApi\Refunds\DataUtils instead.
  * @package Automattic\WooCommerce\Internal\RestApi\Routes\V4\Refunds
  */
-class DataUtils {
-	/**
-	 * Order statuses that allow refunds.
-	 */
-	public const REFUNDABLE_STATUSES = array(
-		OrderStatus::COMPLETED,
-		OrderStatus::PROCESSING,
-		OrderStatus::ON_HOLD,
-	);
-
-	/**
-	 * Convert line items (schema format) to internal format. This keys arrays by item ID and has some different naming
-	 * conventions.
-	 *
-	 * 111 => [
-	 *   "qty" => 1,
-	 *   "refund_total" => 123,
-	 *   "refund_tax" => [
-	 *     1 => 123,
-	 *     2 => 456,
-	 *   ],
-	 * ]
-	 *
-	 * @param array    $line_items The line items to convert.
-	 * @param WC_Order $order The order being refunded.
-	 * @return array The converted line items.
-	 */
-	public function convert_line_items_to_internal_format( $line_items, WC_Order $order ) {
-		$prepared_line_items = array();
-
-		foreach ( $line_items as $line_item ) {
-			// A line item is processable when it has an ID and at least one of
-			// quantity or refund_total. The legacy v3-style form may omit
-			// quantity entirely; in that case qty=0 is recorded on the refund,
-			// matching v3 semantics ("refunded $X of this line without consuming
-			// specific units"). Dollar accounting via get_remaining_refund_amount
-			// still bounds subsequent refunds, so per-unit looseness here does
-			// not enable over-refunding.
-			if ( ! isset( $line_item['line_item_id'] ) ) {
-				continue;
-			}
-			if ( ! isset( $line_item['quantity'] ) && ! isset( $line_item['refund_total'] ) ) {
-				continue;
-			}
-
-			// refund_tax presence is the discriminator for how refund_total is interpreted:
-			// when refund_tax is absent, refund_total is tax-inclusive and the tax portion is
-			// split out below; when refund_tax is present, refund_total is the tax-exclusive
-			// subtotal and is stored as-is, with the supplied taxes added on top.
-			//
-			// If no explicit refund_tax provided, extract tax from the tax-inclusive
-			// refund_total. Skip when refund_total is also missing — there's nothing
-			// to extract tax from. The split is by the line's own stored total/tax
-			// ratio via split_inclusive_by_stored_ratio(), the same method the preview
-			// uses, so the stored refund matches what build_refund_preview() showed.
-			if ( ! isset( $line_item['refund_tax'] ) && isset( $line_item['refund_total'] ) ) {
-				$original_item = $order->get_item( $line_item['line_item_id'] );
-				if ( $original_item instanceof WC_Order_Item_Product || $original_item instanceof WC_Order_Item_Shipping || $original_item instanceof WC_Order_Item_Fee ) {
-					$split = $this->split_inclusive_by_stored_ratio( (float) $line_item['refund_total'], $original_item, wc_get_price_decimals() );
-
-					// Leave a tax-free line untouched: refund_total stays the full
-					// (tax-exclusive == tax-inclusive) amount and no refund_tax is set.
-					if ( ! empty( $split['taxes'] ) ) {
-						$line_item['refund_tax']   = $this->convert_proportional_taxes_to_schema_format( $split['taxes'] );
-						$line_item['refund_total'] = $split['subtotal'];
-					}
-				}
-			}
-
-			// Default qty=0 when quantity was omitted (legacy v3-style explicit
-			// refund_total path). Default refund_total=0 defensively; in practice
-			// validate_line_items ensures one of them is set by this point.
-			$prepared_line_items[ $line_item['line_item_id'] ] = array(
-				'qty'          => $line_item['quantity'] ?? 0,
-				'refund_total' => $line_item['refund_total'] ?? 0,
-				'refund_tax'   => $this->convert_line_item_taxes_to_internal_format( $line_item['refund_tax'] ?? array() ),
-			);
-		}
-
-		return $prepared_line_items;
-	}
-
-	/**
-	 * Convert line item taxes (schema format) to internal format. This keys arrays by tax ID and has some different naming.
-	 *
-	 * @param array $line_item_taxes The taxes to convert.
-	 * @return array The converted taxes.
-	 *
-	 * @since 10.9.0
-	 */
-	protected function convert_line_item_taxes_to_internal_format( $line_item_taxes ) {
-		$prepared_taxes = array();
-
-		foreach ( $line_item_taxes as $line_item_tax ) {
-			if ( ! isset( $line_item_tax['id'], $line_item_tax['refund_total'] ) ) {
-				continue;
-			}
-			$prepared_taxes[ $line_item_tax['id'] ] = $line_item_tax['refund_total'];
-		}
-
-		return $prepared_taxes;
-	}
-
-	/**
-	 * Calculate the gross refund amount from line items (schema format).
-	 *
-	 * Sums refund_total plus any explicit refund_tax. This yields the tax-inclusive gross
-	 * for both forms: when refund_tax is omitted, refund_total is already tax-inclusive (and
-	 * there is no refund_tax to add); when refund_tax is supplied, refund_total is the
-	 * tax-exclusive subtotal and the taxes are added on top.
-	 *
-	 * @param array $line_items The line items to calculate the refund amount from.
-	 * @return float|null The refund amount, or null if it can't be calculated.
-	 */
-	public function calculate_refund_amount( array $line_items ): ?float {
-		if ( empty( $line_items ) ) {
-			return null;
-		}
-
-		$amount = 0;
-
-		foreach ( $line_items as $line_item ) {
-			// is_numeric() (not !empty) — an explicit refund_total of 0 can be part
-			// of a valid tax-only refund and must be included in the gross sum.
-			if ( isset( $line_item['refund_total'] ) && is_numeric( $line_item['refund_total'] ) ) {
-				$amount += $line_item['refund_total'];
-			}
-
-			if ( ! empty( $line_item['refund_tax'] ) && is_array( $line_item['refund_tax'] ) ) {
-				foreach ( $line_item['refund_tax'] as $tax ) {
-					if ( isset( $tax['refund_total'] ) && is_numeric( $tax['refund_total'] ) ) {
-						$amount += $tax['refund_total'];
-					}
-				}
-			}
-		}
-
-		return (float) NumberUtil::round( $amount, wc_get_price_decimals() );
-	}
-
-	/**
-	 * Validate line items (schema format) before conversion to internal format.
-	 *
-	 * @param array    $line_items The line items to validate.
-	 * @param WC_Order $order The order object.
-	 * @return boolean|WP_Error
-	 */
-	public function validate_line_items( $line_items, WC_Order $order ) {
-		// Reject non-refundable order statuses up front, mirroring the preview path
-		// so create and preview agree on which orders accept refunds.
-		if ( ! in_array( $order->get_status(), self::REFUNDABLE_STATUSES, true ) ) {
-			return new WP_Error(
-				'order_not_refundable',
-				__( 'This order cannot be refunded.', 'woocommerce' ),
-				array( 'status' => WP_Http::UNPROCESSABLE_ENTITY )
-			);
-		}
-
-		// Reject a fully-refunded order up front with the same code/status the
-		// preview path returns, so a fully-refunded order is rejected identically
-		// by both endpoints rather than via the controller's later
-		// refund_exceeds_remaining guard.
-		if ( (float) $order->get_remaining_refund_amount() <= 0 ) {
-			return new WP_Error(
-				'order_not_refundable',
-				__( 'This order has already been fully refunded.', 'woocommerce' ),
-				array( 'status' => WP_Http::UNPROCESSABLE_ENTITY )
-			);
-		}
-
-		// Precompute refunded quantities/totals once so the over-refund check
-		// below caps against remaining refundable quantity, not the original.
-		// Loaded here rather than passed in: the controller dispatches through this
-		// method so subclass overrides keep working, and WC_Order::get_refunds()
-		// serves repeat loads within the request from the object cache.
-		$refund_data = $this->compute_refunded_quantities_and_totals( $order );
-
-		$seen_ids = array();
-		foreach ( $line_items as $line_item ) {
-			$line_item_id = $line_item['line_item_id'] ?? null;
-
-			if ( ! $line_item_id ) {
-				return new WP_Error(
-					'missing_line_item_id',
-					__( 'Line item ID is required.', 'woocommerce' ),
-					array( 'status' => WP_Http::BAD_REQUEST )
-				);
-			}
-
-			// Reject duplicate line items: each is validated against the same remaining
-			// snapshot, so repeating an ID would let the per-line cap pass twice for the
-			// same line. Callers must combine a line into a single entry.
-			if ( isset( $seen_ids[ $line_item_id ] ) ) {
-				return new WP_Error(
-					'duplicate_line_item',
-					__( 'Each line item may appear only once per request.', 'woocommerce' ),
-					array( 'status' => WP_Http::BAD_REQUEST )
-				);
-			}
-			$seen_ids[ $line_item_id ] = true;
-
-			$item = $order->get_item( $line_item_id );
-
-			// Validate item exists and belongs to the order.
-			if ( ! $item || $item->get_order_id() !== $order->get_id() ) {
-				return new WP_Error(
-					'line_item_not_found',
-					__( 'Line item not found.', 'woocommerce' ),
-					array( 'status' => WP_Http::BAD_REQUEST )
-				);
-			}
-
-			if ( ! $item instanceof \WC_Order_Item_Product && ! $item instanceof \WC_Order_Item_Fee && ! $item instanceof \WC_Order_Item_Shipping ) {
-				return new WP_Error(
-					'unsupported_item_type',
-					__( 'Line item is not a product, fee, or shipping line.', 'woocommerce' ),
-					array( 'status' => WP_Http::BAD_REQUEST )
-				);
-			}
-
-			// Quantity is required only when the client omits refund_total — the
-			// auto-compute path needs a real quantity to derive the unit price.
-			// When refund_total is provided explicitly (legacy v3-style path),
-			// quantity is informational and can be missing/zero, matching the
-			// original v4 schema's `default: 0` behavior.
-			$refund_total_missing = ! array_key_exists( 'refund_total', $line_item ) || null === $line_item['refund_total'];
-
-			// Reject the ambiguous "auto-computed refund_total + explicit refund_tax"
-			// combination. Auto-compute writes a tax-inclusive value; the
-			// converter then skips tax extraction because refund_tax is set,
-			// and calculate_refund_amount double-counts the tax. The client
-			// must either supply refund_total explicitly (and may then supply
-			// refund_tax to override the auto-extracted split) or let the
-			// server handle taxes (omit both).
-			if ( $refund_total_missing && isset( $line_item['refund_tax'] ) ) {
-				return new WP_Error(
-					'invalid_line_item',
-					__( 'refund_tax cannot be combined with an auto-computed refund_total. Provide refund_total explicitly when supplying refund_tax.', 'woocommerce' ),
-					array( 'status' => WP_Http::BAD_REQUEST )
-				);
-			}
-
-			if ( $refund_total_missing && ( ! isset( $line_item['quantity'] ) || ! is_int( $line_item['quantity'] ) || $line_item['quantity'] < 1 ) ) {
-				return new WP_Error(
-					'missing_quantity_or_refund_total',
-					__( 'Line item quantity must be a positive integer when refund_total is omitted.', 'woocommerce' ),
-					array( 'status' => WP_Http::BAD_REQUEST )
-				);
-			}
-
-			// When refund_total is provided, a supplied quantity is informational, but it must
-			// still be a non-negative integer so it round-trips cleanly onto the refund line —
-			// a negative or fractional value would be stored verbatim as the line qty. 0 (or an
-			// omitted quantity) means "dollars only". This mirrors the integer/range checks the
-			// preview path applies before branching on item type.
-			if ( ! $refund_total_missing && isset( $line_item['quantity'] ) && ( ! is_int( $line_item['quantity'] ) || $line_item['quantity'] < 0 ) ) {
-				return new WP_Error(
-					'invalid_quantity',
-					__( 'Line item quantity must be a non-negative integer.', 'woocommerce' ),
-					array( 'status' => WP_Http::BAD_REQUEST )
-				);
-			}
-
-			// Auto-compute requires a non-zero source quantity to derive the unit
-			// price from. If the client omitted refund_total (or sent null) and the
-			// source product has zero quantity, surface a clear error rather than
-			// letting the request slip into the misleading "must be greater than
-			// zero" branch downstream.
-			if ( $refund_total_missing && $item instanceof \WC_Order_Item_Product && 0 === $item->get_quantity() ) {
-				return new WP_Error(
-					'invalid_line_item',
-					sprintf(
-						/* translators: %d: line item id */
-						__( 'Cannot auto-compute refund for line item %d: source quantity is zero. Provide an explicit refund_total.', 'woocommerce' ),
-						(int) $line_item_id
-					),
-					array( 'status' => WP_Http::BAD_REQUEST )
-				);
-			}
-
-			// Validate refund quantity does not exceed remaining refundable
-			// quantity for this line. compute_refunded_quantities_and_totals
-			// returns negative values for already-refunded units (matches the
-			// convention used by validate_preview_line_items), so adding to
-			// $item->get_quantity() yields the remaining count.
-			// Only fires when a quantity was provided — the legacy
-			// explicit-refund_total path may omit it.
-			if ( isset( $line_item['quantity'] ) && $item instanceof \WC_Order_Item_Product ) {
-				$remaining_qty = $item->get_quantity() + ( $refund_data['qtys'][ $line_item_id ] ?? 0 );
-				if ( $line_item['quantity'] > $remaining_qty ) {
-					return new WP_Error(
-						'quantity_exceeds_refundable',
-						sprintf(
-							/* translators: %d: remaining refundable quantity */
-							__( 'Line item quantity cannot be greater than the remaining refundable quantity (%d).', 'woocommerce' ),
-							$remaining_qty
-						),
-						array( 'status' => WP_Http::UNPROCESSABLE_ENTITY )
-					);
-				}
-			} elseif ( isset( $line_item['quantity'] ) && $line_item['quantity'] > 1 ) {
-				return new WP_Error(
-					'invalid_quantity',
-					__( 'Shipping and fee line items must be refunded with quantity of 1.', 'woocommerce' ),
-					array( 'status' => WP_Http::BAD_REQUEST )
-				);
-			}
-
-			// Validate refund total against the remaining refundable amount for this
-			// line (including tax), subtracting any prior partial refunds. Rounds both
-			// sides to currency precision and uses abs() so the cap matches
-			// validate_preview_line_items() exactly — a previewed amount that is
-			// accepted (or rejected) there behaves the same way here.
-			if ( isset( $line_item['refund_total'] ) ) {
-				$price_decimals    = wc_get_price_decimals();
-				$signed_line_total = (float) $item->get_total() + (float) $item->get_total_tax();
-
-				// Reject a refund_total whose sign is opposite the line: you cannot refund
-				// a positive amount from a discount line, or a negative amount from a normal
-				// line. Without this, abs() in the cap below would let a wrong-sign value
-				// pass and be stored (e.g. a negative refund_total on a positive line in a
-				// mixed-line request whose total stays positive). A gross line refund that
-				// rounds to 0 is rejected below, so create and preview stay aligned for the
-				// tax-inclusive form while explicit tax-only create requests remain valid.
-				if ( (float) $line_item['refund_total'] * $signed_line_total < 0 ) {
-					return new WP_Error(
-						'invalid_refund_total',
-						__( 'Refund total has the wrong sign for this line item.', 'woocommerce' ),
-						array( 'status' => WP_Http::BAD_REQUEST )
-					);
-				}
-
-				// Cap and zero-check the GROSS line refund against the line's tax-inclusive
-				// total. When an explicit refund_tax breakdown is supplied, refund_total is
-				// the tax-exclusive (net) subtotal and the tax is added on top (core Woo
-				// semantics — see RefundSchema); without it, refund_total is already
-				// tax-inclusive, so the gross equals refund_total. Capping the net alone
-				// would let a client push the overage into refund_tax and over-refund the
-				// line. Preview has no refund_tax field, so its (refund_total-only) cap stays
-				// equivalent for the inclusive form.
-				$line_refund_gross = (float) $line_item['refund_total'];
-				if ( ! empty( $line_item['refund_tax'] ) && is_array( $line_item['refund_tax'] ) ) {
-					$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 );
-					}
-				}
-
-				// Reject a gross line refund that rounds to zero. A zero line refund is a
-				// no-op that would otherwise be stored as an empty qty:0 refund line.
-				if ( 0.0 === (float) NumberUtil::round( $line_refund_gross, $price_decimals ) ) {
-					return new WP_Error(
-						'invalid_refund_total',
-						__( 'refund_total must be a number greater than zero.', 'woocommerce' ),
-						array( 'status' => WP_Http::BAD_REQUEST )
-					);
-				}
-
-				$refunded_total   = (float) ( $refund_data['totals'][ $line_item_id ] ?? 0.0 );
-				$validation_error = $this->validate_refund_line_total( $item, $line_refund_gross, $refunded_total );
-				if ( is_wp_error( $validation_error ) ) {
-					return $validation_error;
-				}
-			}
-
-			if ( isset( $line_item['refund_tax'] ) ) {
-				$item_taxes = $item->get_taxes();
-
-				if ( $item_taxes ) {
-					$allowed_tax_ids = array_keys( $item_taxes['total'] ?? array() );
-
-					foreach ( $line_item['refund_tax'] as $refund_tax ) {
-						if ( ! isset( $refund_tax['id'], $refund_tax['refund_total'] ) ) {
-							return new WP_Error( 'invalid_line_item', __( 'Tax id and refund_total are required.', 'woocommerce' ), array( 'status' => WP_Http::BAD_REQUEST ) );
-						}
-						$tax_id           = $refund_tax['id'];
-						$tax_refund_total = $refund_tax['refund_total'];
-
-						if ( ! in_array( $tax_id, $allowed_tax_ids, true ) ) {
-							return new WP_Error(
-								'invalid_line_item',
-								sprintf(
-								/* translators: %s: tax IDs */
-									__( 'Line item tax not found. Must be: %s.', 'woocommerce' ),
-									implode( ', ', $allowed_tax_ids )
-								),
-								array( 'status' => WP_Http::BAD_REQUEST )
-							);
-						}
-
-						$price_decimals = wc_get_price_decimals();
-						$stored_tax     = (float) $item_taxes['total'][ $tax_id ];
-						$requested_tax  = (float) $tax_refund_total;
-
-						// Reject a refund_tax whose sign is opposite the stored tax bucket: you
-						// cannot refund a positive tax from a negative (discount) bucket or vice
-						// versa. Mirrors the refund_total wrong-sign guard. Compare on absolute
-						// magnitudes below so a negative bucket is capped the same way a positive
-						// one is — a signed `<` admits an over-refund of a negative bucket and
-						// rejects a valid partial one. An explicit 0 is allowed (a no-op).
-						if ( $requested_tax * $stored_tax < 0 ) {
-							return new WP_Error(
-								'invalid_refund_amount',
-								__( 'Refund tax total has the wrong sign for this line item.', 'woocommerce' ),
-								array( 'status' => WP_Http::BAD_REQUEST )
-							);
-						}
-
-						// Cap against the remaining tax for this bucket, subtracting any tax
-						// already refunded for this tax id on prior refunds — not the original
-						// line tax — so sequential refunds cannot over-refund a single bucket.
-						// $already_refunded_tax is accumulated as a positive magnitude
-						// (compute_refunded_quantities_and_totals() uses abs()), so compare it
-						// against the stored bucket's magnitude. Round both sides to currency
-						// precision: the accumulator is built from repeated float additions, so
-						// an unrounded compare could reject or admit an exactly-correct amount by
-						// a sub-cent residue.
-						$already_refunded_tax = (float) ( $refund_data['tax_totals'][ $line_item_id ][ $tax_id ] ?? 0.0 );
-						$remaining_tax        = abs( $stored_tax ) - $already_refunded_tax;
-						if ( abs( $requested_tax ) > NumberUtil::round( $remaining_tax, $price_decimals ) ) {
-							// 400, not the 422 the over-refund caps above use: this is the
-							// status the released wc/v4 envelope already backfills for this
-							// error, so anything else would change a shipped response. It
-							// also keeps the code-to-status mapping one-to-one across this
-							// file — invalid_refund_amount is 400 at every site, sharing the
-							// code with the wrong-sign guard above, which is malformed
-							// input, while each 422 carries its own over-refund code.
-							return new WP_Error(
-								'invalid_refund_amount',
-								sprintf(
-								/* translators: %s: remaining refundable tax total */
-									__( 'Refund tax total cannot be greater than the remaining refundable tax for this line item (%s).', 'woocommerce' ),
-									wc_format_decimal( $remaining_tax, $price_decimals )
-								),
-								array( 'status' => WP_Http::BAD_REQUEST )
-							);
-						}
-					}
-				}
-			}
-		}
-
-		return true;
-	}
-
-	/**
-	 * Check a line refund against the original line and previous refunds.
-	 *
-	 * Both refund paths use this limit. The computed path passes a tax-inclusive
-	 * amount; the explicit wc/v3 path passes its tax-inclusive gross amount.
-	 *
-	 * @since 11.3.0
-	 *
-	 * @param WC_Order_Item_Product|WC_Order_Item_Fee|WC_Order_Item_Shipping $item                  Original order item.
-	 * @param float                                                          $refund_total          Requested line item refund amount.
-	 * @param float                                                          $already_refunded_total Prior refunds for this item, including tax.
-	 * @return true|WP_Error Validation result.
-	 */
-	public function validate_refund_line_total( $item, float $refund_total, float $already_refunded_total ) {
-		$price_decimals      = wc_get_price_decimals();
-		$signed_line_total   = (float) $item->get_total() + (float) $item->get_total_tax();
-		$item_total_with_tax = abs( $signed_line_total );
-		$abs_refund_total    = NumberUtil::round( abs( $refund_total ), $price_decimals );
-
-		if ( $refund_total * $signed_line_total < 0 ) {
-			return new WP_Error(
-				'invalid_refund_total',
-				__( 'Refund total has the wrong sign for this line item.', 'woocommerce' ),
-				array( 'status' => WP_Http::BAD_REQUEST )
-			);
-		}
-
-		// Mirror the preview path's three distinct over-refund errors (same
-		// codes, messages, and 422 status) so create and preview reject the
-		// same input identically. An over-refund is a well-formed but
-		// unprocessable request, so 422 — not 400 — is the correct status,
-		// matching the order-level cap the controller already returns.
-		if ( $abs_refund_total > NumberUtil::round( $item_total_with_tax, $price_decimals ) ) {
-			return new WP_Error(
-				'refund_total_exceeds_line',
-				sprintf(
-					/* translators: %s: line item total including tax */
-					__( 'refund_total cannot exceed the line item total including tax (%s).', 'woocommerce' ),
-					wc_format_decimal( $item_total_with_tax, $price_decimals )
-				),
-				array( 'status' => WP_Http::UNPROCESSABLE_ENTITY )
-			);
-		}
-
-		// Remaining is rounded to currency precision before both checks, so a
-		// sub-cent residue left by rounding drift counts as fully refunded
-		// rather than producing a "cannot exceed 0.00" rejection.
-		$remaining_total = NumberUtil::round( $item_total_with_tax - abs( $already_refunded_total ), $price_decimals );
-		if ( $remaining_total <= 0 ) {
-			return new WP_Error(
-				'line_item_already_refunded',
-				__( 'This line item has already been fully refunded.', 'woocommerce' ),
-				array( 'status' => WP_Http::UNPROCESSABLE_ENTITY )
-			);
-		}
-		if ( $abs_refund_total > $remaining_total ) {
-			return new WP_Error(
-				'refund_total_exceeds_remaining',
-				sprintf(
-					/* translators: %s: remaining refundable amount */
-					__( 'refund_total cannot exceed the remaining refundable amount for this line item (%s).', 'woocommerce' ),
-					wc_format_decimal( $remaining_total, $price_decimals )
-				),
-				array( 'status' => WP_Http::UNPROCESSABLE_ENTITY )
-			);
-		}
-
-		return true;
-	}
-
-	/**
-	 * Convert calculated taxes (internal format) to schema format.
-	 *
-	 * @param array $calculated_taxes Taxes keyed by tax ID with amounts.
-	 * @return array Schema format with id and refund_total keys.
-	 *
-	 * @since 10.9.0
-	 */
-	protected function convert_proportional_taxes_to_schema_format( array $calculated_taxes ): array {
-		$result = array();
-		foreach ( $calculated_taxes as $tax_id => $amount ) {
-			$result[] = array(
-				'id'           => (int) $tax_id,
-				'refund_total' => $amount,
-			);
-		}
-		return $result;
-	}
-
-	/**
-	 * Split a tax-inclusive amount into net subtotal and per-tax-ID tax amounts using
-	 * the line item's own stored total/tax ratio.
-	 *
-	 * Splitting by the line's actual stored proportion — rather than re-deriving tax
-	 * from the order tax item's rate percent — returns exactly what was charged. It
-	 * stays correct when the stored tax is not an exact rate% of net (manually edited
-	 * tax, or a rate that changed after the order) and when a taxed line's rate
-	 * resolves to zero. Preview ({@see build_refund_preview()}) and create
-	 * ({@see convert_line_items_to_internal_format()}) share this method so a previewed
-	 * split always matches the split stored on the created refund.
-	 *
-	 * Per-ID amounts are rounded and the subtotal is derived as amount - sum(tax), so
-	 * the invariant subtotal + total_tax == amount holds exactly at $dp precision.
-	 *
-	 * @param float                                                          $amount Tax-inclusive amount to split. Rounded to $dp before splitting.
-	 * @param WC_Order_Item_Product|WC_Order_Item_Shipping|WC_Order_Item_Fee $item   Order item supplying the stored total/tax ratio.
-	 * @param int                                                            $dp     Price decimal places.
-	 * @return array{subtotal: float, total_tax: float, taxes: array<int, float>} Net subtotal, summed tax, and per-tax-ID amounts.
-	 *
-	 * @since 10.9.0
-	 */
-	protected function split_inclusive_by_stored_ratio( float $amount, $item, int $dp ): array {
-		$amount       = NumberUtil::round( $amount, $dp );
-		$stored_total = (float) $item->get_total();
-
-		// Keep only non-zero numeric stored taxes (positive or negative). A negative-tax
-		// discount fee must retain its breakdown; a zero entry contributes nothing.
-		$stored_taxes = array_filter(
-			$item->get_taxes()['total'] ?? array(),
-			function ( $t ) {
-				return is_numeric( $t ) && 0.0 !== (float) $t;
-			}
-		);
-
-		$stored_tax_total = array_sum( array_map( 'floatval', $stored_taxes ) );
-		$stored_with_tax  = $stored_total + $stored_tax_total;
-
-		// Fallback used whenever the stored data can't yield a sane proportional split:
-		// treat the whole amount as net (no tax) and log for observability.
-		$unsplittable = function ( string $reason ) use ( $amount, $item ) {
-			wc_get_logger()->warning(
-				sprintf(
-					'Refund tax split: cannot split tax for item %d on order %d (%s).',
-					(int) $item->get_id(),
-					(int) $item->get_order_id(),
-					$reason
-				),
-				array( 'source' => 'wc-v4-refunds' )
-			);
-			return array(
-				'subtotal'  => $amount,
-				'total_tax' => 0.0,
-				'taxes'     => array(),
-			);
-		};
-
-		// No tax on the line: the whole amount is net (not an error, no log).
-		if ( empty( $stored_taxes ) ) {
-			return array(
-				'subtotal'  => $amount,
-				'total_tax' => 0.0,
-				'taxes'     => array(),
-			);
-		}
-
-		// A zero-value line (stored total nets to zero while a tax was charged) can't be
-		// split proportionally — avoid division by zero.
-		if ( 0.0 === (float) $stored_with_tax ) {
-			return $unsplittable( 'stored total incl. tax is zero' );
-		}
-
-		// Scale each stored tax by the share of the line being refunded.
-		$taxes = array();
-		foreach ( $stored_taxes as $tax_id => $stored_tax ) {
-			$taxes[ (int) $tax_id ] = NumberUtil::round( $amount * ( (float) $stored_tax / $stored_with_tax ), $dp );
-		}
-		$total_tax = NumberUtil::round( array_sum( $taxes ), $dp );
-
-		// Sanity clamp: the tax portion of a tax-inclusive amount can never exceed the
-		// amount itself. A larger value means the stored total/tax nearly cancel (e.g. a
-		// near-zero inclusive total from manually edited data), which would explode the
-		// ratio. Fall back rather than emit a nonsensical negative subtotal.
-		if ( abs( $total_tax ) > abs( $amount ) ) {
-			return $unsplittable( 'stored total and tax nearly cancel' );
-		}
-
-		$subtotal = NumberUtil::round( $amount - $total_tax, $dp );
-
-		return array(
-			'subtotal'  => $subtotal,
-			'total_tax' => $total_tax,
-			'taxes'     => $taxes,
-		);
-	}
-
-	/**
-	 * Compute the tax-inclusive refund total for a line item at a given quantity.
-	 *
-	 * Precondition: $item must be one of WC_Order_Item_Product, WC_Order_Item_Shipping,
-	 * WC_Order_Item_Fee, and $quantity must be a positive integer (>= 1). For
-	 * shipping and fee items the quantity is informational only — the full item
-	 * total is returned regardless. Callers using untrusted input should validate
-	 * via {@see validate_preview_line_items()} first.
-	 *
-	 * @param WC_Order_Item_Product|WC_Order_Item_Shipping|WC_Order_Item_Fee $item     The order item.
-	 * @param int                                                            $quantity The quantity to refund (>= 1).
-	 * @return float The tax-inclusive refund total. May be negative for items with negative totals (e.g. discount fees).
-	 * @throws \InvalidArgumentException When $quantity is less than 1.
-	 *
-	 * @since 10.9.0
-	 */
-	public function compute_line_item_refund_total( $item, int $quantity ): float {
-		if ( $quantity < 1 ) {
-			// Exception message is developer-facing only; the value is a typed int and the format is a literal string.
-			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
-			throw new \InvalidArgumentException( sprintf( 'Quantity must be >= 1, got %d.', (int) $quantity ) );
-		}
-
-		$price_decimals = wc_get_price_decimals();
-
-		if ( $item instanceof WC_Order_Item_Product ) {
-			$original_qty = $item->get_quantity();
-			if ( 0 === $original_qty ) {
-				wc_get_logger()->warning(
-					sprintf( 'Refund preview: product item %d has zero original quantity on order %d.', $item->get_id(), $item->get_order_id() ),
-					array( 'source' => 'wc-v4-refunds' )
-				);
-				return 0.0;
-			}
-			$unit_price_with_tax = ( (float) $item->get_total() + (float) $item->get_total_tax() ) / $original_qty;
-			return NumberUtil::round( $unit_price_with_tax * $quantity, $price_decimals );
-		}
-
-		return NumberUtil::round( (float) $item->get_total() + (float) $item->get_total_tax(), $price_decimals );
-	}

-	/**
-	 * Compute the tax-inclusive refund total for a quantity-form line item, capped to
-	 * the line's remaining refundable amount.
-	 *
-	 * {@see compute_line_item_refund_total()} rounds each request independently, so a
-	 * sequence of partial quantity refunds can drift from the stored line gross by up
-	 * to a cent per request — round(unit × 2) + round(unit × 4) may exceed the line
-	 * total that round(unit × 6) would produce. The quantity form is "server, compute
-	 * the amount for me", so rather than rejecting its own arithmetic the server caps
-	 * the result:
-	 *
-	 * - The unit-derived amount is always clamped down to the remaining amount, so
-	 *   rounding drift can never push it over the remaining-amount cap the validators
-	 *   enforce. Shipping and fee lines carry a single unit whose derived amount is the
-	 *   full line total, so partially-refunded ones always resolve to their remainder
-	 *   through this clamp.
-	 * - A product quantity that consumes the line's remaining refundable units is
-	 *   topped up to the exact remaining amount — closing the line at currency
-	 *   precision with no stranded cents — but only when every prior refund on the
-	 *   line matches its own quantity-derived amount, i.e. the shortfall is provably
-	 *   accumulated rounding drift. An off-schedule prior refund (an explicit partial
-	 *   amount, or a dollar-only refund with no units) means the residue was
-	 *   deliberately withheld, and the quantity form must never silently pay it back
-	 *   out; the residue stays refundable through an explicit refund_total.
-	 *
-	 * When no refundable amount remains, the unclamped amount is returned as-is:
-	 * clamping to zero would trip the zero-refund guard with a misleading error, while
-	 * the validators reject the line with line_item_already_refunded. Explicit
-	 * client-supplied refund_total values are never capped — those stay strictly
-	 * validated.
-	 *
-	 * @param WC_Order_Item_Product|WC_Order_Item_Shipping|WC_Order_Item_Fee $item        The order item.
-	 * @param int                                                            $quantity    The quantity to refund (>= 1).
-	 * @param array                                                          $refund_data Refund-history snapshot from {@see compute_refunded_quantities_and_totals()} (see its return shape).
-	 * @return float The tax-inclusive refund total, carrying the line's sign.
-	 *
-	 * @since 11.1.0
-	 */
-	private function compute_quantity_refund_total( $item, int $quantity, array $refund_data ): float {
-		$computed          = $this->compute_line_item_refund_total( $item, $quantity );
-		$price_decimals    = wc_get_price_decimals();
-		$signed_line_total = (float) $item->get_total() + (float) $item->get_total_tax();
-		$refunded_total    = abs( (float) ( $refund_data['totals'][ $item->get_id() ] ?? 0.0 ) );
-		$remaining_total   = NumberUtil::round( abs( $signed_line_total ) - $refunded_total, $price_decimals );
-
-		if ( $remaining_total <= 0 ) {
-			return $computed;
-		}
-
-		$sign         = $signed_line_total < 0 ? -1.0 : 1.0;
-		$abs_computed = abs( $computed );
-
-		if ( $abs_computed > $remaining_total ) {
-			return $sign * $remaining_total;
-		}
-
-		if ( $item instanceof WC_Order_Item_Product && $abs_computed < $remaining_total ) {
-			$remaining_qty = $item->get_quantity() + ( $refund_data['qtys'][ $item->get_id() ] ?? 0 );
-			if (
-				$quantity >= $remaining_qty &&
-				$this->line_refund_history_matches_quantities( $item, $refund_data['line_refunds'][ $item->get_id() ] ?? array() )
-			) {
-				return $sign * $remaining_total;
-			}
-		}
-
-		return $computed;
-	}
-
-	/**
-	 * Whether every prior refund line for a product matches its quantity-derived amount.
-	 *
-	 * True means the difference between the line's refunded total and the sum of
-	 * unit-price amounts is pure rounding drift, so the final-chunk top-up in
-	 * {@see compute_quantity_refund_total()} can safely reconcile it. Gross values are
-	 * compared as formatted decimals at currency precision — never raw float
-	 * equality. A refund line with no units (qty 0, the dollar-only form) is
-	 * off-schedule by definition.
-	 *
-	 * @param WC_Order_Item_Product $item         The original order line.
-	 * @param array                 $line_refunds Prior refund lines as list<array{qty: int, gross: float}> (positive magnitudes).
-	 * @return bool
-	 */
-	private function line_refund_history_matches_quantities( WC_Order_Item_Product $item, array $line_refunds ): bool {
-		$price_decimals = wc_get_price_decimals();
-
-		foreach ( $line_refunds as $refund_line ) {
-			if ( $refund_line['qty'] <= 0 ) {
-				return false;
-			}
-
-			$expected = abs( $this->compute_line_item_refund_total( $item, $refund_line['qty'] ) );
-			if ( wc_format_decimal( $refund_line['gross'], $price_decimals ) !== wc_format_decimal( $expected, $price_decimals ) ) {
-				return false;
-			}
-		}
-
-		return true;
-	}
-
-	/**
-	 * Round every caller-supplied refund_total to currency precision.
-	 *
-	 * Applied at the entry of both the preview and create flows so a value the client
-	 * sends is validated, summed, split, and stored at the same precision. A previewed
-	 * amount therefore always matches the created refund to the cent. A missing or null
-	 * refund_total (the auto-compute form) is left untouched — those are computed later
-	 * and already rounded by {@see compute_line_item_refund_total()}.
-	 *
-	 * @param array $line_items Line items in schema format.
-	 * @return array Line items with numeric refund_total values rounded to wc_get_price_decimals().
-	 *
-	 * @since 10.9.0
-	 */
-	public function normalize_refund_totals( array $line_items ): array {
-		$price_decimals = wc_get_price_decimals();
-		foreach ( $line_items as $key => $line_item ) {
-			if ( isset( $line_item['refund_total'] ) && is_numeric( $line_item['refund_total'] ) ) {
-				$line_items[ $key ]['refund_total'] = NumberUtil::round( (float) $line_item['refund_total'], $price_decimals );
-			}
-		}
-		return $line_items;
-	}
-
-	/**
-	 * Fill in refund_total for any line item that omits it, computing the value from
-	 * the order item's unit price × quantity via compute_quantity_refund_total(),
-	 * which caps the result to the line's remaining refundable amount.
-	 *
-	 * Items that already have refund_total (including an explicit 0) are left
-	 * untouched so validation can decide whether the explicit amount is valid.
-	 * Items where refund_total is omitted OR is explicitly null are treated as
-	 * "compute it for me". Items that can't be resolved (missing line_item_id,
-	 * item not on order, invalid quantity, unsupported item type, product with
-	 * zero source quantity) are also left untouched — validate_line_items surfaces
-	 * the right error for those cases.
-	 *
-	 * Auto-computed values are tax-inclusive, matching the convention enforced by
-	 * the existing converter (convert_line_items_to_internal_format extracts tax
-	 * from a tax-inclusive refund_total).
-	 *
-	 * @param array    $line_items Line items from the request (schema format).
-	 *                             Each item: array{line_item_id?: int, quantity?: int,
-	 *                             refund_total?: float|int|null, refund_tax?: array<int, mixed>}.
-	 * @param WC_Order $order      The order being refunded.
-	 * @return array The line items with refund_total populated where possible (same shape as input).
-	 *
-	 * @since 10.9.0
-	 */
-	public function fill_missing_refund_totals( array $line_items, WC_Order $order ): array {
-		// Round caller-supplied amounts up front so explicit values are stored at the
-		// same precision the preview validated and showed. Computed values below are
-		// already rounded by compute_quantity_refund_total().
-		$line_items = $this->normalize_refund_totals( $line_items );
-
-		// Loaded lazily: only requests with at least one auto-computed line pay for
-		// the refund-history scan the remaining-amount cap needs.
-		$refund_data = null;
-
-		foreach ( $line_items as $key => $line_item ) {
-			// Treat a missing key and an explicit `null` value the same — both mean
-			// "compute it for me". An explicit `0` is caller-supplied input, so leave
-			// it untouched and let validation decide whether the gross line refund is valid.
-			if ( array_key_exists( 'refund_total', $line_item ) && null !== $line_item['refund_total'] ) {
-				continue;
-			}
-
-			// Skip auto-compute when the client also supplied an explicit
-			// refund_tax. Auto-compute writes a tax-inclusive refund_total, but
-			// the converter then skips tax extraction whenever refund_tax is
-			// already present — and calculate_refund_amount would add both,
-			// inflating the total by the tax amount. Leave refund_total unset;
-			// validate_line_items rejects this ambiguous combination with a
-			// clear error.
-			if ( isset( $line_item['refund_tax'] ) ) {
-				continue;
-			}
-
-			$line_item_id = $line_item['line_item_id'] ?? null;
-			$quantity     = $line_item['quantity'] ?? null;
-			if ( ! $line_item_id || ! is_int( $quantity ) || $quantity < 1 ) {
-				continue;
-			}
-
-			$item = $order->get_item( $line_item_id );
-			if ( ! $item || ! ( $item instanceof WC_Order_Item_Product || $item instanceof WC_Order_Item_Shipping || $item instanceof WC_Order_Item_Fee ) ) {
-				continue;
-			}
-
-			// A product whose source line has zero quantity has no unit price to
-			// derive a refund from. Skip so validate_line_items surfaces a clear
-			// 'invalid_line_item' error to the API consumer instead of letting a
-			// silent 0.0 propagate into the misleading "must be greater than zero"
-			// branch downstream.
-			if ( $item instanceof WC_Order_Item_Product && 0 === $item->get_quantity() ) {
-				continue;
-			}
-
-			if ( null === $refund_data ) {
-				$refund_data = $this->compute_refunded_quantities_and_totals( $order );
-			}
-
-			$line_items[ $key ]['refund_total'] = $this->compute_quantity_refund_total( $item, $quantity, $refund_data );
-		}
-
-		return $line_items;
-	}
-
-	/**
-	 * Build a refund preview showing authoritative totals and breakdowns.
-	 *
-	 * Callers must invoke {@see validate_preview_line_items()} first — this
-	 * method assumes inputs have been validated and throws on missing items.
-	 *
-	 * Each line item must have 'line_item_id' and at least one of 'quantity'
-	 * (positive int) or 'refund_total' (positive tax-inclusive float). When
-	 * 'refund_total' is present and positive it is used directly; otherwise the
-	 * total is computed from quantity via {@see compute_quantity_refund_total()},
-	 * capped to the line's remaining refundable amount — the same computation the
-	 * create flow stores, so the previewed amounts always match the created refund.
-	 *
-	 * @param WC_Order $order      The order being previewed for refund.
-	 * @param array    $line_items Line items. Each: array{line_item_id: int, quantity?: int, refund_total?: float}.
-	 * @return array The structured preview response.
-	 * @throws \InvalidArgumentException When a line_item_id does not resolve to an item on the order.
-	 *
-	 * @since 10.9.0
-	 */
-	public function build_refund_preview( WC_Order $order, array $line_items ): array {
-		$price_decimals = wc_get_price_decimals();
-		$refund_data    = $this->compute_refunded_quantities_and_totals( $order );
-		$sections       = array(
-			'products' => array(
-				'items'    => array(),
-				'subtotal' => 0.0,
-				'tax'      => 0.0,
-				'total'    => 0.0,
-			),
-			'shipping' => array(
-				'items'    => array(),
-				'subtotal' => 0.0,
-				'tax'      => 0.0,
-				'total'    => 0.0,
-			),
-			'fees'     => array(
-				'items'    => array(),
-				'subtotal' => 0.0,
-				'tax'      => 0.0,
-				'total'    => 0.0,
-			),
-		);
-
-		foreach ( $line_items as $line_item ) {
-			$item = $order->get_item( $line_item['line_item_id'] );
-			if ( ! $item ) {
-				// Exception message is developer-facing only; both values are typed ints and the format is a literal string.
-				// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
-				throw new \InvalidArgumentException( sprintf( 'Line item %d not found on order %d.', (int) $line_item['line_item_id'], (int) $order->get_id() ) );
-			}
-
-			/**
-			 * Validated by validate_preview_line_items() upstream.
-			 *
-			 * @var WC_Order_Item_Product|WC_Order_Item_Shipping|WC_Order_Item_Fee $item
-			 */
-			// When the caller provides an explicit refund_total (partial-amount form) use it
-			// directly. The quantity-based form computes the tax-inclusive total from unit price,
-			// capped to the line's remaining refundable amount — the same computation
-			// fill_missing_refund_totals() feeds the create flow.
-			// A non-zero check (not > 0) mirrors validate_preview_line_items(), which accepts a
-			// negative refund_total for a negative discount line and rejects a present-but-zero
-			// one before this method runs — so a signed value is honoured rather than falling
-			// through to the (possibly absent) quantity.
-			$refund_total_with_tax = isset( $line_item['refund_total'] ) && is_numeric( $line_item['refund_total'] ) && 0.0 !== (float) $line_item['refund_total']
-				? NumberUtil::round( (float) $line_item['refund_total'], $price_decimals )
-				: $this->compute_quantity_refund_total( $item, (int) $line_item['quantity'], $refund_data );
-
-			// Split by the line's own stored total/tax ratio so the preview reflects what
-			// was actually charged and matches the split create stores (both call this).
-			$split    = $this->split_inclusive_by_stored_ratio( $refund_total_with_tax, $item, $price_decimals );
-			$subtotal = $split['subtotal'];
-			$tax      = $split['total_tax'];
-
-			$item_data = array(
-				'id'       => $line_item['line_item_id'],
-				'quantity' => $line_item['quantity'] ?? null,
-				'subtotal' => wc_format_decimal( $subtotal, $price_decimals ),
-				'tax'      => wc_format_decimal( $tax, $price_decimals ),
-				'total'    => wc_format_decimal( $refund_total_with_tax, $price_decimals ),
-			);
-
-			$item_data['name'] = $item->get_name();
-
-			if ( $item instanceof WC_Order_Item_Product ) {
-				$variation_id            = $item->get_variation_id();
-				$item_data['product_id'] = $variation_id > 0 ? $variation_id : $item->get_product_id();
-				$section_key             = 'products';
-			} elseif ( $item instanceof WC_Order_Item_Shipping ) {
-				$section_key = 'shipping';
-			} else {
-				$section_key = 'fees';
-			}
-
-			$sections[ $section_key ]['items'][]   = $item_data;
-			$sections[ $section_key ]['subtotal'] += $subtotal;
-			$sections[ $section_key ]['tax']      += $tax;
-			$sections[ $section_key ]['total']    += $refund_total_with_tax;
-		}
-
-		$format_section = function ( array $section ) use ( $price_decimals ): array {
-			return array(
-				'items'    => $section['items'],
-				'subtotal' => wc_format_decimal( $section['subtotal'], $price_decimals ),
-				'tax'      => wc_format_decimal( $section['tax'], $price_decimals ),
-				'total'    => wc_format_decimal( $section['total'], $price_decimals ),
-			);
-		};
-
-		$grand_subtotal = $sections['products']['subtotal'] + $sections['shipping']['subtotal'] + $sections['fees']['subtotal'];
-		$grand_tax      = $sections['products']['tax'] + $sections['shipping']['tax'] + $sections['fees']['tax'];
-		$grand_total    = $sections['products']['total'] + $sections['shipping']['total'] + $sections['fees']['total'];
-
-		return array(
-			'breakdown'      => array(
-				'products' => $format_section( $sections['products'] ),
-				'shipping' => $format_section( $sections['shipping'] ),
-				'fees'     => $format_section( $sections['fees'] ),
-			),
-			'subtotal'       => wc_format_decimal( $grand_subtotal, $price_decimals ),
-			'tax'            => wc_format_decimal( $grand_tax, $price_decimals ),
-			'total'          => wc_format_decimal( $grand_total, $price_decimals ),
-			'max_refundable' => wc_format_decimal( $order->get_remaining_refund_amount(), $price_decimals ),
-		);
-	}
-
-	/**
-	 * Validate line items for a preview request.
-	 *
-	 * @param array    $line_items The line items to validate.
-	 * @param WC_Order $order      The order object.
-	 * @return true|WP_Error True on success, WP_Error on failure.
-	 *
-	 * @since 10.9.0
-	 */
-	public function validate_preview_line_items( array $line_items, WC_Order $order ) {
-		if ( empty( $line_items ) ) {
-			return new WP_Error(
-				'missing_line_items',
-				__( 'At least one line item is required.', 'woocommerce' ),
-				array( 'status' => WP_Http::BAD_REQUEST )
-			);
-		}
-
-		if ( ! in_array( $order->get_status(), self::REFUNDABLE_STATUSES, true ) ) {
-			return new WP_Error(
-				'order_not_refundable',
-				__( 'This order cannot be refunded.', 'woocommerce' ),
-				array( 'status' => WP_Http::UNPROCESSABLE_ENTITY )
-			);
-		}
-
-		if ( (float) $order->get_remaining_refund_amount() <= 0 ) {
-			return new WP_Error(
-				'order_not_refundable',
-				__( 'This order has already been fully refunded.', 'woocommerce' ),
-				array( 'status' => WP_Http::UNPROCESSABLE_ENTITY )
-			);
-		}
-
-		$refund_data = $this->compute_refunded_quantities_and_totals( $order );
-
-		$seen_ids = array();
-		foreach ( $line_items as $line_item ) {
-			$line_item_id = $line_item['line_item_id'] ?? null;
-			if ( ! $line_item_id ) {
-				return new WP_Error(
-					'missing_line_item_id',
-					__( 'Line item ID is required.', 'woocommerce' ),
-					array( 'status' => WP_Http::BAD_REQUEST )
-				);
-			}
-
-			// Reject duplicate line items: each is validated against the same remaining
-			// snapshot, so repeating an ID would let the per-line cap pass twice for the
-			// same line and double-count it in the preview breakdown.
-			if ( isset( $seen_ids[ $line_item_id ] ) ) {
-				return new WP_Error(
-					'duplicate_line_item',
-					__( 'Each line item may appear only once per request.', 'woocommerce' ),
-					array( 'status' => WP_Http::BAD_REQUEST )
-				);
-			}
-			$seen_ids[ $line_item_id ] = true;
-
-			// A bad line_item_id reference (not on the order, or an unsupported type) is a
-			// malformed request, returned as 400 to match the create endpoint's handling
-			// of the same conditions.
-			$item = $order->get_item( $line_item_id );
-			if ( ! $item || $item->get_order_id() !== $order->get_id() ) {
-				return new WP_Error(
-					'line_item_not_found',
-					__( 'Line item not found.', 'woocommerce' ),
-					array( 'status' => WP_Http::BAD_REQUEST )
-				);
-			}
-
-			if ( ! $item instanceof WC_Order_Item_Product && ! $item instanceof WC_Order_Item_Fee && ! $item instanceof WC_Order_Item_Shipping ) {
-				return new WP_Error(
-					'unsupported_item_type',
-					__( 'Line item is not a product, fee, or shipping line.', 'woocommerce' ),
-					array( 'status' => WP_Http::BAD_REQUEST )
-				);
-			}
-
-			// A present refund_total may be negative (a discount/credit line) but must be a
-			// non-zero number with the same sign as the line — validated below, mirroring
-			// validate_line_items() so create and preview accept and reject the same input.
-			// A null refund_total means "use the quantity form" (isset() is false for null).
-			$has_refund_total = isset( $line_item['refund_total'] );
-			if ( $has_refund_total && ! is_numeric( $line_item['refund_total'] ) ) {
-				return new WP_Error(
-					'invalid_refund_total',
-					__( 'refund_total must be a number greater than zero.', 'woocommerce' ),
-					array( 'status' => WP_Http::BAD_REQUEST )
-				);
-			}
-
-			$has_quantity = isset( $line_item['quantity'] ) && is_int( $line_item['quantity'] ) && $line_item['quantity'] >= 1;
-
-			if ( ! $has_quantity && ! $has_refund_total ) {
-				return new WP_Error(
-					'missing_quantity_or_refund_total',
-					__( 'Either a positive integer quantity or a numeric refund_total is required.', 'woocommerce' ),
-					array( 'status' => WP_Http::BAD_REQUEST )
-				);
-			}
-
-			$price_decimals    = wc_get_price_decimals();
-			$signed_line_total = (float) $item->get_total() + (float) $item->get_total_tax();
-
-			// Validate an explicit refund_total. Mirrors validate_line_items() exactly (sign,
-			// zero, and the three over-refund caps) so a previewed amount that is accepted or
-			// rejected here behaves identically at create.
-			if ( $has_refund_total ) {
-				$refund_total = (float) $line_item['refund_total'];
-
-				// Reject a refund_total whose sign is opposite the line: you cannot refund a
-				// positive amount from a discount line, or a negative amount from a normal line.
-				if ( $refund_total * $signed_line_total < 0 ) {
-					return new WP_Error(
-						'invalid_refund_total',
-						__( 'Refund total has the wrong sign for this line item.', 'woocommerce' ),
-						array( 'status' => WP_Http::BAD_REQUEST )
-					);
-				}
-
-				// Reject a refund_total that rounds to zero — a no-op the create path also rejects.
-				if ( 0.0 === (float) NumberUtil::round( $refund_total, $price_decimals ) ) {
-					return new WP_Error(
-						'invalid_refund_total',
-						__( 'refund_total must be a number greater than zero.', 'woocommerce' ),
-						array( 'status' => WP_Http::BAD_REQUEST )
-					);
-				}
-
-				$item_total_with_tax = abs( $signed_line_total );
-				$abs_refund_total    = NumberUtil::round( abs( $refund_total ), $price_decimals );
-				if ( $abs_refund_total > NumberUtil::round( $item_total_with_tax, $price_decimals ) ) {
-					return new WP_Error(
-						'refund_total_exceeds_line',
-						sprintf(
-							/* translators: %s: line item total including tax */
-							__( 'refund_total cannot exceed the line item total including tax (%s).', 'woocommerce' ),
-							wc_format_decimal( $item_total_with_tax, $price_decimals )
-						),
-						array( 'status' => WP_Http::UNPROCESSABLE_ENTITY )
-					);
-				}
-
-				// Cap against the remaining refundable amount for this line.
-				// compute_refunded_quantities_and_totals() tracks tax-inclusive totals
-				// for all item types so the comparison is consistent. Remaining is
-				// rounded to currency precision before both checks, matching
-				// validate_line_items(), so a sub-cent residue counts as fully refunded.
-				$refunded_total  = abs( (float) ( $refund_data['totals'][ $line_item_id ] ?? 0.0 ) );
-				$remaining_total = NumberUtil::round( $item_total_with_tax - $refunded_total, $price_decimals );
-				if ( $remaining_total <= 0 ) {
-					return new WP_Error(
-						'line_item_already_refunded',
-						__( 'This line item has already been fully refunded.', 'woocommerce' ),
-						array( 'status' => WP_Http::UNPROCESSABLE_ENTITY )
-					);
-				}
-				if ( $abs_refund_total > $remaining_total ) {
-					return new WP_Error(
-						'refund_total_exceeds_remaining',
-						sprintf(
-							/* translators: %s: remaining refundable amount */
-							__( 'refund_total cannot exceed the remaining refundable amount for this line item (%s).', 'woocommerce' ),
-							wc_format_decimal( $remaining_total, $price_decimals )
-						),
-						array( 'status' => WP_Http::UNPROCESSABLE_ENTITY )
-					);
-				}
-			}
-
-			// Validate a supplied quantity whenever present — even alongside refund_total —
-			// matching validate_line_items(). Skipping this when refund_total was given let a
-			// preview accept a quantity the create path then rejects.
-			if ( $has_quantity ) {
-				$quantity = $line_item['quantity'];
-
-				if ( $item instanceof WC_Order_Item_Product ) {
-					$remaining_qty = $item->get_quantity() + ( $refund_data['qtys'][ $line_item_id ] ?? 0 );
-					if ( $quantity > $remaining_qty ) {
-						return new WP_Error(
-							'quantity_exceeds_refundable',
-							sprintf(
-								/* translators: %d: remaining refundable quantity */
-								__( 'Requested quantity exceeds remaining refundable quantity (%d).', 'woocommerce' ),
-								$remaining_qty
-							),
-							array( 'status' => WP_Http::UNPROCESSABLE_ENTITY )
-						);
-					}
-				} elseif ( 1 !== $quantity ) {
-					// Shipping and fee lines carry a single refundable unit.
-					return new WP_Error(
-						'invalid_quantity',
-						__( 'Shipping and fee line items must be refunded with quantity of 1.', 'woocommerce' ),
-						array( 'status' => WP_Http::BAD_REQUEST )
-					);
-				}
-			}
-
-			// Amount-from-quantity: the server derives the amount itself via
-			// compute_quantity_refund_total(), which caps it to the remaining line amount,
-			// so the only invalid state left to reject is a line with nothing refundable
-			// remaining. Rounded to currency precision so a sub-cent residue left by
-			// rounding drift counts as fully refunded. Create fills refund_total through
-			// the same capped computation, so preview and create accept identical input.
-			if ( $has_quantity && ! $has_refund_total ) {
-				$refunded_total  = abs( (float) ( $refund_data['totals'][ $line_item_id ] ?? 0.0 ) );
-				$remaining_total = NumberUtil::round( abs( $signed_line_total ) - $refunded_total, $price_decimals );
-				if ( $remaining_total <= 0 ) {
-					return new WP_Error(
-						'line_item_already_refunded',
-						__( 'This line item has already been fully refunded.', 'woocommerce' ),
-						array( 'status' => WP_Http::UNPROCESSABLE_ENTITY )
-					);
-				}
-			}
-		}
-
-		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.
-	 *
-	 * Loads refunds once and builds lookup maps for refunded quantities and totals per item ID,
-	 * avoiding repeated get_refunds() calls during serialization. Fee and shipping totals are
-	 * tax-inclusive so they can be compared directly against {@see compute_line_item_refund_total()}.
-	 *
-	 * line_refunds records each product refund line individually — quantity and tax-inclusive
-	 * gross, both as positive magnitudes — so the quantity-form top-up can verify that prior
-	 * refunds match their quantity-derived amounts before reconciling rounding drift.
-	 *
-	 * @param WC_Order $order Order instance.
-	 * @return array{qtys: array<int, int>, totals: array<int, float>, tax_totals: array<int, array<int, float>>, line_refunds: array<int, list<array{qty: int, gross: float}>>}
-	 */
-	public function compute_refunded_quantities_and_totals( WC_Order $order ): array {
-		$qtys         = array();
-		$totals       = array();
-		$tax_totals   = array();
-		$line_refunds = array();
-
-		// Accumulate the already-refunded tax per original item, keyed by tax rate
-		// id, as a positive amount. Refund line items store taxes as negatives, so
-		// flip the sign. Lets the per-tax-id cap subtract prior refunds.
-		$add_refunded_taxes = function ( $refunded_item, int $original_id ) use ( &$tax_totals ) {
-			$taxes = $refunded_item->get_taxes();
-			foreach ( (array) ( $taxes['total'] ?? array() ) as $tax_id => $amount ) {
-				$tax_totals[ $original_id ][ $tax_id ] = ( $tax_totals[ $original_id ][ $tax_id ] ?? 0.0 ) + abs( (float) $amount );
-			}
-		};
+declare(strict_types=1);

-		foreach ( $order->get_refunds() as $refund ) {
-			/**
-			 * Refunded product line items.
-			 *
-			 * @var \WC_Order_Item_Product[] $refunded_line_items
-			 */
-			$refunded_line_items = $refund->get_items( 'line_item' );
-			foreach ( $refunded_line_items as $refunded_item ) {
-				$original_id                    = absint( $refunded_item->get_meta( '_refunded_item_id' ) );
-				$qtys[ $original_id ]           = ( $qtys[ $original_id ] ?? 0 ) + $refunded_item->get_quantity();
-				$totals[ $original_id ]         = ( $totals[ $original_id ] ?? 0.0 ) + ( (float) $refunded_item->get_total() + (float) $refunded_item->get_total_tax() ) * -1;
-				$line_refunds[ $original_id ][] = array(
-					'qty'   => absint( $refunded_item->get_quantity() ),
-					'gross' => abs( (float) $refunded_item->get_total() + (float) $refunded_item->get_total_tax() ),
-				);
-				$add_refunded_taxes( $refunded_item, $original_id );
-			}
-			/**
-			 * Refunded fee items.
-			 *
-			 * @var \WC_Order_Item_Fee[] $refunded_fees
-			 */
-			$refunded_fees = $refund->get_items( 'fee' );
-			foreach ( $refunded_fees as $refunded_item ) {
-				$original_id            = absint( $refunded_item->get_meta( '_refunded_item_id' ) );
-				$totals[ $original_id ] = ( $totals[ $original_id ] ?? 0.0 ) + ( (float) $refunded_item->get_total() + (float) $refunded_item->get_total_tax() ) * -1;
-				$add_refunded_taxes( $refunded_item, $original_id );
-			}
-			/**
-			 * Refunded shipping items.
-			 *
-			 * @var \WC_Order_Item_Shipping[] $refunded_shipping
-			 */
-			$refunded_shipping = $refund->get_items( 'shipping' );
-			foreach ( $refunded_shipping as $refunded_item ) {
-				$original_id            = absint( $refunded_item->get_meta( '_refunded_item_id' ) );
-				$totals[ $original_id ] = ( $totals[ $original_id ] ?? 0.0 ) + ( (float) $refunded_item->get_total() + (float) $refunded_item->get_total_tax() ) * -1;
-				$add_refunded_taxes( $refunded_item, $original_id );
-			}
-		}
+defined( 'ABSPATH' ) || exit;

-		return array(
-			'qtys'         => $qtys,
-			'totals'       => $totals,
-			'tax_totals'   => $tax_totals,
-			'line_refunds' => $line_refunds,
-		);
-	}
-}
+class_alias(
+	\Automattic\WooCommerce\Internal\RestApi\Refunds\DataUtils::class,
+	'Automattic\WooCommerce\Internal\RestApi\Routes\V4\Refunds\DataUtils'
+);
diff --git a/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Refunds/Schema/RefundPreviewSchema.php b/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Refunds/Schema/RefundPreviewSchema.php
index 6e4188f7e7d..6a575965f64 100644
--- a/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Refunds/Schema/RefundPreviewSchema.php
+++ b/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Refunds/Schema/RefundPreviewSchema.php
@@ -1,23 +1,26 @@
 <?php
 /**
- * RefundPreviewSchema class.
+ * Backward compatibility shim for the relocated RefundPreviewSchema class.
  *
- * @package WooCommerce\RestApi
+ * @package Automattic\WooCommerce\Internal\RestApi\Routes\V4\Refunds
  */

-declare( strict_types=1 );
+declare(strict_types=1);

 namespace Automattic\WooCommerce\Internal\RestApi\Routes\V4\Refunds\Schema;

 defined( 'ABSPATH' ) || exit;

+use Automattic\WooCommerce\Internal\RestApi\Refunds\Schema\RefundPreviewSchema as RelocatedRefundPreviewSchema;
 use Automattic\WooCommerce\Internal\RestApi\Routes\V4\AbstractSchema;
 use WP_REST_Request;

 /**
- * Schema for the refund preview response.
+ * Keeps the old RefundPreviewSchema FQCN working after the class moved to the
+ * version-neutral Internal\RestApi\Refunds namespace. It still extends the V4
+ * AbstractSchema, like the old class did, and reads its properties from the relocated class.
  *
- * @since 10.9.0
+ * @deprecated 11.3.0 Use Automattic\WooCommerce\Internal\RestApi\Refunds\Schema\RefundPreviewSchema instead.
  */
 class RefundPreviewSchema extends AbstractSchema {

@@ -26,15 +29,23 @@ class RefundPreviewSchema extends AbstractSchema {
 	 *
 	 * @var string
 	 */
-	const IDENTIFIER = 'refund-preview';
+	const IDENTIFIER = RelocatedRefundPreviewSchema::IDENTIFIER;
+
+	/**
+	 * Return all properties for the item schema.
+	 *
+	 * @return array
+	 *
+	 * @since 10.9.0
+	 */
+	public function get_item_schema_properties(): array {
+		return wc_get_container()->get( RelocatedRefundPreviewSchema::class )->get_item_schema_properties();
+	}

 	// The next method always throws so its return type can never be reached.
 	// phpcs:disable Squiz.Commenting.FunctionComment.InvalidNoReturn
 	/**
-	 * Not used. The refund preview controller bypasses prepare_item_for_response
-	 * and returns the raw data array directly via rest_ensure_response, so this
-	 * method must never be invoked. The `: array` return type is required to
-	 * satisfy AbstractSchema::get_item_response, but the body always throws.
+	 * AbstractSchema requires this method. It always throws, as the old class did.
 	 *
 	 * @param mixed           $item           Item data.
 	 * @param WP_REST_Request $request        Request object.
@@ -53,155 +64,4 @@ class RefundPreviewSchema extends AbstractSchema {
 		);
 	}
 	// phpcs:enable Squiz.Commenting.FunctionComment.InvalidNoReturn
-
-	/**
-	 * Return all properties for the item schema.
-	 *
-	 * @return array
-	 *
-	 * @since 10.9.0
-	 */
-	public function get_item_schema_properties(): array {
-		return array(
-			'breakdown'      => array(
-				'description' => __( 'Refund breakdown by item type.', 'woocommerce' ),
-				'type'        => 'object',
-				'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
-				'readonly'    => true,
-				'properties'  => array(
-					'products' => $this->get_section_schema( 'products' ),
-					'shipping' => $this->get_section_schema( 'shipping' ),
-					'fees'     => $this->get_section_schema( 'fees' ),
-				),
-			),
-			'subtotal'       => array(
-				'description' => __( 'Grand subtotal of the refund preview (excluding tax).', 'woocommerce' ),
-				'type'        => 'string',
-				'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
-				'readonly'    => true,
-			),
-			'tax'            => array(
-				'description' => __( 'Grand tax total of the refund preview.', 'woocommerce' ),
-				'type'        => 'string',
-				'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
-				'readonly'    => true,
-			),
-			'total'          => array(
-				'description' => __( 'Grand total of the refund preview (tax-inclusive).', 'woocommerce' ),
-				'type'        => 'string',
-				'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
-				'readonly'    => true,
-			),
-			'max_refundable' => array(
-				'description' => __( 'Maximum refundable amount remaining on the order.', 'woocommerce' ),
-				'type'        => 'string',
-				'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
-				'readonly'    => true,
-			),
-		);
-	}
-
-	/**
-	 * Schema for one section of the breakdown (products, shipping, or fees).
-	 *
-	 * @param string $section_key One of 'products', 'shipping', 'fees'. Determines which item schema variant is used.
-	 * @return array
-	 */
-	private function get_section_schema( string $section_key ): array {
-		return array(
-			'type'       => 'object',
-			'properties' => array(
-				'items'    => array(
-					'description' => __( 'Line items in this section.', 'woocommerce' ),
-					'type'        => 'array',
-					'items'       => 'products' === $section_key ? $this->get_product_item_schema() : $this->get_base_item_schema(),
-					'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
-					'readonly'    => true,
-				),
-				'subtotal' => array(
-					'description' => __( 'Section subtotal (excluding tax).', 'woocommerce' ),
-					'type'        => 'string',
-					'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
-					'readonly'    => true,
-				),
-				'tax'      => array(
-					'description' => __( 'Section tax total.', 'woocommerce' ),
-					'type'        => 'string',
-					'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
-					'readonly'    => true,
-				),
-				'total'    => array(
-					'description' => __( 'Section total (tax-inclusive).', 'woocommerce' ),
-					'type'        => 'string',
-					'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
-					'readonly'    => true,
-				),
-			),
-		);
-	}
-
-	/**
-	 * Schema for an item entry in the shipping or fees sections.
-	 *
-	 * @return array
-	 */
-	private function get_base_item_schema(): array {
-		return array(
-			'type'       => 'object',
-			'properties' => array(
-				'id'       => array(
-					'description' => __( 'The original order line item ID.', 'woocommerce' ),
-					'type'        => 'integer',
-					'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
-					'readonly'    => true,
-				),
-				'name'     => array(
-					'description' => __( 'The line item name.', 'woocommerce' ),
-					'type'        => 'string',
-					'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
-					'readonly'    => true,
-				),
-				'quantity' => array(
-					'description' => __( 'The quantity being refunded. Null when the refund was specified by amount only.', 'woocommerce' ),
-					'type'        => array( 'integer', 'null' ),
-					'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
-					'readonly'    => true,
-				),
-				'subtotal' => array(
-					'description' => __( 'The refund subtotal for this item (excluding tax).', 'woocommerce' ),
-					'type'        => 'string',
-					'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
-					'readonly'    => true,
-				),
-				'tax'      => array(
-					'description' => __( 'The tax amount for this item.', 'woocommerce' ),
-					'type'        => 'string',
-					'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
-					'readonly'    => true,
-				),
-				'total'    => array(
-					'description' => __( 'The refund total for this item (tax-inclusive).', 'woocommerce' ),
-					'type'        => 'string',
-					'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
-					'readonly'    => true,
-				),
-			),
-		);
-	}
-
-	/**
-	 * Schema for an item entry in the products section (extends the base with product_id).
-	 *
-	 * @return array
-	 */
-	private function get_product_item_schema(): array {
-		$schema                             = $this->get_base_item_schema();
-		$schema['properties']['product_id'] = array(
-			'description' => __( 'Product or variation ID.', 'woocommerce' ),
-			'type'        => 'integer',
-			'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
-			'readonly'    => true,
-		);
-		return $schema;
-	}
 }
diff --git a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version4/Orders/class-wc-rest-orders-v4-controller-tests.php b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version4/Orders/class-wc-rest-orders-v4-controller-tests.php
index 7a7494031a1..b90ae8ee5a6 100644
--- a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version4/Orders/class-wc-rest-orders-v4-controller-tests.php
+++ b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version4/Orders/class-wc-rest-orders-v4-controller-tests.php
@@ -11,7 +11,7 @@ use Automattic\WooCommerce\Internal\RestApi\Routes\V4\Orders\Schema\OrderCouponS
 use Automattic\WooCommerce\Internal\RestApi\Routes\V4\Orders\Schema\OrderFeeSchema;
 use Automattic\WooCommerce\Internal\RestApi\Routes\V4\Orders\Schema\OrderTaxSchema;
 use Automattic\WooCommerce\Internal\RestApi\Routes\V4\Orders\Schema\OrderShippingSchema;
-use Automattic\WooCommerce\Internal\RestApi\Routes\V4\Refunds\DataUtils;
+use Automattic\WooCommerce\Internal\RestApi\Refunds\DataUtils;

 /**
  * Orders Controller tests for V4 REST API.
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 ae6c5ddbabd..cadad960a6e 100644
--- a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version4/Refunds/class-wc-rest-refunds-v4-controller-tests.php
+++ b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version4/Refunds/class-wc-rest-refunds-v4-controller-tests.php
@@ -5,10 +5,10 @@ use Automattic\WooCommerce\Enums\OrderStatus;
 use Automattic\WooCommerce\RestApi\UnitTests\HPOSToggleTrait;
 use Automattic\WooCommerce\Tests\Helpers\MetaDataAssertionTrait;
 use Automattic\WooCommerce\Internal\RestApi\Routes\V4\Refunds\Controller as RefundsController;
-use Automattic\WooCommerce\Internal\RestApi\Routes\V4\Refunds\Schema\RefundPreviewSchema;
+use Automattic\WooCommerce\Internal\RestApi\Refunds\Schema\RefundPreviewSchema;
 use Automattic\WooCommerce\Internal\RestApi\Routes\V4\Refunds\Schema\RefundSchema;
 use Automattic\WooCommerce\Internal\RestApi\Routes\V4\Refunds\CollectionQuery;
-use Automattic\WooCommerce\Internal\RestApi\Routes\V4\Refunds\DataUtils;
+use Automattic\WooCommerce\Internal\RestApi\Refunds\DataUtils;

 /**
  * Refunds Controller tests for V4 REST API.
diff --git a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version4/Refunds/class-wc-rest-refunds-v4-preview-tests.php b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version4/Refunds/class-wc-rest-refunds-v4-preview-tests.php
index 64c20e27d35..0149885004e 100644
--- a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version4/Refunds/class-wc-rest-refunds-v4-preview-tests.php
+++ b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version4/Refunds/class-wc-rest-refunds-v4-preview-tests.php
@@ -573,7 +573,7 @@ class WC_REST_Refunds_V4_Preview_Tests extends WC_REST_Unit_Test_Case {
 		$data = $response->get_data();

 		$schema_properties = wc_get_container()
-			->get( \Automattic\WooCommerce\Internal\RestApi\Routes\V4\Refunds\Schema\RefundPreviewSchema::class )
+			->get( \Automattic\WooCommerce\Internal\RestApi\Refunds\Schema\RefundPreviewSchema::class )
 			->get_item_schema_properties();

 		$this->assertSchemaKeysMatchData( $schema_properties, $data, 'root' );
@@ -638,7 +638,7 @@ class WC_REST_Refunds_V4_Preview_Tests extends WC_REST_Unit_Test_Case {
 		$item_id = $this->get_first_line_item_id( $order );

 		// Stub DataUtils so validate_preview_line_items passes but build_refund_preview throws.
-		$stub = new class() extends \Automattic\WooCommerce\Internal\RestApi\Routes\V4\Refunds\DataUtils {
+		$stub = new class() extends \Automattic\WooCommerce\Internal\RestApi\Refunds\DataUtils {
 			/**
 			 * Validation is forced to pass so the controller reaches the build step.
 			 *
@@ -667,7 +667,7 @@ class WC_REST_Refunds_V4_Preview_Tests extends WC_REST_Unit_Test_Case {
 		wc_get_container()->get( \Automattic\WooCommerce\Internal\RestApi\Routes\V4\Refunds\Controller::class )
 			->init(
 				wc_get_container()->get( \Automattic\WooCommerce\Internal\RestApi\Routes\V4\Refunds\Schema\RefundSchema::class ),
-				wc_get_container()->get( \Automattic\WooCommerce\Internal\RestApi\Routes\V4\Refunds\Schema\RefundPreviewSchema::class ),
+				wc_get_container()->get( \Automattic\WooCommerce\Internal\RestApi\Refunds\Schema\RefundPreviewSchema::class ),
 				wc_get_container()->get( \Automattic\WooCommerce\Internal\RestApi\Routes\V4\Refunds\CollectionQuery::class ),
 				$stub
 			);
@@ -691,9 +691,9 @@ class WC_REST_Refunds_V4_Preview_Tests extends WC_REST_Unit_Test_Case {
 			wc_get_container()->get( \Automattic\WooCommerce\Internal\RestApi\Routes\V4\Refunds\Controller::class )
 				->init(
 					wc_get_container()->get( \Automattic\WooCommerce\Internal\RestApi\Routes\V4\Refunds\Schema\RefundSchema::class ),
-					wc_get_container()->get( \Automattic\WooCommerce\Internal\RestApi\Routes\V4\Refunds\Schema\RefundPreviewSchema::class ),
+					wc_get_container()->get( \Automattic\WooCommerce\Internal\RestApi\Refunds\Schema\RefundPreviewSchema::class ),
 					wc_get_container()->get( \Automattic\WooCommerce\Internal\RestApi\Routes\V4\Refunds\CollectionQuery::class ),
-					wc_get_container()->get( \Automattic\WooCommerce\Internal\RestApi\Routes\V4\Refunds\DataUtils::class )
+					wc_get_container()->get( \Automattic\WooCommerce\Internal\RestApi\Refunds\DataUtils::class )
 				);
 		}
 	}
diff --git a/plugins/woocommerce/tests/php/src/Internal/RestApi/Routes/V4/Refunds/DataUtilsTest.php b/plugins/woocommerce/tests/php/src/Internal/RestApi/Refunds/DataUtilsTest.php
similarity index 96%
rename from plugins/woocommerce/tests/php/src/Internal/RestApi/Routes/V4/Refunds/DataUtilsTest.php
rename to plugins/woocommerce/tests/php/src/Internal/RestApi/Refunds/DataUtilsTest.php
index d69b9b893d0..a9ab861869f 100644
--- a/plugins/woocommerce/tests/php/src/Internal/RestApi/Routes/V4/Refunds/DataUtilsTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/RestApi/Refunds/DataUtilsTest.php
@@ -1,10 +1,11 @@
 <?php
 declare(strict_types=1);

-namespace Automattic\WooCommerce\Tests\Internal\RestApi\Routes\V4\Refunds;
+namespace Automattic\WooCommerce\Tests\Internal\RestApi\Refunds;

 use Automattic\WooCommerce\Enums\OrderStatus;
-use Automattic\WooCommerce\Internal\RestApi\Routes\V4\Refunds\DataUtils;
+use Automattic\WooCommerce\Internal\RestApi\Refunds\DataUtils;
+use Automattic\WooCommerce\RestApi\UnitTests\LoggerSpyTrait;
 use WC_Cache_Helper;
 use WC_Helper_Product;
 use WC_Order;
@@ -19,6 +20,8 @@ use WC_Unit_Test_Case;
  */
 class DataUtilsTest extends WC_Unit_Test_Case {

+	use LoggerSpyTrait;
+
 	/**
 	 * DataUtils instance.
 	 *
@@ -3379,6 +3382,124 @@ class DataUtilsTest extends WC_Unit_Test_Case {
 		$this->assertEqualsWithDelta( 25.00, $result['subtotal'], 0.0001 );
 	}

+	/**
+	 * Helper: create an order holding one product line item with zero original quantity.
+	 *
+	 * @return array{0: WC_Order, 1: WC_Order_Item_Product} The order and the item.
+	 */
+	private function create_order_with_zero_quantity_item(): array {
+		$order = wc_create_order();
+		$item  = new WC_Order_Item_Product();
+		$item->set_props(
+			array(
+				'quantity' => 0,
+				'subtotal' => 0,
+				'total'    => 0,
+			)
+		);
+		$item->save();
+		$order->add_item( $item );
+		$order->save();
+		return array( $order, $item );
+	}
+
+	/**
+	 * @testdox Warnings from direct helper calls are logged under the default wc-v4-refunds source.
+	 */
+	public function test_direct_helper_warnings_use_default_log_source(): void {
+		list( $order, $item ) = $this->create_order_with_zero_quantity_item();
+
+		$this->data_utils->compute_line_item_refund_total( $item, 1 );
+
+		$this->assertLogged( 'warning', 'zero original quantity', array( 'source' => 'wc-v4-refunds' ) );
+
+		$order->delete( true );
+	}
+
+	/**
+	 * Helper: create a processing order holding one fee whose stored total and tax
+	 * nearly cancel (gross 0.01), so any split of it hits the unsplittable fallback.
+	 *
+	 * @return array{0: WC_Order, 1: WC_Order_Item_Fee} The order and the fee item.
+	 */
+	private function create_order_with_degenerate_fee(): array {
+		$order = wc_create_order();
+		$fee   = new WC_Order_Item_Fee();
+		$fee->set_total( 100.00 );
+		$fee->set_taxes(
+			array(
+				'total' => array( 1 => -99.99 ),
+			)
+		);
+		$fee->save();
+		$order->add_item( $fee );
+		$order->set_status( OrderStatus::PROCESSING );
+		$order->set_total( 0.01 );
+		$order->save();
+		return array( $order, $fee );
+	}
+
+	/**
+	 * @testdox The preview pipeline logs warnings under the caller's log source.
+	 */
+	public function test_preview_pipeline_threads_log_source(): void {
+		list( $order, $fee ) = $this->create_order_with_degenerate_fee();
+
+		$line_items = array(
+			array(
+				'line_item_id' => $fee->get_id(),
+				'refund_total' => 0.01,
+			),
+		);
+
+		$this->data_utils->compute_refund_preview_or_error( $order, $line_items, 'wc-rest-refunds' );
+
+		$this->assertLogged( 'warning', 'cannot split tax', array( 'source' => 'wc-rest-refunds' ) );
+
+		$order->delete( true );
+	}
+
+	/**
+	 * @testdox The creation pipeline logs warnings under the caller's log source.
+	 */
+	public function test_creation_pipeline_threads_log_source(): void {
+		list( $order, $fee ) = $this->create_order_with_degenerate_fee();
+
+		$line_items = array(
+			array(
+				'line_item_id' => $fee->get_id(),
+				'refund_total' => 0.01,
+			),
+		);
+
+		$this->data_utils->prepare_refund_creation_or_error( $order, $line_items, false, null, 'wc-rest-refunds' );
+
+		$this->assertLogged( 'warning', 'cannot split tax', array( 'source' => 'wc-rest-refunds' ) );
+
+		$order->delete( true );
+	}
+
+	/**
+	 * @testdox The log source is restored to the default after an entry-point call.
+	 */
+	public function test_log_source_restored_after_entry_point(): void {
+		list( $order, $fee ) = $this->create_order_with_degenerate_fee();
+
+		$line_items = array(
+			array(
+				'line_item_id' => $fee->get_id(),
+				'refund_total' => 0.01,
+			),
+		);
+
+		$this->data_utils->compute_refund_preview_or_error( $order, $line_items, 'wc-rest-refunds' );
+		$this->invoke_split_inclusive( 0.01, $fee );
+
+		$this->assertLogged( 'warning', 'cannot split tax', array( 'source' => 'wc-v4-refunds' ) );
+
+		$order->delete( true );
+	}
+
 	/**
 	 * @testdox split_inclusive_by_stored_ratio clamps to net-only when the stored total and tax nearly cancel.
 	 */
diff --git a/plugins/woocommerce/tests/php/src/Internal/RestApi/Routes/V4/Refunds/ClassAliasesTest.php b/plugins/woocommerce/tests/php/src/Internal/RestApi/Routes/V4/Refunds/ClassAliasesTest.php
new file mode 100644
index 00000000000..473a021deea
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/RestApi/Routes/V4/Refunds/ClassAliasesTest.php
@@ -0,0 +1,82 @@
+<?php
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Tests\Internal\RestApi\Routes\V4\Refunds;
+
+use Automattic\WooCommerce\Internal\RestApi\Refunds\DataUtils;
+use Automattic\WooCommerce\Internal\RestApi\Refunds\Schema\RefundPreviewSchema;
+use Automattic\WooCommerce\Internal\RestApi\Routes\V4\AbstractSchema;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for the backward compatibility shims that keep the old V4 refund engine
+ * FQCNs working after the classes moved to Internal\RestApi\Refunds.
+ */
+class ClassAliasesTest extends WC_Unit_Test_Case {
+
+	private const OLD_DATA_UTILS     = 'Automattic\WooCommerce\Internal\RestApi\Routes\V4\Refunds\DataUtils';
+	private const OLD_PREVIEW_SCHEMA = 'Automattic\WooCommerce\Internal\RestApi\Routes\V4\Refunds\Schema\RefundPreviewSchema';
+
+	/**
+	 * @testdox The old DataUtils FQCN autoloads and aliases the relocated class.
+	 */
+	public function test_old_data_utils_name_aliases_new_class(): void {
+		$this->assertTrue( class_exists( self::OLD_DATA_UTILS ), 'The old DataUtils FQCN should still autoload via its shim' );
+
+		$this->assertSame( DataUtils::class, ( new \ReflectionClass( self::OLD_DATA_UTILS ) )->getName(), 'The old DataUtils FQCN should be an alias of the relocated class, not a subclass' );
+	}
+
+	/**
+	 * @testdox The old RefundPreviewSchema FQCN autoloads and still extends the V4 AbstractSchema.
+	 */
+	public function test_old_preview_schema_name_extends_abstract_schema(): void {
+		$this->assertTrue( class_exists( self::OLD_PREVIEW_SCHEMA ), 'The old RefundPreviewSchema FQCN should still autoload via its shim' );
+
+		$old_name = self::OLD_PREVIEW_SCHEMA;
+		$this->assertInstanceOf( AbstractSchema::class, new $old_name(), 'An instance built from the old FQCN should still be an AbstractSchema' );
+	}
+
+	/**
+	 * @testdox The old RefundPreviewSchema FQCN keeps the members it inherited from AbstractSchema.
+	 */
+	public function test_old_preview_schema_keeps_inherited_members(): void {
+		$old_name = self::OLD_PREVIEW_SCHEMA;
+		$schema   = new $old_name();
+
+		$this->assertSame( array( 'view', 'edit' ), constant( $old_name . '::VIEW_EDIT_CONTEXT' ), 'The VIEW_EDIT_CONTEXT constant should still exist on the old FQCN' );
+		$this->assertSame( array(), $schema->get_writable_item_schema_properties(), 'All preview properties are readonly, so the writable set should be empty' );
+		$this->assertSame( $schema->get_item_schema(), ( new RefundPreviewSchema() )->get_item_schema(), 'The old FQCN should produce the same schema as the relocated class' );
+
+		$this->expectException( \LogicException::class );
+		$schema->get_item_response( array(), new \WP_REST_Request() );
+	}
+
+	/**
+	 * @testdox A subclass of the old RefundPreviewSchema can add properties to the item schema.
+	 */
+	public function test_old_preview_schema_subclass_properties_reach_item_schema(): void {
+		$sut = new class() extends \Automattic\WooCommerce\Internal\RestApi\Routes\V4\Refunds\Schema\RefundPreviewSchema {
+			/**
+			 * Adds one property to the inherited ones.
+			 *
+			 * @return array
+			 */
+			public function get_item_schema_properties(): array {
+				return parent::get_item_schema_properties() + array( 'extra' => array( 'type' => 'string' ) );
+			}
+		};
+
+		$properties = $sut->get_item_schema()['properties'];
+
+		$this->assertArrayHasKey( 'extra', $properties, 'A property added by a subclass should appear in the item schema' );
+		$this->assertArrayHasKey( 'total', $properties, 'The inherited properties should still be in the item schema' );
+	}
+
+	/**
+	 * @testdox The DI container resolves the old FQCNs.
+	 */
+	public function test_container_resolves_old_names(): void {
+		$this->assertInstanceOf( DataUtils::class, wc_get_container()->get( self::OLD_DATA_UTILS ), 'The container should resolve the old DataUtils FQCN' );
+		$this->assertInstanceOf( AbstractSchema::class, wc_get_container()->get( self::OLD_PREVIEW_SCHEMA ), 'The container should resolve the old RefundPreviewSchema FQCN' );
+	}
+}