From 9903b49ac9335bb26944320efc78fe09940e47fb Mon Sep 17 00:00:00 2001 From: sultann Date: Mon, 20 Apr 2026 16:42:34 +0600 Subject: [PATCH] Add opt-in decimal quantity support --- includes/Admin/MetaBoxes.php | 15 ++++--- includes/Admin/Settings.php | 82 ++++++++++++++++++++++++------------ includes/Cart.php | 80 +++++++++++++++++------------------ includes/Plugin.php | 5 +++ includes/functions.php | 75 ++++++++++++++++++++++++++++----- 5 files changed, 174 insertions(+), 83 deletions(-) diff --git a/includes/Admin/MetaBoxes.php b/includes/Admin/MetaBoxes.php index 916f2db..b8421cd 100644 --- a/includes/Admin/MetaBoxes.php +++ b/includes/Admin/MetaBoxes.php @@ -82,12 +82,15 @@ public static function write_tab_options() { woocommerce_wp_text_input( array( - 'id' => '_wcmmq_step', - 'label' => __( 'Quantity step', 'wc-min-max-quantities' ), - 'description' => __( 'Enter a number that will increment or decrement every time a quantity is changed for this product.', 'wc-min-max-quantities' ), - 'desc_tip' => true, - 'type' => 'number', - 'min' => '0', + 'id' => '_wcmmq_step', + 'label' => __( 'Quantity step', 'wc-min-max-quantities' ), + 'description' => __( 'Enter a number that will increment or decrement every time a quantity is changed for this product.', 'wc-min-max-quantities' ), + 'desc_tip' => true, + 'type' => 'number', + 'custom_attributes' => array( + 'step' => 'any', + 'min' => '0', + ), ) ); diff --git a/includes/Admin/Settings.php b/includes/Admin/Settings.php index 414390a..5d4c7e2 100644 --- a/includes/Admin/Settings.php +++ b/includes/Admin/Settings.php @@ -47,27 +47,47 @@ public function get_settings( $tab ) { ), // set the minimum quantity. array( - 'title' => __( 'Minimum quantity', 'wc-min-max-quantities' ), - 'desc' => __( 'Set minimum quantity for each product. Keep it blank if you don’t want to set any rule for this.', 'wc-min-max-quantities' ), - 'id' => 'wcmmq_min_qty', - 'default' => 0, - 'type' => 'number', + 'title' => __( 'Minimum quantity', 'wc-min-max-quantities' ), + 'desc' => __( 'Set minimum quantity for each product. Keep it blank if you don’t want to set any rule for this.', 'wc-min-max-quantities' ), + 'id' => 'wcmmq_min_qty', + 'default' => 0, + 'type' => 'number', + 'custom_attributes' => array( + 'step' => 'any', + 'min' => '0', + ), ), // set the maximum quantity. array( - 'title' => __( 'Maximum quantity', 'wc-min-max-quantities' ), - 'desc' => __( 'Set maximum quantity for each product. Keep it blank if you don’t want to set any rule for this.', 'wc-min-max-quantities' ), - 'id' => 'wcmmq_max_qty', - 'default' => 0, - 'type' => 'number', + 'title' => __( 'Maximum quantity', 'wc-min-max-quantities' ), + 'desc' => __( 'Set maximum quantity for each product. Keep it blank if you don’t want to set any rule for this.', 'wc-min-max-quantities' ), + 'id' => 'wcmmq_max_qty', + 'default' => 0, + 'type' => 'number', + 'custom_attributes' => array( + 'step' => 'any', + 'min' => '0', + ), ), // Quantity step. array( - 'title' => __( 'Quantity step', 'wc-min-max-quantities' ), - 'desc' => __( 'Each time the quantity is changed, it will be increased or decreased by this value. Keep it blank if you don’t want to set any rule for this.', 'wc-min-max-quantities' ), - 'id' => 'wcmmq_step', - 'default' => 0, - 'type' => 'number', + 'title' => __( 'Quantity step', 'wc-min-max-quantities' ), + 'desc' => __( 'Each time the quantity is changed, it will be increased or decreased by this value. Keep it blank if you don’t want to set any rule for this.', 'wc-min-max-quantities' ), + 'id' => 'wcmmq_step', + 'default' => 0, + 'type' => 'number', + 'custom_attributes' => array( + 'step' => 'any', + 'min' => '0', + ), + ), + // Allow decimal quantities. + array( + 'title' => __( 'Allow decimal quantities', 'wc-min-max-quantities' ), + 'desc' => __( 'Allow products and cart items to use fractional quantities (e.g., 0.5, 1.25). Some third-party integrations, reports, and stock sync tools may assume whole numbers — test on staging before enabling in production.', 'wc-min-max-quantities' ), + 'id' => 'wcmmq_decimal_quantities', + 'default' => 'no', + 'type' => 'checkbox', ), // end product restrictions section. array( @@ -81,20 +101,28 @@ public function get_settings( $tab ) { 'desc' => __( 'Set the minimum and maximum limits for the order. Restrictions will be applied to the order total.', 'wc-min-max-quantities' ), ), array( - 'title' => esc_html__( 'Minimum quantity', 'wc-min-max-quantities' ), - 'desc' => __( 'Set minimum quantity for the order. Keep it blank if you don’t want to set any rule for this.', 'wc-min-max-quantities' ), - 'desc_tip' => __( 'This will be calculated by adding the quantity of all products in the cart.', 'wc-min-max-quantities' ), - 'id' => 'wcmmq_min_cart_qty', - 'default' => 0, - 'type' => 'number', + 'title' => esc_html__( 'Minimum quantity', 'wc-min-max-quantities' ), + 'desc' => __( 'Set minimum quantity for the order. Keep it blank if you don’t want to set any rule for this.', 'wc-min-max-quantities' ), + 'desc_tip' => __( 'This will be calculated by adding the quantity of all products in the cart.', 'wc-min-max-quantities' ), + 'id' => 'wcmmq_min_cart_qty', + 'default' => 0, + 'type' => 'number', + 'custom_attributes' => array( + 'step' => 'any', + 'min' => '0', + ), ), array( - 'title' => esc_html__( 'Maximum quantity', 'wc-min-max-quantities' ), - 'desc' => __( 'Set maximum quantity for the order. Keep it blank if you don’t want to set any rule for this.', 'wc-min-max-quantities' ), - 'desc_tip' => __( 'This will be calculated by adding the quantity of all products in the cart.', 'wc-min-max-quantities' ), - 'id' => 'wcmmq_max_cart_qty', - 'default' => 0, - 'type' => 'number', + 'title' => esc_html__( 'Maximum quantity', 'wc-min-max-quantities' ), + 'desc' => __( 'Set maximum quantity for the order. Keep it blank if you don’t want to set any rule for this.', 'wc-min-max-quantities' ), + 'desc_tip' => __( 'This will be calculated by adding the quantity of all products in the cart.', 'wc-min-max-quantities' ), + 'id' => 'wcmmq_max_cart_qty', + 'default' => 0, + 'type' => 'number', + 'custom_attributes' => array( + 'step' => 'any', + 'min' => '0', + ), ), array( 'title' => esc_html__( 'Minimum total', 'wc-min-max-quantities' ), diff --git a/includes/Cart.php b/includes/Cart.php index ef6a185..16ee083 100644 --- a/includes/Cart.php +++ b/includes/Cart.php @@ -199,7 +199,7 @@ public static function set_quantity_args( $data, $product ) { $limits = wcmmq_get_product_limits( $product_id, $variation_id ); if ( $limits['min_qty'] > 0 ) { - if ( $product->managing_stock() && ! $product->backorders_allowed() && absint( $limits['min_qty'] ) > $product->get_stock_quantity() ) { + if ( $product->managing_stock() && ! $product->backorders_allowed() && $limits['min_qty'] > $product->get_stock_quantity() ) { $data['min_value'] = $product->get_stock_quantity(); } else { @@ -212,7 +212,7 @@ public static function set_quantity_args( $data, $product ) { if ( $product->managing_stock() && $product->backorders_allowed() ) { $data['max_value'] = $limits['max_qty']; - } elseif ( $product->managing_stock() && absint( $limits['max_qty'] ) > $product->get_stock_quantity() ) { + } elseif ( $product->managing_stock() && $limits['max_qty'] > $product->get_stock_quantity() ) { $data['max_value'] = $product->get_stock_quantity(); } else { @@ -221,9 +221,9 @@ public static function set_quantity_args( $data, $product ) { } if ( $limits['step'] > 0 ) { - $data['step'] = 1; - // If both minimum and maximum quantity are set, make sure both are equally divisible by group of quantity. - if ( ( empty( $limits['max_qty'] ) || absint( $limits['max_qty'] ) % absint( $limits['step'] ) === 0 ) && ( empty( $limits['min_qty'] ) || absint( $limits['min_qty'] ) % absint( $limits['step'] ) === 0 ) ) { + $data['step'] = wcmmq_is_integer_qty() ? 1 : 'any'; + // Only expose the configured step when both limits are divisible by it, otherwise fall back to a permissive step. + if ( ( empty( $limits['max_qty'] ) || wcmmq_is_valid_step( $limits['max_qty'], $limits['step'] ) ) && ( empty( $limits['min_qty'] ) || wcmmq_is_valid_step( $limits['min_qty'], $limits['step'] ) ) ) { $data['step'] = $limits['step']; } } @@ -270,23 +270,23 @@ public static function add_to_cart_validation( $pass, $product_id, $quantity, $v } if ( $product_limits['max_qty'] > 0 && ( $total_quantity > $product_limits['max_qty'] ) ) { - /* translators: %1$s: Product name, %2$d: Maximum quantity */ - $message = sprintf( __( 'The maximum allowed quantity for %1$s is %2$s.', 'wc-min-max-quantities' ), esc_html( $product->get_formatted_name() ), number_format( $product_limits['max_qty'] ) ); + /* translators: %1$s: Product name, %2$s: Maximum quantity */ + $message = sprintf( __( 'The maximum allowed quantity for %1$s is %2$s.', 'wc-min-max-quantities' ), esc_html( $product->get_formatted_name() ), wcmmq_format_qty( $product_limits['max_qty'] ) ); wcmmq_add_cart_notice( $message ); return false; } if ( ! wcmmq_is_allow_combination( $product_id ) && $product_limits['min_qty'] > 0 && $total_quantity < $product_limits['min_qty'] ) { - /* translators: %1$s: Product name, %2$d: Minimum quantity */ - wcmmq_add_cart_notice( sprintf( __( 'The minimum required quantity for %1$s is %2$s.', 'wc-min-max-quantities' ), $product->get_formatted_name(), number_format( $product_limits['min_qty'] ) ) ); + /* translators: %1$s: Product name, %2$s: Minimum quantity */ + wcmmq_add_cart_notice( sprintf( __( 'The minimum required quantity for %1$s is %2$s.', 'wc-min-max-quantities' ), $product->get_formatted_name(), wcmmq_format_qty( $product_limits['min_qty'] ) ) ); return false; } - if ( ! wcmmq_is_allow_combination( $product_id ) && $product_limits['step'] > 0 && ( (int) $quantity % (int) $product_limits['step'] > 0 ) ) { - /* translators: %1$s: Product name, %2$d: Group amount */ - wcmmq_add_cart_notice( sprintf( __( 'The quantity of %1$s must be purchased in groups of %2$s.', 'wc-min-max-quantities' ), $product->get_formatted_name(), $product_limits['step'], $product_limits['step'] - ( $quantity % $product_limits['step'] ) ) ); + if ( ! wcmmq_is_allow_combination( $product_id ) && ! wcmmq_is_valid_step( $quantity, $product_limits['step'] ) ) { + /* translators: %1$s: Product name, %2$s: Group amount */ + wcmmq_add_cart_notice( sprintf( __( 'The quantity of %1$s must be purchased in groups of %2$s.', 'wc-min-max-quantities' ), $product->get_formatted_name(), wcmmq_format_qty( $product_limits['step'] ) ) ); return false; } @@ -295,8 +295,8 @@ public static function add_to_cart_validation( $pass, $product_id, $quantity, $v $cart_limits = wcmmq_get_cart_limits(); if ( $cart_limits['max_qty'] > 1 && WC()->cart->cart_contents_count > $cart_limits['max_qty'] ) { - /* translators: %d: Maximum quantity */ - wcmmq_add_cart_notice( sprintf( __( 'The maximum allowed order quantity is %s.', 'wc-min-max-quantities' ), number_format( $cart_limits['max_qty'] ) ) ); + /* translators: %s: Maximum quantity */ + wcmmq_add_cart_notice( sprintf( __( 'The maximum allowed order quantity is %s.', 'wc-min-max-quantities' ), wcmmq_format_qty( $cart_limits['max_qty'] ) ) ); return false; } @@ -358,8 +358,8 @@ public static function check_cart_items() { $quantity = ! empty( $quantities[ $product_id ] ) ? $quantities[ $product_id ] : 0; if ( $product_limits['max_qty'] > 0 && ( $quantity > $product_limits['max_qty'] ) ) { - /* translators: %1$s: Product name, %2$d: Maximum quantity */ - $message = sprintf( __( 'The maximum allowed quantity for %1$s is %2$s.', 'wc-min-max-quantities' ), esc_html( $product->get_title() ), number_format( $product_limits['max_qty'] ) ); + /* translators: %1$s: Product name, %2$s: Maximum quantity */ + $message = sprintf( __( 'The maximum allowed quantity for %1$s is %2$s.', 'wc-min-max-quantities' ), esc_html( $product->get_title() ), wcmmq_format_qty( $product_limits['max_qty'] ) ); remove_action( 'woocommerce_proceed_to_checkout', 'woocommerce_button_proceed_to_checkout', 20 ); wcmmq_add_cart_notice( $message ); @@ -367,15 +367,15 @@ public static function check_cart_items() { } if ( $product_limits['min_qty'] > 0 && $quantity < $product_limits['min_qty'] ) { - /* translators: %1$s: Product name, %2$d: Minimum quantity */ - wcmmq_add_cart_notice( sprintf( __( 'The minimum required quantity for %1$s is %2$s.', 'wc-min-max-quantities' ), esc_html( $product->get_formatted_name() ), number_format( $product_limits['min_qty'] ) ) ); + /* translators: %1$s: Product name, %2$s: Minimum quantity */ + wcmmq_add_cart_notice( sprintf( __( 'The minimum required quantity for %1$s is %2$s.', 'wc-min-max-quantities' ), esc_html( $product->get_formatted_name() ), wcmmq_format_qty( $product_limits['min_qty'] ) ) ); remove_action( 'woocommerce_proceed_to_checkout', 'woocommerce_button_proceed_to_checkout', 20 ); return; } - if ( $product_limits['step'] > 0 && ( (float) $quantity % (float) $product_limits['step'] > 0 ) ) { - /* translators: %1$s: Product name, %2$d: quantity amount */ - wcmmq_add_cart_notice( sprintf( __( '%1$s must be bought in groups of %2$s. Please increase or decrease the quantity to continue.', 'wc-min-max-quantities' ), $product->get_formatted_name(), $product_limits['step'], $product_limits['step'] - ( $quantity % $product_limits['step'] ) ) ); + if ( ! wcmmq_is_valid_step( $quantity, $product_limits['step'] ) ) { + /* translators: %1$s: Product name, %2$s: quantity amount */ + wcmmq_add_cart_notice( sprintf( __( '%1$s must be bought in groups of %2$s. Please increase or decrease the quantity to continue.', 'wc-min-max-quantities' ), $product->get_formatted_name(), wcmmq_format_qty( $product_limits['step'] ) ) ); remove_action( 'woocommerce_proceed_to_checkout', 'woocommerce_button_proceed_to_checkout', 20 ); return; @@ -386,25 +386,25 @@ public static function check_cart_items() { $order_total = array_sum( array_values( $line_amount ) ); $cart_limits = wcmmq_get_cart_limits(); - if ( (int) $cart_limits['min_qty'] > 0 && $order_quantity < (int) $cart_limits['min_qty'] ) { - /* translators: %d: Minimum amount of items in the cart */ - wcmmq_add_cart_notice( sprintf( __( 'The minimum required quantity in the cart is %s. Please consider increasing the quantity in your cart.', 'wc-min-max-quantities' ), (int) $cart_limits['min_qty'] ) ); + if ( $cart_limits['min_qty'] > 0 && $order_quantity < $cart_limits['min_qty'] ) { + /* translators: %s: Minimum amount of items in the cart */ + wcmmq_add_cart_notice( sprintf( __( 'The minimum required quantity in the cart is %s. Please consider increasing the quantity in your cart.', 'wc-min-max-quantities' ), wcmmq_format_qty( $cart_limits['min_qty'] ) ) ); remove_action( 'woocommerce_proceed_to_checkout', 'woocommerce_button_proceed_to_checkout', 20 ); return; } - if ( (int) $cart_limits['max_qty'] > 0 && $order_quantity > (int) $cart_limits['max_qty'] ) { - /* translators: %d: Maximum amount of items in the cart */ - wcmmq_add_cart_notice( sprintf( __( 'The maximum allowed order quantity is %s. Please reduce the quantity in your cart.', 'wc-min-max-quantities' ), (int) $cart_limits['max_qty'] ) ); + if ( $cart_limits['max_qty'] > 0 && $order_quantity > $cart_limits['max_qty'] ) { + /* translators: %s: Maximum amount of items in the cart */ + wcmmq_add_cart_notice( sprintf( __( 'The maximum allowed order quantity is %s. Please reduce the quantity in your cart.', 'wc-min-max-quantities' ), wcmmq_format_qty( $cart_limits['max_qty'] ) ) ); remove_action( 'woocommerce_proceed_to_checkout', 'woocommerce_button_proceed_to_checkout', 20 ); return; } - if ( (int) $cart_limits['min_total'] > 0 && $order_total < (int) $cart_limits['min_total'] ) { - /* translators: %d: Minimum amount of items in the cart */ + if ( $cart_limits['min_total'] > 0 && $order_total < $cart_limits['min_total'] ) { + /* translators: %s: Minimum amount of items in the cart */ wcmmq_add_cart_notice( sprintf( __( 'The minimum allowed order total value is %s. Please consider increasing the quantity in your cart.', 'wc-min-max-quantities' ), wc_price( $cart_limits['min_total'] ) ) ); remove_action( 'woocommerce_proceed_to_checkout', 'woocommerce_button_proceed_to_checkout', 20 ); @@ -412,8 +412,8 @@ public static function check_cart_items() { } - if ( (int) $cart_limits['max_total'] > 0 && $order_total > (int) $cart_limits['max_total'] ) { - /* translators: %d: Maximum amount of items in the cart */ + if ( $cart_limits['max_total'] > 0 && $order_total > $cart_limits['max_total'] ) { + /* translators: %s: Maximum amount of items in the cart */ wcmmq_add_cart_notice( sprintf( __( 'The maximum allowed order total value is %s. Please reduce the quantity in your cart.', 'wc-min-max-quantities' ), wc_price( $cart_limits['max_total'] ) ) ); remove_action( 'woocommerce_proceed_to_checkout', 'woocommerce_button_proceed_to_checkout', 20 ); @@ -433,7 +433,7 @@ public static function set_cart_quantity( $product_id ) { if ( 'add_to_cart' !== $add_to_cart ) { return $product_id; } - $quantity = isset( $_POST['quantity'] ) ? absint( $_POST['quantity'] ) : 1; + $quantity = isset( $_POST['quantity'] ) ? wc_stock_amount( wp_unslash( $_POST['quantity'] ) ) : 1; // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized if ( empty( $quantity ) ) { return $quantity; } @@ -470,9 +470,9 @@ public static function set_cart_quantity( $product_id ) { return $product_id; } - $remainder = $quantity % $product_limits['step']; + $remainder = wcmmq_is_integer_qty() ? ( absint( $quantity ) % absint( $product_limits['step'] ) ) : fmod( (float) $quantity, (float) $product_limits['step'] ); - if ( 0 === $remainder ) { + if ( abs( $remainder ) < 0.000001 ) { $_REQUEST['quantity'] = $product_limits['step']; } else { $_REQUEST['quantity'] = $product_limits['step'] - $remainder; @@ -542,7 +542,7 @@ public static function available_variation( $data, $product, $variation ) { $product_limits = wcmmq_get_product_limits( $product->get_id(), $variation->get_id() ); if ( ! empty( $product_limits['min_qty'] ) ) { - if ( $product->managing_stock() && $product->backorders_allowed() && absint( $product_limits['min_qty'] ) > $product->get_stock_quantity() ) { + if ( $product->managing_stock() && $product->backorders_allowed() && $product_limits['min_qty'] > $product->get_stock_quantity() ) { $data['min_qty'] = $product->get_stock_quantity(); } else { @@ -555,7 +555,7 @@ public static function available_variation( $data, $product, $variation ) { if ( $product->managing_stock() && $product->backorders_allowed() ) { $data['max_qty'] = $product_limits['max_qty']; - } elseif ( $product->managing_stock() && absint( $product_limits['max_qty'] ) > $product->get_stock_quantity() ) { + } elseif ( $product->managing_stock() && $product_limits['max_qty'] > $product->get_stock_quantity() ) { $data['max_qty'] = $product->get_stock_quantity(); } else { @@ -564,13 +564,13 @@ public static function available_variation( $data, $product, $variation ) { } if ( ! empty( $product_limits['step'] ) ) { - $data['step'] = 1; - // If both minimum and maximum quantity are set, make sure both are equally divisible by quantity step of quantity. + $data['step'] = wcmmq_is_integer_qty() ? 1 : 'any'; + // Only expose the configured step when the limits line up cleanly against it. if ( $product_limits['max_qty'] && $product_limits['min_qty'] ) { - if ( absint( $product_limits['max_qty'] ) % absint( $product_limits['min_qty'] ) === 0 && absint( $product_limits['max_qty'] ) % absint( $product_limits['step'] ) === 0 ) { + if ( wcmmq_is_valid_step( $product_limits['max_qty'], $product_limits['min_qty'] ) && wcmmq_is_valid_step( $product_limits['max_qty'], $product_limits['step'] ) ) { $data['step'] = $product_limits['step']; } - } elseif ( ! $product_limits['max_qty'] || absint( $product_limits['max_qty'] ) % absint( $product_limits['step'] ) === 0 ) { + } elseif ( ! $product_limits['max_qty'] || wcmmq_is_valid_step( $product_limits['max_qty'], $product_limits['step'] ) ) { $data['step'] = $product_limits['step']; } diff --git a/includes/Plugin.php b/includes/Plugin.php index 45fd6c7..c5d3aed 100644 --- a/includes/Plugin.php +++ b/includes/Plugin.php @@ -36,6 +36,11 @@ protected function bootstrap(): void { // Redis/Memcached/etc. would leak one user's limits to another. wp_cache_add_non_persistent_groups( array( 'wc-min-max-quantities' ) ); + if ( 'yes' === get_option( 'wcmmq_decimal_quantities', 'no' ) ) { + remove_filter( 'woocommerce_stock_amount', 'intval' ); + add_filter( 'woocommerce_stock_amount', 'floatval' ); + } + register_activation_hook( $this->file, array( Installer::class, 'install' ) ); add_filter( 'plugin_action_links_' . $this->basename(), array( $this, 'plugin_action_links' ) ); add_filter( 'plugin_row_meta', array( $this, 'plugin_row_meta' ), 10, 2 ); diff --git a/includes/functions.php b/includes/functions.php index 7fe537b..5638d09 100644 --- a/includes/functions.php +++ b/includes/functions.php @@ -14,6 +14,61 @@ function wc_min_max_quantities() { // phpcs:ignore WordPress.NamingConventions.V return Plugin::instance(); } +/** + * Whether WooCommerce is currently treating stock quantities as integers. + * + * Wraps wc_is_stock_amount_integer() (WC 10.1+) with a safe fallback to true for + * older WooCommerce versions that don't expose the helper. + * + * @since 2.3.0 + * @return bool + */ +function wcmmq_is_integer_qty() { + return ! function_exists( 'wc_is_stock_amount_integer' ) || wc_is_stock_amount_integer(); +} + +/** + * Format a quantity for display in user-facing messages. + * + * Uses localized integer formatting when WooCommerce is in integer mode, otherwise + * preserves decimal precision and trims trailing zeros. + * + * @since 2.3.0 + * @param int|float $qty The quantity to format. + * @return string + */ +function wcmmq_format_qty( $qty ) { + if ( wcmmq_is_integer_qty() ) { + return number_format_i18n( (int) $qty ); + } + + return wc_trim_zeros( (string) (float) $qty ); +} + +/** + * Check whether a quantity is a valid multiple of a given step. + * + * Uses integer modulo when WooCommerce is in integer mode, and `fmod` with a + * floating-point epsilon tolerance otherwise. Returns true when the step is + * zero or negative so callers can skip the check for products without a step. + * + * @since 2.3.0 + * @param int|float $qty The quantity to check. + * @param int|float $step The step value. + * @return bool + */ +function wcmmq_is_valid_step( $qty, $step ) { + if ( $step <= 0 ) { + return true; + } + + if ( wcmmq_is_integer_qty() ) { + return absint( $qty ) % absint( $step ) === 0; + } + + return abs( fmod( (float) $qty, (float) $step ) ) < 0.000001; +} + /** * Check if the product is excluded from min/max quantity. * @@ -48,16 +103,16 @@ function wcmmq_get_product_limits( $product_id, $variation_id = 0 ) { if ( $override ) { $limits = array( - 'step' => (int) get_post_meta( $product->get_id(), '_wcmmq_step', true ), - 'min_qty' => (int) get_post_meta( $product->get_id(), '_wcmmq_min_qty', true ), - 'max_qty' => (int) get_post_meta( $product->get_id(), '_wcmmq_max_qty', true ), + 'step' => (float) get_post_meta( $product->get_id(), '_wcmmq_step', true ), + 'min_qty' => (float) get_post_meta( $product->get_id(), '_wcmmq_min_qty', true ), + 'max_qty' => (float) get_post_meta( $product->get_id(), '_wcmmq_max_qty', true ), 'rule' => 'product', ); } else { $limits = array( - 'step' => (int) get_option( 'wcmmq_step', 1 ), - 'min_qty' => (int) get_option( 'wcmmq_min_qty', 1 ), - 'max_qty' => (int) get_option( 'wcmmq_max_qty', 0 ), + 'step' => (float) get_option( 'wcmmq_step', 1 ), + 'min_qty' => (float) get_option( 'wcmmq_min_qty', 1 ), + 'max_qty' => (float) get_option( 'wcmmq_max_qty', 0 ), 'rule' => 'global', ); } @@ -79,10 +134,10 @@ function wcmmq_get_cart_limits() { $limits = apply_filters( 'wc_min_max_quantities_cart_limits', array( - 'min_qty' => (int) get_option( 'wcmmq_min_cart_qty' ), - 'max_qty' => (int) get_option( 'wcmmq_max_cart_qty' ), - 'min_total' => (int) get_option( 'wcmmq_min_cart_total' ), - 'max_total' => (int) get_option( 'wcmmq_max_cart_total' ), + 'min_qty' => (float) get_option( 'wcmmq_min_cart_qty' ), + 'max_qty' => (float) get_option( 'wcmmq_max_cart_qty' ), + 'min_total' => (float) get_option( 'wcmmq_min_cart_total' ), + 'max_total' => (float) get_option( 'wcmmq_max_cart_total' ), ) );