diff --git a/services/web/scripts/recurly/change_existing_subscription_prices.mjs b/services/web/scripts/recurly/change_existing_subscription_prices.mjs new file mode 100644 index 0000000000..0e7e121b94 --- /dev/null +++ b/services/web/scripts/recurly/change_existing_subscription_prices.mjs @@ -0,0 +1,530 @@ +#!/usr/bin/env node + +/** + * This script changes prices for existing Recurly subscriptions. + * It schedules changes to apply at the next renewal. + * + * Usage: + * node scripts/recurly/change_existing_subscription_prices.mjs [OPTS] [INPUT-FILE] + * + * Options: + * --timeframe TIMEFRAME Either 'renewal' or 'now' (default: renewal) + * --output PATH Output file path (default: /tmp/change_prices_output_.csv) + * Use '-' to write to stdout + * --commit Apply changes (without this flag, runs in dry-run mode) + * --throttle DURATION Minimum time (in ms) between subscriptions processed (default: 2400) + * --force Overwrite any existing pending changes + * --help Show a help message + * + * CSV Input Format: + * The CSV must have the following columns: + * - subscription_uuid: Recurly subscription UUID + * - plan_code: Current plan code + * - currency: Current currency + * - unit_amount: Current price per unit + * - new_unit_amount: New price per unit + * - subscription_add_on_unit_amount_in_cents: Current additional-licenses add-on price (optional) + * - new_subscription_add_on_unit_amount_in_cents: New additional-licenses add-on price (optional) + * + * Output: + * Writes a CSV with columns: + * - subscription_uuid: The subscription UUID processed + * - status: Result status (changed, validated, not-found, inactive, mismatch, pending-change, or error) + * - note: Additional information about the status (includes dry run notice when not using --commit) + * + * Running on a Pod: + * This script may run for multiple days. When running using `rake run:longpod[ENV,web]`, + * use one of these strategies to preserve output: + * + * 1. Tail the output file from another session (the filename is logged when the script starts): + * kubectl exec -it -- tail -f /tmp/change_prices_output_.csv > local_backup.csv + * + * 2. Periodically copy the output file to your laptop: + * kubectl cp :/tmp/change_prices_output_.csv ./backup.csv + * + * 3. Write to stdout and capture locally: + * kubectl exec -it -- node scripts/recurly/change_existing_subscription_prices.mjs \ + * --timeframe renewal --commit --output - input.csv > output.csv + * + * For monitoring handoffs, have the next person start tailing (or copying periodically) + * before the current monitor disconnects to ensure no records are lost. + */ + +import fs from 'node:fs' +import path from 'node:path' +import { setTimeout } from 'node:timers/promises' +import * as csv from 'csv' +import minimist from 'minimist' +import recurly from 'recurly' +import Settings from '@overleaf/settings' +import AnalyticsManager from '../../app/src/Features/Analytics/AnalyticsManager.mjs' +import { z } from '../../app/src/infrastructure/Validation.mjs' +import { scriptRunner } from '../lib/ScriptRunner.mjs' + +/** + * @import { ReadStream } from 'node:fs' + * @import { Parser } from 'csv-parse' + * @import { Stringifier } from 'csv-stringify' + * @import { Subscription } from 'recurly' + */ + +/** + * @typedef {Object} CSVSubscriptionChange + * @property {string} subscription_uuid + * @property {string} plan_code + * @property {string} currency + * @property {number} unit_amount + * @property {number} new_unit_amount + * @property {number | null} subscription_add_on_unit_amount_in_cents + * @property {number | null} new_subscription_add_on_unit_amount_in_cents + */ + +/** + * @typedef {'renewal' | 'now'} Timeframe + */ + +const recurlyClient = new recurly.Client(Settings.apis.recurly.apiKey) + +// 2400 ms corresponds to approx. 3000 API calls per hour +const DEFAULT_THROTTLE = 2400 + +/** + * Print usage information to stderr + */ +function usage() { + console.error(`Usage: node scripts/recurly/change_existing_subscription_prices.mjs [OPTS] [INPUT-FILE] + +Options: + --timeframe TIMEFRAME Either 'renewal' or 'now' (default: renewal) + --output PATH Output file path (default: /tmp/change_prices_output_.csv) + Use '-' to write to stdout + --commit Apply changes (without this, runs in dry-run mode) + --throttle DURATION Minimum time between requests in ms (default: ${DEFAULT_THROTTLE}) + --force Overwrite any existing pending changes + --help Show this help message + +See the source file header for detailed documentation on CSV format and pod usage. +`) +} + +/** + * Main script entry point + * @param {function(string): Promise} trackProgress - Function to log progress messages + */ +async function main(trackProgress) { + const opts = parseArgs() + const timestamp = new Date().toISOString().replace(/[:.]/g, '-') + const outputFile = opts.output ?? `/tmp/change_prices_output_${timestamp}.csv` + + await trackProgress('Starting price change script for Recurly') + await trackProgress( + `Timeframe: ${opts.timeframe === 'now' ? 'now (immediate)' : 'renewal (at next cycle)'}` + ) + await trackProgress(`Run mode: ${opts.commit ? 'COMMIT' : 'DRY RUN'}`) + await trackProgress(`Throttle: ${opts.throttle}ms between requests`) + await trackProgress(`Force mode: ${opts.force ? 'enabled' : 'disabled'}`) + + const inputStream = opts.inputFile + ? fs.createReadStream(opts.inputFile) + : process.stdin + const csvReader = getCsvReader(inputStream) + const csvWriter = getCsvWriter(outputFile) + + await trackProgress(`Output: ${outputFile === '-' ? 'stdout' : outputFile}`) + + let processedCount = 0 + let successCount = 0 + let errorCount = 0 + + 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() + + processedCount++ + + try { + const subscription = await processChange( + change, + opts.commit, + opts.force, + opts.timeframe + ) + + if (opts.commit && subscription) { + try { + const userId = subscription.account.code + await AnalyticsManager.recordEventForUser( + userId, + 'script_price_change', + { + subscriptionId: change.subscription_uuid, + } + ) + } catch (err) { + await trackProgress( + `Warning: failed to record analytics event after successful price change for ${change.subscription_uuid}: ${err.message}` + ) + } + } + + csvWriter.write({ + subscription_uuid: change.subscription_uuid, + status: opts.commit ? 'changed' : 'validated', + note: opts.commit ? undefined : 'dry run - no changes applied', + }) + successCount++ + + if (processedCount % 10 === 0) { + await trackProgress( + `Processed ${processedCount} subscriptions (${successCount} ${opts.commit ? 'changed' : 'validated'}, ${errorCount} errors)` + ) + } + } catch (err) { + errorCount++ + if (err instanceof ReportError) { + csvWriter.write({ + subscription_uuid: change.subscription_uuid, + status: err.status, + note: err.message, + }) + } else { + csvWriter.write({ + subscription_uuid: change.subscription_uuid, + status: 'error', + note: err.message, + }) + await trackProgress( + `Error processing ${change.subscription_uuid}: ${err.message}` + ) + } + } + } + + await trackProgress('\n✨ FINAL SUMMARY ✨') + await trackProgress(`šŸ“Š Total processed: ${processedCount}`) + if (opts.commit) { + await trackProgress(`āœ… Successfully changed: ${successCount}`) + } else { + await trackProgress(`āœ… Successfully validated: ${successCount}`) + await trackProgress('ā„¹ļø DRY RUN: No changes were applied to Recurly') + } + await trackProgress(`āŒ Errors: ${errorCount}`) + await trackProgress('šŸŽ‰ Script completed!') + + csvWriter.end() +} + +/** + * Get a CSV parser configured for subscription change input + * @param {ReadStream | NodeJS.ReadableStream} inputStream - The input stream to parse + * @returns {Parser} The configured CSV parser + */ +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': { + const parsed = parseFloat(value) + if (Number.isNaN(parsed)) { + throw new ReportError( + 'mismatch', + `Invalid number for ${context.column} at row ${context.lines}: "${value}"` + ) + } + return parsed + } + case 'subscription_add_on_unit_amount_in_cents': + case 'new_subscription_add_on_unit_amount_in_cents': { + if (value === '') { + return null + } + const parsed = parseInt(value, 10) + if (Number.isNaN(parsed)) { + throw new ReportError( + 'mismatch', + `Invalid number for ${context.column} at row ${context.lines}: "${value}"` + ) + } + return parsed + } + default: + return value + } + }, + }) + inputStream.pipe(parser) + return parser +} + +/** + * Get a CSV stringifier configured for output + * @param {string} outputFile - The output file path to write to, or '-' for stdout + * @returns {Stringifier} The configured CSV stringifier + */ +function getCsvWriter(outputFile) { + let outputStream + if (outputFile === '-') { + outputStream = process.stdout + } else { + fs.mkdirSync(path.dirname(outputFile), { recursive: true }) + outputStream = fs.createWriteStream(outputFile) + } + 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 +} + +/** + * Process a single subscription change + * @param {CSVSubscriptionChange} change - The subscription change to process + * @param {boolean} commit - Whether to commit changes or run in dry-run mode + * @param {boolean} force - Whether to overwrite existing pending changes + * @param {Timeframe} timeframe - When to apply the change + * @returns {Promise} The subscription if commit mode, undefined otherwise + */ +async function processChange(change, commit, force, timeframe) { + const subscription = await fetchSubscription(change.subscription_uuid) + validateChange(change, subscription, force) + + if (!commit) { + // Dry run mode - validation passed, no changes applied + return + } + + await createSubscriptionChange(change, subscription, timeframe) + + return subscription +} + +/** + * Fetch a subscription from Recurly + * @param {string} uuid - The Recurly subscription UUID + * @returns {Promise} The subscription + * @throws {ReportError} If subscription is not found + */ +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 + } + } +} + +/** + * Validate that the subscription matches the expected state + * @param {CSVSubscriptionChange} change - The subscription change to validate + * @param {Subscription} subscription - The Recurly subscription + * @param {boolean} force - Whether to ignore existing pending changes + * @throws {ReportError} If validation fails + */ +function validateChange(change, subscription, force) { + 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 (Math.abs(change.unit_amount - change.new_unit_amount) < 0.01) { + throw new ReportError( + 'mismatch', + `price not expected to change (before: ${change.unit_amount}, after: ${change.new_unit_amount})` + ) + } + + if (subscription.pendingChange != null && !force) { + throw new ReportError( + 'pending-change', + 'subscription already has a pending change' + ) + } + + const additionalLicenseAddOn = subscription.addOns.find( + addOnItem => addOnItem.addOn.code === 'additional-license' + ) + + if (change.subscription_add_on_unit_amount_in_cents != null) { + if (!additionalLicenseAddOn) { + throw new ReportError( + 'mismatch', + 'add-on for additional-license not found' + ) + } + const expectedAddOnPrice = + change.subscription_add_on_unit_amount_in_cents / 100 + if (additionalLicenseAddOn.unitAmount !== expectedAddOnPrice) { + throw new ReportError( + 'mismatch', + `add-on price (${additionalLicenseAddOn.unitAmount}) does not match expected price (${expectedAddOnPrice})` + ) + } + if (change.new_subscription_add_on_unit_amount_in_cents == null) { + throw new ReportError( + 'mismatch', + 'new_subscription_add_on_unit_amount_in_cents is required when subscription_add_on_unit_amount_in_cents is provided' + ) + } + } else if (additionalLicenseAddOn) { + throw new ReportError( + 'mismatch', + 'subscription has additional-license add-on but subscription_add_on_unit_amount_in_cents not provided in CSV' + ) + } +} + +/** + * Create a subscription change in Recurly + * @param {CSVSubscriptionChange} change - The subscription change to create + * @param {Subscription} subscription - The Recurly subscription + * @param {Timeframe} timeframe - When to apply the change + */ +async function createSubscriptionChange(change, subscription, timeframe) { + const subscriptionChange = { + timeframe, + unitAmount: change.new_unit_amount, + } + + if (timeframe === 'now') { + // TODO: the Recurly Node SDK usually uses camel case, but this field isn't in the type definitions... + subscriptionChange.prorationSettings = { + charge: 'none', + credit: 'none', + } + + // TODO: this field is in the API docs but not in their type definitions + subscriptionChange.proration_settings = { + charge: 'none', + credit: 'none', + } + } + + const additionalLicenseAddOn = subscription.addOns.find( + addOnItem => addOnItem.addOn.code === 'additional-license' + ) + if (additionalLicenseAddOn != null) { + subscriptionChange.addOns = subscription.addOns.map(item => { + const result = { id: item.id } + if (item.addOn.code === 'additional-license') { + result.unitAmount = + change.new_subscription_add_on_unit_amount_in_cents / 100 + } + return result + }) + } + await recurlyClient.createSubscriptionChange( + `uuid-${change.subscription_uuid}`, + subscriptionChange + ) +} + +const paramsSchema = z.object({ + timeframe: z.enum(['renewal', 'now']).default('renewal'), + output: z.string().optional(), + commit: z.boolean().default(false), + force: z.boolean().default(false), + throttle: z + .string() + .optional() + .transform(val => (val ? parseInt(val, 10) : DEFAULT_THROTTLE)), + _: z.array(z.string()).max(1), + help: z.boolean().optional(), +}) + +/** + * Parse command line arguments + * @returns {{inputFile: string | undefined, output: string | undefined, force: boolean, commit: boolean, timeframe: 'renewal' | 'now', throttle: number}} Parsed options + */ +function parseArgs() { + const argv = minimist(process.argv.slice(2), { + string: ['throttle', 'timeframe', 'output'], + boolean: ['help', 'force', 'commit'], + }) + + if (argv.help) { + usage() + process.exit(0) + } + + const parseResult = paramsSchema.safeParse(argv) + + if (!parseResult.success) { + console.error(`Invalid parameters: ${parseResult.error.message}`) + usage() + process.exit(1) + } + + const { timeframe, output, commit, force, throttle, _ } = parseResult.data + + return { + inputFile: _[0], + output, + force, + commit, + timeframe, + throttle, + } +} + +/** + * Custom error class for reportable errors that should be written to CSV output + */ +class ReportError extends Error { + /** + * @param {string} status - The error status code for CSV output + * @param {string} message - The error message + */ + constructor(status, message) { + super(message) + this.status = status + } +} + +try { + await scriptRunner(main) + process.exit(0) +} catch (error) { + console.error(error) + process.exit(1) +} diff --git a/services/web/scripts/recurly/change_prices_at_renewal.mjs b/services/web/scripts/recurly/change_prices_at_renewal.mjs deleted file mode 100644 index 53016acc7d..0000000000 --- a/services/web/scripts/recurly/change_prices_at_renewal.mjs +++ /dev/null @@ -1,231 +0,0 @@ -import fs from 'node:fs' -import { setTimeout } from 'node:timers/promises' -import * as csv from 'csv' -import minimist from 'minimist' -import recurly from 'recurly' -import Settings from '@overleaf/settings' -import { scriptRunner } from '../lib/ScriptRunner.mjs' - -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.mjs [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 - } -} - -try { - await scriptRunner(main) -} catch (error) { - console.error(error) - process.exit(1) -}