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

DataUtils::validate_preview_line_itemspublicWC 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() 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;
}