Commit d360d11c6b3 for woocommerce

commit d360d11c6b3ae196432728ff26b21de0fa4c3107
Author: Thilina Pituwala <thilina.hasantha@gmail.com>
Date:   Mon Sep 21 20:13:57 2026 +1000

    Show WCCOM extension license status on the wp-admin plugins screen (#68316)

    * Show a connect prompt on Woo plugin rows without a Core update notice

    WooCommerce's existing connection messaging hangs off
    in_plugin_update_message, which Core only fires from wp_plugin_update_row()
    when a plugin has a pending update. An unconnected store running only
    up-to-date WooCommerce.com extensions therefore gets no indication on the
    plugins screen that it won't receive updates.

    Hook after_plugin_row, which fires for every row, and render the prompt on
    WooCommerce.com plugin rows that Core renders no update row for. Rows that do
    have an update are skipped so the two prompts never appear together.

    Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01XG5mvm83GUuaYQujthxLCR

    * Skip the connect prompt on the Woo Update Manager row

    The Update Manager is what delivers the updates the prompt is about, so
    pointing it at its own row reads as circular.

    Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01XG5mvm83GUuaYQujthxLCR

    * Reword the connect prompt on Woo plugin rows

    Lead with where the extension comes from, since that is what the row is
    explaining, and keep "Connect your store" as the call to action so it matches
    add_connect_woocom_plugin_message() and the settings screen banner.

    Avoids calling the extension paid: the notice keys off the Woo plugin header,
    which says where a plugin is distributed from, not what it cost. Free
    WooCommerce.com extensions such as Facebook for WooCommerce, Square and
    ShipStation carry that header too.

    Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

    * Show purchase or renewal message on plugin list when a WCCOM extension is expired or installed without a subscription.

    * Reword the plugin row notices and renew the exact subscription

    The three plugin row notices now read:

    - Not connected: "Extension distributed via WooCommerce.com. Connect your
      store for security updates, product improvements, and support."
    - No subscription: "You don't have an active subscription for this product.
      Subscribe now for security updates, product improvements, and support."
    - Expired: "Your subscription for this extension has expired. Renew your
      subscription for security updates, product improvements, and support."

    The renew link used to add the product to the cart, which buys a new
    subscription. It now sends renew_product, product_key, and order_id, the same
    parameters the My Subscriptions screen and the product usage notice send, so
    the expired subscription itself is renewed. The update-row message shares the
    new builder. The price is no longer part of the row message.

    The subscribe and renew links open WooCommerce.com in a new tab. The connect
    link stays in the same tab, since it goes to a wp-admin page.

    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

    * Describe every plugin row notice in the changelog entry

    The entry only mentioned the connect prompt. The PR also adds the
    no-subscription, expired, and expiring notices, and changes the renew link to
    renew the subscription itself.

    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

    * Only read product IDs from update responses WooCommerce wrote

    Both update-row callbacks scraped the digits out of the response ID, so a
    filter that replaced the ID with a near miss such as woocommerce-com-12foo3
    made them show subscription data or a purchase link for product 123.

    The ID now has to be exactly "woocommerce-com-" followed by digits. Anything
    else is treated as not ours and the callbacks print nothing.

    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

    * Fall back to the cart when a subscription cannot be renewed by reference

    Renewing one specific subscription needs its product key and order ID, and
    the API does not guarantee either. A record missing one produced a renewal
    link with empty identifiers.

    Such a record now gets the add-to-cart link instead, which buys the product
    rather than renewing nothing. The message is unchanged, so the merchant still
    learns the subscription expired.

    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

    * Keep the plugin row notice links in the admin tab

    The subscribe and renew links opened WooCommerce.com in a new tab without
    telling screen reader users so. Rather than add the hint, every link in the
    notices now stays in the current tab, like the connect and auto-renew links
    already did, and the notice printer no longer allows target or rel.

    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

    * Check the shape of renewal identifiers, not just for an empty string

    A key or order ID sent as false, zero, or an array passed the empty-string
    check and produced a renewal link with a missing or malformed identifier,
    since add_query_arg() drops false and nests arrays.

    The key must now be a non-empty string and the order ID a positive integer
    or digit string, the same shape check the helper applies to product IDs.
    Anything else takes the add-to-cart fallback.

    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

    * Restore the tearDown method the trunk merge swallowed into a doc comment

    The merge in 152d0e0f371 dropped the closing "*/" and the method
    signature, so the whole teardown body sat inside the docblock and the
    plugins-screen and theme-fixture cleanup never ran.

    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

    * Drop two PHPStan baseline entries the updater no longer triggers

    The update-row callbacks now check that the response is an object with
    a string ID before reading it, and pass an int product ID to
    WC_Helper::has_product_subscription(), so the two baselined errors for
    class-wc-helper-updater.php no longer occur and PHPStan fails on the
    unmatched patterns.

    * Skip the plugin row subscription notice while the API fetch is failing

    After a failed subscriptions fetch WC_Helper::get_subscriptions() caches
    an empty list for 15 minutes, and during a rate-limit backoff it returns
    one without calling the API. has_product_subscription() then reports
    false, so every up-to-date WooCommerce.com plugin row said the merchant
    had no subscription. The row now prints nothing while
    WC_Helper::get_api_error() reports a current failure, which lasts exactly
    as long as the empty list does.

    * Share subscription messages between the update row and the plugin row

    The messages appended to WordPress's update row and the new plugin-row
    notices described the same condition in different words with different
    links. Both paths now call the same builders; only the utm_campaign
    prefix differs.

    * Skip plugin-row notices on multisite sub-sites

    Core renders no plugin update rows on a sub-site's Plugins screen, so
    the connect and subscription notice rows should not appear there
    either. Mirror core's active-row class check in the network admin.

    * Register the purchase-subscription tracking script on the Plugins screen

    The script was enqueued without being registered, so it never loaded and
    its Tracks events never fired.

    * Mark Tracks events from plugin-row notices with a no_update property

    Links in core's update row keep firing the existing events unchanged.
    Links in the notice rows under up-to-date plugins fire the same events
    with no_update: true, so the two placements can be told apart. The four
    tracking scripts now share one helper.

    * Add changelog entry for Plugins screen tracking changes

    * Judge subscription notices on every subscription for the product

    The row checked whether the account held any subscription, then looked
    for renewals only among the ones attached to this store, so a
    subscription attached elsewhere produced no message at all. Both steps
    now consider every subscription, and the row stays quiet while one of
    them still covers the product.

    * Show subscription expiry dates in the store's format and time zone

    The hardcoded format printed an English ordinal suffix in every language,
    and passing a UTC timestamp to date_i18n() rendered the UTC date rather
    than the store's, naming the wrong day either side of midnight. A record
    with no usable expiry now skips the message instead of naming a date it
    does not have.

    * Apply the same expiry date fix to the settings screen notice

    The WooCommerce settings notice formats the same date the same way.

    * Keep the auto-renew prompt when the expiry date is unusable

    A subscription record carrying no usable expiry previously suppressed the
    whole notice. Enabling auto-renew is the same action whether or not the
    day is known, so the message now drops only the date.

    * Resolve subscriptions once per row and guard both placements

    The plugin row skipped its notice while the subscriptions fetch was
    failing, but the update row printed the same sentence regardless, so one
    placement was honest and the other was not. Both now decide from a single
    resolved list, which also removes the second pass over every subscription
    that each row was making.

    Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

    * Test subscription records that carry only the guaranteed fields

    filter_valid_subscriptions() guarantees product_id and connections and
    nothing else, so every other field read needs a guard. The suite turns
    warnings into failures, so rendering a sparse record through the plugin
    row and both update-row handlers now catches an unguarded read.

    Also point the two renewal price assertions at the padded entity that
    wp_kses emits. Neither could match a rendered price before, so both
    passed whether or not the price was there.

    Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

    ---------

    Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
    Co-authored-by: Akeda Bagus <akeda.bagus@automattic.com>

diff --git a/plugins/woocommerce/changelog/add-woocom-connect-notice-plugin-rows b/plugins/woocommerce/changelog/add-woocom-connect-notice-plugin-rows
new file mode 100644
index 00000000000..1f6486c2c94
--- /dev/null
+++ b/plugins/woocommerce/changelog/add-woocom-connect-notice-plugin-rows
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add notices under WooCommerce.com extensions on the Plugins screen when the store isn't connected, the extension has no subscription, or its subscription has expired or is about to expire without auto-renew, and make the renew link renew that subscription rather than add the product to the cart
diff --git a/plugins/woocommerce/changelog/fix-plugins-screen-purchase-tracking-script b/plugins/woocommerce/changelog/fix-plugins-screen-purchase-tracking-script
new file mode 100644
index 00000000000..bacf22fc880
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-plugins-screen-purchase-tracking-script
@@ -0,0 +1,3 @@
+Significance: patch
+Type: dev
+Comment: Register the woo-purchase-subscription admin script so its Tracks events fire on the Plugins screen, and mark events from the new notice rows with a no_update property. No merchant-facing change.
diff --git a/plugins/woocommerce/changelog/fix-subscription-expiry-date-format b/plugins/woocommerce/changelog/fix-subscription-expiry-date-format
new file mode 100644
index 00000000000..8e7e56ba08f
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-subscription-expiry-date-format
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Show subscription expiry dates in the store's own date format and time zone, rather than an English-only ordinal on the UTC date.
diff --git a/plugins/woocommerce/client/admin/client/utils/plugin-notice-tracking.ts b/plugins/woocommerce/client/admin/client/utils/plugin-notice-tracking.ts
new file mode 100644
index 00000000000..25550315769
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/utils/plugin-notice-tracking.ts
@@ -0,0 +1,53 @@
+/**
+ * External dependencies
+ */
+import { recordEvent } from '@woocommerce/tracks';
+
+/**
+ * Row attribute WooCommerce sets on the notice rows it prints under up-to-date plugins on the
+ * Plugins screen. Core's own update rows don't carry it.
+ */
+const NOTICE_ROW_SELECTOR = 'tr[data-plugin-row-type]';
+
+/**
+ * Record Tracks events for the subscription links WooCommerce prints on the Plugins screen.
+ *
+ * Links inside a core update row fire `<eventPrefix>_shown` and `<eventPrefix>_clicked` with no
+ * properties. Links inside a WooCommerce notice row, printed for a plugin with no update pending,
+ * fire the same events with `no_update: true`. The shown event fires once per placement present.
+ *
+ * @param {string} selector    CSS selector matching the links.
+ * @param {string} eventPrefix Event name without the `_shown` / `_clicked` suffix.
+ */
+export function trackPluginNoticeLinks(
+	selector: string,
+	eventPrefix: string
+) {
+	const links = Array.from(
+		document.querySelectorAll< HTMLAnchorElement >( selector )
+	);
+	const updateRowLinks = links.filter(
+		( link ) => ! link.closest( NOTICE_ROW_SELECTOR )
+	);
+	const noticeRowLinks = links.filter( ( link ) =>
+		link.closest( NOTICE_ROW_SELECTOR )
+	);
+
+	if ( updateRowLinks.length > 0 ) {
+		recordEvent( `${ eventPrefix }_shown` );
+		updateRowLinks.forEach( ( link ) => {
+			link.addEventListener( 'click', () => {
+				recordEvent( `${ eventPrefix }_clicked` );
+			} );
+		} );
+	}
+
+	if ( noticeRowLinks.length > 0 ) {
+		recordEvent( `${ eventPrefix }_shown`, { no_update: true } );
+		noticeRowLinks.forEach( ( link ) => {
+			link.addEventListener( 'click', () => {
+				recordEvent( `${ eventPrefix }_clicked`, { no_update: true } );
+			} );
+		} );
+	}
+}
diff --git a/plugins/woocommerce/client/admin/client/utils/test/plugin-notice-tracking.spec.ts b/plugins/woocommerce/client/admin/client/utils/test/plugin-notice-tracking.spec.ts
new file mode 100644
index 00000000000..bc4c1f63924
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/utils/test/plugin-notice-tracking.spec.ts
@@ -0,0 +1,88 @@
+/**
+ * External dependencies
+ */
+import { recordEvent } from '@woocommerce/tracks';
+
+/**
+ * Internal dependencies
+ */
+import { trackPluginNoticeLinks } from '../plugin-notice-tracking';
+
+jest.mock( '@woocommerce/tracks', () => ( { recordEvent: jest.fn() } ) );
+
+const updateRow = ( id: string ) =>
+	`<tr class="plugin-update-tr" id="${ id }-update"><td><p><a href="#" class="woocommerce-renew-subscription" id="${ id }">Renew</a></p></td></tr>`;
+
+const noticeRow = ( id: string ) =>
+	`<tr class="plugin-update-tr" data-plugin-row-type="woo-subscription-notice"><td><p><a href="#" class="woocommerce-renew-subscription" id="${ id }">Renew</a></p></td></tr>`;
+
+const render = ( rows: string ) => {
+	document.body.innerHTML = `<table><tbody id="the-list">${ rows }</tbody></table>`;
+	trackPluginNoticeLinks(
+		'.woocommerce-renew-subscription',
+		'woo_renew_subscription_in_plugins'
+	);
+};
+
+describe( 'trackPluginNoticeLinks', () => {
+	beforeEach( () => {
+		jest.clearAllMocks();
+	} );
+
+	it( 'records nothing when no link matches', () => {
+		render( '' );
+
+		expect( recordEvent ).not.toHaveBeenCalled();
+	} );
+
+	it( 'records the shown event without properties for update-row links', () => {
+		render( updateRow( 'a' ) + updateRow( 'b' ) );
+
+		expect( recordEvent ).toHaveBeenCalledTimes( 1 );
+		expect( recordEvent ).toHaveBeenCalledWith(
+			'woo_renew_subscription_in_plugins_shown'
+		);
+	} );
+
+	it( 'records the shown event with no_update for notice-row links', () => {
+		render( noticeRow( 'a' ) + noticeRow( 'b' ) );
+
+		expect( recordEvent ).toHaveBeenCalledTimes( 1 );
+		expect( recordEvent ).toHaveBeenCalledWith(
+			'woo_renew_subscription_in_plugins_shown',
+			{ no_update: true }
+		);
+	} );
+
+	it( 'records one shown event per placement when both are on the page', () => {
+		render( updateRow( 'a' ) + noticeRow( 'b' ) );
+
+		expect( recordEvent ).toHaveBeenCalledTimes( 2 );
+		expect( recordEvent ).toHaveBeenCalledWith(
+			'woo_renew_subscription_in_plugins_shown'
+		);
+		expect( recordEvent ).toHaveBeenCalledWith(
+			'woo_renew_subscription_in_plugins_shown',
+			{ no_update: true }
+		);
+	} );
+
+	it( 'records clicks with the placement of the clicked link', () => {
+		render( updateRow( 'a' ) + noticeRow( 'b' ) );
+		jest.clearAllMocks();
+
+		document.getElementById( 'a' )?.click();
+		document.getElementById( 'b' )?.click();
+
+		expect( recordEvent ).toHaveBeenCalledTimes( 2 );
+		expect( recordEvent ).toHaveBeenNthCalledWith(
+			1,
+			'woo_renew_subscription_in_plugins_clicked'
+		);
+		expect( recordEvent ).toHaveBeenNthCalledWith(
+			2,
+			'woo_renew_subscription_in_plugins_clicked',
+			{ no_update: true }
+		);
+	} );
+} );
diff --git a/plugins/woocommerce/client/admin/client/wp-admin-scripts/woo-enable-autorenew/index.js b/plugins/woocommerce/client/admin/client/wp-admin-scripts/woo-enable-autorenew/index.js
index 8429cb41919..5c67d372a87 100644
--- a/plugins/woocommerce/client/admin/client/wp-admin-scripts/woo-enable-autorenew/index.js
+++ b/plugins/woocommerce/client/admin/client/wp-admin-scripts/woo-enable-autorenew/index.js
@@ -2,19 +2,15 @@
  * External dependencies
  */
 import domReady from '@wordpress/dom-ready';
-import { recordEvent } from '@woocommerce/tracks';
+
+/**
+ * Internal dependencies
+ */
+import { trackPluginNoticeLinks } from '~/utils/plugin-notice-tracking';

 domReady( () => {
-	const enableAutorenewLink = document.querySelectorAll(
-		'.woocommerce-enable-autorenew'
+	trackPluginNoticeLinks(
+		'.woocommerce-enable-autorenew',
+		'woo_enable_autorenew_in_plugins'
 	);
-
-	if ( enableAutorenewLink.length > 0 ) {
-		recordEvent( 'woo_enable_autorenew_in_plugins_shown' );
-		enableAutorenewLink.forEach( ( link ) => {
-			link.addEventListener( 'click', function () {
-				recordEvent( 'woo_enable_autorenew_in_plugins_clicked' );
-			} );
-		} );
-	}
 } );
diff --git a/plugins/woocommerce/client/admin/client/wp-admin-scripts/woo-plugin-update-connect-notice/index.js b/plugins/woocommerce/client/admin/client/wp-admin-scripts/woo-plugin-update-connect-notice/index.js
index 23977a34254..843ec99b7e0 100644
--- a/plugins/woocommerce/client/admin/client/wp-admin-scripts/woo-plugin-update-connect-notice/index.js
+++ b/plugins/woocommerce/client/admin/client/wp-admin-scripts/woo-plugin-update-connect-notice/index.js
@@ -2,19 +2,15 @@
  * External dependencies
  */
 import domReady from '@wordpress/dom-ready';
-import { recordEvent } from '@woocommerce/tracks';
+
+/**
+ * Internal dependencies
+ */
+import { trackPluginNoticeLinks } from '~/utils/plugin-notice-tracking';

 domReady( () => {
-	const connectYourStoreLinks = document.querySelectorAll(
-		'.woocommerce-connect-your-store'
+	trackPluginNoticeLinks(
+		'.woocommerce-connect-your-store',
+		'woo_connect_notice_in_plugins'
 	);
-
-	if ( connectYourStoreLinks.length > 0 ) {
-		recordEvent( 'woo_connect_notice_in_plugins_shown' );
-		connectYourStoreLinks.forEach( ( link ) => {
-			link.addEventListener( 'click', function () {
-				recordEvent( 'woo_connect_notice_in_plugins_clicked' );
-			} );
-		} );
-	}
 } );
diff --git a/plugins/woocommerce/client/admin/client/wp-admin-scripts/woo-purchase-subscription/index.js b/plugins/woocommerce/client/admin/client/wp-admin-scripts/woo-purchase-subscription/index.js
index 42d2bd1ea14..bc79be91a1a 100644
--- a/plugins/woocommerce/client/admin/client/wp-admin-scripts/woo-purchase-subscription/index.js
+++ b/plugins/woocommerce/client/admin/client/wp-admin-scripts/woo-purchase-subscription/index.js
@@ -2,19 +2,15 @@
  * External dependencies
  */
 import domReady from '@wordpress/dom-ready';
-import { recordEvent } from '@woocommerce/tracks';
+
+/**
+ * Internal dependencies
+ */
+import { trackPluginNoticeLinks } from '~/utils/plugin-notice-tracking';

 domReady( () => {
-	const purchaseSubscriptionLink = document.querySelectorAll(
-		'.woocommerce-purchase-subscription'
+	trackPluginNoticeLinks(
+		'.woocommerce-purchase-subscription',
+		'woo_purchase_subscription_in_plugins'
 	);
-
-	if ( purchaseSubscriptionLink.length > 0 ) {
-		recordEvent( 'woo_purchase_subscription_in_plugins_shown' );
-		purchaseSubscriptionLink.forEach( ( link ) => {
-			link.addEventListener( 'click', function () {
-				recordEvent( 'woo_purchase_subscription_in_plugins_clicked' );
-			} );
-		} );
-	}
 } );
diff --git a/plugins/woocommerce/client/admin/client/wp-admin-scripts/woo-renew-subscription/index.js b/plugins/woocommerce/client/admin/client/wp-admin-scripts/woo-renew-subscription/index.js
index 3eff82fb7c0..34855d984b6 100644
--- a/plugins/woocommerce/client/admin/client/wp-admin-scripts/woo-renew-subscription/index.js
+++ b/plugins/woocommerce/client/admin/client/wp-admin-scripts/woo-renew-subscription/index.js
@@ -2,19 +2,15 @@
  * External dependencies
  */
 import domReady from '@wordpress/dom-ready';
-import { recordEvent } from '@woocommerce/tracks';
+
+/**
+ * Internal dependencies
+ */
+import { trackPluginNoticeLinks } from '~/utils/plugin-notice-tracking';

 domReady( () => {
-	const renewSubscriptionLink = document.querySelectorAll(
-		'.woocommerce-renew-subscription'
+	trackPluginNoticeLinks(
+		'.woocommerce-renew-subscription',
+		'woo_renew_subscription_in_plugins'
 	);
-
-	if ( renewSubscriptionLink.length > 0 ) {
-		recordEvent( 'woo_renew_subscription_in_plugins_shown' );
-		renewSubscriptionLink.forEach( ( link ) => {
-			link.addEventListener( 'click', function () {
-				recordEvent( 'woo_renew_subscription_in_plugins_clicked' );
-			} );
-		} );
-	}
 } );
diff --git a/plugins/woocommerce/includes/admin/helper/class-wc-helper-updater.php b/plugins/woocommerce/includes/admin/helper/class-wc-helper-updater.php
index 5014be6630c..1e6ca483bbc 100644
--- a/plugins/woocommerce/includes/admin/helper/class-wc-helper-updater.php
+++ b/plugins/woocommerce/includes/admin/helper/class-wc-helper-updater.php
@@ -20,6 +20,16 @@ if ( ! defined( 'ABSPATH' ) ) {
  */
 class WC_Helper_Updater {

+	/**
+	 * Prefix for the utm_campaign value of links in the message appended to Core's update row.
+	 */
+	private const CAMPAIGN_UPDATE_ROW = 'pu_plugin_screen';
+
+	/**
+	 * Prefix for the utm_campaign value of links in the notice row under an up-to-date plugin.
+	 */
+	private const CAMPAIGN_PLUGIN_ROW = 'pu_plugin_row';
+
 	/**
 	 * Loads the class, runs on init.
 	 */
@@ -41,6 +51,9 @@ class WC_Helper_Updater {
 		if ( WC_Helper::is_site_connected() ) {
 			add_action( 'load-plugins.php', array( __CLASS__, 'setup_message_for_expired_and_expiring_subscriptions' ), 11 );
 			add_action( 'load-plugins.php', array( __CLASS__, 'setup_message_for_plugins_without_subscription' ), 11 );
+			add_action( 'after_plugin_row', array( __CLASS__, 'display_subscription_notice_for_woo_plugins' ), 10, 2 );
+		} else {
+			add_action( 'after_plugin_row', array( __CLASS__, 'display_connect_notice_for_woo_plugins' ), 10, 2 );
 		}
 	}

@@ -250,29 +263,437 @@ class WC_Helper_Updater {
 	 * @return void.
 	 */
 	public static function add_connect_woocom_plugin_message() {
+		self::print_update_row_message( self::get_connect_notice( self::CAMPAIGN_UPDATE_ROW ) );
+	}
+
+	/**
+	 * Append a message to the update row Core renders for a plugin.
+	 *
+	 * Core has already printed its own sentence, so the message is preceded by a space.
+	 *
+	 * @since 11.2.0
+	 *
+	 * @param string $notice The notice text, which may contain a single link.
+	 *
+	 * @return void
+	 */
+	private static function print_update_row_message( string $notice ): void {
+		if ( '' === $notice ) {
+			return;
+		}
+
+		echo ' ' . self::kses_notice( $notice ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- kses_notice() escapes.
+	}
+
+	/**
+	 * Strip a notice down to text and a link with a class.
+	 *
+	 * @since 11.2.0
+	 *
+	 * @param string $notice The notice HTML.
+	 *
+	 * @return string
+	 */
+	private static function kses_notice( string $notice ): string {
+		return wp_kses(
+			$notice,
+			array(
+				'a' => array(
+					'href'  => array(),
+					'class' => array(),
+				),
+			)
+		);
+	}
+
+	/**
+	 * Message asking an unconnected store to connect to WooCommerce.com.
+	 *
+	 * @since 11.2.0
+	 *
+	 * @param string $campaign_prefix Prefix for the link's utm_campaign value.
+	 *
+	 * @return string
+	 */
+	private static function get_connect_notice( string $campaign_prefix ): string {
 		$connect_page_url = add_query_arg(
 			array(
 				'page'         => 'wc-admin',
 				'tab'          => 'my-subscriptions',
 				'path'         => rawurlencode( '/extensions' ),
 				'utm_source'   => 'pu',
-				'utm_campaign' => 'pu_plugin_screen_connect',
+				'utm_campaign' => $campaign_prefix . '_connect',
 			),
 			admin_url( 'admin.php' )
 		);

+		return sprintf(
+			/* translators: 1: URL of the WooCommerce.com connect page */
+			__( 'Extension distributed via WooCommerce.com. <a href="%1$s" class="woocommerce-connect-your-store">Connect your store</a> for security updates, product improvements, and support.', 'woocommerce' ),
+			esc_url( $connect_page_url )
+		);
+	}
+
+	/**
+	 * Product ID of the WooCommerce.com plugin a row notice applies to.
+	 *
+	 * Returns 0 when the row should get no notice: the plugin isn't WooCommerce.com hosted, the
+	 * screen is one Core renders no update rows on, or Core already renders an update row for it,
+	 * which the in_plugin_update_message-{file-name} handlers append their own message to.
+	 *
+	 * @since 11.2.0
+	 *
+	 * @param string $plugin_file Path to the plugin file relative to the plugins directory.
+	 * @param array  $plugin_data An array of plugin metadata.
+	 *
+	 * @return int
+	 */
+	private static function get_product_id_for_plugin_row_notice( $plugin_file, $plugin_data ): int {
+		global $wp_list_table;
+
+		// Core skips its own update rows for these users, so there is nothing to act on here either.
+		if ( is_null( $wp_list_table ) || ! current_user_can( 'update_plugins' ) ) {
+			return 0;
+		}
+
+		// On multisite, plugins are managed from the network admin, and Core renders no update rows on a sub-site's screen.
+		if ( is_multisite() && ! is_network_admin() ) {
+			return 0;
+		}
+
+		if ( empty( $plugin_data['Woo'] ) ) {
+			return 0;
+		}
+
+		// The Update Manager is what delivers the updates these notices are about, so prompting on its own row reads as circular.
+		if ( WC_Woo_Update_Manager_Plugin::WOO_UPDATE_MANAGER_PLUGIN_MAIN_FILE === $plugin_file ) {
+			return 0;
+		}
+
+		$woo_plugins = WC_Helper::get_local_woo_plugins();
+		if ( ! isset( $woo_plugins[ $plugin_file ] ) ) {
+			return 0;
+		}
+
+		$updates = get_site_transient( 'update_plugins' );
+		if ( isset( $updates->response[ $plugin_file ] ) ) {
+			return 0;
+		}
+
+		return (int) $woo_plugins[ $plugin_file ]['_product_id'];
+	}
+
+	/**
+	 * Print a notice row underneath a plugin row, styled like the update rows Core renders.
+	 *
+	 * @since 11.2.0
+	 *
+	 * @param string $plugin_file Path to the plugin file relative to the plugins directory.
+	 * @param string $row_type    Value for the data-plugin-row-type attribute.
+	 * @param string $notice      The notice text, which may contain a single link.
+	 *
+	 * @return void
+	 */
+	private static function print_plugin_row_notice( $plugin_file, $row_type, $notice ): void {
+		global $wp_list_table;
+
 		printf(
-			wp_kses(
-			/* translators: 1: Woo Update Manager plugin install URL */
-				__( ' <a href="%1$s" class="woocommerce-connect-your-store">Connect your store</a> to woocommerce.com to update.', 'woocommerce' ),
+			'<tr class="plugin-update-tr %1$s" data-plugin="%2$s" data-plugin-row-type="%3$s"><td colspan="%4$s" class="plugin-update colspanchange"><div class="update-message notice inline notice-warning notice-alt"><p>%5$s</p></div></td></tr>',
+			esc_attr( ( is_network_admin() ? is_plugin_active_for_network( $plugin_file ) : is_plugin_active( $plugin_file ) ) ? 'active' : 'inactive' ),
+			esc_attr( $plugin_file ),
+			esc_attr( $row_type ),
+			esc_attr( (string) $wp_list_table->get_column_count() ),
+			self::kses_notice( $notice ) // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- kses_notice() escapes.
+		);
+	}
+
+	/**
+	 * Runs on after_plugin_row, show a connect message on WooCommerce.com plugin rows that Core
+	 * renders no update notice for.
+	 *
+	 * Core only renders its update row -- and with it the message appended by
+	 * add_connect_woocom_plugin_message() -- when an update is pending, so an up-to-date plugin
+	 * gives an unconnected store no indication that it won't receive updates.
+	 *
+	 * @since 11.2.0
+	 *
+	 * @param string $plugin_file Path to the plugin file relative to the plugins directory.
+	 * @param array  $plugin_data An array of plugin metadata.
+	 *
+	 * @return void
+	 */
+	public static function display_connect_notice_for_woo_plugins( $plugin_file, $plugin_data ): void {
+		if ( 0 === self::get_product_id_for_plugin_row_notice( $plugin_file, $plugin_data ) ) {
+			return;
+		}
+
+		self::print_plugin_row_notice( $plugin_file, 'woo-connect-notice', self::get_connect_notice( self::CAMPAIGN_PLUGIN_ROW ) );
+	}
+
+	/**
+	 * Runs on after_plugin_row, show a subscription message on WooCommerce.com plugin rows that
+	 * Core renders no update notice for.
+	 *
+	 * Rows that do have an update are covered by the in_plugin_update_message-{file-name} handlers.
+	 * Without one, a plugin whose subscription is missing, expired, or lapsing says nothing at all.
+	 *
+	 * @since 11.2.0
+	 *
+	 * @param string $plugin_file Path to the plugin file relative to the plugins directory.
+	 * @param array  $plugin_data An array of plugin metadata.
+	 *
+	 * @return void
+	 */
+	public static function display_subscription_notice_for_woo_plugins( $plugin_file, $plugin_data ): void {
+		$product_id = self::get_product_id_for_plugin_row_notice( $plugin_file, $plugin_data );
+		if ( 0 === $product_id ) {
+			return;
+		}
+
+		$notice = self::get_subscription_notice( $product_id, self::get_product_page_url( $plugin_file ), self::CAMPAIGN_PLUGIN_ROW );
+		if ( '' === $notice ) {
+			return;
+		}
+
+		self::print_plugin_row_notice( $plugin_file, 'woo-subscription-notice', $notice );
+	}
+
+	/**
+	 * Message for a product's subscription state, or an empty string when there is nothing to say.
+	 *
+	 * Resolves the product's subscriptions once and hands them to whichever message needs them,
+	 * so a screen full of WooCommerce.com plugins doesn't filter the whole list twice per row.
+	 *
+	 * @since 11.2.0
+	 *
+	 * @param int    $product_id       WooCommerce.com product ID.
+	 * @param string $product_page_url WooCommerce.com product page URL, or an empty string.
+	 * @param string $campaign_prefix  Prefix for the link's utm_campaign value.
+	 *
+	 * @return string
+	 */
+	private static function get_subscription_notice( int $product_id, string $product_page_url, string $campaign_prefix ): string {
+		// While the last subscriptions fetch is still failing, the list is stale or empty, so a
+		// missing subscription would be a guess rather than a fact.
+		if ( null !== WC_Helper::get_api_error() ) {
+			return '';
+		}
+
+		$subscriptions = self::get_subscriptions_for_product( $product_id );
+		if ( empty( $subscriptions ) ) {
+			return self::get_purchase_notice( $product_id, $product_page_url, $campaign_prefix );
+		}
+
+		return self::get_renewal_notice( $subscriptions, $campaign_prefix );
+	}
+
+	/**
+	 * Every subscription the account holds for a product.
+	 *
+	 * Read straight from get_subscriptions() so the per-request static caches in
+	 * get_installed_subscriptions() and get_unconnected_subscriptions() don't pin the first
+	 * result for the rest of the request.
+	 *
+	 * @since 11.2.0
+	 *
+	 * @param int $product_id WooCommerce.com product ID.
+	 *
+	 * @return array
+	 */
+	private static function get_subscriptions_for_product( int $product_id ): array {
+		return wp_list_filter( WC_Helper::get_subscriptions(), array( 'product_id' => $product_id ) );
+	}
+
+	/**
+	 * WooCommerce.com product page URL for a plugin, from the update data Core already holds.
+	 *
+	 * @since 11.2.0
+	 *
+	 * @param string $plugin_file Path to the plugin file relative to the plugins directory.
+	 *
+	 * @return string The product page URL, or an empty string when the update data carries none.
+	 */
+	private static function get_product_page_url( $plugin_file ): string {
+		$updates = get_site_transient( 'update_plugins' );
+
+		return self::get_product_page_url_from_update_response( $updates->no_update[ $plugin_file ] ?? $updates->response[ $plugin_file ] ?? null );
+	}
+
+	/**
+	 * WooCommerce.com product page URL carried by an update response, or an empty string.
+	 *
+	 * @since 11.2.0
+	 *
+	 * @param mixed $response Update response from the update_plugins transient.
+	 *
+	 * @return string
+	 */
+	private static function get_product_page_url_from_update_response( $response ): string {
+		return is_object( $response ) && ! empty( $response->url ) ? (string) $response->url : '';
+	}
+
+	/**
+	 * Message for a plugin whose product the connected account holds no subscription for.
+	 *
+	 * Links to the product page so the reader can see what a subscription covers before buying.
+	 * Falls back to the cart when the update data carries no product URL.
+	 *
+	 * @since 11.2.0
+	 *
+	 * @param int    $product_id       WooCommerce.com product ID.
+	 * @param string $product_page_url WooCommerce.com product page URL, or an empty string.
+	 * @param string $campaign_prefix  Prefix for the link's utm_campaign value.
+	 *
+	 * @return string
+	 */
+	private static function get_purchase_notice( int $product_id, string $product_page_url, string $campaign_prefix ): string {
+		$purchase_link = '' !== $product_page_url
+			? add_query_arg(
 				array(
-					'a' => array(
-						'href'  => array(),
-						'class' => array(),
-					),
-				)
+					'utm_source'   => 'pu',
+					'utm_campaign' => $campaign_prefix . '_purchase',
+				),
+				$product_page_url
+			)
+			: add_query_arg(
+				array(
+					'add-to-cart'  => $product_id,
+					'utm_source'   => 'pu',
+					'utm_campaign' => $campaign_prefix . '_purchase',
+				),
+				PluginsHelper::WOO_CART_PAGE_URL
+			);
+
+		return sprintf(
+			/* translators: 1: URL of the WooCommerce.com product page */
+			__( 'You don\'t have an active subscription for this product. <a href="%1$s" class="woocommerce-purchase-subscription">Subscribe</a> now for security updates, product improvements, and support.', 'woocommerce' ),
+			esc_url( $purchase_link )
+		);
+	}
+
+	/**
+	 * Message for a plugin whose subscription has expired or is lapsing.
+	 *
+	 * Returns an empty string for a subscription that needs no action.
+	 *
+	 * @since 11.2.0
+	 *
+	 * @param array  $subscriptions   Every subscription the account holds for the product.
+	 * @param string $campaign_prefix Prefix for the link's utm_campaign value.
+	 *
+	 * @return string
+	 */
+	private static function get_renewal_notice( array $subscriptions, string $campaign_prefix ): string {
+		list( $expired_subscription, $expiring_subscription ) = self::get_renewable_subscriptions( $subscriptions );
+
+		if ( ! empty( $expired_subscription ) ) {
+			return sprintf(
+				/* translators: 1: URL of the WooCommerce.com cart set up to renew the subscription */
+				__( 'Your subscription for this extension has expired. <a href="%1$s" class="woocommerce-renew-subscription">Renew your subscription</a> for security updates, product improvements, and support.', 'woocommerce' ),
+				esc_url( self::get_renew_link( $expired_subscription, $campaign_prefix . '_renew' ) )
+			);
+		}
+
+		if ( ! empty( $expiring_subscription ) ) {
+			$autorenew_link = add_query_arg(
+				array(
+					'utm_source'   => 'pu',
+					'utm_campaign' => $campaign_prefix . '_enable_autorenew',
+				),
+				PluginsHelper::WOO_SUBSCRIPTION_PAGE_URL
+			);
+
+			// Auto-renew still needs turning on when the record carries no usable expiry; only
+			// the date is unknown, so name everything except the day.
+			if ( ! is_numeric( $expiring_subscription['expires'] ?? null ) ) {
+				return sprintf(
+					/* translators: 1: URL of the My Subscriptions page */
+					__( 'Your subscription for this extension expires soon. <a href="%1$s" class="woocommerce-enable-autorenew">Enable auto-renew</a> to keep getting updates and support.', 'woocommerce' ),
+					esc_url( $autorenew_link )
+				);
+			}
+
+			return sprintf(
+				/* translators: 1: Expiry date, 2: URL of the My Subscriptions page */
+				__( 'Your subscription for this extension expires on %1$s. <a href="%2$s" class="woocommerce-enable-autorenew">Enable auto-renew</a> to keep getting updates and support.', 'woocommerce' ),
+				wp_date( get_option( 'date_format' ), (int) $expiring_subscription['expires'] ),
+				esc_url( $autorenew_link )
+			);
+		}
+
+		return '';
+	}
+
+	/**
+	 * Product ID carried by an update response WooCommerce wrote, or 0 for anything else.
+	 *
+	 * The updater sets the ID to "woocommerce-com-<product ID>". A filter can replace the
+	 * response, so anything that does not match exactly is treated as not ours rather than
+	 * having its digits scraped out.
+	 *
+	 * @since 11.2.0
+	 *
+	 * @param mixed $response Update response from the update_plugins transient.
+	 *
+	 * @return int
+	 */
+	private static function get_product_id_from_update_response( $response ): int {
+		if ( ! is_object( $response ) || ! isset( $response->id ) || ! is_string( $response->id ) ) {
+			return 0;
+		}
+
+		$prefix = 'woocommerce-com-';
+		if ( 0 !== strpos( $response->id, $prefix ) ) {
+			return 0;
+		}
+
+		$product_id = substr( $response->id, strlen( $prefix ) );
+
+		return ctype_digit( $product_id ) ? (int) $product_id : 0;
+	}
+
+	/**
+	 * Cart link that renews one specific subscription.
+	 *
+	 * A plain add-to-cart link would buy a new subscription instead. This is the same link the
+	 * My Subscriptions screen and the product usage notice use. Renewing needs the product key
+	 * and the order ID; a record missing either gets the add-to-cart link, which still works.
+	 *
+	 * @since 11.2.0
+	 *
+	 * @param array  $subscription Subscription record from the WooCommerce.com API.
+	 * @param string $campaign     utm_campaign value for the link.
+	 *
+	 * @return string
+	 */
+	private static function get_renew_link( array $subscription, string $campaign ): string {
+		$product_id  = $subscription['product_id'];
+		$product_key = $subscription['product_key'] ?? '';
+		$order_id    = $subscription['order_id'] ?? '';
+
+		// The same shape check filter_valid_subscriptions() applies to the product ID.
+		$has_order_id = ( is_int( $order_id ) || ( is_string( $order_id ) && ctype_digit( $order_id ) ) ) && 0 < (int) $order_id;
+
+		if ( ! is_string( $product_key ) || '' === $product_key || ! $has_order_id ) {
+			return add_query_arg(
+				array(
+					'add-to-cart'  => $product_id,
+					'utm_source'   => 'pu',
+					'utm_campaign' => $campaign,
+				),
+				PluginsHelper::WOO_CART_PAGE_URL
+			);
+		}
+
+		return add_query_arg(
+			array(
+				'renew_product' => $product_id,
+				'product_key'   => $product_key,
+				'order_id'      => $order_id,
+				'utm_source'    => 'pu',
+				'utm_campaign'  => $campaign,
 			),
-			esc_url( $connect_page_url ),
+			PluginsHelper::WOO_CART_PAGE_URL
 		);
 	}

@@ -312,97 +733,67 @@ class WC_Helper_Updater {
 	}

 	/**
-	 * Runs on in_plugin_update_message-{file-name}, show a message if plugins subscription expired or expiring soon.
+	 * Expired and lapsing subscriptions for a product that the merchant should act on.
 	 *
-	 * @param object $plugin_data An array of plugin metadata.
-	 * @param object $response  An object of metadata about the available plugin update.
+	 * Every subscription the account holds for the product counts, wherever it is connected,
+	 * because renewing and buying can be done from any store. Nothing is returned while another
+	 * subscription still covers the product, so a store that keeps its updates stays quiet.
 	 *
-	 * @return void.
+	 * @since 11.2.0
+	 *
+	 * @param array $subscriptions Every subscription the account holds for the product.
+	 *
+	 * @return array A pair of subscriptions, each an array or false: the expired one, then the
+	 *               lapsing one without auto-renew.
 	 */
-	public static function display_notice_for_expired_and_expiring_subscriptions( $plugin_data, $response ) {
-		// Extract product ID from the response.
-		$product_id = preg_replace( '/[^0-9]/', '', $response->id );
-
-		$installed_or_unconnected = array_merge(
-			WC_Helper::get_installed_subscriptions(),
-			WC_Helper::get_unconnected_subscriptions()
-		);
-
-		// Product subscriptions.
-		$subscriptions = wp_list_filter( $installed_or_unconnected, array( 'product_id' => $product_id ) );
+	private static function get_renewable_subscriptions( array $subscriptions ): array {
 		if ( empty( $subscriptions ) ) {
-			return;
+			return array( false, false );
 		}

-		$expired_subscription = current(
-			array_filter(
-				$subscriptions,
-				function ( $subscription ) {
-					return ! empty( $subscription['expired'] ) && ! $subscription['lifetime'];
-				}
-			)
-		);
-
-		$expiring_subscription = current(
-			array_filter(
-				$subscriptions,
-				function ( $subscription ) {
-					return ! empty( $subscription['expiring'] ) && ! $subscription['autorenew'];
-				}
-			)
+		// A lifetime subscription never lapses, so an expired flag on one is nothing to act on.
+		$active = array_filter(
+			$subscriptions,
+			function ( $subscription ) {
+				return empty( $subscription['expired'] ) || ! empty( $subscription['lifetime'] );
+			}
 		);

-		// Prepare the expiry notice based on subscription status.
-		$expiry_notice = '';
-		if ( ! empty( $expired_subscription ) ) {
+		// Nothing covers the product any more, so renewing is what restores updates.
+		if ( empty( $active ) ) {
+			return array( current( $subscriptions ), false );
+		}

-			$renew_link = add_query_arg(
-				array(
-					'add-to-cart'  => $product_id,
-					'utm_source'   => 'pu',
-					'utm_campaign' => 'pu_plugin_screen_renew',
-				),
-				PluginsHelper::WOO_CART_PAGE_URL
-			);
+		$lapsing = array_filter(
+			$active,
+			function ( $subscription ) {
+				return ! empty( $subscription['expiring'] ) && empty( $subscription['autorenew'] );
+			}
+		);

-			/* translators: 1: Product regular price */
-			$product_price = ! empty( $expired_subscription['product_regular_price'] ) ? sprintf( __( 'for %s ', 'woocommerce' ), esc_html( $expired_subscription['product_regular_price'] ) ) : '';
+		// One subscription that outlives the others keeps the product covered.
+		if ( count( $lapsing ) !== count( $active ) ) {
+			return array( false, false );
+		}

-			$expiry_notice = sprintf(
-			/* translators: 1: URL to My Subscriptions page 2: Product price */
-				__( ' Your subscription expired, <a href="%1$s" class="woocommerce-renew-subscription">renew %2$s</a>to update.', 'woocommerce' ),
-				esc_url( $renew_link ),
-				$product_price
-			);
-		} elseif ( ! empty( $expiring_subscription ) ) {
-			$renew_link = add_query_arg(
-				array(
-					'utm_source'   => 'pu',
-					'utm_campaign' => 'pu_plugin_screen_enable_autorenew',
-				),
-				PluginsHelper::WOO_SUBSCRIPTION_PAGE_URL
-			);
+		return array( false, current( $lapsing ) );
+	}

-			$expiry_notice = sprintf(
-			/* translators: 1: Expiry date 1: URL to My Subscriptions page */
-				__( ' Your subscription expires on %1$s, <a href="%2$s" class="woocommerce-enable-autorenew">enable auto-renew</a> to continue receiving updates.', 'woocommerce' ),
-				date_i18n( 'F jS', $expiring_subscription['expires'] ),
-				esc_url( $renew_link )
-			);
+	/**
+	 * Runs on in_plugin_update_message-{file-name}, show a message if plugins subscription expired or expiring soon.
+	 *
+	 * @param object $plugin_data An array of plugin metadata.
+	 * @param object $response  An object of metadata about the available plugin update.
+	 *
+	 * @return void.
+	 */
+	public static function display_notice_for_expired_and_expiring_subscriptions( $plugin_data, $response ) {
+		$product_id = self::get_product_id_from_update_response( $response );
+		if ( 0 === $product_id || null !== WC_Helper::get_api_error() ) {
+			return;
 		}

-		// Display the expiry notice.
-		if ( ! empty( $expiry_notice ) ) {
-			echo wp_kses(
-				$expiry_notice,
-				array(
-					'a' => array(
-						'href'  => array(),
-						'class' => array(),
-					),
-				)
-			);
-		}
+		self::print_update_row_message( self::get_renewal_notice( self::get_subscriptions_for_product( $product_id ), self::CAMPAIGN_UPDATE_ROW ) );
 	}

 	/**
@@ -416,38 +807,18 @@ class WC_Helper_Updater {
 	 * @return void.
 	 */
 	public static function display_notice_for_plugins_without_subscription( $plugin_data, $response ) {
-		// Extract product ID from the response.
-		$product_id = preg_replace( '/[^0-9]/', '', $response->id );
-
-		if ( WC_Helper::has_product_subscription( $product_id ) ) {
+		$product_id = self::get_product_id_from_update_response( $response );
+		if ( 0 === $product_id || null !== WC_Helper::get_api_error() ) {
 			return;
 		}

-		// Prepare the expiry notice based on subscription status.
-		$purchase_link = add_query_arg(
-			array(
-				'add-to-cart'  => $product_id,
-				'utm_source'   => 'pu',
-				'utm_campaign' => 'pu_plugin_screen_purchase',
-			),
-			PluginsHelper::WOO_CART_PAGE_URL,
-		);
-
-		$notice = sprintf(
-			/* translators: 1: URL to My Subscriptions page */
-			__( ' You don\'t have a subscription, <a href="%1$s" class="woocommerce-purchase-subscription">subscribe</a> to update.', 'woocommerce' ),
-			esc_url( $purchase_link ),
-		);
+		// An empty list after a failed fetch would be a guess, which the guard above rules out.
+		if ( ! empty( self::get_subscriptions_for_product( $product_id ) ) ) {
+			return;
+		}

-		// Display the expiry notice.
-		echo wp_kses(
-			$notice,
-			array(
-				'a' => array(
-					'href'  => array(),
-					'class' => array(),
-				),
-			)
+		self::print_update_row_message(
+			self::get_purchase_notice( $product_id, self::get_product_page_url_from_update_response( $response ), self::CAMPAIGN_UPDATE_ROW )
 		);
 	}

diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index 85d7650ad9f..5148e25d971 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -3582,12 +3582,6 @@ parameters:
 			count: 1
 			path: includes/admin/helper/class-wc-helper-updater.php

-		-
-			message: '#^Access to an undefined property object\:\:\$id\.$#'
-			identifier: property.notFound
-			count: 2
-			path: includes/admin/helper/class-wc-helper-updater.php
-
 		-
 			message: '#^Access to an undefined property object\:\:\$response\.$#'
 			identifier: property.notFound
@@ -3714,12 +3708,6 @@ parameters:
 			count: 1
 			path: includes/admin/helper/class-wc-helper-updater.php

-		-
-			message: '#^Parameter \#1 \$product_id of static method WC_Helper\:\:has_product_subscription\(\) expects int, \(array\<string\>\|string\|null\) given\.$#'
-			identifier: argument.type
-			count: 1
-			path: includes/admin/helper/class-wc-helper-updater.php
-
 		-
 			message: '#^Parameter \#1 \$str of function md5 expects string, string\|false given\.$#'
 			identifier: argument.type
diff --git a/plugins/woocommerce/src/Admin/PluginsHelper.php b/plugins/woocommerce/src/Admin/PluginsHelper.php
index a33f6ce6ec9..43023b307f4 100644
--- a/plugins/woocommerce/src/Admin/PluginsHelper.php
+++ b/plugins/woocommerce/src/Admin/PluginsHelper.php
@@ -860,6 +860,7 @@ class PluginsHelper {
 		WCAdminAssets::register_script( 'wp-admin-scripts', 'woo-plugin-update-connect-notice' );
 		WCAdminAssets::register_script( 'wp-admin-scripts', 'woo-enable-autorenew' );
 		WCAdminAssets::register_script( 'wp-admin-scripts', 'woo-renew-subscription' );
+		WCAdminAssets::register_script( 'wp-admin-scripts', 'woo-purchase-subscription' );
 		wp_enqueue_script( 'woo-plugin-update-connect-notice' );
 		wp_enqueue_script( 'woo-enable-autorenew' );
 		wp_enqueue_script( 'woo-renew-subscription' );
@@ -1004,7 +1005,7 @@ class PluginsHelper {
 			/* translators: 1: Product price */
 			$renew_string = sprintf( __( 'Renew for %1$s', 'woocommerce' ), $subscription['product_regular_price'] );
 		}
-		$expiry_date   = date_i18n( 'F jS', $subscription['expires'] );
+		$expiry_date   = (string) wp_date( get_option( 'date_format' ), (int) $subscription['expires'] );
 		$hyperlink_url = add_query_arg(
 			array(
 				'product_id'   => $product_id,
diff --git a/plugins/woocommerce/tests/php/includes/admin/helper/class-wc-helper-updater-test.php b/plugins/woocommerce/tests/php/includes/admin/helper/class-wc-helper-updater-test.php
index d115090056a..0efbb23f02d 100644
--- a/plugins/woocommerce/tests/php/includes/admin/helper/class-wc-helper-updater-test.php
+++ b/plugins/woocommerce/tests/php/includes/admin/helper/class-wc-helper-updater-test.php
@@ -57,6 +57,7 @@ class WC_Helper_Updater_Test extends WC_Unit_Test_Case {
 		try {
 			// The fixture theme lives on disk and in a global, neither of which the parent teardown resets.
 			$this->cleanup_theme_fixture();
+			$this->cleanup_plugins_screen();
 		} finally {
 			parent::tearDown();
 		}
@@ -616,6 +617,978 @@ class WC_Helper_Updater_Test extends WC_Unit_Test_Case {
 		);
 	}

+	/**
+	 * Plugin list entry for a WooCommerce.com hosted plugin.
+	 *
+	 * @var array
+	 */
+	private $woo_plugin_file = 'test-woo-extension/test-woo-extension.php';
+
+	/**
+	 * @testdox Connect notice renders on a Woo plugin row that has no pending update.
+	 */
+	public function test_connect_notice_renders_without_pending_update(): void {
+		$this->prepare_plugins_screen();
+		delete_site_transient( 'update_plugins' );
+
+		$output = $this->render_connect_notice( $this->woo_plugin_file, $this->woo_plugin_data() );
+
+		$this->assertStringContainsString( 'woo-connect-notice', $output, 'The notice row should be rendered.' );
+		$this->assertStringContainsString( 'woocommerce-connect-your-store', $output, 'The notice should link to the connect page.' );
+		$this->assertStringContainsString( '>Connect your store</a> for security updates, product improvements, and support.', $output );
+	}
+
+	/**
+	 * @testdox Connect notice is skipped when Core already renders an update row for the plugin.
+	 */
+	public function test_connect_notice_is_skipped_when_core_renders_an_update_row(): void {
+		$this->prepare_plugins_screen();
+
+		$transient                                     = new stdClass();
+		$transient->response                           = array();
+		$transient->response[ $this->woo_plugin_file ] = (object) array(
+			'id'          => 'woocommerce-com-123',
+			'new_version' => '2.0.0',
+			'package'     => '',
+		);
+		set_site_transient( 'update_plugins', $transient );
+
+		$output = $this->render_connect_notice( $this->woo_plugin_file, $this->woo_plugin_data() );
+
+		$this->assertSame( '', $output, 'Core already carries the connect prompt on its update row.' );
+	}
+
+	/**
+	 * @testdox Connect notice is skipped for plugins that are not hosted on WooCommerce.com.
+	 */
+	public function test_connect_notice_is_skipped_for_non_woo_plugins(): void {
+		$this->prepare_plugins_screen();
+		delete_site_transient( 'update_plugins' );
+
+		$output = $this->render_connect_notice(
+			'some-other-plugin/some-other-plugin.php',
+			array(
+				'Name'    => 'Some Other Plugin',
+				'Version' => '1.0.0',
+				'Woo'     => '',
+			)
+		);
+
+		$this->assertSame( '', $output, 'Only WooCommerce.com hosted plugins should get the notice.' );
+	}
+
+	/**
+	 * @testdox Connect notice is skipped for users who cannot update plugins.
+	 */
+	public function test_connect_notice_is_skipped_without_the_update_plugins_capability(): void {
+		$this->prepare_plugins_screen();
+		delete_site_transient( 'update_plugins' );
+		wp_set_current_user( self::factory()->user->create( array( 'role' => 'customer' ) ) );
+
+		$output = $this->render_connect_notice( $this->woo_plugin_file, $this->woo_plugin_data() );
+
+		$this->assertSame( '', $output, 'Core renders no update row for these users either.' );
+	}
+
+	/**
+	 * @testdox Row notices are skipped on a multisite sub-site, where Core renders no update rows.
+	 */
+	public function test_row_notices_are_skipped_on_a_multisite_sub_site(): void {
+		if ( ! is_multisite() ) {
+			$this->markTestSkipped( 'Sub-site Plugins screens only exist on multisite.' );
+		}
+
+		$this->prepare_plugins_screen();
+		delete_site_transient( 'update_plugins' );
+		$this->set_subscriptions( array() );
+
+		$this->assertFalse( is_network_admin(), 'Without a network screen this is a sub-site request.' );
+		$this->assertSame( '', $this->render_connect_notice( $this->woo_plugin_file, $this->woo_plugin_data() ) );
+		$this->assertSame( '', $this->render_subscription_notice( $this->woo_plugin_file, $this->woo_plugin_data() ) );
+	}
+
+	/**
+	 * @testdox Connect notice is skipped on the Woo Update Manager row.
+	 */
+	public function test_connect_notice_is_skipped_for_the_woo_update_manager(): void {
+		$this->prepare_plugins_screen();
+		delete_site_transient( 'update_plugins' );
+
+		$output = $this->render_connect_notice(
+			WC_Woo_Update_Manager_Plugin::WOO_UPDATE_MANAGER_PLUGIN_MAIN_FILE,
+			array(
+				'Name'    => 'WooCommerce.com Update Manager',
+				'Version' => '1.0.3',
+				'Woo'     => '18734003407318:abcdef',
+			)
+		);
+
+		$this->assertSame( '', $output, 'The Update Manager delivers these updates, so it gets no prompt of its own.' );
+	}
+
+	/**
+	 * @testdox Purchase notice links to the product page when the connected account holds no subscription.
+	 */
+	public function test_purchase_notice_renders_without_a_subscription(): void {
+		$this->prepare_plugins_screen();
+		$this->set_subscriptions( array() );
+
+		$transient                                      = new stdClass();
+		$transient->response                            = array();
+		$transient->no_update                           = array();
+		$transient->no_update[ $this->woo_plugin_file ] = (object) array(
+			'id'  => 'woocommerce-com-123',
+			'url' => 'https://woocommerce.com/products/test-woo-extension/',
+		);
+		set_site_transient( 'update_plugins', $transient );
+
+		$output = $this->render_subscription_notice( $this->woo_plugin_file, $this->woo_plugin_data() );
+
+		$this->assertStringContainsString( 'woo-subscription-notice', $output, 'The notice row should be rendered.' );
+		$this->assertStringContainsString( 'products/test-woo-extension', $output, 'The link should point at the product page.' );
+		$this->assertStringContainsString( 'utm_campaign=pu_plugin_row_purchase', $output, 'The link should carry the campaign parameters.' );
+		$this->assertStringContainsString( 'woocommerce-purchase-subscription', $output, 'The link should carry the tracked class.' );
+		$this->assertStringNotContainsString( 'target=', $output, 'Links in the notices stay in the admin tab.' );
+		$this->assertStringContainsString( '>Subscribe</a> now for security updates, product improvements, and support.', $output );
+	}
+
+	/**
+	 * @testdox Purchase notice falls back to the cart when the update data carries no product URL.
+	 */
+	public function test_purchase_notice_falls_back_to_the_cart_without_a_product_url(): void {
+		$this->prepare_plugins_screen();
+		delete_site_transient( 'update_plugins' );
+		$this->set_subscriptions( array() );
+
+		$output = $this->render_subscription_notice( $this->woo_plugin_file, $this->woo_plugin_data() );
+
+		$this->assertStringContainsString( 'add-to-cart=123', $output, 'The link should add the product to the WooCommerce.com cart.' );
+	}
+
+	/**
+	 * @testdox Subscription notice is skipped while the last subscriptions fetch is still failing.
+	 */
+	public function test_subscription_notice_is_skipped_while_the_api_is_failing(): void {
+		$this->prepare_plugins_screen();
+		delete_site_transient( 'update_plugins' );
+		$this->set_subscriptions( array() );
+		set_transient(
+			'_woocommerce_helper_subscriptions_api_error',
+			array(
+				'code'    => 500,
+				'message' => 'Server error',
+			),
+			HOUR_IN_SECONDS
+		);
+
+		$output = $this->render_subscription_notice( $this->woo_plugin_file, $this->woo_plugin_data() );
+
+		$this->assertSame( '', $output, 'An empty list after a failed fetch is not proof that no subscription exists.' );
+	}
+
+	/**
+	 * @testdox Update-row notices are skipped while the last subscriptions fetch is still failing.
+	 */
+	public function test_update_row_notices_are_skipped_while_the_api_is_failing(): void {
+		$this->prepare_plugins_screen();
+		$this->set_subscriptions( array() );
+		set_transient(
+			'_woocommerce_helper_subscriptions_api_error',
+			array(
+				'code'    => 500,
+				'message' => 'Server error',
+			),
+			HOUR_IN_SECONDS
+		);
+		$response = (object) array( 'id' => 'woocommerce-com-123' );
+
+		ob_start();
+		WC_Helper_Updater::display_notice_for_plugins_without_subscription( $this->woo_plugin_data(), $response );
+		WC_Helper_Updater::display_notice_for_expired_and_expiring_subscriptions( $this->woo_plugin_data(), $response );
+		$output = ob_get_clean();
+
+		$this->assertSame( '', $output, 'The update row must not claim a subscription is missing on a stale list.' );
+	}
+
+	/**
+	 * @testdox One plugin row reads the subscription list once.
+	 */
+	public function test_subscription_notice_reads_the_subscription_list_once(): void {
+		$this->prepare_plugins_screen();
+		$this->set_subscriptions( array( $this->subscription( array( 'expired' => true ) ) ) );
+
+		$reads   = 0;
+		$counter = function ( $pre ) use ( &$reads ) {
+			++$reads;
+			return $pre;
+		};
+
+		add_filter( 'pre_transient__woocommerce_helper_subscriptions', $counter );
+		try {
+			$output = $this->render_subscription_notice( $this->woo_plugin_file, $this->woo_plugin_data() );
+		} finally {
+			remove_filter( 'pre_transient__woocommerce_helper_subscriptions', $counter );
+		}
+
+		$this->assertStringContainsString( 'Your subscription for this extension has expired.', $output, 'The row still renders its notice.' );
+		$this->assertSame( 1, $reads, 'Filtering the whole list twice per row is wasted work on a screen full of extensions.' );
+	}
+
+	/**
+	 * @testdox Subscription notice is skipped for a subscription that needs no action.
+	 */
+	public function test_subscription_notice_is_skipped_for_a_healthy_subscription(): void {
+		$this->prepare_plugins_screen();
+		delete_site_transient( 'update_plugins' );
+		$this->set_subscriptions( array( $this->subscription() ) );
+
+		$output = $this->render_subscription_notice( $this->woo_plugin_file, $this->woo_plugin_data() );
+
+		$this->assertSame( '', $output, 'Nothing to say about a current subscription.' );
+	}
+
+	/**
+	 * @testdox Renewal notice renders when the subscription has expired.
+	 */
+	public function test_subscription_notice_renders_renewal_for_an_expired_subscription(): void {
+		$this->prepare_plugins_screen();
+		delete_site_transient( 'update_plugins' );
+		$this->set_subscriptions(
+			array(
+				$this->subscription(
+					array(
+						'expired'               => true,
+						'product_regular_price' => '&#36;59',
+					)
+				),
+			)
+		);
+
+		$output = $this->render_subscription_notice( $this->woo_plugin_file, $this->woo_plugin_data() );
+
+		$this->assertStringContainsString( 'Your subscription for this extension has expired.', $output );
+		$this->assertStringContainsString( '>Renew your subscription</a> for security updates, product improvements, and support.', $output );
+		// wp_kses() pads the currency entity, so a rendered price reads &#036;59 whatever the record held.
+		$this->assertStringNotContainsString( '&#036;59', $output, 'The price is no longer part of the message.' );
+		$this->assertStringContainsString( 'renew_product=123', $output, 'Renewal should renew the subscription, not buy a new one.' );
+		$this->assertStringContainsString( 'product_key=key', $output, 'The link should name the subscription being renewed.' );
+		$this->assertStringContainsString( 'order_id=456', $output, 'The link should name the order being renewed.' );
+		$this->assertStringNotContainsString( 'add-to-cart', $output );
+		$this->assertStringContainsString( 'woocommerce-renew-subscription', $output, 'The link should carry the tracked class.' );
+		$this->assertStringNotContainsString( 'target=', $output, 'Links in the notices stay in the admin tab.' );
+	}
+
+	/**
+	 * @testdox Renewal falls back to adding the product to the cart when the record cannot name the subscription.
+	 *
+	 * @dataProvider provider_records_missing_a_renewal_identifier
+	 *
+	 * @param array $overrides Fields to change on the expired subscription record.
+	 */
+	public function test_subscription_notice_falls_back_to_the_cart_without_renewal_identifiers( array $overrides ): void {
+		$this->prepare_plugins_screen();
+		delete_site_transient( 'update_plugins' );
+		$this->set_subscriptions( array( $this->subscription( array_merge( array( 'expired' => true ), $overrides ) ) ) );
+
+		$output = $this->render_subscription_notice( $this->woo_plugin_file, $this->woo_plugin_data() );
+
+		$this->assertStringContainsString( 'Your subscription for this extension has expired.', $output, 'The merchant still learns the subscription expired.' );
+		$this->assertStringContainsString( 'add-to-cart=123', $output, 'A link that buys the product beats one that renews nothing.' );
+		$this->assertStringNotContainsString( 'renew_product', $output );
+		$this->assertStringContainsString( 'utm_campaign=pu_plugin_row_renew', $output );
+	}
+
+	/**
+	 * @testdox Renewal accepts an order ID sent as a digit string.
+	 */
+	public function test_subscription_notice_renews_with_a_digit_string_order_id(): void {
+		$this->prepare_plugins_screen();
+		delete_site_transient( 'update_plugins' );
+		$this->set_subscriptions(
+			array(
+				$this->subscription(
+					array(
+						'expired'  => true,
+						'order_id' => '456',
+					)
+				),
+			)
+		);
+
+		$output = $this->render_subscription_notice( $this->woo_plugin_file, $this->woo_plugin_data() );
+
+		$this->assertStringContainsString( 'renew_product=123', $output );
+		$this->assertStringContainsString( 'order_id=456', $output );
+	}
+
+	/**
+	 * Expired records that cannot be renewed by reference.
+	 *
+	 * @return array[]
+	 */
+	public function provider_records_missing_a_renewal_identifier(): array {
+		return array(
+			'no product key'         => array( array( 'product_key' => '' ) ),
+			'product key is false'   => array( array( 'product_key' => false ) ),
+			'product key is a list'  => array( array( 'product_key' => array( 'key' ) ) ),
+			'no order ID'            => array( array( 'order_id' => '' ) ),
+			'order ID absent'        => array( array( 'order_id' => null ) ),
+			'order ID is zero'       => array( array( 'order_id' => 0 ) ),
+			'order ID is false'      => array( array( 'order_id' => false ) ),
+			'order ID is a list'     => array( array( 'order_id' => array( 456 ) ) ),
+			'order ID is not digits' => array( array( 'order_id' => '45a6' ) ),
+		);
+	}
+
+	/**
+	 * @testdox The update-row message renews the exact expired subscription too.
+	 */
+	public function test_update_row_notice_renews_the_expired_subscription(): void {
+		$this->prepare_plugins_screen();
+		$this->set_subscriptions( array( $this->subscription( array( 'expired' => true ) ) ) );
+
+		ob_start();
+		WC_Helper_Updater::display_notice_for_expired_and_expiring_subscriptions(
+			$this->woo_plugin_data(),
+			(object) array( 'id' => 'woocommerce-com-123' )
+		);
+		$output = ob_get_clean();
+
+		$this->assertStringContainsString( 'renew_product=123', $output );
+		$this->assertStringContainsString( 'product_key=key', $output );
+		$this->assertStringContainsString( 'order_id=456', $output );
+		$this->assertStringContainsString( 'utm_campaign=pu_plugin_screen_renew', $output );
+		$this->assertStringNotContainsString( 'add-to-cart', $output );
+	}
+
+	/**
+	 * @testdox Auto-renew notice renders for a subscription lapsing without auto-renew.
+	 */
+	public function test_subscription_notice_renders_autorenew_for_an_expiring_subscription(): void {
+		$this->prepare_plugins_screen();
+		delete_site_transient( 'update_plugins' );
+		$this->set_subscriptions(
+			array(
+				$this->subscription(
+					array(
+						'expiring'  => true,
+						'autorenew' => false,
+					)
+				),
+			)
+		);
+
+		$output = $this->render_subscription_notice( $this->woo_plugin_file, $this->woo_plugin_data() );
+
+		$this->assertStringContainsString( 'woocommerce-enable-autorenew', $output, 'The link should point at auto-renew.' );
+		$this->assertStringContainsString( 'my-subscriptions', $output, 'Auto-renew is enabled from the My Subscriptions page.' );
+	}
+
+	/**
+	 * @testdox Auto-renew notice is skipped when the lapsing subscription already auto-renews.
+	 */
+	public function test_subscription_notice_is_skipped_when_autorenew_is_on(): void {
+		$this->prepare_plugins_screen();
+		delete_site_transient( 'update_plugins' );
+		$this->set_subscriptions(
+			array(
+				$this->subscription(
+					array(
+						'expiring'  => true,
+						'autorenew' => true,
+					)
+				),
+			)
+		);
+
+		$output = $this->render_subscription_notice( $this->woo_plugin_file, $this->woo_plugin_data() );
+
+		$this->assertSame( '', $output, 'Auto-renew already covers this subscription.' );
+	}
+
+	/**
+	 * @testdox Renewal notice renders for an expired subscription attached to another store.
+	 */
+	public function test_subscription_notice_renders_renewal_for_a_subscription_expired_elsewhere(): void {
+		$this->prepare_plugins_screen();
+		$this->set_subscriptions(
+			array(
+				$this->subscription(
+					array(
+						'expired'     => true,
+						'connections' => array( 999 ),
+					)
+				),
+			)
+		);
+
+		$output = $this->render_subscription_notice( $this->woo_plugin_file, $this->woo_plugin_data() );
+
+		$this->assertStringContainsString( 'Your subscription for this extension has expired.', $output, 'Renewing can be started from any store.' );
+		$this->assertStringContainsString( 'renew_product=123', $output );
+	}
+
+	/**
+	 * @testdox Renewal notice is skipped while another subscription still covers the product.
+	 */
+	public function test_subscription_notice_is_skipped_when_another_subscription_is_active(): void {
+		$this->prepare_plugins_screen();
+		$this->set_subscriptions(
+			array(
+				$this->subscription( array( 'expired' => true ) ),
+				$this->subscription( array( 'connections' => array( 45 ) ) ),
+			)
+		);
+
+		$output = $this->render_subscription_notice( $this->woo_plugin_file, $this->woo_plugin_data() );
+
+		$this->assertSame( '', $output, 'The product is still covered, so the expired one needs no action.' );
+	}
+
+	/**
+	 * @testdox A lifetime subscription keeps the product covered.
+	 */
+	public function test_subscription_notice_is_skipped_for_a_lifetime_subscription(): void {
+		$this->prepare_plugins_screen();
+		$this->set_subscriptions(
+			array(
+				$this->subscription(
+					array(
+						'expired'  => true,
+						'lifetime' => true,
+					)
+				),
+			)
+		);
+
+		$output = $this->render_subscription_notice( $this->woo_plugin_file, $this->woo_plugin_data() );
+
+		$this->assertSame( '', $output, 'A lifetime subscription never lapses.' );
+	}
+
+	/**
+	 * @testdox Auto-renew notice is skipped while another subscription outlives the lapsing one.
+	 */
+	public function test_subscription_notice_is_skipped_when_another_subscription_outlives_the_lapsing_one(): void {
+		$this->prepare_plugins_screen();
+		$this->set_subscriptions(
+			array(
+				$this->subscription(
+					array(
+						'expiring'  => true,
+						'autorenew' => false,
+					)
+				),
+				$this->subscription( array( 'connections' => array( 45 ) ) ),
+			)
+		);
+
+		$output = $this->render_subscription_notice( $this->woo_plugin_file, $this->woo_plugin_data() );
+
+		$this->assertSame( '', $output, 'Coverage continues after the lapsing subscription ends.' );
+	}
+
+	/**
+	 * @testdox Auto-renew notice renders when every subscription is lapsing.
+	 */
+	public function test_subscription_notice_renders_autorenew_when_every_subscription_is_lapsing(): void {
+		$this->prepare_plugins_screen();
+		update_option( 'date_format', 'F j, Y' );
+		$this->set_subscriptions(
+			array(
+				$this->subscription(
+					array(
+						'expiring'  => true,
+						'autorenew' => false,
+						'expires'   => strtotime( '2030-03-03' ),
+					)
+				),
+				$this->subscription(
+					array(
+						'expiring'    => true,
+						'autorenew'   => false,
+						'connections' => array( 999 ),
+					)
+				),
+			)
+		);
+
+		$output = $this->render_subscription_notice( $this->woo_plugin_file, $this->woo_plugin_data() );
+
+		$this->assertStringContainsString( 'Your subscription for this extension expires on March 3, 2030.', $output );
+		$this->assertStringContainsString( 'woocommerce-enable-autorenew', $output );
+	}
+
+	/**
+	 * @testdox The update-row message renews a subscription attached to another store too.
+	 */
+	public function test_update_row_notice_renews_a_subscription_expired_elsewhere(): void {
+		$this->prepare_plugins_screen();
+		$this->set_subscriptions(
+			array(
+				$this->subscription(
+					array(
+						'expired'     => true,
+						'connections' => array( 999 ),
+					)
+				),
+			)
+		);
+
+		ob_start();
+		WC_Helper_Updater::display_notice_for_expired_and_expiring_subscriptions(
+			$this->woo_plugin_data(),
+			(object) array( 'id' => 'woocommerce-com-123' )
+		);
+		$output = ob_get_clean();
+
+		$this->assertStringContainsString( 'Your subscription for this extension has expired.', $output );
+	}
+
+	/**
+	 * @testdox The expiry date follows the store's date format and time zone.
+	 */
+	public function test_expiry_date_uses_the_store_date_format_and_time_zone(): void {
+		$this->prepare_plugins_screen();
+		update_option( 'date_format', 'j F Y' );
+		update_option( 'timezone_string', 'Pacific/Auckland' );
+		$this->set_subscriptions(
+			array(
+				$this->subscription(
+					array(
+						'expiring'  => true,
+						'autorenew' => false,
+						// Late on 3 March in UTC is already 4 March in Auckland.
+						'expires'   => strtotime( '2030-03-03 23:30:00 UTC' ),
+					)
+				),
+			)
+		);
+
+		$output = $this->render_subscription_notice( $this->woo_plugin_file, $this->woo_plugin_data() );
+
+		$this->assertStringContainsString( 'expires on 4 March 2030.', $output, 'The date is the store\'s, in the store\'s format.' );
+	}
+
+	/**
+	 * @testdox Auto-renew notice drops the date, not the prompt, for a record with no usable expiry.
+	 *
+	 * @dataProvider provider_unusable_expiry_values
+	 *
+	 * @param mixed $expires The expiry value on the subscription record.
+	 */
+	public function test_subscription_notice_omits_the_date_without_a_usable_expiry( $expires ): void {
+		$this->prepare_plugins_screen();
+		$this->set_subscriptions(
+			array(
+				$this->subscription(
+					array(
+						'expiring'  => true,
+						'autorenew' => false,
+						'expires'   => $expires,
+					)
+				),
+			)
+		);
+
+		$output = $this->render_subscription_notice( $this->woo_plugin_file, $this->woo_plugin_data() );
+
+		$this->assertStringContainsString( 'Your subscription for this extension expires soon.', $output, 'Auto-renew still needs turning on.' );
+		$this->assertStringContainsString( '>Enable auto-renew</a> to keep getting updates and support.', $output );
+		$this->assertStringContainsString( 'woocommerce-enable-autorenew', $output, 'The link keeps its tracked class.' );
+		$this->assertStringNotContainsString( 'expires on', $output, 'A date that is not known must not be named.' );
+	}
+
+	/**
+	 * Expiry values that name no day.
+	 *
+	 * @return array[]
+	 */
+	public function provider_unusable_expiry_values(): array {
+		return array(
+			'absent'      => array( null ),
+			'empty'       => array( '' ),
+			'not a date'  => array( 'not a timestamp' ),
+			'is an array' => array( array( 123 ) ),
+			'is a bool'   => array( true ),
+		);
+	}
+
+	/**
+	 * @testdox A record carrying only the guaranteed fields reads no field that is missing.
+	 *
+	 * filter_valid_subscriptions() guarantees product_id and connections and nothing else, and
+	 * phpunit.xml converts warnings into failures, so reading a missing field fails this test.
+	 *
+	 * @dataProvider provider_sparse_subscription_records
+	 *
+	 * @param array  $subscription Subscription record holding only the fields it names.
+	 * @param string $expected     Text both placements must contain, or an empty string when
+	 *                             neither has anything to say.
+	 */
+	public function test_sparse_subscription_record_reads_no_missing_field( array $subscription, string $expected ): void {
+		$this->prepare_plugins_screen();
+		$this->set_subscriptions( array( $subscription ) );
+		$response = (object) array( 'id' => 'woocommerce-com-123' );
+
+		$plugin_row = $this->render_subscription_notice( $this->woo_plugin_file, $this->woo_plugin_data() );
+
+		ob_start();
+		WC_Helper_Updater::display_notice_for_expired_and_expiring_subscriptions( $this->woo_plugin_data(), $response );
+		WC_Helper_Updater::display_notice_for_plugins_without_subscription( $this->woo_plugin_data(), $response );
+		$update_row = ob_get_clean();
+
+		if ( '' === $expected ) {
+			$this->assertSame( '', $plugin_row, 'Nothing about this subscription is known to need attention.' );
+			$this->assertSame( '', $update_row );
+			return;
+		}
+
+		$this->assertStringContainsString( $expected, $plugin_row );
+		$this->assertStringContainsString( $expected, $update_row, 'Both placements read the same record.' );
+	}
+
+	/**
+	 * Records holding only what filter_valid_subscriptions() guarantees, plus the one field that
+	 * drives each message. Everything the message code reads beyond that is absent: lifetime,
+	 * autorenew, expires, product_key and order_id.
+	 *
+	 * @return array[]
+	 */
+	public function provider_sparse_subscription_records(): array {
+		return array(
+			'expired, with no lifetime, product key or order ID' => array(
+				array(
+					'product_id'  => 123,
+					'connections' => array(),
+					'expired'     => true,
+				),
+				'Your subscription for this extension has expired.',
+			),
+			'lapsing, with no auto-renew flag or expiry' => array(
+				array(
+					'product_id'  => 123,
+					'connections' => array(),
+					'expiring'    => true,
+				),
+				'Your subscription for this extension expires soon.',
+			),
+			'nothing beyond the guaranteed fields'       => array(
+				array(
+					'product_id'  => 123,
+					'connections' => array(),
+				),
+				'',
+			),
+		);
+	}
+
+	/**
+	 * A subscription record as the WooCommerce.com API returns it.
+	 *
+	 * @param array $overrides Fields to override on the default record.
+	 * @return array
+	 */
+	private function subscription( array $overrides = array() ): array {
+		return array_merge(
+			array(
+				'product_id'  => 123,
+				'product_key' => 'key',
+				'order_id'    => 456,
+				'expired'     => false,
+				'expiring'    => false,
+				'lifetime'    => false,
+				'autorenew'   => true,
+				'expires'     => time() + DAY_IN_SECONDS,
+				'connections' => array(),
+			),
+			$overrides
+		);
+	}
+
+	/**
+	 * Stand in a subscriptions payload, which WC_Helper reads from its transient.
+	 *
+	 * @param array $subscriptions Subscription records.
+	 * @return void
+	 */
+	private function set_subscriptions( array $subscriptions ): void {
+		WC_Helper_Options::update( 'auth', array( 'site_id' => 45 ) );
+		set_transient( '_woocommerce_helper_subscriptions', $subscriptions, HOUR_IN_SECONDS );
+	}
+
+	/**
+	 * @testdox Subscription notice is skipped when Core already renders an update row for the plugin.
+	 */
+	public function test_subscription_notice_is_skipped_when_core_renders_an_update_row(): void {
+		$this->prepare_plugins_screen();
+		$this->set_subscriptions( array() );
+
+		$transient                                     = new stdClass();
+		$transient->response                           = array();
+		$transient->response[ $this->woo_plugin_file ] = (object) array(
+			'id'          => 'woocommerce-com-123',
+			'new_version' => '2.0.0',
+			'package'     => '',
+		);
+		set_site_transient( 'update_plugins', $transient );
+
+		$output = $this->render_subscription_notice( $this->woo_plugin_file, $this->woo_plugin_data() );
+
+		$this->assertSame( '', $output, 'display_notice_for_plugins_without_subscription() covers that row.' );
+	}
+
+	/**
+	 * @testdox Update-row notices ignore a response whose ID is not one WooCommerce wrote.
+	 *
+	 * @dataProvider provider_foreign_update_response_ids
+	 *
+	 * @param mixed $id The response ID, or null for a response without one.
+	 */
+	public function test_update_row_notices_ignore_foreign_response_ids( $id ): void {
+		$this->prepare_plugins_screen();
+		$this->set_subscriptions( array( $this->subscription( array( 'expired' => true ) ) ) );
+
+		$response = is_null( $id ) ? new stdClass() : (object) array( 'id' => $id );
+
+		ob_start();
+		WC_Helper_Updater::display_notice_for_expired_and_expiring_subscriptions( $this->woo_plugin_data(), $response );
+		WC_Helper_Updater::display_notice_for_plugins_without_subscription( $this->woo_plugin_data(), $response );
+		$output = ob_get_clean();
+
+		$this->assertSame( '', $output, 'Digits must not be scraped out of an ID that is not ours.' );
+	}
+
+	/**
+	 * Response IDs that must not resolve to product 123.
+	 *
+	 * @return array[]
+	 */
+	public function provider_foreign_update_response_ids(): array {
+		return array(
+			'missing'                => array( null ),
+			'empty'                  => array( '' ),
+			'not a string'           => array( 123 ),
+			'WordPress.org plugin'   => array( 'w.org/plugins/some-plugin' ),
+			'digits in the wrong id' => array( 'woocommerce-com-12foo3' ),
+			'prefix only'            => array( 'woocommerce-com-' ),
+			'trailing characters'    => array( 'woocommerce-com-123-beta' ),
+		);
+	}
+
+	/**
+	 * @testdox The no-subscription update-row notice renders for an ID WooCommerce wrote.
+	 */
+	public function test_update_row_notice_renders_for_a_product_without_a_subscription(): void {
+		$this->prepare_plugins_screen();
+		$this->set_subscriptions( array() );
+
+		ob_start();
+		WC_Helper_Updater::display_notice_for_plugins_without_subscription(
+			$this->woo_plugin_data(),
+			(object) array( 'id' => 'woocommerce-com-123' )
+		);
+		$output = ob_get_clean();
+
+		$this->assertStringContainsString( 'add-to-cart=123', $output );
+		$this->assertStringContainsString( 'woocommerce-purchase-subscription', $output );
+		$this->assertStringContainsString( 'utm_campaign=pu_plugin_screen_purchase', $output );
+	}
+
+	/**
+	 * @testdox The no-subscription update-row notice says the same thing as the plugin-row notice.
+	 */
+	public function test_update_row_purchase_notice_shares_the_plugin_row_wording(): void {
+		$this->prepare_plugins_screen();
+		$this->set_subscriptions( array() );
+
+		ob_start();
+		WC_Helper_Updater::display_notice_for_plugins_without_subscription(
+			$this->woo_plugin_data(),
+			(object) array(
+				'id'  => 'woocommerce-com-123',
+				'url' => 'https://woocommerce.com/products/test-woo-extension/',
+			)
+		);
+		$output = ob_get_clean();
+
+		$this->assertStringStartsWith( ' ', $output, 'Core has already printed its own sentence on the row.' );
+		$this->assertStringContainsString( "You don't have an active subscription for this product.", $output );
+		$this->assertStringContainsString( '>Subscribe</a> now for security updates, product improvements, and support.', $output );
+		$this->assertStringContainsString( 'products/test-woo-extension', $output, 'The update row links to the product page like the plugin row does.' );
+		$this->assertStringNotContainsString( 'add-to-cart', $output );
+	}
+
+	/**
+	 * @testdox The expired update-row notice says the same thing as the plugin-row notice.
+	 */
+	public function test_update_row_expired_notice_shares_the_plugin_row_wording(): void {
+		$this->prepare_plugins_screen();
+		$this->set_subscriptions(
+			array(
+				$this->subscription(
+					array(
+						'expired'               => true,
+						'product_regular_price' => '&#36;59',
+					)
+				),
+			)
+		);
+
+		ob_start();
+		WC_Helper_Updater::display_notice_for_expired_and_expiring_subscriptions(
+			$this->woo_plugin_data(),
+			(object) array( 'id' => 'woocommerce-com-123' )
+		);
+		$output = ob_get_clean();
+
+		$this->assertStringStartsWith( ' ', $output, 'Core has already printed its own sentence on the row.' );
+		$this->assertStringContainsString( 'Your subscription for this extension has expired.', $output );
+		$this->assertStringContainsString( '>Renew your subscription</a> for security updates, product improvements, and support.', $output );
+		// wp_kses() pads the currency entity, so a rendered price reads &#036;59 whatever the record held.
+		$this->assertStringNotContainsString( '&#036;59', $output, 'The price no longer appears in the link text.' );
+	}
+
+	/**
+	 * @testdox The expiring update-row notice says the same thing as the plugin-row notice.
+	 */
+	public function test_update_row_expiring_notice_shares_the_plugin_row_wording(): void {
+		$this->prepare_plugins_screen();
+		update_option( 'date_format', 'F j, Y' );
+		$this->set_subscriptions(
+			array(
+				$this->subscription(
+					array(
+						'expiring'  => true,
+						'autorenew' => false,
+						'expires'   => strtotime( '2030-03-03' ),
+					)
+				),
+			)
+		);
+
+		ob_start();
+		WC_Helper_Updater::display_notice_for_expired_and_expiring_subscriptions(
+			$this->woo_plugin_data(),
+			(object) array( 'id' => 'woocommerce-com-123' )
+		);
+		$output = ob_get_clean();
+
+		$this->assertStringContainsString( 'Your subscription for this extension expires on March 3, 2030.', $output );
+		$this->assertStringContainsString( '>Enable auto-renew</a> to keep getting updates and support.', $output );
+		$this->assertStringContainsString( 'utm_campaign=pu_plugin_screen_enable_autorenew', $output );
+	}
+
+	/**
+	 * @testdox The connect update-row notice says the same thing as the plugin-row notice.
+	 */
+	public function test_update_row_connect_notice_shares_the_plugin_row_wording(): void {
+		ob_start();
+		WC_Helper_Updater::add_connect_woocom_plugin_message();
+		$output = ob_get_clean();
+
+		$this->assertStringStartsWith( ' Extension distributed via WooCommerce.com.', $output );
+		$this->assertStringContainsString( '>Connect your store</a> for security updates, product improvements, and support.', $output );
+		$this->assertStringContainsString( 'utm_campaign=pu_plugin_screen_connect', $output );
+		$this->assertStringContainsString( 'tab=my-subscriptions', $output );
+	}
+
+	/**
+	 * Capture what the subscription notice callback prints for a plugin row.
+	 *
+	 * @param string $plugin_file Path to the plugin file relative to the plugins directory.
+	 * @param array  $plugin_data An array of plugin metadata.
+	 * @return string
+	 */
+	private function render_subscription_notice( string $plugin_file, array $plugin_data ): string {
+		ob_start();
+		WC_Helper_Updater::display_subscription_notice_for_woo_plugins( $plugin_file, $plugin_data );
+
+		return ob_get_clean();
+	}
+
+	/**
+	 * Plugin metadata for the WooCommerce.com hosted plugin used by these tests.
+	 *
+	 * @return array
+	 */
+	private function woo_plugin_data(): array {
+		return array(
+			'Name'    => 'Test Woo Extension',
+			'Version' => '1.0.0',
+			'Woo'     => '123:abcdef',
+		);
+	}
+
+	/**
+	 * Set up the plugins screen state that the after_plugin_row callback reads from.
+	 *
+	 * get_plugins() returns whatever is in the 'plugins' cache group, which lets these tests
+	 * stand in a plugin list without touching the filesystem.
+	 *
+	 * @return void
+	 */
+	private function prepare_plugins_screen(): void {
+		require_once ABSPATH . 'wp-admin/includes/plugin.php';
+
+		$admin_id = self::factory()->user->create( array( 'role' => 'administrator' ) );
+		$admin    = new WP_User( $admin_id );
+		$admin->add_cap( 'update_plugins' );
+		wp_set_current_user( $admin_id );
+
+		wp_cache_set(
+			'plugins',
+			array(
+				'' => array(
+					$this->woo_plugin_file => $this->woo_plugin_data(),
+					'some-other-plugin/some-other-plugin.php' => array(
+						'Name'    => 'Some Other Plugin',
+						'Version' => '1.0.0',
+						'Woo'     => '',
+					),
+				),
+			),
+			'plugins'
+		);
+
+		$list_table = $this->getMockBuilder( stdClass::class )
+			->addMethods( array( 'get_column_count' ) )
+			->getMock();
+		$list_table->method( 'get_column_count' )->willReturn( 4 );
+
+		// phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Stands in for the plugins screen list table; cleanup_plugins_screen() unsets it.
+		$GLOBALS['wp_list_table'] = $list_table;
+	}
+
+	/**
+	 * Capture what the after_plugin_row callback prints for a plugin row.
+	 *
+	 * @param string $plugin_file Path to the plugin file relative to the plugins directory.
+	 * @param array  $plugin_data An array of plugin metadata.
+	 * @return string
+	 */
+	private function render_connect_notice( string $plugin_file, array $plugin_data ): string {
+		ob_start();
+		WC_Helper_Updater::display_connect_notice_for_woo_plugins( $plugin_file, $plugin_data );
+
+		return ob_get_clean();
+	}
+
+	/**
+	 * Clean up the plugins screen state set up by prepare_plugins_screen().
+	 *
+	 * @return void
+	 */
+	private function cleanup_plugins_screen(): void {
+		WC_Helper_Options::update( 'auth', array() );
+		delete_transient( '_woocommerce_helper_subscriptions' );
+		delete_transient( '_woocommerce_helper_subscriptions_api_error' );
+		wp_cache_delete( 'plugins', 'plugins' );
+		delete_site_transient( 'update_plugins' );
+		unset( $GLOBALS['wp_list_table'] );
+		wp_set_current_user( 0 );
+	}
+
 	/**
 	 * @testdox A forced auto-update flag reaches the plugin update item only when the package can be installed.
 	 * @testWith [true, "https://woocommerce.com/package.zip", true]