finalize-stripe-subscription-migration.mjs 16 KB

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