Skip to content

Card expiry warning email (NPPD-1524)#4756

Open
kmwilkerson wants to merge 4 commits into
nppd-1527-slice-2-surface-woocommerce-emails-in-the-unified-emails-uifrom
nppd-1524-card-expiry-warning-email
Open

Card expiry warning email (NPPD-1524)#4756
kmwilkerson wants to merge 4 commits into
nppd-1527-slice-2-surface-woocommerce-emails-in-the-unified-emails-uifrom
nppd-1524-card-expiry-warning-email

Conversation

@kmwilkerson
Copy link
Copy Markdown

@kmwilkerson kmwilkerson commented May 26, 2026

What this PR does

Adds a new Newspack-managed Card expiry warning email (NPPD-1524). Sent ~14 days before a reader's saved card expires on an active WC Subscription, prompting them to update their payment method before renewal fails silently.

image

Depends on slice 2 (#4732) for the chip mechanism, which depends on slice 1 (#4727).

Code changes

Backend — includes/plugins/woocommerce-subscriptions/class-card-expiry-warning.php (new)

  • New Card_Expiry_Warning class. Registers the email config on newspack_email_configs, schedules a daily cron via wp_schedule_event, and hooks woocommerce_subscription_payment_method_updated to clear the idempotency flag. Unschedules the cron on newspack_deactivation.
  • Token-first scan algorithm: a direct DB query on wp_woocommerce_payment_tokens + wp_woocommerce_payment_tokenmeta finds type='CC' tokens whose last-valid-day falls within the configured window (default 14 days). For each match, WCS_Payment_Tokens::get_subscriptions_from_token() resolves to the active subscriptions actually using that token for automatic renewals. This avoids scanning all subscriptions: the matching set is narrow by definition.
  • Multi-card aware: only warns about the card in use on a subscription, not all saved cards. A reader with three saved cards but only one in use on a sub gets one warning (or none, if the in-use card isn't in the expiry window) — get_subscriptions_from_token() matches the token string against the gateway-specific subscription meta, scoped to automatic-renewal subs only.
  • Idempotency: per-subscription meta _newspack_card_expiry_warning_sent storing {token_id}:{MM}/{YYYY}. Self-invalidates when the token changes (new token_id) or for a new expiry cycle (different MM/YYYY). Explicitly cleared via woocommerce_subscription_payment_method_updated so an updated card resets the warning state immediately.
  • Filterable window: apply_filters( 'newspack_card_expiry_warning_days', 14 ). No UI control in v1 — filter-only.
  • Scope: active subscriptions only (skips pending and on-hold even though get_subscriptions_from_token() returns them — deliberate narrowing for v1, documented inline), automatic-renewal only (manual subs have no stored token), WC_Payment_Token_CC only (skips eCheck, PayPal, etc.), and not-yet-expired cards only.

Backend — includes/templates/reader-revenue-emails/card-expiry-warning.php (new)

  • Email template matching the structure of receipt.php and cancellation.php. Returns post_title, post_content (Gutenberg blocks for the email editor), and email_html (MJML-style HTML for delivery). Uses newspack_get_theme_colors() and newspack_get_social_markup() for consistent branding.
  • Tokens: *BILLING_FIRST_NAME*, *CARD_LAST_4*, *EXPIRY_DATE*, *RENEWAL_DATE*, *UPDATE_PAYMENT_URL* (resolves to wc_get_account_endpoint_url( 'payment-methods' )).
  • Includes an "if you've already updated your payment method, you can disregard this email" disclaimer to handle the 14-day lead time.

Backend — registry + bootstrap

  • includes/wizards/newspack/class-emails-section.php: adds card-expiry-warning to get_email_registry() (inserted after cancellation, before woo-renewal-reminder) with chip='reader-revenue', recommended=true, plugin_dependency='woocommerce-subscriptions', recipient='reader'.
  • includes/wizards/newspack/class-email-preview.php: extends get_sample_substitutions() with new tokens — *CARD_LAST_4*4242, *EXPIRY_DATE*12/2026, *RENEWAL_DATE* → a stable future date, *UPDATE_PAYMENT_URL*#.
  • includes/plugins/woocommerce-subscriptions/class-woocommerce-subscriptions.php: includes the new class and calls Card_Expiry_Warning::init() from the WC Subs integration init.

Tests

PHPUnit (tests/unit-tests/card-expiry-warning.php, new): email config registration + required keys + template file existence + placeholder shape; registry entry presence, chip, recommended, plugin_dependency, recipient, position in the registry; default 14-day window + filter override.

Integration smoke test (tests/integration/card-expiry-warning-smoke.php, new): runnable via wp eval-file on a site with WooCommerce + WC Subscriptions active. Creates real DB fixtures (user, CC tokens, subscription) and exercises the full scan pipeline — 7 scenarios covering cron scheduling, happy-path send with wp_mail capture, idempotency, clear_sent_flag handler, new-card re-send, unattached-card filtering, and cleanup. 7/7 passing on local.

No frontend test changes — no UI surface in v1.

Why slice this PR

Card expiry warning is a self-contained email. Keeping it as a separate PR off slice 2 makes it easier to review the cron + token scan logic on its own and ships independent reader-facing value as soon as slice 2 lands.

Manual testing

Tested locally with Newspack, WooCommerce, and WooCommerce Subscriptions active. Verified:

  • "Card expiry warning" appears in Settings > Emails > Reader revenue chip with the expected label, description, and enabled-by-default state
  • Preview renders with sample tokens (4242, 12/2026, etc.)
  • wp cron event list | grep newspack_card_expiry_warning_scan confirms the daily cron is scheduled
  • Set up a test subscription with a Stripe test card expiring next month, manually ran the cron (wp cron event run newspack_card_expiry_warning_scan), verified the email sent to the customer email with correct token substitutions
  • Re-ran the cron: no-op (idempotency meta present)
  • Updated the payment method on the sub via My Account: idempotency meta cleared; re-ran cron, no send (new card not in expiry window)
  • Swapped to a different test card also in the expiry window: cron sent a new warning with the new token's last 4
  • Multi-card check: added a second saved card (not used on any sub) also expiring soon → cron correctly ignored it; only the card on the sub triggered a send

All Submissions:

@kmwilkerson kmwilkerson marked this pull request as ready for review May 26, 2026 17:30
@kmwilkerson kmwilkerson requested a review from a team as a code owner May 26, 2026 17:30
Copilot AI review requested due to automatic review settings May 26, 2026 17:30
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a new Newspack-managed “Card expiry warning” email for WooCommerce Subscriptions, including cron-driven scanning of expiring CC payment tokens and wiring the email into the unified Emails registry + preview token system.

Changes:

  • Introduces Card_Expiry_Warning (cron scheduling, token-first DB scan, idempotent send tracking, and reset on payment-method update).
  • Adds a new reader-revenue email template (card-expiry-warning.php) and registers it in the Emails registry UI.
  • Extends email preview sample substitutions and adds unit + manual integration smoke tests.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
includes/plugins/woocommerce-subscriptions/class-card-expiry-warning.php New cron + scan + send implementation for the card-expiry warning email.
includes/plugins/woocommerce-subscriptions/class-woocommerce-subscriptions.php Boots the new Card Expiry Warning integration from the WC Subscriptions integration init.
includes/templates/reader-revenue-emails/card-expiry-warning.php New MJML-style + block email template for the warning email.
includes/wizards/newspack/class-emails-section.php Registers the email in the unified Emails registry (chip/recipient/dependency metadata).
includes/wizards/newspack/class-email-preview.php Adds preview sample substitutions for the new card-expiry tokens.
tests/unit-tests/card-expiry-warning.php Unit tests for config/registry wiring and filterable window behavior.
tests/integration/card-expiry-warning-smoke.php WP-CLI eval-file smoke test for end-to-end behavior on a real WC/WCS site.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread tests/unit-tests/card-expiry-warning.php
Comment thread tests/integration/card-expiry-warning-smoke.php Outdated
Comment thread tests/integration/card-expiry-warning-smoke.php
Comment thread includes/plugins/woocommerce-subscriptions/class-card-expiry-warning.php Outdated
@kmwilkerson kmwilkerson force-pushed the nppd-1527-slice-2-surface-woocommerce-emails-in-the-unified-emails-ui branch from 51b2d86 to c5e7984 Compare May 26, 2026 17:41
@kmwilkerson kmwilkerson force-pushed the nppd-1524-card-expiry-warning-email branch from fce23a9 to 4f55717 Compare May 26, 2026 17:42
@kmwilkerson kmwilkerson force-pushed the nppd-1524-card-expiry-warning-email branch from facde84 to 98b0e2d Compare May 26, 2026 20:39
@kmwilkerson kmwilkerson force-pushed the nppd-1524-card-expiry-warning-email branch from 98b0e2d to 05ae747 Compare May 26, 2026 23:17
@kmwilkerson
Copy link
Copy Markdown
Author

Force-pushed after rebase onto the updated slice 2 branch, which now contains
NPPD-1555's escape/i18n refactor of Email_Preview::get_sample_substitutions().

Three of the tokens this PR originally added (*CARD_LAST_4*, *EXPIRY_DATE*,
*UPDATE_PAYMENT_URL*) now live in slice 2's new three-key html/url/raw
structure with identical values, so they were dropped from this PR's diff.
*RENEWAL_DATE* was kept conceptually but moved into slice 2's html sub-array
and its sample offset was updated from +1 year to +30 days (more realistic
for a card-expiry warning preview).

Net effect on this PR's review surface: no behavioral changes — same tokens,
same values (except the more-realistic *RENEWAL_DATE* offset), just now
defined upstream. Card expiry warning email itself is unchanged.

Resolved review threads remain resolved.

kmwilkerson and others added 4 commits May 26, 2026 18:38
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
…D-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 <noreply@anthropic.com>
- 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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants