Procházet zdrojové kódy

Merge pull request #23457 from overleaf/ar-recurly-account-mapping-initial

[web] setup Recurly -> Mongo account mapping

GitOrigin-RevId: ee08cad60ee04e62100f3d5a4f76fdbcf5543917
Andrew Rumble před 1 rokem
rodič
revize
0e64fe2b21

+ 97 - 0
services/web/app/src/Features/Analytics/AccountMappingHelper.js

@@ -0,0 +1,97 @@
+const mappings = new Map([
+  ['salesforce_id', generateSubscriptionToSalesforceMapping],
+  ['v1_id', generateSubscriptionToV1Mapping],
+  ['recurlySubscription_id', generateSubscriptionToRecurlyMapping],
+])
+
+/**
+ * @typedef {(import('./types.d.ts').AccountMapping)} AccountMapping
+ */
+
+/**
+ *
+ * @param {Object} subscription
+ * @param {Object} updatedSubscription
+ * @return {Array<AccountMapping>}
+ */
+function extractAccountMappingsFromSubscription(
+  subscription,
+  updatedSubscription
+) {
+  const accountMappings = []
+  mappings.forEach((generateMapping, param) => {
+    if (updatedSubscription[param] || updatedSubscription[param] === '') {
+      if (subscription[param] !== updatedSubscription[param]) {
+        accountMappings.push(
+          generateMapping(subscription.id, updatedSubscription[param])
+        )
+      }
+    }
+  })
+  return accountMappings
+}
+
+function generateV1Mapping(v1Id, salesforceId, createdAt) {
+  return {
+    source: 'salesforce',
+    sourceEntity: 'account',
+    sourceEntityId: salesforceId,
+    target: 'v1',
+    targetEntity: 'university',
+    targetEntityId: v1Id,
+    createdAt,
+  }
+}
+
+function generateSubscriptionToV1Mapping(subscriptionId, v1Id) {
+  return {
+    source: 'v1',
+    sourceEntity: 'university',
+    sourceEntityId: v1Id,
+    target: 'v2',
+    targetEntity: 'subscription',
+    targetEntityId: subscriptionId,
+    createdAt: new Date().toISOString(),
+  }
+}
+
+function generateSubscriptionToSalesforceMapping(subscriptionId, salesforceId) {
+  return {
+    source: 'salesforce',
+    sourceEntity: 'account',
+    sourceEntityId: salesforceId,
+    target: 'v2',
+    targetEntity: 'subscription',
+    targetEntityId: subscriptionId,
+    createdAt: new Date().toISOString(),
+  }
+}
+
+/**
+ *
+ * @param {string} subscriptionId
+ * @param {string} recurlyId
+ * @param {string} [createdAt] - Should be an ISO date
+ * @return {AccountMapping}
+ */
+function generateSubscriptionToRecurlyMapping(
+  subscriptionId,
+  recurlyId,
+  createdAt = new Date().toISOString()
+) {
+  return {
+    source: 'recurly',
+    sourceEntity: 'subscription',
+    sourceEntityId: recurlyId,
+    target: 'v2',
+    targetEntity: 'subscription',
+    targetEntityId: subscriptionId,
+    createdAt,
+  }
+}
+
+module.exports = {
+  extractAccountMappingsFromSubscription,
+  generateV1Mapping,
+  generateSubscriptionToRecurlyMapping,
+}

+ 0 - 71
services/web/app/src/Features/Analytics/AccountMappingHelper.mjs

