From 23c1a0ba4d609e9099d276b0c4d0a4eb3c1e14e9 Mon Sep 17 00:00:00 2001
From: Kristina <7614497+khjrtbrg@users.noreply.github.com>
Date: Mon, 28 Apr 2025 10:12:21 +0200
Subject: [PATCH] Merge pull request #25082 from
overleaf/kh-prevent-pausing-and-group-plans
[web] prevent pausing or upgrading to group for Stripe subscriptions
GitOrigin-RevId: 4d194339282d8bc165ffa1b89e8e1cf298c2d343
---
.../SubscriptionViewModelBuilder.js | 12 ++
.../web/frontend/extracted-translations.json | 1 +
.../active/cancel-subscription-button.tsx | 7 +-
.../change-plan/change-to-group-plan.tsx | 11 +-
services/web/locales/en.json | 1 +
.../subscription/fixtures/subscriptions.ts | 22 +++
.../SubscriptionViewModelBuilderTests.js | 143 ++++++++++++++++--
.../subscription/dashboard/subscription.ts | 2 +
8 files changed, 176 insertions(+), 23 deletions(-)
diff --git a/services/web/app/src/Features/Subscription/SubscriptionViewModelBuilder.js b/services/web/app/src/Features/Subscription/SubscriptionViewModelBuilder.js
index 03c7bd19ce..129463dcf0 100644
--- a/services/web/app/src/Features/Subscription/SubscriptionViewModelBuilder.js
+++ b/services/web/app/src/Features/Subscription/SubscriptionViewModelBuilder.js
@@ -242,6 +242,9 @@ async function buildUsersSubscriptionViewModel(user, locale = 'en') {
}
})
const totalLicenses = (plan.membersLimit || 0) + additionalLicenses
+ const isInTrial =
+ paymentRecord.subscription.trialPeriodEnd &&
+ paymentRecord.subscription.trialPeriodEnd.getTime() > Date.now()
personalSubscription.payment = {
taxRate,
billingDetailsLink:
@@ -269,6 +272,15 @@ async function buildUsersSubscriptionViewModel(user, locale = 'en') {
hasPastDueInvoice: paymentRecord.account.hasPastDueInvoice,
pausedAt: paymentRecord.subscription.pausePeriodStart,
remainingPauseCycles: paymentRecord.subscription.remainingPauseCycles,
+ isEligibleForPause:
+ paymentRecord.subscription.service === 'recurly' &&
+ !personalSubscription.pendingPlan &&
+ !personalSubscription.groupPlan &&
+ !isInTrial &&
+ !paymentRecord.subscription.planCode.includes('ann') &&
+ !paymentRecord.subscription.addOns?.length > 0,
+ isEligibleForGroupPlan:
+ paymentRecord.subscription.service === 'recurly' && !isInTrial,
}
if (paymentRecord.subscription.pendingChange) {
const pendingPlanCode =
diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json
index 1afe9f8092..dcf37871ef 100644
--- a/services/web/frontend/extracted-translations.json
+++ b/services/web/frontend/extracted-translations.json
@@ -1560,6 +1560,7 @@
"sorry_there_was_an_issue_adding_x_users_to_your_subscription": "",
"sorry_there_was_an_issue_upgrading_your_subscription": "",
"sorry_you_can_only_change_to_group_from_trial_via_support": "",
+ "sorry_you_can_only_change_to_group_via_support": "",
"sorry_your_table_cant_be_displayed_at_the_moment": "",
"sort_by": "",
"sort_by_x": "",
diff --git a/services/web/frontend/js/features/subscription/components/dashboard/states/active/cancel-subscription-button.tsx b/services/web/frontend/js/features/subscription/components/dashboard/states/active/cancel-subscription-button.tsx
index 9ce46288f8..7713b4671d 100644
--- a/services/web/frontend/js/features/subscription/components/dashboard/states/active/cancel-subscription-button.tsx
+++ b/services/web/frontend/js/features/subscription/components/dashboard/states/active/cancel-subscription-button.tsx
@@ -24,12 +24,7 @@ export function CancelSubscriptionButton() {
(subscription.payment.state === 'active' &&
subscription.payment.remainingPauseCycles &&
subscription.payment.remainingPauseCycles > 0)
- const planIsEligibleForPause =
- !subscription.pendingPlan &&
- !subscription.groupPlan &&
- !isInTrial &&
- !subscription.planCode.includes('ann') &&
- !subscription.addOns?.length
+ const planIsEligibleForPause = subscription.payment.isEligibleForPause
const enablePause =
useFeatureFlag('pause-subscription') &&
!hasPendingOrActivePause &&
diff --git a/services/web/frontend/js/features/subscription/components/dashboard/states/active/change-plan/change-to-group-plan.tsx b/services/web/frontend/js/features/subscription/components/dashboard/states/active/change-plan/change-to-group-plan.tsx
index 12303af6b0..bd602ccd75 100644
--- a/services/web/frontend/js/features/subscription/components/dashboard/states/active/change-plan/change-to-group-plan.tsx
+++ b/services/web/frontend/js/features/subscription/components/dashboard/states/active/change-plan/change-to-group-plan.tsx
@@ -15,6 +15,7 @@ export function ChangeToGroupPlan() {
personalSubscription && 'payment' in personalSubscription
? (personalSubscription as PaidSubscription)
: null
+ const isInTrial = isInFreeTrial(subscription?.payment?.trialEndsAt)
const handleClick = () => {
handleOpenModal('change-to-group')
@@ -25,13 +26,15 @@ export function ChangeToGroupPlan() {
diff --git a/services/web/locales/en.json b/services/web/locales/en.json
index 0ab4bfe27d..500319ee82 100644
--- a/services/web/locales/en.json
+++ b/services/web/locales/en.json
@@ -2042,6 +2042,7 @@
"sorry_there_was_an_issue_upgrading_your_subscription": "Sorry, there was an issue upgrading your subscription. Please <0>contact our Support team0> for help.",
"sorry_this_account_has_been_suspended": "Sorry, this account has been suspended.",
"sorry_you_can_only_change_to_group_from_trial_via_support": "Sorry, you can only change to a group plan during a free trial by contacting support.",
+ "sorry_you_can_only_change_to_group_via_support": "Sorry, you can only change to a group plan by contacting support.",
"sorry_your_table_cant_be_displayed_at_the_moment": "Sorry, your table can’t be displayed at the moment.",
"sorry_your_token_expired": "Sorry, your token expired",
"sort_by": "Sort by",
diff --git a/services/web/test/frontend/features/subscription/fixtures/subscriptions.ts b/services/web/test/frontend/features/subscription/fixtures/subscriptions.ts
index b23a5b1241..ebd741b240 100644
--- a/services/web/test/frontend/features/subscription/fixtures/subscriptions.ts
+++ b/services/web/test/frontend/features/subscription/fixtures/subscriptions.ts
@@ -52,6 +52,8 @@ export const annualActiveSubscription: PaidSubscription = {
planOnlyDisplayPrice: '',
addOns: [],
addOnDisplayPricesWithoutAdditionalLicense: {},
+ isEligibleForGroupPlan: true,
+ isEligibleForPause: false,
},
}
@@ -92,6 +94,8 @@ export const annualActiveSubscriptionEuro: PaidSubscription = {
planOnlyDisplayPrice: '',
addOns: [],
addOnDisplayPricesWithoutAdditionalLicense: {},
+ isEligibleForGroupPlan: true,
+ isEligibleForPause: true,
},
}
@@ -131,6 +135,8 @@ export const annualActiveSubscriptionPro: PaidSubscription = {
planOnlyDisplayPrice: '',
addOns: [],
addOnDisplayPricesWithoutAdditionalLicense: {},
+ isEligibleForGroupPlan: true,
+ isEligibleForPause: true,
},
}
@@ -171,6 +177,8 @@ export const pastDueExpiredSubscription: PaidSubscription = {
planOnlyDisplayPrice: '',
addOns: [],
addOnDisplayPricesWithoutAdditionalLicense: {},
+ isEligibleForGroupPlan: true,
+ isEligibleForPause: true,
},
}
@@ -211,6 +219,8 @@ export const canceledSubscription: PaidSubscription = {
planOnlyDisplayPrice: '',
addOns: [],
addOnDisplayPricesWithoutAdditionalLicense: {},
+ isEligibleForGroupPlan: true,
+ isEligibleForPause: true,
},
}
@@ -251,6 +261,8 @@ export const pendingSubscriptionChange: PaidSubscription = {
planOnlyDisplayPrice: '',
addOns: [],
addOnDisplayPricesWithoutAdditionalLicense: {},
+ isEligibleForGroupPlan: true,
+ isEligibleForPause: false,
},
pendingPlan: {
planCode: 'professional-annual',
@@ -302,6 +314,8 @@ export const groupActiveSubscription: GroupSubscription = {
planOnlyDisplayPrice: '',
addOns: [],
addOnDisplayPricesWithoutAdditionalLicense: {},
+ isEligibleForGroupPlan: true,
+ isEligibleForPause: false,
},
}
@@ -349,6 +363,8 @@ export const groupActiveSubscriptionWithPendingLicenseChange: GroupSubscription
planOnlyDisplayPrice: '',
addOns: [],
addOnDisplayPricesWithoutAdditionalLicense: {},
+ isEligibleForGroupPlan: true,
+ isEligibleForPause: false,
},
pendingPlan: {
planCode: 'group_collaborator_10_enterprise',
@@ -399,6 +415,8 @@ export const trialSubscription: PaidSubscription = {
planOnlyDisplayPrice: '',
addOns: [],
addOnDisplayPricesWithoutAdditionalLicense: {},
+ isEligibleForGroupPlan: true,
+ isEligibleForPause: false,
},
}
@@ -460,6 +478,8 @@ export const trialCollaboratorSubscription: PaidSubscription = {
planOnlyDisplayPrice: '',
addOns: [],
addOnDisplayPricesWithoutAdditionalLicense: {},
+ isEligibleForGroupPlan: true,
+ isEligibleForPause: true,
},
}
@@ -499,5 +519,7 @@ export const monthlyActiveCollaborator: PaidSubscription = {
planOnlyDisplayPrice: '',
addOns: [],
addOnDisplayPricesWithoutAdditionalLicense: {},
+ isEligibleForGroupPlan: true,
+ isEligibleForPause: true,
},
}
diff --git a/services/web/test/unit/src/Subscription/SubscriptionViewModelBuilderTests.js b/services/web/test/unit/src/Subscription/SubscriptionViewModelBuilderTests.js
index 90a69f9a89..e969cf381c 100644
--- a/services/web/test/unit/src/Subscription/SubscriptionViewModelBuilderTests.js
+++ b/services/web/test/unit/src/Subscription/SubscriptionViewModelBuilderTests.js
@@ -507,12 +507,24 @@ describe('SubscriptionViewModelBuilder', function () {
})
describe('buildUsersSubscriptionViewModel', function () {
- describe('with a recurly subscription', function () {
+ beforeEach(function () {
+ this.SubscriptionLocator.getUsersSubscription.yields(
+ null,
+ this.individualSubscription
+ )
+ this.Modules.hooks.fire
+ .withArgs('getPaymentFromRecord', this.individualSubscription)
+ .yields(null, [
+ {
+ subscription: this.paymentRecord,
+ account: new PaymentProviderAccount({}),
+ coupons: [],
+ },
+ ])
+ })
+
+ describe('with a paid subscription', function () {
it('adds payment data to the personal subscription', async function () {
- this.SubscriptionLocator.getUsersSubscription.yields(
- null,
- this.individualSubscription
- )
this.Modules.hooks.fire
.withArgs('getPaymentFromRecord', this.individualSubscription)
.yields(null, [
@@ -561,14 +573,123 @@ describe('SubscriptionViewModelBuilder', function () {
addOnDisplayPricesWithoutAdditionalLicense: {
'addon-code': '€2.20',
},
+ isEligibleForGroupPlan: true,
+ isEligibleForPause: false,
+ })
+ })
+
+ describe('isEligibleForGroupPlan', function () {
+ it('is false for Stripe subscriptions', async function () {
+ this.paymentRecord.service = 'stripe'
+ const result =
+ await this.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel(
+ this.user
+ )
+ assert.isFalse(
+ result.personalSubscription.payment.isEligibleForGroupPlan
+ )
+ })
+
+ it('is false when in trial', async function () {
+ const msIn24Hours = 24 * 60 * 60 * 1000
+ const tomorrow = new Date(Date.now() + msIn24Hours)
+ this.paymentRecord.trialPeriodEnd = tomorrow
+ this.paymentRecord.service = 'recurly'
+ const result =
+ await this.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel(
+ this.user
+ )
+ assert.isFalse(
+ result.personalSubscription.payment.isEligibleForGroupPlan
+ )
+ })
+
+ it('is true when not in trial and for a Recurly subscription', async function () {
+ this.paymentRecord.service = 'recurly'
+ const result =
+ await this.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel(
+ this.user
+ )
+ assert.isTrue(
+ result.personalSubscription.payment.isEligibleForGroupPlan
+ )
+ })
+ })
+
+ describe('isEligibleForPause', function () {
+ it('is false for Stripe subscriptions', async function () {
+ this.paymentRecord.service = 'stripe'
+ const result =
+ await this.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel(
+ this.user
+ )
+ assert.isFalse(result.personalSubscription.payment.isEligibleForPause)
+ })
+
+ it('is false for subscriptions with pending plan', async function () {
+ this.paymentRecord.service = 'recurly'
+ this.individualSubscription.pendingPlan = {} // anything
+ const result =
+ await this.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel(
+ this.user
+ )
+ assert.isFalse(result.personalSubscription.payment.isEligibleForPause)
+ })
+
+ it('is false for a group subscription', async function () {
+ this.paymentRecord.service = 'recurly'
+ this.individualSubscription.groupPlan = true
+ const result =
+ await this.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel(
+ this.user
+ )
+ assert.isFalse(result.personalSubscription.payment.isEligibleForPause)
+ })
+
+ it('is false when in trial', async function () {
+ this.paymentRecord.service = 'recurly'
+ const msIn24Hours = 24 * 60 * 60 * 1000
+ const tomorrow = new Date(Date.now() + msIn24Hours)
+ this.paymentRecord.trialPeriodEnd = tomorrow
+ const result =
+ await this.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel(
+ this.user
+ )
+ assert.isFalse(result.personalSubscription.payment.isEligibleForPause)
+ })
+
+ it('is false for annual subscriptions', async function () {
+ this.paymentRecord.service = 'recurly'
+ this.paymentRecord.planCode = 'collaborator-annual'
+ const result =
+ await this.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel(
+ this.user
+ )
+ assert.isFalse(result.personalSubscription.payment.isEligibleForPause)
+ })
+
+ it('is false for subscriptions with add-ons', async function () {
+ this.paymentRecord.service = 'recurly'
+ this.paymentRecord.addOns = [{}] // anything
+ const result =
+ await this.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel(
+ this.user
+ )
+ assert.isFalse(result.personalSubscription.payment.isEligibleForPause)
+ })
+
+ it('is true when conditions are met', async function () {
+ this.paymentRecord.service = 'recurly'
+ this.paymentRecord.addOns = []
+ const result =
+ await this.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel(
+ this.user
+ )
+ assert.isTrue(result.personalSubscription.payment.isEligibleForPause)
})
})
it('includes pending changes', async function () {
- this.SubscriptionLocator.getUsersSubscription.yields(
- null,
- this.individualSubscription
- )
this.paymentRecord.pendingChange =
new PaymentProviderSubscriptionChange({
subscription: this.paymentRecord,
@@ -628,10 +749,6 @@ describe('SubscriptionViewModelBuilder', function () {
it('does not add a billing details link for a Stripe subscription', async function () {
this.paymentRecord.service = 'stripe'
- this.SubscriptionLocator.getUsersSubscription.yields(
- null,
- this.individualSubscription
- )
this.Modules.hooks.fire
.withArgs('getPaymentFromRecord', this.individualSubscription)
.yields(null, [
diff --git a/services/web/types/subscription/dashboard/subscription.ts b/services/web/types/subscription/dashboard/subscription.ts
index cd467c80a5..1272f33eb0 100644
--- a/services/web/types/subscription/dashboard/subscription.ts
+++ b/services/web/types/subscription/dashboard/subscription.ts
@@ -44,6 +44,8 @@ type PaymentProviderRecord = {
pendingTotalLicenses?: number
pausedAt?: Nullable
remainingPauseCycles?: Nullable
+ isEligibleForPause: boolean
+ isEligibleForGroupPlan: boolean
}
export type GroupPolicy = {