recurly_prices.js 6.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213
  1. // script to sync plan prices to/from recurly
  2. //
  3. // Usage:
  4. //
  5. // Save current plan and addon prices to file
  6. // $ node scripts/recurly/recurly_prices.js --download -o prices.json
  7. //
  8. // Upload new plan and addon prices (change --dry-run to --commit to make the change)
  9. // $ node scripts/recurly/recurly_prices.js --upload -f prices.json --dry-run
  10. //
  11. // File format is JSON of the plans returned by recurly, with an extra _addOns property for the
  12. // addOns associated with that plan.
  13. //
  14. // The idea is to download the current prices to a file, update them locally (e.g. via a script)
  15. // and then upload them to recurly.
  16. const recurly = require('recurly')
  17. const Settings = require('@overleaf/settings')
  18. const minimist = require('minimist')
  19. const _ = require('lodash')
  20. const fs = require('fs')
  21. const recurlySettings = Settings.apis.recurly
  22. const recurlyApiKey = recurlySettings ? recurlySettings.apiKey : undefined
  23. const client = new recurly.Client(recurlyApiKey)
  24. async function getRecurlyPlans() {
  25. const plans = client.listPlans({ params: { limit: 200, state: 'active' } })
  26. const result = []
  27. for await (const plan of plans.each()) {
  28. plan._addOns = await getRecurlyPlanAddOns(plan) // store the addOns in a private property
  29. if (VERBOSE) {
  30. console.error('plan', plan.code, 'found', plan._addOns.length, 'addons')
  31. }
  32. result.push(plan)
  33. }
  34. return result
  35. }
  36. async function getRecurlyPlanAddOns(plan) {
  37. // also store the addons for each plan
  38. const addOns = await client.listPlanAddOns(plan.id, {
  39. params: { limit: 200, state: 'active' },
  40. })
  41. const result = []
  42. for await (const addOn of addOns.each()) {
  43. result.push(addOn)
  44. }
  45. return result
  46. }
  47. async function download(outputFile) {
  48. const plans = await getRecurlyPlans()
  49. console.error('retrieved', plans.length, 'plans')
  50. fs.writeFileSync(outputFile, JSON.stringify(plans, null, 2))
  51. }
  52. async function upload(inputFile) {
  53. const localPlans = JSON.parse(fs.readFileSync(inputFile))
  54. console.error('local plans', localPlans.length)
  55. console.error('checking remote plans for consistency')
  56. const remotePlans = await getRecurlyPlans() // includes addOns
  57. // compare local with remote
  58. console.error('remote plans', remotePlans.length)
  59. const matching = _.intersectionBy(localPlans, remotePlans, 'code')
  60. const localOnly = _.differenceBy(localPlans, remotePlans, 'code')
  61. const remoteOnly = _.differenceBy(remotePlans, localPlans, 'code')
  62. console.error(
  63. 'plan status:',
  64. matching.length,
  65. 'matching,',
  66. localOnly.length,
  67. 'local only,',
  68. remoteOnly.length,
  69. 'remote only.'
  70. )
  71. if (localOnly.length > 0) {
  72. const localOnlyPlanCodes = localOnly.map(p => p.code)
  73. throw new Error(
  74. `plans not found in Recurly: ${localOnlyPlanCodes.join(', ')}`
  75. )
  76. }
  77. // update remote plan pricing with local version
  78. for (const localPlan of localPlans) {
  79. console.error(`=== ${localPlan.code} ===`)
  80. await updatePlan(localPlan)
  81. if (!localPlan._addOns?.length) {
  82. console.error('no addons for this plan')
  83. continue
  84. }
  85. for (const localPlanAddOn of localPlan._addOns) {
  86. await updatePlanAddOn(localPlan, localPlanAddOn)
  87. }
  88. process.stderr.write('\n')
  89. }
  90. }
  91. async function updatePlan(localPlan) {
  92. const planCodeId = `code-${localPlan.code}`
  93. const originalPlan = await client.getPlan(planCodeId)
  94. const changes = _.differenceWith(
  95. localPlan.currencies,
  96. originalPlan.currencies,
  97. (a, b) => _.isEqual(a, _.assign({}, b))
  98. )
  99. if (changes.length === 0) {
  100. console.error('no changes to plan currencies')
  101. return
  102. } else {
  103. console.error('changes', changes)
  104. }
  105. const planUpdate = { currencies: localPlan.currencies }
  106. try {
  107. if (DRY_RUN) {
  108. console.error('skipping update to', planCodeId)
  109. return
  110. }
  111. const newPlan = await client.updatePlan(planCodeId, planUpdate)
  112. if (VERBOSE) {
  113. console.error('new plan', newPlan)
  114. }
  115. } catch (err) {
  116. console.error('failed to update', localPlan.code, 'error', err)
  117. }
  118. }
  119. async function updatePlanAddOn(plan, localAddOn) {
  120. if (localAddOn.code != null && localAddOn.code !== 'additional-license') {
  121. return
  122. }
  123. const planCodeId = `code-${plan.code}`
  124. const addOnId = 'code-additional-license'
  125. const originalPlanAddOn = await client.getPlanAddOn(planCodeId, addOnId)
  126. const changes = _.differenceWith(
  127. localAddOn.currencies,
  128. originalPlanAddOn.currencies,
  129. (a, b) => _.isEqual(a, _.assign({}, b))
  130. )
  131. if (changes.length === 0) {
  132. console.error('no changes to addon currencies')
  133. return
  134. } else {
  135. console.error('changes', changes)
  136. }
  137. const planAddOnUpdate = { currencies: localAddOn.currencies }
  138. try {
  139. if (DRY_RUN) {
  140. console.error('skipping update to additional licencse for', planCodeId)
  141. return
  142. }
  143. const newPlanAddOn = await client.updatePlanAddOn(
  144. planCodeId,
  145. addOnId,
  146. planAddOnUpdate
  147. )
  148. if (VERBOSE) {
  149. console.error('new plan addon', newPlanAddOn)
  150. }
  151. } catch (err) {
  152. console.error(
  153. 'failed to update plan addon',
  154. plan.code,
  155. '=>',
  156. localAddOn.code
  157. )
  158. }
  159. }
  160. const argv = minimist(process.argv.slice(2), {
  161. boolean: ['download', 'upload', 'dry-run', 'commit', 'verbose'],
  162. string: ['output', 'file'],
  163. alias: { o: 'output', f: 'file', v: 'verbose' },
  164. default: { output: '/dev/stdout' },
  165. })
  166. const DRY_RUN = argv['dry-run']
  167. const COMMIT = argv.commit
  168. const VERBOSE = argv.verbose
  169. if (argv.download === argv.upload) {
  170. console.error('specify one of --download or --upload')
  171. process.exit(1)
  172. }
  173. if (argv.upload && DRY_RUN === COMMIT) {
  174. console.error('specify one of --dry-run or --commit when uploading prices')
  175. process.exit(1)
  176. }
  177. if (argv.download) {
  178. download(argv.output)
  179. .then(() => {
  180. process.exit(0)
  181. })
  182. .catch(error => {
  183. console.error({ error })
  184. process.exit(1)
  185. })
  186. } else if (argv.upload) {
  187. upload(argv.file)
  188. .then(() => {
  189. process.exit(0)
  190. })
  191. .catch(error => {
  192. console.error({ error })
  193. process.exit(1)
  194. })
  195. } else {
  196. console.log(
  197. 'usage:\n' + ' --save -o file.json\n' + ' --load -f file.json\n'
  198. )
  199. }