mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-05-30 20:31:34 +02:00
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:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 ?? [],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user