finalize-stripe-subscription-migration.mjs 29 KB

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