Commit f53e0253ff8 for woocommerce

commit f53e0253ff85bc175b61ffbf2e3ad2175f6a1e8f
Author: Adam Grzybkowski <agrzybkowski@outlook.com>
Date:   Tue Oct 27 10:17:39 2026 +0100

    Fix new order pushes for orders created before push was enabled (#70071)

    * Skip new order pushes for orders created before push was enabled

    * Add changelog entry for stale new order push fix

    * Mark orders reaching a notifiable status while push is switched off

    * Mark orders only when they move into a notifiable status

    * Store the push notifiable marker without a full HPOS order save

    * Read the push start time only when an order becomes notifiable

    * Treat every status an order can be paid from as a payment arriving

diff --git a/plugins/woocommerce/changelog/woomob-3992-push-new-order-predates-enablement b/plugins/woocommerce/changelog/woomob-3992-push-new-order-predates-enablement
new file mode 100644
index 00000000000..0851776aa57
--- /dev/null
+++ b/plugins/woocommerce/changelog/woomob-3992-push-new-order-predates-enablement
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Stop push notifications from announcing orders placed before they were turned on as new orders.
diff --git a/plugins/woocommerce/src/Internal/DataStores/Orders/OrdersTableDataStore.php b/plugins/woocommerce/src/Internal/DataStores/Orders/OrdersTableDataStore.php
index a72ee202067..a48cee218b2 100644
--- a/plugins/woocommerce/src/Internal/DataStores/Orders/OrdersTableDataStore.php
+++ b/plugins/woocommerce/src/Internal/DataStores/Orders/OrdersTableDataStore.php
@@ -125,6 +125,7 @@ class OrdersTableDataStore extends \Abstract_WC_Order_Data_Store_CPT implements
 		'_wp_trash_meta_comments_status',
 		'_debug_log_source',
 		'_debug_log_source_pending_deletion',
+		'_wc_push_notification_notifiable_at',
 	);

 	/**
diff --git a/plugins/woocommerce/src/Internal/PushNotifications/PushNotifications.php b/plugins/woocommerce/src/Internal/PushNotifications/PushNotifications.php
index 3ca92dfb73e..9afd0d615fa 100644
--- a/plugins/woocommerce/src/Internal/PushNotifications/PushNotifications.php
+++ b/plugins/woocommerce/src/Internal/PushNotifications/PushNotifications.php
@@ -35,6 +35,14 @@ class PushNotifications {
 	 */
 	const FEATURE_NAME = 'push_notifications';

+	/**
+	 * Option holding the Unix timestamp of when push notifications were first
+	 * recorded as enabled on this site.
+	 *
+	 * @since 11.4.0
+	 */
+	const ENABLED_AT_OPTION = 'woocommerce_push_notifications_enabled_at';
+
 	/**
 	 * Roles that can receive push notifications.
 	 *
@@ -93,9 +101,16 @@ class PushNotifications {
 		wc_get_container()->get( StepLogRestController::class )->register();

 		if ( ! $this->should_be_enabled() ) {
+			// Keep marking orders that reach a notifiable status while push
+			// notifications are off, so turning them back on does not announce
+			// those orders as new.
+			( new NewOrderNotificationTrigger() )->register_while_disabled();
+
 			return;
 		}

+		$this->record_enabled_at();
+
 		$this->register_post_types();

 		wc_get_container()->get( PendingNotificationStore::class )->register();
@@ -160,4 +175,32 @@ class PushNotifications {

 		return $this->enabled;
 	}
+
+	/**
+	 * Returns when push notifications were first recorded as enabled on this site.
+	 *
+	 * @since 11.4.0
+	 *
+	 * @return int|null Unix timestamp, or null when it has not been recorded.
+	 */
+	public function get_enabled_at(): ?int {
+		$enabled_at = (int) get_option( self::ENABLED_AT_OPTION, 0 );
+
+		return $enabled_at > 0 ? $enabled_at : null;
+	}
+
+	/**
+	 * Records the first time push notifications are enabled. Never updated
+	 * afterwards, so switching the module off and on keeps the original date.
+	 *
+	 * Autoloaded because the order trigger reads it whenever an order moves
+	 * into a notifiable status.
+	 *
+	 * @return void
+	 */
+	private function record_enabled_at(): void {
+		if ( false === get_option( self::ENABLED_AT_OPTION ) ) {
+			add_option( self::ENABLED_AT_OPTION, time(), '', true );
+		}
+	}
 }
diff --git a/plugins/woocommerce/src/Internal/PushNotifications/Triggers/NewOrderNotificationTrigger.php b/plugins/woocommerce/src/Internal/PushNotifications/Triggers/NewOrderNotificationTrigger.php
index 402875edd32..0a6d011ad9d 100644
--- a/plugins/woocommerce/src/Internal/PushNotifications/Triggers/NewOrderNotificationTrigger.php
+++ b/plugins/woocommerce/src/Internal/PushNotifications/Triggers/NewOrderNotificationTrigger.php
@@ -4,8 +4,15 @@ declare( strict_types = 1 );

 namespace Automattic\WooCommerce\Internal\PushNotifications\Triggers;

