finalize-stripe-subscription-migration.mjs 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622
  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. * --throttle DURATION Minimum time between requests in ms (default: 40)
  19. * --help Show help message
  20. *
  21. * CSV Input Format:
  22. * recurly_account_code,target_stripe_account,stripe_customer_id
  23. * 507f1f77bcf86cd799439011,stripe-uk,cus_1234567890abcdef
  24. *
  25. * CSV Output Format:
  26. * recurly_account_code,target_stripe_account,stripe_customer_id,previous_recurly_status,previous_recurly_subscription_id,email,analyticsId,status,note
  27. *
  28. * Note: recurly_account_code is the Overleaf user ID (admin_id)
  29. */
  30. import fs from 'node:fs'
  31. import path from 'node:path'
  32. import { setTimeout } from 'node:timers/promises'
  33. import * as csv from 'csv'
  34. import minimist from 'minimist'
  35. import { z } from '../../app/src/infrastructure/Validation.mjs'
  36. import { scriptRunner } from '../lib/ScriptRunner.mjs'
  37. import {
  38. getRegionClient,
  39. convertStripeStatusToSubscriptionState,
  40. } from '../../modules/subscriptions/app/src/StripeClient.mjs'
  41. import RecurlyWrapper from '../../app/src/Features/Subscription/RecurlyWrapper.mjs'
  42. import { Subscription } from '../../app/src/models/Subscription.mjs'
  43. import AnalyticsManager from '../../app/src/Features/Analytics/AnalyticsManager.mjs'
  44. import AccountMappingHelper from '../../app/src/Features/Analytics/AccountMappingHelper.mjs'
  45. import PlansLocator from '../../app/src/Features/Subscription/PlansLocator.mjs'
  46. import UserAnalyticsIdCache from '../../app/src/Features/Analytics/UserAnalyticsIdCache.mjs'
  47. import CustomerIoHandler from '../../modules/customer-io/app/src/CustomerIoHandler.mjs'
  48. import { ReportError } from './helpers.mjs'
  49. import isEqual from 'lodash/isEqual.js'
  50. const DEFAULT_THROTTLE = 40
  51. const preloadedProductMetadata = new Map()
  52. function usage() {
  53. console.error(`Usage: node scripts/stripe/finalize-stripe-subscription-migration.mjs [OPTS] [INPUT-FILE]
  54. Options:
  55. --output PATH Output file path (default: /tmp/migrate_output_<timestamp>.csv)
  56. --commit Apply changes (without this, runs in dry-run mode)
  57. --throttle DURATION Minimum time between requests in ms (default: ${DEFAULT_THROTTLE})
  58. --help Show this help message
  59. `)
  60. }
  61. async function main(trackProgress) {
  62. const opts = parseArgs()
  63. const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
  64. const outputFile = opts.output ?? `/tmp/migrate_output_${timestamp}.csv`
  65. await trackProgress('Starting Recurly to Stripe migration cutover')
  66. await trackProgress(`Run mode: ${opts.commit ? 'COMMIT' : 'DRY RUN'}`)
  67. await trackProgress(`Throttle: ${opts.throttle}ms between requests`)
  68. const inputStream = opts.inputFile
  69. ? fs.createReadStream(opts.inputFile)
  70. : process.stdin
  71. const csvReader = getCsvReader(inputStream)
  72. const csvWriter = getCsvWriter(outputFile)
  73. await trackProgress('Populating product metadata...')
  74. await preloadProductMetadata('uk')
  75. await preloadProductMetadata('us')
  76. await trackProgress('Product metadata populated')
  77. await trackProgress(`Output: ${outputFile}`)
  78. let processedCount = 0
  79. let successCount = 0
  80. let errorCount = 0
  81. let lastLoopTimestamp = 0
  82. for await (const input of csvReader) {
  83. const timeSinceLastLoop = Date.now() - lastLoopTimestamp
  84. if (timeSinceLastLoop < opts.throttle) {
  85. await setTimeout(opts.throttle - timeSinceLastLoop)
  86. }
  87. lastLoopTimestamp = Date.now()
  88. processedCount++
  89. try {
  90. const result = await processMigration(input, opts.commit)
  91. csvWriter.write({
  92. recurly_account_code: input.recurly_account_code,
  93. target_stripe_account: input.target_stripe_account,
  94. stripe_customer_id: input.stripe_customer_id,
  95. previous_recurly_status: result.previousRecurlyStatus || '',
  96. previous_recurly_subscription_id:
  97. result.previousRecurlySubscriptionId || '',
  98. email: result.email || '',
  99. analyticsId: result.analyticsId || '',
  100. status: result.status,
  101. note: result.note,
  102. })
  103. if (
  104. result.status.startsWith('migrated') ||
  105. result.status === 'validated'
  106. ) {
  107. successCount++
  108. } else {
  109. errorCount++
  110. }
  111. if (processedCount % 25 === 0) {
  112. await trackProgress(
  113. `Progress: ${processedCount} processed, ${successCount} successful, ${errorCount} errors`
  114. )
  115. }
  116. } catch (err) {
  117. errorCount++
  118. if (err instanceof ReportError) {
  119. csvWriter.write({
  120. recurly_account_code: input.recurly_account_code,
  121. target_stripe_account: input.target_stripe_account,
  122. stripe_customer_id: input.stripe_customer_id,
  123. previous_recurly_status: '',
  124. previous_recurly_subscription_id: '',
  125. email: '',
  126. analyticsId: '',
  127. status: err.status,
  128. note: err.message,
  129. })
  130. } else {
  131. csvWriter.write({
  132. recurly_account_code: input.recurly_account_code,
  133. target_stripe_account: input.target_stripe_account,
  134. stripe_customer_id: input.stripe_customer_id,
  135. previous_recurly_status: '',
  136. previous_recurly_subscription_id: '',
  137. email: '',
  138. analyticsId: '',
  139. status: 'error',
  140. note: err.message,
  141. })
  142. }
  143. }
  144. }
  145. await trackProgress(`✅ Total processed: ${processedCount}`)
  146. if (opts.commit) {
  147. await trackProgress(`✅ Successfully migrated: ${successCount}`)
  148. } else {
  149. await trackProgress(`✅ Successfully validated: ${successCount}`)
  150. await trackProgress('ℹ️ DRY RUN: No changes were applied')
  151. }
  152. await trackProgress(`❌ Errors: ${errorCount}`)
  153. await trackProgress('🎉 Script completed!')
  154. csvWriter.end()
  155. await CustomerIoHandler.closeCustomerIo()
  156. }
  157. function getCsvReader(inputStream) {
  158. const parser = csv.parse({ columns: true })
  159. inputStream.pipe(parser)
  160. return parser
  161. }
  162. function getCsvWriter(outputFile) {
  163. fs.mkdirSync(path.dirname(outputFile), { recursive: true })
  164. const outputStream = fs.createWriteStream(outputFile)
  165. const writer = csv.stringify({
  166. columns: [
  167. 'recurly_account_code',
  168. 'target_stripe_account',
  169. 'stripe_customer_id',
  170. 'previous_recurly_status',
  171. 'previous_recurly_subscription_id',
  172. 'email',
  173. 'analyticsId',
  174. 'status',
  175. 'note',
  176. ],
  177. header: true,
  178. })
  179. writer.on('error', err => {
  180. console.error(err)
  181. process.exit(1)
  182. })
  183. writer.pipe(outputStream)
  184. return writer
  185. }
  186. async function preloadProductMetadata(region) {
  187. if (preloadedProductMetadata.has(region)) return
  188. const stripeClient = getRegionClient(region)
  189. const products = await stripeClient.stripe.products.list({
  190. active: true,
  191. limit: 100,
  192. })
  193. const results = new Map()
  194. for (const product of products.data) {
  195. results.set(product.id, product.metadata)
  196. }
  197. preloadedProductMetadata.set(region, results)
  198. }
  199. async function processMigration(input, commit) {
  200. const {
  201. recurly_account_code: accountCode,
  202. target_stripe_account: targetStripeAccount,
  203. stripe_customer_id: stripeCustomerId,
  204. } = input
  205. // Get Stripe client for the target account (strip 'stripe-' prefix if present)
  206. const region = targetStripeAccount.replace(/^stripe-/, '')
  207. const stripeClient = getRegionClient(region)
  208. // 1. Fetch Mongo subscription
  209. const mongoSubscription = await Subscription.findOne({
  210. admin_id: accountCode,
  211. }).exec()
  212. if (!mongoSubscription) {
  213. throw new ReportError(
  214. 'no-mongo-subscription',
  215. 'No subscription found in Mongo'
  216. )
  217. }
  218. // 2. Check if already migrated to Stripe
  219. if (mongoSubscription.paymentProvider?.service?.includes('stripe')) {
  220. throw new ReportError('already-stripe', 'Subscription already using Stripe')
  221. }
  222. // 3. Store previous state for output
  223. const previousRecurlyStatus = mongoSubscription.recurlyStatus
  224. ? JSON.stringify(mongoSubscription.recurlyStatus)
  225. : ''
  226. const previousRecurlySubscriptionId =
  227. mongoSubscription.recurlySubscription_id || ''
  228. // 4. Find Stripe subscription for this customer
  229. let stripeCustomer
  230. let stripeSubscription
  231. try {
  232. stripeCustomer = await stripeClient.getCustomerById(stripeCustomerId, [
  233. 'subscriptions',
  234. ])
  235. if (
  236. !stripeCustomer.subscriptions ||
  237. stripeCustomer.subscriptions.data.length === 0
  238. ) {
  239. throw new ReportError(
  240. 'no-stripe-subscription',
  241. 'No Stripe subscriptions found for customer'
  242. )
  243. }
  244. // find the subscription with migration metadata
  245. stripeSubscription = stripeCustomer.subscriptions.data.find(
  246. sub => sub.metadata?.recurly_to_stripe_migration_status === 'in_progress'
  247. )
  248. if (!stripeSubscription) {
  249. throw new ReportError(
  250. 'no-stripe-subscription',
  251. 'No target Stripe subscription found for customer'
  252. )
  253. }
  254. } catch (err) {
  255. if (err instanceof ReportError) throw err
  256. throw new ReportError(
  257. 'stripe-fetch-error',
  258. `Failed to fetch Stripe subscription: ${err.message}`
  259. )
  260. }
  261. // 5. Fetch Recurly subscription
  262. let recurlySubscription
  263. try {
  264. recurlySubscription = await RecurlyWrapper.promises.getSubscription(
  265. previousRecurlySubscriptionId,
  266. {}
  267. )
  268. } catch (err) {
  269. throw new ReportError(
  270. 'no-recurly-subscription',
  271. `Recurly subscription not found: ${err.message}`
  272. )
  273. }
  274. // 6. Detect changes between Recurly and Stripe
  275. const changes = detectChanges(recurlySubscription, stripeSubscription, region)
  276. if (changes.length > 0) {
  277. throw new ReportError(
  278. 'changes-detected',
  279. `Changes detected between Recurly and Stripe: ${changes.join('; ')}`
  280. )
  281. }
  282. // 7. If commit mode, perform migration
  283. const adminUserId = mongoSubscription.admin_id.toString()
  284. const analyticsId = await UserAnalyticsIdCache.get(adminUserId)
  285. const result = {
  286. status: 'not-migrated',
  287. note: 'Not yet migrated',
  288. previousRecurlyStatus,
  289. previousRecurlySubscriptionId,
  290. email: stripeCustomer.email,
  291. analyticsId,
  292. }
  293. if (commit) {
  294. try {
  295. await performCutover(
  296. mongoSubscription,
  297. stripeSubscription,
  298. recurlySubscription,
  299. stripeClient,
  300. stripeCustomer,
  301. analyticsId
  302. )
  303. } catch (err) {
  304. if (err instanceof ReportError && err.status?.startsWith('migrated-')) {
  305. result.status = err.status
  306. result.note = err.message
  307. return result
  308. }
  309. throw err
  310. }
  311. result.status = 'migrated'
  312. result.note = 'Successfully migrated to Stripe'
  313. return result
  314. } else {
  315. result.status = 'validated'
  316. result.note = 'DRY RUN: Ready to migrate'
  317. return result
  318. }
  319. }
  320. /**
  321. * Format subscription items for display in error messages
  322. */
  323. function formatItems(items) {
  324. return items
  325. .map(item => `${item.code}(qty:${item.quantity},amt:${item.amount})`)
  326. .join(', ')
  327. }
  328. function detectChanges(recurlySubscription, stripeSubscription, region) {
  329. const changes = []
  330. // Extract item details from Recurly subscription
  331. const recurlyPlanItem =
  332. PlansLocator.convertLegacyGroupPlanCodeToConsolidatedGroupPlanCodeIfNeeded(
  333. recurlySubscription.plan.plan_code
  334. )
  335. const simplifiedPlanCode = recurlyPlanItem.planCode.replace(
  336. /_free_trial.*$/,
  337. ''
  338. )
  339. const additionalLicenseQuantity =
  340. (recurlySubscription.subscription_add_ons || []).find(
  341. addOn => addOn.add_on_code === 'additional-license'
  342. )?.quantity || 0
  343. const recurlyItems = [
  344. {
  345. code: simplifiedPlanCode,
  346. quantity: recurlyPlanItem.quantity + additionalLicenseQuantity,
  347. amount:
  348. recurlySubscription.unit_amount_in_cents / recurlyPlanItem.quantity,
  349. },
  350. ...(recurlySubscription.subscription_add_ons || [])
  351. .filter(addOn => addOn.add_on_code !== 'additional-license')
  352. .map(addOn => ({
  353. code: addOn.add_on_code,
  354. quantity: addOn.quantity,
  355. amount: addOn.unit_amount_in_cents,
  356. })),
  357. ].sort((a, b) => a.code.localeCompare(b.code))
  358. // Extract item details from Stripe subscription
  359. const products = preloadedProductMetadata.get(region)
  360. const hasAddOns = stripeSubscription.items.data.length > 1
  361. const stripeItems = stripeSubscription.items.data
  362. .map(item => {
  363. const productMetadata = products.get(item.price.product)
  364. if (!productMetadata) {
  365. throw new ReportError(
  366. 'unknown-stripe-product',
  367. `Unknown Stripe product: ${item.price.product}`
  368. )
  369. }
  370. return {
  371. code:
  372. productMetadata?.planCode?.includes('assistant') && hasAddOns
  373. ? productMetadata?.addOnCode
  374. : productMetadata?.planCode,
  375. quantity: item.quantity,
  376. amount: item.price.unit_amount,
  377. }
  378. })
  379. .sort((a, b) => a.code.localeCompare(b.code))
  380. // Compare items
  381. if (!isEqual(recurlyItems, stripeItems)) {
  382. changes.push(
  383. `Items: Recurly=[${formatItems(recurlyItems)}], Stripe=[${formatItems(stripeItems)}]`
  384. )
  385. }
  386. // Compare states
  387. const recurlyState = recurlySubscription.state
  388. const stripeState = convertStripeStatusToSubscriptionState(stripeSubscription)
  389. if (recurlyState !== stripeState) {
  390. changes.push(`State: Recurly=${recurlyState}, Stripe=${stripeState}`)
  391. }
  392. // Verify no changes have been scheduled in Recurly
  393. if (recurlySubscription.pending_subscription != null) {
  394. changes.push('Pending change now exists in Recurly subscription')
  395. }
  396. return changes
  397. }
  398. async function performCutover(
  399. mongoSubscription,
  400. stripeSubscription,
  401. recurlySubscription,
  402. stripeClient,
  403. stripeCustomer,
  404. analyticsId
  405. ) {
  406. const adminUserId = mongoSubscription.admin_id.toString()
  407. // Step 1: Update Mongo subscription to point to Stripe
  408. mongoSubscription.paymentProvider = {
  409. service: stripeClient.serviceName,
  410. subscriptionId: stripeSubscription.id,
  411. state: convertStripeStatusToSubscriptionState(stripeSubscription),
  412. }
  413. mongoSubscription.recurlySubscription_id = undefined
  414. mongoSubscription.recurlyStatus = undefined
  415. try {
  416. await mongoSubscription.save()
  417. } catch (err) {
  418. throw new ReportError(
  419. 'not-migrated-mongo-update-failed',
  420. `Failed to update Mongo subscription: ${err.message}`
  421. )
  422. }
  423. // Step 2: Emit migration analytics event
  424. AnalyticsManager.recordEventForUserInBackground(
  425. adminUserId,
  426. 'subscription-migrated-to-stripe',
  427. {
  428. subscriptionId: mongoSubscription._id.toString(),
  429. migrationDirection: 'recurly-to-stripe',
  430. }
  431. )
  432. // Step 3: Postpone Recurly billing by +10 years if Recurly subscription is active
  433. if (recurlySubscription.state !== 'canceled') {
  434. const currentBillingDate = new Date(
  435. recurlySubscription.current_period_ends_at
  436. )
  437. const postponedDate = new Date(currentBillingDate)
  438. postponedDate.setFullYear(currentBillingDate.getFullYear() + 10)
  439. try {
  440. await RecurlyWrapper.promises.apiRequest({
  441. url: `subscriptions/${recurlySubscription.uuid}/postpone`,
  442. qs: { bulk: true, next_bill_date: postponedDate },
  443. method: 'PUT',
  444. })
  445. } catch (err) {
  446. throw new ReportError(
  447. 'migrated-recurly-postpone-failed',
  448. `Failed to postpone Recurly billing: ${err.message}`
  449. )
  450. }
  451. }
  452. // Step 4: Remove migration metadata from Stripe
  453. try {
  454. await stripeClient.updateSubscriptionMetadata(stripeSubscription.id, {
  455. recurly_to_stripe_migration_status: '',
  456. })
  457. } catch (err) {
  458. throw new ReportError(
  459. 'migrated-metadata-removal-failed',
  460. `Successfully migrated to Stripe but failed to remove metadata: ${err.message}`
  461. )
  462. }
  463. // Step 5: Register analytics mapping
  464. try {
  465. AnalyticsManager.registerAccountMapping(
  466. AccountMappingHelper.generateSubscriptionToStripeMapping(
  467. mongoSubscription._id,
  468. stripeSubscription.id,
  469. stripeClient.serviceName
  470. )
  471. )
  472. } catch (err) {
  473. throw new ReportError(
  474. 'migrated-analytics-mapping-failed',
  475. `Successfully migrated to Stripe but failed to register analytics mapping: ${err.message}`
  476. )
  477. }
  478. // Step 6. Remap customer metadata (if needed) in Stripe
  479. if (
  480. stripeCustomer.metadata != null &&
  481. stripeCustomer.metadata.recurlyAccountCode != null &&
  482. stripeCustomer.metadata.userId == null
  483. ) {
  484. try {
  485. await stripeClient.updateCustomerMetadata(stripeCustomer.id, {
  486. recurlyAccountCode: '',
  487. userId: adminUserId,
  488. })
  489. } catch (err) {
  490. throw new ReportError(
  491. 'migrated-customer-metadata-removal-failed',
  492. `Successfully migrated to Stripe and registered analytics mapping but failed to remove customer metadata: ${err.message}`
  493. )
  494. }
  495. }
  496. // Step 7. Send data to customer.io
  497. if (analyticsId) {
  498. try {
  499. const migrationDate = new Date().toISOString().slice(0, 10)
  500. const needsToUpdateTaxInfo =
  501. (stripeCustomer.metadata?.taxInfoPending || '').length > 0
  502. // TODO: request Recurly account and billingInfo to verify if tax info in Stripe is up to date
  503. CustomerIoHandler.updateUserAttributes(analyticsId, {
  504. email: stripeCustomer.email,
  505. stripe_migration: {
  506. migration_date: migrationDate,
  507. needs_to_update_tax_id: needsToUpdateTaxInfo,
  508. },
  509. })
  510. } catch (err) {
  511. throw new ReportError(
  512. 'migrated-customerio-upload-failed',
  513. `Successfully migrated to Stripe but failed to upload user to customer.io: ${err.message}`
  514. )
  515. }
  516. }
  517. }
  518. function parseArgs() {
  519. const args = minimist(process.argv.slice(2), {
  520. string: ['output'],
  521. number: ['throttle'],
  522. boolean: ['commit', 'help'],
  523. default: { commit: false, throttle: DEFAULT_THROTTLE },
  524. })
  525. if (args.help) {
  526. usage()
  527. process.exit(0)
  528. }
  529. const inputFile = args._[0]
  530. const paramsSchema = z.object({
  531. output: z.string().optional(),
  532. commit: z.boolean(),
  533. throttle: z.number().int().positive(),
  534. inputFile: z.string().optional(),
  535. })
  536. try {
  537. return paramsSchema.parse({
  538. output: args.output,
  539. commit: args.commit,
  540. throttle: args.throttle,
  541. inputFile,
  542. })
  543. } catch (err) {
  544. console.error('Invalid arguments:', err.message)
  545. usage()
  546. process.exit(1)
  547. }
  548. }
  549. try {
  550. await scriptRunner(main)
  551. process.exit(0)
  552. } catch (error) {
  553. console.error(error)
  554. process.exit(1)
  555. }