Commit 90ff68559f8 for woocommerce

commit 90ff68559f8c83ea6b5515aa8019937cb58e3b83
Author: Adam Grzybkowski <agrzybkowski@outlook.com>
Date:   Thu May 28 15:05:25 2026 +0200

    Clear stock notification dedup meta on stock recovery (#64727)

    Stock push notifications dedup per resource via the
    _wc_push_notification_sent_<event_type> post meta written by
    NotificationProcessor after a successful WPCOM send. For store_order
    and store_review the resource is unique per event, but for store_stock
    the resource is the product, which can cycle low -> restocked -> low
    again. The meta was never cleared, so a product only ever emitted one
    push per event subtype for its entire lifetime; subsequent crossings
    were silently suppressed.

    Add StockNotificationRecoveryHandler hooked on
    woocommerce_product_set_stock and woocommerce_variation_set_stock.
    For each subtype, evaluate whether the new stock level has recovered
    above its threshold and, if so, delete the namespaced sent-meta via
    StockNotification::delete_meta:

    - low_stock cleared when stock > wc_get_low_stock_amount(product)
      (already honours the per-product _low_stock_amount override and the
      variation->parent fallback)
    - out_of_stock cleared when stock > woocommerce_notify_no_stock_amount
    - on_backorder cleared when stock >= 0

    The handler is a no-op for null stock_quantity, non-positive product
    ids, and when no meta exists. Idempotency comes from
    WC_Product::delete_meta_data. Recovery is silent (no notification
    fires); only the next downward crossing emits a fresh push.

    Linear: RSM-2345

diff --git a/plugins/woocommerce/changelog/issue-RSM-2345 b/plugins/woocommerce/changelog/issue-RSM-2345
new file mode 100644
index 00000000000..4a87f978a76
--- /dev/null
+++ b/plugins/woocommerce/changelog/issue-RSM-2345
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Clear store_stock push notification dedup meta when product stock recovers above its threshold so future downward crossings emit a fresh push.
diff --git a/plugins/woocommerce/src/Internal/PushNotifications/PushNotifications.php b/plugins/woocommerce/src/Internal/PushNotifications/PushNotifications.php
index d7213b98702..690d3cd9630 100644
--- a/plugins/woocommerce/src/Internal/PushNotifications/PushNotifications.php
+++ b/plugins/woocommerce/src/Internal/PushNotifications/PushNotifications.php
@@ -15,6 +15,7 @@ use Automattic\WooCommerce\Internal\PushNotifications\Services\NotificationProce
 use Automattic\WooCommerce\Internal\PushNotifications\Services\PendingNotificationStore;
 use Automattic\WooCommerce\Internal\PushNotifications\Triggers\NewOrderNotificationTrigger;
 use Automattic\WooCommerce\Internal\PushNotifications\Triggers\NewReviewNotificationTrigger;
+use Automattic\WooCommerce\Internal\PushNotifications\Triggers\StockNotificationRecoveryHandler;
 use Automattic\WooCommerce\Internal\PushNotifications\Triggers\StockNotificationTrigger;
 use Automattic\WooCommerce\Proxies\LegacyProxy;
 use Automattic\WooCommerce\Utilities\FeaturesUtil;
@@ -84,6 +85,7 @@ class PushNotifications {
 		( new NewOrderNotificationTrigger() )->register();
 		( new NewReviewNotificationTrigger() )->register();
 		( new StockNotificationTrigger() )->register();
+		( new StockNotificationRecoveryHandler() )->register();

 		wc_get_container()->get( NotificationProcessor::class )->register();
 	}
diff --git a/plugins/woocommerce/src/Internal/PushNotifications/Triggers/StockNotificationRecoveryHandler.php b/plugins/woocommerce/src/Internal/PushNotifications/Triggers/StockNotificationRecoveryHandler.php
new file mode 100644
index 00000000000..802abb7c771
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/PushNotifications/Triggers/StockNotificationRecoveryHandler.php
@@ -0,0 +1,93 @@
+<?php
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\PushNotifications\Triggers;
+
+use Automattic\WooCommerce\Internal\PushNotifications\Notifications\StockNotification;
+use Automattic\WooCommerce\Internal\PushNotifications\Services\NotificationProcessor;
+use WC_Product;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Clears the per-event sent-meta on a product when its stock recovers
+ * above the threshold that would have re-triggered the notification.
+ *
+ * The dedup meta written by {@see NotificationProcessor} is namespaced
+ * per event subtype (e.g. `_wc_push_notification_sent_low_stock`). Without
+ * recovery, a product only ever emits one push per subtype for its entire
+ * lifetime — every subsequent low → restocked → low cycle is silently
+ * suppressed. This handler clears each meta the moment stock crosses back
+ * above the relevant threshold, so the next downward crossing emits a
+ * fresh push.
+ *
+ * @since 10.9.0
+ */
+class StockNotificationRecoveryHandler {
+	/**
+	 * Registers the recovery hook.
+	 *
+	 * Hooks both `woocommerce_product_set_stock` and
+	 * `woocommerce_variation_set_stock` because variations dispatch a
+	 * separate action (see `wc-stock-functions.php`). The trigger side
+	 * fires for variations too, so without the variation hook a variable
+	 * product's sent-meta would never clear.
+	 *
+	 * @return void
+	 *
+	 * @since 10.9.0
+	 */
+	public function register(): void {
+		add_action( 'woocommerce_product_set_stock', array( $this, 'on_stock_change' ) );
+		add_action( 'woocommerce_variation_set_stock', array( $this, 'on_stock_change' ) );
+	}
+
+	/**
+	 * Evaluates each event subtype's recovery threshold and clears
+	 * the corresponding sent-meta when the new stock level has recovered.
+	 *
+	 * @param WC_Product $product The product whose stock changed.
+	 * @return void
+	 *
+	 * @since 10.9.0
+	 */
+	public function on_stock_change( WC_Product $product ): void {
+		$product_id = $product->get_id();
+
+		if ( $product_id <= 0 ) {
+			return;
+		}
+
+		$stock_quantity = $product->get_stock_quantity();
+
+		if ( null === $stock_quantity ) {
+			return;
+		}
+
+		$stock = (int) $stock_quantity;
+
+		if ( $stock > (int) wc_get_low_stock_amount( $product ) ) {
+			$this->clear_meta( $product_id, StockNotification::EVENT_LOW_STOCK );
+		}
+
+		if ( $stock > (int) get_option( 'woocommerce_notify_no_stock_amount', 0 ) ) {
+			$this->clear_meta( $product_id, StockNotification::EVENT_OUT_OF_STOCK );
+		}
+
+		if ( $stock >= 0 ) {
+			$this->clear_meta( $product_id, StockNotification::EVENT_ON_BACKORDER );
+		}
+	}
+
+	/**
+	 * Clears the namespaced sent-meta for a given product and event subtype.
+	 *
+	 * @param int    $product_id The product ID.
+	 * @param string $event_type One of the StockNotification::EVENT_* constants.
+	 * @return void
+	 */
+	private function clear_meta( int $product_id, string $event_type ): void {
+		( new StockNotification( $product_id, $event_type ) )->delete_meta( NotificationProcessor::SENT_META_KEY );
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Triggers/StockNotificationRecoveryHandlerTest.php b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Triggers/StockNotificationRecoveryHandlerTest.php
new file mode 100644
index 00000000000..80b01bfb35b
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Triggers/StockNotificationRecoveryHandlerTest.php
@@ -0,0 +1,348 @@
+<?php
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\PushNotifications\Triggers;
+
+use Automattic\WooCommerce\Internal\PushNotifications\Notifications\StockNotification;
+use Automattic\WooCommerce\Internal\PushNotifications\Services\NotificationProcessor;
+use Automattic\WooCommerce\Internal\PushNotifications\Triggers\StockNotificationRecoveryHandler;
+use WC_Helper_Product;
+use WC_Product;
+use WC_Product_Simple;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for the StockNotificationRecoveryHandler class.
+ */
+class StockNotificationRecoveryHandlerTest extends WC_Unit_Test_Case {
+	/**
+	 * The System Under Test.
+	 *
+	 * @var StockNotificationRecoveryHandler
+	 */
+	private $sut;
+
+	/**
+	 * @var string|false
+	 */
+	private $original_no_stock_amount;
+
+	/**
+	 * Set up test fixtures.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+
+		$this->original_no_stock_amount = get_option( 'woocommerce_notify_no_stock_amount' );
+		update_option( 'woocommerce_notify_no_stock_amount', 0 );
+
+		$this->sut = new StockNotificationRecoveryHandler();
+		$this->sut->register();
+	}
+
+	/**
+	 * Tear down test fixtures.
+	 */
+	public function tearDown(): void {
+		remove_action( 'woocommerce_product_set_stock', array( $this->sut, 'on_stock_change' ) );
+		remove_action( 'woocommerce_variation_set_stock', array( $this->sut, 'on_stock_change' ) );
+
+		if ( false === $this->original_no_stock_amount ) {
+			delete_option( 'woocommerce_notify_no_stock_amount' );
+		} else {
+			update_option( 'woocommerce_notify_no_stock_amount', $this->original_no_stock_amount );
+		}
+
+		parent::tearDown();
+	}
+
+	/**
+	 * Creates a managed-stock product with the given quantity.
+	 *
+	 * @param int|null $stock_quantity Stock quantity, or null for unmanaged stock.
+	 * @return WC_Product_Simple
+	 */
+	private function create_product( ?int $stock_quantity ): WC_Product_Simple {
+		$props = null === $stock_quantity
+			? array( 'manage_stock' => false )
+			: array(
+				'manage_stock'   => true,
+				'stock_quantity' => $stock_quantity,
+			);
+
+		return WC_Helper_Product::create_simple_product( true, $props );
+	}
+
+	/**
+	 * Seeds the namespaced sent-meta on a product, mirroring what
+	 * StockNotification::write_meta() does.
+	 *
+	 * @param WC_Product $product    The product.
+	 * @param string     $event_type The event subtype.
+	 */
+	private function seed_meta( WC_Product $product, string $event_type ): void {
+		$product->update_meta_data( $this->meta_key( $event_type ), (string) time() );
+		$product->save_meta_data();
+	}
+
+	/**
+	 * Returns the namespaced sent-meta key for a given event subtype.
+	 *
+	 * @param string $event_type The event subtype.
+	 * @return string
+	 */
+	private function meta_key( string $event_type ): string {
+		return NotificationProcessor::SENT_META_KEY . '_' . $event_type;
+	}
+
+	/**
+	 * Asserts that the namespaced sent-meta exists for a product.
+	 *
+	 * @param int    $product_id The product ID.
+	 * @param string $event_type The event subtype.
+	 */
+	private function assert_meta_exists( int $product_id, string $event_type ): void {
+		$fresh = wc_get_product( $product_id );
+		$this->assertInstanceOf( WC_Product::class, $fresh );
+		$this->assertTrue(
+			$fresh->meta_exists( $this->meta_key( $event_type ) ),
+			"Expected sent-meta for '{$event_type}' to exist."
+		);
+	}
+
+	/**
+	 * Asserts that the namespaced sent-meta has been cleared for a product.
+	 *
+	 * @param int    $product_id The product ID.
+	 * @param string $event_type The event subtype.
+	 */
+	private function assert_meta_cleared( int $product_id, string $event_type ): void {
+		$fresh = wc_get_product( $product_id );
+		$this->assertInstanceOf( WC_Product::class, $fresh );
+		$this->assertFalse(
+			$fresh->meta_exists( $this->meta_key( $event_type ) ),
+			"Expected sent-meta for '{$event_type}' to be cleared."
+		);
+	}
+
+	/**
+	 * @testdox Clears low_stock meta when stock recovers above threshold.
+	 */
+	public function test_clears_low_stock_meta_when_stock_recovers_above_threshold(): void {
+		update_option( 'woocommerce_notify_low_stock_amount', 2 );
+
+		$product = $this->create_product( 5 );
+		$this->seed_meta( $product, StockNotification::EVENT_LOW_STOCK );
+
+		$this->sut->on_stock_change( $product );
+
+		$this->assert_meta_cleared( $product->get_id(), StockNotification::EVENT_LOW_STOCK );
+	}
+
+	/**
+	 * @testdox Preserves low_stock meta when stock is at threshold (recovery is strict >).
+	 */
+	public function test_preserves_low_stock_meta_when_stock_at_threshold(): void {
+		update_option( 'woocommerce_notify_low_stock_amount', 2 );
+
+		$product = $this->create_product( 2 );
+		$this->seed_meta( $product, StockNotification::EVENT_LOW_STOCK );
+
+		$this->sut->on_stock_change( $product );
+
+		$this->assert_meta_exists( $product->get_id(), StockNotification::EVENT_LOW_STOCK );
+	}
+
+	/**
+	 * @testdox Honours the per-product low-stock threshold override.
+	 */
+	public function test_respects_per_product_low_stock_override(): void {
+		update_option( 'woocommerce_notify_low_stock_amount', 2 );
+
+		$product = $this->create_product( 5 );
+		$product->set_low_stock_amount( 10 );
+		$product->save();
+		$this->seed_meta( $product, StockNotification::EVENT_LOW_STOCK );
+
+		$this->sut->on_stock_change( $product );
+
+		$this->assert_meta_exists( $product->get_id(), StockNotification::EVENT_LOW_STOCK );
+	}
+
+	/**
+	 * @testdox Clears out_of_stock meta when stock is above the no-stock-amount option.
+	 */
+	public function test_clears_out_of_stock_meta_when_stock_above_no_stock_amount(): void {
+		update_option( 'woocommerce_notify_no_stock_amount', 0 );
+
+		$product = $this->create_product( 1 );
+		$this->seed_meta( $product, StockNotification::EVENT_OUT_OF_STOCK );
+
+		$this->sut->on_stock_change( $product );
+
+		$this->assert_meta_cleared( $product->get_id(), StockNotification::EVENT_OUT_OF_STOCK );
+	}
+
+	/**
+	 * @testdox Preserves out_of_stock meta when stock equals the no-stock-amount option.
+	 */
+	public function test_preserves_out_of_stock_meta_at_no_stock_amount(): void {
+		update_option( 'woocommerce_notify_no_stock_amount', 0 );
+
+		$product = $this->create_product( 0 );
+		$this->seed_meta( $product, StockNotification::EVENT_OUT_OF_STOCK );
+
+		$this->sut->on_stock_change( $product );
+
+		$this->assert_meta_exists( $product->get_id(), StockNotification::EVENT_OUT_OF_STOCK );
+	}
+
+	/**
+	 * @testdox Clears on_backorder meta when stock is non-negative.
+	 */
+	public function test_clears_on_backorder_meta_when_stock_non_negative(): void {
+		$product = $this->create_product( 0 );
+		$this->seed_meta( $product, StockNotification::EVENT_ON_BACKORDER );
+
+		$this->sut->on_stock_change( $product );
+
+		$this->assert_meta_cleared( $product->get_id(), StockNotification::EVENT_ON_BACKORDER );
+	}
+
+	/**
+	 * @testdox Preserves on_backorder meta when stock is still negative.
+	 */
+	public function test_preserves_on_backorder_meta_when_stock_negative(): void {
+		$product = $this->create_product( -1 );
+		$this->seed_meta( $product, StockNotification::EVENT_ON_BACKORDER );
+
+		$this->sut->on_stock_change( $product );
+
+		$this->assert_meta_exists( $product->get_id(), StockNotification::EVENT_ON_BACKORDER );
+	}
+
+	/**
+	 * @testdox Clears each meta independently based on its own threshold.
+	 */
+	public function test_independent_meta_clearing(): void {
+		update_option( 'woocommerce_notify_low_stock_amount', 5 );
+		update_option( 'woocommerce_notify_no_stock_amount', 0 );
+
+		$product = $this->create_product( 2 );
+		$this->seed_meta( $product, StockNotification::EVENT_LOW_STOCK );
+		$this->seed_meta( $product, StockNotification::EVENT_OUT_OF_STOCK );
+		$this->seed_meta( $product, StockNotification::EVENT_ON_BACKORDER );
+
+		$this->sut->on_stock_change( $product );
+
+		$this->assert_meta_exists( $product->get_id(), StockNotification::EVENT_LOW_STOCK );
+		$this->assert_meta_cleared( $product->get_id(), StockNotification::EVENT_OUT_OF_STOCK );
+		$this->assert_meta_cleared( $product->get_id(), StockNotification::EVENT_ON_BACKORDER );
+	}
+
+	/**
+	 * @testdox Clears all metas when stock jumps above all thresholds in one step.
+	 */
+	public function test_clears_all_metas_when_stock_jumps_above_all_thresholds(): void {
+		update_option( 'woocommerce_notify_low_stock_amount', 2 );
+		update_option( 'woocommerce_notify_no_stock_amount', 0 );
+
+		$product = $this->create_product( 10 );
+		$this->seed_meta( $product, StockNotification::EVENT_LOW_STOCK );
+		$this->seed_meta( $product, StockNotification::EVENT_OUT_OF_STOCK );
+		$this->seed_meta( $product, StockNotification::EVENT_ON_BACKORDER );
+
+		$this->sut->on_stock_change( $product );
+
+		$this->assert_meta_cleared( $product->get_id(), StockNotification::EVENT_LOW_STOCK );
+		$this->assert_meta_cleared( $product->get_id(), StockNotification::EVENT_OUT_OF_STOCK );
+		$this->assert_meta_cleared( $product->get_id(), StockNotification::EVENT_ON_BACKORDER );
+	}
+
+	/**
+	 * @testdox Is a no-op when no sent-meta exists.
+	 */
+	public function test_idempotent_when_no_meta_exists(): void {
+		$product = $this->create_product( 10 );
+
+		$this->sut->on_stock_change( $product );
+
+		$this->assert_meta_cleared( $product->get_id(), StockNotification::EVENT_LOW_STOCK );
+		$this->assert_meta_cleared( $product->get_id(), StockNotification::EVENT_OUT_OF_STOCK );
+		$this->assert_meta_cleared( $product->get_id(), StockNotification::EVENT_ON_BACKORDER );
+	}
+
+	/**
+	 * @testdox Is a no-op for products with null stock quantity.
+	 */
+	public function test_no_op_for_null_stock_quantity(): void {
+		$product = $this->create_product( null );
+		$this->seed_meta( $product, StockNotification::EVENT_LOW_STOCK );
+		$this->seed_meta( $product, StockNotification::EVENT_OUT_OF_STOCK );
+		$this->seed_meta( $product, StockNotification::EVENT_ON_BACKORDER );
+
+		$this->sut->on_stock_change( $product );
+
+		$this->assert_meta_exists( $product->get_id(), StockNotification::EVENT_LOW_STOCK );
+		$this->assert_meta_exists( $product->get_id(), StockNotification::EVENT_OUT_OF_STOCK );
+		$this->assert_meta_exists( $product->get_id(), StockNotification::EVENT_ON_BACKORDER );
+	}
+
+	/**
+	 * @testdox Is a no-op for products with non-positive IDs (defensive).
+	 */
+	public function test_no_op_for_invalid_product_id(): void {
+		$product = $this->getMockBuilder( WC_Product_Simple::class )
+			->disableOriginalConstructor()
+			->onlyMethods( array( 'get_id', 'get_stock_quantity' ) )
+			->getMock();
+
+		$product->method( 'get_id' )->willReturn( 0 );
+		$product->expects( $this->never() )->method( 'get_stock_quantity' );
+
+		$this->sut->on_stock_change( $product );
+	}
+
+	/**
+	 * @testdox Recovery fires through the woocommerce_product_set_stock action.
+	 */
+	public function test_handler_fires_via_action(): void {
+		update_option( 'woocommerce_notify_low_stock_amount', 2 );
+
+		$product = $this->create_product( 0 );
+		$this->seed_meta( $product, StockNotification::EVENT_LOW_STOCK );
+		$this->seed_meta( $product, StockNotification::EVENT_OUT_OF_STOCK );
+
+		wc_update_product_stock( $product->get_id(), 10, 'set' );
+
+		$this->assert_meta_cleared( $product->get_id(), StockNotification::EVENT_LOW_STOCK );
+		$this->assert_meta_cleared( $product->get_id(), StockNotification::EVENT_OUT_OF_STOCK );
+	}
+
+	/**
+	 * @testdox Recovery fires through the woocommerce_variation_set_stock action when a variation's stock changes.
+	 */
+	public function test_handler_fires_for_variation_via_action(): void {
+		update_option( 'woocommerce_notify_low_stock_amount', 2 );
+
+		$variable_product = WC_Helper_Product::create_variation_product();
+		$variation_ids    = $variable_product->get_children();
+		$this->assertNotEmpty( $variation_ids );
+
+		$variation = wc_get_product( $variation_ids[0] );
+		$this->assertInstanceOf( \WC_Product_Variation::class, $variation );
+
+		$variation->set_manage_stock( true );
+		$variation->set_stock_quantity( 0 );
+		$variation->save();
+
+		$this->seed_meta( $variation, StockNotification::EVENT_LOW_STOCK );
+
+		// `wc_update_product_stock` routes to `woocommerce_variation_set_stock`
+		// for variations (see `wc-stock-functions.php:68-76`).
+		wc_update_product_stock( $variation->get_id(), 10, 'set' );
+
+		$this->assert_meta_cleared( $variation->get_id(), StockNotification::EVENT_LOW_STOCK );
+	}
+}