| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660 |
- // @ts-check
- import Settings from '@overleaf/settings'
- import { AI_ADD_ON_CODE, isStandaloneAiAddOnPlanCode } from './AiHelper.mjs'
- import FeaturesHelper from './FeaturesHelper.mjs'
- /**
- * @typedef {InstanceType<typeof import('../../models/Subscription.mjs').Subscription>} 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>} [plan]
- * @property {{ teamName?: string, membersLimit?: number }} [subscription]
- * @property {number} [remainingTrialDays]
- */
- const INACTIVE_NEXT_RENEWAL_DATE_STATES = new Set([
- 'canceled',
- 'cancelled',
- 'expired',
- ])
- const PENDING_CANCELLATION_STATES = new Set(['canceled', 'cancelled'])
- /**
- * @param {Nullable<MongoSubscription>} [subscription]
- * @returns {string}
- */
- function getSubscriptionState(subscription) {
- return (
- subscription?.recurlyStatus?.state ||
- subscription?.paymentProvider?.state ||
- ''
- )
- }
- /**
- * @param {Nullable<Date | string | number>} [dateValue]
- * @returns {number | null}
- */
- function toUnixTimestamp(dateValue) {
- if (!dateValue) {
- return null
- }
- const date = new Date(dateValue)
- if (Number.isNaN(date.getTime())) {
- return null
- }
- return Math.floor(date.getTime() / 1000)
- }
- /**
- * @param {Nullable<BestSubscription>} [bestSubscription]
- * @returns {string}
- */
- function normalizePlanType(bestSubscription) {
- if (!bestSubscription) {
- return ''
- }
- if (
- bestSubscription.type === 'standalone-ai-add-on' ||
- bestSubscription.type === 'commons'
- ) {
- return bestSubscription.type
- }
- const planCode = bestSubscription.plan?.planCode
- const isGroupPlan = bestSubscription.plan?.groupPlan === true
- if (!planCode) {
- return bestSubscription.type || ''
- }
- if (planCode.startsWith('v1_')) {
- return 'v1'
- }
- if (planCode.includes('student')) {
- return 'student'
- }
- if (planCode.includes('professional')) {
- return isGroupPlan ? 'group-professional' : 'professional'
- }
- if (planCode.includes('collaborator')) {
- return isGroupPlan ? 'group-standard' : 'standard'
- }
- if (planCode.includes('personal')) {
- return 'personal'
- }
- if (isGroupPlan) {
- return 'group-standard'
- }
- return planCode
- }
- /**
- * @param {Nullable<string>} [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<string>} [planType]
- * @returns {string}
- */
- function getFriendlyPlanName(planType) {
- if (!planType) {
- return ''
- }
- /** @type {Record<string, string>} */
- const friendlyPlanNames = {
- free: 'Free',
- personal: 'Personal',
- standard: 'Standard',
- professional: 'Pro',
- student: 'Student',
- commons: 'Commons',
- 'group-standard': 'Group Standard',
- 'group-professional': 'Group Pro',
- 'standalone-ai-add-on': 'AI Assist add-on',
- v1: 'Legacy',
- }
- if (friendlyPlanNames[planType]) {
- return friendlyPlanNames[planType]
- }
- return planType
- }
- /**
- * @param {Nullable<BestSubscription>} [bestSubscription]
- * @returns {'annual' | 'monthly' | null}
- */
- function getPlanCadence(bestSubscription) {
- if (!bestSubscription?.plan) {
- return null
- }
- return bestSubscription.plan.annual ? 'annual' : 'monthly'
- }
- /**
- * @param {Nullable<string>} [planCode]
- * @returns {'annual' | 'monthly' | null}
- */
- function getPlanCadenceFromPlanCode(planCode) {
- if (!planCode) {
- return null
- }
- const plan = /** @type {Plan[]} */ (Settings.plans).find(
- candidate => candidate.planCode === planCode
- )
- if (plan) {
- return plan.annual ? 'annual' : 'monthly'
- }
- if (planCode.includes('annual')) {
- return 'annual'
- }
- if (isStandaloneAiAddOnPlanCode(planCode)) {
- return 'monthly'
- }
- return null
- }
- /**
- * @param {Nullable<PaymentRecord>} [paymentRecord]
- * @returns {number | null}
- */
- function getNextRenewalDateFromPaymentRecord(paymentRecord) {
- const subscriptionState = paymentRecord?.subscription?.state
- if (
- subscriptionState &&
- INACTIVE_NEXT_RENEWAL_DATE_STATES.has(subscriptionState)
- ) {
- return null
- }
- return toUnixTimestamp(paymentRecord?.subscription?.periodEnd)
- }
- /**
- * @param {Nullable<MongoSubscription>} [subscription]
- * @returns {boolean}
- */
- function shouldClearNextRenewalDate(subscription) {
- if (!subscription) {
- return true
- }
- return INACTIVE_NEXT_RENEWAL_DATE_STATES.has(
- getSubscriptionState(subscription)
- )
- }
- /**
- * @param {Nullable<PaymentRecord>} [paymentRecord]
- * @returns {number | null}
- */
- function getExpiryDateFromPaymentRecord(paymentRecord) {
- const subscriptionState = paymentRecord?.subscription?.state
- if (
- subscriptionState == null ||
- !PENDING_CANCELLATION_STATES.has(subscriptionState)
- ) {
- return null
- }
- const expiryDate = toUnixTimestamp(paymentRecord?.subscription?.periodEnd)
- if (expiryDate == null) {
- return null
- }
- return expiryDate > Math.floor(Date.now() / 1000) ? expiryDate : null
- }
- /**
- * @param {Nullable<MongoSubscription>} [subscription]
- * @returns {boolean}
- */
- function shouldClearExpiryDate(subscription) {
- if (!subscription) {
- return true
- }
- return !PENDING_CANCELLATION_STATES.has(getSubscriptionState(subscription))
- }
- /**
- * @param {Nullable<MongoSubscription>} [individualSubscription]
- * @returns {number | null}
- */
- function getTrialEndDate(individualSubscription) {
- const trialEndsAt =
- individualSubscription?.recurlyStatus?.trialEndsAt ||
- individualSubscription?.paymentProvider?.trialEndsAt
- return toUnixTimestamp(trialEndsAt)
- }
- /**
- * @param {Nullable<MongoSubscription>} [individualSubscription]
- * @param {Nullable<PaymentRecord>} [paymentRecord]
- * @returns {boolean}
- */
- function hasIndividualAiAssistAddOn(individualSubscription, paymentRecord) {
- if (
- !individualSubscription ||
- individualSubscription.groupPlan ||
- isStandaloneAiAddOnPlanCode(individualSubscription.planCode)
- ) {
- return false
- }
- return Boolean(
- paymentRecord?.subscription?.addOns?.some(
- addOn => addOn.code === AI_ADD_ON_CODE
- )
- )
- }
- /**
- * @param {Nullable<BestSubscription>} [bestSubscription]
- * @param {Nullable<MongoSubscription>} [individualSubscription]
- * @param {Nullable<PaymentRecord>} [paymentRecord]
- * @param {Nullable<{ isPremium?: boolean }>} [writefullData]
- * @returns {'ai-assist-standalone' | 'ai-assist-add-on' | 'writefull-premium' | 'none'}
- */
- function getAiPlanType(
- bestSubscription,
- individualSubscription,
- paymentRecord,
- writefullData
- ) {
- if (
- bestSubscription?.type === 'standalone-ai-add-on' ||
- isStandaloneAiAddOnPlanCode(individualSubscription?.planCode)
- ) {
- return 'ai-assist-standalone'
- }
- if (hasIndividualAiAssistAddOn(individualSubscription, paymentRecord)) {
- return 'ai-assist-add-on'
- }
- if (writefullData?.isPremium) {
- return 'writefull-premium'
- }
- return 'none'
- }
- /**
- * @param {Nullable<string>} [aiPlan]
- * @param {Nullable<BestSubscription>} [bestSubscription]
- * @param {Nullable<MongoSubscription>} [individualSubscription]
- * @param {Nullable<PaymentRecord>} [paymentRecord]
- * @returns {'annual' | 'monthly' | null}
- */
- function getAiPlanCadence(
- aiPlan,
- bestSubscription,
- individualSubscription,
- paymentRecord
- ) {
- if (aiPlan === 'ai-assist-standalone') {
- return (
- getPlanCadenceFromPlanCode(individualSubscription?.planCode) ||
- getPlanCadenceFromPlanCode(paymentRecord?.subscription?.planCode)
- )
- }
- if (aiPlan === 'ai-assist-add-on') {
- return (
- getPlanCadence(bestSubscription) ||
- getPlanCadenceFromPlanCode(individualSubscription?.planCode)
- )
- }
- return null
- }
- /**
- * @param {Nullable<Partial<Plan>>} [plan]
- * @returns {boolean}
- */
- function hasPlanAiEnabled(plan) {
- if (!plan?.features) {
- return false
- }
- return (
- plan.features.aiUsageQuota === Settings.aiFeatures.unlimitedQuota ||
- plan.features.aiErrorAssistant === true
- )
- }
- /**
- * @param {MongoSubscription[]} [memberGroupSubscriptions]
- * @param {MongoSubscription[]} [managedGroupSubscriptions]
- * @param {boolean} [userIsMemberOfGroupSubscription]
- * @param {Map<string, boolean>} [aiBlockedByPolicyId]
- * @returns {boolean | null}
- */
- function getGroupAiEnabled(
- memberGroupSubscriptions = [],
- managedGroupSubscriptions = [],
- userIsMemberOfGroupSubscription,
- aiBlockedByPolicyId = new Map()
- ) {
- if (!userIsMemberOfGroupSubscription) {
- return null
- }
- const allGroupSubscriptions = [
- ...memberGroupSubscriptions,
- ...managedGroupSubscriptions,
- ]
- const someBlocked = allGroupSubscriptions.some(subscription => {
- const policyId = subscription.groupPolicy?.toString()
- return policyId ? aiBlockedByPolicyId.get(policyId) : false
- })
- return !someBlocked
- }
- /**
- * @param {Nullable<BestSubscription>} [bestSubscription]
- * @param {MongoSubscription[]} [memberGroupSubscriptions]
- * @param {MongoSubscription[]} [managedGroupSubscriptions]
- * @returns {number | null}
- */
- function getGroupSize(
- bestSubscription,
- memberGroupSubscriptions = [],
- managedGroupSubscriptions = []
- ) {
- const allGroupSubscriptions = [
- ...memberGroupSubscriptions,
- ...managedGroupSubscriptions,
- ]
- if (allGroupSubscriptions.length === 0) {
- return null
- }
- const matchingBestGroupSubscription =
- bestSubscription?.type === 'group'
- ? allGroupSubscriptions.find(
- subscription =>
- subscription.planCode === bestSubscription.plan?.planCode &&
- subscription.teamName === bestSubscription.subscription?.teamName
- )
- : null
- if (matchingBestGroupSubscription?.membersLimit != null) {
- return matchingBestGroupSubscription.membersLimit
- }
- if (bestSubscription?.subscription?.membersLimit != null) {
- return bestSubscription.subscription.membersLimit
- }
- if (
- bestSubscription?.plan?.groupPlan &&
- bestSubscription.plan.membersLimit != null
- ) {
- return bestSubscription.plan.membersLimit
- }
- return allGroupSubscriptions.reduce((largestGroupSize, subscription) => {
- const plan = /** @type {Plan[]} */ (Settings.plans).find(
- candidate => candidate.planCode === subscription.planCode
- )
- const groupSize = subscription.membersLimit ?? plan?.membersLimit ?? 0
- return Math.max(largestGroupSize, groupSize)
- }, 0)
- }
- /**
- * @param {Nullable<MongoSubscription>} [individualSubscription]
- * @param {MongoSubscription[]} [memberGroupSubscriptions]
- * @param {MongoSubscription[]} [managedGroupSubscriptions]
- * @returns {'stripe' | 'recurly' | null}
- */
- function getPaymentProvider(
- individualSubscription,
- memberGroupSubscriptions = [],
- managedGroupSubscriptions = []
- ) {
- const candidates = /** @type {MongoSubscription[]} */ (
- [
- individualSubscription,
- ...memberGroupSubscriptions,
- ...managedGroupSubscriptions,
- ].filter(Boolean)
- )
- if (candidates.length === 0) {
- return null
- }
- for (const candidate of candidates) {
- const service = candidate.paymentProvider?.service
- if (service) {
- return service.includes('stripe') ? 'stripe' : 'recurly'
- }
- }
- return 'recurly'
- }
- /**
- * @param {boolean} hasCommons
- * @param {Nullable<BestSubscription>} [bestSubscription]
- * @param {Nullable<Partial<Plan>>} [commonsPlan]
- * @returns {boolean}
- */
- function shouldUseCommonsBestSubscription(
- hasCommons,
- bestSubscription,
- commonsPlan
- ) {
- if (!hasCommons) {
- return false
- }
- if (bestSubscription == null) {
- return true
- }
- return FeaturesHelper.isFeatureSetBetter(
- commonsPlan?.features || {},
- bestSubscription.plan?.features || {}
- )
- }
- /**
- * Determine the user's role in any group subscription they participate in.
- *
- * @param {MongoSubscription[]} memberGroupSubscriptions
- * @param {MongoSubscription[]} managedGroupSubscriptions
- * @param {string|object} userId
- * @returns {''|'admin'|'manager'|'member'}
- */
- function getGroupRole(
- memberGroupSubscriptions = [],
- managedGroupSubscriptions = [],
- userId
- ) {
- if (
- managedGroupSubscriptions.length === 0 &&
- memberGroupSubscriptions.length === 0
- ) {
- return ''
- }
- const userIdStr = userId.toString()
- const isAdmin = managedGroupSubscriptions.some(
- sub => sub.admin_id?._id?.toString() === userIdStr
- )
- if (isAdmin) return 'admin'
- if (managedGroupSubscriptions.length > 0) return 'manager'
- return 'member'
- }
- /**
- * Compute plan-related user properties for sending to customer.io.
- *
- * @param {object} options
- * @param {BestSubscription} options.bestSubscription
- * @param {Nullable<MongoSubscription>} [options.individualSubscription]
- * @param {Nullable<PaymentRecord>} [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<string, boolean>} [options.aiBlockedByPolicyId]
- * @param {string|object} options.userId
- */
- function getPlanProperties({
- bestSubscription,
- individualSubscription,
- individualPaymentRecord,
- memberGroupSubscriptions,
- managedGroupSubscriptions,
- userIsMemberOfGroupSubscription,
- hasCommons,
- writefullData,
- aiBlockedByPolicyId,
- userId,
- }) {
- const planType = normalizePlanType(bestSubscription)
- const displayPlanType = getFriendlyPlanName(planType)
- const planTermLabel = getPlanCadence(bestSubscription)
- const aiPlan = getAiPlanType(
- bestSubscription,
- individualSubscription,
- individualPaymentRecord,
- writefullData
- )
- const aiPlanTermLabel = getAiPlanCadence(
- aiPlan,
- bestSubscription,
- individualSubscription,
- individualPaymentRecord
- )
- const groupAiEnabled = getGroupAiEnabled(
- memberGroupSubscriptions,
- managedGroupSubscriptions,
- userIsMemberOfGroupSubscription,
- aiBlockedByPolicyId
- )
- const nextRenewalDate = getNextRenewalDateFromPaymentRecord(
- individualPaymentRecord
- )
- const expiryDate = getExpiryDateFromPaymentRecord(individualPaymentRecord)
- const groupSizeValue = getGroupSize(
- bestSubscription,
- memberGroupSubscriptions,
- managedGroupSubscriptions
- )
- const nextRenewalDateTrait =
- nextRenewalDate ??
- (shouldClearNextRenewalDate(individualSubscription) ? '' : undefined)
- const expiryDateTrait =
- expiryDate ??
- (shouldClearExpiryDate(individualSubscription) ? '' : undefined)
- const trialEndDate = getTrialEndDate(individualSubscription)
- /** @type {Record<string, unknown>} */
- const properties = {
- ai_plan: aiPlan,
- group: userIsMemberOfGroupSubscription,
- group_role: getGroupRole(
- memberGroupSubscriptions,
- managedGroupSubscriptions,
- userId
- ),
- commons: Boolean(hasCommons),
- individual_subscription: Boolean(
- individualSubscription && !individualSubscription.groupPlan
- ),
- past_due: getSubscriptionState(individualSubscription) === 'past_due',
- }
- if (trialEndDate != null) properties.trial_end_date = trialEndDate
- const paymentProvider = getPaymentProvider(
- individualSubscription,
- memberGroupSubscriptions,
- managedGroupSubscriptions
- )
- if (paymentProvider) properties.payment_provider = paymentProvider
- if (planType) properties.plan_type = planType
- if (displayPlanType) properties.display_plan_type = displayPlanType
- if (planTermLabel) properties.plan_term_label = planTermLabel
- if (aiPlanTermLabel) properties.ai_plan_term_label = aiPlanTermLabel
- if (groupAiEnabled !== null) properties.group_ai_enabled = groupAiEnabled
- if (nextRenewalDateTrait !== undefined)
- properties.next_renewal_date = nextRenewalDateTrait
- if (expiryDateTrait !== undefined) properties.expiry_date = expiryDateTrait
- if (groupSizeValue !== null) properties.group_size = groupSizeValue
- return properties
- }
- export default {
- normalizePlanType,
- normalizePlanTypeFromPlanCode,
- getFriendlyPlanName,
- getNextRenewalDateFromPaymentRecord,
- getExpiryDateFromPaymentRecord,
- getAiPlanType,
- getAiPlanCadence,
- hasPlanAiEnabled,
- shouldUseCommonsBestSubscription,
- getGroupRole,
- getPlanProperties,
- }
|