Automattic\WooCommerce\Internal\RestApi\Routes\V4\Refunds

DataUtils{}WC 1.0

Helper methods for the REST API.

Class DataUtils

No Hooks.

Usage

$DataUtils = new DataUtils();
// use class methods

Methods

  1. public build_refund_preview( WC_Order $order, array $line_items )
  2. public calculate_refund_amount( array $line_items )
  3. public compute_line_item_refund_total( $item, int $quantity )
  4. public compute_refund_preview_or_error( WC_Order $order, array $line_items, string $log_source )
  5. public compute_refunded_quantities_and_totals( WC_Order $order )
  6. public convert_line_items_to_internal_format( $line_items, WC_Order $order )
  7. public fill_missing_refund_totals( array $line_items, WC_Order $order )
  8. public get_preview_line_items_arg_schema()
  9. public normalize_refund_totals( array $line_items )
  10. public prepare_refund_creation_or_error( WC_Order $order, array $line_items, bool $has_amount_override, $amount_override, string $log_source )
  11. public validate_line_items( $line_items, WC_Order $order )
  12. public validate_preview_line_items( array $line_items, WC_Order $order )
  13. private compute_quantity_refund_total( $item, int $quantity, array $refund_data )
  14. protected convert_line_item_taxes_to_internal_format( $line_item_taxes )
  15. protected convert_proportional_taxes_to_schema_format( array $calculated_taxes )
  16. private line_refund_history_matches_quantities( WC_Order_Item_Product $item, array $line_refunds )
  17. protected split_inclusive_by_stored_ratio( float $amount, $item, int $dp )

Notes

Package: Automattic\WooCommerce\Internal\RestApi\Routes\V4\Refunds

DataUtils{} code WC 11.1.1

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

				$item_total_with_tax = abs( $signed_line_total );
				$abs_refund_total    = abs( $line_refund_gross );

				// 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.
				$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 )
					);
				}
			}

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

	/**
	 * 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    = abs( $refund_total );
				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 );
			}
		};

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