Просмотр исходного кода

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

Send analytics events and user properties from Recurly webhook

GitOrigin-RevId: 3227dd9e42bad61e17d2ca471f6d68adb7212dab
Alexandre Bourdin 5 лет назад
Родитель
Сommit
ca1e828ea7

+ 145 - 0
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,
+}

+ 4 - 0
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',

+ 7 - 0
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,
       },
     })
   }

+ 275 - 0
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'
+    )
+  })
+})

+ 15 - 0
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',
+                },
               },
             },
           },