rollback-finalized-stripe-migration.mjs 12 KB

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