Jelajahi Sumber

Merge pull request #11435 from overleaf/em-price-increase-script

Script to increase prices for existing customers

GitOrigin-RevId: 8b5357b05a9f09f2ed1b2b52f5dff8d296d06bf3
Eric Mc Sween 3 tahun lalu
induk
melakukan
deb7d82e68

+ 67 - 0
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",

+ 1 - 0
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",

+ 228 - 0
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)
+})