@@ -1,71 +0,0 @@
-export function extractAccountMappingsFromSubscription(
-  subscription,
-  updatedSubscription
-) {
-  const accountMappings = []
-  if (
-    updatedSubscription.salesforce_id ||
-    updatedSubscription.salesforce_id === ''
-  ) {
-    if (subscription.salesforce_id !== updatedSubscription.salesforce_id) {
-      accountMappings.push(
-        generateSubscriptionToSalesforceMapping(
-          subscription.id,
-          updatedSubscription.salesforce_id
-        )
-      )
-    }
-  }
-  if (updatedSubscription.v1_id || updatedSubscription.v1_id === '') {
-    if (subscription.v1_id !== updatedSubscription.v1_id) {
-      accountMappings.push(
-        generateSubscriptionToV1Mapping(
-          subscription.id,
-          updatedSubscription.v1_id
-        )
-      )
-    }
-  }
-  return accountMappings
-}
-
-export function generateV1Mapping(v1Id, salesforceId, createdAt) {
-  return {
-    source: 'salesforce',
-    sourceEntity: 'account',
-    sourceEntityId: salesforceId,
-    target: 'v1',
-    targetEntity: 'university',
-    targetEntityId: v1Id,
-    createdAt,
-  }
-}
-
-function generateSubscriptionToV1Mapping(subscriptionId, v1Id) {
-  return {
-    source: 'v1',
-    sourceEntity: 'university',
-    sourceEntityId: v1Id,
-    target: 'v2',
-    targetEntity: 'subscription',
-    targetEntityId: subscriptionId,
-    createdAt: new Date().toISOString(),
-  }
-}
-
-function generateSubscriptionToSalesforceMapping(subscriptionId, salesforceId) {
-  return {
-    source: 'salesforce',
-    sourceEntity: 'account',
-    sourceEntityId: salesforceId,
-    target: 'v2',
-    targetEntity: 'subscription',
-    targetEntityId: subscriptionId,
-    createdAt: new Date().toISOString(),
-  }
-}
-
-export default {
-  extractAccountMappingsFromSubscription,
-  generateV1Mapping,
-}

+ 2 - 2
services/web/app/src/Features/Analytics/AnalyticsController.mjs

@@ -4,7 +4,7 @@ import SessionManager from '../Authentication/SessionManager.js'
 import GeoIpLookup from '../../infrastructure/GeoIpLookup.js'
 import Features from '../../infrastructure/Features.js'
 import { expressify } from '@overleaf/promise-utils'
-import { generateV1Mapping } from './AccountMappingHelper.mjs'
+import AccountMappingHelper from './AccountMappingHelper.js'
 
 async function registerSalesforceMapping(req, res, next) {
   if (!Features.hasFeature('analytics')) {
@@ -12,7 +12,7 @@ async function registerSalesforceMapping(req, res, next) {
   }
   const { createdAt, salesforceId, v1Id } = req.body
   AnalyticsManager.registerAccountMapping(
-    generateV1Mapping(v1Id, salesforceId, createdAt)
+    AccountMappingHelper.generateV1Mapping(v1Id, salesforceId, createdAt)
   )
   res.sendStatus(202)
 }

+ 5 - 7
services/web/app/src/Features/Analytics/AnalyticsManager.js

@@ -146,16 +146,14 @@ function setUserPropertyForSessionInBackground(session, property, value) {
   })
 }
 
