Implement free trial limits (#19507)

* Add additional validations for subscription trials

GitOrigin-RevId: 1cb821c62e02d3eaa5b2bcacaee63b6bc7a63311
This commit is contained in:
Thomas 2024-08-07 17:30:55 +02:00 committed by Copybot
parent 3836323724
commit a16db120c0
16 changed files with 87 additions and 100 deletions

View file

@ -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(

View file

@ -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

View file

@ -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: {

View file

@ -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)

View file

@ -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": "",

View file

@ -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'

View file

@ -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,
]
)

View file

@ -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)
)
}

View file

@ -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
}

View file

@ -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

View file

@ -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 youve 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.",

View file

@ -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 = 'Ill 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,

View file

@ -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
})
})

View file

@ -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
})
})

View file

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

View file

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