Skip to content

Commit 85439b4

Browse files
author
kenedytorcatt
committed
feat(reactivation): add reactivation flow for cancelled memberships + 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
1 parent ce04b0a commit 85439b4

7 files changed

Lines changed: 354 additions & 19 deletions

File tree

inc/checkout/class-cart.php

Lines changed: 65 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -794,17 +794,6 @@ protected function build_from_membership($membership_id): bool {
794794
return false;
795795
}
796796

797-
/*
798-
* We got here, that means
799-
* the intend behind this cart was to actually
800-
* change a membership.
801-
*
802-
* We can set the cart type provisionally.
803-
* This assignment might change in the future, as we make
804-
* additional assertions about the contents of the cart.
805-
*/
806-
$this->cart_type = 'upgrade';
807-
808797
/*
809798
* Now, let's try to fetch the membership in question.
810799
*/
@@ -816,6 +805,60 @@ protected function build_from_membership($membership_id): bool {
816805
return true;
817806
}
818807

808+
/*
809+
* Reactivation flow.
810+
*
811+
* If the membership is cancelled and the cart contains the same plan
812+
* (or no products, meaning we rebuild from the membership), we treat
813+
* this as a reactivation rather than an upgrade/downgrade. Reactivations
814+
* charge the full plan price immediately with no trial and no signup fee.
815+
*
816+
* @since 2.4.14
817+
*/
818+
if (method_exists($membership, 'is_cancelled') && $membership->is_cancelled()) {
819+
$plan_matches = ! empty($this->attributes->products)
820+
&& in_array($membership->get_plan_id(), (array) $this->attributes->products, false);
821+
822+
if ($plan_matches || empty($this->attributes->products)) {
823+
$this->cart_type = 'reactivation';
824+
$this->membership = $membership;
825+
826+
$this->country = $this->country ?: ($this->customer ? $this->customer->get_country() : '');
827+
$this->set_currency($membership->get_currency());
828+
829+
if (empty($this->attributes->products)) {
830+
$this->add_product($membership->get_plan_id());
831+
} else {
832+
foreach ($this->attributes->products as $product_id) {
833+
$this->add_product($product_id);
834+
}
835+
}
836+
837+
$plan_product = $membership->get_plan();
838+
839+
if ($plan_product) {
840+
$this->duration = $plan_product->get_duration();
841+
$this->duration_unit = $plan_product->get_duration_unit();
842+
}
843+
844+
// Skip signup fee for reactivations — they already paid it.
845+
add_filter('wu_apply_signup_fee', '__return_false');
846+
847+
return true;
848+
}
849+
}
850+
851+
/*
852+
* We got here, that means
853+
* the intend behind this cart was to actually
854+
* change a membership.
855+
*
856+
* We can set the cart type provisionally.
857+
* This assignment might change in the future, as we make
858+
* additional assertions about the contents of the cart.
859+
*/
860+
$this->cart_type = 'upgrade';
861+
819862
/*
820863
* The membership exists, set it globally.
821864
*/
@@ -2576,6 +2619,17 @@ public function get_billing_start_date() {
25762619
return null;
25772620
}
25782621

2622+
/*
2623+
* Reactivations never get a trial — the customer already used it
2624+
* when they originally signed up. Giving them another trial would
2625+
* let anyone cancel + re-signup to extend their trial indefinitely.
2626+
*
2627+
* @since 2.4.14
2628+
*/
2629+
if ($this->get_cart_type() === 'reactivation') {
2630+
return null;
2631+
}
2632+
25792633
/*
25802634
* Set extremely high value at first to prevent any change of errors.
25812635
*/