+use Automattic\WooCommerce\Enums\OrderStatus;
 use Automattic\WooCommerce\Internal\PushNotifications\Notifications\NewOrderNotification;
+use Automattic\WooCommerce\Internal\PushNotifications\PushNotifications;
+use Automattic\WooCommerce\Internal\PushNotifications\Services\NotificationStepLogger;
 use Automattic\WooCommerce\Internal\PushNotifications\Services\PendingNotificationStore;
+use Automattic\WooCommerce\Proxies\LegacyProxy;
+use Automattic\WooCommerce\Utilities\OrderUtil;
+use Throwable;
+use WC_Logger_Interface;
 use WC_Order;

 defined( 'ABSPATH' ) || exit;
@@ -41,6 +48,13 @@ class NewOrderNotificationTrigger {
 		'partial-payment',
 	);

+	/**
+	 * Order meta recording when the order first reached a notifiable status.
+	 *
+	 * @since 11.4.0
+	 */
+	const NOTIFIABLE_META_KEY = '_wc_push_notification_notifiable_at';
+
 	/**
 	 * Registers WordPress hooks for order events.
 	 *
@@ -53,6 +67,19 @@ class NewOrderNotificationTrigger {
 		add_action( 'woocommerce_order_status_changed', array( $this, 'on_order_status_changed' ), 10, 4 );
 	}

+	/**
+	 * Registers only the notifiable marker, for when push notifications are
+	 * switched off. Orders reaching a notifiable status meanwhile are then not
+	 * announced as new once they are back on.
+	 *
+	 * @since 11.4.0
+	 *
+	 * @return void
+	 */
+	public function register_while_disabled(): void {
+		add_action( 'woocommerce_order_status_changed', array( $this, 'on_order_status_changed_while_disabled' ), 10, 4 );
+	}
+
 	/**
 	 * Handles the woocommerce_new_order hook.
 	 *
@@ -91,15 +118,146 @@ class NewOrderNotificationTrigger {
 		WC_Order $order
 	): void {
 		// phpcs:enable Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed
+		if ( ! self::enters_notifiable_status( $previous_status, $next_status ) ) {
+			return;
+		}
+
+		$was_notifiable = $order->meta_exists( self::NOTIFIABLE_META_KEY );
+		$this->mark_notifiable( $order );
+
+		$notification = new NewOrderNotification( $order_id );
+
+		if ( $was_notifiable ) {
+			wc_get_container()->get( NotificationStepLogger::class )->log_notification_step( $notification, 'triggered', 'previously_notifiable' );
+			return;
+		}
+
+		if ( $this->predates_push_notifications( $previous_status, $order ) ) {
+			wc_get_container()->get( NotificationStepLogger::class )->log_notification_step( $notification, 'triggered', 'predates_push_notifications' );
+			return;
+		}
+
+		wc_get_container()->get( PendingNotificationStore::class )->add( $notification );
+	}
+
+	// phpcs:disable Generic.CodeAnalysis.UnusedFunctionParameter.FoundBeforeLastUsed
+	/**
+	 * Handles the woocommerce_order_status_changed hook while push notifications
+	 * are switched off: marks the order without queuing a notification, on sites
+	 * that have used push notifications.
+	 *
+	 * Parameters are untyped and optional because this runs on every store, where
+	 * a bad or missing value must not become a fatal.
+	 *
+	 * @since 11.4.0
+	 *
+	 * @param mixed $order_id        The order ID.
+	 * @param mixed $previous_status The previous order status.
+	 * @param mixed $next_status     The new order status.
+	 * @param mixed $order           The order object.
+	 * @return void
+	 */
+	public function on_order_status_changed_while_disabled( $order_id = null, $previous_status = null, $next_status = null, $order = null ): void {
+		// phpcs:enable Generic.CodeAnalysis.UnusedFunctionParameter.FoundBeforeLastUsed
+		// The option is read last, so only when an order moves into a notifiable status.
 		if (
-			in_array( $previous_status, self::NOTIFIABLE_STATUSES, true )
-			|| ! in_array( $next_status, self::NOTIFIABLE_STATUSES, true )
+			$order instanceof WC_Order
+			&& self::enters_notifiable_status( $previous_status, $next_status )
+			&& null !== wc_get_container()->get( PushNotifications::class )->get_enabled_at()
 		) {
-			return;
+			$this->mark_notifiable( $order );
 		}
+	}

