diff --git a/services/web/app/src/Features/Subscription/SubscriptionController.js b/services/web/app/src/Features/Subscription/SubscriptionController.js index 1dbf5491de..596dc971f9 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionController.js +++ b/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( diff --git a/services/web/app/src/Features/Subscription/SubscriptionHandler.js b/services/web/app/src/Features/Subscription/SubscriptionHandler.js index 4ccbfde9e4..1f75dc8d49 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionHandler.js +++ b/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 diff --git a/services/web/app/src/models/User.js b/services/web/app/src/models/User.js index 69e9b900bd..01babf4cd5 100644 --- a/services/web/app/src/models/User.js +++ b/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: { diff --git a/services/web/app/views/subscriptions/dashboard-react.pug b/services/web/app/views/subscriptions/dashboard-react.pug index 96cefee91e..f0da840b33 100644 --- a/services/web/app/views/subscriptions/dashboard-react.pug +++ b/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) diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index 491c0b566a..c8d7e350fb 100644 --- a/services/web/frontend/extracted-translations.json +++ b/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": "", diff --git a/services/web/frontend/js/features/subscription/components/dashboard/states/active/cancel-plan/cancel-subscription.tsx b/services/web/frontend/js/features/subscription/components/dashboard/states/active/cancel-plan/cancel-subscription.tsx index 7881094b2f..83e3fe5f54 100644 --- a/services/web/frontend/js/features/subscription/components/dashboard/states/active/cancel-plan/cancel-subscription.tsx +++ b/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' diff --git a/services/web/frontend/js/features/subscription/context/subscription-dashboard-context.tsx b/services/web/frontend/js/features/subscription/context/subscription-dashboard-context.tsx index a3c25c9a03..766054f2c5 100644 --- a/services/web/frontend/js/features/subscription/context/subscription-dashboard-context.tsx +++ b/services/web/frontend/js/features/subscription/context/subscription-dashboard-context.tsx @@ -74,6 +74,7 @@ type SubscriptionDashboardContextValue = { setShowCancellation: React.Dispatch> leavingGroupId?: string setLeavingGroupId: React.Dispatch> + 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, ] ) diff --git a/services/web/frontend/js/features/subscription/util/can-extend-trial.ts b/services/web/frontend/js/features/subscription/util/can-extend-trial.ts deleted file mode 100644 index 69e7d33d2a..0000000000 --- a/services/web/frontend/js/features/subscription/util/can-extend-trial.ts +++ /dev/null @@ -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) - ) -} diff --git a/services/web/frontend/js/features/subscription/util/free-trial-expires-under-seven-days.ts b/services/web/frontend/js/features/subscription/util/free-trial-expires-under-seven-days.ts deleted file mode 100644 index 72af0e7d5d..0000000000 --- a/services/web/frontend/js/features/subscription/util/free-trial-expires-under-seven-days.ts +++ /dev/null @@ -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 -} diff --git a/services/web/frontend/js/utils/meta.ts b/services/web/frontend/js/utils/meta.ts index b5552b6405..5750292705 100644 --- a/services/web/frontend/js/utils/meta.ts +++ b/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 diff --git a/services/web/locales/en.json b/services/web/locales/en.json index f2729af572..78f579341d 100644 --- a/services/web/locales/en.json +++ b/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__ plan as a <1>confirmed member of <1>__institutionName__", "you_are_on_x_plan_as_member_of_group_subscription_y_administered_by_z": "You are on our <0>__planName__ plan as a <1>member of the group subscription <1>__groupName__ administered by <1>__adminEmail__", "you_can_also_choose_to_view_anonymously_or_leave_the_project": "You can also choose to <0>view anonymously (you will lose edit access) or <1>leave the project.", + "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.", "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.", diff --git a/services/web/test/frontend/features/subscription/components/dashboard/states/active/active.test.tsx b/services/web/test/frontend/features/subscription/components/dashboard/states/active/active.test.tsx index f91a117238..e3b132bb3f 100644 --- a/services/web/test/frontend/features/subscription/components/dashboard/states/active/active.test.tsx +++ b/services/web/test/frontend/features/subscription/components/dashboard/states/active/active.test.tsx @@ -318,10 +318,14 @@ describe('', 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('', 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('', 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('', 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('', function () { status: 200, } fetchMock.put(extendTrialUrl, endPointResponse) - renderActiveSubscription(trialCollaboratorSubscription) + renderActiveSubscription(trialCollaboratorSubscription, [canExtend]) showConfirmCancelUI() const extendTrialButton = screen.getByRole('button', { name: extendTrialButtonText, diff --git a/services/web/test/frontend/features/subscription/util/can-extend-trial.test.ts b/services/web/test/frontend/features/subscription/util/can-extend-trial.test.ts deleted file mode 100644 index 1799f01994..0000000000 --- a/services/web/test/frontend/features/subscription/util/can-extend-trial.test.ts +++ /dev/null @@ -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 - }) -}) diff --git a/services/web/test/frontend/features/subscription/util/free-trial-expires-under-seven-days.test.ts b/services/web/test/frontend/features/subscription/util/free-trial-expires-under-seven-days.test.ts deleted file mode 100644 index 744619aeca..0000000000 --- a/services/web/test/frontend/features/subscription/util/free-trial-expires-under-seven-days.test.ts +++ /dev/null @@ -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 - }) -}) diff --git a/services/web/test/unit/src/Subscription/SubscriptionHandlerTests.js b/services/web/test/unit/src/Subscription/SubscriptionHandlerTests.js index 11878a60b6..32876e5455 100644 --- a/services/web/test/unit/src/Subscription/SubscriptionHandlerTests.js +++ b/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 () { diff --git a/services/web/types/subscription/payment-context-value.tsx b/services/web/types/subscription/payment-context-value.tsx index 7e7bc121d1..a305095dbb 100644 --- a/services/web/types/subscription/payment-context-value.tsx +++ b/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 }