Merge pull request #29924 from overleaf/mg-cancellation-survey

[web] add cancellation survey split test

GitOrigin-RevId: acdbb83678352b34e43262d8188844e74bbe73e9
This commit is contained in:
Malik Glossop
2025-11-28 09:05:28 +00:00
committed by Copybot
parent b35d70d81b
commit c7cf9f70f3
6 changed files with 308 additions and 28 deletions
@@ -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}`
@@ -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) {
@@ -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)
}
}
/**
@@ -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'
@@ -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 = {
@@ -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
)
})
})
})
})