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 );
+ }
+}