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

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297
  1. #!/usr/bin/env node
  2. /**
  3. * This script CLEANS UP Recurly subscriptions after migration to Stripe is finalized.
  4. *
  5. * IMPORTANT: Only run this AFTER the cutover is complete, verified, and
  6. * we've confirmed that Stripe is working correctly.
  7. *
  8. * WARNING: After running this script, rollback is NO LONGER POSSIBLE.
  9. *
  10. * NOTE: 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. * --throttle DURATION Minimum time between requests in ms (default: 100)
  20. * --help Show help message
  21. *
  22. * CSV Input Format:
  23. * recurly_account_code,previous_recurly_subscription_id
  24. * 507f1f77bcf86cd799439011,abcd1234efgh5678
  25. *
  26. * CSV Output Format:
  27. * recurly_account_code,previous_recurly_subscription_id,status,note
  28. *
  29. * Note: recurly_account_code is the Overleaf user ID (admin_id)
  30. */
  31. import fs from 'node:fs'
  32. import path from 'node:path'
  33. import { setTimeout } from 'node:timers/promises'
  34. import * as csv from 'csv'
  35. import minimist from 'minimist'
  36. import RecurlyClient from '../../app/src/Features/Subscription/RecurlyClient.mjs'
  37. import { z } from '../../app/src/infrastructure/Validation.mjs'
  38. import { scriptRunner } from '../lib/ScriptRunner.mjs'
  39. import { Subscription } from '../../app/src/models/Subscription.mjs'
  40. import { ReportError } from '../stripe/helpers.mjs'
  41. const DEFAULT_THROTTLE = 100
  42. function usage() {
  43. console.error(`Usage: node scripts/recurly/cleanup-recurly-subscriptions-post-migration.mjs [OPTS] [INPUT-FILE]
  44. Options:
  45. --output PATH Output file path (default: /tmp/terminate_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. `)
  50. }
  51. async function main(trackProgress) {
  52. const opts = parseArgs()
  53. const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
  54. const outputFile = opts.output ?? `/tmp/terminate_output_${timestamp}.csv`
  55. await trackProgress('Starting Recurly subscription termination')
  56. await trackProgress(`Run mode: ${opts.commit ? 'COMMIT' : 'DRY RUN'}`)
  57. await trackProgress(`Throttle: ${opts.throttle}ms between requests`)
  58. const inputStream = opts.inputFile
  59. ? fs.createReadStream(opts.inputFile)
  60. : process.stdin
  61. const csvReader = getCsvReader(inputStream)
  62. const csvWriter = getCsvWriter(outputFile)
  63. await trackProgress(`Output: ${outputFile}`)
  64. let processedCount = 0
  65. let successCount = 0
  66. let errorCount = 0
  67. let lastLoopTimestamp = 0
  68. for await (const input of csvReader) {
  69. const timeSinceLastLoop = Date.now() - lastLoopTimestamp
  70. if (timeSinceLastLoop < opts.throttle) {
  71. await setTimeout(opts.throttle - timeSinceLastLoop)
  72. }
  73. lastLoopTimestamp = Date.now()
  74. processedCount++
  75. try {
  76. const result = await processTermination(input, opts.commit)
  77. csvWriter.write({
  78. recurly_account_code: input.recurly_account_code,
  79. status: result.status,
  80. note: result.note,
  81. previous_recurly_subscription_id:
  82. input.previous_recurly_subscription_id,
  83. })
  84. if (result.status === 'terminated' || result.status === 'validated') {
  85. successCount++
  86. } else {
  87. errorCount++
  88. }
  89. if (processedCount % 25 === 0) {
  90. await trackProgress(
  91. `Progress: ${processedCount} processed, ${successCount} successful, ${errorCount} errors`
  92. )
  93. }
  94. } catch (err) {
  95. errorCount++
  96. if (err instanceof ReportError) {
  97. csvWriter.write({
  98. recurly_account_code: input.recurly_account_code,
  99. previous_recurly_subscription_id:
  100. input.previous_recurly_subscription_id,
  101. status: err.status,
  102. note: err.message,
  103. })
  104. } else {
  105. csvWriter.write({
  106. recurly_account_code: input.recurly_account_code,
  107. previous_recurly_subscription_id:
  108. input.previous_recurly_subscription_id,
  109. status: 'error',
  110. note: err.message,
  111. })
  112. }
  113. }
  114. }
  115. await trackProgress(`✅ Total processed: ${processedCount}`)
  116. if (opts.commit) {
  117. await trackProgress(`✅ Successfully terminated: ${successCount}`)
  118. } else {
  119. await trackProgress(`✅ Successfully validated: ${successCount}`)
  120. await trackProgress('ℹ️ DRY RUN: No changes were applied')
  121. }
  122. await trackProgress(`❌ Errors: ${errorCount}`)
  123. await trackProgress('🎉 Script completed!')
  124. csvWriter.end()
  125. }
  126. function getCsvReader(inputStream) {
  127. const parser = csv.parse({ columns: true })
  128. inputStream.pipe(parser)
  129. return parser
  130. }
  131. function getCsvWriter(outputFile) {
  132. fs.mkdirSync(path.dirname(outputFile), { recursive: true })
  133. const outputStream = fs.createWriteStream(outputFile)
  134. const writer = csv.stringify({
  135. columns: [
  136. 'recurly_account_code',
  137. 'previous_recurly_subscription_id',
  138. 'status',
  139. 'note',
  140. ],
  141. header: true,
  142. })
  143. writer.on('error', err => {
  144. console.error(err)
  145. process.exit(1)
  146. })
  147. writer.pipe(outputStream)
  148. return writer
  149. }
  150. async function processTermination(input, commit) {
  151. const {
  152. recurly_account_code: adminUserId,
  153. previous_recurly_subscription_id: subscriptionUuid,
  154. } = input
  155. // 1. Fetch Mongo subscription
  156. const mongoSubscription = await Subscription.findOne({
  157. admin_id: adminUserId,
  158. }).exec()
  159. // 2. Verify subscription has been migrated to Stripe (skipping if the
  160. // Mongo subscription is missing, which would indicate that the Stripe
  161. // subscription has expired after the cutover)
  162. if (
  163. mongoSubscription &&
  164. !mongoSubscription.paymentProvider?.service?.includes('stripe')
  165. ) {
  166. throw new ReportError(
  167. 'not-migrated',
  168. 'Subscription has not been migrated to Stripe yet'
  169. )
  170. }
  171. // 3. Fetch Recurly subscription and verify it is in our expected state
  172. let recurlySubscription
  173. let isInExpectedEndState = true
  174. try {
  175. recurlySubscription =
  176. await RecurlyClient.promises.getSubscription(subscriptionUuid)
  177. } catch (err) {
  178. isInExpectedEndState = false
  179. }
  180. if (recurlySubscription) {
  181. const nineYearsFromNow = new Date()
  182. nineYearsFromNow.setFullYear(new Date().getFullYear() + 9)
  183. if (
  184. recurlySubscription.periodEnd > nineYearsFromNow &&
  185. recurlySubscription.state === 'canceled'
  186. ) {
  187. isInExpectedEndState = false
  188. }
  189. } else {
  190. throw new ReportError(
  191. 'missing-subscription',
  192. 'Recurly subscription not found'
  193. )
  194. }
  195. const warning = isInExpectedEndState
  196. ? ''
  197. : `(subscription was NOT in expected state: periodEnd=${recurlySubscription?.periodEnd?.toISOString()}, state=${recurlySubscription?.state})`
  198. // 4. If commit mode, terminate the subscription
  199. if (commit) {
  200. try {
  201. await RecurlyClient.promises.terminateSubscriptionByUuid(subscriptionUuid)
  202. return {
  203. status: isInExpectedEndState
  204. ? 'terminated'
  205. : 'terminated-with-warnings',
  206. note: `Successfully terminated Recurly subscription ${warning}`,
  207. }
  208. } catch (err) {
  209. throw new ReportError(
  210. 'terminate-failed',
  211. `Failed to terminate: ${err.message} ${warning}`
  212. )
  213. }
  214. } else {
  215. const note = isInExpectedEndState
  216. ? 'DRY RUN: Ready to terminate'
  217. : `DRY RUN: Ready to terminate ${warning}`
  218. return {
  219. status: 'validated',
  220. note,
  221. }
  222. }
  223. }
  224. function parseArgs() {
  225. const args = minimist(process.argv.slice(2), {
  226. string: ['output'],
  227. number: ['throttle'],
  228. boolean: ['commit', 'help'],
  229. default: { commit: false, throttle: DEFAULT_THROTTLE },
  230. })
  231. if (args.help) {
  232. usage()
  233. process.exit(0)
  234. }
  235. const inputFile = args._[0]
  236. const paramsSchema = z.object({
  237. output: z.string().optional(),
  238. commit: z.boolean(),
  239. throttle: z.number().int().positive(),
  240. inputFile: z.string().optional(),
  241. })
  242. try {
  243. return paramsSchema.parse({
  244. output: args.output,
  245. commit: args.commit,
  246. throttle: args.throttle,
  247. inputFile,
  248. })
  249. } catch (err) {
  250. console.error('Invalid arguments:', err.message)
  251. usage()
  252. process.exit(1)
  253. }
  254. }
  255. try {
  256. await scriptRunner(main)
  257. process.exit(0)
  258. } catch (error) {
  259. console.error(error)
  260. process.exit(1)
  261. }