Browse Source

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

[web] add cancellation survey split test

GitOrigin-RevId: acdbb83678352b34e43262d8188844e74bbe73e9
Malik Glossop 10 months ago
parent
commit
c7cf9f70f3

+ 20 - 0
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}`

+ 1 - 1
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) {

+ 51 - 14
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'
@@ -132,18 +134,36 @@ async function cancelPendingSubscriptionChange(user) {
 }
 
 /**
+ * Send cancellation email to user with split test for AI Assist addon
  * @param user
  */
-async function cancelSubscription(user) {
-  const { hasSubscription, subscription } =
-    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
+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,
@@ -152,6 +172,19 @@ async function cancelSubscription(user) {
   }
 }
 
+/**
+ * @param user
+ */
+async function cancelSubscription(user) {
+  const { hasSubscription, subscription } =
+    await LimitationsManager.promises.userHasSubscription(user)
+  if (hasSubscription && subscription != null) {
+    await Modules.promises.hooks.fire('cancelPaidSubscription', subscription)
+
+    await _sendCancellationEmail(user)
+  }
+}
+
 /**
  * @param 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)
+  }
 }
 
 /**

+ 39 - 0
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'

+ 51 - 0
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 = {

+ 146 - 13
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
+          )
+        })
       })
     })
   })