Commit a68f7284444 for woocommerce

commit a68f72844447e1b0671ab699fe2929b44b5ec756
Author: Adam Grzybkowski <agrzybkowski@outlook.com>
Date:   Tue Nov 3 10:02:59 2026 +0100

    Add a daily cleanup for stale push notification tokens (#70349)

    * Add a daily cleanup for stale push notification tokens

    * Reset the has_tokens cache after deleting a user's push tokens

    * Add changelog entry for stale push token cleanup

diff --git a/plugins/woocommerce/changelog/woomob-4215-stale-push-token-cleanup b/plugins/woocommerce/changelog/woomob-4215-stale-push-token-cleanup
new file mode 100644
index 00000000000..afa9ee74cf5
--- /dev/null
+++ b/plugins/woocommerce/changelog/woomob-4215-stale-push-token-cleanup
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Stop sending push notifications to devices that haven't checked in for 90 days (180 for older app versions).
diff --git a/plugins/woocommerce/src/Internal/PushNotifications/DataStores/PushTokensDataStore.php b/plugins/woocommerce/src/Internal/PushNotifications/DataStores/PushTokensDataStore.php
index 1499c8948b8..58d06cbd073 100644
--- a/plugins/woocommerce/src/Internal/PushNotifications/DataStores/PushTokensDataStore.php
+++ b/plugins/woocommerce/src/Internal/PushNotifications/DataStores/PushTokensDataStore.php
@@ -297,10 +297,128 @@ class PushTokensDataStore {
 		// Anything read earlier in this request now includes deleted tokens.
 		$this->tokens_by_roles_cache = array();
 		$this->token_count           = null;
+		$this->has_tokens            = null;

 		return $deleted;
 	}

+	/**
+	 * Returns the IDs of the push tokens last registered before a cutoff.
+	 *
+	 * Re-registering a device updates its post, so `post_modified_gmt` is the
+	 * last time an app confirmed the token. Oldest first, all in one query.
+	 *
+	 * @since 11.4.0
+	 *
+	 * @param string $cutoff_gmt A GMT `Y-m-d H:i:s` datetime. Tokens registered at exactly this time are not returned.
+	 * @return int[]
+	 */
+	public function get_stale_token_ids( string $cutoff_gmt ): array {
+		global $wpdb;
+
+		return array_map(
+			'intval',
+			$wpdb->get_col(
+				$wpdb->prepare(
+					"SELECT ID FROM {$wpdb->posts} WHERE post_type = %s AND post_status = 'private' AND post_modified_gmt < %s ORDER BY post_modified_gmt ASC, ID ASC",
+					PushToken::POST_TYPE,
+					$cutoff_gmt
+				)
+			)
+		);
+	}
+
+	/**
+	 * Deletes push tokens by ID, one at a time so a failure cannot stop the rest.
+	 *
+	 * IDs that are not push tokens, or no longer exist, are skipped. With a
+	 * cutoff, each token is checked again just before it is deleted, so a device
+	 * that re-registered since the caller selected it is kept.
+	 *
+	 * @since 11.4.0
+	 *
+	 * @param int[]       $ids        The push token IDs.
+	 * @param string|null $cutoff_gmt Optional GMT `Y-m-d H:i:s` datetime a token must have been registered before.
+	 * @return array{deleted: int[], failed: int[]} The IDs deleted, and the IDs whose deletion failed.
+	 */
+	public function delete_tokens( array $ids, ?string $cutoff_gmt = null ): array {
+		$result = array(
+			'deleted' => array(),
+			'failed'  => array(),
+		);
+
+		$ids = array_values( array_unique( array_filter( array_map( 'intval', $ids ), fn( int $id ) => $id > 0 ) ) );
+
+		if ( empty( $ids ) ) {
+			return $result;
+		}
+
+		// Prime caches to reduce future queries.
+		_prime_post_caches( $ids, false, false );
+
+		foreach ( $ids as $id ) {
+			if ( ! $this->is_deletable_token( $id, $cutoff_gmt ) ) {
+				continue;
+			}
+
+			try {
+				$deleted = wp_delete_post( $id, true );
+			} catch ( Throwable $e ) {
+				$deleted = false;
+			}
+
+			if ( $deleted ) {
+				$result['deleted'][] = $id;
+				unset( $this->pending_last_sent_at[ $id ] );
+			} else {
+				$result['failed'][] = $id;
+			}
+		}
+
+		// Anything read earlier in this request now includes deleted tokens.
+		$this->tokens_by_roles_cache = array();
+		$this->token_count           = null;
+		$this->has_tokens            = null;
+
+		return $result;
+	}
+
+	/**
+	 * Whether an ID is a push token that may be deleted: it still exists and,
+	 * with a cutoff, was last registered before it.
+	 *
+	 * Read from the database rather than the post cache, so a re-registration
+	 * by another request since the caller selected the ID is seen.
+	 *
+	 * @param int         $id         The post ID.
+	 * @param string|null $cutoff_gmt Optional GMT `Y-m-d H:i:s` datetime the token must have been registered before.
+	 * @return bool
+	 */
+	private function is_deletable_token( int $id, ?string $cutoff_gmt ): bool {
+		global $wpdb;
+
+		if ( null === $cutoff_gmt ) {
+			$found = $wpdb->get_var(
+				$wpdb->prepare(
+					"SELECT ID FROM {$wpdb->posts} WHERE ID = %d AND post_type = %s",
+					$id,
+					PushToken::POST_TYPE
+				)
+			);
+		} else {
+			$found = $wpdb->get_var(
+				$wpdb->prepare(
+					"SELECT ID FROM {$wpdb->posts} WHERE ID = %d AND post_type = %s AND post_modified_gmt < %s",
+					$id,
+					PushToken::POST_TYPE,
+					$cutoff_gmt
+				)
+			);
+		}
+
+		return null !== $found;
+	}
+
 	/**
 	 * Find tokens for this user and platform that match either the token
 	 * or device UUID. We check the token value to avoid creating a duplicate.
diff --git a/plugins/woocommerce/src/Internal/PushNotifications/Enums/CleanupOutcome.php b/plugins/woocommerce/src/Internal/PushNotifications/Enums/CleanupOutcome.php
new file mode 100644
index 00000000000..58211528d4f
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/PushNotifications/Enums/CleanupOutcome.php
@@ -0,0 +1,54 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Internal\PushNotifications\Enums;
+
+/**
+ * Outcomes of a stale push token cleanup run.
+ */
+final class CleanupOutcome {
+
+	/**
+	 * Every stale token the run selected was handled.
+	 *
+	 * @var string
+	 */
+	public const COMPLETED = 'completed';
+
+	/**
+	 * The run hit its deletion limit, leaving stale tokens for the next run.
+	 *
+	 * @var string
+	 */
+	public const CAPPED = 'capped';
+
+	/**
+	 * Push notifications are not enabled on the store, so nothing was deleted.
+	 *
+	 * @var string
+	 */
+	public const SKIPPED_DISABLED = 'skipped_disabled';
+
+	/**
+	 * The cleanup was switched off with a filter, so nothing was deleted.
+	 *
+	 * @var string
+	 */
+	public const SKIPPED_FILTER = 'skipped_filter';
+
+	/**
+	 * Push notifications came back on less than a retention window ago, so the
+	 * apps have not yet had the chance to check in and nothing was deleted.
+	 *
+	 * @var string
+	 */
+	public const SKIPPED_RECENTLY_RESUMED = 'skipped_recently_resumed';
+
+	/**
+	 * The run stopped on an unexpected error.
+	 *
+	 * @var string
+	 */
+	public const FAILED = 'failed';
+}
diff --git a/plugins/woocommerce/src/Internal/PushNotifications/PushNotifications.php b/plugins/woocommerce/src/Internal/PushNotifications/PushNotifications.php
index 9afd0d615fa..ff4f5217760 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\Entities\PushToken;
 use Automattic\WooCommerce\Internal\PushNotifications\Services\DriverAvailabilityService;
 use Automattic\WooCommerce\Internal\PushNotifications\Services\NotificationProcessor;
 use Automattic\WooCommerce\Internal\PushNotifications\Services\NotificationRetryHandler;
+use Automattic\WooCommerce\Internal\PushNotifications\Services\StaleTokenCleanupService;
 use Automattic\WooCommerce\Internal\PushNotifications\Services\UserDataCleanupService;
 use Automattic\WooCommerce\Internal\PushNotifications\Services\PendingNotificationStore;
 use Automattic\WooCommerce\Internal\PushNotifications\Triggers\NewOrderNotificationTrigger;
@@ -90,6 +91,10 @@ class PushNotifications {
 		// to start sending against.
 		wc_get_container()->get( UserDataCleanupService::class )->register();

+		// Also registered ahead of the enablement check: it unschedules the
+		// cleanup while the feature is off, and tracks how long it was off.
+		wc_get_container()->get( StaleTokenCleanupService::class )->register();
+
 		// Registered ahead of the enablement check so the token list can still be
 		// read on a store that has been switched off. The controller registers the
 		// write routes only while the module is enabled, like everything below.
diff --git a/plugins/woocommerce/src/Internal/PushNotifications/ROADMAP.md b/plugins/woocommerce/src/Internal/PushNotifications/ROADMAP.md
index 908283b007a..07bc1399f59 100644
--- a/plugins/woocommerce/src/Internal/PushNotifications/ROADMAP.md
+++ b/plugins/woocommerce/src/Internal/PushNotifications/ROADMAP.md
@@ -30,6 +30,7 @@ The following terms have been used in this post to mean:
     - Users who don't connect Jetpack
     - Users who don't upgrade WooCommerce
     - WordPress.com Notification Control Center preferences
+    - Stale token cleanup

 ## Goals

@@ -237,3 +238,15 @@ These users won't receive push notifications - this is the same as the existing
 ### WordPress.com Notification Control Center preferences

 - These will still be respected for all WooCommerce notifications, as it is part of our duplicate notification avoidance strategy
+
+### Stale token cleanup
+
+- From 25.8, the iOS and Android apps check in with every visible store at most once a day while the app is used (on iOS, only when it is opened). Each check-in re-registers the token, which moves its `post_modified_gmt` forward and records `metadata.app_version`.
+- A daily Action Scheduler job (`wc_push_notification_stale_token_cleanup`) deletes:
+    - tokens last registered by a WooCommerce app at 25.8 or later that have not checked in for 90 days
+    - any other token, such as one from an older app version, that has not re-registered for 180 days
+- Older app versions are kept for longer because they do not check in daily, so a quiet token is not a sign the install is gone.
+- Creation time and send attempts are not used as a sign of activity: Woo attempts a send to every token and cannot tell which deliveries succeeded.
+- While push notifications are off (feature disabled, Jetpack disconnected or WooCommerce deactivated) the job is unscheduled, because the apps cannot re-register. Once they are back on, silence from before that moment does not count: the 90-day rule waits 90 days and the 180-day rule waits 180 days.
+- Each run deletes at most 100 tokens, oldest first, and attempts at most 500. It writes one `info` summary line to the `push_notifications` log, even when step logging is off; a log threshold above `info` drops it.
+- Return false from the `woocommerce_push_notification_stale_token_cleanup_enabled` filter to keep every token.
diff --git a/plugins/woocommerce/src/Internal/PushNotifications/Services/NotificationStepLogger.php b/plugins/woocommerce/src/Internal/PushNotifications/Services/NotificationStepLogger.php
index dad1dc76841..ea5ec8606e3 100644
--- a/plugins/woocommerce/src/Internal/PushNotifications/Services/NotificationStepLogger.php
+++ b/plugins/woocommerce/src/Internal/PushNotifications/Services/NotificationStepLogger.php
@@ -168,6 +168,33 @@ class NotificationStepLogger {
 		}
 	}

+	/**
+	 * Records a step of a maintenance job that belongs to no notification,
+	 * such as the stale token cleanup.
+	 *
+	 * Written under the module's own source, and not switched off with step
+	 * logging, since it is the only record of what a job deleted.
+	 *
+	 * @since 11.4.0
+	 *
+	 * @param string $step    Machine-readable step name, e.g. `stale_token_cleanup`.
+	 * @param string $outcome Machine-readable outcome, e.g. `completed`.
+	 * @param array  $context Extra fields to store with the line.
+	 * @return void
+	 */
+	public function log_maintenance_step( string $step, string $outcome, array $context = array() ): void {
+		try {
+			$context['step']           = $step;
+			$context['outcome']        = $outcome;
+			$context['source']         = PushNotifications::FEATURE_NAME;
+			$context['remote-logging'] = false;
+
+			wc_get_logger()->info( self::format_message( $step, $outcome ), self::bound( $context ) );
+		} catch ( Throwable $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch
+			// Deliberately ignored: a logging failure must not affect the job.
+		}
+	}
+
 	/**
 	 * Records a failure that happened before a notification could be
 	 * identified, such as a loopback request that failed authorization.
diff --git a/plugins/woocommerce/src/Internal/PushNotifications/Services/StaleTokenCleanupService.php b/plugins/woocommerce/src/Internal/PushNotifications/Services/StaleTokenCleanupService.php
new file mode 100644
index 00000000000..a10cdb52c3f
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/PushNotifications/Services/StaleTokenCleanupService.php
@@ -0,0 +1,477 @@
+<?php
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\PushNotifications\Services;
+
+defined( 'ABSPATH' ) || exit;
+
+use Automattic\WooCommerce\Internal\PushNotifications\DataStores\PushTokensDataStore;
+use Automattic\WooCommerce\Internal\PushNotifications\Entities\PushToken;
+use Automattic\WooCommerce\Internal\PushNotifications\Enums\CleanupOutcome;
+use Automattic\WooCommerce\Internal\PushNotifications\PushNotifications;
+use Throwable;
+
+/**
+ * Deletes push tokens that no app has re-registered for a long time.
+ *
+ * Apps from 25.8 check in daily while used, so their tokens go after
+ * {@see self::RETENTION_DAYS} days of silence; any other token after
+ * {@see self::FALLBACK_RETENTION_DAYS}. See ROADMAP.md for the reasoning.
+ *
+ * @since 11.4.0
+ *
+ * @internal
+ */
+class StaleTokenCleanupService {
+	/**
+	 * Action Scheduler hook for the daily cleanup.
+	 */
+	const CLEANUP_HOOK = 'wc_push_notification_stale_token_cleanup';
+
+	/**
+	 * Days a token from an app that checks in daily is kept without a registration.
+	 */
+	const RETENTION_DAYS = 90;
+
+	/**
+	 * Days any other token is kept without a registration.
+	 */
+	const FALLBACK_RETENTION_DAYS = 180;
+
+	/**
+	 * The first app version that checks in daily.
+	 */
+	const MIN_CHECK_IN_APP_VERSION = '25.8';
+
+	/**
+	 * Origins of the apps that check in daily.
+	 */
+	const CHECK_IN_ORIGINS = array(
+		PushToken::ORIGIN_WOOCOMMERCE_ANDROID,
+		PushToken::ORIGIN_WOOCOMMERCE_ANDROID_DEV,
+		PushToken::ORIGIN_WOOCOMMERCE_IOS,
+		PushToken::ORIGIN_WOOCOMMERCE_IOS_DEV,
+	);
+
+	/**
+	 * How many stale tokens to load and classify at once.
+	 */
+	const CHUNK_SIZE = 100;
+
+	/**
+	 * The most tokens one run deletes, so a bug cannot empty a store at once.
+	 */
+	const MAX_DELETIONS_PER_RUN = 100;
+
+	/**
+	 * The most deletions one run attempts, counting failures, so tokens that
+	 * never delete cannot make a run endless.
+	 */
+	const MAX_ATTEMPTS_PER_RUN = 500;
+
+	/**
+	 * Option set while push notifications are off, as a Unix timestamp.
+	 */
+	const PAUSED_AT_OPTION = 'woocommerce_push_notification_cleanup_paused_at';
+
+	/**
+	 * Option holding when push notifications came back on, as a Unix timestamp.
+	 *
+	 * Apps cannot re-register while push notifications are off, so silence from
+	 * before this moment does not count towards a token's age.
+	 */
+	const RESUMED_AT_OPTION = 'woocommerce_push_notification_cleanup_resumed_at';
+
+	/**
+	 * Step name for the run's log line.
+	 */
+	const LOG_STEP = 'stale_token_cleanup';
+
+	/**
+	 * The push tokens data store.
+	 *
+	 * @var PushTokensDataStore
+	 */
+	private PushTokensDataStore $data_store;
+
+	/**
+	 * The step logger.
+	 *
+	 * @var NotificationStepLogger
+	 */
+	private NotificationStepLogger $step_logger;
+
+	/**
+	 * The push notifications feature class.
+	 *
+	 * @var PushNotifications
+	 */
+	private PushNotifications $push_notifications;
+
+	/**
+	 * Initialize dependencies.
+	 *
+	 * @since 11.4.0
+	 *
+	 * @internal
+	 *
+	 * @param PushTokensDataStore    $data_store         The push tokens data store.
+	 * @param NotificationStepLogger $step_logger        The step logger.
+	 * @param PushNotifications      $push_notifications The push notifications feature class.
+	 */
+	final public function init(
+		PushTokensDataStore $data_store,
+		NotificationStepLogger $step_logger,
+		PushNotifications $push_notifications
+	): void {
+		$this->data_store         = $data_store;
+		$this->step_logger        = $step_logger;
+		$this->push_notifications = $push_notifications;
+	}
+
+	/**
+	 * Registers the cleanup, scheduling and deactivation callbacks.
+	 *
+	 * Called whether or not push notifications are enabled, so the schedule
+	 * follows the store's state and an action left pending never lacks a callback.
+	 *
+	 * @since 11.4.0
+	 *
+	 * @return void
+	 */
+	public function register(): void {
+		add_action( self::CLEANUP_HOOK, array( $this, 'handle_stale_token_cleanup' ) );
+		add_action( 'action_scheduler_ensure_recurring_actions', array( $this, 'handle_ensure_recurring_actions' ) );
+
+		if ( defined( 'WC_PLUGIN_BASENAME' ) ) {
+			add_action( 'deactivate_' . WC_PLUGIN_BASENAME, array( $this, 'handle_plugin_deactivation' ) );
+		}
+	}
+
+	/**
+	 * Handles the daily action_scheduler_ensure_recurring_actions hook.
+	 *
+	 * Keeps the cleanup scheduled while push notifications are on, and removes
+	 * it while they are off.
+	 *
+	 * @since 11.4.0
+	 *
+	 * @internal
+	 *
+	 * @return void
+	 */
+	public function handle_ensure_recurring_actions(): void {
+		if ( ! $this->push_notifications->should_be_enabled() ) {
+			$this->pause();
+			return;
+		}
+
+		$this->note_resumed( time() );
+
+		// The first run waits a day, so a store's first cleanup never follows
+		// straight on from the update or switch that scheduled it.
+		as_schedule_recurring_action(
+			time() + DAY_IN_SECONDS,
+			DAY_IN_SECONDS,
+			self::CLEANUP_HOOK,
+			array(),
+			NotificationProcessor::ACTION_SCHEDULER_GROUP,
+			true
+		);
+	}
+
+	/**
+	 * Handles WooCommerce being deactivated.
+	 *
+	 * @since 11.4.0
+	 *
+	 * @internal
+	 *
+	 * @return void
+	 */
+	public function handle_plugin_deactivation(): void {
+		$this->pause();
+	}
+
+	/**
+	 * Handles the daily cleanup action.
+	 *
+	 * @since 11.4.0
+	 *
+	 * @internal
+	 *
+	 * @return void
+	 */
+	public function handle_stale_token_cleanup(): void {
+		try {
+			$this->run();
+		} catch ( Throwable $e ) {
+			$this->step_logger->log_unattributed_failure(
+				self::LOG_STEP,
+				CleanupOutcome::FAILED,
+				'error',
+				sprintf( 'Stale push token cleanup failed: %s', $e->getMessage() )
+			);
+		}
+	}
+
+	/**
+	 * Deletes up to {@see self::MAX_DELETIONS_PER_RUN} stale tokens, oldest
+	 * first, and logs what it did.
+	 *
+	 * @since 11.4.0
+	 *
+	 * @param int|null $now Unix timestamp to measure the retention from. Defaults to now.
+	 * @return array The summary that was logged, including the outcome.
+	 */
+	public function run( ?int $now = null ): array {
+		$now = $now ?? time();
+
+		if ( ! $this->push_notifications->should_be_enabled() ) {
+			$this->mark_paused( $now );
+			return $this->log_summary( CleanupOutcome::SKIPPED_DISABLED, array() );
+		}
+
+		if ( ! $this->is_enabled_by_filter() ) {
+			return $this->log_summary( CleanupOutcome::SKIPPED_FILTER, array() );
+		}
+
+		$resumed_at      = $this->note_resumed( $now );
+		$check_in_cutoff = $this->get_cutoff( $now, self::RETENTION_DAYS, $resumed_at );
+		$cutoffs         = array(
+			'check_in' => $check_in_cutoff,
+			'fallback' => $this->get_cutoff( $now, self::FALLBACK_RETENTION_DAYS, $resumed_at ),
+		);
+
+		if ( null === $check_in_cutoff ) {
+			return $this->log_summary( CleanupOutcome::SKIPPED_RECENTLY_RESUMED, array( 'resumed_at' => gmdate( 'Y-m-d H:i:s', (int) $resumed_at ) ) );
+		}
+
+		$summary = array(
+			'cutoff'                 => $cutoffs['check_in'],
+			'fallback_cutoff'        => $cutoffs['fallback'],
+			'resumed_at'             => null === $resumed_at ? null : gmdate( 'Y-m-d H:i:s', $resumed_at ),
+			'tokens_before'          => $this->data_store->count_tokens(),
+			'stale'                  => 0,
+			'kept_older_apps'        => 0,
+			'deleted_daily_check_in' => 0,
+			'deleted_fallback'       => 0,
+			'failed'                 => 0,
+			'deleted_token_ids'      => array(),
+			'failed_token_ids'       => array(),
+		);
+		$outcome = CleanupOutcome::COMPLETED;
+
+		try {
+			$stale_ids          = $this->data_store->get_stale_token_ids( $check_in_cutoff );
+			$summary['stale']   = count( $stale_ids );
+			$attempts_remaining = self::MAX_ATTEMPTS_PER_RUN;
+
+			foreach ( array_chunk( $stale_ids, self::CHUNK_SIZE ) as $chunk ) {
+				$deletions_remaining = self::MAX_DELETIONS_PER_RUN - count( $summary['deleted_token_ids'] );
+				$limit               = min( $deletions_remaining, $attempts_remaining );
+
+				list( $deletable, $kept ) = $this->classify( $chunk, $cutoffs['fallback'] );
+
+				$summary['kept_older_apps'] += $kept;
+
+				if ( count( $deletable ) > $limit ) {
+					$deletable = array_slice( $deletable, 0, $limit, true );
+					$outcome   = CleanupOutcome::CAPPED;
+				}
+
+				$attempts_remaining -= count( $deletable );
+
+				foreach ( array( 'check_in', 'fallback' ) as $rule ) {
+					$ids    = array_keys( array_filter( $deletable, fn( string $matched ) => $rule === $matched ) );
+					$result = $this->data_store->delete_tokens( $ids, $cutoffs[ $rule ] );
+
+					$summary[ 'check_in' === $rule ? 'deleted_daily_check_in' : 'deleted_fallback' ] += count( $result['deleted'] );
+					$summary['failed']           += count( $result['failed'] );
+					$summary['deleted_token_ids'] = array_merge( $summary['deleted_token_ids'], $result['deleted'] );
+					$summary['failed_token_ids']  = array_merge( $summary['failed_token_ids'], $result['failed'] );
+				}
+
+				if ( CleanupOutcome::CAPPED === $outcome ) {
+					break;
+				}
+			}
+		} catch ( Throwable $e ) {
+			$outcome          = CleanupOutcome::FAILED;
+			$summary['error'] = $e->getMessage();
+		}
+
+		return $this->log_summary( $outcome, $summary );
+	}
+
+	/**
+	 * Returns the GMT datetime a token must have registered before to be
+	 * deleted under a rule, or null while the rule is waiting.
+	 *
+	 * Silence from before push notifications came back on does not count, so
+	 * a rule applies only once its full window has passed since then.
+	 *
+	 * @param int      $now        Unix timestamp.
+	 * @param int      $days       The rule's retention in days.
+	 * @param int|null $resumed_at When push notifications came back on, if that still matters.
+	 * @return string|null
+	 */
+	private function get_cutoff( int $now, int $days, ?int $resumed_at ): ?string {
+		$cutoff = $now - $days * DAY_IN_SECONDS;
+
+		if ( null !== $resumed_at && $resumed_at >= $cutoff ) {
+			return null;
+		}
+
+		return gmdate( 'Y-m-d H:i:s', $cutoff );
+	}
+
+	/**
+	 * Picks the stale tokens to delete, in the order given, and counts the rest.
+	 *
+	 * @param int[]       $ids             Tokens last registered before the daily check-in cutoff, oldest first.
+	 * @param string|null $fallback_cutoff GMT `Y-m-d H:i:s` datetime before which any token is deleted, or null when that rule is paused.
+	 * @return array{0: array<int, string>, 1: int} Token ID => the rule it is deleted under (`check_in` or `fallback`), and how many were kept.
+	 */
+	private function classify( array $ids, ?string $fallback_cutoff ): array {
+		$deletable = array();
+		$kept      = 0;
+
+		if ( ! empty( $ids ) ) {
+			// Prime caches to reduce future queries.
+			_prime_post_caches( $ids, false, true );
+		}
+
+		foreach ( $ids as $id ) {
+			$post = get_post( $id );
+
+			if ( ! $post ) {
+				continue;
+			}
+
+			if ( $this->checks_in_daily( $id ) ) {
+				$deletable[ $id ] = 'check_in';
+			} elseif ( null !== $fallback_cutoff && $post->post_modified_gmt < $fallback_cutoff ) {
+				$deletable[ $id ] = 'fallback';
+			} else {
+				++$kept;
+			}
+		}
+
+		return array( $deletable, $kept );
+	}
+
+	/**
+	 * Whether a token was last registered by an app that checks in daily.
+	 *
+	 * Compares only `major.minor`, since `version_compare()` ranks Android's
+	 * `25.8-rc-1` below `25.8`. A missing or unrecognised version counts as old.
+	 *
+	 * @param int $id The token's post ID.
+	 * @return bool
+	 */
+	private function checks_in_daily( int $id ): bool {
+		if ( ! in_array( get_post_meta( $id, 'origin', true ), self::CHECK_IN_ORIGINS, true ) ) {
+			return false;
+		}
+
+		$metadata = get_post_meta( $id, 'metadata', true );
+
+		if ( ! is_array( $metadata ) || ! is_scalar( $metadata['app_version'] ?? null ) ) {
+			return false;
+		}
+
+		if ( ! preg_match( '/^(\d+)\.(\d+)/', (string) $metadata['app_version'], $matches ) ) {
+			return false;
+		}
+
+		return version_compare( $matches[1] . '.' . $matches[2], self::MIN_CHECK_IN_APP_VERSION, '>=' );
+	}
+
+	/**
+	 * Removes the cleanup and records that push notifications are off.
+	 *
+	 * @return void
+	 */
+	private function pause(): void {
+		as_unschedule_all_actions( self::CLEANUP_HOOK, array(), NotificationProcessor::ACTION_SCHEDULER_GROUP );
+		$this->mark_paused( time() );
+	}
+
+	/**
+	 * Records that push notifications are off, keeping the first time it was seen.
+	 *
+	 * @param int $now Unix timestamp.
+	 * @return void
+	 */
+	private function mark_paused( int $now ): void {
+		add_option( self::PAUSED_AT_OPTION, $now, '', false );
+	}
+
+	/**
+	 * Turns a recorded pause into a resume time, and returns the resume time
+	 * while it still affects a rule.
+	 *
+	 * @param int $now Unix timestamp.
+	 * @return int|null When push notifications came back on, or null if that no longer matters.
+	 */
+	private function note_resumed( int $now ): ?int {
+		if ( false !== get_option( self::PAUSED_AT_OPTION ) ) {
+			update_option( self::RESUMED_AT_OPTION, $now, false );
+			delete_option( self::PAUSED_AT_OPTION );
+		}
+
+		$resumed_at = (int) get_option( self::RESUMED_AT_OPTION, 0 );
+
+		if ( $resumed_at <= 0 ) {
+			return null;
+		}
+
+		if ( $resumed_at < $now - self::FALLBACK_RETENTION_DAYS * DAY_IN_SECONDS ) {
+			delete_option( self::RESUMED_AT_OPTION );
+			return null;
+		}
+
+		return $resumed_at;
+	}
+
+	/**
+	 * Whether the cleanup filter allows this run. A callback that throws or
+	 * returns something unrecognised turns the cleanup off.
+	 *
+	 * @return bool
+	 */
+	private function is_enabled_by_filter(): bool {
+		try {
+			/**
+			 * Filters whether stale push tokens are deleted.
+			 *
+			 * Runs once per daily cleanup. Return false to keep every token, for
+			 * example while investigating missing notifications.
+			 *
+			 * @since 11.4.0
+			 *
+			 * @param bool $enabled Whether stale push tokens are deleted. Default true.
+			 */
+			$enabled = apply_filters( 'woocommerce_push_notification_stale_token_cleanup_enabled', true );
+
+			return ( is_bool( $enabled ) || is_string( $enabled ) || is_int( $enabled ) ) && wc_string_to_bool( $enabled );
+		} catch ( Throwable $e ) {
+			return false;
+		}
+	}
+
+	/**
+	 * Writes the run's summary line and returns it.
+	 *
+	 * @param string $outcome One of the CleanupOutcome constants.
+	 * @param array  $summary The run's counts.
+	 * @return array The summary, with the outcome added.
+	 */
+	private function log_summary( string $outcome, array $summary ): array {
+		$this->step_logger->log_maintenance_step( self::LOG_STEP, $outcome, $summary );
+
+		return array_merge( $summary, array( 'outcome' => $outcome ) );
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/DataStores/PushTokensDataStoreTest.php b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/DataStores/PushTokensDataStoreTest.php
index 312de3e4d12..fe14ba24924 100644
--- a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/DataStores/PushTokensDataStoreTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/DataStores/PushTokensDataStoreTest.php
@@ -9,6 +9,8 @@ use Automattic\WooCommerce\Internal\PushNotifications\Entities\PushToken;
 use Automattic\WooCommerce\Internal\PushNotifications\Exceptions\PushTokenInvalidDataException;
 use Automattic\WooCommerce\Internal\PushNotifications\Exceptions\PushTokenNotFoundException;
 use Automattic\WooCommerce\RestApi\UnitTests\LoggerSpyTrait;
+use Automattic\WooCommerce\Tests\Internal\PushNotifications\Helpers\PushNotificationsTestTrait;
+use RuntimeException;
 use WC_Unit_Test_Case;

 /**
@@ -19,6 +21,7 @@ use WC_Unit_Test_Case;
 class PushTokensDataStoreTest extends WC_Unit_Test_Case {

 	use LoggerSpyTrait;
+	use PushNotificationsTestTrait;

 	/**
 	 * Tear down the test case.
@@ -1243,31 +1246,6 @@ class PushTokensDataStoreTest extends WC_Unit_Test_Case {
 		}
 	}

-	/**
-	 * Sets the date a token was first registered and the date it was last registered.
-	 *
-	 * @param int         $post_id        The token's post ID.
-	 * @param string      $created        The first registration date, as GMT.
-	 * @param string      $modified       The last registration date, as GMT.
-	 * @param string|null $modified_local The last registration date in site time, defaulting to $modified.
-	 */
-	private function set_token_dates( int $post_id, string $created, string $modified, ?string $modified_local = null ): void {
-		global $wpdb;
-
-		$wpdb->update(
-			$wpdb->posts,
-			array(
-				'post_date'         => $created,
-				'post_date_gmt'     => $created,
-				'post_modified'     => $modified_local ?? $modified,
-				'post_modified_gmt' => $modified,
-			),
-			array( 'ID' => $post_id )
-		);
-
-		clean_post_cache( $post_id );
-	}
-
 	/**
 	 * Creates a push token owned by the given user.
 	 *
@@ -1348,6 +1326,205 @@ class PushTokensDataStoreTest extends WC_Unit_Test_Case {
 		$this->assertSame( '', get_post_meta( $token_id, 'token', true ) );
 	}

+	/**
+	 * @testdox Should stop reporting tokens in the same request once a user's last token is deleted.
+	 */
+	public function test_delete_for_user_resets_the_memoized_has_tokens(): void {
+		$data_store = new PushTokensDataStore();
+		$this->create_push_token_for_user( $data_store, 106 );
+
+		$this->assertTrue( $data_store->has_tokens(), 'Memoizes has_tokens() before the delete.' );
+
+		$data_store->delete_for_user( 106 );
+
+		$this->assertFalse( $data_store->has_tokens(), 'has_tokens() should be read again after the delete.' );
+	}
+
+	/**
+	 * @testdox Should return only the tokens last registered strictly before the cutoff.
+	 */
+	public function test_get_stale_token_ids_excludes_tokens_registered_at_or_after_the_cutoff(): void {
+		$data_store = new PushTokensDataStore();
+		$before     = $this->create_push_token_for_user( $data_store, 101 );
+		$at         = $this->create_push_token_for_user( $data_store, 101 );
+		$after      = $this->create_push_token_for_user( $data_store, 101 );
+
+		$this->set_token_dates( $before->get_id(), '2026-01-01 00:00:00', '2026-06-30 23:59:59' );
+		$this->set_token_dates( $at->get_id(), '2026-01-01 00:00:00', '2026-07-01 00:00:00' );
+		$this->set_token_dates( $after->get_id(), '2026-01-01 00:00:00', '2026-07-01 00:00:01' );
+
+		$this->assertSame( array( $before->get_id() ), $data_store->get_stale_token_ids( '2026-07-01 00:00:00' ) );
+	}
+
+	/**
+	 * @testdox Should return stale tokens oldest first by last registration, not by ID or creation date.
+	 */
+	public function test_get_stale_token_ids_orders_by_last_registration(): void {
+		$data_store = new PushTokensDataStore();
+		$newer      = $this->create_push_token_for_user( $data_store, 101 );
+		$older      = $this->create_push_token_for_user( $data_store, 101 );
+
+		$this->set_token_dates( $newer->get_id(), '2026-01-01 00:00:00', '2026-03-01 00:00:00' );
+		$this->set_token_dates( $older->get_id(), '2026-02-01 00:00:00', '2026-02-01 00:00:00' );
+
+		$this->assertSame( array( $older->get_id(), $newer->get_id() ), $data_store->get_stale_token_ids( '2026-07-01 00:00:00' ) );
+	}
+
+	/**
+	 * @testdox Should select stale tokens by their GMT registration time, not the site's local time.
+	 */
+	public function test_get_stale_token_ids_compares_gmt_times(): void {
+		$data_store = new PushTokensDataStore();
+		$token      = $this->create_push_token_for_user( $data_store, 101 );
+
+		$this->set_token_dates( $token->get_id(), '2026-01-01 00:00:00', '2026-07-01 00:00:01', '2026-06-30 20:00:01' );
+
+		$this->assertSame( array(), $data_store->get_stale_token_ids( '2026-07-01 00:00:00' ), 'A token registered just after the cutoff in GMT should not be stale.' );
+	}
+
+	/**
+	 * @testdox Should not return old posts of other types as stale tokens.
+	 */
+	public function test_get_stale_token_ids_ignores_other_post_types(): void {
+		$data_store = new PushTokensDataStore();
+		self::factory()->post->create(
+			array(
+				'post_status'       => 'private',
+				'post_date'         => '2020-01-01 00:00:00',
+				'post_modified_gmt' => '2020-01-01 00:00:00',
+			)
+		);
+
+		$this->assertSame( array(), $data_store->get_stale_token_ids( '2026-07-01 00:00:00' ) );
+	}
+
+	/**
+	 * @testdox Should keep a token that re-registers while earlier tokens in the same call are being deleted.
+	 */
+	public function test_delete_tokens_checks_each_token_just_before_deleting_it(): void {
+		global $wpdb;
+
+		$data_store = new PushTokensDataStore();
+		$first      = $this->create_push_token_for_user( $data_store, 101 );
+		$second     = $this->create_push_token_for_user( $data_store, 101 );
+		$this->set_token_dates( $first->get_id(), '2026-01-01 00:00:00', '2026-01-01 00:00:00' );
+		$this->set_token_dates( $second->get_id(), '2026-01-01 00:00:00', '2026-01-01 00:00:00' );
+
+		add_action(
+			'before_delete_post',
+			function ( $post_id ) use ( $wpdb, $first, $second ) {
+				if ( $post_id === $first->get_id() ) {
+					$wpdb->update( $wpdb->posts, array( 'post_modified_gmt' => '2026-08-01 00:00:00' ), array( 'ID' => $second->get_id() ) );
+				}
+			}
+		);
+
+		$result = $data_store->delete_tokens( array( $first->get_id(), $second->get_id() ), '2026-07-01 00:00:00' );
+
+		$this->assertSame( array( $first->get_id() ), $result['deleted'], 'Only the token still stale at its turn should go.' );
+		$this->assertNotNull( get_post( $second->get_id() ), 'The token that re-registered mid-call should be kept.' );
+	}
+
+	/**
+	 * @testdox Should drop a deleted token's pending last-send stamp so the flush writes no orphan meta.
+	 */
+	public function test_delete_tokens_drops_pending_last_sent_at(): void {
+		global $wpdb;
+
+		$data_store = new PushTokensDataStore();
+		$token      = $this->create_push_token_for_user( $data_store, 101 );
+		$data_store->record_last_sent_at( array( $token ) );
+
+		$data_store->delete_tokens( array( $token->get_id() ) );
+		$data_store->flush_last_sent_at();
+
+		$this->assertSame(
+			'0',
+			$wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->postmeta} WHERE post_id = %d", $token->get_id() ) ),
+			'No meta should be written for a deleted token.'
+		);
+	}
+
+	/**
+	 * @testdox Should delete the tokens and refresh the counts read earlier in the request.
+	 */
+	public function test_delete_tokens_deletes_and_resets_cached_counts(): void {
+		$data_store = new PushTokensDataStore();
+		$first      = $this->create_push_token_for_user( $data_store, 101 );
+		$second     = $this->create_push_token_for_user( $data_store, 101 );
+
+		$this->assertSame( 2, $data_store->count_tokens(), 'Memoizes the count before the delete.' );
+		$this->assertTrue( $data_store->has_tokens(), 'Memoizes has_tokens() before the delete.' );
+
+		$result = $data_store->delete_tokens( array( $first->get_id(), $second->get_id() ) );
+
+		$this->assertSame( array( $first->get_id(), $second->get_id() ), $result['deleted'], 'Both tokens should be reported deleted.' );
+		$this->assertSame( array(), $result['failed'], 'Nothing should fail.' );
+		$this->assertSame( 0, $data_store->count_tokens(), 'The memoized count should be dropped after a delete.' );
+		$this->assertFalse( $data_store->has_tokens(), 'The memoized has_tokens() should be dropped after a delete.' );
+	}
+
+	/**
+	 * @testdox Should leave posts that are not push tokens untouched.
+	 */
+	public function test_delete_tokens_skips_posts_of_other_types(): void {
+		$data_store = new PushTokensDataStore();
+		$post_id    = self::factory()->post->create();
+
+		$result = $data_store->delete_tokens( array( $post_id ) );
+
+		$this->assertSame( array(), $result['deleted'] );
+		$this->assertSame( array(), $result['failed'] );
+		$this->assertNotNull( get_post( $post_id ) );
+	}
+
+	/**
+	 * @testdox Should keep a token re-registered at or after the cutoff since it was selected.
+	 */
+	public function test_delete_tokens_keeps_a_token_re_registered_since_the_cutoff(): void {
+		$data_store = new PushTokensDataStore();
+		$stale      = $this->create_push_token_for_user( $data_store, 101 );
+		$refreshed  = $this->create_push_token_for_user( $data_store, 101 );
+
+		$this->set_token_dates( $stale->get_id(), '2026-01-01 00:00:00', '2026-01-01 00:00:00' );
+		$this->set_token_dates( $refreshed->get_id(), '2026-01-01 00:00:00', '2026-07-01 00:00:00' );
+
+		$result = $data_store->delete_tokens( array( $stale->get_id(), $refreshed->get_id() ), '2026-07-01 00:00:00' );
+
+		$this->assertSame( array( $stale->get_id() ), $result['deleted'] );
+		$this->assertNotNull( get_post( $refreshed->get_id() ) );
+	}
+
+	/**
+	 * @testdox Should carry on deleting when one token cannot be deleted or its deletion throws.
+	 */
+	public function test_delete_tokens_reports_failures_without_stopping(): void {
+		$data_store = new PushTokensDataStore();
+		$refused    = $this->create_push_token_for_user( $data_store, 101 );
+		$throwing   = $this->create_push_token_for_user( $data_store, 101 );
+		$deletable  = $this->create_push_token_for_user( $data_store, 101 );
+
+		add_filter(
+			'pre_delete_post',
+			fn( $check, $post ) => $post->ID === $refused->get_id() ? false : $check,
+			10,
+			2
+		);
+		add_action(
+			'before_delete_post',
+			function ( $post_id ) use ( $throwing ) {
+				if ( $post_id === $throwing->get_id() ) {
+					throw new RuntimeException( 'A third-party callback failed.' );
+				}
+			}
+		);
+
+		$result = $data_store->delete_tokens( array( $refused->get_id(), $throwing->get_id(), $deletable->get_id() ) );
+
+		$this->assertSame( array( $deletable->get_id() ), $result['deleted'] );
+		$this->assertSame( array( $refused->get_id(), $throwing->get_id() ), $result['failed'] );
+	}
+
 	/**
 	 * Counts the push token records owned by a user.
 	 *
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 a0215079ee4..7ae938e1aa0 100644
--- a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Helpers/PushNotificationsTestTrait.php
+++ b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Helpers/PushNotificationsTestTrait.php
@@ -18,7 +18,8 @@ use ReflectionClass;
  * Mocks the Jetpack connection state and resets the memoized enablement flag on
  * the container's `PushNotifications` instance — the two things every
  * push-notifications-related controller test needs in setUp. Also builds the
- * push tokens data store mock that PendingNotificationStore tests need.
+ * push tokens data store mock that PendingNotificationStore tests need, and
+ * backdates token registrations for the tests that depend on token age.
  *
  * @package WooCommerce\Tests\PushNotifications
  */
@@ -102,4 +103,29 @@ trait PushNotificationsTestTrait {
 			}
 		}
 	}
