Ver Fonte

Merge pull request #32871 from overleaf/rh-cio-comms-attributes

Expose remaining marketing properties to customer.io

GitOrigin-RevId: 6956e1faf90ecc650108404fe13b2f6de2eb4d0c
roo hutton há 3 meses atrás
pai
commit
dece22ba92

+ 49 - 0
services/web/app/src/Features/Subscription/CustomerIoPlanHelpers.mjs

@@ -166,6 +166,13 @@ function shouldClearExpiryDate(subscription) {
   return !PENDING_CANCELLATION_STATES.has(getSubscriptionState(subscription))
 }
 
+function getTrialEndDate(individualSubscription) {
+  const trialEndsAt =
+    individualSubscription?.recurlyStatus?.trialEndsAt ||
+    individualSubscription?.paymentProvider?.trialEndsAt
+  return toUnixTimestamp(trialEndsAt)
+}
+
 function hasIndividualAiAssistAddOn(individualSubscription, paymentRecord) {
   if (
     !individualSubscription ||
@@ -310,6 +317,31 @@ function getGroupSize(
   }, 0)
 }
 
+function getPaymentProvider(
+  individualSubscription,
+  memberGroupSubscriptions = [],
+  managedGroupSubscriptions = []
+) {
+  const candidates = [
+    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'
+}
+
 function shouldUseCommonsBestSubscription(
   hasCommons,
   bestSubscription,
@@ -339,6 +371,7 @@ function getPlanProperties({
   memberGroupSubscriptions,
   managedGroupSubscriptions,
   userIsMemberOfGroupSubscription,
+  hasCommons,
   writefullData,
 }) {
   const planType = normalizePlanType(bestSubscription)
@@ -378,10 +411,26 @@ function getPlanProperties({
     expiryDate ??
     (shouldClearExpiryDate(individualSubscription) ? '' : undefined)
 
+  const trialEndDate = getTrialEndDate(individualSubscription)
+
   const properties = {
     ai_plan: aiPlan,
+    group: userIsMemberOfGroupSubscription,
+    commons: Boolean(hasCommons),
+    individual_subscription: Boolean(
+      individualSubscription && !individualSubscription.groupPlan
+    ),
   }
 
+  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

+ 3 - 0
services/web/app/src/Features/Subscription/FeaturesUpdater.mjs

@@ -135,12 +135,14 @@ async function _updateCustomerIoSubscriptionProperties(user, features) {
     individualSubscription,
     memberGroupSubscriptions,
     managedGroupSubscriptions,
+    currentInstitutionsWithLicence,
   } = await SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails({
     _id: userId,
   })
 
   const userIsMemberOfGroupSubscription =
     memberGroupSubscriptions.length > 0 || managedGroupSubscriptions.length > 0
+  const hasCommons = (currentInstitutionsWithLicence?.length ?? 0) > 0
 
   let individualPaymentRecord = null
   if (individualSubscription && !individualSubscription.groupPlan) {
@@ -174,6 +176,7 @@ async function _updateCustomerIoSubscriptionProperties(user, features) {
     memberGroupSubscriptions,
     managedGroupSubscriptions,
     userIsMemberOfGroupSubscription,
+    hasCommons,
     writefullData,
   })
 

+ 21 - 0
services/web/app/src/Features/Subscription/SubscriptionHandler.mjs

@@ -12,6 +12,7 @@ import { callbackify } from '@overleaf/promise-utils'
 import UserUpdater from '../User/UserUpdater.mjs'
 import Modules from '../../infrastructure/Modules.mjs'
 import { AI_ADD_ON_CODE } from './AiHelper.mjs'
+import CustomerIoPlanHelpers from './CustomerIoPlanHelpers.mjs'
 
 /**
  * @import { PaymentProviderSubscriptionChange } from './PaymentProviderEntities.mjs'
@@ -115,12 +116,32 @@ async function updateSubscription(user, planCode) {
     return
   }
 
+  const previousPlanType = CustomerIoPlanHelpers.normalizePlanType({
+    plan: {
+      planCode: subscription.planCode,
+      groupPlan: subscription.groupPlan,
+    },
+  })
+
   await Modules.promises.hooks.fire(
     'updatePaidSubscription',
     subscription,
     planCode,
     user._id
   )
+
+  if (previousPlanType) {
+    Modules.promises.hooks
+      .fire('setUserProperties', user._id, {
+        previous_plan_type: previousPlanType,
+      })
+      .catch(err => {
+        logger.warn(
+          { err, userId: user._id },
+          'Failed to set previous_plan_type in customer.io'
+        )
+      })
+  }
 }
 
 /**

+ 3 - 1
services/web/app/src/Features/Subscription/SubscriptionViewModelBuilder.mjs

@@ -25,6 +25,7 @@ const { MEMBERS_LIMIT_ADD_ON_CODE } = PaymentProviderEntities
 /**
  * @import { Subscription } from "../../../../types/project/dashboard/subscription"
  * @import { Subscription as DBSubscription } from "../../models/Subscription"
+ * @import { Institution } from "../../../../types/institution"
  */
 
 function buildHostedLink(type) {
@@ -390,7 +391,7 @@ async function buildUsersSubscriptionViewModel(user, locale = 'en') {
 
 /**
  * @param {{_id: string}} user
- * @returns {Promise<{bestSubscription:Subscription,individualSubscription:DBSubscription|null,memberGroupSubscriptions:DBSubscription[],managedGroupSubscriptions:DBSubscription[]}>}
+ * @returns {Promise<{bestSubscription:Subscription,individualSubscription:DBSubscription|null,memberGroupSubscriptions:DBSubscription[],managedGroupSubscriptions:DBSubscription[],currentInstitutionsWithLicence:Institution[]}>}
  */
 async function getUsersSubscriptionDetails(user) {
   let [
@@ -487,6 +488,7 @@ async function getUsersSubscriptionDetails(user) {
     individualSubscription,
     memberGroupSubscriptions,
     managedGroupSubscriptions,
+    currentInstitutionsWithLicence: currentInstitutionsWithLicence ?? [],
   }
 }
 

+ 237 - 0
services/web/test/unit/src/Subscription/FeaturesUpdater.test.mjs

@@ -449,10 +449,200 @@ describe('FeaturesUpdater', function () {
             ai_plan: 'none',
             next_renewal_date: Math.floor(ctx.renewalDate.getTime() / 1000),
             expiry_date: '',
+            group: false,
+            commons: false,
+            individual_subscription: true,
+            payment_provider: 'recurly',
             features: sinon.match.object,
           })
         )
       })
+
+      it('should not set trial_end_date when no trial is active', function (ctx) {
+        const call = ctx.Modules.promises.hooks.fire
+          .getCalls()
+          .find(c => c.args[0] === 'setUserProperties')
+        expect(call).to.exist
+        expect(call.args[2]).to.not.have.property('trial_end_date')
+      })
+    })
+
+    describe('when the individual subscription is on a trial', function () {
+      beforeEach(async function (ctx) {
+        ctx.trialEndsAt = new Date('2099-05-01T00:00:00Z')
+        const trialingSubscription = {
+          ...ctx.subscriptions.individual,
+          recurlyStatus: {
+            state: 'active',
+            trialEndsAt: ctx.trialEndsAt,
+          },
+        }
+        ctx.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails.resolves(
+          {
+            bestSubscription: { type: 'individual' },
+            individualSubscription: trialingSubscription,
+            memberGroupSubscriptions: [],
+            managedGroupSubscriptions: [],
+          }
+        )
+        ctx.Modules.promises.hooks.fire
+          .withArgs('getPaymentFromRecordPromise', trialingSubscription)
+          .resolves([
+            {
+              subscription: {
+                state: 'active',
+                periodEnd: ctx.renewalDate,
+              },
+            },
+          ])
+        await ctx.FeaturesUpdater.promises.refreshFeatures(ctx.user._id, 'test')
+      })
+
+      it('should sync trial_end_date to customer.io', function (ctx) {
+        expect(ctx.Modules.promises.hooks.fire).to.have.been.calledWith(
+          'setUserProperties',
+          ctx.user._id,
+          sinon.match({
+            trial_end_date: Math.floor(ctx.trialEndsAt.getTime() / 1000),
+          })
+        )
+      })
+    })
+
+    describe('when the individual subscription uses stripe', function () {
+      beforeEach(async function (ctx) {
+        const stripeSubscription = {
+          ...ctx.subscriptions.individual,
+          paymentProvider: { service: 'stripe-us', state: 'active' },
+        }
+        ctx.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails.resolves(
+          {
+            bestSubscription: { type: 'individual' },
+            individualSubscription: stripeSubscription,
+            memberGroupSubscriptions: [],
+            managedGroupSubscriptions: [],
+          }
+        )
+        ctx.Modules.promises.hooks.fire
+          .withArgs('getPaymentFromRecordPromise', stripeSubscription)
+          .resolves([
+            {
+              subscription: {
+                state: 'active',
+                periodEnd: ctx.renewalDate,
+              },
+            },
+          ])
+        await ctx.FeaturesUpdater.promises.refreshFeatures(ctx.user._id, 'test')
+      })
+
+      it('should report stripe as the payment_provider', function (ctx) {
+        expect(ctx.Modules.promises.hooks.fire).to.have.been.calledWith(
+          'setUserProperties',
+          ctx.user._id,
+          sinon.match({
+            payment_provider: 'stripe',
+            individual_subscription: true,
+          })
+        )
+      })
+    })
+
+    describe('when the user has a commons institution licence', function () {
+      beforeEach(async function (ctx) {
+        ctx.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails.resolves(
+          {
+            bestSubscription: { type: 'commons' },
+            individualSubscription: null,
+            memberGroupSubscriptions: [],
+            managedGroupSubscriptions: [],
+            currentInstitutionsWithLicence: [{ id: 1, name: 'Uni' }],
+          }
+        )
+        await ctx.FeaturesUpdater.promises.refreshFeatures(ctx.user._id, 'test')
+      })
+
+      it('should sync commons=true to customer.io', function (ctx) {
+        expect(ctx.Modules.promises.hooks.fire).to.have.been.calledWith(
+          'setUserProperties',
+          ctx.user._id,
+          sinon.match({
+            commons: true,
+            group: false,
+            individual_subscription: false,
+          })
+        )
+      })
+    })
+
+    describe('when the user has commons and an individual AI add-on', function () {
+      beforeEach(async function (ctx) {
+        ctx.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails.resolves(
+          {
+            bestSubscription: {
+              type: 'individual',
+              plan: { planCode: 'individual-plan' },
+            },
+            individualSubscription: ctx.subscriptions.individual,
+            memberGroupSubscriptions: [],
+            managedGroupSubscriptions: [],
+            currentInstitutionsWithLicence: [{ id: 1, name: 'Uni' }],
+          }
+        )
+        ctx.Modules.promises.hooks.fire
+          .withArgs('getPaymentFromRecordPromise', ctx.subscriptions.individual)
+          .resolves([
+            {
+              subscription: {
+                state: 'active',
+                periodEnd: ctx.renewalDate,
+                addOns: [{ code: AI_ADD_ON_CODE }],
+              },
+            },
+          ])
+        await ctx.FeaturesUpdater.promises.refreshFeatures(ctx.user._id, 'test')
+      })
+
+      it('should set commons, individual_subscription, and ai-assist-add-on together', function (ctx) {
+        expect(ctx.Modules.promises.hooks.fire).to.have.been.calledWith(
+          'setUserProperties',
+          ctx.user._id,
+          sinon.match({
+            commons: true,
+            individual_subscription: true,
+            group: false,
+            ai_plan: 'ai-assist-add-on',
+          })
+        )
+      })
+    })
+
+    describe('when the user has no subscription', function () {
+      beforeEach(async function (ctx) {
+        ctx.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails.resolves(
+          {
+            bestSubscription: null,
+            individualSubscription: null,
+            memberGroupSubscriptions: [],
+            managedGroupSubscriptions: [],
+          }
+        )
+        await ctx.FeaturesUpdater.promises.refreshFeatures(ctx.user._id, 'test')
+      })
+
+      it('should sync false subscription flags and no payment_provider', function (ctx) {
+        const call = ctx.Modules.promises.hooks.fire
+          .getCalls()
+          .find(c => c.args[0] === 'setUserProperties')
+        expect(call).to.exist
+        expect(call.args[2]).to.include({
+          group: false,
+          commons: false,
+          individual_subscription: false,
+        })
+        expect(call.args[2]).to.not.have.property('payment_provider')
+        expect(call.args[2]).to.not.have.property('trial_end_date')
+      })
     })
 
     describe('when the individual subscription has a pending cancellation', function () {
@@ -544,6 +734,10 @@ describe('FeaturesUpdater', function () {
             group_size: 8,
             next_renewal_date: '',
             expiry_date: '',
+            group: true,
+            commons: false,
+            individual_subscription: false,
+            payment_provider: 'recurly',
             features: sinon.match.object,
             overleaf_id: ctx.user._id,
           })
@@ -551,6 +745,49 @@ describe('FeaturesUpdater', function () {
       })
     })
 
+    describe('when the user is in a stripe group subscription', function () {
+      beforeEach(async function (ctx) {
+        ctx.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails.resolves(
+          {
+            bestSubscription: {
+              type: 'group',
+              plan: {
+                planCode: 'group-plan-1',
+                groupPlan: true,
+                membersLimit: 5,
+              },
+              subscription: {
+                teamName: 'Team Alpha',
+              },
+            },
+            memberGroupSubscriptions: [
+              {
+                planCode: 'group-plan-1',
+                teamName: 'Team Alpha',
+                membersLimit: 8,
+                paymentProvider: { service: 'stripe-uk' },
+              },
+            ],
+            managedGroupSubscriptions: [],
+            individualSubscription: null,
+          }
+        )
+        await ctx.FeaturesUpdater.promises.refreshFeatures(ctx.user._id, 'test')
+      })
+
+      it('should derive payment_provider from the group subscription', function (ctx) {
+        expect(ctx.Modules.promises.hooks.fire).to.have.been.calledWith(
+          'setUserProperties',
+          ctx.user._id,
+          sinon.match({
+            payment_provider: 'stripe',
+            group: true,
+            individual_subscription: false,
+          })
+        )
+      })
+    })
+
     describe('with a non-standard feature set', async function () {
       beforeEach(async function (ctx) {
         ctx.SubscriptionLocator.promises.getGroupSubscriptionsMemberOf