Commit decfa3a6637 for woocommerce

commit decfa3a663789dd021e8fe098aa8c4fa1f293701
Author: Adam Grzybkowski <agrzybkowski@outlook.com>
Date:   Thu May 28 14:25:45 2026 +0200

    Add store_stock push notification for low/out/backorder events (#64600)

    * Add StockNotification type for store_stock push notifications

    Introduces a polymorphic StockNotification class with three event
    subtypes (low_stock, out_of_stock, on_backorder) keyed off a single
    store_stock entry in NOTIFICATION_CLASSES. The same product can have
    distinct notifications for different stock events in flight at the
    same time because get_identifier() includes the event type.

    For variations, meta.product_id resolves to the parent product ID so
    the mobile app can navigate to the product details screen.
    resource_id keeps the actual entity ID (variation or simple product)
    so per-event identifier and dedup logic still distinguish individual
    variations.

    Stock quantity is captured at construction time and threaded through
    to_array/hydrate so the low_stock message renders the trigger-time
    value rather than re-fetching the product at dispatch — which runs
    in a separate process and can return stale cached stock.

    Notification::from_array() gains an optional hydrate() hook so
    subclasses can restore extra state that the base constructor cannot
    receive. hydrate() throws on unrecognized event_type so corrupt
    safety-net jobs are dropped instead of dispatching the wrong subtype.

    Linear: RSM-1551

    * Add stock notification trigger and register on stock events

    Listens on woocommerce_low_stock, woocommerce_no_stock and
    woocommerce_product_on_backorder, mapping each to its corresponding
    StockNotification event subtype and adding it to the pending store
    for shutdown dispatch.

    The low_stock handler captures get_stock_quantity() at trigger time
    and passes it to the notification so the dispatcher (which runs in a
    separate process) doesn't have to re-fetch the product and risk
    reading a stale value.

    Linear: RSM-1551

    * Add store_stock REST schema, preferences and safety-net round-trip

    Extends the preferences contract with store_stock sub-flags
    (low_stock, out_of_stock, on_backorder), defaulting on_backorder to
    false so backorder events are opt-in. The REST schema validates the
    sub-flags as booleans and the service drops unknown sub-fields.

    PendingNotificationStore now schedules the safety-net via
    $notification->to_array() so subclass state (event_type, stock
    quantity snapshot) survives serialization. NotificationProcessor
    ::handle_safety_net accepts optional third (event_type) and fourth
    (stock_quantity_at_trigger) parameters; the defaults keep any
    in-flight 2-arg actions working.

    Linear: RSM-1551

    * Add changelog entry for store_stock push notification

    * Pass safety-net extras as an array param

    Replace the growing positional param list on
    NotificationProcessor::handle_safety_net (type, resource_id, event_type,
    stock_quantity_at_trigger) with a single optional `array $extra` so
    future notification subclasses can add fields to to_array() without
    extending the signature each time.

    PendingNotificationStore now splits to_array() into [type, resource_id,
    $rest] before scheduling the ActionScheduler action, and the receiver
    array_merges the extras back onto type+resource_id when reconstructing
    via Notification::from_array.

    Addresses review feedback on RSM-1551.

    * Guard safety-net extras against overwriting positional params

    Switch handle_safety_net's payload construction from array_merge to the
    `+` array union operator so the positional $type and $resource_id always
    win over any colliding keys in the $extra array. Defends against a
    malformed payload reconstructing the wrong notification target.

    Addresses CodeRabbit review feedback on RSM-1551.

    * Use a warning glyph for stock push notifications

    Stock events (low / out / backorder) are merchant alerts — "you
    should know about this" — which a warning triangle communicates more
    directly than a payment icon. WPCOM's tos-warning-note-icon.png is
    already at retina resolution (512x512) despite missing the -2x suffix
    in its name, so no resolution trade-off vs the sibling notifications
    that use explicitly -2x-named assets.

    WPCOM has no stock-specific icon — coordinating a proper retina
    stock asset with the design team is tracked as a follow-up.

    * Enable on_backorder stock notifications by default

    Backorder events are actionable for merchants — selling something
    you don't have on hand is usually news worth a push. Defaulting the
    sub-flag to true matches the behavior of low_stock and out_of_stock
    and aligns with WC core's email defaults (which send backorder
    emails by default).

    Merchants who run backorder-heavy stores can still opt out via the
    preferences REST endpoint.

diff --git a/plugins/woocommerce/changelog/issue-RSM-1551 b/plugins/woocommerce/changelog/issue-RSM-1551
new file mode 100644
index 00000000000..6b0122884de
--- /dev/null
+++ b/plugins/woocommerce/changelog/issue-RSM-1551
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add the store_stock push notification covering low-stock, out-of-stock, and backorder events. All three events are enabled by default and each one has its own merchant-configurable sub-flag.
diff --git a/plugins/woocommerce/src/Internal/PushNotifications/Controllers/NotificationPreferencesRestController.php b/plugins/woocommerce/src/Internal/PushNotifications/Controllers/NotificationPreferencesRestController.php
index 170351beb79..5478baebc12 100644
--- a/plugins/woocommerce/src/Internal/PushNotifications/Controllers/NotificationPreferencesRestController.php
+++ b/plugins/woocommerce/src/Internal/PushNotifications/Controllers/NotificationPreferencesRestController.php
@@ -224,6 +224,20 @@ class NotificationPreferencesRestController extends RestApiControllerBase {
 				);
 			}

