Explorar o código

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
Kristina hai 1 ano
pai
achega
23c1a0ba4d

+ 12 - 0
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 =

+ 1 - 0
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": "",

+ 1 - 6
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 &&

+ 7 - 4
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() {
       <h2 style={{ marginTop: 0 }}>{t('looking_multiple_licenses')}</h2>
       <p style={{ margin: 0 }}>{t('reduce_costs_group_licenses')}</p>
       <br />
-      {isInFreeTrial(subscription?.payment?.trialEndsAt) ? (
+      {!subscription?.payment?.isEligibleForGroupPlan ? (
         <>
           <OLTooltip
             id="disabled-change-to-group-plan"
-            description={t(
-              'sorry_you_can_only_change_to_group_from_trial_via_support'
-            )}
+            description={
+              isInTrial
+                ? t('sorry_you_can_only_change_to_group_from_trial_via_support')
+                : t('sorry_you_can_only_change_to_group_via_support')
+            }
             overlayProps={{ placement: 'top' }}
           >
             <div>

+ 1 - 0
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</0> 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",

+ 22 - 0
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,
   },
 }

+ 130 - 13
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, [

+ 2 - 0
services/web/types/subscription/dashboard/subscription.ts

@@ -44,6 +44,8 @@ type PaymentProviderRecord = {
   pendingTotalLicenses?: number
   pausedAt?: Nullable<string>
   remainingPauseCycles?: Nullable<number>
+  isEligibleForPause: boolean
+  isEligibleForGroupPlan: boolean
 }
 
 export type GroupPolicy = {