recurly_prices.js 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227
  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 _.sortBy(result, 'code')
  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. if (addOn.code === 'additional-license') {
  44. result.push(addOn)
  45. } else {
  46. console.error('UNRECOGNISED ADD-ON CODE', plan.code, addOn.code)
  47. }
  48. }
  49. return result
  50. }
  51. async function download(outputFile) {
  52. const plans = await getRecurlyPlans()
  53. console.error('retrieved', plans.length, 'plans')
  54. fs.writeFileSync(outputFile, JSON.stringify(plans, null, 2))
  55. }
  56. async function upload(inputFile) {
  57. const localPlans = JSON.parse(fs.readFileSync(inputFile))
  58. console.error('local plans', localPlans.length)
  59. console.error('checking remote plans for consistency')
  60. const remotePlans = await getRecurlyPlans() // includes addOns
  61. // compare local with remote
  62. console.error('remote plans', remotePlans.length)
  63. const matching = _.intersectionBy(localPlans, remotePlans, 'code')
  64. const localOnly = _.differenceBy(localPlans, remotePlans, 'code')
  65. const remoteOnly = _.differenceBy(remotePlans, localPlans, 'code')
  66. console.error(
  67. 'plan status:',
  68. matching.length,
  69. 'matching,',
  70. localOnly.length,
  71. 'local only,',
  72. remoteOnly.length,
  73. 'remote only.'
  74. )
  75. if (localOnly.length > 0) {
  76. const localOnlyPlanCodes = localOnly.map(p => p.code)
  77. throw new Error(
  78. `plans not found in Recurly: ${localOnlyPlanCodes.join(', ')}`
  79. )
  80. }
  81. // update remote plan pricing with local version
  82. for (const localPlan of localPlans) {
  83. console.error(`=== ${localPlan.code} ===`)
  84. await updatePlan(localPlan)
  85. if (!localPlan._addOns?.length) {
  86. console.error('no addons for this plan')
  87. continue
  88. }
  89. for (const localPlanAddOn of localPlan._addOns) {
  90. await updatePlanAddOn(localPlan, localPlanAddOn)
  91. }
  92. process.stderr.write('\n')
  93. }
  94. }
  95. async function updatePlan(localPlan) {
  96. const planCodeId = `code-${localPlan.code}`
  97. const originalPlan = await client.getPlan(planCodeId)
  98. const changes = _.differenceWith(
  99. localPlan.currencies,
  100. originalPlan.currencies,
  101. (a, b) => _.isEqual(a, _.assign({}, b))
  102. )
  103. if (changes.length === 0) {
  104. console.error('no changes to plan currencies')
  105. return
  106. } else {
  107. console.error('changes', changes)
  108. }
  109. const planUpdate = { currencies: localPlan.currencies }
  110. try {
  111. if (DRY_RUN) {
  112. console.error('skipping update to', planCodeId)
  113. return
  114. }
  115. const newPlan = await client.updatePlan(planCodeId, planUpdate)
  116. if (VERBOSE) {
  117. console.error('new plan', newPlan)
  118. }
  119. } catch (err) {
  120. console.error('failed to update', localPlan.code, 'error', err)
  121. }
  122. }
  123. async function updatePlanAddOn(plan, localAddOn) {
  124. if (localAddOn.code != null && localAddOn.code !== 'additional-license') {
  125. return
  126. }
  127. const planCodeId = `code-${plan.code}`
  128. const addOnId = 'code-additional-license'
  129. let originalPlanAddOn
  130. try {
  131. originalPlanAddOn = await client.getPlanAddOn(planCodeId, addOnId)
  132. } catch (error) {
  133. if (error instanceof recurly.errors.NotFoundError) {
  134. console.error('plan add-on not found', planCodeId, addOnId)
  135. return
  136. } else {
  137. throw error
  138. }
  139. }
  140. const changes = _.differenceWith(
  141. localAddOn.currencies,
  142. originalPlanAddOn.currencies,
  143. (a, b) => _.isEqual(a, _.assign({}, b))
  144. )
  145. if (changes.length === 0) {
  146. console.error('no changes to addon currencies')
  147. return
  148. } else {
  149. console.error('changes', changes)
  150. }
  151. const planAddOnUpdate = { currencies: localAddOn.currencies }
  152. try {
  153. if (DRY_RUN) {
  154. console.error('skipping update to additional license for', planCodeId)
  155. return
  156. }
  157. const newPlanAddOn = await client.updatePlanAddOn(
  158. planCodeId,
  159. addOnId,
  160. planAddOnUpdate
  161. )
  162. if (VERBOSE) {
  163. console.error('new plan addon', newPlanAddOn)
  164. }
  165. } catch (err) {
  166. console.error(
  167. 'failed to update plan addon',
  168. plan.code,
  169. '=>',
  170. localAddOn.code
  171. )
  172. }
  173. }
  174. const argv = minimist(process.argv.slice(2), {
  175. boolean: ['download', 'upload', 'dry-run', 'commit', 'verbose'],
  176. string: ['output', 'file'],
  177. alias: { o: 'output', f: 'file', v: 'verbose' },
  178. default: { output: '/dev/stdout' },
  179. })
  180. const DRY_RUN = argv['dry-run']
  181. const COMMIT = argv.commit
  182. const VERBOSE = argv.verbose
  183. if (argv.download === argv.upload) {
  184. console.error('specify one of --download or --upload')
  185. process.exit(1)
  186. }
  187. if (argv.upload && DRY_RUN === COMMIT) {
  188. console.error('specify one of --dry-run or --commit when uploading prices')
  189. process.exit(1)
  190. }
  191. if (argv.download) {
  192. download(argv.output)
  193. .then(() => {
  194. process.exit(0)
  195. })
  196. .catch(error => {
  197. console.error({ error })
  198. process.exit(1)
  199. })
  200. } else if (argv.upload) {
  201. upload(argv.file)
  202. .then(() => {
  203. process.exit(0)
  204. })
  205. .catch(error => {
  206. console.error({ error })
  207. process.exit(1)
  208. })
  209. } else {
  210. console.log(
  211. 'usage:\n' + ' --save -o file.json\n' + ' --load -f file.json\n'
  212. )
  213. }