From 8fb5b0ed0565b441cd543df6bd3d132c80aee031 Mon Sep 17 00:00:00 2001 From: roo hutton Date: Mon, 23 Feb 2026 13:20:53 +0000 Subject: [PATCH] Merge pull request #31678 from overleaf/rh-cio-subscription-status Sync subscription type and features to customer.io GitOrigin-RevId: 4c23a6b4ec9f103e73b26203b0d43f177e56bb6e --- .../Features/Subscription/FeaturesUpdater.mjs | 31 +++++++++++++++++++ .../src/Subscription/FeaturesUpdater.test.mjs | 25 +++++++++++++++ 2 files changed, 56 insertions(+) diff --git a/services/web/app/src/Features/Subscription/FeaturesUpdater.mjs b/services/web/app/src/Features/Subscription/FeaturesUpdater.mjs index 368bfc4a9b..b1ca1c27cb 100644 --- a/services/web/app/src/Features/Subscription/FeaturesUpdater.mjs +++ b/services/web/app/src/Features/Subscription/FeaturesUpdater.mjs @@ -15,6 +15,7 @@ import UserGetter from '../User/UserGetter.mjs' import AnalyticsManager from '../Analytics/AnalyticsManager.mjs' import Queues from '../../infrastructure/Queues.mjs' import Modules from '../../infrastructure/Modules.mjs' +import SubscriptionViewModelBuilder from './SubscriptionViewModelBuilder.mjs' import { AI_ADD_ON_CODE } from './AiHelper.mjs' import { fetchNothing } from '@overleaf/fetch-utils' @@ -54,6 +55,20 @@ async function refreshFeatures(userId, reason) { const { features: newFeatures, featuresChanged } = await UserFeaturesUpdater.promises.updateFeatures(userId, features) + + // TODO: this call is quite expensive, so ideally we'd update cio with something + // that doesn't require the best subscription to be computed, ie. the plan code (or type) + const bestSubscriptionType = await _getBestSubscriptionType(userId) + + Modules.promises.hooks + .fire('setUserProperties', userId, { + features, + 'best-subscription-type': bestSubscriptionType, + }) + .catch(err => { + logger.error({ err, userId }, 'Failed to sync features to customer.io') + }) + if (oldFeatures.dropbox === true && features.dropbox === false) { logger.debug({ userId }, '[FeaturesUpdater] must unlink dropbox') try { @@ -101,6 +116,22 @@ async function refreshFeatures(userId, reason) { return { features: newFeatures, featuresChanged } } +async function _getBestSubscriptionType(userId) { + try { + const { bestSubscription } = + await SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails({ + _id: userId, + }) + return bestSubscription?.type || 'free' + } catch (err) { + logger.warn( + { err, userId }, + 'Failed to calculate best-subscription-type for customer.io' + ) + return 'free' + } +} + /** * Return the features that the given user should have. */ diff --git a/services/web/test/unit/src/Subscription/FeaturesUpdater.test.mjs b/services/web/test/unit/src/Subscription/FeaturesUpdater.test.mjs index abaebbbba7..e17011ac2c 100644 --- a/services/web/test/unit/src/Subscription/FeaturesUpdater.test.mjs +++ b/services/web/test/unit/src/Subscription/FeaturesUpdater.test.mjs @@ -113,6 +113,13 @@ describe('FeaturesUpdater', function () { ctx.Modules = { promises: { hooks: { fire: sinon.stub().resolves() } }, } + ctx.SubscriptionViewModelBuilder = { + promises: { + getUsersSubscriptionDetails: sinon.stub().resolves({ + bestSubscription: { type: 'individual' }, + }), + }, + } ctx.Queues = { getQueue: sinon.stub().returns({ add: sinon.stub().resolves(), @@ -170,6 +177,13 @@ describe('FeaturesUpdater', function () { default: ctx.Modules, })) + vi.doMock( + '../../../../app/src/Features/Subscription/SubscriptionViewModelBuilder', + () => ({ + default: ctx.SubscriptionViewModelBuilder, + }) + ) + vi.doMock('../../../../app/src/infrastructure/Queues', () => ({ default: ctx.Queues, })) @@ -374,6 +388,17 @@ describe('FeaturesUpdater', function () { ctx.AnalyticsManager.setUserPropertyForUserInBackground ).to.have.been.calledWith(ctx.user._id, 'feature-set', 'all') }) + + it('should sync features to customer.io', function (ctx) { + expect(ctx.Modules.promises.hooks.fire).to.have.been.calledWith( + 'setUserProperties', + ctx.user._id, + { + features: ctx.Settings.features.all, + 'best-subscription-type': 'individual', + } + ) + }) }) describe('with a non-standard feature set', async function () {