Merge pull request #33467 from overleaf/rh-cio-prev-plan-type-fix

Only set previous_plan_type when normalised plan type changes

GitOrigin-RevId: 43133fc248bfb32b921da68bee91b445ca44eb1f
This commit is contained in:
roo hutton
2026-05-07 09:01:45 +01:00
committed by Copybot
parent 0d40b7aca0
commit 498af9b07b
4 changed files with 230 additions and 15 deletions

View File

@@ -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<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',
@@ -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<Date | string | number>} [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>} [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<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 null
return ''
}
/** @type {Record<string, string>} */
const friendlyPlanNames = {
free: 'Free',
personal: 'Personal',
@@ -96,6 +160,10 @@ function getFriendlyPlanName(planType) {
return planType
}
/**
* @param {Nullable<BestSubscription>} [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<string>} [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>} [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<MongoSubscription>} [subscription]
* @returns {boolean}
*/
function shouldClearNextRenewalDate(subscription) {
if (!subscription) {
return true
@@ -144,9 +229,16 @@ function shouldClearNextRenewalDate(subscription) {
)
}
/**
* @param {Nullable<PaymentRecord>} [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<MongoSubscription>} [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<MongoSubscription>} [individualSubscription]
* @returns {number | null}
*/
function getTrialEndDate(individualSubscription) {
const trialEndsAt =
individualSubscription?.recurlyStatus?.trialEndsAt ||
@@ -173,6 +273,11 @@ function getTrialEndDate(individualSubscription) {
return toUnixTimestamp(trialEndsAt)
}
/**
* @param {Nullable<MongoSubscription>} [individualSubscription]
* @param {Nullable<PaymentRecord>} [paymentRecord]
* @returns {boolean}
*/
function hasIndividualAiAssistAddOn(individualSubscription, paymentRecord) {
if (
!individualSubscription ||
@@ -189,6 +294,13 @@ function hasIndividualAiAssistAddOn(individualSubscription, paymentRecord) {
)
}
/**
* @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,
@@ -213,6 +325,13 @@ function getAiPlanType(
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,
@@ -236,6 +355,10 @@ function getAiPlanCadence(
return null
}
/**
* @param {Nullable<Partial<Plan>>} [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<string, boolean>} [aiBlockedByPolicyId]
* @returns {boolean | null}
*/
function getGroupAiEnabled(
memberGroupSubscriptions = [],
managedGroupSubscriptions = [],
@@ -270,6 +400,12 @@ function getGroupAiEnabled(
return !someBlocked
}
/**
* @param {Nullable<BestSubscription>} [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<MongoSubscription>} [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>} [bestSubscription]
* @param {Nullable<Partial<Plan>>} [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<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]
*/
function getPlanProperties({
bestSubscription,
@@ -416,6 +577,7 @@ function getPlanProperties({
const trialEndDate = getTrialEndDate(individualSubscription)
/** @type {Record<string, unknown>} */
const properties = {
ai_plan: aiPlan,
group: userIsMemberOfGroupSubscription,
@@ -449,6 +611,7 @@ function getPlanProperties({
export default {
normalizePlanType,
normalizePlanTypeFromPlanCode,
getFriendlyPlanName,
getNextRenewalDateFromPaymentRecord,
getExpiryDateFromPaymentRecord,

View File

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

View File

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

View File

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