diff --git a/includes/plugins/woocommerce-subscriptions/class-card-expiry-warning.php b/includes/plugins/woocommerce-subscriptions/class-card-expiry-warning.php
new file mode 100644
index 0000000000..4432cefa67
--- /dev/null
+++ b/includes/plugins/woocommerce-subscriptions/class-card-expiry-warning.php
@@ -0,0 +1,305 @@
+ self::EMAIL_TYPE,
+ 'category' => 'reader-revenue',
+ 'label' => __( 'Card expiry warning', 'newspack-plugin' ),
+ 'description' => __( "Email sent when a reader's saved payment method is about to expire.", 'newspack-plugin' ),
+ 'template' => dirname( NEWSPACK_PLUGIN_FILE ) . '/includes/templates/reader-revenue-emails/card-expiry-warning.php',
+ 'editor_notice' => __( 'This email will be sent to readers when their saved credit card is about to expire on an active subscription.', 'newspack-plugin' ),
+ 'from_email' => Reader_Revenue_Emails::get_from_email(),
+ 'available_placeholders' => [
+ [
+ 'label' => __( 'the customer billing first name', 'newspack-plugin' ),
+ 'template' => '*BILLING_FIRST_NAME*',
+ ],
+ [
+ 'label' => __( 'the last four digits of the expiring card', 'newspack-plugin' ),
+ 'template' => '*CARD_LAST_4*',
+ ],
+ [
+ 'label' => __( 'the card expiry date (MM/YYYY)', 'newspack-plugin' ),
+ 'template' => '*EXPIRY_DATE*',
+ ],
+ [
+ 'label' => __( 'the next renewal date', 'newspack-plugin' ),
+ 'template' => '*RENEWAL_DATE*',
+ ],
+ [
+ 'label' => __( 'link to update payment method', 'newspack-plugin' ),
+ 'template' => '*UPDATE_PAYMENT_URL*',
+ ],
+ [
+ 'label' => __(
+ 'the contact email to your site (same as the "From" email address)',
+ 'newspack-plugin'
+ ),
+ 'template' => '*CONTACT_EMAIL*',
+ ],
+ [
+ 'label' => __( 'the site title', 'newspack-plugin' ),
+ 'template' => '*SITE_TITLE*',
+ ],
+ [
+ 'label' => __( 'the site url', 'newspack-plugin' ),
+ 'template' => '*SITE_URL*',
+ ],
+ ],
+ ];
+ return $configs;
+ }
+
+ /**
+ * Schedule the daily cron event if not already scheduled.
+ */
+ public static function schedule_cron() {
+ if ( ! wp_next_scheduled( self::CRON_HOOK ) ) {
+ wp_schedule_event( time(), 'daily', self::CRON_HOOK );
+ }
+ }
+
+ /**
+ * Scan for expiring credit cards and send warning emails.
+ *
+ * Token-first approach: query CC tokens expiring within the warning window,
+ * then find active subscriptions using each token via WCS_Payment_Tokens.
+ */
+ public static function scan_expiring_cards() {
+ if ( ! Emails::can_send_email( self::EMAIL_TYPE ) ) {
+ return;
+ }
+ $days = self::get_days_before_expiry();
+
+ // 1. Find CC tokens expiring within the warning window.
+ $expiring_tokens = self::get_expiring_cc_tokens( $days );
+ if ( empty( $expiring_tokens ) ) {
+ return;
+ }
+
+ // 2. For each expiring token, find active subscriptions using it.
+ if ( ! class_exists( 'WCS_Payment_Tokens' ) ) {
+ return;
+ }
+ foreach ( $expiring_tokens as $token ) {
+ $subscription_ids = \WCS_Payment_Tokens::get_subscriptions_from_token( $token );
+ foreach ( $subscription_ids as $subscription_id ) {
+ $subscription = WooCommerce_Subscriptions::sanitize_subscription( $subscription_id );
+ if ( ! $subscription || 'active' !== $subscription->get_status() ) {
+ continue;
+ }
+ self::maybe_send_warning( $subscription, $token );
+ }
+ }
+ }
+
+ /**
+ * Find CC tokens expiring within the given number of days.
+ *
+ * Direct DB query on woocommerce_payment_tokenmeta joined to
+ * woocommerce_payment_tokens to filter at the DB level.
+ *
+ * @param int $days Number of days in the warning window.
+ * @return \WC_Payment_Token_CC[] Array of expiring CC token objects.
+ */
+ private static function get_expiring_cc_tokens( int $days ): array {
+ global $wpdb;
+
+ $today = gmdate( 'Y-m-d' );
+ $cutoff = gmdate( 'Y-m-d', time() + $days * DAY_IN_SECONDS );
+
+ // A card with expiry MM/YYYY is valid through the last day of that month.
+ // Find tokens whose last-valid-day falls between today and $cutoff.
+ // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
+ $token_ids = $wpdb->get_col(
+ $wpdb->prepare(
+ "SELECT t.token_id
+ FROM {$wpdb->prefix}woocommerce_payment_tokens t
+ INNER JOIN {$wpdb->prefix}woocommerce_payment_tokenmeta em
+ ON em.payment_token_id = t.token_id AND em.meta_key = 'expiry_month'
+ INNER JOIN {$wpdb->prefix}woocommerce_payment_tokenmeta ey
+ ON ey.payment_token_id = t.token_id AND ey.meta_key = 'expiry_year'
+ WHERE t.type = 'CC'
+ AND LAST_DAY(
+ STR_TO_DATE(
+ CONCAT(ey.meta_value, '-', em.meta_value, '-01'),
+ '%%Y-%%m-%%d'
+ )
+ ) BETWEEN %s AND %s",
+ $today,
+ $cutoff
+ )
+ );
+ // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
+
+ if ( empty( $token_ids ) ) {
+ return [];
+ }
+
+ $tokens = [];
+ foreach ( $token_ids as $token_id ) {
+ $token = \WC_Payment_Tokens::get( (int) $token_id );
+ if ( $token instanceof \WC_Payment_Token_CC ) {
+ $tokens[] = $token;
+ }
+ }
+ return $tokens;
+ }
+
+ /**
+ * Send the expiry warning for a subscription if not already sent.
+ *
+ * Uses per-subscription meta for idempotency: the meta value encodes
+ * the token ID and expiry date, so it auto-invalidates when the
+ * payment method changes or for a new expiry cycle.
+ *
+ * @param \WC_Subscription $subscription The subscription.
+ * @param \WC_Payment_Token_CC $token The expiring CC token.
+ */
+ private static function maybe_send_warning( $subscription, $token ) {
+ $expiry_key = $token->get_id() . ':'
+ . $token->get_expiry_month() . '/' . $token->get_expiry_year();
+
+ // Idempotency: skip if we already sent for this token+expiry combo.
+ if ( $subscription->get_meta( self::SENT_META, true ) === $expiry_key ) {
+ return;
+ }
+
+ $customer = $subscription->get_user();
+ if ( ! $customer ) {
+ return;
+ }
+
+ $update_url = \wc_get_account_endpoint_url( 'payment-methods' );
+ $next_payment = $subscription->get_date( 'next_payment' );
+ $renewal_date = $next_payment
+ ? date_i18n( get_option( 'date_format', 'F j, Y' ), $subscription->get_time( 'next_payment' ) )
+ : __( 'your next renewal', 'newspack-plugin' );
+
+ $first_name = $subscription->get_billing_first_name();
+ if ( '' === $first_name ) {
+ $first_name = $customer->first_name;
+ }
+
+ $placeholders = [
+ [
+ 'template' => '*BILLING_FIRST_NAME*',
+ 'value' => esc_html( $first_name ),
+ ],
+ [
+ 'template' => '*CARD_LAST_4*',
+ 'value' => esc_html( $token->get_last4() ),
+ ],
+ [
+ 'template' => '*EXPIRY_DATE*',
+ 'value' => esc_html( $token->get_expiry_month() . '/' . $token->get_expiry_year() ),
+ ],
+ [
+ 'template' => '*RENEWAL_DATE*',
+ 'value' => esc_html( $renewal_date ),
+ ],
+ [
+ 'template' => '*UPDATE_PAYMENT_URL*',
+ 'value' => esc_url( $update_url ),
+ ],
+ ];
+
+ $sent = Emails::send_email(
+ self::EMAIL_TYPE,
+ $subscription->get_billing_email(),
+ $placeholders
+ );
+
+ if ( $sent ) {
+ $subscription->update_meta_data( self::SENT_META, $expiry_key );
+ $subscription->save();
+ }
+ }
+
+ /**
+ * Clear the sent flag when the payment method is updated on a subscription.
+ *
+ * Hooked to 'woocommerce_subscription_payment_method_updated'.
+ *
+ * @param \WC_Subscription $subscription The subscription.
+ */
+ public static function clear_sent_flag( $subscription ) {
+ $subscription->delete_meta_data( self::SENT_META );
+ $subscription->save();
+ }
+}
diff --git a/includes/plugins/woocommerce-subscriptions/class-woocommerce-subscriptions.php b/includes/plugins/woocommerce-subscriptions/class-woocommerce-subscriptions.php
index 7d06aadc82..6a3c674a34 100644
--- a/includes/plugins/woocommerce-subscriptions/class-woocommerce-subscriptions.php
+++ b/includes/plugins/woocommerce-subscriptions/class-woocommerce-subscriptions.php
@@ -88,11 +88,13 @@ public static function woocommerce_subscriptions_integration_init() {
include_once __DIR__ . '/class-subscriptions-meta.php';
include_once __DIR__ . '/class-subscriptions-confirmation.php';
include_once __DIR__ . '/class-subscriptions-tiers.php';
+ include_once __DIR__ . '/class-card-expiry-warning.php';
On_Hold_Duration::init();
Renewal::init();
Subscriptions_Meta::init();
Subscriptions_Confirmation::init();
+ Card_Expiry_Warning::init();
}
diff --git a/includes/templates/reader-revenue-emails/card-expiry-warning.php b/includes/templates/reader-revenue-emails/card-expiry-warning.php
new file mode 100644
index 0000000000..57d6526e7a
--- /dev/null
+++ b/includes/templates/reader-revenue-emails/card-expiry-warning.php
@@ -0,0 +1,308 @@
+ $primary_color,
+ 'primary_text_color' => $primary_text_color,
+] = newspack_get_theme_colors();
+
+[
+ 'block_markup' => $social_links,
+ 'html_markup' => $social_links_html
+] = newspack_get_social_markup( $primary_text_color );
+
+$post_title = __( 'Update your payment method', 'newspack-plugin' );
+
+$post_content =
+ // Main body.
+ '
+
+
+
+
+
+
' . $post_title . '
+
+
+
+
' . sprintf(
+ /* translators: 1: billing first name. 2: last 4 digits of card. 3: expiry date. 4: site title. 5: renewal date. */
+ __( 'Hi %1$s, the card ending in %2$s (expires %3$s) is the payment method for your subscription to %4$s. Your next renewal is on %5$s.', 'newspack-plugin' ),
+ '*BILLING_FIRST_NAME*',
+ '*CARD_LAST_4*',
+ '*EXPIRY_DATE*',
+ '*SITE_TITLE*',
+ '*RENEWAL_DATE*'
+ ) . '
+
+
+
+
' . __( 'To avoid any interruption, please update your payment method before your card expires.', 'newspack-plugin' ) . '
+
+
+
+
+
+
+
+
' . __( "If you've already updated your payment method, you can disregard this email.", 'newspack-plugin' ) . '
+
+
+
+
' . sprintf(
+ /* translators: %s: site admin email address. */
+ __( 'Questions? Contact us at %s.', 'newspack-plugin' ),
+ '*CONTACT_EMAIL*'
+ ) . '
+
+
+
+ ' .
+
+ // Footer.
+ '
+ ' .
+
+ $social_links .
+
+ '
+
*SITE_CONTACT*
' . sprintf(
+ /* translators: %s: link to site url. */
+ __( 'You received this email because you have an active subscription to %s', 'newspack-plugin' ),
+ '*SITE_URL*'
+ ) . '
+
+
+
+ ';
+
+$email_html = '
+
+
+
+ ' . esc_html( $post_title ) . '
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ' . esc_html( $post_title ) . ' |
|
|
+
+ ' . sprintf(
+ /* translators: 1: billing first name. 2: last 4 digits of card. 3: expiry date. 4: site title. 5: renewal date. */
+ __( 'Hi %1$s, the card ending in %2$s (expires %3$s) is the payment method for your subscription to %4$s. Your next renewal is on %5$s.', 'newspack-plugin' ),
+ '*BILLING_FIRST_NAME*',
+ '*CARD_LAST_4*',
+ '*EXPIRY_DATE*',
+ '*SITE_TITLE*',
+ '*RENEWAL_DATE*'
+ ) . ' |
|
|
+
+ ' . __( 'To avoid any interruption, please update your payment method before your card expires.', 'newspack-plugin' ) . ' |
|
|
+
+
+ ' . __( "If you've already updated your payment method, you can disregard this email.", 'newspack-plugin' ) . ' |
|
|
+
+ ' . sprintf(
+ /* translators: %s: site admin email address. */
+ __( 'Questions? Contact us at %s.', 'newspack-plugin' ),
+ '*CONTACT_EMAIL*'
+ ) . ' |
|
|
|
+
+
+ | ' . $social_links_html . ' |
|
|
+
+ *SITE_CONTACT* ' . sprintf(
+ /* translators: %s: link to site url. */
+ __( 'You received this email because you have an active subscription to %s', 'newspack-plugin' ),
+ '*SITE_URL*'
+ ) . ' |
|
|
|
+
+
+ ';
+
+return array(
+ 'post_title' => $post_title,
+ 'post_content' => $post_content,
+ 'email_html' => $email_html,
+);
diff --git a/includes/wizards/newspack/class-email-preview.php b/includes/wizards/newspack/class-email-preview.php
index 27a3df6651..517199aa9b 100644
--- a/includes/wizards/newspack/class-email-preview.php
+++ b/includes/wizards/newspack/class-email-preview.php
@@ -182,7 +182,7 @@ public static function get_sample_substitutions(): array {
// Card expiry warning details.
'*CARD_LAST_4*' => '4242',
'*EXPIRY_DATE*' => '12/2026',
- '*RENEWAL_DATE*' => wp_date( get_option( 'date_format', 'F j, Y' ), strtotime( '+1 year' ) ),
+ '*RENEWAL_DATE*' => wp_date( get_option( 'date_format', 'F j, Y' ), strtotime( '+30 days' ) ),
// OTP code — stable sample value.
'*MAGIC_LINK_OTP*' => '123456',
diff --git a/includes/wizards/newspack/class-emails-section.php b/includes/wizards/newspack/class-emails-section.php
index 014e9db6bf..a423561a0f 100644
--- a/includes/wizards/newspack/class-emails-section.php
+++ b/includes/wizards/newspack/class-emails-section.php
@@ -164,6 +164,16 @@ public static function get_email_registry(): array {
'label' => __( 'Cancellation confirmation', 'newspack-plugin' ),
'trigger_description' => __( 'Sent when a reader cancels their subscription.', 'newspack-plugin' ),
],
+ 'card-expiry-warning' => [
+ 'source' => 'newspack',
+ 'newspack_type' => 'card-expiry-warning',
+ 'recommended' => true,
+ 'plugin_dependency' => 'woocommerce-subscriptions',
+ 'recipient' => 'reader',
+ 'chip' => 'reader-revenue',
+ 'label' => __( 'Card expiry warning', 'newspack-plugin' ),
+ 'trigger_description' => __( 'Sent when a reader\'s saved payment method is about to expire.', 'newspack-plugin' ),
+ ],
'woo-renewal-reminder' => [
'source' => 'woocommerce',
'woo_email_id' => 'customer_notification_auto_renewal',
diff --git a/tests/integration/README.md b/tests/integration/README.md
new file mode 100644
index 0000000000..b56ea4cbc9
--- /dev/null
+++ b/tests/integration/README.md
@@ -0,0 +1,21 @@
+# Integration Smoke Tests
+
+Integration smoke tests that exercise Newspack features against a real WordPress + WooCommerce environment. Unlike the PHPUnit suite under `tests/unit-tests/`, these scripts are run manually via `wp eval-file` and require the full plugin stack to be active.
+
+## Running
+
+```bash
+wp eval-file tests/integration/