+
+	/**
+	 * Sets the date a token was first registered and the date it was last registered.
+	 *
+	 * @param int         $post_id        The token's post ID.
+	 * @param string      $created        The first registration date, as GMT.
+	 * @param string      $modified       The last registration date, as GMT.
+	 * @param string|null $modified_local The last registration date in site time, defaulting to $modified.
+	 */
+	protected function set_token_dates( int $post_id, string $created, string $modified, ?string $modified_local = null ): void {
+		global $wpdb;
+
+		$wpdb->update(
+			$wpdb->posts,
+			array(
+				'post_date'         => $created,
+				'post_date_gmt'     => $created,
+				'post_modified'     => $modified_local ?? $modified,
+				'post_modified_gmt' => $modified,
+			),
+			array( 'ID' => $post_id )
+		);
+
+		clean_post_cache( $post_id );
+	}
 }
diff --git a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/PushNotificationsTest.php b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/PushNotificationsTest.php
index 70b6d0b5c42..fe0b3a9f615 100644
--- a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/PushNotificationsTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/PushNotificationsTest.php
@@ -13,6 +13,7 @@ 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\Services\StaleTokenCleanupService;
 use Automattic\WooCommerce\Internal\PushNotifications\Triggers\NewOrderNotificationTrigger;
 use Automattic\WooCommerce\Tests\Internal\PushNotifications\Helpers\PushNotificationsTestTrait;
 use Automattic\WooCommerce\Proxies\LegacyProxy;