-		wc_get_container()->get( PendingNotificationStore::class )->add(
-			new NewOrderNotification( $order_id )
-		);
+	/**
+	 * Whether the order moves into a notifiable status from one that is not.
+	 *
+	 * Moves between notifiable statuses are skipped and not marked: a plugin that
+	 * completes the order from the processing hook fires processing to completed
+	 * before pending to processing.
+	 *
+	 * @param mixed $previous_status The previous order status.
+	 * @param mixed $next_status     The new order status.
+	 * @return bool
+	 */
+	private static function enters_notifiable_status( $previous_status, $next_status ): bool {
+		return ! in_array( $previous_status, self::NOTIFIABLE_STATUSES, true )
+			&& in_array( $next_status, self::NOTIFIABLE_STATUSES, true );
+	}
+
+	/**
+	 * Records that the order has reached a notifiable status, once. Failures are
+	 * logged rather than thrown, as this runs inside another plugin's status change.
+	 *
+	 * Writes meta only, and the key is listed as ephemeral in OrdersTableDataStore,
+	 * so on HPOS the write does not turn into a full order save.
+	 *
+	 * @param WC_Order $order The order object.
+	 * @return void
+	 */
+	private function mark_notifiable( WC_Order $order ): void {
+		try {
+			if ( $order->meta_exists( self::NOTIFIABLE_META_KEY ) ) {
+				return;
+			}
+
+			$order->update_meta_data( self::NOTIFIABLE_META_KEY, (string) time() );
+			$order->save_meta_data();
+		} catch ( Throwable $e ) {
+			$logger = wc_get_container()->get( LegacyProxy::class )->call_function( 'wc_get_logger' );
+
+			if ( $logger instanceof WC_Logger_Interface ) {
+				$logger->error(
+					sprintf( 'Could not mark order %d as notifiable: %s', $order->get_id(), $e->getMessage() ),
+					array( 'source' => PushNotifications::FEATURE_NAME )
+				);
+			}
+		}
+	}
+
+	/**
+	 * Whether the order was created before push notifications were enabled.
+	 *
+	 * Such an order was never marked as notified, so moving it out of a custom
+	 * status into a notifiable one would otherwise announce an old order as new.
+	 * Moves out of a status that can take a payment are a payment arriving, so
+	 * they still notify.
+	 *
+	 * @param string   $previous_status The previous order status.
+	 * @param WC_Order $order           The order object.
+	 * @return bool
+	 */
+	private function predates_push_notifications( string $previous_status, WC_Order $order ): bool {
+		$enabled_at   = wc_get_container()->get( PushNotifications::class )->get_enabled_at();
+		$date_created = $order->get_date_created();
+
+		if ( null === $enabled_at || null === $date_created || $date_created->getTimestamp() >= $enabled_at ) {
+			return false;
+		}
+
+		return ! $this->can_be_paid_from_status( $previous_status, $order );
+	}
+
+	/**
+	 * Whether an order can be paid from this status: those
+	 * WC_Order::payment_complete() accepts, plus checkout drafts.
+	 *
+	 * @param string   $status The order status.
+	 * @param WC_Order $order  The order object.
+	 * @return bool
+	 */
+	private function can_be_paid_from_status( string $status, WC_Order $order ): bool {
+		/**
+		 * This filter is documented in includes/class-wc-order.php
+		 *
+		 * @since 2.7.0
+		 */
+		$statuses = apply_filters( 'woocommerce_valid_order_statuses_for_payment_complete', OrderStatus::PAYMENT_COMPLETE_STATUSES, $order );
+		$statuses = is_array( $statuses ) ? array_filter( $statuses, 'is_string' ) : OrderStatus::PAYMENT_COMPLETE_STATUSES;
+
+		$statuses   = array_map( array( OrderUtil::class, 'remove_status_prefix' ), $statuses );
+		$statuses[] = OrderStatus::CHECKOUT_DRAFT;
+
+		return in_array( $status, $statuses, true );
 	}
 }
diff --git a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Helpers/PushNotificationsTestTrait.php b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Helpers/PushNotificationsTestTrait.php
index af548dfabb5..a0215079ee4 100644
--- a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Helpers/PushNotificationsTestTrait.php
+++ b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Helpers/PushNotificationsTestTrait.php
@@ -7,6 +7,7 @@ namespace Automattic\WooCommerce\Tests\Internal\PushNotifications\Helpers;
 use Automattic\Jetpack\Connection\Manager as JetpackConnectionManager;
 use Automattic\WooCommerce\Internal\PushNotifications\DataStores\PushTokensDataStore;
 use Automattic\WooCommerce\Internal\PushNotifications\PushNotifications;
+use Automattic\WooCommerce\Internal\PushNotifications\Triggers\NewOrderNotificationTrigger;
 use Automattic\WooCommerce\Proxies\LegacyProxy;
 use PHPUnit\Framework\MockObject\MockObject;
 use ReflectionClass;
@@ -78,4 +79,27 @@ trait PushNotificationsTestTrait {

 		return $data_store;
 	}
+
+	/**
+	 * Removes new order trigger callbacks that WooCommerce registered when it booted.
+	 * Push notifications are off in tests, so the off-state marker is registered and
+	 * would otherwise mark orders before the code under test does.
+	 *
+	 * @return void
+	 */
+	protected function remove_trigger_callbacks_registered_at_boot(): void {
+		global $wp_filter;
+
+		foreach ( array( 'woocommerce_new_order', 'woocommerce_order_status_changed' ) as $hook ) {
+			$callbacks = isset( $wp_filter[ $hook ] ) ? $wp_filter[ $hook ]->callbacks : array();
+
+			foreach ( $callbacks as $priority => $by_priority ) {
+				foreach ( $by_priority as $callback ) {
+					if ( is_array( $callback['function'] ) && $callback['function'][0] instanceof NewOrderNotificationTrigger ) {
+						remove_action( $hook, $callback['function'], $priority );
+					}
+				}
+			}
+		}
+	}
 }
