Skip to content

feat: reactivation flow for cancelled memberships + trial skip#750

Open
kenedytorcatt wants to merge 1 commit intoUltimate-Multisite:mainfrom
kenedytorcatt:feature/reactivation-flow-upstream
Open

feat: reactivation flow for cancelled memberships + trial skip#750
kenedytorcatt wants to merge 1 commit intoUltimate-Multisite:mainfrom
kenedytorcatt:feature/reactivation-flow-upstream

Conversation

@kenedytorcatt
Copy link
Copy Markdown

@kenedytorcatt kenedytorcatt commented Apr 5, 2026

Reactivation Flow for Cancelled Memberships (+ trial skip)

Hi David 👋 — this PR adds a complete self-service reactivation flow for users whose memberships have been cancelled, so they can re-subscribe without admin intervention, and fixes a revenue leak where cancelled users were getting a second free trial.

This branch is rebased directly onto current upstream/main — all of your recent work (FrankenPHP, SSO GlotPress, password auto-generate, t524, etc.) is preserved untouched. The diff is purely additive: +354 lines / −19 lines across 5 core files.

Status: Fully built, tested end-to-end with a real cancelled user, and currently running in production on kursopro.com as 2.4.13-beta.21 while we wait for your review.


The Problem

When a user's membership is cancelled, Site_Manager::lock_site() blocks their site frontend. The blocked page tells them to "log in" — but:

  1. Login creates an infinite redirect loop — cancelled users are trapped forever
  2. No reactivation checkout existscart_type supports new, retry, upgrade, downgrade but NOT reactivation
  3. No reactivate() method on the 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 — returning cancelled users going through checkout again got the full 15-day free trial again and were charged $0 today. This is the critical revenue leak and the reason for the trial-skip commit.

