From ca1e828ea77b86054a9e62ad3a3452cefc947cee Mon Sep 17 00:00:00 2001 From: Alexandre Bourdin Date: Thu, 10 Jun 2021 10:04:30 +0200 Subject: [PATCH] Merge pull request #4138 from overleaf/ab-recurly-webhook-analytics Send analytics events and user properties from Recurly webhook GitOrigin-RevId: 3227dd9e42bad61e17d2ca471f6d68adb7212dab --- .../Subscription/RecurlyEventHandler.js | 145 +++++++++ .../Subscription/SubscriptionController.js | 4 + .../src/helpers/RecurlySubscription.js | 7 + .../Subscription/RecurlyEventHandlerTests.js | 275 ++++++++++++++++++ .../SubscriptionControllerTests.js | 15 + 5 files changed, 446 insertions(+) create mode 100644 services/web/app/src/Features/Subscription/RecurlyEventHandler.js create mode 100644 services/web/test/unit/src/Subscription/RecurlyEventHandlerTests.js diff --git a/services/web/app/src/Features/Subscription/RecurlyEventHandler.js b/services/web/app/src/Features/Subscription/RecurlyEventHandler.js new file mode 100644 index 0000000000..3b0b2c5826 --- /dev/null +++ b/services/web/app/src/Features/Subscription/RecurlyEventHandler.js @@ -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, +} diff --git a/services/web/app/src/Features/Subscription/SubscriptionController.js b/services/web/app/src/Features/Subscription/SubscriptionController.js index 051e22eef1..0b4aea9e82 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionController.js +++ b/services/web/app/src/Features/Subscription/SubscriptionController.js @@ -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', diff --git a/services/web/test/acceptance/src/helpers/RecurlySubscription.js b/services/web/test/acceptance/src/helpers/RecurlySubscription.js index edc1e08c59..51dd7f3cb5 100644 --- a/services/web/test/acceptance/src/helpers/RecurlySubscription.js +++ b/services/web/test/acceptance/src/helpers/RecurlySubscription.js @@ -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, }, }) } diff --git a/services/web/test/unit/src/Subscription/RecurlyEventHandlerTests.js b/services/web/test/unit/src/Subscription/RecurlyEventHandlerTests.js new file mode 100644 index 0000000000..c0126059d7 --- /dev/null +++ b/services/web/test/unit/src/Subscription/RecurlyEventHandlerTests.js @@ -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' + ) + }) +}) diff --git a/services/web/test/unit/src/Subscription/SubscriptionControllerTests.js b/services/web/test/unit/src/Subscription/SubscriptionControllerTests.js index 837896b995..348d456d04 100644 --- a/services/web/test/unit/src/Subscription/SubscriptionControllerTests.js +++ b/services/web/test/unit/src/Subscription/SubscriptionControllerTests.js @@ -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', + }, }, }, },