diff --git a/services/web/app/src/Features/Subscription/CustomerIoPlanHelpers.mjs b/services/web/app/src/Features/Subscription/CustomerIoPlanHelpers.mjs index 7e8ea5b357..4792c02d76 100644 --- a/services/web/app/src/Features/Subscription/CustomerIoPlanHelpers.mjs +++ b/services/web/app/src/Features/Subscription/CustomerIoPlanHelpers.mjs @@ -1,7 +1,30 @@ +// @ts-check import Settings from '@overleaf/settings' import { AI_ADD_ON_CODE, isStandaloneAiAddOnPlanCode } from './AiHelper.mjs' import FeaturesHelper from './FeaturesHelper.mjs' +/** + * @typedef {InstanceType} MongoSubscription + * @typedef {import('../../../../types/subscription/plan').Plan} Plan + * @typedef {import('../../../../modules/subscriptions/app/src/PaymentService.mjs').PaymentRecord} PaymentRecord + */ + +/** + * @template T + * @typedef {T | null} Nullable + */ + +/** + * Subset of the "best subscription" object from + * SubscriptionViewModelBuilder.buildUsersSubscriptionViewModel + * + * @typedef {object} BestSubscription + * @property {'free' | 'individual' | 'group' | 'commons' | 'standalone-ai-add-on'} [type] + * @property {Partial} [plan] + * @property {{ teamName?: string, membersLimit?: number }} [subscription] + * @property {number} [remainingTrialDays] + */ + const INACTIVE_NEXT_RENEWAL_DATE_STATES = new Set([ 'canceled', 'cancelled', @@ -9,12 +32,22 @@ const INACTIVE_NEXT_RENEWAL_DATE_STATES = new Set([ ]) const PENDING_CANCELLATION_STATES = new Set(['canceled', 'cancelled']) +/** + * @param {MongoSubscription} subscription + * @returns {string} + */ function getSubscriptionState(subscription) { return ( - subscription?.recurlyStatus?.state || subscription?.paymentProvider?.state + subscription.recurlyStatus?.state || + subscription.paymentProvider?.state || + '' ) } +/** + * @param {Nullable} [dateValue] + * @returns {number | null} + */ function toUnixTimestamp(dateValue) { if (!dateValue) { return null @@ -28,12 +61,19 @@ function toUnixTimestamp(dateValue) { return Math.floor(date.getTime() / 1000) } +/** + * @param {Nullable} [bestSubscription] + * @returns {string} + */ function normalizePlanType(bestSubscription) { if (!bestSubscription) { - return null + return '' } - if (['standalone-ai-add-on', 'commons'].includes(bestSubscription.type)) { + if ( + bestSubscription.type === 'standalone-ai-add-on' || + bestSubscription.type === 'commons' + ) { return bestSubscription.type } @@ -41,7 +81,7 @@ function normalizePlanType(bestSubscription) { const isGroupPlan = bestSubscription.plan?.groupPlan === true if (!planCode) { - return bestSubscription.type || null + return bestSubscription.type || '' } if (planCode.startsWith('v1_')) { @@ -71,11 +111,35 @@ function normalizePlanType(bestSubscription) { return planCode } +/** + * @param {Nullable} [planCode] + * @returns {string} + */ +function normalizePlanTypeFromPlanCode(planCode) { + if (!planCode) { + return '' + } + const plan = /** @type {Plan[]} */ (Settings.plans).find( + candidate => candidate.planCode === planCode + ) + return normalizePlanType({ + plan: { + planCode, + groupPlan: plan?.groupPlan === true, + }, + }) +} + +/** + * @param {Nullable} [planType] + * @returns {string} + */ function getFriendlyPlanName(planType) { if (!planType) { - return null + return '' } + /** @type {Record} */ const friendlyPlanNames = { free: 'Free', personal: 'Personal', @@ -96,6 +160,10 @@ function getFriendlyPlanName(planType) { return planType } +/** + * @param {Nullable} [bestSubscription] + * @returns {'annual' | 'monthly' | null} + */ function getPlanCadence(bestSubscription) { if (!bestSubscription?.plan) { return null @@ -104,12 +172,18 @@ function getPlanCadence(bestSubscription) { return bestSubscription.plan.annual ? 'annual' : 'monthly' } +/** + * @param {Nullable} [planCode] + * @returns {'annual' | 'monthly' | null} + */ function getPlanCadenceFromPlanCode(planCode) { if (!planCode) { return null } - const plan = Settings.plans.find(candidate => candidate.planCode === planCode) + const plan = /** @type {Plan[]} */ (Settings.plans).find( + candidate => candidate.planCode === planCode + ) if (plan) { return plan.annual ? 'annual' : 'monthly' } @@ -125,15 +199,26 @@ function getPlanCadenceFromPlanCode(planCode) { return null } +/** + * @param {Nullable} [paymentRecord] + * @returns {number | null} + */ function getNextRenewalDateFromPaymentRecord(paymentRecord) { const subscriptionState = paymentRecord?.subscription?.state - if (INACTIVE_NEXT_RENEWAL_DATE_STATES.has(subscriptionState)) { + if ( + subscriptionState && + INACTIVE_NEXT_RENEWAL_DATE_STATES.has(subscriptionState) + ) { return null } return toUnixTimestamp(paymentRecord?.subscription?.periodEnd) } +/** + * @param {Nullable} [subscription] + * @returns {boolean} + */ function shouldClearNextRenewalDate(subscription) { if (!subscription) { return true @@ -144,9 +229,16 @@ function shouldClearNextRenewalDate(subscription) { ) } +/** + * @param {Nullable} [paymentRecord] + * @returns {number | null} + */ function getExpiryDateFromPaymentRecord(paymentRecord) { const subscriptionState = paymentRecord?.subscription?.state - if (!PENDING_CANCELLATION_STATES.has(subscriptionState)) { + if ( + subscriptionState == null || + !PENDING_CANCELLATION_STATES.has(subscriptionState) + ) { return null } @@ -158,6 +250,10 @@ function getExpiryDateFromPaymentRecord(paymentRecord) { return expiryDate > Math.floor(Date.now() / 1000) ? expiryDate : null } +/** + * @param {Nullable} [subscription] + * @returns {boolean} + */ function shouldClearExpiryDate(subscription) { if (!subscription) { return true @@ -166,6 +262,10 @@ function shouldClearExpiryDate(subscription) { return !PENDING_CANCELLATION_STATES.has(getSubscriptionState(subscription)) } +/** + * @param {Nullable} [individualSubscription] + * @returns {number | null} + */ function getTrialEndDate(individualSubscription) { const trialEndsAt = individualSubscription?.recurlyStatus?.trialEndsAt || @@ -173,6 +273,11 @@ function getTrialEndDate(individualSubscription) { return toUnixTimestamp(trialEndsAt) } +/** + * @param {Nullable} [individualSubscription] + * @param {Nullable} [paymentRecord] + * @returns {boolean} + */ function hasIndividualAiAssistAddOn(individualSubscription, paymentRecord) { if ( !individualSubscription || @@ -189,6 +294,13 @@ function hasIndividualAiAssistAddOn(individualSubscription, paymentRecord) { ) } +/** + * @param {Nullable} [bestSubscription] + * @param {Nullable} [individualSubscription] + * @param {Nullable} [paymentRecord] + * @param {Nullable<{ isPremium?: boolean }>} [writefullData] + * @returns {'ai-assist-standalone' | 'ai-assist-add-on' | 'writefull-premium' | 'none'} + */ function getAiPlanType( bestSubscription, individualSubscription, @@ -213,6 +325,13 @@ function getAiPlanType( return 'none' } +/** + * @param {Nullable} [aiPlan] + * @param {Nullable} [bestSubscription] + * @param {Nullable} [individualSubscription] + * @param {Nullable} [paymentRecord] + * @returns {'annual' | 'monthly' | null} + */ function getAiPlanCadence( aiPlan, bestSubscription, @@ -236,6 +355,10 @@ function getAiPlanCadence( return null } +/** + * @param {Nullable>} [plan] + * @returns {boolean} + */ function hasPlanAiEnabled(plan) { if (!plan?.features) { return false @@ -247,6 +370,13 @@ function hasPlanAiEnabled(plan) { ) } +/** + * @param {MongoSubscription[]} [memberGroupSubscriptions] + * @param {MongoSubscription[]} [managedGroupSubscriptions] + * @param {boolean} [userIsMemberOfGroupSubscription] + * @param {Map} [aiBlockedByPolicyId] + * @returns {boolean | null} + */ function getGroupAiEnabled( memberGroupSubscriptions = [], managedGroupSubscriptions = [], @@ -270,6 +400,12 @@ function getGroupAiEnabled( return !someBlocked } +/** + * @param {Nullable} [bestSubscription] + * @param {MongoSubscription[]} [memberGroupSubscriptions] + * @param {MongoSubscription[]} [managedGroupSubscriptions] + * @returns {number | null} + */ function getGroupSize( bestSubscription, memberGroupSubscriptions = [], @@ -309,7 +445,7 @@ function getGroupSize( } return allGroupSubscriptions.reduce((largestGroupSize, subscription) => { - const plan = Settings.plans.find( + const plan = /** @type {Plan[]} */ (Settings.plans).find( candidate => candidate.planCode === subscription.planCode ) const groupSize = subscription.membersLimit ?? plan?.membersLimit ?? 0 @@ -318,16 +454,24 @@ function getGroupSize( }, 0) } +/** + * @param {Nullable} [individualSubscription] + * @param {MongoSubscription[]} [memberGroupSubscriptions] + * @param {MongoSubscription[]} [managedGroupSubscriptions] + * @returns {'stripe' | 'recurly' | null} + */ function getPaymentProvider( individualSubscription, memberGroupSubscriptions = [], managedGroupSubscriptions = [] ) { - const candidates = [ - individualSubscription, - ...memberGroupSubscriptions, - ...managedGroupSubscriptions, - ].filter(Boolean) + const candidates = /** @type {MongoSubscription[]} */ ( + [ + individualSubscription, + ...memberGroupSubscriptions, + ...managedGroupSubscriptions, + ].filter(Boolean) + ) if (candidates.length === 0) { return null @@ -343,6 +487,12 @@ function getPaymentProvider( return 'recurly' } +/** + * @param {boolean} hasCommons + * @param {Nullable} [bestSubscription] + * @param {Nullable>} [commonsPlan] + * @returns {boolean} + */ function shouldUseCommonsBestSubscription( hasCommons, bestSubscription, @@ -364,6 +514,17 @@ function shouldUseCommonsBestSubscription( /** * Compute plan-related user properties for sending to customer.io. + * + * @param {object} options + * @param {BestSubscription} options.bestSubscription + * @param {Nullable} [options.individualSubscription] + * @param {Nullable} [options.individualPaymentRecord] + * @param {MongoSubscription[]} [options.memberGroupSubscriptions] + * @param {MongoSubscription[]} [options.managedGroupSubscriptions] + * @param {boolean} options.userIsMemberOfGroupSubscription + * @param {boolean} options.hasCommons + * @param {Nullable<{ isPremium?: boolean }>} [options.writefullData] + * @param {Map} [options.aiBlockedByPolicyId] */ function getPlanProperties({ bestSubscription, @@ -416,6 +577,7 @@ function getPlanProperties({ const trialEndDate = getTrialEndDate(individualSubscription) + /** @type {Record} */ const properties = { ai_plan: aiPlan, group: userIsMemberOfGroupSubscription, @@ -449,6 +611,7 @@ function getPlanProperties({ export default { normalizePlanType, + normalizePlanTypeFromPlanCode, getFriendlyPlanName, getNextRenewalDateFromPaymentRecord, getExpiryDateFromPaymentRecord, diff --git a/services/web/app/src/Features/Subscription/SubscriptionHandler.mjs b/services/web/app/src/Features/Subscription/SubscriptionHandler.mjs index f04607859a..5f693aab7d 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionHandler.mjs +++ b/services/web/app/src/Features/Subscription/SubscriptionHandler.mjs @@ -139,7 +139,9 @@ async function updateSubscription(user, planCode) { logger.error({ err, userId: user._id }, 'failed to reset AI usage limits') } - if (previousPlanType) { + const newPlanType = + CustomerIoPlanHelpers.normalizePlanTypeFromPlanCode(planCode) + if (previousPlanType && previousPlanType !== newPlanType) { Modules.promises.hooks .fire('setUserProperties', user._id, { previous_plan_type: previousPlanType, diff --git a/services/web/test/unit/src/Subscription/SubscriptionHandler.test.mjs b/services/web/test/unit/src/Subscription/SubscriptionHandler.test.mjs index 0b41882900..a2bb4cfe82 100644 --- a/services/web/test/unit/src/Subscription/SubscriptionHandler.test.mjs +++ b/services/web/test/unit/src/Subscription/SubscriptionHandler.test.mjs @@ -432,6 +432,54 @@ describe('SubscriptionHandler', function () { expect(ctx.AiFeatureUsageRateLimiter.resetFeatureUsage).to.not.have.been .called }) + + describe('previous_plan_type customer.io attribute', function () { + beforeEach(function (ctx) { + ctx.subscription.planCode = 'collaborator' + ctx.subscription.groupPlan = false + ctx.LimitationsManager.promises.userHasSubscription.resolves({ + hasSubscription: true, + subscription: ctx.subscription, + }) + ctx.Modules.promises.hooks.fire.resolves() + }) + + it('should not set previous_plan_type when the new plan code matches the current plan code', async function (ctx) { + await ctx.SubscriptionHandler.promises.updateSubscription( + ctx.user, + 'collaborator' + ) + expect(ctx.Modules.promises.hooks.fire).to.not.have.been.calledWith( + 'setUserProperties', + sinon.match.any, + sinon.match.has('previous_plan_type') + ) + }) + + it('should not set previous_plan_type when the new plan code resolves to the same normalised plan type', async function (ctx) { + await ctx.SubscriptionHandler.promises.updateSubscription( + ctx.user, + 'collaborator-annual' + ) + expect(ctx.Modules.promises.hooks.fire).to.not.have.been.calledWith( + 'setUserProperties', + sinon.match.any, + sinon.match.has('previous_plan_type') + ) + }) + + it('should set previous_plan_type when the new plan resolves to a different normalised plan type', async function (ctx) { + await ctx.SubscriptionHandler.promises.updateSubscription( + ctx.user, + 'professional' + ) + expect(ctx.Modules.promises.hooks.fire).to.have.been.calledWith( + 'setUserProperties', + ctx.user._id, + { previous_plan_type: 'standard' } + ) + }) + }) }) describe('cancelPendingSubscriptionChange', function () { diff --git a/services/web/types/subscription/plan.ts b/services/web/types/subscription/plan.ts index 9beca6b5d0..2f575045a1 100644 --- a/services/web/types/subscription/plan.ts +++ b/services/web/types/subscription/plan.ts @@ -2,6 +2,8 @@ import { StripeCurrencyCode } from './currency' export type Features = { aiUsageQuota: 'basic' | 'unlimited' + // todo: quota clean-up: remove aiErrorAssistant once migration finishes + aiErrorAssistant?: boolean collaborators: number compileGroup: string compileTimeout: number