Automattic\WooCommerce\Internal\RestApi\Routes\V4\Refunds
DataUtils::validate_line_items │ public │ WC 1.0
Validate line items (schema format) before conversion to internal format.
Method of the class: DataUtils{}
No Hooks.
Returns
true|false|WP_Error.
Usage
$DataUtils = new DataUtils(); $DataUtils->validate_line_items( $line_items, $order );
- $line_items(array) (required)
- The line items to validate.
- $order(WC_Order) (required)
- The order object.
DataUtils::validate_line_items() DataUtils::validate line items code WC 11.0.1
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.
$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' )
);
}
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
)
);
}
// 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'] ) ) {
foreach ( $line_item['refund_tax'] as $tax ) {
$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 )
);
}
$refunded_total = abs( (float) ( $refund_data['totals'][ $line_item_id ] ?? 0.0 ) );
$remaining_total = $item_total_with_tax - $refunded_total;
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 > NumberUtil::round( $remaining_total, $price_decimals ) ) {
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' ) );
}
$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 )
)
);
}
$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 ) ) {
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 )
)
);
}
}
}
}
}
return true;
}