rollback-finalized-stripe-migration.mjs 16 KB

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