finalize-stripe-subscription-migration.mjs 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930
  1. #!/usr/bin/env node
  2. /**
  3. * This script handles the cutover for subscriptions migrating from Recurly to Stripe.
  4. *
  5. * IMPORTANT: Only run this after Stripe subscriptions have been created in Stripe and
  6. * are ready to take over billing from Recurly.
  7. *
  8. * NOTE: This script will trigger lifecycle emails to be sent. Please turn off:
  9. * - "Send emails about upcoming renewals" (https://dashboard.stripe.com/<account>/settings/billing/subscriptions)
  10. * - "Subscription Change Template" (https://sharelatex.recurly.com/emails/subscription_change/template/edit)
  11. *
  12. * Usage:
  13. * node scripts/stripe/finalize-stripe-subscription-migration.mjs [OPTS] [INPUT-FILE]
  14. *
  15. * Options:
  16. * --output PATH Output file path (default: /tmp/migrate_output_<timestamp>.csv)
  17. * --commit Apply changes (without this, runs in dry-run mode)
  18. * --concurrency, -c <n> Number of customers to process concurrently (default: 10)
  19. * --recurly-rate-limit N Requests per second for Recurly (default: 10)
  20. * --recurly-api-retries N Number of retries on Recurly 429s (default: 5)
  21. * --recurly-retry-delay-ms N Delay between Recurly retries in ms (default: 1000)
  22. * --stripe-rate-limit N Requests per second for Stripe (default: 50)
  23. * --stripe-api-retries N Number of retries on Stripe 429s (default: 5)
  24. * --stripe-retry-delay-ms N Delay between Stripe retries in ms (default: 1000)
  25. * --help Show help message
  26. *
  27. * CSV Input Format:
  28. * recurly_account_code,target_stripe_account,stripe_customer_id
  29. * 507f1f77bcf86cd799439011,stripe-uk,cus_1234567890abcdef
  30. *
  31. * CSV Output Format:
  32. * recurly_account_code,target_stripe_account,stripe_customer_id,previous_recurly_status,previous_recurly_subscription_id,email,analyticsId,status,note
  33. *
  34. * Note: recurly_account_code is the Overleaf user ID (admin_id)
  35. */
  36. import fs from 'node:fs'
  37. import path from 'node:path'
  38. import * as csv from 'csv'
  39. import minimist from 'minimist'
  40. import Settings from '@overleaf/settings'
  41. import recurly from 'recurly'
  42. import PQueue from 'p-queue'
  43. import { z } from '../../app/src/infrastructure/Validation.mjs'
  44. import { scriptRunner } from '../lib/ScriptRunner.mjs'
  45. import {
  46. getRegionClient,
  47. convertStripeStatusToSubscriptionState,
  48. } from '../../modules/subscriptions/app/src/StripeClient.mjs'
  49. import RecurlyWrapper from '../../app/src/Features/Subscription/RecurlyWrapper.mjs'
  50. import { Subscription } from '../../app/src/models/Subscription.mjs'
  51. import { User } from '../../app/src/models/User.mjs'
  52. import AnalyticsManager from '../../app/src/Features/Analytics/AnalyticsManager.mjs'
  53. import AccountMappingHelper from '../../app/src/Features/Analytics/AccountMappingHelper.mjs'
  54. import PlansLocator from '../../app/src/Features/Subscription/PlansLocator.mjs'
  55. import UserAnalyticsDataCache from '../../app/src/Features/Analytics/UserAnalyticsDataCache.mjs'
  56. import CustomerIoHandler from '../../modules/customer-io/app/src/CustomerIoHandler.mjs'
  57. import { ReportError, convertToMinorUnits } from './helpers.mjs'
  58. import { compareAccountFields } from '../helpers/migrate_recurly_customers_to_stripe.helpers.mjs'
  59. import {
  60. createRateLimitedApiWrappers,
  61. DEFAULT_RECURLY_RATE_LIMIT,
  62. DEFAULT_STRIPE_RATE_LIMIT,
  63. DEFAULT_RECURLY_API_RETRIES,
  64. DEFAULT_RECURLY_RETRY_DELAY_MS,
  65. DEFAULT_STRIPE_API_RETRIES,
  66. DEFAULT_STRIPE_RETRY_DELAY_MS,
  67. } from './RateLimiter.mjs'
  68. const preloadedProductMetadata = new Map()
  69. // Tolerance for comparing amounts (handles repeating decimals in per-seat prices)
  70. const AMOUNT_TOLERANCE = 1e-6
  71. // rate limiters - initialized in main()
  72. let rateLimiters
  73. // Recurly SDK client - initialized at module level
  74. const recurlyApiKey =
  75. process.env.RECURLY_API_KEY || Settings.apis?.recurly?.apiKey
  76. if (!recurlyApiKey) {
  77. throw new Error(
  78. 'Recurly API key is not set. Set RECURLY_API_KEY env var or configure Settings.apis.recurly.apiKey'
  79. )
  80. }
  81. const recurlyClient = new recurly.Client(recurlyApiKey)
  82. function usage() {
  83. console.error(`Usage: node scripts/stripe/finalize-stripe-subscription-migration.mjs [OPTS] [INPUT-FILE]
  84. Options:
  85. --output PATH Output file path (default: /tmp/migrate_output_<timestamp>.csv)
  86. --commit Apply changes (without this, runs in dry-run mode)
  87. --concurrency N Number of customers to process concurrently (default: 10)
  88. --recurly-rate-limit N Requests per second for Recurly (default: ${DEFAULT_RECURLY_RATE_LIMIT})
  89. --recurly-api-retries N Number of retries on Recurly 429s (default: ${DEFAULT_RECURLY_API_RETRIES})
  90. --recurly-retry-delay-ms N Delay between Recurly retries in ms (default: ${DEFAULT_RECURLY_RETRY_DELAY_MS})
  91. --stripe-rate-limit N Requests per second for Stripe (default: ${DEFAULT_STRIPE_RATE_LIMIT})
  92. --stripe-api-retries N Number of retries on Stripe 429s (default: ${DEFAULT_STRIPE_API_RETRIES})
  93. --stripe-retry-delay-ms N Delay between Stripe retries in ms (default: ${DEFAULT_STRIPE_RETRY_DELAY_MS})
  94. --help Show this help message
  95. `)
  96. }
  97. async function main(trackProgress) {
  98. const opts = parseArgs()
  99. const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
  100. const outputFile = opts.output ?? `/tmp/migrate_output_${timestamp}.csv`
  101. // initialize rate limiters
  102. rateLimiters = createRateLimitedApiWrappers({
  103. recurlyRateLimit: opts.recurlyRateLimit,
  104. recurlyApiRetries: opts.recurlyApiRetries,
  105. recurlyRetryDelayMs: opts.recurlyRetryDelayMs,
  106. stripeRateLimit: opts.stripeRateLimit,
  107. stripeApiRetries: opts.stripeApiRetries,
  108. stripeRetryDelayMs: opts.stripeRetryDelayMs,
  109. })
  110. await trackProgress('Starting Recurly to Stripe migration cutover')
  111. await trackProgress(`Run mode: ${opts.commit ? 'COMMIT' : 'DRY RUN'}`)
  112. await trackProgress(
  113. `Rate limits: Recurly ${opts.recurlyRateLimit}/s, Stripe ${opts.stripeRateLimit}/s`
  114. )
  115. await trackProgress(`Concurrency: ${opts.concurrency}`)
  116. const inputStream = opts.inputFile
  117. ? fs.createReadStream(opts.inputFile)
  118. : process.stdin
  119. const csvReader = getCsvReader(inputStream)
  120. const csvWriter = getCsvWriter(outputFile)
  121. await trackProgress('Populating product metadata...')
  122. await preloadProductMetadata('uk')
  123. await preloadProductMetadata('us')
  124. await trackProgress('Product metadata populated')
  125. await trackProgress(`Output: ${outputFile}`)
  126. let processedCount = 0
  127. let successCount = 0
  128. let errorCount = 0
  129. const queue = new PQueue({ concurrency: opts.concurrency })
  130. const maxQueueSize = opts.concurrency
  131. try {
  132. for await (const input of csvReader) {
  133. // throttle input if queue is full
  134. if (queue.size >= maxQueueSize) {
  135. await queue.onSizeLessThan(maxQueueSize)
  136. }
  137. queue.add(async () => {
  138. try {
  139. const result = await processMigration(input, opts.commit)
  140. csvWriter.write({
  141. recurly_account_code: input.recurly_account_code,
  142. target_stripe_account: input.target_stripe_account,
  143. stripe_customer_id: input.stripe_customer_id,
  144. previous_recurly_status: result.previousRecurlyStatus || '',
  145. previous_recurly_subscription_id:
  146. result.previousRecurlySubscriptionId || '',
  147. email: result.email || '',
  148. analyticsId: result.analyticsId || '',
  149. status: result.status,
  150. note: result.note,
  151. })
  152. if (
  153. result.status.startsWith('migrated') ||
  154. result.status === 'validated'
  155. ) {
  156. successCount++
  157. } else {
  158. errorCount++
  159. }
  160. } catch (err) {
  161. errorCount++
  162. if (err instanceof ReportError) {
  163. csvWriter.write({
  164. recurly_account_code: input.recurly_account_code,
  165. target_stripe_account: input.target_stripe_account,
  166. stripe_customer_id: input.stripe_customer_id,
  167. previous_recurly_status: '',
  168. previous_recurly_subscription_id: '',
  169. email: '',
  170. analyticsId: '',
  171. status: err.status,
  172. note: err.message,
  173. })
  174. } else {
  175. csvWriter.write({
  176. recurly_account_code: input.recurly_account_code,
  177. target_stripe_account: input.target_stripe_account,
  178. stripe_customer_id: input.stripe_customer_id,
  179. previous_recurly_status: '',
  180. previous_recurly_subscription_id: '',
  181. email: '',
  182. analyticsId: '',
  183. status: 'error',
  184. note: err.message,
  185. })
  186. }
  187. }
  188. processedCount++
  189. if (processedCount % 25 === 0) {
  190. await trackProgress(
  191. `Progress: ${processedCount} processed, ${successCount} successful, ${errorCount} errors`
  192. )
  193. }
  194. })
  195. }
  196. } finally {
  197. // wait for all queued tasks to complete
  198. await queue.onIdle()
  199. }
  200. await trackProgress(`✅ Total processed: ${processedCount}`)
  201. if (opts.commit) {
  202. await trackProgress(`✅ Successfully migrated: ${successCount}`)
  203. } else {
  204. await trackProgress(`✅ Successfully validated: ${successCount}`)
  205. await trackProgress('ℹ️ DRY RUN: No changes were applied')
  206. }
  207. await trackProgress(`❌ Errors: ${errorCount}`)
  208. await trackProgress('🎉 Script completed!')
  209. csvWriter.end()
  210. await CustomerIoHandler.closeCustomerIo()
  211. }
  212. function getCsvReader(inputStream) {
  213. const parser = csv.parse({ columns: true })
  214. inputStream.pipe(parser)
  215. return parser
  216. }
  217. function getCsvWriter(outputFile) {
  218. fs.mkdirSync(path.dirname(outputFile), { recursive: true })
  219. const outputStream = fs.createWriteStream(outputFile)
  220. const writer = csv.stringify({
  221. columns: [
  222. 'recurly_account_code',
  223. 'target_stripe_account',
  224. 'stripe_customer_id',
  225. 'previous_recurly_status',
  226. 'previous_recurly_subscription_id',
  227. 'email',
  228. 'analyticsId',
  229. 'status',
  230. 'note',
  231. ],
  232. header: true,
  233. })
  234. writer.on('error', err => {
  235. console.error(err)
  236. process.exit(1)
  237. })
  238. writer.pipe(outputStream)
  239. return writer
  240. }
  241. async function preloadProductMetadata(region) {
  242. if (preloadedProductMetadata.has(region)) return
  243. const stripeClient = getRegionClient(region)
  244. const products = await rateLimiters.requestWithRetries(
  245. stripeClient.serviceName,
  246. () =>
  247. stripeClient.stripe.products.list({
  248. active: true,
  249. limit: 100,
  250. }),
  251. { operation: 'products.list', region: stripeClient.serviceName }
  252. )
  253. const results = new Map()
  254. for (const product of products.data) {
  255. results.set(product.id, product.metadata)
  256. }
  257. preloadedProductMetadata.set(region, results)
  258. }
  259. async function processMigration(input, commit) {
  260. const {
  261. recurly_account_code: overleafUserId,
  262. target_stripe_account: targetStripeAccount,
  263. stripe_customer_id: stripeCustomerId,
  264. } = input
  265. // Get Stripe client for the target account (strip 'stripe-' prefix if present)
  266. const region = targetStripeAccount.replace(/^stripe-/, '')
  267. const stripeClient = getRegionClient(region)
  268. // 1. Fetch Mongo subscription
  269. const mongoSubscription = await Subscription.findOne({
  270. admin_id: overleafUserId,
  271. }).exec()
  272. if (!mongoSubscription) {
  273. throw new ReportError(
  274. 'no-mongo-subscription',
  275. 'No subscription found in Mongo'
  276. )
  277. }
  278. // 2. Check if already migrated to Stripe
  279. if (mongoSubscription.paymentProvider?.service?.includes('stripe')) {
  280. throw new ReportError('already-stripe', 'Subscription already using Stripe')
  281. }
  282. // 3. Store previous state for output
  283. const previousRecurlyStatus = mongoSubscription.recurlyStatus
  284. ? JSON.stringify(mongoSubscription.recurlyStatus)
  285. : ''
  286. const previousRecurlySubscriptionId =
  287. mongoSubscription.recurlySubscription_id || ''
  288. // 4. Find Stripe subscription for this customer
  289. let stripeCustomer
  290. let stripeSubscription
  291. try {
  292. stripeCustomer = await rateLimiters.requestWithRetries(
  293. stripeClient.serviceName,
  294. () =>
  295. stripeClient.getCustomerById(stripeCustomerId, [
  296. 'subscriptions',
  297. 'subscriptions.data.schedule',
  298. ]),
  299. {
  300. operation: 'getCustomerById',
  301. stripeCustomerId,
  302. region: stripeClient.serviceName,
  303. }
  304. )
  305. // handle no subscriptions found
  306. if (
  307. !stripeCustomer.subscriptions ||
  308. stripeCustomer.subscriptions.data.length === 0
  309. ) {
  310. throw new ReportError(
  311. 'no-stripe-subscription',
  312. 'No Stripe subscriptions found for customer'
  313. )
  314. }
  315. // handle multiple active subscriptions found
  316. const activeSubscriptions = stripeCustomer.subscriptions.data.filter(sub =>
  317. ['active', 'past_due', 'incomplete'].includes(sub.status)
  318. )
  319. if (activeSubscriptions.length > 1) {
  320. throw new ReportError(
  321. 'multiple-active-stripe-subscriptions',
  322. 'Multiple active Stripe subscriptions found for customer'
  323. )
  324. }
  325. // find the target subscription with migration metadata
  326. stripeSubscription = stripeCustomer.subscriptions.data.find(
  327. sub => sub.metadata?.recurly_to_stripe_migration_status === 'in_progress'
  328. )
  329. if (!stripeSubscription) {
  330. throw new ReportError(
  331. 'no-target-stripe-subscription',
  332. 'No target Stripe subscription found for customer'
  333. )
  334. }
  335. } catch (err) {
  336. if (err instanceof ReportError) throw err
  337. throw new ReportError(
  338. 'stripe-fetch-error',
  339. `Failed to fetch Stripe subscription: ${err.message}`
  340. )
  341. }
  342. // 5. Fetch Recurly subscription and account
  343. let recurlySubscription
  344. try {
  345. recurlySubscription = await rateLimiters.requestWithRetries(
  346. 'recurly',
  347. () =>
  348. recurlyClient.getSubscription(`uuid-${previousRecurlySubscriptionId}`),
  349. {
  350. operation: 'getSubscription',
  351. recurlySubscriptionId: previousRecurlySubscriptionId,
  352. }
  353. )
  354. } catch (err) {
  355. throw new ReportError(
  356. 'no-recurly-subscription',
  357. `Recurly subscription not found: ${err.message}`
  358. )
  359. }
  360. let recurlyAccount
  361. try {
  362. recurlyAccount = await rateLimiters.requestWithRetries(
  363. 'recurly',
  364. () => recurlyClient.getAccount(`code-${overleafUserId}`),
  365. {
  366. operation: 'getAccount',
  367. overleafUserId,
  368. }
  369. )
  370. } catch (err) {
  371. throw new ReportError(
  372. 'no-recurly-account',
  373. `Recurly account not found: ${err.message}`
  374. )
  375. }
  376. // 6. Detect changes between Recurly and Stripe
  377. const subscriptionChanges = detectSubscriptionChanges(
  378. recurlySubscription,
  379. stripeSubscription,
  380. region
  381. )
  382. const accountChanges = await detectAccountChanges(
  383. overleafUserId,
  384. stripeCustomerId,
  385. stripeClient,
  386. recurlyAccount,
  387. recurlySubscription.collectionMethod || null
  388. )
  389. const allChanges = [...subscriptionChanges, ...accountChanges]
  390. if (allChanges.length > 0) {
  391. throw new ReportError(
  392. 'changes-detected',
  393. `Changes detected between Recurly and Stripe: ${allChanges.join('; ')}`
  394. )
  395. }
  396. // 7. If commit mode, perform migration
  397. const analyticsId = await UserAnalyticsDataCache.getAnalyticsId(
  398. overleafUserId,
  399. 'script' // no-op, metrics are not collected from scripts.
  400. )
  401. const mongoUser = await User.findOne({
  402. _id: overleafUserId,
  403. }).exec()
  404. const result = {
  405. status: 'not-migrated',
  406. note: 'Not yet migrated',
  407. previousRecurlyStatus,
  408. previousRecurlySubscriptionId,
  409. email: mongoUser?.email || stripeCustomer.email,
  410. analyticsId,
  411. }
  412. if (commit) {
  413. try {
  414. await performCutover(
  415. mongoSubscription,
  416. stripeSubscription,
  417. recurlySubscription,
  418. stripeClient,
  419. stripeCustomer,
  420. mongoUser?.email
  421. )
  422. } catch (err) {
  423. if (err instanceof ReportError && err.status?.startsWith('migrated-')) {
  424. result.status = err.status
  425. result.note = err.message
  426. return result
  427. }
  428. throw err
  429. }
  430. result.status = 'migrated'
  431. result.note = 'Successfully migrated to Stripe'
  432. if (stripeCustomer.metadata?.taxInfoPending) {
  433. result.status += '-tax-info-pending'
  434. result.note += '; Tax info pending'
  435. }
  436. return result
  437. } else {
  438. result.status = 'validated'
  439. result.note = 'DRY RUN: Ready to migrate'
  440. return result
  441. }
  442. }
  443. /**
  444. * Format subscription items for display in error messages
  445. */
  446. function formatItems(items) {
  447. return items
  448. .map(item => `${item.code}(qty:${item.quantity},amt:${item.amount})`)
  449. .join(', ')
  450. }
  451. function detectSubscriptionChanges(
  452. recurlySubscription,
  453. stripeSubscription,
  454. region
  455. ) {
  456. const changes = []
  457. // Extract item details from Recurly subscription
  458. const targetRecurlySubscription =
  459. recurlySubscription.pendingChange || recurlySubscription
  460. const recurlyPlanItem =
  461. PlansLocator.convertLegacyGroupPlanCodeToConsolidatedGroupPlanCodeIfNeeded(
  462. targetRecurlySubscription.plan.code
  463. )
  464. const simplifiedPlanCode = recurlyPlanItem.planCode.replace(
  465. /_free_trial.*$/,
  466. ''
  467. )
  468. const additionalLicenseQuantity =
  469. (targetRecurlySubscription.addOns || []).find(
  470. addOn => addOn.addOn.code === 'additional-license'
  471. )?.quantity || 0
  472. const currency = recurlySubscription.currency
  473. const recurlyItems = [
  474. {
  475. code: simplifiedPlanCode,
  476. quantity: recurlyPlanItem.quantity + additionalLicenseQuantity,
  477. amount:
  478. convertToMinorUnits(targetRecurlySubscription.unitAmount, currency) /
  479. recurlyPlanItem.quantity,
  480. },
  481. ...(targetRecurlySubscription.addOns || [])
  482. .filter(addOn => addOn.addOn.code !== 'additional-license')
  483. .map(addOn => ({
  484. code: addOn.addOn.code,
  485. quantity: addOn.quantity,
  486. amount: convertToMinorUnits(addOn.unitAmount, currency),
  487. })),
  488. ].sort((a, b) => a.code.localeCompare(b.code))
  489. // Extract item details from Stripe subscription
  490. const products = preloadedProductMetadata.get(region)
  491. const hasAddOns = stripeSubscription.items.data.length > 1
  492. const stripeItems = stripeSubscription.items.data
  493. .map(item => {
  494. const productMetadata = products.get(item.price.product)
  495. if (!productMetadata) {
  496. throw new ReportError(
  497. 'unknown-stripe-product',
  498. `Unknown Stripe product: ${item.price.product}`
  499. )
  500. }
  501. return {
  502. code:
  503. productMetadata?.planCode?.includes('assistant') && hasAddOns
  504. ? productMetadata?.addOnCode
  505. : productMetadata?.planCode,
  506. quantity: item.quantity,
  507. amount:
  508. item.price.unit_amount != null
  509. ? item.price.unit_amount
  510. : parseFloat(item.price.unit_amount_decimal),
  511. }
  512. })
  513. .sort((a, b) => a.code.localeCompare(b.code))
  514. // Compare items (use tolerance for amounts due to repeating decimals in per-seat prices)
  515. const itemsMatch =
  516. recurlyItems.length === stripeItems.length &&
  517. recurlyItems.every((rItem, i) => {
  518. const sItem = stripeItems[i]
  519. return (
  520. rItem.code === sItem.code &&
  521. rItem.quantity === sItem.quantity &&
  522. Math.abs(rItem.amount - sItem.amount) < AMOUNT_TOLERANCE
  523. )
  524. })
  525. if (!itemsMatch) {
  526. changes.push(
  527. `Items: Recurly=[${formatItems(recurlyItems)}], Stripe=[${formatItems(stripeItems)}]`
  528. )
  529. }
  530. // Compare states
  531. const recurlyState = recurlySubscription.state
  532. const stripeState = convertStripeStatusToSubscriptionState(stripeSubscription)
  533. if (recurlyState !== stripeState) {
  534. changes.push(`State: Recurly=${recurlyState}, Stripe=${stripeState}`)
  535. }
  536. return changes
  537. }
  538. /**
  539. * Detect account-level drift between the Recurly account and the migrated Stripe customer.
  540. *
  541. * Uses the Recurly SDK account (which includes billing info), and re-retrieves
  542. * the Stripe customer with expanded tax_ids and default_payment_method so the
  543. * comparison can cover all the fields that the customer-migration script set.
  544. *
  545. * @param {string} overleafUserId - Recurly account code / Overleaf user ID
  546. * @param {string} stripeCustomerId - Stripe customer ID
  547. * @param {object} stripeClient - Stripe client (from getRegionClient)
  548. * @param {object} account - Recurly SDK account object (from recurlyClient.getAccount)
  549. * @param {string|null} collectionMethod - Recurly subscription collection method
  550. * @returns {Promise<string[]>} - Array of change descriptions (empty = no drift)
  551. */
  552. async function detectAccountChanges(
  553. overleafUserId,
  554. stripeCustomerId,
  555. stripeClient,
  556. account,
  557. collectionMethod
  558. ) {
  559. const context = { overleafUserId, stripeCustomerId }
  560. // Fetch the Stripe customer with tax_ids and payment method expanded
  561. const stripeCustomer = await rateLimiters.requestWithRetries(
  562. stripeClient.serviceName,
  563. () =>
  564. stripeClient.stripe.customers.retrieve(stripeCustomerId, {
  565. expand: ['tax_ids', 'invoice_settings.default_payment_method'],
  566. }),
  567. { ...context, operation: 'customers.retrieve' }
  568. )
  569. if (stripeCustomer.deleted) {
  570. return [`Stripe customer ${stripeCustomerId} has been deleted`]
  571. }
  572. // Pre-fetch payment methods if needed for comparison
  573. let stripePaymentMethods = []
  574. const isPaypalBillingAgreement =
  575. account.billingInfo?.paymentMethod?.object === 'paypal_billing_agreement'
  576. if (!isPaypalBillingAgreement && account.billingInfo?.paymentMethod) {
  577. const result = await rateLimiters.requestWithRetries(
  578. stripeClient.serviceName,
  579. () => stripeClient.stripe.customers.listPaymentMethods(stripeCustomerId),
  580. { ...context, operation: 'customers.listPaymentMethods' }
  581. )
  582. stripePaymentMethods = result.data
  583. }
  584. const diffs = await compareAccountFields({
  585. account,
  586. stripeCustomer,
  587. overleafUserId,
  588. fetchCollectionMethod: async () => collectionMethod,
  589. stripePaymentMethods,
  590. stripeServiceName: stripeClient.serviceName,
  591. })
  592. return formatDiffsAsChanges(diffs)
  593. }
  594. /**
  595. * Convert structured diffs from compareAccountFields into human-readable change descriptions.
  596. */
  597. function formatDiffsAsChanges(diffs) {
  598. const changes = []
  599. for (const [field, diff] of Object.entries(diffs)) {
  600. if (field === 'address') {
  601. changes.push(
  602. `Address: Recurly=${JSON.stringify(diff.recurly)}, Stripe=${JSON.stringify(diff.stripe)}`
  603. )
  604. } else if (field === 'cc_emails') {
  605. changes.push(
  606. `CC emails: Recurly=[${[...diff.recurly].sort().join(',')}], Stripe=[${[...(diff.stripe || [])].sort().join(',')}]`
  607. )
  608. } else if (field === 'tax_id') {
  609. const stripeStr = diff.stripe
  610. ? diff.stripe.map(t => `{type:${t.type}, value:${t.value}}`).join(', ')
  611. : '(none)'
  612. changes.push(
  613. `Tax ID: Recurly={type:${diff.recurly.type}, value:${diff.recurly.value}}, Stripe=${stripeStr}`
  614. )
  615. } else if (field === 'default_payment_method') {
  616. if (diff.bothPaypal) {
  617. changes.push(
  618. `Payment method: both PayPal, but Recurly billing info updatedAt (${diff.recurly.updatedAt}) is newer than Stripe payment method created (${diff.stripe.created})`
  619. )
  620. } else {
  621. changes.push(
  622. `Payment method: Recurly=${diff.recurly.type || diff.recurly.last4 || '(none)'}, Stripe=${diff.stripe.type || '(none)'}`
  623. )
  624. }
  625. } else if (field.startsWith('metadata.')) {
  626. const key = field.slice('metadata.'.length)
  627. changes.push(
  628. `Metadata ${key}: Recurly=${diff.recurly || '(empty)'}, Stripe=${diff.stripe || '(empty)'}`
  629. )
  630. } else {
  631. const label =
  632. field.charAt(0).toUpperCase() + field.slice(1).replace(/_/g, ' ')
  633. changes.push(
  634. `${label}: Recurly=${diff.recurly || '(empty)'}, Stripe=${diff.stripe || '(empty)'}`
  635. )
  636. }
  637. }
  638. return changes
  639. }
  640. async function performCutover(
  641. mongoSubscription,
  642. stripeSubscription,
  643. recurlySubscription,
  644. stripeClient,
  645. stripeCustomer,
  646. mongoUserEmail
  647. ) {
  648. const adminUserId = mongoSubscription.admin_id.toString()
  649. // Step 1: Update Mongo subscription to point to Stripe
  650. mongoSubscription.paymentProvider = {
  651. service: stripeClient.serviceName,
  652. subscriptionId: stripeSubscription.id,
  653. state: convertStripeStatusToSubscriptionState(stripeSubscription),
  654. }
  655. mongoSubscription.recurlySubscription_id = undefined
  656. mongoSubscription.recurlyStatus = undefined
  657. try {
  658. await mongoSubscription.save()
  659. } catch (err) {
  660. throw new ReportError(
  661. 'not-migrated-mongo-update-failed',
  662. `Failed to update Mongo subscription: ${err.message}`
  663. )
  664. }
  665. // Step 2: Emit migration analytics event
  666. AnalyticsManager.recordEventForUserInBackground(
  667. adminUserId,
  668. 'subscription-migrated-to-stripe',
  669. {
  670. subscriptionId: mongoSubscription._id.toString(),
  671. migrationDirection: 'recurly-to-stripe',
  672. }
  673. )
  674. // Step 3: Postpone Recurly billing by +10 years if Recurly subscription is active
  675. if (recurlySubscription.state !== 'canceled') {
  676. const currentBillingDate = new Date(recurlySubscription.currentPeriodEndsAt)
  677. const postponedDate = new Date(currentBillingDate)
  678. postponedDate.setFullYear(currentBillingDate.getFullYear() + 10)
  679. try {
  680. await rateLimiters.requestWithRetries(
  681. 'recurly',
  682. () =>
  683. RecurlyWrapper.promises.apiRequest({
  684. url: `subscriptions/${recurlySubscription.uuid}/postpone`,
  685. qs: { bulk: true, next_bill_date: postponedDate },
  686. method: 'PUT',
  687. }),
  688. {
  689. operation: 'postpone',
  690. recurlySubscriptionId: recurlySubscription.uuid,
  691. }
  692. )
  693. } catch (err) {
  694. throw new ReportError(
  695. 'migrated-recurly-postpone-failed',
  696. `Failed to postpone Recurly billing: ${err.message}`
  697. )
  698. }
  699. }
  700. // Step 4: Remove migration metadata from Stripe
  701. try {
  702. await rateLimiters.requestWithRetries(
  703. stripeClient.serviceName,
  704. () =>
  705. stripeClient.updateSubscriptionMetadata(stripeSubscription.id, {
  706. recurly_to_stripe_migration_status: '',
  707. }),
  708. {
  709. operation: 'updateSubscriptionMetadata',
  710. stripeSubscriptionId: stripeSubscription.id,
  711. region: stripeClient.serviceName,
  712. }
  713. )
  714. } catch (err) {
  715. throw new ReportError(
  716. 'migrated-metadata-removal-failed',
  717. `Successfully migrated to Stripe but failed to remove metadata: ${err.message}`
  718. )
  719. }
  720. // Step 5: Register analytics mapping
  721. try {
  722. AnalyticsManager.registerAccountMapping(
  723. AccountMappingHelper.generateSubscriptionToStripeMapping(
  724. mongoSubscription._id,
  725. stripeSubscription.id,
  726. stripeClient.serviceName
  727. )
  728. )
  729. } catch (err) {
  730. throw new ReportError(
  731. 'migrated-analytics-mapping-failed',
  732. `Successfully migrated to Stripe but failed to register analytics mapping: ${err.message}`
  733. )
  734. }
  735. // Step 6. Send data to customer.io
  736. try {
  737. const migrationDate = new Date().toISOString().slice(0, 10)
  738. const needsToUpdateTaxInfo =
  739. (stripeCustomer.metadata?.taxInfoPending || '').length > 0
  740. // TODO: request Recurly account and billingInfo to verify if tax info in Stripe is up to date
  741. CustomerIoHandler.updateUserAttributes(adminUserId, {
  742. email: mongoUserEmail || stripeCustomer.email,
  743. stripe_migration: {
  744. migration_date: migrationDate,
  745. needs_to_update_tax_id: needsToUpdateTaxInfo,
  746. },
  747. })
  748. } catch (err) {
  749. throw new ReportError(
  750. 'migrated-customerio-upload-failed',
  751. `Successfully migrated to Stripe but failed to upload user to customer.io: ${err.message}`
  752. )
  753. }
  754. // Step 7: Release subscription schedule associated with the migration
  755. const schedule = stripeSubscription.schedule
  756. if (
  757. schedule &&
  758. typeof schedule !== 'string' &&
  759. schedule.metadata?.billing_migration_id
  760. ) {
  761. try {
  762. await rateLimiters.requestWithRetries(
  763. stripeClient.serviceName,
  764. () =>
  765. stripeClient.stripe.subscriptionSchedules.release(schedule.id, {
  766. preserve_cancel_date: true,
  767. }),
  768. {
  769. operation: 'subscriptionSchedules.release',
  770. scheduleId: schedule.id,
  771. region: stripeClient.serviceName,
  772. }
  773. )
  774. } catch (err) {
  775. throw new ReportError(
  776. 'migrated-schedule-release-failed',
  777. `Successfully migrated to Stripe but failed to release subscription schedule: ${err.message}`
  778. )
  779. }
  780. }
  781. }
  782. function parseArgs() {
  783. const args = minimist(process.argv.slice(2), {
  784. string: [
  785. 'output',
  786. 'concurrency',
  787. 'recurly-rate-limit',
  788. 'recurly-api-retries',
  789. 'recurly-retry-delay-ms',
  790. 'stripe-rate-limit',
  791. 'stripe-api-retries',
  792. 'stripe-retry-delay-ms',
  793. ],
  794. boolean: ['commit', 'help'],
  795. default: {
  796. commit: false,
  797. concurrency: 10,
  798. 'recurly-rate-limit': DEFAULT_RECURLY_RATE_LIMIT,
  799. 'recurly-api-retries': DEFAULT_RECURLY_API_RETRIES,
  800. 'recurly-retry-delay-ms': DEFAULT_RECURLY_RETRY_DELAY_MS,
  801. 'stripe-rate-limit': DEFAULT_STRIPE_RATE_LIMIT,
  802. 'stripe-api-retries': DEFAULT_STRIPE_API_RETRIES,
  803. 'stripe-retry-delay-ms': DEFAULT_STRIPE_RETRY_DELAY_MS,
  804. },
  805. })
  806. if (args.help) {
  807. usage()
  808. process.exit(0)
  809. }
  810. const inputFile = args._[0]
  811. const paramsSchema = z.object({
  812. output: z.string().optional(),
  813. commit: z.boolean(),
  814. concurrency: z.number().int().positive(),
  815. recurlyRateLimit: z.number().positive(),
  816. recurlyApiRetries: z.number().int().nonnegative(),
  817. recurlyRetryDelayMs: z.number().int().nonnegative(),
  818. stripeRateLimit: z.number().positive(),
  819. stripeApiRetries: z.number().int().nonnegative(),
  820. stripeRetryDelayMs: z.number().int().nonnegative(),
  821. inputFile: z.string().optional(),
  822. })
  823. try {
  824. return paramsSchema.parse({
  825. output: args.output,
  826. commit: args.commit,
  827. concurrency: Number(args.concurrency),
  828. recurlyRateLimit: Number(args['recurly-rate-limit']),
  829. recurlyApiRetries: Number(args['recurly-api-retries']),
  830. recurlyRetryDelayMs: Number(args['recurly-retry-delay-ms']),
  831. stripeRateLimit: Number(args['stripe-rate-limit']),
  832. stripeApiRetries: Number(args['stripe-api-retries']),
  833. stripeRetryDelayMs: Number(args['stripe-retry-delay-ms']),
  834. inputFile,
  835. })
  836. } catch (err) {
  837. console.error('Invalid arguments:', err.message)
  838. usage()
  839. process.exit(1)
  840. }
  841. }
  842. try {
  843. await scriptRunner(main)
  844. process.exit(0)
  845. } catch (error) {
  846. console.error(error)
  847. process.exit(1)
  848. }