| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906 |
- #!/usr/bin/env node
- /**
- * This script handles the cutover for subscriptions migrating from Recurly to Stripe.
- *
- * IMPORTANT: Only run this after Stripe subscriptions have been created in Stripe and
- * are ready to take over billing from Recurly.
- *
- * NOTE: This script will trigger lifecycle emails to be sent. Please turn off:
- * - "Send emails about upcoming renewals" (https://dashboard.stripe.com/<account>/settings/billing/subscriptions)
- * - "Subscription Change Template" (https://sharelatex.recurly.com/emails/subscription_change/template/edit)
- *
- * Usage:
- * node scripts/stripe/finalize-stripe-subscription-migration.mjs [OPTS] [INPUT-FILE]
- *
- * Options:
- * --output PATH Output file path (default: /tmp/migrate_output_<timestamp>.csv)
- * --commit Apply changes (without this, runs in dry-run mode)
- * --concurrency, -c <n> Number of customers to process concurrently (default: 10)
- * --recurly-rate-limit N Requests per second for Recurly (default: 10)
- * --recurly-api-retries N Number of retries on Recurly 429s (default: 5)
- * --recurly-retry-delay-ms N Delay between Recurly retries in ms (default: 1000)
- * --stripe-rate-limit N Requests per second for Stripe (default: 50)
- * --stripe-api-retries N Number of retries on Stripe 429s (default: 5)
- * --stripe-retry-delay-ms N Delay between Stripe retries in ms (default: 1000)
- * --help Show help message
- *
- * CSV Input Format:
- * recurly_account_code,target_stripe_account,stripe_customer_id
- * 507f1f77bcf86cd799439011,stripe-uk,cus_1234567890abcdef
- *
- * CSV Output Format:
- * recurly_account_code,target_stripe_account,stripe_customer_id,previous_recurly_status,previous_recurly_subscription_id,email,analyticsId,status,note
- *
- * Note: recurly_account_code is the Overleaf user ID (admin_id)
- */
- import fs from 'node:fs'
- import path from 'node:path'
- import * as csv from 'csv'
- import minimist from 'minimist'
- import Settings from '@overleaf/settings'
- import recurly from 'recurly'
- import PQueue from 'p-queue'
- import { z } from '../../app/src/infrastructure/Validation.mjs'
- import { scriptRunner } from '../lib/ScriptRunner.mjs'
- import {
- getRegionClient,
- convertStripeStatusToSubscriptionState,
- } from '../../modules/subscriptions/app/src/StripeClient.mjs'
- import RecurlyWrapper from '../../app/src/Features/Subscription/RecurlyWrapper.mjs'
- import { Subscription } from '../../app/src/models/Subscription.mjs'
- import { User } from '../../app/src/models/User.mjs'
- import AnalyticsManager from '../../app/src/Features/Analytics/AnalyticsManager.mjs'
- import AccountMappingHelper from '../../app/src/Features/Analytics/AccountMappingHelper.mjs'
- import PlansLocator from '../../app/src/Features/Subscription/PlansLocator.mjs'
- import UserAnalyticsIdCache from '../../app/src/Features/Analytics/UserAnalyticsIdCache.mjs'
- import CustomerIoHandler from '../../modules/customer-io/app/src/CustomerIoHandler.mjs'
- import { ReportError, convertToMinorUnits } from './helpers.mjs'
- import isEqual from 'lodash/isEqual.js'
- import { compareAccountFields } from '../helpers/migrate_recurly_customers_to_stripe.helpers.mjs'
- import {
- createRateLimitedApiWrappers,
- DEFAULT_RECURLY_RATE_LIMIT,
- DEFAULT_STRIPE_RATE_LIMIT,
- DEFAULT_RECURLY_API_RETRIES,
- DEFAULT_RECURLY_RETRY_DELAY_MS,
- DEFAULT_STRIPE_API_RETRIES,
- DEFAULT_STRIPE_RETRY_DELAY_MS,
- } from './RateLimiter.mjs'
- const preloadedProductMetadata = new Map()
- // rate limiters - initialized in main()
- let rateLimiters
- // Recurly SDK client - initialized at module level
- const recurlyApiKey =
- process.env.RECURLY_API_KEY || Settings.apis?.recurly?.apiKey
- if (!recurlyApiKey) {
- throw new Error(
- 'Recurly API key is not set. Set RECURLY_API_KEY env var or configure Settings.apis.recurly.apiKey'
- )
- }
- const recurlyClient = new recurly.Client(recurlyApiKey)
- function usage() {
- console.error(`Usage: node scripts/stripe/finalize-stripe-subscription-migration.mjs [OPTS] [INPUT-FILE]
- Options:
- --output PATH Output file path (default: /tmp/migrate_output_<timestamp>.csv)
- --commit Apply changes (without this, runs in dry-run mode)
- --concurrency N Number of customers to process concurrently (default: 10)
- --recurly-rate-limit N Requests per second for Recurly (default: ${DEFAULT_RECURLY_RATE_LIMIT})
- --recurly-api-retries N Number of retries on Recurly 429s (default: ${DEFAULT_RECURLY_API_RETRIES})
- --recurly-retry-delay-ms N Delay between Recurly retries in ms (default: ${DEFAULT_RECURLY_RETRY_DELAY_MS})
- --stripe-rate-limit N Requests per second for Stripe (default: ${DEFAULT_STRIPE_RATE_LIMIT})
- --stripe-api-retries N Number of retries on Stripe 429s (default: ${DEFAULT_STRIPE_API_RETRIES})
- --stripe-retry-delay-ms N Delay between Stripe retries in ms (default: ${DEFAULT_STRIPE_RETRY_DELAY_MS})
- --help Show this help message
- `)
- }
- async function main(trackProgress) {
- const opts = parseArgs()
- const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
- const outputFile = opts.output ?? `/tmp/migrate_output_${timestamp}.csv`
- // initialize rate limiters
- rateLimiters = createRateLimitedApiWrappers({
- recurlyRateLimit: opts.recurlyRateLimit,
- recurlyApiRetries: opts.recurlyApiRetries,
- recurlyRetryDelayMs: opts.recurlyRetryDelayMs,
- stripeRateLimit: opts.stripeRateLimit,
- stripeApiRetries: opts.stripeApiRetries,
- stripeRetryDelayMs: opts.stripeRetryDelayMs,
- })
- await trackProgress('Starting Recurly to Stripe migration cutover')
- await trackProgress(`Run mode: ${opts.commit ? 'COMMIT' : 'DRY RUN'}`)
- await trackProgress(
- `Rate limits: Recurly ${opts.recurlyRateLimit}/s, Stripe ${opts.stripeRateLimit}/s`
- )
- await trackProgress(`Concurrency: ${opts.concurrency}`)
- const inputStream = opts.inputFile
- ? fs.createReadStream(opts.inputFile)
- : process.stdin
- const csvReader = getCsvReader(inputStream)
- const csvWriter = getCsvWriter(outputFile)
- await trackProgress('Populating product metadata...')
- await preloadProductMetadata('uk')
- await preloadProductMetadata('us')
- await trackProgress('Product metadata populated')
- await trackProgress(`Output: ${outputFile}`)
- let processedCount = 0
- let successCount = 0
- let errorCount = 0
- const queue = new PQueue({ concurrency: opts.concurrency })
- const maxQueueSize = opts.concurrency
- try {
- for await (const input of csvReader) {
- // throttle input if queue is full
- if (queue.size >= maxQueueSize) {
- await queue.onSizeLessThan(maxQueueSize)
- }
- queue.add(async () => {
- try {
- const result = await processMigration(input, opts.commit)
- csvWriter.write({
- recurly_account_code: input.recurly_account_code,
- target_stripe_account: input.target_stripe_account,
- stripe_customer_id: input.stripe_customer_id,
- previous_recurly_status: result.previousRecurlyStatus || '',
- previous_recurly_subscription_id:
- result.previousRecurlySubscriptionId || '',
- email: result.email || '',
- analyticsId: result.analyticsId || '',
- status: result.status,
- note: result.note,
- })
- if (
- result.status.startsWith('migrated') ||
- result.status === 'validated'
- ) {
- successCount++
- } else {
- errorCount++
- }
- } catch (err) {
- errorCount++
- if (err instanceof ReportError) {
- csvWriter.write({
- recurly_account_code: input.recurly_account_code,
- target_stripe_account: input.target_stripe_account,
- stripe_customer_id: input.stripe_customer_id,
- previous_recurly_status: '',
- previous_recurly_subscription_id: '',
- email: '',
- analyticsId: '',
- status: err.status,
- note: err.message,
- })
- } else {
- csvWriter.write({
- recurly_account_code: input.recurly_account_code,
- target_stripe_account: input.target_stripe_account,
- stripe_customer_id: input.stripe_customer_id,
- previous_recurly_status: '',
- previous_recurly_subscription_id: '',
- email: '',
- analyticsId: '',
- status: 'error',
- note: err.message,
- })
- }
- }
- processedCount++
- if (processedCount % 25 === 0) {
- await trackProgress(
- `Progress: ${processedCount} processed, ${successCount} successful, ${errorCount} errors`
- )
- }
- })
- }
- } finally {
- // wait for all queued tasks to complete
- await queue.onIdle()
- }
- await trackProgress(`✅ Total processed: ${processedCount}`)
- if (opts.commit) {
- await trackProgress(`✅ Successfully migrated: ${successCount}`)
- } else {
- await trackProgress(`✅ Successfully validated: ${successCount}`)
- await trackProgress('ℹ️ DRY RUN: No changes were applied')
- }
- await trackProgress(`❌ Errors: ${errorCount}`)
- await trackProgress('🎉 Script completed!')
- csvWriter.end()
- await CustomerIoHandler.closeCustomerIo()
- }
- function getCsvReader(inputStream) {
- const parser = csv.parse({ columns: true })
- inputStream.pipe(parser)
- return parser
- }
- function getCsvWriter(outputFile) {
- fs.mkdirSync(path.dirname(outputFile), { recursive: true })
- const outputStream = fs.createWriteStream(outputFile)
- const writer = csv.stringify({
- columns: [
- 'recurly_account_code',
- 'target_stripe_account',
- 'stripe_customer_id',
- 'previous_recurly_status',
- 'previous_recurly_subscription_id',
- 'email',
- 'analyticsId',
- 'status',
- 'note',
- ],
- header: true,
- })
- writer.on('error', err => {
- console.error(err)
- process.exit(1)
- })
- writer.pipe(outputStream)
- return writer
- }
- async function preloadProductMetadata(region) {
- if (preloadedProductMetadata.has(region)) return
- const stripeClient = getRegionClient(region)
- const products = await rateLimiters.requestWithRetries(
- stripeClient.serviceName,
- () =>
- stripeClient.stripe.products.list({
- active: true,
- limit: 100,
- }),
- { operation: 'products.list', region: stripeClient.serviceName }
- )
- const results = new Map()
- for (const product of products.data) {
- results.set(product.id, product.metadata)
- }
- preloadedProductMetadata.set(region, results)
- }
- async function processMigration(input, commit) {
- const {
- recurly_account_code: overleafUserId,
- target_stripe_account: targetStripeAccount,
- stripe_customer_id: stripeCustomerId,
- } = input
- // Get Stripe client for the target account (strip 'stripe-' prefix if present)
- const region = targetStripeAccount.replace(/^stripe-/, '')
- const stripeClient = getRegionClient(region)
- // 1. Fetch Mongo subscription
- const mongoSubscription = await Subscription.findOne({
- admin_id: overleafUserId,
- }).exec()
- if (!mongoSubscription) {
- throw new ReportError(
- 'no-mongo-subscription',
- 'No subscription found in Mongo'
- )
- }
- // 2. Check if already migrated to Stripe
- if (mongoSubscription.paymentProvider?.service?.includes('stripe')) {
- throw new ReportError('already-stripe', 'Subscription already using Stripe')
- }
- // 3. Store previous state for output
- const previousRecurlyStatus = mongoSubscription.recurlyStatus
- ? JSON.stringify(mongoSubscription.recurlyStatus)
- : ''
- const previousRecurlySubscriptionId =
- mongoSubscription.recurlySubscription_id || ''
- // 4. Find Stripe subscription for this customer
- let stripeCustomer
- let stripeSubscription
- try {
- stripeCustomer = await rateLimiters.requestWithRetries(
- stripeClient.serviceName,
- () =>
- stripeClient.getCustomerById(stripeCustomerId, [
- 'subscriptions',
- 'subscriptions.data.schedule',
- ]),
- {
- operation: 'getCustomerById',
- stripeCustomerId,
- region: stripeClient.serviceName,
- }
- )
- // handle no subscriptions found
- if (
- !stripeCustomer.subscriptions ||
- stripeCustomer.subscriptions.data.length === 0
- ) {
- throw new ReportError(
- 'no-stripe-subscription',
- 'No Stripe subscriptions found for customer'
- )
- }
- // handle multiple active subscriptions found
- const activeSubscriptions = stripeCustomer.subscriptions.data.filter(sub =>
- ['active', 'past_due', 'incomplete'].includes(sub.status)
- )
- if (activeSubscriptions.length > 1) {
- throw new ReportError(
- 'multiple-active-stripe-subscriptions',
- 'Multiple active Stripe subscriptions found for customer'
- )
- }
- // find the target subscription with migration metadata
- stripeSubscription = stripeCustomer.subscriptions.data.find(
- sub => sub.metadata?.recurly_to_stripe_migration_status === 'in_progress'
- )
- if (!stripeSubscription) {
- throw new ReportError(
- 'no-target-stripe-subscription',
- 'No target Stripe subscription found for customer'
- )
- }
- } catch (err) {
- if (err instanceof ReportError) throw err
- throw new ReportError(
- 'stripe-fetch-error',
- `Failed to fetch Stripe subscription: ${err.message}`
- )
- }
- // 5. Fetch Recurly subscription and account
- let recurlySubscription
- try {
- recurlySubscription = await rateLimiters.requestWithRetries(
- 'recurly',
- () =>
- recurlyClient.getSubscription(`uuid-${previousRecurlySubscriptionId}`),
- {
- operation: 'getSubscription',
- recurlySubscriptionId: previousRecurlySubscriptionId,
- }
- )
- } catch (err) {
- throw new ReportError(
- 'no-recurly-subscription',
- `Recurly subscription not found: ${err.message}`
- )
- }
- let recurlyAccount
- try {
- recurlyAccount = await rateLimiters.requestWithRetries(
- 'recurly',
- () => recurlyClient.getAccount(`code-${overleafUserId}`),
- {
- operation: 'getAccount',
- overleafUserId,
- }
- )
- } catch (err) {
- throw new ReportError(
- 'no-recurly-account',
- `Recurly account not found: ${err.message}`
- )
- }
- // 6. Detect changes between Recurly and Stripe
- const subscriptionChanges = detectSubscriptionChanges(
- recurlySubscription,
- stripeSubscription,
- region
- )
- const accountChanges = await detectAccountChanges(
- overleafUserId,
- stripeCustomerId,
- stripeClient,
- recurlyAccount,
- recurlySubscription.collectionMethod || null
- )
- const allChanges = [...subscriptionChanges, ...accountChanges]
- if (allChanges.length > 0) {
- throw new ReportError(
- 'changes-detected',
- `Changes detected between Recurly and Stripe: ${allChanges.join('; ')}`
- )
- }
- // 7. If commit mode, perform migration
- const analyticsId = await UserAnalyticsIdCache.get(overleafUserId)
- const mongoUser = await User.findOne({
- _id: overleafUserId,
- }).exec()
- const result = {
- status: 'not-migrated',
- note: 'Not yet migrated',
- previousRecurlyStatus,
- previousRecurlySubscriptionId,
- email: mongoUser?.email || stripeCustomer.email,
- analyticsId,
- }
- if (commit) {
- try {
- await performCutover(
- mongoSubscription,
- stripeSubscription,
- recurlySubscription,
- stripeClient,
- stripeCustomer,
- mongoUser?.email
- )
- } catch (err) {
- if (err instanceof ReportError && err.status?.startsWith('migrated-')) {
- result.status = err.status
- result.note = err.message
- return result
- }
- throw err
- }
- result.status = 'migrated'
- result.note = 'Successfully migrated to Stripe'
- if (stripeCustomer.metadata?.taxInfoPending) {
- result.status += '-tax-info-pending'
- result.note += '; Tax info pending'
- }
- return result
- } else {
- result.status = 'validated'
- result.note = 'DRY RUN: Ready to migrate'
- return result
- }
- }
- /**
- * Format subscription items for display in error messages
- */
- function formatItems(items) {
- return items
- .map(item => `${item.code}(qty:${item.quantity},amt:${item.amount})`)
- .join(', ')
- }
- function detectSubscriptionChanges(
- recurlySubscription,
- stripeSubscription,
- region
- ) {
- const changes = []
- // Extract item details from Recurly subscription
- const targetRecurlySubscription =
- recurlySubscription.pendingChange || recurlySubscription
- const recurlyPlanItem =
- PlansLocator.convertLegacyGroupPlanCodeToConsolidatedGroupPlanCodeIfNeeded(
- targetRecurlySubscription.plan.code
- )
- const simplifiedPlanCode = recurlyPlanItem.planCode.replace(
- /_free_trial.*$/,
- ''
- )
- const additionalLicenseQuantity =
- (targetRecurlySubscription.addOns || []).find(
- addOn => addOn.addOn.code === 'additional-license'
- )?.quantity || 0
- const currency = recurlySubscription.currency
- const recurlyItems = [
- {
- code: simplifiedPlanCode,
- quantity: recurlyPlanItem.quantity + additionalLicenseQuantity,
- amount:
- convertToMinorUnits(targetRecurlySubscription.unitAmount, currency) /
- recurlyPlanItem.quantity,
- },
- ...(targetRecurlySubscription.addOns || [])
- .filter(addOn => addOn.addOn.code !== 'additional-license')
- .map(addOn => ({
- code: addOn.addOn.code,
- quantity: addOn.quantity,
- amount: convertToMinorUnits(addOn.unitAmount, currency),
- })),
- ].sort((a, b) => a.code.localeCompare(b.code))
- // Extract item details from Stripe subscription
- const products = preloadedProductMetadata.get(region)
- const hasAddOns = stripeSubscription.items.data.length > 1
- const stripeItems = stripeSubscription.items.data
- .map(item => {
- const productMetadata = products.get(item.price.product)
- if (!productMetadata) {
- throw new ReportError(
- 'unknown-stripe-product',
- `Unknown Stripe product: ${item.price.product}`
- )
- }
- return {
- code:
- productMetadata?.planCode?.includes('assistant') && hasAddOns
- ? productMetadata?.addOnCode
- : productMetadata?.planCode,
- quantity: item.quantity,
- amount: item.price.unit_amount,
- }
- })
- .sort((a, b) => a.code.localeCompare(b.code))
- // Compare items
- if (!isEqual(recurlyItems, stripeItems)) {
- changes.push(
- `Items: Recurly=[${formatItems(recurlyItems)}], Stripe=[${formatItems(stripeItems)}]`
- )
- }
- // Compare states
- const recurlyState = recurlySubscription.state
- const stripeState = convertStripeStatusToSubscriptionState(stripeSubscription)
- if (recurlyState !== stripeState) {
- changes.push(`State: Recurly=${recurlyState}, Stripe=${stripeState}`)
- }
- return changes
- }
- /**
- * Detect account-level drift between the Recurly account and the migrated Stripe customer.
- *
- * Uses the Recurly SDK account (which includes billing info), and re-retrieves
- * the Stripe customer with expanded tax_ids and default_payment_method so the
- * comparison can cover all the fields that the customer-migration script set.
- *
- * @param {string} overleafUserId - Recurly account code / Overleaf user ID
- * @param {string} stripeCustomerId - Stripe customer ID
- * @param {object} stripeClient - Stripe client (from getRegionClient)
- * @param {object} account - Recurly SDK account object (from recurlyClient.getAccount)
- * @param {string|null} collectionMethod - Recurly subscription collection method
- * @returns {Promise<string[]>} - Array of change descriptions (empty = no drift)
- */
- async function detectAccountChanges(
- overleafUserId,
- stripeCustomerId,
- stripeClient,
- account,
- collectionMethod
- ) {
- const context = { overleafUserId, stripeCustomerId }
- // Fetch the Stripe customer with tax_ids and payment method expanded
- const stripeCustomer = await rateLimiters.requestWithRetries(
- stripeClient.serviceName,
- () =>
- stripeClient.stripe.customers.retrieve(stripeCustomerId, {
- expand: ['tax_ids', 'invoice_settings.default_payment_method'],
- }),
- { ...context, operation: 'customers.retrieve' }
- )
- if (stripeCustomer.deleted) {
- return [`Stripe customer ${stripeCustomerId} has been deleted`]
- }
- // Pre-fetch payment methods if needed for comparison
- let stripePaymentMethods = []
- const isPaypalBillingAgreement =
- account.billingInfo?.paymentMethod?.object === 'paypal_billing_agreement'
- if (!isPaypalBillingAgreement && account.billingInfo?.paymentMethod) {
- const result = await rateLimiters.requestWithRetries(
- stripeClient.serviceName,
- () => stripeClient.stripe.customers.listPaymentMethods(stripeCustomerId),
- { ...context, operation: 'customers.listPaymentMethods' }
- )
- stripePaymentMethods = result.data
- }
- const diffs = await compareAccountFields({
- account,
- stripeCustomer,
- overleafUserId,
- fetchCollectionMethod: async () => collectionMethod,
- stripePaymentMethods,
- stripeServiceName: stripeClient.serviceName,
- })
- return formatDiffsAsChanges(diffs)
- }
- /**
- * Convert structured diffs from compareAccountFields into human-readable change descriptions.
- */
- function formatDiffsAsChanges(diffs) {
- const changes = []
- for (const [field, diff] of Object.entries(diffs)) {
- if (field === 'address') {
- changes.push(
- `Address: Recurly=${JSON.stringify(diff.recurly)}, Stripe=${JSON.stringify(diff.stripe)}`
- )
- } else if (field === 'cc_emails') {
- changes.push(
- `CC emails: Recurly=[${[...diff.recurly].sort().join(',')}], Stripe=[${[...(diff.stripe || [])].sort().join(',')}]`
- )
- } else if (field === 'tax_id') {
- const stripeStr = diff.stripe
- ? diff.stripe.map(t => `{type:${t.type}, value:${t.value}}`).join(', ')
- : '(none)'
- changes.push(
- `Tax ID: Recurly={type:${diff.recurly.type}, value:${diff.recurly.value}}, Stripe=${stripeStr}`
- )
- } else if (field === 'default_payment_method') {
- changes.push(
- `Payment method: Recurly=${diff.recurly.type || diff.recurly.last4 || '(none)'}, Stripe=${diff.stripe.type || '(none)'}`
- )
- } else if (field.startsWith('metadata.')) {
- const key = field.slice('metadata.'.length)
- changes.push(
- `Metadata ${key}: Recurly=${diff.recurly || '(empty)'}, Stripe=${diff.stripe || '(empty)'}`
- )
- } else {
- const label =
- field.charAt(0).toUpperCase() + field.slice(1).replace(/_/g, ' ')
- changes.push(
- `${label}: Recurly=${diff.recurly || '(empty)'}, Stripe=${diff.stripe || '(empty)'}`
- )
- }
- }
- return changes
- }
- async function performCutover(
- mongoSubscription,
- stripeSubscription,
- recurlySubscription,
- stripeClient,
- stripeCustomer,
- mongoUserEmail
- ) {
- const adminUserId = mongoSubscription.admin_id.toString()
- // Step 1: Update Mongo subscription to point to Stripe
- mongoSubscription.paymentProvider = {
- service: stripeClient.serviceName,
- subscriptionId: stripeSubscription.id,
- state: convertStripeStatusToSubscriptionState(stripeSubscription),
- }
- mongoSubscription.recurlySubscription_id = undefined
- mongoSubscription.recurlyStatus = undefined
- try {
- await mongoSubscription.save()
- } catch (err) {
- throw new ReportError(
- 'not-migrated-mongo-update-failed',
- `Failed to update Mongo subscription: ${err.message}`
- )
- }
- // Step 2: Emit migration analytics event
- AnalyticsManager.recordEventForUserInBackground(
- adminUserId,
- 'subscription-migrated-to-stripe',
- {
- subscriptionId: mongoSubscription._id.toString(),
- migrationDirection: 'recurly-to-stripe',
- }
- )
- // Step 3: Postpone Recurly billing by +10 years if Recurly subscription is active
- if (recurlySubscription.state !== 'canceled') {
- const currentBillingDate = new Date(recurlySubscription.currentPeriodEndsAt)
- const postponedDate = new Date(currentBillingDate)
- postponedDate.setFullYear(currentBillingDate.getFullYear() + 10)
- try {
- await rateLimiters.requestWithRetries(
- 'recurly',
- () =>
- RecurlyWrapper.promises.apiRequest({
- url: `subscriptions/${recurlySubscription.uuid}/postpone`,
- qs: { bulk: true, next_bill_date: postponedDate },
- method: 'PUT',
- }),
- {
- operation: 'postpone',
- recurlySubscriptionId: recurlySubscription.uuid,
- }
- )
- } catch (err) {
- throw new ReportError(
- 'migrated-recurly-postpone-failed',
- `Failed to postpone Recurly billing: ${err.message}`
- )
- }
- }
- // Step 4: Remove migration metadata from Stripe
- try {
- await rateLimiters.requestWithRetries(
- stripeClient.serviceName,
- () =>
- stripeClient.updateSubscriptionMetadata(stripeSubscription.id, {
- recurly_to_stripe_migration_status: '',
- }),
- {
- operation: 'updateSubscriptionMetadata',
- stripeSubscriptionId: stripeSubscription.id,
- region: stripeClient.serviceName,
- }
- )
- } catch (err) {
- throw new ReportError(
- 'migrated-metadata-removal-failed',
- `Successfully migrated to Stripe but failed to remove metadata: ${err.message}`
- )
- }
- // Step 5: Register analytics mapping
- try {
- AnalyticsManager.registerAccountMapping(
- AccountMappingHelper.generateSubscriptionToStripeMapping(
- mongoSubscription._id,
- stripeSubscription.id,
- stripeClient.serviceName
- )
- )
- } catch (err) {
- throw new ReportError(
- 'migrated-analytics-mapping-failed',
- `Successfully migrated to Stripe but failed to register analytics mapping: ${err.message}`
- )
- }
- // Step 6. Send data to customer.io
- try {
- const migrationDate = new Date().toISOString().slice(0, 10)
- const needsToUpdateTaxInfo =
- (stripeCustomer.metadata?.taxInfoPending || '').length > 0
- // TODO: request Recurly account and billingInfo to verify if tax info in Stripe is up to date
- CustomerIoHandler.updateUserAttributes(adminUserId, {
- email: mongoUserEmail || stripeCustomer.email,
- stripe_migration: {
- migration_date: migrationDate,
- needs_to_update_tax_id: needsToUpdateTaxInfo,
- },
- })
- } catch (err) {
- throw new ReportError(
- 'migrated-customerio-upload-failed',
- `Successfully migrated to Stripe but failed to upload user to customer.io: ${err.message}`
- )
- }
- // Step 7: Release subscription schedule associated with the migration
- const schedule = stripeSubscription.schedule
- if (
- schedule &&
- typeof schedule !== 'string' &&
- schedule.metadata?.billing_migration_id
- ) {
- try {
- await rateLimiters.requestWithRetries(
- stripeClient.serviceName,
- () =>
- stripeClient.stripe.subscriptionSchedules.release(schedule.id, {
- preserve_cancel_date: true,
- }),
- {
- operation: 'subscriptionSchedules.release',
- scheduleId: schedule.id,
- region: stripeClient.serviceName,
- }
- )
- } catch (err) {
- throw new ReportError(
- 'migrated-schedule-release-failed',
- `Successfully migrated to Stripe but failed to release subscription schedule: ${err.message}`
- )
- }
- }
- }
- function parseArgs() {
- const args = minimist(process.argv.slice(2), {
- string: [
- 'output',
- 'concurrency',
- 'recurly-rate-limit',
- 'recurly-api-retries',
- 'recurly-retry-delay-ms',
- 'stripe-rate-limit',
- 'stripe-api-retries',
- 'stripe-retry-delay-ms',
- ],
- boolean: ['commit', 'help'],
- default: {
- commit: false,
- concurrency: 10,
- 'recurly-rate-limit': DEFAULT_RECURLY_RATE_LIMIT,
- 'recurly-api-retries': DEFAULT_RECURLY_API_RETRIES,
- 'recurly-retry-delay-ms': DEFAULT_RECURLY_RETRY_DELAY_MS,
- 'stripe-rate-limit': DEFAULT_STRIPE_RATE_LIMIT,
- 'stripe-api-retries': DEFAULT_STRIPE_API_RETRIES,
- 'stripe-retry-delay-ms': DEFAULT_STRIPE_RETRY_DELAY_MS,
- },
- })
- if (args.help) {
- usage()
- process.exit(0)
- }
- const inputFile = args._[0]
- const paramsSchema = z.object({
- output: z.string().optional(),
- commit: z.boolean(),
- concurrency: z.number().int().positive(),
- recurlyRateLimit: z.number().positive(),
- recurlyApiRetries: z.number().int().nonnegative(),
- recurlyRetryDelayMs: z.number().int().nonnegative(),
- stripeRateLimit: z.number().positive(),
- stripeApiRetries: z.number().int().nonnegative(),
- stripeRetryDelayMs: z.number().int().nonnegative(),
- inputFile: z.string().optional(),
- })
- try {
- return paramsSchema.parse({
- output: args.output,
- commit: args.commit,
- concurrency: Number(args.concurrency),
- recurlyRateLimit: Number(args['recurly-rate-limit']),
- recurlyApiRetries: Number(args['recurly-api-retries']),
- recurlyRetryDelayMs: Number(args['recurly-retry-delay-ms']),
- stripeRateLimit: Number(args['stripe-rate-limit']),
- stripeApiRetries: Number(args['stripe-api-retries']),
- stripeRetryDelayMs: Number(args['stripe-retry-delay-ms']),
- inputFile,
- })
- } catch (err) {
- console.error('Invalid arguments:', err.message)
- usage()
- process.exit(1)
- }
- }
- try {
- await scriptRunner(main)
- process.exit(0)
- } catch (error) {
- console.error(error)
- process.exit(1)
- }
|