diff --git a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/PushNotificationsTest.php b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/PushNotificationsTest.php
index 242ac845694..70b6d0b5c42 100644
--- a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/PushNotificationsTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/PushNotificationsTest.php
@@ -5,6 +5,7 @@ declare(strict_types=1);
 namespace Automattic\WooCommerce\Tests\Internal\PushNotifications;

 use Automattic\Jetpack\Connection\Manager as JetpackConnectionManager;
+use Automattic\WooCommerce\Enums\OrderStatus;
 use Automattic\WooCommerce\Internal\PushNotifications\Controllers\NotificationPreferencesRestController;
 use Automattic\WooCommerce\Internal\PushNotifications\Controllers\PushNotificationRestController;
 use Automattic\WooCommerce\Internal\PushNotifications\Controllers\PushNotificationStatusRestController;
@@ -12,9 +13,10 @@ use Automattic\WooCommerce\Internal\PushNotifications\Controllers\PushTokenRestC
 use Automattic\WooCommerce\Internal\PushNotifications\DataStores\PushTokensDataStore;
 use Automattic\WooCommerce\Internal\PushNotifications\Entities\PushToken;
 use Automattic\WooCommerce\Internal\PushNotifications\PushNotifications;
+use Automattic\WooCommerce\Internal\PushNotifications\Triggers\NewOrderNotificationTrigger;
+use Automattic\WooCommerce\Tests\Internal\PushNotifications\Helpers\PushNotificationsTestTrait;
 use Automattic\WooCommerce\Proxies\LegacyProxy;
 use Exception;
-use PHPUnit\Framework\MockObject\MockObject;
 use WC_Logger;
 use WC_Unit_Test_Case;
 use WP_Http;
