mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-11-14 06:03:45 +01:00
Merge pull request #29924 from overleaf/mg-cancellation-survey
[web] add cancellation survey split test GitOrigin-RevId: acdbb83678352b34e43262d8188844e74bbe73e9
This commit is contained in:
@@ -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
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user