From c7cf9f70f3a812708686e912291359b3683424d3 Mon Sep 17 00:00:00 2001 From: Malik Glossop Date: Thu, 27 Nov 2025 10:13:57 +0100 Subject: [PATCH] Merge pull request #29924 from overleaf/mg-cancellation-survey [web] add cancellation survey split test GitOrigin-RevId: acdbb83678352b34e43262d8188844e74bbe73e9 --- .../app/src/Features/Email/EmailBuilder.mjs | 20 +++ .../Subscription/SubscriptionController.mjs | 2 +- .../Subscription/SubscriptionHandler.mjs | 65 +++++-- .../test/unit/src/Email/EmailBuilder.test.mjs | 39 +++++ .../SubscriptionController.test.mjs | 51 ++++++ .../Subscription/SubscriptionHandler.test.mjs | 159 ++++++++++++++++-- 6 files changed, 308 insertions(+), 28 deletions(-) diff --git a/services/web/app/src/Features/Email/EmailBuilder.mjs b/services/web/app/src/Features/Email/EmailBuilder.mjs index c78e878528..751abf11c0 100644 --- a/services/web/app/src/Features/Email/EmailBuilder.mjs +++ b/services/web/app/src/Features/Email/EmailBuilder.mjs @@ -181,6 +181,26 @@ templates.canceledSubscription = ctaTemplate({ }, }) +templates.canceledSubscriptionOrAddOn = ctaTemplate({ + subject() { + return `${settings.appName} thoughts` + }, + message() { + return [ + `We are sorry to see you cancelled your ${settings.appName} subscription. Would you mind giving us some feedback on what the site is lacking at the moment via this quick survey?`, + ] + }, + secondaryMessage() { + return ['Thank you in advance!'] + }, + ctaText() { + return 'Leave feedback' + }, + ctaURL(opts) { + return 'https://digitalscience.qualtrics.com/jfe/form/SV_2n2aSlWgvoxXdGK' + }, +}) + templates.reactivatedSubscription = ctaTemplate({ subject() { return `Subscription Reactivated - ${settings.appName}` diff --git a/services/web/app/src/Features/Subscription/SubscriptionController.mjs b/services/web/app/src/Features/Subscription/SubscriptionController.mjs index e85c7fd351..a1943ae8ef 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionController.mjs +++ b/services/web/app/src/Features/Subscription/SubscriptionController.mjs @@ -726,7 +726,7 @@ async function removeAddon(req, res, next) { logger.debug({ userId: user._id, addOnCode }, 'removing add-ons') try { - await SubscriptionHandler.promises.removeAddon(user._id, addOnCode) + await SubscriptionHandler.promises.removeAddon(user, addOnCode) res.sendStatus(200) } catch (err) { if (err instanceof AddOnNotPresentError) { diff --git a/services/web/app/src/Features/Subscription/SubscriptionHandler.mjs b/services/web/app/src/Features/Subscription/SubscriptionHandler.mjs index edd5456230..0aed675c84 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionHandler.mjs +++ b/services/web/app/src/Features/Subscription/SubscriptionHandler.mjs @@ -14,6 +14,8 @@ import { callbackify } from '@overleaf/promise-utils' import UserUpdater from '../User/UserUpdater.mjs' import { IndeterminateInvoiceError } from '../Errors/Errors.js' import Modules from '../../infrastructure/Modules.js' +import SplitTestHandler from '../SplitTests/SplitTestHandler.mjs' +import { AI_ADD_ON_CODE } from './AiHelper.mjs' /** * @import { PaymentProviderSubscriptionChange } from './PaymentProviderEntities.mjs' @@ -131,6 +133,45 @@ async function cancelPendingSubscriptionChange(user) { } } +/** + * Send cancellation email to user with split test for AI Assist addon + * @param user + */ +async function _sendCancellationEmail(user) { + const { variant } = await SplitTestHandler.promises.getAssignmentForUser( + user._id, + 'cancellation-survey-ai-assist' + ) + + const emailOpts = { + to: user.email, + first_name: user.first_name, + } + + const ONE_HOUR_IN_MS = 1000 * 60 * 60 + + if (variant === 'enabled') { + logger.debug( + { userId: user._id }, + 'deferred email: canceledSubscriptionOrAddOn' + ) + + EmailHandler.sendDeferredEmail( + 'canceledSubscriptionOrAddOn', + emailOpts, + ONE_HOUR_IN_MS + ) + } else { + logger.debug({ userId: user._id }, 'deferred email: canceledSubscription') + + EmailHandler.sendDeferredEmail( + 'canceledSubscription', + emailOpts, + ONE_HOUR_IN_MS + ) + } +} + /** * @param user */ @@ -139,16 +180,8 @@ async function cancelSubscription(user) { await LimitationsManager.promises.userHasSubscription(user) if (hasSubscription && subscription != null) { await Modules.promises.hooks.fire('cancelPaidSubscription', subscription) - const emailOpts = { - to: user.email, - first_name: user.first_name, - } - const ONE_HOUR_IN_MS = 1000 * 60 * 60 - EmailHandler.sendDeferredEmail( - 'canceledSubscription', - emailOpts, - ONE_HOUR_IN_MS - ) + + await _sendCancellationEmail(user) } } @@ -277,13 +310,17 @@ async function purchaseAddon(userId, addOnCode, quantity) { } /** - * Cancels and add-on for a user + * Cancels an add-on for a user * - * @param {string} userId + * @param user * @param {string} addOnCode */ -async function removeAddon(userId, addOnCode) { - await Modules.promises.hooks.fire('removeAddOn', userId, addOnCode) +async function removeAddon(user, addOnCode) { + await Modules.promises.hooks.fire('removeAddOn', user._id, addOnCode) + + if (addOnCode === AI_ADD_ON_CODE) { + await _sendCancellationEmail(user) + } } /** diff --git a/services/web/test/unit/src/Email/EmailBuilder.test.mjs b/services/web/test/unit/src/Email/EmailBuilder.test.mjs index d9970c4d4d..607a706530 100644 --- a/services/web/test/unit/src/Email/EmailBuilder.test.mjs +++ b/services/web/test/unit/src/Email/EmailBuilder.test.mjs @@ -252,6 +252,45 @@ describe('EmailBuilder', function () { }) }) + describe('canceledSubscriptionOrAddOn', function () { + beforeEach(function (ctx) { + ctx.emailAddress = 'example@overleaf.com' + ctx.opts = { + to: ctx.emailAddress, + } + ctx.email = ctx.EmailBuilder.buildEmail( + 'canceledSubscriptionOrAddOn', + ctx.opts + ) + ctx.expectedUrl = + 'https://digitalscience.qualtrics.com/jfe/form/SV_2n2aSlWgvoxXdGK' + }) + + it('should build the email', function (ctx) { + expect(ctx.email.html).to.exist + expect(ctx.email.text).to.exist + }) + + describe('HTML email', function () { + it('should include a CTA button and a fallback CTA link', function (ctx) { + const dom = cheerio.load(ctx.email.html) + const buttonLink = dom('a:contains("Leave feedback")') + expect(buttonLink.length).to.equal(1) + expect(buttonLink.attr('href')).to.equal(ctx.expectedUrl) + const fallback = dom('.force-overleaf-style').last() + expect(fallback.length).to.equal(1) + const fallbackLink = fallback.html() + expect(fallbackLink).to.contain(ctx.expectedUrl) + }) + }) + + describe('plain text email', function () { + it('should contain the CTA link', function (ctx) { + expect(ctx.email.text).to.contain(ctx.expectedUrl) + }) + }) + }) + describe('confirmEmail', function () { beforeEach(function (ctx) { ctx.emailAddress = 'example@overleaf.com' diff --git a/services/web/test/unit/src/Subscription/SubscriptionController.test.mjs b/services/web/test/unit/src/Subscription/SubscriptionController.test.mjs index 98bc3d84fe..b6020458aa 100644 --- a/services/web/test/unit/src/Subscription/SubscriptionController.test.mjs +++ b/services/web/test/unit/src/Subscription/SubscriptionController.test.mjs @@ -1126,6 +1126,57 @@ describe('SubscriptionController', function () { }) }) + describe('removeAddon', function () { + beforeEach(function (ctx) { + ctx.SessionManager.getSessionUser.returns(ctx.user) + ctx.next = sinon.stub() + ctx.req.params = { addOnCode: AI_ADD_ON_CODE } + ctx.SubscriptionHandler.promises.removeAddon = sinon.stub().resolves() + }) + + it('should return 200 on successful removal of AI add-on', async function (ctx) { + ctx.res.sendStatus = sinon.spy() + + await ctx.SubscriptionController.removeAddon(ctx.req, ctx.res, ctx.next) + + expect(ctx.SubscriptionHandler.promises.removeAddon).to.have.been.called + expect( + ctx.SubscriptionHandler.promises.removeAddon + ).to.have.been.calledWith(ctx.user, AI_ADD_ON_CODE) + expect(ctx.res.sendStatus).to.have.been.calledWith(200) + expect(ctx.logger.debug).to.have.been.calledWith( + { userId: ctx.user._id, addOnCode: AI_ADD_ON_CODE }, + 'removing add-ons' + ) + }) + + it('should return 404 if the add-on code is not AI_ADD_ON_CODE', async function (ctx) { + ctx.req.params = { addOnCode: 'some-other-addon' } + ctx.res.sendStatus = sinon.spy() + + await ctx.SubscriptionController.removeAddon(ctx.req, ctx.res, ctx.next) + + expect(ctx.SubscriptionHandler.promises.removeAddon).to.not.have.been + .called + expect(ctx.res.sendStatus).to.have.been.calledWith(404) + }) + + it('should handle AddOnNotPresentError and send badRequest', async function (ctx) { + ctx.SubscriptionHandler.promises.removeAddon.rejects( + new SubscriptionErrors.AddOnNotPresentError() + ) + + await ctx.SubscriptionController.removeAddon(ctx.req, ctx.res, ctx.next) + + expect(ctx.HttpErrorHandler.badRequest).to.have.been.calledWith( + ctx.req, + ctx.res, + 'Your subscription does not contain the requested add-on', + { addon: AI_ADD_ON_CODE } + ) + }) + }) + describe('checkSubscriptionPauseStatus', function () { beforeEach(function (ctx) { ctx.user = { diff --git a/services/web/test/unit/src/Subscription/SubscriptionHandler.test.mjs b/services/web/test/unit/src/Subscription/SubscriptionHandler.test.mjs index 2a3ce3a33a..1ec71d8d4c 100644 --- a/services/web/test/unit/src/Subscription/SubscriptionHandler.test.mjs +++ b/services/web/test/unit/src/Subscription/SubscriptionHandler.test.mjs @@ -2,6 +2,7 @@ import { expect, vi } from 'vitest' import sinon from 'sinon' import PaymentProviderEntities from '../../../../app/src/Features/Subscription/PaymentProviderEntities.mjs' import SubscriptionHelper from '../../../../app/src/Features/Subscription/SubscriptionHelper.mjs' +import { AI_ADD_ON_CODE } from '../../../../app/src/Features/Subscription/AiHelper.mjs' const { PaymentProviderSubscription } = PaymentProviderEntities const MODULE_PATH = @@ -141,6 +142,12 @@ describe('SubscriptionHandler', function () { }, } + ctx.SplitTestHandler = { + promises: { + getAssignmentForUser: sinon.stub().resolves({ variant: 'default' }), + }, + } + vi.doMock( '../../../../app/src/Features/Subscription/RecurlyWrapper', () => ({ @@ -206,6 +213,13 @@ describe('SubscriptionHandler', function () { default: ctx.UserUpdater, })) + vi.doMock( + '../../../../app/src/Features/SplitTests/SplitTestHandler', + () => ({ + default: ctx.SplitTestHandler, + }) + ) + vi.doMock('../../../../app/src/infrastructure/Modules', () => ({ default: (ctx.Modules = { promises: { @@ -412,6 +426,92 @@ describe('SubscriptionHandler', function () { }) }) + describe('removeAddon', function () { + beforeEach(function (ctx) { + ctx.addOnCode = AI_ADD_ON_CODE + }) + + describe('when split test is disabled', function () { + beforeEach(async function (ctx) { + ctx.SplitTestHandler.promises.getAssignmentForUser.resolves({ + variant: 'control', + }) + await ctx.SubscriptionHandler.promises.removeAddon( + ctx.user, + ctx.addOnCode + ) + }) + + it('should remove the addon', function (ctx) { + expect(ctx.Modules.promises.hooks.fire).to.have.been.calledWith( + 'removeAddOn', + ctx.user._id, + ctx.addOnCode + ) + }) + + it('should send the email after 1 hour', function (ctx) { + const ONE_HOUR_IN_MS = 1000 * 60 * 60 + expect(ctx.EmailHandler.sendDeferredEmail).to.have.been.calledWith( + 'canceledSubscription', + { to: ctx.user.email, first_name: ctx.user.first_name }, + ONE_HOUR_IN_MS + ) + }) + }) + + describe('when split test is enabled', function () { + beforeEach(async function (ctx) { + ctx.SplitTestHandler.promises.getAssignmentForUser.resolves({ + variant: 'enabled', + }) + await ctx.SubscriptionHandler.promises.removeAddon( + ctx.user, + ctx.addOnCode + ) + }) + + it('should remove the addon', function (ctx) { + expect(ctx.Modules.promises.hooks.fire).to.have.been.calledWith( + 'removeAddOn', + ctx.user._id, + ctx.addOnCode + ) + }) + + it('should send the email after 1 hour', function (ctx) { + const ONE_HOUR_IN_MS = 1000 * 60 * 60 + expect(ctx.EmailHandler.sendDeferredEmail).to.have.been.calledWith( + 'canceledSubscriptionOrAddOn', + { to: ctx.user.email, first_name: ctx.user.first_name }, + ONE_HOUR_IN_MS + ) + }) + }) + + describe('when addon is not AI assistant', function () { + beforeEach(async function (ctx) { + ctx.addOnCode = 'other-addon' + await ctx.SubscriptionHandler.promises.removeAddon( + ctx.user, + ctx.addOnCode + ) + }) + + it('should remove the addon', function (ctx) { + expect(ctx.Modules.promises.hooks.fire).to.have.been.calledWith( + 'removeAddOn', + ctx.user._id, + ctx.addOnCode + ) + }) + + it('should not send an email', function (ctx) { + expect(ctx.EmailHandler.sendDeferredEmail).to.not.have.been.called + }) + }) + }) + describe('cancelSubscription', function () { describe('with a user without a subscription', function () { beforeEach(async function (ctx) { @@ -435,23 +535,56 @@ describe('SubscriptionHandler', function () { hasSubscription: true, subscription: ctx.subscription, }) - await ctx.SubscriptionHandler.promises.cancelSubscription(ctx.user) }) - it('should cancel the subscription', function (ctx) { - expect(ctx.Modules.promises.hooks.fire).to.have.been.calledWith( - 'cancelPaidSubscription', - ctx.subscription - ) + describe('when split test is disabled', function () { + beforeEach(async function (ctx) { + ctx.SplitTestHandler.promises.getAssignmentForUser.resolves({ + variant: 'control', + }) + await ctx.SubscriptionHandler.promises.cancelSubscription(ctx.user) + }) + + it('should cancel the subscription', function (ctx) { + expect(ctx.Modules.promises.hooks.fire).to.have.been.calledWith( + 'cancelPaidSubscription', + ctx.subscription + ) + }) + + it('should send the email after 1 hour', function (ctx) { + const ONE_HOUR_IN_MS = 1000 * 60 * 60 + expect(ctx.EmailHandler.sendDeferredEmail).to.have.been.calledWith( + 'canceledSubscription', + { to: ctx.user.email, first_name: ctx.user.first_name }, + ONE_HOUR_IN_MS + ) + }) }) - it('should send the email after 1 hour', function (ctx) { - const ONE_HOUR_IN_MS = 1000 * 60 * 60 - expect(ctx.EmailHandler.sendDeferredEmail).to.have.been.calledWith( - 'canceledSubscription', - { to: ctx.user.email, first_name: ctx.user.first_name }, - ONE_HOUR_IN_MS - ) + describe('when split test is enabled', function () { + beforeEach(async function (ctx) { + ctx.SplitTestHandler.promises.getAssignmentForUser.resolves({ + variant: 'enabled', + }) + await ctx.SubscriptionHandler.promises.cancelSubscription(ctx.user) + }) + + it('should cancel the subscription', function (ctx) { + expect(ctx.Modules.promises.hooks.fire).to.have.been.calledWith( + 'cancelPaidSubscription', + ctx.subscription + ) + }) + + it('should send the email after 1 hour', function (ctx) { + const ONE_HOUR_IN_MS = 1000 * 60 * 60 + expect(ctx.EmailHandler.sendDeferredEmail).to.have.been.calledWith( + 'canceledSubscriptionOrAddOn', + { to: ctx.user.email, first_name: ctx.user.first_name }, + ONE_HOUR_IN_MS + ) + }) }) }) })