From dece22ba9261ecbdd4806dfba1ab6b37f6b85f9d Mon Sep 17 00:00:00 2001 From: roo hutton Date: Wed, 22 Apr 2026 09:12:51 +0100 Subject: [PATCH] Merge pull request #32871 from overleaf/rh-cio-comms-attributes Expose remaining marketing properties to customer.io GitOrigin-RevId: 6956e1faf90ecc650108404fe13b2f6de2eb4d0c --- .../Subscription/CustomerIoPlanHelpers.mjs | 49 ++++ .../Features/Subscription/FeaturesUpdater.mjs | 3 + .../Subscription/SubscriptionHandler.mjs | 21 ++ .../SubscriptionViewModelBuilder.mjs | 4 +- .../src/Subscription/FeaturesUpdater.test.mjs | 237 ++++++++++++++++++ 5 files changed, 313 insertions(+), 1 deletion(-) diff --git a/services/web/app/src/Features/Subscription/CustomerIoPlanHelpers.mjs b/services/web/app/src/Features/Subscription/CustomerIoPlanHelpers.mjs index e1071bd9b2..ee6cc88524 100644 --- a/services/web/app/src/Features/Subscription/CustomerIoPlanHelpers.mjs +++ b/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 diff --git a/services/web/app/src/Features/Subscription/FeaturesUpdater.mjs b/services/web/app/src/Features/Subscription/FeaturesUpdater.mjs index 1e400b0f24..3035a05a29 100644 --- a/services/web/app/src/Features/Subscription/FeaturesUpdater.mjs +++ b/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, }) diff --git a/services/web/app/src/Features/Subscription/SubscriptionHandler.mjs b/services/web/app/src/Features/Subscription/SubscriptionHandler.mjs index 1da9ba202c..930838d662 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionHandler.mjs +++ b/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' + ) + }) + } } /** diff --git a/services/web/app/src/Features/Subscription/SubscriptionViewModelBuilder.mjs b/services/web/app/src/Features/Subscription/SubscriptionViewModelBuilder.mjs index 3d2ee09e72..383fbaadea 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionViewModelBuilder.mjs +++ b/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 ?? [], } } diff --git a/services/web/test/unit/src/Subscription/FeaturesUpdater.test.mjs b/services/web/test/unit/src/Subscription/FeaturesUpdater.test.mjs index f60d2ad451..473df52f73 100644 --- a/services/web/test/unit/src/Subscription/FeaturesUpdater.test.mjs +++ b/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