Commit 0a46e4a6b15 for woocommerce

commit 0a46e4a6b154099565662b36ead91b205dc156ce
Author: Samuel Urbanowicz <samiuelson@gmail.com>
Date:   Wed Jun 10 18:35:02 2026 +0200

    [1/2] Add DataUtils helpers for v4 refund preview endpoint (#65334)

    * Add DataUtils helpers for refund preview endpoint

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

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

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

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

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

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

    * Tighten validate_preview_line_items input validation

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

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

    * Throw on missing item in build_refund_preview

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

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

    Addresses review issue #2.

    * Accumulate raw floats in build_refund_preview section sums

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

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

    Addresses review issue #5.

    * Log malformed tax data and zero-quantity branches

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

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

    Addresses review issues #6 and #8.

    * Add preconditions to compute_line_item_refund_total

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

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

    Addresses review issue #7.

    * Expand DataUtils unit tests, drop reflection tests

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

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

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

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

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

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

    * Apply PHPCBF auto-fixes to DataUtilsTest

    * Remove unused @var docblock to satisfy lint

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

    * Populate HTTP status data on validate_preview_line_items WP_Errors

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

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

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

    * Address review comments

    CodeRabbit + codex review findings closed:

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

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

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

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

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

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

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

    * Address review comments on validate_preview_line_items

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

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

    * Align tax filter in convert_line_items_to_internal_format with preview side

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

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

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

    ---------

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

diff --git a/plugins/woocommerce/changelog/65334-woomob-2684-refund-preview-helpers b/plugins/woocommerce/changelog/65334-woomob-2684-refund-preview-helpers
new file mode 100644
index 00000000000..a50de52f046
--- /dev/null
+++ b/plugins/woocommerce/changelog/65334-woomob-2684-refund-preview-helpers
@@ -0,0 +1,4 @@
+Significance: patch
+Type: dev
+
+Add internal helper methods on Refunds\DataUtils to support the upcoming refund preview endpoint.
\ No newline at end of file
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 280a3d93b65..fb0376f79b8 100644
--- a/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Refunds/DataUtils.php
+++ b/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Refunds/DataUtils.php
@@ -12,9 +12,13 @@ defined( 'ABSPATH' ) || exit;
 use Automattic\WooCommerce\Enums\OrderItemType;
 use Automattic\WooCommerce\Enums\OrderStatus;
 use Automattic\WooCommerce\Utilities\NumberUtil;
-use WP_Error;
 use WC_Order;
+use WC_Order_Item_Fee;
+use WC_Order_Item_Product;
+use WC_Order_Item_Shipping;
 use WC_Tax;
+use WP_Error;
+use WP_Http;

 /**
  * Helper methods for the REST API.
@@ -63,11 +67,15 @@ class DataUtils {
 				$original_item = $order->get_item( $line_item['line_item_id'] );
 				if ( $original_item ) {
 					$original_taxes = $original_item->get_taxes();
-					// Filter to only include tax IDs that have non-zero amounts.
+					// Keep any non-zero stored tax (positive or negative). Negative-tax
+					// discount fees (e.g. a -$10 fee with -$1 stored tax) must retain
+					// their tax breakdown so the create side matches the preview side
+					// in build_refund_preview() — filtering on `> 0` previously dropped
+					// them and emitted refund_total=$line_total / refund_tax=[].
 					$tax_totals = array_filter(
 						$original_taxes['total'] ?? array(),
 						function ( $amount ) {
-							return is_numeric( $amount ) && $amount > 0;
+							return is_numeric( $amount ) && 0.0 !== (float) $amount;
 						}
 					);
 					$tax_ids    = array_keys( $tax_totals );
@@ -113,12 +121,14 @@ class DataUtils {
 	}

 	/**
-	 * Convert line item taxes (schema format) to internal format. This keys arrays by tax ID and has some different naming
+	 * 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
 	 */
-	private function convert_line_item_taxes_to_internal_format( $line_item_taxes ) {
+	protected function convert_line_item_taxes_to_internal_format( $line_item_taxes ) {
 		$prepared_taxes = array();

 		foreach ( $line_item_taxes as $line_item_tax ) {
@@ -253,8 +263,10 @@ class DataUtils {
 	 *
 	 * @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
 	 */
-	private function convert_proportional_taxes_to_schema_format( array $calculated_taxes ): array {
+	protected function convert_proportional_taxes_to_schema_format( array $calculated_taxes ): array {
 		$result = array();
 		foreach ( $calculated_taxes as $tax_id => $amount ) {
 			$result[] = array(
@@ -271,8 +283,10 @@ class DataUtils {
 	 * @param WC_Order $order The order.
 	 * @param array    $tax_ids Array of tax rate IDs that apply to an item.
 	 * @return array Tax rates array formatted for WC_Tax::calc_*_tax() methods.
+	 *
+	 * @since 10.9.0
 	 */
-	private function build_tax_rates_array( WC_Order $order, array $tax_ids ): array {
+	protected function build_tax_rates_array( WC_Order $order, array $tax_ids ): array {
 		$tax_rates = array();
 		$tax_items = $order->get_items( OrderItemType::TAX );

@@ -292,11 +306,328 @@ class DataUtils {
 		return $tax_rates;
 	}

+	/**
+	 * 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 );
+	}
+
+	/**
+	 * 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.
+	 *
+	 * @param WC_Order $order      The order being previewed for refund.
+	 * @param array    $line_items Array of line items with 'line_item_id' and 'quantity' keys.
+	 * @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();
+		$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
+			 */
+			$refund_total_with_tax = $this->compute_line_item_refund_total( $item, $line_item['quantity'] );
+			$subtotal              = $refund_total_with_tax;
+			$tax                   = 0.0;
+
+			$original_taxes = $item->get_taxes();
+			// Keep any non-zero stored tax (positive or negative). Negative-tax
+			// discount fees (e.g. a -$10 fee with -$1 stored tax) must retain
+			// their tax breakdown — filtering on `> 0` previously dropped them
+			// and emitted subtotal=$line_total / tax=0 instead of the correct
+			// signed split.
+			$tax_totals = array_filter(
+				$original_taxes['total'] ?? array(),
+				function ( $amount ) {
+					return is_numeric( $amount ) && 0.0 !== (float) $amount;
+				}
+			);
+
+			if ( ! empty( $original_taxes['total'] ?? array() ) && empty( $tax_totals ) ) {
+				wc_get_logger()->warning(
+					sprintf(
+						'Refund preview: tax totals filtered to empty for item %d on order %d (non-numeric or zero values).',
+						(int) $line_item['line_item_id'],
+						$order->get_id()
+					),
+					array( 'source' => 'wc-v4-refunds' )
+				);
+			}
+
+			if ( ! empty( $tax_totals ) ) {
+				$tax_rates        = $this->build_tax_rates_array( $order, array_keys( $tax_totals ) );
+				$calculated_taxes = WC_Tax::calc_inclusive_tax( $refund_total_with_tax, $tax_rates );
+				$calculated_taxes = array_map(
+					function ( $t ) use ( $price_decimals ) {
+						return NumberUtil::round( $t, $price_decimals );
+					},
+					$calculated_taxes
+				);
+				$tax              = NumberUtil::round( array_sum( $calculated_taxes ), $price_decimals );
+				$subtotal         = NumberUtil::round( $refund_total_with_tax - $tax, $price_decimals );
+			}
+
+			$item_data = array(
+				'id'       => $line_item['line_item_id'],
+				'quantity' => $line_item['quantity'],
+				'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 ) {
+				$item_data['product_id']   = $item->get_product_id();
+				$item_data['variation_id'] = $item->get_variation_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 );
+
+		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 )
+				);
+			}
+
+			$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::NOT_FOUND )
+				);
+			}
+
+			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::UNPROCESSABLE_ENTITY )
+				);
+			}
+
+			if ( ! isset( $line_item['quantity'] ) || ! is_int( $line_item['quantity'] ) || $line_item['quantity'] < 1 ) {
+				return new WP_Error(
+					'invalid_quantity',
+					__( 'Quantity must be a positive integer.', 'woocommerce' ),
+					array( 'status' => WP_Http::BAD_REQUEST )
+				);
+			}
+			$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 )
+					);
+				}
+			}
+
+			if ( $item instanceof WC_Order_Item_Shipping || $item instanceof WC_Order_Item_Fee ) {
+				if ( 1 !== $quantity ) {
+					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 )
+					);
+				}
+
+				// Compare on a tax-inclusive basis: compute_line_item_refund_total() (and
+				// therefore $requested_total below) already includes tax, and
+				// compute_refunded_quantities_and_totals() also returns tax-inclusive
+				// fee/shipping totals.
+				$refunded_total  = abs( (float) ( $refund_data['totals'][ $line_item_id ] ?? 0.0 ) );
+				$remaining_total = abs( (float) $item->get_total() + (float) $item->get_total_tax() ) - $refunded_total;
+				if ( $remaining_total <= 0 ) {
+					return new WP_Error(
+						'quantity_exceeds_refundable',
+						__( 'This line item has already been fully refunded.', 'woocommerce' ),
+						array( 'status' => WP_Http::UNPROCESSABLE_ENTITY )
+					);
+				}
+
+				// Cap against the line's remaining refundable amount. The preview
+				// shape only takes quantity, so a fee/shipping line that's been
+				// partially refunded cannot be previewed again at the full original
+				// total — the request would over-refund and the eventual create
+				// call would fail. Reject up-front with a clear error.
+				$requested_total = abs( $this->compute_line_item_refund_total( $item, $quantity ) );
+				if ( $requested_total > NumberUtil::round( $remaining_total, wc_get_price_decimals() ) ) {
+					return new WP_Error(
+						'quantity_exceeds_refundable',
+						sprintf(
+							/* translators: %s: remaining refundable amount */
+							__( 'Requested refund exceeds the remaining refundable amount for this line item (%s).', 'woocommerce' ),
+							wc_format_decimal( $remaining_total, wc_get_price_decimals() )
+						),
+						array( 'status' => WP_Http::UNPROCESSABLE_ENTITY )
+					);
+				}
+			}
+		}
+
+		return true;
+	}
+
 	/**
 	 * 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.
+	 * 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()}.
 	 *
 	 * @param WC_Order $order Order instance.
 	 * @return array{qtys: array<int, int>, totals: array<int, float>}
@@ -324,7 +655,7 @@ class DataUtils {
 			$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() * -1;
+				$totals[ $original_id ] = ( $totals[ $original_id ] ?? 0.0 ) + ( (float) $refunded_item->get_total() + (float) $refunded_item->get_total_tax() ) * -1;
 			}
 			/**
 			 * Refunded shipping items.
@@ -334,7 +665,7 @@ class DataUtils {
 			$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() * -1;
+				$totals[ $original_id ] = ( $totals[ $original_id ] ?? 0.0 ) + ( (float) $refunded_item->get_total() + (float) $refunded_item->get_total_tax() ) * -1;
 			}
 		}

diff --git a/plugins/woocommerce/tests/php/src/Internal/RestApi/Routes/V4/Refunds/DataUtilsTest.php b/plugins/woocommerce/tests/php/src/Internal/RestApi/Routes/V4/Refunds/DataUtilsTest.php
index 4759e733eab..d37f19147f1 100644
--- a/plugins/woocommerce/tests/php/src/Internal/RestApi/Routes/V4/Refunds/DataUtilsTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/RestApi/Routes/V4/Refunds/DataUtilsTest.php
@@ -3,14 +3,16 @@ declare(strict_types=1);

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

+use Automattic\WooCommerce\Enums\OrderStatus;
 use Automattic\WooCommerce\Internal\RestApi\Routes\V4\Refunds\DataUtils;
 use WC_Cache_Helper;
 use WC_Helper_Product;
 use WC_Order;
+use WC_Order_Item_Fee;
 use WC_Order_Item_Product;
+use WC_Order_Item_Shipping;
 use WC_Tax;
 use WC_Unit_Test_Case;
-use ReflectionClass;

 /**
  * DataUtilsTest class.
@@ -45,101 +47,6 @@ class DataUtilsTest extends WC_Unit_Test_Case {
 		parent::tearDown();
 	}

-	/**
-	 * Test building tax rates array with a single rate.
-	 */
-	public function test_build_tax_rates_array_with_single_rate() {
-		// Create a tax rate.
-		$tax_rate_id = WC_Tax::_insert_tax_rate(
-			array(
-				'tax_rate_country'  => 'US',
-				'tax_rate_state'    => '',
-				'tax_rate'          => '10.0000',
-				'tax_rate_name'     => 'VAT',
-				'tax_rate_priority' => '1',
-				'tax_rate_compound' => '0',
-				'tax_rate_shipping' => '1',
-				'tax_rate_order'    => '1',
-				'tax_rate_class'    => '',
-			)
-		);
-
-		// Create an order with the tax rate.
-		$order = $this->create_order_with_taxes( array( $tax_rate_id ) );
-
-		// Use reflection to access private method.
-		$reflection = new ReflectionClass( $this->data_utils );
-		$method     = $reflection->getMethod( 'build_tax_rates_array' );
-		$method->setAccessible( true );
-
-		// Call the method.
-		$result = $method->invoke( $this->data_utils, $order, array( $tax_rate_id ) );
-
-		// Assertions.
-		$this->assertIsArray( $result );
-		$this->assertArrayHasKey( $tax_rate_id, $result );
-		$this->assertEquals( 10.0, $result[ $tax_rate_id ]['rate'] );
-		$this->assertEquals( 'VAT', $result[ $tax_rate_id ]['label'] );
-		$this->assertEquals( 'no', $result[ $tax_rate_id ]['compound'] );
-	}
-
-	/**
-	 * Test building tax rates array with multiple rates.
-	 */
-	public function test_build_tax_rates_array_with_multiple_rates() {
-		// Create two tax rates.
-		$tax_rate_id_1 = WC_Tax::_insert_tax_rate(
-			array(
-				'tax_rate_country'  => 'US',
-				'tax_rate_state'    => '',
-				'tax_rate'          => '10.0000',
-				'tax_rate_name'     => 'VAT',
-				'tax_rate_priority' => '1',
-				'tax_rate_compound' => '0',
-				'tax_rate_shipping' => '1',
-				'tax_rate_order'    => '1',
-				'tax_rate_class'    => '',
-			)
-		);
-
-		$tax_rate_id_2 = WC_Tax::_insert_tax_rate(
-			array(
-				'tax_rate_country'  => 'US',
-				'tax_rate_state'    => '',
-				'tax_rate'          => '5.0000',
-				'tax_rate_name'     => 'Regional',
-				'tax_rate_priority' => '2',
-				'tax_rate_compound' => '0',
-				'tax_rate_shipping' => '1',
-				'tax_rate_order'    => '2',
-				'tax_rate_class'    => '',
-			)
-		);
-
-		// Create an order with both tax rates.
-		$order = $this->create_order_with_taxes( array( $tax_rate_id_1, $tax_rate_id_2 ) );
-
-		// Use reflection to access private method.
-		$reflection = new ReflectionClass( $this->data_utils );
-		$method     = $reflection->getMethod( 'build_tax_rates_array' );
-		$method->setAccessible( true );
-
-		// Call the method.
-		$result = $method->invoke( $this->data_utils, $order, array( $tax_rate_id_1, $tax_rate_id_2 ) );
-
-		// Assertions.
-		$this->assertIsArray( $result );
-		$this->assertCount( 2, $result );
-
-		$this->assertArrayHasKey( $tax_rate_id_1, $result );
-		$this->assertEquals( 10.0, $result[ $tax_rate_id_1 ]['rate'] );
-		$this->assertEquals( 'VAT', $result[ $tax_rate_id_1 ]['label'] );
-
-		$this->assertArrayHasKey( $tax_rate_id_2, $result );
-		$this->assertEquals( 5.0, $result[ $tax_rate_id_2 ]['rate'] );
-		$this->assertEquals( 'Regional', $result[ $tax_rate_id_2 ]['label'] );
-	}
-
 	/**
 	 * Test that tax is automatically extracted when not provided.
 	 */
