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

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