Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 65 additions & 11 deletions inc/checkout/class-cart.php
Original file line number Diff line number Diff line change
Expand Up @@ -794,17 +794,6 @@ protected function build_from_membership($membership_id): bool {
return false;
}

/*
* We got here, that means
* the intend behind this cart was to actually
* change a membership.
*
* We can set the cart type provisionally.
* This assignment might change in the future, as we make
* additional assertions about the contents of the cart.
*/
$this->cart_type = 'upgrade';

/*
* Now, let's try to fetch the membership in question.
*/
Expand All @@ -816,6 +805,60 @@ protected function build_from_membership($membership_id): bool {
return true;
}

/*
* Reactivation flow.
*
* If the membership is cancelled and the cart contains the same plan
* (or no products, meaning we rebuild from the membership), we treat
* this as a reactivation rather than an upgrade/downgrade. Reactivations
* charge the full plan price immediately with no trial and no signup fee.
*
* @since 2.4.14
*/
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;
Comment on lines +818 to +847
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.

}
}

/*
* We got here, that means
* the intend behind this cart was to actually
* change a membership.
*
* We can set the cart type provisionally.
* This assignment might change in the future, as we make
* additional assertions about the contents of the cart.
*/
$this->cart_type = 'upgrade';

/*
* The membership exists, set it globally.
*/
Expand Down Expand Up @@ -2576,6 +2619,17 @@ public function get_billing_start_date() {
return null;
}

/*
* 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.
*
* @since 2.4.14
*/
if ($this->get_cart_type() === 'reactivation') {
return null;
}

/*
* Set extremely high value at first to prevent any change of errors.
*/
Expand Down
33 changes: 33 additions & 0 deletions inc/checkout/class-checkout.php
Original file line number Diff line number Diff line change
Expand Up @@ -467,6 +467,27 @@ public function setup_checkout($element = null): void {

$this->step['fields'] ??= [];

/*
* For reactivation carts, skip site-creation fields.
*
* Users reactivating a cancelled membership already have a site,
* so we remove fields related to site URL, title, and template selection.
*
* @since 2.4.14
*/
$cart_type = $this->request_or_session('cart_type', 'new');

if ('reactivation' === $cart_type || (isset($this->order) && $this->order && $this->order->get_cart_type() === 'reactivation')) {
$site_field_types = ['site_url', 'template_selection', 'site_title'];

$this->step['fields'] = array_filter(
$this->step['fields'],
function ($field) use ($site_field_types) {
return ! in_array(wu_get_isset($field, 'type', ''), $site_field_types, true);
}
);
}

$this->auto_submittable_field = $this->contains_auto_submittable_field($this->step['fields']);

$this->step['fields'] = wu_create_checkout_fields($this->step['fields']);
Expand Down Expand Up @@ -1313,6 +1334,18 @@ protected function maybe_create_membership() {
* @return bool|\WP_Ultimo\Models\Site|\WP_Error
*/
protected function maybe_create_site() {
/*
* Reactivation carts should not create a new site.
* The user already has an existing site attached to the membership.
*
* @since 2.4.14
*/
if ($this->order && $this->order->get_cart_type() === 'reactivation') {
$sites = $this->membership->get_sites();

return ! empty($sites) ? current($sites) : false;
Comment on lines +1343 to +1346
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.

}

/*
* Let's get a list of membership sites.
* This list includes pending sites as well.
Expand Down
2 changes: 1 addition & 1 deletion inc/class-wp-ultimo.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ final class WP_Ultimo {
* @since 2.1.0
* @var string
*/
const VERSION = '2.4.13-beta.1';
const VERSION = '2.4.13-beta.21';

/**
* Core log handle for Ultimate Multisite.
Expand Down
93 changes: 93 additions & 0 deletions inc/managers/class-payment-manager.php
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,8 @@ function () {
);
add_action('wp_login', [$this, 'check_pending_payments'], 10);

add_action('wp_login', [$this, 'maybe_redirect_cancelled_membership'], 20, 2);

add_action('wp_enqueue_scripts', [$this, 'show_pending_payments'], 10);

add_action('admin_enqueue_scripts', [$this, 'show_pending_payments'], 10);
Expand Down Expand Up @@ -182,6 +184,97 @@ public function check_pending_payments($user): void {
}
}

/**
* Redirects users with cancelled memberships to the checkout page for reactivation.
*
* If a user logs in on the main site and has no active membership but does
* have a cancelled one, redirect them to the checkout with reactivation params.
*
* @since 2.4.14
*
* @param string $user_login The user login name.
* @param \WP_User $user The WP_User object.
* @return void
*/
public function maybe_redirect_cancelled_membership($user_login, $user): void {

if ( ! is_main_site()) {
return;
}

if ( ! $user instanceof \WP_User) {
return;
}

$customer = wu_get_customer_by_user_id($user->ID);

if ( ! $customer) {
return;
}

$memberships = $customer->get_memberships();

if (empty($memberships)) {
return;
}

/*
* If the customer has any active membership, no redirect is needed.
*/
foreach ($memberships as $membership) {
if ($membership->is_active()) {
return;
}
}

/*
* 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) {
return;
}
Comment on lines +233 to +245
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.


$checkout_pages = \WP_Ultimo\Checkout\Checkout_Pages::get_instance();
$checkout_url = $checkout_pages->get_page_url('register');

if ( ! $checkout_url) {
return;
}

$redirect_url = add_query_arg(
[
'plan_id' => $cancelled_membership->get_plan_id(),
'membership_id' => $cancelled_membership->get_id(),
],
$checkout_url
);

/**
* Filters the redirect URL for users with cancelled memberships on login.
*
* @param string $redirect_url The reactivation checkout URL.
* @param \WP_Ultimo\Models\Membership $membership The cancelled membership.
* @param \WP_User $user The WP_User object.
*
* @since 2.4.14
*/
$redirect_url = apply_filters('wu_cancelled_membership_redirect_url', $redirect_url, $cancelled_membership, $user);

wp_safe_redirect($redirect_url);

exit;
}

/**
* Add and trigger a popup in screen with the pending payments
*
Expand Down
92 changes: 87 additions & 5 deletions inc/managers/class-site-manager.php
Original file line number Diff line number Diff line change
Expand Up @@ -452,11 +452,93 @@ public function lock_site(): void {
exit;
}

wp_die(
// translators: %s: link to the login page
sprintf(wp_kses_post(__('This site is not available at the moment.<br><small>If you are the site admin, click <a href="%s">here</a> to login.</small>', 'ultimate-multisite')), esc_attr(wp_login_url())),
esc_html__('Site not available', 'ultimate-multisite'),
);
/*
* Build a reactivation URL for cancelled memberships.
*
* Instead of a dead-end wp_die, we show a friendly page
* with a button to renew the subscription.
*
* @since 2.4.14
*/
$reactivation_url = '';

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);
}
}
Comment on lines +465 to +489
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.


