diff --git a/package-lock.json b/package-lock.json index c41bd1125c..2ba761e881 100644 --- a/package-lock.json +++ b/package-lock.json @@ -34424,6 +34424,7 @@ "core-js": "^3.6.2", "crypto-js": "^3.1.9-1", "csurf": "^1.11.0", + "csv": "^6.2.5", "d3": "^3.5.16", "dateformat": "1.0.4-1.2.3", "daterangepicker": "https://github.com/overleaf/daterangepicker/archive/e496d2d44ca53e208c930e4cb4bcf29bcefa4550.tar.gz", @@ -35332,6 +35333,35 @@ "postcss": "^8.2.15" } }, + "services/web/node_modules/csv": { + "version": "6.2.5", + "resolved": "https://registry.npmjs.org/csv/-/csv-6.2.5.tgz", + "integrity": "sha512-T+K0H7MIrlrnP6KxYKo3lK+uLl6OC2Gmwdd81TG/VdkhKvpatl35sR7tyRSpDLGl22y2T+q9KvNHnVtn4OAscQ==", + "dependencies": { + "csv-generate": "^4.2.1", + "csv-parse": "^5.3.3", + "csv-stringify": "^6.2.3", + "stream-transform": "^3.2.1" + }, + "engines": { + "node": ">= 0.1.90" + } + }, + "services/web/node_modules/csv-generate": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/csv-generate/-/csv-generate-4.2.1.tgz", + "integrity": "sha512-w6GFHjvApv6bcJ2xdi9JGsH6ZvUBfC+vUdfefnEzurXG6hMRwzkBLnhztU2H7v7+zfCk1I/knnQ+tGbgpxWrBw==" + }, + "services/web/node_modules/csv-parse": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/csv-parse/-/csv-parse-5.3.3.tgz", + "integrity": "sha512-kEWkAPleNEdhFNkHQpFHu9RYPogsFj3dx6bCxL847fsiLgidzWg0z/O0B1kVWMJUc5ky64zGp18LX2T3DQrOfw==" + }, + "services/web/node_modules/csv-stringify": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/csv-stringify/-/csv-stringify-6.2.3.tgz", + "integrity": "sha512-4qGjUMwnlaRc00gc2jrIYh2w/h1fo25B0mTuY9K8fBiIgtmCX3LcgUbrEGViL98Ci4Se/F5LFEtu8k+dItJVZQ==" + }, "services/web/node_modules/cypress": { "version": "12.2.0", "resolved": "https://registry.npmjs.org/cypress/-/cypress-12.2.0.tgz", @@ -36829,6 +36859,11 @@ "node": "*" } }, + "services/web/node_modules/stream-transform": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/stream-transform/-/stream-transform-3.2.1.tgz", + "integrity": "sha512-ApK+WTJ5bCOf0A2tlec1qhvr8bGEBM/sgXXB7mysdCYgZJO5DZeaV3h3G+g0HnAQ372P5IhiGqnW29zoLOfTzQ==" + }, "services/web/node_modules/streamsearch": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", @@ -43588,6 +43623,7 @@ "css-loader": "^6.7.1", "css-minimizer-webpack-plugin": "^3.4.1", "csurf": "^1.11.0", + "csv": "^6.2.5", "cypress": "12.2.0", "d3": "^3.5.16", "dateformat": "1.0.4-1.2.3", @@ -44242,6 +44278,32 @@ "dev": true, "requires": {} }, + "csv": { + "version": "6.2.5", + "resolved": "https://registry.npmjs.org/csv/-/csv-6.2.5.tgz", + "integrity": "sha512-T+K0H7MIrlrnP6KxYKo3lK+uLl6OC2Gmwdd81TG/VdkhKvpatl35sR7tyRSpDLGl22y2T+q9KvNHnVtn4OAscQ==", + "requires": { + "csv-generate": "^4.2.1", + "csv-parse": "^5.3.3", + "csv-stringify": "^6.2.3", + "stream-transform": "^3.2.1" + } + }, + "csv-generate": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/csv-generate/-/csv-generate-4.2.1.tgz", + "integrity": "sha512-w6GFHjvApv6bcJ2xdi9JGsH6ZvUBfC+vUdfefnEzurXG6hMRwzkBLnhztU2H7v7+zfCk1I/knnQ+tGbgpxWrBw==" + }, + "csv-parse": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/csv-parse/-/csv-parse-5.3.3.tgz", + "integrity": "sha512-kEWkAPleNEdhFNkHQpFHu9RYPogsFj3dx6bCxL847fsiLgidzWg0z/O0B1kVWMJUc5ky64zGp18LX2T3DQrOfw==" + }, + "csv-stringify": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/csv-stringify/-/csv-stringify-6.2.3.tgz", + "integrity": "sha512-4qGjUMwnlaRc00gc2jrIYh2w/h1fo25B0mTuY9K8fBiIgtmCX3LcgUbrEGViL98Ci4Se/F5LFEtu8k+dItJVZQ==" + }, "cypress": { "version": "12.2.0", "resolved": "https://registry.npmjs.org/cypress/-/cypress-12.2.0.tgz", @@ -45264,6 +45326,11 @@ "integrity": "sha1-VHxws0fo0ytOEI6hoqFZ5f3eGcA=", "dev": true }, + "stream-transform": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/stream-transform/-/stream-transform-3.2.1.tgz", + "integrity": "sha512-ApK+WTJ5bCOf0A2tlec1qhvr8bGEBM/sgXXB7mysdCYgZJO5DZeaV3h3G+g0HnAQ372P5IhiGqnW29zoLOfTzQ==" + }, "streamsearch": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", diff --git a/services/web/package.json b/services/web/package.json index 8f837aebf7..1796b147e5 100644 --- a/services/web/package.json +++ b/services/web/package.json @@ -148,6 +148,7 @@ "core-js": "^3.6.2", "crypto-js": "^3.1.9-1", "csurf": "^1.11.0", + "csv": "^6.2.5", "d3": "^3.5.16", "dateformat": "1.0.4-1.2.3", "daterangepicker": "https://github.com/overleaf/daterangepicker/archive/e496d2d44ca53e208c930e4cb4bcf29bcefa4550.tar.gz", diff --git a/services/web/scripts/recurly/change_prices_at_renewal.js b/services/web/scripts/recurly/change_prices_at_renewal.js new file mode 100644 index 0000000000..596b1e8278 --- /dev/null +++ b/services/web/scripts/recurly/change_prices_at_renewal.js @@ -0,0 +1,228 @@ +const fs = require('fs') +const { setTimeout } = require('timers/promises') +const csv = require('csv') +const minimist = require('minimist') +const recurly = require('recurly') +const Settings = require('@overleaf/settings') + +const recurlyClient = new recurly.Client(Settings.apis.recurly.apiKey) + +// 2400 ms corresponds to approx. 3000 API calls per hour +const DEFAULT_THROTTLE = 2400 + +async function main() { + const opts = parseArgs() + const inputStream = opts.inputFile + ? fs.createReadStream(opts.inputFile) + : process.stdin + const csvReader = getCsvReader(inputStream) + const csvWriter = getCsvWriter(process.stdout) + + let lastLoopTimestamp = 0 + for await (const change of csvReader) { + const timeSinceLastLoop = Date.now() - lastLoopTimestamp + if (timeSinceLastLoop < opts.throttle) { + await setTimeout(opts.throttle - timeSinceLastLoop) + } + lastLoopTimestamp = Date.now() + try { + await processChange(change, opts) + csvWriter.write({ + subscription_uuid: change.subscription_uuid, + status: 'changed', + }) + } catch (err) { + if (err instanceof ReportError) { + csvWriter.write({ + subscription_uuid: change.subscription_uuid, + status: err.status, + note: err.message, + }) + } else { + throw err + } + } + } + + process.exit(0) +} + +function getCsvReader(inputStream) { + const parser = csv.parse({ + columns: true, + cast: (value, context) => { + if (context.header) { + return value + } + switch (context.column) { + case 'unit_amount': + case 'new_unit_amount': + return parseFloat(value) + case 'subscription_add_on_unit_amount_in_cents': + case 'new_subscription_add_on_unit_amount_in_cents': + return value === '' ? null : parseInt(value, 10) + default: + return value + } + }, + }) + inputStream.pipe(parser) + return parser +} + +function getCsvWriter(outputStream) { + const writer = csv.stringify({ + columns: ['subscription_uuid', 'status', 'note'], + header: true, + }) + writer.on('error', err => { + console.error(err) + process.exit(1) + }) + writer.pipe(outputStream) + return writer +} + +async function processChange(change, opts) { + const subscription = await fetchSubscription(change.subscription_uuid) + validateChange(change, subscription, opts) + await createSubscriptionChange(change, subscription) +} + +async function fetchSubscription(uuid) { + try { + const subscription = await recurlyClient.getSubscription(`uuid-${uuid}`) + return subscription + } catch (err) { + if (err instanceof recurly.errors.NotFoundError) { + throw new ReportError('not-found', 'subscription not found') + } else { + throw err + } + } +} + +function validateChange(change, subscription, opts) { + if (subscription.state !== 'active') { + throw new ReportError( + 'inactive', + `subscription state: ${subscription.state}` + ) + } + + if (subscription.plan.code !== change.plan_code) { + throw new ReportError( + 'mismatch', + `subscription plan (${subscription.plan.code}) does not match expected plan (${change.plan_code})` + ) + } + + if (subscription.currency !== change.currency) { + throw new ReportError( + 'mismatch', + `subscription currency (${subscription.currency}) does not match expected currency (${change.currency})` + ) + } + + if (subscription.unitAmount !== change.unit_amount) { + throw new ReportError( + 'mismatch', + `subscription price (${subscription.unitAmount}) does not match expected price (${change.unit_amount})` + ) + } + + if (subscription.pendingChange != null && !opts.force) { + throw new ReportError( + 'pending-change', + 'subscription already has a pending change' + ) + } + + if (subscription.addOns.length === 0) { + if (change.subscription_add_on_unit_amount_in_cents != null) { + throw new ReportError('mismatch', 'add-on not found') + } + } else if (subscription.addOns.length === 1) { + const addOn = subscription.addOns[0] + if (addOn.addOn.code !== 'additional-license') { + throw new ReportError( + 'mismatch', + `unexpected add-on code: ${addOn.addOn.code}` + ) + } + if ( + addOn.unitAmount !== + change.subscription_add_on_unit_amount_in_cents / 100 + ) { + throw new ReportError( + 'mismatch', + `add-on price (${addOn.unitAmount}) does not match expected price (${ + change.subscription_add_on_unit_amount_in_cents / 100 + })` + ) + } + } else { + throw new ReportError('mismatch', 'subscription has more than one addon') + } +} + +async function createSubscriptionChange(change, subscription) { + const subscriptionChange = { + timeframe: 'renewal', + unitAmount: change.new_unit_amount, + } + const addOn = subscription.addOns[0] + if (addOn != null) { + subscriptionChange.addOns = [ + { + id: addOn.id, + unitAmount: change.new_subscription_add_on_unit_amount_in_cents / 100, + }, + ] + } + await recurlyClient.createSubscriptionChange( + `uuid-${change.subscription_uuid}`, + subscriptionChange + ) +} + +function parseArgs() { + const argv = minimist(process.argv.slice(2), { + string: ['throttle'], + boolean: ['help', 'force'], + }) + + if (argv.help || argv._.length > 1) { + usage() + process.exit(1) + } + + const opts = { + inputFile: argv._[0], + force: argv.force, + throttle: argv.throttle ? parseInt(argv.throttle, 10) : DEFAULT_THROTTLE, + } + return opts +} + +function usage() { + console.error(`Usage: node scripts/recurly/change_prices_at_renewal.js [OPTS] [INPUT-FILE] + +Options: + + --throttle DURATION Minimum time (in ms) between subscriptions processed + --force Overwrite any existing pending changes +`) +} + +class ReportError extends Error { + constructor(status, message) { + super(message) + this.status = status + } +} + +main().catch(err => { + console.error(err) + process.exit(1) +})