finalize-stripe-subscription-migration.mjs 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759
  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 PQueue from 'p-queue'
  41. import { z } from '../../app/src/infrastructure/Validation.mjs'
  42. import { scriptRunner } from '../lib/ScriptRunner.mjs'
  43. import {
  44. getRegionClient,
  45. convertStripeStatusToSubscriptionState,
  46. } from '../../modules/subscriptions/app/src/StripeClient.mjs'
  47. import RecurlyWrapper from '../../app/src/Features/Subscription/RecurlyWrapper.mjs'
  48. import { Subscription } from '../../app/src/models/Subscription.mjs'
  49. import { User } from '../../app/src/models/User.mjs'
  50. import AnalyticsManager from '../../app/src/Features/Analytics/AnalyticsManager.mjs'
  51. import AccountMappingHelper from '../../app/src/Features/Analytics/AccountMappingHelper.mjs'
  52. import PlansLocator from '../../app/src/Features/Subscription/PlansLocator.mjs'
  53. import UserAnalyticsIdCache from '../../app/src/Features/Analytics/UserAnalyticsIdCache.mjs'
  54. import CustomerIoHandler from '../../modules/customer-io/app/src/CustomerIoHandler.mjs'
  55. import { ReportError } from './helpers.mjs'
  56. import isEqual from 'lodash/isEqual.js'
  57. import {
  58. createRateLimitedApiWrappers,
  59. DEFAULT_RECURLY_RATE_LIMIT,
  60. DEFAULT_STRIPE_RATE_LIMIT,
  61. DEFAULT_RECURLY_API_RETRIES,
  62. DEFAULT_RECURLY_RETRY_DELAY_MS,
  63. DEFAULT_STRIPE_API_RETRIES,
  64. DEFAULT_STRIPE_RETRY_DELAY_MS,
  65. } from './RateLimiter.mjs'
  66. const preloadedProductMetadata = new Map()
  67. // rate limiters - initialized in main()
  68. let rateLimiters
  69. function usage() {
  70. console.error(`Usage: node scripts/stripe/finalize-stripe-subscription-migration.mjs [OPTS] [INPUT-FILE]
  71. Options:
  72. --output PATH Output file path (default: /tmp/migrate_output_<timestamp>.csv)
  73. --commit Apply changes (without this, runs in dry-run mode)
  74. --concurrency N Number of customers to process concurrently (default: 10)
  75. --recurly-rate-limit N Requests per second for Recurly (default: ${DEFAULT_RECURLY_RATE_LIMIT})
  76. --recurly-api-retries N Number of retries on Recurly 429s (default: ${DEFAULT_RECURLY_API_RETRIES})
  77. --recurly-retry-delay-ms N Delay between Recurly retries in ms (default: ${DEFAULT_RECURLY_RETRY_DELAY_MS})
  78. --stripe-rate-limit N Requests per second for Stripe (default: ${DEFAULT_STRIPE_RATE_LIMIT})
  79. --stripe-api-retries N Number of retries on Stripe 429s (default: ${DEFAULT_STRIPE_API_RETRIES})
  80. --stripe-retry-delay-ms N Delay between Stripe retries in ms (default: ${DEFAULT_STRIPE_RETRY_DELAY_MS})
  81. --help Show this help message
  82. `)
  83. }
  84. async function main(trackProgress) {
  85. const opts = parseArgs()
  86. const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
  87. const outputFile = opts.output ?? `/tmp/migrate_output_${timestamp}.csv`
  88. // initialize rate limiters
  89. rateLimiters = createRateLimitedApiWrappers({
  90. recurlyRateLimit: opts.recurlyRateLimit,
  91. recurlyApiRetries: opts.recurlyApiRetries,
  92. recurlyRetryDelayMs: opts.recurlyRetryDelayMs,
  93. stripeRateLimit: opts.stripeRateLimit,
  94. stripeApiRetries: opts.stripeApiRetries,
  95. stripeRetryDelayMs: opts.stripeRetryDelayMs,
  96. })
  97. await trackProgress('Starting Recurly to Stripe migration cutover')
  98. await trackProgress(`Run mode: ${opts.commit ? 'COMMIT' : 'DRY RUN'}`)
  99. await trackProgress(
  100. `Rate limits: Recurly ${opts.recurlyRateLimit}/s, Stripe ${opts.stripeRateLimit}/s`
  101. )
  102. await trackProgress(`Concurrency: ${opts.concurrency}`)
  103. const inputStream = opts.inputFile
  104. ? fs.createReadStream(opts.inputFile)
  105. : process.stdin
  106. const csvReader = getCsvReader(inputStream)
  107. const csvWriter = getCsvWriter(outputFile)
  108. await trackProgress('Populating product metadata...')
  109. await preloadProductMetadata('uk')
  110. await preloadProductMetadata('us')
  111. await trackProgress('Product metadata populated')
  112. await trackProgress(`Output: ${outputFile}`)
  113. let processedCount = 0
  114. let successCount = 0
  115. let errorCount = 0
  116. const queue = new PQueue({ concurrency: opts.concurrency })
  117. const maxQueueSize = opts.concurrency
  118. try {
  119. for await (const input of csvReader) {
  120. // throttle input if queue is full
  121. if (queue.size >= maxQueueSize) {
  122. await queue.onSizeLessThan(maxQueueSize)
  123. }
  124. queue.add(async () => {
  125. try {
  126. const result = await processMigration(input, opts.commit)
  127. csvWriter.write({
  128. recurly_account_code: input.recurly_account_code,
  129. target_stripe_account: input.target_stripe_account,
  130. stripe_customer_id: input.stripe_customer_id,
  131. previous_recurly_status: result.previousRecurlyStatus || '',
  132. previous_recurly_subscription_id:
  133. result.previousRecurlySubscriptionId || '',
  134. email: result.email || '',
  135. analyticsId: result.analyticsId || '',
  136. status: result.status,
  137. note: result.note,
  138. })
  139. if (
  140. result.status.startsWith('migrated') ||
  141. result.status === 'validated'
  142. ) {
  143. successCount++
  144. } else {
  145. errorCount++
  146. }
  147. } catch (err) {
  148. errorCount++
  149. if (err instanceof ReportError) {
  150. csvWriter.write({
  151. recurly_account_code: input.recurly_account_code,
  152. target_stripe_account: input.target_stripe_account,
  153. stripe_customer_id: input.stripe_customer_id,
  154. previous_recurly_status: '',
  155. previous_recurly_subscription_id: '',
  156. email: '',
  157. analyticsId: '',
  158. status: err.status,
  159. note: err.message,
  160. })
  161. } else {
  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: 'error',
  171. note: err.message,
  172. })
  173. }
  174. }
  175. processedCount++
  176. if (processedCount % 25 === 0) {
  177. await trackProgress(
  178. `Progress: ${processedCount} processed, ${successCount} successful, ${errorCount} errors`
  179. )
  180. }
  181. })
  182. }
  183. } finally {
  184. // wait for all queued tasks to complete
  185. await queue.onIdle()
  186. }
  187. await trackProgress(`✅ Total processed: ${processedCount}`)
  188. if (opts.commit) {
  189. await trackProgress(`✅ Successfully migrated: ${successCount}`)
  190. } else {
  191. await trackProgress(`✅ Successfully validated: ${successCount}`)
  192. await trackProgress('ℹ️ DRY RUN: No changes were applied')
  193. }
  194. await trackProgress(`❌ Errors: ${errorCount}`)
  195. await trackProgress('🎉 Script completed!')
  196. csvWriter.end()
  197. await CustomerIoHandler.closeCustomerIo()
  198. }
  199. function getCsvReader(inputStream) {
  200. const parser = csv.parse({ columns: true })
  201. inputStream.pipe(parser)
  202. return parser
  203. }
  204. function getCsvWriter(outputFile) {
  205. fs.mkdirSync(path.dirname(outputFile), { recursive: true })
  206. const outputStream = fs.createWriteStream(outputFile)
  207. const writer = csv.stringify({
  208. columns: [
  209. 'recurly_account_code',
  210. 'target_stripe_account',
  211. 'stripe_customer_id',
  212. 'previous_recurly_status',
  213. 'previous_recurly_subscription_id',
  214. 'email',
  215. 'analyticsId',
  216. 'status',
  217. 'note',
  218. ],
  219. header: true,
  220. })
  221. writer.on('error', err => {
  222. console.error(err)
  223. process.exit(1)
  224. })
  225. writer.pipe(outputStream)
  226. return writer
  227. }
  228. async function preloadProductMetadata(region) {
  229. if (preloadedProductMetadata.has(region)) return
  230. const stripeClient = getRegionClient(region)
  231. const products = await rateLimiters.requestWithRetries(
  232. stripeClient.serviceName,
  233. () =>
  234. stripeClient.stripe.products.list({
  235. active: true,
  236. limit: 100,
  237. }),
  238. { operation: 'products.list', region: stripeClient.serviceName }
  239. )
  240. const results = new Map()
  241. for (const product of products.data) {
  242. results.set(product.id, product.metadata)
  243. }
  244. preloadedProductMetadata.set(region, results)
  245. }
  246. async function processMigration(input, commit) {
  247. const {
  248. recurly_account_code: overleafUserId,
  249. target_stripe_account: targetStripeAccount,
  250. stripe_customer_id: stripeCustomerId,
  251. } = input
  252. // Get Stripe client for the target account (strip 'stripe-' prefix if present)
  253. const region = targetStripeAccount.replace(/^stripe-/, '')
  254. const stripeClient = getRegionClient(region)
  255. // 1. Fetch Mongo subscription
  256. const mongoSubscription = await Subscription.findOne({
  257. admin_id: overleafUserId,
  258. }).exec()
  259. if (!mongoSubscription) {
  260. throw new ReportError(
  261. 'no-mongo-subscription',
  262. 'No subscription found in Mongo'
  263. )
  264. }
  265. // 2. Check if already migrated to Stripe
  266. if (mongoSubscription.paymentProvider?.service?.includes('stripe')) {
  267. throw new ReportError('already-stripe', 'Subscription already using Stripe')
  268. }
  269. // 3. Store previous state for output
  270. const previousRecurlyStatus = mongoSubscription.recurlyStatus
  271. ? JSON.stringify(mongoSubscription.recurlyStatus)
  272. : ''
  273. const previousRecurlySubscriptionId =
  274. mongoSubscription.recurlySubscription_id || ''
  275. // 4. Find Stripe subscription for this customer
  276. let stripeCustomer
  277. let stripeSubscription
  278. try {
  279. stripeCustomer = await rateLimiters.requestWithRetries(
  280. stripeClient.serviceName,
  281. () =>
  282. stripeClient.getCustomerById(stripeCustomerId, [
  283. 'subscriptions',
  284. 'subscriptions.data.schedule',
  285. ]),
  286. {
  287. operation: 'getCustomerById',
  288. stripeCustomerId,
  289. region: stripeClient.serviceName,
  290. }
  291. )
  292. // handle no subscriptions found
  293. if (
  294. !stripeCustomer.subscriptions ||
  295. stripeCustomer.subscriptions.data.length === 0
  296. ) {
  297. throw new ReportError(
  298. 'no-stripe-subscription',
  299. 'No Stripe subscriptions found for customer'
  300. )
  301. }
  302. // handle multiple active subscriptions found
  303. const activeSubscriptions = stripeCustomer.subscriptions.data.filter(sub =>
  304. ['active', 'past_due', 'incomplete'].includes(sub.status)
  305. )
  306. if (activeSubscriptions.length > 1) {
  307. throw new ReportError(
  308. 'multiple-active-stripe-subscriptions',
  309. 'Multiple active Stripe subscriptions found for customer'
  310. )
  311. }
  312. // find the target subscription with migration metadata
  313. stripeSubscription = stripeCustomer.subscriptions.data.find(
  314. sub => sub.metadata?.recurly_to_stripe_migration_status === 'in_progress'
  315. )
  316. if (!stripeSubscription) {
  317. throw new ReportError(
  318. 'no-target-stripe-subscription',
  319. 'No target Stripe subscription found for customer'
  320. )
  321. }
  322. } catch (err) {
  323. if (err instanceof ReportError) throw err
  324. throw new ReportError(
  325. 'stripe-fetch-error',
  326. `Failed to fetch Stripe subscription: ${err.message}`
  327. )
  328. }
  329. // 5. Fetch Recurly subscription
  330. let recurlySubscription
  331. try {
  332. recurlySubscription = await rateLimiters.requestWithRetries(
  333. 'recurly',
  334. () =>
  335. RecurlyWrapper.promises.getSubscription(
  336. previousRecurlySubscriptionId,
  337. {}
  338. ),
  339. {
  340. operation: 'getSubscription',
  341. recurlySubscriptionId: previousRecurlySubscriptionId,
  342. }
  343. )
  344. } catch (err) {
  345. throw new ReportError(
  346. 'no-recurly-subscription',
  347. `Recurly subscription not found: ${err.message}`
  348. )
  349. }
  350. // 6. Detect changes between Recurly and Stripe
  351. const changes = detectChanges(recurlySubscription, stripeSubscription, region)
  352. if (changes.length > 0) {
  353. throw new ReportError(
  354. 'changes-detected',
  355. `Changes detected between Recurly and Stripe: ${changes.join('; ')}`
  356. )
  357. }
  358. // 7. If commit mode, perform migration
  359. const analyticsId = await UserAnalyticsIdCache.get(overleafUserId)
  360. const mongoUser = await User.findOne({
  361. _id: overleafUserId,
  362. }).exec()
  363. const result = {
  364. status: 'not-migrated',
  365. note: 'Not yet migrated',
  366. previousRecurlyStatus,
  367. previousRecurlySubscriptionId,
  368. email: mongoUser?.email || stripeCustomer.email,
  369. analyticsId,
  370. }
  371. if (commit) {
  372. try {
  373. await performCutover(
  374. mongoSubscription,
  375. stripeSubscription,
  376. recurlySubscription,
  377. stripeClient,
  378. stripeCustomer,
  379. analyticsId,
  380. mongoUser?.email
  381. )
  382. } catch (err) {
  383. if (err instanceof ReportError && err.status?.startsWith('migrated-')) {
  384. result.status = err.status
  385. result.note = err.message
  386. return result
  387. }
  388. throw err
  389. }
  390. result.status = 'migrated'
  391. result.note = 'Successfully migrated to Stripe'
  392. return result
  393. } else {
  394. result.status = 'validated'
  395. result.note = 'DRY RUN: Ready to migrate'
  396. return result
  397. }
  398. }
  399. /**
  400. * Format subscription items for display in error messages
  401. */
  402. function formatItems(items) {
  403. return items
  404. .map(item => `${item.code}(qty:${item.quantity},amt:${item.amount})`)
  405. .join(', ')
  406. }
  407. function detectChanges(recurlySubscription, stripeSubscription, region) {
  408. const changes = []
  409. // Extract item details from Recurly subscription
  410. const targetRecurlySubscription =
  411. recurlySubscription.pending_subscription || recurlySubscription
  412. const recurlyPlanItem =
  413. PlansLocator.convertLegacyGroupPlanCodeToConsolidatedGroupPlanCodeIfNeeded(
  414. targetRecurlySubscription.plan.plan_code
  415. )
  416. const simplifiedPlanCode = recurlyPlanItem.planCode.replace(
  417. /_free_trial.*$/,
  418. ''
  419. )
  420. const additionalLicenseQuantity =
  421. (targetRecurlySubscription.subscription_add_ons || []).find(
  422. addOn => addOn.add_on_code === 'additional-license'
  423. )?.quantity || 0
  424. const recurlyItems = [
  425. {
  426. code: simplifiedPlanCode,
  427. quantity: recurlyPlanItem.quantity + additionalLicenseQuantity,
  428. amount:
  429. targetRecurlySubscription.unit_amount_in_cents /
  430. recurlyPlanItem.quantity,
  431. },
  432. ...(targetRecurlySubscription.subscription_add_ons || [])
  433. .filter(addOn => addOn.add_on_code !== 'additional-license')
  434. .map(addOn => ({
  435. code: addOn.add_on_code,
  436. quantity: addOn.quantity,
  437. amount: addOn.unit_amount_in_cents,
  438. })),
  439. ].sort((a, b) => a.code.localeCompare(b.code))
  440. // Extract item details from Stripe subscription
  441. const products = preloadedProductMetadata.get(region)
  442. const hasAddOns = stripeSubscription.items.data.length > 1
  443. const stripeItems = stripeSubscription.items.data
  444. .map(item => {
  445. const productMetadata = products.get(item.price.product)
  446. if (!productMetadata) {
  447. throw new ReportError(
  448. 'unknown-stripe-product',
  449. `Unknown Stripe product: ${item.price.product}`
  450. )
  451. }
  452. return {
  453. code:
  454. productMetadata?.planCode?.includes('assistant') && hasAddOns
  455. ? productMetadata?.addOnCode
  456. : productMetadata?.planCode,
  457. quantity: item.quantity,
  458. amount: item.price.unit_amount,
  459. }
  460. })
  461. .sort((a, b) => a.code.localeCompare(b.code))
  462. // Compare items
  463. if (!isEqual(recurlyItems, stripeItems)) {
  464. changes.push(
  465. `Items: Recurly=[${formatItems(recurlyItems)}], Stripe=[${formatItems(stripeItems)}]`
  466. )
  467. }
  468. // Compare states
  469. const recurlyState = recurlySubscription.state
  470. const stripeState = convertStripeStatusToSubscriptionState(stripeSubscription)
  471. if (recurlyState !== stripeState) {
  472. changes.push(`State: Recurly=${recurlyState}, Stripe=${stripeState}`)
  473. }
  474. return changes
  475. }
  476. async function performCutover(
  477. mongoSubscription,
  478. stripeSubscription,
  479. recurlySubscription,
  480. stripeClient,
  481. stripeCustomer,
  482. analyticsId,
  483. mongoUserEmail
  484. ) {
  485. const adminUserId = mongoSubscription.admin_id.toString()
  486. // Step 1: Update Mongo subscription to point to Stripe
  487. mongoSubscription.paymentProvider = {
  488. service: stripeClient.serviceName,
  489. subscriptionId: stripeSubscription.id,
  490. state: convertStripeStatusToSubscriptionState(stripeSubscription),
  491. }
  492. mongoSubscription.recurlySubscription_id = undefined
  493. mongoSubscription.recurlyStatus = undefined
  494. try {
  495. await mongoSubscription.save()
  496. } catch (err) {
  497. throw new ReportError(
  498. 'not-migrated-mongo-update-failed',
  499. `Failed to update Mongo subscription: ${err.message}`
  500. )
  501. }
  502. // Step 2: Emit migration analytics event
  503. AnalyticsManager.recordEventForUserInBackground(
  504. adminUserId,
  505. 'subscription-migrated-to-stripe',
  506. {
  507. subscriptionId: mongoSubscription._id.toString(),
  508. migrationDirection: 'recurly-to-stripe',
  509. }
  510. )
  511. // Step 3: Postpone Recurly billing by +10 years if Recurly subscription is active
  512. if (recurlySubscription.state !== 'canceled') {
  513. const currentBillingDate = new Date(
  514. recurlySubscription.current_period_ends_at
  515. )
  516. const postponedDate = new Date(currentBillingDate)
  517. postponedDate.setFullYear(currentBillingDate.getFullYear() + 10)
  518. try {
  519. await rateLimiters.requestWithRetries(
  520. 'recurly',
  521. () =>
  522. RecurlyWrapper.promises.apiRequest({
  523. url: `subscriptions/${recurlySubscription.uuid}/postpone`,
  524. qs: { bulk: true, next_bill_date: postponedDate },
  525. method: 'PUT',
  526. }),
  527. {
  528. operation: 'postpone',
  529. recurlySubscriptionId: recurlySubscription.uuid,
  530. }
  531. )
  532. } catch (err) {
  533. throw new ReportError(
  534. 'migrated-recurly-postpone-failed',
  535. `Failed to postpone Recurly billing: ${err.message}`
  536. )
  537. }
  538. }
  539. // Step 4: Remove migration metadata from Stripe
  540. try {
  541. await rateLimiters.requestWithRetries(
  542. stripeClient.serviceName,
  543. () =>
  544. stripeClient.updateSubscriptionMetadata(stripeSubscription.id, {
  545. recurly_to_stripe_migration_status: '',
  546. }),
  547. {
  548. operation: 'updateSubscriptionMetadata',
  549. stripeSubscriptionId: stripeSubscription.id,
  550. region: stripeClient.serviceName,
  551. }
  552. )
  553. } catch (err) {
  554. throw new ReportError(
  555. 'migrated-metadata-removal-failed',
  556. `Successfully migrated to Stripe but failed to remove metadata: ${err.message}`
  557. )
  558. }
  559. // Step 5: Register analytics mapping
  560. try {
  561. AnalyticsManager.registerAccountMapping(
  562. AccountMappingHelper.generateSubscriptionToStripeMapping(
  563. mongoSubscription._id,
  564. stripeSubscription.id,
  565. stripeClient.serviceName
  566. )
  567. )
  568. } catch (err) {
  569. throw new ReportError(
  570. 'migrated-analytics-mapping-failed',
  571. `Successfully migrated to Stripe but failed to register analytics mapping: ${err.message}`
  572. )
  573. }
  574. // Step 6. Send data to customer.io
  575. if (analyticsId) {
  576. try {
  577. const migrationDate = new Date().toISOString().slice(0, 10)
  578. const needsToUpdateTaxInfo =
  579. (stripeCustomer.metadata?.taxInfoPending || '').length > 0
  580. // TODO: request Recurly account and billingInfo to verify if tax info in Stripe is up to date
  581. CustomerIoHandler.updateUserAttributes(analyticsId, {
  582. email: mongoUserEmail || stripeCustomer.email,
  583. stripe_migration: {
  584. migration_date: migrationDate,
  585. needs_to_update_tax_id: needsToUpdateTaxInfo,
  586. },
  587. })
  588. } catch (err) {
  589. throw new ReportError(
  590. 'migrated-customerio-upload-failed',
  591. `Successfully migrated to Stripe but failed to upload user to customer.io: ${err.message}`
  592. )
  593. }
  594. }
  595. // Step 7: Release subscription schedule associated with the migration
  596. const schedule = stripeSubscription.schedule
  597. if (
  598. schedule &&
  599. typeof schedule !== 'string' &&
  600. schedule.metadata?.billing_migration_id
  601. ) {
  602. try {
  603. await rateLimiters.requestWithRetries(
  604. stripeClient.serviceName,
  605. () =>
  606. stripeClient.stripe.subscriptionSchedules.release(schedule.id, {
  607. preserve_cancel_date: true,
  608. }),
  609. {
  610. operation: 'subscriptionSchedules.release',
  611. scheduleId: schedule.id,
  612. region: stripeClient.serviceName,
  613. }
  614. )
  615. } catch (err) {
  616. throw new ReportError(
  617. 'migrated-schedule-release-failed',
  618. `Successfully migrated to Stripe but failed to release subscription schedule: ${err.message}`
  619. )
  620. }
  621. }
  622. }
  623. function parseArgs() {
  624. const args = minimist(process.argv.slice(2), {
  625. string: [
  626. 'output',
  627. 'concurrency',
  628. 'recurly-rate-limit',
  629. 'recurly-api-retries',
  630. 'recurly-retry-delay-ms',
  631. 'stripe-rate-limit',
  632. 'stripe-api-retries',
  633. 'stripe-retry-delay-ms',
  634. ],
  635. boolean: ['commit', 'help'],
  636. default: {
  637. commit: false,
  638. concurrency: 10,
  639. 'recurly-rate-limit': DEFAULT_RECURLY_RATE_LIMIT,
  640. 'recurly-api-retries': DEFAULT_RECURLY_API_RETRIES,
  641. 'recurly-retry-delay-ms': DEFAULT_RECURLY_RETRY_DELAY_MS,
  642. 'stripe-rate-limit': DEFAULT_STRIPE_RATE_LIMIT,
  643. 'stripe-api-retries': DEFAULT_STRIPE_API_RETRIES,
  644. 'stripe-retry-delay-ms': DEFAULT_STRIPE_RETRY_DELAY_MS,
  645. },
  646. })
  647. if (args.help) {
  648. usage()
  649. process.exit(0)
  650. }
  651. const inputFile = args._[0]
  652. const paramsSchema = z.object({
  653. output: z.string().optional(),
  654. commit: z.boolean(),
  655. concurrency: z.number().int().positive(),
  656. recurlyRateLimit: z.number().positive(),
  657. recurlyApiRetries: z.number().int().nonnegative(),
  658. recurlyRetryDelayMs: z.number().int().nonnegative(),
  659. stripeRateLimit: z.number().positive(),
  660. stripeApiRetries: z.number().int().nonnegative(),
  661. stripeRetryDelayMs: z.number().int().nonnegative(),
  662. inputFile: z.string().optional(),
  663. })
  664. try {
  665. return paramsSchema.parse({
  666. output: args.output,
  667. commit: args.commit,
  668. concurrency: Number(args.concurrency),
  669. recurlyRateLimit: Number(args['recurly-rate-limit']),
  670. recurlyApiRetries: Number(args['recurly-api-retries']),
  671. recurlyRetryDelayMs: Number(args['recurly-retry-delay-ms']),
  672. stripeRateLimit: Number(args['stripe-rate-limit']),
  673. stripeApiRetries: Number(args['stripe-api-retries']),
  674. stripeRetryDelayMs: Number(args['stripe-retry-delay-ms']),
  675. inputFile,
  676. })
  677. } catch (err) {
  678. console.error('Invalid arguments:', err.message)
  679. usage()
  680. process.exit(1)
  681. }
  682. }
  683. try {
  684. await scriptRunner(main)
  685. process.exit(0)
  686. } catch (error) {
  687. console.error(error)
  688. process.exit(1)
  689. }