Просмотр исходного кода

Implement free trial limits (#19507)

* Add additional validations for subscription trials

GitOrigin-RevId: 1cb821c62e02d3eaa5b2bcacaee63b6bc7a63311
Thomas 2 лет назад
Родитель
Сommit
a16db120c0

+ 13 - 0
services/web/app/src/Features/Subscription/SubscriptionController.js

@@ -218,6 +218,10 @@ async function userSubscriptionPage(req, res) {
   } = results
   const hasSubscription =
     await LimitationsManager.promises.userHasV1OrV2Subscription(user)
+
+  const userCanExtendTrial = (
+    await Modules.promises.hooks.fire('userCanExtendTrial', user)
+  )?.[0]
   const fromPlansPage = req.query.hasSubscription
   const plansData =
     SubscriptionViewModelBuilder.buildPlansListForSubscriptionDash(
@@ -270,6 +274,7 @@ async function userSubscriptionPage(req, res) {
     hasSubscription,
     fromPlansPage,
     personalSubscription,
+    userCanExtendTrial,
     memberGroupSubscriptions,
     managedGroupSubscriptions,
     managedInstitutions,
@@ -566,6 +571,14 @@ async function extendTrial(req, res) {
   const { subscription } =
     await LimitationsManager.promises.userHasV2Subscription(user)
 
+  const allowed = (
+    await Modules.promises.hooks.fire('userCanExtendTrial', user)
+  )?.[0]
+  if (!allowed) {
+    logger.warn({ userId: user._id }, 'user can not extend trial')
+    return res.sendStatus(403)
+  }
+
   try {
     await SubscriptionHandler.promises.extendTrial(subscription, 14)
     AnalyticsManager.recordEventForSession(

+ 9 - 0
services/web/app/src/Features/Subscription/SubscriptionHandler.js

@@ -8,6 +8,7 @@ const EmailHandler = require('../Email/EmailHandler')
 const PlansLocator = require('./PlansLocator')
 const SubscriptionHelper = require('./SubscriptionHelper')
 const { callbackify } = require('@overleaf/promise-utils')
+const UserUpdater = require('../User/UserUpdater')
 
 async function validateNoSubscriptionInRecurly(userId) {
   let subscriptions =
@@ -42,6 +43,14 @@ async function createSubscription(user, subscriptionDetails, recurlyTokenIds) {
     recurlyTokenIds
   )
 
+  if (recurlySubscription.trial_started_at) {
+    const trialStartedAt = new Date(recurlySubscription.trial_started_at)
+    await UserUpdater.promises.updateUser(
+      { _id: user._id, lastTrial: { $not: { $gt: trialStartedAt } } },
+      { $set: { lastTrial: trialStartedAt } }
+    )
+  }
+
   await SubscriptionUpdater.promises.syncSubscription(
     recurlySubscription,
     user._id

+ 1 - 0
services/web/app/src/models/User.js

@@ -72,6 +72,7 @@ const UserSchema = new Schema(
     lastLoggedIn: { type: Date },
     lastLoginIp: { type: String, default: '' },
     lastPrimaryEmailCheck: { type: Date },
+    lastTrial: { type: Date },
     loginCount: { type: Number, default: 0 },
     holdingAccount: { type: Boolean, default: false },
     ace: {

+ 1 - 0
services/web/app/views/subscriptions/dashboard-react.pug

@@ -8,6 +8,7 @@ block head-scripts
 
 block append meta
 	meta(name="ol-subscription" data-type="json" content=personalSubscription)
+	meta(name="ol-userCanExtendTrial" data-type="boolean" content=userCanExtendTrial)
 	meta(name="ol-managedGroupSubscriptions" data-type="json" content=managedGroupSubscriptions)
 	meta(name="ol-memberGroupSubscriptions" data-type="json" content=memberGroupSubscriptions)
 	meta(name="ol-managedInstitutions" data-type="json" content=managedInstitutions)

+ 1 - 0
services/web/frontend/extracted-translations.json

@@ -1668,6 +1668,7 @@
   "you_are_on_x_plan_as_a_confirmed_member_of_institution_y": "",
   "you_are_on_x_plan_as_member_of_group_subscription_y_administered_by_z": "",
   "you_can_also_choose_to_view_anonymously_or_leave_the_project": "",
+  "you_can_buy_this_plan_but_not_as_a_trial": "",
   "you_can_now_enable_sso": "",
   "you_can_now_log_in_sso": "",
   "you_can_only_add_n_people_to_edit_a_project": "",

+ 3 - 7
services/web/frontend/js/features/subscription/components/dashboard/states/active/cancel-plan/cancel-subscription.tsx

@@ -8,7 +8,6 @@ import {
   cancelSubscriptionUrl,
   redirectAfterCancelSubscriptionUrl,
 } from '../../../../../data/subscription-url'
-import canExtendTrial from '../../../../../util/can-extend-trial'
 import showDowngradeOption from '../../../../../util/show-downgrade-option'
 import ActionButtonText from '../../../action-button-text'
 import GenericErrorAlert from '../../../generic-error-alert'
@@ -144,7 +143,8 @@ function NotCancelOption({
 export function CancelSubscription() {
   const { t } = useTranslation()
   const location = useLocation()
-  const { personalSubscription, plans } = useSubscriptionDashboardContext()
+  const { personalSubscription, plans, userCanExtendTrial } =
+    useSubscriptionDashboardContext()
   const {
     isLoading: isLoadingCancel,
     isError: isErrorCancel,
@@ -186,11 +186,7 @@ export function CancelSubscription() {
     }
   }
 
-  const showExtendFreeTrial = canExtendTrial(
-    personalSubscription.plan.planCode,
-    personalSubscription.plan.groupPlan,
-    personalSubscription.recurly.trial_ends_at
-  )
+  const showExtendFreeTrial = userCanExtendTrial
 
   let confirmCancelButtonText = t('cancel_my_account')
   let confirmCancelButtonClass = 'btn-primary'

+ 4 - 0
services/web/frontend/js/features/subscription/context/subscription-dashboard-context.tsx

@@ -74,6 +74,7 @@ type SubscriptionDashboardContextValue = {
   setShowCancellation: React.Dispatch<React.SetStateAction<boolean>>
   leavingGroupId?: string
   setLeavingGroupId: React.Dispatch<React.SetStateAction<string | undefined>>
+  userCanExtendTrial: boolean
 }
 
 export const SubscriptionDashboardContext = createContext<
@@ -115,6 +116,7 @@ export function SubscriptionDashboardProvider({
   const plansWithoutDisplayPrice = getMeta('ol-plans')
   const institutionMemberships = getMeta('ol-currentInstitutionsWithLicence')
   const personalSubscription = getMeta('ol-subscription')
+  const userCanExtendTrial = getMeta('ol-userCanExtendTrial')
   const managedGroupSubscriptions = getMeta('ol-managedGroupSubscriptions')
   const memberGroupSubscriptions = getMeta('ol-memberGroupSubscriptions')
   const [managedInstitutions, setManagedInstitutions] = useState(
@@ -294,6 +296,7 @@ export function SubscriptionDashboardProvider({
       setShowCancellation,
       leavingGroupId,
       setLeavingGroupId,
+      userCanExtendTrial,
     }),
     [
       groupPlanToChangeToCode,
@@ -329,6 +332,7 @@ export function SubscriptionDashboardProvider({
       setShowCancellation,
       leavingGroupId,
       setLeavingGroupId,
+      userCanExtendTrial,
     ]
   )
 

+ 0 - 13
services/web/frontend/js/features/subscription/util/can-extend-trial.ts

@@ -1,13 +0,0 @@
-import freeTrialExpiresUnderSevenDays from './free-trial-expires-under-seven-days'
-import isMonthlyCollaboratorPlan from './is-monthly-collaborator-plan'
-
-export default function canExtendTrial(
-  planCode: string,
-  isGroupPlan?: boolean,
-  trialEndsAt?: string | null
-) {
-  return (
-    isMonthlyCollaboratorPlan(planCode, isGroupPlan) &&
-    freeTrialExpiresUnderSevenDays(trialEndsAt)
-  )
-}

+ 0 - 11
services/web/frontend/js/features/subscription/util/free-trial-expires-under-seven-days.ts

@@ -1,11 +0,0 @@
-export default function freeTrialExpiresUnderSevenDays(
-  trialEndsAt?: string | null
-) {
-  if (!trialEndsAt) return false
-
-  const sevenDaysTime = new Date()
-  sevenDaysTime.setDate(sevenDaysTime.getDate() + 7)
-  const freeTrialEndDate = new Date(trialEndsAt)
-
-  return new Date() < freeTrialEndDate && freeTrialEndDate < sevenDaysTime
-}

+ 2 - 0
services/web/frontend/js/utils/meta.ts

@@ -197,6 +197,8 @@ export interface Meta {
   'ol-usedLatex': 'never' | 'occasionally' | 'often' | undefined
   'ol-user': User
   'ol-userAffiliations': Affiliation[]
+  'ol-userCanExtendTrial': boolean
+  'ol-userCanNotStartRequestedTrial': boolean
   'ol-userEmails': UserEmailData[]
   'ol-userSettings': UserSettings
   'ol-user_id': string | undefined

+ 1 - 0
services/web/locales/en.json

@@ -2346,6 +2346,7 @@
   "you_are_on_x_plan_as_a_confirmed_member_of_institution_y": "You are on our <0>__planName__</0> plan as a <1>confirmed member</1> of <1>__institutionName__</1>",
   "you_are_on_x_plan_as_member_of_group_subscription_y_administered_by_z": "You are on our <0>__planName__</0> plan as a <1>member</1> of the group subscription <1>__groupName__</1> administered by <1>__adminEmail__</1>",
   "you_can_also_choose_to_view_anonymously_or_leave_the_project": "You can also choose to <0>view anonymously</0> (you will lose edit access) or <1>leave the project</1>.",
+  "you_can_buy_this_plan_but_not_as_a_trial": "You can buy this plan but not as a trial, as you’ve completed a trial recently.",
   "you_can_now_enable_sso": "You can now enable SSO on your Group settings page.",
   "you_can_now_log_in_sso": "You can now log in through your institution and if eligible you will receive <0>__appName__ Professional features</0>.",
   "you_can_only_add_n_people_to_edit_a_project": "You can only add __count__ person to edit a project with you on your current plan. Upgrade to add more.",

+ 10 - 20
services/web/test/frontend/features/subscription/components/dashboard/states/active/active.test.tsx

@@ -318,10 +318,14 @@ describe('<ActiveSubscription />', function () {
     })
 
     describe('extend trial', function () {
+      const canExtend = {
+        name: 'ol-userCanExtendTrial',
+        value: 'true',
+      }
       const cancelButtonText = 'No thanks, I still want to cancel'
       const extendTrialButtonText = 'I’ll take it!'
       it('shows alternate cancel subscription button text for cancel button and option to extend trial', function () {
-        renderActiveSubscription(trialCollaboratorSubscription)
+        renderActiveSubscription(trialCollaboratorSubscription, [canExtend])
         showConfirmCancelUI()
         screen.getByText('Have another', { exact: false })
         screen.getByText('14 days', { exact: false })
@@ -335,7 +339,7 @@ describe('<ActiveSubscription />', function () {
       })
 
       it('disables both buttons and updates text for when trial button clicked', function () {
-        renderActiveSubscription(trialCollaboratorSubscription)
+        renderActiveSubscription(trialCollaboratorSubscription, [canExtend])
         showConfirmCancelUI()
         const extendTrialButton = screen.getByRole('button', {
           name: extendTrialButtonText,
@@ -355,7 +359,7 @@ describe('<ActiveSubscription />', function () {
       })
 
       it('disables both buttons and updates text for when cancel button clicked', function () {
-        renderActiveSubscription(trialCollaboratorSubscription)
+        renderActiveSubscription(trialCollaboratorSubscription, [canExtend])
         showConfirmCancelUI()
         const cancelButtton = screen.getByRole('button', {
           name: cancelButtonText,
@@ -374,22 +378,8 @@ describe('<ActiveSubscription />', function () {
         })
       })
 
-      it('does not show option to extend trial when not a collaborator trial', function () {
-        const trialPlan = cloneDeep(trialCollaboratorSubscription)
-        trialPlan.plan.planCode = 'anotherplan'
-        renderActiveSubscription(trialPlan)
-        showConfirmCancelUI()
-        expect(
-          screen.queryByRole('button', {
-            name: extendTrialButtonText,
-          })
-        ).to.be.null
-      })
-
-      it('does not show option to extend trial when a collaborator trial but does not expire in 7 days', function () {
-        const trialPlan = cloneDeep(trialCollaboratorSubscription)
-        trialPlan.recurly.trial_ends_at = null
-        renderActiveSubscription(trialPlan)
+      it('does not show option to extend trial when user is not eligible', function () {
+        renderActiveSubscription(trialCollaboratorSubscription)
         showConfirmCancelUI()
         expect(
           screen.queryByRole('button', {
@@ -403,7 +393,7 @@ describe('<ActiveSubscription />', function () {
           status: 200,
         }
         fetchMock.put(extendTrialUrl, endPointResponse)
-        renderActiveSubscription(trialCollaboratorSubscription)
+        renderActiveSubscription(trialCollaboratorSubscription, [canExtend])
         showConfirmCancelUI()
         const extendTrialButton = screen.getByRole('button', {
           name: extendTrialButtonText,

+ 0 - 24
services/web/test/frontend/features/subscription/util/can-extend-trial.test.ts

@@ -1,24 +0,0 @@
-import { expect } from 'chai'
-import canExtendTrial from '../../../../../frontend/js/features/subscription/util/can-extend-trial'
-
-describe('canExtendTrial', function () {
-  const d = new Date()
-  d.setDate(d.getDate() + 6)
-
-  it('returns false when no trial end date', function () {
-    expect(canExtendTrial('collab')).to.be.false
-  })
-  it('returns false when a plan code without "collaborator" ', function () {
-    expect(canExtendTrial('test', false, d.toString())).to.be.false
-  })
-  it('returns false when on a plan with trial date in future but has "collaborator" and "ann" in plan code', function () {
-    expect(canExtendTrial('collaborator-annual', false, d.toString())).to.be
-      .false
-  })
-  it('returns false when on a plan with trial date in future and plan code has "collaborator" and no "ann" but is a group plan', function () {
-    expect(canExtendTrial('collaborator', true, d.toString())).to.be.false
-  })
-  it('returns true when on a plan with "collaborator" and without "ann" and trial date in future', function () {
-    expect(canExtendTrial('collaborator', false, d.toString())).to.be.true
-  })
-})

+ 0 - 25
services/web/test/frontend/features/subscription/util/free-trial-expires-under-seven-days.test.ts

@@ -1,25 +0,0 @@
-import { expect } from 'chai'
-import freeTrialExpiresUnderSevenDays from '../../../../../frontend/js/features/subscription/util/free-trial-expires-under-seven-days'
-
-describe('freeTrialExpiresUnderSevenDays', function () {
-  it('returns false when no date sent', function () {
-    expect(freeTrialExpiresUnderSevenDays()).to.be.false
-  })
-  it('returns false when date is null', function () {
-    expect(freeTrialExpiresUnderSevenDays(null)).to.be.false
-  })
-  it('returns false when date is in the past', function () {
-    expect(freeTrialExpiresUnderSevenDays('2000-02-16T17:59:07.000Z')).to.be
-      .false
-  })
-  it('returns true when date is in 6 days', function () {
-    const d = new Date()
-    d.setDate(d.getDate() + 6)
-    expect(freeTrialExpiresUnderSevenDays(d.toString())).to.be.true
-  })
-  it('returns false when date is in 8 days', function () {
-    const d = new Date()
-    d.setDate(d.getDate() + 8)
-    expect(freeTrialExpiresUnderSevenDays(d.toString())).to.be.false
-  })
-})

+ 41 - 0
services/web/test/unit/src/Subscription/SubscriptionHandlerTests.js

@@ -130,6 +130,12 @@ describe('SubscriptionHandler', function () {
       shouldPlanChangeAtTermEnd: sinon.stub(),
     }
 
+    this.UserUpdater = {
+      promises: {
+        updateUser: sinon.stub().resolves(),
+      },
+    }
+
     this.SubscriptionHandler = SandboxedModule.require(MODULE_PATH, {
       requires: {
         './RecurlyWrapper': this.RecurlyWrapper,
@@ -144,6 +150,7 @@ describe('SubscriptionHandler', function () {
         '../Analytics/AnalyticsManager': this.AnalyticsManager,
         './PlansLocator': this.PlansLocator,
         './SubscriptionHelper': this.SubscriptionHelper,
+        '../User/UserUpdater': this.UserUpdater,
       },
     })
   })
@@ -183,6 +190,40 @@ describe('SubscriptionHandler', function () {
           this.user._id
         )
       })
+
+      it('should not set last trial date if not a trial/the trial_started_at is not set', function () {
+        this.UserUpdater.promises.updateUser.should.not.have.been.called
+      })
+    })
+
+    describe('when the subscription is a trial and has a trial_started_at date', function () {
+      beforeEach(async function () {
+        this.activeRecurlySubscription.trial_started_at =
+          '2024-01-01T09:58:35.531+00:00'
+        await this.SubscriptionHandler.promises.createSubscription(
+          this.user,
+          this.subscriptionDetails,
+          this.recurlyTokenIds
+        )
+      })
+      it('should set the users lastTrial date', function () {
+        this.UserUpdater.promises.updateUser.should.have.been.calledOnce
+        expect(this.UserUpdater.promises.updateUser.args[0][0]).to.deep.equal({
+          _id: this.user_id,
+          lastTrial: {
+            $not: {
+              $gt: new Date(this.activeRecurlySubscription.trial_started_at),
+            },
+          },
+        })
+        expect(this.UserUpdater.promises.updateUser.args[0][1]).to.deep.equal({
+          $set: {
+            lastTrial: new Date(
+              this.activeRecurlySubscription.trial_started_at
+            ),
+          },
+        })
+      })
     })
 
     describe('when there is already a subscription in Recurly', function () {

+ 1 - 0
services/web/types/subscription/payment-context-value.tsx

@@ -65,4 +65,5 @@ export type PaymentContextValue = {
   addCoupon: (coupon: PricingFormState['coupon']) => void
   changeCurrency: (newCurrency: CurrencyCode) => void
   updateCountry: (country: PricingFormState['country']) => void
+  userCanNotStartRequestedTrial: boolean
 }