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

Expose remaining marketing properties to customer.io

GitOrigin-RevId: 6956e1faf90ecc650108404fe13b2f6de2eb4d0c
This commit is contained in:
roo hutton
2026-04-22 09:12:51 +01:00
committed by Copybot
parent 2e5d7675ba
commit dece22ba92
5 changed files with 313 additions and 1 deletions

View File

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

View File

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

View File

@@ -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'
)
})
}
}
/**

View File

@@ -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 ?? [],
}
}

View File

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