declare( strict_types = 1 );
namespace Automattic\WooCommerce\StoreApi\Utilities;
use Automattic\WooCommerce\Checkout\Helpers\ReserveStock;
use Automattic\WooCommerce\StoreApi\Utilities\DraftOrderTrait;
use Automattic\WooCommerce\Utilities\NumberUtil;
* Returns limits for products and cart items when using the StoreAPI and supporting classes.
final class QuantityLimits {
* Get quantity limits (min, max, step/multiple) for a product or cart item.
* @param array $cart_item A cart item array.
public function get_cart_item_quantity_limits( $cart_item ) {
$product = $cart_item['data'] ?? false;
if ( ! $product instanceof \WC_Product ) {
$this->get_add_to_cart_limits( $product, $cart_item ),
'editable' => $this->filter_boolean_value( ! $product->is_sold_individually(), 'editable', $product, $cart_item ),
* Get limits for product add to cart forms.
* @param \WC_Product $product Product instance.
* @param array|null $cart_item Optional cart item associated with the product.
public function get_add_to_cart_limits( \WC_Product $product, $cart_item = null ) {
// Compatibility with the woocommerce_quantity_input_args filter. Gets initial values to match classic quantity input.
$args = wc_get_quantity_input_args( [], $product );
$minimum = $this->filter_numeric_value( $args['min_value'], 'minimum', $product, $cart_item );
$maximum = $this->filter_numeric_value(
$this->adjust_product_quantity_limit( $args['max_value'], $product, $cart_item ),
$multiple_of = $this->filter_numeric_value( $args['step'], 'multiple_of', $product, $cart_item );
// Ensure values are compatible with each other.
$minimum = max( $multiple_of, $this->limit_to_multiple( $minimum, $multiple_of, 'ceil' ) );
$maximum = max( $minimum, $this->limit_to_multiple( $maximum, $multiple_of, 'floor' ) );
'multiple_of' => $multiple_of,
* Fix a quantity violation by adjusting it to the nearest valid quantity.
* @param int|float $quantity Quantity.
* @param array $cart_item Cart item.
public function normalize_cart_item_quantity( $quantity, array $cart_item ) {
$product = $cart_item['data'] ?? false;
if ( ! $product instanceof \WC_Product ) {
return wc_stock_amount( $quantity );
$quantity = NumberUtil::normalize( $quantity );
return wc_stock_amount( 0 );
$limits = $this->get_cart_item_quantity_limits( $cart_item );
$new_quantity = $this->limit_to_multiple( $quantity, $limits['multiple_of'], 'round' );
if ( $new_quantity < $limits['minimum'] ) {
$new_quantity = $limits['minimum'];
if ( $new_quantity > $limits['maximum'] ) {
$new_quantity = $limits['maximum'];
return wc_stock_amount( $new_quantity );
* Return a number using the closest multiple of another number. Used to enforce step/multiple values.
* @param int|float $number Number to round.
* @param int|float $multiple_of The multiple.
* @param string $rounding_function ceil, floor, or round.
public function limit_to_multiple( $number, $multiple_of, string $rounding_function = 'round' ) {
$number = NumberUtil::normalize( $number, null );
$multiple_of = NumberUtil::normalize( $multiple_of, null );
if ( is_null( $multiple_of ) || is_null( $number ) ) {
if ( 0 >= $multiple_of || $this->is_multiple_of( $number, $multiple_of ) ) {
// Ensure valid rounding function.
$rounding_function = in_array( $rounding_function, [ 'ceil', 'floor', 'round' ], true ) ? $rounding_function : 'round';
return NumberUtil::normalize( $rounding_function( $number / $multiple_of ) * $multiple_of );
* Checks if a number is a multiple of another number.
* @param int|float $number The number to check.
* @param int|float $multiple_of The multiple.
protected function is_multiple_of( $number, $multiple_of ) {
if ( 0 >= $multiple_of ) {
$division_result = $number / $multiple_of;
// Use tolerance for floating-point comparison to handle precision errors.
// Example: 0.3 / 0.1 = 2.9999999999999996 instead of exactly 3.0 due to floating-point precision.
return abs( $division_result - round( $division_result ) ) < 0.0001;
* Check that a given quantity is valid according to any limits in place.
* @param int|float $quantity Quantity to validate.
* @param array $cart_item Cart item.
public function validate_cart_item_quantity( $quantity, $cart_item ) {
$limits = $this->get_cart_item_quantity_limits( $cart_item );
$product = $cart_item['data'] ?? false;
$quantity = wc_stock_amount( $quantity );
if ( ! $product instanceof \WC_Product ) {
if ( ! $limits['editable'] && $quantity > $limits['maximum'] ) {
/* translators: 1: product name */
return new \WP_Error( 'readonly_quantity', sprintf( __( 'The quantity of "%1$s" cannot be changed', 'woocommerce' ), $product->get_name() ) );
if ( $quantity < $limits['minimum'] ) {
/* translators: 1: product name 2: minimum quantity */
return new \WP_Error( 'invalid_quantity', sprintf( __( 'The minimum quantity of "%1$s" allowed in the cart is %2$s', 'woocommerce' ), $product->get_name(), $limits['minimum'] ) );
if ( $quantity > $limits['maximum'] ) {
/* translators: 1: product name 2: maximum quantity */
return new \WP_Error( 'invalid_quantity', sprintf( __( 'The maximum quantity of "%1$s" allowed in the cart is %2$s', 'woocommerce' ), $product->get_name(), $limits['maximum'] ) );
if ( ! $this->is_multiple_of( $quantity, NumberUtil::normalize( $limits['multiple_of'] ) ) ) {
/* translators: 1: product name 2: multiple of */
return new \WP_Error( 'invalid_quantity', sprintf( __( 'The quantity of "%1$s" must be a multiple of %2$s', 'woocommerce' ), $product->get_name(), $limits['multiple_of'] ) );
* Get the limit for the total number of a product allowed in the cart.
* This is based on product properties, including remaining stock, and defaults to a maximum of 9999 of any product
* @param int|float $purchase_limit The purchase limit from the product. Usually maps to `get_max_purchase_quantity`.
* @param \WC_Product $product Product instance.
* @param array|null $cart_item Optional cart item associated with the product.
protected function adjust_product_quantity_limit( $purchase_limit, \WC_Product $product, $cart_item = null ) {
$limits = [ $purchase_limit > 0 ? $purchase_limit : 9999 ];
// If managing stock and backorders are not allowed, get the remaining stock considering active carts.
if ( $product->managing_stock() && ! $product->backorders_allowed() ) {
$limits[] = $this->get_remaining_stock( $product );
return $this->filter_numeric_value( min( array_filter( $limits ) ), 'limit', $product, $cart_item );
* Returns the remaining stock for a product if it has stock.
* This also factors in draft orders.
* @param \WC_Product $product Product instance.
protected function get_remaining_stock( \WC_Product $product ) {
if ( is_null( $product->get_stock_quantity() ) ) {
$reserve_stock = new ReserveStock();
$reserved_stock = $reserve_stock->get_reserved_stock( $product, $this->get_draft_order_id() );
return wc_stock_amount( $product->get_stock_quantity() - $reserved_stock );
* Get a numeric value while running it through a filter hook.
* @param int|float $value Value to filter.
* @param string $value_type Type of value. Used for filter suffix.
* @param \WC_Product $product Product instance.
* @param array|null $cart_item Optional cart item associated with the product.
protected function filter_numeric_value( $value, string $value_type, \WC_Product $product, $cart_item = null ) {
* Filters a quantity for a cart item in Store API. This allows extensions to control the qty of items.
* The suffix of the hook will vary depending on the value being filtered.
* For example, minimum, maximum, multiple_of, editable.
* @param mixed $value The value being filtered.
* @param \WC_Product $product The product object.
* @param array|null $cart_item The cart item if the product exists in the cart, or null.
$filtered_value = apply_filters( 'woocommerce_store_api_product_quantity_' . $value_type, $value, $product, $cart_item );
return wc_stock_amount( NumberUtil::normalize( $filtered_value, $value ) );
* Get a boolean value while running it through a filter hook.
* @param bool $value Value to filter.
* @param string $value_type Type of value. Used for filter suffix.
* @param \WC_Product $product Product instance.
* @param array|null $cart_item Optional cart item associated with the product.
protected function filter_boolean_value( $value, string $value_type, \WC_Product $product, $cart_item = null ) {
* Filters boolean data for a cart item in Store API.
* The suffix of the hook will vary depending on the value being filtered. For example, editable.
* @param mixed $value The value being filtered.
* @param \WC_Product $product The product object.
* @param array|null $cart_item The cart item if the product exists in the cart, or null.
$filtered_value = apply_filters( 'woocommerce_store_api_product_quantity_' . $value_type, $value, $product, $cart_item );
return is_bool( $filtered_value ) ? $filtered_value : (bool) $value;