cleanup-recurly-subscriptions-post-migration.mjs 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350
  1. #!/usr/bin/env node
  2. /**
  3. * This script CLEANS UP Recurly subscriptions after migration to Stripe is finalized.
  4. *
  5. * ⚠️ IMPORTANT NOTES:
  6. * - Only run this AFTER the cutover from Recurly to Stripe is complete and verified
  7. * - After running this script, rollback is NO LONGER POSSIBLE
  8. * - NEVER extend this script to close Recurly accounts or remove billing info for Paypal
  9. * (could trigger PayPal billing agreement cancellation)
  10. * - This script will trigger lifecycle emails to be sent. Please turn off:
  11. * "Subscription Expired Template" (https://sharelatex.recurly.com/emails/subscription_expired/template/edit)
  12. *
  13. * Usage:
  14. * node scripts/recurly/cleanup-recurly-subscriptions-post-migration.mjs [OPTS] [INPUT-FILE]
  15. *
  16. * Options:
  17. * --output PATH Output file path (default: /tmp/cancel_output_<timestamp>.csv)
  18. * --commit Apply changes (without this, runs in dry-run mode)
  19. * --concurrency N Number of customers to process concurrently (default: 10)
  20. * --recurly-rate-limit N Requests per second for Recurly (default: 10)
  21. * --recurly-api-retries N Number of retries on Recurly 429s (default: 5)
  22. * --recurly-retry-delay-ms N Delay between Recurly retries in ms (default: 1000)
  23. * --help Show help message
  24. *
  25. * CSV Input Format:
  26. * recurly_account_code,previous_recurly_subscription_id
  27. * 507f1f77bcf86cd799439011,abcd1234efgh5678
  28. *
  29. * CSV Output Format:
  30. * recurly_account_code,previous_recurly_subscription_id,status,note
  31. *
  32. * Note: recurly_account_code is the Overleaf user ID (admin_id)
  33. */
  34. import fs from 'node:fs'
  35. import path from 'node:path'
  36. import * as csv from 'csv'
  37. import minimist from 'minimist'
  38. import PQueue from 'p-queue'
  39. import RecurlyClient from '../../app/src/Features/Subscription/RecurlyClient.mjs'
  40. import { z } from '../../app/src/infrastructure/Validation.mjs'
  41. import { scriptRunner } from '../lib/ScriptRunner.mjs'
  42. import { Subscription } from '../../app/src/models/Subscription.mjs'
  43. import { ReportError } from '../stripe/helpers.mjs'
  44. import {
  45. createRateLimitedApiWrappers,
  46. DEFAULT_RECURLY_RATE_LIMIT,
  47. DEFAULT_RECURLY_API_RETRIES,
  48. DEFAULT_RECURLY_RETRY_DELAY_MS,
  49. } from '../stripe/RateLimiter.mjs'
  50. const DEFAULT_CONCURRENCY = 10
  51. // rate limiters - initialized in main()
  52. let rateLimiters
  53. function usage() {
  54. console.error(`Usage: node scripts/recurly/cleanup-recurly-subscriptions-post-migration.mjs [OPTS] [INPUT-FILE]
  55. Options:
  56. --output PATH Output file path (default: /tmp/terminate_output_<timestamp>.csv)
  57. --commit Apply changes (without this, runs in dry-run mode)
  58. --concurrency N Number of customers to process concurrently (default: ${DEFAULT_CONCURRENCY})
  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. --help Show this help message
  63. `)
  64. }
  65. async function main(trackProgress) {
  66. const opts = parseArgs()
  67. const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
  68. const outputFile = opts.output ?? `/tmp/terminate_output_${timestamp}.csv`
  69. // initialize rate limiters
  70. rateLimiters = createRateLimitedApiWrappers({
  71. recurlyRateLimit: opts.recurlyRateLimit,
  72. recurlyApiRetries: opts.recurlyApiRetries,
  73. recurlyRetryDelayMs: opts.recurlyRetryDelayMs,
  74. })
  75. await trackProgress('Starting Recurly subscription termination')
  76. await trackProgress(`Run mode: ${opts.commit ? 'COMMIT' : 'DRY RUN'}`)
  77. await trackProgress(`Rate limit: Recurly ${opts.recurlyRateLimit}/s`)
  78. await trackProgress(`Concurrency: ${opts.concurrency}`)
  79. const inputStream = opts.inputFile
  80. ? fs.createReadStream(opts.inputFile)
  81. : process.stdin
  82. const csvReader = getCsvReader(inputStream)
  83. const csvWriter = getCsvWriter(outputFile)
  84. await trackProgress(`Output: ${outputFile}`)
  85. let processedCount = 0
  86. let successCount = 0
  87. let errorCount = 0
  88. const queue = new PQueue({ concurrency: opts.concurrency })
  89. const maxQueueSize = opts.concurrency
  90. try {
  91. for await (const input of csvReader) {
  92. if (queue.size >= maxQueueSize) {
  93. await queue.onSizeLessThan(maxQueueSize)
  94. }
  95. queue.add(async () => {
  96. try {
  97. const result = await processTermination(input, opts.commit)
  98. csvWriter.write({
  99. recurly_account_code: input.recurly_account_code,
  100. status: result.status,
  101. note: result.note,
  102. previous_recurly_subscription_id:
  103. input.previous_recurly_subscription_id,
  104. })
  105. if (result.status === 'terminated' || result.status === 'validated') {
  106. successCount++
  107. } else {
  108. errorCount++
  109. }
  110. } catch (err) {
  111. errorCount++
  112. if (err instanceof ReportError) {
  113. csvWriter.write({
  114. recurly_account_code: input.recurly_account_code,
  115. previous_recurly_subscription_id:
  116. input.previous_recurly_subscription_id,
  117. status: err.status,
  118. note: err.message,
  119. })
  120. } else {
  121. csvWriter.write({
  122. recurly_account_code: input.recurly_account_code,
  123. previous_recurly_subscription_id:
  124. input.previous_recurly_subscription_id,
  125. status: 'error',
  126. note: err.message,
  127. })
  128. }
  129. }
  130. processedCount++
  131. if (processedCount % 25 === 0) {
  132. await trackProgress(
  133. `Progress: ${processedCount} processed, ${successCount} successful, ${errorCount} errors`
  134. )
  135. }
  136. })
  137. }
  138. } finally {
  139. await queue.onIdle()
  140. }
  141. await trackProgress(`✅ Total processed: ${processedCount}`)
  142. if (opts.commit) {
  143. await trackProgress(`✅ Successfully terminated: ${successCount}`)
  144. } else {
  145. await trackProgress(`✅ Successfully validated: ${successCount}`)
  146. await trackProgress('ℹ️ DRY RUN: No changes were applied')
  147. }
  148. await trackProgress(`❌ Errors: ${errorCount}`)
  149. await trackProgress('🎉 Script completed!')
  150. csvWriter.end()
  151. }
  152. function getCsvReader(inputStream) {
  153. const parser = csv.parse({ columns: true })
  154. inputStream.pipe(parser)
  155. return parser
  156. }
  157. function getCsvWriter(outputFile) {
  158. fs.mkdirSync(path.dirname(outputFile), { recursive: true })
  159. const outputStream = fs.createWriteStream(outputFile)
  160. const writer = csv.stringify({
  161. columns: [
  162. 'recurly_account_code',
  163. 'previous_recurly_subscription_id',
  164. 'status',
  165. 'note',
  166. ],
  167. header: true,
  168. })
  169. writer.on('error', err => {
  170. console.error(err)
  171. process.exit(1)
  172. })
  173. writer.pipe(outputStream)
  174. return writer
  175. }
  176. async function processTermination(input, commit) {
  177. const {
  178. recurly_account_code: adminUserId,
  179. previous_recurly_subscription_id: subscriptionUuid,
  180. } = input
  181. // 1. Fetch Mongo subscription
  182. const mongoSubscription = await Subscription.findOne({
  183. admin_id: adminUserId,
  184. }).exec()
  185. // 2. Verify subscription has been migrated to Stripe (skipping if the
  186. // Mongo subscription is missing, which would indicate that the Stripe
  187. // subscription has expired after the cutover)
  188. if (
  189. mongoSubscription &&
  190. !mongoSubscription.paymentProvider?.service?.includes('stripe')
  191. ) {
  192. throw new ReportError(
  193. 'not-migrated',
  194. 'Subscription has not been migrated to Stripe yet'
  195. )
  196. }
  197. // 3. Fetch Recurly subscription and verify it is in our expected state
  198. let recurlySubscription
  199. let isInExpectedEndState = true
  200. try {
  201. recurlySubscription = await rateLimiters.requestWithRetries(
  202. 'recurly',
  203. () => RecurlyClient.promises.getSubscription(subscriptionUuid),
  204. { operation: 'getSubscription', subscriptionUuid }
  205. )
  206. } catch (err) {
  207. isInExpectedEndState = false
  208. }
  209. if (recurlySubscription) {
  210. const nineYearsFromNow = new Date()
  211. nineYearsFromNow.setFullYear(new Date().getFullYear() + 9)
  212. if (
  213. recurlySubscription.periodEnd > nineYearsFromNow &&
  214. recurlySubscription.state === 'canceled'
  215. ) {
  216. isInExpectedEndState = false
  217. }
  218. } else {
  219. throw new ReportError(
  220. 'missing-subscription',
  221. 'Recurly subscription not found'
  222. )
  223. }
  224. const warning = isInExpectedEndState
  225. ? ''
  226. : `(subscription was NOT in expected state: periodEnd=${recurlySubscription?.periodEnd?.toISOString()}, state=${recurlySubscription?.state})`
  227. // 4. If commit mode, terminate the subscription
  228. if (commit) {
  229. try {
  230. await rateLimiters.requestWithRetries(
  231. 'recurly',
  232. () =>
  233. RecurlyClient.promises.terminateSubscriptionByUuid(subscriptionUuid),
  234. { operation: 'terminateSubscriptionByUuid', subscriptionUuid }
  235. )
  236. return {
  237. status: isInExpectedEndState
  238. ? 'terminated'
  239. : 'terminated-with-warnings',
  240. note: `Successfully terminated Recurly subscription ${warning}`,
  241. }
  242. } catch (err) {
  243. throw new ReportError(
  244. 'terminate-failed',
  245. `Failed to terminate: ${err.message} ${warning}`
  246. )
  247. }
  248. } else {
  249. const note = isInExpectedEndState
  250. ? 'DRY RUN: Ready to terminate'
  251. : `DRY RUN: Can terminate, with this warning: ${warning}`
  252. return {
  253. status: isInExpectedEndState ? 'validated' : 'validated-with-warnings',
  254. note,
  255. }
  256. }
  257. }
  258. function parseArgs() {
  259. const args = minimist(process.argv.slice(2), {
  260. string: [
  261. 'output',
  262. 'concurrency',
  263. 'recurly-rate-limit',
  264. 'recurly-api-retries',
  265. 'recurly-retry-delay-ms',
  266. ],
  267. boolean: ['commit', 'help'],
  268. default: {
  269. commit: false,
  270. concurrency: DEFAULT_CONCURRENCY,
  271. 'recurly-rate-limit': DEFAULT_RECURLY_RATE_LIMIT,
  272. 'recurly-api-retries': DEFAULT_RECURLY_API_RETRIES,
  273. 'recurly-retry-delay-ms': DEFAULT_RECURLY_RETRY_DELAY_MS,
  274. },
  275. })
  276. if (args.help) {
  277. usage()
  278. process.exit(0)
  279. }
  280. const inputFile = args._[0]
  281. const paramsSchema = z.object({
  282. output: z.string().optional(),
  283. commit: z.boolean(),
  284. concurrency: z.number().int().positive(),
  285. recurlyRateLimit: z.number().positive(),
  286. recurlyApiRetries: z.number().int().nonnegative(),
  287. recurlyRetryDelayMs: z.number().int().nonnegative(),
  288. inputFile: z.string().optional(),
  289. })
  290. try {
  291. return paramsSchema.parse({
  292. output: args.output,
  293. commit: args.commit,
  294. concurrency: Number(args.concurrency),
  295. recurlyRateLimit: Number(args['recurly-rate-limit']),
  296. recurlyApiRetries: Number(args['recurly-api-retries']),
  297. recurlyRetryDelayMs: Number(args['recurly-retry-delay-ms']),
  298. inputFile,
  299. })
  300. } catch (err) {
  301. console.error('Invalid arguments:', err.message)
  302. usage()
  303. process.exit(1)
  304. }
  305. }
  306. try {
  307. await scriptRunner(main)
  308. process.exit(0)
  309. } catch (error) {
  310. console.error(error)
  311. process.exit(1)
  312. }