rollback-finalized-stripe-migration.mjs 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512
  1. #!/usr/bin/env node
  2. /**
  3. * This script rolls back the cutover of a subscription from Recurly to Stripe.
  4. *
  5. * IMPORTANT: This script does NOT cancel the Stripe subscription.
  6. * Use scripts/stripe/bulk-cancel-subscriptions.mjs to cancel them separately.
  7. *
  8. * It undoes everything done by finalize-stripe-subscription-migration.mjs
  9. *
  10. * Usage:
  11. * node scripts/stripe/rollback-finalized-stripe-migration.mjs [OPTS] [INPUT-FILE]
  12. *
  13. * Options:
  14. * --output PATH Output file path (default: /tmp/rollback_output_<timestamp>.csv)
  15. * --commit Apply changes (without this, runs in dry-run mode)
  16. * --throttle DURATION Minimum time between requests in ms (default: 40)
  17. * --help Show help message
  18. *
  19. * CSV Input Format:
  20. * recurly_account_code,target_stripe_account,stripe_customer_id
  21. * 507f1f77bcf86cd799439011,stripe-uk,cus_1234567890abcdef
  22. *
  23. * CSV Output Format:
  24. * recurly_account_code,target_stripe_account,stripe_customer_id,status,note
  25. *
  26. * Note: recurly_account_code is the Overleaf user ID (admin_id)
  27. */
  28. import fs from 'node:fs'
  29. import path from 'node:path'
  30. import * as csv from 'csv'
  31. import minimist from 'minimist'
  32. import PQueue from 'p-queue'
  33. import { z } from '../../app/src/infrastructure/Validation.mjs'
  34. import { scriptRunner } from '../lib/ScriptRunner.mjs'
  35. import { getRegionClient } from '../../modules/subscriptions/app/src/StripeClient.mjs'
  36. import RecurlyWrapper from '../../app/src/Features/Subscription/RecurlyWrapper.mjs'
  37. import { Subscription } from '../../app/src/models/Subscription.mjs'
  38. import AnalyticsManager from '../../app/src/Features/Analytics/AnalyticsManager.mjs'
  39. import CustomerIoHandler from '../../modules/customer-io/app/src/CustomerIoHandler.mjs'
  40. import { ReportError } from './helpers.mjs'
  41. import AccountMappingHelper from '../../app/src/Features/Analytics/AccountMappingHelper.mjs'
  42. import {
  43. createRateLimitedApiWrappers,
  44. DEFAULT_RECURLY_RATE_LIMIT,
  45. DEFAULT_STRIPE_RATE_LIMIT,
  46. DEFAULT_RECURLY_API_RETRIES,
  47. DEFAULT_RECURLY_RETRY_DELAY_MS,
  48. DEFAULT_STRIPE_API_RETRIES,
  49. DEFAULT_STRIPE_RETRY_DELAY_MS,
  50. } from './RateLimiter.mjs'
  51. // rate limiters - initialized in main()
  52. let rateLimiters
  53. function usage() {
  54. console.error(`Usage: node scripts/stripe/rollback-finalized-stripe-migration.mjs [OPTS] [INPUT-FILE]
  55. Options:
  56. --output PATH Output file path (default: /tmp/rollback_output_<timestamp>.csv)
  57. --commit Apply changes (without this, runs in dry-run mode)
  58. --concurrency N Number of rollbacks to process concurrently (default: 10)
  59. --recurly-rate-limit N Requests per second for Recurly (default: ${DEFAULT_RECURLY_RATE_LIMIT})
  60. --recurly-api-retries N Number of retries on Recurly 429s (default: ${DEFAULT_RECURLY_API_RETRIES})
  61. --recurly-retry-delay-ms N Delay between Recurly retries in ms (default: ${DEFAULT_RECURLY_RETRY_DELAY_MS})
  62. --stripe-rate-limit N Requests per second for Stripe (default: ${DEFAULT_STRIPE_RATE_LIMIT})
  63. --stripe-api-retries N Number of retries on Stripe 429s (default: ${DEFAULT_STRIPE_API_RETRIES})
  64. --stripe-retry-delay-ms N Delay between Stripe retries in ms (default: ${DEFAULT_STRIPE_RETRY_DELAY_MS})
  65. --help Show this help message
  66. Note: This script does NOT cancel Stripe subscriptions. Use scripts/stripe/bulk-cancel-subscriptions.mjs separately.
  67. `)
  68. }
  69. async function main(trackProgress) {
  70. const opts = parseArgs()
  71. const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
  72. const outputFile = opts.output ?? `/tmp/rollback_output_${timestamp}.csv`
  73. // initialize rate limiters
  74. rateLimiters = createRateLimitedApiWrappers({
  75. recurlyRateLimit: opts.recurlyRateLimit,
  76. recurlyApiRetries: opts.recurlyApiRetries,
  77. recurlyRetryDelayMs: opts.recurlyRetryDelayMs,
  78. stripeRateLimit: opts.stripeRateLimit,
  79. stripeApiRetries: opts.stripeApiRetries,
  80. stripeRetryDelayMs: opts.stripeRetryDelayMs,
  81. })
  82. await trackProgress('Starting Stripe to Recurly rollback')
  83. await trackProgress(`Run mode: ${opts.commit ? 'COMMIT' : 'DRY RUN'}`)
  84. await trackProgress(
  85. 'Note: Stripe subscriptions are NOT cancelled by this script'
  86. )
  87. await trackProgress(
  88. `Rate limits: Recurly ${opts.recurlyRateLimit}/s, Stripe ${opts.stripeRateLimit}/s`
  89. )
  90. await trackProgress(`Concurrency: ${opts.concurrency}`)
  91. const inputStream = opts.inputFile
  92. ? fs.createReadStream(opts.inputFile)
  93. : process.stdin
  94. const csvReader = getCsvReader(inputStream)
  95. const csvWriter = getCsvWriter(outputFile)
  96. await trackProgress(`Output: ${outputFile}`)
  97. let processedCount = 0
  98. let successCount = 0
  99. let errorCount = 0
  100. const queue = new PQueue({ concurrency: opts.concurrency })
  101. const maxQueueSize = opts.concurrency
  102. try {
  103. for await (const input of csvReader) {
  104. // throttle input if queue is full
  105. if (queue.size >= maxQueueSize) {
  106. await queue.onSizeLessThan(maxQueueSize)
  107. }
  108. queue.add(async () => {
  109. try {
  110. const result = await processRollback(input, opts.commit)
  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. status: result.status,
  116. note: result.note,
  117. })
  118. if (
  119. result.status === 'rolled-back' ||
  120. result.status === 'validated' ||
  121. result.status === 'already-recurly'
  122. ) {
  123. successCount++
  124. } else {
  125. errorCount++
  126. }
  127. } catch (err) {
  128. errorCount++
  129. if (err instanceof ReportError) {
  130. csvWriter.write({
  131. recurly_account_code: input.recurly_account_code,
  132. target_stripe_account: input.target_stripe_account,
  133. stripe_customer_id: input.stripe_customer_id,
  134. status: err.status,
  135. note: err.message,
  136. })
  137. } else {
  138. csvWriter.write({
  139. recurly_account_code: input.recurly_account_code,
  140. target_stripe_account: input.target_stripe_account,
  141. stripe_customer_id: input.stripe_customer_id,
  142. status: 'error',
  143. note: err.message,
  144. })
  145. }
  146. }
  147. processedCount++
  148. if (processedCount % 25 === 0) {
  149. await trackProgress(
  150. `Progress: ${processedCount} processed, ${successCount} successful, ${errorCount} errors`
  151. )
  152. }
  153. })
  154. }
  155. } finally {
  156. // wait for all queued tasks to complete
  157. await queue.onIdle()
  158. }
  159. await trackProgress(`✅ Total processed: ${processedCount}`)
  160. if (opts.commit) {
  161. await trackProgress(`✅ Successfully rolled back: ${successCount}`)
  162. } else {
  163. await trackProgress(`✅ Successfully validated: ${successCount}`)
  164. await trackProgress('ℹ️ DRY RUN: No changes were applied')
  165. }
  166. await trackProgress(`❌ Errors: ${errorCount}`)
  167. await trackProgress('🎉 Script completed!')
  168. csvWriter.end()
  169. await CustomerIoHandler.closeCustomerIo()
  170. }
  171. function getCsvReader(inputStream) {
  172. const parser = csv.parse({ columns: true })
  173. inputStream.pipe(parser)
  174. return parser
  175. }
  176. function getCsvWriter(outputFile) {
  177. fs.mkdirSync(path.dirname(outputFile), { recursive: true })
  178. const outputStream = fs.createWriteStream(outputFile)
  179. const writer = csv.stringify({
  180. columns: [
  181. 'recurly_account_code',
  182. 'target_stripe_account',
  183. 'stripe_customer_id',
  184. 'status',
  185. 'note',
  186. ],
  187. header: true,
  188. })
  189. writer.on('error', err => {
  190. console.error(err)
  191. process.exit(1)
  192. })
  193. writer.pipe(outputStream)
  194. return writer
  195. }
  196. async function processRollback(input, commit) {
  197. const {
  198. recurly_account_code: accountCode,
  199. target_stripe_account: targetStripeAccount,
  200. } = input
  201. // Get Stripe client for the target account (strip 'stripe-' prefix if present)
  202. const region = targetStripeAccount.replace(/^stripe-/, '')
  203. const stripeClient = getRegionClient(region)
  204. // 1. Fetch Mongo subscription
  205. const mongoSubscription = await Subscription.findOne({
  206. admin_id: accountCode,
  207. }).exec()
  208. if (!mongoSubscription) {
  209. throw new ReportError(
  210. 'no-mongo-subscription',
  211. 'No subscription found in Mongo'
  212. )
  213. }
  214. // 2. Check if already using Recurly
  215. if (
  216. mongoSubscription.recurlySubscription_id &&
  217. !mongoSubscription.paymentProvider?.service?.includes('stripe')
  218. ) {
  219. throw new ReportError(
  220. 'already-recurly',
  221. 'Subscription already using Recurly'
  222. )
  223. }
  224. // 3. Verify subscription is using Stripe
  225. if (!mongoSubscription.paymentProvider?.service?.includes('stripe')) {
  226. throw new ReportError(
  227. 'not-using-stripe',
  228. 'Subscription is not using Stripe'
  229. )
  230. }
  231. const stripeSubscriptionId = mongoSubscription.paymentProvider.subscriptionId
  232. // 4. Find Recurly subscription ID from Stripe metadata
  233. let recurlySubscriptionId
  234. try {
  235. const stripeSubData = await rateLimiters.requestWithRetries(
  236. stripeClient.serviceName,
  237. () => stripeClient.stripe.subscriptions.retrieve(stripeSubscriptionId),
  238. {
  239. operation: 'subscriptions.retrieve',
  240. stripeSubscriptionId,
  241. region: stripeClient.serviceName,
  242. }
  243. )
  244. recurlySubscriptionId = stripeSubData.metadata?.recurly_subscription_id
  245. if (!recurlySubscriptionId) {
  246. throw new ReportError(
  247. 'no-recurly-id-in-metadata',
  248. 'No recurly_subscription_id found in Stripe metadata'
  249. )
  250. }
  251. } catch (err) {
  252. if (err instanceof ReportError) throw err
  253. throw new ReportError(
  254. 'stripe-fetch-error',
  255. `Failed to fetch Stripe subscription: ${err.message}`
  256. )
  257. }
  258. // 5. Fetch Recurly subscription to get original billing date
  259. let recurlySubscription
  260. try {
  261. recurlySubscription = await rateLimiters.requestWithRetries(
  262. 'recurly',
  263. () => RecurlyWrapper.promises.getSubscription(recurlySubscriptionId, {}),
  264. {
  265. operation: 'getSubscription',
  266. recurlySubscriptionId,
  267. }
  268. )
  269. } catch (err) {
  270. throw new ReportError(
  271. 'no-recurly-subscription',
  272. `Recurly subscription not found: ${err.message}`
  273. )
  274. }
  275. // 6. If commit mode, perform rollback
  276. if (commit) {
  277. await performRollback(mongoSubscription, recurlySubscription, stripeClient)
  278. return {
  279. status: 'rolled-back',
  280. note: 'Successfully rolled back to Recurly',
  281. }
  282. } else {
  283. return {
  284. status: 'validated',
  285. note: 'DRY RUN: Ready to rollback to Recurly',
  286. }
  287. }
  288. }
  289. async function performRollback(
  290. mongoSubscription,
  291. recurlySubscription,
  292. stripeClient
  293. ) {
  294. const adminUserId = mongoSubscription.admin_id.toString()
  295. const recurlySubscriptionId = recurlySubscription.uuid
  296. const stripeSubscriptionId = mongoSubscription.paymentProvider.subscriptionId
  297. // Step 1: Restore Recurly fields in Mongo
  298. mongoSubscription.recurlySubscription_id = recurlySubscriptionId
  299. mongoSubscription.recurlyStatus = {
  300. state: recurlySubscription.state,
  301. trialStartedAt: recurlySubscription.trial_started_at,
  302. trialEndsAt: recurlySubscription.trial_ends_at,
  303. }
  304. mongoSubscription.paymentProvider = undefined
  305. await mongoSubscription.save()
  306. // Step 2: Emit rollback analytics event
  307. AnalyticsManager.recordEventForUserInBackground(
  308. adminUserId,
  309. 'subscription-rolled-back-from-stripe',
  310. {
  311. subscriptionId: mongoSubscription._id.toString(),
  312. migrationDirection: 'stripe-to-recurly',
  313. }
  314. )
  315. // Step 3: Un-postpone Recurly billing by 10 years if next billing period was postponed
  316. const currentPeriodEnd = new Date(recurlySubscription.current_period_ends_at)
  317. const nineYearsFromNow = new Date()
  318. nineYearsFromNow.setFullYear(new Date().getFullYear() + 9)
  319. if (currentPeriodEnd > nineYearsFromNow) {
  320. const nextBillingDate = new Date(currentPeriodEnd)
  321. nextBillingDate.setFullYear(currentPeriodEnd.getFullYear() - 10)
  322. const targetBillingDateIsInFuture = nextBillingDate.getTime() > Date.now()
  323. if (targetBillingDateIsInFuture) {
  324. try {
  325. await rateLimiters.requestWithRetries(
  326. 'recurly',
  327. () =>
  328. RecurlyWrapper.promises.apiRequest({
  329. url: `subscriptions/${recurlySubscriptionId}/postpone`,
  330. qs: { bulk: true, next_bill_date: nextBillingDate },
  331. method: 'PUT',
  332. }),
  333. {
  334. operation: 'postpone',
  335. recurlySubscriptionId,
  336. }
  337. )
  338. } catch (err) {
  339. throw new ReportError(
  340. 'rolled-back-recurly-restore-failed',
  341. `Restored Mongo but failed to restore Recurly billing: ${err.message}`
  342. )
  343. }
  344. } else {
  345. throw new ReportError(
  346. 'rolled-back-recurly-restore-failed',
  347. `Restored Mongo and Recurly but failed to restore Recurly billing: target next billing date is in the past (${nextBillingDate.toISOString()})`
  348. )
  349. }
  350. }
  351. // Step 4: Restore migration metadata to Stripe
  352. try {
  353. await rateLimiters.requestWithRetries(
  354. stripeClient.serviceName,
  355. () =>
  356. stripeClient.updateSubscriptionMetadata(stripeSubscriptionId, {
  357. recurly_to_stripe_migration_status: 'in_progress',
  358. }),
  359. {
  360. operation: 'updateSubscriptionMetadata',
  361. stripeSubscriptionId,
  362. region: stripeClient.serviceName,
  363. }
  364. )
  365. } catch (err) {
  366. throw new ReportError(
  367. 'rolled-back-metadata-restore-failed',
  368. `Restored Mongo and Recurly but failed to restore Stripe metadata: ${err.message}`
  369. )
  370. }
  371. // Step 5: Register analytics mapping for the Recurly subscription
  372. try {
  373. AnalyticsManager.registerAccountMapping(
  374. AccountMappingHelper.generateSubscriptionToRecurlyMapping(
  375. mongoSubscription._id,
  376. recurlySubscriptionId,
  377. 'recurly'
  378. )
  379. )
  380. } catch (err) {
  381. throw new ReportError(
  382. 'rolled-back-analytics-mapping-failed',
  383. `Restored Mongo, Recurly, Stripe but failed to register analytics mapping: ${err.message}`
  384. )
  385. }
  386. // Step 5: Remove migration date from customer.io
  387. try {
  388. CustomerIoHandler.updateUserAttributes(adminUserId, {
  389. stripe_migration: {},
  390. })
  391. } catch (err) {
  392. throw new ReportError(
  393. 'rolled-back-customerio-update-failed',
  394. `Restored Mongo, Recurly, Stripe but failed to update user in customer.io: ${err.message}`
  395. )
  396. }
  397. }
  398. function parseArgs() {
  399. const args = minimist(process.argv.slice(2), {
  400. string: [
  401. 'output',
  402. 'concurrency',
  403. 'recurly-rate-limit',
  404. 'recurly-api-retries',
  405. 'recurly-retry-delay-ms',
  406. 'stripe-rate-limit',
  407. 'stripe-api-retries',
  408. 'stripe-retry-delay-ms',
  409. ],
  410. boolean: ['commit', 'help'],
  411. default: {
  412. commit: false,
  413. concurrency: 10,
  414. 'recurly-rate-limit': DEFAULT_RECURLY_RATE_LIMIT,
  415. 'recurly-api-retries': DEFAULT_RECURLY_API_RETRIES,
  416. 'recurly-retry-delay-ms': DEFAULT_RECURLY_RETRY_DELAY_MS,
  417. 'stripe-rate-limit': DEFAULT_STRIPE_RATE_LIMIT,
  418. 'stripe-api-retries': DEFAULT_STRIPE_API_RETRIES,
  419. 'stripe-retry-delay-ms': DEFAULT_STRIPE_RETRY_DELAY_MS,
  420. },
  421. })
  422. if (args.help) {
  423. usage()
  424. process.exit(0)
  425. }
  426. const inputFile = args._[0]
  427. const paramsSchema = z.object({
  428. output: z.string().optional(),
  429. commit: z.boolean(),
  430. concurrency: z.number().int().positive(),
  431. recurlyRateLimit: z.number().positive(),
  432. recurlyApiRetries: z.number().int().nonnegative(),
  433. recurlyRetryDelayMs: z.number().int().nonnegative(),
  434. stripeRateLimit: z.number().positive(),
  435. stripeApiRetries: z.number().int().nonnegative(),
  436. stripeRetryDelayMs: z.number().int().nonnegative(),
  437. inputFile: z.string().optional(),
  438. })
  439. try {
  440. return paramsSchema.parse({
  441. output: args.output,
  442. commit: args.commit,
  443. concurrency: Number(args.concurrency),
  444. recurlyRateLimit: Number(args['recurly-rate-limit']),
  445. recurlyApiRetries: Number(args['recurly-api-retries']),
  446. recurlyRetryDelayMs: Number(args['recurly-retry-delay-ms']),
  447. stripeRateLimit: Number(args['stripe-rate-limit']),
  448. stripeApiRetries: Number(args['stripe-api-retries']),
  449. stripeRetryDelayMs: Number(args['stripe-retry-delay-ms']),
  450. inputFile,
  451. })
  452. } catch (err) {
  453. console.error('Invalid arguments:', err.message)
  454. usage()
  455. process.exit(1)
  456. }
  457. }
  458. try {
  459. await scriptRunner(main)
  460. process.exit(0)
  461. } catch (error) {
  462. console.error(error)
  463. process.exit(1)
  464. }