inc/checkout/class-checkout.php

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -467,6 +467,27 @@ public function setup_checkout($element = null): void {
467467

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

470+
/*
471+
* For reactivation carts, skip site-creation fields.
472+
*
473+
* Users reactivating a cancelled membership already have a site,
474+
* so we remove fields related to site URL, title, and template selection.
475+
*
476+
* @since 2.4.14
477+
*/
478+
$cart_type = $this->request_or_session('cart_type', 'new');
479+
480+
if ('reactivation' === $cart_type || (isset($this->order) && $this->order && $this->order->get_cart_type() === 'reactivation')) {
481+
$site_field_types = ['site_url', 'template_selection', 'site_title'];
482+
483+
$this->step['fields'] = array_filter(
484+
$this->step['fields'],
485+
function ($field) use ($site_field_types) {
486+
return ! in_array(wu_get_isset($field, 'type', ''), $site_field_types, true);
487+
}
488+
);
489+
}
490+
470491
$this->auto_submittable_field = $this->contains_auto_submittable_field($this->step['fields']);
471492

472493
$this->step['fields'] = wu_create_checkout_fields($this->step['fields']);
@@ -1313,6 +1334,18 @@ protected function maybe_create_membership() {
13131334
* @return bool|\WP_Ultimo\Models\Site|\WP_Error
13141335
*/
13151336
protected function maybe_create_site() {
1337+
/*
1338+
* Reactivation carts should not create a new site.
1339+
* The user already has an existing site attached to the membership.
1340+
*
1341+
* @since 2.4.14
1342+
*/
1343+
if ($this->order && $this->order->get_cart_type() === 'reactivation') {
1344+
$sites = $this->membership->get_sites();
1345+
1346+
return ! empty($sites) ? current($sites) : false;
1347+
}
1348+
13161349
/*
13171350
* Let's get a list of membership sites.
13181351
* This list includes pending sites as well.

inc/class-wp-ultimo.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ final class WP_Ultimo {
3131
* @since 2.1.0
3232
* @var string
3333
*/
34-
const VERSION = '2.4.13-beta.1';
34+
const VERSION = '2.4.13-beta.21';
3535

3636
/**
3737
* Core log handle for Ultimate Multisite.

inc/managers/class-payment-manager.php

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,8 @@ function () {
8181
);
8282
add_action('wp_login', [$this, 'check_pending_payments'], 10);
8383

84+
add_action('wp_login', [$this, 'maybe_redirect_cancelled_membership'], 20, 2);
85+
8486
add_action('wp_enqueue_scripts', [$this, 'show_pending_payments'], 10);
8587

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

187+
/**
188+
* Redirects users with cancelled memberships to the checkout page for reactivation.
189+
*
190+
* If a user logs in on the main site and has no active membership but does
191+
* have a cancelled one, redirect them to the checkout with reactivation params.
192+
*
193+
* @since 2.4.14
194+
*
195+
* @param string $user_login The user login name.
196+
* @param \WP_User $user The WP_User object.
197+
* @return void
198+
*/
199+
public function maybe_redirect_cancelled_membership($user_login, $user): void {
200+
201+
if ( ! is_main_site()) {
202+
return;
203+
}
204+
205+
if ( ! $user instanceof \WP_User) {
206+
return;
207+
}
208+
209+
$customer = wu_get_customer_by_user_id($user->ID);
210+
211+
if ( ! $customer) {
212+
return;
213+
}
214+
215+
$memberships = $customer->get_memberships();
216+
217+
if (empty($memberships)) {
218+
return;
219+
}
220+
221+
/*
222+
* If the customer has any active membership, no redirect is needed.
223+
*/
224+
foreach ($memberships as $membership) {
225+
if ($membership->is_active()) {
226+
return;
227+
}
228+
}
229+
230+
/*
231+
* No active membership found. Look for a cancelled one.
232+
*/
233+
$cancelled_membership = null;
234+
235+
foreach ($memberships as $membership) {
236+
if (method_exists($membership, 'is_cancelled') && $membership->is_cancelled()) {
237+
$cancelled_membership = $membership;
238+
239+
break;
240+
}
241+
}
242+
243+
if ( ! $cancelled_membership) {
244+
return;
245+
}
246+
247+
$checkout_pages = \WP_Ultimo\Checkout\Checkout_Pages::get_instance();
248+
$checkout_url = $checkout_pages->get_page_url('register');
249+
250+
if ( ! $checkout_url) {
251+
return;
252+
}
253+
254+
$redirect_url = add_query_arg(
255+
[
256+
'plan_id' => $cancelled_membership->get_plan_id(),
257+
'membership_id' => $cancelled_membership->get_id(),
258+
],
259+
$checkout_url
260+
);
261+
262+
/**
263+
* Filters the redirect URL for users with cancelled memberships on login.
264+
*
265+
* @param string $redirect_url The reactivation checkout URL.
266+
* @param \WP_Ultimo\Models\Membership $membership The cancelled membership.
267+
* @param \WP_User $user The WP_User object.
268+
*
269+
* @since 2.4.14
270+
*/
271+
$redirect_url = apply_filters('wu_cancelled_membership_redirect_url', $redirect_url, $cancelled_membership, $user);
272+
273+
wp_safe_redirect($redirect_url);
274+
275+
exit;
276+
}
277+
185278
/**
186279
* Add and trigger a popup in screen with the pending payments
187280
*

inc/managers/class-site-manager.php

Lines changed: 87 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -452,11 +452,93 @@ public function lock_site(): void {
452452
exit;
453453
}
454454

455-
wp_die(
456-
// translators: %s: link to the login page
457-
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())),
458-
esc_html__('Site not available', 'ultimate-multisite'),
459-
);
455+
/*
456+
* Build a reactivation URL for cancelled memberships.
457+
*
458+
* Instead of a dead-end wp_die, we show a friendly page
459+
* with a button to renew the subscription.
460+
*
461+
* @since 2.4.14
462+
*/
463+
$reactivation_url = '';
464+
465+
if ($membership && method_exists($membership, 'is_cancelled') && $membership->is_cancelled()) {
466+
$checkout_pages = \WP_Ultimo\Checkout\Checkout_Pages::get_instance();
467+
$checkout_url = $checkout_pages->get_page_url('register');
468+
469+
if ($checkout_url) {
470+
$reactivation_url = add_query_arg(
471+
[
472+
'plan_id' => $membership->get_plan_id(),
473+
'membership_id' => $membership->get_id(),
474+
],
475+
$checkout_url
476+
);
477+
478+
/**
479+
* Filters the reactivation URL shown on blocked sites.
480+
*
481+
* @param string $reactivation_url The reactivation checkout URL.
482+
* @param \WP_Ultimo\Models\Membership $membership The cancelled membership.
483+
* @param \WP_Ultimo\Models\Site $site The blocked site.
484+
*
485+
* @since 2.4.14
486+
*/
487+
$reactivation_url = apply_filters('wu_blocked_site_reactivation_url', $reactivation_url, $membership, $site);
488+
}
489+
}
490+
491+
$login_url = wp_login_url();
492+
$support_url = apply_filters('wu_blocked_site_support_url', '', $membership, $site);
493+
494+
$html = '<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">';
495+
$html .= '<title>' . esc_html__('Site not available', 'ultimate-multisite') . '</title>';
496+
$html .= '<style>';
497+
$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;}';
498+
$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);}';
499+
$html .= '.wu-blocked h1{font-size:22px;margin:0 0 12px;color:#1d2327;}';
500+
$html .= '.wu-blocked p{font-size:14px;line-height:1.6;margin:0 0 24px;color:#646970;}';
501+
$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;}';
502+
$html .= '.wu-blocked .wu-btn-primary{background:#2271b1;color:#fff;border:1px solid #2271b1;}';
503+
$html .= '.wu-blocked .wu-btn-primary:hover{background:#135e96;}';
504+
$html .= '.wu-blocked .wu-links{margin-top:16px;font-size:13px;}';
505+
$html .= '.wu-blocked .wu-links a{color:#2271b1;text-decoration:none;}';
506+
$html .= '.wu-blocked .wu-links a:hover{text-decoration:underline;}';
507+
$html .= '</style></head><body>';
508+
$html .= '<div class="wu-blocked">';
509+
$html .= '<h1>' . esc_html__('This site is not available', 'ultimate-multisite') . '</h1>';
510+
$html .= '<p>' . esc_html__('The subscription for this site has expired or been cancelled. To restore access, please renew your subscription.', 'ultimate-multisite') . '</p>';
511+
512+
if ( ! empty($reactivation_url)) {
513+
$html .= '<a class="wu-btn wu-btn-primary" href="' . esc_url($reactivation_url) . '">' . esc_html__('Renew your subscription', 'ultimate-multisite') . '</a>';
514+
}
515+
516+
$html .= '<div class="wu-links">';
517+
$html .= '<a href="' . esc_url($login_url) . '">' . esc_html__('Log in', 'ultimate-multisite') . '</a>';
518+
519+
if ( ! empty($support_url)) {
520+
$html .= ' &middot; <a href="' . esc_url($support_url) . '">' . esc_html__('Contact support', 'ultimate-multisite') . '</a>';
521+
}
522+
523+
$html .= '</div></div></body></html>';
524+
525+
/**
526+
* Filters the full HTML template for blocked sites.
527+
*
528+
* @param string $html The HTML template.
529+
* @param \WP_Ultimo\Models\Membership $membership The membership (may be null).
530+
* @param \WP_Ultimo\Models\Site $site The blocked site.
531+
*
532+
* @since 2.4.14
533+
*/
534+
$html = apply_filters('wu_blocked_site_template', $html, $membership, $site);
535+
536+
status_header(403);
537+
nocache_headers();
538+
539+
echo $html; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Escaped above.
540+
541+
exit;
460542
}
461543
}
462544

0 commit comments

Comments
 (0)