Commit 98f7bfd92e6 for woocommerce

commit 98f7bfd92e6daf21d23272436c8ebafd64cf32eb
Author: Chris Lilitsas <1105590+xristos3490@users.noreply.github.com>
Date:   Mon Nov 2 10:53:18 2026 +0200

    [BIS] Fire ID-based lifecycle actions for stock notifications (#69878)

    * feat: fire lifecycle actions for stock notifications

    Fire ID-based actions from the stock notifications data store so every
    path that creates, changes the status of or deletes a notification is
    covered: woocommerce_new_stock_notification,
    woocommerce_stock_notification_status_changed and
    woocommerce_delete_stock_notification. The privacy eraser also fires
    woocommerce_stock_notification_erased with the original email before
    the row is anonymized, so integrations can locate the external profile.

    * feat: fire woocommerce_update_stock_notification on data store update

    Completes the new / update / delete set the other core data stores
    expose, firing after apply_changes() and before the status_changed
    action so a listener sees the persisted row first.

    * fix: capture persisted status before firing the update action

    A listener on woocommerce_update_stock_notification that calls set_status()
    repopulates the object's pending changes, so reading get_status( 'edit' )
    after the action would compare against the listener's unsaved value and
    could suppress or misreport woocommerce_stock_notification_status_changed.
    Snapshot the status right after apply_changes() instead.

    * fix: stop the stock email saving the notification mid-render

    CustomerStockNotificationEmail saved the notification while building the
    unsubscribe link, firing woocommerce_update_stock_notification inside the
    processor's send try. A listener that threw there turned into a failed
    send, cancelled the sign-up, and threw again on the final save, ending the
    batch before the next one was scheduled.

    Generate the key once in prepare_email() without saving; both callers
    already save after sending. This also keeps the HTML and plain parts of a
    multipart email on the same key.

    * docs: drop caller lists from stock notification lifecycle action docblocks

    The lists had already drifted: retention cleanup only deletes, yet it was listed under update and status_changed. The data store fires these actions for every caller, so naming callers adds nothing.

    * refactor: rename the stock notification eraser action to a before hook

    woocommerce_stock_notification_erased fires before the notification is anonymized and saved, and that save can still fail. Rename it to woocommerce_privacy_before_remove_stock_notification_personal_data, matching core's woocommerce_privacy_before_remove_order_personal_data. Its @since moves to 11.4.0.

    * docs: target 11.4.0 and note pending sign-ups on the new stock notification action

    release/11.3 has been cut, so the lifecycle actions ship in 11.4.0. With double opt-in a new notification starts as pending, so point integrations that want confirmed sign-ups at status_changed to active.

    * chore: drop stale PHPStan baseline entry for the stock email unsubscribe key

    * chore: set @since to 11.3.0 for stock notification lifecycle hooks

    * docs: qualify when status_changed fires after the stock notification eraser

diff --git a/plugins/woocommerce/changelog/add-stock-notification-lifecycle-actions b/plugins/woocommerce/changelog/add-stock-notification-lifecycle-actions
new file mode 100644
index 00000000000..769388a76b9
--- /dev/null
+++ b/plugins/woocommerce/changelog/add-stock-notification-lifecycle-actions
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Fire ID-based lifecycle actions when a stock notification is created, updated, changes status, is deleted or is about to be anonymized by the privacy eraser.
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index 2caaad452f7..6ddad26f090 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -64122,12 +64122,6 @@ parameters:
 			count: 1
 			path: src/Internal/StockNotifications/Emails/CustomerStockNotificationEmail.php

-		-
-			message: '#^Cannot call method get_unsubscribe_key\(\) on bool\|object\.$#'
-			identifier: method.nonObject
-			count: 1
-			path: src/Internal/StockNotifications/Emails/CustomerStockNotificationEmail.php
-
 		-
 			message: '#^Method Automattic\\WooCommerce\\Internal\\StockNotifications\\Emails\\CustomerStockNotificationEmail\:\:maybe_restore_notification_locale\(\) has no return type specified\.$#'
 			identifier: missingType.return
diff --git a/plugins/woocommerce/src/Internal/DataStores/StockNotifications/StockNotificationsDataStore.php b/plugins/woocommerce/src/Internal/DataStores/StockNotifications/StockNotificationsDataStore.php
index da9d24b99a3..0ec8e589734 100644
--- a/plugins/woocommerce/src/Internal/DataStores/StockNotifications/StockNotificationsDataStore.php
+++ b/plugins/woocommerce/src/Internal/DataStores/StockNotifications/StockNotificationsDataStore.php
@@ -198,7 +198,22 @@ CREATE TABLE $meta_table_name (
 		$notification->save_meta_data();
 		$notification->apply_changes();

-		return $notification->get_id();
+		/**
+		 * Action: woocommerce_new_stock_notification
+		 *
+		 * Fires after a stock notification is created, from any code path.
+		 * With double opt-in the new notification is `pending` until the shopper verifies it, so
+		 * integrations that only want confirmed sign-ups can wait for
+		 * `woocommerce_stock_notification_status_changed` to `active` instead.
+		 *
+		 * @since 11.3.0
+		 *
+		 * @param int          $notification_id The notification ID.
+		 * @param Notification $notification    The notification.
+		 */
+		do_action( 'woocommerce_new_stock_notification', $notification_id, $notification );
+
+		return $notification_id;
 	}

 	/**
@@ -263,8 +278,9 @@ CREATE TABLE $meta_table_name (
 			return new \WP_Error( 'invalid_stock_notification', 'Invalid notification ID.' );
 		}

-		$changes = $notification->get_changes();
-		$result  = 0;
+		$changes     = $notification->get_changes();
+		$result      = 0;
+		$status_from = $notification->get_data()['status'];

 		if ( array_intersect( array( 'product_id', 'user_id', 'user_email', 'status', 'date_modified', 'date_confirmed', 'date_last_attempt', 'date_notified', 'date_cancelled', 'cancellation_source' ), array_keys( $changes ) ) ) {

@@ -303,6 +319,37 @@ CREATE TABLE $meta_table_name (
 			$notification->apply_changes();
 		}

+		$status_to = $notification->get_status( 'edit' );
+
+		/**
+		 * Action: woocommerce_update_stock_notification
+		 *
+		 * Fires after a stock notification is updated, from any code path, whether or not anything changed.
+		 * Also fires inside the back-in-stock send batch, so defer remote calls to Action Scheduler.
+		 *
+		 * @since 11.3.0
+		 *
+		 * @param int          $notification_id The notification ID.
+		 * @param Notification $notification    The notification.
+		 */
+		do_action( 'woocommerce_update_stock_notification', $notification->get_id(), $notification );
+
+		if ( array_key_exists( 'status', $changes ) && $status_from !== $status_to ) {
+			/**
+			 * Action: woocommerce_stock_notification_status_changed
+			 *
+			 * Fires after a stock notification's status is persisted with a different value, from any code path.
+			 *
+			 * @since 11.3.0
+			 *
+			 * @param int          $notification_id The notification ID.
+			 * @param string       $status_from     The previous status.
+			 * @param string       $status_to       The new status.
+			 * @param Notification $notification    The notification.
+			 */
+			do_action( 'woocommerce_stock_notification_status_changed', $notification->get_id(), $status_from, $status_to, $notification );
+		}
+
 		return $result;
 	}

