finalize-stripe-subscription-migration.mjs 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519
  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. )
  281. return {
  282. status: 'migrated',
  283. note: 'Successfully migrated to Stripe',
  284. previousRecurlyStatus,
  285. previousRecurlySubscriptionId,
  286. email: stripeCustomer.email,
  287. }
  288. } else {
  289. return {
  290. status: 'validated',
  291. note: 'DRY RUN: Ready to migrate',
  292. previousRecurlyStatus,
  293. previousRecurlySubscriptionId,
  294. email: stripeCustomer.email,
  295. }
  296. }
  297. }
  298. // TODO: add other plan codes as needed
  299. const RECURLY_PLAN_CODE_TO_STRIPE_PLAN_CODE = {
  300. student_free_trial_7_days: 'student',
  301. collaborator_free_trial_7_days: 'collaborator',
  302. student: 'student',
  303. collaborator: 'collaborator',
  304. 'collaborator-annual': 'collaborator-annual',
  305. 'collaborator-annual_free_trial_7_days': 'collaborator-annual',
  306. professional_free_trial_7_days: 'professional',
  307. professional: 'professional',
  308. 'professional-annual': 'professional-annual',
  309. 'student-annual': 'student-annual',
  310. }
  311. function detectChanges(recurlySubscription, stripeSubscription, region) {
  312. const changes = []
  313. // Extract item codes from Recurly subscription (excluding additional-license
  314. // add-on, which is not a separate add-on in Stripe)
  315. const planCode = recurlySubscription.plan.plan_code
  316. const recurlyItemCodes = JSON.stringify(
  317. [
  318. RECURLY_PLAN_CODE_TO_STRIPE_PLAN_CODE[planCode] || planCode,
  319. ...(recurlySubscription.subscription_add_ons || [])
  320. .filter(addOn => addOn.add_on_code !== 'additional-license')
  321. .map(addOn => addOn.add_on_code),
  322. ].sort()
  323. )
  324. // Extract item codes from Stripe subscription
  325. const cache = preloadedProductMetadata.get(region)
  326. const stripeItemCodes = JSON.stringify(
  327. stripeSubscription.items.data
  328. .map(item => {
  329. const productMetadata = cache.get(item.price.product)
  330. return productMetadata?.planCode || productMetadata?.addOnCode || null
  331. })
  332. .filter(code => code !== null)
  333. .sort()
  334. )
  335. // Compare item codes
  336. if (recurlyItemCodes !== stripeItemCodes) {
  337. changes.push(
  338. `Items: Recurly=[${recurlyItemCodes}], Stripe=[${stripeItemCodes}]`
  339. )
  340. }
  341. // TODO: compare quantities for each item, taking additional-license add-ons into account
  342. // Compare states
  343. const recurlyState = recurlySubscription.state
  344. const stripeState = convertStripeStatusToSubscriptionState(stripeSubscription)
  345. if (recurlyState !== stripeState) {
  346. changes.push(`State: Recurly=${recurlyState}, Stripe=${stripeState}`)
  347. }
  348. // Verify no changes have been scheduled in Recurly
  349. if (recurlySubscription.pending_subscription != null) {
  350. changes.push('Pending change now exists in Recurly subscription')
  351. }
  352. return changes
  353. }
  354. async function performCutover(
  355. mongoSubscription,
  356. stripeSubscription,
  357. recurlySubscription,
  358. stripeClient
  359. ) {
  360. const adminUserId = mongoSubscription.admin_id.toString()
  361. // Step 1: Update Mongo subscription to point to Stripe
  362. mongoSubscription.paymentProvider = {
  363. service: stripeClient.serviceName,
  364. subscriptionId: stripeSubscription.id,
  365. state: convertStripeStatusToSubscriptionState(stripeSubscription),
  366. }
  367. mongoSubscription.recurlySubscription_id = undefined
  368. mongoSubscription.recurlyStatus = undefined
  369. await mongoSubscription.save()
  370. // Step 2: Emit migration analytics event
  371. AnalyticsManager.recordEventForUserInBackground(
  372. adminUserId,
  373. 'subscription-migrated-to-stripe',
  374. {
  375. subscriptionId: mongoSubscription._id.toString(),
  376. migrationDirection: 'recurly-to-stripe',
  377. }
  378. )
  379. // Step 3: Postpone Recurly billing by +10 years
  380. const currentBillingDate = new Date(
  381. recurlySubscription.current_period_ends_at
  382. )
  383. const postponedDate = new Date(currentBillingDate)
  384. postponedDate.setFullYear(currentBillingDate.getFullYear() + 10)
  385. try {
  386. await RecurlyWrapper.promises.apiRequest({
  387. url: `subscriptions/${recurlySubscription.uuid}/postpone`,
  388. qs: { bulk: true, next_bill_date: postponedDate },
  389. method: 'PUT',
  390. })
  391. } catch (err) {
  392. throw new Error(`Failed to postpone Recurly billing: ${err.message}`)
  393. }
  394. // Step 4: Remove migration metadata from Stripe
  395. try {
  396. await stripeClient.updateSubscriptionMetadata(stripeSubscription.id, {
  397. recurly_to_stripe_migration_status: '',
  398. })
  399. } catch (err) {
  400. throw new ReportError(
  401. 'migrated-metadata-removal-failed',
  402. `Successfully migrated to Stripe but failed to remove metadata: ${err.message}`
  403. )
  404. }
  405. // Step 5: Register analytics mapping
  406. try {
  407. AnalyticsManager.registerAccountMapping(
  408. AccountMappingHelper.generateSubscriptionToStripeMapping(
  409. mongoSubscription._id,
  410. stripeSubscription.id,
  411. stripeSubscription.service
  412. )
  413. )
  414. } catch (err) {
  415. throw new ReportError(
  416. 'analytics-mapping-failed',
  417. `Successfully migrated to Stripe but failed to register analytics mapping: ${err.message}`
  418. )
  419. }
  420. }
  421. function parseArgs() {
  422. const args = minimist(process.argv.slice(2), {
  423. string: ['output'],
  424. number: ['throttle'],
  425. boolean: ['commit', 'help'],
  426. default: { commit: false, throttle: DEFAULT_THROTTLE },
  427. })
  428. if (args.help) {
  429. usage()
  430. process.exit(0)
  431. }
  432. const inputFile = args._[0]
  433. const paramsSchema = z.object({
  434. output: z.string().optional(),
  435. commit: z.boolean(),
  436. throttle: z.number().int().positive(),
  437. inputFile: z.string().optional(),
  438. })
  439. try {
  440. return paramsSchema.parse({
  441. output: args.output,
  442. commit: args.commit,
  443. throttle: args.throttle,
  444. inputFile,
  445. })
  446. } catch (err) {
  447. console.error('Invalid arguments:', err.message)
  448. usage()
  449. process.exit(1)
  450. }
  451. }
  452. try {
  453. await scriptRunner(main)
  454. process.exit(0)
  455. } catch (error) {
  456. console.error(error)
  457. process.exit(1)
  458. }