diff --git a/services/web/scripts/recurly/recurly_prices.js b/services/web/scripts/recurly/recurly_prices.js new file mode 100644 index 0000000000..4b7fd47962 --- /dev/null +++ b/services/web/scripts/recurly/recurly_prices.js @@ -0,0 +1,213 @@ +// script to sync plan prices to/from recurly +// +// Usage: +// +// Save current plan and addon prices to file +// $ node scripts/recurly/sync_plan_prices_to_recurly.js --download -o prices.json +// +// Upload new plan and addon prices (change --dry-run to --commit to make the change) +// $ node scripts/recurly/sync_plan_prices_to_recurly.js --upload -f prices.json --dry-run +// +// File format is JSON of the plans returned by recurly, with an extra _addOns property for the +// addOns associated with that plan. +// +// The idea is to download the current prices to a file, update them locally (e.g. via a script) +// and then upload them to recurly. + +const recurly = require('recurly') +const Settings = require('@overleaf/settings') +const minimist = require('minimist') +const _ = require('lodash') +const fs = require('fs') + +const recurlySettings = Settings.apis.recurly +const recurlyApiKey = recurlySettings ? recurlySettings.apiKey : undefined + +const client = new recurly.Client(recurlyApiKey) + +async function getRecurlyPlans() { + const plans = client.listPlans({ params: { limit: 200, state: 'active' } }) + const result = [] + for await (const plan of plans.each()) { + plan._addOns = await getRecurlyPlanAddOns(plan) // store the addOns in a private property + if (VERBOSE) { + console.error('plan', plan.code, 'found', plan._addOns.length, 'addons') + } + result.push(plan) + } + return result +} + +async function getRecurlyPlanAddOns(plan) { + // also store the addons for each plan + const addOns = await client.listPlanAddOns(plan.id, { + params: { limit: 200, state: 'active' }, + }) + const result = [] + for await (const addOn of addOns.each()) { + result.push(addOn) + } + return result +} + +async function download(outputFile) { + const plans = await getRecurlyPlans() + console.error('retrieved', plans.length, 'plans') + fs.writeFileSync(outputFile, JSON.stringify(plans, null, 2)) +} + +async function upload(inputFile) { + const localPlans = JSON.parse(fs.readFileSync(inputFile)) + console.error('local plans', localPlans.length) + console.error('checking remote plans for consistency') + const remotePlans = await getRecurlyPlans() // includes addOns + // compare local with remote + console.error('remote plans', remotePlans.length) + const matching = _.intersectionBy(localPlans, remotePlans, 'code') + const localOnly = _.differenceBy(localPlans, remotePlans, 'code') + const remoteOnly = _.differenceBy(remotePlans, localPlans, 'code') + console.error( + 'plan status:', + matching.length, + 'matching,', + localOnly.length, + 'local only,', + remoteOnly.length, + 'remote only.' + ) + if (localOnly.length > 0) { + const localOnlyPlanCodes = localOnly.map(p => p.code) + throw new Error( + `plans not found in Recurly: ${localOnlyPlanCodes.join(', ')}` + ) + } + // update remote plan pricing with local version + for (const localPlan of localPlans) { + console.error(`=== ${localPlan.code} ===`) + await updatePlan(localPlan) + if (!localPlan._addOns?.length) { + console.error('no addons for this plan') + continue + } + for (const localPlanAddOn of localPlan._addOns) { + await updatePlanAddOn(localPlan, localPlanAddOn) + } + process.stderr.write('\n') + } +} + +async function updatePlan(localPlan) { + const planCodeId = `code-${localPlan.code}` + const originalPlan = await client.getPlan(planCodeId) + const changes = _.differenceWith( + localPlan.currencies, + originalPlan.currencies, + (a, b) => _.isEqual(a, _.assign({}, b)) + ) + if (changes.length === 0) { + console.error('no changes to plan currencies') + return + } else { + console.error('changes', changes) + } + const planUpdate = { currencies: localPlan.currencies } + try { + if (DRY_RUN) { + console.error('skipping update to', planCodeId) + return + } + const newPlan = await client.updatePlan(planCodeId, planUpdate) + if (VERBOSE) { + console.error('new plan', newPlan) + } + } catch (err) { + console.error('failed to update', localPlan.code, 'error', err) + } +} + +async function updatePlanAddOn(plan, localAddOn) { + if (localAddOn.code != null && localAddOn.code !== 'additional-license') { + return + } + const planCodeId = `code-${plan.code}` + const addOnId = 'code-additional-license' + const originalPlanAddOn = await client.getPlanAddOn(planCodeId, addOnId) + const changes = _.differenceWith( + localAddOn.currencies, + originalPlanAddOn.currencies, + (a, b) => _.isEqual(a, _.assign({}, b)) + ) + if (changes.length === 0) { + console.error('no changes to addon currencies') + return + } else { + console.error('changes', changes) + } + const planAddOnUpdate = { currencies: localAddOn.currencies } + try { + if (DRY_RUN) { + console.error('skipping update to additional licencse for', planCodeId) + return + } + const newPlanAddOn = await client.updatePlanAddOn( + planCodeId, + addOnId, + planAddOnUpdate + ) + if (VERBOSE) { + console.error('new plan addon', newPlanAddOn) + } + } catch (err) { + console.error( + 'failed to update plan addon', + plan.code, + '=>', + localAddOn.code + ) + } +} + +const argv = minimist(process.argv.slice(2), { + boolean: ['download', 'upload', 'dry-run', 'commit', 'verbose'], + string: ['output', 'file'], + alias: { o: 'output', f: 'file', v: 'verbose' }, + default: { output: '/dev/stdout' }, +}) + +const DRY_RUN = argv['dry-run'] +const COMMIT = argv.commit +const VERBOSE = argv.verbose + +if (argv.download === argv.upload) { + console.error('specify one of --download or --upload') + process.exit(1) +} + +if (argv.upload && DRY_RUN === COMMIT) { + console.error('specify one of --dry-run or --commit when uploading prices') + process.exit(1) +} + +if (argv.download) { + download(argv.output) + .then(() => { + process.exit(0) + }) + .catch(error => { + console.error({ error }) + process.exit(1) + }) +} else if (argv.upload) { + upload(argv.file) + .then(() => { + process.exit(0) + }) + .catch(error => { + console.error({ error }) + process.exit(1) + }) +} else { + console.log( + 'usage:\n' + ' --save -o file.json\n' + ' --load -f file.json\n' + ) +}