Skip to content

Add deprecated gateway migration workflow - #3700

Open
dparker1005 wants to merge 18 commits into
strangerstudios:devfrom
dparker1005:feature/deprecated-gateway-sunset-flow
Open

Add deprecated gateway migration workflow#3700
dparker1005 wants to merge 18 commits into
strangerstudios:devfrom
dparker1005:feature/deprecated-gateway-sunset-flow

Conversation

@dparker1005

Copy link
Copy Markdown
Member

What this does

Adds a guided sunset workflow for deprecated payment gateways on the Payment Settings page. When editing a deprecated gateway, admins now see a three-step panel:

  1. Activate a new gateway — offers the Stripe Connect flow (or activates Stripe directly if already connected, including webhook setup), with a pointer to the PayPal Add On as an alternative.

  2. Migrate subscriptions — a batched Action Scheduler workflow that processes every active subscription for the gateway in the current environment, with live progress, per-subscription logging, and a stop button. Two strategies:

    • Migrate to Stripe: each subscription is recreated at Stripe as a trialing subscription with no payment method (trial ends on the old next payment date, missing_payment_method => cancel). The member is emailed to add billing information before that date. Billing limits carry over as a remaining-payment count enforced by a $0 bridge order.
    • Cancel and set expiration dates: the membership expires on the old next payment date and the member is emailed to check out again.

    In both cases the old gateway subscription is cancelled last, so members are never left unnotified by a partial failure. Subscriptions without an upcoming payment date are skipped unless the Force option is enabled. Every outcome is recorded as complete / skipped / needs review in a downloadable migration log.

  3. Remove gateway data — once no live subscriptions remain, deletes the stored credentials (PayPal-shared credentials only once no PayPal gateway needs them) and stops loading the gateway.

Implementation notes

  • New includes/deprecated-gateways.php owns gateway loading (moved from includes/deprecated.php) plus the workflow: state machine in a per-gateway/environment option, batches chained through Action Scheduler with a unique initial action, and recovery designed so reruns are safe (crash-safe meta ordering, per-attempt Stripe idempotency keys, active-only placeholder adoption, already-processed subscriptions are skipped).
  • The workflow refuses to run when PMPro services are paused (cloned sites), when the environment or active gateway changed after scheduling, or when a workflow is already queued.
  • Two new editable email templates: deprecated_gateway_stripe_migration and deprecated_gateway_checkout_required, registered only while a deprecated gateway is loaded.
  • New PMProGateway_stripe::create_deprecated_gateway_migration_subscription() creates the placeholder subscription with migration metadata.
  • The Update Billing Information links on the Account and Billing pages no longer require the subscription to have an order, since Stripe placeholder subscriptions start with none (the billing preheader already supports order-less subscriptions).

Testing

  • Migrated active subscriptions to Stripe placeholders in sandbox: placeholder created as trialing with the correct next payment date, member emailed with the billing update link, old subscription cancelled, progress/counters correct.
  • Expiration strategy: enddate set to the old next payment date, checkout-required email sent, subscription cancelled.
  • Edge cases covered by the workflow and verified in code review: deleted users, deleted subscriptions, users who already re-checked-out, billing limits already reached, dead placeholders on rerun (fresh placeholder + re-email), stop/restart, environment switching, cleanup blocked while live subscriptions remain.

🤖 Generated with Claude Code

dparker1005 and others added 6 commits June 3, 2026 12:27
Consolidate all deprecated gateway handling into includes/deprecated-gateways.php
(replacing deprecated-gateway-sunset.php and the gateway functions in
deprecated.php) and rename everything from the sunset prefix to
pmpro_deprecated_gateway_*.

Workflow changes:
- Live workflow state per gateway/environment with progress polling, reset on
  each new run, and deleted on gateway cleanup
- Pause mode guards in both scheduling and the batch worker
- Email members before cancelling, with meta to prevent duplicate emails
- Stripe placeholder recovery via stored transaction IDs to prevent duplicates
- Skip members who already have another active subscription for the level
- Treat missing and past next payment dates the same; Force option expires the
  membership on the missed date and cancels instead of skipping
- Migrate billing limits to Stripe as a remaining-payment count, with a $0
  migration order standing in for the initial order
- Cleanup blocked by live subscriptions in either environment and redirects to
  payment settings with a confirmation message

UI changes:
- Replace the POST form with an AJAX panel built on PMPro components
  (pmpro_section, pmpro_message, pmpro_tag, the wizard stepper)
- Three-step flow: activate a new gateway (with inline Stripe Connect),
  migrate subscriptions, remove gateway data
- Live progress bar with Complete/Skipped/Failed counts and a migration log
  download button on completion