@@ -165,11 +72,12 @@ class DataUtilsTest extends WC_Unit_Test_Case {
 		$item  = reset( $items );

 		// Line items WITHOUT explicit refund_tax.
+		// refund_total 110.00 includes 10% tax.
 		$line_items = array(
 			array(
 				'line_item_id' => $item->get_id(),
 				'quantity'     => 1,
-				'refund_total' => 110.00, // Includes 10% tax.
+				'refund_total' => 110.00,
 			),
 		);

@@ -214,6 +122,7 @@ class DataUtilsTest extends WC_Unit_Test_Case {
 		$item  = reset( $items );

 		// Line items WITH explicit refund_tax (legacy format).
+		// Explicit refund_tax value (7.50) should be preserved by the converter.
 		$line_items = array(
 			array(
 				'line_item_id' => $item->get_id(),
@@ -222,7 +131,7 @@ class DataUtilsTest extends WC_Unit_Test_Case {
 				'refund_tax'   => array(
 					array(
 						'id'           => $tax_rate_id,
-						'refund_total' => 7.50, // Explicit value.
+						'refund_total' => 7.50,
 					),
 				),
 			),
@@ -251,7 +160,7 @@ class DataUtilsTest extends WC_Unit_Test_Case {
 	 * attempt to extract taxes from refund_total in this case.
 	 */
 	public function test_convert_line_items_skips_tax_extraction_for_zero_tax_items() {
-		// Create a tax rate (applied to products but not shipping in this test).
+		// Create a tax rate that applies to products but NOT shipping (tax_rate_shipping => '0').
 		$tax_rate_id = WC_Tax::_insert_tax_rate(
 			array(
 				'tax_rate_country'  => 'US',
@@ -260,7 +169,7 @@ class DataUtilsTest extends WC_Unit_Test_Case {
 				'tax_rate_name'     => 'VAT',
 				'tax_rate_priority' => '1',
 				'tax_rate_compound' => '0',
-				'tax_rate_shipping' => '0', // Tax does NOT apply to shipping.
+				'tax_rate_shipping' => '0',
 				'tax_rate_order'    => '1',
 				'tax_rate_class'    => '',
 			)
@@ -279,11 +188,12 @@ class DataUtilsTest extends WC_Unit_Test_Case {
 		$this->assertEquals( 0, (float) $shipping_taxes['total'][ $tax_rate_id ] );

 		// Line items WITHOUT explicit refund_tax for shipping.
+		// refund_total 10.00 is the shipping cost (no tax included).
 		$line_items = array(
 			array(
 				'line_item_id' => $shipping_item->get_id(),
 				'quantity'     => 1,
-				'refund_total' => 10.00, // Shipping cost to refund (no tax included).
+				'refund_total' => 10.00,
 			),
 		);

@@ -301,6 +211,72 @@ class DataUtilsTest extends WC_Unit_Test_Case {
 		$this->assertEmpty( $result[ $shipping_item->get_id() ]['refund_tax'] );
 	}

+	/**
+	 * @testdox Should extract a negative tax split when converting a negative-fee line with stored negative tax.
+	 *
+	 * Regression guard for the creation/preview tax-filter divergence: an earlier
+	 * filter rule of `$amount > 0` dropped the negative tax ID for a discount fee,
+	 * so the internal format ended up with refund_total = -$11 and refund_tax = [].
+	 * The preview path (build_refund_preview) already keeps non-zero taxes; the
+	 * create path must agree, otherwise a refund moved from preview to create loses
+	 * the signed split.
+	 */
+	public function test_convert_line_items_extracts_negative_tax_for_negative_fee() {
+		$tax_rate_id = WC_Tax::_insert_tax_rate(
+			array(
+				'tax_rate_country'  => 'US',
+				'tax_rate_state'    => '',
+				'tax_rate'          => '10.0000',
+				'tax_rate_name'     => 'VAT',
+				'tax_rate_priority' => '1',
+				'tax_rate_compound' => '0',
+				'tax_rate_shipping' => '0',
+				'tax_rate_order'    => '1',
+				'tax_rate_class'    => '',
+			)
+		);
+
+		$order = wc_create_order();
+		$fee   = new WC_Order_Item_Fee();
+		$fee->set_props(
+			array(
+				'name'  => 'Loyalty discount',
+				'total' => -10.00,
+			)
+		);
+		$fee->set_taxes( array( 'total' => array( $tax_rate_id => -1.00 ) ) );
+		$fee->save();
+		$order->add_item( $fee );
+
+		$tax_item = new \WC_Order_Item_Tax();
+		$tax_item->set_rate( $tax_rate_id );
+		$tax_item->set_tax_total( -1.00 );
+		$tax_item->save();
+		$order->add_item( $tax_item );
+
+		$order->save();
+
+		// refund_total -11.00 is the tax-inclusive amount; the converter should
+		// split it into a -10.00 base and -1.00 tax for the matching rate ID.
+		$line_items = array(
+			array(
+				'line_item_id' => $fee->get_id(),
+				'quantity'     => 1,
+				'refund_total' => -11.00,
+			),
+		);
+
+		$result = $this->data_utils->convert_line_items_to_internal_format( $line_items, $order );
+
+		$this->assertArrayHasKey( $fee->get_id(), $result );
+		$this->assertArrayHasKey( 'refund_tax', $result[ $fee->get_id() ] );
+		$this->assertArrayHasKey( $tax_rate_id, $result[ $fee->get_id() ]['refund_tax'] );
+		$this->assertEqualsWithDelta( -1.00, $result[ $fee->get_id() ]['refund_tax'][ $tax_rate_id ], 0.01 );
+		$this->assertEqualsWithDelta( -10.00, $result[ $fee->get_id() ]['refund_total'], 0.01 );
+
+		$order->delete( true );
+	}
+
 	/**
 	 * Test that calculate_refund_amount handles floating point precision correctly.
 	 *
@@ -359,6 +335,994 @@ class DataUtilsTest extends WC_Unit_Test_Case {
 		$this->assertNull( $this->data_utils->calculate_refund_amount( array() ) );
 	}

+	/**
+	 * @testdox Should compute line item refund total for a product based on unit price and quantity.
+	 */
+	public function test_compute_line_item_refund_total_product(): void {
+		$product = WC_Helper_Product::create_simple_product();
+		$product->set_regular_price( 25.00 );
+		$product->save();
+
+		$order = wc_create_order();
+		$item  = new WC_Order_Item_Product();
+		$item->set_props(
+			array(
+				'product'  => $product,
+				'quantity' => 4,
+				'subtotal' => 100.00,
+				'total'    => 100.00,
+			)
+		);
+		$item->save();
+		$order->add_item( $item );
+		$order->save();
+
+		$this->assertSame( 50.00, $this->data_utils->compute_line_item_refund_total( $item, 2 ) );
+
+		$product->delete( true );
+		$order->delete( true );
+	}
+
+	/**
+	 * @testdox Should return error when preview line item quantity exceeds refundable.
+	 */
+	public function test_validate_preview_line_items_quantity_exceeds_refundable(): void {
+		$product = WC_Helper_Product::create_simple_product();
+		$product->set_regular_price( 25.00 );
+		$product->save();
+
+		$order = wc_create_order();
+		$item  = new WC_Order_Item_Product();
+		$item->set_props(
+			array(
+				'product'  => $product,
+				'quantity' => 2,
+				'subtotal' => 50.00,
+				'total'    => 50.00,
+			)
+		);
+		$item->save();
+		$order->add_item( $item );
+		$order->set_total( 50.00 );
+		$order->set_status( OrderStatus::COMPLETED );
+		$order->save();
+
+		wc_create_refund(
+			array(
+				'order_id'   => $order->get_id(),
+				'amount'     => 25.00,
+				'line_items' => array(
+					$item->get_id() => array(
+						'qty'          => 1,
+						'refund_total' => 25.00,
+						'refund_tax'   => array(),
+					),
+				),
+			)
+		);
+
+		$result = $this->data_utils->validate_preview_line_items(
+			array(
+				array(
+					'line_item_id' => $item->get_id(),
+					'quantity'     => 2,
+				),
+			),
+			$order
+		);
+
+		$this->assertInstanceOf( \WP_Error::class, $result );
+		$this->assertEquals( 'quantity_exceeds_refundable', $result->get_error_code() );
+
+		$product->delete( true );
+		$order->delete( true );
+	}
+
+	/**
+	 * @testdox Should return error when order is not refundable.
+	 */
+	public function test_validate_preview_line_items_order_not_refundable(): void {
+		$order = $this->create_order_with_taxes( array(), 50.00 );
+		$order->set_status( OrderStatus::CANCELLED );
+		$order->save();
+
+		$items = $order->get_items( 'line_item' );
+		$item  = reset( $items );
+
+		$result = $this->data_utils->validate_preview_line_items(
+			array(
+				array(
+					'line_item_id' => $item->get_id(),
+					'quantity'     => 1,
+				),
+			),
+			$order
+		);
+
+		$this->assertInstanceOf( \WP_Error::class, $result );
+		$this->assertEquals( 'order_not_refundable', $result->get_error_code() );
+	}
+
+	/**
+	 * @testdox Should return 0.0 for product line item with zero original quantity.
+	 */
+	public function test_compute_line_item_refund_total_zero_original_quantity(): void {
+		$order = wc_create_order();
+		$item  = new WC_Order_Item_Product();
+		$item->set_props(
+			array(
+				'quantity' => 0,
+				'subtotal' => 0,
+				'total'    => 0,
+			)
+		);
+		$item->save();
+		$order->add_item( $item );
+		$order->save();
+
+		$this->assertSame( 0.0, $this->data_utils->compute_line_item_refund_total( $item, 1 ) );
+
+		$order->delete( true );
+	}
+
+	/**
+	 * @testdox Should return full item total + tax for shipping items, ignoring quantity.
+	 */
+	public function test_compute_line_item_refund_total_shipping(): void {
+		$shipping = new WC_Order_Item_Shipping();
+		$shipping->set_props(
+			array(
+				'method_title' => 'Flat Rate',
+				'total'        => 10.00,
+			)
+		);
+		$shipping->set_taxes( array( 'total' => array( 1 => 1.50 ) ) );
+		$shipping->save();
+
+		$this->assertSame( 11.50, $this->data_utils->compute_line_item_refund_total( $shipping, 1 ) );
+	}
+
+	/**
+	 * @testdox Should return full item total + tax for fee items.
+	 */
+	public function test_compute_line_item_refund_total_fee_positive(): void {
+		$fee = new WC_Order_Item_Fee();
+		$fee->set_props(
+			array(
+				'name'  => 'Handling',
+				'total' => 20.00,
+			)
+		);
+		$fee->set_taxes( array( 'total' => array( 1 => 3.00 ) ) );
+		$fee->save();
+
+		$this->assertSame( 23.00, $this->data_utils->compute_line_item_refund_total( $fee, 1 ) );
+	}
+
+	/**
+	 * @testdox Should preserve negative sign for negative-total fee items (discount fees).
+	 */
+	public function test_compute_line_item_refund_total_fee_negative(): void {
+		$fee = new WC_Order_Item_Fee();
+		$fee->set_props(
+			array(
+				'name'  => 'Loyalty discount',
+				'total' => -10.00,
+			)
+		);
+		$fee->set_taxes( array( 'total' => array() ) );
+		$fee->save();
+
+		$this->assertSame( -10.00, $this->data_utils->compute_line_item_refund_total( $fee, 1 ) );
+	}
+
+	/**
+	 * @testdox Should throw InvalidArgumentException when quantity is less than 1.
+	 *
+	 * @dataProvider provider_invalid_quantities_for_compute
+	 *
+	 * @param int $quantity Quantity to test.
+	 */
+	public function test_compute_line_item_refund_total_invalid_quantity( int $quantity ): void {
+		$fee = new WC_Order_Item_Fee();
+		$fee->set_props(
+			array(
+				'name'  => 'Fee',
+				'total' => 5.00,
+			)
+		);
+		$fee->save();
+
+		$this->expectException( \InvalidArgumentException::class );
+		$this->data_utils->compute_line_item_refund_total( $fee, $quantity );
+	}
+
+	/**
+	 * @return array<string, array<int>>
+	 */
+	public function provider_invalid_quantities_for_compute(): array {
+		return array(
+			'zero'     => array( 0 ),
+			'negative' => array( -1 ),
+		);
+	}
+
+	/**
+	 * @testdox Should populate breakdown.shipping for orders with only shipping line items.
+	 */
+	public function test_build_refund_preview_shipping_only(): void {
+		$order    = wc_create_order();
+		$shipping = new WC_Order_Item_Shipping();
+		$shipping->set_props(
+			array(
+				'method_title' => 'Flat Rate',
+				'total'        => 10.00,
+			)
+		);
+		$shipping->save();
+		$order->add_item( $shipping );
+		$order->save();
+
+		$result = $this->data_utils->build_refund_preview(
+			$order,
+			array(
+				array(
+					'line_item_id' => $shipping->get_id(),
+					'quantity'     => 1,
+				),
+			)
+		);
+
+		$this->assertCount( 1, $result['breakdown']['shipping']['items'] );
+		$this->assertSame( array(), $result['breakdown']['products']['items'] );
+		$this->assertSame( array(), $result['breakdown']['fees']['items'] );
+		$this->assertEquals( '10.00', $result['breakdown']['shipping']['total'] );
+		$this->assertEquals( '10.00', $result['total'] );
+
+		$order->delete( true );
+	}
+
+	/**
+	 * @testdox Should populate breakdown.fees for orders with only fee line items.
+	 */
+	public function test_build_refund_preview_fee_only(): void {
+		$order = wc_create_order();
+		$fee   = new WC_Order_Item_Fee();
+		$fee->set_props(
+			array(
+				'name'  => 'Service fee',
+				'total' => 20.00,
+			)
+		);
+		$fee->save();
+		$order->add_item( $fee );
+		$order->save();
+
+		$result = $this->data_utils->build_refund_preview(
+			$order,
+			array(
+				array(
+					'line_item_id' => $fee->get_id(),
+					'quantity'     => 1,
+				),
+			)
+		);
+
+		$this->assertCount( 1, $result['breakdown']['fees']['items'] );
+		$this->assertSame( array(), $result['breakdown']['products']['items'] );
+		$this->assertSame( array(), $result['breakdown']['shipping']['items'] );
+		$this->assertEquals( '20.00', $result['breakdown']['fees']['total'] );
+		$this->assertEquals( '20.00', $result['total'] );
+
+		$order->delete( true );
+	}
+
+	/**
+	 * @testdox Should aggregate products, shipping, and fees across all three sections in mixed orders.
+	 */
+	public function test_build_refund_preview_mixed_sections(): void {
+		$product = WC_Helper_Product::create_simple_product();
+		$product->set_regular_price( 50.00 );
+		$product->save();
+
+		$order = wc_create_order();
+		$item  = new WC_Order_Item_Product();
+		$item->set_props(
+			array(
+				'product'  => $product,
+				'quantity' => 1,
+				'subtotal' => 50.00,
+				'total'    => 50.00,
+			)
+		);
+		$item->save();
+		$order->add_item( $item );
+
+		$shipping = new WC_Order_Item_Shipping();
+		$shipping->set_props(
+			array(
+				'method_title' => 'Flat Rate',
+				'total'        => 10.00,
+			)
+		);
+		$shipping->save();
+		$order->add_item( $shipping );
+
+		$fee = new WC_Order_Item_Fee();
+		$fee->set_props(
+			array(
+				'name'  => 'Service fee',
+				'total' => 5.00,
+			)
+		);
+		$fee->save();
+		$order->add_item( $fee );
+
+		$order->save();
+
+		$result = $this->data_utils->build_refund_preview(
+			$order,
+			array(
+				array(
+					'line_item_id' => $item->get_id(),
+					'quantity'     => 1,
+				),
+				array(
+					'line_item_id' => $shipping->get_id(),
+					'quantity'     => 1,
+				),
+				array(
+					'line_item_id' => $fee->get_id(),
+					'quantity'     => 1,
+				),
+			)
+		);
+
+		$this->assertEquals( '50.00', $result['breakdown']['products']['total'] );
+		$this->assertEquals( '10.00', $result['breakdown']['shipping']['total'] );
+		$this->assertEquals( '5.00', $result['breakdown']['fees']['total'] );
+		$this->assertEquals( '65.00', $result['total'] );
+
+		$product->delete( true );
+		$order->delete( true );
+	}
+
+	/**
+	 * @testdox Section totals should equal the sum of item totals at byte-exact precision across many fractional-price items.
+	 */
+	public function test_build_refund_preview_multi_item_fractional_aggregation(): void {
+		$product = WC_Helper_Product::create_simple_product();
+		$product->save();
+
+		$order  = wc_create_order();
+		$prices = array( 19.99, 7.33, 12.50, 4.99, 0.01 );
+		$ids    = array();
+		foreach ( $prices as $price ) {
+			$item = new WC_Order_Item_Product();
+			$item->set_props(
+				array(
+					'product'  => $product,
+					'quantity' => 1,
+					'subtotal' => $price,
+					'total'    => $price,
+				)
+			);
+			$item->save();
+			$order->add_item( $item );
+			$ids[] = $item->get_id();
+		}
+		$order->save();
+
+		$line_items = array_map(
+			fn( $id ) => array(
+				'line_item_id' => $id,
+				'quantity'     => 1,
+			),
+			$ids
+		);
+		$result     = $this->data_utils->build_refund_preview( $order, $line_items );
+
+		$item_total_sum = 0.0;
+		foreach ( $result['breakdown']['products']['items'] as $i ) {
+			$item_total_sum += (float) $i['total'];
+		}
+		$this->assertEqualsWithDelta(
+			(float) $result['breakdown']['products']['total'],
+			$item_total_sum,
+			0.0001,
+			'Section total should equal sum of item totals without drift.'
+		);
+		$this->assertEquals( '44.82', $result['breakdown']['products']['total'] );
+
+		$product->delete( true );
+		$order->delete( true );
+	}
+
+	/**
+	 * @testdox Should throw InvalidArgumentException when line_item_id does not resolve to an order item.
+	 */
+	public function test_build_refund_preview_missing_line_item_id(): void {
+		$order = wc_create_order();
+		$order->save();
+
+		$this->expectException( \InvalidArgumentException::class );
+		$this->data_utils->build_refund_preview(
+			$order,
+			array(
+				array(
+					'line_item_id' => 999999,
+					'quantity'     => 1,
+				),
+			)
+		);
+
+		$order->delete( true );
+	}
+
+	/**
+	 * @testdox Should return missing_line_items error for empty line_items array.
+	 */
+	public function test_validate_preview_line_items_empty(): void {
+		$order = $this->create_order_with_taxes( array(), 50.00 );
+		$order->set_status( OrderStatus::COMPLETED );
+		$order->save();
+
+		$result = $this->data_utils->validate_preview_line_items( array(), $order );
+
+		$this->assertInstanceOf( \WP_Error::class, $result );
+		$this->assertEquals( 'missing_line_items', $result->get_error_code() );
+	}
+
+	/**
+	 * @testdox Should return order_not_refundable when remaining refund amount is zero.
+	 */
+	public function test_validate_preview_line_items_no_remaining_amount(): void {
+		$order = $this->create_order_with_taxes( array(), 50.00 );
+		$order->set_status( OrderStatus::COMPLETED );
+		$order->save();
+		$items = $order->get_items( 'line_item' );
+		$item  = reset( $items );
+
+		wc_create_refund(
+			array(
+				'order_id'   => $order->get_id(),
+				'amount'     => 50.00,
+				'line_items' => array(
+					$item->get_id() => array(
+						'qty'          => 1,
+						'refund_total' => 50.00,
+						'refund_tax'   => array(),
+					),
+				),
+			)
+		);
+
+		$result = $this->data_utils->validate_preview_line_items(
+			array(
+				array(
+					'line_item_id' => $item->get_id(),
+					'quantity'     => 1,
+				),
+			),
+			$order
+		);
+
+		$this->assertInstanceOf( \WP_Error::class, $result );
+		$this->assertEquals( 'order_not_refundable', $result->get_error_code() );
+	}
+
+	/**
+	 * @testdox Should return missing_line_item_id when line_item_id key is absent.
+	 */
+	public function test_validate_preview_line_items_missing_id(): void {
+		$order = $this->create_order_with_taxes( array(), 50.00 );
+		$order->set_status( OrderStatus::COMPLETED );
+		$order->save();
+
+		$result = $this->data_utils->validate_preview_line_items(
+			array( array( 'quantity' => 1 ) ),
+			$order
+		);
+
+		$this->assertInstanceOf( \WP_Error::class, $result );
+		$this->assertEquals( 'missing_line_item_id', $result->get_error_code() );
+	}
+
+	/**
+	 * @testdox Should return line_item_not_found when line_item_id belongs to a different order.
+	 */
+	public function test_validate_preview_line_items_cross_order_id(): void {
+		$order_a = $this->create_order_with_taxes( array(), 50.00 );
+		$order_a->set_status( OrderStatus::COMPLETED );
+		$order_a->save();
+		$order_b = $this->create_order_with_taxes( array(), 50.00 );
+		$order_b->set_status( OrderStatus::COMPLETED );
+		$order_b->save();
+		$order_b_items = $order_b->get_items( 'line_item' );
+		$order_b_item  = reset( $order_b_items );
+
+		$result = $this->data_utils->validate_preview_line_items(
+			array(
+				array(
+					'line_item_id' => $order_b_item->get_id(),
+					'quantity'     => 1,
+				),
+			),
+			$order_a
+		);
+
+		$this->assertInstanceOf( \WP_Error::class, $result );
+		$this->assertEquals( 'line_item_not_found', $result->get_error_code() );
+	}
+
+	/**
+	 * @testdox Should return unsupported_item_type when line_item_id refers to a tax line.
+	 */
+	public function test_validate_preview_line_items_unsupported_type(): void {
+		$tax_rate_id = WC_Tax::_insert_tax_rate(
+			array(
+				'tax_rate_country'  => 'US',
+				'tax_rate_state'    => '',
+				'tax_rate'          => '10.0000',
+				'tax_rate_name'     => 'VAT',
+				'tax_rate_priority' => '1',
+				'tax_rate_compound' => '0',
+				'tax_rate_shipping' => '1',
+				'tax_rate_order'    => '1',
+				'tax_rate_class'    => '',
+			)
+		);
+		$order       = $this->create_order_with_taxes( array( $tax_rate_id ), 50.00 );
+		$order->set_status( OrderStatus::COMPLETED );
+		$order->save();
+		$tax_items = $order->get_items( 'tax' );
+		$tax_item  = reset( $tax_items );
+
+		$result = $this->data_utils->validate_preview_line_items(
+			array(
+				array(
+					'line_item_id' => $tax_item->get_id(),
+					'quantity'     => 1,
+				),
+			),
+			$order
+		);
+
+		$this->assertInstanceOf( \WP_Error::class, $result );
+		$this->assertEquals( 'unsupported_item_type', $result->get_error_code() );
+	}
+
+	/**
+	 * @testdox Should return invalid_quantity for missing, zero, negative, string, or float quantity values.
+	 *
+	 * @dataProvider provider_invalid_quantities_for_validate
+	 *
+	 * @param array<string, mixed> $line_item_overrides Keys to merge into the test line item.
+	 */
+	public function test_validate_preview_line_items_invalid_quantity( array $line_item_overrides ): void {
+		$order = $this->create_order_with_taxes( array(), 50.00 );
+		$order->set_status( OrderStatus::COMPLETED );
+		$order->save();
+		$items = $order->get_items( 'line_item' );
+		$item  = reset( $items );
+
+		$line_item = array_merge( array( 'line_item_id' => $item->get_id() ), $line_item_overrides );
+
+		$result = $this->data_utils->validate_preview_line_items( array( $line_item ), $order );
+
+		$this->assertInstanceOf( \WP_Error::class, $result );
+		$this->assertEquals( 'invalid_quantity', $result->get_error_code() );
+	}
+
+	/**
+	 * @return array<string, array<array<string, mixed>>>
+	 */
+	public function provider_invalid_quantities_for_validate(): array {
+		return array(
+			'missing key' => array( array() ),
+			'zero'        => array( array( 'quantity' => 0 ) ),
+			'negative'    => array( array( 'quantity' => -1 ) ),
+			'string'      => array( array( 'quantity' => 'abc' ) ),
+			'float'       => array( array( 'quantity' => 1.5 ) ),
+			'null'        => array( array( 'quantity' => null ) ),
+		);
+	}
+
+	/**
+	 * @testdox Should reject shipping/fee items with quantity other than 1.
+	 */
+	public function test_validate_preview_line_items_shipping_quantity_must_be_one(): void {
+		$order    = wc_create_order();
+		$shipping = new WC_Order_Item_Shipping();
+		$shipping->set_props(
+			array(
+				'method_title' => 'Flat Rate',
+				'total'        => 10.00,
+			)
+		);
+		$shipping->save();
+		$order->add_item( $shipping );
+		$order->set_status( OrderStatus::COMPLETED );
+		$order->set_total( 10.00 );
+		$order->save();
+
+		$result = $this->data_utils->validate_preview_line_items(
+			array(
+				array(
+					'line_item_id' => $shipping->get_id(),
+					'quantity'     => 2,
+				),
+			),
+			$order
+		);
+
+		$this->assertInstanceOf( \WP_Error::class, $result );
+		$this->assertEquals( 'invalid_quantity', $result->get_error_code() );
+	}
+
+	/**
+	 * @testdox Should return order_not_refundable when shipping line is fully refunded.
+	 *
+	 * Once the shipping line is fully refunded the order's remaining refundable
+	 * amount drops to zero, so the order-level guard fires before the per-line
+	 * `quantity_exceeds_refundable` check is reached.
+	 */
+	public function test_validate_preview_line_items_shipping_fully_refunded(): void {
+		$order    = wc_create_order();
+		$shipping = new WC_Order_Item_Shipping();
+		$shipping->set_props(
+			array(
+				'method_title' => 'Flat Rate',
+				'total'        => 10.00,
+			)
+		);
+		$shipping->save();
+		$order->add_item( $shipping );
+		$order->set_total( 10.00 );
+		$order->set_status( OrderStatus::COMPLETED );
+		$order->save();
+
+		wc_create_refund(
+			array(
+				'order_id'   => $order->get_id(),
+				'amount'     => 10.00,
+				'line_items' => array(
+					$shipping->get_id() => array(
+						'qty'          => 0,
+						'refund_total' => 10.00,
+						'refund_tax'   => array(),
+					),
+				),
+			)
+		);
+
+		$result = $this->data_utils->validate_preview_line_items(
+			array(
+				array(
+					'line_item_id' => $shipping->get_id(),
+					'quantity'     => 1,
+				),
+			),
+			$order
+		);
+
+		$this->assertInstanceOf( \WP_Error::class, $result );
+		// 'order_not_refundable' is returned first because the order's total refundable amount is now zero.
+		$this->assertEquals( 'order_not_refundable', $result->get_error_code() );
+	}
+
+	/**
+	 * @testdox Should return quantity_exceeds_refundable when a partially-refunded shipping line cannot fit a full preview at its original total.
+	 *
+	 * Order has a $10 shipping line + a $50 product line so the order is still
+	 * refundable after a $5 partial shipping refund. Previewing the shipping
+	 * line at qty=1 would refund the full $10 — exceeds the $5 remaining on
+	 * that line — so validation must reject with `quantity_exceeds_refundable`.
+	 * Without the per-line cap, validate would pass and `build_refund_preview`
+	 * would return an oversized total.
+	 */
+	public function test_validate_preview_line_items_shipping_partial_remaining(): void {
+		$product = WC_Helper_Product::create_simple_product();
+		$product->set_regular_price( 50.00 );
+		$product->save();
+
+		$order = wc_create_order();
+		$item  = new WC_Order_Item_Product();
+		$item->set_props(
+			array(
+				'product'  => $product,
+				'quantity' => 1,
+				'subtotal' => 50.00,
+				'total'    => 50.00,
+			)
+		);
+		$item->save();
+		$order->add_item( $item );
+
+		$shipping = new WC_Order_Item_Shipping();
+		$shipping->set_props(
+			array(
+				'method_title' => 'Flat Rate',
+				'total'        => 10.00,
+			)
+		);
+		$shipping->save();
+		$order->add_item( $shipping );
+
+		$order->set_total( 60.00 );
+		$order->set_status( OrderStatus::COMPLETED );
+		$order->save();
+
+		// Pre-refund $5 of the shipping line, leaving $5 remaining on it but
+		// keeping the order overall refundable ($55 of $60 remains).
+		wc_create_refund(
+			array(
+				'order_id'   => $order->get_id(),
+				'amount'     => 5.00,
+				'line_items' => array(
+					$shipping->get_id() => array(
+						'qty'          => 0,
+						'refund_total' => 5.00,
+						'refund_tax'   => array(),
+					),
+				),
+			)
+		);
+
+		$result = $this->data_utils->validate_preview_line_items(
+			array(
+				array(
+					'line_item_id' => $shipping->get_id(),
+					'quantity'     => 1,
+				),
+			),
+			$order
+		);
+
+		$this->assertInstanceOf( \WP_Error::class, $result );
+		$this->assertEquals( 'quantity_exceeds_refundable', $result->get_error_code() );
+
+		$product->delete( true );
+		$order->delete( true );
+	}
+
+	/**
+	 * @testdox Should allow previewing a full shipping refund when the line carries tax and has no prior refund.
+	 *
+	 * Regression guard: an earlier implementation compared the tax-inclusive
+	 * $requested_total (from compute_line_item_refund_total) against a
+	 * tax-exclusive $remaining_total (only get_total()). For a $10 shipping line
+	 * with $1.50 of tax that produced 11.50 > 10.00 → wrongly rejected.
+	 */
+	public function test_validate_preview_line_items_shipping_with_tax_allows_full_refund(): void {
+		$tax_rate_id = WC_Tax::_insert_tax_rate(
+			array(
+				'tax_rate_country'  => 'US',
+				'tax_rate_state'    => '',
+				'tax_rate'          => '15.0000',
+				'tax_rate_name'     => 'VAT',
+				'tax_rate_priority' => '1',
+				'tax_rate_compound' => '0',
+				'tax_rate_shipping' => '1',
+				'tax_rate_order'    => '1',
+				'tax_rate_class'    => '',
+			)
+		);
+
+		$product = WC_Helper_Product::create_simple_product();
+		$product->set_regular_price( 50.00 );
+		$product->save();
+
+		$order = wc_create_order();
+		$item  = new WC_Order_Item_Product();
+		$item->set_props(
+			array(
+				'product'  => $product,
+				'quantity' => 1,
+				'subtotal' => 50.00,
+				'total'    => 50.00,
+			)
+		);
+		$item->save();
+		$order->add_item( $item );
+
+		$shipping = new WC_Order_Item_Shipping();
+		$shipping->set_props(
+			array(
+				'method_title' => 'Flat Rate',
+				'total'        => 10.00,
+			)
+		);
+		$shipping->set_taxes( array( 'total' => array( $tax_rate_id => 1.50 ) ) );
+		$shipping->save();
+		$order->add_item( $shipping );
+
+		$tax_item = new \WC_Order_Item_Tax();
+		$tax_item->set_rate( $tax_rate_id );
+		$tax_item->set_shipping_tax_total( 1.50 );
+		$tax_item->save();
+		$order->add_item( $tax_item );
+
+		$order->set_total( 61.50 );
+		$order->set_status( OrderStatus::COMPLETED );
+		$order->save();
+
+		$result = $this->data_utils->validate_preview_line_items(
+			array(
+				array(
+					'line_item_id' => $shipping->get_id(),
+					'quantity'     => 1,
+				),
+			),
+			$order
+		);
+
+		$this->assertTrue( $result, 'Full shipping refund covering line total + tax with no prior refund should pass validation.' );
+
+		$product->delete( true );
+		$order->delete( true );
+	}
+
+	/**
+	 * @testdox build_refund_preview preserves the negative tax split on a fee with a negative stored tax.
+	 *
+	 * Regression guard: a previous implementation filtered tax IDs by `amount > 0`,
+	 * which dropped negative tax entries entirely and emitted `tax: 0.00` on
+	 * negative-fee discount lines. The fix keeps any non-zero stored tax so the
+	 * preview returns the signed split.
+	 */
+	public function test_build_refund_preview_negative_fee_with_negative_tax(): void {
+		// A 10% rate is needed so WC_Tax::calc_inclusive_tax can split a tax-inclusive total.
+		$tax_rate_id = WC_Tax::_insert_tax_rate(
+			array(
+				'tax_rate_country'  => 'US',
+				'tax_rate_state'    => '',
+				'tax_rate'          => '10.0000',
+				'tax_rate_name'     => 'VAT',
+				'tax_rate_priority' => '1',
+				'tax_rate_compound' => '0',
+				'tax_rate_shipping' => '0',
+				'tax_rate_order'    => '1',
+				'tax_rate_class'    => '',
+			)
+		);
+
+		$order = wc_create_order();
+		$fee   = new WC_Order_Item_Fee();
+		$fee->set_props(
+			array(
+				'name'  => 'Loyalty discount',
+				'total' => -10.00,
+			)
+		);
+		$fee->set_taxes( array( 'total' => array( $tax_rate_id => -1.00 ) ) );
+		$fee->save();
+		$order->add_item( $fee );
+
+		$tax_item = new \WC_Order_Item_Tax();
+		$tax_item->set_rate( $tax_rate_id );
+		$tax_item->set_tax_total( -1.00 );
+		$tax_item->save();
+		$order->add_item( $tax_item );
+
+		$order->save();
+
+		$result = $this->data_utils->build_refund_preview(
+			$order,
+			array(
+				array(
+					'line_item_id' => $fee->get_id(),
+					'quantity'     => 1,
+				),
+			)
+		);
+
+		// Total stays at the tax-inclusive -$11. The split between subtotal
+		// (-$10) and tax (-$1) must be preserved on the fee item entry.
+		$this->assertSame( '-11.00', $result['breakdown']['fees']['total'] );
+		$this->assertCount( 1, $result['breakdown']['fees']['items'] );
+		$this->assertEquals( '-10.00', $result['breakdown']['fees']['items'][0]['subtotal'] );
+		$this->assertEquals( '-1.00', $result['breakdown']['fees']['items'][0]['tax'] );
+		$this->assertEquals( '-11.00', $result['breakdown']['fees']['items'][0]['total'] );
+
+		$order->delete( true );
+	}
+
+	/**
+	 * @testdox Should allow validating a negative-total fee (discount fee) that has no prior refund.
+	 */
+	public function test_validate_preview_line_items_negative_fee_passes(): void {
+		$product = WC_Helper_Product::create_simple_product();
+		$product->set_regular_price( 50.00 );
+		$product->save();
+
+		$order = wc_create_order();
+		$item  = new WC_Order_Item_Product();
+		$item->set_props(
+			array(
+				'product'  => $product,
+				'quantity' => 1,
+				'subtotal' => 50.00,
+				'total'    => 50.00,
+			)
+		);
+		$item->save();
+		$order->add_item( $item );
+
+		$fee = new WC_Order_Item_Fee();
+		$fee->set_props(
+			array(
+				'name'  => 'Discount',
+				'total' => -10.00,
+			)
+		);
+		$fee->save();
+		$order->add_item( $fee );
+
+		$order->set_total( 40.00 );
+		$order->set_status( OrderStatus::COMPLETED );
+		$order->save();
+
+		$result = $this->data_utils->validate_preview_line_items(
+			array(
+				array(
+					'line_item_id' => $fee->get_id(),
+					'quantity'     => 1,
+				),
+			),
+			$order
+		);
+
+		$this->assertTrue( $result, 'Negative-total fee with no prior refund should pass validation.' );
+
+		$product->delete( true );
+		$order->delete( true );
+	}
+
+	/**
+	 * @testdox Should build refund preview with correct tax extraction.
+	 */
+	public function test_build_refund_preview_with_tax(): void {
+		$tax_rate_id = WC_Tax::_insert_tax_rate(
+			array(
+				'tax_rate_country'  => 'US',
+				'tax_rate_state'    => '',
+				'tax_rate'          => '10.0000',
+				'tax_rate_name'     => 'VAT',
+				'tax_rate_priority' => '1',
+				'tax_rate_compound' => '0',
+				'tax_rate_shipping' => '1',
+				'tax_rate_order'    => '1',
+				'tax_rate_class'    => '',
+			)
+		);
+
+		$order = $this->create_order_with_taxes( array( $tax_rate_id ), 100.00 );
+		$order->set_status( OrderStatus::COMPLETED );
+		$order->save();
+
+		$items  = $order->get_items( 'line_item' );
+		$item   = reset( $items );
+		$result = $this->data_utils->build_refund_preview(
+			$order,
+			array(
+				array(
+					'line_item_id' => $item->get_id(),
+					'quantity'     => 1,
+				),
+			)
+		);
+
+		$this->assertEquals( '100.00', $result['subtotal'] );
+		$this->assertEquals( '10.00', $result['tax'] );
+		$this->assertEquals( '110.00', $result['total'] );
+		$this->assertArrayHasKey( 'breakdown', $result );
+		$this->assertArrayHasKey( 'max_refundable', $result );
+		$this->assertCount( 1, $result['breakdown']['products']['items'] );
+		$this->assertArrayHasKey( 'name', $result['breakdown']['products']['items'][0] );
+		$this->assertArrayHasKey( 'product_id', $result['breakdown']['products']['items'][0] );
+	}
+
 	/**
 	 * Helper: Create an order with shipping that has tax rate IDs but zero tax amounts.
 	 *
@@ -393,7 +1357,8 @@ class DataUtilsTest extends WC_Unit_Test_Case {
 		$tax_item = new \WC_Order_Item_Tax();
 		$tax_item->set_rate( $tax_rate_id );
 		$tax_item->set_order_id( $order->get_id() );
-		$tax_item->set_tax_total( 0 ); // Product tax would be here, but we're focusing on shipping.
+		$tax_item->set_tax_total( 0 );
+		// Product tax would be here, but we're focusing on shipping.
 		$tax_item->set_shipping_tax_total( 0 );
 		$tax_item->save();
 		$order->add_item( $tax_item );