Automattic\WooCommerce\Internal\RestApi\Routes\V4\Refunds
DataUtils::validate_preview_line_items │ public │ WC 10.9.0
Validate line items for a preview request.
Method of the class: DataUtils{}
No Hooks.
Returns
true|WP_Error. True on success, WP_Error on failure.
Usage
$DataUtils = new DataUtils(); $DataUtils->validate_preview_line_items( $line_items, $order );
- $line_items(array) (required)
- The line items to validate.
- $order(WC_Order) (required)
- The order object.
Changelog
| Since 10.9.0 | Introduced. |
DataUtils::validate_preview_line_items() DataUtils::validate preview line items code WC 11.0.1
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.
$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 )
);
}
}
// 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 cap: when the amount is derived from quantity (no explicit
// refund_total), cap the computed tax-inclusive amount against the remaining line
// amount for every item type. Mirrors create, which auto-fills refund_total from
// quantity and then applies the same cap — so a product with prior amount-only
// refunds (units still uncounted) can no longer preview an over-refund.
if ( $has_quantity && ! $has_refund_total ) {
$refunded_total = abs( (float) ( $refund_data['totals'][ $line_item_id ] ?? 0.0 ) );
$remaining_total = abs( $signed_line_total ) - $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 )
);
}
$requested_total = abs( $this->compute_line_item_refund_total( $item, $line_item['quantity'] ) );
if ( $requested_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 )
);
}
}
}
return true;
}