Merge pull request #36496 from overleaf/oa-annual-events

[web] Subscription event segmentation

GitOrigin-RevId: fdc25ac7bc7320ed82617b7c27c12a17eb31d723
This commit is contained in:
Olzhas Askar
2026-09-17 11:59:20 +00:00
committed by Copybot
parent ba4e6ef8ed
commit 6053781e57
14 changed files with 305 additions and 47 deletions
@@ -11,6 +11,7 @@ import {
import { renderActiveSubscription } from '../../helpers/render-active-subscription'
import { location } from '@/shared/components/location'
import { MetaTag } from '@/utils/meta'
import * as eventTracking from '@/infrastructure/event-tracking'
const pauseSubscriptionSplitTestMeta: MetaTag[] = [
{ name: 'ol-splitTestVariants', value: { 'pause-subscription': 'enabled' } },
@@ -85,6 +86,32 @@ describe('<PauseSubscriptionModal />', function () {
await screen.findByText('Pause instead, to pick up where you left off')
})
it('sends the cancel event with the same segmentation as the dashboard', async function () {
const sendMBSpy = sinon.spy(eventTracking, 'sendMB')
try {
renderSubscriptionWithPauseSupport()
clickCancelButton()
await screen.findByText('Pause instead, to pick up where you left off')
sendMBSpy.resetHistory()
fireEvent.click(
screen.getByRole('button', { name: 'Cancel subscription' })
)
expect(sendMBSpy).to.be.calledOnceWith(
'subscription-page-cancel-button-click',
sinon.match({
plan_code: 'collaborator',
billing_cycle: 'monthly',
is_trial: false,
currency: 'USD',
})
)
} finally {
sendMBSpy.restore()
}
})
it('renders options for pause duration', async function () {
renderSubscriptionWithPauseSupport()
clickCancelButton()
@@ -135,6 +135,38 @@ describe('<ActiveSubscription />', function () {
)
})
it('sends an event when the change plan button is clicked', function () {
renderActiveSubscription(monthlyActiveCollaborator)
fireEvent.click(screen.getByRole('button', { name: 'Change plan' }))
expect(sendMBSpy).to.be.calledOnceWith(
'subscription-page-upgrade-button-click',
sinon.match({
plan_code: 'collaborator',
billing_cycle: 'monthly',
is_trial: false,
currency: 'USD',
})
)
})
it('sends the annual billing cycle when on an annual plan', function () {
renderActiveSubscription(annualActiveSubscriptionEuro)
fireEvent.click(screen.getByRole('button', { name: 'Change plan' }))
expect(sendMBSpy).to.be.calledOnceWith(
'subscription-page-upgrade-button-click',
sinon.match({
plan_code: annualActiveSubscriptionEuro.planCode,
billing_cycle: 'annual',
is_trial: false,
currency: 'EUR',
})
)
})
it('does not show "Change plan" option when past due', function () {
// account is likely in expired state, but be sure to not show option if state is still active
const activePastDueSubscription = cloneDeep(annualActiveSubscription)
@@ -329,13 +361,35 @@ describe('<ActiveSubscription />', function () {
showConfirmCancelUI()
expect(sendMBSpy).to.be.calledOnceWith(
'subscription-page-cancel-button-click'
'subscription-page-cancel-button-click',
sinon.match({
plan_code: annualActiveSubscription.planCode,
billing_cycle: 'annual',
is_trial: false,
currency: 'USD',
})
)
screen.getByText('We’d love you to stay')
screen.getByRole('button', { name: 'Cancel my subscription' })
})
it('sends the cancel event with trial segmentation when in a trial', function () {
renderActiveSubscription(trialCollaboratorSubscription)
showConfirmCancelUI()
expect(sendMBSpy).to.be.calledOnceWith(
'subscription-page-cancel-button-click',
sinon.match({
plan_code: trialCollaboratorSubscription.planCode,
billing_cycle: 'monthly',
is_trial: true,
currency: trialCollaboratorSubscription.payment.currency,
})
)
})
it('cancels subscription and redirects page', async function () {
const endPointResponse = {
status: 200,
@@ -0,0 +1,51 @@
import { expect } from 'chai'
import getSubscriptionEventSegmentation from '../../../../../frontend/js/features/subscription/util/subscription-event-segmentation'
import {
annualActiveSubscriptionEuro,
monthlyActiveCollaborator,
trialCollaboratorSubscription,
} from '../fixtures/subscriptions'
describe('getSubscriptionEventSegmentation', function () {
it('segments a monthly subscription', function () {
expect(
getSubscriptionEventSegmentation(monthlyActiveCollaborator)
).to.deep.equal({
plan_code: 'collaborator',
billing_cycle: 'monthly',
is_trial: false,
currency: 'USD',
})
})
it('segments an annual subscription', function () {
expect(
getSubscriptionEventSegmentation(annualActiveSubscriptionEuro)
).to.deep.equal({
plan_code: 'collaborator-annual',
billing_cycle: 'annual',
is_trial: false,
currency: 'EUR',
})
})
it('segments a subscription in a free trial', function () {
expect(
getSubscriptionEventSegmentation(trialCollaboratorSubscription)
).to.deep.equal({
plan_code: 'collaborator_free_trial_7_days',
billing_cycle: 'monthly',
is_trial: true,
currency: 'USD',
})
})
it('omits the plan segmentation without a subscription', function () {
expect(getSubscriptionEventSegmentation(undefined)).to.deep.equal({
plan_code: undefined,
billing_cycle: undefined,
is_trial: false,
currency: undefined,
})
})
})
@@ -209,6 +209,10 @@ describe('RecurlyEventHandler', function () {
it('with updated_subscription_notification', async function (ctx) {
ctx.planCode = 'new-plan-code'
ctx.eventData.subscription.plan.plan_code = ctx.planCode
ctx.SubscriptionLocator.promises.getUsersSubscription.resolves({
_id: 'sub123',
planCode: 'collaborator',
})
await ctx.RecurlyEventHandler.sendRecurlyAnalyticsEvent(
'updated_subscription_notification',
ctx.eventData
@@ -219,6 +223,7 @@ describe('RecurlyEventHandler', function () {
'subscription-updated',
{
plan_code: ctx.planCode,
previous_plan_code: 'collaborator',
quantity: 1,
is_trial: true,
has_ai_add_on: false,
@@ -246,6 +251,19 @@ describe('RecurlyEventHandler', function () {
)
})
it('with updated_subscription_notification and no subscription in mongo', async function (ctx) {
await ctx.RecurlyEventHandler.sendRecurlyAnalyticsEvent(
'updated_subscription_notification',
ctx.eventData
)
sinon.assert.calledWith(
ctx.AnalyticsManager.recordEventForUserInBackground,
ctx.userId,
'subscription-updated',
sinon.match({ previous_plan_code: undefined })
)
})
it('with canceled_subscription_notification', async function (ctx) {
ctx.eventData.subscription.state = 'cancelled'
await ctx.RecurlyEventHandler.sendRecurlyAnalyticsEvent(
@@ -7,6 +7,7 @@ import MockResponse from '../helpers/MockResponse.mjs'
import SubscriptionErrors from '../../../../app/src/Features/Subscription/Errors.mjs'
import { PaymentServiceResourceNotFoundError } from '../../../../modules/subscriptions/app/src/PaymentServiceErrors.mjs'
import SubscriptionHelper from '../../../../app/src/Features/Subscription/SubscriptionHelper.mjs'
import PlansLocator from '../../../../app/src/Features/Subscription/PlansLocator.mjs'
import { AI_ADD_ON_CODE } from '../../../../app/src/Features/Subscription/AiHelper.mjs'
const modulePath =
@@ -360,6 +361,7 @@ describe('SubscriptionController', function () {
vi.doMock('../../../../app/src/Features/Subscription/PlansLocator', () => ({
default: (ctx.PlansLocator = {
getPlanCadence: PlansLocator.getPlanCadence,
findLocalPlanInSettings: sinon.stub().returns({
annual: false,
}),
@@ -611,6 +613,79 @@ describe('SubscriptionController', function () {
})
})
describe('subscription-page-view event', function () {
const renderWithSubscription = async (ctx, personalSubscription) => {
ctx.AnalyticsManager.recordEventForSession.resetHistory()
ctx.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel.resolves(
{ personalSubscription, memberGroupSubscriptions: [] }
)
await new Promise((resolve, reject) => {
ctx.res.render = () => resolve()
ctx.SubscriptionController.userSubscriptionPage(
ctx.req,
ctx.res,
ctx.rejectOnError(reject)
)
})
return ctx.AnalyticsManager.recordEventForSession.lastCall.args[2]
}
it('should segment a monthly subscription', async function (ctx) {
const segmentation = await renderWithSubscription(ctx, {
planCode: 'collaborator',
plan: { planCode: 'collaborator' },
payment: { currency: 'USD', trialEndsAt: null },
})
expect(segmentation).to.deep.include({
plan_code: 'collaborator',
billing_cycle: 'monthly',
is_trial: false,
currency: 'USD',
})
})
it('should segment an annual subscription', async function (ctx) {
const segmentation = await renderWithSubscription(ctx, {
planCode: 'collaborator-annual',
plan: { planCode: 'collaborator-annual', annual: true },
payment: { currency: 'EUR', trialEndsAt: null },
})
expect(segmentation).to.deep.include({
plan_code: 'collaborator-annual',
billing_cycle: 'annual',
is_trial: false,
currency: 'EUR',
})
})
it('should segment a subscription in trial', async function (ctx) {
const segmentation = await renderWithSubscription(ctx, {
planCode: 'collaborator_free_trial_7_days',
plan: { planCode: 'collaborator_free_trial_7_days' },
payment: {
currency: 'USD',
trialEndsAt: new Date(Date.now() + 1000 * 60 * 60 * 24),
},
})
expect(segmentation).to.deep.include({
plan_code: 'collaborator_free_trial_7_days',
billing_cycle: 'monthly',
is_trial: true,
currency: 'USD',
})
})
it('should omit the plan segmentation without a personal subscription', async function (ctx) {
const segmentation = await renderWithSubscription(ctx, undefined)
expect(segmentation).to.deep.include({
plan_code: undefined,
billing_cycle: null,
is_trial: false,
currency: undefined,
})
})
})
describe('when an unrecognized query param is present', function () {
beforeEach(async function (ctx) {
setReqValidationModeForTests('log')