@@ -393,6 +394,29 @@ class PushNotificationsTest extends WC_Unit_Test_Case {
 		$this->assertNotContains( NotificationPreferencesRestController::class, $registered );
 	}

+	/**
+	 * @testdox Tests that on_init registers the stale token cleanup callbacks whether push notifications are enabled ($enabled) or not.
+	 * @testWith [true]
+	 *           [false]
+	 *
+	 * @param bool $enabled Whether push notifications are enabled.
+	 */
+	public function test_on_init_registers_stale_token_cleanup( bool $enabled ) {
+		if ( $enabled ) {
+			$this->set_up_jetpack_connection_manager_mock( array( 'is_connected' ) );
+			$this->jetpack_connection_manager_mock->method( 'is_connected' )->willReturn( true );
+		} else {
+			add_filter( 'woocommerce_enhanced_push_notifications_disabled', '__return_true' );
+		}
+
+		wc_get_container()->get( PushNotifications::class )->on_init();
+
+		$cleanup = wc_get_container()->get( StaleTokenCleanupService::class );
+
+		$this->assertNotFalse( has_action( StaleTokenCleanupService::CLEANUP_HOOK, array( $cleanup, 'handle_stale_token_cleanup' ) ), 'The cleanup callback should always be registered.' );
+		$this->assertNotFalse( has_action( 'action_scheduler_ensure_recurring_actions', array( $cleanup, 'handle_ensure_recurring_actions' ) ), 'The scheduling callback should always be registered, to unschedule while off.' );
+	}
+
 	/**
 	 * @testdox Tests that the tokens endpoint returns the store's registered tokens while
 	 * the module is disabled via the filter.
diff --git a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Services/StaleTokenCleanupServiceTest.php b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Services/StaleTokenCleanupServiceTest.php
new file mode 100644
index 00000000000..a916bc89c24
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Services/StaleTokenCleanupServiceTest.php
@@ -0,0 +1,567 @@
+<?php
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\PushNotifications\Services;
+
+use ActionScheduler_Action;
+use ActionScheduler_Store;
+use Automattic\WooCommerce\Internal\PushNotifications\DataStores\PushTokensDataStore;
+use Automattic\WooCommerce\Internal\PushNotifications\Entities\PushToken;
+use Automattic\WooCommerce\Internal\PushNotifications\Enums\CleanupOutcome;
+use Automattic\WooCommerce\Internal\PushNotifications\PushNotifications;
+use Automattic\WooCommerce\Internal\PushNotifications\Services\NotificationProcessor;
+use Automattic\WooCommerce\Internal\PushNotifications\Services\NotificationStepLogger;
+use Automattic\WooCommerce\Internal\PushNotifications\Services\StaleTokenCleanupService;
+use Automattic\WooCommerce\RestApi\UnitTests\LoggerSpyTrait;
+use Automattic\WooCommerce\Tests\Internal\PushNotifications\Helpers\PushNotificationsTestTrait;
+use PHPUnit\Framework\MockObject\MockObject;
+use RuntimeException;
+use WC_Unit_Test_Case;
+use WP_Post;
+
+/**
+ * Tests for the StaleTokenCleanupService class.
+ *
+ * @covers \Automattic\WooCommerce\Internal\PushNotifications\Services\StaleTokenCleanupService
+ */
+class StaleTokenCleanupServiceTest extends WC_Unit_Test_Case {
+
+	use LoggerSpyTrait;
+	use PushNotificationsTestTrait;
+
+	/**
+	 * The moment every test measures token age from.
+	 */
+	const NOW = '2026-10-23 12:00:00';
+
+	/**
+	 * The System Under Test.
+	 *
+	 * @var StaleTokenCleanupService
+	 */
+	private $sut;
+
+	/**
+	 * The push tokens data store.
+	 *
+	 * @var PushTokensDataStore
+	 */
+	private PushTokensDataStore $data_store;
+
+	/**
+	 * Whether the mocked push notifications feature reports itself enabled.
+	 *
+	 * @var bool
+	 */
+	private bool $push_enabled = true;
+
+	/**
+	 * Set up test fixtures.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+
+		$this->data_store = new PushTokensDataStore();
+
+		/**
+		 * The feature class, enabled unless a test switches it off.
+		 *
+		 * @var PushNotifications|MockObject $push_notifications
+		 */
+		$push_notifications = $this->createMock( PushNotifications::class );
+		$push_notifications->method( 'should_be_enabled' )->willReturnCallback( fn() => $this->push_enabled );
+
+		$this->sut = new StaleTokenCleanupService();
+		$this->sut->init( $this->data_store, new NotificationStepLogger(), $push_notifications );
+	}
+
+	/**
+	 * @testdox Should delete a token from an app that checks in daily once it is past 90 days, and keep a recent one.
+	 */
+	public function test_deletes_check_in_tokens_past_the_retention_window(): void {
+		$stale  = $this->create_token( '25.8', $this->days( 91 ) );
+		$recent = $this->create_token( '25.8', $this->days( 89 ) );
+
+		$summary = $this->sut->run( $this->now() );
+
+		$this->assertNull( get_post( $stale ), 'A 25.8 token silent for 91 days should be deleted.' );
+		$this->assertNotNull( get_post( $recent ), 'A 25.8 token seen 89 days ago should be kept.' );
+		$this->assertSame( CleanupOutcome::COMPLETED, $summary['outcome'], 'The run should complete.' );
+		$this->assertSame( 1, $summary['deleted_daily_check_in'], 'One token should be deleted under the 90-day rule.' );
+	}
+
+	/**
+	 * @testdox Should treat app version $app_version from $origin as one that checks in daily.
+	 * @testWith ["25.8", "com.automattic.woocommerce"]
+	 *           ["25.8.0.0", "com.automattic.woocommerce"]
+	 *           ["25.8.0.1", "com.automattic.woocommerce:dev"]
+	 *           ["25.8-rc-1", "com.woocommerce.android"]
+	 *           ["25.8.1", "com.woocommerce.android"]
+	 *           ["25.10", "com.woocommerce.android:dev"]
+	 *           ["26.0", "com.automattic.woocommerce"]
+	 *
+	 * @param string $app_version The app version the token was registered with.
+	 * @param string $origin      The app the token was registered from.
+	 */
+	public function test_deletes_tokens_from_versions_that_check_in_daily( string $app_version, string $origin ): void {
+		$token = $this->create_token( $app_version, $this->days( 91 ), $origin );
+
+		$this->sut->run( $this->now() );
+
+		$this->assertNull( get_post( $token ), "A {$app_version} token from {$origin} silent for 91 days should be deleted." );
+	}
+
+	/**
+	 * @testdox Should keep a 91-day-old token with app version $app_version until the 180-day fallback.
+	 * @testWith ["25.7"]
+	 *           ["25.7.0.1"]
+	 *           ["pr123-abc1234"]
+	 *           [""]
+	 *           [null]
+	 *
+	 * @param string|null $app_version The app version the token was registered with, or null for none.
+	 */
+	public function test_keeps_tokens_from_older_or_unknown_versions_inside_the_fallback_window( ?string $app_version ): void {
+		$token = $this->create_token( $app_version, $this->days( 91 ) );
+
+		$summary = $this->sut->run( $this->now() );
+
+		$this->assertNotNull( get_post( $token ), 'An older or unknown app version should get the longer window.' );
+		$this->assertSame( 1, $summary['kept_older_apps'], 'The kept token should be counted.' );
+	}
+
+	/**
+	 * @testdox Should delete a $app_version token only when it is older than the cutoff by at least a second.
+	 * @testWith ["25.8", 7776000, true]
+	 *           ["25.8", 7776001, false]
+	 *           ["25.7", 15552000, true]
+	 *           ["25.7", 15552001, false]
+	 *
+	 * @param string $app_version The app version the token was registered with.
+	 * @param int    $age         Seconds since the token last registered.
+	 * @param bool   $kept        Whether the token should survive.
+	 */
+	public function test_deletes_only_tokens_strictly_older_than_the_cutoff( string $app_version, int $age, bool $kept ): void {
+		$token = $this->create_token( $app_version, $age );
+
+		$this->sut->run( $this->now() );
+
+		$this->assertSame( $kept, get_post( $token ) instanceof WP_Post, "A {$app_version} token {$age}s old should " . ( $kept ? 'be kept.' : 'be deleted.' ) );
+	}
+
+	/**
+	 * @testdox Should apply only the 180-day rule to a token whose origin is not a WooCommerce app.
+	 */
+	public function test_applies_the_fallback_to_tokens_from_other_origins(): void {
+		$inside  = $this->create_token( '25.8', $this->days( 100 ) );
+		$outside = $this->create_token( '25.8', $this->days( 181 ) );
+		update_post_meta( $inside, 'origin', PushToken::ORIGIN_BROWSER );
+		update_post_meta( $outside, 'origin', PushToken::ORIGIN_BROWSER );
+
+		$this->sut->run( $this->now() );
+
+		$this->assertNotNull( get_post( $inside ), 'A browser token seen 100 days ago should be kept.' );
+		$this->assertNull( get_post( $outside ), 'A browser token silent for 181 days should be deleted.' );
+	}
+
+	/**
+	 * @testdox Should spend the deletion limit on the oldest tokens, whichever rule they fall under.
+	 */
+	public function test_deletes_the_oldest_tokens_first_when_capped(): void {
+		$fallback = array();
+		for ( $i = 0; $i < 60; $i++ ) {
+			$fallback[] = $this->create_token( '25.7', $this->days( 300 + $i ) );
+		}
+
+		$check_in = array();
+		for ( $i = 0; $i < 60; $i++ ) {
+			$check_in[] = $this->create_token( '25.8', $this->days( 100 + $i ) );
+		}
+
+		$first = $this->sut->run( $this->now() );
+
+		$this->assertSame( CleanupOutcome::CAPPED, $first['outcome'], 'The first run should stop at the limit.' );
+		$this->assertSame( 60, $first['deleted_fallback'], 'Every fallback token is older than every daily check-in token.' );
+		$this->assertSame( 40, $first['deleted_daily_check_in'], 'The limit should be filled with the oldest daily check-in tokens.' );
+		$this->assertSame( array_slice( $check_in, 0, 20 ), $this->existing( $check_in ), 'The 20 newest daily check-in tokens should remain.' );
+
+		$second = $this->sut->run( $this->now() );
+
+		$this->assertSame( CleanupOutcome::COMPLETED, $second['outcome'], 'The second run should finish.' );
+		$this->assertSame( 20, $second['deleted_daily_check_in'], 'The second run should delete the rest.' );
+	}
+
+	/**
+	 * @testdox Should report a completed run when it deletes exactly the limit.
+	 */
+	public function test_reports_completed_when_deleting_exactly_the_limit(): void {
+		for ( $i = 0; $i < StaleTokenCleanupService::MAX_DELETIONS_PER_RUN; $i++ ) {
+			$this->create_token( '25.8', $this->days( 91 ) );
+		}
+		$this->create_token( '25.7', $this->days( 100 ) );
+
+		$summary = $this->sut->run( $this->now() );
+
+		$this->assertSame( CleanupOutcome::COMPLETED, $summary['outcome'], 'Nothing deletable is left, so the run is not capped.' );
+		$this->assertSame( StaleTokenCleanupService::MAX_DELETIONS_PER_RUN, $summary['deleted_daily_check_in'], 'Every eligible token should go.' );
+	}
+
+	/**
+	 * @testdox Should finish across runs whatever old-app tokens come first, and do nothing on a repeat run.
+	 */
+	public function test_respects_the_deletion_limit_across_runs(): void {
+		for ( $i = 0; $i < 3; $i++ ) {
+			$this->create_token( '25.7', $this->days( 150 ) );
+		}
+
+		$deletable = array();
+		for ( $i = 0; $i < StaleTokenCleanupService::MAX_DELETIONS_PER_RUN + 1; $i++ ) {
+			$deletable[] = $this->create_token( '25.8', $this->days( 91 ) );
+		}
+
+		$first  = $this->sut->run( $this->now() );
+		$second = $this->sut->run( $this->now() );
+		$third  = $this->sut->run( $this->now() );
+
+		$this->assertSame( CleanupOutcome::CAPPED, $first['outcome'], 'The first run should stop at the limit.' );
+		$this->assertSame( StaleTokenCleanupService::MAX_DELETIONS_PER_RUN, $first['deleted_daily_check_in'], 'The first run should delete up to the limit.' );
+		$this->assertSame( 1, $second['deleted_daily_check_in'], 'The second run should delete the one left.' );
+		$this->assertSame( 0, $third['deleted_daily_check_in'], 'A repeat run should find nothing left to delete.' );
+		$this->assertSame( array(), $this->existing( $deletable ), 'Every eligible token should be gone.' );
+		$this->assertSame( 3, $this->data_store->count_tokens(), 'Only the old-app tokens inside the fallback window should remain.' );
+	}
+
+	/**
+	 * @testdox Should report a token that cannot be deleted and carry on with the rest.
+	 */
+	public function test_reports_failures_and_carries_on(): void {
+		$refused   = $this->create_token( '25.8', $this->days( 95 ) );
+		$deletable = $this->create_token( '25.8', $this->days( 91 ) );
+		$this->refuse_deletion_of( array( $refused ) );
+
+		$summary = $this->sut->run( $this->now() );
+
+		$this->assertSame( CleanupOutcome::COMPLETED, $summary['outcome'], 'A failed token should not stop the run.' );
+		$this->assertSame( 1, $summary['failed'], 'The failure should be counted.' );
+		$this->assertSame( array( $refused ), $summary['failed_token_ids'], 'The failed token should be named.' );
+		$this->assertNull( get_post( $deletable ), 'The other token should still be deleted.' );
+	}
+
+	/**
+	 * @testdox Should not let tokens that never delete use up the deletion limit.
+	 */
+	public function test_failures_do_not_count_towards_the_deletion_limit(): void {
+		$refused = array();
+		for ( $i = 0; $i < StaleTokenCleanupService::MAX_DELETIONS_PER_RUN + 1; $i++ ) {
+			$refused[] = $this->create_token( '25.8', $this->days( 120 ) );
+		}
+		$deletable = $this->create_token( '25.8', $this->days( 91 ) );
+		$this->refuse_deletion_of( $refused );
+
+		$summary = $this->sut->run( $this->now() );
+
+		$this->assertNull( get_post( $deletable ), 'A newer deletable token should be reached past the failing ones.' );
+		$this->assertSame( StaleTokenCleanupService::MAX_DELETIONS_PER_RUN + 1, $summary['failed'], 'Every failure should be counted.' );
+	}
+
+	/**
+	 * @testdox Should keep a $app_version token that re-registered after it was selected for deletion.
+	 * @testWith ["25.8", 91]
+	 *           ["25.7", 181]
+	 *
+	 * @param string $app_version The app version the token was registered with.
+	 * @param int    $age_days    Days since the token last registered when the run starts.
+	 */
+	public function test_keeps_a_token_that_re_registers_during_the_run( string $app_version, int $age_days ): void {
+		global $wpdb;
+
+		$token = $this->create_token( $app_version, $this->days( $age_days ) );
+
+		// Simulates the app checking in between the selection and the deletion.
+		// 100 days is still past the 90-day cutoff, so only the 180-day cutoff
+		// spares an old-app token.
+		$refreshed_at = gmdate( 'Y-m-d H:i:s', $this->now() - ( '25.8' === $app_version ? 0 : $this->days( 100 ) ) );
+		add_filter(
+			'get_post_metadata',
+			function ( $value, $object_id, $meta_key ) use ( $wpdb, $token, $refreshed_at ) {
+				if ( $object_id === $token && 'origin' === $meta_key ) {
+					$wpdb->update( $wpdb->posts, array( 'post_modified_gmt' => $refreshed_at ), array( 'ID' => $token ) );
+				}
+				return $value;
+			},
+			10,
+			3
+		);
+
+		$this->sut->run( $this->now() );
+
+		$this->assertNotNull( get_post( $token ), 'A token that checked in mid-run should be kept.' );
+	}
+
+	/**
+	 * @testdox Should delete nothing while push notifications are off, and record that they are off.
+	 */
+	public function test_skips_and_records_a_pause_when_push_notifications_are_disabled(): void {
+		$this->push_enabled = false;
+		$token              = $this->create_token( '25.8', $this->days( 91 ) );
+
+		$summary = $this->sut->run( $this->now() );
+
+		$this->assertSame( CleanupOutcome::SKIPPED_DISABLED, $summary['outcome'], 'The run should be skipped.' );
+		$this->assertNotNull( get_post( $token ), 'Nothing should be deleted while push notifications are off.' );
+		$this->assertSame( $this->now(), (int) get_option( StaleTokenCleanupService::PAUSED_AT_OPTION ), 'The pause should be recorded.' );
+	}
+
+	/**
+	 * @testdox Should not count the time push notifications were off, so a store back from 100 days off keeps its tokens until they miss 90 more days.
+	 */
+	public function test_waits_a_full_window_after_push_notifications_come_back_on(): void {
+		$off_at    = $this->now();
+		$check_in  = $this->create_token( '25.8', 0 );
+		$older_app = $this->create_token( '25.7', 0 );
+
+		$this->push_enabled = false;
+		$this->sut->run( $off_at + $this->days( 1 ) );
+
+		$this->push_enabled = true;
+		$back_at            = $off_at + $this->days( 100 );
+		$resumed            = $this->sut->run( $back_at );
+
+		$this->assertSame( CleanupOutcome::SKIPPED_RECENTLY_RESUMED, $resumed['outcome'], 'The first run after coming back should delete nothing.' );
+		$this->assertNotNull( get_post( $check_in ), 'A token silent only because push was off should be kept.' );
+		$this->assertSame( $back_at, (int) get_option( StaleTokenCleanupService::RESUMED_AT_OPTION ), 'The resume should be recorded.' );
+		$this->assertFalse( get_option( StaleTokenCleanupService::PAUSED_AT_OPTION ), 'The pause should be cleared.' );
+
+		$this->sut->run( $back_at + $this->days( 89 ) );
+		$this->assertNotNull( get_post( $check_in ), 'The 90-day rule should still wait 89 days after coming back.' );
+
+		$after_90 = $this->sut->run( $back_at + $this->days( 91 ) );
+		$this->assertNull( get_post( $check_in ), 'The 90-day rule should apply once 90 days have passed since coming back.' );
+		$this->assertNotNull( get_post( $older_app ), 'The 180-day rule should still be waiting.' );
+		$this->assertNull( $after_90['fallback_cutoff'], 'The summary should show the 180-day rule as paused.' );
+
+		$this->sut->run( $back_at + $this->days( 181 ) );
+		$this->assertNull( get_post( $older_app ), 'The 180-day rule should apply once 180 days have passed since coming back.' );
+		$this->assertFalse( get_option( StaleTokenCleanupService::RESUMED_AT_OPTION ), 'The resume time should be dropped once it no longer matters.' );
+	}
+
+	/**
+	 * @testdox Should delete nothing when the filter returns $returned.
+	 * @testWith ["false"]
+	 *           ["'false'"]
+	 *           ["array"]
+	 *           ["throw"]
+	 *
+	 * @param string $returned What the filter callback does.
+	 */
+	public function test_skips_when_the_filter_switches_cleanup_off( string $returned ): void {
+		add_filter(
+			'woocommerce_push_notification_stale_token_cleanup_enabled',
+			function () use ( $returned ) {
+				switch ( $returned ) {
+					case 'throw':
+						throw new RuntimeException( 'A broken callback.' );
+					case 'array':
+						return array( true );
+					case "'false'":
+						return 'false';
+					default:
+						return false;
+				}
+			}
+		);
+		$token = $this->create_token( '25.8', $this->days( 91 ) );
+
+		$summary = $this->sut->run( $this->now() );
+
+		$this->assertSame( CleanupOutcome::SKIPPED_FILTER, $summary['outcome'], 'The run should be skipped.' );
+		$this->assertNotNull( get_post( $token ), 'Nothing should be deleted.' );
+		$this->assertLogged( 'info', 'Stale token cleanup: skipped filter', array( 'source' => PushNotifications::FEATURE_NAME ) );
+	}
+
+	/**
+	 * @testdox Should log the run's counts even when step logging is switched off.
+	 */
+	public function test_logs_a_summary_when_step_logging_is_off(): void {
+		add_filter( 'woocommerce_push_notification_step_logging_enabled', '__return_false' );
+		$token = $this->create_token( '25.8', $this->days( 91 ) );
+		$this->create_token( '25.7', $this->days( 91 ) );
+
+		$this->sut->run( $this->now() );
+
+		$this->assertLogged(
+			'info',
+			'Stale token cleanup: completed',
+			array(
+				'source'                 => PushNotifications::FEATURE_NAME,
+				'cutoff'                 => gmdate( 'Y-m-d H:i:s', $this->now() - $this->days( 90 ) ),
+				'tokens_before'          => 2,
+				'stale'                  => 2,
+				'kept_older_apps'        => 1,
+				'deleted_daily_check_in' => 1,
+				'deleted_token_ids'      => array( $token ),
+			)
+		);
+	}
+
+	/**
+	 * @testdox Should log a failed outcome, with the error, when selecting the stale tokens throws.
+	 */
+	public function test_logs_a_failed_outcome_when_selection_throws(): void {
+		$data_store = $this->createMock( PushTokensDataStore::class );
+		$data_store->method( 'get_stale_token_ids' )->willThrowException( new RuntimeException( 'Database gone.' ) );
+		$push_notifications = $this->createMock( PushNotifications::class );
+		$push_notifications->method( 'should_be_enabled' )->willReturn( true );
+		$this->sut->init( $data_store, new NotificationStepLogger(), $push_notifications );
+
+		$this->sut->handle_stale_token_cleanup();
+
+		$this->assertLogged(
+			'info',
+			'Stale token cleanup: failed',
+			array( 'error' => 'Database gone.' )
+		);
+	}
+
+	/**
+	 * @testdox Should keep one daily cleanup scheduled a day out while push notifications are on.
+	 */
+	public function test_ensure_recurring_actions_schedules_one_daily_cleanup(): void {
+		$this->sut->handle_ensure_recurring_actions();
+		$this->sut->handle_ensure_recurring_actions();
+
+		$actions = as_get_scheduled_actions(
+			array(
+				'hook'   => StaleTokenCleanupService::CLEANUP_HOOK,
+				'group'  => NotificationProcessor::ACTION_SCHEDULER_GROUP,
+				'status' => ActionScheduler_Store::STATUS_PENDING,
+			)
+		);
+
+		$this->assertCount( 1, $actions, 'Scheduling twice should leave one pending action.' );
+
+		/**
+		 * The pending cleanup.
+		 *
+		 * @var ActionScheduler_Action $action
+		 */
+		$action   = reset( $actions );
+		$schedule = $action->get_schedule();
+		$next     = $schedule->get_date()->getTimestamp();
+
+		$this->assertTrue( $schedule->is_recurring(), 'The cleanup should recur.' );
+		$this->assertSame( DAY_IN_SECONDS, (int) $schedule->get_recurrence(), 'The cleanup should run daily.' );
+		$this->assertGreaterThan( time() + DAY_IN_SECONDS - MINUTE_IN_SECONDS, $next, 'The first run should wait a day.' );
+		$this->assertLessThanOrEqual( time() + DAY_IN_SECONDS, $next, 'The first run should not wait longer than a day.' );
+	}
+
+	/**
+	 * @testdox Should remove the cleanup and record a pause while push notifications are off.
+	 */
+	public function test_ensure_recurring_actions_unschedules_while_disabled(): void {
+		$this->sut->handle_ensure_recurring_actions();
+		$this->push_enabled = false;
+
+		$this->sut->handle_ensure_recurring_actions();
+
+		$this->assertFalse( as_next_scheduled_action( StaleTokenCleanupService::CLEANUP_HOOK ), 'The cleanup should be unscheduled.' );
+		$this->assertNotFalse( get_option( StaleTokenCleanupService::PAUSED_AT_OPTION ), 'The pause should be recorded.' );
+	}
+
+	/**
+	 * @testdox Should remove the cleanup and record a pause when WooCommerce is deactivated.
+	 */
+	public function test_plugin_deactivation_unschedules_and_records_a_pause(): void {
+		$this->sut->handle_ensure_recurring_actions();
+
+		$this->sut->handle_plugin_deactivation();
+
+		$this->assertFalse( as_next_scheduled_action( StaleTokenCleanupService::CLEANUP_HOOK ), 'The cleanup should be unscheduled.' );
+		$this->assertNotFalse( get_option( StaleTokenCleanupService::PAUSED_AT_OPTION ), 'The pause should be recorded.' );
+	}
+
+	/**
+	 * @testdox Should register the cleanup, scheduling and deactivation callbacks.
+	 */
+	public function test_register_adds_the_callbacks(): void {
+		$this->sut->register();
+
+		$this->assertNotFalse( has_action( StaleTokenCleanupService::CLEANUP_HOOK, array( $this->sut, 'handle_stale_token_cleanup' ) ), 'The cleanup callback should be registered.' );
+		$this->assertNotFalse( has_action( 'action_scheduler_ensure_recurring_actions', array( $this->sut, 'handle_ensure_recurring_actions' ) ), 'The scheduling callback should be registered.' );
+		$this->assertNotFalse( has_action( 'deactivate_' . WC_PLUGIN_BASENAME, array( $this->sut, 'handle_plugin_deactivation' ) ), 'The deactivation callback should be registered.' );
+	}
+
+	/**
+	 * Creates a token last registered a number of seconds before {@see self::NOW}.
+	 *
+	 * @param string|null $app_version The app version to register with, or null for none.
+	 * @param int         $age         How many seconds ago the token last registered.
+	 * @param string      $origin      The app the token is registered from.
+	 * @return int The token's post ID.
+	 */
+	private function create_token( ?string $app_version, int $age, string $origin = PushToken::ORIGIN_WOOCOMMERCE_IOS ): int {
+		$is_android = in_array( $origin, array( PushToken::ORIGIN_WOOCOMMERCE_ANDROID, PushToken::ORIGIN_WOOCOMMERCE_ANDROID_DEV ), true );
+
+		$token = $this->data_store->create(
+			array(
+				'user_id'       => 1,
+				'token'         => 'test_token_' . wp_rand(),
+				'platform'      => $is_android ? PushToken::PLATFORM_ANDROID : PushToken::PLATFORM_APPLE,
+				'device_uuid'   => 'test-device-uuid-' . wp_rand(),
+				'origin'        => $origin,
+				'device_locale' => 'en_US',
+				'metadata'      => null === $app_version ? array() : array( 'app_version' => $app_version ),
+			)
+		);
+
+		$modified = gmdate( 'Y-m-d H:i:s', $this->now() - $age );
+		$this->set_token_dates( (int) $token->get_id(), $modified, $modified );
+
+		return (int) $token->get_id();
+	}
+
+	/**
+	 * Makes WordPress refuse to delete the given posts.
+	 *
+	 * @param int[] $ids The post IDs.
+	 * @return void
+	 */
+	private function refuse_deletion_of( array $ids ): void {
+		add_filter(
+			'pre_delete_post',
+			fn( $check, $post ) => in_array( $post->ID, $ids, true ) ? false : $check,
+			10,
+			2
+		);
+	}
+
+	/**
+	 * Returns the given post IDs that still exist, in the order given.
+	 *
+	 * @param int[] $ids The post IDs.
+	 * @return int[]
+	 */
+	private function existing( array $ids ): array {
+		return array_values( array_filter( $ids, fn( int $id ) => null !== get_post( $id ) ) );
+	}
+
+	/**
+	 * Converts days to seconds.
+	 *
+	 * @param int $days The number of days.
+	 * @return int
+	 */
+	private function days( int $days ): int {
+		return $days * DAY_IN_SECONDS;
+	}
+
+	/**
+	 * Returns {@see self::NOW} as a Unix timestamp.
+	 *
+	 * @return int
+	 */
+	private function now(): int {
+		return (int) strtotime( self::NOW . ' UTC' );
+	}
+}