Merge pull request #4138 from overleaf/ab-recurly-webhook-analytics

Send analytics events and user properties from Recurly webhook

GitOrigin-RevId: 3227dd9e42bad61e17d2ca471f6d68adb7212dab
This commit is contained in:
Alexandre Bourdin 2021-06-10 10:04:30 +02:00 committed by Copybot
parent c634f51eee
commit ca1e828ea7
5 changed files with 446 additions and 0 deletions

View file

@ -0,0 +1,145 @@
const AnalyticsManager = require('../Analytics/AnalyticsManager')
function sendRecurlyAnalyticsEvent(event, eventData) {
switch (event) {
case 'new_subscription_notification':
_sendSubscriptionStartedEvent(eventData)
break
case 'updated_subscription_notification':
_sendSubscriptionUpdatedEvent(eventData)
break
case 'canceled_subscription_notification':
_sendSubscriptionCancelledEvent(eventData)
break
case 'expired_subscription_notification':
_sendSubscriptionExpiredEvent(eventData)
break
case 'renewed_subscription_notification':
_sendSubscriptionRenewedEvent(eventData)
break
case 'reactivated_account_notification':
_sendSubscriptionReactivatedEvent(eventData)
break
case 'paid_charge_invoice_notification':
if (eventData.invoice.state === 'paid') {
_sendInvoicePaidEvent(eventData)
}
break
case 'closed_invoice_notification':
if (eventData.invoice.state === 'collected') {
_sendInvoicePaidEvent(eventData)
}
break
}
}
function _sendSubscriptionStartedEvent(eventData) {
const userId = _getUserId(eventData)
const { planCode, quantity, state, isTrial } = _getSubscriptionData(eventData)
AnalyticsManager.recordEvent(userId, 'subscription-started', {
plan_code: planCode,
quantity,
is_trial: isTrial,
})
AnalyticsManager.setUserProperty(userId, 'subscription-plan-code', planCode)
AnalyticsManager.setUserProperty(userId, 'subscription-state', state)
AnalyticsManager.setUserProperty(userId, 'subscription-is-trial', isTrial)
}
function _sendSubscriptionUpdatedEvent(eventData) {
const userId = _getUserId(eventData)
const { planCode, quantity, state, isTrial } = _getSubscriptionData(eventData)
AnalyticsManager.recordEvent(userId, 'subscription-updated', {
plan_code: planCode,
quantity,
})
AnalyticsManager.setUserProperty(userId, 'subscription-plan-code', planCode)
AnalyticsManager.setUserProperty(userId, 'subscription-state', state)
AnalyticsManager.setUserProperty(userId, 'subscription-is-trial', isTrial)
}
function _sendSubscriptionCancelledEvent(eventData) {
const userId = _getUserId(eventData)
const { planCode, quantity, state, isTrial } = _getSubscriptionData(eventData)
AnalyticsManager.recordEvent(userId, 'subscription-cancelled', {
plan_code: planCode,
quantity,
is_trial: isTrial,
})
AnalyticsManager.setUserProperty(userId, 'subscription-state', state)
AnalyticsManager.setUserProperty(userId, 'subscription-is-trial', isTrial)
}
function _sendSubscriptionExpiredEvent(eventData) {
const userId = _getUserId(eventData)
const { planCode, quantity, state, isTrial } = _getSubscriptionData(eventData)
AnalyticsManager.recordEvent(userId, 'subscription-expired', {
plan_code: planCode,
quantity,
is_trial: isTrial,
})
AnalyticsManager.setUserProperty(userId, 'subscription-plan-code', null)
AnalyticsManager.setUserProperty(userId, 'subscription-state', state)
AnalyticsManager.setUserProperty(userId, 'subscription-is-trial', isTrial)
}
function _sendSubscriptionRenewedEvent(eventData) {
const userId = _getUserId(eventData)
const { planCode, quantity, state, isTrial } = _getSubscriptionData(eventData)
AnalyticsManager.recordEvent(userId, 'subscription-renewed', {
plan_code: planCode,
quantity,
is_trial: isTrial,
})
AnalyticsManager.setUserProperty(userId, 'subscription-plan-code', planCode)
AnalyticsManager.setUserProperty(userId, 'subscription-state', state)
AnalyticsManager.setUserProperty(userId, 'subscription-is-trial', isTrial)
}
function _sendSubscriptionReactivatedEvent(eventData) {
const userId = _getUserId(eventData)
const { planCode, quantity, state, isTrial } = _getSubscriptionData(eventData)
AnalyticsManager.recordEvent(userId, 'subscription-reactivated', {
plan_code: planCode,
quantity,
})
AnalyticsManager.setUserProperty(userId, 'subscription-plan-code', planCode)
AnalyticsManager.setUserProperty(userId, 'subscription-state', state)
AnalyticsManager.setUserProperty(userId, 'subscription-is-trial', isTrial)
}
function _sendInvoicePaidEvent(eventData) {
const userId = _getUserId(eventData)
AnalyticsManager.recordEvent(userId, 'subscription-invoice-collected')
AnalyticsManager.setUserProperty(userId, 'subscription-is-trial', false)
}
function _getUserId(eventData) {
let userId
if (eventData && eventData.account && eventData.account.account_code) {
userId = eventData.account.account_code
} else {
throw new Error(
'account.account_code missing in event data to identity user ID'
)
}
return userId
}
function _getSubscriptionData(eventData) {
const isTrial =
eventData.subscription.trial_started_at &&
eventData.subscription.current_period_started_at &&
eventData.subscription.trial_started_at.getTime() ===
eventData.subscription.current_period_started_at.getTime()
return {
planCode: eventData.subscription.plan.plan_code,
quantity: eventData.subscription.quantity,
state: eventData.subscription.state,
isTrial,
}
}
module.exports = {
sendRecurlyAnalyticsEvent,
}

