Commit eca90038e50 for woocommerce

commit eca90038e509a2bc05307457e1f4045d9fd3559c
Author: Chris Lilitsas <1105590+xristos3490@users.noreply.github.com>
Date:   Thu Oct 15 21:02:24 2026 +0300

    Reject negative values for the unverified stock notification deletion threshold (#69505)

    * fix: treat negative BIS unverified sign-up deletion threshold as off

    * fix: unschedule BIS daily cleanup when the stored threshold is negative

    * fix: render the clamped BIS unverified deletion threshold so a stored negative value does not block saving

    With min="0" on the field, a stored negative value rendered as-is and failed browser validation. When double opt-in is off the row is hidden, so the form could not be submitted and no message was shown. Pass the clamped Config value as the field value.

diff --git a/plugins/woocommerce/changelog/fix-wooplug-7926-bis-threshold-negative b/plugins/woocommerce/changelog/fix-wooplug-7926-bis-threshold-negative
new file mode 100644
index 00000000000..8af2f69ba3b
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-wooplug-7926-bis-threshold-negative
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Save a negative "Delete unverified notification sign-ups after" value as 0, which turns the cleanup off.
diff --git a/plugins/woocommerce/src/Internal/StockNotifications/Admin/SettingsController.php b/plugins/woocommerce/src/Internal/StockNotifications/Admin/SettingsController.php
index 204957fe046..25ad5c412fd 100644
--- a/plugins/woocommerce/src/Internal/StockNotifications/Admin/SettingsController.php
+++ b/plugins/woocommerce/src/Internal/StockNotifications/Admin/SettingsController.php
@@ -26,6 +26,8 @@ class SettingsController {
 		// Add the My Account endpoint setting to the Advanced tab.
 		add_filter( 'woocommerce_get_settings_advanced', array( $this, 'add_my_account_endpoint_setting' ), 100, 2 );

+		add_filter( 'woocommerce_admin_settings_sanitize_option_woocommerce_customer_stock_notifications_unverified_deletions_days_threshold', array( $this, 'sanitize_unverified_deletion_days_threshold' ) );
+
 		// The product edit hooks stay admin-only: process_product_object() reads $_POST and
 		// checks an admin nonce, so it must not run for cron or CLI code that fires the hook.
 		if ( ! is_admin() ) {
@@ -174,11 +176,16 @@ class SettingsController {
 				),

 				array(
-					'title'   => __( 'Delete unverified notification sign-ups after (in days)', 'woocommerce' ),
-					'desc'    => __( 'Controls how long unverified notification sign-ups are stored in the database. Enter zero, or leave this field empty, to store unverified sign-up requests indefinitely.', 'woocommerce' ),
-					'id'      => 'woocommerce_customer_stock_notifications_unverified_deletions_days_threshold',
-					'default' => Config::get_unverified_deletion_days_threshold(),
-					'type'    => 'number',
+					'title'             => __( 'Delete unverified notification sign-ups after (in days)', 'woocommerce' ),
+					'desc'              => __( 'Controls how long unverified notification sign-ups are stored in the database. Enter zero, or leave this field empty, to store unverified sign-up requests indefinitely.', 'woocommerce' ),
+					'id'                => 'woocommerce_customer_stock_notifications_unverified_deletions_days_threshold',
+					'default'           => Config::get_unverified_deletion_days_threshold(),
+					'value'             => Config::get_unverified_deletion_days_threshold(),
+					'type'              => 'number',
+					'custom_attributes' => array(
+						'min'  => 0,
+						'step' => 1,
+					),
 				),

 				array(
@@ -202,6 +209,26 @@ class SettingsController {
 		return $settings;
 	}

+	/**
+	 * Store negative values of the unverified sign-up deletion threshold as 0, which turns the cleanup off.
+	 *
+	 * An empty value is kept as is, since it also means off.
+	 *
+	 * @internal
+	 *
+	 * @since 11.3.0
+	 *
+	 * @param mixed $value The value about to be saved.
+	 * @return mixed
+	 */
+	public function sanitize_unverified_deletion_days_threshold( $value ) {
+		if ( null === $value || '' === $value ) {
+			return $value;
+		}
+
+		return max( 0, (int) $value );
+	}
+
 	/**
 	 * Display admin notices about incompatible settings combinations.
 	 *
diff --git a/plugins/woocommerce/src/Internal/StockNotifications/Config.php b/plugins/woocommerce/src/Internal/StockNotifications/Config.php
index 978c9f81d44..d1f839254f0 100644
--- a/plugins/woocommerce/src/Internal/StockNotifications/Config.php
+++ b/plugins/woocommerce/src/Internal/StockNotifications/Config.php
@@ -181,8 +181,9 @@ class Config {
 	 * @return int
 	 */
 	public static function get_unverified_deletion_days_threshold(): int {
-		return absint(
-			get_option(
+		return max(
+			0,
+			(int) get_option(
 				'woocommerce_customer_stock_notifications_unverified_deletions_days_threshold',
 				0
 			)
diff --git a/plugins/woocommerce/src/Internal/StockNotifications/DataRetentionController.php b/plugins/woocommerce/src/Internal/StockNotifications/DataRetentionController.php
index 8348f2ec82b..caa3d1c2d60 100644
--- a/plugins/woocommerce/src/Internal/StockNotifications/DataRetentionController.php
+++ b/plugins/woocommerce/src/Internal/StockNotifications/DataRetentionController.php
@@ -38,14 +38,14 @@ class DataRetentionController {
 	/**
 	 * Responds to changes in the option for deleting unverified notifications.
 	 * If the new value is numeric and greater than zero, it schedules a daily task.
-	 * If the new value is not numeric or is empty, it clears the scheduled tasks.
+	 * If the new value is not numeric, or is zero or less, it clears the scheduled tasks.
 	 *
 	 * @param mixed $unused The old option value or option name (not used in this function).
 	 * @param mixed $new_option_value The new value of the option.
 	 * @return void
 	 */
 	public function schedule_or_unschedule_daily_task( $unused, $new_option_value ): void {
-		if ( ! is_numeric( $new_option_value ) || empty( $new_option_value ) ) {
+		if ( ! is_numeric( $new_option_value ) || (int) $new_option_value <= 0 ) {
 			$this->clear_daily_task();
 			return;
 		}
diff --git a/plugins/woocommerce/tests/php/src/Internal/StockNotifications/Admin/SettingsControllerTests.php b/plugins/woocommerce/tests/php/src/Internal/StockNotifications/Admin/SettingsControllerTests.php
index 053f3752124..f66062f9712 100644
--- a/plugins/woocommerce/tests/php/src/Internal/StockNotifications/Admin/SettingsControllerTests.php
+++ b/plugins/woocommerce/tests/php/src/Internal/StockNotifications/Admin/SettingsControllerTests.php
@@ -5,6 +5,7 @@ namespace Automattic\WooCommerce\Tests\Internal\StockNotifications\Admin;

 use Automattic\WooCommerce\Internal\StockNotifications\Admin\SettingsController;
 use Automattic\WooCommerce\Internal\StockNotifications\Config;
+use Automattic\WooCommerce\Internal\StockNotifications\DataRetentionController;
 use Automattic\WooCommerce\Internal\StockNotifications\Frontend\MyAccountEndpoint;
 use WC_Helper_Product;
 use WC_Settings_Advanced;
@@ -86,6 +87,7 @@ class SettingsControllerTests extends \WC_Settings_Unit_Test_Case {
 		$this->assertSame( 100, has_filter( 'woocommerce_get_sections_products', array( $sut, 'add_customer_stock_notifications_section' ) ) );
 		$this->assertSame( 100, has_filter( 'woocommerce_get_settings_products', array( $sut, 'add_customer_stock_notifications_settings' ) ) );
 		$this->assertSame( 100, has_filter( 'woocommerce_get_settings_advanced', array( $sut, 'add_my_account_endpoint_setting' ) ) );
+		$this->assertSame( 10, has_filter( 'woocommerce_admin_settings_sanitize_option_woocommerce_customer_stock_notifications_unverified_deletions_days_threshold', array( $sut, 'sanitize_unverified_deletion_days_threshold' ) ) );
 		$this->assertFalse( has_action( 'admin_notices', array( $sut, 'output_admin_notices' ) ) );
 		$this->assertFalse( has_action( 'woocommerce_product_options_stock_status', array( $sut, 'add_disable_stock_notifications_checkbox' ) ) );
 		$this->assertFalse( has_action( 'woocommerce_admin_process_product_object', array( $sut, 'process_product_object' ) ) );
@@ -226,6 +228,47 @@ class SettingsControllerTests extends \WC_Settings_Unit_Test_Case {
 		$this->assertSame( 'yes', $product->get_meta( $meta_key ) );
 	}

+	/**
+	 * @testdox Saving a negative unverified sign-up deletion threshold stores 0 and unschedules the cleanup.
+	 */
+	public function test_negative_unverified_deletion_days_threshold_is_saved_as_zero() {
+		$option_id            = 'woocommerce_customer_stock_notifications_unverified_deletions_days_threshold';
+		$retention_controller = new DataRetentionController();
+		update_option( $option_id, 30 );
+		$this->assertSame( 'daily', wp_get_schedule( DataRetentionController::DAILY_TASK_HOOK ), 'A positive threshold should schedule the cleanup.' );
+
+		\WC_Admin_Settings::save_fields(
+			array(
+				array(
+					'id'   => $option_id,
+					'type' => 'number',
+				),
+			),
+			array( $option_id => '-5' )
+		);
+
+		$this->assertSame( 0, (int) get_option( $option_id ), 'A negative threshold should be stored as 0.' );
+		$this->assertFalse( wp_get_schedule( DataRetentionController::DAILY_TASK_HOOK ), 'A negative threshold should turn the cleanup off.' );
+
+		$retention_controller->clear_daily_task();
+	}
+
+	/**
+	 * @testdox A stored negative unverified sign-up deletion threshold renders as 0 so the settings form stays submittable.
+	 */
+	public function test_stored_negative_unverified_deletion_days_threshold_renders_as_zero() {
+		$option_id = 'woocommerce_customer_stock_notifications_unverified_deletions_days_threshold';
+		update_option( $option_id, -5 );
+
+		$settings = ( new WC_Settings_Products() )->get_settings_for_section( 'customer_stock_notifications' );
+		$field    = current( array_filter( $settings, fn( $setting ) => ( $setting['id'] ?? '' ) === $option_id ) );
+
+		$this->assertSame( 0, $field['value'] );
+		$this->assertSame( 0, $field['custom_attributes']['min'] );
+
+		delete_option( $option_id );
+	}
+
 	/**
 	 * Reset the REST server and request globals so this test does not leak them into tests that run after it.
 	 */
diff --git a/plugins/woocommerce/tests/php/src/Internal/StockNotifications/DataRetentionControllerTests.php b/plugins/woocommerce/tests/php/src/Internal/StockNotifications/DataRetentionControllerTests.php
index 48185282de8..cc806a5e129 100644
--- a/plugins/woocommerce/tests/php/src/Internal/StockNotifications/DataRetentionControllerTests.php
+++ b/plugins/woocommerce/tests/php/src/Internal/StockNotifications/DataRetentionControllerTests.php
@@ -3,6 +3,7 @@
 declare( strict_types = 1 );
 namespace Automattic\WooCommerce\Tests\Internal\StockNotifications;

+use Automattic\WooCommerce\Internal\StockNotifications\Config;
 use Automattic\WooCommerce\Internal\StockNotifications\DataRetentionController;
 use Automattic\WooCommerce\Internal\StockNotifications\Enums\NotificationStatus;
 use Automattic\WooCommerce\Internal\StockNotifications\Frontend\NotificationManagementService;
@@ -81,6 +82,19 @@ class DataRetentionControllerTests extends \WC_Unit_Test_Case {
 		$this->assertFalse( $schedule );
 	}

+	/**
+	 * Test that an already stored negative threshold turns the cleanup off and unschedules the daily task.
+	 */
+	public function test_negative_threshold_is_treated_as_zero() {
+		update_option( 'woocommerce_customer_stock_notifications_unverified_deletions_days_threshold', 30 );
+		$this->assertEquals( 'daily', wp_get_schedule( DataRetentionController::DAILY_TASK_HOOK ) );
+
+		update_option( 'woocommerce_customer_stock_notifications_unverified_deletions_days_threshold', '-5' );
+
+		$this->assertSame( 0, Config::get_unverified_deletion_days_threshold() );
+		$this->assertFalse( wp_get_schedule( DataRetentionController::DAILY_TASK_HOOK ) );
+	}
+
 	/**
 	 * Test that the daily task deletes expired notifications.
 	 *