@@ -316,10 +363,23 @@ CREATE TABLE $meta_table_name (
 	public function delete( &$notification, $args = array() ) {
 		global $wpdb;

-		$deleted = $wpdb->delete( $this->get_table_name(), array( 'id' => $notification->get_id() ), array( '%d' ) );
+		$notification_id = $notification->get_id();
+		$deleted         = $wpdb->delete( $this->get_table_name(), array( 'id' => $notification_id ), array( '%d' ) );

 		if ( $deleted > 0 ) {
-			$this->data_store_meta->delete_by_notification_id( $notification->get_id() );
+			$this->data_store_meta->delete_by_notification_id( $notification_id );
+
+			/**
+			 * Action: woocommerce_delete_stock_notification
+			 *
+			 * Fires after a stock notification is deleted, from any code path.
+			 *
+			 * @since 11.3.0
+			 *
+			 * @param int          $notification_id The deleted notification ID.
+			 * @param Notification $notification    The notification, as it was before deletion.
+			 */
+			do_action( 'woocommerce_delete_stock_notification', $notification_id, $notification );
 		}
 	}

diff --git a/plugins/woocommerce/src/Internal/StockNotifications/Emails/CustomerStockNotificationEmail.php b/plugins/woocommerce/src/Internal/StockNotifications/Emails/CustomerStockNotificationEmail.php
index ec522ca6b39..3e937fafbdf 100644
--- a/plugins/woocommerce/src/Internal/StockNotifications/Emails/CustomerStockNotificationEmail.php
+++ b/plugins/woocommerce/src/Internal/StockNotifications/Emails/CustomerStockNotificationEmail.php
@@ -14,6 +14,13 @@ use WC_Email;
  */
 class CustomerStockNotificationEmail extends WC_Email {

+	/**
+	 * Unsubscribe key for the email being sent. The caller's save persists its hash.
+	 *
+	 * @var string
+	 */
+	private $unsubscribe_key = '';
+
 	/**
 	 * Constructor.
 	 */
@@ -170,9 +177,8 @@ class CustomerStockNotificationEmail extends WC_Email {
 			$product
 		);

-		$unsubscribe_key = $notification->get_unsubscribe_key( true );
-		$user            = $notification instanceof Notification && $notification->get_user_id() ? get_user_by( 'id', $notification->get_user_id() ) : false;
-		$is_guest        = ! is_a( $user, 'WP_User' );
+		$user     = $notification instanceof Notification && $notification->get_user_id() ? get_user_by( 'id', $notification->get_user_id() ) : false;
+		$is_guest = ! is_a( $user, 'WP_User' );

 		return array(
 			'button_text'      => $button_text,
@@ -181,7 +187,7 @@ class CustomerStockNotificationEmail extends WC_Email {
 				add_query_arg(
 					array(
 						'email_link_action'     => EmailActionController::ACTION_UNSUBSCRIBE,
-						'email_link_action_key' => $unsubscribe_key,
+						'email_link_action_key' => $this->unsubscribe_key,
 						'notification_id'       => $notification->get_id(),
 					),
 					home_url( '/' )
@@ -236,6 +242,7 @@ class CustomerStockNotificationEmail extends WC_Email {
 	public function prepare_email( Notification $notification ): void {
 		$this->object                         = $notification;
 		$this->recipient                      = $notification->get_user_email();
+		$this->unsubscribe_key                = $notification->get_unsubscribe_key( false );
 		$product                              = $notification->get_product();
 		$this->placeholders['{product_name}'] = preg_replace( $this->plain_search, $this->plain_replace, $product->get_name() );
 		$this->placeholders['{site_title}']   = preg_replace( $this->plain_search, $this->plain_replace, $this->get_blogname() );
diff --git a/plugins/woocommerce/src/Internal/StockNotifications/Privacy/PrivacyEraser.php b/plugins/woocommerce/src/Internal/StockNotifications/Privacy/PrivacyEraser.php
index 1e21205e3cc..f6a5658fb12 100644
--- a/plugins/woocommerce/src/Internal/StockNotifications/Privacy/PrivacyEraser.php
+++ b/plugins/woocommerce/src/Internal/StockNotifications/Privacy/PrivacyEraser.php
@@ -233,6 +233,23 @@ class PrivacyEraser extends \WC_Abstract_Privacy {
 				continue;
 			}

+			/**
+			 * Action: woocommerce_privacy_before_remove_stock_notification_personal_data
+			 *
+			 * Fires before a stock notification is anonymized by the privacy eraser, while it still
+			 * holds the original email and user ID. The save that follows fires
+			 * `woocommerce_update_stock_notification` with the anonymized row, plus
+			 * `woocommerce_stock_notification_status_changed` only when an active or pending
+			 * notification is cancelled. Neither can identify the shopper any more.
+			 *
+			 * @since 11.3.0
+			 *
+			 * @param int          $notification_id The notification ID.
+			 * @param string       $original_email  The email stored on the notification before anonymization.
+			 * @param Notification $notification    The notification, not yet anonymized.
+			 */
+			do_action( 'woocommerce_privacy_before_remove_stock_notification_personal_data', $notification->get_id(), $notification->get_user_email(), $notification );
+
 			$anonymous_email = wp_privacy_anonymize_data( 'email', $email_address );
 			$notification->set_user_email( $anonymous_email );
 			$notification->set_user_id( 0 );
diff --git a/plugins/woocommerce/tests/php/src/Internal/DataStores/StockNotifications/StockNotificationsDataStoreTests.php b/plugins/woocommerce/tests/php/src/Internal/DataStores/StockNotifications/StockNotificationsDataStoreTests.php
index a2d4902de50..70f82a28ae4 100644
--- a/plugins/woocommerce/tests/php/src/Internal/DataStores/StockNotifications/StockNotificationsDataStoreTests.php
+++ b/plugins/woocommerce/tests/php/src/Internal/DataStores/StockNotifications/StockNotificationsDataStoreTests.php
@@ -214,6 +214,178 @@ class StockNotificationsDataStoreTests extends \WC_Unit_Test_Case {
 		new Notification( $notification_id );
 	}

+	/**
+	 * @testdox Should fire woocommerce_new_stock_notification with the ID once a notification is created.
+	 */
+	public function test_create_fires_new_stock_notification_action(): void {
+		$fired = array();
+		add_action(
+			'woocommerce_new_stock_notification',
+			function ( $id, $notification ) use ( &$fired ) {
+				$fired[] = array( $id, $notification );
+			},
+			10,
+			2
+		);
+
+		$notification = new Notification();
+		$notification->set_product_id( 1 );
+		$notification->set_user_email( 'test@test.com' );
+		$notification->save();
+
+		$this->assertCount( 1, $fired );
+		$this->assertSame( $notification->get_id(), $fired[0][0] );
+		$this->assertSame( $notification, $fired[0][1] );
+
+		// Updating the same notification does not fire it again.
+		$notification->set_status( NotificationStatus::ACTIVE );
+		$notification->save();
+		$this->assertCount( 1, $fired );
+	}
+
+	/**
+	 * @testdox Should fire woocommerce_update_stock_notification with the ID once a notification is updated.
+	 */
+	public function test_update_fires_update_stock_notification_action(): void {
+		$fired = array();
+		add_action(
+			'woocommerce_update_stock_notification',
+			function ( $id, $notification ) use ( &$fired ) {
+				$fired[] = array( 'update', $id, $notification );
+			},
+			10,
+			2
+		);
+		add_action(
+			'woocommerce_stock_notification_status_changed',
+			function ( $id ) use ( &$fired ) {
+				$fired[] = array( 'status_changed', $id );
+			}
+		);
+
+		$notification = new Notification();
+		$notification->set_product_id( 1 );
+		$notification->set_user_email( 'test@test.com' );
+		$notification->save();
+		$this->assertCount( 0, $fired, 'Creating does not fire an update.' );
+
+		$notification->set_user_email( 'other@test.com' );
+		$notification->save();
+		$this->assertSame( array( array( 'update', $notification->get_id(), $notification ) ), $fired );
+
+		// A status change fires the update action first, then the status change.
+		$fired = array();
+		$notification->set_status( NotificationStatus::ACTIVE );
+		$notification->save();
+		$this->assertSame( array( 'update', 'status_changed' ), array_column( $fired, 0 ) );
+
+		// The admin list table updates through the data store directly.
+		$fired = array();
+		$notification->set_product_id( 2 );
+		$this->data_store->update( $notification );
+		$this->assertCount( 1, $fired );
+		$this->assertSame( $notification->get_id(), $fired[0][1] );
+	}
+
+	/**
+	 * @testdox Should fire woocommerce_stock_notification_status_changed only when a different status is persisted.
+	 */
+	public function test_update_fires_status_changed_action(): void {
+		$fired = array();
+		add_action(
+			'woocommerce_stock_notification_status_changed',
+			function ( $id, $from, $to, $notification ) use ( &$fired ) {
+				$fired[] = array( $id, $from, $to, $notification );
+			},
+			10,
+			4
+		);
+
+		$notification = new Notification();
+		$notification->set_product_id( 1 );
+		$notification->set_user_email( 'test@test.com' );
+		$notification->save();
+		$this->assertCount( 0, $fired, 'Creating does not fire a status change.' );
+
+		// A non-status change does not fire it.
+		$notification->set_product_id( 2 );
+		$notification->save();
+		$this->assertCount( 0, $fired );
+
+		// Re-setting the current status does not fire it.
+		$notification->set_status( NotificationStatus::PENDING );
+		$notification->save();
+		$this->assertCount( 0, $fired );
+
+		$notification->set_status( NotificationStatus::ACTIVE );
+		$notification->save();
+		$this->assertCount( 1, $fired );
+		$this->assertSame( array( $notification->get_id(), NotificationStatus::PENDING, NotificationStatus::ACTIVE, $notification ), $fired[0] );
+
+		// The admin list table updates through the data store directly.
+		$notification->set_status( NotificationStatus::CANCELLED );
+		$this->data_store->update( $notification );
+		$this->assertCount( 2, $fired );
+		$this->assertSame( NotificationStatus::ACTIVE, $fired[1][1] );
+		$this->assertSame( NotificationStatus::CANCELLED, $fired[1][2] );
+		$this->assertSame( NotificationStatus::CANCELLED, $fired[1][3]->get_status() );
+
+		// An update-action listener that changes the status in memory does not affect the persisted pair.
+		add_action(
+			'woocommerce_update_stock_notification',
+			function ( $id, $notification ) {
+				$notification->set_status( NotificationStatus::CANCELLED );
+			},
+			10,
+			2
+		);
+		$notification->set_status( NotificationStatus::ACTIVE );
+		$notification->save();
+		$this->assertCount( 3, $fired );
+		$this->assertSame( NotificationStatus::CANCELLED, $fired[2][1] );
+		$this->assertSame( NotificationStatus::ACTIVE, $fired[2][2] );
+	}
+
+	/**
+	 * @testdox Should fire woocommerce_delete_stock_notification with the ID once a notification is deleted.
+	 */
+	public function test_delete_fires_delete_stock_notification_action(): void {
+		$fired = array();
+		add_action(
+			'woocommerce_delete_stock_notification',
+			function ( $id, $notification ) use ( &$fired ) {
+				$fired[] = array( $id, $notification );
+			},
+			10,
+			2
+		);
+
+		$notification = new Notification();
+		$notification->set_product_id( 1 );
+		$notification->set_user_email( 'test@test.com' );
+		$notification->save();
+		$notification_id = $notification->get_id();
+
+		$notification->delete();
+		$this->assertCount( 1, $fired );
+		$this->assertSame( $notification_id, $fired[0][0] );
+		$this->assertSame( $notification, $fired[0][1] );
+
+		// Deleting a row that is already gone does not fire it.
+		$notification->set_id( $notification_id );
+		$this->data_store->delete( $notification );
+		$this->assertCount( 1, $fired );
+
+		// The admin list table deletes through the data store directly.
+		$other = new Notification();
+		$other->set_product_id( 1 );
+		$other->set_user_email( 'other@test.com' );
+		$other->save();
+		$this->data_store->delete( $other );
+		$this->assertCount( 2, $fired );
+		$this->assertSame( $other->get_id(), $fired[1][0] );
+	}
+
 	/**
 	 * Test reading a non-existent notification.
 	 */
diff --git a/plugins/woocommerce/tests/php/src/Internal/StockNotifications/Emails/EmailManagerTests.php b/plugins/woocommerce/tests/php/src/Internal/StockNotifications/Emails/EmailManagerTests.php
index c9d4060554a..8f9e0ec4c53 100644
--- a/plugins/woocommerce/tests/php/src/Internal/StockNotifications/Emails/EmailManagerTests.php
+++ b/plugins/woocommerce/tests/php/src/Internal/StockNotifications/Emails/EmailManagerTests.php
@@ -209,6 +209,29 @@ class EmailManagerTests extends \WC_Unit_Test_Case {
 		$this->assertArrayHasKey( $notification->get_user_email(), $this->sent_to );
 	}

+	/**
+	 * @testdox Should not save the notification while sending the stock email, leaving the unsubscribe key to the caller's save.
+	 */
+	public function test_send_stock_notification_email_leaves_saving_the_unsubscribe_key_to_the_caller() {
+		$notification = $this->build_notification();
+		$updates      = 0;
+		$count        = function () use ( &$updates ) {
+			++$updates;
+		};
+		add_action( 'woocommerce_update_stock_notification', $count );
+
+		$this->sut->send_stock_notification_email( $notification );
+
+		remove_action( 'woocommerce_update_stock_notification', $count );
+		$this->assertSame( 0, $updates );
+		$this->assertCount( 1, $this->sent_messages );
+		$this->assertSame( 1, preg_match( '/email_link_action_key=([A-Za-z0-9]+)/', $this->sent_messages[0], $matches ) );
+
+		$notification->save();
+		$notification = new Notification( $notification->get_id() );
+		$this->assertTrue( $notification->check_unsubscribe_key( $matches[1] ) );
+	}
+
 	/**
 	 * @testdox Should build the $email_method action link on home_url() when WordPress core lives in a subdirectory.
 	 * @dataProvider provider_email_send_methods
diff --git a/plugins/woocommerce/tests/php/src/Internal/StockNotifications/Privacy/PrivacyEraserTests.php b/plugins/woocommerce/tests/php/src/Internal/StockNotifications/Privacy/PrivacyEraserTests.php
index 26d7dcc19ce..62be7fc3960 100644
--- a/plugins/woocommerce/tests/php/src/Internal/StockNotifications/Privacy/PrivacyEraserTests.php
+++ b/plugins/woocommerce/tests/php/src/Internal/StockNotifications/Privacy/PrivacyEraserTests.php
@@ -56,6 +56,42 @@ class PrivacyEraserTests extends \WC_Unit_Test_Case {
 		$this->assertNotNull( $anonymous_notification->get_date_cancelled() );
 	}

+	/**
+	 * @testdox Should fire woocommerce_privacy_before_remove_stock_notification_personal_data with the original email before anonymizing.
+	 */
+	public function test_privacy_eraser_fires_before_remove_action_before_anonymizing(): void {
+		$notification = new Notification();
+		$notification->set_user_email( 'jon@doe.com' );
+		$notification->set_user_id( 5 );
+		$notification->set_product_id( 1 );
+		$notification->set_status( NotificationStatus::ACTIVE );
+		$notification_id = $notification->save();
+
+		$erased = array();
+		add_action(
+			'woocommerce_privacy_before_remove_stock_notification_personal_data',
+			function ( $id, $original_email, $notification ) use ( &$erased ) {
+				$erased[] = array( $id, $original_email, $notification->get_user_email(), $notification->get_user_id(), $notification->get_status() );
+			},
+			10,
+			3
+		);
+		$status_changes = array();
+		add_action(
+			'woocommerce_stock_notification_status_changed',
+			function ( $id, $from, $to, $notification ) use ( &$status_changes ) {
+				$status_changes[] = array( $id, $from, $to, $notification->get_user_email() );
+			},
+			10,
+			4
+		);
+
+		PrivacyEraser::erase_notification_data( 'jon@doe.com' );
+
+		$this->assertSame( array( array( $notification_id, 'jon@doe.com', 'jon@doe.com', 5, NotificationStatus::ACTIVE ) ), $erased );
+		$this->assertSame( array( array( $notification_id, NotificationStatus::ACTIVE, NotificationStatus::CANCELLED, wp_privacy_anonymize_data( 'email', '' ) ) ), $status_changes );
+	}
+
 	/**
 	 * @testdox Should keep a sent notification sent, with its sent date, while still anonymizing it.
 	 */