From 85439b40bb3efd6b869a77e304e0f76304c36e3a Mon Sep 17 00:00:00 2001 From: kenedytorcatt Date: Sat, 4 Apr 2026 23:35:31 -0600 Subject: [PATCH] feat(reactivation): add reactivation flow for cancelled memberships + trial skip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- inc/checkout/class-cart.php | 76 ++++++++++++++++++--- inc/checkout/class-checkout.php | 33 +++++++++ inc/class-wp-ultimo.php | 2 +- inc/managers/class-payment-manager.php | 93 ++++++++++++++++++++++++++ inc/managers/class-site-manager.php | 92 +++++++++++++++++++++++-- inc/models/class-membership.php | 73 ++++++++++++++++++++ ultimate-multisite.php | 4 +- 7 files changed, 354 insertions(+), 19 deletions(-) diff --git a/inc/checkout/class-cart.php b/inc/checkout/class-cart.php index ce70bb318..a98d6dce7 100644 --- a/inc/checkout/class-cart.php +++ b/inc/checkout/class-cart.php @@ -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. */ @@ -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; + } + } + + /* + * 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. */ @@ -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. */ diff --git a/inc/checkout/class-checkout.php b/inc/checkout/class-checkout.php index 0552724fa..11c8f914b 100644 --- a/inc/checkout/class-checkout.php +++ b/inc/checkout/class-checkout.php @@ -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']); @@ -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; + } + /* * Let's get a list of membership sites. * This list includes pending sites as well. diff --git a/inc/class-wp-ultimo.php b/inc/class-wp-ultimo.php index 0017b8388..64c31a6f5 100644 --- a/inc/class-wp-ultimo.php +++ b/inc/class-wp-ultimo.php @@ -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. diff --git a/inc/managers/class-payment-manager.php b/inc/managers/class-payment-manager.php index 79fd84e3f..48b250e87 100644 --- a/inc/managers/class-payment-manager.php +++ b/inc/managers/class-payment-manager.php @@ -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); @@ -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; + } + + $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 * diff --git a/inc/managers/class-site-manager.php b/inc/managers/class-site-manager.php index 13bff3abb..cb9d6846d 100644 --- a/inc/managers/class-site-manager.php +++ b/inc/managers/class-site-manager.php @@ -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.
If you are the site admin, click here to login.', '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); + } + } + + $login_url = wp_login_url(); + $support_url = apply_filters('wu_blocked_site_support_url', '', $membership, $site); + + $html = ''; + $html .= '' . esc_html__('Site not available', 'ultimate-multisite') . ''; + $html .= ''; + $html .= '
'; + $html .= '

' . esc_html__('This site is not available', 'ultimate-multisite') . '

'; + $html .= '

' . esc_html__('The subscription for this site has expired or been cancelled. To restore access, please renew your subscription.', 'ultimate-multisite') . '

'; + + if ( ! empty($reactivation_url)) { + $html .= '' . esc_html__('Renew your subscription', 'ultimate-multisite') . ''; + } + + $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; } } diff --git a/inc/models/class-membership.php b/inc/models/class-membership.php index 6be6bfa37..8fda121b9 100644 --- a/inc/models/class-membership.php +++ b/inc/models/class-membership.php @@ -2391,6 +2391,79 @@ public function cancel($reason = ''): void { do_action('wu_membership_post_cancel', $this->get_id(), $this); } + /** + * Checks if the membership is cancelled. + * + * @since 2.4.14 + * @return bool + */ + public function is_cancelled(): bool { + + return $this->get_status() === Membership_Status::CANCELLED; + } + + /** + * Reactivates a cancelled membership. + * + * This reuses the existing renewal logic to set a new expiration date + * and restore the membership to active status. + * + * @since 2.4.14 + * @return bool True on success, false if not cancelled or on failure. + */ + public function reactivate(): bool { + + if ( ! $this->is_cancelled()) { + return false; + } + + $id = $this->get_id(); + + wu_log_add("membership-{$id}", sprintf('Starting membership reactivation for membership #%d.', $id)); + + /** + * Triggers before the membership is reactivated. + * + * @param int $membership_id The ID of the membership. + * @param \WP_Ultimo\Models\Membership $membership Membership object. + * + * @since 2.4.14 + */ + do_action('wu_membership_pre_reactivate', $this->get_id(), $this); + + $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; + } + + /** + * Triggers after the membership is reactivated. + * + * @param int $membership_id The ID of the membership. + * @param \WP_Ultimo\Models\Membership $membership Membership object. + * + * @since 2.4.14 + */ + do_action('wu_membership_post_reactivate', $this->get_id(), $this); + + wu_log_add("membership-{$id}", sprintf('Completed membership reactivation for membership #%d. New Status: %s', $id, $this->get_status())); + + return true; + } + /** * Returns the number of days still left in the cycle. * diff --git a/ultimate-multisite.php b/ultimate-multisite.php index c0c67f534..742db48c8 100644 --- a/ultimate-multisite.php +++ b/ultimate-multisite.php @@ -3,7 +3,7 @@ * Plugin Name: Ultimate Multisite – WordPress Multisite SaaS & WaaS Platform * Plugin URI: https://ultimatemultisite.com * Description: Ultimate Multisite is a WordPress Multisite plugin that turns your network into a complete Website-as-a-Service (WaaS) platform with subscriptions, site provisioning, domain mapping, and customer management. Formerly WP Ultimo. - * Version: 2.4.13-beta.1 + * Version: 2.4.13-beta.21 * Author: Ultimate Multisite Community * Author URI: https://ultimatemultisite.com * License: GPLv2 or later @@ -29,7 +29,7 @@ * @author Arindo Duque, NextPress, WPMUDEV, and the Ultimate Multisite Community * @category Core * @package Ultimate_Multisite - * @version 2.4.13-beta.1 + * @version 2.4.13-beta.21 */ // Exit if accessed directly