diff --git a/services/web/app/src/Features/Analytics/AccountMappingHelper.js b/services/web/app/src/Features/Analytics/AccountMappingHelper.js index 5967149b8e..4568047d37 100644 --- a/services/web/app/src/Features/Analytics/AccountMappingHelper.js +++ b/services/web/app/src/Features/Analytics/AccountMappingHelper.js @@ -6,6 +6,7 @@ const mappings = new Map([ /** * @typedef {(import('./types.d.ts').AccountMapping)} AccountMapping + * @import { StripePaymentProviderService } from '../../../../types/subscription/dashboard/subscription' */ /** @@ -90,8 +91,34 @@ function generateSubscriptionToRecurlyMapping( } } +/** + * + * @param {string} subscriptionId + * @param {string} stripeId + * @param {StripePaymentProviderService} stripePaymentProviderService + * @param {string} [createdAt] - Should be an ISO date + * @return {AccountMapping} + */ +function generateSubscriptionToStripeMapping( + subscriptionId, + stripeId, + stripePaymentProviderService, + createdAt = new Date().toISOString() +) { + return { + source: stripePaymentProviderService, + sourceEntity: 'subscription', + sourceEntityId: stripeId, + target: 'v2', + targetEntity: 'subscription', + targetEntityId: subscriptionId, + createdAt, + } +} + module.exports = { extractAccountMappingsFromSubscription, generateV1Mapping, generateSubscriptionToRecurlyMapping, + generateSubscriptionToStripeMapping, } diff --git a/services/web/scripts/analytics/backfill_stripe_to_subscription_mapping.mjs b/services/web/scripts/analytics/backfill_stripe_to_subscription_mapping.mjs new file mode 100644 index 0000000000..1c7a8935af --- /dev/null +++ b/services/web/scripts/analytics/backfill_stripe_to_subscription_mapping.mjs @@ -0,0 +1,128 @@ +/** + * This script backfills the account mapping for subscriptions that are active and backed by Stripe. + * + * The mapping joins a Stripe subscription 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, for example `--endDate=2024-01-01` + */ +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' +import { scriptRunner } from '../lib/ScriptRunner.mjs' + +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.generateSubscriptionToStripeMapping( + subscription._id, + subscription.paymentProvider.subscriptionId, + subscription.paymentProvider.service, + now + ) + logger.debug( + { + stripe: subscription.paymentProvider.subscriptionId, + stripeService: subscription.paymentProvider.service, + mapping, + }, + `processing subscription ${subscription._id}` + ) + if (commit) { + registerAccountMapping(mapping) + mapped++ + } +} + +async function main(trackProgress) { + const additionalBatchedUpdateOptions = {} + + if (endDate) { + additionalBatchedUpdateOptions.BATCH_RANGE_END = endDate + } + + await batchedUpdate( + db.subscriptions, + { + 'paymentProvider.service': { $in: ['stripe-us', 'stripe-uk'] }, + }, + subscriptions => subscriptions.forEach(registerMapping), + { + _id: 1, + 'paymentProvider.subscriptionId': 1, + 'paymentProvider.service': 1, + }, + { + readPreference: 'secondaryPreferred', + }, + { + verboseLogging: verbose, + ...additionalBatchedUpdateOptions, + trackProgress, + } + ) + + 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({ + close(done) { + logger.info({}, 'shutting down') + done(1) + }, + }) +} else { + logger.info({ verbose, commit, endDate }, commit ? 'COMMITTING' : 'DRY RUN') + await scriptRunner(main) + triggerGracefulShutdown({ + close(done) { + logger.info({}, 'shutting down') + done() + }, + }) +}