From 0e64fe2b2131e52dc13e319a6e9b862fdc1649ba Mon Sep 17 00:00:00 2001 From: Andrew Rumble Date: Thu, 20 Feb 2025 12:03:49 +0000 Subject: [PATCH] Merge pull request #23457 from overleaf/ar-recurly-account-mapping-initial [web] setup Recurly -> Mongo account mapping GitOrigin-RevId: ee08cad60ee04e62100f3d5a4f76fdbcf5543917 --- .../Analytics/AccountMappingHelper.js | 97 ++++++ .../Analytics/AccountMappingHelper.mjs | 71 ----- .../Analytics/AnalyticsController.mjs | 4 +- .../Features/Analytics/AnalyticsManager.js | 12 +- .../web/app/src/Features/Analytics/types.d.ts | 9 + .../Subscription/SubscriptionUpdater.js | 11 + ...ckfill_recurly_to_subscription_mapping.mjs | 120 ++++++++ .../Analytics/AccountMappingHelperTests.js | 276 ++++++++++++++++++ .../Analytics/AccountMappingHelperTests.mjs | 228 --------------- .../Subscription/SubscriptionUpdaterTests.js | 39 +++ 10 files changed, 559 insertions(+), 308 deletions(-) create mode 100644 services/web/app/src/Features/Analytics/AccountMappingHelper.js delete mode 100644 services/web/app/src/Features/Analytics/AccountMappingHelper.mjs create mode 100644 services/web/app/src/Features/Analytics/types.d.ts create mode 100644 services/web/scripts/analytics/backfill_recurly_to_subscription_mapping.mjs create mode 100644 services/web/test/unit/src/Analytics/AccountMappingHelperTests.js delete mode 100644 services/web/test/unit/src/Analytics/AccountMappingHelperTests.mjs diff --git a/services/web/app/src/Features/Analytics/AccountMappingHelper.js b/services/web/app/src/Features/Analytics/AccountMappingHelper.js new file mode 100644 index 0000000000..5967149b8e --- /dev/null +++ b/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} + */ +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, +} diff --git a/services/web/app/src/Features/Analytics/AccountMappingHelper.mjs b/services/web/app/src/Features/Analytics/AccountMappingHelper.mjs deleted file mode 100644 index 76e4bc51bc..0000000000 --- a/services/web/app/src/Features/Analytics/AccountMappingHelper.mjs +++ /dev/null @@ -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, -} diff --git a/services/web/app/src/Features/Analytics/AnalyticsController.mjs b/services/web/app/src/Features/Analytics/AnalyticsController.mjs index 8ae54518b9..7d9188f64e 100644 --- a/services/web/app/src/Features/Analytics/AnalyticsController.mjs +++ b/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) } diff --git a/services/web/app/src/Features/Analytics/AnalyticsManager.js b/services/web/app/src/Features/Analytics/AnalyticsManager.js index 0a1cd32e55..4afdb08f83 100644 --- a/services/web/app/src/Features/Analytics/AnalyticsManager.js +++ b/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, diff --git a/services/web/app/src/Features/Analytics/types.d.ts b/services/web/app/src/Features/Analytics/types.d.ts new file mode 100644 index 0000000000..44df848bd0 --- /dev/null +++ b/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 +} diff --git a/services/web/app/src/Features/Subscription/SubscriptionUpdater.js b/services/web/app/src/Features/Subscription/SubscriptionUpdater.js index 704335955e..2e43454da9 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionUpdater.js +++ b/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) } diff --git a/services/web/scripts/analytics/backfill_recurly_to_subscription_mapping.mjs b/services/web/scripts/analytics/backfill_recurly_to_subscription_mapping.mjs new file mode 100644 index 0000000000..16f5abe0ad --- /dev/null +++ b/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=` 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() + }, + }) +} diff --git a/services/web/test/unit/src/Analytics/AccountMappingHelperTests.js b/services/web/test/unit/src/Analytics/AccountMappingHelperTests.js new file mode 100644 index 0000000000..91eb6b9f87 --- /dev/null +++ b/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 + ) + }) + }) +}) diff --git a/services/web/test/unit/src/Analytics/AccountMappingHelperTests.mjs b/services/web/test/unit/src/Analytics/AccountMappingHelperTests.mjs deleted file mode 100644 index 62dadae849..0000000000 --- a/services/web/test/unit/src/Analytics/AccountMappingHelperTests.mjs +++ /dev/null @@ -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 - ) - }) - }) - }) -}) diff --git a/services/web/test/unit/src/Subscription/SubscriptionUpdaterTests.js b/services/web/test/unit/src/Subscription/SubscriptionUpdaterTests.js index 3d8212201f..666f7ffda9 100644 --- a/services/web/test/unit/src/Subscription/SubscriptionUpdaterTests.js +++ b/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(