From e04597d67f59b724eb26a468d6132ca279c345a5 Mon Sep 17 00:00:00 2001 From: Katie Wilkerson Rethman Date: Tue, 26 May 2026 11:48:03 -0500 Subject: [PATCH 1/4] feat(emails): add card expiry warning email (NPPD-1524) Add a new Newspack-managed "Card expiry warning" email that runs on a daily cron scan and sends ~14 days before a reader's credit card expires on an active WooCommerce Subscription. - New Card_Expiry_Warning class with daily wp_cron scan, token-first DB query for expiring CC tokens, idempotency via subscription meta - Email template matching existing receipt/cancellation structure - Registry entry in unified emails UI (reader-revenue chip) - Preview substitutions for new tokens - PHPUnit tests for config, registry, filter, and ordering Co-Authored-By: Claude Opus 4.6 --- .../class-card-expiry-warning.php | 306 +++++++++++++++++ .../class-woocommerce-subscriptions.php | 2 + .../card-expiry-warning.php | 308 ++++++++++++++++++ .../wizards/newspack/class-email-preview.php | 2 +- .../wizards/newspack/class-emails-section.php | 10 + tests/unit-tests/card-expiry-warning.php | 166 ++++++++++ 6 files changed, 793 insertions(+), 1 deletion(-) create mode 100644 includes/plugins/woocommerce-subscriptions/class-card-expiry-warning.php create mode 100644 includes/templates/reader-revenue-emails/card-expiry-warning.php create mode 100644 tests/unit-tests/card-expiry-warning.php 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..b1457d0c24 --- /dev/null +++ b/includes/plugins/woocommerce-subscriptions/class-card-expiry-warning.php @@ -0,0 +1,306 @@ + 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' ); + + $placeholders = [ + [ + 'template' => '*BILLING_FIRST_NAME*', + 'value' => esc_html( $subscription->get_billing_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. + * @param string $new_payment_method The new payment method ID. + */ + public static function clear_sent_flag( $subscription, $new_payment_method ) { + $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. + ' + + ' . + + // Footer. + ' + + '; + +$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/unit-tests/card-expiry-warning.php b/tests/unit-tests/card-expiry-warning.php new file mode 100644 index 0000000000..d88a14f230 --- /dev/null +++ b/tests/unit-tests/card-expiry-warning.php @@ -0,0 +1,166 @@ +assertArrayHasKey( 'card-expiry-warning', $configs, 'card-expiry-warning email config should be registered.' ); + } + + /** + * Test the email config has all required keys. + */ + public function test_email_config_has_required_keys() { + $configs = apply_filters( 'newspack_email_configs', [] ); + $config = $configs['card-expiry-warning']; + $required_keys = [ 'name', 'category', 'label', 'description', 'template', 'editor_notice', 'from_email', 'available_placeholders' ]; + + foreach ( $required_keys as $key ) { + $this->assertArrayHasKey( $key, $config, "Email config is missing required key '$key'." ); + } + } + + /** + * Test the email config name matches the constant. + */ + public function test_email_config_name() { + $configs = apply_filters( 'newspack_email_configs', [] ); + $config = $configs['card-expiry-warning']; + $this->assertSame( 'card-expiry-warning', $config['name'] ); + } + + /** + * Test the email config category is reader-revenue. + */ + public function test_email_config_category() { + $configs = apply_filters( 'newspack_email_configs', [] ); + $config = $configs['card-expiry-warning']; + $this->assertSame( 'reader-revenue', $config['category'] ); + } + + /** + * Test the email config template file exists. + */ + public function test_email_config_template_exists() { + $configs = apply_filters( 'newspack_email_configs', [] ); + $config = $configs['card-expiry-warning']; + $this->assertFileExists( $config['template'], 'Email template file should exist.' ); + } + + /** + * Test the email config has the expected placeholders. + */ + public function test_email_config_placeholders() { + $configs = apply_filters( 'newspack_email_configs', [] ); + $placeholders = $configs['card-expiry-warning']['available_placeholders']; + $templates = array_column( $placeholders, 'template' ); + + $expected = [ + '*BILLING_FIRST_NAME*', + '*CARD_LAST_4*', + '*EXPIRY_DATE*', + '*RENEWAL_DATE*', + '*UPDATE_PAYMENT_URL*', + '*CONTACT_EMAIL*', + '*SITE_TITLE*', + '*SITE_URL*', + ]; + + foreach ( $expected as $token ) { + $this->assertContains( $token, $templates, "Placeholder '$token' should be in available_placeholders." ); + } + } + + /** + * Test the registry entry is present. + */ + public function test_registry_entry_present() { + $registry = Emails_Section::get_email_registry(); + $this->assertArrayHasKey( 'card-expiry-warning', $registry, 'card-expiry-warning should be in the email registry.' ); + } + + /** + * Test the registry entry has the correct newspack_type. + */ + public function test_registry_entry_newspack_type() { + $registry = Emails_Section::get_email_registry(); + $this->assertSame( 'card-expiry-warning', $registry['card-expiry-warning']['newspack_type'] ); + } + + /** + * Test the registry entry chip is reader-revenue. + */ + public function test_registry_entry_chip_is_reader_revenue() { + $registry = Emails_Section::get_email_registry(); + $this->assertSame( 'reader-revenue', $registry['card-expiry-warning']['chip'] ); + } + + /** + * Test the registry entry is recommended. + */ + public function test_registry_entry_is_recommended() { + $registry = Emails_Section::get_email_registry(); + $this->assertTrue( $registry['card-expiry-warning']['recommended'] ); + } + + /** + * Test the registry entry has woocommerce-subscriptions plugin dependency. + */ + public function test_registry_entry_plugin_dependency() { + $registry = Emails_Section::get_email_registry(); + $this->assertSame( 'woocommerce-subscriptions', $registry['card-expiry-warning']['plugin_dependency'] ); + } + + /** + * Test the registry entry recipient is reader. + */ + public function test_registry_entry_recipient() { + $registry = Emails_Section::get_email_registry(); + $this->assertSame( 'reader', $registry['card-expiry-warning']['recipient'] ); + } + + /** + * Test the default days before expiry is 14. + */ + public function test_days_before_expiry_default() { + $this->assertSame( 14, Card_Expiry_Warning::get_days_before_expiry() ); + } + + /** + * Test the days before expiry is filterable. + */ + public function test_days_before_expiry_filterable() { + add_filter( 'newspack_card_expiry_warning_days', fn() => 7 ); + $this->assertSame( 7, Card_Expiry_Warning::get_days_before_expiry() ); + remove_all_filters( 'newspack_card_expiry_warning_days' ); + } + + /** + * Test the registry entry appears after cancellation and before woo-renewal-reminder. + */ + public function test_registry_entry_order() { + $slugs = array_keys( Emails_Section::get_email_registry() ); + + $cancellation_idx = array_search( 'cancellation', $slugs, true ); + $card_expiry_idx = array_search( 'card-expiry-warning', $slugs, true ); + $renewal_reminder_idx = array_search( 'woo-renewal-reminder', $slugs, true ); + + $this->assertNotFalse( $card_expiry_idx, 'card-expiry-warning should be in the registry.' ); + $this->assertGreaterThan( $cancellation_idx, $card_expiry_idx, 'card-expiry-warning should appear after cancellation.' ); + $this->assertLessThan( $renewal_reminder_idx, $card_expiry_idx, 'card-expiry-warning should appear before woo-renewal-reminder.' ); + } +} From e70ba1e5657d7ae89ec57b5e24116230ba72de25 Mon Sep 17 00:00:00 2001 From: Katie Wilkerson Rethman Date: Tue, 26 May 2026 11:57:26 -0500 Subject: [PATCH 2/4] fix(emails): make card expiry tests independent of WC Subs (NPPD-1524) In CI, WooCommerce Subscriptions is not active so Card_Expiry_Warning::init() never runs. Call add_email_config() directly in tests instead of relying on the filter being hooked. Also add a test for the min-days-before-expiry guard. Co-Authored-By: Claude Opus 4.6 --- tests/unit-tests/card-expiry-warning.php | 41 ++++++++++++++++++------ 1 file changed, 31 insertions(+), 10 deletions(-) diff --git a/tests/unit-tests/card-expiry-warning.php b/tests/unit-tests/card-expiry-warning.php index d88a14f230..9f1128d1d0 100644 --- a/tests/unit-tests/card-expiry-warning.php +++ b/tests/unit-tests/card-expiry-warning.php @@ -14,10 +14,22 @@ class Newspack_Test_Card_Expiry_Warning extends WP_UnitTestCase { /** - * Test the email config is registered via the newspack_email_configs filter. + * Helper: get the email config by calling add_email_config directly. + * + * In CI, WooCommerce Subscriptions is not active so init() never hooks + * the filter. We test the static method directly instead. + * + * @return array Email configs including card-expiry-warning. + */ + private function get_email_configs(): array { + return Card_Expiry_Warning::add_email_config( [] ); + } + + /** + * Test the email config is registered. */ public function test_email_config_registered() { - $configs = apply_filters( 'newspack_email_configs', [] ); + $configs = $this->get_email_configs(); $this->assertArrayHasKey( 'card-expiry-warning', $configs, 'card-expiry-warning email config should be registered.' ); } @@ -25,7 +37,7 @@ public function test_email_config_registered() { * Test the email config has all required keys. */ public function test_email_config_has_required_keys() { - $configs = apply_filters( 'newspack_email_configs', [] ); + $configs = $this->get_email_configs(); $config = $configs['card-expiry-warning']; $required_keys = [ 'name', 'category', 'label', 'description', 'template', 'editor_notice', 'from_email', 'available_placeholders' ]; @@ -38,7 +50,7 @@ public function test_email_config_has_required_keys() { * Test the email config name matches the constant. */ public function test_email_config_name() { - $configs = apply_filters( 'newspack_email_configs', [] ); + $configs = $this->get_email_configs(); $config = $configs['card-expiry-warning']; $this->assertSame( 'card-expiry-warning', $config['name'] ); } @@ -47,7 +59,7 @@ public function test_email_config_name() { * Test the email config category is reader-revenue. */ public function test_email_config_category() { - $configs = apply_filters( 'newspack_email_configs', [] ); + $configs = $this->get_email_configs(); $config = $configs['card-expiry-warning']; $this->assertSame( 'reader-revenue', $config['category'] ); } @@ -56,7 +68,7 @@ public function test_email_config_category() { * Test the email config template file exists. */ public function test_email_config_template_exists() { - $configs = apply_filters( 'newspack_email_configs', [] ); + $configs = $this->get_email_configs(); $config = $configs['card-expiry-warning']; $this->assertFileExists( $config['template'], 'Email template file should exist.' ); } @@ -65,7 +77,7 @@ public function test_email_config_template_exists() { * Test the email config has the expected placeholders. */ public function test_email_config_placeholders() { - $configs = apply_filters( 'newspack_email_configs', [] ); + $configs = $this->get_email_configs(); $placeholders = $configs['card-expiry-warning']['available_placeholders']; $templates = array_column( $placeholders, 'template' ); @@ -149,15 +161,24 @@ public function test_days_before_expiry_filterable() { remove_all_filters( 'newspack_card_expiry_warning_days' ); } + /** + * Test the days before expiry enforces a minimum of 1. + */ + public function test_days_before_expiry_minimum() { + add_filter( 'newspack_card_expiry_warning_days', fn() => -5 ); + $this->assertSame( 1, Card_Expiry_Warning::get_days_before_expiry() ); + remove_all_filters( 'newspack_card_expiry_warning_days' ); + } + /** * Test the registry entry appears after cancellation and before woo-renewal-reminder. */ public function test_registry_entry_order() { $slugs = array_keys( Emails_Section::get_email_registry() ); - $cancellation_idx = array_search( 'cancellation', $slugs, true ); - $card_expiry_idx = array_search( 'card-expiry-warning', $slugs, true ); - $renewal_reminder_idx = array_search( 'woo-renewal-reminder', $slugs, true ); + $cancellation_idx = array_search( 'cancellation', $slugs, true ); + $card_expiry_idx = array_search( 'card-expiry-warning', $slugs, true ); + $renewal_reminder_idx = array_search( 'woo-renewal-reminder', $slugs, true ); $this->assertNotFalse( $card_expiry_idx, 'card-expiry-warning should be in the registry.' ); $this->assertGreaterThan( $cancellation_idx, $card_expiry_idx, 'card-expiry-warning should appear after cancellation.' ); From 2422d2046ebb5c53c61fe863f07ff56c97492525 Mon Sep 17 00:00:00 2001 From: Katie Wilkerson Rethman Date: Tue, 26 May 2026 12:19:59 -0500 Subject: [PATCH 3/4] test(emails): add integration smoke test for card expiry warning (NPPD-1524) Runnable via `wp eval-file tests/integration/card-expiry-warning-smoke.php`. Covers: cron scheduling, happy-path send, idempotency, clear_sent_flag handler, new-card re-send, unattached-card filtering, and cleanup. Co-Authored-By: Claude Opus 4.6 --- .../integration/card-expiry-warning-smoke.php | 457 ++++++++++++++++++ 1 file changed, 457 insertions(+) create mode 100644 tests/integration/card-expiry-warning-smoke.php diff --git a/tests/integration/card-expiry-warning-smoke.php b/tests/integration/card-expiry-warning-smoke.php new file mode 100644 index 0000000000..02ac87b616 --- /dev/null +++ b/tests/integration/card-expiry-warning-smoke.php @@ -0,0 +1,457 @@ + 'WooCommerce (WC_Payment_Token_CC)', + 'WC_Payment_Tokens' => 'WooCommerce (WC_Payment_Tokens)', + 'WCS_Payment_Tokens' => 'WooCommerce Subscriptions (WCS_Payment_Tokens)', + 'Newspack\\Card_Expiry_Warning' => 'Newspack Card_Expiry_Warning', + 'Newspack\\Emails' => 'Newspack Emails', + 'Newspack_Newsletters' => 'Newspack Newsletters', +]; +foreach ( $prereqs as $class => $label ) { + if ( ! class_exists( $class ) ) { + WP_CLI::error( "Prerequisite not met: $label ($class). Aborting." ); + } +} +if ( ! function_exists( 'wcs_create_subscription' ) ) { + WP_CLI::error( 'wcs_create_subscription() not available. Aborting.' ); +} +WP_CLI::log( 'Prerequisites OK.' ); + +// ── Intercept wp_mail via pre_wp_mail ──────────────────────────────── +// Returning non-null from pre_wp_mail short-circuits wp_mail() without +// actually sending. We capture the args and return true ("sent OK"). +add_filter( + 'pre_wp_mail', + function ( $null, $atts ) use ( &$mails ) { + $mails[] = $atts; + return true; + }, + 10, + 2 +); + +// ── Widen scan window for reliable expiry detection ────────────────── +// A CC token "expires" at the end of its expiry month. The scan query +// finds tokens whose LAST_DAY(expiry) falls in [today, today + N days]. +// End-of-current-month is at most ~30 days away, so a 32-day window +// guarantees the token is within range regardless of when the test runs. +add_filter( + 'newspack_card_expiry_warning_days', + function () { + return 32; + }, + 99 +); + +// ── Create test user ───────────────────────────────────────────────── +$rand = wp_rand( 10000, 99999 ); +$user_id = wp_insert_user( + [ + 'user_login' => "smoke_cew_$rand", + 'user_email' => "smoke-cew-$rand@example.test", + 'user_pass' => wp_generate_password(), + 'first_name' => 'Smoke', + 'last_name' => 'Tester', + 'role' => 'subscriber', + ] +); +if ( is_wp_error( $user_id ) ) { + WP_CLI::error( 'Could not create test user: ' . $user_id->get_error_message() ); +} +$cleanup[] = function () use ( $user_id ) { + wp_delete_user( $user_id ); +}; +WP_CLI::log( " Created user #$user_id." ); + +// ── Create CC token 1 (expires end of current month) ───────────────── +$expiry_month = (int) gmdate( 'n' ); +$expiry_year = (int) gmdate( 'Y' ); + +$token1 = new WC_Payment_Token_CC(); +$token1->set_gateway_id( 'stripe' ); +$token1->set_token( 'pm_smoke_' . $rand . '_a' ); +$token1->set_last4( '4242' ); +$token1->set_expiry_month( str_pad( $expiry_month, 2, '0', STR_PAD_LEFT ) ); +$token1->set_expiry_year( (string) $expiry_year ); +$token1->set_card_type( 'visa' ); +$token1->set_user_id( $user_id ); +$token1->save(); +$cleanup[] = function () use ( $token1 ) { + $token1->delete( true ); +}; +WP_CLI::log( " Created token #{$token1->get_id()} (last4=4242, exp=$expiry_month/$expiry_year)." ); + +// ── Create WC Subscription linked to token 1 ──────────────────────── +$subscription = wcs_create_subscription( + [ + 'customer_id' => $user_id, + 'status' => 'active', + 'billing_period' => 'month', + 'billing_interval' => 1, + ] +); +if ( is_wp_error( $subscription ) ) { + WP_CLI::error( 'Could not create subscription: ' . $subscription->get_error_message() ); +} +$sub_id = $subscription->get_id(); + +$subscription->set_billing_email( "smoke-cew-$rand@example.test" ); +$subscription->set_billing_first_name( 'Smoke' ); +$subscription->set_payment_method( 'stripe' ); + +// Mark as automatic renewal — wcs_create_subscription() defaults to +// manual, and get_subscriptions_from_token() excludes manual-renewal subs. +$subscription->set_requires_manual_renewal( false ); + +// Store token string in gateway-specific meta so +// WCS_Payment_Tokens::get_subscriptions_from_token() can match it +// (key-less meta_query on the raw token string). +$subscription->update_meta_data( '_stripe_source_id', $token1->get_token() ); + +// Set a future next-payment date. +$subscription->update_dates( + [ 'next_payment' => gmdate( 'Y-m-d H:i:s', time() + 20 * DAY_IN_SECONDS ) ] +); +$subscription->save(); + +$cleanup[] = function () use ( $sub_id ) { + wp_delete_post( $sub_id, true ); +}; +WP_CLI::log( " Created subscription #$sub_id." ); + + +// ══════════════════════════════════════════════════════════════════════ +// SCENARIO 1: Cron scheduled +// ══════════════════════════════════════════════════════════════════════ +WP_CLI::log( '' ); +WP_CLI::log( '1. Cron scheduled' ); + +Card_Expiry_Warning::schedule_cron(); + +if ( wp_next_scheduled( Card_Expiry_Warning::CRON_HOOK ) ) { + smoke_pass( 'Cron event is scheduled.' ); +} else { + smoke_fail( 'Cron event was not scheduled.' ); +} + + +// ══════════════════════════════════════════════════════════════════════ +// SCENARIO 2: Happy-path send +// ══════════════════════════════════════════════════════════════════════ +WP_CLI::log( '' ); +WP_CLI::log( '2. Happy-path send' ); + +$mails = []; +Card_Expiry_Warning::scan_expiring_cards(); + +$s2_ok = true; +if ( count( $mails ) !== 1 ) { + smoke_fail( 'Expected exactly 1 email, got ' . count( $mails ) . '.' ); + if ( count( $mails ) === 0 ) { + // Debug: check prerequisites. + if ( ! Emails::can_send_email( 'card-expiry-warning' ) ) { + WP_CLI::log( ' (Debug: Emails::can_send_email returned false.)' ); + } + } + $s2_ok = false; +} else { + $mail = $mails[0]; + + // Check recipient. + if ( $mail['to'] !== "smoke-cew-$rand@example.test" ) { + smoke_fail( "Wrong recipient: expected smoke-cew-$rand@example.test, got {$mail['to']}." ); + $s2_ok = false; + } + + // Check body contains last-4 and expiry. + $body = $mail['message'] ?? ''; + if ( false === strpos( $body, '4242' ) ) { + smoke_fail( 'Email body missing card last-4 "4242".' ); + $s2_ok = false; + } + // Use the token's own accessors for the padded month format. + $expiry_str = $token1->get_expiry_month() . '/' . $token1->get_expiry_year(); + if ( false === strpos( $body, $expiry_str ) ) { + smoke_fail( "Email body missing expiry \"$expiry_str\"." ); + $s2_ok = false; + } + + // Check idempotency meta. + $subscription = wcs_get_subscription( $sub_id ); + $meta_val = $subscription->get_meta( '_newspack_card_expiry_warning_sent', true ); + $expected_meta = $token1->get_id() . ':' . $token1->get_expiry_month() . '/' . $token1->get_expiry_year(); + if ( $meta_val !== $expected_meta ) { + smoke_fail( "Idempotency meta mismatch: expected '$expected_meta', got '$meta_val'." ); + $s2_ok = false; + } +} + +if ( $s2_ok ) { + smoke_pass( 'Correct recipient, body tokens, and idempotency meta.' ); +} + + +// ══════════════════════════════════════════════════════════════════════ +// SCENARIO 3: Idempotency — re-running scan sends nothing +// ══════════════════════════════════════════════════════════════════════ +WP_CLI::log( '' ); +WP_CLI::log( '3. Idempotency (no duplicate send)' ); + +$mails = []; +Card_Expiry_Warning::scan_expiring_cards(); + +if ( 0 === count( $mails ) ) { + $subscription = wcs_get_subscription( $sub_id ); + $meta_val = $subscription->get_meta( '_newspack_card_expiry_warning_sent', true ); + $expected_meta = $token1->get_id() . ':' . $token1->get_expiry_month() . '/' . $token1->get_expiry_year(); + + if ( $meta_val === $expected_meta ) { + smoke_pass( 'No duplicate email; meta unchanged.' ); + } else { + smoke_fail( "Meta changed unexpectedly to '$meta_val'." ); + } +} else { + smoke_fail( 'Duplicate email sent (' . count( $mails ) . ' captured).' ); +} + + +// ══════════════════════════════════════════════════════════════════════ +// SCENARIO 4: clear_sent_flag() handler clears idempotency meta +// ══════════════════════════════════════════════════════════════════════ +WP_CLI::log( '' ); +WP_CLI::log( '4. clear_sent_flag() handler clears meta' ); + +// Call clear_sent_flag() directly rather than firing the full +// woocommerce_subscription_payment_method_updated action. That action +// triggers third-party hooks (WCS PayPal, etc.) whose callbacks expect +// 3 args and fatal with our minimal test fixture. +$subscription = wcs_get_subscription( $sub_id ); +Card_Expiry_Warning::clear_sent_flag( $subscription, 'stripe' ); + +$subscription = wcs_get_subscription( $sub_id ); +$meta_val = $subscription->get_meta( '_newspack_card_expiry_warning_sent', true ); + +if ( empty( $meta_val ) ) { + smoke_pass( 'Idempotency meta cleared.' ); +} else { + smoke_fail( "Meta should be empty after clear, got '$meta_val'." ); +} + + +// ══════════════════════════════════════════════════════════════════════ +// SCENARIO 5: New card in window triggers a new send +// ══════════════════════════════════════════════════════════════════════ +WP_CLI::log( '' ); +WP_CLI::log( '5. New card triggers new send' ); + +$token2 = new WC_Payment_Token_CC(); +$token2->set_gateway_id( 'stripe' ); +$token2->set_token( 'pm_smoke_' . $rand . '_b' ); +$token2->set_last4( '1234' ); +$token2->set_expiry_month( str_pad( $expiry_month, 2, '0', STR_PAD_LEFT ) ); +$token2->set_expiry_year( (string) $expiry_year ); +$token2->set_card_type( 'mastercard' ); +$token2->set_user_id( $user_id ); +$token2->save(); +$cleanup[] = function () use ( $token2 ) { + $token2->delete( true ); +}; +WP_CLI::log( " Created token #{$token2->get_id()} (last4=1234)." ); + +// Point subscription at the new token. +$subscription = wcs_get_subscription( $sub_id ); +$subscription->update_meta_data( '_stripe_source_id', $token2->get_token() ); +$subscription->save(); + +$mails = []; +Card_Expiry_Warning::scan_expiring_cards(); + +$s5_ok = true; +if ( count( $mails ) < 1 ) { + smoke_fail( 'No email sent for the new token.' ); + $s5_ok = false; +} else { + $mail = $mails[ count( $mails ) - 1 ]; + $body = $mail['message'] ?? ''; + + if ( false === strpos( $body, '1234' ) ) { + smoke_fail( 'Email body missing new card last-4 "1234".' ); + $s5_ok = false; + } + + $subscription = wcs_get_subscription( $sub_id ); + $meta_val = $subscription->get_meta( '_newspack_card_expiry_warning_sent', true ); + $expected_meta = $token2->get_id() . ':' . $token2->get_expiry_month() . '/' . $token2->get_expiry_year(); + if ( $meta_val !== $expected_meta ) { + smoke_fail( "Meta should reflect token2: expected '$expected_meta', got '$meta_val'." ); + $s5_ok = false; + } +} + +if ( $s5_ok ) { + smoke_pass( 'New card triggered email with correct last-4 and updated meta.' ); +} + + +// ══════════════════════════════════════════════════════════════════════ +// SCENARIO 6: Unattached card does NOT trigger an email +// ══════════════════════════════════════════════════════════════════════ +WP_CLI::log( '' ); +WP_CLI::log( '6. Unattached card does not trigger email' ); + +// Create a separate user + token with no subscription. +$orphan_user_id = wp_insert_user( + [ + 'user_login' => "smoke_cew_orphan_$rand", + 'user_email' => "smoke-orphan-$rand@example.test", + 'user_pass' => wp_generate_password(), + 'role' => 'subscriber', + ] +); +if ( is_wp_error( $orphan_user_id ) ) { + WP_CLI::error( 'Could not create orphan user: ' . $orphan_user_id->get_error_message() ); +} +$cleanup[] = function () use ( $orphan_user_id ) { + wp_delete_user( $orphan_user_id ); +}; + +$token3 = new WC_Payment_Token_CC(); +$token3->set_gateway_id( 'stripe' ); +$token3->set_token( 'pm_smoke_' . $rand . '_c' ); +$token3->set_last4( '9999' ); +$token3->set_expiry_month( str_pad( $expiry_month, 2, '0', STR_PAD_LEFT ) ); +$token3->set_expiry_year( (string) $expiry_year ); +$token3->set_card_type( 'amex' ); +$token3->set_user_id( $orphan_user_id ); +$token3->save(); +$cleanup[] = function () use ( $token3 ) { + $token3->delete( true ); +}; +WP_CLI::log( " Created orphan token #{$token3->get_id()} (last4=9999, no subscription)." ); + +// Token2's subscription already has the idempotency flag from scenario 5, +// so no email for that. Token1 is no longer on the subscription (replaced +// by token2). Token3 has no subscription at all. +$mails = []; +Card_Expiry_Warning::scan_expiring_cards(); + +if ( 0 === count( $mails ) ) { + smoke_pass( 'No email sent for unattached card.' ); +} else { + $orphan_hit = false; + foreach ( $mails as $mail_item ) { + if ( false !== strpos( $mail_item['to'], 'orphan' ) ) { + $orphan_hit = true; + } + } + if ( $orphan_hit ) { + smoke_fail( 'Email was sent to orphan user with no subscription.' ); + } else { + smoke_fail( count( $mails ) . ' unexpected email(s) sent (not to orphan user).' ); + } +} + + +// ══════════════════════════════════════════════════════════════════════ +// SCENARIO 7: Cleanup +// ══════════════════════════════════════════════════════════════════════ +WP_CLI::log( '' ); +WP_CLI::log( '7. Cleanup' ); + +$clean_ok = true; +foreach ( array_reverse( $cleanup ) as $fn ) { + try { + $fn(); + } catch ( \Throwable $e ) { + WP_CLI::log( ' Cleanup error: ' . $e->getMessage() ); + $clean_ok = false; + } +} + +// Remove filters added by this script. +remove_all_filters( 'pre_wp_mail' ); +remove_all_filters( 'newspack_card_expiry_warning_days' ); + +// Unschedule cron (may have been scheduled before the test too). +wp_clear_scheduled_hook( Card_Expiry_Warning::CRON_HOOK ); + +if ( $clean_ok ) { + smoke_pass( 'All fixtures cleaned up.' ); +} else { + smoke_fail( 'Some cleanup steps failed (see above).' ); +} + + +// ── Summary ────────────────────────────────────────────────────────── +WP_CLI::log( '' ); +WP_CLI::log( "== $passed/$total PASSED ==" ); +WP_CLI::log( '' ); + +exit( $passed === $total ? 0 : 1 ); From fa0f290bf5775d3cb3c9bdc0fc67ef1017d21e09 Mon Sep 17 00:00:00 2001 From: Katie Wilkerson Rethman Date: Tue, 26 May 2026 13:15:31 -0500 Subject: [PATCH 4/4] fix(emails): address Copilot review feedback (NPPD-1524) - card-expiry-warning unit test: assert cancellation and woo-renewal-reminder indices are not false before comparing ordering. - card-expiry-warning smoke test: add Reader Activation prereq check so it fails fast instead of silently capturing 0 emails; drop the temporary "delete after merge" notice; add a README documenting tests/integration/. - Card_Expiry_Warning::maybe_send_warning(): use $customer->first_name as a fallback when subscription billing_first_name is empty. - Card_Expiry_Warning::clear_sent_flag(): drop the unused $new_payment_method parameter and matching accepted_args from the action. --- .../class-card-expiry-warning.php | 19 ++++++++--------- tests/integration/README.md | 21 +++++++++++++++++++ .../integration/card-expiry-warning-smoke.php | 21 ++++++++++++------- tests/unit-tests/card-expiry-warning.php | 2 ++ 4 files changed, 46 insertions(+), 17 deletions(-) create mode 100644 tests/integration/README.md diff --git a/includes/plugins/woocommerce-subscriptions/class-card-expiry-warning.php b/includes/plugins/woocommerce-subscriptions/class-card-expiry-warning.php index b1457d0c24..4432cefa67 100644 --- a/includes/plugins/woocommerce-subscriptions/class-card-expiry-warning.php +++ b/includes/plugins/woocommerce-subscriptions/class-card-expiry-warning.php @@ -44,12 +44,7 @@ public static function init() { add_filter( 'newspack_email_configs', [ __CLASS__, 'add_email_config' ] ); add_action( 'init', [ __CLASS__, 'schedule_cron' ] ); add_action( self::CRON_HOOK, [ __CLASS__, 'scan_expiring_cards' ] ); - add_action( - 'woocommerce_subscription_payment_method_updated', - [ __CLASS__, 'clear_sent_flag' ], - 10, - 2 - ); + add_action( 'woocommerce_subscription_payment_method_updated', [ __CLASS__, 'clear_sent_flag' ] ); add_action( 'newspack_deactivation', [ __CLASS__, 'unschedule_cron' ] ); } @@ -256,10 +251,15 @@ private static function maybe_send_warning( $subscription, $token ) { ? 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( $subscription->get_billing_first_name() ), + 'value' => esc_html( $first_name ), ], [ 'template' => '*CARD_LAST_4*', @@ -296,10 +296,9 @@ private static function maybe_send_warning( $subscription, $token ) { * * Hooked to 'woocommerce_subscription_payment_method_updated'. * - * @param \WC_Subscription $subscription The subscription. - * @param string $new_payment_method The new payment method ID. + * @param \WC_Subscription $subscription The subscription. */ - public static function clear_sent_flag( $subscription, $new_payment_method ) { + public static function clear_sent_flag( $subscription ) { $subscription->delete_meta_data( self::SENT_META ); $subscription->save(); } 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/