- Instructions to search the migration log for failed and skipped entries

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Skip the in-progress placeholder in the duplicate-subscription check so
  a rerun after a mid-run crash adopts it instead of cancelling the old
  subscription without notifying the member.
- Vary the Stripe idempotency key per creation attempt and only adopt
  active local records, so a dead placeholder is never silently reused.
- Write only counters from record_result() so it cannot revert a
  concurrent stop.
- Normalize the gateway environment everywhere to live/sandbox.
- Enqueue the initial batch as a unique action to prevent parallel
  chains from concurrent start requests.
- Set up Stripe webhooks when activating Stripe from the panel.
- Keep the status poll alive through transient request failures.

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

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Adds an admin-guided “sunset” workflow for deprecated payment gateways, including UI on the Payment Settings screen, Action Scheduler–driven subscription migration (to Stripe placeholders or expiration dates), migration logging, and two new member-facing email templates. It also updates Account/Billing UI to allow “Update Billing Info” links for subscriptions that may not have an associated order (e.g., Stripe placeholder subscriptions).

Changes:

  • Introduces includes/deprecated-gateways.php to own deprecated gateway loading plus a 3-step admin migration/cleanup workflow with logging + AJAX status polling.
  • Adds Stripe-side placeholder subscription creation (PMProGateway_stripe::create_deprecated_gateway_migration_subscription()) and registers two new email templates for member notifications.
  • Relaxes “Update Billing Info” link gating in account/billing templates to support order-less placeholder subscriptions.

Reviewed changes

Copilot reviewed 9 out of 9 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
shortcodes/pmpro_account.php Shows update-billing action link without requiring an order.
pages/billing.php Shows update-billing link for subscriptions without requiring a successful order.
paid-memberships-pro.php Loads the new deprecated-gateways module and new email template classes.
includes/deprecated.php Removes deprecated-gateway helpers moved into the new module.
includes/deprecated-gateways.php New deprecated gateway loading + migration state machine, batch processing, panel UI, AJAX, and logging.
classes/gateways/class.pmprogateway_stripe.php Adds Stripe API helper for creating migration placeholder subscriptions.
classes/email-templates/class-pmpro-email-template-deprecated-gateway-stripe-migration.php New email template for Stripe placeholder migrations.
classes/email-templates/class-pmpro-email-template-deprecated-gateway-checkout-required.php New email template for “checkout again” expiration strategy.
adminpages/paymentsettings.php Replaces deprecated gateway warning with the guided workflow panel + adds “removed” success notice.

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

Comment thread includes/deprecated-gateways.php Outdated
Comment thread includes/deprecated-gateways.php

@flintfromthebasement flintfromthebasement left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

PR: #3700 — Add deprecated gateway sunset workflow
dparker1005 → v3.8 | 9 files, +2081 -103
#3700


Summary

Large, well-constructed feature. The state machine design, idempotency scheme, crash recovery, and staging-site guards are all solid. One bug in the stop implementation that leaves queued AS actions un-cancelled, plus three minor issues. Not blocking, but the stop bug should be fixed before this ships.


Issues

  • Major includes/deprecated-gateways.php:1003as_unschedule_all_actions( '', array(), $group ) passes an empty string as the hook name. AS builds WHERE hook = '' internally, which matches no real actions. The queued pmpro_deprecated_gateway_process_batch actions are not removed from the queue. The state is correctly set to 'stopped', so batches that run will detect it and exit early — but for large migrations, this means dozens to hundreds of no-op AS action runs before the queue drains. The PR description says "Pending batches are unscheduled immediately," which isn't accurate. Fix:

    as_unschedule_all_actions(
        'pmpro_deprecated_gateway_process_batch',
        null,
        pmpro_deprecated_gateway_get_action_group( $gateway, $environment )
    );
  • Minor includes/deprecated-gateways.php:860-866pmpro_deprecated_gateway_record_result() is a read-modify-write on counter fields. With concurrent AS workers (multi-threaded queue runners), two workers can read processed=5, both compute processed=6, and both write processed=6 — losing one count. Low risk with AS's default single-worker behavior, but worth noting if sites run parallel queues.

  • Minor classes/gateways/class.pmprogateway_stripe.php:617catch ( \Throwable $e ) (PHP 7+) subsumes all exceptions and errors. The catch ( \Exception $e ) block immediately after it is dead code in PHP 7+. Remove it, or reorder to \Exception first if PHP 5 compatibility matters here.

  • Minor includes/deprecated-gateways.php:1334$bridge_order->saveOrder() result is unchecked. If the insert fails silently, the billing limit bridge order is missing, and the remaining-payment count will be off by one (allowing one extra renewal cycle for billing-limited subscriptions).


