change_existing_subscription_prices.mjs 17 KB

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