mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-05-23 17:19:37 +02:00
910 lines
29 KiB
JavaScript
Executable File
910 lines
29 KiB
JavaScript
Executable File
#!/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.getWithMetrics(
|
||
overleafUserId,
|
||
'script' // no-op, metrics are not collected from scripts.
|
||
)
|
||
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)
|
||
}
|