rollback-finalized-stripe-migration.mjs 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426
  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 { setTimeout } from 'node:timers/promises'
  31. import * as csv from 'csv'
  32. import minimist from 'minimist'
  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. const DEFAULT_THROTTLE = 40
  44. function usage() {
  45. console.error(`Usage: node scripts/stripe/rollback-finalized-stripe-migration.mjs [OPTS] [INPUT-FILE]
  46. Options:
  47. --output PATH Output file path (default: /tmp/rollback_output_<timestamp>.csv)
  48. --commit Apply changes (without this, runs in dry-run mode)
  49. --throttle DURATION Minimum time between requests in ms (default: ${DEFAULT_THROTTLE})
  50. --help Show this help message
  51. Note: This script does NOT cancel Stripe subscriptions. Use scripts/stripe/bulk-cancel-subscriptions.mjs separately.
  52. `)
  53. }
  54. async function main(trackProgress) {
  55. const opts = parseArgs()
  56. const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
  57. const outputFile = opts.output ?? `/tmp/rollback_output_${timestamp}.csv`
  58. await trackProgress('Starting Stripe to Recurly rollback')
  59. await trackProgress(`Run mode: ${opts.commit ? 'COMMIT' : 'DRY RUN'}`)
  60. await trackProgress(
  61. 'Note: Stripe subscriptions are NOT cancelled by this script'
  62. )
  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(`Output: ${outputFile}`)
  70. let processedCount = 0
  71. let successCount = 0
  72. let errorCount = 0
  73. let lastLoopTimestamp = 0
  74. for await (const input of csvReader) {
  75. const timeSinceLastLoop = Date.now() - lastLoopTimestamp
  76. if (timeSinceLastLoop < opts.throttle) {
  77. await setTimeout(opts.throttle - timeSinceLastLoop)
  78. }
  79. lastLoopTimestamp = Date.now()
  80. processedCount++
  81. try {
  82. const result = await processRollback(input, opts.commit)
  83. csvWriter.write({
  84. recurly_account_code: input.recurly_account_code,
  85. target_stripe_account: input.target_stripe_account,
  86. stripe_customer_id: input.stripe_customer_id,
  87. status: result.status,
  88. note: result.note,
  89. })
  90. if (
  91. result.status === 'rolled-back' ||
  92. result.status === 'validated' ||
  93. result.status === 'already-recurly'
  94. ) {
  95. successCount++
  96. } else {
  97. errorCount++
  98. }
  99. if (processedCount % 25 === 0) {
  100. await trackProgress(
  101. `Progress: ${processedCount} processed, ${successCount} successful, ${errorCount} errors`
  102. )
  103. }
  104. } catch (err) {
  105. errorCount++
  106. if (err instanceof ReportError) {
  107. csvWriter.write({
  108. recurly_account_code: input.recurly_account_code,
  109. target_stripe_account: input.target_stripe_account,
  110. stripe_customer_id: input.stripe_customer_id,
  111. status: err.status,
  112. note: err.message,
  113. })
  114. } else {
  115. csvWriter.write({
  116. recurly_account_code: input.recurly_account_code,
  117. target_stripe_account: input.target_stripe_account,
  118. stripe_customer_id: input.stripe_customer_id,
  119. status: 'error',
  120. note: err.message,
  121. })
  122. }
  123. }
  124. }
  125. await trackProgress(`✅ Total processed: ${processedCount}`)
  126. if (opts.commit) {
  127. await trackProgress(`✅ Successfully rolled back: ${successCount}`)
  128. } else {
  129. await trackProgress(`✅ Successfully validated: ${successCount}`)
  130. await trackProgress('ℹ️ DRY RUN: No changes were applied')
  131. }
  132. await trackProgress(`❌ Errors: ${errorCount}`)
  133. await trackProgress('🎉 Script completed!')
  134. csvWriter.end()
  135. await CustomerIoHandler.closeCustomerIo()
  136. }
  137. function getCsvReader(inputStream) {
  138. const parser = csv.parse({ columns: true })
  139. inputStream.pipe(parser)
  140. return parser
  141. }
  142. function getCsvWriter(outputFile) {
  143. fs.mkdirSync(path.dirname(outputFile), { recursive: true })
  144. const outputStream = fs.createWriteStream(outputFile)
  145. const writer = csv.stringify({
  146. columns: [
  147. 'recurly_account_code',
  148. 'target_stripe_account',
  149. 'stripe_customer_id',
  150. 'status',
  151. 'note',
  152. ],
  153. header: true,
  154. })
  155. writer.on('error', err => {
  156. console.error(err)
  157. process.exit(1)
  158. })
  159. writer.pipe(outputStream)
  160. return writer
  161. }
  162. async function processRollback(input, commit) {
  163. const {
  164. recurly_account_code: accountCode,
  165. target_stripe_account: targetStripeAccount,
  166. } = input
  167. // Get Stripe client for the target account (strip 'stripe-' prefix if present)
  168. const region = targetStripeAccount.replace(/^stripe-/, '')
  169. const stripeClient = getRegionClient(region)
  170. // 1. Fetch Mongo subscription
  171. const mongoSubscription = await Subscription.findOne({
  172. admin_id: accountCode,
  173. }).exec()
  174. if (!mongoSubscription) {
  175. throw new ReportError(
  176. 'no-mongo-subscription',
  177. 'No subscription found in Mongo'
  178. )
  179. }
  180. // 2. Check if already using Recurly
  181. if (
  182. mongoSubscription.recurlySubscription_id &&
  183. !mongoSubscription.paymentProvider?.service?.includes('stripe')
  184. ) {
  185. throw new ReportError(
  186. 'already-recurly',
  187. 'Subscription already using Recurly'
  188. )
  189. }
  190. // 3. Verify subscription is using Stripe
  191. if (!mongoSubscription.paymentProvider?.service?.includes('stripe')) {
  192. throw new ReportError(
  193. 'not-using-stripe',
  194. 'Subscription is not using Stripe'
  195. )
  196. }
  197. const stripeSubscriptionId = mongoSubscription.paymentProvider.subscriptionId
  198. // 4. Find Recurly subscription ID from Stripe metadata
  199. let recurlySubscriptionId
  200. try {
  201. const stripeSubData =
  202. await stripeClient.stripe.subscriptions.retrieve(stripeSubscriptionId)
  203. recurlySubscriptionId = stripeSubData.metadata?.recurly_subscription_id
  204. if (!recurlySubscriptionId) {
  205. throw new ReportError(
  206. 'no-recurly-id-in-metadata',
  207. 'No recurly_subscription_id found in Stripe metadata'
  208. )
  209. }
  210. } catch (err) {
  211. if (err instanceof ReportError) throw err
  212. throw new ReportError(
  213. 'stripe-fetch-error',
  214. `Failed to fetch Stripe subscription: ${err.message}`
  215. )
  216. }
  217. // 5. Fetch Recurly subscription to get original billing date
  218. let recurlySubscription
  219. try {
  220. recurlySubscription = await RecurlyWrapper.promises.getSubscription(
  221. recurlySubscriptionId,
  222. {}
  223. )
  224. } catch (err) {
  225. throw new ReportError(
  226. 'no-recurly-subscription',
  227. `Recurly subscription not found: ${err.message}`
  228. )
  229. }
  230. // 6. If commit mode, perform rollback
  231. if (commit) {
  232. await performRollback(mongoSubscription, recurlySubscription, stripeClient)
  233. return {
  234. status: 'rolled-back',
  235. note: 'Successfully rolled back to Recurly',
  236. }
  237. } else {
  238. return {
  239. status: 'validated',
  240. note: 'DRY RUN: Ready to rollback to Recurly',
  241. }
  242. }
  243. }
  244. async function performRollback(
  245. mongoSubscription,
  246. recurlySubscription,
  247. stripeClient
  248. ) {
  249. const adminUserId = mongoSubscription.admin_id.toString()
  250. const recurlySubscriptionId = recurlySubscription.uuid
  251. const stripeSubscriptionId = mongoSubscription.paymentProvider.subscriptionId
  252. // Step 1: Restore Recurly fields in Mongo
  253. mongoSubscription.recurlySubscription_id = recurlySubscriptionId
  254. mongoSubscription.recurlyStatus = {
  255. state: recurlySubscription.state,
  256. trialStartedAt: recurlySubscription.trial_started_at,
  257. trialEndsAt: recurlySubscription.trial_ends_at,
  258. }
  259. mongoSubscription.paymentProvider = undefined
  260. await mongoSubscription.save()
  261. // Step 2: Emit rollback analytics event
  262. AnalyticsManager.recordEventForUserInBackground(
  263. adminUserId,
  264. 'subscription-rolled-back-from-stripe',
  265. {
  266. subscriptionId: mongoSubscription._id.toString(),
  267. migrationDirection: 'stripe-to-recurly',
  268. }
  269. )
  270. // Step 3: Un-postpone Recurly billing by 10 years if next billing period was postponed
  271. const currentPeriodEnd = new Date(recurlySubscription.current_period_ends_at)
  272. const nineYearsFromNow = new Date()
  273. nineYearsFromNow.setFullYear(new Date().getFullYear() + 9)
  274. if (currentPeriodEnd > nineYearsFromNow) {
  275. const nextBillingDate = new Date(currentPeriodEnd)
  276. nextBillingDate.setFullYear(currentPeriodEnd.getFullYear() - 10)
  277. const targetBillingDateIsInFuture = nextBillingDate.getTime() > Date.now()
  278. if (targetBillingDateIsInFuture) {
  279. try {
  280. await RecurlyWrapper.promises.apiRequest({
  281. url: `subscriptions/${recurlySubscriptionId}/postpone`,
  282. qs: { bulk: true, next_bill_date: nextBillingDate },
  283. method: 'PUT',
  284. })
  285. } catch (err) {
  286. throw new ReportError(
  287. 'rolled-back-recurly-restore-failed',
  288. `Restored Mongo but failed to restore Recurly billing: ${err.message}`
  289. )
  290. }
  291. } else {
  292. throw new ReportError(
  293. 'rolled-back-recurly-restore-failed',
  294. `Restored Mongo and Recurly but failed to restore Recurly billing: target next billing date is in the past (${nextBillingDate.toISOString()})`
  295. )
  296. }
  297. }
  298. // Step 4: Restore migration metadata to Stripe
  299. try {
  300. await stripeClient.updateSubscriptionMetadata(stripeSubscriptionId, {
  301. recurly_to_stripe_migration_status: 'in_progress',
  302. })
  303. } catch (err) {
  304. throw new ReportError(
  305. 'rolled-back-metadata-restore-failed',
  306. `Restored Mongo and Recurly but failed to restore Stripe metadata: ${err.message}`
  307. )
  308. }
  309. // Step 5: Register analytics mapping for the Recurly subscription
  310. try {
  311. AnalyticsManager.registerAccountMapping(
  312. AccountMappingHelper.generateSubscriptionToRecurlyMapping(
  313. mongoSubscription._id,
  314. recurlySubscriptionId,
  315. 'recurly'
  316. )
  317. )
  318. } catch (err) {
  319. throw new ReportError(
  320. 'rolled-back-analytics-mapping-failed',
  321. `Restored Mongo, Recurly, Stripe but failed to register analytics mapping: ${err.message}`
  322. )
  323. }
  324. // Step 5: Remove migration date from customer.io
  325. const analyticsId = await UserAnalyticsIdCache.get(adminUserId)
  326. if (analyticsId) {
  327. try {
  328. CustomerIoHandler.updateUserAttributes(analyticsId, {
  329. stripe_migration: {},
  330. })
  331. } catch (err) {
  332. throw new ReportError(
  333. 'rolled-back-customerio-update-failed',
  334. `Restored Mongo, Recurly, Stripe but failed to update user in customer.io: ${err.message}`
  335. )
  336. }
  337. }
  338. }
  339. function parseArgs() {
  340. const args = minimist(process.argv.slice(2), {
  341. string: ['output'],
  342. number: ['throttle'],
  343. boolean: ['commit', 'help'],
  344. default: {
  345. commit: false,
  346. throttle: DEFAULT_THROTTLE,
  347. },
  348. })
  349. if (args.help) {
  350. usage()
  351. process.exit(0)
  352. }
  353. const inputFile = args._[0]
  354. const paramsSchema = z.object({
  355. output: z.string().optional(),
  356. commit: z.boolean(),
  357. throttle: z.number().int().positive(),
  358. inputFile: z.string().optional(),
  359. })
  360. try {
  361. return paramsSchema.parse({
  362. output: args.output,
  363. commit: args.commit,
  364. throttle: args.throttle,
  365. inputFile,
  366. })
  367. } catch (err) {
  368. console.error('Invalid arguments:', err.message)
  369. usage()
  370. process.exit(1)
  371. }
  372. }
  373. try {
  374. await scriptRunner(main)
  375. process.exit(0)
  376. } catch (error) {
  377. console.error(error)
  378. process.exit(1)
  379. }