Browse Source

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
roo hutton 5 tháng trước cách đây
mục cha
commit
498af9b07b

+ 177 - 14
services/web/app/src/Features/Subscription/CustomerIoPlanHelpers.mjs

@@ -1,7 +1,30 @@
+// @ts-check
 import Settings from '@overleaf/settings'
 import Settings from '@overleaf/settings'
 import { AI_ADD_ON_CODE, isStandaloneAiAddOnPlanCode } from './AiHelper.mjs'
 import { AI_ADD_ON_CODE, isStandaloneAiAddOnPlanCode } from './AiHelper.mjs'
 import FeaturesHelper from './FeaturesHelper.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([
 const INACTIVE_NEXT_RENEWAL_DATE_STATES = new Set([
   'canceled',
   'canceled',
   'cancelled',
   'cancelled',
@@ -9,12 +32,22 @@ const INACTIVE_NEXT_RENEWAL_DATE_STATES = new Set([
 ])
 ])
 const PENDING_CANCELLATION_STATES = new Set(['canceled', 'cancelled'])
 const PENDING_CANCELLATION_STATES = new Set(['canceled', 'cancelled'])
 
 
+/**
+ * @param {MongoSubscription} subscription
+ * @returns {string}
+ */
 function getSubscriptionState(subscription) {
 function getSubscriptionState(subscription) {
   return (
   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) {
 function toUnixTimestamp(dateValue) {
   if (!dateValue) {
   if (!dateValue) {
     return null
     return null
@@ -28,12 +61,19 @@ function toUnixTimestamp(dateValue) {
   return Math.floor(date.getTime() / 1000)
   return Math.floor(date.getTime() / 1000)
 }
 }
 
 
+/**
+ * @param {Nullable<BestSubscription>} [bestSubscription]
+ * @returns {string}
+ */
 function normalizePlanType(bestSubscription) {
 function normalizePlanType(bestSubscription) {
   if (!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
     return bestSubscription.type
   }
   }
 
 
@@ -41,7 +81,7 @@ function normalizePlanType(bestSubscription) {
   const isGroupPlan = bestSubscription.plan?.groupPlan === true
   const isGroupPlan = bestSubscription.plan?.groupPlan === true
 
 
   if (!planCode) {
   if (!planCode) {
-    return bestSubscription.type || null
+    return bestSubscription.type || ''
   }
   }
 
 
   if (planCode.startsWith('v1_')) {
   if (planCode.startsWith('v1_')) {
@@ -71,11 +111,35 @@ function normalizePlanType(bestSubscription) {
   return planCode
   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) {
 function getFriendlyPlanName(planType) {
   if (!planType) {
   if (!planType) {
-    return null
+    return ''
   }
   }
 
 
+  /** @type {Record<string, string>} */
   const friendlyPlanNames = {
   const friendlyPlanNames = {
     free: 'Free',
     free: 'Free',
     personal: 'Personal',
     personal: 'Personal',
@@ -96,6 +160,10 @@ function getFriendlyPlanName(planType) {
   return planType
   return planType
 }
 }
 
 
+/**
+ * @param {Nullable<BestSubscription>} [bestSubscription]
+ * @returns {'annual' | 'monthly' | null}
+ */
 function getPlanCadence(bestSubscription) {
 function getPlanCadence(bestSubscription) {
   if (!bestSubscription?.plan) {
   if (!bestSubscription?.plan) {
     return null
     return null
@@ -104,12 +172,18 @@ function getPlanCadence(bestSubscription) {
   return bestSubscription.plan.annual ? 'annual' : 'monthly'
   return bestSubscription.plan.annual ? 'annual' : 'monthly'
 }
 }
 
 
+/**
+ * @param {Nullable<string>} [planCode]
+ * @returns {'annual' | 'monthly' | null}
+ */
 function getPlanCadenceFromPlanCode(planCode) {
 function getPlanCadenceFromPlanCode(planCode) {
   if (!planCode) {
   if (!planCode) {
     return null
     return null
   }
   }
 
 
-  const plan = Settings.plans.find(candidate => candidate.planCode === planCode)
+  const plan = /** @type {Plan[]} */ (Settings.plans).find(
+    candidate => candidate.planCode === planCode
+  )
   if (plan) {
   if (plan) {
     return plan.annual ? 'annual' : 'monthly'
     return plan.annual ? 'annual' : 'monthly'
   }
   }
@@ -125,15 +199,26 @@ function getPlanCadenceFromPlanCode(planCode) {
   return null
   return null
 }
 }
 
 
+/**
+ * @param {Nullable<PaymentRecord>} [paymentRecord]
+ * @returns {number | null}
+ */
 function getNextRenewalDateFromPaymentRecord(paymentRecord) {
 function getNextRenewalDateFromPaymentRecord(paymentRecord) {
   const subscriptionState = paymentRecord?.subscription?.state
   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 null
   }
   }
 
 
   return toUnixTimestamp(paymentRecord?.subscription?.periodEnd)
   return toUnixTimestamp(paymentRecord?.subscription?.periodEnd)
 }
 }
 
 
+/**
+ * @param {Nullable<MongoSubscription>} [subscription]
+ * @returns {boolean}
+ */
 function shouldClearNextRenewalDate(subscription) {
 function shouldClearNextRenewalDate(subscription) {
   if (!subscription) {
   if (!subscription) {
     return true
     return true
@@ -144,9 +229,16 @@ function shouldClearNextRenewalDate(subscription) {
   )
   )
 }
 }
 
 
+/**
+ * @param {Nullable<PaymentRecord>} [paymentRecord]
+ * @returns {number | null}
+ */
 function getExpiryDateFromPaymentRecord(paymentRecord) {
 function getExpiryDateFromPaymentRecord(paymentRecord) {
   const subscriptionState = paymentRecord?.subscription?.state
   const subscriptionState = paymentRecord?.subscription?.state
-  if (!PENDING_CANCELLATION_STATES.has(subscriptionState)) {
+  if (
+    subscriptionState == null ||
+    !PENDING_CANCELLATION_STATES.has(subscriptionState)
+  ) {
     return null
     return null
   }
   }
 
 
@@ -158,6 +250,10 @@ function getExpiryDateFromPaymentRecord(paymentRecord) {
   return expiryDate > Math.floor(Date.now() / 1000) ? expiryDate : null
   return expiryDate > Math.floor(Date.now() / 1000) ? expiryDate : null
 }
 }
 
 
+/**
+ * @param {Nullable<MongoSubscription>} [subscription]
+ * @returns {boolean}
+ */
 function shouldClearExpiryDate(subscription) {
 function shouldClearExpiryDate(subscription) {
   if (!subscription) {
   if (!subscription) {
     return true
     return true
@@ -166,6 +262,10 @@ function shouldClearExpiryDate(subscription) {
   return !PENDING_CANCELLATION_STATES.has(getSubscriptionState(subscription))
   return !PENDING_CANCELLATION_STATES.has(getSubscriptionState(subscription))
 }
 }
 
 
+/**
+ * @param {Nullable<MongoSubscription>} [individualSubscription]
+ * @returns {number | null}
+ */
 function getTrialEndDate(individualSubscription) {
 function getTrialEndDate(individualSubscription) {
   const trialEndsAt =
   const trialEndsAt =
     individualSubscription?.recurlyStatus?.trialEndsAt ||
     individualSubscription?.recurlyStatus?.trialEndsAt ||
@@ -173,6 +273,11 @@ function getTrialEndDate(individualSubscription) {
   return toUnixTimestamp(trialEndsAt)
   return toUnixTimestamp(trialEndsAt)
 }
 }
 
 
+/**
+ * @param {Nullable<MongoSubscription>} [individualSubscription]
+ * @param {Nullable<PaymentRecord>} [paymentRecord]
+ * @returns {boolean}
+ */
 function hasIndividualAiAssistAddOn(individualSubscription, paymentRecord) {
 function hasIndividualAiAssistAddOn(individualSubscription, paymentRecord) {
   if (
   if (
     !individualSubscription ||
     !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(
 function getAiPlanType(
   bestSubscription,
   bestSubscription,
   individualSubscription,
   individualSubscription,
@@ -213,6 +325,13 @@ function getAiPlanType(
   return 'none'
   return 'none'
 }
 }
 
 
+/**
+ * @param {Nullable<string>} [aiPlan]
+ * @param {Nullable<BestSubscription>} [bestSubscription]
+ * @param {Nullable<MongoSubscription>} [individualSubscription]
+ * @param {Nullable<PaymentRecord>} [paymentRecord]
+ * @returns {'annual' | 'monthly' | null}
+ */
 function getAiPlanCadence(
 function getAiPlanCadence(
   aiPlan,
   aiPlan,
   bestSubscription,
   bestSubscription,
@@ -236,6 +355,10 @@ function getAiPlanCadence(
   return null
   return null
 }
 }
 
 
+/**
+ * @param {Nullable<Partial<Plan>>} [plan]
+ * @returns {boolean}
+ */
 function hasPlanAiEnabled(plan) {
 function hasPlanAiEnabled(plan) {
   if (!plan?.features) {
   if (!plan?.features) {
     return false
     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(
 function getGroupAiEnabled(
   memberGroupSubscriptions = [],
   memberGroupSubscriptions = [],
   managedGroupSubscriptions = [],
   managedGroupSubscriptions = [],
@@ -270,6 +400,12 @@ function getGroupAiEnabled(
   return !someBlocked
   return !someBlocked
 }
 }
 
 
+/**
+ * @param {Nullable<BestSubscription>} [bestSubscription]
+ * @param {MongoSubscription[]} [memberGroupSubscriptions]
+ * @param {MongoSubscription[]} [managedGroupSubscriptions]
+ * @returns {number | null}
+ */
 function getGroupSize(
 function getGroupSize(
   bestSubscription,
   bestSubscription,
   memberGroupSubscriptions = [],
   memberGroupSubscriptions = [],
@@ -309,7 +445,7 @@ function getGroupSize(
   }
   }
 
 
   return allGroupSubscriptions.reduce((largestGroupSize, subscription) => {
   return allGroupSubscriptions.reduce((largestGroupSize, subscription) => {
-    const plan = Settings.plans.find(
+    const plan = /** @type {Plan[]} */ (Settings.plans).find(
       candidate => candidate.planCode === subscription.planCode
       candidate => candidate.planCode === subscription.planCode
     )
     )
     const groupSize = subscription.membersLimit ?? plan?.membersLimit ?? 0
     const groupSize = subscription.membersLimit ?? plan?.membersLimit ?? 0
@@ -318,16 +454,24 @@ function getGroupSize(
   }, 0)
   }, 0)
 }
 }
 
 
+/**
+ * @param {Nullable<MongoSubscription>} [individualSubscription]
+ * @param {MongoSubscription[]} [memberGroupSubscriptions]
+ * @param {MongoSubscription[]} [managedGroupSubscriptions]
+ * @returns {'stripe' | 'recurly' | null}
+ */
 function getPaymentProvider(
 function getPaymentProvider(
   individualSubscription,
   individualSubscription,
   memberGroupSubscriptions = [],
   memberGroupSubscriptions = [],
   managedGroupSubscriptions = []
   managedGroupSubscriptions = []
 ) {
 ) {
-  const candidates = [
-    individualSubscription,
-    ...memberGroupSubscriptions,
-    ...managedGroupSubscriptions,
-  ].filter(Boolean)
+  const candidates = /** @type {MongoSubscription[]} */ (
+    [
+      individualSubscription,
+      ...memberGroupSubscriptions,
+      ...managedGroupSubscriptions,
+    ].filter(Boolean)
+  )
 
 
   if (candidates.length === 0) {
   if (candidates.length === 0) {
     return null
     return null
@@ -343,6 +487,12 @@ function getPaymentProvider(
   return 'recurly'
   return 'recurly'
 }
 }
 
 
+/**
+ * @param {boolean} hasCommons
+ * @param {Nullable<BestSubscription>} [bestSubscription]
+ * @param {Nullable<Partial<Plan>>} [commonsPlan]
+ * @returns {boolean}
+ */
 function shouldUseCommonsBestSubscription(
 function shouldUseCommonsBestSubscription(
   hasCommons,
   hasCommons,
   bestSubscription,
   bestSubscription,
@@ -364,6 +514,17 @@ function shouldUseCommonsBestSubscription(
 
 
 /**
 /**
  * Compute plan-related user properties for sending to customer.io.
  * 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({
 function getPlanProperties({
   bestSubscription,
   bestSubscription,
@@ -416,6 +577,7 @@ function getPlanProperties({
 
 
   const trialEndDate = getTrialEndDate(individualSubscription)
   const trialEndDate = getTrialEndDate(individualSubscription)
 
 
+  /** @type {Record<string, unknown>} */
   const properties = {
   const properties = {
     ai_plan: aiPlan,
     ai_plan: aiPlan,
     group: userIsMemberOfGroupSubscription,
     group: userIsMemberOfGroupSubscription,
@@ -449,6 +611,7 @@ function getPlanProperties({
 
 
 export default {
 export default {
   normalizePlanType,
   normalizePlanType,
+  normalizePlanTypeFromPlanCode,
   getFriendlyPlanName,
   getFriendlyPlanName,
   getNextRenewalDateFromPaymentRecord,
   getNextRenewalDateFromPaymentRecord,
   getExpiryDateFromPaymentRecord,
   getExpiryDateFromPaymentRecord,

+ 3 - 1
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')
     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
     Modules.promises.hooks
       .fire('setUserProperties', user._id, {
       .fire('setUserProperties', user._id, {
         previous_plan_type: previousPlanType,
         previous_plan_type: previousPlanType,

+ 48 - 0
services/web/test/unit/src/Subscription/SubscriptionHandler.test.mjs

@@ -432,6 +432,54 @@ describe('SubscriptionHandler', function () {
       expect(ctx.AiFeatureUsageRateLimiter.resetFeatureUsage).to.not.have.been
       expect(ctx.AiFeatureUsageRateLimiter.resetFeatureUsage).to.not.have.been
         .called
         .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 () {
   describe('cancelPendingSubscriptionChange', function () {

+ 2 - 0
services/web/types/subscription/plan.ts

@@ -2,6 +2,8 @@ import { StripeCurrencyCode } from './currency'
 
 
 export type Features = {
 export type Features = {
   aiUsageQuota: 'basic' | 'unlimited'
   aiUsageQuota: 'basic' | 'unlimited'
+  // todo: quota clean-up: remove aiErrorAssistant once migration finishes
+  aiErrorAssistant?: boolean
   collaborators: number
   collaborators: number
   compileGroup: string
   compileGroup: string
   compileTimeout: number
   compileTimeout: number