View file

@ -16,6 +16,7 @@ const HttpErrorHandler = require('../Errors/HttpErrorHandler')
const SubscriptionErrors = require('./Errors')
const SplitTestHandler = require('../SplitTests/SplitTestHandler')
const AnalyticsManager = require('../Analytics/AnalyticsManager')
const RecurlyEventHandler = require('./RecurlyEventHandler')
const { expressify } = require('../../util/promises')
const OError = require('@overleaf/o-error')
const _ = require('lodash')
@ -350,6 +351,9 @@ function recurlyCallback(req, res, next) {
logger.log({ data: req.body }, 'received recurly callback')
const event = Object.keys(req.body)[0]
const eventData = req.body[event]
RecurlyEventHandler.sendRecurlyAnalyticsEvent(event, eventData)
if (
[
'new_subscription_notification',

View file

@ -43,6 +43,13 @@ class RecurlySubscription {
return RecurlyWrapper._buildXml('expired_subscription_notification', {
subscription: {
uuid: this.uuid,
state: 'expired',
plan: {
plan_code: 'collaborator',
},
},
account: {
account_code: this.account.id,
},
})
}

View file

@ -0,0 +1,275 @@
const SandboxedModule = require('sandboxed-module')
const sinon = require('sinon')
const modulePath =
'../../../../app/src/Features/Subscription/RecurlyEventHandler'
describe('RecurlyEventHandler', function () {
beforeEach(function () {
this.userId = '123456789abcde'
this.planCode = 'collaborator-annual'
this.eventData = {
account: {
account_code: this.userId,
},
subscription: {
plan: {
plan_code: 'collaborator-annual',
},
quantity: 1,
state: 'active',
trial_started_at: new Date('2021-01-01 12:34:56'),
trial_ends_at: new Date('2021-01-08 12:34:56'),
current_period_started_at: new Date('2021-01-01 12:34:56'),
current_period_ends_at: new Date('2021-01-08 12:34:56'),
},
}
this.RecurlyEventHandler = SandboxedModule.require(modulePath, {
requires: {
'../Analytics/AnalyticsManager': (this.AnalyticsManager = {
recordEvent: sinon.stub(),
setUserProperty: sinon.stub(),
}),
},
})
})
it('with new_subscription_notification - free trial', function () {
this.RecurlyEventHandler.sendRecurlyAnalyticsEvent(
'new_subscription_notification',
this.eventData
)
sinon.assert.calledWith(
this.AnalyticsManager.recordEvent,
this.userId,
'subscription-started',
{
plan_code: this.planCode,
quantity: 1,
is_trial: true,
}
)
sinon.assert.calledWith(
this.AnalyticsManager.setUserProperty,
this.userId,
'subscription-plan-code',
this.planCode
)
sinon.assert.calledWith(
this.AnalyticsManager.setUserProperty,
this.userId,
'subscription-state',
'active'
)
sinon.assert.calledWith(
this.AnalyticsManager.setUserProperty,
this.userId,
'subscription-is-trial',
true
)
})
it('with new_subscription_notification - no free trial', function () {
this.eventData.subscription.current_period_started_at = new Date(
'2021-02-10 12:34:56'
)
this.eventData.subscription.current_period_ends_at = new Date(
'2021-02-17 12:34:56'
)
this.eventData.subscription.quantity = 3
this.RecurlyEventHandler.sendRecurlyAnalyticsEvent(
'new_subscription_notification',
this.eventData
)
sinon.assert.calledWith(
this.AnalyticsManager.recordEvent,
this.userId,
'subscription-started',
{
plan_code: this.planCode,
quantity: 3,
is_trial: false,
}
)
sinon.assert.calledWith(
this.AnalyticsManager.setUserProperty,
this.userId,
'subscription-state',
'active'
)
sinon.assert.calledWith(
this.AnalyticsManager.setUserProperty,
this.userId,
'subscription-is-trial',
false
)
})
it('with updated_subscription_notification', function () {
this.planCode = 'new-plan-code'
this.eventData.subscription.plan.plan_code = this.planCode
this.RecurlyEventHandler.sendRecurlyAnalyticsEvent(
'updated_subscription_notification',
this.eventData
)
sinon.assert.calledWith(
this.AnalyticsManager.recordEvent,
this.userId,
'subscription-updated',
{
plan_code: this.planCode,
quantity: 1,
}
)
sinon.assert.calledWith(
this.AnalyticsManager.setUserProperty,
this.userId,
'subscription-plan-code',
this.planCode
)
sinon.assert.calledWith(
this.AnalyticsManager.setUserProperty,
this.userId,
'subscription-state',
'active'
)
sinon.assert.calledWith(
this.AnalyticsManager.setUserProperty,
this.userId,
'subscription-is-trial',
true
)
})
it('with canceled_subscription_notification', function () {
this.eventData.subscription.state = 'cancelled'
this.RecurlyEventHandler.sendRecurlyAnalyticsEvent(
'canceled_subscription_notification',
this.eventData
)
sinon.assert.calledWith(
this.AnalyticsManager.recordEvent,
this.userId,
'subscription-cancelled',
{
plan_code: this.planCode,
quantity: 1,
is_trial: true,
}
)
sinon.assert.calledWith(
this.AnalyticsManager.setUserProperty,
this.userId,
'subscription-state',
'cancelled'
)
sinon.assert.calledWith(
this.AnalyticsManager.setUserProperty,
this.userId,
'subscription-is-trial',
true
)
})
it('with expired_subscription_notification', function () {
this.eventData.subscription.state = 'expired'
this.RecurlyEventHandler.sendRecurlyAnalyticsEvent(
'expired_subscription_notification',
this.eventData
)
sinon.assert.calledWith(
this.AnalyticsManager.recordEvent,
this.userId,
'subscription-expired',
{
plan_code: this.planCode,
quantity: 1,
is_trial: true,
}
)
sinon.assert.calledWith(
this.AnalyticsManager.setUserProperty,
this.userId,
'subscription-state',
'expired'
)
sinon.assert.calledWith(
this.AnalyticsManager.setUserProperty,
this.userId,
'subscription-is-trial',
true
)
})
it('with renewed_subscription_notification', function () {
this.RecurlyEventHandler.sendRecurlyAnalyticsEvent(
'renewed_subscription_notification',
this.eventData
)
sinon.assert.calledWith(
this.AnalyticsManager.recordEvent,
this.userId,
'subscription-renewed',
{
plan_code: this.planCode,
quantity: 1,
is_trial: true,
}
)
})
it('with reactivated_account_notification', function () {
this.RecurlyEventHandler.sendRecurlyAnalyticsEvent(
'reactivated_account_notification',
this.eventData
)
sinon.assert.calledWith(
this.AnalyticsManager.recordEvent,
this.userId,
'subscription-reactivated',
{
plan_code: this.planCode,
quantity: 1,
}
)
})
it('with paid_charge_invoice_notification', function () {
this.RecurlyEventHandler.sendRecurlyAnalyticsEvent(
'paid_charge_invoice_notification',
{
account: {
account_code: this.userId,
},
invoice: {
state: 'paid',
},
}
)
sinon.assert.calledWith(
this.AnalyticsManager.recordEvent,
this.userId,
'subscription-invoice-collected'
)
})
it('with closed_invoice_notification', function () {
this.RecurlyEventHandler.sendRecurlyAnalyticsEvent(
'closed_invoice_notification',
{
account: {
account_code: this.userId,
},
invoice: {
state: 'collected',
},
}
)
sinon.assert.calledWith(
this.AnalyticsManager.recordEvent,
this.userId,
'subscription-invoice-collected'
)
})
})

View file

@ -142,6 +142,7 @@ describe('SubscriptionController', function () {
'./Errors': SubscriptionErrors,
'../Analytics/AnalyticsManager': (this.AnalyticsManager = {
recordEvent: sinon.stub(),
setUserProperty: sinon.stub(),
}),
'../SplitTests/SplitTestHandler': (this.SplitTestHandler = {
getTestSegmentation: () => {},
@ -575,8 +576,15 @@ describe('SubscriptionController', function () {
this.req = {
body: {
expired_subscription_notification: {
account: {
account_code: this.user._id,
},
subscription: {
uuid: this.activeRecurlySubscription.uuid,
plan: {
plan_code: 'collaborator',
state: 'active',
},
},
},
},
@ -640,8 +648,15 @@ describe('SubscriptionController', function () {
this.req = {
body: {
renewed_subscription_notification: {
account: {
account_code: this.user._id,
},
subscription: {
uuid: this.activeRecurlySubscription.uuid,
plan: {
plan_code: 'collaborator',
state: 'active',
},
},
},
},