change_prices_at_renewal.mjs 5.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231
  1. import fs from 'node:fs'
  2. import { setTimeout } from 'node:timers/promises'
  3. import * as csv from 'csv'
  4. import minimist from 'minimist'
  5. import recurly from 'recurly'
  6. import Settings from '@overleaf/settings'
  7. import { scriptRunner } from '../lib/ScriptRunner.mjs'
  8. const recurlyClient = new recurly.Client(Settings.apis.recurly.apiKey)
  9. // 2400 ms corresponds to approx. 3000 API calls per hour
  10. const DEFAULT_THROTTLE = 2400
  11. async function main() {
  12. const opts = parseArgs()
  13. const inputStream = opts.inputFile
  14. ? fs.createReadStream(opts.inputFile)
  15. : process.stdin
  16. const csvReader = getCsvReader(inputStream)
  17. const csvWriter = getCsvWriter(process.stdout)
  18. let lastLoopTimestamp = 0
  19. for await (const change of csvReader) {
  20. const timeSinceLastLoop = Date.now() - lastLoopTimestamp
  21. if (timeSinceLastLoop < opts.throttle) {
  22. await setTimeout(opts.throttle - timeSinceLastLoop)
  23. }
  24. lastLoopTimestamp = Date.now()
  25. try {
  26. await processChange(change, opts)
  27. csvWriter.write({
  28. subscription_uuid: change.subscription_uuid,
  29. status: 'changed',
  30. })
  31. } catch (err) {
  32. if (err instanceof ReportError) {
  33. csvWriter.write({
  34. subscription_uuid: change.subscription_uuid,
  35. status: err.status,
  36. note: err.message,
  37. })
  38. } else {
  39. throw err
  40. }
  41. }
  42. }
  43. process.exit(0)
  44. }
  45. function getCsvReader(inputStream) {
  46. const parser = csv.parse({
  47. columns: true,
  48. cast: (value, context) => {
  49. if (context.header) {
  50. return value
  51. }
  52. switch (context.column) {
  53. case 'unit_amount':
  54. case 'new_unit_amount':
  55. return parseFloat(value)
  56. case 'subscription_add_on_unit_amount_in_cents':
  57. case 'new_subscription_add_on_unit_amount_in_cents':
  58. return value === '' ? null : parseInt(value, 10)
  59. default:
  60. return value
  61. }
  62. },
  63. })
  64. inputStream.pipe(parser)
  65. return parser
  66. }
  67. function getCsvWriter(outputStream) {
  68. const writer = csv.stringify({
  69. columns: ['subscription_uuid', 'status', 'note'],
  70. header: true,
  71. })
  72. writer.on('error', err => {
  73. console.error(err)
  74. process.exit(1)
  75. })
  76. writer.pipe(outputStream)
  77. return writer
  78. }
  79. async function processChange(change, opts) {
  80. const subscription = await fetchSubscription(change.subscription_uuid)
  81. validateChange(change, subscription, opts)
  82. await createSubscriptionChange(change, subscription)
  83. }
  84. async function fetchSubscription(uuid) {
  85. try {
  86. const subscription = await recurlyClient.getSubscription(`uuid-${uuid}`)
  87. return subscription
  88. } catch (err) {
  89. if (err instanceof recurly.errors.NotFoundError) {
  90. throw new ReportError('not-found', 'subscription not found')
  91. } else {
  92. throw err
  93. }
  94. }
  95. }
  96. function validateChange(change, subscription, opts) {
  97. if (subscription.state !== 'active') {
  98. throw new ReportError(
  99. 'inactive',
  100. `subscription state: ${subscription.state}`
  101. )
  102. }
  103. if (subscription.plan.code !== change.plan_code) {
  104. throw new ReportError(
  105. 'mismatch',
  106. `subscription plan (${subscription.plan.code}) does not match expected plan (${change.plan_code})`
  107. )
  108. }
  109. if (subscription.currency !== change.currency) {
  110. throw new ReportError(
  111. 'mismatch',
  112. `subscription currency (${subscription.currency}) does not match expected currency (${change.currency})`
  113. )
  114. }
  115. if (subscription.unitAmount !== change.unit_amount) {
  116. throw new ReportError(
  117. 'mismatch',
  118. `subscription price (${subscription.unitAmount}) does not match expected price (${change.unit_amount})`
  119. )
  120. }
  121. if (subscription.pendingChange != null && !opts.force) {
  122. throw new ReportError(
  123. 'pending-change',
  124. 'subscription already has a pending change'
  125. )
  126. }
  127. if (subscription.addOns.length === 0) {
  128. if (change.subscription_add_on_unit_amount_in_cents != null) {
  129. throw new ReportError('mismatch', 'add-on not found')
  130. }
  131. } else if (subscription.addOns.length === 1) {
  132. const addOn = subscription.addOns[0]
  133. if (addOn.addOn.code !== 'additional-license') {
  134. throw new ReportError(
  135. 'mismatch',
  136. `unexpected add-on code: ${addOn.addOn.code}`
  137. )
  138. }
  139. if (
  140. addOn.unitAmount !==
  141. change.subscription_add_on_unit_amount_in_cents / 100
  142. ) {
  143. throw new ReportError(
  144. 'mismatch',
  145. `add-on price (${addOn.unitAmount}) does not match expected price (${
  146. change.subscription_add_on_unit_amount_in_cents / 100
  147. })`
  148. )
  149. }
  150. } else {
  151. throw new ReportError('mismatch', 'subscription has more than one addon')
  152. }
  153. }
  154. async function createSubscriptionChange(change, subscription) {
  155. const subscriptionChange = {
  156. timeframe: 'renewal',
  157. unitAmount: change.new_unit_amount,
  158. }
  159. const addOn = subscription.addOns[0]
  160. if (addOn != null) {
  161. subscriptionChange.addOns = [
  162. {
  163. id: addOn.id,
  164. unitAmount: change.new_subscription_add_on_unit_amount_in_cents / 100,
  165. },
  166. ]
  167. }
  168. await recurlyClient.createSubscriptionChange(
  169. `uuid-${change.subscription_uuid}`,
  170. subscriptionChange
  171. )
  172. }
  173. function parseArgs() {
  174. const argv = minimist(process.argv.slice(2), {
  175. string: ['throttle'],
  176. boolean: ['help', 'force'],
  177. })
  178. if (argv.help || argv._.length > 1) {
  179. usage()
  180. process.exit(1)
  181. }
  182. const opts = {
  183. inputFile: argv._[0],
  184. force: argv.force,
  185. throttle: argv.throttle ? parseInt(argv.throttle, 10) : DEFAULT_THROTTLE,
  186. }
  187. return opts
  188. }
  189. function usage() {
  190. console.error(`Usage: node scripts/recurly/change_prices_at_renewal.mjs [OPTS] [INPUT-FILE]
  191. Options:
  192. --throttle DURATION Minimum time (in ms) between subscriptions processed
  193. --force Overwrite any existing pending changes
  194. `)
  195. }
  196. class ReportError extends Error {
  197. constructor(status, message) {
  198. super(message)
  199. this.status = status
  200. }
  201. }
  202. try {
  203. await scriptRunner(main)
  204. } catch (error) {
  205. console.error(error)
  206. process.exit(1)
  207. }