check_upcoming_schedules.mjs 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439
  1. #!/usr/bin/env node
  2. // @ts-check
  3. /**
  4. * This script checks upcoming schedules for existing Recurly subscriptions.
  5. *
  6. * Usage:
  7. * node scripts/recurly/check_upcoming_schedules.mjs [OPTS] [INPUT-FILE]
  8. *
  9. * Options:
  10. * --output PATH Output file path (default: /tmp/check_schedules_output_<timestamp>.csv)
  11. * Use '-' to write to stdout
  12. * --throttle DURATION Minimum time (in ms) between subscriptions processed (default: 2400)
  13. * --help Show a help message
  14. *
  15. * CSV Input Format:
  16. * The CSV must have the following columns:
  17. * - subscription_uuid: Recurly subscription UUID
  18. * - plan_code: Current plan code
  19. * - currency: Current currency
  20. * - unit_amount: Current price per unit
  21. * - new_unit_amount: New price per unit
  22. * - subscription_add_on_unit_amount_in_cents: Current additional-licenses add-on price (optional)
  23. * - new_subscription_add_on_unit_amount_in_cents: New additional-licenses add-on price (optional)
  24. * - user_id: Overleaf user ID
  25. *
  26. * Output:
  27. * Writes a CSV with columns:
  28. * - subscription_uuid: The subscription UUID processed
  29. * - status: Result status (validated, not-found, inactive, mismatch, no-pending-change, or error)
  30. * - note: Additional information about the status
  31. * - user_id: Overleaf user ID
  32. *
  33. * Running on a Pod:
  34. * This script may run for a long time. When running using `rake run:longpod[ENV,web]`,
  35. * use one of these strategies to preserve output:
  36. *
  37. * 1. Tail the output file from another session (the filename is logged when the script starts):
  38. * kubectl exec -it <pod-name> -- tail -f /tmp/check_schedules_output_<timestamp>.csv > local_backup.csv
  39. *
  40. * 2. Periodically copy the output file to your laptop:
  41. * kubectl cp <pod-name>:/tmp/check_schedules_output_<timestamp>.csv ./backup.csv
  42. *
  43. * 3. Write to stdout and capture locally:
  44. * kubectl exec -it <pod-name> -- node scripts/recurly/check_upcoming_schedules.mjs \
  45. * --output - input.csv > output.csv
  46. *
  47. * For monitoring handoffs, have the next person start tailing (or copying periodically)
  48. * before the current monitor disconnects to ensure no records are lost.
  49. */
  50. import fs from 'node:fs'
  51. import path from 'node:path'
  52. import { setTimeout } from 'node:timers/promises'
  53. import * as csv from 'csv'
  54. import minimist from 'minimist'
  55. import recurly from 'recurly'
  56. import Settings from '@overleaf/settings'
  57. import { z } from '../../app/src/infrastructure/Validation.mjs'
  58. import { scriptRunner } from '../lib/ScriptRunner.mjs'
  59. /**
  60. * @import { ReadStream } from 'node:fs'
  61. * @import { Parser } from 'csv-parse'
  62. * @import { Stringifier } from 'csv-stringify'
  63. * @import { Subscription } from 'recurly'
  64. */
  65. /**
  66. * @typedef {Object} CSVSubscriptionChange
  67. * @property {string} subscription_uuid
  68. * @property {string} plan_code
  69. * @property {string} currency
  70. * @property {number} unit_amount
  71. * @property {number} new_unit_amount
  72. * @property {number | null} subscription_add_on_unit_amount_in_cents
  73. * @property {number | null} new_subscription_add_on_unit_amount_in_cents
  74. * @property {string} user_id
  75. */
  76. const recurlyClient = new recurly.Client(Settings.apis.recurly.apiKey)
  77. // 2400 ms corresponds to approx. 3000 API calls per hour
  78. const DEFAULT_THROTTLE = 2400
  79. /**
  80. * Print usage information to stderr
  81. */
  82. function usage() {
  83. console.error(`Usage: node scripts/recurly/check_upcoming_schedules.mjs [OPTS] [INPUT-FILE]
  84. Options:
  85. --output PATH Output file path (default: /tmp/check_schedules_output_<timestamp>.csv)
  86. Use '-' to write to stdout
  87. --throttle DURATION Minimum time between requests in ms (default: ${DEFAULT_THROTTLE})
  88. --help Show this help message
  89. See the source file header for detailed documentation on CSV format and pod usage.
  90. `)
  91. }
  92. /**
  93. * Main script entry point
  94. * @param {function(string): Promise<void>} trackProgress - Function to log progress messages
  95. */
  96. async function main(trackProgress) {
  97. const opts = parseArgs()
  98. const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
  99. const outputFile =
  100. opts.output ?? `/tmp/check_schedules_output_${timestamp}.csv`
  101. await trackProgress('Starting schedule check script for Recurly')
  102. await trackProgress(`Throttle: ${opts.throttle}ms between requests`)
  103. const inputStream = opts.inputFile
  104. ? fs.createReadStream(opts.inputFile)
  105. : process.stdin
  106. const csvReader = getCsvReader(inputStream)
  107. const csvWriter = getCsvWriter(outputFile)
  108. await trackProgress(`Output: ${outputFile === '-' ? 'stdout' : outputFile}`)
  109. let processedCount = 0
  110. let successCount = 0
  111. let errorCount = 0
  112. let lastLoopTimestamp = 0
  113. for await (const change of csvReader) {
  114. const timeSinceLastLoop = Date.now() - lastLoopTimestamp
  115. if (timeSinceLastLoop < opts.throttle) {
  116. await setTimeout(opts.throttle - timeSinceLastLoop)
  117. }
  118. lastLoopTimestamp = Date.now()
  119. processedCount++
  120. try {
  121. const subscription = await fetchSubscription(change.subscription_uuid)
  122. validateChange(change, subscription)
  123. csvWriter.write({
  124. subscription_uuid: change.subscription_uuid,
  125. status: 'validated',
  126. note: 'everything looks as expected',
  127. user_id: change.user_id,
  128. })
  129. successCount++
  130. if (processedCount % 10 === 0) {
  131. await trackProgress(
  132. `Processed ${processedCount} subscriptions (${successCount}, ${errorCount} errors)`
  133. )
  134. }
  135. } catch (err) {
  136. errorCount++
  137. if (err instanceof ReportError) {
  138. csvWriter.write({
  139. subscription_uuid: change.subscription_uuid,
  140. status: err.status,
  141. note: err.message,
  142. user_id: change.user_id,
  143. })
  144. } else if (err instanceof Error) {
  145. csvWriter.write({
  146. subscription_uuid: change.subscription_uuid,
  147. status: 'error',
  148. note: err.message,
  149. user_id: change.user_id,
  150. })
  151. await trackProgress(
  152. `Error processing ${change.subscription_uuid}: ${err.message}`
  153. )
  154. }
  155. }
  156. }
  157. await trackProgress('\n✨ FINAL SUMMARY ✨')
  158. await trackProgress(`📊 Total processed: ${processedCount}`)
  159. await trackProgress(`✅ Successfully validated: ${successCount}`)
  160. await trackProgress(`❌ Errors: ${errorCount}`)
  161. await trackProgress('🎉 Script completed!')
  162. csvWriter.end()
  163. }
  164. /**
  165. * Get a CSV parser configured for subscription change input
  166. * @param {ReadStream | NodeJS.ReadableStream} inputStream - The input stream to parse
  167. * @returns {Parser} The configured CSV parser
  168. */
  169. function getCsvReader(inputStream) {
  170. const parser = csv.parse({
  171. columns: true,
  172. cast: (value, context) => {
  173. if (context.header) {
  174. return value
  175. }
  176. switch (context.column) {
  177. case 'unit_amount':
  178. case 'new_unit_amount': {
  179. const parsed = parseFloat(value)
  180. if (Number.isNaN(parsed)) {
  181. throw new ReportError(
  182. 'mismatch',
  183. `Invalid number for ${context.column} at row ${context.lines}: "${value}"`
  184. )
  185. }
  186. return parsed
  187. }
  188. case 'subscription_add_on_unit_amount_in_cents':
  189. case 'new_subscription_add_on_unit_amount_in_cents': {
  190. if (value === '') {
  191. return null
  192. }
  193. const parsed = parseInt(value, 10)
  194. if (Number.isNaN(parsed)) {
  195. throw new ReportError(
  196. 'mismatch',
  197. `Invalid number for ${context.column} at row ${context.lines}: "${value}"`
  198. )
  199. }
  200. return parsed
  201. }
  202. default:
  203. return value
  204. }
  205. },
  206. })
  207. inputStream.pipe(parser)
  208. return parser
  209. }
  210. /**
  211. * Get a CSV stringifier configured for output
  212. * @param {string} outputFile - The output file path to write to, or '-' for stdout
  213. * @returns {Stringifier} The configured CSV stringifier
  214. */
  215. function getCsvWriter(outputFile) {
  216. let outputStream
  217. if (outputFile === '-') {
  218. outputStream = process.stdout
  219. } else {
  220. fs.mkdirSync(path.dirname(outputFile), { recursive: true })
  221. outputStream = fs.createWriteStream(outputFile)
  222. }
  223. const writer = csv.stringify({
  224. columns: ['subscription_uuid', 'status', 'note', 'user_id'],
  225. header: true,
  226. })
  227. writer.on('error', err => {
  228. console.error(err)
  229. process.exit(1)
  230. })
  231. writer.pipe(outputStream)
  232. return writer
  233. }
  234. /**
  235. * Fetch a subscription from Recurly
  236. * @param {string} uuid - The Recurly subscription UUID
  237. * @returns {Promise<Subscription>} The subscription
  238. * @throws {ReportError} If subscription is not found
  239. */
  240. async function fetchSubscription(uuid) {
  241. try {
  242. const subscription = await recurlyClient.getSubscription(`uuid-${uuid}`)
  243. return subscription
  244. } catch (err) {
  245. if (err instanceof recurly.errors.NotFoundError) {
  246. throw new ReportError('not-found', 'subscription not found')
  247. } else {
  248. throw err
  249. }
  250. }
  251. }
  252. /**
  253. * Validate that the subscription matches the expected state
  254. * @param {CSVSubscriptionChange} change - The subscription change to validate
  255. * @param {Subscription} subscription - The Recurly subscription
  256. * @throws {ReportError} If validation fails
  257. */
  258. function validateChange(change, subscription) {
  259. if (subscription.state !== 'active') {
  260. throw new ReportError(
  261. 'inactive',
  262. `subscription state: ${subscription.state}`
  263. )
  264. }
  265. if (subscription.plan?.code !== change.plan_code) {
  266. throw new ReportError(
  267. 'mismatch',
  268. `subscription plan (${subscription.plan?.code}) does not match expected plan (${change.plan_code})`
  269. )
  270. }
  271. if (subscription.currency !== change.currency) {
  272. throw new ReportError(
  273. 'mismatch',
  274. `subscription currency (${subscription.currency}) does not match expected currency (${change.currency})`
  275. )
  276. }
  277. if (
  278. !subscription.unitAmount ||
  279. Math.abs(subscription.unitAmount - change.unit_amount) > 0.01
  280. ) {
  281. throw new ReportError(
  282. 'mismatch',
  283. `subscription price (${subscription.unitAmount}) does not match expected price (${change.unit_amount})`
  284. )
  285. }
  286. if (Math.abs(change.unit_amount - change.new_unit_amount) < 0.01) {
  287. throw new ReportError(
  288. 'mismatch',
  289. `price not expected to change (before: ${change.unit_amount}, after: ${change.new_unit_amount})`
  290. )
  291. }
  292. if (!subscription.pendingChange) {
  293. throw new ReportError(
  294. 'no-pending-change',
  295. 'subscription has no pending change'
  296. )
  297. }
  298. if (
  299. !subscription.pendingChange.unitAmount ||
  300. Math.abs(subscription.pendingChange.unitAmount - change.new_unit_amount) >
  301. 0.01
  302. ) {
  303. throw new ReportError(
  304. 'mismatch',
  305. `subscription price (${subscription.pendingChange.unitAmount}) does not match expected price (${change.new_unit_amount})`
  306. )
  307. }
  308. const additionalLicenseAddOn = subscription.addOns?.find(
  309. addOnItem => addOnItem.addOn?.code === 'additional-license'
  310. )
  311. if (change.subscription_add_on_unit_amount_in_cents != null) {
  312. if (!additionalLicenseAddOn) {
  313. throw new ReportError(
  314. 'mismatch',
  315. 'add-on for additional-license not found'
  316. )
  317. }
  318. const expectedAddOnPrice =
  319. change.subscription_add_on_unit_amount_in_cents / 100
  320. if (
  321. !additionalLicenseAddOn.unitAmount ||
  322. Math.abs(additionalLicenseAddOn.unitAmount - expectedAddOnPrice) > 0.01
  323. ) {
  324. throw new ReportError(
  325. 'mismatch',
  326. `add-on price (${additionalLicenseAddOn.unitAmount}) does not match expected price (${expectedAddOnPrice})`
  327. )
  328. }
  329. if (change.new_subscription_add_on_unit_amount_in_cents == null) {
  330. throw new ReportError(
  331. 'mismatch',
  332. 'new_subscription_add_on_unit_amount_in_cents is required when subscription_add_on_unit_amount_in_cents is provided'
  333. )
  334. }
  335. } else if (additionalLicenseAddOn) {
  336. throw new ReportError(
  337. 'mismatch',
  338. 'subscription has additional-license add-on but subscription_add_on_unit_amount_in_cents not provided in CSV'
  339. )
  340. }
  341. }
  342. const paramsSchema = z.object({
  343. output: z.string().optional(),
  344. throttle: z
  345. .string()
  346. .optional()
  347. .transform(val => (val ? parseInt(val, 10) : DEFAULT_THROTTLE)),
  348. _: z.array(z.string()).max(1),
  349. help: z.boolean().optional(),
  350. })
  351. /**
  352. * Parse command line arguments
  353. * @returns {{inputFile: string | undefined, output: string | undefined, throttle: number}} Parsed options
  354. */
  355. function parseArgs() {
  356. const argv = minimist(process.argv.slice(2), {
  357. string: ['throttle', 'output'],
  358. boolean: ['help'],
  359. })
  360. if (argv.help) {
  361. usage()
  362. process.exit(0)
  363. }
  364. const parseResult = paramsSchema.safeParse(argv)
  365. if (!parseResult.success) {
  366. console.error(`Invalid parameters: ${parseResult.error.message}`)
  367. usage()
  368. process.exit(1)
  369. }
  370. const { output, throttle, _ } = parseResult.data
  371. return {
  372. inputFile: _[0],
  373. output,
  374. throttle,
  375. }
  376. }
  377. /**
  378. * Custom error class for reportable errors that should be written to CSV output
  379. */
  380. class ReportError extends Error {
  381. /**
  382. * @param {string} status - The error status code for CSV output
  383. * @param {string} message - The error message
  384. */
  385. constructor(status, message) {
  386. super(message)
  387. this.status = status
  388. }
  389. }
  390. try {
  391. await scriptRunner(main)
  392. process.exit(0)
  393. } catch (error) {
  394. console.error(error)
  395. process.exit(1)
  396. }