Looks Good

  • The "cancel last" ordering (create replacement → email → cancel old) is the right call. Partial failures leave the member's original subscription intact.
  • The attempt-counter idempotency scheme for dead placeholders is correctly handled — bumping the counter before clearing stale meta ensures Stripe's 24-hour replay window doesn't return the cancelled subscription.
  • Auth check before nonce check in pmpro_deprecated_gateway_ajax() is correct ordering.
  • pmpro_is_paused() guard in both schedule() and process_batch() is good belt-and-suspenders against staging database copies.
  • The shared PayPal credential deletion logic (only delete once all three PayPal gateways are removed) is handled correctly.

Questions

  1. PMPro_Action_Scheduler::dispatch_queue() is called after scheduling the initial batch — does this reliably kick the queue on sites using WP-Cron, or are there configurations where it silently no-ops and the first batch waits for the next cron tick?

dparker1005 and others added 2 commits June 10, 2026 15:33
State explicitly in the strategy description, start confirmation,
member email, and template help text that members who do not add a
payment method before their next payment date will have their
membership cancelled.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Key the bridge order on the placeholder's stored billing limit and only
count successful orders, mirroring billing_limit_reached(). Flag the
subscription as needs_review if the bridge order cannot be saved, since
reruns never revisit this branch and the limit would silently allow one
extra payment.

Addresses PR review feedback from Copilot and flintfromthebasement.

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

Copy link
Copy Markdown
Member Author

@flintfromthebasement Thanks for the thorough review. Point-by-point:

as_unschedule_all_actions( '', array(), $group ) — this works as written. In the bundled Action Scheduler (3.9.2), as_unschedule_all_actions() special-cases this exact signature: with an empty hook, empty args, and a non-empty group, it routes to ActionScheduler_Store::instance()->cancel_actions_by_group( $group ) before any hook-based query is built (see includes/lib/action-scheduler/functions.php). No WHERE hook = '' query is ever constructed, and all pending actions in the group are cancelled in one store call. The explicit-hook form you suggested also works, but it skips that branch and falls through to the one-action-at-a-time as_unschedule_action() loop, so the current call is actually the more efficient path. Possibly older AS versions behaved the way you describe, but not the one shipped here.

record_result() read-modify-write — agreed the pattern is real in general, but it isn't reachable in this design: each batch enqueues the next, so a workflow never has two actions in flight; the initial enqueue uses Action Scheduler's $unique flag, so concurrent start requests can't create parallel chains; and workflows for different gateways/environments write different option keys. If it ever did race, the damage is a cosmetic off-by-one in the progress counters — the per-subscription work is idempotent and unaffected. Leaving as is.

Triple-catch in the Stripe class — you're right that \Exception after \Throwable is dead code on PHP 7+, but the Stripe\Error\Base / \Throwable / \Exception chain is the established pattern throughout class.pmprogateway_stripe.php, so this method keeps it for consistency.

Unchecked $bridge_order->saveOrder() — good catch, fixed in c538a79 together with a related issue Copilot flagged on the same block. A save failure now marks the subscription needs_review with a log note explaining that the billing limit may allow one extra payment — important because reruns never revisit that branch once the placeholder exists, so a silent failure would have been permanent.

dparker1005 and others added 5 commits June 10, 2026 15:46
- Only count same-environment subscriptions in the already-subscribed
  check. A leftover sandbox subscription could previously cause a live
  subscription to be cancelled with no replacement and no member email.
- Block gateway cleanup while sandbox subscriptions remain, matching
  the panel UI, so a direct request cannot orphan them.
- Use esc_url() for the Stripe Connect link output.
- Guard email template recipient accessors against deleted users.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Stripe placeholder subscriptions created by the deprecated gateway
migration start with no payment method, and previously nothing surfaced
that state after the migration email: members got the generic upcoming
payment reminder, and admins had no view of who was still at risk.

- Flag placeholders with deprecated_gateway_needs_payment_method meta,
  cleared when a billing update or completed payment proves a payment
  method exists, or by reverifying against Stripe (throttled to one API
  call per subscription per six hours). Visiting the billing update page
  resets the throttle so payment methods added through the Stripe
  Customer Portal are picked up as soon as the member returns.
- Show an action-required notice on the Membership Account page and send
  the migration email in place of the generic recurring payment reminder
  while the flag is set.
- Add an "Awaiting Payment Method" filter and status tag to the
  Subscriptions list, and an admin notice on PMPro pages linking to the
  filtered list while any flagged subscriptions remain.
- Refuse to start a Stripe migration when Stripe credentials or the
  webhook are missing for the current environment (every subscription
  would otherwise be flagged needs_review), stop a queued workflow if
  credentials are disconnected mid-run, and explain a disabled Stripe
  option in the panel.