Redirect loop reproduced with a real cancelled user (membership #256):

wp-login.php       302 → /login/
/login/            302 → /wp-admin/profile.php
/wp-admin/profile  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. An admin has to manually reactivate each account. Lost revenue from users who genuinely want to resubscribe.


Why the 15-day trial must be skipped on reactivation

This is the most important part to review — the business reasoning, not just the code:

  • A reactivation is not a new signup. The customer was already a paying client; they cancelled; now they want back in.
  • They already consumed the 15-day free trial when they originally signed up. Giving them a second trial means they pay $0 today and get 15 more free days — effectively letting anyone cancel + re-signup on the same day to extend their trial indefinitely.
  • Before this fix, the checkout was showing "Empieza gratis por 15 días" and Total a Pagar Hoy: $0 to cancelled users reactivating. Confirmed revenue leak in production.
  • A reactivation should charge the full plan price immediately and resume the subscription cycle from day one.

The fix enforces this at the core level in 4 lines so the whole stack (core → addon → WC Subscriptions → Stripe) honors it with zero ambiguity.


The trial skip (4 lines in Cart::get_billing_start_date())

// Reactivations never get a trial — the customer already used it
// when they originally signed up. Giving them another trial would
// let anyone cancel + re-signup to extend their trial indefinitely.
if ($this->get_cart_type() === 'reactivation') {
    return null;
}

Why this is enough — no addon changes needed:

  1. ultimate-multisite-woocommerce reads $cart->has_trial() from core
  2. Cart::has_trial() depends on Cart::get_billing_start_date()
  3. If core returns nullhas_trial() === false → addon skips the _subscription_trial_length meta → WC Subscriptions shows no trial and charges the full amount immediately
  4. A single core decision flips the entire cascade
  5. Zero changes to the addon, WooCommerce, or WooCommerce Subscriptions

Full problem chain we traced end-to-end before finding the root cause:

  1. Cancelled user hits /renovar/ → clicks "Pagar y reactivar"
  2. Our companion mu-plugin POSTs to the WU WC Gateway
  3. Gateway sets cart_type='reactivation' (new cart type added in this PR)
  4. Gateway calls $cart->has_trial() → internally calls get_billing_start_date()
  5. get_billing_start_date() iterated all products and returned the smallest trial → reactivation carts were inheriting the 15-day trial from the plan product
  6. Gateway saw has_trial() === true → computed $days_to_start = 15
  7. Addon at ultimate-multisite-woocommerce/inc/gateways/class-woocommerce-gateway.php:736 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

What Changed (5 core files, all additive)

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 Detect cancelled membership in build_from_membership()cart_type='reactivation' + trial skip in get_billing_start_date() Checkout had no concept of reactivating + cancelled users were getting a second free trial
inc/checkout/class-checkout.php Skip site_url/site_title/template_selection fields for reactivation carts + maybe_create_site() returns existing site Existing customers already have a site — don't ask them to create another
inc/managers/class-payment-manager.php Added maybe_redirect_cancelled_membership() on wp_login Nothing detected cancelled memberships at login time — caused the redirect loop
inc/managers/class-site-manager.php Replaced the wp_die() dead-end in lock_site() with a friendly HTML page showing a "Renew your subscription" button The wp_die() was a dead end with no path to payment

All new code is gated behind cart_type === 'reactivation' or method_exists($membership, 'is_cancelled') && $membership->is_cancelled() checks. Normal new/retry/upgrade/downgrade flows are completely untouched. The keep_until_live demo-mode logic you recently added in lock_site() is preserved exactly as-is.


New Hooks (6)

Hook Type Purpose
wu_membership_pre_reactivate Action Fires before membership reactivation
wu_membership_post_reactivate Action Fires after membership reactivation
wu_blocked_site_reactivation_url Filter Customize the "Renew" button URL on the blocked page
wu_blocked_site_template Filter Customize the entire blocked-page HTML
wu_blocked_site_support_url Filter Add a support URL to the blocked page
wu_cancelled_membership_redirect_url Filter Customize the post-login redirect URL for cancelled users

Backward Compatibility

  • All changes additive — no existing behavior modified for active/pending/expired/trialing memberships
  • No database schema changes
  • Trial skip only triggers when cart_type === 'reactivation' — normal subscriptions still get their trial as before
  • All new behavior is filterable via hooks
  • No new dependencies
  • No addon changes required
  • No WooCommerce or WC Subscriptions changes required
  • method_exists() guards used when calling is_cancelled() on the membership object so the code degrades gracefully if applied to older forks

End-to-End Test Results (real cancelled user, production)

Plan: KURSO INICIAL $69/month, status: cancelled, user: pierinaferreiro (membership #256)

Check Result
Blocked page shows "Renew your subscription" button with dynamic checkout URL
Login redirects cancelled user to reactivation checkout (no more loop)
Reactivation checkout page loads with correct plan + price + status
Cart is created with cart_type='reactivation'
Trial banner "Empieza gratis por 15 días" NO longer shown
Total a Pagar Hoy shows full price ($69) — NOT $0
Recurring-percent coupon applied (1 cycle): $69 → $55 first month, $69/mo after
php -l clean on all 5 files
No fatals in debug.log
Active/trialing/pending/expired memberships not affected

Deployment Status

  • Core plugin (this PR's changes): deployed to kursopro.com production as 2.4.13-beta.21
  • HTTP checks after deploy: kursopro.com 200, /login/ 200, /planes/ 200, /renovar/ 302 (correct redirect for unauthenticated) ✅
  • debug.log: clean of any errors from the deployed files ✅

We're running our fork in production while we wait for your review. Once you merge and release upstream, we'll realign to your tag.


What I need from you

  1. Review the 5 core files. The trial skip in Cart::get_billing_start_date() is the critical one for the revenue leak.
  2. Merge if everything looks good.
  3. Release as 2.4.13-beta.21 (or any version scheme you prefer).

Zero urgency on your side — everything is working for our users right now. Take the time you need to review. I just needed to ship this because cancelled users were piling up with no self-service path back in.

Thanks David 🙏

Summary by CodeRabbit

  • New Features

    • Membership reactivation flow allowing cancelled members to renew and restore access
    • Automatic redirect to renewal checkout when logging in with a cancelled membership
  • Checkout Improvements

    • Streamlined reactivation checkout: removed certain site-setup steps, no signup fees, and no trial start date
  • Blocked Site Experience

    • Customizable blocked-site page with a “Renew your subscription” link when applicable
  • Version

    • Updated to 2.4.13-beta.21

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Apr 5, 2026

📝 Walkthrough

Walkthrough

Detects cancelled memberships and introduces a "reactivation" cart/checkout flow: login redirects to a reactivation checkout, the cart can be rebuilt from a cancelled membership with signup fees disabled, checkout skips site-creation/customization fields, and membership model gains reactivation helpers.

Changes

Cohort / File(s) Summary
Reactivation cart logic
inc/checkout/class-cart.php
Adds detection of cancelled memberships in build_from_membership() to set cart_type = 'reactivation' when conditions match; copies membership/customer locale/currency, populates products/duration, disables signup fees, and returns early. get_billing_start_date() now returns null for reactivation carts.
Reactivation checkout flow
inc/checkout/class-checkout.php
Filters out site-creation/customization fields (site_url, template_selection, site_title) in setup_checkout() for reactivation carts; maybe_create_site() short-circuits and returns an existing site for reactivation orders.
Login redirect for cancelled memberships
inc/managers/class-payment-manager.php
Registers maybe_redirect_cancelled_membership() on wp_login to detect cancelled memberships and redirect to the register checkout with plan_id and membership_id; exposes wu_cancelled_membership_redirect_url filter.
Blocked-site handling & templates
inc/managers/class-site-manager.php
Replaces wp_die() with explicit 403 HTML output, adds reactivation URL generation for cancelled memberships, and introduces filters wu_blocked_site_reactivation_url, wu_blocked_site_support_url, and wu_blocked_site_template.
Membership reactivation methods
inc/models/class-membership.php
Adds is_cancelled(): bool and reactivate(): boolreactivate() fires pre/post hooks, calls renew(false,'active'), clears cancellation date, saves, and logs outcomes.
Version bump
inc/class-wp-ultimo.php, ultimate-multisite.php
Updates plugin version from 2.4.13-beta.1 to 2.4.13-beta.21 (constants/header metadata).

Sequence Diagram

sequenceDiagram
    participant User as User
    participant WP as WordPress (wp_login)
    participant PM as Payment Manager
    participant Mem as Membership Model
    participant CO as Checkout
    participant Cart as Cart Model

    User->>WP: login
    WP->>PM: maybe_redirect_cancelled_membership(user_login,user)
    PM->>Mem: load customer's memberships
    Mem-->>PM: returns cancelled membership
    PM->>PM: build reactivation URL (plan_id,membership_id)
    PM->>User: wp_safe_redirect(reactivation URL)

    User->>CO: open reactivation checkout
    CO->>Cart: build_from_membership(membership, product_ids?)
    Cart->>Mem: membership->is_cancelled()
    Mem-->>Cart: true
    Cart->>Cart: set cart_type='reactivation', copy currency/country, populate products, disable signup fees
    Cart-->>CO: configured cart

    CO->>CO: setup_checkout() -> remove site fields for reactivation
    CO->>CO: maybe_create_site() -> return existing site (skip creation)

    User->>Mem: confirm/reactivate
    Mem->>Mem: reactivate() (renew, clear cancellation, save, hooks)
    Mem-->>User: reactivation result
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Poem

🐰 I sniffed a cancelled membership trail,
A hop, a redirect—reactivation set sail.
Signup fees hidden, fields tucked away,
One gentle click brings membership day.
Hooray! 🥕✨

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title clearly and concisely summarizes the two main features: reactivation flow for cancelled memberships and trial skip. It matches the actual changes across multiple files and aligns with the PR objectives.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@kenedytorcatt kenedytorcatt force-pushed the feature/reactivation-flow-upstream branch from e9fac9a to 8e4d6c3 Compare April 5, 2026 05:30
@superdav42
Copy link
Copy Markdown
Collaborator

Please do not include the images in the docs folder

… trial skip

Adds a complete self-service reactivation flow for cancelled memberships
and skips the 15-day trial on reactivation carts to prevent a revenue leak.

Core changes (5 files, additive — gated behind cart_type='reactivation'):

* inc/models/class-membership.php
  - Add is_cancelled() helper
  - Add reactivate() method (reuses renew() + wu_membership_pre/post_reactivate hooks)

* inc/checkout/class-cart.php
  - Detect cancelled membership in build_from_membership() and set cart_type='reactivation'
  - Skip trial in get_billing_start_date() when cart_type='reactivation'
    (customer already used their trial — prevents infinite free trials via cancel+resub)

* inc/checkout/class-checkout.php
  - Skip site_url/site_title/template_selection fields for reactivation carts
  - maybe_create_site() returns existing site instead of creating a new one

* inc/managers/class-payment-manager.php
  - maybe_redirect_cancelled_membership() on wp_login: redirects users
    whose only memberships are cancelled to the reactivation checkout
    (breaks the infinite login redirect loop)

* inc/managers/class-site-manager.php
  - Replace the wp_die() dead-end in lock_site() with a friendly HTML page
    that shows a 'Renew your subscription' button. Filterable via
    wu_blocked_site_reactivation_url and wu_blocked_site_template.

New hooks:
  * wu_membership_pre_reactivate         (action)
  * wu_membership_post_reactivate        (action)
  * wu_blocked_site_reactivation_url     (filter)
  * wu_blocked_site_template             (filter)
  * wu_blocked_site_support_url          (filter)
  * wu_cancelled_membership_redirect_url (filter)

No DB changes. No addon changes. No WooCommerce changes.
Active/pending/expired memberships are not affected — new logic only runs
when cart_type === 'reactivation'.

Version bump: 2.4.13-beta.21
@kenedytorcatt kenedytorcatt force-pushed the feature/reactivation-flow-upstream branch from 8e4d6c3 to 85439b4 Compare April 5, 2026 05:35
@kenedytorcatt
Copy link
Copy Markdown
Author

Done — removed the docs/reactivation-flow/ folder. The PR is now code-only (7 files, same +354 / -19). Thanks @superdav42 🙏

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 7

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@inc/checkout/class-cart.php`:
- Around line 818-847: The reactivation branch returns before validating that
the current customer actually owns the cancelled membership, allowing a
guest/other customer to reuse someone else's membership; modify the logic in the
method containing the shown block (class-cart.php) to perform the ownership
check (compare $this->customer->get_id() / $this->customer with
$membership->get_user_id() or the membership ownership accessor) before setting
cart_type = 'reactivation', calling set_currency(), adding products, or adding
the wu_apply_signup_fee filter, and only proceed with the reactivation setup and
the early return if the ownership check passes; if the check fails, do not treat
it as a reactivation so the later permission guard and maybe_create_membership()
run as normal.
- Around line 829-835: When $this->attributes->products is empty the cart
rebuild only adds $membership->get_plan_id(), skipping any attached add-ons on
the existing membership; change the empty case to pull product IDs from the
membership model and add them all. Replace the current branch so that when
$this->attributes->products is empty you retrieve the membership's product IDs
(e.g. via $membership->get_product_ids() or $membership->get_products()),
iterate those IDs and call $this->add_product(...) for each (making sure the
plan ID is included and duplicates are avoided), otherwise keep the existing
foreach for $this->attributes->products.

In `@inc/checkout/class-checkout.php`:
- Around line 1343-1346: The branch that returns current($sites) on reactivation
can return a pending/stale site because Membership::get_sites() includes pending
metadata; update the logic in the reactivation branch (the code that checks
$this->order->get_cart_type() === 'reactivation' and calls
$this->membership->get_sites()) to scan/filter the returned sites for a
published/live site (e.g., check each site's published status or
is_published()/status === 'published') and return the first published site,
otherwise return false so site creation proceeds. Ensure you replace the direct
current($sites) return with that filtered lookup using the site object
property/method that indicates published state.
- Around line 478-489: The reactivation check runs too late because
request_or_session('cart_type') and $this->order may be 'new' or null on the
initial request; update the logic in the checkout class (around the block using
request_or_session, $this->order, and get_cart_type) to also infer reactivation
when membership_id or plan_id is present in the request (e.g.,
$this->request_or_session or $_REQUEST), and then apply the same filtering of
$this->step['fields'] (removing site_field_types via wu_get_isset) so the
reactivation path is used before process_order() builds the cart; ensure you
still preserve the existing checks against $this->order->get_cart_type() to
cover already-built orders.

In `@inc/managers/class-payment-manager.php`:
- Around line 233-245: The code currently stops on the first cancelled
membership (the $cancelled_membership set inside the foreach that breaks), which
can pick the wrong subscription; instead, collect all cancelled memberships from
get_memberships() by checking method_exists(..., 'is_cancelled') &&
is_cancelled(), then choose the appropriate one deterministically: prefer a
membership whose site/blog id matches the current site (e.g., compare
$membership->get_site_id() or get_blog_details()/get_current_blog_id()), or if
none match and there are multiple cancelled memberships, do not
auto-redirect—return or present a selection to the user; update the logic around
$cancelled_membership and the redirect to use this deterministic selection
strategy rather than breaking on the first match.

In `@inc/managers/class-site-manager.php`:
- Around line 465-489: Initialize and ensure a sensible fallback for
$reactivation_url so the blocked-site template always has a CTA: before the
membership/is_cancelled block set $reactivation_url = '' (or fetch the generic
checkout/register URL) and then, if the cancelled-membership logic builds a
specific URL use that; otherwise keep the generic checkout URL (or a safe
placeholder) and still run it through
apply_filters('wu_blocked_site_reactivation_url', ...). Update the code around
the membership check (the block using $membership->is_cancelled(),
$checkout_pages->get_page_url('register'), and apply_filters) so
$reactivation_url is always defined and contains a usable fallback when not
built for cancelled memberships.

In `@inc/models/class-membership.php`:
- Around line 2434-2449: The reactivation flow does a second save after calling
$this->renew(), which causes listeners to run with the stale cancellation date
and makes reactivate() return false even when renewal already persisted; move
the call to $this->set_date_cancellation(null) so it runs before
$this->renew(false, 'active') and remove the subsequent $this->save() /
is_wp_error() block so reactivation becomes a single write handled by renew();
update reactivate() to rely on renew() to persist and only log/return based on
the renew() result.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 5232449b-c676-49bd-8643-60d8d259b3b8

📥 Commits

Reviewing files that changed from the base of the PR and between ce04b0a and e9fac9a.

⛔ Files ignored due to path filters (6)
  • docs/reactivation-flow/after-01-renew-button.png is excluded by !**/*.png
  • docs/reactivation-flow/after-02-checkout-page.png is excluded by !**/*.png
  • docs/reactivation-flow/after-03-blocked-page-improved.png is excluded by !**/*.png
  • docs/reactivation-flow/before-01-suspended-page.png is excluded by !**/*.png
  • docs/reactivation-flow/before-02-after-login-loop.png is excluded by !**/*.png
  • docs/reactivation-flow/before-03-wpdie-ugly.png is excluded by !**/*.png
📒 Files selected for processing (7)
  • inc/checkout/class-cart.php
  • inc/checkout/class-checkout.php
  • inc/class-wp-ultimo.php
  • inc/managers/class-payment-manager.php
  • inc/managers/class-site-manager.php
  • inc/models/class-membership.php
  • ultimate-multisite.php

Comment on lines +818 to +847
if (method_exists($membership, 'is_cancelled') && $membership->is_cancelled()) {
$plan_matches = ! empty($this->attributes->products)
&& in_array($membership->get_plan_id(), (array) $this->attributes->products, false);

if ($plan_matches || empty($this->attributes->products)) {
$this->cart_type = 'reactivation';
$this->membership = $membership;

$this->country = $this->country ?: ($this->customer ? $this->customer->get_country() : '');
$this->set_currency($membership->get_currency());

if (empty($this->attributes->products)) {
$this->add_product($membership->get_plan_id());
} else {
foreach ($this->attributes->products as $product_id) {
$this->add_product($product_id);
}
}

$plan_product = $membership->get_plan();

if ($plan_product) {
$this->duration = $plan_product->get_duration();
$this->duration_unit = $plan_product->get_duration_unit();
}

// Skip signup fee for reactivations — they already paid it.
add_filter('wu_apply_signup_fee', '__return_false');

return true;
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.

⚠️ Potential issue | 🔴 Critical

Reactivation currently bypasses membership ownership checks.

This early return runs before the permission guard below, so a guest or another customer can pass a cancelled membership_id and checkout will later reuse that membership in maybe_create_membership(). The customer ownership check needs to happen before any reactivation setup.

🛠️ Suggested fix
 		$membership = wu_get_membership($membership_id);
 
 		if ( ! $membership) {
 			$this->errors->add('membership_not_found', __('The membership in question was not found.', 'ultimate-multisite'));
 
 			return true;
 		}
+
+		if (empty($this->customer) || $this->customer->get_id() !== $membership->get_customer_id()) {
+			$this->errors->add('lacks_permission', __('You are not allowed to modify this membership.', 'ultimate-multisite'));
+
+			return true;
+		}
 
 		/*
 		 * Reactivation flow.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@inc/checkout/class-cart.php` around lines 818 - 847, The reactivation branch
returns before validating that the current customer actually owns the cancelled
membership, allowing a guest/other customer to reuse someone else's membership;
modify the logic in the method containing the shown block (class-cart.php) to
perform the ownership check (compare $this->customer->get_id() / $this->customer
with $membership->get_user_id() or the membership ownership accessor) before
setting cart_type = 'reactivation', calling set_currency(), adding products, or
adding the wu_apply_signup_fee filter, and only proceed with the reactivation
setup and the early return if the ownership check passes; if the check fails, do
not treat it as a reactivation so the later permission guard and
maybe_create_membership() run as normal.

Comment on lines +1343 to +1346
if ($this->order && $this->order->get_cart_type() === 'reactivation') {
$sites = $this->membership->get_sites();

return ! empty($sites) ? current($sites) : false;
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.

⚠️ Potential issue | 🟠 Major

Require a published site before skipping site creation.

Membership::get_sites() includes pending-site metadata. If a cancelled membership only has a stale pending site, this branch returns that object and checkout skips site creation entirely, so the customer can pay for a reactivation without any live site to restore.

🛠️ Suggested fix
-		if ($this->order && $this->order->get_cart_type() === 'reactivation') {
-			$sites = $this->membership->get_sites();
-
-			return ! empty($sites) ? current($sites) : false;
+		if ($this->order && 'reactivation' === $this->order->get_cart_type()) {
+			$sites = $this->membership->get_sites(false);
+
+			if (empty($sites)) {
+				return new \WP_Error(
+					'reactivation_site_missing',
+					__('We could not find an existing site for this membership. Please contact support.', 'ultimate-multisite')
+				);
+			}
+
+			return current($sites);
 		}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if ($this->order && $this->order->get_cart_type() === 'reactivation') {
$sites = $this->membership->get_sites();
return ! empty($sites) ? current($sites) : false;
if ($this->order && 'reactivation' === $this->order->get_cart_type()) {
$sites = $this->membership->get_sites(false);
if (empty($sites)) {
return new \WP_Error(
'reactivation_site_missing',
__('We could not find an existing site for this membership. Please contact support.', 'ultimate-multisite')
);
}
return current($sites);
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@inc/checkout/class-checkout.php` around lines 1343 - 1346, The branch that
returns current($sites) on reactivation can return a pending/stale site because
Membership::get_sites() includes pending metadata; update the logic in the
reactivation branch (the code that checks $this->order->get_cart_type() ===
'reactivation' and calls $this->membership->get_sites()) to scan/filter the
returned sites for a published/live site (e.g., check each site's published
status or is_published()/status === 'published') and return the first published
site, otherwise return false so site creation proceeds. Ensure you replace the
direct current($sites) return with that filtered lookup using the site object
property/method that indicates published state.

Comment on lines +233 to +245
$cancelled_membership = null;

foreach ($memberships as $membership) {
if (method_exists($membership, 'is_cancelled') && $membership->is_cancelled()) {
$cancelled_membership = $membership;

break;
}
}

if ( ! $cancelled_membership) {
return;
}
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.

⚠️ Potential issue | 🟠 Major

Don't auto-redirect to the first cancelled membership.

Customers can have more than one cancelled membership/site. Breaking on the first match makes this redirect depend on get_memberships() ordering and can send the user to reactivate the wrong subscription.

🛠️ Suggested fix
-		$cancelled_membership = null;
-
-		foreach ($memberships as $membership) {
-			if (method_exists($membership, 'is_cancelled') && $membership->is_cancelled()) {
-				$cancelled_membership = $membership;
-
-				break;
-			}
-		}
-
-		if ( ! $cancelled_membership) {
+		$cancelled_memberships = array_values(
+			array_filter(
+				$memberships,
+				static fn($membership) => $membership->is_cancelled()
+			)
+		);
+
+		if (1 !== count($cancelled_memberships)) {
 			return;
 		}
+
+		$cancelled_membership = $cancelled_memberships[0];
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@inc/managers/class-payment-manager.php` around lines 233 - 245, The code
currently stops on the first cancelled membership (the $cancelled_membership set
inside the foreach that breaks), which can pick the wrong subscription; instead,
collect all cancelled memberships from get_memberships() by checking
method_exists(..., 'is_cancelled') && is_cancelled(), then choose the
appropriate one deterministically: prefer a membership whose site/blog id
matches the current site (e.g., compare $membership->get_site_id() or
get_blog_details()/get_current_blog_id()), or if none match and there are
multiple cancelled memberships, do not auto-redirect—return or present a
selection to the user; update the logic around $cancelled_membership and the
redirect to use this deterministic selection strategy rather than breaking on
the first match.

Comment on lines +465 to +489
if ($membership && method_exists($membership, 'is_cancelled') && $membership->is_cancelled()) {
$checkout_pages = \WP_Ultimo\Checkout\Checkout_Pages::get_instance();
$checkout_url = $checkout_pages->get_page_url('register');

if ($checkout_url) {
$reactivation_url = add_query_arg(
[
'plan_id' => $membership->get_plan_id(),
'membership_id' => $membership->get_id(),
],
$checkout_url
);

/**
* Filters the reactivation URL shown on blocked sites.
*
* @param string $reactivation_url The reactivation checkout URL.
* @param \WP_Ultimo\Models\Membership $membership The cancelled membership.
* @param \WP_Ultimo\Models\Site $site The blocked site.
*
* @since 2.4.14
*/
$reactivation_url = apply_filters('wu_blocked_site_reactivation_url', $reactivation_url, $membership, $site);
}
}
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.

⚠️ Potential issue | 🟡 Minor

Keep the fallback page generic when no reactivation URL exists.

$reactivation_url is only built for cancelled memberships, but this template is also used by other false === $can_access paths. When the URL is empty, the page still tells users to renew their subscription and then offers no matching CTA.

Suggested tweak
-			$html .= '<h1>' . esc_html__('This site is not available', 'ultimate-multisite') . '</h1>';
-			$html .= '<p>' . esc_html__('The subscription for this site has expired or been cancelled. To restore access, please renew your subscription.', 'ultimate-multisite') . '</p>';
-
-			if ( ! empty($reactivation_url)) {
-				$html .= '<a class="wu-btn wu-btn-primary" href="' . esc_url($reactivation_url) . '">' . esc_html__('Renew your subscription', 'ultimate-multisite') . '</a>';
-			}
+			$html .= '<h1>' . esc_html__('This site is not available', 'ultimate-multisite') . '</h1>';
+
+			if ( ! empty($reactivation_url)) {
+				$html .= '<p>' . esc_html__('The subscription for this site has expired or been cancelled. To restore access, please renew your subscription.', 'ultimate-multisite') . '</p>';
+				$html .= '<a class="wu-btn wu-btn-primary" href="' . esc_url($reactivation_url) . '">' . esc_html__('Renew your subscription', 'ultimate-multisite') . '</a>';
+			} else {
+				$html .= '<p>' . esc_html__('This site is currently unavailable. Please log in for more details.', 'ultimate-multisite') . '</p>';
+			}

Also applies to: 509-514

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@inc/managers/class-site-manager.php` around lines 465 - 489, Initialize and
ensure a sensible fallback for $reactivation_url so the blocked-site template
always has a CTA: before the membership/is_cancelled block set $reactivation_url
= '' (or fetch the generic checkout/register URL) and then, if the
cancelled-membership logic builds a specific URL use that; otherwise keep the
generic checkout URL (or a safe placeholder) and still run it through
apply_filters('wu_blocked_site_reactivation_url', ...). Update the code around
the membership check (the block using $membership->is_cancelled(),
$checkout_pages->get_page_url('register'), and apply_filters) so
$reactivation_url is always defined and contains a usable fallback when not
built for cancelled memberships.

Comment on lines +2434 to +2449
$renewed = $this->renew(false, 'active');

if ( ! $renewed) {
wu_log_add("membership-{$id}", sprintf('Membership reactivation failed for membership #%d: renewal returned false.', $id));

return false;
}

$this->set_date_cancellation(null);

$status = $this->save();

if (is_wp_error($status)) {
wu_log_add("membership-{$id}", sprintf('Membership reactivation failed for membership #%d: %s', $id, $status->get_error_message()));

return false;
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.

⚠️ Potential issue | 🟠 Major

Make reactivation a single write.

renew() already saves the membership and fires wu_membership_post_renew. If the second save() fails, reactivate() returns false even though the membership is already active, and renew listeners already ran with a stale cancellation date.

🛠️ Suggested fix
-		$renewed = $this->renew(false, 'active');
+		$this->set_date_cancellation(null);
+		$renewed = $this->renew(false, Membership_Status::ACTIVE);
 
 		if ( ! $renewed) {
 			wu_log_add("membership-{$id}", sprintf('Membership reactivation failed for membership #%d: renewal returned false.', $id));
 
 			return false;
 		}
-
-		$this->set_date_cancellation(null);
-
-		$status = $this->save();
-
-		if (is_wp_error($status)) {
-			wu_log_add("membership-{$id}", sprintf('Membership reactivation failed for membership #%d: %s', $id, $status->get_error_message()));
-
-			return false;
-		}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@inc/models/class-membership.php` around lines 2434 - 2449, The reactivation
flow does a second save after calling $this->renew(), which causes listeners to
run with the stale cancellation date and makes reactivate() return false even
when renewal already persisted; move the call to
$this->set_date_cancellation(null) so it runs before $this->renew(false,
'active') and remove the subsequent $this->save() / is_wp_error() block so
reactivation becomes a single write handled by renew(); update reactivate() to
rely on renew() to persist and only log/return based on the renew() result.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

♻️ Duplicate comments (5)
inc/models/class-membership.php (1)

2434-2449: ⚠️ Potential issue | 🟠 Major

Make reactivation a single persistence step.

renew() already saves and fires renewal hooks. Clearing date_cancellation in a second save() means renew listeners still see a cancelled membership, and a failure on that second write returns false after the membership is already active.

Suggested fix
-		$renewed = $this->renew(false, 'active');
+		$this->set_date_cancellation(null);
+		$renewed = $this->renew(false, Membership_Status::ACTIVE);
 
 		if ( ! $renewed) {
 			wu_log_add("membership-{$id}", sprintf('Membership reactivation failed for membership #%d: renewal returned false.', $id));
 
 			return false;
 		}
-
-		$this->set_date_cancellation(null);
-
-		$status = $this->save();
-
-		if (is_wp_error($status)) {
-			wu_log_add("membership-{$id}", sprintf('Membership reactivation failed for membership #%d: %s', $id, $status->get_error_message()));
-
-			return false;
-		}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@inc/models/class-membership.php` around lines 2434 - 2449, Clear the
membership's date_cancellation before invoking renew so the single save inside
renew() persists the non-cancelled active state; specifically, call
$this->set_date_cancellation(null) prior to $this->renew(false, 'active'),
remove the subsequent $this->save() call and its is_wp_error($status) check/log
path, and keep the existing logging/false return handling only for $renewed
being false (use the symbols renew(), set_date_cancellation(), save(), and the
membership reactivation log lines to locate the code to change).
inc/managers/class-site-manager.php (1)

463-488: ⚠️ Potential issue | 🟡 Minor

Keep the blocked page actionable when no specific reactivation URL exists.

wu_blocked_site_reactivation_url only runs inside the cancelled-membership branch, and the template still tells the user to renew even when no URL was built. If the register page is missing or the site is blocked for another inactive state, this becomes a dead end.

Suggested tweak
-			$reactivation_url = '';
+			$checkout_pages   = \WP_Ultimo\Checkout\Checkout_Pages::get_instance();
+			$reactivation_url = $checkout_pages->get_page_url('register') ?: '';
 
 			if ($membership && method_exists($membership, 'is_cancelled') && $membership->is_cancelled()) {
-				$checkout_pages = \WP_Ultimo\Checkout\Checkout_Pages::get_instance();
-				$checkout_url   = $checkout_pages->get_page_url('register');
+				$checkout_url = $reactivation_url;
 
 				if ($checkout_url) {
 					$reactivation_url = add_query_arg(
 						[
 							'plan_id'       => $membership->get_plan_id(),
 							'membership_id' => $membership->get_id(),
 						],
 						$checkout_url
 					);
-
-					/**
-					 * Filters the reactivation URL shown on blocked sites.
-					 *
-					 * `@param` string                       $reactivation_url The reactivation checkout URL.
-					 * `@param` \WP_Ultimo\Models\Membership $membership       The cancelled membership.
-					 * `@param` \WP_Ultimo\Models\Site       $site             The blocked site.
-					 *
-					 * `@since` 2.4.14
-					 */
-					$reactivation_url = apply_filters('wu_blocked_site_reactivation_url', $reactivation_url, $membership, $site);
 				}
 			}
+
+			$reactivation_url = apply_filters('wu_blocked_site_reactivation_url', $reactivation_url, $membership, $site);
@@
-			$html .= '<p>' . esc_html__('The subscription for this site has expired or been cancelled. To restore access, please renew your subscription.', 'ultimate-multisite') . '</p>';
-
-			if ( ! empty($reactivation_url)) {
+			if ( ! empty($reactivation_url)) {
+				$html .= '<p>' . esc_html__('The subscription for this site has expired or been cancelled. To restore access, please renew your subscription.', 'ultimate-multisite') . '</p>';
 				$html .= '<a class="wu-btn wu-btn-primary" href="' . esc_url($reactivation_url) . '">' . esc_html__('Renew your subscription', 'ultimate-multisite') . '</a>';
+			} else {
+				$html .= '<p>' . esc_html__('This site is currently unavailable. Please log in for more details.', 'ultimate-multisite') . '</p>';
 			}

Also applies to: 509-514

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@inc/managers/class-site-manager.php` around lines 463 - 488, The template can
show a "renew" action even when no reactivation URL is built because
apply_filters('wu_blocked_site_reactivation_url') is only invoked inside the
cancelled-membership branch; move or duplicate the filter call so
$reactivation_url is passed through
apply_filters('wu_blocked_site_reactivation_url', $reactivation_url,
$membership, $site) in all cases (i.e., after you initialize $reactivation_url =
'' and regardless of membership->is_cancelled()), so themes/plugins can supply
an alternative URL when the checkout/register page is missing or the site is
blocked for other inactive states; update the code paths where $reactivation_url
is used (including the similar block around the other branch mentioned) to rely
on the filtered value.
inc/managers/class-payment-manager.php (1)

221-245: ⚠️ Potential issue | 🟠 Major

Only auto-redirect when there is one unambiguous cancelled membership.

Membership::is_active() does not cover trialing, so a customer with a current trial can still fall through here. The break also makes the redirect target depend on get_memberships() ordering when more than one cancelled membership exists.

Suggested fix
-		foreach ($memberships as $membership) {
-			if ($membership->is_active()) {
-				return;
-			}
-		}
+		$cancelled_memberships = [];
+
+		foreach ($memberships as $membership) {
+			if ($membership->is_active() || \WP_Ultimo\Database\Memberships\Membership_Status::TRIALING === $membership->get_status()) {
+				return;
+			}
+
+			if (method_exists($membership, 'is_cancelled') && $membership->is_cancelled()) {
+				$cancelled_memberships[] = $membership;
+			}
+		}
 
 		/*
 		 * No active membership found. Look for a cancelled one.
 		 */
-		$cancelled_membership = null;
-
-		foreach ($memberships as $membership) {
-			if (method_exists($membership, 'is_cancelled') && $membership->is_cancelled()) {
-				$cancelled_membership = $membership;
-
-				break;
-			}
-		}
-
-		if ( ! $cancelled_membership) {
+		if (1 !== count($cancelled_memberships)) {
 			return;
 		}
+
+		$cancelled_membership = $cancelled_memberships[0];
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@inc/managers/class-payment-manager.php` around lines 221 - 245, The current
logic returns only if any Membership::is_active() is true but misses trialing
statuses and picks the first cancelled membership found, making behavior
order-dependent; update the checks so you treat trialing memberships as active
(check method_exists($membership,'is_trialing') && $membership->is_trialing()
alongside is_active()) and change the cancelled-membership logic to collect all
cancelled entries from $memberships into an array (use
method_exists($membership,'is_cancelled') && $membership->is_cancelled()), then
only proceed when count($cancelled_memberships) === 1 and set
$cancelled_membership = $cancelled_memberships[0]; otherwise return.
inc/checkout/class-cart.php (2)

829-841: ⚠️ Potential issue | 🟠 Major

Rebuild reactivation carts from the membership’s actual products.

The empty-products path only adds get_plan_id(). That drops attached add-ons and can also re-add the base plan instead of the membership’s current variation/billing period.

Suggested fix
-				if (empty($this->attributes->products)) {
-					$this->add_product($membership->get_plan_id());
+				if (empty($this->attributes->products)) {
+					foreach ($membership->get_all_products() as $membership_product) {
+						$product = $membership_product['product'];
+
+						if ($product) {
+							$this->add_product($product->get_id(), $membership_product['quantity']);
+						}
+					}
 				} else {
 					foreach ($this->attributes->products as $product_id) {
 						$this->add_product($product_id);
 					}
 				}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@inc/checkout/class-cart.php` around lines 829 - 841, The empty-products
branch currently calls $this->add_product($membership->get_plan_id()) which
drops add-ons and can add the wrong variation; instead rebuild the cart from the
membership’s actual products: replace that single add_product call with
iterating over the membership’s stored product list (e.g.
$membership->get_products() or $membership->get_product_ids()) and call
$this->add_product($product_id) for each entry so attached add-ons and the
correct plan variation/billing period are preserved; keep the existing foreach
path for when $this->attributes->products is present and ensure you reference
$this->attributes->products, $this->add_product(), and
$membership->get_plan_id()/get_products() in the fix.

800-847: ⚠️ Potential issue | 🔴 Critical

Run the ownership guard before the reactivation early return.

This branch returns before the existing permission check. Passing another customer's cancelled membership_id is enough to build a reactivation cart against that membership.

Suggested fix
 		if ( ! $membership) {
 			$this->errors->add('membership_not_found', __('The membership in question was not found.', 'ultimate-multisite'));
 
 			return true;
 		}
+
+		if (empty($this->customer) || $this->customer->get_id() !== $membership->get_customer_id()) {
+			$this->errors->add('lacks_permission', __('You are not allowed to modify this membership.', 'ultimate-multisite'));
+
+			return true;
+		}
 
 		/*
 		 * Reactivation flow.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@inc/checkout/class-cart.php` around lines 800 - 847, Run the
ownership/permission check for $membership before performing the reactivation
early return: ensure the same guard that verifies the current customer owns the
membership (the check that adds errors via $this->errors->add when not
permitted) is executed before the "if (method_exists($membership,
'is_cancelled') && $membership->is_cancelled())" branch returns true; either
move that existing ownership check above this reactivation block or call it at
the top of the block and abort (add the appropriate error and return) if the
check fails, so you don't allow building a reactivation cart for another
customer's cancelled membership.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@inc/checkout/class-cart.php`:
- Around line 829-841: The empty-products branch currently calls
$this->add_product($membership->get_plan_id()) which drops add-ons and can add
the wrong variation; instead rebuild the cart from the membership’s actual
products: replace that single add_product call with iterating over the
membership’s stored product list (e.g. $membership->get_products() or
$membership->get_product_ids()) and call $this->add_product($product_id) for
each entry so attached add-ons and the correct plan variation/billing period are
preserved; keep the existing foreach path for when $this->attributes->products
is present and ensure you reference $this->attributes->products,
$this->add_product(), and $membership->get_plan_id()/get_products() in the fix.
- Around line 800-847: Run the ownership/permission check for $membership before
performing the reactivation early return: ensure the same guard that verifies
the current customer owns the membership (the check that adds errors via
$this->errors->add when not permitted) is executed before the "if
(method_exists($membership, 'is_cancelled') && $membership->is_cancelled())"
branch returns true; either move that existing ownership check above this
reactivation block or call it at the top of the block and abort (add the
appropriate error and return) if the check fails, so you don't allow building a
reactivation cart for another customer's cancelled membership.

In `@inc/managers/class-payment-manager.php`:
- Around line 221-245: The current logic returns only if any
Membership::is_active() is true but misses trialing statuses and picks the first
cancelled membership found, making behavior order-dependent; update the checks
so you treat trialing memberships as active (check
method_exists($membership,'is_trialing') && $membership->is_trialing() alongside
is_active()) and change the cancelled-membership logic to collect all cancelled
entries from $memberships into an array (use
method_exists($membership,'is_cancelled') && $membership->is_cancelled()), then
only proceed when count($cancelled_memberships) === 1 and set
$cancelled_membership = $cancelled_memberships[0]; otherwise return.

In `@inc/managers/class-site-manager.php`:
- Around line 463-488: The template can show a "renew" action even when no
reactivation URL is built because
apply_filters('wu_blocked_site_reactivation_url') is only invoked inside the
cancelled-membership branch; move or duplicate the filter call so
$reactivation_url is passed through
apply_filters('wu_blocked_site_reactivation_url', $reactivation_url,
$membership, $site) in all cases (i.e., after you initialize $reactivation_url =
'' and regardless of membership->is_cancelled()), so themes/plugins can supply
an alternative URL when the checkout/register page is missing or the site is
blocked for other inactive states; update the code paths where $reactivation_url
is used (including the similar block around the other branch mentioned) to rely
on the filtered value.

In `@inc/models/class-membership.php`:
- Around line 2434-2449: Clear the membership's date_cancellation before
invoking renew so the single save inside renew() persists the non-cancelled
active state; specifically, call $this->set_date_cancellation(null) prior to
$this->renew(false, 'active'), remove the subsequent $this->save() call and its
is_wp_error($status) check/log path, and keep the existing logging/false return
handling only for $renewed being false (use the symbols renew(),
set_date_cancellation(), save(), and the membership reactivation log lines to
locate the code to change).

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: a8989638-357b-402b-b009-c7acf893e1e6

📥 Commits

Reviewing files that changed from the base of the PR and between e9fac9a and 85439b4.

📒 Files selected for processing (7)
  • inc/checkout/class-cart.php
  • inc/checkout/class-checkout.php
  • inc/class-wp-ultimo.php
  • inc/managers/class-payment-manager.php
  • inc/managers/class-site-manager.php
  • inc/models/class-membership.php
  • ultimate-multisite.php
✅ Files skipped from review due to trivial changes (2)
  • ultimate-multisite.php
  • inc/checkout/class-checkout.php
🚧 Files skipped from review as they are similar to previous changes (1)
  • inc/class-wp-ultimo.php

@superdav42 superdav42 added the needs-maintainer-review Requires maintainer review before proceeding label Apr 5, 2026
@superdav42
Copy link
Copy Markdown
Collaborator

Supervisor review — flagging for maintainer attention before merge.

This is a valuable external contribution (reactivation flow + trial-skip revenue fix) but CodeRabbit identified 7 actionable issues including one critical security vulnerability that must be addressed before merge:

Critical (security):

  • class-cart.php:800-847 — Ownership check missing before reactivation early return. A guest/other customer can pass another user's cancelled membership_id to build a reactivation cart against that membership. The existing permission guard is bypassed by the early return.

Major:

  • class-cart.php:829-841 — Empty-products path only adds get_plan_id(), dropping attached add-ons and potentially using wrong billing period variation.
  • class-payment-manager.php:221-245 — Trialing memberships not treated as active; first-match break makes redirect target order-dependent when multiple cancelled memberships exist.
  • class-membership.php:2434-2449 — Double-save race: renew() already saves, then a second save() clears date_cancellation — listeners see stale cancelled state and the method can return false after membership is already active.

Minor:

  • class-site-manager.php:463-488wu_blocked_site_reactivation_url filter only runs inside cancelled-membership branch; blocked page shows "renew" CTA with no URL in other inactive states.
  • class-checkout.php:478-489 — Reactivation detection runs too late (after process_order() builds the cart).
  • class-checkout.php:1343-1346current($sites) can return a pending/stale site.

Recommended action: The contributor should address the critical ownership check and the major issues before this is merge-ready. The trial-skip logic and the core reactivation flow are sound — this just needs the security and correctness fixes applied.


aidevops.sh v3.6.97 plugin for OpenCode v1.3.0 with claude-sonnet-4-6 Overall, 2h 20m since this issue was created.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

needs-maintainer-review Requires maintainer review before proceeding

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants