mirror of
https://github.com/overleaf/overleaf.git
synced 2025-04-20 02:03:48 +00:00
Implement free trial limits (#19507)
* Add additional validations for subscription trials GitOrigin-RevId: 1cb821c62e02d3eaa5b2bcacaee63b6bc7a63311
This commit is contained in:
parent
3836323724
commit
a16db120c0
16 changed files with 87 additions and 100 deletions
|
@ -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(
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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": "",
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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,
|
||||
]
|
||||
)
|
||||
|
||||
|
|
|
@ -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)
|
||||
)
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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.",
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
})
|
||||
})
|
|
@ -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
|
||||
})
|
||||
})
|
|
@ -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 () {
|
||||
|
|
|
@ -65,4 +65,5 @@ export type PaymentContextValue = {
|
|||
addCoupon: (coupon: PricingFormState['coupon']) => void
|
||||
changeCurrency: (newCurrency: CurrencyCode) => void
|
||||
updateCountry: (country: PricingFormState['country']) => void
|
||||
userCanNotStartRequestedTrial: boolean
|
||||
}
|
||||
|
|
Loading…
Add table
Reference in a new issue