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. + ' + + ' . + + // 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/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/