+			$boolean_sub_fields = array( 'low_stock', 'out_of_stock', 'on_backorder' );
+			foreach ( $boolean_sub_fields as $sub_field ) {
+				if ( array_key_exists( $sub_field, $shape ) ) {
+					$properties[ $sub_field ] = array(
+						'type'        => 'boolean',
+						'description' => sprintf(
+							/* translators: %s: sub-field name (e.g. low_stock). */
+							__( 'Whether %s notifications are enabled for this type.', 'woocommerce' ),
+							$sub_field
+						),
+					);
+				}
+			}
+
 			$args[ $key ] = array(
 				'description'       => sprintf(
 					/* translators: %s: notification preference key (e.g. store_order). */
diff --git a/plugins/woocommerce/src/Internal/PushNotifications/Notifications/Notification.php b/plugins/woocommerce/src/Internal/PushNotifications/Notifications/Notification.php
index 1ed640290d7..35404ad7445 100644
--- a/plugins/woocommerce/src/Internal/PushNotifications/Notifications/Notification.php
+++ b/plugins/woocommerce/src/Internal/PushNotifications/Notifications/Notification.php
@@ -25,6 +25,7 @@ abstract class Notification {
 	const NOTIFICATION_CLASSES = array(
 		'store_order'  => NewOrderNotification::class,
 		'store_review' => NewReviewNotification::class,
+		'store_stock'  => StockNotification::class,
 	);

 	/**
@@ -139,7 +140,13 @@ abstract class Notification {
 			throw new InvalidArgumentException( sprintf( 'Unknown notification type: %s', $type ) );
 		}

-		return new $class( $resource_id );
+		$instance = new $class( $resource_id );
+
+		if ( method_exists( $instance, 'hydrate' ) ) {
+			$instance->hydrate( $data );
+		}
+
+		return $instance;
 	}

 	/**
diff --git a/plugins/woocommerce/src/Internal/PushNotifications/Notifications/StockNotification.php b/plugins/woocommerce/src/Internal/PushNotifications/Notifications/StockNotification.php
new file mode 100644
index 00000000000..42a75799277
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/PushNotifications/Notifications/StockNotification.php
@@ -0,0 +1,335 @@
+<?php
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\PushNotifications\Notifications;
+
+use InvalidArgumentException;
+use WC_Product;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Notification for product stock events (low stock, out of stock, backorder).
+ *
+ * @since 10.9.0
+ */
+class StockNotification extends Notification {
+	const TYPE = 'store_stock';
+
+	const EVENT_LOW_STOCK    = 'low_stock';
+	const EVENT_OUT_OF_STOCK = 'out_of_stock';
+	const EVENT_ON_BACKORDER = 'on_backorder';
+
+	const VALID_EVENT_TYPES = array(
+		self::EVENT_LOW_STOCK,
+		self::EVENT_OUT_OF_STOCK,
+		self::EVENT_ON_BACKORDER,
+	);
+
+	const ICON = 'https://s.wp.com/wp-content/mu-plugins/notes/images/tos-warning-note-icon.png';
+
+	/**
+	 * The stock event that triggered this notification.
+	 *
+	 * @var string
+	 */
+	private string $event_type;
+
+	/**
+	 * Stock quantity captured at the moment the WC stock event fired.
+	 *
+	 * Captured at trigger time rather than read at dispatch time so the
+	 * notification reflects the threshold-crossing moment, not whatever
+	 * stock level the product happens to be at when the dispatcher (which
+	 * runs in a separate process — internal REST endpoint or ActionScheduler
+	 * safety net) eventually re-fetches the product. Avoids stale-cache reads
+	 * and remains correct even if subsequent orders reduce stock further
+	 * before dispatch.
+	 *
+	 * Only meaningful for the low_stock event today; null for the other two.
+	 *
+	 * @var int|null
+	 */
+	private ?int $stock_quantity_at_trigger;
+
+	/**
+	 * Creates a new StockNotification instance.
+	 *
+	 * @param int      $resource_id              The product ID.
+	 * @param string   $event_type               One of the EVENT_* constants.
+	 * @param int|null $stock_quantity_at_trigger Stock quantity captured when the WC stock event fired, or null if unknown.
+	 *
+	 * @throws InvalidArgumentException If the resource ID or event type is invalid.
+	 *
+	 * @since 10.9.0
+	 */
+	public function __construct( int $resource_id, string $event_type = self::EVENT_LOW_STOCK, ?int $stock_quantity_at_trigger = null ) {
+		parent::__construct( $resource_id );
+
+		if ( ! in_array( $event_type, self::VALID_EVENT_TYPES, true ) ) {
+			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
+			throw new InvalidArgumentException( sprintf( 'Invalid stock notification event type: %s', $event_type ) );
+		}
+
+		$this->event_type                = $event_type;
+		$this->stock_quantity_at_trigger = $stock_quantity_at_trigger;
+	}
+
+	/**
+	 * Restores extra state from a serialized notification array.
+	 *
+	 * Called by {@see Notification::from_array()} after construction to
+	 * restore the event type that the default constructor cannot receive.
+	 *
+	 * Throws when `event_type` is present but unrecognized so the safety-net
+	 * caller (which wraps reconstruction in a try/catch) drops the corrupt
+	 * job rather than silently dispatching the wrong notification subtype.
+	 * A missing `event_type` is allowed — the default set by the constructor
+	 * survives, which preserves backward compatibility with any in-flight
+	 * scheduled actions that pre-date this field.
+	 *
+	 * @param array $data The serialized notification data.
+	 *
+	 * @throws InvalidArgumentException If `event_type` is present but not a known value.
+	 *
+	 * @since 10.9.0
+	 */
+	public function hydrate( array $data ): void {
+		if ( array_key_exists( 'event_type', $data ) ) {
+			$event_type = $data['event_type'];
+
+			if ( ! in_array( $event_type, self::VALID_EVENT_TYPES, true ) ) {
+				// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
+				throw new InvalidArgumentException( sprintf( 'Invalid stock notification event type during hydrate: %s', is_scalar( $event_type ) ? (string) $event_type : gettype( $event_type ) ) );
+			}
+
+			$this->event_type = $event_type;
+		}
+
+		if ( array_key_exists( 'stock_quantity_at_trigger', $data ) ) {
+			$stock                           = $data['stock_quantity_at_trigger'];
+			$this->stock_quantity_at_trigger = is_int( $stock ) ? $stock : null;
+		}
+	}
+
+	/**
+	 * {@inheritDoc}
+	 */
+	public function get_type(): string {
+		return self::TYPE;
+	}
+
+	/**
+	 * Returns the stock event type.
+	 *
+	 * @return string
+	 *
+	 * @since 10.9.0
+	 */
+	public function get_event_type(): string {
+		return $this->event_type;
+	}
+
+	/**
+	 * {@inheritDoc}
+	 *
+	 * Includes `event_type` so the same product can have distinct
+	 * notifications for different stock events in-flight simultaneously.
+	 */
+	public function get_identifier(): string {
+		return sprintf(
+			'%s_%s_%s_%s',
+			get_current_blog_id(),
+			$this->get_type(),
+			$this->event_type,
+			$this->get_resource_id()
+		);
+	}
+
+	/**
+	 * {@inheritDoc}
+	 *
+	 * Extends the parent array with `event_type` and the trigger-time stock
+	 * snapshot so both fields survive serialization through the safety-net
+	 * scheduler and the internal-REST round-trip.
+	 *
+	 * @return array{type: string, resource_id: int, event_type: string, stock_quantity_at_trigger: int|null}
+	 *
+	 * @since 10.9.0
+	 */
+	public function to_array(): array {
+		return array_merge(
+			parent::to_array(),
+			array(
+				'event_type'                => $this->event_type,
+				'stock_quantity_at_trigger' => $this->stock_quantity_at_trigger,
+			)
+		);
+	}
+
+	/**
+	 * Returns the WPCOM-ready payload for this notification.
+	 *
+	 * Returns null if the product no longer exists.
+	 *
+	 * @return array|null
+	 *
+	 * @since 10.9.0
+	 */
+	public function to_payload(): ?array {
+		$product = WC()->call_function( 'wc_get_product', $this->get_resource_id() );
+
+		if ( ! $product || ! $product instanceof WC_Product ) {
+			return null;
+		}
+
+		$product_name = wp_strip_all_tags( $product->get_name() );
+		$site_title   = wp_strip_all_tags( get_bloginfo( 'name' ) );
+
+		// For variations, `meta.product_id` is the parent product ID so the mobile app
+		// can always navigate to the product details screen. `resource_id` keeps the
+		// actual entity ID (variation or simple product) for identification and dedup.
+		$is_variation = $product->is_type( 'variation' );
+		$product_id   = $is_variation ? $product->get_parent_id() : $product->get_id();
+
+		return array(
+			'type'        => $this->get_type(),
+			'icon'        => self::ICON,
+			'timestamp'   => gmdate( 'c' ),
+			'resource_id' => $this->get_resource_id(),
+			'title'       => $this->build_title( $product_name ),
+			'message'     => $this->build_message( $product_name, $site_title, $product ),
+			'meta'        => array(
+				'product_id' => $product_id,
+				'event_type' => $this->event_type,
+			),
+		);
+	}
+
+	/**
+	 * {@inheritDoc}
+	 *
+	 * Extends the base enabled-toggle check with per-event sub-flag filtering.
+	 *
+	 * @param mixed $pref_value The user's stored preference value, or null.
+	 * @return bool
+	 *
+	 * @since 10.9.0
+	 */
+	public function should_send_to_user( $pref_value ): bool {
+		if ( ! parent::should_send_to_user( $pref_value ) ) {
+			return false;
+		}
+
+		if ( ! is_array( $pref_value ) || ! array_key_exists( $this->event_type, $pref_value ) ) {
+			return true;
+		}
+
+		return (bool) $pref_value[ $this->event_type ];
+	}
+
+	/**
+	 * {@inheritDoc}
+	 *
+	 * @param string $key The meta key.
+	 */
+	public function has_meta( string $key ): bool {
+		$product = WC()->call_function( 'wc_get_product', $this->get_resource_id() );
+		return $product instanceof WC_Product && $product->meta_exists( $key . '_' . $this->event_type );
+	}
+
+	/**
+	 * {@inheritDoc}
+	 *
+	 * @param string $key The meta key.
+	 */
+	public function write_meta( string $key ): void {
+		$product = WC()->call_function( 'wc_get_product', $this->get_resource_id() );
+
+		if ( $product instanceof WC_Product ) {
+			$product->update_meta_data( $key . '_' . $this->event_type, (string) time() );
+			$product->save_meta_data();
+		}
+	}
+
+	/**
+	 * {@inheritDoc}
+	 *
+	 * @param string $key The meta key.
+	 */
+	public function delete_meta( string $key ): void {
+		$product = WC()->call_function( 'wc_get_product', $this->get_resource_id() );
+
+		if ( $product instanceof WC_Product ) {
+			$product->delete_meta_data( $key . '_' . $this->event_type );
+			$product->save_meta_data();
+		}
+	}
+
+	/**
+	 * Builds the title payload for the notification.
+	 *
+	 * @param string $product_name The sanitized product name.
+	 * @return array{format: string, args: string[]}
+	 */
+	private function build_title( string $product_name ): array {
+		switch ( $this->event_type ) {
+			case self::EVENT_OUT_OF_STOCK:
+				return array(
+					'format' => 'Out of stock: %1$s',
+					'args'   => array( $product_name ),
+				);
+
+			case self::EVENT_ON_BACKORDER:
+				return array(
+					'format' => 'Backordered: %1$s',
+					'args'   => array( $product_name ),
+				);
+
+			default:
+				return array(
+					'format' => 'Low stock: %1$s',
+					'args'   => array( $product_name ),
+				);
+		}
+	}
+
+	/**
+	 * Builds the message payload for the notification.
+	 *
+	 * @param string     $product_name The sanitized product name.
+	 * @param string     $site_title   The sanitized site title.
+	 * @param WC_Product $product      The product object (used as a fallback when no trigger-time stock was captured).
+	 * @return array{format: string, args: string[]}
+	 */
+	private function build_message( string $product_name, string $site_title, WC_Product $product ): array {
+		switch ( $this->event_type ) {
+			case self::EVENT_OUT_OF_STOCK:
+				return array(
+					'format' => '%1$s is out of stock on %2$s',
+					'args'   => array( $product_name, $site_title ),
+				);
+
+			case self::EVENT_ON_BACKORDER:
+				return array(
+					'format' => '%1$s has been backordered on %2$s',
+					'args'   => array( $product_name, $site_title ),
+				);
+
+			default:
+				$stock = null !== $this->stock_quantity_at_trigger
+					? $this->stock_quantity_at_trigger
+					: $product->get_stock_quantity();
+
+				return array(
+					'format' => '%1$s is running low (%2$s remaining) on %3$s',
+					'args'   => array(
+						$product_name,
+						(string) $stock,
+						$site_title,
+					),
+				);
+		}//end switch
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/PushNotifications/PushNotifications.php b/plugins/woocommerce/src/Internal/PushNotifications/PushNotifications.php
index 752894d78ea..d7213b98702 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\StockNotificationTrigger;
 use Automattic\WooCommerce\Proxies\LegacyProxy;
 use Automattic\WooCommerce\Utilities\FeaturesUtil;
 use WC_Logger;
@@ -82,6 +83,7 @@ class PushNotifications {
 		( new NotificationPreferencesRestController() )->register();
 		( new NewOrderNotificationTrigger() )->register();
 		( new NewReviewNotificationTrigger() )->register();
+		( new StockNotificationTrigger() )->register();

 		wc_get_container()->get( NotificationProcessor::class )->register();
 	}
diff --git a/plugins/woocommerce/src/Internal/PushNotifications/Services/NotificationPreferencesService.php b/plugins/woocommerce/src/Internal/PushNotifications/Services/NotificationPreferencesService.php
index d758214696e..4b30b15620b 100644
--- a/plugins/woocommerce/src/Internal/PushNotifications/Services/NotificationPreferencesService.php
+++ b/plugins/woocommerce/src/Internal/PushNotifications/Services/NotificationPreferencesService.php
@@ -116,8 +116,11 @@ class NotificationPreferencesService {
 			$defaults[ $type ] = array( 'enabled' => true );
 		}

-		$defaults['store_order']['min_amount']  = null;
-		$defaults['store_review']['max_rating'] = null;
+		$defaults['store_order']['min_amount']   = null;
+		$defaults['store_review']['max_rating']  = null;
+		$defaults['store_stock']['low_stock']    = true;
+		$defaults['store_stock']['out_of_stock'] = true;
+		$defaults['store_stock']['on_backorder'] = true;

 		return $defaults;
 	}
@@ -188,6 +191,13 @@ class NotificationPreferencesService {
 				$sanitized[ $sub_key ] = ( $rating >= 1 && $rating <= 5 ) ? $rating : null;
 				continue;
 			}
+
+			if ( in_array( $sub_key, array( 'low_stock', 'out_of_stock', 'on_backorder' ), true ) ) {
+				$sanitized[ $sub_key ] = array_key_exists( $sub_key, $value )
+					? (bool) $value[ $sub_key ]
+					: (bool) $sub_default;
+				continue;
+			}
 		}//end foreach

 		return $sanitized;
diff --git a/plugins/woocommerce/src/Internal/PushNotifications/Services/NotificationProcessor.php b/plugins/woocommerce/src/Internal/PushNotifications/Services/NotificationProcessor.php
index 558183ffc39..f3c3093cb69 100644
--- a/plugins/woocommerce/src/Internal/PushNotifications/Services/NotificationProcessor.php
+++ b/plugins/woocommerce/src/Internal/PushNotifications/Services/NotificationProcessor.php
@@ -99,7 +99,7 @@ class NotificationProcessor {
 	 * @since 10.7.0
 	 */
 	public function register(): void {
-		add_action( self::SAFETY_NET_HOOK, array( $this, 'handle_safety_net' ), 10, 2 );
+		add_action( self::SAFETY_NET_HOOK, array( $this, 'handle_safety_net' ), 10, 3 );
 	}

 	/**
@@ -233,18 +233,23 @@ class NotificationProcessor {
 	 *
 	 * @param string $type        The notification type.
 	 * @param int    $resource_id The resource ID.
+	 * @param array  $extra       Optional subclass-specific extras (e.g. event_type, stock_quantity_at_trigger).
+	 *                            Empty for notification types whose state is fully described by type + resource_id.
 	 * @return void
 	 *
 	 * @since 10.7.0
 	 */
-	public function handle_safety_net( string $type, int $resource_id ): void {
+	public function handle_safety_net( string $type, int $resource_id, array $extra = array() ): void {
 		try {
-			$notification = Notification::from_array(
-				array(
-					'type'        => $type,
-					'resource_id' => $resource_id,
-				)
-			);
+			// Use the `+` array union operator (not array_merge) so the positional
+			// $type and $resource_id always win over any colliding keys in $extra.
+			// Defends against a malformed payload reconstructing the wrong target.
+			$data = array(
+				'type'        => $type,
+				'resource_id' => $resource_id,
+			) + $extra;
+
+			$notification = Notification::from_array( $data );

 			$this->process( $notification, true );
 		} catch ( Exception $e ) {
diff --git a/plugins/woocommerce/src/Internal/PushNotifications/Services/PendingNotificationStore.php b/plugins/woocommerce/src/Internal/PushNotifications/Services/PendingNotificationStore.php
index 43a80cb4891..c9bcc3e0185 100644
--- a/plugins/woocommerce/src/Internal/PushNotifications/Services/PendingNotificationStore.php
+++ b/plugins/woocommerce/src/Internal/PushNotifications/Services/PendingNotificationStore.php
@@ -120,10 +120,16 @@ class PendingNotificationStore {
 	 * @since 10.7.0
 	 */
 	private function schedule_safety_net( Notification $notification ): void {
-		$args = array(
-			'type'        => $notification->get_type(),
-			'resource_id' => $notification->get_resource_id(),
-		);
+		$data        = $notification->to_array();
+		$type        = $data['type'];
+		$resource_id = $data['resource_id'];
+		unset( $data['type'], $data['resource_id'] );
+
+		// Pass `type` and `resource_id` positionally and bundle any subclass-specific
+		// extras (event_type, stock_quantity_at_trigger, etc.) into a single array
+		// argument so the safety-net callback signature stays stable as new
+		// notification subclasses add fields to to_array().
+		$args = array( $type, $resource_id, $data );

 		if ( as_has_scheduled_action( NotificationProcessor::SAFETY_NET_HOOK, $args, NotificationProcessor::ACTION_SCHEDULER_GROUP ) ) {
 			return;
diff --git a/plugins/woocommerce/src/Internal/PushNotifications/Triggers/StockNotificationTrigger.php b/plugins/woocommerce/src/Internal/PushNotifications/Triggers/StockNotificationTrigger.php
new file mode 100644
index 00000000000..173b7ecd310
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/PushNotifications/Triggers/StockNotificationTrigger.php
@@ -0,0 +1,102 @@
+<?php
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\PushNotifications\Triggers;
+
+use Automattic\WooCommerce\Internal\PushNotifications\Notifications\StockNotification;
+use Automattic\WooCommerce\Internal\PushNotifications\Services\PendingNotificationStore;
+use WC_Product;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Listens for WooCommerce stock events and feeds stock notifications into
+ * the PendingNotificationStore.
+ *
+ * @since 10.9.0
+ */
+class StockNotificationTrigger {
+	/**
+	 * Registers WordPress hooks for stock events.
+	 *
+	 * @return void
+	 *
+	 * @since 10.9.0
+	 */
+	public function register(): void {
+		add_action( 'woocommerce_low_stock', array( $this, 'on_low_stock' ) );
+		add_action( 'woocommerce_no_stock', array( $this, 'on_no_stock' ) );
+		add_action( 'woocommerce_product_on_backorder', array( $this, 'on_backorder' ) );
+	}
+
+	/**
+	 * Handles the woocommerce_low_stock hook.
+	 *
+	 * Captures the product's stock quantity at this moment so the dispatcher,
+	 * which runs in a separate process and re-fetches the product, doesn't
+	 * read a stale value if cache invalidation hasn't fully propagated.
+	 *
+	 * @param WC_Product $product The product whose stock is low.
+	 * @return void
+	 *
+	 * @since 10.9.0
+	 */
+	public function on_low_stock( WC_Product $product ): void {
+		$stock = $product->get_stock_quantity();
+		$this->add_notification(
+			$product->get_id(),
+			StockNotification::EVENT_LOW_STOCK,
+			null !== $stock ? (int) $stock : null
+		);
+	}
+
+	/**
+	 * Handles the woocommerce_no_stock hook.
+	 *
+	 * @param WC_Product $product The product that is out of stock.
+	 * @return void
+	 *
+	 * @since 10.9.0
+	 */
+	public function on_no_stock( WC_Product $product ): void {
+		$this->add_notification( $product->get_id(), StockNotification::EVENT_OUT_OF_STOCK );
+	}
+
+	/**
+	 * Handles the woocommerce_product_on_backorder hook.
+	 *
+	 * @param array $args Backorder event data.
+	 * @phpstan-param array{product: WC_Product, order_id: int, quantity: int|float} $args
+	 * @return void
+	 *
+	 * @since 10.9.0
+	 */
+	public function on_backorder( array $args ): void {
+		$product = $args['product'] ?? null;
+
+		if ( ! $product instanceof WC_Product ) {
+			return;
+		}
+
+		$this->add_notification( $product->get_id(), StockNotification::EVENT_ON_BACKORDER );
+	}
+
+	/**
+	 * Creates a stock notification and adds it to the pending store.
+	 *
+	 * @param int      $product_id                The product ID.
+	 * @param string   $event_type                The stock event type.
+	 * @param int|null $stock_quantity_at_trigger Stock quantity at the moment WC fired the event, or null when not applicable.
+	 * @return void
+	 */
+	private function add_notification( int $product_id, string $event_type, ?int $stock_quantity_at_trigger = null ): void {
+		if ( $product_id <= 0 ) {
+			return;
+		}
+
+		wc_get_container()->get( PendingNotificationStore::class )->add(
+			new StockNotification( $product_id, $event_type, $stock_quantity_at_trigger )
+		);
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Controllers/NotificationPreferencesRestControllerTest.php b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Controllers/NotificationPreferencesRestControllerTest.php
index 2da84125d81..785924f9f2c 100644
--- a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Controllers/NotificationPreferencesRestControllerTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Controllers/NotificationPreferencesRestControllerTest.php
@@ -464,6 +464,69 @@ class NotificationPreferencesRestControllerTest extends WC_REST_Unit_Test_Case {
 		$this->assertNull( $data['store_review']['max_rating'] );
 	}

+	/**
+	 * @testdox GET should include store_stock with all sub-flags in the defaults.
+	 */
+	public function test_get_preferences_includes_store_stock_with_sub_flags() {
+		wp_set_current_user( $this->user_id );
+		$this->mock_jetpack_connection_manager_is_connected( true );
+		$this->register_routes();
+
+		$request  = new WP_REST_Request( 'GET', '/wc-push-notifications/preferences' );
+		$response = $this->server->dispatch( $request );
+
+		$this->assertSame( WP_Http::OK, $response->get_status() );
+
+		$data = $response->get_data();
+		$this->assertArrayHasKey( 'store_stock', $data );
+		$this->assertArrayHasKey( 'enabled', $data['store_stock'] );
+		$this->assertArrayHasKey( 'low_stock', $data['store_stock'] );
+		$this->assertArrayHasKey( 'out_of_stock', $data['store_stock'] );
+		$this->assertArrayHasKey( 'on_backorder', $data['store_stock'] );
+	}
+
+	/**
+	 * @testdox POST should accept and persist store_stock sub-flag updates.
+	 */
+	public function test_post_preferences_updates_stock_sub_flags() {
+		wp_set_current_user( $this->user_id );
+		$this->mock_jetpack_connection_manager_is_connected( true );
+		$this->register_routes();
+
+		$request = new WP_REST_Request( 'POST', '/wc-push-notifications/preferences' );
+		$request->set_param(
+			'store_stock',
+			array(
+				'low_stock'    => false,
+				'on_backorder' => true,
+			)
+		);
+
+		$response = $this->server->dispatch( $request );
+
+		$this->assertSame( WP_Http::OK, $response->get_status() );
+
+		$data = $response->get_data();
+		$this->assertFalse( $data['store_stock']['low_stock'] );
+		$this->assertTrue( $data['store_stock']['on_backorder'] );
+	}
+
+	/**
+	 * @testdox POST should reject non-boolean store_stock sub-fields via REST validation.
+	 */
+	public function test_post_preferences_rejects_non_boolean_stock_sub_flag() {
+		wp_set_current_user( $this->user_id );
+		$this->mock_jetpack_connection_manager_is_connected( true );
+		$this->register_routes();
+
+		$request = new WP_REST_Request( 'POST', '/wc-push-notifications/preferences' );
+		$request->set_param( 'store_stock', array( 'low_stock' => 'not-a-boolean' ) );
+
+		$response = $this->server->dispatch( $request );
+
+		$this->assertSame( WP_Http::BAD_REQUEST, $response->get_status() );
+	}
+
 	/**
 	 * @testdox Should not collide with PushTokenRestController on the WC REST namespaces filter.
 	 *
diff --git a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Notifications/NotificationTest.php b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Notifications/NotificationTest.php
index 4b3e91aa3c1..25e73e6c087 100644
--- a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Notifications/NotificationTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Notifications/NotificationTest.php
@@ -7,6 +7,7 @@ namespace Automattic\WooCommerce\Tests\Internal\PushNotifications\Notifications;
 use Automattic\WooCommerce\Internal\PushNotifications\Notifications\NewOrderNotification;
 use Automattic\WooCommerce\Internal\PushNotifications\Notifications\NewReviewNotification;
 use Automattic\WooCommerce\Internal\PushNotifications\Notifications\Notification;
+use Automattic\WooCommerce\Internal\PushNotifications\Notifications\StockNotification;
 use InvalidArgumentException;
 use WC_Unit_Test_Case;

@@ -60,6 +61,7 @@ class NotificationTest extends WC_Unit_Test_Case {
 	 * @testdox from_array should create correct notification for $type type.
 	 * @testWith ["store_order", "Automattic\\WooCommerce\\Internal\\PushNotifications\\Notifications\\NewOrderNotification"]
 	 *           ["store_review", "Automattic\\WooCommerce\\Internal\\PushNotifications\\Notifications\\NewReviewNotification"]
+	 *           ["store_stock", "Automattic\\WooCommerce\\Internal\\PushNotifications\\Notifications\\StockNotification"]
 	 *
 	 * @param string $type           The notification type.
 	 * @param string $expected_class The expected class name.
@@ -144,4 +146,36 @@ class NotificationTest extends WC_Unit_Test_Case {

 		$this->assertSame( $expected, $notification->should_send_to_user( $pref_value ) );
 	}
+
+	/**
+	 * @testdox from_array should call hydrate() on classes that implement it.
+	 */
+	public function test_from_array_hydrates_extra_fields(): void {
+		$notification = Notification::from_array(
+			array(
+				'type'        => 'store_stock',
+				'resource_id' => 42,
+				'event_type'  => 'out_of_stock',
+			)
+		);
+
+		$this->assertInstanceOf( StockNotification::class, $notification );
+		$this->assertSame( 'out_of_stock', $notification->get_event_type() );
+	}
+
+	/**
+	 * @testdox from_array should not break for types without hydrate() when extra data is present.
+	 */
+	public function test_from_array_ignores_extra_fields_for_types_without_hydrate(): void {
+		$notification = Notification::from_array(
+			array(
+				'type'        => 'store_order',
+				'resource_id' => 42,
+				'extra'       => 'should be ignored',
+			)
+		);
+
+		$this->assertInstanceOf( NewOrderNotification::class, $notification );
+		$this->assertSame( 42, $notification->get_resource_id() );
+	}
 }
diff --git a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Notifications/StockNotificationTest.php b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Notifications/StockNotificationTest.php
new file mode 100644
index 00000000000..62bfa258d04
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Notifications/StockNotificationTest.php
@@ -0,0 +1,414 @@
+<?php
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\PushNotifications\Notifications;
+
+use Automattic\WooCommerce\Internal\PushNotifications\Notifications\StockNotification;
+use InvalidArgumentException;
+use WC_Helper_Product;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for the StockNotification class.
+ */
+class StockNotificationTest extends WC_Unit_Test_Case {
+	/**
+	 * @testdox Should return store_stock as the notification type.
+	 */
+	public function test_type_is_store_stock(): void {
+		$notification = new StockNotification( 1 );
+
+		$this->assertSame( 'store_stock', $notification->get_type() );
+	}
+
+	/**
+	 * @testdox Should return the product ID as the resource ID.
+	 */
+	public function test_resource_id_matches_product_id(): void {
+		$notification = new StockNotification( 42 );
+
+		$this->assertSame( 42, $notification->get_resource_id() );
+	}
+
+	/**
+	 * @testdox Should return the event type passed to the constructor.
+	 * @dataProvider event_types_provider
+	 *
+	 * @param string $event_type The event type constant.
+	 */
+	public function test_get_event_type_returns_constructor_value( string $event_type ): void {
+		$notification = new StockNotification( 1, $event_type );
+
+		$this->assertSame( $event_type, $notification->get_event_type() );
+	}
+
+	/**
+	 * @testdox Should default event_type to low_stock when not provided.
+	 */
+	public function test_constructor_defaults_event_type_to_low_stock(): void {
+		$notification = new StockNotification( 1 );
+
+		$this->assertSame( StockNotification::EVENT_LOW_STOCK, $notification->get_event_type() );
+	}
+
+	/**
+	 * @testdox Should throw for an invalid event type.
+	 */
+	public function test_constructor_throws_for_invalid_event_type(): void {
+		$this->expectException( InvalidArgumentException::class );
+
+		new StockNotification( 1, 'invalid_event' );
+	}
+
+	/**
+	 * @testdox Should return a payload with all required keys for an existing product.
+	 */
+	public function test_to_payload_contains_required_keys(): void {
+		$product = WC_Helper_Product::create_simple_product(
+			true,
+			array(
+				'manage_stock'   => true,
+				'stock_quantity' => 3,
+			)
+		);
+
+		$notification = new StockNotification( $product->get_id(), StockNotification::EVENT_LOW_STOCK );
+		$payload      = $notification->to_payload();
+
+		$this->assertArrayHasKey( 'type', $payload );
+		$this->assertArrayHasKey( 'timestamp', $payload );
+		$this->assertArrayHasKey( 'resource_id', $payload );
+		$this->assertArrayHasKey( 'title', $payload );
+		$this->assertArrayHasKey( 'format', $payload['title'] );
+		$this->assertArrayHasKey( 'args', $payload['title'] );
+		$this->assertArrayHasKey( 'message', $payload );
+		$this->assertArrayHasKey( 'format', $payload['message'] );
+		$this->assertArrayHasKey( 'args', $payload['message'] );
+		$this->assertArrayHasKey( 'icon', $payload );
+		$this->assertArrayHasKey( 'meta', $payload );
+		$this->assertArrayHasKey( 'product_id', $payload['meta'] );
+		$this->assertArrayHasKey( 'event_type', $payload['meta'] );
+	}
+
+	/**
+	 * @testdox Should vary title format by event type.
+	 * @dataProvider event_type_title_provider
+	 *
+	 * @param string $event_type      The event type constant.
+	 * @param string $expected_prefix The expected start of the title format.
+	 */
+	public function test_to_payload_varies_title_by_event_type( string $event_type, string $expected_prefix ): void {
+		$product      = WC_Helper_Product::create_simple_product();
+		$notification = new StockNotification( $product->get_id(), $event_type );
+		$payload      = $notification->to_payload();
+
+		$this->assertStringStartsWith( $expected_prefix, $payload['title']['format'] );
+	}
+
+	/**
+	 * @testdox Should include stock quantity in the low_stock message args.
+	 */
+	public function test_to_payload_includes_stock_quantity_for_low_stock(): void {
+		$product = WC_Helper_Product::create_simple_product(
+			true,
+			array(
+				'manage_stock'   => true,
+				'stock_quantity' => 5,
+			)
+		);
+
+		$notification = new StockNotification( $product->get_id(), StockNotification::EVENT_LOW_STOCK );
+		$payload      = $notification->to_payload();
+
+		$this->assertSame( '5', $payload['message']['args'][1] );
+	}
+
+	/**
+	 * @testdox Should prefer the trigger-time stock snapshot over the product's current stock so cache lag in the dispatcher process can't surface a stale value.
+	 */
+	public function test_to_payload_uses_stock_quantity_at_trigger_for_low_stock(): void {
+		// Product currently shows stock=5 — simulating what a stale-cache re-fetch in the dispatcher process might return.
+		$product = WC_Helper_Product::create_simple_product(
+			true,
+			array(
+				'manage_stock'   => true,
+				'stock_quantity' => 5,
+			)
+		);
+
+		// But at trigger time, the actual post-decrement stock was 1. That's what the merchant should see in the push.
+		$notification = new StockNotification( $product->get_id(), StockNotification::EVENT_LOW_STOCK, 1 );
+		$payload      = $notification->to_payload();
+
+		$this->assertSame( '1', $payload['message']['args'][1] );
+	}
+
+	/**
+	 * @testdox For a simple product, meta.product_id should equal the product ID.
+	 */
+	public function test_to_payload_meta_product_id_for_simple_product(): void {
+		$product = WC_Helper_Product::create_simple_product(
+			true,
+			array(
+				'manage_stock'   => true,
+				'stock_quantity' => 0,
+			)
+		);
+
+		$notification = new StockNotification( $product->get_id(), StockNotification::EVENT_OUT_OF_STOCK );
+		$payload      = $notification->to_payload();
+
+		$this->assertSame( $product->get_id(), $payload['meta']['product_id'] );
+		$this->assertSame( $product->get_id(), $payload['resource_id'] );
+	}
+
+	/**
+	 * @testdox For a variation, meta.product_id should be the parent product ID so mobile can navigate to the product details screen, while resource_id keeps the variation ID for identification.
+	 */
+	public function test_to_payload_meta_product_id_for_variation(): void {
+		$parent     = WC_Helper_Product::create_variation_product();
+		$variations = $parent->get_children();
+		$variation  = wc_get_product( $variations[0] );
+		$variation->set_manage_stock( true );
+		$variation->set_stock_quantity( 0 );
+		$variation->save();
+
+		$notification = new StockNotification( $variation->get_id(), StockNotification::EVENT_OUT_OF_STOCK );
+		$payload      = $notification->to_payload();
+
+		$this->assertSame( $parent->get_id(), $payload['meta']['product_id'] );
+		$this->assertSame( $variation->get_id(), $payload['resource_id'] );
+		$this->assertNotSame( $variation->get_id(), $payload['meta']['product_id'] );
+	}
+
+	/**
+	 * @testdox Should return null when the product no longer exists.
+	 */
+	public function test_to_payload_returns_null_for_deleted_product(): void {
+		$notification = new StockNotification( 999999 );
+
+		$this->assertNull( $notification->to_payload() );
+	}
+
+	/**
+	 * @testdox to_array should include event_type alongside type and resource_id.
+	 */
+	public function test_to_array_includes_event_type(): void {
+		$notification = new StockNotification( 42, StockNotification::EVENT_OUT_OF_STOCK );
+		$data         = $notification->to_array();
+
+		$this->assertArrayHasKey( 'event_type', $data );
+		$this->assertSame( StockNotification::EVENT_OUT_OF_STOCK, $data['event_type'] );
+		$this->assertSame( 'store_stock', $data['type'] );
+		$this->assertSame( 42, $data['resource_id'] );
+	}
+
+	/**
+	 * @testdox hydrate should restore event_type from serialized data.
+	 */
+	public function test_hydrate_restores_event_type(): void {
+		$notification = new StockNotification( 1 );
+		$this->assertSame( StockNotification::EVENT_LOW_STOCK, $notification->get_event_type() );
+
+		$notification->hydrate( array( 'event_type' => StockNotification::EVENT_ON_BACKORDER ) );
+
+		$this->assertSame( StockNotification::EVENT_ON_BACKORDER, $notification->get_event_type() );
+	}
+
+	/**
+	 * @testdox hydrate should throw on an unrecognized event_type so corrupt safety-net jobs are dropped instead of dispatching the wrong subtype.
+	 */
+	public function test_hydrate_throws_on_invalid_event_type(): void {
+		$notification = new StockNotification( 1, StockNotification::EVENT_OUT_OF_STOCK );
+
+		$this->expectException( \InvalidArgumentException::class );
+
+		$notification->hydrate( array( 'event_type' => 'not-a-valid-event' ) );
+	}
+
+	/**
+	 * @testdox hydrate should keep current event_type when key is missing.
+	 */
+	public function test_hydrate_keeps_current_when_key_missing(): void {
+		$notification = new StockNotification( 1, StockNotification::EVENT_OUT_OF_STOCK );
+
+		$notification->hydrate( array() );
+
+		$this->assertSame( StockNotification::EVENT_OUT_OF_STOCK, $notification->get_event_type() );
+	}
+
+	/**
+	 * @testdox to_array should include the trigger-time stock snapshot when present.
+	 */
+	public function test_to_array_includes_stock_quantity_at_trigger(): void {
+		$notification = new StockNotification( 42, StockNotification::EVENT_LOW_STOCK, 1 );
+		$data         = $notification->to_array();
+
+		$this->assertArrayHasKey( 'stock_quantity_at_trigger', $data );
+		$this->assertSame( 1, $data['stock_quantity_at_trigger'] );
+	}
+
+	/**
+	 * @testdox hydrate should restore stock_quantity_at_trigger from serialized data so the safety-net path keeps the threshold-crossing value.
+	 */
+	public function test_hydrate_restores_stock_quantity_at_trigger(): void {
+		$product = WC_Helper_Product::create_simple_product(
+			true,
+			array(
+				'manage_stock'   => true,
+				'stock_quantity' => 5,
+			)
+		);
+
+		$notification = new StockNotification( $product->get_id(), StockNotification::EVENT_LOW_STOCK );
+		$notification->hydrate(
+			array(
+				'event_type'                => StockNotification::EVENT_LOW_STOCK,
+				'stock_quantity_at_trigger' => 1,
+			)
+		);
+
+		$payload = $notification->to_payload();
+		$this->assertSame( '1', $payload['message']['args'][1] );
+	}
+
+	/**
+	 * @testdox get_identifier should differ for the same product with different event types.
+	 */
+	public function test_get_identifier_differs_for_different_event_types(): void {
+		$low  = new StockNotification( 42, StockNotification::EVENT_LOW_STOCK );
+		$out  = new StockNotification( 42, StockNotification::EVENT_OUT_OF_STOCK );
+		$back = new StockNotification( 42, StockNotification::EVENT_ON_BACKORDER );
+
+		$this->assertNotSame( $low->get_identifier(), $out->get_identifier() );
+		$this->assertNotSame( $out->get_identifier(), $back->get_identifier() );
+		$this->assertNotSame( $low->get_identifier(), $back->get_identifier() );
+	}
+
+	/**
+	 * @testdox should_send_to_user should return true when enabled and event sub-flag is true.
+	 */
+	public function test_should_send_to_user_when_enabled_and_sub_flag_true(): void {
+		$notification = new StockNotification( 1, StockNotification::EVENT_LOW_STOCK );
+
+		$this->assertTrue(
+			$notification->should_send_to_user(
+				array(
+					'enabled'   => true,
+					'low_stock' => true,
+				)
+			)
+		);
+	}
+
+	/**
+	 * @testdox should_send_to_user should return false when enabled but event sub-flag is false.
+	 */
+	public function test_should_not_send_to_user_when_sub_flag_false(): void {
+		$notification = new StockNotification( 1, StockNotification::EVENT_ON_BACKORDER );
+
+		$this->assertFalse(
+			$notification->should_send_to_user(
+				array(
+					'enabled'      => true,
+					'on_backorder' => false,
+				)
+			)
+		);
+	}
+
+	/**
+	 * @testdox should_send_to_user should return false when notification is disabled regardless of sub-flags.
+	 */
+	public function test_should_not_send_to_user_when_disabled(): void {
+		$notification = new StockNotification( 1, StockNotification::EVENT_LOW_STOCK );
+
+		$this->assertFalse(
+			$notification->should_send_to_user(
+				array(
+					'enabled'   => false,
+					'low_stock' => true,
+				)
+			)
+		);
+	}
+
+	/**
+	 * @testdox should_send_to_user should default to true when event sub-flag key is missing.
+	 */
+	public function test_should_send_to_user_when_sub_flag_missing(): void {
+		$notification = new StockNotification( 1, StockNotification::EVENT_LOW_STOCK );
+
+		$this->assertTrue(
+			$notification->should_send_to_user( array( 'enabled' => true ) )
+		);
+	}
+
+	/**
+	 * @testdox should_send_to_user should return true when pref_value is null.
+	 */
+	public function test_should_send_to_user_when_pref_null(): void {
+		$notification = new StockNotification( 1 );
+
+		$this->assertTrue( $notification->should_send_to_user( null ) );
+	}
+
+	/**
+	 * @testdox Meta operations should use product post meta namespaced by event type.
+	 */
+	public function test_meta_operations_use_product_meta(): void {
+		$product      = WC_Helper_Product::create_simple_product();
+		$notification = new StockNotification( $product->get_id(), StockNotification::EVENT_LOW_STOCK );
+		$meta_key     = '_test_meta';
+
+		$this->assertFalse( $notification->has_meta( $meta_key ) );
+
+		$notification->write_meta( $meta_key );
+		$this->assertTrue( $notification->has_meta( $meta_key ) );
+
+		$notification->delete_meta( $meta_key );
+		$this->assertFalse( $notification->has_meta( $meta_key ) );
+	}
+
+	/**
+	 * @testdox Meta keys should be namespaced by event type to avoid collisions.
+	 */
+	public function test_meta_key_is_namespaced_by_event_type(): void {
+		$product  = WC_Helper_Product::create_simple_product();
+		$low      = new StockNotification( $product->get_id(), StockNotification::EVENT_LOW_STOCK );
+		$out      = new StockNotification( $product->get_id(), StockNotification::EVENT_OUT_OF_STOCK );
+		$meta_key = '_test_meta';
+
+		$low->write_meta( $meta_key );
+
+		$this->assertTrue( $low->has_meta( $meta_key ) );
+		$this->assertFalse( $out->has_meta( $meta_key ) );
+	}
+
+	/**
+	 * Data provider for all valid event types.
+	 *
+	 * @return array<string, array{string}>
+	 */
+	public function event_types_provider(): array {
+		return array(
+			'low_stock'    => array( StockNotification::EVENT_LOW_STOCK ),
+			'out_of_stock' => array( StockNotification::EVENT_OUT_OF_STOCK ),
+			'on_backorder' => array( StockNotification::EVENT_ON_BACKORDER ),
+		);
+	}
+
+	/**
+	 * Data provider mapping event types to expected title prefixes.
+	 *
+	 * @return array<string, array{string, string}>
+	 */
+	public function event_type_title_provider(): array {
+		return array(
+			'low_stock'    => array( StockNotification::EVENT_LOW_STOCK, 'Low stock:' ),
+			'out_of_stock' => array( StockNotification::EVENT_OUT_OF_STOCK, 'Out of stock:' ),
+			'on_backorder' => array( StockNotification::EVENT_ON_BACKORDER, 'Backordered:' ),
+		);
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Services/NotificationPreferencesServiceTest.php b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Services/NotificationPreferencesServiceTest.php
index 743d0d77c96..1e44f279c79 100644
--- a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Services/NotificationPreferencesServiceTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Services/NotificationPreferencesServiceTest.php
@@ -285,6 +285,7 @@ class NotificationPreferencesServiceTest extends WC_Unit_Test_Case {
 		$this->assertIsArray( $defaults );
 		$this->assertArrayHasKey( 'store_order', $defaults );
 		$this->assertArrayHasKey( 'store_review', $defaults );
+		$this->assertArrayHasKey( 'store_stock', $defaults );

 		foreach ( $defaults as $type => $shape ) {
 			$this->assertIsArray( $shape, "Default for {$type} should be an object/array." );
@@ -445,4 +446,98 @@ class NotificationPreferencesServiceTest extends WC_Unit_Test_Case {
 			array( 'store_review' => array( 'enabled' => false ) )
 		);
 	}
+
+	/**
+	 * @testdox Should include stock sub-flag defaults for store_stock.
+	 */
+	public function test_get_defaults_includes_stock_sub_flags(): void {
+		$defaults = $this->sut->get_defaults();
+
+		$this->assertArrayHasKey( 'low_stock', $defaults['store_stock'] );
+		$this->assertTrue( $defaults['store_stock']['low_stock'] );
+		$this->assertArrayHasKey( 'out_of_stock', $defaults['store_stock'] );
+		$this->assertTrue( $defaults['store_stock']['out_of_stock'] );
+		$this->assertArrayHasKey( 'on_backorder', $defaults['store_stock'] );
+		$this->assertTrue( $defaults['store_stock']['on_backorder'] );
+	}
+
+	/**
+	 * @testdox Should coerce stock sub-flags to booleans.
+	 */
+	public function test_sanitize_coerces_stock_sub_flags_to_bool(): void {
+		$this->data_store->method( 'read' )->willReturn( null );
+
+		$result = $this->sut->save_preferences(
+			$this->user_id,
+			array(
+				'store_stock' => array(
+					'low_stock'    => 1,
+					'out_of_stock' => 0,
+				),
+			)
+		);
+
+		$this->assertTrue( $result['store_stock']['low_stock'] );
+		$this->assertFalse( $result['store_stock']['out_of_stock'] );
+	}
+
+	/**
+	 * @testdox Should drop unknown sub-fields within store_stock.
+	 */
+	public function test_save_preferences_drops_unknown_stock_sub_fields(): void {
+		$this->data_store->method( 'read' )->willReturn( null );
+
+		$result = $this->sut->save_preferences(
+			$this->user_id,
+			array(
+				'store_stock' => array(
+					'enabled'       => true,
+					'low_stock'     => true,
+					'unknown_event' => true,
+				),
+			)
+		);
+
+		$this->assertArrayHasKey( 'store_stock', $result );
+		$this->assertArrayNotHasKey( 'unknown_event', $result['store_stock'] );
+	}
+
+	/**
+	 * @testdox Should deep-merge partial store_stock updates preserving unrelated sub-fields.
+	 */
+	public function test_save_preferences_deep_merges_stock_sub_flags(): void {
+		$this->data_store->method( 'read' )->willReturn(
+			array(
+				'schema_version' => NotificationPreferencesDataStore::CURRENT_SCHEMA_VERSION,
+				'preferences'    => array(
+					'store_stock' => array(
+						'enabled'      => true,
+						'low_stock'    => true,
+						'out_of_stock' => true,
+						'on_backorder' => false,
+					),
+				),
+			)
+		);
+
+		$this->data_store
+			->expects( $this->once() )
+			->method( 'write' )
+			->with(
+				$this->anything(),
+				$this->callback(
+					function ( $envelope ) {
+						$prefs = $envelope['preferences']['store_stock'];
+						return false === $prefs['low_stock']
+							&& true === $prefs['out_of_stock']
+							&& false === $prefs['on_backorder'];
+					}
+				)
+			);
+
+		$this->sut->save_preferences(
+			$this->user_id,
+			array( 'store_stock' => array( 'low_stock' => false ) )
+		);
+	}
 }
diff --git a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Services/NotificationProcessorTest.php b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Services/NotificationProcessorTest.php
index 7ba071671ed..c8871bb5957 100644
--- a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Services/NotificationProcessorTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Services/NotificationProcessorTest.php
@@ -10,6 +10,7 @@ use Automattic\WooCommerce\Internal\PushNotifications\Entities\PushToken;
 use Automattic\WooCommerce\Internal\PushNotifications\Notifications\NewOrderNotification;
 use Automattic\WooCommerce\Internal\PushNotifications\Notifications\NewReviewNotification;
 use Automattic\WooCommerce\Internal\PushNotifications\Notifications\Notification;
+use Automattic\WooCommerce\Internal\PushNotifications\Notifications\StockNotification;
 use Automattic\WooCommerce\Internal\PushNotifications\PushNotifications;
 use Automattic\WooCommerce\Internal\PushNotifications\Services\NotificationPreferencesService;
 use Automattic\WooCommerce\Internal\PushNotifications\Services\NotificationProcessor;
@@ -80,6 +81,12 @@ class NotificationProcessorTest extends WC_Unit_Test_Case {
 					'min_amount' => null,
 				),
 				'store_review' => array( 'enabled' => true ),
+				'store_stock'  => array(
+					'enabled'      => true,
+					'low_stock'    => true,
+					'out_of_stock' => true,
+					'on_backorder' => false,
+				),
 			)
 		);

@@ -603,4 +610,189 @@ class NotificationProcessorTest extends WC_Unit_Test_Case {
 		$this->assertTrue( $result );
 		$this->assertNotEmpty( get_comment_meta( $comment_id, NotificationProcessor::SENT_META_KEY, true ) );
 	}
+
+	/**
+	 * @testdox Should handle safety net callback with a stock notification event_type.
+	 */
+	public function test_handle_safety_net_with_stock_event_type(): void {
+		$product = WC_Helper_Product::create_simple_product(
+			true,
+			array(
+				'manage_stock'   => true,
+				'stock_quantity' => 3,
+			)
+		);
+
+		$this->dispatcher->expects( $this->once() )->method( 'dispatch' )->willReturn(
+			array(
+				'success'     => true,
+				'retry_after' => null,
+			)
+		);
+
+		$this->sut->handle_safety_net(
+			'store_stock',
+			$product->get_id(),
+			array( 'event_type' => 'low_stock' )
+		);
+
+		$refreshed = wc_get_product( $product->get_id() );
+		$this->assertNotEmpty(
+			$refreshed->get_meta( NotificationProcessor::SENT_META_KEY . '_low_stock' )
+		);
+	}
+
+	/**
+	 * @testdox Safety net should default the extras array so notification types without subclass-specific state still work.
+	 */
+	public function test_handle_safety_net_omits_extras_for_simple_types(): void {
+		$this->dispatcher->expects( $this->once() )->method( 'dispatch' )->willReturn(
+			array(
+				'success'     => true,
+				'retry_after' => null,
+			)
+		);
+
+		$this->sut->handle_safety_net( 'store_order', $this->order_id );
+
+		$order = wc_get_order( $this->order_id );
+		$this->assertNotEmpty( $order->get_meta( NotificationProcessor::SENT_META_KEY ) );
+	}
+
+	/**
+	 * @testdox Safety net should ignore type/resource_id keys smuggled into the extras array so the positional params remain authoritative.
+	 */
+	public function test_handle_safety_net_extras_cannot_override_positional_params(): void {
+		$product = WC_Helper_Product::create_simple_product(
+			true,
+			array(
+				'manage_stock'   => true,
+				'stock_quantity' => 3,
+			)
+		);
+
+		$captured_notification = null;
+		$this->dispatcher
+			->expects( $this->once() )
+			->method( 'dispatch' )
+			->with(
+				$this->callback(
+					function ( $notification ) use ( &$captured_notification ) {
+						$captured_notification = $notification;
+						return true;
+					}
+				)
+			)
+			->willReturn(
+				array(
+					'success'     => true,
+					'retry_after' => null,
+				)
+			);
+
+		// `type` and `resource_id` keys inside the extras array are smuggled values
+		// that must be ignored — the positional params should remain authoritative.
+		$this->sut->handle_safety_net(
+			'store_stock',
+			$product->get_id(),
+			array(
+				'event_type'  => 'low_stock',
+				'type'        => 'store_order',
+				'resource_id' => 999999,
+			)
+		);
+
+		$this->assertNotNull( $captured_notification );
+		$this->assertSame( 'store_stock', $captured_notification->get_type() );
+		$this->assertSame( $product->get_id(), $captured_notification->get_resource_id() );
+	}
+
+	/**
+	 * @testdox Safety net should propagate the stock quantity captured at trigger time when reconstructing the notification.
+	 */
+	public function test_handle_safety_net_with_stock_quantity_at_trigger(): void {
+		$product = WC_Helper_Product::create_simple_product(
+			true,
+			array(
+				'manage_stock'   => true,
+				// Product currently shows stock=5 (the dispatcher might re-fetch and see this).
+				'stock_quantity' => 5,
+			)
+		);
+
+		$captured_payload = null;
+		$this->dispatcher
+			->expects( $this->once() )
+			->method( 'dispatch' )
+			->with(
+				$this->callback(
+					function ( $notification ) use ( &$captured_payload ) {
+						$captured_payload = $notification->to_payload();
+						return true;
+					}
+				)
+			)
+			->willReturn(
+				array(
+					'success'     => true,
+					'retry_after' => null,
+				)
+			);
+
+		// Trigger-time stock was 1 (post-decrement), even though current product stock is 5.
+		$this->sut->handle_safety_net(
+			'store_stock',
+			$product->get_id(),
+			array(
+				'event_type'                => 'low_stock',
+				'stock_quantity_at_trigger' => 1,
+			)
+		);
+
+		$this->assertNotNull( $captured_payload );
+		$this->assertSame( '1', $captured_payload['message']['args'][1] );
+	}
+
+	/**
+	 * @testdox Should skip dispatch for stock notification when the matching sub-flag is disabled.
+	 */
+	public function test_process_skips_dispatch_when_stock_sub_flag_disabled(): void {
+		$product = WC_Helper_Product::create_simple_product(
+			true,
+			array(
+				'manage_stock'   => true,
+				'stock_quantity' => 3,
+			)
+		);
+
+		$preferences_service = $this->createMock( NotificationPreferencesService::class );
+		$preferences_service->method( 'get_preferences' )->willReturn(
+			array(
+				'store_order'  => array(
+					'enabled'    => true,
+					'min_amount' => null,
+				),
+				'store_review' => array(
+					'enabled'    => true,
+					'max_rating' => null,
+				),
+				'store_stock'  => array(
+					'enabled'      => true,
+					'low_stock'    => false,
+					'out_of_stock' => true,
+					'on_backorder' => false,
+				),
+			)
+		);
+
+		$this->dispatcher->expects( $this->never() )->method( 'dispatch' );
+
+		$sut = new NotificationProcessor();
+		$sut->init( $this->dispatcher, $this->data_store, $preferences_service );
+
+		$notification = new StockNotification( $product->get_id(), StockNotification::EVENT_LOW_STOCK );
+		$result       = $sut->process( $notification );
+
+		$this->assertTrue( $result );
+	}
 }
diff --git a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Services/PendingNotificationStoreTest.php b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Services/PendingNotificationStoreTest.php
index 26a7b522d19..18f3028e7c9 100644
--- a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Services/PendingNotificationStoreTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Services/PendingNotificationStoreTest.php
@@ -7,6 +7,7 @@ namespace Automattic\WooCommerce\Tests\Internal\PushNotifications\Services;
 use Automattic\WooCommerce\Internal\PushNotifications\Dispatchers\InternalNotificationDispatcher;
 use Automattic\WooCommerce\Internal\PushNotifications\Notifications\NewOrderNotification;
 use Automattic\WooCommerce\Internal\PushNotifications\Notifications\NewReviewNotification;
+use Automattic\WooCommerce\Internal\PushNotifications\Notifications\StockNotification;
 use Automattic\WooCommerce\Internal\PushNotifications\Services\PendingNotificationStore;
 use WC_Unit_Test_Case;

@@ -203,4 +204,38 @@ class PendingNotificationStoreTest extends WC_Unit_Test_Case {
 			->onlyMethods( array( 'to_payload', 'has_meta', 'write_meta' ) )
 			->getMock();
 	}
+
+	/**
+	 * Creates a mock StockNotification that avoids database calls.
+	 *
+	 * @param int    $resource_id The resource ID.
+	 * @param string $event_type  The stock event type.
+	 * @return StockNotification
+	 */
+	private function create_stock_mock( int $resource_id, string $event_type ): StockNotification {
+		return $this->getMockBuilder( StockNotification::class )
+			->setConstructorArgs( array( $resource_id, $event_type ) )
+			->onlyMethods( array( 'to_payload', 'has_meta', 'write_meta' ) )
+			->getMock();
+	}
+
+	/**
+	 * @testdox Should store different stock event types for the same product separately.
+	 */
+	public function test_add_allows_different_stock_event_types_for_same_product(): void {
+		$this->store->add( $this->create_stock_mock( 42, StockNotification::EVENT_LOW_STOCK ) );
+		$this->store->add( $this->create_stock_mock( 42, StockNotification::EVENT_OUT_OF_STOCK ) );
+
+		$this->assertSame( 2, $this->store->count() );
+	}
+
+	/**
+	 * @testdox Should deduplicate the same stock event type for the same product.
+	 */
+	public function test_add_deduplicates_same_stock_event_type_and_product(): void {
+		$this->store->add( $this->create_stock_mock( 42, StockNotification::EVENT_LOW_STOCK ) );
+		$this->store->add( $this->create_stock_mock( 42, StockNotification::EVENT_LOW_STOCK ) );
+
+		$this->assertSame( 1, $this->store->count() );
+	}
 }
diff --git a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Triggers/StockNotificationTriggerTest.php b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Triggers/StockNotificationTriggerTest.php
new file mode 100644
index 00000000000..765b3e0ade5
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Triggers/StockNotificationTriggerTest.php
@@ -0,0 +1,190 @@
+<?php
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\PushNotifications\Triggers;
+
+use Automattic\WooCommerce\Internal\PushNotifications\Dispatchers\InternalNotificationDispatcher;
+use Automattic\WooCommerce\Internal\PushNotifications\Notifications\StockNotification;
+use Automattic\WooCommerce\Internal\PushNotifications\Services\PendingNotificationStore;
+use Automattic\WooCommerce\Internal\PushNotifications\Triggers\StockNotificationTrigger;
+use WC_Helper_Product;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for the StockNotificationTrigger class.
+ */
+class StockNotificationTriggerTest extends WC_Unit_Test_Case {
+	/**
+	 * An instance of StockNotificationTrigger.
+	 *
+	 * @var StockNotificationTrigger
+	 */
+	private $trigger;
+
+	/**
+	 * The notification store used by the trigger.
+	 *
+	 * @var PendingNotificationStore
+	 */
+	private $store;
+
+	/**
+	 * Set up test fixtures.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+
+		$dispatcher  = $this->createMock( InternalNotificationDispatcher::class );
+		$this->store = new PendingNotificationStore();
+
+		$this->store->init( $dispatcher );
+		$this->store->register();
+
+		wc_get_container()->replace( PendingNotificationStore::class, $this->store );
+		wc_get_container()->reset_all_resolved();
+
+		$this->trigger = new StockNotificationTrigger();
+		$this->trigger->register();
+	}
+
+	/**
+	 * Tear down test fixtures.
+	 */
+	public function tearDown(): void {
+		remove_action( 'woocommerce_low_stock', array( $this->trigger, 'on_low_stock' ) );
+		remove_action( 'woocommerce_no_stock', array( $this->trigger, 'on_no_stock' ) );
+		remove_action( 'woocommerce_product_on_backorder', array( $this->trigger, 'on_backorder' ) );
+		remove_action( 'shutdown', array( $this->store, 'dispatch_all' ) );
+
+		$this->reset_container_replacements();
+		wc_get_container()->reset_all_resolved();
+
+		parent::tearDown();
+	}
+
+	/**
+	 * @testdox Should add a notification when the low stock hook fires and capture the stock quantity at trigger time.
+	 */
+	public function test_low_stock_hook_adds_notification(): void {
+		$product = WC_Helper_Product::create_simple_product(
+			true,
+			array(
+				'manage_stock'   => true,
+				'stock_quantity' => 2,
+			)
+		);
+
+		$this->trigger->on_low_stock( $product );
+
+		$this->assertSame( 1, $this->store->count() );
+
+		$notifications = $this->store->get_all();
+		$this->assertInstanceOf( StockNotification::class, $notifications[0] );
+		$this->assertSame( StockNotification::EVENT_LOW_STOCK, $notifications[0]->get_event_type() );
+
+		// The stock snapshot is captured at trigger time so the dispatcher (which runs in a separate process)
+		// doesn't read a stale value if cache invalidation hasn't propagated.
+		$this->assertSame( 2, $notifications[0]->to_array()['stock_quantity_at_trigger'] );
+	}
+
+	/**
+	 * @testdox Should add a notification when the no stock hook fires.
+	 */
+	public function test_no_stock_hook_adds_notification(): void {
+		$product = WC_Helper_Product::create_simple_product(
+			true,
+			array(
+				'manage_stock'   => true,
+				'stock_quantity' => 0,
+			)
+		);
+
+		$this->trigger->on_no_stock( $product );
+
+		$this->assertSame( 1, $this->store->count() );
+
+		$notifications = $this->store->get_all();
+		$this->assertSame( StockNotification::EVENT_OUT_OF_STOCK, $notifications[0]->get_event_type() );
+	}
+
+	/**
+	 * @testdox Should add a notification when the backorder hook fires with a valid product.
+	 */
+	public function test_backorder_hook_adds_notification(): void {
+		$product = WC_Helper_Product::create_simple_product();
+
+		$this->trigger->on_backorder(
+			array(
+				'product'  => $product,
+				'order_id' => 1,
+				'quantity' => 3,
+			)
+		);
+
+		$this->assertSame( 1, $this->store->count() );
+
+		$notifications = $this->store->get_all();
+		$this->assertSame( StockNotification::EVENT_ON_BACKORDER, $notifications[0]->get_event_type() );
+	}
+
+	/**
+	 * @testdox Should ignore the backorder hook when the product is not a WC_Product.
+	 */
+	public function test_backorder_hook_ignores_invalid_product(): void {
+		$this->trigger->on_backorder(
+			array(
+				'product'  => 'not-a-product',
+				'order_id' => 1,
+				'quantity' => 3,
+			)
+		);
+
+		$this->assertSame( 0, $this->store->count() );
+	}
+
+	/**
+	 * @testdox Should ignore the backorder hook when the product key is missing.
+	 */
+	public function test_backorder_hook_ignores_missing_product(): void {
+		$this->trigger->on_backorder( array( 'order_id' => 1 ) );
+
+		$this->assertSame( 0, $this->store->count() );
+	}
+
+	/**
+	 * @testdox Should store different event types for the same product separately.
+	 */
+	public function test_different_events_same_product_stored_separately(): void {
+		$product = WC_Helper_Product::create_simple_product(
+			true,
+			array(
+				'manage_stock'   => true,
+				'stock_quantity' => 0,
+			)
+		);
+
+		$this->trigger->on_low_stock( $product );
+		$this->trigger->on_no_stock( $product );
+
+		$this->assertSame( 2, $this->store->count() );
+	}
+
+	/**
+	 * @testdox Should deduplicate the same event type for the same product.
+	 */
+	public function test_same_event_same_product_deduplicated(): void {
+		$product = WC_Helper_Product::create_simple_product(
+			true,
+			array(
+				'manage_stock'   => true,
+				'stock_quantity' => 2,
+			)
+		);
+
+		$this->trigger->on_low_stock( $product );
+		$this->trigger->on_low_stock( $product );
+
+		$this->assertSame( 1, $this->store->count() );
+	}
+}