-
-
Notifications
You must be signed in to change notification settings - Fork 75
feat: reactivation flow for cancelled memberships + trial skip #750
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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; | ||
|
Comment on lines
+818
to
+847
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 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 π οΈ 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 |
||
| } | ||
| } | ||
|
|
||
| /* | ||
| * 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. | ||
| */ | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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); | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
coderabbitai[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| $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; | ||||||||||||||||||||||||||||||||||
|
Comment on lines
+1343
to
+1346
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Require a published site before skipping site creation.
π οΈ 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
Suggested change
π€ Prompt for AI Agents |
||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| /* | ||||||||||||||||||||||||||||||||||
| * Let's get a list of membership sites. | ||||||||||||||||||||||||||||||||||
| * This list includes pending sites as well. | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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; | ||
| } | ||
|
Comment on lines
+233
to
+245
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 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 π οΈ 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 |
||
|
|
||
| $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 | ||
| * | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Keep the fallback page generic when no reactivation URL exists.
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 |
||
|
|
||
| $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 .= ' · <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; | ||
| } | ||
| } | ||
|
|
||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.