diff --git a/inc/checkout/class-cart.php b/inc/checkout/class-cart.php
index ce70bb31..a98d6dce 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 0552724f..11c8f914 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 0017b838..64c31a6f 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 79fd84e3..48b250e8 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 13bff3ab..cb9d6846 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 = '
' . 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 .= '