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() {

{t('looking_multiple_licenses')}

{t('reduce_costs_group_licenses')}


- {isInFreeTrial(subscription?.payment?.trialEndsAt) ? ( + {!subscription?.payment?.isEligibleForGroupPlan ? ( <>
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 team 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 = {