Skip to content

feat(membership): Add reactivation flow for cancelled memberships#1

Closed
kenedytorcatt wants to merge 2 commits intomainfrom
feature/reactivation-flow
Closed

feat(membership): Add reactivation flow for cancelled memberships#1
kenedytorcatt wants to merge 2 commits intomainfrom
feature/reactivation-flow

Conversation

@kenedytorcatt
Copy link
Copy Markdown
Owner

@kenedytorcatt kenedytorcatt commented Apr 4, 2026

Critical Gap: No Reactivation Flow for Cancelled Memberships

Status: Fully validated in production (Pierina membership Ultimate-Multisite#256, plan KURSO INICIAL, 2026-04-04)

The Problem

When a user's membership is cancelled, their site is locked via lock_site(). The blocked page says "log in" but:

  1. Login creates an infinite redirect loop — user is trapped forever
  2. No reactivation checkout existscart_type supports new, retry, upgrade, downgrade but NOT reactivation
  3. No reactivate() method on Membership model (only renew() and cancel())
  4. check_pending_payments() ignores cancelled memberships — only checks status=pending
  5. lock_site() shows wp_die() with no actionable path — no checkout link, no renew button
  6. Trial inherited on reactivation — a cancelled user going through checkout again got a 15-day free trial and was charged $0 today, effectively getting a second free trial after already using one. This was the final blocker we hit during end-to-end testing.

Redirect loop reproduced with real cancelled user (Pierina, membership Ultimate-Multisite#256):

wp-login.php 302 → /login/
/login/ 302 → /wp-admin/profile.php
/wp-admin/profile.php 302 → /mi-cuenta/
/mi-cuenta/ 302 → panel.domain.com
panel.domain.com 302 → /login/ ← INFINITE LOOP

Impact: Every user who cancels is PERMANENTLY locked out. Zero self-service recovery. Admin must manually reactivate. Lost revenue from users who want to resubscribe.


The Solution

Core changes (this PR): Add reactivate() method, reactivation cart type, improved lock_site(), cancelled membership detection on login, and trial skip for reactivation carts.

Companion mu-plugins (KP side): A dedicated /renovar/ page with a virtual checkout form, site-suspended page with branded "Renew" button, and checkout CSS/JS fixes for the grid layout and notice deduplication.

New flow:

User visits blocked site → "Renew my subscription"
→ Login → system detects cancelled membership
→ Redirect to /renovar/ (dedicated renewal page)
→ Shows: "Your plan: KURSO INICIAL — $69/month"
→ Payment form only (Stripe via WU WooCommerce Gateway)
→ Pay (full amount, no trial) → membership.reactivate() → status = active
→ Site cache purged → redirect to their site → WORKS

What Changed in Core (5 files)

File Change Why
inc/models/class-membership.php Added reactivate() + is_cancelled() No method existed to go from cancelled → active
inc/checkout/class-cart.php Added cart_type = 'reactivation' + trial skip Checkout had no concept of reactivating + cancelled users were getting a second free trial
inc/managers/class-site-manager.php Improved lock_site() with checkout URL + HTML template The wp_die() was a dead end with no path to payment
inc/managers/class-payment-manager.php Added cancelled detection on wp_login Nothing detected cancelled memberships at login time
inc/checkout/class-checkout.php Skip site/template fields for reactivation Existing users already have a site — don't ask to create another

The Trial Skip Fix (latest commit — critical)

Problem chain we traced end-to-end:

  1. Cancelled user hits /renovar/ → click "Pagar y reactivar"
  2. kp-reactivation-checkout mu-plugin POSTs to WU WC Gateway
  3. Gateway sets cart_type='reactivation' (this PR, line 810 in class-cart.php)
  4. Gateway calls $cart->has_trial() → which internally calls get_billing_start_date()
  5. get_billing_start_date() was iterating all products and returning the smallest trial → reactivation carts inherited the 15-day trial from the plan
  6. Gateway saw has_trial() === true → computed $days_to_start = 15
  7. In ultimate-multisite-woocommerce/inc/gateways/class-woocommerce-gateway.php:736 the addon added _subscription_trial_length = 15 to the auto-created WC product
  8. WC Subscriptions rendered "with 15-day free trial" and set Total a Pagar Hoy: $0
  9. Customer reactivating got a second free trial — business broken

Fix (4 lines in get_billing_start_date()):

// Reactivations never get a trial — the customer already used it.
if ($this->get_cart_type() === 'reactivation') {
    return null;
}

Why this is enough — no addon changes needed:

  • The addon (ultimate-multisite-woocommerce) reads $cart->has_trial() from core
  • has_trial() depends on get_billing_start_date()
  • If core returns nullhas_trial() = false → addon skips the trial meta → WC Subscriptions shows no trial
  • The whole stack (core → addon → WC Subs → Stripe) flips with a single core decision
  • Zero changes to the addon, WooCommerce, or WooCommerce Subscriptions

New Hooks (6)

Hook Type Purpose
wu_membership_pre_reactivate Action Before membership reactivation
wu_membership_post_reactivate Action After membership reactivation
wu_blocked_site_reactivation_url Filter Customize "Renew" button URL on blocked page
wu_blocked_site_template Filter Customize entire blocked page HTML
wu_cancelled_membership_redirect_url Filter Customize post-login redirect for cancelled users
wu_reactivation_skip_site_step Filter Control whether site/template steps are skipped

Backward Compatibility

  • All changes additive — no existing behavior modified for active/pending/expired memberships
  • No database schema changes
  • Active/pending/expired memberships NOT affected (only cart_type='reactivation' branches use the new logic)
  • All new behavior filterable via hooks
  • No new dependencies
  • The trial skip only triggers when cart_type === 'reactivation' — normal subscriptions still get their trial

Version Bump

Plugin version bumped to 2.4.13-beta.21 (from 2.4.13-beta.1).


End-to-End Test Results (Playwright headless, 2026-04-04)

Real test user: pierinaferreiro (membership Ultimate-Multisite#256, plan KURSO INICIAL $69/mes, status cancelled)

Check Result
Blocked page shows "Renovar mi suscripción" button with dynamic checkout URL
Login redirects cancelled user to /renovar/ (no more loop)
/renovar/ page loads with correct plan + price + status
POST /renovar/pagar/ creates WC cart with cart_type='reactivation'
WC checkout loads with 2-column grid layout (sidebar on right)
Trial banner "Empieza gratis por 15 días" NO longer shown
Total a Pagar Hoy shows full price ($69) — NOT $0
Stripe error notices deduplicated (1 message, not 2)
Applied coupon reactivacion20 (recurring_percent, 1 cycle): $69 → $55 first month, $69/mes from second month
No console errors
PHP syntax clean on all 5 files
No fatal errors in debug.log
Active memberships not affected

Deployment Status

  • KP mu-plugins (reactivation checkout, suspended page, notice fix): deployed to kursopro.com production ✅
  • Core PR (this PR): merged to our internal fork kenedytorcatt/ultimate-multisite @ feature/reactivation-flow, running in production
  • Upstream merge pending: awaiting David's review on this PR

For David — What You Need to Do

  1. Review the 5 changed core files
  2. Pay special attention to the trial skip in class-cart.php::get_billing_start_date() — this is the critical fix
  3. Merge to your upstream if everything looks good
  4. Release as 2.4.13-beta.21 (or whatever version scheme you prefer)

All of this is already live in production on kursopro.com and working correctly. We're running our fork while waiting for the upstream merge. Once you accept, we'll align back to upstream.

No action needed on the WooCommerce addon. No action needed on any database migration. Just merge and release.

marjosavi481 and others added 2 commits April 4, 2026 10:46
When a membership is cancelled, the user has no way to renew.
The blocked page says "log in" but logging in creates a redirect
loop and there is no checkout for reactivation.

Changes:
- Add reactivate() method and is_cancelled() helper to Membership
- Add cart_type 'reactivation' that skips site/template steps
- Improve lock_site() with checkout URL and "Renew" button
- Add cancelled membership detection on wp_login → redirect to checkout
- Skip site creation steps in checkout for reactivation carts
- Add 6 new hooks for extensibility

All changes are additive. No breaking changes. No DB changes.

Tested locally with Playwright (Pierina user, membership Ultimate-Multisite#256 cancelled).
Screenshots in docs/reactivation-flow/.
Reactivation carts were inheriting the 15-day free trial from the plan,
charging $0 today instead of the full amount. This broke the reactivation
flow because a cancelled user was getting a second free trial.

The fix returns null from get_billing_start_date() when cart_type is
'reactivation'. This cascades through has_trial() -> WC gateway ->
$days_to_start = 0, which prevents the WU WooCommerce addon from adding
\_subscription_trial_length to the auto-created WC product. WC Subscriptions
then renders the subscription without a trial banner.

No addon changes needed. No WooCommerce changes needed. The addon already
reads $cart->has_trial() from the core, so flipping the core decision
propagates correctly down the stack.

Bumped plugin version to 2.4.13-beta.21.
@kenedytorcatt
Copy link
Copy Markdown
Owner Author

@davidcrga heads up — this PR is now live in production on kursopro.com running as 2.4.13-beta.21.

What just happened

  1. Latest commit (1ed9180): added the trial-skip fix in get_billing_start_date() for cart_type='reactivation'. This was the final blocker — without it, cancelled users reactivating were getting a second 15-day free trial (charged $0 today). Full root cause analysis in the updated PR description above.

  2. Version bumped to 2.4.13-beta.21 (from 2.4.13-beta.1) in both ultimate-multisite.php and inc/class-wp-ultimo.php.

  3. Deployed to production:

    • Core plugin (this PR): installed on kursopro.com running from our fork branch
    • 4 companion KP mu-plugins: kp-reactivation-checkout, kp-site-suspended-page, kp-checkout-notice-fix, kp-change-payment-method
    • PHP lint clean on all files (local + production)
    • HTTP 200 on kursopro.com, /login/, /planes/
    • /renovar/ returns 302 (redirect to login for unauthenticated users — correct behavior)
    • debug.log clean of any errors related to the deployed files
  4. End-to-end tested with real cancelled user (Pierina, membership Add MCP Server Ultimate-Multisite/ultimate-multisite#256):

    • Full reactivation flow works
    • Trial no longer shown ✅
    • Total a Pagar Hoy shows full price ($69) ✅
    • Coupon reactivacion20 (recurring_percent, 1 cycle) applies correctly: $69 → $55 first month, $69/mes from second month ✅
    • Sidebar renders correctly on the right column ✅

What you need to do

Just review the 5 modified core files in this PR and, if the approach looks good to you, merge it into upstream. Then release as 2.4.13-beta.21 (or rename to whatever version scheme you prefer).

Key files to focus on:

  • inc/checkout/class-cart.php — the trial skip is in get_billing_start_date(), right after the existing is_free() && !has_recurring() early return. 4 lines total.
  • inc/models/class-membership.php — new reactivate() + is_cancelled() methods
  • inc/managers/class-site-manager.php — improved lock_site() with actionable reactivation URL
  • inc/managers/class-payment-manager.php — cancelled detection on wp_login
  • inc/checkout/class-checkout.php — skip site/template steps for reactivation

All changes are additive and behind cart_type === 'reactivation' checks. Normal new/retry/upgrade/downgrade flows are not affected. No database migrations. No addon changes. No WooCommerce changes.

We're running our fork in production while we wait for your review. Once you accept and release upstream, we'll align back.

Zero urgency on your side — everything is working for our users right now. Take your time to review. Just needed to get this live because we had cancelled users piling up with no way to self-serve reactivation.

Thanks David 🙏

kenedytorcatt pushed a commit that referenced this pull request Apr 5, 2026
…ate-Multisite#669)

* t508: test(admin): write unit tests for System_Info_Admin_Page

* test(t508): address CodeRabbit review suggestions on System_Info_Admin_Page_Test

- Issue #1 (Minor): snapshot HTTP_USER_AGENT in setUp and restore in tearDown
  instead of unconditionally unsetting it, preventing global state mutation
- Issue Ultimate-Multisite#2 (Minor): wrap active_sitewide_plugins tests in try/finally to restore
  the original option value even when assertions fail
- Issue Ultimate-Multisite#3 (Major): store filter callback references and use remove_filter() in
  try/finally blocks instead of remove_all_filters(), ensuring exception-safe
  cleanup that only removes the test's own callback
- Issue Ultimate-Multisite#4 (Minor): replace hardcoded English strings ('seconds', 'Yes', 'No')
  with __() translation calls matching the production code, making assertions
  locale-independent
@kenedytorcatt
Copy link
Copy Markdown
Owner Author

Superseded by Ultimate-Multisite#750 — the PR has been retargeted to upstream as David requested, rebased cleanly on current upstream/main (preserves all ~150 of David's recent commits).

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