generate_recurly_prices.mjs 3.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131
  1. // script to generate plan prices for recurly from a csv file
  2. //
  3. // Usage:
  4. //
  5. // $ node scripts/recurly/generate_recurly_prices.mjs -f input.csv -o prices.json
  6. //
  7. // The input csv file has the following format:
  8. //
  9. // plan_code,USD,EUR,GBP,...
  10. // student,9,8,7,...
  11. // student-annual,89,79,69,...
  12. // group_professional_2_educational,558,516,446,...
  13. //
  14. // The output file format is the JSON of the plans returned by recurly, with an
  15. // extra _addOns property for the addOns associated with that plan.
  16. //
  17. // The output can be used as input for the upload script `recurly_prices.js`.
  18. import minimist from 'minimist'
  19. // https://github.com/import-js/eslint-plugin-import/issues/1810
  20. // eslint-disable-next-line import/no-unresolved
  21. import * as csv from 'csv/sync'
  22. import _ from 'lodash'
  23. import fs from 'node:fs'
  24. const argv = minimist(process.argv.slice(2), {
  25. string: ['output', 'file'],
  26. alias: { o: 'output', f: 'file' },
  27. default: { output: '/dev/stdout' },
  28. })
  29. // All currency codes are 3 uppercase letters
  30. const CURRENCY_CODE_REGEX = /^[A-Z]{3}$/
  31. // Group plans have a plan code of the form group_name_size_type, e.g.
  32. const GROUP_SIZE_REGEX = /group_\w+_([0-9]+)_\w+/
  33. // Compute prices for the base plan
  34. function computePrices(plan) {
  35. const prices = _.pickBy(plan, (value, key) => CURRENCY_CODE_REGEX.test(key))
  36. const result = []
  37. for (const currency in prices) {
  38. result.push({
  39. currency,
  40. setupFee: 0,
  41. unitAmount: parseInt(prices[currency], 10),
  42. })
  43. }
  44. return _.sortBy(result, 'currency')
  45. }
  46. // Handle prices for license add-ons associated with group plans
  47. function isGroupPlan(plan) {
  48. return plan.plan_code.startsWith('group_')
  49. }
  50. function getGroupSize(plan) {
  51. // extract the group size from the plan code group_name_size_type using a regex
  52. const match = plan.plan_code.match(GROUP_SIZE_REGEX)
  53. if (!match) {
  54. throw new Error(`cannot find group size in plan code: ${plan.plan_code}`)
  55. }
  56. const size = parseInt(match[1], 10)
  57. return size
  58. }
  59. function computeAddOnPrices(prices, size) {
  60. // The price of an additional license is the per-user cost of the base plan,
  61. // i.e. the price of the plan divided by the group size of the plan
  62. return prices.map(price => {
  63. return {
  64. currency: price.currency,
  65. unitAmount: Math.round((100 * price.unitAmount) / size) / 100,
  66. unitAmountDecimal: null,
  67. }
  68. })
  69. }
  70. function shouldSkipPlan(record) {
  71. const planCode = record.plan_code
  72. // Skip non-legacy group plan codes (e.g. group_professional_20_enterprise)
  73. if (planCode.startsWith('group_') && !GROUP_SIZE_REGEX.test(planCode)) {
  74. return true
  75. }
  76. return false
  77. }
  78. // Convert the raw records into the output format
  79. function transformRecordToPlan(record) {
  80. const prices = computePrices(record)
  81. // The base plan has no add-ons
  82. const plan = {
  83. code: record.plan_code,
  84. currencies: prices,
  85. }
  86. // Large group plans have an add-on for additional licenses
  87. if (isGroupPlan(record)) {
  88. const size = getGroupSize(record)
  89. const addOnPrices = computeAddOnPrices(prices, size)
  90. plan._addOns = [
  91. {
  92. code: 'additional-license',
  93. currencies: addOnPrices,
  94. },
  95. ]
  96. }
  97. return plan
  98. }
  99. function generate(inputFile, outputFile) {
  100. const input = fs.readFileSync(inputFile, 'utf8')
  101. const rawRecords = csv.parse(input, { columns: true })
  102. // filter out plans that should be skipped
  103. const filteredRecords = rawRecords.filter(record => !shouldSkipPlan(record))
  104. // transform the raw records into the output format
  105. const plans = _.sortBy(filteredRecords, 'plan_code').map(
  106. transformRecordToPlan
  107. )
  108. const output = JSON.stringify(plans, null, 2)
  109. fs.writeFileSync(outputFile, output)
  110. }
  111. if (argv.file) {
  112. generate(argv.file, argv.output)
  113. } else {
  114. console.log('usage:\n' + ' --file input.csv -o file.json\n')
  115. }