change_prices_at_renewal.js 5.8 KB

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