@@ -27,10 +29,7 @@ use WP_REST_Response;
  * @covers \Automattic\WooCommerce\Internal\PushNotifications\PushNotifications
  */
 class PushNotificationsTest extends WC_Unit_Test_Case {
-	/**
-	 * @var JetpackConnectionManager|MockObject
-	 */
-	private $jetpack_connection_manager_mock;
+	use PushNotificationsTestTrait;

 	/**
 	 * Tear down the test case.
@@ -251,6 +250,107 @@ class PushNotificationsTest extends WC_Unit_Test_Case {
 		);
 	}

+	/**
+	 * @testdox Tests that on_init records when push notifications were first enabled, as an autoloaded option.
+	 */
+	public function test_on_init_records_enabled_at_when_enabled() {
+		global $wpdb;
+
+		$this->set_up_jetpack_connection_manager_mock( array( 'is_connected' ) );
+		$this->jetpack_connection_manager_mock->method( 'is_connected' )->willReturn( true );
+
+		$push_notifications = new PushNotifications();
+
+		$before = time();
+		$push_notifications->on_init();
+		$after = time();
+
+		$enabled_at = $push_notifications->get_enabled_at();
+		$this->assertNotNull( $enabled_at, 'The enablement time should be recorded' );
+		$this->assertGreaterThanOrEqual( $before, $enabled_at, 'The enablement time should not predate on_init' );
+		$this->assertLessThanOrEqual( $after, $enabled_at, 'The enablement time should not postdate on_init' );
+
+		$autoload = $wpdb->get_var(
+			$wpdb->prepare( "SELECT autoload FROM {$wpdb->options} WHERE option_name = %s", PushNotifications::ENABLED_AT_OPTION )
+		);
+		$this->assertSame( 'on', $autoload, 'The option should be autoloaded' );
+	}
+
+	/**
+	 * @testdox Tests that on_init keeps the first recorded enablement time.
+	 */
+	public function test_on_init_does_not_overwrite_enabled_at() {
+		$this->set_up_jetpack_connection_manager_mock( array( 'is_connected' ) );
+		$this->jetpack_connection_manager_mock->method( 'is_connected' )->willReturn( true );
+
+		update_option( PushNotifications::ENABLED_AT_OPTION, 1000 );
+		$push_notifications = new PushNotifications();
+
+		$push_notifications->on_init();
+
+		$this->assertSame( 1000, $push_notifications->get_enabled_at(), 'The first recorded enablement time should be kept' );
+	}
+
+	/**
+	 * @testdox Tests that on_init does not record an enablement time when Jetpack is not connected.
+	 */
+	public function test_on_init_does_not_record_enabled_at_when_jetpack_is_not_connected() {
+		$this->set_up_jetpack_connection_manager_mock( array( 'is_connected' ) );
+		$this->jetpack_connection_manager_mock->method( 'is_connected' )->willReturn( false );
+
+		$push_notifications = new PushNotifications();
+
+		$push_notifications->on_init();
+
+		$this->assertNull( $push_notifications->get_enabled_at(), 'No enablement time should be recorded while disabled' );
+	}
+
+	/**
+	 * @testdox Tests that on_init does not read the enablement time while disabled.
+	 */
+	public function test_on_init_does_not_read_enabled_at_while_disabled() {
+		$this->set_up_jetpack_connection_manager_mock( array( 'is_connected' ) );
+		$this->jetpack_connection_manager_mock->method( 'is_connected' )->willReturn( false );
+		$reads = 0;
+		add_filter(
+			'pre_option_' . PushNotifications::ENABLED_AT_OPTION,
+			function ( $pre ) use ( &$reads ) {
+				++$reads;
+				return $pre;
+			}
+		);
+
+		( new PushNotifications() )->on_init();
+
+		$this->assertSame( 0, $reads, 'The option should only be read when an order moves into a notifiable status' );
+	}
+
+	/**
+	 * @testdox Tests that on_init keeps marking orders while disabled only on a site that has used push notifications.
+	 * @testWith [1000, true]
+	 *           [null, false]
+	 *
+	 * @param int|null $enabled_at    The recorded enablement time, or null when never enabled.
+	 * @param bool     $expect_marked Whether the order should be marked.
+	 */
+	public function test_on_init_marks_orders_while_disabled_only_after_first_enablement( ?int $enabled_at, bool $expect_marked ) {
+		$this->remove_trigger_callbacks_registered_at_boot();
+		if ( null !== $enabled_at ) {
+			update_option( PushNotifications::ENABLED_AT_OPTION, $enabled_at );
+		}
+		$this->set_up_jetpack_connection_manager_mock( array( 'is_connected' ) );
+		$this->jetpack_connection_manager_mock->method( 'is_connected' )->willReturn( false );
+
+		( new PushNotifications() )->on_init();
+		$order = wc_create_order( array( 'status' => OrderStatus::PROCESSING ) );
+
+		$this->assertSame(
+			$expect_marked,
+			wc_get_order( $order->get_id() )->meta_exists( NewOrderNotificationTrigger::NOTIFIABLE_META_KEY ),
+			'Orders should only be marked while disabled once push notifications have been enabled'
+		);
+	}
+
 	/**
 	 * @testdox Tests that on_init registers the status and token controllers, but not the send
 	 * or preferences controllers, when Jetpack is not connected.
diff --git a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Triggers/NewOrderNotificationTriggerTest.php b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Triggers/NewOrderNotificationTriggerTest.php
index cb3fd062439..b6939528423 100644
--- a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Triggers/NewOrderNotificationTriggerTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Triggers/NewOrderNotificationTriggerTest.php
@@ -4,11 +4,19 @@ declare( strict_types = 1 );

 namespace Automattic\WooCommerce\Tests\Internal\PushNotifications\Triggers;

+use Automattic\WooCommerce\Enums\OrderStatus;
+use Automattic\WooCommerce\Internal\DataStores\Orders\DataSynchronizer;
 use Automattic\WooCommerce\Internal\PushNotifications\Dispatchers\InternalNotificationDispatcher;
+use Automattic\WooCommerce\Internal\PushNotifications\Notifications\NewOrderNotification;
+use Automattic\WooCommerce\Internal\PushNotifications\PushNotifications;
 use Automattic\WooCommerce\Internal\PushNotifications\Services\PendingNotificationStore;
 use Automattic\WooCommerce\Internal\PushNotifications\Services\NotificationStepLogger;
 use Automattic\WooCommerce\Internal\PushNotifications\Triggers\NewOrderNotificationTrigger;
+use Automattic\WooCommerce\RestApi\UnitTests\Helpers\OrderHelper;
 use Automattic\WooCommerce\Tests\Internal\PushNotifications\Helpers\PushNotificationsTestTrait;
+use Automattic\WooCommerce\Utilities\OrderUtil;
+use Exception;
+use WC_Logger;
 use WC_Order;
 use WC_Unit_Test_Case;

@@ -19,6 +27,11 @@ class NewOrderNotificationTriggerTest extends WC_Unit_Test_Case {

 	use PushNotificationsTestTrait;

+	/**
+	 * A custom order status, like those fulfilment plugins add.
+	 */
+	private const CUSTOM_STATUS = 'in-production';
+
 	/**
 	 * An instance of NewOrderNotificationTrigger.
 	 *
@@ -48,6 +61,7 @@ class NewOrderNotificationTriggerTest extends WC_Unit_Test_Case {
 		wc_get_container()->replace( PendingNotificationStore::class, $this->store );
 		wc_get_container()->reset_all_resolved();

+		$this->remove_trigger_callbacks_registered_at_boot();
 		$this->trigger = new NewOrderNotificationTrigger();
 		$this->trigger->register();
 	}
@@ -134,6 +148,339 @@ class NewOrderNotificationTriggerTest extends WC_Unit_Test_Case {
 		$this->assertSame( 0, $this->store->count() );
 	}

+	/**
+	 * @testdox Should notify about an order leaving a custom status only when it was not created before push notifications were enabled.
+	 * @testWith [60, 0]
+	 *           [0, 1]
+	 *           [-60, 1]
+	 *
+	 * @param int $enabled_offset Seconds between the order's creation and enablement.
+	 * @param int $expected_count The number of notifications expected.
+	 */
+	public function test_status_change_from_custom_status_respects_enablement_time( int $enabled_offset, int $expected_count ): void {
+		$order = $this->create_order_in_custom_status();
+		update_option( PushNotifications::ENABLED_AT_OPTION, $order->get_date_created()->getTimestamp() + $enabled_offset );
+
+		$order->set_status( OrderStatus::COMPLETED );
+		$order->save();
+
+		$this->assertSame( $expected_count, $this->store->count(), "Enablement {$enabled_offset}s after the order was created" );
+	}
+
+	/**
+	 * @testdox Should notify about an order created before push notifications were enabled when it is paid from an unpaid status.
+	 * @testWith ["pending"]
+	 *           ["failed"]
+	 *           ["cancelled"]
+	 *           ["checkout-draft"]
+	 *
+	 * @param string $previous_status The unpaid status the order leaves.
+	 */
+	public function test_status_change_from_unpaid_status_notifies_order_predating_enablement( string $previous_status ): void {
+		$order = wc_create_order();
+		update_option( PushNotifications::ENABLED_AT_OPTION, $order->get_date_created()->getTimestamp() + 60 );
+
+		$this->trigger->on_order_status_changed( $order->get_id(), $previous_status, OrderStatus::PROCESSING, $order );
+
+		$this->assertSame( 1, $this->store->count(), "A payment arriving on a '{$previous_status}' order should still be announced" );
+	}
+
+	/**
+	 * @testdox Should treat statuses added to the payment complete filter as unpaid, with or without the wc- prefix, ignoring non-string values.
+	 * @testWith ["wc-in-production"]
+	 *           ["in-production"]
+	 *
+	 * @param string $filtered_status The status a plugin adds to the filter.
+	 */
+	public function test_status_change_from_status_added_to_payment_complete_filter_notifies( string $filtered_status ): void {
+		add_filter(
+			'woocommerce_valid_order_statuses_for_payment_complete',
+			static function ( array $statuses ) use ( $filtered_status ): array {
+				return array_merge( $statuses, array( $filtered_status, 123, null ) );
+			}
+		);
+		$order = $this->create_order_in_custom_status();
+		update_option( PushNotifications::ENABLED_AT_OPTION, $order->get_date_created()->getTimestamp() + 60 );
+
+		$order->set_status( OrderStatus::PROCESSING );
+		$order->save();
+
+		$this->assertSame( 1, $this->store->count(), "A payment arriving from '{$filtered_status}' should still be announced" );
+	}
+
+	/**
+	 * @testdox Should not run the payment complete filter for an order created after push notifications were enabled.
+	 */
+	public function test_payment_complete_filter_is_not_run_for_orders_created_after_enablement(): void {
+		$calls = 0;
+		add_filter(
+			'woocommerce_valid_order_statuses_for_payment_complete',
+			function ( $statuses ) use ( &$calls ) {
+				++$calls;
+				return $statuses;
+			}
+		);
+		$order = $this->create_order_in_custom_status();
+		update_option( PushNotifications::ENABLED_AT_OPTION, $order->get_date_created()->getTimestamp() - 60 );
+
+		$order->set_status( OrderStatus::PROCESSING );
+		$order->save();
+
+		$this->assertSame( 0, $calls, 'The filter should only run for orders created before push notifications were enabled' );
+		$this->assertSame( 1, $this->store->count(), 'The new order should be announced' );
+	}
+
+	/**
+	 * @testdox Should fall back to the default unpaid statuses when the payment complete filter does not return an array.
+	 */
+	public function test_invalid_payment_complete_filter_value_falls_back_to_defaults(): void {
+		add_filter( 'woocommerce_valid_order_statuses_for_payment_complete', '__return_false', PHP_INT_MAX );
+		$order = wc_create_order();
+		update_option( PushNotifications::ENABLED_AT_OPTION, $order->get_date_created()->getTimestamp() + 60 );
+
+		$this->trigger->on_order_status_changed( $order->get_id(), OrderStatus::CANCELLED, OrderStatus::PROCESSING, $order );
+
+		$this->assertSame( 1, $this->store->count(), 'Cancelled should still count as unpaid when the filter returns a bad value' );
+	}
+
+	/**
+	 * @testdox Should log the skipped notification when the order predates enablement.
+	 */
+	public function test_status_change_on_order_predating_enablement_logs_step(): void {
+		$step_logger = $this->createMock( NotificationStepLogger::class );
+		wc_get_container()->replace( NotificationStepLogger::class, $step_logger );
+
+		$order = $this->create_order_in_custom_status();
+		update_option( PushNotifications::ENABLED_AT_OPTION, $order->get_date_created()->getTimestamp() + 60 );
+
+		$step_logger->expects( $this->once() )
+			->method( 'log_notification_step' )
+			->with( $this->isInstanceOf( NewOrderNotification::class ), 'triggered', 'predates_push_notifications' );
+
+		$order->set_status( OrderStatus::COMPLETED );
+		$order->save();
+
+		$this->assertSame( 0, $this->store->count(), 'The skipped order should not be queued' );
+	}
+
+	/**
+	 * @testdox Should add a notification when the order has no creation date.
+	 */
+	public function test_status_change_on_order_without_creation_date_adds_notification(): void {
+		update_option( PushNotifications::ENABLED_AT_OPTION, time() );
+		$order = $this->createMock( WC_Order::class );
+		$order->method( 'get_date_created' )->willReturn( null );
+
+		$this->trigger->on_order_status_changed( 1, self::CUSTOM_STATUS, OrderStatus::COMPLETED, $order );
+
+		$this->assertSame( 1, $this->store->count(), 'An order without a creation date cannot be shown to predate enablement' );
+	}
+
+	/**
+	 * @testdox Should mark an order the first time it reaches a notifiable status.
+	 */
+	public function test_status_change_to_notifiable_status_marks_order(): void {
+		$order = wc_create_order( array( 'status' => OrderStatus::PENDING ) );
+
+		$order->set_status( OrderStatus::PROCESSING );
+		$order->save();
+
+		$this->assertTrue( wc_get_order( $order->get_id() )->meta_exists( NewOrderNotificationTrigger::NOTIFIABLE_META_KEY ), 'The order should be marked as notifiable' );
+	}
+
+	/**
+	 * @testdox Should notify about a new order that a plugin completes from the processing status hook.
+	 */
+	public function test_new_order_completed_from_processing_hook_adds_notification(): void {
+		add_action(
+			'woocommerce_order_status_processing',
+			static function ( $order_id, $order ) {
+				unset( $order_id ); // Avoid parameter not used PHPCS errors.
+				$order->update_status( OrderStatus::COMPLETED );
+			},
+			10,
+			2
+		);
+		$order = wc_create_order( array( 'status' => OrderStatus::PENDING ) );
+
+		$order->set_status( OrderStatus::PROCESSING );
+		$order->save();
+
+		$this->assertSame( OrderStatus::COMPLETED, wc_get_order( $order->get_id() )->get_status(), 'The plugin should have completed the order' );
+		$this->assertSame( 1, $this->store->count(), 'The nested processing to completed change should not hide the new order' );
+	}
+
+	/**
+	 * @testdox Should not mark an order moving between two notifiable statuses.
+	 * @testWith ["on_order_status_changed"]
+	 *           ["on_order_status_changed_while_disabled"]
+	 *
+	 * @param string $handler The trigger callback that handles the status change.
+	 */
+	public function test_status_change_between_notifiable_statuses_does_not_mark_order( string $handler ): void {
+		update_option( PushNotifications::ENABLED_AT_OPTION, 1000 );
+		$order = wc_create_order( array( 'status' => OrderStatus::PENDING ) );
+
+		$this->trigger->{$handler}( $order->get_id(), OrderStatus::PROCESSING, OrderStatus::COMPLETED, $order );
+
+		$this->assertFalse( wc_get_order( $order->get_id() )->meta_exists( NewOrderNotificationTrigger::NOTIFIABLE_META_KEY ), "{$handler} should only mark an order moving into a notifiable status" );
+	}
+
+	/**
+	 * @testdox Should not notify about an order that already reached a notifiable status, and should log why.
+	 */
+	public function test_status_change_on_previously_notifiable_order_is_ignored(): void {
+		$step_logger = $this->createMock( NotificationStepLogger::class );
+		wc_get_container()->replace( NotificationStepLogger::class, $step_logger );
+
+		$order = $this->create_order_in_custom_status();
+		$order->update_meta_data( NewOrderNotificationTrigger::NOTIFIABLE_META_KEY, '1000' );
+		$order->save_meta_data();
+		update_option( PushNotifications::ENABLED_AT_OPTION, $order->get_date_created()->getTimestamp() - 60 );
+
+		$step_logger->expects( $this->once() )
+			->method( 'log_notification_step' )
+			->with( $this->isInstanceOf( NewOrderNotification::class ), 'triggered', 'previously_notifiable' );
+
+		$order->set_status( OrderStatus::COMPLETED );
+		$order->save();
+
+		$this->assertSame( 0, $this->store->count(), 'An order marked while push notifications were off should not be announced as new' );
+	}
+
+	/**
+	 * @testdox Should mark an order reaching a notifiable status while push notifications are off on a site that has used them, without queuing a notification.
+	 */
+	public function test_status_change_while_disabled_marks_order_without_notifying(): void {
+		update_option( PushNotifications::ENABLED_AT_OPTION, 1000 );
+		$order = $this->create_order_in_custom_status();
+
+		$this->trigger->on_order_status_changed_while_disabled( $order->get_id(), self::CUSTOM_STATUS, OrderStatus::COMPLETED, $order );
+
+		$this->assertTrue( wc_get_order( $order->get_id() )->meta_exists( NewOrderNotificationTrigger::NOTIFIABLE_META_KEY ), 'The order should be marked as notifiable' );
+		$this->assertSame( 0, $this->store->count(), 'Nothing should be queued while push notifications are off' );
+	}
+
+	/**
+	 * @testdox Should not mark an order while push notifications are off on a site that never enabled them.
+	 */
+	public function test_status_change_while_disabled_does_not_mark_order_before_first_enablement(): void {
+		$order = $this->create_order_in_custom_status();
+
+		$this->trigger->on_order_status_changed_while_disabled( $order->get_id(), self::CUSTOM_STATUS, OrderStatus::COMPLETED, $order );
+
+		$this->assertFalse( wc_get_order( $order->get_id() )->meta_exists( NewOrderNotificationTrigger::NOTIFIABLE_META_KEY ), 'Orders should only be marked once push notifications have been enabled' );
+	}
+
+	/**
+	 * @testdox Should not read the enablement time while push notifications are off when an order moves to a non-notifiable status.
+	 */
+	public function test_status_change_while_disabled_to_non_notifiable_status_does_not_read_enabled_at(): void {
+		$order = wc_create_order( array( 'status' => OrderStatus::PENDING ) );
+		$reads = 0;
+		add_filter(
+			'pre_option_' . PushNotifications::ENABLED_AT_OPTION,
+			function ( $pre ) use ( &$reads ) {
+				++$reads;
+				return $pre;
+			}
+		);
+
+		$this->trigger->on_order_status_changed_while_disabled( $order->get_id(), OrderStatus::PENDING, OrderStatus::CANCELLED, $order );
+
+		$this->assertSame( 0, $reads, 'The option should only be read when an order moves into a notifiable status' );
+	}
+
+	/**
+	 * @testdox Should not mark an order moving to a non-notifiable status while push notifications are off.
+	 */
+	public function test_status_change_while_disabled_ignores_non_notifiable_status(): void {
+		update_option( PushNotifications::ENABLED_AT_OPTION, 1000 );
+		$order = wc_create_order( array( 'status' => OrderStatus::PENDING ) );
+
+		$this->trigger->on_order_status_changed_while_disabled( $order->get_id(), OrderStatus::PENDING, OrderStatus::CANCELLED, $order );
+
+		$this->assertFalse( wc_get_order( $order->get_id() )->meta_exists( NewOrderNotificationTrigger::NOTIFIABLE_META_KEY ), 'A cancelled order should not be marked' );
+	}
+
+	/**
+	 * @testdox Should mark the order on HPOS without a full order save, so woocommerce_update_order does not fire.
+	 * @testWith [false]
+	 *           [true]
+	 *
+	 * @param bool $sync_enabled Whether HPOS and posts data sync is on.
+	 */
+	public function test_marking_order_does_not_fire_update_order_hook( bool $sync_enabled ): void {
+		$previous_hpos = OrderUtil::custom_orders_table_usage_is_enabled();
+		add_filter( 'wc_allow_changing_orders_storage_while_sync_is_pending', '__return_true' );
+		add_filter(
+			'pre_option_' . DataSynchronizer::ORDERS_DATA_SYNC_ENABLED_OPTION,
+			static function () use ( $sync_enabled ) {
+				return wc_bool_to_string( $sync_enabled );
+			}
+		);
+		OrderHelper::toggle_cot_feature_and_usage( true );
+
+		try {
+			update_option( PushNotifications::ENABLED_AT_OPTION, 1000 );
+			$order = wc_create_order( array( 'status' => OrderStatus::PENDING ) );
+			// HPOS saves the whole order after a meta change unless the order was modified in the current second.
+			$order->set_date_modified( time() - HOUR_IN_SECONDS );
+			$order->save();
+			$update_order_count = 0;
+			add_action(
+				'woocommerce_update_order',
+				function () use ( &$update_order_count ) {
+					++$update_order_count;
+				}
+			);
+
+			$this->trigger->on_order_status_changed_while_disabled( $order->get_id(), OrderStatus::PENDING, OrderStatus::PROCESSING, $order );
+
+			$this->assertTrue( wc_get_order( $order->get_id() )->meta_exists( NewOrderNotificationTrigger::NOTIFIABLE_META_KEY ), 'The order should be marked as notifiable' );
+			$this->assertSame( 0, $update_order_count, 'Marking the order should not fire woocommerce_update_order' );
+			if ( $sync_enabled ) {
+				$this->assertNotEmpty( get_post_meta( $order->get_id(), NewOrderNotificationTrigger::NOTIFIABLE_META_KEY, true ), 'The marker should be copied to the posts table when sync is on' );
+			}
+		} finally {
+			OrderHelper::toggle_cot_feature_and_usage( $previous_hpos );
+		}
+	}
+
+	/**
+	 * @testdox Should log, not throw, when the order cannot be marked.
+	 */
+	public function test_marking_failure_is_logged_not_thrown(): void {
+		update_option( PushNotifications::ENABLED_AT_OPTION, 1000 );
+		$logger = $this->createMock( WC_Logger::class );
+		$logger->expects( $this->once() )
+			->method( 'error' )
+			->with( $this->stringContains( 'Could not mark order' ), array( 'source' => PushNotifications::FEATURE_NAME ) );
+		$this->register_legacy_proxy_function_mocks( array( 'wc_get_logger' => fn () => $logger ) );
+
+		$order = $this->createMock( WC_Order::class );
+		$order->method( 'save_meta_data' )->willThrowException( new Exception( 'Database unavailable' ) );
+
+		$this->trigger->on_order_status_changed_while_disabled( 1, self::CUSTOM_STATUS, OrderStatus::COMPLETED, $order );
+	}
+
+	/**
+	 * Creates an order in a custom, non-notifiable status registered for the test.
+	 *
+	 * @return WC_Order
+	 */
+	private function create_order_in_custom_status(): WC_Order {
+		add_filter(
+			'wc_order_statuses',
+			static function ( array $statuses ): array {
+				$statuses[ 'wc-' . self::CUSTOM_STATUS ] = 'In Production';
+				return $statuses;
+			}
+		);
+
+		return wc_create_order( array( 'status' => self::CUSTOM_STATUS ) );
+	}
+
 	/**
 	 * Data provider for all notifiable statuses.
 	 *