$login_url = wp_login_url();
$support_url = apply_filters('wu_blocked_site_support_url', '', $membership, $site);

$html = '<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">';
$html .= '<title>' . esc_html__('Site not available', 'ultimate-multisite') . '</title>';
$html .= '<style>';
$html .= 'body{margin:0;padding:0;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Oxygen,Ubuntu,Cantarell,sans-serif;background:#f0f0f1;color:#3c434a;display:flex;align-items:center;justify-content:center;min-height:100vh;}';
$html .= '.wu-blocked{background:#fff;border:1px solid #c3c4c7;border-radius:4px;padding:40px;max-width:480px;width:90%;text-align:center;box-shadow:0 1px 3px rgba(0,0,0,.04);}';
$html .= '.wu-blocked h1{font-size:22px;margin:0 0 12px;color:#1d2327;}';
$html .= '.wu-blocked p{font-size:14px;line-height:1.6;margin:0 0 24px;color:#646970;}';
$html .= '.wu-blocked .wu-btn{display:inline-block;padding:10px 24px;font-size:14px;font-weight:600;text-decoration:none;border-radius:3px;margin:4px;}';
$html .= '.wu-blocked .wu-btn-primary{background:#2271b1;color:#fff;border:1px solid #2271b1;}';
$html .= '.wu-blocked .wu-btn-primary:hover{background:#135e96;}';
$html .= '.wu-blocked .wu-links{margin-top:16px;font-size:13px;}';
$html .= '.wu-blocked .wu-links a{color:#2271b1;text-decoration:none;}';
$html .= '.wu-blocked .wu-links a:hover{text-decoration:underline;}';
$html .= '</style></head><body>';
$html .= '<div class="wu-blocked">';
$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 .= '<div class="wu-links">';
$html .= '<a href="' . esc_url($login_url) . '">' . esc_html__('Log in', 'ultimate-multisite') . '</a>';

if ( ! empty($support_url)) {
$html .= ' &middot; <a href="' . esc_url($support_url) . '">' . esc_html__('Contact support', 'ultimate-multisite') . '</a>';
}

$html .= '</div></div></body></html>';

/**
* Filters the full HTML template for blocked sites.
*
* @param string $html The HTML template.
* @param \WP_Ultimo\Models\Membership $membership The membership (may be null).
* @param \WP_Ultimo\Models\Site $site The blocked site.
*
* @since 2.4.14
*/
$html = apply_filters('wu_blocked_site_template', $html, $membership, $site);

status_header(403);
nocache_headers();

echo $html; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Escaped above.

exit;
}
}

Expand Down
Loading