+/**
+ * @typedef {(import('./types').AccountMapping)} AccountMapping
+ */
+
 /**
  * Register mapping between two accounts.
  *
- * @param {object} payload - The event payload to send to Analytics
- * @param {string} payload.source - The type of account linked from
- * @param {string} payload.sourceId - The ID of the account linked from
- * @param {string} payload.target - The type of account linked to
- * @param {string} payload.targetId - The ID of the account linked to
- * @param {Date} payload.createdAt - The date the mapping was created
- * @property
+ * @param {AccountMapping} payload - The event payload to send to Analytics
  */
 function registerAccountMapping({
   source,

+ 9 - 0
services/web/app/src/Features/Analytics/types.d.ts

@@ -0,0 +1,9 @@
+export type AccountMapping = {
+  source: string
+  sourceEntity: string
+  sourceEntityId: string
+  target: string
+  targetEntity: string
+  targetEntityId: string
+  createdAt: string
+}

+ 11 - 0
services/web/app/src/Features/Subscription/SubscriptionUpdater.js

@@ -10,6 +10,7 @@ const { DeletedSubscription } = require('../../models/DeletedSubscription')
 const logger = require('@overleaf/logger')
 const Features = require('../../infrastructure/Features')
 const UserAuditLogHandler = require('../User/UserAuditLogHandler')
+const AccountMappingHelper = require('../Analytics/AccountMappingHelper')
 const { SSOConfig } = require('../../models/SSOConfig')
 
 /**
@@ -345,6 +346,16 @@ async function updateSubscriptionFromRecurly(
     }
   }
   await subscription.save()
+
+  const accountMapping =
+    AccountMappingHelper.generateSubscriptionToRecurlyMapping(
+      subscription._id,
+      subscription.recurlySubscription_id
+    )
+  if (accountMapping) {
+    AnalyticsManager.registerAccountMapping(accountMapping)
+  }
+
   await _scheduleRefreshFeatures(subscription)
 }
 

+ 120 - 0
services/web/scripts/analytics/backfill_recurly_to_subscription_mapping.mjs

@@ -0,0 +1,120 @@
+/**
+ * This script backfills the account mapping for subscriptions that are active and have a group plan.
+ *
+ * The mapping joins a recurlySubscription_id to a subscription _id in BigQuery.
+ *
+ * This script has an assumption that it is being run in a clean slate condition, it will create some
+ * duplicate mappings if run multiple times. The Analytics team will have the expectation
+ * that this table may need to be deduplicated as it is an event sourcing record.
+ *
+ * Call it with `--commit` to actually register the mappings.
+ * Call it with `--verbose` to see debug logs.
+ * Call it with `--endDate=<subscription ID>` to stop processing at a certain date
+ */
+import logger from '@overleaf/logger'
+import minimist from 'minimist'
+import { batchedUpdate } from '@overleaf/mongo-utils/batchedUpdate.js'
+import { db } from '../../app/src/infrastructure/mongodb.js'
+import AccountMappingHelper from '../../app/src/Features/Analytics/AccountMappingHelper.js'
+import { registerAccountMapping } from '../../app/src/Features/Analytics/AnalyticsManager.js'
+import { triggerGracefulShutdown } from '../../app/src/infrastructure/GracefulShutdown.js'
+import Validation from '../../app/src/infrastructure/Validation.js'
+
+const paramsSchema = Validation.Joi.object({
+  endDate: Validation.Joi.string().isoDate(),
+  commit: Validation.Joi.boolean().default(false),
+  verbose: Validation.Joi.boolean().default(false),
+}).unknown(true)
+
+let mapped = 0
+let subscriptionCount = 0
+
+const now = new Date().toISOString() // use the same timestamp for all mappings
+
+const seenSubscriptions = new Set()
+
+function registerMapping(subscription) {
+  if (seenSubscriptions.has(subscription._id)) {
+    logger.warn({ subscription }, 'duplicate subscription found, skipping')
+    return
+  }
+  seenSubscriptions.add(subscription._id)
+  subscriptionCount++
+
+  const mapping = AccountMappingHelper.generateSubscriptionToRecurlyMapping(
+    subscription._id,
+    subscription.recurlySubscription_id,
+    now
+  )
+  logger.debug(
+    {
+      recurly: subscription.recurlySubscription_id,
+      mapping,
+    },
+    `processing subscription ${subscription._id}`
+  )
+  if (commit) {
+    registerAccountMapping(mapping)
+    mapped++
+  }
+}
+
+async function main() {
+  const additionalBatchedUpdateOptions = {}
+
+  if (endDate) {
+    additionalBatchedUpdateOptions.BATCH_RANGE_END = endDate
+  }
+
+  await batchedUpdate(
+    db.subscriptions,
+    {
+      'recurlyStatus.state': 'active',
+      groupPlan: true,
+    },
+    subscriptions => subscriptions.forEach(registerMapping),
+    {
+      _id: 1,
+      recurlySubscription_id: 1,
+    },
+    {
+      readPreference: 'secondaryPreferred',
+    },
+    {
+      verboseLogging: verbose,
+      ...additionalBatchedUpdateOptions,
+    }
+  )
+
+  logger.debug({}, `${subscriptionCount} subscriptions processed`)
+  if (commit) {
+    logger.debug({}, `${mapped} mappings registered`)
+  }
+}
+
+const {
+  error,
+  value: { commit, endDate, verbose },
+} = paramsSchema.validate(
+  minimist(process.argv.slice(2), {
+    boolean: ['commit', 'verbose'],
+    string: ['endDate'],
+  })
+)
+
+logger.logger.level(verbose ? 'debug' : 'info')
+
+if (error) {
+  logger.error({ error }, 'error with parameters')
+  triggerGracefulShutdown(done => done(1))
+} else {
+  logger.info({ verbose, commit, endDate }, commit ? 'COMMITTING' : 'DRY RUN')
+  await main()
+
+  triggerGracefulShutdown({
+    close(done) {
+      logger.info({}, 'shutting down')
+      done()
+    },
+  })
+}

+ 276 - 0
services/web/test/unit/src/Analytics/AccountMappingHelperTests.js

@@ -0,0 +1,276 @@
+const SandboxedModule = require('sandboxed-module')
+const { expect } = require('chai')
+const { ObjectId } = require('mongodb-legacy')
+const path = require('node:path')
+
+const MODULE_PATH = path.join(
+  __dirname,
+  '../../../../app/src/Features/Analytics/AccountMappingHelper'
+)
+
+describe('AccountMappingHelper', function () {
+  beforeEach(function () {
+    this.AccountMappingHelper = SandboxedModule.require(MODULE_PATH)
+  })
+
+  describe('extractAccountMappingsFromSubscription', function () {
+    describe('when the v1 id is the same in the updated subscription and the subscription', function () {
+      describe('when the salesforce id is the same in the updated subscription and the subscription', function () {
+        beforeEach(function () {
+          this.subscription = {
+            id: new ObjectId('abc123abc123abc123abc123'),
+            salesforce_id: 'def456def456def456',
+          }
+          this.updatedSubscription = { salesforce_id: 'def456def456def456' }
+          this.result =
+            this.AccountMappingHelper.extractAccountMappingsFromSubscription(
+              this.subscription,
+              this.updatedSubscription
+            )
+        })
+
+        it('returns an empty array', function () {
+          expect(this.result).to.be.an('array')
+          expect(this.result).to.have.length(0)
+        })
+      })
+      describe('when the salesforce id has changed between the subscription and the updated subscription', function () {
+        beforeEach(function () {
+          this.subscription = {
+            id: new ObjectId('abc123abc123abc123abc123'),
+            salesforce_id: 'def456def456def456',
+          }
+          this.updatedSubscription = { salesforce_id: 'ghi789ghi789ghi789' }
+          this.result =
+            this.AccountMappingHelper.extractAccountMappingsFromSubscription(
+              this.subscription,
+              this.updatedSubscription
+            )
+        })
+
+        it('returns an array with a single item', function () {
+          expect(this.result).to.be.an('array')
+          expect(this.result).to.have.length(1)
+        })
+
+        it('uses "account" as sourceEntity', function () {
+          expect(this.result[0]).to.haveOwnProperty('sourceEntity', 'account')
+        })
+
+        it('uses the salesforceId from the updated subscription as sourceEntityId', function () {
+          expect(this.result[0]).to.haveOwnProperty(
+            'sourceEntityId',
+            this.updatedSubscription.salesforce_id
+          )
+        })
+
+        it('uses "subscription" as targetEntity', function () {
+          expect(this.result[0]).to.haveOwnProperty(
+            'targetEntity',
+            'subscription'
+          )
+        })
+
+        it('uses the subscriptionId as targetEntityId', function () {
+          expect(this.result[0]).to.haveOwnProperty(
+            'targetEntityId',
+            this.subscription.id
+          )
+        })
+      })
+      describe('when the update subscription has a salesforce id and the subscription has no salesforce_id', function () {
+        beforeEach(function () {
+          this.subscription = { id: new ObjectId('abc123abc123abc123abc123') }
+          this.updatedSubscription = { salesforce_id: 'def456def456def456' }
+          this.result =
+            this.AccountMappingHelper.extractAccountMappingsFromSubscription(
+              this.subscription,
+              this.updatedSubscription
+            )
+        })
+
+        it('returns an array with a single item', function () {
+          expect(this.result).to.be.an('array')
+          expect(this.result).to.have.length(1)
+        })
+
+        it('uses "account" as sourceEntity', function () {
+          expect(this.result[0]).to.haveOwnProperty('sourceEntity', 'account')
+        })
+
+        it('uses the salesforceId from the updated subscription as sourceEntityId', function () {
+          expect(this.result[0]).to.haveOwnProperty(
+            'sourceEntityId',
+            this.updatedSubscription.salesforce_id
+          )
+        })
+
+        it('uses "subscription" as targetEntity', function () {
+          expect(this.result[0]).to.haveOwnProperty(
+            'targetEntity',
+            'subscription'
+          )
+        })
+
+        it('uses the subscriptionId as targetEntityId', function () {
+          expect(this.result[0]).to.haveOwnProperty(
+            'targetEntityId',
+            this.subscription.id
+          )
+        })
+      })
+    })
+
+    describe('when the v1 id has changed between the subscription and the updated subscription', function () {
+      describe('when the salesforce id has not changed between the subscription and the updated subscription', function () {
+        beforeEach(function () {
+          this.subscription = {
+            id: new ObjectId('abc123abc123abc123abc123'),
+            v1_id: '1',
+            salesforce_id: '',
+          }
+          this.updatedSubscription = { v1_id: '2', salesforce_id: '' }
+          this.result =
+            this.AccountMappingHelper.extractAccountMappingsFromSubscription(
+              this.subscription,
+              this.updatedSubscription
+            )
+        })
+
+        it('returns an array with a single item', function () {
+          expect(this.result).to.be.an('array')
+          expect(this.result).to.have.length(1)
+        })
+
+        it('uses "university" as the sourceEntity', function () {
+          expect(this.result[0]).to.haveOwnProperty(
+            'sourceEntity',
+            'university'
+          )
+        })
+
+        it('uses the v1_id from the updated subscription as the sourceEntityId', function () {
+          expect(this.result[0]).to.haveOwnProperty(
+            'sourceEntityId',
+            this.updatedSubscription.v1_id
+          )
+        })
+
+        it('uses "subscription" as the targetEntity', function () {
+          expect(this.result[0]).to.haveOwnProperty(
+            'targetEntity',
+            'subscription'
+          )
+        })
+
+        it('uses the subscription id as the targetEntityId', function () {
+          expect(this.result[0]).to.haveOwnProperty(
+            'targetEntityId',
+            this.subscription.id
+          )
+        })
+      })
+      describe('when the salesforce id has changed between the subscription and the updated subscription', function () {
+        beforeEach(function () {
+          this.subscription = {
+            id: new ObjectId('abc123abc123abc123abc123'),
+            v1_id: '',
+            salesforce_id: 'def456def456def456',
+          }
+          this.updatedSubscription = {
+            v1_id: '2',
+            salesforce_id: '',
+          }
+          this.result =
+            this.AccountMappingHelper.extractAccountMappingsFromSubscription(
+              this.subscription,
+              this.updatedSubscription
+            )
+        })
+
+        it('returns an array with two items', function () {
+          expect(this.result).to.be.an('array')
+          expect(this.result).to.have.length(2)
+        })
+
+        it('uses the salesforce_id from the updated subscription as the sourceEntityId for the first item', function () {
+          expect(this.result[0]).to.haveOwnProperty(
+            'sourceEntityId',
+            this.updatedSubscription.salesforce_id
+          )
+        })
+
+        it('uses the subscription id as the targetEntityId for the first item', function () {
+          expect(this.result[0]).to.haveOwnProperty(
+            'targetEntityId',
+            this.subscription.id
+          )
+        })
+
+        it('uses the v1_id from the updated subscription as the sourceEntityId for the second item', function () {
+          expect(this.result[1]).to.haveOwnProperty(
+            'sourceEntityId',
+            this.updatedSubscription.v1_id
+          )
+        })
+
+        it('uses the subscription id as the targetEntityId for the second item', function () {
+          expect(this.result[1]).to.haveOwnProperty(
+            'targetEntityId',
+            this.subscription.id
+          )
+        })
+      })
+    })
+  })
+  describe('when the recurlySubscription_id has changed between the subscription and the updated subscription', function () {
+    beforeEach(function () {
+      this.subscription = {
+        id: new ObjectId('abc123abc123abc123abc123'),
+        recurlySubscription_id: '',
+      }
+      this.updatedSubscription = {
+        recurlySubscription_id: '1234a5678b90123cd4567e8f901a2b34',
+      }
+      this.result =
+        this.AccountMappingHelper.extractAccountMappingsFromSubscription(
+          this.subscription,
+          this.updatedSubscription
+        )
+    })
+    it('returns an array with one item', function () {
+      expect(this.result).to.be.an('array')
+      expect(this.result).to.have.length(1)
+    })
+
+    it('uses "recurly" as the source', function () {
+      expect(this.result[0]).to.haveOwnProperty('source', 'recurly')
+    })
+
+    it('uses "subscription" as the sourceEntity', function () {
+      expect(this.result[0]).to.haveOwnProperty('sourceEntity', 'subscription')
+    })
+
+    it('uses the recurlySubscription_id as the sourceEntityId', function () {
+      expect(this.result[0]).to.haveOwnProperty(
+        'sourceEntityId',
+        this.updatedSubscription.recurlySubscription_id
+      )
+    })
+
+    it('uses "v2" as the target', function () {
+      expect(this.result[0]).to.haveOwnProperty('target', 'v2')
+    })
+
+    it('uses "subscription" as the targetEntity', function () {
+      expect(this.result[0]).to.haveOwnProperty('targetEntity', 'subscription')
+    })
+
+    it('uses the subscription id as the targetEntityId', function () {
+      expect(this.result[0]).to.haveOwnProperty(
+        'targetEntityId',
+        this.subscription.id
+      )
+    })
+  })
+})

+ 0 - 228
services/web/test/unit/src/Analytics/AccountMappingHelperTests.mjs

@@ -1,228 +0,0 @@
-import path from 'node:path'
-import esmock from 'esmock'
-import { expect } from 'chai'
-import mongodb from 'mongodb-legacy'
-import { fileURLToPath } from 'node:url'
-
-const { ObjectId } = mongodb
-
-const __dirname = fileURLToPath(new URL('.', import.meta.url))
-
-const MODULE_PATH = path.join(
-  __dirname,
-  '../../../../app/src/Features/Analytics/AccountMappingHelper'
-)
-
-describe('AccountMappingHelper', function () {
-  beforeEach(async function () {
-    this.AccountMappingHelper = await esmock.strict(MODULE_PATH)
-  })
-
-  describe('extractAccountMappingsFromSubscription', function () {
-    describe('when the v1 id is the same in the updated subscription and the subscription', function () {
-      describe('when the salesforce id is the same in the updated subscription and the subscription', function () {
-        beforeEach(function () {
-          this.subscription = {
-            id: new ObjectId('abc123abc123abc123abc123'),
-            salesforce_id: 'def456def456def456',
-          }
-          this.updatedSubscription = { salesforce_id: 'def456def456def456' }
-          this.result =
-            this.AccountMappingHelper.extractAccountMappingsFromSubscription(
-              this.subscription,
-              this.updatedSubscription
-            )
-        })
-
-        it('returns an empty array', function () {
-          expect(this.result).to.be.an('array')
-          expect(this.result).to.have.length(0)
-        })
-      })
-      describe('when the salesforce id has changed between the subscription and the updated subscription', function () {
-        beforeEach(function () {
-          this.subscription = {
-            id: new ObjectId('abc123abc123abc123abc123'),
-            salesforce_id: 'def456def456def456',
-          }
-          this.updatedSubscription = { salesforce_id: 'ghi789ghi789ghi789' }
-          this.result =
-            this.AccountMappingHelper.extractAccountMappingsFromSubscription(
-              this.subscription,
-              this.updatedSubscription
-            )
-        })
-
-        it('returns an array with a single item', function () {
-          expect(this.result).to.be.an('array')
-          expect(this.result).to.have.length(1)
-        })
-
-        it('uses "account" as sourceEntity', function () {
-          expect(this.result[0]).to.haveOwnProperty('sourceEntity', 'account')
-        })
-
-        it('uses the salesforceId from the updated subscription as sourceEntityId', function () {
-          expect(this.result[0]).to.haveOwnProperty(
-            'sourceEntityId',
-            this.updatedSubscription.salesforce_id
-          )
-        })
-
-        it('uses "subscription" as targetEntity', function () {
-          expect(this.result[0]).to.haveOwnProperty(
-            'targetEntity',
-            'subscription'
-          )
-        })
-
-        it('uses the subscriptionId as targetEntityId', function () {
-          expect(this.result[0]).to.haveOwnProperty(
-            'targetEntityId',
-            this.subscription.id
-          )
-        })
-      })
-      describe('when the update subscription has a salesforce id and the subscription has no salesforce_id', function () {
-        beforeEach(function () {
-          this.subscription = { id: new ObjectId('abc123abc123abc123abc123') }
-          this.updatedSubscription = { salesforce_id: 'def456def456def456' }
-          this.result =
-            this.AccountMappingHelper.extractAccountMappingsFromSubscription(
-              this.subscription,
-              this.updatedSubscription
-            )
-        })
-
-        it('returns an array with a single item', function () {
-          expect(this.result).to.be.an('array')
-          expect(this.result).to.have.length(1)
-        })
-
-        it('uses "account" as sourceEntity', function () {
-          expect(this.result[0]).to.haveOwnProperty('sourceEntity', 'account')
-        })
-
-        it('uses the salesforceId from the updated subscription as sourceEntityId', function () {
-          expect(this.result[0]).to.haveOwnProperty(
-            'sourceEntityId',
-            this.updatedSubscription.salesforce_id
-          )
-        })
-
-        it('uses "subscription" as targetEntity', function () {
-          expect(this.result[0]).to.haveOwnProperty(
-            'targetEntity',
-            'subscription'
-          )
-        })
-
-        it('uses the subscriptionId as targetEntityId', function () {
-          expect(this.result[0]).to.haveOwnProperty(
-            'targetEntityId',
-            this.subscription.id
-          )
-        })
-      })
-    })
-  })
-
-  describe('when the v1 id has changed between the subscription and the updated subscription', function () {
-    describe('when the salesforce id has not changed between the subscription and the updated subscription', function () {
-      beforeEach(function () {
-        this.subscription = {
-          id: new ObjectId('abc123abc123abc123abc123'),
-          v1_id: '1',
-          salesforce_id: '',
-        }
-        this.updatedSubscription = { v1_id: '2', salesforce_id: '' }
-        this.result =
-          this.AccountMappingHelper.extractAccountMappingsFromSubscription(
-            this.subscription,
-            this.updatedSubscription
-          )
-      })
-
-      it('returns an array with a single item', function () {
-        expect(this.result).to.be.an('array')
-        expect(this.result).to.have.length(1)
-      })
-
-      it('uses "university" as the sourceEntity', function () {
-        expect(this.result[0]).to.haveOwnProperty('sourceEntity', 'university')
-      })
-
-      it('uses the v1_id from the updated subscription as the sourceEntityId', function () {
-        expect(this.result[0]).to.haveOwnProperty(
-          'sourceEntityId',
-          this.updatedSubscription.v1_id
-        )
-      })
-
-      it('uses "subscription" as the targetEntity', function () {
-        expect(this.result[0]).to.haveOwnProperty(
-          'targetEntity',
-          'subscription'
-        )
-      })
-
-      it('uses the subscription id as the targetEntityId', function () {
-        expect(this.result[0]).to.haveOwnProperty(
-          'targetEntityId',
-          this.subscription.id
-        )
-      })
-    })
-    describe('when the salesforce id has changed between the subscription and the updated subscription', function () {
-      beforeEach(function () {
-        this.subscription = {
-          id: new ObjectId('abc123abc123abc123abc123'),
-          v1_id: '',
-          salesforce_id: 'def456def456def456',
-        }
-        this.updatedSubscription = {
-          v1_id: '2',
-          salesforce_id: '',
-        }
-        this.result =
-          this.AccountMappingHelper.extractAccountMappingsFromSubscription(
-            this.subscription,
-            this.updatedSubscription
-          )
-      })
-
-      it('returns an array with two items', function () {
-        expect(this.result).to.be.an('array')
-        expect(this.result).to.have.length(2)
-      })
-
-      it('uses the salesforce_id from the updated subscription as the sourceEntityId for the first item', function () {
-        expect(this.result[0]).to.haveOwnProperty(
-          'sourceEntityId',
-          this.updatedSubscription.salesforce_id
-        )
-      })
-
-      it('uses the subscription id as the targetEntityId for the first item', function () {
-        expect(this.result[0]).to.haveOwnProperty(
-          'targetEntityId',
-          this.subscription.id
-        )
-      })
-
-      it('uses the v1_id from the updated subscription as the sourceEntityId for the second item', function () {
-        expect(this.result[1]).to.haveOwnProperty(
-          'sourceEntityId',
-          this.updatedSubscription.v1_id
-        )
-      })
-
-      it('uses the subscription id as the targetEntityId for the second item', function () {
-        expect(this.result[1]).to.haveOwnProperty(
-          'targetEntityId',
-          this.subscription.id
-        )
-      })
-    })
-  })
-})

+ 39 - 0
services/web/test/unit/src/Subscription/SubscriptionUpdaterTests.js

@@ -148,6 +148,7 @@ describe('SubscriptionUpdater', function () {
     this.AnalyticsManager = {
       recordEventForUserInBackground: sinon.stub().resolves(),
       setUserPropertyForUserInBackground: sinon.stub(),
+      registerAccountMapping: sinon.stub(),
     }
 
     this.Features = {
@@ -175,6 +176,9 @@ describe('SubscriptionUpdater', function () {
           DeletedSubscription: this.DeletedSubscription,
         },
         '../Analytics/AnalyticsManager': this.AnalyticsManager,
+        '../Analytics/AccountMappingHelper': (this.AccountMappingHelper = {
+          generateSubscriptionToRecurlyMapping: sinon.stub(),
+        }),
         '../../infrastructure/Features': this.Features,
         '../User/UserAuditLogHandler': this.UserAuditLogHandler,
       },
@@ -280,6 +284,41 @@ describe('SubscriptionUpdater', function () {
       ).to.have.been.calledWith(this.adminUser._id)
     })
 
+    it('should send a recurly account mapping event', async function () {
+      const createdAt = new Date().toISOString()
+      this.AccountMappingHelper.generateSubscriptionToRecurlyMapping.returns({
+        source: 'recurly',
+        sourceEntity: 'subscription',
+        sourceEntityId: this.recurlySubscription.uuid,
+        target: 'v2',
+        targetEntity: 'subscription',
+        targetEntityId: this.subscription._id,
+        createdAt,
+      })
+      await this.SubscriptionUpdater.promises.updateSubscriptionFromRecurly(
+        this.recurlySubscription,
+        this.subscription,
+        {}
+      )
+      expect(
+        this.AccountMappingHelper.generateSubscriptionToRecurlyMapping
+      ).to.have.been.calledWith(
+        this.subscription._id,
+        this.recurlySubscription.uuid
+      )
+      expect(
+        this.AnalyticsManager.registerAccountMapping
+      ).to.have.been.calledWith({
+        source: 'recurly',
+        sourceEntity: 'subscription',
+        sourceEntityId: this.recurlySubscription.uuid,
+        target: 'v2',
+        targetEntity: 'subscription',
+        targetEntityId: this.subscription._id,
+        createdAt,
+      })
+    })
+
     it('should remove the subscription when expired', async function () {
       this.recurlySubscription.state = 'expired'
       await this.SubscriptionUpdater.promises.updateSubscriptionFromRecurly(