- Keep the migration email template registered after gateway cleanup
  while flagged subscriptions remain.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Describe what each strategy actually trades off: the Stripe option
  keeps the member's price and billing schedule and is backed by the
  account notice, swapped payment reminder, and Awaiting Payment Method
  tracking; the expiration option sends the standard expiration emails
  but a new checkout uses current level pricing.
- Match the skipped warning to the "[skipped]" log format, matching the
  needs_review warning.
- Tighten the step 3 removal text.
- Note in the Stripe migration email template description that it is
  also sent in place of the upcoming payment reminder.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Webhook detection and warnings already live on the Stripe gateway
settings page, so they no longer gate the migration panel; on local
sites Stripe disables undeliverable test webhooks, which was greying
out the Stripe migration option. Missing credentials still block the
Stripe strategy in the panel, at schedule time, and mid-run.

Replace the migration-specific readiness method with a reusable
PMProGateway_stripe::has_credentials(), which unlike
has_connect_credentials() also covers legacy API keys, and route all
three call sites through the panel blockers helper. With no API call
involved, the readiness transient cache is no longer needed.

Also note in the Stripe strategy description that trial pricing cannot
be migrated: members still in a trial period will be billed the regular
price beginning with their next payment.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Subscriptions can carry nonstandard gateway_environment values (imports,
hand-edited data). The counts bucketed anything non-live as sandbox, but
the batch query and per-subscription checks matched 'sandbox' exactly, so
those rows inflated the total, were never processed, and blocked gateway
cleanup permanently. Treat anything that is not 'live' as sandbox
everywhere via a single helper.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@dparker1005 dparker1005 added this to the 3.8 milestone Jun 11, 2026
@dparker1005 dparker1005 changed the title Add deprecated gateway sunset workflow Add deprecated gateway migration workflow Jun 11, 2026
dparker1005 and others added 5 commits June 12, 2026 10:44
A dry run analyzes every subscription with the same decision logic as a
real run and records the planned outcome in the migration log, but makes
no changes: no gateway calls, no cancellations, no emails, and no
database writes. Local data problems (lapsed payment dates, reached
billing limits, members who already re-checked-out, deleted users) show
up in the preview; gateway-side failures can only surface during a real
run. Subscriptions already mid-migration from an earlier run are
reported as resumable rather than simulating the recovery logic.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Default the migration to a dry run and add a clear real-run treatment:
  unchecking Dry Run turns the panel red, relabels the button to "Start
  Real Migration", and flashes a permanence warning (respects
  prefers-reduced-motion). Avoids the word "live" to prevent confusion
  with the gateway environment.
- Add external-link icons to the offsite links in the panel.
- Make the two migration email templates more end-user friendly.
- Add a pmpro_deprecated_gateway_batch_size filter for the per-batch count.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Each Stripe migration can make several sequential gateway API calls, so a
batch of 10 risked approaching the PHP execution time limit mid-run, which
would break the chain to the next batch. Halving the default greatly reduces
that timeout exposure. Still filterable via pmpro_deprecated_gateway_batch_size.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replaces the jargony "Force Migration" checkbox with a labeled choice about
how subscriptions with a missed payment (a next payment date in the past, or
none at all) are handled: skip and log for manual review (default), or cancel
at the old gateway and expire the membership.

The copy now notes that a missed payment date is most often a missed IPN or
webhook rather than a stopped subscription, so the gateway may still be
billing the member -- making "skip and verify" the safe default and the
cancel/expire path an explicit opt-in.

Renames the internal flag from $force to $expire_past_due (and the per-
subscription $force_expiration to $expire_this_subscription) across the
schedule/batch/process functions, state, AJAX wire param, and log messages.
No change to migration behavior.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Each migration run now writes a downloadable CSV of the members it touched, in
addition to the existing global text log. A run ID stored in the workflow state
names the file (deprecated-gateways-{gateway}_{env}_{stamp}.csv), and each batch
appends one row per processed subscription:

  processed_date, user_id, user_email, display_name, membership_level,
  old_subscription_id, new_subscription_id, new_subscription_transaction_id,
  action, handoff_date, outcome, email_sent, notes

The two subscription-id columns point at the new placeholder in PMPro and in the
Stripe dashboard respectively. This lets sites bulk-send through an email service
(run with emails off, export, send) instead of firing individual emails, and
gives an audit/triage record per run.

- process_subscription() now returns structured fields via a result helper.
- The panel offers a "Download Migration CSV" button after a completed run and
  lists all of a gateway's CSVs at the cleanup step.
- The Member Emails setting explains the ESP/CSV alternative.
- The restricted-file access filter allows per-run .csv/.txt files (capability
  gated, traversal-safe).
- Cleanup keeps the CSVs and text log as a record; it does not delete them.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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.

3 participants