Просмотр исходного кода

[Security upgrade] bump brace-expansion to 5.0.6 (#33915)

* Bump brace-expansion to 5.0.6 in linked-url-proxy

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* drop unnecessary brace-expansion resolution; ^5.0.5 already permits 5.0.6

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
GitOrigin-RevId: 837dcd88e5e0a6181d3ac2fe4f512a6ec1904002
Lucie Germain 2 месяцев назад
Родитель
Сommit
d52b5ae141
40 измененных файлов с 0 добавлено и 14514 удалено
  1. 0 4
      services/web/scripts/recurly/Gemfile
  2. 0 530
      services/web/scripts/recurly/change_existing_subscription_prices.mjs
  3. 0 439
      services/web/scripts/recurly/check_upcoming_schedules.mjs
  4. 0 350
      services/web/scripts/recurly/cleanup-recurly-subscriptions-post-migration.mjs
  5. 0 131
      services/web/scripts/recurly/collect_paypal_past_due_invoice.mjs
  6. 0 804
      services/web/scripts/recurly/compare_recurly_stripe_customers.mjs
  7. 0 63
      services/web/scripts/recurly/generate_addon_prices.mjs
  8. 0 131
      services/web/scripts/recurly/generate_recurly_prices.mjs
  9. 0 104
      services/web/scripts/recurly/get_manually_billed_users_details.mjs
  10. 0 107
      services/web/scripts/recurly/get_paypal_accounts_csv.mjs
  11. 0 48
      services/web/scripts/recurly/get_recurly_group_prices.mjs
  12. 0 293
      services/web/scripts/recurly/list_recurly_accounts.mjs
  13. 0 2511
      services/web/scripts/recurly/migrate_recurly_customers_to_stripe.mjs
  14. 0 226
      services/web/scripts/recurly/recurly_prices.mjs
  15. 0 137
      services/web/scripts/recurly/resync_recurly_state_single_subscription.mjs
  16. 0 191
      services/web/scripts/recurly/resync_subscriptions.mjs
  17. 0 606
      services/web/scripts/recurly/rollback_price_changes.mjs
  18. 0 138
      services/web/scripts/recurly/set_manually_collected_subscriptions.mjs
  19. 0 219
      services/web/scripts/recurly/setup_assistant_addon.mjs
  20. 0 63
      services/web/scripts/recurly/sync_recurly.rb
  21. 0 104
      services/web/scripts/recurly/update_terms_and_conditions_for_manually_billed_users.mjs
  22. 0 1
      services/web/scripts/stripe/.gitignore
  23. 0 310
      services/web/scripts/stripe/RateLimiter.mjs
  24. 0 324
      services/web/scripts/stripe/archive_prices_by_version_key.mjs
  25. 0 340
      services/web/scripts/stripe/bulk-cancel-subscription-schedules.mjs
  26. 0 391
      services/web/scripts/stripe/bulk-cancel-subscriptions.mjs
  27. 0 362
      services/web/scripts/stripe/bulk-release-subscription-schedules.mjs
  28. 0 553
      services/web/scripts/stripe/calculate_taxes.mjs
  29. 0 746
      services/web/scripts/stripe/change_existing_subscription_prices.mjs
  30. 0 378
      services/web/scripts/stripe/convert_yearly_prices_to_12months.mjs
  31. 0 125
      services/web/scripts/stripe/create_coupons.mjs
  32. 0 334
      services/web/scripts/stripe/create_custom_prices_from_csv.mjs
  33. 0 294
      services/web/scripts/stripe/create_prices_from_csv.mjs
  34. 0 168
      services/web/scripts/stripe/export_products_from_environment.mjs
  35. 0 930
      services/web/scripts/stripe/finalize-stripe-subscription-migration.mjs
  36. 0 130
      services/web/scripts/stripe/helpers.mjs
  37. 0 282
      services/web/scripts/stripe/import_products_to_environment.mjs
  38. 0 512
      services/web/scripts/stripe/rollback-finalized-stripe-migration.mjs
  39. 0 585
      services/web/scripts/stripe/rollback_price_changes.mjs
  40. 0 550
      services/web/scripts/stripe/update_prices_from_csv.mjs

+ 0 - 4
services/web/scripts/recurly/Gemfile

@@ -1,4 +0,0 @@
-source 'https://rubygems.org'
-
-gem 'recurly'
-gem 'json'

+ 0 - 530
services/web/scripts/recurly/change_existing_subscription_prices.mjs

@@ -1,530 +0,0 @@
-#!/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_<timestamp>.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 <pod-name> -- tail -f /tmp/change_prices_output_<timestamp>.csv > local_backup.csv
- *
- *   2. Periodically copy the output file to your laptop:
- *      kubectl cp <pod-name>:/tmp/change_prices_output_<timestamp>.csv ./backup.csv
- *
- *   3. Write to stdout and capture locally:
- *      kubectl exec -it <pod-name> -- 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_<timestamp>.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<void>} 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<Subscription | undefined>} 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<Subscription>} 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)
-}

+ 0 - 439
services/web/scripts/recurly/check_upcoming_schedules.mjs

@@ -1,439 +0,0 @@
-#!/usr/bin/env node
-
-// @ts-check
-
-/**
- * This script checks upcoming schedules for existing Recurly subscriptions.
- *
- * Usage:
- *   node scripts/recurly/check_upcoming_schedules.mjs [OPTS] [INPUT-FILE]
- *
- * Options:
- *   --output PATH          Output file path (default: /tmp/check_schedules_output_<timestamp>.csv)
- *                          Use '-' to write to stdout
- *   --throttle DURATION    Minimum time (in ms) between subscriptions processed (default: 2400)
- *   --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)
- *   - user_id: Overleaf user ID
- *
- * Output:
- *   Writes a CSV with columns:
- *   - subscription_uuid: The subscription UUID processed
- *   - status: Result status (validated, not-found, inactive, mismatch, no-pending-change, or error)
- *   - note: Additional information about the status
- *   - user_id: Overleaf user ID
- *
- * Running on a Pod:
- *   This script may run for a long time. 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 <pod-name> -- tail -f /tmp/check_schedules_output_<timestamp>.csv > local_backup.csv
- *
- *   2. Periodically copy the output file to your laptop:
- *      kubectl cp <pod-name>:/tmp/check_schedules_output_<timestamp>.csv ./backup.csv
- *
- *   3. Write to stdout and capture locally:
- *      kubectl exec -it <pod-name> -- node scripts/recurly/check_upcoming_schedules.mjs \
- *        --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 { 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
- * @property {string} user_id
- */
-
-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/check_upcoming_schedules.mjs [OPTS] [INPUT-FILE]
-
-Options:
-    --output PATH          Output file path (default: /tmp/check_schedules_output_<timestamp>.csv)
-                           Use '-' to write to stdout
-    --throttle DURATION    Minimum time between requests in ms (default: ${DEFAULT_THROTTLE})
-    --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<void>} 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/check_schedules_output_${timestamp}.csv`
-
-  await trackProgress('Starting schedule check script for Recurly')
-  await trackProgress(`Throttle: ${opts.throttle}ms between requests`)
-
-  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 fetchSubscription(change.subscription_uuid)
-      validateChange(change, subscription)
-
-      csvWriter.write({
-        subscription_uuid: change.subscription_uuid,
-        status: 'validated',
-        note: 'everything looks as expected',
-        user_id: change.user_id,
-      })
-      successCount++
-
-      if (processedCount % 10 === 0) {
-        await trackProgress(
-          `Processed ${processedCount} subscriptions (${successCount}, ${errorCount} errors)`
-        )
-      }
-    } catch (err) {
-      errorCount++
-      if (err instanceof ReportError) {
-        csvWriter.write({
-          subscription_uuid: change.subscription_uuid,
-          status: err.status,
-          note: err.message,
-          user_id: change.user_id,
-        })
-      } else if (err instanceof Error) {
-        csvWriter.write({
-          subscription_uuid: change.subscription_uuid,
-          status: 'error',
-          note: err.message,
-          user_id: change.user_id,
-        })
-        await trackProgress(
-          `Error processing ${change.subscription_uuid}: ${err.message}`
-        )
-      }
-    }
-  }
-
-  await trackProgress('\n✨ FINAL SUMMARY ✨')
-  await trackProgress(`📊 Total processed: ${processedCount}`)
-  await trackProgress(`✅ Successfully validated: ${successCount}`)
-  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', 'user_id'],
-    header: true,
-  })
-  writer.on('error', err => {
-    console.error(err)
-    process.exit(1)
-  })
-  writer.pipe(outputStream)
-  return writer
-}
-
-/**
- * Fetch a subscription from Recurly
- * @param {string} uuid - The Recurly subscription UUID
- * @returns {Promise<Subscription>} 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
- * @throws {ReportError} If validation fails
- */
-function validateChange(change, subscription) {
-  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 ||
-    Math.abs(subscription.unitAmount - change.unit_amount) > 0.01
-  ) {
-    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) {
-    throw new ReportError(
-      'no-pending-change',
-      'subscription has no pending change'
-    )
-  }
-
-  if (
-    !subscription.pendingChange.unitAmount ||
-    Math.abs(subscription.pendingChange.unitAmount - change.new_unit_amount) >
-      0.01
-  ) {
-    throw new ReportError(
-      'mismatch',
-      `subscription price (${subscription.pendingChange.unitAmount}) does not match expected price (${change.new_unit_amount})`
-    )
-  }
-
-  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 ||
-      Math.abs(additionalLicenseAddOn.unitAmount - expectedAddOnPrice) > 0.01
-    ) {
-      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'
-    )
-  }
-}
-
-const paramsSchema = z.object({
-  output: z.string().optional(),
-  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, throttle: number}} Parsed options
- */
-function parseArgs() {
-  const argv = minimist(process.argv.slice(2), {
-    string: ['throttle', 'output'],
-    boolean: ['help'],
-  })
-
-  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 { output, throttle, _ } = parseResult.data
-
-  return {
-    inputFile: _[0],
-    output,
-    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)
-}

+ 0 - 350
services/web/scripts/recurly/cleanup-recurly-subscriptions-post-migration.mjs

@@ -1,350 +0,0 @@
-#!/usr/bin/env node
-
-/**
- * This script CLEANS UP Recurly subscriptions after migration to Stripe is finalized.
- *
- * ⚠️ IMPORTANT NOTES:
- * - Only run this AFTER the cutover from Recurly to Stripe is complete and verified
- * - After running this script, rollback is NO LONGER POSSIBLE
- * - NEVER extend this script to close Recurly accounts or remove billing info for Paypal
- *   (could trigger PayPal billing agreement cancellation)
- * - This script will trigger lifecycle emails to be sent. Please turn off:
- *     "Subscription Expired Template" (https://sharelatex.recurly.com/emails/subscription_expired/template/edit)
- *
- * Usage:
- *   node scripts/recurly/cleanup-recurly-subscriptions-post-migration.mjs [OPTS] [INPUT-FILE]
- *
- * Options:
- *   --output PATH                 Output file path (default: /tmp/cancel_output_<timestamp>.csv)
- *   --commit                      Apply changes (without this, runs in dry-run mode)
- *   --concurrency N               Number of customers to process concurrently (default: 10)
- *   --recurly-rate-limit N        Requests per second for Recurly (default: 10)
- *   --recurly-api-retries N       Number of retries on Recurly 429s (default: 5)
- *   --recurly-retry-delay-ms N    Delay between Recurly retries in ms (default: 1000)
- *   --help                        Show help message
- *
- * CSV Input Format:
- *   recurly_account_code,previous_recurly_subscription_id
- *   507f1f77bcf86cd799439011,abcd1234efgh5678
- *
- * CSV Output Format:
- *   recurly_account_code,previous_recurly_subscription_id,status,note
- *
- * Note: recurly_account_code is the Overleaf user ID (admin_id)
- */
-
-import fs from 'node:fs'
-import path from 'node:path'
-import * as csv from 'csv'
-import minimist from 'minimist'
-import PQueue from 'p-queue'
-import RecurlyClient from '../../app/src/Features/Subscription/RecurlyClient.mjs'
-import { z } from '../../app/src/infrastructure/Validation.mjs'
-import { scriptRunner } from '../lib/ScriptRunner.mjs'
-import { Subscription } from '../../app/src/models/Subscription.mjs'
-import { ReportError } from '../stripe/helpers.mjs'
-import {
-  createRateLimitedApiWrappers,
-  DEFAULT_RECURLY_RATE_LIMIT,
-  DEFAULT_RECURLY_API_RETRIES,
-  DEFAULT_RECURLY_RETRY_DELAY_MS,
-} from '../stripe/RateLimiter.mjs'
-
-const DEFAULT_CONCURRENCY = 10
-
-// rate limiters - initialized in main()
-let rateLimiters
-
-function usage() {
-  console.error(`Usage: node scripts/recurly/cleanup-recurly-subscriptions-post-migration.mjs [OPTS] [INPUT-FILE]
-
-Options:
-    --output PATH                 Output file path (default: /tmp/terminate_output_<timestamp>.csv)
-    --commit                      Apply changes (without this, runs in dry-run mode)
-    --concurrency N               Number of customers to process concurrently (default: ${DEFAULT_CONCURRENCY})
-    --recurly-rate-limit N        Requests per second for Recurly (default: ${DEFAULT_RECURLY_RATE_LIMIT})
-    --recurly-api-retries N       Number of retries on Recurly 429s (default: ${DEFAULT_RECURLY_API_RETRIES})
-    --recurly-retry-delay-ms N    Delay between Recurly retries in ms (default: ${DEFAULT_RECURLY_RETRY_DELAY_MS})
-    --help                        Show this help message
-`)
-}
-
-async function main(trackProgress) {
-  const opts = parseArgs()
-  const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
-  const outputFile = opts.output ?? `/tmp/terminate_output_${timestamp}.csv`
-
-  // initialize rate limiters
-  rateLimiters = createRateLimitedApiWrappers({
-    recurlyRateLimit: opts.recurlyRateLimit,
-    recurlyApiRetries: opts.recurlyApiRetries,
-    recurlyRetryDelayMs: opts.recurlyRetryDelayMs,
-  })
-
-  await trackProgress('Starting Recurly subscription termination')
-  await trackProgress(`Run mode: ${opts.commit ? 'COMMIT' : 'DRY RUN'}`)
-  await trackProgress(`Rate limit: Recurly ${opts.recurlyRateLimit}/s`)
-  await trackProgress(`Concurrency: ${opts.concurrency}`)
-
-  const inputStream = opts.inputFile
-    ? fs.createReadStream(opts.inputFile)
-    : process.stdin
-  const csvReader = getCsvReader(inputStream)
-  const csvWriter = getCsvWriter(outputFile)
-
-  await trackProgress(`Output: ${outputFile}`)
-
-  let processedCount = 0
-  let successCount = 0
-  let errorCount = 0
-
-  const queue = new PQueue({ concurrency: opts.concurrency })
-  const maxQueueSize = opts.concurrency
-
-  try {
-    for await (const input of csvReader) {
-      if (queue.size >= maxQueueSize) {
-        await queue.onSizeLessThan(maxQueueSize)
-      }
-
-      queue.add(async () => {
-        try {
-          const result = await processTermination(input, opts.commit)
-
-          csvWriter.write({
-            recurly_account_code: input.recurly_account_code,
-            status: result.status,
-            note: result.note,
-            previous_recurly_subscription_id:
-              input.previous_recurly_subscription_id,
-          })
-
-          if (result.status === 'terminated' || result.status === 'validated') {
-            successCount++
-          } else {
-            errorCount++
-          }
-        } catch (err) {
-          errorCount++
-          if (err instanceof ReportError) {
-            csvWriter.write({
-              recurly_account_code: input.recurly_account_code,
-              previous_recurly_subscription_id:
-                input.previous_recurly_subscription_id,
-              status: err.status,
-              note: err.message,
-            })
-          } else {
-            csvWriter.write({
-              recurly_account_code: input.recurly_account_code,
-              previous_recurly_subscription_id:
-                input.previous_recurly_subscription_id,
-              status: 'error',
-              note: err.message,
-            })
-          }
-        }
-
-        processedCount++
-        if (processedCount % 25 === 0) {
-          await trackProgress(
-            `Progress: ${processedCount} processed, ${successCount} successful, ${errorCount} errors`
-          )
-        }
-      })
-    }
-  } finally {
-    await queue.onIdle()
-  }
-
-  await trackProgress(`✅ Total processed: ${processedCount}`)
-  if (opts.commit) {
-    await trackProgress(`✅ Successfully terminated: ${successCount}`)
-  } else {
-    await trackProgress(`✅ Successfully validated: ${successCount}`)
-    await trackProgress('ℹ️  DRY RUN: No changes were applied')
-  }
-  await trackProgress(`❌ Errors: ${errorCount}`)
-  await trackProgress('🎉 Script completed!')
-
-  csvWriter.end()
-}
-
-function getCsvReader(inputStream) {
-  const parser = csv.parse({ columns: true })
-  inputStream.pipe(parser)
-  return parser
-}
-
-function getCsvWriter(outputFile) {
-  fs.mkdirSync(path.dirname(outputFile), { recursive: true })
-  const outputStream = fs.createWriteStream(outputFile)
-
-  const writer = csv.stringify({
-    columns: [
-      'recurly_account_code',
-      'previous_recurly_subscription_id',
-      'status',
-      'note',
-    ],
-    header: true,
-  })
-
-  writer.on('error', err => {
-    console.error(err)
-    process.exit(1)
-  })
-
-  writer.pipe(outputStream)
-  return writer
-}
-
-async function processTermination(input, commit) {
-  const {
-    recurly_account_code: adminUserId,
-    previous_recurly_subscription_id: subscriptionUuid,
-  } = input
-
-  // 1. Fetch Mongo subscription
-  const mongoSubscription = await Subscription.findOne({
-    admin_id: adminUserId,
-  }).exec()
-
-  // 2. Verify subscription has been migrated to Stripe (skipping if the
-  // Mongo subscription is missing, which would indicate that the Stripe
-  // subscription has expired after the cutover)
-  if (
-    mongoSubscription &&
-    !mongoSubscription.paymentProvider?.service?.includes('stripe')
-  ) {
-    throw new ReportError(
-      'not-migrated',
-      'Subscription has not been migrated to Stripe yet'
-    )
-  }
-
-  // 3. Fetch Recurly subscription and verify it is in our expected state
-  let recurlySubscription
-  let isInExpectedEndState = true
-  try {
-    recurlySubscription = await rateLimiters.requestWithRetries(
-      'recurly',
-      () => RecurlyClient.promises.getSubscription(subscriptionUuid),
-      { operation: 'getSubscription', subscriptionUuid }
-    )
-  } catch (err) {
-    isInExpectedEndState = false
-  }
-
-  if (recurlySubscription) {
-    const nineYearsFromNow = new Date()
-    nineYearsFromNow.setFullYear(new Date().getFullYear() + 9)
-
-    if (
-      recurlySubscription.periodEnd > nineYearsFromNow &&
-      recurlySubscription.state === 'canceled'
-    ) {
-      isInExpectedEndState = false
-    }
-  } else {
-    throw new ReportError(
-      'missing-subscription',
-      'Recurly subscription not found'
-    )
-  }
-  const warning = isInExpectedEndState
-    ? ''
-    : `(subscription was NOT in expected state: periodEnd=${recurlySubscription?.periodEnd?.toISOString()}, state=${recurlySubscription?.state})`
-
-  // 4. If commit mode, terminate the subscription
-  if (commit) {
-    try {
-      await rateLimiters.requestWithRetries(
-        'recurly',
-        () =>
-          RecurlyClient.promises.terminateSubscriptionByUuid(subscriptionUuid),
-        { operation: 'terminateSubscriptionByUuid', subscriptionUuid }
-      )
-      return {
-        status: isInExpectedEndState
-          ? 'terminated'
-          : 'terminated-with-warnings',
-        note: `Successfully terminated Recurly subscription ${warning}`,
-      }
-    } catch (err) {
-      throw new ReportError(
-        'terminate-failed',
-        `Failed to terminate: ${err.message} ${warning}`
-      )
-    }
-  } else {
-    const note = isInExpectedEndState
-      ? 'DRY RUN: Ready to terminate'
-      : `DRY RUN: Can terminate, with this warning: ${warning}`
-
-    return {
-      status: isInExpectedEndState ? 'validated' : 'validated-with-warnings',
-      note,
-    }
-  }
-}
-
-function parseArgs() {
-  const args = minimist(process.argv.slice(2), {
-    string: [
-      'output',
-      'concurrency',
-      'recurly-rate-limit',
-      'recurly-api-retries',
-      'recurly-retry-delay-ms',
-    ],
-    boolean: ['commit', 'help'],
-    default: {
-      commit: false,
-      concurrency: DEFAULT_CONCURRENCY,
-      'recurly-rate-limit': DEFAULT_RECURLY_RATE_LIMIT,
-      'recurly-api-retries': DEFAULT_RECURLY_API_RETRIES,
-      'recurly-retry-delay-ms': DEFAULT_RECURLY_RETRY_DELAY_MS,
-    },
-  })
-
-  if (args.help) {
-    usage()
-    process.exit(0)
-  }
-
-  const inputFile = args._[0]
-  const paramsSchema = z.object({
-    output: z.string().optional(),
-    commit: z.boolean(),
-    concurrency: z.number().int().positive(),
-    recurlyRateLimit: z.number().positive(),
-    recurlyApiRetries: z.number().int().nonnegative(),
-    recurlyRetryDelayMs: z.number().int().nonnegative(),
-    inputFile: z.string().optional(),
-  })
-
-  try {
-    return paramsSchema.parse({
-      output: args.output,
-      commit: args.commit,
-      concurrency: Number(args.concurrency),
-      recurlyRateLimit: Number(args['recurly-rate-limit']),
-      recurlyApiRetries: Number(args['recurly-api-retries']),
-      recurlyRetryDelayMs: Number(args['recurly-retry-delay-ms']),
-      inputFile,
-    })
-  } catch (err) {
-    console.error('Invalid arguments:', err.message)
-    usage()
-    process.exit(1)
-  }
-}
-
-try {
-  await scriptRunner(main)
-  process.exit(0)
-} catch (error) {
-  console.error(error)
-  process.exit(1)
-}

+ 0 - 131
services/web/scripts/recurly/collect_paypal_past_due_invoice.mjs

@@ -1,131 +0,0 @@
-import RecurlyWrapper from '../../app/src/Features/Subscription/RecurlyWrapper.mjs'
-import minimist from 'minimist'
-import logger from '@overleaf/logger'
-import { fileURLToPath } from 'node:url'
-import { scriptRunner } from '../lib/ScriptRunner.mjs'
-
-const waitMs =
-  fileURLToPath(import.meta.url) === process.argv[1]
-    ? timeout => new Promise(resolve => setTimeout(() => resolve(), timeout))
-    : () => Promise.resolve()
-
-// NOTE: Errors are not propagated to the caller
-const handleAPIError = async (source, id, error) => {
-  logger.warn(`Errors in ${source} with id=${id}`, error)
-  if (typeof error === 'string' && error.match(/429$/)) {
-    return waitMs(1000 * 60 * 5)
-  }
-  await waitMs(80)
-}
-
-/**
- * @returns {Promise<{
- *   INVOICES_COLLECTED: string[],
- *   INVOICES_COLLECTED_SUCCESS: string[],
- *   USERS_COLLECTED: string[],
- * }>}
- */
-export async function collectPastDueInvoices(DRY_RUN = false) {
-  const attemptInvoiceCollection = async invoice => {
-    const isPaypal = await isAccountUsingPaypal(invoice)
-
-    if (!isPaypal) {
-      return
-    }
-    const accountId = invoice.account.url.match(/accounts\/(.*)/)[1]
-    if (USERS_COLLECTED.indexOf(accountId) > -1) {
-      logger.warn(`Skipping duplicate user ${accountId}`)
-      return
-    }
-    INVOICES_COLLECTED.push(invoice.invoice_number)
-    USERS_COLLECTED.push(accountId)
-    if (DRY_RUN) {
-      return
-    }
-    try {
-      await RecurlyWrapper.promises.attemptInvoiceCollection(
-        invoice.invoice_number
-      )
-      INVOICES_COLLECTED_SUCCESS.push(invoice.invoice_number)
-      await waitMs(80)
-    } catch (error) {
-      return handleAPIError(
-        'attemptInvoiceCollection',
-        invoice.invoice_number,
-        error
-      )
-    }
-  }
-
-  const isAccountUsingPaypal = async invoice => {
-    const accountId = invoice.account.url.match(/accounts\/(.*)/)[1]
-    try {
-      const response = await RecurlyWrapper.promises.getBillingInfo(accountId)
-      await waitMs(80)
-      return !!response.billing_info.paypal_billing_agreement_id
-    } catch (error) {
-      return handleAPIError('billing info', accountId, error)
-    }
-  }
-
-  const attemptInvoicesCollection = async () => {
-    let getPage = await RecurlyWrapper.promises.getPaginatedEndpointIterator(
-      'invoices',
-      { state: 'past_due' }
-    )
-
-    while (getPage) {
-      const { items, getNextPage } = await getPage()
-      logger.info('invoices', items?.length)
-      for (const invoice of items) {
-        await attemptInvoiceCollection(invoice)
-      }
-      getPage = getNextPage
-    }
-  }
-
-  const INVOICES_COLLECTED = []
-  const INVOICES_COLLECTED_SUCCESS = []
-  const USERS_COLLECTED = []
-
-  try {
-    await attemptInvoicesCollection()
-
-    const diff = INVOICES_COLLECTED.length - INVOICES_COLLECTED_SUCCESS.length
-    if (diff !== 0) {
-      logger.warn(`Invoices collection failed for ${diff} invoices`)
-    }
-
-    return {
-      INVOICES_COLLECTED,
-      INVOICES_COLLECTED_SUCCESS,
-      USERS_COLLECTED,
-    }
-  } finally {
-    logger.info(
-      {
-        INVOICES_COLLECTED,
-        INVOICES_COLLECTED_SUCCESS,
-        USERS_COLLECTED,
-      },
-      `DONE (DRY_RUN=${DRY_RUN}). ${INVOICES_COLLECTED.length} invoices collection attempts for ${USERS_COLLECTED.length} users. ${INVOICES_COLLECTED_SUCCESS.length} successful collections`
-    )
-  }
-}
-
-async function main() {
-  const argv = minimist(process.argv.slice(2))
-  const DRY_RUN = argv.n !== undefined
-  await collectPastDueInvoices(DRY_RUN)
-}
-
-if (fileURLToPath(import.meta.url) === process.argv[1]) {
-  try {
-    await scriptRunner(main)
-    logger.info('Done.')
-    process.exit(0)
-  } catch (error) {
-    logger.error({ error }, 'Error')
-    process.exit(1)
-  }
-}

+ 0 - 804
services/web/scripts/recurly/compare_recurly_stripe_customers.mjs

@@ -1,804 +0,0 @@
-#!/usr/bin/env node
-
-/**
- * This script compares existing Stripe customer records with data from Recurly
- * to detect any drift since the last migration run.
- *
- * It is a read-only companion to migrate_recurly_customers_to_stripe.mjs.
- * It uses the same normalization logic but makes no changes to Stripe.
- *
- * Input CSV format:
- *   recurly_account_code,target_stripe_account,stripe_customer_id
- *
- * Output files:
- *   --output (comparison file): One row per customer with match/mismatch status
- *     Format: recurly_account_code,target_stripe_account,stripe_customer_id,status,diffs
- *
- *   <output>_details.json: Detailed diff for each customer with mismatches
- *     Format: Array of { recurly_account_code, stripe_customer_id, diffs: { field: { recurly, stripe } } }
- *
- *   <output>_errors.csv: Records that failed
- *     Format: recurly_account_code,target_stripe_account,stripe_customer_id,error
- *
- * Usage:
- *   node scripts/recurly/compare_recurly_stripe_customers.mjs --input customers.csv --output comparison.csv --comparison-date 2026-03-19
- *
- * Options:
- *   --input, -i <file>     Path to input CSV file (required)
- *   --output, -o <file>    Path to output CSV file (required)
- *   --comparison-date <date> Date in YYYY-MM-DD to compare against (required)
- *   --limit, -l <n>        Limit number of records processed (default: no limit)
- *   --concurrency, -c <n>  Number of customers to process concurrently (default: 10)
- *   --recurly-rate-limit <n>     Requests per second for Recurly (default: 10)
- *   --recurly-api-retries <n>    Number of retries on Recurly 429s (default: 5)
- *   --recurly-retry-delay-ms <n> Delay between Recurly retries in ms (default: 1000)
- *   --stripe-rate-limit <n>      Requests per second for Stripe (default: 50)
- *   --stripe-api-retries <n>     Number of retries on Stripe 429s (default: 5)
- *   --stripe-retry-delay-ms <n>  Delay between Stripe retries in ms (default: 1000)
- *   --verbose, -v          Enable debug logging
- *
- * Note, prior to running this script, environment variables must have been loaded from config/local.env
- *
- * ```
- * set -a
- * source ../../config/local.env
- * set +a
- * ```
- */
-
-import Settings from '@overleaf/settings'
-import Stripe from 'stripe'
-import recurly from 'recurly'
-import minimist from 'minimist'
-import PQueue from 'p-queue'
-import fs from 'node:fs'
-import * as csv from 'csv'
-import { scriptRunner } from '../lib/ScriptRunner.mjs'
-
-import { compareAccountFields } from '../helpers/migrate_recurly_customers_to_stripe.helpers.mjs'
-import {
-  createRateLimitedApiWrappers,
-  DEFAULT_RECURLY_RATE_LIMIT,
-  DEFAULT_STRIPE_RATE_LIMIT,
-  DEFAULT_RECURLY_API_RETRIES,
-  DEFAULT_RECURLY_RETRY_DELAY_MS,
-  DEFAULT_STRIPE_API_RETRIES,
-  DEFAULT_STRIPE_RETRY_DELAY_MS,
-} from '../stripe/RateLimiter.mjs'
-
-// =============================================================================
-// STRIPE CLIENT SETUP
-// =============================================================================
-
-const stripeClients = {}
-
-function getRegionClient(region) {
-  const regionLower = String(region || '')
-    .trim()
-    .toLowerCase()
-
-  if (regionLower !== 'us' && regionLower !== 'uk') {
-    throw new Error(
-      `Unknown Stripe region: ${region}. Expected stripe-us or stripe-uk.`
-    )
-  }
-
-  if (stripeClients[regionLower]) return stripeClients[regionLower]
-
-  const secretKey =
-    regionLower === 'us'
-      ? Settings.apis?.stripeUS?.secretKey ||
-        process.env.STRIPE_OL_SECRET_KEY ||
-        process.env.STRIPE_OL_US_SECRET_KEY
-      : Settings.apis?.stripeUK?.secretKey ||
-        process.env.STRIPE_OL_UK_SECRET_KEY
-
-  if (!secretKey || !String(secretKey).trim()) {
-    throw new Error(
-      `No Stripe secret key configured for region ${regionLower}.`
-    )
-  }
-
-  const client = new Stripe(secretKey, {
-    httpClient: Stripe.createFetchHttpClient(),
-    telemetry: false,
-  })
-
-  client.serviceName = `stripe-${regionLower}`
-  stripeClients[regionLower] = client
-  return client
-}
-
-// =============================================================================
-// RECURLY CLIENT SETUP
-// =============================================================================
-
-const recurlyApiKey =
-  process.env.RECURLY_API_KEY || Settings.apis?.recurly?.apiKey
-if (!recurlyApiKey) {
-  throw new Error(
-    'Recurly API key is not set. Set RECURLY_API_KEY env var or configure Settings.apis.recurly.apiKey'
-  )
-}
-const recurlyClient = new recurly.Client(recurlyApiKey)
-
-// =============================================================================
-// LOGGING UTILITIES
-// =============================================================================
-
-function timestamp() {
-  return new Date().toISOString()
-}
-
-function logWarn(message, context = {}) {
-  const contextStr =
-    Object.keys(context).length > 0 ? ` ${JSON.stringify(context)}` : ''
-  console.warn(`[${timestamp()}] WARN: ${message}${contextStr}`)
-}
-
-function logError(message, error = null, context = {}) {
-  const contextStr =
-    Object.keys(context).length > 0 ? ` ${JSON.stringify(context)}` : ''
-  console.error(`[${timestamp()}] ERROR: ${message}${contextStr}`)
-  if (error?.stack) {
-    console.error(`[${timestamp()}] STACK: ${error.stack}`)
-  }
-}
-
-let DEBUG_MODE = false
-
-function logDebug(message, context = {}, { verboseOnly = false } = {}) {
-  if (verboseOnly && !DEBUG_MODE) return
-  const contextStr =
-    Object.keys(context).length > 0 ? ` ${JSON.stringify(context)}` : ''
-  const level = verboseOnly ? 'DEBUG' : 'INFO'
-  console.log(`[${timestamp()}] ${level}: ${message}${contextStr}`)
-}
-
-// =============================================================================
-// DATA FETCHING
-// =============================================================================
-
-let rateLimiters
-
-async function fetchRecurlyData(accountCode, context) {
-  return await rateLimiters.requestWithRetries(
-    'recurly',
-    () => recurlyClient.getAccount(`code-${accountCode}`),
-    context
-  )
-}
-
-async function fetchRecurlySubscription(accountCode, context) {
-  // Try live subscriptions first, then fall back to expired.
-  for (const state of ['live', 'expired']) {
-    const subscriptions = await rateLimiters.requestWithRetries(
-      'recurly',
-      async () => {
-        const pager = recurlyClient.listAccountSubscriptions(
-          `code-${accountCode}`,
-          { params: { state, order: 'desc', sort: 'updated_at' } }
-        )
-        const results = []
-        for await (const subscription of pager.each()) {
-          results.push(subscription)
-        }
-        return results
-      },
-      context
-    )
-    if (subscriptions.length > 0) {
-      // Return the most recently updated subscription in this state
-      return subscriptions[0]
-    }
-  }
-  return null
-}
-
-async function fetchTargetStripeCustomer(
-  stripeClient,
-  stripeCustomerId,
-  context
-) {
-  const customer = await rateLimiters.requestWithRetries(
-    stripeClient.serviceName,
-    () =>
-      stripeClient.customers.retrieve(stripeCustomerId, {
-        expand: ['tax_ids', 'invoice_settings.default_payment_method'],
-      }),
-    { ...context, stripeApi: 'customers.retrieve' }
-  )
-  if (customer.deleted) {
-    throw new Error(`Stripe customer ${stripeCustomerId} has been deleted`)
-  }
-  return customer
-}
-
-async function fetchTargetStripeCustomerPaymentMethods(
-  stripeClient,
-  stripeCustomerId,
-  context
-) {
-  const paymentMethods = await rateLimiters.requestWithRetries(
-    stripeClient.serviceName,
-    () => stripeClient.customers.listPaymentMethods(stripeCustomerId),
-    { ...context, stripeApi: 'customers.listPaymentMethods' }
-  )
-  return paymentMethods.data
-}
-
-// =============================================================================
-// COMPARISON LOGIC
-// =============================================================================
-
-/**
- * Compare a single customer's Recurly data against Stripe.
- */
-async function compareCustomer(row, rowNumber, comparisonDate) {
-  const {
-    recurly_account_code: recurlyAccountCode,
-    target_stripe_account: targetStripeAccount,
-    stripe_customer_id: stripeCustomerId,
-  } = row
-
-  const context = {
-    rowNumber,
-    recurlyAccountCode,
-    targetStripeAccount,
-    stripeCustomerId,
-  }
-
-  const stripeContext = {
-    rowNumber,
-    stripeCustomerId,
-    stripeAccount: targetStripeAccount,
-  }
-
-  const result = {
-    recurly_account_code: recurlyAccountCode,
-    target_stripe_account: targetStripeAccount,
-    stripe_customer_id: stripeCustomerId,
-    status: '', // 'match', 'mismatch', or 'error'
-    diffs: '',
-    error: '',
-    diffDetails: null,
-  }
-
-  try {
-    if (!recurlyAccountCode) throw new Error('Missing recurly_account_code')
-    if (!targetStripeAccount) throw new Error('Missing target_stripe_account')
-    if (!stripeCustomerId) throw new Error('Missing stripe_customer_id')
-
-    const region = String(targetStripeAccount || '')
-      .trim()
-      .toLowerCase()
-      .replace(/^stripe-/, '')
-    const stripeClient = getRegionClient(region)
-
-    const account = await fetchRecurlyData(recurlyAccountCode, context)
-    const recurlyAccountUpdatedAt = account.updatedAt?.getTime() || 0
-
-    // If Recurly account was not updated after the comparison date, consider it a match
-    if (recurlyAccountUpdatedAt <= comparisonDate.getTime()) {
-      result.status = 'match'
-      result.diffs = ''
-      return result
-    }
-
-    const stripeCustomer = await fetchTargetStripeCustomer(
-      stripeClient,
-      stripeCustomerId,
-      stripeContext
-    )
-
-    const stripePaymentMethods = await fetchTargetStripeCustomerPaymentMethods(
-      stripeClient,
-      stripeCustomerId,
-      stripeContext
-    )
-
-    const diffs = await compareAccountFields({
-      account,
-      stripeCustomer,
-      overleafUserId: recurlyAccountCode,
-      fetchCollectionMethod: async () => {
-        const subscription = await fetchRecurlySubscription(
-          recurlyAccountCode,
-          context
-        )
-        return subscription?.collectionMethod || null
-      },
-      stripePaymentMethods,
-      stripeServiceName: stripeClient.serviceName,
-    })
-
-    // Determine result
-    const diffKeys = Object.keys(diffs)
-    if (diffKeys.length === 0) {
-      result.status = 'match'
-      result.diffs = ''
-    } else {
-      result.status = 'mismatch'
-      result.diffs = diffKeys.join('; ')
-      result.diffDetails = {
-        recurly_account_code: recurlyAccountCode,
-        stripe_customer_id: stripeCustomerId,
-        target_stripe_account: targetStripeAccount,
-        comparison_date: comparisonDate.toISOString(),
-        recurly_account_updated_at: account.updatedAt?.toISOString(),
-        diffs,
-      }
-
-      logDebug('Customer has diffs', {
-        ...context,
-        diffFields: diffKeys,
-      })
-    }
-  } catch (error) {
-    result.status = 'error'
-    const errorDetails = [error.message]
-    if (error.code) errorDetails.push(`code=${error.code}`)
-    if (error.type) errorDetails.push(`type=${error.type}`)
-    if (error.statusCode) errorDetails.push(`statusCode=${error.statusCode}`)
-    result.error = errorDetails.join('; ')
-    logError('Failed to compare customer', error, context)
-  }
-
-  return result
-}
-
-// =============================================================================
-// CSV HELPERS
-// =============================================================================
-
-function formatCsvRow(columns, row) {
-  const values = columns.map(col => {
-    const raw = row[col]
-    const val = raw == null ? '' : String(raw)
-    if (val.includes(',') || val.includes('"') || val.includes('\n')) {
-      return `"${val.replace(/"/g, '""')}"`
-    }
-    return val
-  })
-  return values.join(',') + '\n'
-}
-
-function createJsonArrayWriter(jsonPath) {
-  const stream = fs.createWriteStream(jsonPath, { flags: 'w' })
-  stream.write('[\n')
-  let wroteAny = false
-
-  function write(value) {
-    const serialized = JSON.stringify(value, null, 2)
-    if (wroteAny) stream.write(',\n')
-    stream.write(serialized)
-    wroteAny = true
-  }
-
-  async function close() {
-    stream.write('\n]\n')
-    stream.end()
-    await new Promise((resolve, reject) => {
-      stream.on('finish', resolve)
-      stream.on('error', reject)
-    })
-  }
-
-  return { write, close }
-}
-
-// =============================================================================
-// CLI
-// =============================================================================
-
-function usage() {
-  console.error(
-    'Compare Recurly customer data against migrated Stripe customers'
-  )
-  console.error('')
-  console.error('Usage:')
-  console.error(
-    '  node scripts/recurly/compare_recurly_stripe_customers.mjs [options]'
-  )
-  console.error('')
-  console.error('Options:')
-  console.error('  --input, -i <file>   Path to input CSV file (required)')
-  console.error('  --output, -o <file>  Path to output CSV file (required)')
-  console.error(
-    '  --comparison-date <date> Date in YYYY-MM-DD to compare against (required)'
-  )
-  console.error(
-    '  --limit, -l <n>      Limit number of records processed (default: no limit)'
-  )
-  console.error(
-    '  --concurrency, -c <n> Number of customers to process concurrently (default: 10)'
-  )
-  console.error(
-    '  --recurly-rate-limit <n> Requests per second for Recurly (default: 10)'
-  )
-  console.error(
-    '  --recurly-api-retries <n> Number of retries on Recurly 429s (default: 5)'
-  )
-  console.error(
-    '  --recurly-retry-delay-ms <n> Delay between Recurly retries in ms (default: 1000)'
-  )
-  console.error(
-    '  --stripe-rate-limit <n>  Requests per second for Stripe (default: 50)'
-  )
-  console.error(
-    '  --stripe-api-retries <n> Number of retries on Stripe 429s (default: 5)'
-  )
-  console.error(
-    '  --stripe-retry-delay-ms <n> Delay between Stripe retries in ms (default: 1000)'
-  )
-  console.error('  --verbose, -v         Enable debug logging')
-}
-
-function parseConcurrency(value, { defaultValue = 10 } = {}) {
-  if (value === undefined || value === null || value === '') return defaultValue
-  const parsed = Number(value)
-  if (!Number.isFinite(parsed) || !Number.isInteger(parsed) || parsed < 1) {
-    throw new Error(
-      `Invalid --concurrency value: ${value}. Expected a positive integer.`
-    )
-  }
-  return parsed
-}
-
-function parseRateLimit(value, { defaultValue, name }) {
-  if (value === undefined || value === null || value === '') return defaultValue
-  const parsed = Number(value)
-  if (!Number.isFinite(parsed) || parsed <= 0) {
-    throw new Error(
-      `Invalid --${name} value: ${value}. Expected a positive number.`
-    )
-  }
-  return parsed
-}
-
-function parseNonNegativeInt(value, { defaultValue, name }) {
-  if (value === undefined || value === null || value === '') return defaultValue
-  const parsed = Number(value)
-  if (!Number.isFinite(parsed) || !Number.isInteger(parsed) || parsed < 0) {
-    throw new Error(
-      `Invalid --${name} value: ${value}. Expected a non-negative integer.`
-    )
-  }
-  return parsed
-}
-
-function parseArgs() {
-  return minimist(process.argv.slice(2), {
-    alias: {
-      i: 'input',
-      o: 'output',
-      h: 'help',
-      v: 'verbose',
-      c: 'concurrency',
-      l: 'limit',
-    },
-    string: [
-      'input',
-      'output',
-      'comparison-date',
-      'limit',
-      'recurly-rate-limit',
-      'recurly-api-retries',
-      'recurly-retry-delay-ms',
-      'stripe-rate-limit',
-      'stripe-api-retries',
-      'stripe-retry-delay-ms',
-    ],
-    boolean: ['verbose', 'help'],
-    default: {
-      verbose: false,
-      concurrency: 10,
-      'recurly-rate-limit': DEFAULT_RECURLY_RATE_LIMIT,
-      'recurly-api-retries': DEFAULT_RECURLY_API_RETRIES,
-      'recurly-retry-delay-ms': DEFAULT_RECURLY_RETRY_DELAY_MS,
-      'stripe-rate-limit': DEFAULT_STRIPE_RATE_LIMIT,
-      'stripe-api-retries': DEFAULT_STRIPE_API_RETRIES,
-      'stripe-retry-delay-ms': DEFAULT_STRIPE_RETRY_DELAY_MS,
-    },
-  })
-}
-
-// =============================================================================
-// MAIN
-// =============================================================================
-
-async function main(trackProgress) {
-  const startTime = new Date()
-  const args = parseArgs()
-  const {
-    input: inputPath,
-    output: outputPath,
-    'comparison-date': comparisonDateRaw,
-    verbose,
-    help,
-    concurrency: concurrencyRaw,
-    limit: limitRaw,
-    'recurly-rate-limit': recurlyRateLimitRaw,
-    'recurly-api-retries': recurlyApiRetriesRaw,
-    'recurly-retry-delay-ms': recurlyRetryDelayMsRaw,
-    'stripe-rate-limit': stripeRateLimitRaw,
-    'stripe-api-retries': stripeApiRetriesRaw,
-    'stripe-retry-delay-ms': stripeRetryDelayMsRaw,
-  } = args
-
-  let concurrency,
-    recurlyRateLimit,
-    recurlyApiRetriesValue,
-    recurlyRetryDelayMsValue,
-    stripeRateLimitPerSecond,
-    stripeApiRetriesValue,
-    stripeRetryDelayMsValue,
-    limit,
-    comparisonDate
-  try {
-    concurrency = parseConcurrency(concurrencyRaw, { defaultValue: 10 })
-    limit = parseNonNegativeInt(limitRaw, {
-      defaultValue: null,
-      name: 'limit',
-    })
-    recurlyRateLimit = parseRateLimit(recurlyRateLimitRaw, {
-      defaultValue: DEFAULT_RECURLY_RATE_LIMIT,
-      name: 'recurly-rate-limit',
-    })
-    recurlyApiRetriesValue = parseNonNegativeInt(recurlyApiRetriesRaw, {
-      defaultValue: DEFAULT_RECURLY_API_RETRIES,
-      name: 'recurly-api-retries',
-    })
-    recurlyRetryDelayMsValue = parseNonNegativeInt(recurlyRetryDelayMsRaw, {
-      defaultValue: DEFAULT_RECURLY_RETRY_DELAY_MS,
-      name: 'recurly-retry-delay-ms',
-    })
-    stripeRateLimitPerSecond = parseRateLimit(stripeRateLimitRaw, {
-      defaultValue: DEFAULT_STRIPE_RATE_LIMIT,
-      name: 'stripe-rate-limit',
-    })
-    stripeApiRetriesValue = parseNonNegativeInt(stripeApiRetriesRaw, {
-      defaultValue: DEFAULT_STRIPE_API_RETRIES,
-      name: 'stripe-api-retries',
-    })
-    stripeRetryDelayMsValue = parseNonNegativeInt(stripeRetryDelayMsRaw, {
-      defaultValue: DEFAULT_STRIPE_RETRY_DELAY_MS,
-      name: 'stripe-retry-delay-ms',
-    })
-    if (!comparisonDateRaw) {
-      throw new Error('--comparison-date is required')
-    }
-    const dateMatch = comparisonDateRaw.match(/^(\d{4})-(\d{2})-(\d{2})$/)
-    if (!dateMatch) {
-      throw new Error('--comparison-date must be in YYYY-MM-DD format')
-    }
-    const [, year, month, day] = dateMatch
-    comparisonDate = new Date(`${year}-${month}-${day}T00:00:00.000Z`)
-    if (isNaN(comparisonDate.getTime())) {
-      throw new Error('Invalid date provided for --comparison-date')
-    }
-  } catch (error) {
-    logError(error.message)
-    usage()
-    process.exit(1)
-  }
-
-  rateLimiters = createRateLimitedApiWrappers({
-    recurlyRateLimit,
-    recurlyApiRetries: recurlyApiRetriesValue,
-    recurlyRetryDelayMs: recurlyRetryDelayMsValue,
-    stripeRateLimit: stripeRateLimitPerSecond,
-    stripeApiRetries: stripeApiRetriesValue,
-    stripeRetryDelayMs: stripeRetryDelayMsValue,
-    logDebug,
-    logWarn,
-  })
-
-  DEBUG_MODE = !!verbose
-
-  if (help || !inputPath || !outputPath) {
-    usage()
-    process.exit(help ? 0 : 1)
-  }
-
-  const errorsPath = outputPath.replace(/\.csv$/, '_errors.csv')
-  const detailsJsonPath = outputPath.replace(/\.csv$/, '_details.json')
-
-  logDebug('Starting comparison', {
-    inputPath,
-    outputPath,
-    errorsPath,
-    detailsJsonPath,
-    comparisonDate: comparisonDate.toISOString(),
-    concurrency,
-    ...(limit != null ? { limit } : {}),
-  })
-  await trackProgress('Starting comparison')
-
-  // Output CSV columns
-  const outputColumns = [
-    'recurly_account_code',
-    'target_stripe_account',
-    'stripe_customer_id',
-    'status',
-    'diffs',
-  ]
-  const errorColumns = [
-    'recurly_account_code',
-    'target_stripe_account',
-    'stripe_customer_id',
-    'error',
-  ]
-
-  const outputStream = fs.createWriteStream(outputPath, { flags: 'w' })
-  outputStream.write(outputColumns.join(',') + '\n')
-
-  const errorsStream = fs.createWriteStream(errorsPath, { flags: 'w' })
-  errorsStream.write(errorColumns.join(',') + '\n')
-
-  const detailsWriter = createJsonArrayWriter(detailsJsonPath)
-
-  try {
-    let totalInInput = 0
-    let processedCount = 0
-    let matchCount = 0
-    let mismatchCount = 0
-    let errorCount = 0
-    let queuedCount = 0
-
-    const inputStream = fs.createReadStream(inputPath)
-    const parser = csv.parse({
-      columns: true,
-      trim: true,
-      bom: true,
-      skip_empty_lines: true,
-      relax_column_count: true,
-      relax_column_count_less: true,
-    })
-    inputStream.pipe(parser)
-
-    const queue = new PQueue({ concurrency })
-    const maxQueueSize = concurrency
-
-    let rowNumber = 0
-    let limitReached = false
-
-    try {
-      for await (const row of parser) {
-        rowNumber++
-        totalInInput++
-
-        const thisRowNumber = rowNumber
-
-        if (limit != null && queuedCount >= limit) {
-          limitReached = true
-          logDebug('Record limit reached', { limit, queuedCount })
-          break
-        }
-
-        if (queue.size >= maxQueueSize) {
-          await queue.onSizeLessThan(maxQueueSize)
-        }
-
-        queuedCount++
-        queue.add(async () => {
-          let result
-          try {
-            result = await compareCustomer(row, thisRowNumber, comparisonDate)
-          } catch (error) {
-            result = {
-              ...row,
-              status: 'error',
-              diffs: '',
-              error: error?.message || String(error),
-              diffDetails: null,
-            }
-            logError('Unhandled error', error, {
-              rowNumber: thisRowNumber,
-              accountCode: row.recurly_account_code,
-            })
-          }
-
-          processedCount++
-
-          if (result.status === 'match') {
-            matchCount++
-            outputStream.write(formatCsvRow(outputColumns, result))
-          } else if (result.status === 'mismatch') {
-            mismatchCount++
-            outputStream.write(formatCsvRow(outputColumns, result))
-            if (result.diffDetails) {
-              detailsWriter.write(result.diffDetails)
-            }
-          } else {
-            errorCount++
-            errorsStream.write(formatCsvRow(errorColumns, result))
-          }
-
-          const progressInterval = DEBUG_MODE ? 100 : 1000
-          if (processedCount % progressInterval === 0) {
-            logDebug('Progress', {
-              processedCount,
-              matchCount,
-              mismatchCount,
-              errorCount,
-            })
-            await trackProgress(
-              `Progress: ${processedCount} processed, ${matchCount} match, ${mismatchCount} mismatch, ${errorCount} errors`
-            )
-          }
-        })
-      }
-    } finally {
-      await queue.onIdle()
-    }
-
-    if (limitReached) {
-      await trackProgress(`Limit reached (${limit}).`)
-    }
-
-    // Final summary
-    const endTime = new Date()
-    const durationMs = endTime.getTime() - startTime.getTime()
-    const durationSeconds = Math.floor(durationMs / 1000)
-
-    const finalStats = rateLimiters.getRateLimiterStats()
-
-    await trackProgress('=== COMPARISON SUMMARY ===')
-    await trackProgress(`Total in input: ${totalInInput}`)
-    await trackProgress(`Processed: ${processedCount}`)
-    await trackProgress(`  Match: ${matchCount}`)
-    await trackProgress(`  Mismatch: ${mismatchCount}`)
-    await trackProgress(`  Error: ${errorCount}`)
-    await trackProgress(`Duration: ${durationSeconds}s`)
-    await trackProgress(`Output: ${outputPath}`)
-    await trackProgress(`Errors: ${errorsPath} (${errorCount} records)`)
-    await trackProgress(
-      `Details: ${detailsJsonPath} (${mismatchCount} records)`
-    )
-    await trackProgress(
-      `API calls - Recurly: ${finalStats.recurly.totalRequests}, Stripe: ${finalStats.stripe.totalRequests}`
-    )
-
-    logDebug('Comparison complete', {
-      totalInInput,
-      processedCount,
-      matchCount,
-      mismatchCount,
-      errorCount,
-    })
-
-    return errorCount === 0 && mismatchCount === 0 ? 0 : 1
-  } finally {
-    outputStream.end()
-    errorsStream.end()
-
-    const results = await Promise.allSettled([
-      new Promise((resolve, reject) => {
-        outputStream.on('finish', resolve)
-        outputStream.on('error', reject)
-      }),
-      new Promise((resolve, reject) => {
-        errorsStream.on('finish', resolve)
-        errorsStream.on('error', reject)
-      }),
-      detailsWriter.close(),
-    ])
-
-    for (const result of results) {
-      if (result.status === 'rejected') {
-        logWarn('Failed to close output stream', {
-          error: result.reason?.message || String(result.reason),
-        })
-      }
-    }
-  }
-}
-
-try {
-  const exitCode = await scriptRunner(main)
-  process.exit(exitCode ?? 0)
-} catch (error) {
-  logError('Script failed with unhandled error', error)
-  process.exit(1)
-}

+ 0 - 63
services/web/scripts/recurly/generate_addon_prices.mjs

@@ -1,63 +0,0 @@
-// @ts-check
-import settings from '@overleaf/settings'
-import recurly from 'recurly'
-import { scriptRunner } from '../lib/ScriptRunner.mjs'
-
-const ADD_ON_CODE = process.argv[2]
-
-async function main() {
-  if (!ADD_ON_CODE) {
-    console.error('Missing add-on code')
-    console.error(
-      'Usage: node scripts/recurly/generate_addon_prices.mjs ADD_ON_CODE'
-    )
-    process.exit(1)
-  }
-
-  /** @type {Record<string, any>} */
-  const localizedAddOnsPricing = {}
-
-  const monthlyPlan = await getPlan(ADD_ON_CODE)
-  if (monthlyPlan == null) {
-    console.error(`Monthly plan missing in Recurly: ${ADD_ON_CODE}`)
-    process.exit(1)
-  }
-  for (const { currency, unitAmount } of monthlyPlan.currencies ?? []) {
-    /** @type {any} */
-    const curr = currency
-    if (!localizedAddOnsPricing[curr]) {
-      localizedAddOnsPricing[curr] = { [ADD_ON_CODE]: {} }
-    }
-    localizedAddOnsPricing[curr][ADD_ON_CODE].monthly = unitAmount
-  }
-
-  const annualPlan = await getPlan(`${ADD_ON_CODE}-annual`)
-  if (annualPlan == null) {
-    console.error(`Annual plan missing in Recurly: ${ADD_ON_CODE}-annual`)
-    process.exit(1)
-  }
-  for (const { currency, unitAmount } of annualPlan.currencies ?? []) {
-    /** @type {any} */
-    const curr = currency
-    if (!localizedAddOnsPricing[curr]) {
-      localizedAddOnsPricing[curr] = { [ADD_ON_CODE]: {} }
-    }
-    localizedAddOnsPricing[curr][ADD_ON_CODE].annual = unitAmount
-    localizedAddOnsPricing[curr][ADD_ON_CODE].annualDividedByTwelve =
-      (unitAmount || 0) / 12
-  }
-
-  console.log(JSON.stringify({ localizedAddOnsPricing }, null, 2))
-}
-
-/**
- * Get a plan configuration from Recurly
- *
- * @param {string} planCode
- */
-async function getPlan(planCode) {
-  const recurlyClient = new recurly.Client(settings.apis.recurly.apiKey)
-  return await recurlyClient.getPlan(`code-${planCode}`)
-}
-
-await scriptRunner(main)

+ 0 - 131
services/web/scripts/recurly/generate_recurly_prices.mjs

@@ -1,131 +0,0 @@
-// script to generate plan prices for recurly from a csv file
-//
-// Usage:
-//
-// $ node scripts/recurly/generate_recurly_prices.mjs -f input.csv -o prices.json
-//
-// The input csv file has the following format:
-//
-//     plan_code,USD,EUR,GBP,...
-//     student,9,8,7,...
-//     student-annual,89,79,69,...
-//     group_professional_2_educational,558,516,446,...
-//
-// The output file format is the JSON of the plans returned by recurly, with an
-// extra _addOns property for the addOns associated with that plan.
-//
-// The output can be used as input for the upload script `recurly_prices.js`.
-
-import minimist from 'minimist'
-
-// https://github.com/import-js/eslint-plugin-import/issues/1810
-// eslint-disable-next-line import/no-unresolved
-import * as csv from 'csv/sync'
-import _ from 'lodash'
-import fs from 'node:fs'
-
-const argv = minimist(process.argv.slice(2), {
-  string: ['output', 'file'],
-  alias: { o: 'output', f: 'file' },
-  default: { output: '/dev/stdout' },
-})
-
-// All currency codes are 3 uppercase letters
-const CURRENCY_CODE_REGEX = /^[A-Z]{3}$/
-// Group plans have a plan code of the form group_name_size_type, e.g.
-const GROUP_SIZE_REGEX = /group_\w+_([0-9]+)_\w+/
-
-// Compute prices for the base plan
-
-function computePrices(plan) {
-  const prices = _.pickBy(plan, (value, key) => CURRENCY_CODE_REGEX.test(key))
-  const result = []
-  for (const currency in prices) {
-    result.push({
-      currency,
-      setupFee: 0,
-      unitAmount: parseInt(prices[currency], 10),
-    })
-  }
-  return _.sortBy(result, 'currency')
-}
-
-// Handle prices for license add-ons associated with group plans
-
-function isGroupPlan(plan) {
-  return plan.plan_code.startsWith('group_')
-}
-
-function getGroupSize(plan) {
-  // extract the group size from the plan code group_name_size_type using a regex
-  const match = plan.plan_code.match(GROUP_SIZE_REGEX)
-  if (!match) {
-    throw new Error(`cannot find group size in plan code: ${plan.plan_code}`)
-  }
-  const size = parseInt(match[1], 10)
-  return size
-}
-
-function computeAddOnPrices(prices, size) {
-  // The price of an additional license is the per-user cost of the base plan,
-  // i.e. the price of the plan divided by the group size of the plan
-  return prices.map(price => {
-    return {
-      currency: price.currency,
-      unitAmount: Math.round((100 * price.unitAmount) / size) / 100,
-      unitAmountDecimal: null,
-    }
-  })
-}
-
-function shouldSkipPlan(record) {
-  const planCode = record.plan_code
-  // Skip non-legacy group plan codes (e.g. group_professional_20_enterprise)
-  if (planCode.startsWith('group_') && !GROUP_SIZE_REGEX.test(planCode)) {
-    return true
-  }
-
-  return false
-}
-
-// Convert the raw records into the output format
-
-function transformRecordToPlan(record) {
-  const prices = computePrices(record)
-  // The base plan has no add-ons
-  const plan = {
-    code: record.plan_code,
-    currencies: prices,
-  }
-  // Large group plans have an add-on for additional licenses
-  if (isGroupPlan(record)) {
-    const size = getGroupSize(record)
-    const addOnPrices = computeAddOnPrices(prices, size)
-    plan._addOns = [
-      {
-        code: 'additional-license',
-        currencies: addOnPrices,
-      },
-    ]
-  }
-  return plan
-}
-
-function generate(inputFile, outputFile) {
-  const input = fs.readFileSync(inputFile, 'utf8')
-  const rawRecords = csv.parse(input, { columns: true })
-  // filter out plans that should be skipped
-  const filteredRecords = rawRecords.filter(record => !shouldSkipPlan(record))
-  // transform the raw records into the output format
-  const plans = _.sortBy(filteredRecords, 'plan_code').map(
-    transformRecordToPlan
-  )
-  const output = JSON.stringify(plans, null, 2)
-  fs.writeFileSync(outputFile, output)
-}
-
-if (argv.file) {
-  generate(argv.file, argv.output)
-} else {
-  console.log('usage:\n' + '  --file input.csv -o file.json\n')
-}

+ 0 - 104
services/web/scripts/recurly/get_manually_billed_users_details.mjs

@@ -1,104 +0,0 @@
-import Settings from '@overleaf/settings'
-import recurly from 'recurly'
-import fs from 'node:fs'
-import { setTimeout } from 'node:timers/promises'
-import minimist from 'minimist'
-import * as csv from 'csv'
-import Stream from 'node:stream/promises'
-import { scriptRunner } from '../lib/ScriptRunner.mjs'
-
-const recurlyApiKey = Settings.apis.recurly.apiKey
-if (!recurlyApiKey) {
-  throw new Error('Recurly API key is not set in the settings')
-}
-const client = new recurly.Client(recurlyApiKey)
-
-function usage() {
-  console.error(
-    'Script to retrieve details of manually billed users from Recurly'
-  )
-  console.error('')
-  console.error('Usage:')
-  console.error(
-    '  node scripts/recurly/get_manually_billed_users_details.mjs [options]'
-  )
-  console.error('')
-  console.error('Options:')
-  console.error(
-    '  --input, -i <file>   Path to CSV file containing subscription_id, period_end, currency (can be exported from Recurly)'
-  )
-  console.error('  --output, -o <file>  Path to output CSV file')
-  console.error('')
-  console.error('Input format:')
-  console.error(
-    '  CSV file with the following columns: subscription_id, period_end, currency (header row is skipped)'
-  )
-}
-
-function parseArgs() {
-  return minimist(process.argv.slice(2), {
-    alias: { i: 'input', o: 'output' },
-    string: ['input', 'output'],
-  })
-}
-
-async function enrichRow(row) {
-  const account = await client.getAccount(`code-${row.account_code}`)
-  return {
-    ...row,
-    email: account.email,
-    first_name: account.firstName,
-    last_name: account.lastName,
-    cc_emails: account.ccEmails,
-  }
-}
-
-async function main() {
-  const { input: inputPath, output: outputPath, h, help } = parseArgs()
-  if (help || h || !inputPath || !outputPath) {
-    usage()
-    process.exit(0)
-  }
-
-  let processedCount = 0
-  await Stream.pipeline([
-    fs.createReadStream(inputPath),
-    csv.parse({ columns: true }),
-    async function* (rows) {
-      for await (const row of rows) {
-        try {
-          yield await enrichRow(row)
-        } catch (error) {
-          console.error(`Error processing subscription ${row.subscription_id}`)
-        }
-        processedCount++
-        if (processedCount % 1 === 0) {
-          console.log(`Processed ${processedCount} subscriptions`)
-        }
-        await setTimeout(1000)
-      }
-    },
-    csv.stringify({
-      header: true,
-      columns: {
-        subscription_id: 'subscription_id',
-        current_period_ends_at: 'period_end',
-        currency: 'currency',
-        email: 'email',
-        first_name: 'first_name',
-        last_name: 'last_name',
-        cc_emails: 'cc_emails',
-      },
-    }),
-    fs.createWriteStream(outputPath),
-  ])
-  console.log(`Processed ${processedCount} subscriptions in total`)
-}
-
-try {
-  await scriptRunner(main)
-  process.exit(0)
-} catch (error) {
-  console.error(error)
-  process.exit(1)
-}

+ 0 - 107
services/web/scripts/recurly/get_paypal_accounts_csv.mjs

@@ -1,107 +0,0 @@
-import RecurlyWrapper from '../../app/src/Features/Subscription/RecurlyWrapper.mjs'
-import async from 'async'
-import { Parser as CSVParser } from 'json2csv'
-
-const NOW = new Date()
-
-const slowCallback = (callback, error, data) =>
-  setTimeout(() => callback(error, data), 80)
-
-const handleAPIError = (type, account, error, callback) => {
-  console.warn(
-    `Errors getting ${type} for account ${account.account_code}`,
-    error
-  )
-  if (typeof error === 'string' && error.match(/429$/)) {
-    return setTimeout(callback, 1000 * 60 * 5)
-  }
-  slowCallback(callback)
-}
-
-const getAccountSubscription = (account, callback) =>
-  RecurlyWrapper.getSubscriptions(account.account_code, (error, response) => {
-    if (error) {
-      return handleAPIError('subscriptions', account, error, callback)
-    }
-    slowCallback(callback, null, response.subscriptions[0])
-  })
-
-const isAccountUsingPaypal = (account, callback) =>
-  RecurlyWrapper.getBillingInfo(account.account_code, (error, response) => {
-    if (error) {
-      return handleAPIError('billing info', account, error, callback)
-    }
-    if (response.billing_info.paypal_billing_agreement_id) {
-      return slowCallback(callback, null, true)
-    }
-    slowCallback(callback, null, false)
-  })
-
-const printAccountCSV = (account, callback) => {
-  isAccountUsingPaypal(account, (error, isPaypal) => {
-    if (error || !isPaypal) {
-      return callback(error)
-    }
-    getAccountSubscription(account, (error, subscription) => {
-      if (error || !subscription) {
-        return callback(error)
-      }
-      const endAt = new Date(subscription.current_period_ends_at)
-      if (subscription.expires_at) {
-        return callback()
-      }
-      const csvData = {
-        email: account.email,
-        first_name: account.first_name,
-        last_name: account.last_name,
-        hosted_login_token: account.hosted_login_token,
-        billing_info_url: `https://sharelatex.recurly.com/account/billing_info/edit?ht=${account.hosted_login_token}`,
-        account_management_url: `https://sharelatex.recurly.com/account/${account.hosted_login_token}`,
-        current_period_ends_at: `${endAt.getFullYear()}-${
-          endAt.getMonth() + 1
-        }-${endAt.getDate()}`,
-        current_period_ends_at_segment: parseInt(
-          ((endAt - NOW) / 1000 / 3600 / 24 / 365) * 7
-        ),
-      }
-      callback(null, csvData)
-    })
-  })
-}
-
-const printAccountsCSV = callback => {
-  RecurlyWrapper.getPaginatedEndpoint(
-    'accounts',
-    { state: 'subscriber' },
-    (error, accounts) => {
-      if (error) {
-        return callback(error)
-      }
-      async.mapSeries(accounts, printAccountCSV, (error, csvData) => {
-        csvData = csvData.filter(d => !!d)
-        callback(error, csvData)
-      })
-    }
-  )
-}
-
-const csvFields = [
-  'email',
-  'first_name',
-  'last_name',
-  'hosted_login_token',
-  'billing_info_url',
-  'account_management_url',
-  'current_period_ends_at',
-  'current_period_ends_at_segment',
-]
-const csvParser = new CSVParser({ csvFields })
-
-// print each account
-printAccountsCSV((error, csvData) => {
-  if (error) {
-    throw error
-  }
-  console.log(csvParser.parse(csvData))
-  process.exit()
-})

+ 0 - 48
services/web/scripts/recurly/get_recurly_group_prices.mjs

@@ -1,48 +0,0 @@
-// Get prices from Recurly in GroupPlansData format, ie to update:
-// app/templates/plans/groups.json
-//
-// Usage example:
-// node scripts/recurly/get_recurly_group_prices.mjs
-
-import recurly from 'recurly'
-
-import Settings from '@overleaf/settings'
-import { scriptRunner } from '../lib/ScriptRunner.mjs'
-
-const recurlySettings = Settings.apis.recurly
-const recurlyApiKey = recurlySettings ? recurlySettings.apiKey : undefined
-
-const client = new recurly.Client(recurlyApiKey)
-
-async function getRecurlyGroupPrices() {
-  const prices = {}
-  const plans = client.listPlans({ params: { limit: 200 } })
-  for await (const plan of plans.each()) {
-    if (plan.code.substr(0, 6) === 'group_') {
-      const [, type, size, usage] = plan.code.split('_')
-      plan.currencies.forEach(planPricing => {
-        const { currency, unitAmount } = planPricing
-        prices[usage] = prices[usage] || {}
-        prices[usage][type] = prices[usage][type] || {}
-        prices[usage][type][currency] = prices[usage][type][currency] || {}
-        prices[usage][type][currency][size] = {
-          price_in_cents: unitAmount * 100,
-        }
-      })
-    }
-  }
-  return prices
-}
-
-async function main() {
-  const prices = await getRecurlyGroupPrices()
-  console.log(JSON.stringify(prices, undefined, 2))
-}
-
-try {
-  await scriptRunner(main)
-  process.exit(0)
-} catch (error) {
-  console.error({ error })
-  process.exit(1)
-}

+ 0 - 293
services/web/scripts/recurly/list_recurly_accounts.mjs

@@ -1,293 +0,0 @@
-/**
- * List Recurly accounts and output as CSV for use with migrate_recurly_customers_to_stripe.mjs
- *
- * Useful for generating list of customers for testing purposes.
- *
- * This script can be deleted once the Recurly to Stripe migration is complete.
- *
- * Usage:
- *   node scripts/recurly/list_recurly_accounts.mjs --limit 100 --output test_customers.csv
- *
- * Options:
- *   --limit N        Number of accounts to fetch (default: 100)
- *   --output FILE    Output CSV file (required)
- *   --stripe-account Account ID to use for target_stripe_account column
- *   --verbose        Enable debug logging
- */
-
-import Settings from '@overleaf/settings'
-import recurly from 'recurly'
-import minimist from 'minimist'
-import fs from 'node:fs'
-import { scriptRunner } from '../lib/ScriptRunner.mjs'
-
-import { normalizeRecurlyAddressToStripe } from '../helpers/migrate_recurly_customers_to_stripe.helpers.mjs'
-
-const recurlyApiKey =
-  process.env.RECURLY_API_KEY || Settings.apis?.recurly?.apiKey
-if (!recurlyApiKey) {
-  throw new Error(
-    'Recurly API key is not set. Set RECURLY_API_KEY env var or configure Settings.apis.recurly.apiKey'
-  )
-}
-
-const client = new recurly.Client(recurlyApiKey)
-
-function usage() {
-  console.error(
-    'List Recurly accounts and output as CSV for use with migrate_recurly_customers_to_stripe.mjs'
-  )
-  console.error('')
-  console.error('Usage:')
-  console.error(
-    '  node scripts/recurly/list_recurly_accounts.mjs --output <file> [options]'
-  )
-  console.error('')
-  console.error('Options:')
-  console.error(
-    '  --limit, -l N           Number of accounts to fetch (default: 100)'
-  )
-  console.error('  --output, -o FILE       Output CSV file (required)')
-  console.error(
-    '  --stripe-account, -s ID Target Stripe account ID for all rows'
-  )
-  console.error('  --verbose, -v          Enable debug logging')
-  console.error('  --help, -h              Show this help message')
-}
-
-function parseArgs() {
-  return minimist(process.argv.slice(2), {
-    alias: {
-      o: 'output',
-      l: 'limit',
-      s: 'stripe-account',
-      v: 'verbose',
-      h: 'help',
-    },
-    default: { limit: 100 },
-    string: ['output', 'stripe-account'],
-    boolean: ['verbose'],
-  })
-}
-
-async function main(trackProgress) {
-  const args = parseArgs()
-
-  const DEBUG = !!args.verbose
-  function debug(message, context = {}) {
-    if (!DEBUG) return
-    const contextStr =
-      Object.keys(context).length > 0 ? ` ${JSON.stringify(context)}` : ''
-    console.log(`[DEBUG] ${message}${contextStr}`)
-  }
-
-  if (args.help || args.h) {
-    usage()
-    process.exit(0)
-  }
-
-  if (!args.output) {
-    usage()
-    console.error('')
-    console.error('Error: --output is required')
-    process.exit(1)
-  }
-
-  const limit = parseInt(args.limit, 10)
-  const targetStripeAccount =
-    args['stripe-account'] || 'REPLACE_WITH_STRIPE_ACCOUNT_ID'
-
-  if (!Number.isFinite(limit) || limit <= 0) {
-    throw new Error(`Invalid --limit: ${args.limit}`)
-  }
-  if (limit < 2) {
-    throw new Error(
-      'Invalid --limit: must be >= 2 to satisfy VAT constraints (>=1 VAT account but <=50% VAT overall)'
-    )
-  }
-
-  await trackProgress(`Fetching up to ${limit} accounts from Recurly...`)
-  await trackProgress(`Target Stripe account: ${targetStripeAccount}`)
-  if (DEBUG) {
-    await trackProgress('Debug logging enabled')
-  }
-
-  const vatCandidates = []
-  const nonVatCandidates = []
-
-  let scanned = 0
-  let acceptedWithAddress = 0
-  let rejectedNoAddress = 0
-  let rejectedVatOverCap = 0
-  let billingInfoFetched = 0
-  let billingInfoNotFound = 0
-  let billingInfoOtherError = 0
-  let usedBillingAddress = 0
-  let usedBillingVatNumber = 0
-
-  const vatCap = Math.floor(limit / 2)
-
-  // List accounts with pagination - Recurly returns a Pager, need to call .each()
-  // Options must be wrapped in { params: { ... } }
-  const accountsPager = client.listAccounts({
-    params: { limit: Math.min(limit, 200) },
-  })
-  for await (const account of accountsPager.each()) {
-    scanned++
-
-    const recurlyAccountCode = account.code
-    const row = {
-      recurly_account_code: recurlyAccountCode,
-      target_stripe_account: targetStripeAccount,
-      stripe_customer_id: '', // Empty - no existing Stripe customer
-      email: account.email,
-      state: account.state,
-    }
-
-    let address = account.address
-    let vatNumber =
-      typeof account.vatNumber === 'string' ? account.vatNumber : null
-
-    // Fetch billing info only if needed for address/vat detection
-    if (!normalizeRecurlyAddressToStripe(address) || !vatNumber) {
-      try {
-        billingInfoFetched++
-        const billingInfo = await client.getBillingInfo(
-          `code-${recurlyAccountCode}`
-        )
-        if (!address && billingInfo?.address) {
-          address = billingInfo.address
-          usedBillingAddress++
-        }
-        if (!vatNumber && billingInfo?.vatNumber) {
-          vatNumber = billingInfo.vatNumber
-          usedBillingVatNumber++
-        }
-      } catch (error) {
-        if (!(error instanceof recurly.errors.NotFoundError)) {
-          billingInfoOtherError++
-          throw error
-        }
-        billingInfoNotFound++
-      }
-    }
-
-    if (!normalizeRecurlyAddressToStripe(address)) {
-      rejectedNoAddress++
-      debug('Rejected account: no valid address', {
-        scanned,
-        recurlyAccountCode,
-        rejectedNoAddress,
-      })
-      continue
-    }
-
-    acceptedWithAddress++
-
-    const hasVat = !!(typeof vatNumber === 'string' && vatNumber.trim())
-    if (hasVat) {
-      // Enforce VAT upper bound while scanning.
-      if (vatCandidates.length >= vatCap) {
-        rejectedVatOverCap++
-        debug('Rejected account: VAT over cap', {
-          scanned,
-          recurlyAccountCode,
-          vatCandidates: vatCandidates.length,
-          vatCap,
-          rejectedVatOverCap,
-        })
-        continue
-      }
-      vatCandidates.push(row)
-    } else {
-      nonVatCandidates.push(row)
-    }
-
-    // Stop once we can satisfy constraints.
-    const vatToTake = Math.min(vatCap, vatCandidates.length)
-    const needsAtLeastOneVat = vatCandidates.length >= 1
-    const nonVatNeeded = limit - Math.max(1, vatToTake)
-    if (needsAtLeastOneVat && nonVatCandidates.length >= nonVatNeeded) {
-      debug('Stopping early: constraints satisfied', {
-        scanned,
-        vatCandidates: vatCandidates.length,
-        nonVatCandidates: nonVatCandidates.length,
-        vatCap,
-        nonVatNeeded,
-      })
-      break
-    }
-
-    if (scanned % 25 === 0) {
-      await trackProgress(
-        `Scanned ${scanned} accounts (acceptedWithAddress=${acceptedWithAddress}, vat=${vatCandidates.length}, nonVat=${nonVatCandidates.length})`
-      )
-      debug('Progress', {
-        scanned,
-        acceptedWithAddress,
-        rejectedNoAddress,
-        rejectedVatOverCap,
-        vatCandidates: vatCandidates.length,
-        nonVatCandidates: nonVatCandidates.length,
-        billingInfoFetched,
-        billingInfoNotFound,
-        billingInfoOtherError,
-        usedBillingAddress,
-        usedBillingVatNumber,
-      })
-    }
-  }
-
-  if (vatCandidates.length < 1) {
-    throw new Error(
-      `Unable to find any accounts with VAT numbers (scanned=${scanned}, acceptedWithAddress=${acceptedWithAddress}, rejectedNoAddress=${rejectedNoAddress})`
-    )
-  }
-
-  const vatToTake = Math.max(1, Math.min(vatCap, vatCandidates.length))
-  const nonVatToTake = limit - vatToTake
-  if (nonVatCandidates.length < nonVatToTake) {
-    throw new Error(
-      `Unable to satisfy VAT ratio constraint: need ${nonVatToTake} non-VAT + ${vatToTake} VAT, but have nonVat=${nonVatCandidates.length}, vat=${vatCandidates.length} (scanned=${scanned}, rejectedNoAddress=${rejectedNoAddress}, rejectedVatOverCap=${rejectedVatOverCap})`
-    )
-  }
-
-  const accounts = [
-    ...vatCandidates.slice(0, vatToTake),
-    ...nonVatCandidates.slice(0, nonVatToTake),
-  ]
-
-  await trackProgress(
-    `Selected ${accounts.length} accounts (vat=${vatToTake}, nonVat=${nonVatToTake}, scanned=${scanned}, rejectedNoAddress=${rejectedNoAddress})`
-  )
-
-  // Output CSV
-  const csvHeader =
-    'recurly_account_code,target_stripe_account,stripe_customer_id'
-  const csvRows = accounts.map(
-    a =>
-      `${a.recurly_account_code},${a.target_stripe_account},${a.stripe_customer_id}`
-  )
-  const csvContent = [csvHeader, ...csvRows].join('\n') + '\n'
-
-  fs.writeFileSync(args.output, csvContent)
-  await trackProgress(`Wrote ${accounts.length} accounts to ${args.output}`)
-
-  // Output a summary
-  const states = {}
-  accounts.forEach(a => {
-    states[a.state] = (states[a.state] || 0) + 1
-  })
-  await trackProgress('Account states:')
-  for (const [state, stateCount] of Object.entries(states)) {
-    await trackProgress(`  ${state}: ${stateCount}`)
-  }
-}
-
-try {
-  await scriptRunner(main)
-  process.exit(0)
-} catch (err) {
-  console.error('Error:', err.message)
-  process.exit(1)
-}

+ 0 - 2511
services/web/scripts/recurly/migrate_recurly_customers_to_stripe.mjs

@@ -1,2511 +0,0 @@
-#!/usr/bin/env node
-
-/**
- * This script updates existing Stripe customer records with data from Recurly.
- *
- * It can be deleted once the Recurly to Stripe migration is complete.
- *
- * PREREQUISITE: Customers must already exist in the target Stripe account (created via PAN import
- * or other process). This script updates them with additional data from Recurly.
- *
- * RESUMABLE EXECUTION:
- *   This script is designed to be re-runnable. If it fails partway through, you can fix
- *   the issue and re-run with the same arguments. It will:
- *   1. Load already-processed records from the success output file
- *   2. Skip any records that were already successfully processed
- *   3. Re-attempt any records not in the success file (including previous failures)
- *
- *   To force a fresh start, use --restart flag or delete the success output file.
- *
- * Input CSV format:
- *   recurly_account_code,target_stripe_account,stripe_customer_id
- *
- * Where:
- *   - recurly_account_code: The Recurly account code (also the Overleaf user ID)
- *   - target_stripe_account: The target Stripe service name ('stripe-us' or 'stripe-uk')
- *   - stripe_customer_id: The Stripe customer ID (required - customers must already exist)
- *
- * Output files:
- *   --output (success file): Records that were successfully updated
- *     Format: recurly_account_code,target_stripe_account,stripe_customer_id
- *
- *   <output>_errors.csv: Records that failed (overwritten each run)
- *     Format: recurly_account_code,target_stripe_account,stripe_customer_id,error
- *
- *   <output>_stripe.json (dry-run only): Stripe customer update params
- *     Format: Array of { recurly_account_code, target_stripe_account, stripe_customer_id, updateParams }
- *
- *   <output>_stripe_existing_fields.json: Stripe customers that already had name/address/business_name set
- *     (written in both dry-run and commit modes)
- *     Format: Array of { recurly_account_code, stripe_account, stripe_customer_id, recurly: {...}, stripe: {...} }
- *
- * Resume behavior:
- *   - Records in the success file are SKIPPED (already done)
- *   - Records in the errors file are RE-PROCESSED (will be retried)
- *   - After each run, the errors file contains ONLY the failures from that run
- *   - Successfully retried records are moved from errors to success file
- *
- * Usage:
- *   # Dry run (no changes made, outputs _stripe.json with what would be updated)
- *   node scripts/recurly/migrate_recurly_customers_to_stripe.mjs --input customers.csv --output results.csv
- *
- *   # Commit changes
- *   node scripts/recurly/migrate_recurly_customers_to_stripe.mjs --input customers.csv --output results.csv --commit
- *
- *   # Resume after failure (just run the same command again)
- *   node scripts/recurly/migrate_recurly_customers_to_stripe.mjs --input customers.csv --output results.csv --commit
- *
- * Options:
- *   --input, -i <file>     Path to input CSV file
- *   --output, -o <file>    Path to success output CSV file
- *   --limit, -l <n>        Limit number of records processed (default: no limit)
- *   --concurrency, -c <n>  Number of customers to process concurrently (default: 10)
- *   --recurly-rate-limit <n>     Requests per second for Recurly (default: 10)
- *   --recurly-api-retries <n>    Number of retries on Recurly 429s (default: 5)
- *   --recurly-retry-delay-ms <n> Delay between Recurly retries in ms (default: 1000)
- *   --stripe-rate-limit <n>      Requests per second for Stripe (default: 50)
- *   --stripe-api-retries <n>     Number of retries on Stripe 429s (default: 5)
- *   --stripe-retry-delay-ms <n>  Delay between Stripe retries in ms (default: 1000)
- *   --force-invalid-tax     Allow VAT numbers that cannot be mapped to a tax ID type (default: false)
- *   --commit               Actually update customers in Stripe (default: dry-run mode)
- *   --verbose, -v          Enable debug logging
- *   --restart              Ignore existing output files and start fresh
- *
- *
- * Note, prior to running this script, environment variables must have been loaded from config/local.env
- *
- * ```
- * set -a
- * source ../../config/local.env
- * set +a
- * ```
- */
-
-import Settings from '@overleaf/settings'
-import Stripe from 'stripe'
-import recurly from 'recurly'
-import minimist from 'minimist'
-import PQueue from 'p-queue'
-import fs from 'node:fs'
-import * as csv from 'csv'
-import { scriptRunner } from '../lib/ScriptRunner.mjs'
-
-import {
-  areStripeAndRecurlyCardDetailsEqual,
-  coalesceOrThrowPaymentMethod,
-  getTaxIdType,
-  normalizeRecurlyAddressToStripe,
-  normalizeName,
-  addressesEqual,
-  resolveCustomerIdentity,
-  sanitizeAccount,
-  normalizeComparableString,
-  hasAnyAddressValue,
-  ccEmailsToArray,
-  RECURLY_CUSTOM_FIELD_NAMES,
-  extractRecurlyCustomFieldMetadata,
-} from '../helpers/migrate_recurly_customers_to_stripe.helpers.mjs'
-import {
-  createRateLimitedApiWrappers,
-  DEFAULT_RECURLY_RATE_LIMIT,
-  DEFAULT_STRIPE_RATE_LIMIT,
-  DEFAULT_RECURLY_API_RETRIES,
-  DEFAULT_RECURLY_RETRY_DELAY_MS,
-  DEFAULT_STRIPE_API_RETRIES,
-  DEFAULT_STRIPE_RETRY_DELAY_MS,
-} from '../stripe/RateLimiter.mjs'
-
-// =============================================================================
-// STRIPE CLIENT SETUP
-// =============================================================================
-
-const stripeClients = {}
-
-/**
- * Get a Stripe client by region ("us" or "uk").
- *
- * This intentionally mirrors the Stripe SDK construction used by subscriptions
- * (fetch http client + telemetry disabled), but without importing the full
- * subscriptions Stripe client module (which pulls in unrelated app code).
- */
-function getRegionClient(region) {
-  const regionLower = String(region || '')
-    .trim()
-    .toLowerCase()
-
-  if (regionLower !== 'us' && regionLower !== 'uk') {
-    throw new Error(
-      `Unknown Stripe region: ${region}. Expected stripe-us or stripe-uk.`
-    )
-  }
-
-  if (stripeClients[regionLower]) return stripeClients[regionLower]
-
-  const secretKey =
-    regionLower === 'us'
-      ? Settings.apis?.stripeUS?.secretKey ||
-        process.env.STRIPE_OL_SECRET_KEY ||
-        process.env.STRIPE_OL_US_SECRET_KEY
-      : Settings.apis?.stripeUK?.secretKey ||
-        process.env.STRIPE_OL_UK_SECRET_KEY
-
-  if (!secretKey || !String(secretKey).trim()) {
-    throw new Error(
-      `No Stripe secret key configured for region ${regionLower}. ` +
-        `Configure Settings.apis.stripeUS/stripeUK.secretKey or set ` +
-        `${
-          regionLower === 'us'
-            ? 'STRIPE_OL_SECRET_KEY (or legacy STRIPE_OL_US_SECRET_KEY)'
-            : 'STRIPE_OL_UK_SECRET_KEY'
-        }.`
-    )
-  }
-
-  const client = new Stripe(secretKey, {
-    httpClient: Stripe.createFetchHttpClient(),
-    telemetry: false,
-  })
-
-  // Add serviceName for rate limiter identification (stripe-us or stripe-uk)
-  client.serviceName = `stripe-${regionLower}`
-
-  stripeClients[regionLower] = client
-  return client
-}
-
-// =============================================================================
-// RECURLY CLIENT SETUP
-// =============================================================================
-
-const recurlyApiKey =
-  process.env.RECURLY_API_KEY || Settings.apis?.recurly?.apiKey
-if (!recurlyApiKey) {
-  throw new Error(
-    'Recurly API key is not set. Set RECURLY_API_KEY env var or configure Settings.apis.recurly.apiKey'
-  )
-}
-const recurlyClient = new recurly.Client(recurlyApiKey)
-
-// =============================================================================
-// LOGGING UTILITIES
-// =============================================================================
-
-/**
- * Get ISO timestamp for logging
- */
-function timestamp() {
-  return new Date().toISOString()
-}
-
-/**
- * Log a warning message with timestamp
- */
-function logWarn(message, context = {}) {
-  const contextStr =
-    Object.keys(context).length > 0 ? ` ${JSON.stringify(context)}` : ''
-  console.warn(`[${timestamp()}] WARN: ${message}${contextStr}`)
-}
-
-/**
- * Log an error message with timestamp and optional stack trace
- */
-function logError(message, error = null, context = {}) {
-  const contextStr =
-    Object.keys(context).length > 0 ? ` ${JSON.stringify(context)}` : ''
-  console.error(`[${timestamp()}] ERROR: ${message}${contextStr}`)
-  if (error?.stack) {
-    console.error(`[${timestamp()}] STACK: ${error.stack}`)
-  }
-}
-
-/**
- * Debug mode flag - controlled by --verbose/-v CLI arg.
- * (Intentionally not controlled via env var to avoid accidental noisy logs.)
- */
-let DEBUG_MODE = false
-
-/**
- * Log a message with timestamp.
- *
- * By default, logs at INFO level.
- * When { verboseOnly: true }, only logs when DEBUG_MODE is enabled.
- */
-function logDebug(message, context = {}, { verboseOnly = false } = {}) {
-  if (verboseOnly && !DEBUG_MODE) return
-  const contextStr =
-    Object.keys(context).length > 0 ? ` ${JSON.stringify(context)}` : ''
-  const level = verboseOnly ? 'DEBUG' : 'INFO'
-  console.log(`[${timestamp()}] ${level}: ${message}${contextStr}`)
-}
-
-// =============================================================================
-// RESUME FUNCTIONALITY
-// =============================================================================
-
-/**
- * Load previously successfully processed records from the success output file.
- * Returns a Set of recurly_account_codes that have been successfully processed.
- *
- * Only records in the SUCCESS file are skipped. Records in the errors file
- * (or not in any file) will be processed/re-attempted.
- *
- * @param {string} successOutputPath - Path to the success output CSV file
- * @returns {Promise<Set<string>>}
- */
-async function loadSuccessfullyProcessed(successOutputPath) {
-  const processed = new Set()
-
-  if (!fs.existsSync(successOutputPath)) {
-    logDebug('No existing success file found, starting fresh', {
-      successOutputPath,
-    })
-    return processed
-  }
-
-  logDebug('Loading previously successful records from success file', {
-    successOutputPath,
-  })
-
-  return new Promise((resolve, reject) => {
-    fs.createReadStream(successOutputPath)
-      .pipe(
-        csv.parse({
-          columns: true,
-          trim: true,
-          skip_empty_lines: true,
-          relax_column_count: true,
-          relax_column_count_less: true,
-        })
-      )
-      .on('data', row => {
-        if (row.recurly_account_code) {
-          processed.add(row.recurly_account_code)
-        }
-      })
-      .on('end', () => {
-        logDebug('Loaded previously successful records', {
-          count: processed.size,
-        })
-        resolve(processed)
-      })
-      .on('error', err => {
-        logError('Failed to read success file', err, { successOutputPath })
-        reject(err)
-      })
-  })
-}
-
-/**
- * Helper to write a CSV row with proper escaping
- */
-// TODO: consider using a CSV library
-function formatCsvRow(columns, row) {
-  const values = columns.map(col => {
-    const val = row[col] ?? ''
-    // Escape CSV values that contain commas, quotes, or newlines
-    if (
-      typeof val === 'string' &&
-      (val.includes(',') || val.includes('"') || val.includes('\n'))
-    ) {
-      return `"${val.replace(/"/g, '""')}"`
-    }
-    return val
-  })
-  return values.join(',') + '\n'
-}
-
-/**
- * Create output writers for success and error files.
- *
- * Success file: Append-only, contains all successfully updated records
- * Errors file: Overwritten each run, contains only failures from this run
- *
- * @param {string} successPath - Path to the success output CSV file
- * @param {string} errorsPath - Path to the errors output CSV file
- * @param {boolean} restart - If true, truncate existing files
- * @returns {{ writeSuccess: (row: object) => void, writeError: (row: object) => void, close: () => Promise<void> }}
- */
-function createOutputWriters(
-  successPath,
-  errorsPath,
-  restart = false,
-  { enableSuccessFile = true } = {}
-) {
-  // Success file columns
-  const successColumns = [
-    'recurly_account_code',
-    'target_stripe_account',
-    'stripe_customer_id',
-  ]
-
-  // Errors file columns (includes error message)
-  const errorsColumns = [
-    'recurly_account_code',
-    'target_stripe_account',
-    'stripe_customer_id',
-    'error',
-  ]
-
-  // Success file: append mode (unless restart)
-  // NOTE: In dry-run mode, we intentionally do NOT create or write to the success file,
-  // because commit mode uses it for resume/skip behavior.
-  const successStream = enableSuccessFile
-    ? (() => {
-        const successExists = fs.existsSync(successPath)
-        const successFlags = restart ? 'w' : 'a'
-        const stream = fs.createWriteStream(successPath, {
-          flags: successFlags,
-        })
-        if (restart || !successExists) {
-          stream.write(successColumns.join(',') + '\n')
-        }
-        return stream
-      })()
-    : null
-
-  // Errors file: always overwrite (contains only this run's errors)
-  const errorsStream = fs.createWriteStream(errorsPath, { flags: 'w' })
-  errorsStream.write(errorsColumns.join(',') + '\n')
-
-  function writeSuccess(row) {
-    if (!successStream) return
-    successStream.write(formatCsvRow(successColumns, row))
-  }
-
-  function writeError(row) {
-    errorsStream.write(formatCsvRow(errorsColumns, row))
-  }
-
-  async function close() {
-    if (successStream) successStream.end()
-    errorsStream.end()
-
-    const closers = [
-      new Promise((resolve, reject) => {
-        errorsStream.on('finish', resolve)
-        errorsStream.on('error', reject)
-      }),
-    ]
-
-    if (successStream) {
-      closers.unshift(
-        new Promise((resolve, reject) => {
-          successStream.on('finish', resolve)
-          successStream.on('error', reject)
-        })
-      )
-    }
-
-    await Promise.all(closers)
-  }
-
-  return { writeSuccess, writeError, close }
-}
-
-/**
- * Get the errors file path from the success file path
- */
-function getErrorsPath(successPath) {
-  return successPath.replace(/\.csv$/, '_errors.csv')
-}
-
-/**
- * Get the stripe.json file path from the success file path (for dry-run mode)
- */
-function getStripeJsonPath(successPath) {
-  return successPath.replace(/\.csv$/, '_stripe.json')
-}
-
-/**
- * Get the stripe_existing_fields.json file path from the success file path.
- */
-function getStripeExistingFieldsJsonPath(successPath) {
-  return successPath.replace(/\.csv$/, '_stripe_existing_fields.json')
-}
-
-/**
- * Stream a JSON array to disk without holding it all in memory.
- */
-function createJsonArrayWriter(jsonPath) {
-  const stream = fs.createWriteStream(jsonPath, { flags: 'w' })
-  stream.write('[\n')
-  let wroteAny = false
-
-  function write(value) {
-    const serialized = JSON.stringify(value, null, 2)
-    if (wroteAny) stream.write(',\n')
-    stream.write(serialized)
-    wroteAny = true
-  }
-
-  async function close() {
-    stream.write('\n]\n')
-    stream.end()
-    await new Promise((resolve, reject) => {
-      stream.on('finish', resolve)
-      stream.on('error', reject)
-    })
-  }
-
-  return { write, close }
-}
-
-// =============================================================================
-// RATE LIMITING
-// =============================================================================
-
-// rate limiters - initialized in main()
-let rateLimiters
-
-// =============================================================================
-// DATA TRANSFORMATION
-// =============================================================================
-
-/**
- * Fetch Recurly account data for a given account code.
- *
- * @param {string} accountCode - The Recurly account code (Overleaf user ID)
- * @returns {Promise<Account>}
- */
-async function fetchRecurlyData(accountCode, context) {
-  return await rateLimiters.requestWithRetries(
-    'recurly',
-    () => recurlyClient.getAccount(`code-${accountCode}`),
-    context
-  )
-}
-
-/**
- * Fetch live Recurly subscriptions for an account and ensure there is at most one.
- *
- * Returns the subscription object, or null if no live subscription exists.
- * Throws if more than one live subscription exists.
- *
- * @param {string} accountCode - Recurly account code
- * @param {object} context - Logging context
- * @returns {Promise<object|null>}
- */
-async function fetchRecurlyActiveSubscription(accountCode, context) {
-  const subscriptions = await rateLimiters.requestWithRetries(
-    'recurly',
-    async () => {
-      const pager = recurlyClient.listAccountSubscriptions(
-        `code-${accountCode}`,
-        {
-          params: { state: 'live' },
-        }
-      )
-
-      // we don't strictly need to fetch all subscriptions since we only
-      // care if there is one or more than one, but this is an unlikely
-      // edge case and knowing the actual number may be helpful for debugging, so we fetch them all
-      const results = []
-      for await (const subscription of pager.each()) {
-        results.push(subscription)
-      }
-      return results
-    },
-    context
-  )
-
-  if (subscriptions.length > 1) {
-    const subscriptionIds = subscriptions
-      .map(subscription => subscription?.id)
-      .filter(Boolean)
-
-    throw new Error(
-      `Expected at most one live Recurly subscription for account ${accountCode}, found ${subscriptions.length}${
-        subscriptionIds.length > 0 ? ` (${subscriptionIds.join(', ')})` : ''
-      }`
-    )
-  }
-
-  return subscriptions[0] ?? null
-}
-
-/**
- * Fetch existing customer from the target Stripe account by ID.
- *
- * @param {Stripe} stripeClient - The Stripe client for the target account
- * @param {string} stripeCustomerId - The Stripe customer ID
- * @returns {Promise<Stripe.Customer>}
- * @throws {Error} If customer is not found or is deleted
- */
-async function fetchTargetStripeCustomer(
-  stripeClient,
-  stripeCustomerId,
-  context
-) {
-  const customer = await rateLimiters.requestWithRetries(
-    stripeClient.serviceName,
-    () =>
-      stripeClient.customers.retrieve(stripeCustomerId, {
-        expand: ['subscriptions'],
-      }),
-    { ...context, stripeApi: 'customers.retrieve' }
-  )
-
-  if (customer.deleted) {
-    throw new Error(`Stripe customer ${stripeCustomerId} has been deleted`)
-  }
-
-  return customer
-}
-
-/**
- * Query for other matching customers from the target Stripe account by ID.
- *
- * @param {Stripe} stripeClient - The Stripe client for the target account
- * @param {string} userId - The user id to query
- * @param {string} stripeCustomerId - The Stripe customer ID to exclude from results (if any)
- * @param {object} context - Context for logging and rate limiter identification
- * @returns {Promise<Stripe.Customer | null>}
- */
-async function fetchOtherStripeCustomerByUserId(
-  stripeClient,
-  userId,
-  stripeCustomerId,
-  context
-) {
-  const results = await rateLimiters.requestWithRetries(
-    stripeClient.serviceName,
-    () =>
-      stripeClient.customers.search({
-        query: `metadata['userId']:"${userId}"`,
-        limit: 100,
-        expand: ['data.subscriptions'],
-      }),
-    { ...context, stripeApi: 'customers.search' }
-  )
-
-  const matchingCustomers = results.data?.filter(
-    customer => customer.id !== stripeCustomerId
-  )
-
-  if (matchingCustomers.length > 1) {
-    throw new Error(
-      `Multiple Stripe customers found with userId metadata "${userId}": ${matchingCustomers.map(c => c.id).join(', ')}`
-    )
-  }
-
-  return matchingCustomers[0] || null
-}
-
-/**
- * Mark a Stripe customer as a duplicate of another customer.
- *
- * @param {Stripe} stripeClient
- * @param {string} stripeCustomerId
- * @param {string} recurlyAccountCode
- * @param {object} context
- * @returns {Promise<void>}
- */
-async function markCustomerAsDuplicate(
-  stripeClient,
-  stripeCustomerId,
-  recurlyAccountCode,
-  context
-) {
-  const email =
-    Settings.duplicateStripeCustomerAccountEmail?.replace(
-      '@',
-      `+${stripeCustomerId}@`
-    ) || ''
-  await rateLimiters.requestWithRetries(
-    stripeClient.serviceName,
-    () =>
-      stripeClient.customers.update(stripeCustomerId, {
-        email,
-        metadata: {
-          userId: '',
-          duplicateUserId: recurlyAccountCode,
-        },
-      }),
-    { ...context, stripeApi: 'customers.update' }
-  )
-}
-
-/**
- * Fetch existing customer's payment method from the target Stripe account by ID.
- *
- * @param {Stripe} stripeClient - The Stripe client for the target account
- * @param {string} stripeCustomerId - The Stripe customer ID
- * @returns {Promise<Stripe.PaymentMethod[]>}
- */
-async function fetchTargetStripeCustomerPaymentMethods(
-  stripeClient,
-  stripeCustomerId,
-  context
-) {
-  const paymentMethods = []
-  let startingAfter
-
-  while (true) {
-    const page = await rateLimiters.requestWithRetries(
-      stripeClient.serviceName,
-      () =>
-        stripeClient.customers.listPaymentMethods(stripeCustomerId, {
-          limit: 100,
-          ...(startingAfter ? { starting_after: startingAfter } : {}),
-        }),
-      { ...context, stripeApi: 'customers.listPaymentMethods' }
-    )
-
-    paymentMethods.push(...page.data)
-
-    if (!page.has_more || page.data.length === 0) {
-      break
-    }
-
-    startingAfter = page.data[page.data.length - 1].id
-  }
-
-  return paymentMethods
-}
-
-/**
- * Creates a Stripe Setup Intent to import a PayPal billing agreement.
- *
- * @param {Stripe} stripeClient - The Stripe client for the target account
- * @param {string} stripeCustomerId - The Stripe customer ID
- * @param {string} billingAgreementId - The PayPal billing agreement ID
- * @param {object} context - Logging context
- * @returns {Promise<Stripe.PaymentMethod>}
- * @throws {Error} If the setup intent fails or does not produce a payment method
- */
-async function createPayPalPaymentMethod(
-  stripeClient,
-  stripeCustomerId,
-  billingAgreementId,
-  context
-) {
-  logDebug(
-    'Creating PayPal setup intent',
-    {
-      ...context,
-      step: 'create_paypal_setup_intent',
-    },
-    { verboseOnly: true }
-  )
-
-  const setupIntent = await rateLimiters.requestWithRetries(
-    stripeClient.serviceName,
-    () =>
-      stripeClient.setupIntents.create({
-        customer: stripeCustomerId,
-        payment_method_types: ['paypal'],
-        payment_method_data: {
-          type: 'paypal',
-        },
-        payment_method_options: {
-          paypal: {
-            billing_agreement_id: billingAgreementId,
-          },
-        },
-        confirm: true,
-        usage: 'off_session',
-        mandate_data: {
-          customer_acceptance: {
-            type: 'offline',
-          },
-        },
-        return_url: `${Settings.siteUrl}/user/subscription`, // required for PayPal setup intents, but not actually used since we're confirming immediately
-        expand: ['payment_method'],
-      }),
-    { ...context, stripeApi: 'setupIntents.create' }
-  )
-
-  if (setupIntent.status !== 'succeeded') {
-    throw new Error(
-      `PayPal setup intent ${setupIntent.id} has unexpected status: ${setupIntent.status}`
-    )
-  }
-
-  if (!setupIntent.payment_method) {
-    throw new Error(
-      `PayPal setup intent ${setupIntent.id} succeeded but has no payment_method`
-    )
-  }
-
-  logDebug(
-    'Successfully created PayPal setup intent',
-    {
-      ...context,
-      setupIntentId: setupIntent.id,
-      paymentMethodId: setupIntent.payment_method.id,
-    },
-    { verboseOnly: true }
-  )
-
-  // The setup intent returns the full payment method object, but we only need the ID
-  // to set it as the default on the customer.
-  return setupIntent.payment_method
-}
-
-// Some Stripe API objects can be returned as either an ID string or a full object depending on context.
-// This helper normalizes to just the ID string for easier comparison
-function normalizeExpandableId(value) {
-  if (!value) return null
-  if (typeof value === 'string') return value
-  if (typeof value === 'object' && typeof value.id === 'string') {
-    return value.id
-  }
-  return null
-}
-
-async function findPayPalPaymentMethodByBillingAgreementId(
-  stripeClient,
-  stripeCustomerId,
-  paypalPaymentMethods,
-  billingAgreementId,
-  context
-) {
-  if (!billingAgreementId) {
-    return {
-      paymentMethod: null,
-      reason: 'billing_agreement_id_not_available',
-    }
-  }
-
-  if (paypalPaymentMethods.length === 0) {
-    return {
-      paymentMethod: null,
-      reason: 'no_existing_paypal_payment_methods',
-    }
-  }
-
-  const matchedPaymentMethods = []
-
-  for (const paymentMethod of paypalPaymentMethods) {
-    let matched = false
-    let startingAfter
-
-    while (true) {
-      const setupIntents = await rateLimiters.requestWithRetries(
-        stripeClient.serviceName,
-        () =>
-          stripeClient.setupIntents.list({
-            payment_method: paymentMethod.id,
-            limit: 100,
-            ...(startingAfter ? { starting_after: startingAfter } : {}),
-          }),
-        { ...context, stripeApi: 'setupIntents.list' }
-      )
-
-      for (const setupIntent of setupIntents.data) {
-        if (setupIntent.status !== 'succeeded') continue
-
-        const setupIntentCustomerId = normalizeExpandableId(
-          setupIntent.customer
-        )
-        if (
-          setupIntentCustomerId &&
-          setupIntentCustomerId !== stripeCustomerId
-        ) {
-          continue
-        }
-
-        const mandateId = normalizeExpandableId(setupIntent.mandate)
-        if (!mandateId) continue
-
-        const mandate = await rateLimiters.requestWithRetries(
-          stripeClient.serviceName,
-          () => stripeClient.mandates.retrieve(mandateId),
-          { ...context, stripeApi: 'mandates.retrieve' }
-        )
-
-        const mandateBaid =
-          mandate?.payment_method_details?.paypal?.billing_agreement_id || null
-        if (mandateBaid === billingAgreementId) {
-          matchedPaymentMethods.push(paymentMethod)
-          matched = true
-          break
-        }
-      }
-
-      if (matched || !setupIntents.has_more || setupIntents.data.length === 0) {
-        break
-      }
-
-      startingAfter = setupIntents.data[setupIntents.data.length - 1].id
-    }
-  }
-
-  if (matchedPaymentMethods.length === 1) {
-    return {
-      paymentMethod: matchedPaymentMethods[0],
-      reason: 'reuse_payment_method_matching_mandate_billing_agreement_id',
-    }
-  }
-
-  if (matchedPaymentMethods.length > 1) {
-    logDebug(
-      "multiple payment methods matched billing agreement ID, we'll reuse the most recently created one",
-      {
-        ...context,
-        paymentMethodIds: matchedPaymentMethods.map(method => method.id),
-      }
-    )
-    matchedPaymentMethods.sort((a, b) => b.created - a.created)
-    return {
-      paymentMethod: matchedPaymentMethods[0],
-      reason: 'reuse_payment_method_matching_mandate_billing_agreement_id',
-    }
-  }
-
-  return {
-    paymentMethod: null,
-    reason: 'no_payment_method_matches_billing_agreement_id',
-  }
-}
-
-/**
- * Determines the payment method to set on the Stripe customer.
- *
- * This handles both migrating a PayPal billing agreement and matching an existing
- * credit card payment method.
- *
- * @param {Stripe} stripeClient - The Stripe client for the target account
- * @param {string} stripeCustomerId - The Stripe customer ID
- * @param {object} billingInfo - Recurly billing info object
- * @param {object} address - The customer's address (used for PayPal country check)
- * @param {boolean} commit - Whether this is a dry-run or a commit
- * @param {object} context - Logging context
- * @returns {Promise<Stripe.PaymentMethod>}
- * @throws {Error} If the payment method cannot be determined or created
- */
-async function getPaymentMethod(
-  stripeClient,
-  stripeCustomerId,
-  billingInfo,
-  address,
-  commit,
-  context
-) {
-  const isPayPalBillingAgreement =
-    billingInfo?.paymentMethod?.object === 'paypal_billing_agreement'
-
-  if (isPayPalBillingAgreement) {
-    const addressCountry = address?.country
-    if (
-      addressCountry === 'CA' ||
-      addressCountry === 'US' ||
-      stripeClient.serviceName === 'stripe-us'
-    ) {
-      throw new Error(
-        `PayPal billing agreement migration is not supported for ${addressCountry} customers`
-      )
-    }
-  }
-
-  const paymentMethods = await fetchTargetStripeCustomerPaymentMethods(
-    stripeClient,
-    stripeCustomerId,
-    context
-  )
-
-  if (isPayPalBillingAgreement) {
-    const paypalPaymentMethods = paymentMethods.filter(
-      method => method.type === 'paypal'
-    )
-
-    const billingAgreementId = billingInfo.paymentMethod.billingAgreementId
-
-    if (!billingAgreementId) {
-      throw new Error(
-        `PayPal billing agreement migration requires billingAgreementId for Stripe customer ${stripeCustomerId}`
-      )
-    }
-
-    logDebug(
-      'Evaluating existing PayPal payment methods by billing agreement ID',
-      {
-        ...context,
-        step: 'evaluate_paypal_payment_methods',
-        paypalPaymentMethodCount: paypalPaymentMethods.length,
-        paypalPaymentMethodIds: paypalPaymentMethods.map(method => method.id),
-      },
-      { verboseOnly: true }
-    )
-
-    const { paymentMethod: baidMatchedPaymentMethod, reason: baidMatchReason } =
-      await findPayPalPaymentMethodByBillingAgreementId(
-        stripeClient,
-        stripeCustomerId,
-        paypalPaymentMethods,
-        billingAgreementId,
-        context
-      )
-
-    if (baidMatchedPaymentMethod) {
-      logDebug(
-        'Reusing existing PayPal payment method by billing agreement ID',
-        {
-          ...context,
-          paymentMethodId: baidMatchedPaymentMethod.id,
-          reason: baidMatchReason,
-          step: 'reuse_paypal_payment_method',
-        },
-        { verboseOnly: true }
-      )
-      return baidMatchedPaymentMethod
-    }
-
-    if (commit) {
-      logDebug(
-        'No PayPal payment method matched billing agreement ID; creating setup intent',
-        {
-          ...context,
-          reason: baidMatchReason,
-          step: 'create_paypal_setup_intent',
-        },
-        { verboseOnly: true }
-      )
-      return await createPayPalPaymentMethod(
-        stripeClient,
-        stripeCustomerId,
-        billingAgreementId,
-        context
-      )
-    } else {
-      logDebug('DRY RUN: Would create PayPal setup intent', context, {
-        verboseOnly: true,
-      })
-      // Return a placeholder for dry-run output
-      return { id: 'pm_placeholder_paypal_dry_run', type: 'paypal' }
-    }
-  }
-
-  return coalesceOrThrowPaymentMethod(
-    paymentMethods,
-    stripeCustomerId,
-    billingInfo
-  )
-}
-
-/**
- * Replace a customer's tax IDs (delete any existing, then create the desired one).
- *
- * This makes re-runs more predictable for customers where a tax ID was created
- * before a later step failed.
- */
-async function replaceCustomerTaxIds(
-  stripeClient,
-  stripeCustomerId,
-  { taxIdType, vatNumber },
-  context
-) {
-  // Stripe customers can have multiple tax IDs. For this migration, we want a single
-  // authoritative tax ID derived from Recurly, so we remove any existing ones first.
-  const existingTaxIds = []
-
-  let startingAfter
-  while (true) {
-    const page = await rateLimiters.requestWithRetries(
-      stripeClient.serviceName,
-      () =>
-        stripeClient.customers.listTaxIds(stripeCustomerId, {
-          limit: 100,
-          ...(startingAfter ? { starting_after: startingAfter } : {}),
-        }),
-      { ...context, stripeApi: 'customers.listTaxIds' }
-    )
-
-    existingTaxIds.push(...page.data)
-
-    if (!page.has_more || page.data.length === 0) break
-    startingAfter = page.data[page.data.length - 1].id
-  }
-
-  if (existingTaxIds.length > 0) {
-    logDebug(
-      'Deleting existing Stripe tax IDs before creating new one',
-      {
-        ...context,
-        existingTaxIdCount: existingTaxIds.length,
-      },
-      { verboseOnly: true }
-    )
-
-    for (const taxId of existingTaxIds) {
-      await rateLimiters.requestWithRetries(
-        stripeClient.serviceName,
-        () => stripeClient.customers.deleteTaxId(stripeCustomerId, taxId.id),
-        { ...context, stripeApi: 'customers.deleteTaxId' }
-      )
-    }
-  }
-
-  try {
-    return await rateLimiters.requestWithRetries(
-      stripeClient.serviceName,
-      () =>
-        stripeClient.customers.createTaxId(stripeCustomerId, {
-          type: taxIdType,
-          value: vatNumber,
-        }),
-      { ...context, stripeApi: 'customers.createTaxId' }
-    )
-  } catch (error) {
-    const parts = [
-      `Failed to create Stripe tax ID (type=${taxIdType}, value=${vatNumber})`,
-    ]
-    if (error.code) parts.push(`code=${error.code}`)
-    if (error.message) parts.push(error.message)
-    const wrappedError = new Error(parts.join(': '))
-    wrappedError.code = error.code
-    wrappedError.type = error.type
-    wrappedError.statusCode = error.statusCode
-    throw wrappedError
-  }
-}
-
-function isStripeTaxIdInvalidError(error) {
-  if (!error) return false
-
-  return error.code === 'tax_id_invalid'
-}
-
-const STRIPE_METADATA_MAX_ALT_EMAILS = 5
-
-// =============================================================================
-// MAIN PROCESSING
-// =============================================================================
-
-/**
- * Resolve the Stripe customer for a given Recurly account.
- *
- * Handles three cases:
- * 1. Another customer with matching userId metadata exists when no stripeCustomerId is provided → reuse it
- * 2. No stripeCustomerId provided → create a new customer (or placeholder in dry-run)
- * 3. stripeCustomerId provided → fetch the existing customer
- *
- * @param {object} params
- * @param {Stripe} params.stripeClient - Stripe SDK client for the target account
- * @param {string|null} params.stripeCustomerId - Stripe customer ID from the input CSV (may be empty)
- * @param {string} params.recurlyAccountCode - Recurly account code / Overleaf user ID
- * @param {object} params.account - Recurly account object (used for email on create)
- * @param {boolean} params.commit - Whether to actually create/fetch in Stripe
- * @param {object} params.context - Logging context
- * @param {object} params.stripeContext - Stripe-specific logging context
- * @returns {Promise<Stripe.Customer|object>} - The resolved Stripe customer object (or placeholder in dry-run)
- * @throws {Error} if there are multiple matching customers found
- */
-async function resolveStripeCustomer({
-  stripeClient,
-  stripeCustomerId,
-  recurlyAccountCode,
-  account,
-  commit,
-  context,
-  stripeContext,
-}) {
-  const otherMatchingCustomer = await fetchOtherStripeCustomerByUserId(
-    stripeClient,
-    recurlyAccountCode,
-    stripeCustomerId,
-    stripeContext
-  )
-
-  if (otherMatchingCustomer) {
-    if (stripeCustomerId) {
-      const otherCustomerPaymentMethods =
-        await fetchTargetStripeCustomerPaymentMethods(
-          stripeClient,
-          otherMatchingCustomer.id,
-          stripeContext
-        )
-      const isRecurlyPaymentMethodPaypal =
-        account?.billingInfo?.paymentMethod?.object ===
-        'paypal_billing_agreement'
-      const isRecurlyPaymentMethodManual = !account?.billingInfo?.paymentMethod // billing info may be missing for manually billed customers
-      const hasMatchingPaymentMethod = otherCustomerPaymentMethods.some(
-        method =>
-          areStripeAndRecurlyCardDetailsEqual(
-            method,
-            account?.billingInfo?.paymentMethod
-          )
-      )
-      if (
-        isRecurlyPaymentMethodPaypal ||
-        isRecurlyPaymentMethodManual ||
-        hasMatchingPaymentMethod
-      ) {
-        logDebug(
-          'Found another Stripe customer with matching userId metadata, reusing',
-          {
-            ...context,
-            nextStripeCustomerId: otherMatchingCustomer.id,
-          },
-          { verboseOnly: true }
-        )
-        if (commit) {
-          await markCustomerAsDuplicate(
-            stripeClient,
-            stripeCustomerId,
-            recurlyAccountCode,
-            stripeContext
-          )
-          logDebug(
-            'Marked CSV customer as a duplicate of the existing Stripe customer',
-            {
-              ...context,
-              nextStripeCustomerId: otherMatchingCustomer.id,
-            },
-            { verboseOnly: true }
-          )
-        } else {
-          logDebug(
-            'DRY RUN: Would mark CSV customer as a duplicate of the existing Stripe customer',
-            {
-              ...context,
-              nextStripeCustomerId: otherMatchingCustomer.id,
-              step: 'mark_duplicate',
-            },
-            { verboseOnly: true }
-          )
-        }
-        return otherMatchingCustomer
-      } else {
-        throw new Error(
-          `Found another Stripe customer with matching userId metadata but no matching payment method: ${otherMatchingCustomer.id}`
-        )
-      }
-    }
-
-    logDebug(
-      'Found Stripe customer with matching userId metadata, reusing',
-      { ...context, otherStripeCustomerId: otherMatchingCustomer.id },
-      { verboseOnly: true }
-    )
-    return otherMatchingCustomer
-  }
-
-  if (!stripeCustomerId) {
-    if (commit) {
-      const newCustomer = await rateLimiters.requestWithRetries(
-        stripeClient.serviceName,
-        () =>
-          stripeClient.customers.create({
-            email: account.email,
-            metadata: { userId: recurlyAccountCode },
-          }),
-        { ...stripeContext, stripeApi: 'customers.create' }
-      )
-      logDebug(
-        'Created new Stripe customer',
-        { ...context, newStripeCustomerId: newCustomer.id },
-        { verboseOnly: true }
-      )
-      return newCustomer
-    }
-
-    logDebug(
-      'DRY RUN: Would create new Stripe customer',
-      { ...context, step: 'create_stripe_customer' },
-      { verboseOnly: true }
-    )
-    return {
-      id: 'cus_dry_run_new_customer_placeholder',
-      metadata: { userId: recurlyAccountCode },
-    }
-  }
-
-  logDebug(
-    'Fetching existing Stripe customer',
-    { ...context, step: 'fetch_stripe_customer' },
-    { verboseOnly: true }
-  )
-  const customer = await fetchTargetStripeCustomer(
-    stripeClient,
-    stripeCustomerId,
-    stripeContext
-  )
-  logDebug(
-    'Resolved existing Stripe customer',
-    { ...context, stripeEmail: customer.email, stripeName: customer.name },
-    { verboseOnly: true }
-  )
-  return customer
-}
-
-/**
- * Compute the billing_details params for a Stripe payment method from Recurly billing info.
- *
- * @param {object} billingInfo - Recurly billing info object
- * @returns {object|null} - billing_details params, or null if there is nothing to set
- */
-function computePaymentMethodBillingDetails(billingInfo) {
-  const name = normalizeName(billingInfo?.firstName, billingInfo?.lastName)
-  const address = normalizeRecurlyAddressToStripe(billingInfo?.address)
-
-  const details = {}
-  if (name) details.name = name
-  if (address) details.address = address
-
-  return Object.keys(details).length > 0 ? details : null
-}
-
-/**
- * Update billing_details on a Stripe payment method with data from Recurly billing info.
- *
- * Used for manual-collection customers when billing info and account info differ:
- * the account info is written to the Stripe customer record, and the billing info
- * is copied to the payment method's billing_details.
- *
- * @param {Stripe} stripeClient
- * @param {string} paymentMethodId
- * @param {object} billingInfo - Recurly billing info object
- * @param {object} context
- * @returns {Promise<void>}
- */
-async function updatePaymentMethodBillingDetails(
-  stripeClient,
-  paymentMethodId,
-  billingInfo,
-  context
-) {
-  const billingDetails = computePaymentMethodBillingDetails(billingInfo)
-  if (!billingDetails) {
-    logDebug(
-      'No billing info details to copy to payment method billing_details',
-      context,
-      { verboseOnly: true }
-    )
-    return
-  }
-
-  logDebug(
-    'Updating payment method billing_details with Recurly billing info',
-    {
-      ...context,
-      paymentMethodId,
-      step: 'update_payment_method_billing_details',
-    },
-    { verboseOnly: true }
-  )
-  await rateLimiters.requestWithRetries(
-    stripeClient.serviceName,
-    () =>
-      stripeClient.paymentMethods.update(paymentMethodId, {
-        billing_details: billingDetails,
-      }),
-    { ...context, stripeApi: 'paymentMethods.update' }
-  )
-  logDebug(
-    'Successfully updated payment method billing_details',
-    { ...context, paymentMethodId },
-    { verboseOnly: true }
-  )
-}
-
-/**
- * Process a single customer row from the input CSV.
- *
- * Customers are expected to already exist in the target Stripe account
- * (created via PAN import). This function updates them with additional
- * data from Recurly.
- *
- * @param {object} row - CSV row with recurly_account_code, target_stripe_account, stripe_customer_id
- * @param {number} rowNumber - The row number in the input file (for logging)
- * @param {boolean} commit - Whether to actually update the customer
- * @returns {Promise<object>} - Result row for output CSV
- */
-async function processCustomer(
-  row,
-  rowNumber,
-  commit,
-  { writeStripeExistingFields, forceInvalidTax = false } = {}
-) {
-  const {
-    recurly_account_code: recurlyAccountCode,
-    target_stripe_account: targetStripeAccount,
-  } = row
-  let stripeCustomerId = row.stripe_customer_id
-
-  const context = {
-    rowNumber,
-    recurlyAccountCode,
-    targetStripeAccount,
-    stripeCustomerId,
-  }
-
-  const stripeContext = {
-    rowNumber,
-    stripeCustomerId,
-    stripeAccount: targetStripeAccount,
-  }
-
-  const result = {
-    recurly_account_code: recurlyAccountCode,
-    target_stripe_account: targetStripeAccount,
-    stripe_customer_id: stripeCustomerId || '',
-    outcome: '', // 'updated', 'dry_run', or 'error'
-    error: '',
-    customerParams: null, // Stripe customer params (for dry-run output)
-    taxInfoPending: null, // Recurly VAT number if tax ID type couldn't be determined
-  }
-
-  try {
-    // Validate required fields
-    if (!recurlyAccountCode) {
-      throw new Error('Missing required field: recurly_account_code')
-    }
-    if (!targetStripeAccount) {
-      throw new Error('Missing required field: target_stripe_account')
-    }
-
-    // Get Stripe client for target account
-    logDebug(
-      'Getting Stripe client',
-      { ...context, step: 'get_stripe_client' },
-      { verboseOnly: true }
-    )
-    // get Stripe client for the target account (strip 'stripe-' prefix if present)
-    const region = String(targetStripeAccount || '')
-      .trim()
-      .toLowerCase()
-      .replace(/^stripe-/, '')
-    const stripeClient = getRegionClient(region)
-
-    // Fetch Recurly data
-    logDebug(
-      'Fetching Recurly data',
-      { ...context, step: 'fetch_recurly' },
-      { verboseOnly: true }
-    )
-    const account = await fetchRecurlyData(recurlyAccountCode, context)
-
-    logDebug(
-      'Fetched Recurly account',
-      {
-        ...context,
-        email: account.email,
-        hasBillingInfo: !!account.billingInfo,
-        paymentMethod:
-          account.billingInfo?.paymentMethod?.object ===
-          'paypal_billing_agreement'
-            ? 'paypal'
-            : account.billingInfo?.cardType || 'none',
-        account: sanitizeAccount(account),
-      },
-      { verboseOnly: true }
-    )
-
-    const existingCustomer = await resolveStripeCustomer({
-      stripeClient,
-      stripeCustomerId,
-      recurlyAccountCode,
-      account,
-      commit,
-      context,
-      stripeContext,
-    })
-    stripeCustomerId = existingCustomer.id
-    result.stripe_customer_id = stripeCustomerId || ''
-    stripeContext.stripeCustomerId = stripeCustomerId
-    context.stripeCustomerId = stripeCustomerId
-
-    if (existingCustomer.subscriptions?.data.length > 0) {
-      throw new Error(
-        `Stripe customer ${stripeCustomerId} already has ${existingCustomer.subscriptions?.data?.length} active subscription(s).`
-      )
-    }
-
-    // Resolve customer identity (name, address, company, VAT number), handling
-    // conflicts between billing info and account fields via the subscription's
-    // collection_method.
-    const {
-      name,
-      address,
-      companyName,
-      vatNumber,
-      billingInfoForPaymentMethod,
-    } = await resolveCustomerIdentity(account, async () => {
-      logWarn(
-        'Conflict between billing info and account fields; fetching subscription collection method to resolve',
-        { ...context }
-      )
-      const subscription = await fetchRecurlyActiveSubscription(
-        recurlyAccountCode,
-        context
-      )
-      const cm = subscription?.collectionMethod || null
-      logDebug(
-        'Resolved collection method for conflict',
-        { ...context, collectionMethod: cm },
-        { verboseOnly: true }
-      )
-      return cm
-    })
-
-    if (name === null && companyName === null) {
-      // This should not happen since we're handling all the known cases in resolveCustomerIdentity but just in case
-      throw new Error(
-        'Unable to resolve customer name: both billing info and account fields are missing'
-      )
-    }
-
-    let taxIdType = null
-    let createdTaxId = null
-    let taxInfoPendingValue = null
-
-    // Determine VAT number tax ID type (if possible)
-    if (vatNumber) {
-      const preValidateFormat = !commit
-      const taxIdTypeResult = getTaxIdType(
-        address?.country,
-        vatNumber,
-        address?.postal_code,
-        preValidateFormat
-      )
-      taxIdType = taxIdTypeResult.type
-      const taxIdTypeFailureReason = taxIdTypeResult.reason
-
-      if (!address?.country) {
-        if (!forceInvalidTax) {
-          throw new Error(
-            `Unprocessable VAT number ${vatNumber} (no country): ${taxIdTypeFailureReason}`
-          )
-        }
-        logWarn('VAT number present but no country in address', {
-          ...context,
-          vatNumber,
-          reason: taxIdTypeFailureReason,
-        })
-        taxInfoPendingValue = vatNumber
-      } else if (!taxIdType) {
-        if (!forceInvalidTax) {
-          throw new Error(
-            `Unprocessable VAT number ${vatNumber} (failed getTaxIdType): ${taxIdTypeFailureReason}`
-          )
-        }
-        logWarn('Unable to determine tax id type for VAT number', {
-          ...context,
-          vatNumber,
-          country: address?.country,
-          postalCode: address?.postal_code,
-          reason: taxIdTypeFailureReason,
-        })
-        taxInfoPendingValue = vatNumber
-      } else {
-        logDebug(
-          'Will create tax ID',
-          {
-            ...context,
-            vatNumber,
-            country: address?.country,
-            taxIdType,
-          },
-          { verboseOnly: true }
-        )
-      }
-    }
-
-    const shouldCreateTaxId = !!(vatNumber && taxIdType && !taxInfoPendingValue)
-
-    if (commit) {
-      // Create tax ID first (validate it works before updating customer)
-      if (shouldCreateTaxId) {
-        logDebug(
-          'Creating tax ID',
-          {
-            ...context,
-            step: 'create_tax_id',
-            taxIdType,
-            vatNumber,
-          },
-          { verboseOnly: true }
-        )
-
-        // Note: if re-running for a customer where the vatNumber was previously present in Recurly
-        // but removed since the last run, this code will not erase that vatNumber from Stripe.
-        // unlikely to ever occur but worth noting
-        try {
-          createdTaxId = await replaceCustomerTaxIds(
-            stripeClient,
-            stripeCustomerId,
-            { taxIdType, vatNumber },
-            context
-          )
-          logDebug(
-            'Successfully created tax ID',
-            {
-              ...context,
-              taxId: createdTaxId.id,
-              taxIdType: createdTaxId.type,
-              taxIdValue: createdTaxId.value,
-            },
-            { verboseOnly: true }
-          )
-        } catch (error) {
-          if (forceInvalidTax && isStripeTaxIdInvalidError(error)) {
-            logWarn(
-              'Stripe rejected tax ID as invalid; continuing because --force-invalid-tax is enabled',
-              {
-                ...context,
-                vatNumber,
-                country: address?.country,
-                taxIdType,
-                error: error.message,
-              }
-            )
-            taxInfoPendingValue = vatNumber
-          } else {
-            throw error
-          }
-        }
-      }
-    }
-
-    // Transform Recurly data to Stripe customer update params
-    logDebug(
-      'Transforming Recurly data to Stripe params',
-      {
-        ...context,
-        step: 'transform',
-      },
-      { verboseOnly: true }
-    )
-
-    const paymentMethod = await getPaymentMethod(
-      stripeClient,
-      stripeCustomerId,
-      account.billingInfo,
-      address,
-      commit,
-      stripeContext
-    )
-
-    /** @type {Record<string, string>} */
-    const metadata = {}
-    if (account.createdAt) {
-      metadata.recurlyCreatedAt = account.createdAt.toISOString()
-    }
-    if (taxInfoPendingValue) {
-      metadata.taxInfoPending = taxInfoPendingValue
-    } else {
-      metadata.taxInfoPending = ''
-    }
-
-    if (
-      existingCustomer.metadata?.recurlyAccountCode &&
-      existingCustomer.metadata?.recurlyAccountCode !== recurlyAccountCode
-    ) {
-      throw new Error(
-        `Existing Stripe customer has unexpected recurlyAccountCode: (expected) ${recurlyAccountCode} (actual) ${existingCustomer.metadata?.recurlyAccountCode}`
-      )
-    }
-    if (
-      existingCustomer.metadata?.userId &&
-      existingCustomer.metadata?.userId !== recurlyAccountCode
-    ) {
-      throw new Error(
-        `Existing Stripe customer has unexpected userId: (expected) ${recurlyAccountCode} (actual) ${existingCustomer.metadata?.userId}`
-      )
-    }
-    metadata.recurlyAccountCode = ''
-    metadata.userId = recurlyAccountCode
-
-    const { metadata: customFieldMetadata, counts: customFieldCounts } =
-      extractRecurlyCustomFieldMetadata(account)
-
-    if (Object.keys(customFieldMetadata).length > 0) {
-      Object.assign(metadata, customFieldMetadata)
-    }
-
-    const ccEmailList = ccEmailsToArray(account.ccEmails)
-    if (ccEmailList.length > STRIPE_METADATA_MAX_ALT_EMAILS) {
-      // this limit is arbitrary just to catch any extreme outliers
-      throw new Error(
-        `Customer has ${ccEmailList.length} ccEmails; max supported is ${STRIPE_METADATA_MAX_ALT_EMAILS}`
-      )
-    }
-    ccEmailList.forEach(email => {
-      if (email.length > 500) {
-        // The limit for account.email is 512 characters.
-        // assuming similar for additional_emails.cc but 500 is plenty
-        // as the longest ccEmails in Recurly is 179
-        throw new Error(
-          `Recurly ${recurlyAccountCode}: ccEmail ${email} exceeds the maximum length of 500 characters`
-        )
-      }
-    })
-
-    // if there are any ccEmails in Recurly or Stripe,
-    // then overwrite additional_emails.cc below with the Recurly value preserving any other fields
-    // that might exist in additional_emails in Stripe
-    const updateCCEmails =
-      ccEmailList.length > 0 ||
-      existingCustomer.additional_emails?.cc?.length > 0
-
-    result.customFieldCounts = customFieldCounts
-
-    /** @type {Stripe.CustomerUpdateParams} */
-    const customerParams = {
-      email: account.email,
-      name,
-      metadata,
-      ...(address ? { address } : {}),
-      ...(companyName ? { business_name: companyName } : {}),
-      ...(paymentMethod
-        ? { invoice_settings: { default_payment_method: paymentMethod.id } }
-        : {}),
-      ...(updateCCEmails
-        ? {
-            additional_emails: {
-              ...existingCustomer.additional_emails,
-              cc: ccEmailList,
-            },
-          }
-        : {}),
-      // Recurly docs say the field is tax_exempt but in the actual response is taxExempt
-      tax_exempt: account.taxExempt ? 'exempt' : 'none',
-    }
-
-    // If Stripe already has any of the fields we're about to set, and the value is
-    // different from what we'd set, warn and capture both desired and existing.
-    const differingFields = []
-
-    if (
-      customerParams?.name != null &&
-      normalizeComparableString(existingCustomer?.name) !== '' &&
-      normalizeComparableString(existingCustomer?.name) !==
-        normalizeComparableString(customerParams.name)
-    ) {
-      differingFields.push('name')
-    }
-
-    if (
-      customerParams?.business_name != null &&
-      normalizeComparableString(existingCustomer?.business_name) !== '' &&
-      normalizeComparableString(existingCustomer?.business_name) !==
-        normalizeComparableString(customerParams.business_name)
-    ) {
-      differingFields.push('business_name')
-    }
-
-    if (
-      customerParams?.address &&
-      hasAnyAddressValue(existingCustomer?.address) &&
-      !addressesEqual(existingCustomer.address, customerParams.address)
-    ) {
-      differingFields.push('address')
-    }
-
-    if (differingFields.length > 0) {
-      logWarn('Stripe customer already has differing fields set', {
-        ...context,
-        fields: differingFields,
-      })
-
-      if (writeStripeExistingFields) {
-        writeStripeExistingFields({
-          recurly_account_code: recurlyAccountCode,
-          stripe_account: targetStripeAccount,
-          stripe_customer_id: stripeCustomerId,
-          recurly: {
-            ...(differingFields.includes('name')
-              ? { name: customerParams.name }
-              : {}),
-            ...(differingFields.includes('business_name')
-              ? { business_name: customerParams.business_name }
-              : {}),
-            ...(differingFields.includes('address')
-              ? { address: customerParams.address }
-              : {}),
-          },
-          stripe: {
-            ...(differingFields.includes('name')
-              ? { name: existingCustomer.name }
-              : {}),
-            ...(differingFields.includes('business_name')
-              ? { business_name: existingCustomer.business_name }
-              : {}),
-            ...(differingFields.includes('address')
-              ? { address: existingCustomer.address }
-              : {}),
-          },
-        })
-      }
-    }
-
-    logDebug(
-      'Transformed customer params',
-      {
-        ...context,
-        params: customerParams,
-      },
-      { verboseOnly: true }
-    )
-
-    if (commit) {
-      // Update customer in Stripe
-      logDebug(
-        'Updating Stripe customer',
-        {
-          ...context,
-          step: 'update_customer',
-        },
-        { verboseOnly: true }
-      )
-      await rateLimiters.requestWithRetries(
-        stripeClient.serviceName,
-        () => stripeClient.customers.update(stripeCustomerId, customerParams),
-        { ...stripeContext, stripeApi: 'customers.update' }
-      )
-
-      // For manual-collection customers where billing info and account info differ,
-      // copy the billing info to the payment method's billing_details.
-      //
-      // Note: If re-running this script for a given customer,
-      // then if by some chance the payment collection method has changed from automatic to manual since the last run,
-      // then we would potentially leave billing details in an inconsistent state
-      // I think this is vanishingly unlikely to be an issue in practice and a tricky problem to solve
-      // Highlighting here just in case.
-      if (billingInfoForPaymentMethod && paymentMethod) {
-        await updatePaymentMethodBillingDetails(
-          stripeClient,
-          paymentMethod.id,
-          billingInfoForPaymentMethod,
-          { ...context, step: 'update_payment_method_billing_details' }
-        )
-      }
-
-      result.outcome = 'updated'
-      logDebug(
-        'Successfully updated Stripe customer',
-        {
-          ...context,
-        },
-        { verboseOnly: true }
-      )
-    } else {
-      result.outcome = 'dry_run'
-      result.customerParams = {
-        ...customerParams,
-        // Include tax ID info in dry-run output for review
-        _taxId: shouldCreateTaxId
-          ? {
-              type: taxIdType,
-              value: vatNumber,
-              country: address?.country,
-              createdTaxId,
-            }
-          : null,
-        _isPaypal: paymentMethod?.type === 'paypal',
-        _targetStripeCustomerId: stripeCustomerId,
-        // Include payment method billing_details update for dry-run review
-        _paymentMethodBillingDetailsUpdate:
-          billingInfoForPaymentMethod && paymentMethod
-            ? {
-                paymentMethodId: paymentMethod.id,
-                billingDetails: computePaymentMethodBillingDetails(
-                  billingInfoForPaymentMethod
-                ),
-              }
-            : null,
-      }
-      logDebug(
-        'DRY RUN: Would update Stripe customer',
-        {
-          ...context,
-          email: account.email,
-          taxId: vatNumber ? { type: taxIdType, value: vatNumber } : null,
-        },
-        { verboseOnly: true }
-      )
-    }
-
-    if (taxInfoPendingValue) {
-      result.taxInfoPending = taxInfoPendingValue
-    }
-  } catch (error) {
-    result.outcome = 'error'
-    // Include more error details
-    const errorDetails = []
-    errorDetails.push(error.message)
-    if (error.code) errorDetails.push(`code=${error.code}`)
-    if (error.type) errorDetails.push(`type=${error.type}`)
-    if (error.statusCode) errorDetails.push(`statusCode=${error.statusCode}`)
-    result.error = errorDetails.join('; ')
-
-    logError('Failed to process customer', error, context)
-  }
-
-  return result
-}
-
-function usage() {
-  console.error('Script to migrate Recurly customers to Stripe')
-  console.error('')
-  console.error('RESUMABLE: This script can be re-run after failures.')
-  console.error(
-    '           It will skip successfully processed records and retry failures.'
-  )
-  console.error('')
-  console.error('Usage:')
-  console.error(
-    '  node scripts/recurly/migrate_recurly_customers_to_stripe.mjs [options]'
-  )
-  console.error('')
-  console.error('Options:')
-  console.error('  --input, -i <file>   Path to input CSV file (required)')
-  console.error(
-    '  --output, -o <file>  Path to SUCCESS output CSV file (required)'
-  )
-  console.error(
-    '  --limit, -l <n>      Limit number of records processed (default: no limit)'
-  )
-  console.error(
-    '  --concurrency, -c <n> Number of customers to process concurrently (default: 10)'
-  )
-  console.error(
-    '  --recurly-rate-limit <n> Requests per second for Recurly (default: 10)'
-  )
-  console.error(
-    '  --recurly-api-retries <n> Number of retries on Recurly 429s (default: 5)'
-  )
-  console.error(
-    '  --recurly-retry-delay-ms <n> Delay between Recurly retries in ms (default: 1000)'
-  )
-  console.error(
-    '  --stripe-rate-limit <n>  Requests per second for Stripe (default: 50)'
-  )
-  console.error(
-    '  --stripe-api-retries <n> Number of retries on Stripe 429s (default: 5)'
-  )
-  console.error(
-    '  --stripe-retry-delay-ms <n> Delay between Stripe retries in ms (default: 1000)'
-  )
-  console.error(
-    '  --force-invalid-tax   Allow VAT numbers that cannot be mapped to a tax ID type (default: false)'
-  )
-  console.error(
-    '  --commit             Actually update customers in Stripe (default: dry-run)'
-  )
-  console.error('  --verbose, -v         Enable debug logging')
-  console.error(
-    '  --restart            Ignore existing output files and start fresh'
-  )
-  console.error('')
-  console.error('Input CSV format:')
-  console.error(
-    '  recurly_account_code,target_stripe_account,stripe_customer_id'
-  )
-  console.error('')
-  console.error('Output files:')
-  console.error('  SUCCESS file (--output): Successfully updated customers')
-  console.error(
-    '    Format: recurly_account_code,target_stripe_account,stripe_customer_id'
-  )
-  console.error('')
-  console.error(
-    '  ERRORS file (<output>_errors.csv): Records that failed THIS run'
-  )
-  console.error(
-    '    Format: recurly_account_code,target_stripe_account,stripe_customer_id,error'
-  )
-  console.error('')
-  console.error(
-    '  STRIPE JSON (<output>_stripe.json): Dry-run only - customer params that would be used for update'
-  )
-  console.error('')
-  console.error(
-    '  STRIPE EXISTING FIELDS (<output>_stripe_existing_fields.json): Customers where Stripe already had name/address/business_name set'
-  )
-  console.error(
-    '    Written in both dry-run and commit modes (for auditing before overwriting fields)'
-  )
-  console.error('')
-  console.error('Resume behavior:')
-  console.error('  - Records in SUCCESS file are SKIPPED (already done)')
-  console.error(
-    '  - Records in ERRORS file are RE-PROCESSED (retried each run)'
-  )
-  console.error(
-    '  - After each run, ERRORS file contains ONLY failures from that run'
-  )
-  console.error(
-    '  - Use --restart to force processing all records from scratch'
-  )
-}
-
-function parseConcurrency(value, { defaultValue = 10 } = {}) {
-  if (value === undefined || value === null || value === '') {
-    return defaultValue
-  }
-
-  const parsed = Number(value)
-  if (!Number.isFinite(parsed) || !Number.isInteger(parsed) || parsed < 1) {
-    throw new Error(
-      `Invalid --concurrency value: ${value}. Expected a positive integer.`
-    )
-  }
-
-  return parsed
-}
-
-function parseRateLimit(value, { defaultValue, name }) {
-  if (value === undefined || value === null || value === '') {
-    return defaultValue
-  }
-
-  const parsed = Number(value)
-  if (!Number.isFinite(parsed) || parsed <= 0) {
-    throw new Error(
-      `Invalid --${name} value: ${value}. Expected a positive number.`
-    )
-  }
-
-  return parsed
-}
-
-function parseNonNegativeInt(value, { defaultValue, name }) {
-  if (value === undefined || value === null || value === '') {
-    return defaultValue
-  }
-
-  const parsed = Number(value)
-  if (!Number.isFinite(parsed) || !Number.isInteger(parsed) || parsed < 0) {
-    throw new Error(
-      `Invalid --${name} value: ${value}. Expected a non-negative integer.`
-    )
-  }
-
-  return parsed
-}
-
-function parseArgs() {
-  return minimist(process.argv.slice(2), {
-    alias: {
-      i: 'input',
-      o: 'output',
-      h: 'help',
-      v: 'verbose',
-      c: 'concurrency',
-      l: 'limit',
-    },
-    string: [
-      'input',
-      'output',
-      'limit',
-      'recurly-rate-limit',
-      'recurly-api-retries',
-      'recurly-retry-delay-ms',
-      'stripe-rate-limit',
-      'stripe-api-retries',
-      'stripe-retry-delay-ms',
-    ],
-    boolean: ['commit', 'verbose', 'help', 'restart', 'force-invalid-tax'],
-    default: {
-      commit: false,
-      verbose: false,
-      restart: false,
-      'force-invalid-tax': false,
-      concurrency: 10,
-      'recurly-rate-limit': DEFAULT_RECURLY_RATE_LIMIT,
-      'recurly-api-retries': DEFAULT_RECURLY_API_RETRIES,
-      'recurly-retry-delay-ms': DEFAULT_RECURLY_RETRY_DELAY_MS,
-      'stripe-rate-limit': DEFAULT_STRIPE_RATE_LIMIT,
-      'stripe-api-retries': DEFAULT_STRIPE_API_RETRIES,
-      'stripe-retry-delay-ms': DEFAULT_STRIPE_RETRY_DELAY_MS,
-    },
-  })
-}
-
-async function main(trackProgress) {
-  const startTime = new Date()
-  const args = parseArgs()
-  const {
-    input: inputPath,
-    output: successOutputPath,
-    commit,
-    verbose,
-    help,
-    restart,
-    'force-invalid-tax': forceInvalidTax,
-    concurrency: concurrencyRaw,
-    limit: limitRaw,
-    'recurly-rate-limit': recurlyRateLimitRaw,
-    'recurly-api-retries': recurlyApiRetriesRaw,
-    'recurly-retry-delay-ms': recurlyRetryDelayMsRaw,
-    'stripe-rate-limit': stripeRateLimitRaw,
-    'stripe-api-retries': stripeApiRetriesRaw,
-    'stripe-retry-delay-ms': stripeRetryDelayMsRaw,
-  } = args
-
-  let concurrency
-  let recurlyRateLimit
-  let recurlyApiRetriesValue
-  let recurlyRetryDelayMsValue
-  let stripeRateLimitPerSecond
-  let stripeApiRetriesValue
-  let stripeRetryDelayMsValue
-  let limit
-  try {
-    concurrency = parseConcurrency(concurrencyRaw, { defaultValue: 10 })
-    limit = parseNonNegativeInt(limitRaw, {
-      defaultValue: null,
-      name: 'limit',
-    })
-    recurlyRateLimit = parseRateLimit(recurlyRateLimitRaw, {
-      defaultValue: DEFAULT_RECURLY_RATE_LIMIT,
-      name: 'recurly-rate-limit',
-    })
-    recurlyApiRetriesValue = parseNonNegativeInt(recurlyApiRetriesRaw, {
-      defaultValue: DEFAULT_RECURLY_API_RETRIES,
-      name: 'recurly-api-retries',
-    })
-    recurlyRetryDelayMsValue = parseNonNegativeInt(recurlyRetryDelayMsRaw, {
-      defaultValue: DEFAULT_RECURLY_RETRY_DELAY_MS,
-      name: 'recurly-retry-delay-ms',
-    })
-    stripeRateLimitPerSecond = parseRateLimit(stripeRateLimitRaw, {
-      defaultValue: DEFAULT_STRIPE_RATE_LIMIT,
-      name: 'stripe-rate-limit',
-    })
-    stripeApiRetriesValue = parseNonNegativeInt(stripeApiRetriesRaw, {
-      defaultValue: DEFAULT_STRIPE_API_RETRIES,
-      name: 'stripe-api-retries',
-    })
-    stripeRetryDelayMsValue = parseNonNegativeInt(stripeRetryDelayMsRaw, {
-      defaultValue: DEFAULT_STRIPE_RETRY_DELAY_MS,
-      name: 'stripe-retry-delay-ms',
-    })
-  } catch (error) {
-    logError(error.message)
-    usage()
-    process.exit(1)
-  }
-
-  // initialize rate limiters
-  rateLimiters = createRateLimitedApiWrappers({
-    recurlyRateLimit,
-    recurlyApiRetries: recurlyApiRetriesValue,
-    recurlyRetryDelayMs: recurlyRetryDelayMsValue,
-    stripeRateLimit: stripeRateLimitPerSecond,
-    stripeApiRetries: stripeApiRetriesValue,
-    stripeRetryDelayMs: stripeRetryDelayMsValue,
-    logDebug,
-    logWarn,
-  })
-
-  // Set DEBUG_MODE only from CLI arg (--verbose/-v)
-  DEBUG_MODE = !!verbose
-
-  if (help || !inputPath || !successOutputPath) {
-    usage()
-    process.exit(help ? 0 : 1)
-  }
-
-  const errorsOutputPath = getErrorsPath(successOutputPath)
-  const stripeJsonPath = getStripeJsonPath(successOutputPath)
-  const stripeExistingFieldsJsonPath =
-    getStripeExistingFieldsJsonPath(successOutputPath)
-
-  const mode = commit ? 'COMMIT MODE' : 'DRY RUN MODE'
-  logDebug(`Starting migration in ${mode}`, {
-    inputPath,
-    successOutputPath,
-    errorsOutputPath,
-    ...(commit ? {} : { stripeJsonPath }),
-    stripeExistingFieldsJsonPath,
-    concurrency,
-    recurlyRateLimit,
-    recurlyApiRetries: recurlyApiRetriesValue,
-    recurlyRetryDelayMs: recurlyRetryDelayMsValue,
-    stripeRateLimit: stripeRateLimitPerSecond,
-    stripeApiRetries: stripeApiRetriesValue,
-    stripeRetryDelayMs: stripeRetryDelayMsValue,
-    forceInvalidTax,
-    ...(limit != null ? { limit } : {}),
-  })
-  await trackProgress(`Starting migration in ${mode}`)
-
-  // Load previously successfully processed records (for resume functionality).
-  // IMPORTANT: commit mode uses the success file for resume/skip behavior.
-  // Dry-run mode does NOT read the success file.
-  let previouslyProcessed = new Set()
-  if (commit && !restart) {
-    try {
-      previouslyProcessed = await loadSuccessfullyProcessed(successOutputPath)
-      if (previouslyProcessed.size > 0) {
-        logDebug(
-          `Will skip ${previouslyProcessed.size} previously successful records`
-        )
-        await trackProgress(
-          `Resuming: will skip ${previouslyProcessed.size} previously successful records`
-        )
-      }
-    } catch (err) {
-      logWarn('Could not load previous success file, starting fresh', {
-        error: err.message,
-      })
-    }
-  } else if (restart) {
-    logDebug('Restart flag set, ignoring existing output files')
-    await trackProgress('Restart mode: processing all records from scratch')
-  }
-
-  // Create output writers.
-  // In dry-run mode, we intentionally do NOT write to the success file, because
-  // commit mode uses it for resume/skip behavior.
-  const {
-    writeSuccess,
-    writeError,
-    close: closeOutputs,
-  } = commit
-    ? createOutputWriters(successOutputPath, errorsOutputPath, restart, {
-        enableSuccessFile: true,
-      })
-    : createOutputWriters(successOutputPath, errorsOutputPath, true, {
-        enableSuccessFile: false,
-      })
-
-  // For dry-run mode, collect Stripe customer params to write to JSON
-  const stripeCustomerParams = []
-
-  // Records where Stripe already had name/address/business_name set
-  const stripeExistingFieldsWriter = createJsonArrayWriter(
-    stripeExistingFieldsJsonPath
-  )
-
-  try {
-    // Statistics
-    let totalInInput = 0
-    let processedThisRun = 0
-    let queuedThisRun = 0
-    let skippedPreviouslyProcessed = 0
-    let updatedCount = 0
-    let errorCount = 0
-    let dryRunCount = 0
-    let taxInfoPendingCount = 0
-
-    const customFieldStats = {
-      channel: 0,
-      Industry: 0,
-      ol_sales_person: 0,
-      MigratedfromFreeAgent: 0,
-      noCustomFields: 0,
-    }
-
-    // Track errors for final summary (just the account codes, not full results - memory efficient)
-    const errorAccountCodes = []
-
-    logDebug('Beginning to process input file', { inputPath })
-
-    // Process input CSV - true streaming (no collecting results in memory)
-    const inputStream = fs.createReadStream(inputPath)
-    const parser = csv.parse({
-      columns: true,
-      trim: true,
-      bom: true,
-      skip_empty_lines: true,
-      relax_column_count: true,
-      relax_column_count_less: true,
-    })
-
-    inputStream.pipe(parser)
-
-    const queue = new PQueue({ concurrency })
-    const maxQueueSize = concurrency
-    let lastCompletedRowNumber = 0
-    let limitReached = false
-
-    let rowNumber = 0
-    try {
-      for await (const row of parser) {
-        rowNumber++
-        totalInInput++
-
-        const thisRowNumber = rowNumber
-        const accountCode = row.recurly_account_code
-
-        // Check if already successfully processed in a previous run
-        if (previouslyProcessed.has(accountCode)) {
-          skippedPreviouslyProcessed++
-          logDebug(
-            'Skipping previously successful record',
-            {
-              rowNumber: thisRowNumber,
-              accountCode,
-            },
-            { verboseOnly: true }
-          )
-          continue
-        }
-
-        if (limit != null && queuedThisRun >= limit) {
-          limitReached = true
-          logDebug('Record limit reached, stopping input processing', {
-            limit,
-            queuedThisRun,
-            rowNumber: thisRowNumber,
-          })
-          break
-        }
-
-        if (queue.size >= maxQueueSize) {
-          await queue.onSizeLessThan(maxQueueSize)
-        }
-
-        queuedThisRun++
-        queue.add(async () => {
-          let result
-          try {
-            result = await processCustomer(row, thisRowNumber, commit, {
-              writeStripeExistingFields: stripeExistingFieldsWriter.write,
-              forceInvalidTax,
-            })
-          } catch (error) {
-            result = {
-              ...row,
-              outcome: 'error',
-              error: error?.message || String(error),
-            }
-            logError('Unhandled error while processing customer', error, {
-              rowNumber: thisRowNumber,
-              accountCode,
-            })
-          }
-
-          processedThisRun++
-          lastCompletedRowNumber = thisRowNumber
-
-          if (result.customFieldCounts) {
-            for (const [field, count] of Object.entries(
-              result.customFieldCounts
-            )) {
-              if (customFieldStats[field] != null) {
-                customFieldStats[field] += count
-              }
-            }
-          }
-
-          if (result.taxInfoPending != null) {
-            taxInfoPendingCount++
-          }
-
-          // Write to appropriate output file based on outcome
-          if (result.outcome === 'error') {
-            writeError(result)
-            errorCount++
-            errorAccountCodes.push(accountCode)
-          } else {
-            writeSuccess(result)
-            // Update statistics and collect dry-run data
-            if (result.outcome === 'updated') {
-              updatedCount++
-            } else if (result.outcome === 'dry_run') {
-              dryRunCount++
-              // Collect customer params for stripe.json output
-              if (result.customerParams) {
-                stripeCustomerParams.push({
-                  recurly_account_code: result.recurly_account_code,
-                  target_stripe_account: result.target_stripe_account,
-                  customerParams: result.customerParams,
-                })
-              }
-            }
-          }
-
-          // Progress update every 1000 customers (or 100 in debug mode)
-          const progressInterval = DEBUG_MODE ? 100 : 1000
-          if (processedThisRun % progressInterval === 0) {
-            const rateLimiterStats = rateLimiters.getRateLimiterStats()
-            const progress = {
-              rowNumber: lastCompletedRowNumber,
-              processedThisRun,
-              updated: updatedCount,
-              dryRun: dryRunCount,
-              taxInfoPending: taxInfoPendingCount,
-              errors: errorCount,
-              skippedPrevious: skippedPreviouslyProcessed,
-              recurlyRate: rateLimiterStats.recurly.currentRate,
-              stripeRate: rateLimiterStats.stripe.currentRate,
-            }
-            logDebug('Progress update', progress)
-            await trackProgress(
-              `Progress: row ${lastCompletedRowNumber}, ${processedThisRun} processed this run, ${errorCount} errors`
-            )
-          }
-        })
-      }
-    } finally {
-      await queue.onIdle()
-    }
-
-    if (limitReached) {
-      await trackProgress(
-        `Limit reached (${limit}). Stopped reading input; waiting for in-flight records to finish.`
-      )
-    }
-
-    // Write stripe.json file in dry-run mode
-    if (!commit && stripeCustomerParams.length > 0) {
-      await fs.promises.writeFile(
-        stripeJsonPath,
-        JSON.stringify(stripeCustomerParams, null, 2)
-      )
-      logDebug(
-        `Wrote ${stripeCustomerParams.length} customer params to ${stripeJsonPath}`
-      )
-    }
-
-    // Final summary
-    const endTime = new Date()
-    const durationMs = endTime.getTime() - startTime.getTime()
-    const durationTotalSeconds = Math.floor(durationMs / 1000)
-    const durationHours = Math.floor(durationTotalSeconds / 3600)
-    const durationMinutes = Math.floor((durationTotalSeconds % 3600) / 60)
-    const durationSeconds = durationTotalSeconds % 60
-    const durationHms =
-      String(durationHours).padStart(2, '0') +
-      ':' +
-      String(durationMinutes).padStart(2, '0') +
-      ':' +
-      String(durationSeconds).padStart(2, '0')
-
-    const totalSuccessful = commit
-      ? previouslyProcessed.size + updatedCount
-      : previouslyProcessed.size
-    const finalRateLimiterStats = rateLimiters.getRateLimiterStats()
-
-    await trackProgress('=== FINAL SUMMARY ===')
-    await trackProgress(`Start time: ${startTime.toISOString()}`)
-    await trackProgress(`End time: ${endTime.toISOString()}`)
-    await trackProgress(`Total runtime: ${durationHms}`)
-    await trackProgress('CLI parameters:')
-    await trackProgress(`  - input: ${inputPath}`)
-    await trackProgress(`  - output: ${successOutputPath}`)
-    await trackProgress(`  - commit: ${commit}`)
-    await trackProgress(`  - verbose: ${verbose}`)
-    await trackProgress(`  - restart: ${restart}`)
-    await trackProgress(`  - limit: ${limit != null ? limit : 'none'}`)
-    await trackProgress(`  - concurrency: ${concurrency}`)
-    await trackProgress(`  - recurly-rate-limit: ${recurlyRateLimit}`)
-    await trackProgress(`  - recurly-api-retries: ${recurlyApiRetriesValue}`)
-    await trackProgress(
-      `  - recurly-retry-delay-ms: ${recurlyRetryDelayMsValue}`
-    )
-    await trackProgress(`  - stripe-rate-limit: ${stripeRateLimitPerSecond}`)
-    await trackProgress(`  - stripe-api-retries: ${stripeApiRetriesValue}`)
-    await trackProgress(`  - stripe-retry-delay-ms: ${stripeRetryDelayMsValue}`)
-    await trackProgress(`  - force-invalid-tax: ${forceInvalidTax}`)
-    await trackProgress(`Input file total rows: ${totalInInput}`)
-    await trackProgress(
-      `Previously successful (skipped): ${skippedPreviouslyProcessed}`
-    )
-    await trackProgress(`Processed this run: ${processedThisRun}`)
-    await trackProgress(
-      `  - ${commit ? 'Updated' : 'Would update'}: ${commit ? updatedCount : dryRunCount}`
-    )
-    await trackProgress(`  - Tax info pending: ${taxInfoPendingCount}`)
-    await trackProgress(`  - Errors: ${errorCount}`)
-    await trackProgress('')
-    await trackProgress('Custom fields summary (Recurly -> Stripe metadata):')
-    for (const fieldName of RECURLY_CUSTOM_FIELD_NAMES) {
-      await trackProgress(
-        `  - ${fieldName}: ${customFieldStats[fieldName] || 0}`
-      )
-    }
-    await trackProgress(
-      `  - No custom fields: ${customFieldStats.noCustomFields}`
-    )
-    await trackProgress('')
-    if (commit) {
-      await trackProgress(
-        `Success file: ${successOutputPath} (${totalSuccessful} records)`
-      )
-    } else {
-      await trackProgress(
-        `Success file: ${successOutputPath} (not modified in dry-run mode)`
-      )
-    }
-    await trackProgress(
-      `Errors file: ${errorsOutputPath} (${errorCount} records)`
-    )
-    await trackProgress(
-      `API calls - Recurly: ${finalRateLimiterStats.recurly.totalRequests}, Stripe: ${finalRateLimiterStats.stripe.totalRequests}`
-    )
-
-    if (!commit && dryRunCount > 0) {
-      await trackProgress('')
-      await trackProgress(
-        `Stripe params file: ${stripeJsonPath} (${stripeCustomerParams.length} records)`
-      )
-      await trackProgress(
-        'To actually update customers, run the script with --commit flag'
-      )
-
-      logDebug('Dry-run params file written', {
-        stripeJsonPath,
-        records: stripeCustomerParams.length,
-      })
-    }
-
-    await trackProgress(
-      `Stripe existing fields file: ${stripeExistingFieldsJsonPath}`
-    )
-
-    // Log error account codes for easy reference
-    if (errorCount > 0) {
-      logWarn(`${errorCount} records failed and are in the errors file.`)
-      logWarn('Failed account codes:', {
-        first20: errorAccountCodes.slice(0, 20),
-        totalErrors: errorAccountCodes.length,
-      })
-      await trackProgress('')
-      await trackProgress(
-        `${errorCount} records failed. Re-run the script to retry them.`
-      )
-      await trackProgress(
-        `Failed accounts (first 20): ${errorAccountCodes.slice(0, 20).join(', ')}`
-      )
-    }
-
-    // Success/warning based on errors
-    if (errorCount === 0) {
-      logDebug('Migration completed successfully', { mode })
-      await trackProgress(`Migration completed successfully in ${mode}`)
-
-      // If no errors and errors file exists but is empty (just header), note that
-      if (fs.existsSync(errorsOutputPath)) {
-        await trackProgress(
-          `Errors file is empty (header only) - all records processed successfully!`
-        )
-      }
-    } else {
-      logWarn('Migration completed with errors', { mode, errorCount })
-      await trackProgress(
-        `Migration completed with ${errorCount} errors in ${mode}`
-      )
-    }
-
-    // Return exit code based on whether there were errors
-    return errorCount === 0 ? 0 : 1
-  } finally {
-    const results = await Promise.allSettled([
-      closeOutputs(),
-      stripeExistingFieldsWriter.close(),
-    ])
-
-    for (const result of results) {
-      if (result.status === 'rejected') {
-        logWarn('Failed to close output stream', {
-          error: result.reason?.message || String(result.reason),
-        })
-      }
-    }
-  }
-}
-
-// Execute the script using the runner
-try {
-  const exitCode = await scriptRunner(main)
-  process.exit(exitCode ?? 0)
-} catch (error) {
-  logError('Script failed with unhandled error', error)
-  process.exit(1)
-}

+ 0 - 226
services/web/scripts/recurly/recurly_prices.mjs

@@ -1,226 +0,0 @@
-// script to sync plan prices to/from recurly
-//
-// Usage:
-//
-// Save current plan and addon prices to file
-// $ node scripts/recurly/recurly_prices.mjs --download -o prices.json
-//
-// Upload new plan and addon prices (change --dry-run to --commit to make the change)
-// $ node scripts/recurly/recurly_prices.mjs --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.
-
-import recurly from 'recurly'
-
-import Settings from '@overleaf/settings'
-import minimist from 'minimist'
-import _ from 'lodash'
-import fs from 'node: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 _.sortBy(result, 'code')
-}
-
-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()) {
-    if (addOn.code === 'additional-license') {
-      result.push(addOn)
-    } else {
-      console.error('UNRECOGNISED ADD-ON CODE', plan.code, addOn.code)
-    }
-  }
-  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'
-  let originalPlanAddOn
-  try {
-    originalPlanAddOn = await client.getPlanAddOn(planCodeId, addOnId)
-  } catch (error) {
-    if (error instanceof recurly.errors.NotFoundError) {
-      console.error('plan add-on not found', planCodeId, addOnId)
-      return
-    } else {
-      throw error
-    }
-  }
-  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 license 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) {
-  try {
-    await download(argv.output)
-    process.exit(0)
-  } catch (error) {
-    console.error({ error })
-    process.exit(1)
-  }
-} else if (argv.upload) {
-  try {
-    await upload(argv.file)
-    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'
-  )
-}

+ 0 - 137
services/web/scripts/recurly/resync_recurly_state_single_subscription.mjs

@@ -1,137 +0,0 @@
-import { Subscription } from '../../app/src/models/Subscription.mjs'
-import RecurlyWrapper from '../../app/src/Features/Subscription/RecurlyWrapper.mjs'
-import SubscriptionUpdater from '../../app/src/Features/Subscription/SubscriptionUpdater.mjs'
-import minimist from 'minimist'
-import { setTimeout } from 'node:timers/promises'
-import util from 'node:util'
-
-util.inspect.defaultOptions.maxArrayLength = null
-
-const handleSyncSubscriptionError = async (subscription, error) => {
-  console.warn(`Errors with subscription id=${subscription._id}:`, error)
-
-  if (typeof error === 'string' && error.match(/429$/)) {
-    console.warn('Recurly rate limit hit (429). Waiting for 5 minutes...')
-    await setTimeout(1000 * 60 * 5)
-    return
-  }
-  if (typeof error === 'string' && error.match(/5\d\d$/)) {
-    console.warn('Recurly server error (5xx). Retrying in 1 minute...')
-    await setTimeout(1000 * 60)
-    await syncRecurlyStateInSubscription(subscription)
-    return
-  }
-  await setTimeout(80)
-}
-
-const syncRecurlyStateInSubscription = async subscription => {
-  let recurlySubscription
-
-  try {
-    recurlySubscription = await RecurlyWrapper.promises.getSubscription(
-      subscription.recurlySubscription_id
-    )
-  } catch (error) {
-    await handleSyncSubscriptionError(subscription, error)
-    return
-  }
-
-  if (!subscription.recurlyStatus) {
-    subscription.recurlyStatus = {}
-  }
-
-  if (subscription.recurlyStatus.state !== recurlySubscription.state) {
-    console.log(
-      `Mismatched recurlyStatus.state for subscription ID ${subscription._id}. ` +
-        `Our database: '${subscription.recurlyStatus.state || 'undefined/null'}', recurly: '${recurlySubscription.state}'.`
-    )
-
-    subscription.recurlyStatus.state = recurlySubscription.state
-
-    if (COMMIT) {
-      try {
-        console.log(
-          `Committing update for subscription ID: ${subscription._id}`
-        )
-        await SubscriptionUpdater.promises.updateSubscriptionFromRecurly(
-          recurlySubscription,
-          subscription,
-          {}
-        )
-      } catch (error) {
-        await handleSyncSubscriptionError(subscription, error)
-      }
-
-      console.log(
-        `Successfully updated subscription ID ${subscription._id} with new recurlyStatus.state: ${subscription.recurlyStatus.state}`
-      )
-    }
-  } else {
-    console.log(
-      `Subscription ID ${subscription._id}: recurlyStatus.state is already in sync.`
-    )
-  }
-
-  await setTimeout(80)
-}
-
-let COMMIT, SUBSCRIPTION_ID
-
-const setup = () => {
-  const argv = minimist(process.argv.slice(2))
-
-  SUBSCRIPTION_ID = argv.subscriptionId
-  if (!SUBSCRIPTION_ID) {
-    console.error(
-      'Error: Please provide a subscription ID using --subscriptionId=<id>'
-    )
-    process.exit(1)
-  }
-  console.log(
-    `Attempting to sync subscription.recurlyStatus with ID: ${SUBSCRIPTION_ID}`
-  )
-
-  COMMIT = argv.commit !== undefined
-  if (!COMMIT) {
-    console.warn(
-      'Doing dry run without --commit. No database changes will be made.'
-    )
-  }
-}
-
-const run = async () => {
-  try {
-    const subscription = await Subscription.findById(SUBSCRIPTION_ID).exec()
-
-    if (!subscription) {
-      console.error(
-        `Error: Subscription with ID ${SUBSCRIPTION_ID} not found in the database.`
-      )
-      process.exit(1)
-    }
-
-    if (!subscription.recurlySubscription_id) {
-      console.error(
-        `Error: Subscription ID ${SUBSCRIPTION_ID} does not have a Recurly subscription ID.`
-      )
-      process.exit(1)
-    }
-
-    console.log(
-      `Found subscription: ${subscription._id}, Recurly ID: ${subscription.recurlySubscription_id}`
-    )
-
-    await syncRecurlyStateInSubscription(subscription)
-
-    console.log('DONE')
-  } catch (error) {
-    console.error('An unhandled error occurred during script execution:', error)
-    process.exit(1)
-  }
-}
-
-setup()
-
-await run()
-
-process.exit(0)

+ 0 - 191
services/web/scripts/recurly/resync_subscriptions.mjs

@@ -1,191 +0,0 @@
-import { Subscription } from '../../app/src/models/Subscription.mjs'
-import RecurlyWrapper from '../../app/src/Features/Subscription/RecurlyWrapper.mjs'
-import SubscriptionUpdater from '../../app/src/Features/Subscription/SubscriptionUpdater.mjs'
-import minimist from 'minimist'
-import { setTimeout } from 'node:timers/promises'
-
-import util from 'node:util'
-
-import pLimit from 'p-limit'
-
-util.inspect.defaultOptions.maxArrayLength = null
-
-const ScriptLogger = {
-  checkedSubscriptionsCount: 0,
-  mismatchSubscriptionsCount: 0,
-  allMismatchReasons: {},
-
-  // make sure all `allMismatchReasons` are displayed in the output
-  recordMismatch: (subscription, recurlySubscription) => {
-    const mismatchReasons = {}
-    if (subscription.planCode !== recurlySubscription.plan.plan_code) {
-      mismatchReasons.recurlyPlan = recurlySubscription.plan.plan_code
-      mismatchReasons.olPlan = subscription.planCode
-    }
-    if (recurlySubscription.state === 'expired') {
-      mismatchReasons.state = 'expired'
-    }
-
-    if (!Object.keys(mismatchReasons).length) {
-      return
-    }
-
-    ScriptLogger.mismatchSubscriptionsCount += 1
-    const mismatchReasonsString = JSON.stringify(mismatchReasons)
-    if (ScriptLogger.allMismatchReasons[mismatchReasonsString]) {
-      ScriptLogger.allMismatchReasons[mismatchReasonsString].push({
-        id: subscription._id,
-        name: subscription.planCode,
-      })
-    } else {
-      ScriptLogger.allMismatchReasons[mismatchReasonsString] = [
-        {
-          id: subscription._id,
-          name: subscription.planCode,
-        },
-      ]
-    }
-  },
-
-  printProgress: () => {
-    console.warn(
-      `Subscriptions checked: ${ScriptLogger.checkedSubscriptionsCount}. Mismatches: ${ScriptLogger.mismatchSubscriptionsCount}`
-    )
-  },
-
-  printSummary: () => {
-    console.log('All Mismatch Reasons:', ScriptLogger.allMismatchReasons)
-    console.log(
-      'Mismatch Subscriptions Count',
-      ScriptLogger.mismatchSubscriptionsCount
-    )
-  },
-}
-
-const handleSyncSubscriptionError = async (subscription, error) => {
-  console.warn(`Errors with subscription id=${subscription._id}:`, error)
-  if (typeof error === 'string' && error.match(/429$/)) {
-    await setTimeout(1000 * 60 * 5)
-    return
-  }
-  if (typeof error === 'string' && error.match(/5\d\d$/)) {
-    await setTimeout(1000 * 60)
-    await syncSubscription(subscription)
-    return
-  }
-  await setTimeout(80)
-}
-
-const syncSubscription = async subscription => {
-  let recurlySubscription
-  try {
-    recurlySubscription = await RecurlyWrapper.promises.getSubscription(
-      subscription.recurlySubscription_id
-    )
-  } catch (error) {
-    await handleSyncSubscriptionError(subscription, error)
-    return
-  }
-
-  ScriptLogger.recordMismatch(subscription, recurlySubscription)
-
-  if (COMMIT) {
-    try {
-      await SubscriptionUpdater.promises.updateSubscriptionFromRecurly(
-        recurlySubscription,
-        subscription,
-        {}
-      )
-    } catch (error) {
-      await handleSyncSubscriptionError(subscription, error)
-    }
-  }
-
-  await setTimeout(80)
-}
-
-const syncSubscriptions = async subscriptions => {
-  const limit = pLimit(ASYNC_LIMIT)
-  return await Promise.all(
-    subscriptions.map(subscription =>
-      limit(() => syncSubscription(subscription))
-    )
-  )
-}
-
-const loopForSubscriptions = async skipInitial => {
-  let skip = skipInitial
-
-  // iterate while there are more subscriptions to fetch
-  while (true) {
-    const subscriptions = await Subscription.find({
-      recurlySubscription_id: { $exists: true, $ne: '' },
-    })
-      .sort('_id')
-      .skip(skip)
-      .limit(FETCH_LIMIT)
-      .exec()
-
-    if (subscriptions.length === 0) {
-      console.warn('DONE')
-      return
-    }
-
-    await syncSubscriptions(subscriptions)
-
-    ScriptLogger.checkedSubscriptionsCount += subscriptions.length
-    retryCounter = 0
-    ScriptLogger.printProgress()
-    ScriptLogger.printSummary()
-
-    skip += FETCH_LIMIT
-  }
-}
-
-let retryCounter = 0
-const run = async () => {
-  while (true) {
-    try {
-      await loopForSubscriptions(
-        MONGO_SKIP + ScriptLogger.checkedSubscriptionsCount
-      )
-      break
-    } catch (error) {
-      if (retryCounter < 3) {
-        console.error(error)
-        retryCounter += 1
-        console.warn(`RETRYING IN 60 SECONDS. (${retryCounter}/3)`)
-        await setTimeout(60000)
-      } else {
-        console.error('Failed after 3 retries')
-        throw error
-      }
-    }
-  }
-}
-
-let FETCH_LIMIT, ASYNC_LIMIT, COMMIT, MONGO_SKIP
-const setup = () => {
-  const argv = minimist(process.argv.slice(2))
-  FETCH_LIMIT = argv.fetch ? argv.fetch : 100
-  ASYNC_LIMIT = argv.async ? argv.async : 10
-  MONGO_SKIP = argv.skip ? argv.skip : 0
-  COMMIT = argv.commit !== undefined
-  if (!COMMIT) {
-    console.warn('Doing dry run without --commit')
-  }
-  if (MONGO_SKIP) {
-    console.warn(`Skipping first ${MONGO_SKIP} records`)
-  }
-}
-
-if (process.env.NODE_ENV !== 'development') {
-  console.warn(
-    'This script can cause issues with manually amended subscriptions and can also exhaust our rate-limit with Recurly so is not intended to be run in production. Please use it in development environments only.'
-  )
-  process.exit(1)
-}
-
-setup()
-await run()
-process.exit()

+ 0 - 606
services/web/scripts/recurly/rollback_price_changes.mjs

@@ -1,606 +0,0 @@
-#!/usr/bin/env node
-
-/**
- * Rollback pending price changes for Recurly subscriptions
- *
- * This script removes pending subscription changes that were created by the
- * change_existing_subscription_prices.mjs script. It only removes changes that
- * are purely price changes on an existing plan - if the user has made any other
- * modifications (plan change, add-on changes), the pending change is left untouched.
- *
- * Usage:
- *   node scripts/recurly/rollback_price_changes.mjs [OPTIONS] [INPUT-FILE]
- *
- * Options:
- *   --output PATH          Output file path (default: /tmp/rollback_prices_output_<timestamp>.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)
- *   --help                 Show a help message
- *
- * CSV Input Format:
- *   The CSV must have the following columns (same format as change_existing_subscription_prices.mjs):
- *   - subscription_uuid: Recurly subscription UUID
- *   - plan_code: Plan code at time of price change
- *   - currency: Currency
- *   - unit_amount: Original price per unit (before our price increase)
- *   - new_unit_amount: New price per unit (after our price increase)
- *   - subscription_add_on_unit_amount_in_cents: Original 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 (rolled-back, skipped, validated, not-found, or error)
- *   - note: Additional information about the status
- *
- * The script will SKIP (not rollback) a subscription if:
- *   - There is no pending change
- *   - The pending change involves a plan change (user downgrade/upgrade)
- *   - The pending change involves add-on additions/removals
- *   - The pending change involves add-on quantity changes
- *   - The prices don't match what we expect from the CSV
- *
- * 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:
- *      kubectl exec -it <pod-name> -- tail -f /tmp/rollback_prices_output_<timestamp>.csv > local_backup.csv
- *
- *   2. Periodically copy the output file to your laptop:
- *      kubectl cp <pod-name>:/tmp/rollback_prices_output_<timestamp>.csv ./backup.csv
- *
- *   3. Write to stdout and capture locally:
- *      kubectl exec -it <pod-name> -- node scripts/recurly/rollback_price_changes.mjs \
- *        --commit --output - input.csv > output.csv
- *
- * Examples:
- *   # Dry run (preview only)
- *   node scripts/recurly/rollback_price_changes.mjs input.csv
- *
- *   # Actually perform the rollback
- *   node scripts/recurly/rollback_price_changes.mjs --commit input.csv
- */
-
-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
- */
-
-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/rollback_price_changes.mjs [OPTIONS] [INPUT-FILE]
-
-Rollback pending price changes for Recurly subscriptions.
-
-This script only removes pending changes that are purely price changes on an
-existing plan. If a user has made any other modifications (plan change, add-on
-changes), the subscription is skipped.
-
-Options:
-    --output PATH          Output file path (default: /tmp/rollback_prices_output_<timestamp>.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})
-    --help                 Show this help message
-
-Output Statuses:
-    rolled-back    Pending price change was removed
-    validated      Dry run - would have removed pending price change
-    mismatch       Input prices malformed or subscription price does not match expected values
-    skipped        Not a price-only change, or values don't match (see note)
-    not-found      Subscription not found in Recurly
-    error          An error occurred
-
-See the source file header for detailed documentation on CSV format and pod usage.
-`)
-}
-
-/**
- * Main script entry point
- * @param {function(string): Promise<void>} 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/rollback_prices_output_${timestamp}.csv`
-
-  await trackProgress('Starting price rollback script for Recurly')
-  await trackProgress(`Run mode: ${opts.commit ? 'COMMIT' : 'DRY RUN'}`)
-  await trackProgress(`Throttle: ${opts.throttle}ms between requests`)
-
-  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 skippedCount = 0
-  let errorCount = 0
-
-  let lastLoopTimestamp = 0
-  for await (const record of csvReader) {
-    const timeSinceLastLoop = Date.now() - lastLoopTimestamp
-    if (timeSinceLastLoop < opts.throttle) {
-      await setTimeout(opts.throttle - timeSinceLastLoop)
-    }
-    lastLoopTimestamp = Date.now()
-
-    processedCount++
-
-    try {
-      const result = await processRollback(record, opts.commit)
-
-      if (opts.commit && result.subscription) {
-        try {
-          const userId = result.subscription.account.code
-          await AnalyticsManager.recordEventForUser(
-            userId,
-            'script_price_change_reversed',
-            {
-              subscriptionId: record.subscription_uuid,
-            }
-          )
-        } catch (err) {
-          await trackProgress(
-            `Warning: failed to record analytics event after successful price rollback for ${record.subscription_uuid}: ${err.message}`
-          )
-        }
-      }
-
-      csvWriter.write({
-        subscription_uuid: record.subscription_uuid,
-        status: result.status,
-        note: result.note || '',
-      })
-
-      if (result.status === 'skipped') {
-        skippedCount++
-      } else {
-        successCount++
-      }
-
-      if (processedCount % 10 === 0) {
-        await trackProgress(
-          `Processed ${processedCount} subscriptions (${successCount} ${opts.commit ? 'rolled-back' : 'validated'}, ${skippedCount} skipped, ${errorCount} errors)`
-        )
-      }
-    } catch (err) {
-      errorCount++
-      if (err instanceof ReportError) {
-        csvWriter.write({
-          subscription_uuid: record.subscription_uuid,
-          status: err.status,
-          note: err.message,
-        })
-      } else {
-        csvWriter.write({
-          subscription_uuid: record.subscription_uuid,
-          status: 'error',
-          note: err.message,
-        })
-        await trackProgress(
-          `Error processing ${record.subscription_uuid}: ${err.message}`
-        )
-      }
-    }
-  }
-
-  await trackProgress('\n✨ FINAL SUMMARY ✨')
-  await trackProgress(`📊 Total processed: ${processedCount}`)
-  if (opts.commit) {
-    await trackProgress(`✅ Successfully rolled back: ${successCount}`)
-  } else {
-    await trackProgress(`✅ Successfully validated: ${successCount}`)
-    await trackProgress('ℹ️  DRY RUN: No changes were applied to Recurly')
-  }
-  await trackProgress(`⏭️  Skipped: ${skippedCount}`)
-  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 rollback
- * @param {CSVSubscriptionChange} record - The subscription record to process
- * @param {boolean} commit - Whether to commit changes or run in dry-run mode
- * @returns {Promise<{status: string, note: string, subscription?: Subscription}>} The result of the rollback
- */
-async function processRollback(record, commit) {
-  const subscription = await fetchSubscription(record.subscription_uuid)
-
-  // Validate this is a price-only change that we created
-  const validation = validatePriceOnlyChange(record, subscription)
-
-  if (!validation.isPriceOnly) {
-    return {
-      status: 'skipped',
-      note: `${validation.reason}: ${validation.detail || 'N/A'}`,
-    }
-  }
-
-  if (!commit) {
-    return {
-      status: 'validated',
-      note: `Would remove pending price change: ${subscription.unitAmount} -> ${subscription.pendingChange.unitAmount}`,
-    }
-  }
-
-  // Safe to remove - this is a price-only change matching our expected values
-  await recurlyClient.removeSubscriptionChange(
-    `uuid-${record.subscription_uuid}`
-  )
-
-  return {
-    status: 'rolled-back',
-    note: `Removed pending price change: ${subscription.unitAmount} -> ${subscription.pendingChange.unitAmount}`,
-    subscription,
-  }
-}
-
-/**
- * Fetch a subscription from Recurly
- * @param {string} uuid - The Recurly subscription UUID
- * @returns {Promise<Subscription>} 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 pending change is a price-only change created by our
- * price increase script, and not a user-initiated plan change or add-on modification.
- *
- * @param {CSVSubscriptionChange} record - The CSV record with expected values
- * @param {Subscription} subscription - The Recurly subscription
- * @returns {{ isPriceOnly: boolean, reason?: string, detail?: string }}
- */
-function validatePriceOnlyChange(record, subscription) {
-  const pendingChange = subscription.pendingChange
-
-  // Check 1: Must have a pending change
-  if (pendingChange == null) {
-    return {
-      isPriceOnly: false,
-      reason: 'no-pending-change',
-      detail: 'subscription has no pending change to rollback',
-    }
-  }
-
-  // Check 2: Subscription must be active
-  if (subscription.state !== 'active') {
-    return {
-      isPriceOnly: false,
-      reason: 'inactive',
-      detail: `subscription state: ${subscription.state}`,
-    }
-  }
-
-  // Check 3: Plan code must match expected (from CSV)
-  if (subscription.plan.code !== record.plan_code) {
-    return {
-      isPriceOnly: false,
-      reason: 'plan-mismatch',
-      detail: `expected plan ${record.plan_code}, got ${subscription.plan.code}`,
-    }
-  }
-
-  // Check 4: Pending change must be for the SAME plan (not a downgrade/upgrade)
-  if (pendingChange.plan.code !== subscription.plan.code) {
-    return {
-      isPriceOnly: false,
-      reason: 'plan-change-detected',
-      detail: `pending plan change: ${subscription.plan.code} -> ${pendingChange.plan.code}`,
-    }
-  }
-
-  // Check 5: Currency must match
-  if (subscription.currency !== record.currency) {
-    return {
-      isPriceOnly: false,
-      reason: 'currency-mismatch',
-      detail: `expected ${record.currency}, got ${subscription.currency}`,
-    }
-  }
-
-  // Check 6: Current price must match expected (from CSV)
-  if (Math.abs(subscription.unitAmount - record.unit_amount) > 0.01) {
-    return {
-      isPriceOnly: false,
-      reason: 'current-price-mismatch',
-      detail: `expected current price ${record.unit_amount}, got ${subscription.unitAmount}`,
-    }
-  }
-
-  // Check 7: Pending price must match expected new price (from CSV)
-  if (Math.abs(pendingChange.unitAmount - record.new_unit_amount) > 0.01) {
-    return {
-      isPriceOnly: false,
-      reason: 'pending-price-mismatch',
-      detail: `expected pending price ${record.new_unit_amount}, got ${pendingChange.unitAmount}`,
-    }
-  }
-
-  // Check 8: Add-on codes must be the same (no add-ons added or removed)
-  const currentAddOnCodes = new Set(
-    (subscription.addOns || []).map(a => a.addOn.code)
-  )
-  const pendingAddOnCodes = new Set(
-    (pendingChange.addOns || []).map(a => a.addOn.code)
-  )
-
-  if (!setsEqual(currentAddOnCodes, pendingAddOnCodes)) {
-    return {
-      isPriceOnly: false,
-      reason: 'addon-change-detected',
-      detail: `current add-ons: [${[...currentAddOnCodes]}], pending: [${[...pendingAddOnCodes]}]`,
-    }
-  }
-
-  // Check 9: Add-on quantities must be the same
-  for (const currentAddOn of subscription.addOns || []) {
-    const pendingAddOn = (pendingChange.addOns || []).find(
-      a => a.addOn.code === currentAddOn.addOn.code
-    )
-    if (pendingAddOn && pendingAddOn.quantity !== currentAddOn.quantity) {
-      return {
-        isPriceOnly: false,
-        reason: 'addon-quantity-change-detected',
-        detail: `${currentAddOn.addOn.code}: quantity ${currentAddOn.quantity} -> ${pendingAddOn.quantity}`,
-      }
-    }
-  }
-
-  // Check 10: Validate add-on prices if provided in CSV
-  if (record.subscription_add_on_unit_amount_in_cents != null) {
-    const additionalLicenseAddOn = (subscription.addOns || []).find(
-      a => a.addOn.code === 'additional-license'
-    )
-
-    if (additionalLicenseAddOn == null) {
-      return {
-        isPriceOnly: false,
-        reason: 'addon-mismatch',
-        detail: 'expected additional-license add-on but not found',
-      }
-    }
-
-    const expectedCurrentAddOnPrice =
-      record.subscription_add_on_unit_amount_in_cents / 100
-    if (
-      Math.abs(additionalLicenseAddOn.unitAmount - expectedCurrentAddOnPrice) >
-      0.01
-    ) {
-      return {
-        isPriceOnly: false,
-        reason: 'addon-price-mismatch',
-        detail: `expected add-on price ${expectedCurrentAddOnPrice}, got ${additionalLicenseAddOn.unitAmount}`,
-      }
-    }
-
-    // Verify pending add-on price matches expected new price
-    if (record.new_subscription_add_on_unit_amount_in_cents != null) {
-      const pendingAddOn = (pendingChange.addOns || []).find(
-        a => a.addOn.code === 'additional-license'
-      )
-      const expectedNewAddOnPrice =
-        record.new_subscription_add_on_unit_amount_in_cents / 100
-
-      if (pendingAddOn == null) {
-        return {
-          isPriceOnly: false,
-          reason: 'pending-addon-mismatch',
-          detail:
-            'expected additional-license add-on in pending change but not found',
-        }
-      }
-
-      if (Math.abs(pendingAddOn.unitAmount - expectedNewAddOnPrice) > 0.01) {
-        return {
-          isPriceOnly: false,
-          reason: 'pending-addon-price-mismatch',
-          detail: `expected pending add-on price ${expectedNewAddOnPrice}, got ${pendingAddOn.unitAmount}`,
-        }
-      }
-    }
-  }
-
-  // All checks passed - this is a price-only change we created
-  return { isPriceOnly: true }
-}
-
-/**
- * Check if two sets are equal
- * @param {Set<string>} a - First set
- * @param {Set<string>} b - Second set
- * @returns {boolean} True if sets are equal
- */
-function setsEqual(a, b) {
-  return a.size === b.size && [...a].every(x => b.has(x))
-}
-
-const paramsSchema = z.object({
-  output: z.string().optional(),
-  commit: 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, commit: boolean, throttle: number}} Parsed options
- */
-function parseArgs() {
-  const argv = minimist(process.argv.slice(2), {
-    string: ['throttle', 'output'],
-    boolean: ['help', '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 { output, commit, throttle, _ } = parseResult.data
-
-  return {
-    inputFile: _[0],
-    output,
-    commit,
-    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)
-}

+ 0 - 138
services/web/scripts/recurly/set_manually_collected_subscriptions.mjs

@@ -1,138 +0,0 @@
-// @ts-check
-
-import fs from 'node:fs'
-import minimist from 'minimist'
-import {
-  db,
-  READ_PREFERENCE_SECONDARY,
-} from '../../app/src/infrastructure/mongodb.mjs'
-
-/**
- * @import { ObjectId } from 'mongodb-legacy'
- */
-
-const OPTS = parseArgs()
-
-const expectedManualRecurlyIds = readFile(OPTS.filename)
-const idsToSetToManual = await getSubscriptionIdsToSetToManual(
-  expectedManualRecurlyIds
-)
-const idsToSetToAutomatic = await getSubscriptionIdsToSetToAutomatic(
-  expectedManualRecurlyIds
-)
-
-if (idsToSetToManual.length > 0) {
-  if (OPTS.commit) {
-    console.log(
-      `Setting ${idsToSetToManual.length} subscriptions to manual invoice collection...`
-    )
-    await setCollectionMethod(idsToSetToManual, 'manual')
-  } else {
-    console.log(
-      `Would set ${idsToSetToManual.length} subscriptions to manual invoice collection`
-    )
-  }
-}
-
-if (idsToSetToAutomatic.length > 0) {
-  if (OPTS.commit) {
-    console.log(
-      `Setting ${idsToSetToAutomatic.length} subscriptions to automatic invoice collection...`
-    )
-    await setCollectionMethod(idsToSetToAutomatic, 'automatic')
-  } else {
-    console.log(
-      `Would set ${idsToSetToAutomatic.length} subscriptions to automatic invoice collection`
-    )
-  }
-}
-
-if (!OPTS.commit) {
-  console.log('This was a dry run. Add the --commit option to apply changes')
-}
-
-process.exit(0)
-
-function parseArgs() {
-  const args = minimist(process.argv.slice(2), {
-    boolean: ['commit'],
-  })
-  if (args._.length !== 1) {
-    usage()
-    process.exit(1)
-  }
-  return {
-    filename: args._[0],
-    commit: args.commit,
-  }
-}
-
-function usage() {
-  console.log(`Usage: node set_manually_collected_subscriptions.mjs FILE [--commit]
-
-    where FILE contains the list of subscription ids that are manually collected`)
-}
-
-/**
- * @param {any} filename
- */
-function readFile(filename) {
-  const contents = fs.readFileSync(filename, { encoding: 'utf-8' })
-  const subscriptionIds = contents.split('\n').filter(id => id.length > 0)
-  return subscriptionIds
-}
-
-/**
- * Get the ids of subscriptions that need to have their collection method set to
- * manual
- *
- * @param {string[]} expectedManualRecurlyIds
- * @return {Promise<ObjectId[]>}
- */
-async function getSubscriptionIdsToSetToManual(expectedManualRecurlyIds) {
-  const ids = await db.subscriptions
-    .find(
-      {
-        recurlySubscription_id: { $in: expectedManualRecurlyIds },
-        collectionMethod: { $ne: 'manual' },
-      },
-      { projection: { _id: 1 }, readPreference: READ_PREFERENCE_SECONDARY }
-    )
-    .map(record => record._id)
-    .toArray()
-  return ids
-}
-
-/**
- * Get the ids of subscriptions that need to have their collection method set to
- * automatic
- *
- * @param {string[]} expectedManualRecurlyIds
- * @return {Promise<ObjectId[]>}
- */
-async function getSubscriptionIdsToSetToAutomatic(expectedManualRecurlyIds) {
-  const ids = await db.subscriptions
-    .find(
-      {
-        recurlySubscription_id: { $nin: expectedManualRecurlyIds },
-        collectionMethod: 'manual',
-      },
-      { projection: { _id: 1 }, readPreference: READ_PREFERENCE_SECONDARY }
-    )
-    .map(record => record._id)
-    .toArray()
-  return ids
-}
-
-/**
- * Set the collection method for the given subscriptions
- *
- * @param {ObjectId[]} subscriptionIds
- * @param {"automatic" | "manual"} collectionMethod
- */
-async function setCollectionMethod(subscriptionIds, collectionMethod) {
-  await db.subscriptions.updateMany(
-    { _id: { $in: subscriptionIds } },
-    { $set: { collectionMethod } }
-  )
-}

+ 0 - 219
services/web/scripts/recurly/setup_assistant_addon.mjs

@@ -1,219 +0,0 @@
-// @ts-check
-import { scriptRunner } from '../lib/ScriptRunner.mjs'
-import _ from 'lodash'
-import recurly from 'recurly'
-import minimist from 'minimist'
-import Settings from '@overleaf/settings'
-
-const ADD_ON_CODE = 'assistant'
-const ADD_ON_NAME = 'AI Assist'
-
-const INDIVIDUAL_PLANS = [
-  'student',
-  'collaborator',
-  'professional',
-  'paid-personal',
-]
-const INDIVIDUAL_VARIANTS = ['', '_free_trial_7_days']
-const GROUP_PLANS = ['collaborator', 'professional']
-const GROUP_SIZES = [2, 3, 4, 5, 10, 20, 50]
-const GROUP_SEGMENTS = ['educational', 'enterprise']
-
-const ARGS = parseArgs()
-
-const recurlyClient = new recurly.Client(Settings.apis.recurly.apiKey)
-
-function usage() {
-  console.log(`Usage: setup_assistant_addon.js [--commit]
-
-This script will copy prices from the ${ADD_ON_CODE} and ${ADD_ON_CODE}-annual
-plans into the ${ADD_ON_CODE} add-on for every other plan
-
-Options:
-
-    --commit    Make actual changes to Recurly
-`)
-}
-
-function parseArgs() {
-  const args = minimist(process.argv.slice(2), {
-    boolean: ['commit', 'help'],
-  })
-
-  if (args.help) {
-    usage()
-    process.exit(0)
-  }
-
-  return { commit: args.commit }
-}
-
-async function main() {
-  const monthlyPlan = await getPlan(ADD_ON_CODE)
-  if (monthlyPlan == null) {
-    console.error(`Monthly plan missing in Recurly: ${ADD_ON_CODE}`)
-    process.exit(1)
-  }
-  console.log('\nMonthly prices:')
-  for (const { currency, unitAmount } of monthlyPlan.currencies ?? []) {
-    console.log(`- ${unitAmount} ${currency}`)
-  }
-
-  const annualPlan = await getPlan(`${ADD_ON_CODE}-annual`)
-  if (annualPlan == null) {
-    console.error(`Annual plan missing in Recurly: ${ADD_ON_CODE}-annual`)
-    process.exit(1)
-  }
-  console.log('\nAnnual prices:')
-  for (const { currency, unitAmount } of annualPlan.currencies ?? []) {
-    console.log(`- ${unitAmount} ${currency}`)
-  }
-  console.log()
-
-  for (const { code, annual } of getPlanSpecs()) {
-    const prices = annual ? annualPlan.currencies : monthlyPlan.currencies
-    await setupAddOn(code, prices ?? [])
-  }
-  if (ARGS.commit) {
-    console.log('Done')
-  } else {
-    console.log('This was a dry run. Re-run with --commit to apply changes.')
-  }
-}
-
-function* getPlanSpecs() {
-  for (const plan of INDIVIDUAL_PLANS) {
-    for (const variant of INDIVIDUAL_VARIANTS) {
-      yield { code: `${plan}${variant}`, annual: false }
-      yield { code: `${plan}-annual${variant}`, annual: true }
-    }
-  }
-  for (const plan of GROUP_PLANS) {
-    for (const size of GROUP_SIZES) {
-      for (const segment of GROUP_SEGMENTS) {
-        yield { code: `group_${plan}_${size}_${segment}`, annual: true }
-      }
-    }
-  }
-}
-
-/**
- * Create or update the assistant add-on for a plan
- *
- * @param {string} planCode
- * @param {recurly.AddOnPricing[]} prices
- */
-async function setupAddOn(planCode, prices) {
-  const currentAddOn = await getAddOn(planCode, ADD_ON_CODE)
-  const newAddOnConfig = getAddOnConfig(prices)
-  if (currentAddOn == null || currentAddOn.deletedAt != null) {
-    await createAddOn(planCode, newAddOnConfig)
-  } else if (_.isMatch(currentAddOn, newAddOnConfig)) {
-    console.log(`No changes for plan ${planCode}`)
-  } else {
-    await updateAddOn(planCode, newAddOnConfig)
-  }
-}
-
-/**
- * Get a plan configuration from Recurly
- *
- * @param {string} planCode
- */
-async function getPlan(planCode) {
-  try {
-    return await recurlyClient.getPlan(`code-${planCode}`)
-  } catch (err) {
-    if (err instanceof recurly.errors.NotFoundError) {
-      return null
-    } else {
-      throw err
-    }
-  }
-}
-
-/**
- * Get an add-on configuration from Recurly
- *
- * @param {string} planCode
- * @param {string} addOnCode
- */
-async function getAddOn(planCode, addOnCode) {
-  try {
-    return await recurlyClient.getPlanAddOn(
-      `code-${planCode}`,
-      `code-${addOnCode}`
-    )
-  } catch (err) {
-    if (err instanceof recurly.errors.NotFoundError) {
-      return null
-    } else {
-      throw err
-    }
-  }
-}
-
-/**
- * Create the add-on described by the given config on the given plan
- *
- * @param {string} planCode
- * @param {recurly.AddOnCreate} config
- */
-async function createAddOn(planCode, config) {
-  if (ARGS.commit) {
-    console.log(`Creating ${ADD_ON_CODE} add-on for plan ${planCode}...`)
-    await recurlyClient.createPlanAddOn(`code-${planCode}`, config)
-  } else {
-    console.log(`Would create ${ADD_ON_CODE} add-on for plan ${planCode}`)
-  }
-}
-
-/**
- * Update the add-on described by the given config on the given plan
- *
- * @param {string} planCode
- * @param {recurly.AddOnUpdate} config
- */
-async function updateAddOn(planCode, config) {
-  if (ARGS.commit) {
-    console.log(`Updating ${ADD_ON_CODE} add-on for plan ${planCode}...`)
-    await recurlyClient.updatePlanAddOn(
-      `code-${planCode}`,
-      `code-${ADD_ON_CODE}`,
-      config
-    )
-  } else {
-    console.log(`Would update ${ADD_ON_CODE} add-on for plan ${planCode}`)
-  }
-}
-
-/**
- * Get an assistant add-on config
- *
- * @param {recurly.AddOnPricing[]} prices
- */
-function getAddOnConfig(prices) {
-  return {
-    code: ADD_ON_CODE,
-    name: ADD_ON_NAME,
-    optional: true,
-    currencies: prices.map(price =>
-      _.pick(
-        price,
-        'currency',
-        'unitAmount',
-        'unitAmountDecimal',
-        'taxInclusive'
-      )
-    ),
-  }
-}
-
-scriptRunner(main)
-  .then(() => {
-    process.exit(0)
-  })
-  .catch(err => {
-    console.error(err)
-    process.exit(1)
-  })

+ 0 - 63
services/web/scripts/recurly/sync_recurly.rb

@@ -1,63 +0,0 @@
-require 'rubygems'
-require 'recurly'
-require 'json'
-
-if ENV['RECURLY_SUBDOMAIN']
-	Recurly.subdomain = ENV['RECURLY_SUBDOMAIN']
-else
-	print "Defaulting to sharelatex-sandbox. Set RECURLY_SUBDOMAIN environment variable to override\n"
-	Recurly.subdomain = "sharelatex-sandbox"
-end
-
-if ENV['RECURLY_API_KEY']
-	Recurly.api_key = ENV['RECURLY_API_KEY']
-else
-	print "Please set RECURLY_API_KEY environment variable\n"
-	exit 1
-end
-
-file = File.read('../../app/templates/plans/groups.json')
-groups = JSON.parse(file)
-# data format: groups[usage][plan_code][currency][size] = price
-
-PLANS = {}
-groups.each do |usage, data|
-	data.each do |plan_code, data|
-		data.each do |currency, data|
-			data.each do |size, price|
-				full_plan_code = "group_#{plan_code}_#{size}_#{usage}"
-				plan = PLANS[full_plan_code] ||= {
-					plan_code: full_plan_code,
-					name: "Overleaf #{plan_code.capitalize} - Group Account (#{size} licenses) - #{usage.capitalize}",
-					unit_amount_in_cents: {},
-					plan_interval_length: 12,
-					plan_interval_unit: 'months',
-					tax_code: 'digital'
-				}
-				plan[:unit_amount_in_cents][currency] = price * 100
-			end
-		end
-	end
-end
-
-PLANS.each do |plan_code, plan|
-	print "Syncing #{plan_code}...\n"
-	print "#{plan}\n"
-	begin
-		recurly_plan = Recurly::Plan.find(plan_code)
-	rescue Recurly::Resource::NotFound => e
-		recurly_plan = nil
-	end
-
-	if recurly_plan.nil?
-		print "No plan found, creating...\n"
-		Recurly::Plan.create(plan)
-	else
-		print "Existing plan found, updating...\n"
-		plan.each do |key, value|
-			recurly_plan[key] = value
-			recurly_plan.save
-		end
-	end
-	print "Done!\n"
-end

+ 0 - 104
services/web/scripts/recurly/update_terms_and_conditions_for_manually_billed_users.mjs

@@ -1,104 +0,0 @@
-import recurly from 'recurly'
-import Settings from '@overleaf/settings'
-import fs from 'node:fs'
-import minimist from 'minimist'
-import * as csv from 'csv'
-import { setTimeout } from 'node:timers/promises'
-import { scriptRunner } from '../lib/ScriptRunner.mjs'
-
-const recurlyApiKey = Settings.apis.recurly.apiKey
-if (!recurlyApiKey) {
-  throw new Error('Recurly API key is not set in the settings')
-}
-const client = new recurly.Client(recurlyApiKey)
-
-function usage() {
-  console.error(
-    'Script to update terms and conditions for manually billed Recurly subscriptions'
-  )
-  console.error('')
-  console.error('Usage:')
-  console.error(
-    '  node scripts/recurly/update_terms_and_conditions_for_manually_billed_users.mjs [options]'
-  )
-  console.error('')
-  console.error('Options:')
-  console.error(
-    '  --input, -i <file>              Path to CSV file containing subscription IDs (can be exported from Recurly)'
-  )
-  console.error(
-    '  --termsAndConditions, -t <file>  Path to text file containing terms and conditions'
-  )
-  console.error('')
-  console.error('Input format:')
-  console.error(
-    '  - Subscription IDs CSV: First column contains subscription IDs (header row is skipped)'
-  )
-  console.error(
-    '  - Terms and conditions: Plain text file with the terms and conditions content'
-  )
-}
-
-function parseArgs() {
-  return minimist(process.argv.slice(2), {
-    string: ['input', 'termsAndConditions'],
-    alias: {
-      i: 'input',
-      t: 'termsAndConditions',
-    },
-  })
-}
-
-async function updateTermsAndConditionsForSubscription(
-  subscriptionId,
-  termsAndConditions
-) {
-  try {
-    await client.updateSubscription(`uuid-${subscriptionId}`, {
-      terms_and_conditions: termsAndConditions,
-    })
-  } catch (error) {
-    console.error(
-      `Error updating subscription ${subscriptionId}: ${error.message}`
-    )
-  }
-}
-
-async function main() {
-  const {
-    termsAndConditions: termsAndConditionsPath,
-    input: inputPath,
-    h,
-    help,
-  } = parseArgs()
-  if (help || h || !termsAndConditionsPath || !inputPath) {
-    usage()
-    process.exit(0)
-  }
-  const termsAndConditions = fs.readFileSync(termsAndConditionsPath, 'utf8')
-
-  const parser = csv.parse({ columns: true })
-  fs.createReadStream(inputPath).pipe(parser)
-  let processedCount = 0
-  for await (const row of parser) {
-    const subscriptionId = row.subscription_id
-    await updateTermsAndConditionsForSubscription(
-      subscriptionId,
-      termsAndConditions
-    )
-    processedCount++
-    if (processedCount % 10 === 0) {
-      console.log(`Processed ${processedCount} subscriptions`)
-    }
-    await setTimeout(1000)
-  }
-  console.log(`Processed ${processedCount} subscriptions in total`)
-}
-
-try {
-  await scriptRunner(main)
-  process.exit(0)
-} catch (error) {
-  console.error(error)
-  process.exit(1)
-}

+ 0 - 1
services/web/scripts/stripe/.gitignore

@@ -1 +0,0 @@
-output

+ 0 - 310
services/web/scripts/stripe/RateLimiter.mjs

@@ -1,310 +0,0 @@
-/* eslint-disable @overleaf/require-script-runner */
-// This file contains helper functions used by other scripts.
-// The scripts that import these helpers should use Script Runner.
-
-import { setTimeout } from 'node:timers/promises'
-
-export const DEFAULT_RECURLY_RATE_LIMIT = 10
-export const DEFAULT_STRIPE_RATE_LIMIT = 50
-export const DEFAULT_RECURLY_API_RETRIES = 5
-export const DEFAULT_RECURLY_RETRY_DELAY_MS = 1000
-export const DEFAULT_STRIPE_API_RETRIES = 5
-export const DEFAULT_STRIPE_RETRY_DELAY_MS = 1000
-
-/**
- * Rate limiter using sliding window algorithm.
- *
- * Rate limits (conservative targets, leaving headroom):
- * - Recurly: 2000 requests per 5 minutes → target 1500/5min = 300/min = 5/sec
- *   https://support.recurly.com/hc/en-us/articles/360034160731-What-Are-Recurly-s-API-Rate-Limits
- * - Stripe: 100 requests per second → target 50/sec (plenty of headroom)
- *   https://docs.stripe.com/rate-limits
- *
- * Recurly is the bottleneck. With 2 Recurly calls per customer (getAccount, getBillingInfo),
- * we can process ~2.5 customers/second = ~150 customers/minute = ~9000 customers/hour.
- * For 150K customers, expect ~17 hours at full throughput.
- */
-
-class RateLimiter {
-  /**
-   * @param {string} name - Name for logging
-   * @param {number} maxRequests - Maximum requests allowed in the window
-   * @param {number} windowMs - Window size in milliseconds
-   * @param {Function} logDebug - Optional debug logging function
-   * @param {Function} logWarn - Optional warning logging function
-   */
-  constructor(
-    name,
-    maxRequests,
-    windowMs,
-    logDebug = () => null,
-    logWarn = () => null
-  ) {
-    this.name = name
-    this.maxRequests = maxRequests
-    this.windowMs = windowMs
-    this.requests = [] // timestamps of recent requests
-    this.totalRequests = 0
-    this._pending = Promise.resolve()
-    this.logDebug = logDebug
-    this.logWarn = logWarn
-  }
-
-  /**
-   * Wait if necessary to stay within rate limits, then record the request.
-   */
-  async throttle() {
-    this._pending = this._pending
-      .catch(error => {
-        // this should never happen since setTimeout or logDebug are very unlikely to ever fail
-        // but if it does, we log it and continue without blocking the queue (fail-open)
-        this.logWarn(`Rate limiter chain error for ${this.name}`, {
-          error: error?.message || String(error),
-        })
-      })
-      .then(async () => {
-        while (true) {
-          const now = Date.now()
-
-          // Remove requests outside the window
-          const windowStart = now - this.windowMs
-          this.requests = this.requests.filter(ts => ts > windowStart)
-
-          // If at limit, wait until the oldest request exits the window
-          if (this.requests.length >= this.maxRequests) {
-            const oldestRequest = this.requests[0]
-            const waitTime = oldestRequest - windowStart + 1
-            if (waitTime > 0) {
-              this.logDebug(
-                `Rate limit throttle for ${this.name}`,
-                {
-                  waitMs: waitTime,
-                  currentRequests: this.requests.length,
-                  maxRequests: this.maxRequests,
-                },
-                { verboseOnly: true }
-              )
-              await setTimeout(waitTime)
-              continue
-            }
-          }
-
-          // Record this request
-          this.requests.push(Date.now())
-          this.totalRequests++
-          break
-        }
-      })
-
-    return this._pending
-  }
-
-  /**
-   * Get current rate (requests per second over the last window)
-   */
-  getCurrentRate() {
-    const now = Date.now()
-    const windowStart = now - this.windowMs
-    const recentRequests = this.requests.filter(ts => ts > windowStart).length
-    return (recentRequests / this.windowMs) * 1000 // requests per second
-  }
-
-  getStats() {
-    return {
-      name: this.name,
-      totalRequests: this.totalRequests,
-      currentWindowRequests: this.requests.length,
-      maxRequests: this.maxRequests,
-      currentRate: this.getCurrentRate().toFixed(2) + '/sec',
-    }
-  }
-}
-
-/**
- * Helper to extract Stripe rate limit reason from error headers
- */
-function getStripeRateLimitReason(error) {
-  const headers =
-    error?.headers || error?.raw?.headers || error?.response?.headers || {}
-  return (
-    headers['stripe-rate-limit-reason'] ||
-    headers['Stripe-Rate-Limited-Reason'] ||
-    headers['stripe-rate-limited-reason'] ||
-    null
-  )
-}
-
-/**
- * Create rate-limited API wrapper with unified service routing.
- *
- * @param {object} config - Configuration options
- * @param {number} config.recurlyRateLimit - Requests per second for Recurly (default: 10)
- * @param {number} config.recurlyApiRetries - Number of retries on Recurly 429s (default: 5)
- * @param {number} config.recurlyRetryDelayMs - Delay between Recurly retries in ms (default: 1000)
- * @param {number} config.stripeRateLimit - Requests per second for Stripe (default: 50)
- * @param {number} config.stripeApiRetries - Number of retries on Stripe 429s (default: 5)
- * @param {number} config.stripeRetryDelayMs - Delay between Stripe retries in ms (default: 1000)
- * @param {Function} config.logDebug - Optional debug logging function
- * @param {Function} config.logWarn - Optional warning logging function
- *
- * @returns {object} Object with unified call function and stats getter
- * @returns {Function} returns.call - Unified wrapper for API calls (service, operation, context)
- * @returns {Function} returns.getRateLimiterStats - Get current rate limiter statistics
- */
-export function createRateLimitedApiWrappers(config = {}) {
-  const {
-    recurlyRateLimit = 10,
-    recurlyApiRetries = 5,
-    recurlyRetryDelayMs = 1000,
-    stripeRateLimit = 50,
-    stripeApiRetries = 5,
-    stripeRetryDelayMs = 1000,
-    logDebug = () => null,
-    logWarn = () => null,
-  } = config
-
-  const RATE_LIMIT_WINDOW_MS = 1000
-
-  // Service configuration registry
-  const serviceConfigs = {
-    recurly: {
-      rateLimit: recurlyRateLimit,
-      apiRetries: recurlyApiRetries,
-      retryDelayMs: recurlyRetryDelayMs,
-      isStripe: false,
-    },
-    stripe: {
-      rateLimit: stripeRateLimit,
-      apiRetries: stripeApiRetries,
-      retryDelayMs: stripeRetryDelayMs,
-      isStripe: true,
-    },
-  }
-
-  // Rate limiter instances per service
-  const rateLimiters = new Map()
-
-  function getRateLimiter(service) {
-    const key = String(service || 'unknown').toLowerCase()
-    if (rateLimiters.has(key)) {
-      return rateLimiters.get(key)
-    }
-
-    // Determine service config
-    let serviceConfig
-    if (key === 'recurly') {
-      serviceConfig = serviceConfigs.recurly
-    } else if (key.startsWith('stripe')) {
-      serviceConfig = serviceConfigs.stripe
-    } else {
-      throw new Error(`Unknown service: ${service}`)
-    }
-
-    const limiter = new RateLimiter(
-      key,
-      serviceConfig.rateLimit,
-      RATE_LIMIT_WINDOW_MS,
-      logDebug,
-      logWarn
-    )
-    rateLimiters.set(key, limiter)
-    return limiter
-  }
-
-  function getServiceConfig(service) {
-    const key = String(service || 'unknown').toLowerCase()
-    if (key === 'recurly') {
-      return serviceConfigs.recurly
-    } else if (key.startsWith('stripe')) {
-      return serviceConfigs.stripe
-    } else {
-      throw new Error(`Unknown service: ${service}`)
-    }
-  }
-
-  async function requestWithRetries(service, operation, { context } = {}) {
-    const serviceConfig = getServiceConfig(service)
-    const rateLimiter = getRateLimiter(service)
-    let attempt = 0
-
-    while (true) {
-      try {
-        await rateLimiter.throttle()
-        return await operation()
-      } catch (error) {
-        const statusCode =
-          error?.statusCode ?? error?.status ?? error?.raw?.statusCode
-        if (statusCode === 429) {
-          attempt++
-          if (attempt > serviceConfig.apiRetries) {
-            logWarn(
-              `${service} rate limit exceeded after ${attempt - 1} retries`,
-              {
-                ...context,
-                service,
-                attempt,
-                ...(serviceConfig.isStripe
-                  ? { rateLimitReason: getStripeRateLimitReason(error) }
-                  : {}),
-              }
-            )
-            throw error
-          }
-          logDebug(`${service} rate limited, retrying`, {
-            ...context,
-            service,
-            attempt,
-            retryDelayMs: serviceConfig.retryDelayMs,
-            ...(serviceConfig.isStripe
-              ? { rateLimitReason: getStripeRateLimitReason(error) }
-              : {}),
-          })
-          await setTimeout(serviceConfig.retryDelayMs)
-          continue
-        }
-        throw error
-      }
-    }
-  }
-
-  /**
-   * Get rate limiter statistics for logging
-   */
-  function getRateLimiterStats() {
-    const allLimiters = [...rateLimiters.values()]
-
-    // Separate Recurly and Stripe limiters
-    const recurlyLimiters = allLimiters.filter(
-      limiter => limiter.name === 'recurly'
-    )
-    const stripeLimiters = allLimiters.filter(limiter =>
-      limiter.name.startsWith('stripe')
-    )
-
-    const stripeTotalRequests = stripeLimiters.reduce(
-      (sum, limiter) => sum + limiter.totalRequests,
-      0
-    )
-    const stripeCurrentRate = stripeLimiters.reduce(
-      (sum, limiter) => sum + limiter.getCurrentRate(),
-      0
-    )
-
-    return {
-      recurly:
-        recurlyLimiters.length > 0
-          ? recurlyLimiters[0].getStats()
-          : { totalRequests: 0, currentRate: '0.00/sec' },
-      stripe: {
-        totalRequests: stripeTotalRequests,
-        currentRate: stripeCurrentRate.toFixed(2) + '/sec',
-      },
-      stripeByRegion: stripeLimiters.map(limiter => limiter.getStats()),
-    }
-  }
-
-  return {
-    requestWithRetries,
-    getRateLimiterStats,
-  }
-}

+ 0 - 324
services/web/scripts/stripe/archive_prices_by_version_key.mjs

@@ -1,324 +0,0 @@
-#!/usr/bin/env node
-
-/**
- * This script marks as archived all prices with a matching version key in their lookup key
- *
- * Usage:
- *   node scripts/stripe/archive_prices_by_version_key.mjs --region us --version versionKey [options]
- *   node scripts/stripe/archive_prices_by_version_key.mjs --region uk --version versionKey [options]
- *
- * Options:
- *   --region           Required. Stripe region to process (us or uk)
- *   --version          Required. Version key to match in lookup keys (e.g., 'jul2025')
- *   --action           Required. Action to perform: 'archive', 'soft-archive', or 'unarchive'
- *                      - archive: Set prices as inactive (if not associated with any active subscriptions) and add [ARCHIVED] to nickname
- *                      - soft-archive: Only add [ARCHIVED] to nickname, keep prices active
- *                      - unarchive: Reactivate prices and remove [ARCHIVED] from nickname
- *   --commit           Actually perform the updates (default: dry-run mode)
- *
- * Examples:
- *   # Dry run archive prices with version 'jul2025' in US region
- *   node scripts/stripe/archive_prices_by_version_key.mjs --region us --version jul2025 --action archive
- *
- *   # Commit archive prices with version 'jul2025' in UK region
- *   node scripts/stripe/archive_prices_by_version_key.mjs --region uk --version jul2025 --action archive --commit
- *
- *   # Soft archive: only mark in nickname, keep prices active
- *   node scripts/stripe/archive_prices_by_version_key.mjs --region us --version jul2025 --action soft-archive --commit
- *
- *   # Unarchive prices with version 'jul2025'
- *   node scripts/stripe/archive_prices_by_version_key.mjs --region us --version jul2025 --action unarchive --commit
- */
-
-import minimist from 'minimist'
-import { z } from '../../app/src/infrastructure/Validation.mjs'
-import { scriptRunner } from '../lib/ScriptRunner.mjs'
-import { getRegionClient } from '../../modules/subscriptions/app/src/StripeClient.mjs'
-
-/**
- * @import Stripe from 'stripe'
- */
-
-const paramsSchema = z.object({
-  region: z.enum(['us', 'uk']),
-  version: z.string(),
-  action: z.enum(['archive', 'soft-archive', 'unarchive']),
-  commit: z.boolean().default(false),
-})
-
-/**
- * Sleep function to respect Stripe rate limits (100 requests per second)
- */
-async function rateLimitSleep() {
-  return new Promise(resolve => setTimeout(resolve, 50))
-}
-
-/**
- * Check if a price has active subscriptions (if an active subscription has
- * archived prices, those customers will run into issues modifying their
- * subscriptions)
- *
- * @param {Stripe} stripe
- * @param {string} priceId
- * @returns {Promise<boolean>}
- */
-async function getHasActiveSubscriptions(stripe, priceId) {
-  const potentiallyActiveStatuses = [
-    'active',
-    'trialing',
-    'past_due',
-    'unpaid',
-    'paused',
-    'incomplete',
-  ]
-  let hasMore = true
-  let startingAfter
-
-  while (hasMore) {
-    const params = {
-      price: priceId,
-      limit: 100,
-    }
-    if (startingAfter) {
-      params.starting_after = startingAfter
-    }
-    const subscriptions = await stripe.subscriptions.list(params)
-    await rateLimitSleep()
-
-    const hasActiveInBatch = subscriptions.data.some(subscription =>
-      potentiallyActiveStatuses.includes(subscription.status)
-    )
-
-    if (hasActiveInBatch) {
-      return true
-    }
-
-    hasMore = subscriptions.has_more
-
-    if (hasMore && subscriptions.data.length > 0) {
-      startingAfter = subscriptions.data[subscriptions.data.length - 1].id
-    }
-  }
-
-  return false
-}
-
-/**
- * Fetch all prices matching the version key from Stripe
- *
- * @param {Stripe} stripe
- * @param {string} version
- * @param {function} trackProgress
- * @returns {Promise<Stripe.Price[]>}
- */
-async function fetchPricesByVersion(stripe, version, trackProgress) {
-  const matchingPrices = []
-  let hasMore = true
-  let startingAfter
-
-  await trackProgress('Fetching prices from Stripe...')
-
-  while (hasMore) {
-    const pricesResult = await stripe.prices.list({
-      limit: 100,
-      starting_after: startingAfter,
-    })
-
-    // Filter prices that have the version in their lookup key
-    const filtered = pricesResult.data.filter(
-      price => price.lookup_key && price.lookup_key.includes(version)
-    )
-
-    matchingPrices.push(...filtered)
-    hasMore = pricesResult.has_more
-
-    if (hasMore) {
-      startingAfter = pricesResult.data[pricesResult.data.length - 1].id
-    }
-
-    await rateLimitSleep()
-  }
-
-  await trackProgress(`Found ${matchingPrices.length} matching prices...`)
-  return matchingPrices
-}
-
-/**
- * Archive or unarchive prices in Stripe
- *
- * @param {Stripe.Price[]} prices
- * @param {Stripe} stripe
- * @param {string} action
- * @param {boolean} commit
- * @param {function} trackProgress
- * @returns {Promise<object>}
- */
-async function processPrices(prices, stripe, action, commit, trackProgress) {
-  const targetActiveStatus = action === 'unarchive'
-  const isSoftArchive = action === 'soft-archive'
-  const isArchiving = action === 'archive' || action === 'soft-archive'
-  const results = {
-    processed: 0,
-    skipped: 0,
-    hasSubscriptions: 0,
-    errored: 0,
-  }
-
-  // pre-filter prices already in the desired state to avoid unnecessary API calls
-  const pricesToProcess = []
-  for (const price of prices) {
-    const hasArchivedNickname = price.nickname?.includes('[ARCHIVED]')
-    const alreadyInDesiredState = isArchiving
-      ? hasArchivedNickname
-      : price.active && !hasArchivedNickname
-
-    if (alreadyInDesiredState) {
-      await trackProgress(
-        `Skipping price ${price.id} (${price.lookup_key}) - already ${price.active ? 'active' : 'archived'}`
-      )
-      results.skipped++
-    } else {
-      pricesToProcess.push(price)
-    }
-  }
-
-  if (pricesToProcess.length === 0) {
-    return results
-  }
-
-  await trackProgress(`Processing ${pricesToProcess.length} prices...`)
-
-  for (const price of pricesToProcess) {
-    try {
-      const hasActiveSubscriptions =
-        action === 'archive'
-          ? await getHasActiveSubscriptions(stripe, price.id)
-          : false
-      if (hasActiveSubscriptions) {
-        results.hasSubscriptions++
-      }
-
-      if (commit) {
-        const updateParams = {}
-
-        // only update active status if not soft-archiving and price doesn't have active subscriptions
-        if (!isSoftArchive && !hasActiveSubscriptions) {
-          updateParams.active = targetActiveStatus
-        }
-
-        if (isArchiving && !price.nickname?.includes('[ARCHIVED]')) {
-          updateParams.nickname = price.nickname
-            ? `[ARCHIVED] ${price.nickname}`
-            : '[ARCHIVED]'
-        }
-        if (action === 'unarchive' && price.nickname?.includes('[ARCHIVED]')) {
-          updateParams.nickname = price.nickname.replace(/^\[ARCHIVED\]\s*/, '')
-        }
-
-        if (Object.keys(updateParams).length > 0) {
-          await stripe.prices.update(price.id, updateParams)
-          let statusNote = ''
-          if (hasActiveSubscriptions) {
-            statusNote = '(soft archived - has active subscriptions)'
-          } else if (isSoftArchive) {
-            statusNote = '(soft archived)'
-          }
-          await trackProgress(
-            `${isArchiving ? 'Archived' : 'Unarchived'} price: ${price.id} (${price.lookup_key}) ${statusNote}`
-          )
-          await rateLimitSleep()
-        }
-      } else {
-        let statusNote = ''
-        if (hasActiveSubscriptions) {
-          statusNote = '(soft archived - has active subscriptions)'
-        } else if (isSoftArchive) {
-          statusNote = '(soft archived)'
-        }
-        await trackProgress(
-          `[DRY RUN] Would ${action} price: ${price.id} (${price.lookup_key}) ${statusNote}`
-        )
-      }
-
-      results.processed++
-    } catch (error) {
-      await trackProgress(
-        `ERROR processing price ${price.id}: ${error.message}`
-      )
-      results.errored++
-    }
-  }
-
-  return results
-}
-
-async function main(trackProgress) {
-  const parseResult = paramsSchema.safeParse(
-    minimist(process.argv.slice(2), {
-      boolean: ['commit'],
-      string: ['region', 'version', 'action'],
-    })
-  )
-
-  if (!parseResult.success) {
-    throw new Error(`Invalid parameters: ${parseResult.error.message}`)
-  }
-
-  const { region, version, action, commit } = parseResult.data
-
-  const mode = commit ? 'COMMIT MODE' : 'DRY RUN MODE'
-  await trackProgress(`Starting ${action} in ${mode} for region: ${region}`)
-  await trackProgress(`Target version: ${version}`)
-
-  const stripe = getRegionClient(region).stripe
-
-  const prices = await fetchPricesByVersion(stripe, version, trackProgress)
-
-  if (prices.length === 0) {
-    await trackProgress('No prices found. Exiting.')
-    return
-  }
-
-  await trackProgress(`Processing ${action} operation...`)
-  const results = await processPrices(
-    prices,
-    stripe,
-    action,
-    commit,
-    trackProgress
-  )
-
-  await trackProgress('OPERATION SUMMARY')
-  await trackProgress(
-    `Prices ${commit ? 'processed' : 'would be processed'}: ${results.processed}`
-  )
-  await trackProgress(
-    `Prices skipped (already in desired state): ${results.skipped}`
-  )
-  await trackProgress(
-    `Prices skipped (has active subscriptions): ${results.hasSubscriptions}`
-  )
-  await trackProgress(`Prices errored: ${results.errored}`)
-
-  if (results.errored > 0) {
-    await trackProgress(
-      'WARNING: Some prices failed to process. Check the logs above.'
-    )
-  }
-
-  if (!commit) {
-    await trackProgress(
-      'This was a dry run. Use --commit to actually perform the operation.'
-    )
-  }
-
-  await trackProgress(`Script completed in ${mode}`)
-}
-
-try {
-  await scriptRunner(main)
-  process.exit(0)
-} catch (error) {
-  console.error('Script failed:', error.message)
-  process.exit(1)
-}

+ 0 - 340
services/web/scripts/stripe/bulk-cancel-subscription-schedules.mjs

@@ -1,340 +0,0 @@
-#!/usr/bin/env node
-
-/**
- * This script bulk cancels pending Stripe subscription schedules (status: "not_started")
- * that are not attached to a subscription (this can be deleted once the migration is complete)
- *
- * For each customer in the input CSV, it:
- * 1. Lists all subscription schedules for the customer
- * 2. Finds the schedule with status "not_started"
- * 3. Cancels that schedule via the Stripe API
- *
- * NOTE: this will NOT email customers to inform them of the cancellation
- *
- * Usage:
- *   node scripts/stripe/bulk-cancel-subscription-schedules.mjs [OPTS] [INPUT-FILE]
- *
- * Options:
- *   --output PATH                 Output file path (default: /tmp/bulk_cancel_schedules_output_<timestamp>.csv)
- *   --commit                      Apply changes (without this, runs in dry-run mode)
- *   --concurrency N               Number of customers to process concurrently (default: 10)
- *   --stripe-rate-limit N         Requests per second for Stripe (default: 50)
- *   --stripe-api-retries N        Number of retries on Stripe 429s (default: 5)
- *   --stripe-retry-delay-ms N     Delay between Stripe retries in ms (default: 1000)
- *   --help                        Show help message
- *
- * CSV Input Format:
- *   The CSV must have the following columns:
- *   - stripe_customer_id: Stripe customer id
- *   - target_stripe_account: Either 'stripe-uk' or 'stripe-us'
- *
- * CSV Output Format:
- *   stripe_customer_id,target_stripe_account,schedule_id,status,note
- */
-
-import fs from 'node:fs'
-import path from 'node:path'
-import * as csv from 'csv'
-import minimist from 'minimist'
-import PQueue from 'p-queue'
-import { z } from '../../app/src/infrastructure/Validation.mjs'
-import { scriptRunner } from '../lib/ScriptRunner.mjs'
-import { getRegionClient } from '../../modules/subscriptions/app/src/StripeClient.mjs'
-import { ReportError } from './helpers.mjs'
-import {
-  createRateLimitedApiWrappers,
-  DEFAULT_STRIPE_RATE_LIMIT,
-  DEFAULT_STRIPE_API_RETRIES,
-  DEFAULT_STRIPE_RETRY_DELAY_MS,
-} from './RateLimiter.mjs'
-
-// rate limiters - initialized in main()
-let rateLimiters
-
-function usage() {
-  console.error(`Usage: node scripts/stripe/bulk-cancel-subscription-schedules.mjs [OPTS] [INPUT-FILE]
-
-Options:
-    --output PATH                 Output file path (default: /tmp/bulk_cancel_schedules_output_<timestamp>.csv)
-    --commit                      Apply changes (without this, runs in dry-run mode)
-    --concurrency N               Number of customers to process concurrently (default: 10)
-    --stripe-rate-limit N         Requests per second for Stripe (default: ${DEFAULT_STRIPE_RATE_LIMIT})
-    --stripe-api-retries N        Number of retries on Stripe 429s (default: ${DEFAULT_STRIPE_API_RETRIES})
-    --stripe-retry-delay-ms N     Delay between Stripe retries in ms (default: ${DEFAULT_STRIPE_RETRY_DELAY_MS})
-    --help                        Show this help message
-`)
-}
-
-async function main(trackProgress) {
-  const opts = parseArgs()
-  const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
-  const outputFile =
-    opts.output ?? `/tmp/bulk_cancel_schedules_output_${timestamp}.csv`
-
-  rateLimiters = createRateLimitedApiWrappers({
-    stripeRateLimit: opts.stripeRateLimit,
-    stripeApiRetries: opts.stripeApiRetries,
-    stripeRetryDelayMs: opts.stripeRetryDelayMs,
-  })
-
-  await trackProgress(
-    'Starting bulk subscription schedule cancellation for Stripe'
-  )
-  await trackProgress(`Run mode: ${opts.commit ? 'COMMIT' : 'DRY RUN'}`)
-  await trackProgress(`Rate limit: Stripe ${opts.stripeRateLimit}/s`)
-  await trackProgress(`Concurrency: ${opts.concurrency}`)
-
-  const inputStream = opts.inputFile
-    ? fs.createReadStream(opts.inputFile)
-    : process.stdin
-  const csvReader = getCsvReader(inputStream)
-  const csvWriter = getCsvWriter(outputFile)
-
-  await trackProgress(`Output: ${outputFile}`)
-
-  let processedCount = 0
-  let successCount = 0
-  let errorCount = 0
-
-  const queue = new PQueue({ concurrency: opts.concurrency })
-  const maxQueueSize = opts.concurrency
-
-  try {
-    for await (const input of csvReader) {
-      if (queue.size >= maxQueueSize) {
-        await queue.onSizeLessThan(maxQueueSize)
-      }
-
-      queue.add(async () => {
-        try {
-          const result = await processScheduleCancellation(input, opts.commit)
-
-          csvWriter.write({
-            stripe_customer_id: input.stripe_customer_id,
-            target_stripe_account: input.target_stripe_account,
-            schedule_id: result.scheduleId || '',
-            status: result.status,
-            note: result.note,
-          })
-
-          if (result.status === 'cancelled' || result.status === 'validated') {
-            successCount++
-          } else {
-            errorCount++
-          }
-        } catch (err) {
-          errorCount++
-          csvWriter.write({
-            stripe_customer_id: input.stripe_customer_id,
-            target_stripe_account: input.target_stripe_account,
-            schedule_id: '',
-            status: err instanceof ReportError ? err.status : 'error',
-            note: err.message,
-          })
-        }
-
-        processedCount++
-        if (processedCount % 25 === 0) {
-          await trackProgress(
-            `Progress: ${processedCount} processed, ${successCount} successful, ${errorCount} errors`
-          )
-        }
-      })
-    }
-  } finally {
-    await queue.onIdle()
-  }
-
-  await trackProgress(`✅ Total processed: ${processedCount}`)
-  if (opts.commit) {
-    await trackProgress(`✅ Successfully cancelled: ${successCount}`)
-  } else {
-    await trackProgress(`✅ Successfully validated: ${successCount}`)
-    await trackProgress('ℹ️  DRY RUN: No changes were applied')
-  }
-  await trackProgress(`❌ Errors: ${errorCount}`)
-  await trackProgress('🎉 Script completed!')
-
-  csvWriter.end()
-}
-
-function parseArgs() {
-  const args = minimist(process.argv.slice(2), {
-    string: [
-      'output',
-      'concurrency',
-      'stripe-rate-limit',
-      'stripe-api-retries',
-      'stripe-retry-delay-ms',
-    ],
-    boolean: ['commit', 'help'],
-    default: {
-      commit: false,
-      concurrency: 10,
-      'stripe-rate-limit': DEFAULT_STRIPE_RATE_LIMIT,
-      'stripe-api-retries': DEFAULT_STRIPE_API_RETRIES,
-      'stripe-retry-delay-ms': DEFAULT_STRIPE_RETRY_DELAY_MS,
-    },
-  })
-
-  if (args.help) {
-    usage()
-    process.exit(0)
-  }
-
-  const inputFile = args._[0]
-  const paramsSchema = z.object({
-    output: z.string().optional(),
-    commit: z.boolean(),
-    concurrency: z.number().int().positive(),
-    stripeRateLimit: z.number().positive(),
-    stripeApiRetries: z.number().int().nonnegative(),
-    stripeRetryDelayMs: z.number().int().nonnegative(),
-    inputFile: z.string().optional(),
-  })
-
-  try {
-    return paramsSchema.parse({
-      output: args.output,
-      commit: args.commit,
-      concurrency: Number(args.concurrency),
-      stripeRateLimit: Number(args['stripe-rate-limit']),
-      stripeApiRetries: Number(args['stripe-api-retries']),
-      stripeRetryDelayMs: Number(args['stripe-retry-delay-ms']),
-      inputFile,
-    })
-  } catch (err) {
-    console.error('Invalid arguments:', err.message)
-    usage()
-    process.exit(1)
-  }
-}
-
-function getCsvReader(inputStream) {
-  const parser = csv.parse({ columns: true })
-  inputStream.pipe(parser)
-  return parser
-}
-
-function getCsvWriter(outputFile) {
-  fs.mkdirSync(path.dirname(outputFile), { recursive: true })
-  const outputStream = fs.createWriteStream(outputFile)
-
-  const writer = csv.stringify({
-    columns: [
-      'stripe_customer_id',
-      'target_stripe_account',
-      'schedule_id',
-      'status',
-      'note',
-    ],
-    header: true,
-  })
-
-  writer.on('error', err => {
-    console.error(err)
-    process.exit(1)
-  })
-
-  writer.pipe(outputStream)
-  return writer
-}
-
-async function processScheduleCancellation(input, commit) {
-  const {
-    stripe_customer_id: customerId,
-    target_stripe_account: targetStripeAccount,
-  } = input
-
-  // get Stripe client for the target account
-  const region = targetStripeAccount.replace(/^stripe-/, '')
-  const stripeClient = getRegionClient(region)
-
-  // list all subscription schedules for this customer
-  let schedules
-  try {
-    schedules = await rateLimiters.requestWithRetries(
-      stripeClient.serviceName,
-      () =>
-        stripeClient.stripe.subscriptionSchedules.list({
-          customer: customerId,
-          limit: 100, // max limit
-        }),
-      {
-        operation: 'subscriptionSchedules.list',
-        customerId,
-        region: stripeClient.serviceName,
-      }
-    )
-  } catch (err) {
-    throw new ReportError(
-      'list-schedules-failed',
-      `Failed to list subscription schedules: ${err.message}`
-    )
-  }
-
-  // find the schedule with status "not_started"
-  const notStartedSchedules = schedules.data.filter(
-    schedule =>
-      schedule.status === 'not_started' &&
-      schedule.subscription == null &&
-      schedule.metadata?.billing_migration_id != null
-  )
-
-  if (notStartedSchedules.length === 0) {
-    throw new ReportError(
-      'no-not-started-schedule',
-      `No subscription schedule with status "not_started" found for customer ${customerId}`
-    )
-  }
-
-  if (notStartedSchedules.length > 1) {
-    const scheduleIds = notStartedSchedules.map(s => s.id).join(', ')
-    throw new ReportError(
-      'multiple-not-started-schedules',
-      `Found ${notStartedSchedules.length} schedules with status "not_started" (${scheduleIds}), expected exactly 1`
-    )
-  }
-
-  const targetSchedule = notStartedSchedules[0]
-
-  if (!commit) {
-    return {
-      status: 'validated',
-      note: `Schedule ${targetSchedule.id} can be cancelled`,
-      scheduleId: targetSchedule.id,
-    }
-  }
-
-  // cancel the schedule
-  try {
-    await rateLimiters.requestWithRetries(
-      stripeClient.serviceName,
-      () => stripeClient.stripe.subscriptionSchedules.cancel(targetSchedule.id),
-      {
-        operation: 'subscriptionSchedules.cancel',
-        scheduleId: targetSchedule.id,
-        region: stripeClient.serviceName,
-      }
-    )
-
-    return {
-      status: 'cancelled',
-      note: `Cancelled schedule ${targetSchedule.id}`,
-      scheduleId: targetSchedule.id,
-    }
-  } catch (err) {
-    throw new ReportError(
-      'cancel-schedule-failed',
-      `Failed to cancel schedule ${targetSchedule.id}: ${err.message}`
-    )
-  }
-}
-
-try {
-  await scriptRunner(main)
-  process.exit(0)
-} catch (error) {
-  console.error(error)
-  process.exit(1)
-}

+ 0 - 391
services/web/scripts/stripe/bulk-cancel-subscriptions.mjs

@@ -1,391 +0,0 @@
-#!/usr/bin/env node
-
-/**
- * This script bulk cancels active Stripe subscriptions immediately without proration.
- *
- * NOTE: this will email customers to inform them of the cancellation unless you turn off
- * the cancellation automation in Stripe beforehand: https://dashboard.stripe.com/<account>/revenue-recovery/automations
- *
- * ⚠️ WARNING: For customers with PayPal billing agreements, do NOT extend this script to
- * delete the Stripe customer (customers.del) or detach payment methods
- * (paymentMethods.detach). Doing so will permanently destroy the PayPal billing
- * agreement, which cannot be recovered without asking the customer to re-authorize.
- * Cancelling a subscription is safe — it does not affect the payment method.
- *
- * Usage:
- *   node scripts/stripe/bulk-cancel-subscriptions.mjs [OPTS] [INPUT-FILE]
- *
- * Options:
- *   --output PATH                 Output file path (default: /tmp/bulk_cancel_output_<timestamp>.csv)
- *                                 Use '-' to write to stdout
- *   --commit                      Apply changes (without this flag, runs in dry-run mode)
- *   --concurrency N               Number of customers to process concurrently (default: 10)
- *   --stripe-rate-limit N         Requests per second for Stripe (default: 50)
- *   --stripe-api-retries N        Number of retries on Stripe 429s (default: 5)
- *   --stripe-retry-delay-ms N     Delay between Stripe retries in ms (default: 1000)
- *   --help                        Show a help message
- *
- * CSV Input Format:
- *   The CSV must have the following columns:
- *   - stripe_customer_id: Stripe customer id
- *   - target_stripe_account: Either 'stripe-uk' or 'stripe-us'
- *
- * Output:
- *   Writes a CSV with columns:
- *   - stripe_customer_id: The customer id processed
- *   - target_stripe_account: The Stripe account
- *   - subscription_id: The subscription id that was cancelled (if found)
- *   - status: Result status (cancelled, validated, no-subscription, already-cancelled, or error)
- *   - note: Additional information about the status
- */
-
-import fs from 'node:fs'
-import path from 'node:path'
-import * as csv from 'csv'
-import minimist from 'minimist'
-import PQueue from 'p-queue'
-import { z } from '../../app/src/infrastructure/Validation.mjs'
-import { scriptRunner } from '../lib/ScriptRunner.mjs'
-import { getRegionClient } from '../../modules/subscriptions/app/src/StripeClient.mjs'
-import { ReportError } from './helpers.mjs'
-import {
-  createRateLimitedApiWrappers,
-  DEFAULT_STRIPE_RATE_LIMIT,
-  DEFAULT_STRIPE_API_RETRIES,
-  DEFAULT_STRIPE_RETRY_DELAY_MS,
-} from './RateLimiter.mjs'
-
-const DEFAULT_CONCURRENCY = 10
-
-// rate limiters - initialized in main()
-let rateLimiters
-
-function usage() {
-  console.error(`Usage: node scripts/stripe/bulk-cancel-subscriptions.mjs [OPTS] [INPUT-FILE]
-
-Options:
-    --output PATH                 Output file path (default: /tmp/bulk_cancel_output_<timestamp>.csv)
-                                  Use '-' to write to stdout
-    --commit                      Apply changes (without this, runs in dry-run mode)
-    --concurrency N               Number of customers to process concurrently (default: ${DEFAULT_CONCURRENCY})
-    --stripe-rate-limit N         Requests per second for Stripe (default: ${DEFAULT_STRIPE_RATE_LIMIT})
-    --stripe-api-retries N        Number of retries on Stripe 429s (default: ${DEFAULT_STRIPE_API_RETRIES})
-    --stripe-retry-delay-ms N     Delay between Stripe retries in ms (default: ${DEFAULT_STRIPE_RETRY_DELAY_MS})
-    --help                        Show this help message
-`)
-}
-
-async function main(trackProgress) {
-  const opts = parseArgs()
-  const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
-  const outputFile = opts.output ?? `/tmp/bulk_cancel_output_${timestamp}.csv`
-
-  // initialize rate limiters
-  rateLimiters = createRateLimitedApiWrappers({
-    stripeRateLimit: opts.stripeRateLimit,
-    stripeApiRetries: opts.stripeApiRetries,
-    stripeRetryDelayMs: opts.stripeRetryDelayMs,
-  })
-
-  await trackProgress('Starting bulk subscription cancellation for Stripe')
-  await trackProgress(`Run mode: ${opts.commit ? 'COMMIT' : 'DRY RUN'}`)
-  await trackProgress(`Rate limit: Stripe ${opts.stripeRateLimit}/s`)
-  await trackProgress(`Concurrency: ${opts.concurrency}`)
-
-  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
-
-  const queue = new PQueue({ concurrency: opts.concurrency })
-  const maxQueueSize = opts.concurrency
-
-  try {
-    for await (const input of csvReader) {
-      if (queue.size >= maxQueueSize) {
-        await queue.onSizeLessThan(maxQueueSize)
-      }
-
-      queue.add(async () => {
-        try {
-          const result = await processCancellation(input, opts.commit)
-
-          csvWriter.write({
-            stripe_customer_id: input.stripe_customer_id,
-            target_stripe_account: input.target_stripe_account,
-            subscription_id: result.subscriptionId || '',
-            status: result.status,
-            note:
-              result.note ||
-              (opts.commit ? '' : 'dry run - no changes applied'),
-          })
-
-          if (result.status === 'cancelled' || result.status === 'validated') {
-            successCount++
-          } else {
-            errorCount++
-          }
-        } catch (err) {
-          errorCount++
-          if (err instanceof ReportError) {
-            csvWriter.write({
-              stripe_customer_id: input.stripe_customer_id,
-              target_stripe_account: input.target_stripe_account,
-              subscription_id: '',
-              status: err.status,
-              note: err.message,
-            })
-          } else {
-            csvWriter.write({
-              stripe_customer_id: input.stripe_customer_id,
-              target_stripe_account: input.target_stripe_account,
-              subscription_id: '',
-              status: 'error',
-              note: err.message,
-            })
-            await trackProgress(
-              `Error processing ${input.stripe_customer_id}: ${err.message}`
-            )
-          }
-        }
-
-        processedCount++
-        if (processedCount % 10 === 0) {
-          await trackProgress(
-            `Processed ${processedCount} customers (${successCount} ${opts.commit ? 'cancelled' : 'validated'}, ${errorCount} errors)`
-          )
-        }
-      })
-    }
-  } finally {
-    await queue.onIdle()
-  }
-
-  await trackProgress(`✅ Total processed: ${processedCount}`)
-  if (opts.commit) {
-    await trackProgress(`✅ Successfully cancelled: ${successCount}`)
-  } else {
-    await trackProgress(`✅ Successfully validated: ${successCount}`)
-    await trackProgress('ℹ️  DRY RUN: No changes were applied')
-  }
-  await trackProgress(`❌ Errors: ${errorCount}`)
-  await trackProgress('🎉 Script completed!')
-
-  csvWriter.end()
-}
-
-function parseArgs() {
-  const args = minimist(process.argv.slice(2), {
-    string: [
-      'output',
-      'concurrency',
-      'stripe-rate-limit',
-      'stripe-api-retries',
-      'stripe-retry-delay-ms',
-    ],
-    boolean: ['commit', 'help'],
-    default: {
-      commit: false,
-      concurrency: DEFAULT_CONCURRENCY,
-      'stripe-rate-limit': DEFAULT_STRIPE_RATE_LIMIT,
-      'stripe-api-retries': DEFAULT_STRIPE_API_RETRIES,
-      'stripe-retry-delay-ms': DEFAULT_STRIPE_RETRY_DELAY_MS,
-    },
-    unknown: arg => {
-      if (arg.startsWith('-')) {
-        console.error(`Unknown option: ${arg}`)
-        usage()
-        process.exit(1)
-      }
-      return true
-    },
-  })
-
-  if (args.help) {
-    usage()
-    process.exit(0)
-  }
-
-  const inputFile = args._[0]
-  const paramsSchema = z.object({
-    output: z.string().optional(),
-    commit: z.boolean(),
-    concurrency: z.number().int().positive(),
-    stripeRateLimit: z.number().positive(),
-    stripeApiRetries: z.number().int().nonnegative(),
-    stripeRetryDelayMs: z.number().int().nonnegative(),
-    inputFile: z.string().optional(),
-  })
-
-  try {
-    return paramsSchema.parse({
-      output: args.output,
-      commit: args.commit,
-      concurrency: Number(args.concurrency),
-      stripeRateLimit: Number(args['stripe-rate-limit']),
-      stripeApiRetries: Number(args['stripe-api-retries']),
-      stripeRetryDelayMs: Number(args['stripe-retry-delay-ms']),
-      inputFile,
-    })
-  } catch (err) {
-    console.error('Invalid arguments:', err.message)
-    usage()
-    process.exit(1)
-  }
-}
-
-function getCsvReader(inputStream) {
-  const parser = csv.parse({ columns: true })
-  inputStream.pipe(parser)
-  return parser
-}
-
-function getCsvWriter(outputFile) {
-  if (outputFile === '-') {
-    const writer = csv.stringify({
-      columns: [
-        'stripe_customer_id',
-        'target_stripe_account',
-        'subscription_id',
-        'status',
-        'note',
-      ],
-      header: true,
-    })
-    writer.on('error', err => {
-      console.error(err)
-      process.exit(1)
-    })
-    writer.pipe(process.stdout)
-    return writer
-  }
-
-  fs.mkdirSync(path.dirname(outputFile), { recursive: true })
-  const outputStream = fs.createWriteStream(outputFile)
-
-  const writer = csv.stringify({
-    columns: [
-      'stripe_customer_id',
-      'target_stripe_account',
-      'subscription_id',
-      'status',
-      'note',
-    ],
-    header: true,
-  })
-
-  writer.on('error', err => {
-    console.error(err)
-    process.exit(1)
-  })
-
-  writer.pipe(outputStream)
-  return writer
-}
-
-async function processCancellation(input, commit) {
-  const {
-    stripe_customer_id: customerId,
-    target_stripe_account: targetStripeAccount,
-  } = input
-
-  // get Stripe client for the target account (strip 'stripe-' prefix if present)
-  const region = targetStripeAccount.replace(/^stripe-/, '')
-  const stripeClient = getRegionClient(region)
-
-  // fetch customer with subscriptions
-  let customer
-  try {
-    customer = await rateLimiters.requestWithRetries(
-      stripeClient.serviceName,
-      () => stripeClient.getCustomerById(customerId, ['subscriptions']),
-      {
-        operation: 'getCustomerById',
-        customerId,
-        region: stripeClient.serviceName,
-      }
-    )
-  } catch (err) {
-    throw new ReportError(
-      'customer-not-found',
-      `Customer not found: ${err.message}`
-    )
-  }
-
-  // check for active subscriptions
-  if (!customer.subscriptions || customer.subscriptions.data.length === 0) {
-    throw new ReportError('no-subscriptions', 'Customer has no subscriptions')
-  }
-
-  // find the subscription with migration metadata
-  const migrationSubscription = customer.subscriptions.data.find(
-    sub => sub.metadata?.recurly_to_stripe_migration_status === 'in_progress'
-  )
-  if (!migrationSubscription) {
-    throw new ReportError(
-      'no-migration-subscription',
-      'Could not find a subscription with migration metadata to cancel'
-    )
-  }
-
-  // in dry-run mode, just validate
-  if (!commit) {
-    return {
-      status: 'validated',
-      note: 'Subscription can be cancelled',
-      subscriptionId: migrationSubscription.id,
-    }
-  }
-
-  // cancel the subscription immediately
-  try {
-    await rateLimiters.requestWithRetries(
-      stripeClient.serviceName,
-      () => stripeClient.terminateSubscription(migrationSubscription.id),
-      {
-        operation: 'terminateSubscription',
-        subscriptionId: migrationSubscription.id,
-        region: stripeClient.serviceName,
-      }
-    )
-
-    await rateLimiters.requestWithRetries(
-      stripeClient.serviceName,
-      () =>
-        stripeClient.updateSubscriptionMetadata(migrationSubscription.id, {
-          recurly_to_stripe_migration_status: 'cancelled',
-        }),
-      {
-        operation: 'updateSubscriptionMetadata',
-        subscriptionId: migrationSubscription.id,
-        region: stripeClient.serviceName,
-      }
-    )
-
-    return {
-      status: 'cancelled',
-      note: `Cancelled subscription ${migrationSubscription.id}`,
-      subscriptionId: migrationSubscription.id,
-    }
-  } catch (err) {
-    throw new ReportError(
-      'cancellation-failed',
-      `Failed to cancel subscription: ${err.message}`
-    )
-  }
-}
-
-try {
-  await scriptRunner(main)
-  process.exit(0)
-} catch (error) {
-  console.error(error)
-  process.exit(1)
-}

+ 0 - 362
services/web/scripts/stripe/bulk-release-subscription-schedules.mjs

@@ -1,362 +0,0 @@
-#!/usr/bin/env node
-
-/**
- * This script bulk releases active Stripe subscription schedules that were
- * created as part of the billing migration (identified by billing_migration_id
- * in schedule metadata).
- *
- * For each customer in the input CSV, it:
- * 1. Lists all subscription schedules for the customer
- * 2. Finds the active schedule with billing_migration_id metadata
- * 3. Releases that schedule via the Stripe API (with preserve_cancel_date: true)
- *
- * If the schedule has already been released (or is in a non-active state), it
- * is reported as "already-released" in the output.
- *
- * Usage:
- *   node scripts/stripe/bulk-release-subscription-schedules.mjs [OPTS] [INPUT-FILE]
- *
- * Options:
- *   --output PATH                 Output file path (default: /tmp/bulk_release_schedules_output_<timestamp>.csv)
- *   --commit                      Apply changes (without this, runs in dry-run mode)
- *   --concurrency N               Number of customers to process concurrently (default: 10)
- *   --stripe-rate-limit N         Requests per second for Stripe (default: 50)
- *   --stripe-api-retries N        Number of retries on Stripe 429s (default: 5)
- *   --stripe-retry-delay-ms N     Delay between Stripe retries in ms (default: 1000)
- *   --help                        Show help message
- *
- * CSV Input Format:
- *   The CSV must have the following columns:
- *   - stripe_customer_id: Stripe customer id
- *   - target_stripe_account: Either 'stripe-uk' or 'stripe-us'
- *
- * CSV Output Format:
- *   stripe_customer_id,target_stripe_account,schedule_id,status,note
- */
-
-import fs from 'node:fs'
-import path from 'node:path'
-import * as csv from 'csv'
-import minimist from 'minimist'
-import PQueue from 'p-queue'
-import { z } from '../../app/src/infrastructure/Validation.mjs'
-import { scriptRunner } from '../lib/ScriptRunner.mjs'
-import { getRegionClient } from '../../modules/subscriptions/app/src/StripeClient.mjs'
-import { ReportError } from './helpers.mjs'
-import {
-  createRateLimitedApiWrappers,
-  DEFAULT_STRIPE_RATE_LIMIT,
-  DEFAULT_STRIPE_API_RETRIES,
-  DEFAULT_STRIPE_RETRY_DELAY_MS,
-} from './RateLimiter.mjs'
-
-// rate limiters - initialized in main()
-let rateLimiters
-
-function usage() {
-  console.error(`Usage: node scripts/stripe/bulk-release-subscription-schedules.mjs [OPTS] [INPUT-FILE]
-
-Options:
-    --output PATH                 Output file path (default: /tmp/bulk_release_schedules_output_<timestamp>.csv)
-    --commit                      Apply changes (without this, runs in dry-run mode)
-    --concurrency N               Number of customers to process concurrently (default: 10)
-    --stripe-rate-limit N         Requests per second for Stripe (default: ${DEFAULT_STRIPE_RATE_LIMIT})
-    --stripe-api-retries N        Number of retries on Stripe 429s (default: ${DEFAULT_STRIPE_API_RETRIES})
-    --stripe-retry-delay-ms N     Delay between Stripe retries in ms (default: ${DEFAULT_STRIPE_RETRY_DELAY_MS})
-    --help                        Show this help message
-`)
-}
-
-async function main(trackProgress) {
-  const opts = parseArgs()
-  const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
-  const outputFile =
-    opts.output ?? `/tmp/bulk_release_schedules_output_${timestamp}.csv`
-
-  rateLimiters = createRateLimitedApiWrappers({
-    stripeRateLimit: opts.stripeRateLimit,
-    stripeApiRetries: opts.stripeApiRetries,
-    stripeRetryDelayMs: opts.stripeRetryDelayMs,
-  })
-
-  await trackProgress('Starting bulk subscription schedule release for Stripe')
-  await trackProgress(`Run mode: ${opts.commit ? 'COMMIT' : 'DRY RUN'}`)
-  await trackProgress(`Rate limit: Stripe ${opts.stripeRateLimit}/s`)
-  await trackProgress(`Concurrency: ${opts.concurrency}`)
-
-  const inputStream = opts.inputFile
-    ? fs.createReadStream(opts.inputFile)
-    : process.stdin
-  const csvReader = getCsvReader(inputStream)
-  const csvWriter = getCsvWriter(outputFile)
-
-  await trackProgress(`Output: ${outputFile}`)
-
-  let processedCount = 0
-  let successCount = 0
-  let errorCount = 0
-
-  const queue = new PQueue({ concurrency: opts.concurrency })
-  const maxQueueSize = opts.concurrency
-
-  try {
-    for await (const input of csvReader) {
-      if (queue.size >= maxQueueSize) {
-        await queue.onSizeLessThan(maxQueueSize)
-      }
-
-      queue.add(async () => {
-        try {
-          const result = await processScheduleRelease(input, opts.commit)
-
-          csvWriter.write({
-            stripe_customer_id: input.stripe_customer_id,
-            target_stripe_account: input.target_stripe_account,
-            schedule_id: result.scheduleId || '',
-            status: result.status,
-            note: result.note,
-          })
-
-          if (
-            result.status === 'released' ||
-            result.status === 'validated' ||
-            result.status === 'already-released'
-          ) {
-            successCount++
-          } else {
-            errorCount++
-          }
-        } catch (err) {
-          errorCount++
-          csvWriter.write({
-            stripe_customer_id: input.stripe_customer_id,
-            target_stripe_account: input.target_stripe_account,
-            schedule_id: '',
-            status: err instanceof ReportError ? err.status : 'error',
-            note: err.message,
-          })
-        }
-
-        processedCount++
-        if (processedCount % 25 === 0) {
-          await trackProgress(
-            `Progress: ${processedCount} processed, ${successCount} successful, ${errorCount} errors`
-          )
-        }
-      })
-    }
-  } finally {
-    await queue.onIdle()
-  }
-
-  await trackProgress(`✅ Total processed: ${processedCount}`)
-  if (opts.commit) {
-    await trackProgress(`✅ Successfully released: ${successCount}`)
-  } else {
-    await trackProgress(`✅ Successfully validated: ${successCount}`)
-    await trackProgress('ℹ️  DRY RUN: No changes were applied')
-  }
-  await trackProgress(`❌ Errors: ${errorCount}`)
-  await trackProgress('🎉 Script completed!')
-
-  csvWriter.end()
-}
-
-function parseArgs() {
-  const args = minimist(process.argv.slice(2), {
-    string: [
-      'output',
-      'concurrency',
-      'stripe-rate-limit',
-      'stripe-api-retries',
-      'stripe-retry-delay-ms',
-    ],
-    boolean: ['commit', 'help'],
-    default: {
-      commit: false,
-      concurrency: 10,
-      'stripe-rate-limit': DEFAULT_STRIPE_RATE_LIMIT,
-      'stripe-api-retries': DEFAULT_STRIPE_API_RETRIES,
-      'stripe-retry-delay-ms': DEFAULT_STRIPE_RETRY_DELAY_MS,
-    },
-  })
-
-  if (args.help) {
-    usage()
-    process.exit(0)
-  }
-
-  const inputFile = args._[0]
-  const paramsSchema = z.object({
-    output: z.string().optional(),
-    commit: z.boolean(),
-    concurrency: z.number().int().positive(),
-    stripeRateLimit: z.number().positive(),
-    stripeApiRetries: z.number().int().nonnegative(),
-    stripeRetryDelayMs: z.number().int().nonnegative(),
-    inputFile: z.string().optional(),
-  })
-
-  try {
-    return paramsSchema.parse({
-      output: args.output,
-      commit: args.commit,
-      concurrency: Number(args.concurrency),
-      stripeRateLimit: Number(args['stripe-rate-limit']),
-      stripeApiRetries: Number(args['stripe-api-retries']),
-      stripeRetryDelayMs: Number(args['stripe-retry-delay-ms']),
-      inputFile,
-    })
-  } catch (err) {
-    console.error('Invalid arguments:', err.message)
-    usage()
-    process.exit(1)
-  }
-}
-
-function getCsvReader(inputStream) {
-  const parser = csv.parse({ columns: true })
-  inputStream.pipe(parser)
-  return parser
-}
-
-function getCsvWriter(outputFile) {
-  fs.mkdirSync(path.dirname(outputFile), { recursive: true })
-  const outputStream = fs.createWriteStream(outputFile)
-
-  const writer = csv.stringify({
-    columns: [
-      'stripe_customer_id',
-      'target_stripe_account',
-      'schedule_id',
-      'status',
-      'note',
-    ],
-    header: true,
-  })
-
-  writer.on('error', err => {
-    console.error(err)
-    process.exit(1)
-  })
-
-  writer.pipe(outputStream)
-  return writer
-}
-
-async function processScheduleRelease(input, commit) {
-  const {
-    stripe_customer_id: customerId,
-    target_stripe_account: targetStripeAccount,
-  } = input
-
-  const region = targetStripeAccount.replace(/^stripe-/, '')
-  const stripeClient = getRegionClient(region)
-
-  let schedules
-  try {
-    schedules = await rateLimiters.requestWithRetries(
-      stripeClient.serviceName,
-      () =>
-        stripeClient.stripe.subscriptionSchedules.list({
-          customer: customerId,
-          limit: 100,
-        }),
-      {
-        operation: 'subscriptionSchedules.list',
-        customerId,
-        region: stripeClient.serviceName,
-      }
-    )
-  } catch (err) {
-    throw new ReportError(
-      'list-schedules-failed',
-      `Failed to list subscription schedules: ${err.message}`
-    )
-  }
-
-  const migrationSchedules = schedules.data.filter(
-    schedule => schedule.metadata?.billing_migration_id != null
-  )
-
-  if (migrationSchedules.length === 0) {
-    throw new ReportError(
-      'no-migration-schedule',
-      `No subscription schedule with billing_migration_id metadata found for customer ${customerId}`
-    )
-  }
-
-  const activeSchedules = migrationSchedules.filter(
-    schedule => schedule.status === 'active'
-  )
-
-  if (activeSchedules.length === 0) {
-    const statuses = migrationSchedules
-      .map(s => `${s.id}(${s.status})`)
-      .join(', ')
-    return {
-      status: 'already-released',
-      note: `No active migration schedules to release (found: ${statuses})`,
-      scheduleId: '',
-    }
-  }
-
-  if (activeSchedules.length > 1) {
-    const scheduleIds = activeSchedules.map(s => s.id).join(', ')
-    throw new ReportError(
-      'multiple-active-schedules',
-      `Found ${activeSchedules.length} active migration schedules (${scheduleIds}), expected at most 1`
-    )
-  }
-
-  const targetSchedule = activeSchedules[0]
-
-  if (targetSchedule.phases.length !== 1) {
-    throw new ReportError(
-      'multiple-phases',
-      `Schedule ${targetSchedule.id} has multiple phases, expected exactly 1`
-    )
-  }
-
-  if (!commit) {
-    return {
-      status: 'validated',
-      note: `Schedule ${targetSchedule.id} can be released`,
-      scheduleId: targetSchedule.id,
-    }
-  }
-
-  try {
-    await rateLimiters.requestWithRetries(
-      stripeClient.serviceName,
-      () =>
-        stripeClient.stripe.subscriptionSchedules.release(targetSchedule.id, {
-          preserve_cancel_date: true,
-        }),
-      {
-        operation: 'subscriptionSchedules.release',
-        scheduleId: targetSchedule.id,
-        region: stripeClient.serviceName,
-      }
-    )
-
-    return {
-      status: 'released',
-      note: `Released schedule ${targetSchedule.id}`,
-      scheduleId: targetSchedule.id,
-    }
-  } catch (err) {
-    throw new ReportError(
-      'release-schedule-failed',
-      `Failed to release schedule ${targetSchedule.id}: ${err.message}`
-    )
-  }
-}
-
-try {
-  await scriptRunner(main)
-  process.exit(0)
-} catch (error) {
-  console.error(error)
-  process.exit(1)
-}

+ 0 - 553
services/web/scripts/stripe/calculate_taxes.mjs

@@ -1,553 +0,0 @@
-#!/usr/bin/env node
-
-/**
- * Calculate Stripe taxes for addresses in a CSV
- *
- * This script reads a CSV file and calls the Stripe tax calculation API for each
- * address. By default, only US/CA addresses are processed.
- *
- * ⚠️ This script calls an API endpoint that incurs a charge per call. Please be conscientious about using this!
- *
- * Usage:
- *   node scripts/stripe/calculate_taxes.mjs [OPTIONS] INPUT-FILE
- *
- * Options:
- *   --output PATH              Output file path (default: /tmp/tax_output_<timestamp>.csv)
- *                              Use '-' to write to stdout
- *   --concurrency, -c N        Number of rows to process concurrently (default: 10)
- *   --countries CODES           Comma-separated country codes to process (default: no filter)
- *   --rate-limit N              Requests per second for Stripe (default: 50)
- *   --api-retries N             Number of retries on Stripe 429s (default: 5)
- *   --retry-delay-ms N          Delay between Stripe retries in ms (default: 1000)
- *   --help                      Show a help message
- *
- * CSV Input Format:
- *   The CSV must contain columns: user_id, billing_country, billing_postal_code, billing_address1, billing_address2, billing_city, billing_state
- *   Optional columns: plan_code
- *
- * Output:
- *   Writes a CSV with:
- *   - user_id
- *   - status (success, skipped_unsupported_country, skipped_missing_postal_code, invalid_address, api_error)
- *   - stripe_tax_breakdown_amount
- *   - stripe_tax_breakdown_taxability_reason
- *   - stripe_tax_breakdown_taxable_amount
- *
- * Examples:
- *   node scripts/stripe/calculate_taxes.mjs cohort.csv
- *   node scripts/stripe/calculate_taxes.mjs --output results.csv cohort.csv
- *   node scripts/stripe/calculate_taxes.mjs --countries US,CA,GB cohort.csv
- */
-
-import fs from 'node:fs'
-import path from 'node:path'
-import * as csv from 'csv'
-import minimist from 'minimist'
-import PQueue from 'p-queue'
-import { z } from '../../app/src/infrastructure/Validation.mjs'
-import { scriptRunner } from '../lib/ScriptRunner.mjs'
-import { getRegionClient } from '../../modules/subscriptions/app/src/StripeClient.mjs'
-import {
-  createRateLimitedApiWrappers,
-  DEFAULT_STRIPE_RATE_LIMIT,
-  DEFAULT_STRIPE_API_RETRIES,
-  DEFAULT_STRIPE_RETRY_DELAY_MS,
-} from './RateLimiter.mjs'
-
-/**
- * @import { ReadStream } from 'node:fs'
- * @import { Parser } from 'csv-parse'
- * @import { Stringifier } from 'csv-stringify'
- */
-
-const DEFAULT_CONCURRENCY = 10
-
-const preloadedProductMetadata = new Map()
-
-const AMOUNTS = {
-  collaborator: 2100,
-  'collaborator-annual': 19900,
-  collaborator_free_trial_7_days: 2100,
-
-  professional: 4200,
-  'professional-annual': 39900,
-  professional_free_trial_7_days: 4200,
-
-  student: 1000,
-  'student-annual': 9800,
-  student_free_trial_7_days: 1000,
-}
-
-/**
- * Print usage information to stderr
- */
-function usage() {
-  console.error(`Usage: node scripts/stripe/calculate_taxes.mjs [OPTIONS] INPUT-FILE
-
-Calculate Stripe taxes for addresses.
-
-⚠️ This script calls an API endpoint that incurs a charge per call. Please be conscientious about using this!
-
-Options:
-    --output PATH              Output file path (default: /tmp/tax_output_<timestamp>.csv)
-                               Use '-' to write to stdout
-    --concurrency N            Number of rows to process concurrently (default: ${DEFAULT_CONCURRENCY})
-    --countries CODES           Comma-separated country codes to process (default: no filter)
-    --rate-limit N              Requests per second for Stripe (default: ${DEFAULT_STRIPE_RATE_LIMIT})
-    --api-retries N             Number of retries on Stripe 429s (default: ${DEFAULT_STRIPE_API_RETRIES})
-    --retry-delay-ms N          Delay between Stripe retries in ms (default: ${DEFAULT_STRIPE_RETRY_DELAY_MS})
-    --help                      Show this help message
-
-Output Fields:
-  - user_id
-  - status (success, skipped_unsupported_country, skipped_missing_postal_code, invalid_address, api_error)
-  - stripe_tax_breakdown_amount
-  - stripe_tax_breakdown_taxability_reason
-  - stripe_tax_breakdown_taxable_amount
-
-See the source file header for detailed documentation.
-`)
-}
-
-// rate limiters - initialized in main()
-let rateLimiters
-
-/**
- * Main script entry point
- * @param {function(string): Promise<void>} 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/tax_output_${timestamp}.csv`
-
-  // initialize rate limiters
-  rateLimiters = createRateLimitedApiWrappers({
-    rateLimit: opts.rateLimit,
-    apiRetries: opts.apiRetries,
-    retryDelayMs: opts.retryDelayMs,
-  })
-
-  await trackProgress('Starting Stripe tax calculation')
-  await trackProgress(
-    '⚠️ This script calls an API endpoint that incurs a charge per call. Please be conscientious about using this!'
-  )
-  await trackProgress(
-    `Run mode: concurrency=${opts.concurrency}, Stripe rate limit=${opts.rateLimit}/s`
-  )
-  if (opts.countries.size > 0) {
-    await trackProgress(
-      `Country filter applied: Only processing addresses from ${Array.from(opts.countries).join(', ')}`
-    )
-  } else {
-    await trackProgress(
-      'No country filter applied: Processing addresses from all countries'
-    )
-  }
-
-  await trackProgress('Populating product metadata...')
-  await preloadProductMetadata('uk')
-  await preloadProductMetadata('us')
-  await trackProgress('Product metadata populated')
-
-  const inputStream = fs.createReadStream(opts.inputFile)
-  const csvReader = getCsvReader(inputStream)
-
-  await trackProgress(`Output: ${outputFile === '-' ? 'stdout' : outputFile}`)
-
-  let csvWriter = null
-  let processedCount = 0
-  let apiCalls = 0
-
-  const queue = new PQueue({ concurrency: opts.concurrency })
-  const maxQueueSize = opts.concurrency
-
-  try {
-    for await (const record of csvReader) {
-      // Initialize writer on first record when we know the input columns
-      if (!csvWriter) {
-        csvWriter = getCsvWriter(outputFile)
-      }
-
-      // throttle input if queue is full
-      if (queue.size >= maxQueueSize) {
-        await queue.onSizeLessThan(maxQueueSize)
-      }
-
-      queue.add(async () => {
-        processedCount++
-
-        try {
-          let taxResult
-
-          const addressResult = buildCustomerAddress(record, opts.countries)
-          if (addressResult.status !== 'ok') {
-            taxResult = {
-              status: addressResult.status,
-              ...emptyTaxFields(),
-            }
-          } else {
-            // Call Stripe API
-            taxResult = await calculateTax(
-              addressResult.address,
-              record.plan_code
-            )
-            apiCalls++
-          }
-
-          csvWriter.write({
-            user_id: record.user_id ?? '',
-            ...taxResult,
-          })
-
-          if (processedCount % 10 === 0) {
-            await trackProgress(
-              `Processed ${processedCount} rows (${apiCalls} API calls)`
-            )
-          }
-        } catch (err) {
-          console.log(err)
-          await trackProgress(
-            `Error processing row ${processedCount}: ${err.message}`
-          )
-          csvWriter.write({
-            user_id: record.user_id ?? '',
-            status: 'api_error',
-            ...emptyTaxFields(),
-          })
-        }
-      })
-    }
-  } finally {
-    // wait for all queued tasks to complete
-    await queue.onIdle()
-  }
-
-  await trackProgress(
-    `🎉 Script completed! Total: ${processedCount} rows, ${apiCalls} API calls`
-  )
-
-  csvWriter.end()
-}
-
-/**
- * Build a Stripe customer address from a CSV record
- * @param {Record<string, unknown>} record
- * @param {Set<string>} allowedCountries - Set of country codes to process
- * @returns {{status: 'ok', address: object} | {status: 'skipped_unsupported_country'} | {status: 'skipped_missing_postal_code'}}
- */
-function buildCustomerAddress(record, allowedCountries) {
-  const country = record.billing_country.trim().toUpperCase()
-
-  if (allowedCountries.size > 0 && !allowedCountries.has(country)) {
-    return { status: 'skipped_unsupported_country' }
-  }
-
-  const address = {
-    country,
-  }
-
-  if (['US', 'CA'].includes(country)) {
-    const postalCode = normalizePostalCode(country, record.billing_postal_code)
-    if (postalCode) {
-      address.postal_code = postalCode
-    } else {
-      return { status: 'skipped_missing_postal_code' }
-    }
-  }
-
-  const maybeSet = (key, value) => {
-    if (value === undefined || value === null) return
-    const str = value.toString().trim()
-    if (!str) return
-    address[key] = str
-  }
-
-  maybeSet('postal_code', record.billing_postal_code)
-  maybeSet('line1', record.billing_address1)
-  maybeSet('line2', record.billing_address2)
-  maybeSet('city', record.billing_city)
-  maybeSet('state', record.billing_state)
-
-  return { status: 'ok', address }
-}
-
-/**
- * Normalize a postal code to a string suitable for Stripe.
- * - US: Pads 4-digit ZIPs to 5 digits.
- * - CA: Uppercases and formats as "A1A 1A1" when possible.
- * @param {'US' | 'CA'} country
- * @param {unknown} value
- * @returns {string}
- */
-function normalizePostalCode(country, value) {
-  if (!value) return ''
-
-  const trimmed = value.toString().trim()
-  if (!trimmed) return ''
-
-  if (country === 'US') {
-    if (trimmed.length === 4) return `0${trimmed}`
-    return trimmed
-  }
-
-  const compact = trimmed.replace(/[^0-9a-z]/gi, '').toUpperCase()
-  if (!compact) return ''
-  if (compact.length === 6) {
-    return `${compact.slice(0, 3)} ${compact.slice(3)}`
-  }
-  return compact
-}
-
-/**
- * Preload product metadata for a given region to avoid redundant API calls during tax calculations
- * @param {'us' | 'uk'} region
- * @returns {Promise<void>}
- */
-async function preloadProductMetadata(region) {
-  if (preloadedProductMetadata.has(region)) return
-
-  const stripeClient = getRegionClient(region)
-  const products = await rateLimiters.requestWithRetries(
-    stripeClient.serviceName,
-    () =>
-      stripeClient.stripe.products.list({
-        active: true,
-        limit: 100,
-      }),
-    { operation: 'products.list', region: stripeClient.serviceName }
-  )
-
-  const results = new Map()
-  for (const product of products.data) {
-    if (!product.metadata?.plan_code) continue
-    results.set(product.metadata?.plan_code, product.id)
-  }
-
-  preloadedProductMetadata.set(region, results)
-}
-
-/**
- * Get a CSV parser configured for 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,
-  })
-  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 taxFields = [
-    'user_id',
-    'status',
-    'stripe_tax_breakdown_taxability_reason',
-    'stripe_tax_breakdown_amount',
-    'stripe_tax_breakdown_taxable_amount',
-  ]
-
-  const writer = csv.stringify({
-    columns: taxFields,
-    header: true,
-  })
-  writer.on('error', err => {
-    console.error(err)
-    process.exit(1)
-  })
-  writer.pipe(outputStream)
-  return writer
-}
-
-/**
- * Calculate tax for an address using Stripe API
- * @param {{country: string, postal_code: string, line1?: string, line2?: string, city?: string, state?: string}} customerAddress
- * @param {string} planCode
- * @returns {Promise<Object>}
- */
-async function calculateTax(customerAddress, planCode) {
-  const region = ['US', 'CA'].includes(customerAddress.country) ? 'us' : 'uk'
-  const stripeClient = getRegionClient(region)
-
-  const productId = preloadedProductMetadata.get(region)?.get(planCode)
-  const amount = AMOUNTS[planCode] || AMOUNTS.collaborator
-
-  let calculation
-  try {
-    calculation = await rateLimiters.requestWithRetries(
-      stripeClient.serviceName,
-      () =>
-        stripeClient.stripe.tax.calculations.create({
-          currency: 'USD',
-          line_items: [
-            {
-              amount,
-              product: productId,
-              quantity: 1,
-              reference: 'tax_calculation',
-            },
-          ],
-          customer_details: {
-            address: {
-              ...customerAddress,
-            },
-            address_source: 'billing',
-          },
-          expand: ['line_items'],
-        }),
-      { operation: 'tax.calculations.create', region: stripeClient.serviceName }
-    )
-  } catch (err) {
-    if (err.code === 'customer_tax_location_invalid') {
-      return {
-        status: 'invalid_address',
-        ...emptyTaxFields(),
-      }
-    }
-    throw err
-  }
-
-  // extract and aggregate taxes applied
-  const result = (calculation.tax_breakdown || []).reduce(
-    (acc, taxBreakdown) => {
-      if (taxBreakdown?.amount > 0) {
-        acc.stripe_tax_breakdown_amount += taxBreakdown?.amount ?? 0
-
-        const taxabilityReason = taxBreakdown?.taxability_reason ?? ''
-        if (taxabilityReason) {
-          acc.stripe_tax_breakdown_taxability_reason +=
-            acc.stripe_tax_breakdown_taxability_reason
-              ? `;${taxabilityReason}`
-              : taxabilityReason
-        }
-
-        acc.stripe_tax_breakdown_taxable_amount +=
-          taxBreakdown?.taxable_amount ?? 0
-      }
-      return acc
-    },
-    emptyTaxFields()
-  )
-
-  return {
-    status: 'success',
-    ...result,
-  }
-}
-
-/**
- * Return an object with all tax fields set to empty strings
- * @returns {Object} Object with empty tax fields
- */
-function emptyTaxFields() {
-  return {
-    stripe_tax_breakdown_amount: 0,
-    stripe_tax_breakdown_taxability_reason: '',
-    stripe_tax_breakdown_taxable_amount: 0,
-  }
-}
-
-/**
- * Parse command line arguments
- * @returns {{inputFile: string, output: string | undefined, concurrency: number, countries: Set<string>, rateLimit: number, apiRetries: number, retryDelayMs: number}} Parsed options
- */
-function parseArgs() {
-  const args = minimist(process.argv.slice(2), {
-    string: [
-      'output',
-      'concurrency',
-      'countries',
-      'rate-limit',
-      'api-retries',
-      'retry-delay-ms',
-    ],
-    boolean: ['help'],
-    alias: { c: 'concurrency' },
-    default: {
-      concurrency: DEFAULT_CONCURRENCY,
-      'rate-limit': DEFAULT_STRIPE_RATE_LIMIT,
-      'api-retries': DEFAULT_STRIPE_API_RETRIES,
-      'retry-delay-ms': DEFAULT_STRIPE_RETRY_DELAY_MS,
-    },
-  })
-
-  if (args.help) {
-    usage()
-    process.exit(0)
-  }
-
-  const inputFile = args._[0]
-  if (!inputFile) {
-    console.error('Input file is required')
-    usage()
-    process.exit(1)
-  }
-
-  const paramsSchema = z.object({
-    output: z.string().optional(),
-    concurrency: z.number().int().positive(),
-    countries: z.string().optional(),
-    rateLimit: z.number().positive(),
-    apiRetries: z.number().int().nonnegative(),
-    retryDelayMs: z.number().int().nonnegative(),
-    inputFile: z.string(),
-  })
-
-  let parsed
-  try {
-    parsed = paramsSchema.parse({
-      output: args.output,
-      concurrency: Number(args.concurrency),
-      countries: args.countries,
-      rateLimit: Number(args['rate-limit']),
-      apiRetries: Number(args['api-retries']),
-      retryDelayMs: Number(args['retry-delay-ms']),
-      inputFile,
-    })
-  } catch (err) {
-    console.error(`Invalid parameters: ${err.message}`)
-    usage()
-    process.exit(1)
-  }
-
-  // Parse countries into a Set, defaulting to no filter
-  const countrySet = new Set(
-    (parsed.countries || '')
-      .split(',')
-      .map(c => c.trim().toUpperCase())
-      .filter(c => c.length > 0)
-  )
-
-  return {
-    inputFile: parsed.inputFile,
-    output: parsed.output,
-    concurrency: parsed.concurrency,
-    countries: countrySet,
-    rateLimit: parsed.rateLimit,
-    apiRetries: parsed.apiRetries,
-    retryDelayMs: parsed.retryDelayMs,
-  }
-}
-
-try {
-  await scriptRunner(main)
-  process.exit(0)
-} catch (error) {
-  console.error(error)
-  process.exit(1)
-}

+ 0 - 746
services/web/scripts/stripe/change_existing_subscription_prices.mjs

@@ -1,746 +0,0 @@
-#!/usr/bin/env node
-
-/**
- * This script changes prices for existing Stripe subscriptions.
- * It schedules changes to apply at the next renewal.
- *
- * Usage:
- *   node scripts/stripe/change_existing_subscription_prices.mjs [OPTS] [INPUT-FILE]
- *
- * Options:
- *   --region REGION        Either 'uk' or 'us' (required)
- *   --timeframe TIMEFRAME  Either 'renewal' or 'now' (default: renewal)
- *   --output PATH          Output file path (default: /tmp/change_prices_output_<timestamp>.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: 100)
- *   --force                Overwrite any existing pending changes
- *   --help                 Show a help message
- *
- * CSV Input Format:
- *   The CSV must have the following columns:
- *   - subscription_id: Stripe subscription id
- *   - current_lookup_key: Current price lookup key
- *   - new_lookup_key: New price lookup key
- *   - current_add_on_lookup_key: Current price lookup key for add-on (optional)
- *   - new_add_on_lookup_key: New price lookup key for add-on (optional)
- *
- * Output:
- *   Writes a CSV with columns:
- *   - subscription_id: The subscription id 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 <pod-name> -- tail -f /tmp/change_prices_output_<timestamp>.csv > local_backup.csv
- *
- *   2. Periodically copy the output file to your laptop:
- *      kubectl cp <pod-name>:/tmp/change_prices_output_<timestamp>.csv ./backup.csv
- *
- *   3. Write to stdout and capture locally:
- *      kubectl exec -it <pod-name> -- node scripts/stripe/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 AnalyticsManager from '../../app/src/Features/Analytics/AnalyticsManager.mjs'
-import { z } from '../../app/src/infrastructure/Validation.mjs'
-import { scriptRunner } from '../lib/ScriptRunner.mjs'
-import { getRegionClient } from '../../modules/subscriptions/app/src/StripeClient.mjs'
-import {
-  ReportError,
-  getProductIdFromPrice,
-  getProductIdFromItem,
-  getPriceIdFromItem,
-} from './helpers.mjs'
-
-/**
- * @import { CSVSubscriptionChange, Timeframe, StripeClient } from './helpers.mjs'
- * @import Stripe from 'stripe'
- * @import { ReadStream } from 'node:fs'
- * @import { Parser } from 'csv-parse'
- * @import { Stringifier } from 'csv-stringify'
- */
-
-// 100 ms corresponds to 10 requests per second (cautious rate within Stripe's 100 req/s limit)
-const DEFAULT_THROTTLE = 100
-
-/**
- * cache for price objects to avoid redundant Stripe API calls
- * @type {Map<string, Stripe.Price>}
- */
-const priceCache = new Map()
-
-/**
- * Print usage information to stderr
- */
-function usage() {
-  console.error(`Usage: node scripts/stripe/change_existing_subscription_prices.mjs [OPTS] [INPUT-FILE]
-
-Options:
-    --region REGION        Either 'uk' or 'us' (required)
-    --timeframe TIMEFRAME  Either 'renewal' or 'now' (default: renewal)
-    --output PATH          Output file path (default: /tmp/change_prices_output_<timestamp>.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<void>} 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`
-
-  const stripeClient = getRegionClient(opts.region)
-
-  await trackProgress('Starting price change script for Stripe')
-  await trackProgress(`Region: ${opts.region}`)
-  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,
-        stripeClient,
-        opts.commit,
-        opts.force,
-        opts.timeframe
-      )
-
-      if (opts.commit && subscription) {
-        try {
-          const userId = subscription.customer.metadata?.userId
-          await AnalyticsManager.recordEventForUser(
-            userId,
-            'script_price_change',
-            {
-              subscriptionId: change.subscription_id,
-            }
-          )
-        } catch (err) {
-          await trackProgress(
-            `Warning: failed to record analytics event after successful price change for ${change.subscription_id}: ${err.message}`
-          )
-        }
-      }
-
-      csvWriter.write({
-        subscription_id: change.subscription_id,
-        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_id: change.subscription_id,
-          status: err.status,
-          note: err.message,
-        })
-      } else {
-        csvWriter.write({
-          subscription_id: change.subscription_id,
-          status: 'error',
-          note: err.message,
-        })
-        await trackProgress(
-          `Error processing ${change.subscription_id}: ${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 Stripe')
-  }
-  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,
-  })
-  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_id', '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 {StripeClient} stripeClient - The Stripe client for the region
- * @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<Stripe.Subscription | undefined>} The subscription if commit mode, undefined otherwise
- */
-async function processChange(change, stripeClient, commit, force, timeframe) {
-  const subscription = await fetchSubscription(
-    change.subscription_id,
-    stripeClient
-  )
-
-  const nextPrices = await fetchPrices(
-    [change.new_lookup_key, change.new_add_on_lookup_key].filter(Boolean),
-    stripeClient
-  )
-
-  validateChange(change, subscription, nextPrices, force)
-
-  if (!commit) {
-    // dry run mode - validation passed, no changes applied
-    return
-  }
-
-  let updatedSubscription
-  if (timeframe === 'now') {
-    updatedSubscription = await updateSubscriptionImmediately(
-      subscription,
-      stripeClient,
-      nextPrices
-    )
-  } else {
-    updatedSubscription = await createSubscriptionSchedule(
-      subscription,
-      stripeClient,
-      nextPrices
-    )
-  }
-
-  return updatedSubscription
-}
-
-/**
- * Fetch a subscription from Stripe
- * @param {string} subscriptionId - The Stripe subscription id
- * @param {StripeClient} stripeClient - The Stripe client for the region
- * @returns {Promise<Stripe.Subscription>} The subscription with expanded items and schedule
- * @throws {ReportError} If subscription is not found
- */
-async function fetchSubscription(subscriptionId, stripeClient) {
-  try {
-    const subscription = await stripeClient.stripe.subscriptions.retrieve(
-      subscriptionId,
-      {
-        expand: ['schedule', 'discounts'],
-      }
-    )
-    return subscription
-  } catch (err) {
-    if (err.type === 'StripeInvalidRequestError' && err.statusCode === 404) {
-      throw new ReportError('not-found', 'subscription not found')
-    }
-    throw err
-  }
-}
-
-/**
- * Fetch price entities from Stripe with caching
- * @param {string[]} lookupKeys
- * @param {StripeClient} stripeClient - The Stripe client for the region
- * @returns {Promise<Stripe.Price[]>} the fetched prices
- * @throws {ReportError} If price is not found
- */
-async function fetchPrices(lookupKeys, stripeClient) {
-  if (lookupKeys.length === 0) {
-    throw new ReportError('not-found', 'no lookup keys provided')
-  }
-
-  const cachedPrices = []
-  const keysToFetch = []
-
-  for (const key of lookupKeys) {
-    if (priceCache.has(key)) {
-      cachedPrices.push(priceCache.get(key))
-    } else {
-      keysToFetch.push(key)
-    }
-  }
-
-  if (keysToFetch.length === 0) {
-    return cachedPrices
-  }
-
-  try {
-    const results = await stripeClient.stripe.prices.list({
-      lookup_keys: keysToFetch,
-      limit: keysToFetch.length,
-    })
-
-    if (results.data.length === 0) {
-      throw new ReportError(
-        'not-found',
-        `${keysToFetch.join(', ')} prices not found in Stripe`
-      )
-    }
-
-    for (const price of results.data) {
-      priceCache.set(price.lookup_key, price)
-    }
-
-    return [...cachedPrices, ...results.data]
-  } catch (err) {
-    if (err.type === 'StripeInvalidRequestError' && err.statusCode === 404) {
-      throw new ReportError(
-        'not-found',
-        `${keysToFetch.join(', ')} prices not found in Stripe`
-      )
-    }
-    throw err
-  }
-}
-
-/**
- * Helper function to validate that the proposed price looks correct
- * @param {Stripe.Price} currentPrice - The current price
- * @param {Stripe.Price[]} nextPrices - Available next prices
- * @throws {ReportError} If validation fails
- */
-function validateProposedPrice(currentPrice, nextPrices) {
-  const currentProductId = getProductIdFromPrice(currentPrice)
-
-  if (!currentProductId) {
-    throw new ReportError('mismatch', 'current price has no associated product')
-  }
-
-  // Find matching new price by product ID
-  const matchingNewPrice = nextPrices.find(p => {
-    const nextProductId = getProductIdFromPrice(p)
-    return nextProductId === currentProductId
-  })
-
-  // Verify new price with matching product exists
-  if (!matchingNewPrice) {
-    throw new ReportError(
-      'mismatch',
-      `no new prices found belonging to the same product: current ${currentProductId}`
-    )
-  }
-
-  // Verify currency matches
-  if (currentPrice.currency !== matchingNewPrice.currency) {
-    throw new ReportError(
-      'mismatch',
-      `currency mismatch found: current ${currentPrice.currency}, new ${matchingNewPrice.currency}`
-    )
-  }
-}
-
-/**
- * Validate that the subscription matches the expected state
- * @param {CSVSubscriptionChange} change - The subscription change to validate
- * @param {Stripe.Subscription} subscription - The Stripe subscription
- * @param {Stripe.Price[]} nextPrices - The next prices for the subscription
- * @param {boolean} force - Whether to ignore existing pending changes
- * @throws {ReportError} If validation fails
- */
-function validateChange(change, subscription, nextPrices, force) {
-  // Check subscription is updatable
-  const inactiveStatuses = [
-    'incomplete',
-    'incomplete_expired',
-    'canceled',
-    'trialing',
-  ]
-  if (inactiveStatuses.includes(subscription.status)) {
-    throw new ReportError(
-      'inactive',
-      `subscription status: ${subscription.status}`
-    )
-  }
-
-  // Skip subscriptions that are scheduled to be canceled
-  if (subscription.cancel_at_period_end) {
-    throw new ReportError(
-      'inactive',
-      'subscription is scheduled to be canceled at period end'
-    )
-  }
-
-  // Skip subscriptions that already have a schedule (unless force mode)
-  if (subscription.schedule && !force) {
-    if (typeof subscription.schedule === 'string') {
-      throw new ReportError(
-        'pending-change',
-        `subscription has a schedule (${subscription.schedule}) - re-run with expanded schedule data`
-      )
-    }
-    if (subscription.schedule.status !== 'released') {
-      throw new ReportError(
-        'pending-change',
-        'subscription already has an active schedule'
-      )
-    }
-  }
-
-  // Verify all requested next prices were found
-  const requestedLookupKeys = [
-    change.new_lookup_key,
-    change.new_add_on_lookup_key,
-  ].filter(Boolean)
-
-  for (const requestedKey of requestedLookupKeys) {
-    if (!nextPrices.find(p => p.lookup_key === requestedKey)) {
-      throw new ReportError(
-        'not-found',
-        `requested price with lookup key ${requestedKey} not found in Stripe`
-      )
-    }
-  }
-
-  // Verify current lookup keys exist in subscription
-  const currentLookupKey = change.current_lookup_key
-  const currentItem = subscription.items.data.find(
-    item => item.price.lookup_key === currentLookupKey
-  )
-  if (!currentItem) {
-    throw new ReportError(
-      'mismatch',
-      `current_lookup_key ${currentLookupKey} not found in subscription items`
-    )
-  }
-
-  if (change.current_add_on_lookup_key) {
-    const currentAddOnItem = subscription.items.data.find(
-      item => item.price.lookup_key === change.current_add_on_lookup_key
-    )
-    if (!currentAddOnItem) {
-      throw new ReportError(
-        'mismatch',
-        `current_add_on_lookup_key ${change.current_add_on_lookup_key} not found in subscription items`
-      )
-    }
-  }
-
-  // Verify the proposed new prices match the current prices' products
-  // For plan price
-  const currentPrice = currentItem.price
-  validateProposedPrice(currentPrice, nextPrices)
-
-  // For add-on price (if present)
-  if (change.current_add_on_lookup_key) {
-    const currentAddOnItem = subscription.items.data.find(
-      item => item.price.lookup_key === change.current_add_on_lookup_key
-    )
-    const currentAddOnPrice = currentAddOnItem.price
-    validateProposedPrice(currentAddOnPrice, nextPrices)
-  }
-}
-
-/**
- * Helper function to find a matching new price for a subscription item
- * @param {Stripe.SubscriptionItem} item - The subscription item
- * @param {Stripe.Price[]} nextPrices - Available next prices
- * @returns {string} The matching price ID or the current price ID if no match
- */
-function findMatchingPriceId(item, nextPrices) {
-  const itemProductId = getProductIdFromItem(item)
-
-  if (!itemProductId) {
-    // No product ID available, keep current price
-    return item.price.id
-  }
-
-  // Find matching new price by product ID
-  const matchingPrice = nextPrices.find(price => {
-    const priceProductId = getProductIdFromPrice(price)
-    return priceProductId === itemProductId
-  })
-
-  return matchingPrice?.id || item.price.id
-}
-
-/**
- * Update subscription prices immediately without proration
- * @param {Stripe.Subscription} subscription - The Stripe subscription
- * @param {StripeClient} stripeClient - The Stripe client for the region
- * @param {Stripe.Price[]} nextPrices - The next prices for the subscription
- * @returns {Promise<Stripe.Subscription>} The updated subscription
- * @throws {Error} If unable to build lookup key for plan
- * @throws {ReportError} If new price doesn't match expected price
- */
-async function updateSubscriptionImmediately(
-  subscription,
-  stripeClient,
-  nextPrices
-) {
-  // Release any existing schedule. This only executes when --force is enabled,
-  // as validation would have rejected subscriptions with active schedules otherwise.
-  if (
-    subscription.schedule &&
-    typeof subscription.schedule !== 'string' &&
-    subscription.schedule.status !== 'released'
-  ) {
-    await stripeClient.stripe.subscriptionSchedules.release(
-      subscription.schedule.id
-    )
-  }
-
-  // NOTE: The `id` field is required for all items when using this endpoint,
-  // otherwise Stripe will append the subscription with the new item instead of
-  // replacing the current item.
-  const subscriptionItems = subscription.items.data.map(item => ({
-    id: item.id,
-    price: findMatchingPriceId(item, nextPrices),
-    quantity: item.quantity || 1,
-  }))
-
-  const updatedSubscription = await stripeClient.stripe.subscriptions.update(
-    subscription.id,
-    {
-      items: subscriptionItems,
-      proration_behavior: 'none',
-      expand: ['customer'],
-    }
-  )
-
-  return updatedSubscription
-}
-
-/**
- * Create a subscription schedule to change prices at renewal
- * @param {Stripe.Subscription} subscription - The Stripe subscription
- * @param {StripeClient} stripeClient - The Stripe client for the region
- * @param {Stripe.Price[]} nextPrices - The next prices for the subscription
- * @returns {Promise<Stripe.Subscription>} The updated subscription
- * @throws {Error} If unable to build lookup key for plan
- * @throws {ReportError} If new price doesn't match expected price
- */
-async function createSubscriptionSchedule(
-  subscription,
-  stripeClient,
-  nextPrices
-) {
-  // Release any existing schedule. This only executes when --force is enabled,
-  // as validation would have rejected subscriptions with active schedules otherwise.
-  if (
-    subscription.schedule &&
-    typeof subscription.schedule !== 'string' &&
-    subscription.schedule.status !== 'released'
-  ) {
-    await stripeClient.stripe.subscriptionSchedules.release(
-      subscription.schedule.id
-    )
-  }
-
-  // NOTE: The `id` field cannot be used with this endpoint
-  const nextPhaseItems = subscription.items.data.map(item => ({
-    price: findMatchingPriceId(item, nextPrices),
-    quantity: item.quantity || 1,
-  }))
-
-  // Create a subscription schedule
-  const schedule = await stripeClient.stripe.subscriptionSchedules.create({
-    from_subscription: subscription.id,
-    expand: ['subscription', 'subscription.customer'],
-  })
-
-  const currentPhase = schedule.phases[0]
-
-  // Update the schedule to include the new phase starting at the end of the current billing period
-  // If the update fails, release the schedule to clean up the state
-  try {
-    const currentPhaseConfig = {
-      start_date: currentPhase.start_date,
-      end_date: currentPhase.end_date,
-      items: currentPhase.items.map(item => ({
-        price: getPriceIdFromItem(item),
-        quantity: item.quantity,
-      })),
-    }
-
-    const nextPhaseConfig = {
-      start_date: currentPhase.end_date,
-      items: nextPhaseItems,
-    }
-
-    // Stripe doesn't copy discount settings from subscription to schedule
-    // so we need to manually preserve them
-    if (subscription.discounts) {
-      const discounts = subscription.discounts
-        .map(discount => {
-          if (discount.promotion_code) {
-            return {
-              promotion_code: discount.promotion_code,
-            }
-          } else if (discount.coupon) {
-            return {
-              coupon:
-                typeof discount.coupon === 'string'
-                  ? discount.coupon
-                  : discount.coupon.id,
-            }
-          }
-          return {}
-        })
-        .filter(d => d.coupon || d.promotion_code)
-
-      currentPhaseConfig.discounts = discounts
-      nextPhaseConfig.discounts = discounts
-    }
-
-    await stripeClient.stripe.subscriptionSchedules.update(schedule.id, {
-      phases: [currentPhaseConfig, nextPhaseConfig],
-      end_behavior: 'release',
-    })
-  } catch (err) {
-    // If the update fails, release the schedule to prevent it from remaining in an invalid state
-    try {
-      await stripeClient.stripe.subscriptionSchedules.release(schedule.id)
-    } catch (releaseErr) {
-      // Do nothing, and throw the original error
-    }
-    throw err
-  }
-
-  return schedule.subscription
-}
-
-const paramsSchema = z.object({
-  region: z.enum(['uk', 'us']),
-  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, region: 'uk' | 'us'}} Parsed options
- */
-function parseArgs() {
-  const argv = minimist(process.argv.slice(2), {
-    string: ['throttle', 'timeframe', 'output', 'region'],
-    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 { region, timeframe, output, commit, force, throttle, _ } =
-    parseResult.data
-
-  return {
-    inputFile: _[0],
-    output,
-    force,
-    commit,
-    timeframe,
-    throttle,
-    region,
-  }
-}
-
-try {
-  await scriptRunner(main)
-  process.exit(0)
-} catch (error) {
-  console.error(error)
-  process.exit(1)
-}

+ 0 - 378
services/web/scripts/stripe/convert_yearly_prices_to_12months.mjs

@@ -1,378 +0,0 @@
-#!/usr/bin/env node
-
-// @ts-check
-
-/**
- * Convert yearly Stripe prices for a product to 12-month prices.
- *
- * For each recurring yearly price on the target product:
- * 1) Create a replacement recurring price with interval=month and interval_count=12
- * 2) Archive (set active=false) the original yearly price
- *
- * Note: Stripe Prices cannot be hard-deleted. Archiving is the supported replacement.
- *
- * Usage:
- * node scripts/stripe/convert_yearly_prices_to_12months.mjs --region <us|uk> --productId <prod_...> [--commit]
- *
- * Options:
- * --region       Required. Stripe region (us or uk)
- * --productId    Required. Stripe product id
- * --commit       Apply changes. Default is dry-run.
- */
-
-import minimist from 'minimist'
-import { z } from '@overleaf/validation-tools'
-import { scriptRunner } from '../lib/ScriptRunner.mjs'
-import { getRegionClient } from '../../modules/subscriptions/app/src/StripeClient.mjs'
-import { rateLimitSleep } from './helpers.mjs'
-
-/**
- * @typedef {import('stripe').Stripe} Stripe
- * @typedef {import('stripe').Stripe.Price} Price
- * @typedef {import('stripe').Stripe.PriceCreateParams} PriceCreateParams
- */
-
-const paramsSchema = z.object({
-  region: z.enum(['us', 'uk']),
-  productId: z.string(),
-  commit: z.boolean().default(false),
-})
-
-const ARCHIVED_PREFIX = '[ARCHIVED]'
-
-/**
- * @param {Stripe} stripe
- * @param {string} priceId
- * @returns {Promise<boolean>}
- */
-async function getHasActiveSubscriptions(stripe, priceId) {
-  const activeStatuses = [
-    'active',
-    'trialing',
-    'past_due',
-    'unpaid',
-    'paused',
-    'incomplete',
-  ]
-
-  let hasMore = true
-  let startingAfter
-
-  while (hasMore) {
-    /** @type {{price: string, limit: number, starting_after?: string}} */
-    const params = {
-      price: priceId,
-      limit: 100,
-    }
-
-    if (startingAfter) {
-      params.starting_after = startingAfter
-    }
-
-    const subscriptions = await stripe.subscriptions.list(params)
-    await rateLimitSleep()
-
-    if (
-      subscriptions.data.some(subscription =>
-        activeStatuses.includes(subscription.status)
-      )
-    ) {
-      return true
-    }
-
-    hasMore = subscriptions.has_more
-    if (hasMore && subscriptions.data.length > 0) {
-      startingAfter = subscriptions.data[subscriptions.data.length - 1].id
-    }
-  }
-
-  return false
-}
-
-/**
- * @param {Stripe} stripe
- * @param {string} productId
- * @returns {Promise<Price[]>}
- */
-async function getAllProductPrices(stripe, productId) {
-  /** @type {Record<string, Price>} */
-  const pricesById = {}
-
-  for (const active of [true, false]) {
-    let startingAfter
-
-    do {
-      /** @type {any} */
-      const response = await stripe.prices.list({
-        product: productId,
-        active,
-        limit: 100,
-        starting_after: startingAfter,
-      })
-
-      for (const price of response.data) {
-        pricesById[price.id] = price
-      }
-
-      startingAfter = response.has_more
-        ? response.data[response.data.length - 1].id
-        : undefined
-    } while (startingAfter)
-  }
-
-  return Object.values(pricesById)
-}
-
-/**
- * Check if there is an active 12-month price that is equivalent to the yearly price (same currency, unit_amount, nickname, and lookup_key).
- * This is mostly a redundant safety check since for this one-off script there won't be any duplicates.
- * @param {Price} yearlyPrice
- * @param {Price[]} productPrices
- * @returns {boolean}
- */
-function hasEquivalent12MonthPrice(yearlyPrice, productPrices) {
-  return productPrices.some(candidate => {
-    return (
-      candidate.active &&
-      candidate.id !== yearlyPrice.id &&
-      candidate.recurring?.interval === 'month' &&
-      candidate.recurring?.interval_count === 12 &&
-      candidate.currency === yearlyPrice.currency &&
-      candidate.unit_amount === yearlyPrice.unit_amount &&
-      candidate.nickname === yearlyPrice.nickname &&
-      candidate.lookup_key === yearlyPrice.lookup_key
-    )
-  })
-}
-
-/**
- * @param {Price} yearlyPrice
- * @returns {PriceCreateParams}
- */
-function build12MonthPriceParams(yearlyPrice) {
-  if (typeof yearlyPrice.product !== 'string') {
-    throw new Error(
-      `Price ${yearlyPrice.id} has an expanded product. Please rerun without expanded product objects.`
-    )
-  }
-
-  if (typeof yearlyPrice.unit_amount !== 'number') {
-    throw new Error(
-      `Price ${yearlyPrice.id} does not have unit_amount. Only per-unit prices are supported by this script.`
-    )
-  }
-
-  /** @type {PriceCreateParams} */
-  const params = {
-    product: yearlyPrice.product,
-    currency: yearlyPrice.currency,
-    unit_amount: yearlyPrice.unit_amount,
-    billing_scheme: yearlyPrice.billing_scheme,
-    recurring: {
-      interval: 'month',
-      interval_count: 12,
-    },
-    active: yearlyPrice.active,
-    metadata: yearlyPrice.metadata,
-    nickname: yearlyPrice.nickname || undefined,
-    tax_behavior: yearlyPrice.tax_behavior || undefined,
-  }
-
-  if (yearlyPrice.lookup_key) {
-    params.lookup_key = yearlyPrice.lookup_key
-    params.transfer_lookup_key = true
-  }
-
-  return params
-}
-
-/**
- * @param {Price[]} activeYearlyPrices
- * @param {Price[]} allProductPrices
- * @param {Stripe} stripe
- * @param {boolean} commit
- * @param {(msg: string) => Promise<void>} trackProgress
- */
-async function convertPrices(
-  activeYearlyPrices,
-  allProductPrices,
-  stripe,
-  commit,
-  trackProgress
-) {
-  const summary = {
-    yearlyFound: activeYearlyPrices.length,
-    created: 0,
-    archived: 0,
-    skippedHasActiveSubscriptions: 0,
-    skippedAlreadyConverted: 0,
-    errors: 0,
-  }
-
-  for (const yearlyPrice of activeYearlyPrices) {
-    try {
-      await trackProgress(
-        `Processing yearly price ${yearlyPrice.id} (${yearlyPrice.currency.toUpperCase()} ${yearlyPrice.unit_amount})`
-      )
-
-      const hasActiveSubscriptions = await getHasActiveSubscriptions(
-        stripe,
-        yearlyPrice.id
-      )
-      if (hasActiveSubscriptions) {
-        await trackProgress(
-          `  WARNING: Price ${yearlyPrice.id} has active subscriptions. Skipping conversion for this price.`
-        )
-        summary.skippedHasActiveSubscriptions++
-        continue
-      }
-
-      const alreadyHasEquivalent = hasEquivalent12MonthPrice(
-        yearlyPrice,
-        allProductPrices
-      )
-
-      if (!alreadyHasEquivalent) {
-        const params = build12MonthPriceParams(yearlyPrice)
-
-        if (commit) {
-          const newPrice = await stripe.prices.create(params)
-          await rateLimitSleep()
-          allProductPrices.push(newPrice)
-          await trackProgress(
-            `  Created 12-month price ${newPrice.id}${newPrice.lookup_key ? ` (lookup_key: ${newPrice.lookup_key})` : ''}`
-          )
-        } else {
-          await trackProgress(
-            `  [DRY RUN] Would create 12-month replacement price${yearlyPrice.lookup_key ? ` with lookup_key transfer (${yearlyPrice.lookup_key})` : ''}`
-          )
-        }
-
-        summary.created++
-      } else {
-        await trackProgress(
-          '  Found an equivalent 12-month price already. Skipping create step.'
-        )
-        summary.skippedAlreadyConverted++
-      }
-
-      if (commit) {
-        const archivedNickname = yearlyPrice.nickname?.includes('[ARCHIVED]')
-          ? yearlyPrice.nickname
-          : yearlyPrice.nickname
-            ? `${ARCHIVED_PREFIX} ${yearlyPrice.nickname}`
-            : ARCHIVED_PREFIX
-
-        await stripe.prices.update(yearlyPrice.id, {
-          active: false,
-          nickname: archivedNickname,
-        })
-        await rateLimitSleep()
-        await trackProgress(`  Archived yearly price ${yearlyPrice.id}`)
-      } else {
-        await trackProgress(
-          `  [DRY RUN] Would archive yearly price ${yearlyPrice.id} and prepend [ARCHIVED] to nickname`
-        )
-      }
-      summary.archived++
-    } catch (error) {
-      const message = error instanceof Error ? error.message : String(error)
-      await trackProgress(
-        `  ERROR processing price ${yearlyPrice.id}: ${message}`
-      )
-      summary.errors++
-    }
-  }
-
-  return summary
-}
-
-/**
- * @param {(msg: string) => Promise<void>} trackProgress
- */
-export async function main(trackProgress) {
-  const rawArgs = minimist(process.argv.slice(2), {
-    boolean: ['commit'],
-    string: ['region', 'productId', 'product-id', 'p'],
-    alias: { p: 'productId' },
-  })
-
-  const parseResult = paramsSchema.safeParse({
-    region: rawArgs.region,
-    productId: rawArgs.productId || rawArgs['product-id'],
-    commit: rawArgs.commit,
-  })
-
-  if (!parseResult.success) {
-    throw new Error(`Invalid parameters: ${parseResult.error.message}`)
-  }
-
-  const { region, productId, commit } = parseResult.data
-  const mode = commit ? 'COMMIT MODE' : 'DRY RUN MODE'
-
-  await trackProgress(`Starting conversion in ${mode} for region: ${region}`)
-  await trackProgress(`Target product: ${productId}`)
-  await trackProgress(
-    'Note: Stripe prices cannot be deleted. This script archives yearly prices after replacement.'
-  )
-
-  const stripe = getRegionClient(region).stripe
-
-  const allProductPrices = await getAllProductPrices(stripe, productId)
-  const activeYearlyPrices = allProductPrices.filter(
-    price => price.active && price.recurring?.interval === 'year'
-  )
-
-  if (activeYearlyPrices.length === 0) {
-    await trackProgress(
-      'No active yearly recurring prices found for this product. Exiting.'
-    )
-    return
-  }
-
-  await trackProgress(
-    `Found ${activeYearlyPrices.length} active yearly recurring price(s) to process.`
-  )
-
-  const summary = await convertPrices(
-    activeYearlyPrices,
-    allProductPrices,
-    stripe,
-    commit,
-    trackProgress
-  )
-
-  await trackProgress('CONVERSION SUMMARY')
-  await trackProgress(`Yearly prices found: ${summary.yearlyFound}`)
-  await trackProgress(
-    `12-month prices ${commit ? 'created' : 'to create'}: ${summary.created}`
-  )
-  await trackProgress(
-    `Yearly prices ${commit ? 'archived' : 'to archive'}: ${summary.archived}`
-  )
-  await trackProgress(
-    `Skipped (has active subscriptions): ${summary.skippedHasActiveSubscriptions}`
-  )
-  await trackProgress(
-    `Create skipped (already converted): ${summary.skippedAlreadyConverted}`
-  )
-  await trackProgress(`Errors: ${summary.errors}`)
-
-  if (!commit) {
-    await trackProgress(
-      'This was a dry run. Use --commit to perform create+archive operations.'
-    )
-  }
-
-  await trackProgress(`Script completed in ${mode}`)
-}
-
-if (import.meta.main) {
-  try {
-    await scriptRunner(main)
-    process.exit(0)
-  } catch (error) {
-    console.error(error)
-    process.exit(1)
-  }
-}

+ 0 - 125
services/web/scripts/stripe/create_coupons.mjs

@@ -1,125 +0,0 @@
-#!/usr/bin/env node
-
-import minimist from 'minimist'
-import { setTimeout } from 'node:timers/promises'
-import { scriptRunner } from '../lib/ScriptRunner.mjs'
-import { getRegionClient } from '../../modules/subscriptions/app/src/StripeClient.mjs'
-// eslint-disable-next-line import/no-unresolved
-import * as csv from 'csv/sync'
-import { readFile } from 'node:fs/promises'
-
-/**
- * This script creates Stripe coupons and promotion codes from a CSV file.
- *
- * Usage:
- *   node scripts/stripe/create_coupons.mjs --region=us INPUT.CSV
- *
- * Options:
- *   --region=us|uk     Required. Stripe region to process (us or uk)
- *
- * CSV Format:
- * name,percent_off,duration,code,max_redemptions
- */
-
-async function main(trackProgress) {
-  const args = minimist(process.argv.slice(2), {
-    string: ['region'],
-  })
-
-  const inputCSV = args._[0]
-  const region = args.region
-
-  await trackProgress(
-    `Starting script for Stripe ${region.toUpperCase()} region`
-  )
-
-  const file = await readFile(inputCSV, { encoding: 'utf8' })
-  const couponsToCreate = csv.parse(file, { columns: true })
-  await trackProgress(
-    `Successfully parsed "${inputCSV}" CSV file with ${couponsToCreate.length} coupons and promotion codes to create`
-  )
-
-  const client = getRegionClient(region)
-
-  const stripeCoupons = await client.stripe.coupons.list({ limit: 100 })
-  const existingCoupons = stripeCoupons.data.reduce((acc, curr) => {
-    acc[curr.name] = curr.id
-    return acc
-  }, {})
-  await trackProgress(
-    `Successfully parsed ${Object.keys(existingCoupons).length} existing coupons for verification`
-  )
-
-  let couponsCreated = 0
-  let promotionCodesCreated = 0
-  let promotionCodesExisted = 0
-
-  const errors = []
-  for (const toCreate of couponsToCreate) {
-    try {
-      let targetCouponId = existingCoupons[toCreate.name]
-      if (!targetCouponId) {
-        const createdCoupon = await client.stripe.coupons.create({
-          name: toCreate.name,
-          percent_off: parseFloat(toCreate.percent_off),
-          duration: toCreate.duration,
-        })
-        targetCouponId = createdCoupon.id
-        existingCoupons[toCreate.name] = targetCouponId
-        couponsCreated++
-      }
-
-      const promotionPayload = {
-        coupon: targetCouponId,
-        code: toCreate.code,
-      }
-      const maxRedemptions = parseInt(toCreate.max_redemptions, 10)
-      if (maxRedemptions > 0) {
-        promotionPayload.max_redemptions = maxRedemptions
-      }
-
-      await client.stripe.promotionCodes.create(promotionPayload)
-      promotionCodesCreated++
-    } catch (error) {
-      if (
-        error.message.includes('promotion code') &&
-        error.message.includes('already exists')
-      ) {
-        promotionCodesExisted++
-      } else {
-        await trackProgress(`Failed to create coupon "${toCreate}"`)
-        await trackProgress(error.message)
-        errors.push(toCreate.name)
-      }
-    }
-    if (promotionCodesCreated > 10 && promotionCodesCreated % 10 === 0) {
-      await trackProgress(
-        `Promotion codes created: ${promotionCodesCreated}, existed: ${promotionCodesExisted}`
-      )
-      await setTimeout(10)
-    }
-  }
-
-  await trackProgress(`\n\nCoupons created: ${couponsCreated}`)
-  await trackProgress(`Promotion codes created: ${promotionCodesCreated}`)
-  await trackProgress(`Promotion codes existed: ${promotionCodesExisted}`)
-
-  if (errors.length > 0) {
-    await trackProgress(
-      `Could not create the following coupons: ${errors.join(', ')}`
-    )
-  } else {
-    await trackProgress(
-      `Successfully created ${couponsToCreate.length} coupon(s) and promotion code(s).`
-    )
-  }
-}
-
-// Execute the script using the runner
-try {
-  await scriptRunner(main)
-  process.exit(0)
-} catch (error) {
-  console.error('Script failed:', error.message)
-  process.exit(1)
-}

+ 0 - 334
services/web/scripts/stripe/create_custom_prices_from_csv.mjs

@@ -1,334 +0,0 @@
-// @ts-check
-
-/**
- * This script creates custom Prices in Stripe from a CSV file.
- * It does not create products; each row must include an existing Stripe productId.
- *
- * Usage:
- * node scripts/stripe/create_custom_prices_from_csv.mjs -f <file> --region <us|uk> --version <v> [options]
- *
- * Options:
- * -f           Path to the prices CSV file.
- * --region     Stripe region (us or uk).
- * --version    Version string for the lookup_key (e.g., 'v1', 'jan2026').
- * --commit     Apply changes to Stripe (default is dry-run).
- *
- * CSV Format:
- * planCode,productId,productName,priceDescription,interval,USD,GBP,EUR
- * essentials,prod_123,Essentials Monthly,"Historical custom price",month,21,17,19
- * essentials-annual,prod_456,Essentials Annual,"Historical custom price",year,199,159,179
- */
-
-import minimist from 'minimist'
-import fs from 'node:fs'
-// https://github.com/import-js/eslint-plugin-import/issues/1810
-// eslint-disable-next-line import/no-unresolved
-import * as csv from 'csv/sync'
-import { scriptRunner } from '../lib/ScriptRunner.mjs'
-import { getRegionClient } from '../../modules/subscriptions/app/src/StripeClient.mjs'
-import { z } from '@overleaf/validation-tools'
-import { convertToMinorUnits, rateLimitSleep } from './helpers.mjs'
-
-/**
- * @typedef {object} PriceRecord
- * @property {string} planCode
- * @property {string} productId - Optional expected Stripe Product ID to validate against
- * @property {string} productName - Optional, can be derived from planCode if not provided
- * @property {string} priceDescription - Optional
- * @property {string} interval - 'month' or 'year'
- * @property {Record<string, string | number>} currencies - Dynamic currency columns
- */
-
-/**
- * @typedef {import('stripe').Stripe} Stripe
- * @typedef {import('stripe').Stripe.Price} Price
- * @typedef {import('stripe').Stripe.PriceCreateParams} PriceCreateParams
- * @typedef {import('stripe').Stripe.Product} Product
- */
-
-const paramsSchema = z.object({
-  f: z.string(),
-  region: z.enum(['us', 'uk']),
-  version: z.string(),
-  commit: z.boolean().default(false),
-})
-
-/**
- * Normalize annual cadence to month+12 so all annual prices share the same
- * recurring shape for downstream import/migration tooling.
- *
- * @param {'month' | 'year'} interval
- * @returns {{ interval: 'month', interval_count?: 1 | 12 }}
- */
-function getRecurringFromInterval(interval) {
-  if (interval === 'year') {
-    return { interval: 'month', interval_count: 12 }
-  }
-
-  return { interval: 'month', interval_count: 1 }
-}
-
-/**
- * @param {import('stripe').Stripe} stripe
- * @returns {Promise<Record<string, Price>>}
- */
-async function getExistingPrices(stripe) {
-  /** @type {Record<string, Price>} */
-  const pricesByLookupKey = {}
-  let startingAfter
-
-  do {
-    /** @type {any} */
-    const response = await stripe.prices.list({
-      limit: 100,
-      starting_after: startingAfter,
-    })
-    for (const price of response.data) {
-      if (price.lookup_key) {
-        pricesByLookupKey[price.lookup_key] = price
-      }
-    }
-    startingAfter = response.has_more
-      ? response.data[response.data.length - 1].id
-      : undefined
-  } while (startingAfter)
-
-  return pricesByLookupKey
-}
-
-/**
- * @param {import('stripe').Stripe} stripe
- * @return {Promise<Record<string, Product>>}
- */
-async function getExistingProducts(stripe) {
-  /** @type {Record<string, Product>} */
-  const productsById = {}
-  let startingAfter
-
-  do {
-    /** @type {any} */
-    const response = await stripe.products.list({
-      limit: 100,
-      starting_after: startingAfter,
-    })
-    for (const product of response.data) {
-      productsById[product.metadata.planCode] = product
-    }
-    startingAfter = response.has_more
-      ? response.data[response.data.length - 1].id
-      : undefined
-  } while (startingAfter)
-
-  return productsById
-}
-
-/**
- * @param {any} trackProgress
- */
-export async function main(trackProgress) {
-  const args = minimist(process.argv.slice(2), {
-    boolean: ['commit'],
-    string: ['region', 'f', 'version'],
-  })
-
-  const parseResult = paramsSchema.safeParse(args)
-  if (!parseResult.success) {
-    throw new Error(`Invalid parameters: ${parseResult.error.message}`)
-  }
-
-  const { f: inputFile, region, version, commit } = parseResult.data
-  const mode = commit ? 'COMMIT MODE' : 'DRY RUN MODE'
-
-  const log = (message = '') =>
-    trackProgress(mode === 'DRY RUN MODE' ? `[DRY RUN] ${message}` : message)
-
-  await log(`Starting creation script in ${mode} for region: ${region}`)
-  const stripe = getRegionClient(region).stripe
-
-  // Load and Parse CSV
-  const content = fs.readFileSync(inputFile, 'utf-8')
-  /** @type {PriceRecord[]} */
-  const records = csv.parse(content, { columns: true, skip_empty_lines: true })
-
-  if (records.length === 0) {
-    throw new Error('CSV file is empty or invalid.')
-  }
-
-  // Identify currency columns (everything except the known non-currency columns)
-  const nonCurrencyKeys = new Set([
-    'planCode',
-    'productId',
-    'productName',
-    'priceDescription',
-    'interval',
-  ])
-  const currencyKeys = Object.keys(records[0]).filter(
-    k => !nonCurrencyKeys.has(k)
-  )
-
-  // Cache existing data to minimize API calls and prevent duplicates
-  await log('Fetching existing Stripe data...')
-  const existingPrices = await getExistingPrices(stripe)
-  const existingProducts = await getExistingProducts(stripe)
-  /** @type {Record<string, Product>} */
-  const existingProductsById = {}
-  for (const product of Object.values(existingProducts)) {
-    existingProductsById[product.id] = product
-  }
-
-  const summary = {
-    productsCreated: 0,
-    pricesCreated: 0,
-    skipped: 0,
-    invalidRows: 0,
-    errors: 0,
-  }
-
-  let rowNumber = 0 // For logging purposes, starting after header
-  for (const /** @type {PriceRecord} */ record of records) {
-    ++rowNumber
-    const { planCode, productId, priceDescription, interval } = record
-    const expectedProductId = String(productId || '').trim()
-
-    if (!planCode) {
-      await log(`✗ No plan code in row ${rowNumber}`)
-      ++summary.invalidRows
-      continue
-    }
-    if (interval !== 'month' && interval !== 'year') {
-      await log(
-        `✗ Invalid interval '${interval}' on row ${rowNumber}. Must be either 'month' or 'year'.`
-      )
-      ++summary.invalidRows
-      continue
-    }
-
-    // If productId is provided, treat it as an assertion that the product already
-    // exists and matches the planCode mapping. Skip the row before product-create.
-    if (expectedProductId) {
-      const existingProduct = existingProducts[planCode]
-      if (!existingProduct) {
-        await log(
-          `✗ CSV productId '${expectedProductId}' provided for plan '${planCode}', but no existing product was found for that planCode. Skipping row.`
-        )
-        summary.errors++
-        continue
-      }
-
-      if (existingProduct.id !== expectedProductId) {
-        await log(
-          `✗ CSV productId '${expectedProductId}' does not match existing product id '${existingProduct.id}' for plan '${planCode}'. Skipping row.`
-        )
-        summary.errors++
-        continue
-      }
-    }
-
-    await log()
-    await log(`--- Processing Plan: ${planCode} ---`)
-
-    // 1. Validate required existing productId from CSV.
-    const productIdForPrice = String(productId || '').trim()
-    if (!productIdForPrice) {
-      await log(`✗ No productId in row ${rowNumber}. Skipping row.`)
-      summary.invalidRows++
-      continue
-    }
-
-    if (!existingProductsById[productIdForPrice]) {
-      await log(
-        `✗ Product '${productIdForPrice}' from CSV row ${rowNumber} was not found in Stripe. Skipping row.`
-      )
-      summary.errors++
-      continue
-    }
-
-    if (
-      existingProducts[planCode]?.id &&
-      existingProducts[planCode].id !== productIdForPrice
-    ) {
-      await log(
-        `  ✗ productId mismatch for plan '${planCode}': CSV has '${productIdForPrice}', planCode resolves to '${existingProducts[planCode].id}'. Skipping row.`
-      )
-      summary.errors++
-      continue
-    }
-
-    // 2. Handle Prices for each currency column
-    for (const currency of currencyKeys) {
-      const amountValue = parseFloat(/** @type {any} */ (record)[currency])
-      if (isNaN(amountValue) || amountValue <= 0) continue
-
-      const currencyLower = currency.toLowerCase()
-      const unitAmount = convertToMinorUnits(amountValue, currencyLower)
-      const lookupKeyInterval = interval === 'month' ? 'monthly' : 'annual'
-      // For custom prices, lookup keys always include the minor-unit amount.
-      const lookupKeyBase = `${planCode}_${lookupKeyInterval}_${version}_${currencyLower}`
-      const lookupKey = `${lookupKeyBase}_${unitAmount}`
-
-      if (existingPrices[lookupKey]) {
-        await log(`  - Price '${lookupKey}' already exists. Skipping.`)
-        summary.skipped++
-        continue
-      }
-
-      /** @type {PriceCreateParams} */
-      const priceParams = {
-        product: productIdForPrice,
-        currency: currencyLower,
-        unit_amount: unitAmount,
-        recurring: getRecurringFromInterval(interval),
-        lookup_key: lookupKey,
-        nickname: priceDescription || undefined,
-      }
-
-      if (commit) {
-        try {
-          await stripe.prices.create(priceParams)
-          await rateLimitSleep()
-        } catch (err) {
-          const errorMessage = err instanceof Error ? err.message : String(err)
-          await log(`  ✗ Error creating price ${lookupKey}: ${errorMessage}`)
-          summary.errors++
-          continue
-        }
-      }
-
-      // Keep in-memory cache in sync so duplicates in the same run are skipped.
-      existingPrices[lookupKey] = /** @type {any} */ ({
-        lookup_key: lookupKey,
-      })
-
-      await log(
-        `  ✓ Created price: ${lookupKey} (${amountValue} ${currencyLower.toUpperCase()})`
-      )
-      summary.pricesCreated++
-    }
-  }
-
-  // Final Summary
-  await log()
-  await log('='.repeat(20))
-  await log()
-  await log('✨ FINAL SUMMARY ✨')
-  await log(` ✅ Products created: ${summary.productsCreated}`)
-  await log(` ✅ Prices created: ${summary.pricesCreated}`)
-  await log(` ⏭️ Items skipped: ${summary.skipped}`)
-  await log(` ⏭️ Invalid rows skipped: ${summary.invalidRows}`)
-  await log(` ❌ Errors encountered: ${summary.errors}`)
-
-  if (!commit) {
-    await log('ℹ️  DRY RUN: No changes were applied to Stripe')
-  }
-  await log('🎉 Script completed!')
-}
-
-if (import.meta.main) {
-  try {
-    await scriptRunner(main)
-    process.exit(0)
-  } catch (error) {
-    console.error(error)
-    process.exit(1)
-  }
-}

+ 0 - 294
services/web/scripts/stripe/create_prices_from_csv.mjs

@@ -1,294 +0,0 @@
-// @ts-check
-
-/**
- * This script creates new Products and Prices in Stripe from a CSV file.
- * Use this when adding entirely new plans that don't exist in Stripe yet.
- *
- * Usage:
- * node scripts/stripe/create_prices_from_csv.mjs -f <file> --region <us|uk> --version <v> [options]
- *
- * Options:
- * -f           Path to the prices CSV file.
- * --region     Stripe region (us or uk).
- * --version    Version string for the lookup_key (e.g., 'v1', 'jan2026').
- * --commit     Apply changes to Stripe (default is dry-run).
- *
- * CSV Format:
- * planCode,productName,productDescription,interval,USD,GBP,EUR
- * essentials,Essentials Monthly,"Editable project limit 10, collaborators 5",month,21,17,19
- * essentials-annual,Essentials Annual,"Editable project limit 10, collaborators 5",year,199,159,179
- */
-
-import minimist from 'minimist'
-import fs from 'node:fs'
-// https://github.com/import-js/eslint-plugin-import/issues/1810
-// eslint-disable-next-line import/no-unresolved
-import * as csv from 'csv/sync'
-import { scriptRunner } from '../lib/ScriptRunner.mjs'
-import { getRegionClient } from '../../modules/subscriptions/app/src/StripeClient.mjs'
-import { z } from '@overleaf/validation-tools'
-import { convertToMinorUnits, rateLimitSleep } from './helpers.mjs'
-
-/**
- * @typedef {object} PriceRecord
- * @property {string} planCode
- * @property {string} productName - Optional, can be derived from planCode if not provided
- * @property {string} productDescription - Optional
- * @property {string} interval - 'month' or 'year'
- * @property {Record<string, string | number>} currencies - Dynamic currency columns
- */
-
-/**
- * @typedef {import('stripe').Stripe} Stripe
- * @typedef {import('stripe').Stripe.Price} Price
- * @typedef {import('stripe').Stripe.PriceCreateParams} PriceCreateParams
- * @typedef {import('stripe').Stripe.Product} Product
- */
-
-const paramsSchema = z.object({
-  f: z.string(),
-  region: z.enum(['us', 'uk']),
-  version: z.string(),
-  commit: z.boolean().default(false),
-})
-
-/**
- * Normalize annual cadence to month+12 so all annual prices share the same
- * recurring shape for downstream import/migration tooling.
- *
- * @param {'month' | 'year'} interval
- * @returns {{ interval: 'month', interval_count: 1 | 12 }}
- */
-function getRecurringFromInterval(interval) {
-  if (interval === 'year') {
-    return { interval: 'month', interval_count: 12 }
-  }
-
-  return { interval: 'month', interval_count: 1 }
-}
-
-/**
- * @param {import('stripe').Stripe} stripe
- * @returns {Promise<Record<string, Price>>}
- */
-async function getExistingPrices(stripe) {
-  /** @type {Record<string, Price>} */
-  const pricesByLookupKey = {}
-  let startingAfter
-
-  do {
-    /** @type {any} */
-    const response = await stripe.prices.list({
-      limit: 100,
-      starting_after: startingAfter,
-    })
-    for (const price of response.data) {
-      if (price.lookup_key) {
-        pricesByLookupKey[price.lookup_key] = price
-      }
-    }
-    startingAfter = response.has_more
-      ? response.data[response.data.length - 1].id
-      : undefined
-  } while (startingAfter)
-
-  return pricesByLookupKey
-}
-
-/**
- * @param {import('stripe').Stripe} stripe
- * @return {Promise<Record<string, Product>>}
- */
-async function getExistingProducts(stripe) {
-  /** @type {Record<string, Product>} */
-  const productsById = {}
-  let startingAfter
-
-  do {
-    /** @type {any} */
-    const response = await stripe.products.list({
-      limit: 100,
-      starting_after: startingAfter,
-    })
-    for (const product of response.data) {
-      productsById[product.id] = product
-    }
-    startingAfter = response.has_more
-      ? response.data[response.data.length - 1].id
-      : undefined
-  } while (startingAfter)
-
-  return productsById
-}
-
-/**
- * @param {any} trackProgress
- */
-export async function main(trackProgress) {
-  const args = minimist(process.argv.slice(2), {
-    boolean: ['commit'],
-    string: ['region', 'f', 'version'],
-  })
-
-  const parseResult = paramsSchema.safeParse(args)
-  if (!parseResult.success) {
-    throw new Error(`Invalid parameters: ${parseResult.error.message}`)
-  }
-
-  const { f: inputFile, region, version, commit } = parseResult.data
-  const mode = commit ? 'COMMIT MODE' : 'DRY RUN MODE'
-
-  const log = (message = '') =>
-    trackProgress(mode === 'DRY RUN MODE' ? `[DRY RUN] ${message}` : message)
-
-  await log(`Starting creation script in ${mode} for region: ${region}`)
-  const stripe = getRegionClient(region).stripe
-
-  // Load and Parse CSV
-  const content = fs.readFileSync(inputFile, 'utf-8')
-  /** @type {PriceRecord[]} */
-  const records = csv.parse(content, { columns: true, skip_empty_lines: true })
-
-  if (records.length === 0) {
-    throw new Error('CSV file is empty or invalid.')
-  }
-
-  // Identify currency columns (everything except planCode)
-  const currencyKeys = Object.keys(records[0]).filter(k => k !== 'planCode')
-
-  // Cache existing data to minimize API calls and prevent duplicates
-  await log('Fetching existing Stripe data...')
-  const existingPrices = await getExistingPrices(stripe)
-  const existingProducts = await getExistingProducts(stripe)
-
-  const summary = {
-    productsCreated: 0,
-    pricesCreated: 0,
-    skipped: 0,
-    invalidRows: 0,
-    errors: 0,
-  }
-
-  let rowNumber = 0 // For logging purposes, starting after header
-  for (const /** @type {PriceRecord} */ record of records) {
-    ++rowNumber
-    const { planCode, productDescription, interval } = record
-    if (!planCode) {
-      await log(`✗ No plan code in row ${rowNumber}`)
-      ++summary.invalidRows
-      continue
-    }
-    if (interval !== 'month' && interval !== 'year') {
-      await log(
-        `✗ Invalid interval '${interval}' on row ${rowNumber}. Must be either 'month' or 'year'.`
-      )
-      ++summary.invalidRows
-      continue
-    }
-
-    await log()
-    await log(`--- Processing Plan: ${planCode} ---`)
-
-    // 1. Handle product
-    if (!existingProducts[planCode]) {
-      const productName =
-        record.productName ||
-        planCode
-          .split(/[_-]/) // Handle underscores or hyphens
-          .map(
-            /** @param {any} word */
-            word => word.charAt(0).toUpperCase() + word.slice(1)
-          )
-          .join(' ')
-
-      if (commit) {
-        try {
-          await stripe.products.create({
-            id: planCode,
-            name: productName,
-            description: productDescription || undefined, // Don't pass an empty string, Stripe thinks we're trying to unset it and doesn't like it
-            tax_code: 'txcd_10103000', // "Software as a service (SaaS) - personal use", which is what existing products have
-            metadata: { planCode },
-          })
-          await rateLimitSleep()
-        } catch (err) {
-          const errorMessage = err instanceof Error ? err.message : String(err)
-          await log(`✗ Error creating product ${planCode}: ${errorMessage}`)
-          summary.errors++
-          continue // Skip prices if product creation failed
-        }
-      }
-      await log(`✓ Created product: ${planCode} ("${productName}")`)
-      summary.productsCreated++
-    } else {
-      await log(`- Product '${planCode}' already exists.`)
-    }
-
-    // 2. Handle Prices for each currency column
-    for (const currency of currencyKeys) {
-      const amountValue = parseFloat(/** @type {any} */ (record)[currency])
-      if (isNaN(amountValue) || amountValue <= 0) continue
-
-      const currencyLower = currency.toLowerCase()
-      // Standardize lookup key format: {plan}_{interval}_{version}_{currency}
-      const lookupKey = `${planCode}_${interval}_${version}_${currencyLower}`
-
-      if (existingPrices[lookupKey]) {
-        await log(`  - Price '${lookupKey}' already exists. Skipping.`)
-        summary.skipped++
-        continue
-      }
-
-      /** @type {PriceCreateParams} */
-      const priceParams = {
-        product: planCode,
-        currency: currencyLower,
-        unit_amount: convertToMinorUnits(amountValue, currencyLower),
-        recurring: getRecurringFromInterval(interval),
-        lookup_key: lookupKey,
-      }
-
-      if (commit) {
-        try {
-          await stripe.prices.create(priceParams)
-          await rateLimitSleep()
-        } catch (err) {
-          const errorMessage = err instanceof Error ? err.message : String(err)
-          await log(`  ✗ Error creating price ${lookupKey}: ${errorMessage}`)
-          summary.errors++
-          continue
-        }
-      }
-      await log(
-        `  ✓ Created price: ${lookupKey} (${amountValue} ${currencyLower.toUpperCase()})`
-      )
-      summary.pricesCreated++
-    }
-  }
-
-  // Final Summary
-  await log()
-  await log('='.repeat(20))
-  await log()
-  await log('✨ FINAL SUMMARY ✨')
-  await log(` ✅ Products created: ${summary.productsCreated}`)
-  await log(` ✅ Prices created: ${summary.pricesCreated}`)
-  await log(` ⏭️ Items skipped: ${summary.skipped}`)
-  await log(` ⏭️ Invalid rows skipped: ${summary.invalidRows}`)
-  await log(` ❌ Errors encountered: ${summary.errors}`)
-
-  if (!commit) {
-    await log('ℹ️  DRY RUN: No changes were applied to Stripe')
-  }
-  await log('🎉 Script completed!')
-}
-
-if (import.meta.main) {
-  try {
-    await scriptRunner(main)
-    process.exit(0)
-  } catch (error) {
-    console.error(error)
-    process.exit(1)
-  }
-}

+ 0 - 168
services/web/scripts/stripe/export_products_from_environment.mjs

@@ -1,168 +0,0 @@
-#!/usr/bin/env node
-
-/**
- * This script exports active products and their active prices from a Stripe environment to a JSON file
- *
- * Usage:
- *   node scripts/stripe/export_products_from_environment.mjs --region us -o fileName [options]
- *   node scripts/stripe/export_products_from_environment.mjs --region uk -o fileName [options]
- *
- * Options:
- *   --region           Required. Stripe region to export from (us or uk)
- *   -o                 Output file path (JSON format)
- *
- * Examples:
- *   # Export all active products from US region
- *   node scripts/stripe/export_products_from_environment.mjs --region us -o export.json
- *
- *   # Export all active products from UK region
- *   node scripts/stripe/export_products_from_environment.mjs --region uk -o export.json
- */
-
-import minimist from 'minimist'
-import fs from 'node:fs'
-import path from 'node:path'
-import { z } from '../../app/src/infrastructure/Validation.mjs'
-import { scriptRunner } from '../lib/ScriptRunner.mjs'
-import { getRegionClient } from '../../modules/subscriptions/app/src/StripeClient.mjs'
-
-/**
- * @import Stripe from 'stripe'
- */
-
-const paramsSchema = z.object({
-  region: z.enum(['us', 'uk']),
-  o: z.string(),
-})
-
-/**
- * Sleep function to respect Stripe rate limits (100 requests per second)
- */
-async function rateLimitSleep() {
-  return new Promise(resolve => setTimeout(resolve, 50))
-}
-
-/**
- * Fetch all active prices with expanded product data from Stripe
- *
- * @param {Stripe} stripe
- * @param {function} trackProgress
- * @returns {Promise<Stripe.Price[]>}
- */
-async function fetchAllPricesWithProducts(stripe, trackProgress) {
-  const allPrices = []
-  let hasMore = true
-  let startingAfter
-
-  await trackProgress('Fetching active prices with product data from Stripe...')
-
-  while (hasMore) {
-    const params = {
-      active: true,
-      limit: 100,
-      starting_after: startingAfter,
-      expand: ['data.product'],
-    }
-
-    const pricesResult = await stripe.prices.list(params)
-    allPrices.push(...pricesResult.data)
-    hasMore = pricesResult.has_more
-
-    if (hasMore) {
-      startingAfter = pricesResult.data[pricesResult.data.length - 1].id
-    }
-
-    await trackProgress(`Fetched ${allPrices.length} prices...`)
-    await rateLimitSleep()
-  }
-
-  return allPrices
-}
-
-/**
- * Build export data structure from prices with expanded products
- *
- * @param {Stripe.Price[]} prices
- * @returns {object}
- */
-function buildExportData(prices) {
-  const productMap = new Map()
-  const pricesByProduct = new Map()
-
-  // Extract unique products and group prices by product
-  for (const price of prices) {
-    const product = price.product
-    const productId = typeof product === 'string' ? product : product.id
-
-    // Store the product object if it's expanded and active
-    if (
-      typeof product !== 'string' &&
-      product.active &&
-      !productMap.has(productId)
-    ) {
-      productMap.set(productId, product)
-    }
-
-    // Only include prices for active products
-    if (typeof product !== 'string' && product.active) {
-      if (!pricesByProduct.has(productId)) {
-        pricesByProduct.set(productId, [])
-      }
-      pricesByProduct.get(productId).push(price)
-    }
-  }
-
-  const products = Array.from(productMap.values())
-
-  return {
-    exportedAt: new Date().toISOString(),
-    totalProducts: products.length,
-    totalPrices: prices.length,
-    products: products.map(product => ({
-      product,
-      prices: pricesByProduct.get(product.id) || [],
-    })),
-  }
-}
-
-async function main(trackProgress) {
-  const parseResult = paramsSchema.safeParse(
-    minimist(process.argv.slice(2), {
-      string: ['region', 'o'],
-    })
-  )
-
-  if (!parseResult.success) {
-    throw new Error(`Invalid parameters: ${parseResult.error.message}`)
-  }
-
-  const { region, o: outputFile } = parseResult.data
-
-  await trackProgress(`Starting export from region: ${region}`)
-
-  const stripe = getRegionClient(region).stripe
-
-  const prices = await fetchAllPricesWithProducts(stripe, trackProgress)
-  await trackProgress(`Found ${prices.length} active prices`)
-
-  await trackProgress('Building export data structure...')
-  const exportData = buildExportData(prices)
-
-  await trackProgress(`Writing to file: ${outputFile}`)
-  const outputDir = path.dirname(outputFile)
-  fs.mkdirSync(outputDir, { recursive: true })
-  fs.writeFileSync(outputFile, JSON.stringify(exportData, null, 2))
-
-  await trackProgress('EXPORT COMPLETE')
-  await trackProgress(`Exported ${exportData.totalProducts} products`)
-  await trackProgress(`Exported ${exportData.totalPrices} prices`)
-  await trackProgress(`Output file: ${outputFile}`)
-}
-
-try {
-  await scriptRunner(main)
-  process.exit(0)
-} catch (error) {
-  console.error('Script failed:', error.message)
-  process.exit(1)
-}

+ 0 - 930
services/web/scripts/stripe/finalize-stripe-subscription-migration.mjs

@@ -1,930 +0,0 @@
-#!/usr/bin/env node
-
-/**
- * This script handles the cutover for subscriptions migrating from Recurly to Stripe.
- *
- * IMPORTANT: Only run this after Stripe subscriptions have been created in Stripe and
- * are ready to take over billing from Recurly.
- *
- * NOTE: This script will trigger lifecycle emails to be sent. Please turn off:
- * - "Send emails about upcoming renewals" (https://dashboard.stripe.com/<account>/settings/billing/subscriptions)
- * - "Subscription Change Template" (https://sharelatex.recurly.com/emails/subscription_change/template/edit)
- *
- * Usage:
- *   node scripts/stripe/finalize-stripe-subscription-migration.mjs [OPTS] [INPUT-FILE]
- *
- * Options:
- *   --output PATH                 Output file path (default: /tmp/migrate_output_<timestamp>.csv)
- *   --commit                      Apply changes (without this, runs in dry-run mode)
- *   --concurrency, -c <n>         Number of customers to process concurrently (default: 10)
- *   --recurly-rate-limit N        Requests per second for Recurly (default: 10)
- *   --recurly-api-retries N       Number of retries on Recurly 429s (default: 5)
- *   --recurly-retry-delay-ms N    Delay between Recurly retries in ms (default: 1000)
- *   --stripe-rate-limit N         Requests per second for Stripe (default: 50)
- *   --stripe-api-retries N        Number of retries on Stripe 429s (default: 5)
- *   --stripe-retry-delay-ms N     Delay between Stripe retries in ms (default: 1000)
- *   --help                        Show help message
- *
- * CSV Input Format:
- *   recurly_account_code,target_stripe_account,stripe_customer_id
- *   507f1f77bcf86cd799439011,stripe-uk,cus_1234567890abcdef
- *
- * CSV Output Format:
- *   recurly_account_code,target_stripe_account,stripe_customer_id,previous_recurly_status,previous_recurly_subscription_id,email,analyticsId,status,note
- *
- * Note: recurly_account_code is the Overleaf user ID (admin_id)
- */
-
-import fs from 'node:fs'
-import path from 'node:path'
-import * as csv from 'csv'
-import minimist from 'minimist'
-import Settings from '@overleaf/settings'
-import recurly from 'recurly'
-import PQueue from 'p-queue'
-import { z } from '../../app/src/infrastructure/Validation.mjs'
-import { scriptRunner } from '../lib/ScriptRunner.mjs'
-import {
-  getRegionClient,
-  convertStripeStatusToSubscriptionState,
-} from '../../modules/subscriptions/app/src/StripeClient.mjs'
-import RecurlyWrapper from '../../app/src/Features/Subscription/RecurlyWrapper.mjs'
-import { Subscription } from '../../app/src/models/Subscription.mjs'
-import { User } from '../../app/src/models/User.mjs'
-import AnalyticsManager from '../../app/src/Features/Analytics/AnalyticsManager.mjs'
-import AccountMappingHelper from '../../app/src/Features/Analytics/AccountMappingHelper.mjs'
-import PlansLocator from '../../app/src/Features/Subscription/PlansLocator.mjs'
-import UserAnalyticsDataCache from '../../app/src/Features/Analytics/UserAnalyticsDataCache.mjs'
-import CustomerIoHandler from '../../modules/customer-io/app/src/CustomerIoHandler.mjs'
-import { ReportError, convertToMinorUnits } from './helpers.mjs'
-import { compareAccountFields } from '../helpers/migrate_recurly_customers_to_stripe.helpers.mjs'
-import {
-  createRateLimitedApiWrappers,
-  DEFAULT_RECURLY_RATE_LIMIT,
-  DEFAULT_STRIPE_RATE_LIMIT,
-  DEFAULT_RECURLY_API_RETRIES,
-  DEFAULT_RECURLY_RETRY_DELAY_MS,
-  DEFAULT_STRIPE_API_RETRIES,
-  DEFAULT_STRIPE_RETRY_DELAY_MS,
-} from './RateLimiter.mjs'
-
-const preloadedProductMetadata = new Map()
-
-// Tolerance for comparing amounts (handles repeating decimals in per-seat prices)
-const AMOUNT_TOLERANCE = 1e-6
-
-// rate limiters - initialized in main()
-let rateLimiters
-
-// Recurly SDK client - initialized at module level
-const recurlyApiKey =
-  process.env.RECURLY_API_KEY || Settings.apis?.recurly?.apiKey
-if (!recurlyApiKey) {
-  throw new Error(
-    'Recurly API key is not set. Set RECURLY_API_KEY env var or configure Settings.apis.recurly.apiKey'
-  )
-}
-const recurlyClient = new recurly.Client(recurlyApiKey)
-
-function usage() {
-  console.error(`Usage: node scripts/stripe/finalize-stripe-subscription-migration.mjs [OPTS] [INPUT-FILE]
-
-Options:
-    --output PATH                 Output file path (default: /tmp/migrate_output_<timestamp>.csv)
-    --commit                      Apply changes (without this, runs in dry-run mode)
-    --concurrency N               Number of customers to process concurrently (default: 10)
-    --recurly-rate-limit N        Requests per second for Recurly (default: ${DEFAULT_RECURLY_RATE_LIMIT})
-    --recurly-api-retries N       Number of retries on Recurly 429s (default: ${DEFAULT_RECURLY_API_RETRIES})
-    --recurly-retry-delay-ms N    Delay between Recurly retries in ms (default: ${DEFAULT_RECURLY_RETRY_DELAY_MS})
-    --stripe-rate-limit N         Requests per second for Stripe (default: ${DEFAULT_STRIPE_RATE_LIMIT})
-    --stripe-api-retries N        Number of retries on Stripe 429s (default: ${DEFAULT_STRIPE_API_RETRIES})
-    --stripe-retry-delay-ms N     Delay between Stripe retries in ms (default: ${DEFAULT_STRIPE_RETRY_DELAY_MS})
-    --help                        Show this help message
-`)
-}
-
-async function main(trackProgress) {
-  const opts = parseArgs()
-  const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
-  const outputFile = opts.output ?? `/tmp/migrate_output_${timestamp}.csv`
-
-  // initialize rate limiters
-  rateLimiters = createRateLimitedApiWrappers({
-    recurlyRateLimit: opts.recurlyRateLimit,
-    recurlyApiRetries: opts.recurlyApiRetries,
-    recurlyRetryDelayMs: opts.recurlyRetryDelayMs,
-    stripeRateLimit: opts.stripeRateLimit,
-    stripeApiRetries: opts.stripeApiRetries,
-    stripeRetryDelayMs: opts.stripeRetryDelayMs,
-  })
-
-  await trackProgress('Starting Recurly to Stripe migration cutover')
-  await trackProgress(`Run mode: ${opts.commit ? 'COMMIT' : 'DRY RUN'}`)
-  await trackProgress(
-    `Rate limits: Recurly ${opts.recurlyRateLimit}/s, Stripe ${opts.stripeRateLimit}/s`
-  )
-  await trackProgress(`Concurrency: ${opts.concurrency}`)
-
-  const inputStream = opts.inputFile
-    ? fs.createReadStream(opts.inputFile)
-    : process.stdin
-  const csvReader = getCsvReader(inputStream)
-  const csvWriter = getCsvWriter(outputFile)
-
-  await trackProgress('Populating product metadata...')
-  await preloadProductMetadata('uk')
-  await preloadProductMetadata('us')
-  await trackProgress('Product metadata populated')
-
-  await trackProgress(`Output: ${outputFile}`)
-
-  let processedCount = 0
-  let successCount = 0
-  let errorCount = 0
-
-  const queue = new PQueue({ concurrency: opts.concurrency })
-  const maxQueueSize = opts.concurrency
-
-  try {
-    for await (const input of csvReader) {
-      // throttle input if queue is full
-      if (queue.size >= maxQueueSize) {
-        await queue.onSizeLessThan(maxQueueSize)
-      }
-
-      queue.add(async () => {
-        try {
-          const result = await processMigration(input, opts.commit)
-
-          csvWriter.write({
-            recurly_account_code: input.recurly_account_code,
-            target_stripe_account: input.target_stripe_account,
-            stripe_customer_id: input.stripe_customer_id,
-            previous_recurly_status: result.previousRecurlyStatus || '',
-            previous_recurly_subscription_id:
-              result.previousRecurlySubscriptionId || '',
-            email: result.email || '',
-            analyticsId: result.analyticsId || '',
-            status: result.status,
-            note: result.note,
-          })
-
-          if (
-            result.status.startsWith('migrated') ||
-            result.status === 'validated'
-          ) {
-            successCount++
-          } else {
-            errorCount++
-          }
-        } catch (err) {
-          errorCount++
-          if (err instanceof ReportError) {
-            csvWriter.write({
-              recurly_account_code: input.recurly_account_code,
-              target_stripe_account: input.target_stripe_account,
-              stripe_customer_id: input.stripe_customer_id,
-              previous_recurly_status: '',
-              previous_recurly_subscription_id: '',
-              email: '',
-              analyticsId: '',
-              status: err.status,
-              note: err.message,
-            })
-          } else {
-            csvWriter.write({
-              recurly_account_code: input.recurly_account_code,
-              target_stripe_account: input.target_stripe_account,
-              stripe_customer_id: input.stripe_customer_id,
-              previous_recurly_status: '',
-              previous_recurly_subscription_id: '',
-              email: '',
-              analyticsId: '',
-              status: 'error',
-              note: err.message,
-            })
-          }
-        }
-
-        processedCount++
-        if (processedCount % 25 === 0) {
-          await trackProgress(
-            `Progress: ${processedCount} processed, ${successCount} successful, ${errorCount} errors`
-          )
-        }
-      })
-    }
-  } finally {
-    // wait for all queued tasks to complete
-    await queue.onIdle()
-  }
-
-  await trackProgress(`✅ Total processed: ${processedCount}`)
-  if (opts.commit) {
-    await trackProgress(`✅ Successfully migrated: ${successCount}`)
-  } else {
-    await trackProgress(`✅ Successfully validated: ${successCount}`)
-    await trackProgress('ℹ️  DRY RUN: No changes were applied')
-  }
-  await trackProgress(`❌ Errors: ${errorCount}`)
-  await trackProgress('🎉 Script completed!')
-
-  csvWriter.end()
-  await CustomerIoHandler.closeCustomerIo()
-}
-
-function getCsvReader(inputStream) {
-  const parser = csv.parse({ columns: true })
-  inputStream.pipe(parser)
-  return parser
-}
-
-function getCsvWriter(outputFile) {
-  fs.mkdirSync(path.dirname(outputFile), { recursive: true })
-  const outputStream = fs.createWriteStream(outputFile)
-
-  const writer = csv.stringify({
-    columns: [
-      'recurly_account_code',
-      'target_stripe_account',
-      'stripe_customer_id',
-      'previous_recurly_status',
-      'previous_recurly_subscription_id',
-      'email',
-      'analyticsId',
-      'status',
-      'note',
-    ],
-    header: true,
-  })
-
-  writer.on('error', err => {
-    console.error(err)
-    process.exit(1)
-  })
-
-  writer.pipe(outputStream)
-  return writer
-}
-
-async function preloadProductMetadata(region) {
-  if (preloadedProductMetadata.has(region)) return
-
-  const stripeClient = getRegionClient(region)
-  const products = await rateLimiters.requestWithRetries(
-    stripeClient.serviceName,
-    () =>
-      stripeClient.stripe.products.list({
-        active: true,
-        limit: 100,
-      }),
-    { operation: 'products.list', region: stripeClient.serviceName }
-  )
-
-  const results = new Map()
-  for (const product of products.data) {
-    results.set(product.id, product.metadata)
-  }
-
-  preloadedProductMetadata.set(region, results)
-}
-
-async function processMigration(input, commit) {
-  const {
-    recurly_account_code: overleafUserId,
-    target_stripe_account: targetStripeAccount,
-    stripe_customer_id: stripeCustomerId,
-  } = input
-
-  // Get Stripe client for the target account (strip 'stripe-' prefix if present)
-  const region = targetStripeAccount.replace(/^stripe-/, '')
-  const stripeClient = getRegionClient(region)
-
-  // 1. Fetch Mongo subscription
-  const mongoSubscription = await Subscription.findOne({
-    admin_id: overleafUserId,
-  }).exec()
-  if (!mongoSubscription) {
-    throw new ReportError(
-      'no-mongo-subscription',
-      'No subscription found in Mongo'
-    )
-  }
-
-  // 2. Check if already migrated to Stripe
-  if (mongoSubscription.paymentProvider?.service?.includes('stripe')) {
-    throw new ReportError('already-stripe', 'Subscription already using Stripe')
-  }
-
-  // 3. Store previous state for output
-  const previousRecurlyStatus = mongoSubscription.recurlyStatus
-    ? JSON.stringify(mongoSubscription.recurlyStatus)
-    : ''
-  const previousRecurlySubscriptionId =
-    mongoSubscription.recurlySubscription_id || ''
-
-  // 4. Find Stripe subscription for this customer
-  let stripeCustomer
-  let stripeSubscription
-  try {
-    stripeCustomer = await rateLimiters.requestWithRetries(
-      stripeClient.serviceName,
-      () =>
-        stripeClient.getCustomerById(stripeCustomerId, [
-          'subscriptions',
-          'subscriptions.data.schedule',
-        ]),
-      {
-        operation: 'getCustomerById',
-        stripeCustomerId,
-        region: stripeClient.serviceName,
-      }
-    )
-
-    // handle no subscriptions found
-    if (
-      !stripeCustomer.subscriptions ||
-      stripeCustomer.subscriptions.data.length === 0
-    ) {
-      throw new ReportError(
-        'no-stripe-subscription',
-        'No Stripe subscriptions found for customer'
-      )
-    }
-
-    // handle multiple active subscriptions found
-    const activeSubscriptions = stripeCustomer.subscriptions.data.filter(sub =>
-      ['active', 'past_due', 'incomplete'].includes(sub.status)
-    )
-    if (activeSubscriptions.length > 1) {
-      throw new ReportError(
-        'multiple-active-stripe-subscriptions',
-        'Multiple active Stripe subscriptions found for customer'
-      )
-    }
-
-    // find the target subscription with migration metadata
-    stripeSubscription = stripeCustomer.subscriptions.data.find(
-      sub => sub.metadata?.recurly_to_stripe_migration_status === 'in_progress'
-    )
-    if (!stripeSubscription) {
-      throw new ReportError(
-        'no-target-stripe-subscription',
-        'No target Stripe subscription found for customer'
-      )
-    }
-  } catch (err) {
-    if (err instanceof ReportError) throw err
-    throw new ReportError(
-      'stripe-fetch-error',
-      `Failed to fetch Stripe subscription: ${err.message}`
-    )
-  }
-
-  // 5. Fetch Recurly subscription and account
-  let recurlySubscription
-  try {
-    recurlySubscription = await rateLimiters.requestWithRetries(
-      'recurly',
-      () =>
-        recurlyClient.getSubscription(`uuid-${previousRecurlySubscriptionId}`),
-      {
-        operation: 'getSubscription',
-        recurlySubscriptionId: previousRecurlySubscriptionId,
-      }
-    )
-  } catch (err) {
-    throw new ReportError(
-      'no-recurly-subscription',
-      `Recurly subscription not found: ${err.message}`
-    )
-  }
-
-  let recurlyAccount
-  try {
-    recurlyAccount = await rateLimiters.requestWithRetries(
-      'recurly',
-      () => recurlyClient.getAccount(`code-${overleafUserId}`),
-      {
-        operation: 'getAccount',
-        overleafUserId,
-      }
-    )
-  } catch (err) {
-    throw new ReportError(
-      'no-recurly-account',
-      `Recurly account not found: ${err.message}`
-    )
-  }
-
-  // 6. Detect changes between Recurly and Stripe
-  const subscriptionChanges = detectSubscriptionChanges(
-    recurlySubscription,
-    stripeSubscription,
-    region
-  )
-  const accountChanges = await detectAccountChanges(
-    overleafUserId,
-    stripeCustomerId,
-    stripeClient,
-    recurlyAccount,
-    recurlySubscription.collectionMethod || null
-  )
-  const allChanges = [...subscriptionChanges, ...accountChanges]
-  if (allChanges.length > 0) {
-    throw new ReportError(
-      'changes-detected',
-      `Changes detected between Recurly and Stripe: ${allChanges.join('; ')}`
-    )
-  }
-
-  // 7. If commit mode, perform migration
-  const analyticsId = await UserAnalyticsDataCache.getAnalyticsId(
-    overleafUserId,
-    'script' // no-op, metrics are not collected from scripts.
-  )
-  const mongoUser = await User.findOne({
-    _id: overleafUserId,
-  }).exec()
-  const result = {
-    status: 'not-migrated',
-    note: 'Not yet migrated',
-    previousRecurlyStatus,
-    previousRecurlySubscriptionId,
-    email: mongoUser?.email || stripeCustomer.email,
-    analyticsId,
-  }
-  if (commit) {
-    try {
-      await performCutover(
-        mongoSubscription,
-        stripeSubscription,
-        recurlySubscription,
-        stripeClient,
-        stripeCustomer,
-        mongoUser?.email
-      )
-    } catch (err) {
-      if (err instanceof ReportError && err.status?.startsWith('migrated-')) {
-        result.status = err.status
-        result.note = err.message
-        return result
-      }
-
-      throw err
-    }
-
-    result.status = 'migrated'
-    result.note = 'Successfully migrated to Stripe'
-
-    if (stripeCustomer.metadata?.taxInfoPending) {
-      result.status += '-tax-info-pending'
-      result.note += '; Tax info pending'
-    }
-
-    return result
-  } else {
-    result.status = 'validated'
-    result.note = 'DRY RUN: Ready to migrate'
-    return result
-  }
-}
-
-/**
- * Format subscription items for display in error messages
- */
-function formatItems(items) {
-  return items
-    .map(item => `${item.code}(qty:${item.quantity},amt:${item.amount})`)
-    .join(', ')
-}
-
-function detectSubscriptionChanges(
-  recurlySubscription,
-  stripeSubscription,
-  region
-) {
-  const changes = []
-
-  // Extract item details from Recurly subscription
-  const targetRecurlySubscription =
-    recurlySubscription.pendingChange || recurlySubscription
-  const recurlyPlanItem =
-    PlansLocator.convertLegacyGroupPlanCodeToConsolidatedGroupPlanCodeIfNeeded(
-      targetRecurlySubscription.plan.code
-    )
-  const simplifiedPlanCode = recurlyPlanItem.planCode.replace(
-    /_free_trial.*$/,
-    ''
-  )
-  const additionalLicenseQuantity =
-    (targetRecurlySubscription.addOns || []).find(
-      addOn => addOn.addOn.code === 'additional-license'
-    )?.quantity || 0
-  const currency = recurlySubscription.currency
-  const recurlyItems = [
-    {
-      code: simplifiedPlanCode,
-      quantity: recurlyPlanItem.quantity + additionalLicenseQuantity,
-      amount:
-        convertToMinorUnits(targetRecurlySubscription.unitAmount, currency) /
-        recurlyPlanItem.quantity,
-    },
-    ...(targetRecurlySubscription.addOns || [])
-      .filter(addOn => addOn.addOn.code !== 'additional-license')
-      .map(addOn => ({
-        code: addOn.addOn.code,
-        quantity: addOn.quantity,
-        amount: convertToMinorUnits(addOn.unitAmount, currency),
-      })),
-  ].sort((a, b) => a.code.localeCompare(b.code))
-
-  // Extract item details from Stripe subscription
-  const products = preloadedProductMetadata.get(region)
-  const hasAddOns = stripeSubscription.items.data.length > 1
-  const stripeItems = stripeSubscription.items.data
-    .map(item => {
-      const productMetadata = products.get(item.price.product)
-      if (!productMetadata) {
-        throw new ReportError(
-          'unknown-stripe-product',
-          `Unknown Stripe product: ${item.price.product}`
-        )
-      }
-
-      return {
-        code:
-          productMetadata?.planCode?.includes('assistant') && hasAddOns
-            ? productMetadata?.addOnCode
-            : productMetadata?.planCode,
-        quantity: item.quantity,
-        amount:
-          item.price.unit_amount != null
-            ? item.price.unit_amount
-            : parseFloat(item.price.unit_amount_decimal),
-      }
-    })
-    .sort((a, b) => a.code.localeCompare(b.code))
-
-  // Compare items (use tolerance for amounts due to repeating decimals in per-seat prices)
-  const itemsMatch =
-    recurlyItems.length === stripeItems.length &&
-    recurlyItems.every((rItem, i) => {
-      const sItem = stripeItems[i]
-      return (
-        rItem.code === sItem.code &&
-        rItem.quantity === sItem.quantity &&
-        Math.abs(rItem.amount - sItem.amount) < AMOUNT_TOLERANCE
-      )
-    })
-  if (!itemsMatch) {
-    changes.push(
-      `Items: Recurly=[${formatItems(recurlyItems)}], Stripe=[${formatItems(stripeItems)}]`
-    )
-  }
-
-  // Compare states
-  const recurlyState = recurlySubscription.state
-  const stripeState = convertStripeStatusToSubscriptionState(stripeSubscription)
-  if (recurlyState !== stripeState) {
-    changes.push(`State: Recurly=${recurlyState}, Stripe=${stripeState}`)
-  }
-
-  return changes
-}
-
-/**
- * Detect account-level drift between the Recurly account and the migrated Stripe customer.
- *
- * Uses the Recurly SDK account (which includes billing info), and re-retrieves
- * the Stripe customer with expanded tax_ids and default_payment_method so the
- * comparison can cover all the fields that the customer-migration script set.
- *
- * @param {string} overleafUserId - Recurly account code / Overleaf user ID
- * @param {string} stripeCustomerId - Stripe customer ID
- * @param {object} stripeClient - Stripe client (from getRegionClient)
- * @param {object} account - Recurly SDK account object (from recurlyClient.getAccount)
- * @param {string|null} collectionMethod - Recurly subscription collection method
- * @returns {Promise<string[]>} - Array of change descriptions (empty = no drift)
- */
-async function detectAccountChanges(
-  overleafUserId,
-  stripeCustomerId,
-  stripeClient,
-  account,
-  collectionMethod
-) {
-  const context = { overleafUserId, stripeCustomerId }
-
-  // Fetch the Stripe customer with tax_ids and payment method expanded
-  const stripeCustomer = await rateLimiters.requestWithRetries(
-    stripeClient.serviceName,
-    () =>
-      stripeClient.stripe.customers.retrieve(stripeCustomerId, {
-        expand: ['tax_ids', 'invoice_settings.default_payment_method'],
-      }),
-    { ...context, operation: 'customers.retrieve' }
-  )
-
-  if (stripeCustomer.deleted) {
-    return [`Stripe customer ${stripeCustomerId} has been deleted`]
-  }
-
-  // Pre-fetch payment methods if needed for comparison
-  let stripePaymentMethods = []
-  const isPaypalBillingAgreement =
-    account.billingInfo?.paymentMethod?.object === 'paypal_billing_agreement'
-  if (!isPaypalBillingAgreement && account.billingInfo?.paymentMethod) {
-    const result = await rateLimiters.requestWithRetries(
-      stripeClient.serviceName,
-      () => stripeClient.stripe.customers.listPaymentMethods(stripeCustomerId),
-      { ...context, operation: 'customers.listPaymentMethods' }
-    )
-    stripePaymentMethods = result.data
-  }
-
-  const diffs = await compareAccountFields({
-    account,
-    stripeCustomer,
-    overleafUserId,
-    fetchCollectionMethod: async () => collectionMethod,
-    stripePaymentMethods,
-    stripeServiceName: stripeClient.serviceName,
-  })
-
-  return formatDiffsAsChanges(diffs)
-}
-
-/**
- * Convert structured diffs from compareAccountFields into human-readable change descriptions.
- */
-function formatDiffsAsChanges(diffs) {
-  const changes = []
-  for (const [field, diff] of Object.entries(diffs)) {
-    if (field === 'address') {
-      changes.push(
-        `Address: Recurly=${JSON.stringify(diff.recurly)}, Stripe=${JSON.stringify(diff.stripe)}`
-      )
-    } else if (field === 'cc_emails') {
-      changes.push(
-        `CC emails: Recurly=[${[...diff.recurly].sort().join(',')}], Stripe=[${[...(diff.stripe || [])].sort().join(',')}]`
-      )
-    } else if (field === 'tax_id') {
-      const stripeStr = diff.stripe
-        ? diff.stripe.map(t => `{type:${t.type}, value:${t.value}}`).join(', ')
-        : '(none)'
-      changes.push(
-        `Tax ID: Recurly={type:${diff.recurly.type}, value:${diff.recurly.value}}, Stripe=${stripeStr}`
-      )
-    } else if (field === 'default_payment_method') {
-      if (diff.bothPaypal) {
-        changes.push(
-          `Payment method: both PayPal, but Recurly billing info updatedAt (${diff.recurly.updatedAt}) is newer than Stripe payment method created (${diff.stripe.created})`
-        )
-      } else {
-        changes.push(
-          `Payment method: Recurly=${diff.recurly.type || diff.recurly.last4 || '(none)'}, Stripe=${diff.stripe.type || '(none)'}`
-        )
-      }
-    } else if (field.startsWith('metadata.')) {
-      const key = field.slice('metadata.'.length)
-      changes.push(
-        `Metadata ${key}: Recurly=${diff.recurly || '(empty)'}, Stripe=${diff.stripe || '(empty)'}`
-      )
-    } else {
-      const label =
-        field.charAt(0).toUpperCase() + field.slice(1).replace(/_/g, ' ')
-      changes.push(
-        `${label}: Recurly=${diff.recurly || '(empty)'}, Stripe=${diff.stripe || '(empty)'}`
-      )
-    }
-  }
-  return changes
-}
-
-async function performCutover(
-  mongoSubscription,
-  stripeSubscription,
-  recurlySubscription,
-  stripeClient,
-  stripeCustomer,
-  mongoUserEmail
-) {
-  const adminUserId = mongoSubscription.admin_id.toString()
-
-  // Step 1: Update Mongo subscription to point to Stripe
-  mongoSubscription.paymentProvider = {
-    service: stripeClient.serviceName,
-    subscriptionId: stripeSubscription.id,
-    state: convertStripeStatusToSubscriptionState(stripeSubscription),
-  }
-
-  mongoSubscription.recurlySubscription_id = undefined
-  mongoSubscription.recurlyStatus = undefined
-
-  try {
-    await mongoSubscription.save()
-  } catch (err) {
-    throw new ReportError(
-      'not-migrated-mongo-update-failed',
-      `Failed to update Mongo subscription: ${err.message}`
-    )
-  }
-
-  // Step 2: Emit migration analytics event
-  AnalyticsManager.recordEventForUserInBackground(
-    adminUserId,
-    'subscription-migrated-to-stripe',
-    {
-      subscriptionId: mongoSubscription._id.toString(),
-      migrationDirection: 'recurly-to-stripe',
-    }
-  )
-
-  // Step 3: Postpone Recurly billing by +10 years if Recurly subscription is active
-  if (recurlySubscription.state !== 'canceled') {
-    const currentBillingDate = new Date(recurlySubscription.currentPeriodEndsAt)
-    const postponedDate = new Date(currentBillingDate)
-    postponedDate.setFullYear(currentBillingDate.getFullYear() + 10)
-
-    try {
-      await rateLimiters.requestWithRetries(
-        'recurly',
-        () =>
-          RecurlyWrapper.promises.apiRequest({
-            url: `subscriptions/${recurlySubscription.uuid}/postpone`,
-            qs: { bulk: true, next_bill_date: postponedDate },
-            method: 'PUT',
-          }),
-        {
-          operation: 'postpone',
-          recurlySubscriptionId: recurlySubscription.uuid,
-        }
-      )
-    } catch (err) {
-      throw new ReportError(
-        'migrated-recurly-postpone-failed',
-        `Failed to postpone Recurly billing: ${err.message}`
-      )
-    }
-  }
-
-  // Step 4: Remove migration metadata from Stripe
-  try {
-    await rateLimiters.requestWithRetries(
-      stripeClient.serviceName,
-      () =>
-        stripeClient.updateSubscriptionMetadata(stripeSubscription.id, {
-          recurly_to_stripe_migration_status: '',
-        }),
-      {
-        operation: 'updateSubscriptionMetadata',
-        stripeSubscriptionId: stripeSubscription.id,
-        region: stripeClient.serviceName,
-      }
-    )
-  } catch (err) {
-    throw new ReportError(
-      'migrated-metadata-removal-failed',
-      `Successfully migrated to Stripe but failed to remove metadata: ${err.message}`
-    )
-  }
-
-  // Step 5: Register analytics mapping
-  try {
-    AnalyticsManager.registerAccountMapping(
-      AccountMappingHelper.generateSubscriptionToStripeMapping(
-        mongoSubscription._id,
-        stripeSubscription.id,
-        stripeClient.serviceName
-      )
-    )
-  } catch (err) {
-    throw new ReportError(
-      'migrated-analytics-mapping-failed',
-      `Successfully migrated to Stripe but failed to register analytics mapping: ${err.message}`
-    )
-  }
-
-  // Step 6. Send data to customer.io
-  try {
-    const migrationDate = new Date().toISOString().slice(0, 10)
-    const needsToUpdateTaxInfo =
-      (stripeCustomer.metadata?.taxInfoPending || '').length > 0
-
-    // TODO: request Recurly account and billingInfo to verify if tax info in Stripe is up to date
-
-    CustomerIoHandler.updateUserAttributes(adminUserId, {
-      email: mongoUserEmail || stripeCustomer.email,
-      stripe_migration: {
-        migration_date: migrationDate,
-        needs_to_update_tax_id: needsToUpdateTaxInfo,
-      },
-    })
-  } catch (err) {
-    throw new ReportError(
-      'migrated-customerio-upload-failed',
-      `Successfully migrated to Stripe but failed to upload user to customer.io: ${err.message}`
-    )
-  }
-
-  // Step 7: Release subscription schedule associated with the migration
-  const schedule = stripeSubscription.schedule
-  if (
-    schedule &&
-    typeof schedule !== 'string' &&
-    schedule.metadata?.billing_migration_id
-  ) {
-    try {
-      await rateLimiters.requestWithRetries(
-        stripeClient.serviceName,
-        () =>
-          stripeClient.stripe.subscriptionSchedules.release(schedule.id, {
-            preserve_cancel_date: true,
-          }),
-        {
-          operation: 'subscriptionSchedules.release',
-          scheduleId: schedule.id,
-          region: stripeClient.serviceName,
-        }
-      )
-    } catch (err) {
-      throw new ReportError(
-        'migrated-schedule-release-failed',
-        `Successfully migrated to Stripe but failed to release subscription schedule: ${err.message}`
-      )
-    }
-  }
-}
-
-function parseArgs() {
-  const args = minimist(process.argv.slice(2), {
-    string: [
-      'output',
-      'concurrency',
-      'recurly-rate-limit',
-      'recurly-api-retries',
-      'recurly-retry-delay-ms',
-      'stripe-rate-limit',
-      'stripe-api-retries',
-      'stripe-retry-delay-ms',
-    ],
-    boolean: ['commit', 'help'],
-    default: {
-      commit: false,
-      concurrency: 10,
-      'recurly-rate-limit': DEFAULT_RECURLY_RATE_LIMIT,
-      'recurly-api-retries': DEFAULT_RECURLY_API_RETRIES,
-      'recurly-retry-delay-ms': DEFAULT_RECURLY_RETRY_DELAY_MS,
-      'stripe-rate-limit': DEFAULT_STRIPE_RATE_LIMIT,
-      'stripe-api-retries': DEFAULT_STRIPE_API_RETRIES,
-      'stripe-retry-delay-ms': DEFAULT_STRIPE_RETRY_DELAY_MS,
-    },
-  })
-
-  if (args.help) {
-    usage()
-    process.exit(0)
-  }
-
-  const inputFile = args._[0]
-  const paramsSchema = z.object({
-    output: z.string().optional(),
-    commit: z.boolean(),
-    concurrency: z.number().int().positive(),
-    recurlyRateLimit: z.number().positive(),
-    recurlyApiRetries: z.number().int().nonnegative(),
-    recurlyRetryDelayMs: z.number().int().nonnegative(),
-    stripeRateLimit: z.number().positive(),
-    stripeApiRetries: z.number().int().nonnegative(),
-    stripeRetryDelayMs: z.number().int().nonnegative(),
-    inputFile: z.string().optional(),
-  })
-
-  try {
-    return paramsSchema.parse({
-      output: args.output,
-      commit: args.commit,
-      concurrency: Number(args.concurrency),
-      recurlyRateLimit: Number(args['recurly-rate-limit']),
-      recurlyApiRetries: Number(args['recurly-api-retries']),
-      recurlyRetryDelayMs: Number(args['recurly-retry-delay-ms']),
-      stripeRateLimit: Number(args['stripe-rate-limit']),
-      stripeApiRetries: Number(args['stripe-api-retries']),
-      stripeRetryDelayMs: Number(args['stripe-retry-delay-ms']),
-      inputFile,
-    })
-  } catch (err) {
-    console.error('Invalid arguments:', err.message)
-    usage()
-    process.exit(1)
-  }
-}
-
-try {
-  await scriptRunner(main)
-  process.exit(0)
-} catch (error) {
-  console.error(error)
-  process.exit(1)
-}

+ 0 - 130
services/web/scripts/stripe/helpers.mjs

@@ -1,130 +0,0 @@
-/* eslint-disable @overleaf/require-script-runner */
-// This file contains helper functions used by other scripts.
-// The scripts that import these helpers should use Script Runner.
-
-/**
- * @import Stripe from 'stripe'
- * @import { getRegionClient } from '../../modules/subscriptions/app/src/StripeClient.mjs'
- */
-
-/**
- * @export
- * @typedef {Object} CSVSubscriptionChange
- * @property {string} subscription_id
- * @property {string} current_lookup_key
- * @property {string} new_lookup_key
- * @property {string} current_add_on_lookup_key
- * @property {string} new_add_on_lookup_key
- */
-
-/**
- * @export
- * @typedef {'renewal' | 'now'} Timeframe
- */
-
-/**
- * @export
- * @typedef {ReturnType<typeof getRegionClient>} StripeClient
- */
-
-/**
- * Custom error class for reportable errors that should be written to CSV output
- */
-export 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
-  }
-}
-
-/**
- * Gets the product ID from a Stripe Subscription Item
- *
- * @param {Stripe.SubscriptionItem | Stripe.SubscriptionSchedule.Phase.Item} item
- * @returns {string}
- */
-export function getProductIdFromItem(item) {
-  const product =
-    typeof item.price === 'string'
-      ? null
-      : 'product' in item.price
-        ? item.price.product
-        : null
-  return typeof product === 'string' ? product : (product?.id ?? '')
-}
-
-/**
- * Gets the price ID from a Stripe Subscription Item
- *
- * @param {Stripe.SubscriptionItem | Stripe.SubscriptionSchedule.Phase.Item} item
- * @returns {string}
- */
-export function getPriceIdFromItem(item) {
-  return typeof item.price === 'string' ? item.price : (item.price?.id ?? '')
-}
-
-/**
- * Gets the product ID from a Stripe Price object
- *
- * @param {Stripe.Price} price
- * @returns {string}
- */
-export function getProductIdFromPrice(price) {
-  return typeof price.product === 'string'
-    ? price.product
-    : (price.product?.id ?? '')
-}
-
-/**
- * Sleep function to respect Stripe rate limits (100 requests per second)
- */
-export async function rateLimitSleep() {
-  return new Promise(resolve => setTimeout(resolve, 50))
-}
-
-/**
- * Convert amount to minor units (cents for most currencies)
- * Some currencies like JPY, KRW, CLP, VND don't have cents
- *
- * Copied from services/web/frontend/js/shared/utils/currency.ts
- *
- * @param {number} amount - Amount in major units (dollars, euros, etc.)
- * @param {string} currency - Currency code (lowercase)
- * @returns {number} Amount in minor units
- */
-export function convertToMinorUnits(amount, currency) {
-  const isNoCentsCurrency = ['clp', 'jpy', 'krw', 'vnd'].includes(
-    currency.toLowerCase()
-  )
-
-  // Determine the multiplier based on currency
-  let multiplier = 100 // default for most currencies (2 decimal places)
-
-  if (isNoCentsCurrency) {
-    multiplier = 1 // no decimal places
-  }
-
-  // Convert and round to an integer
-  return Math.round(amount * multiplier)
-}
-
-/**
- * Convert amount from minor units (cents for most currencies)
- * Some currencies like JPY, KRW, CLP, VND don't have cents
- *
- * Copied from services/web/modules/subscriptions/app/src/StripeClient.mjs
- *
- * @param {number} amount - price in the smallest currency unit (e.g. dollar cents, CLP units, ...)
- * @param {StripeCurrencyCode} currency - currency code
- * @return {number}
- */
-export function convertFromMinorUnits(amount, currency) {
-  const isNoCentsCurrency = ['clp', 'jpy', 'krw', 'vnd'].includes(
-    currency.toLowerCase()
-  )
-  return isNoCentsCurrency ? amount : amount / 100
-}

+ 0 - 282
services/web/scripts/stripe/import_products_to_environment.mjs

@@ -1,282 +0,0 @@
-#!/usr/bin/env node
-
-/**
- * This script imports products and prices into a Stripe environment from a JSON file
- *
- * Usage:
- *   node scripts/stripe/import_products_to_environment.mjs -f fileName --region us [options]
- *   node scripts/stripe/import_products_to_environment.mjs -f fileName --region uk [options]
- *
- * Options:
- *   -f                 Path to import JSON file (from export_products_from_environment.mjs)
- *   --region           Required. Stripe region to import to (us or uk)
- *   --commit           Actually perform the imports (default: dry-run mode)
- *
- * Examples:
- *   # Dry run import to US region
- *   node scripts/stripe/import_products_to_environment.mjs -f export.json --region us
- *
- *   # Commit import to UK region
- *   node scripts/stripe/import_products_to_environment.mjs -f export.json --region uk --commit
- */
-
-import minimist from 'minimist'
-import fs from 'node:fs'
-import { z } from '../../app/src/infrastructure/Validation.mjs'
-import { scriptRunner } from '../lib/ScriptRunner.mjs'
-import { getRegionClient } from '../../modules/subscriptions/app/src/StripeClient.mjs'
-
-/**
- * @import Stripe from 'stripe'
- */
-
-const paramsSchema = z.object({
-  f: z.string(),
-  region: z.enum(['us', 'uk']),
-  commit: z.boolean().default(false),
-})
-
-/**
- * Sleep function to respect Stripe rate limits (100 requests per second)
- */
-async function rateLimitSleep() {
-  return new Promise(resolve => setTimeout(resolve, 50))
-}
-
-/**
- * @typedef {object} ImportProduct
- * @property {Stripe.Product} product
- * @property {Stripe.Price[]} prices
- */
-
-/**
- * @typedef {object} ImportData
- * @property {string} exportedAt
- * @property {number} totalProducts
- * @property {number} totalPrices
- * @property {ImportProduct[]} products
- */
-
-/**
- * Load import data from JSON file
- *
- * @param {string} filePath
- * @returns {ImportData}
- */
-function loadImportData(filePath) {
-  const content = fs.readFileSync(filePath, 'utf-8')
-  const data = JSON.parse(content)
-
-  if (!data.products || !Array.isArray(data.products)) {
-    throw new Error('Invalid import file format: missing products array')
-  }
-
-  // Validate structure of each product entry
-  for (const entry of data.products) {
-    if (!entry.product) {
-      throw new Error(
-        'Invalid import file format: product entry missing "product" field'
-      )
-    }
-    if (!entry.prices || !Array.isArray(entry.prices)) {
-      throw new Error(
-        `Invalid import file format: product ${entry.product.id || 'unknown'} missing "prices" array`
-      )
-    }
-  }
-
-  return data
-}
-
-/**
- * Create a product in Stripe
- *
- * @param {Stripe} stripe
- * @param {Stripe.Product} productData
- * @returns {Promise<Stripe.Product>}
- */
-async function createProduct(stripe, productData) {
-  const params = {
-    name: productData.name,
-    active: productData.active,
-    metadata: productData.metadata,
-    images: productData.images,
-  }
-
-  if (productData.description) {
-    params.description = productData.description
-  }
-
-  if (productData.tax_code) {
-    params.tax_code = productData.tax_code
-  }
-
-  return await stripe.products.create(params)
-}
-
-/**
- * Create a price in Stripe
- *
- * @param {Stripe} stripe
- * @param {Stripe.Price} priceData
- * @param {string} productId
- * @returns {Promise<Stripe.Price>}
- */
-async function createPrice(stripe, priceData, productId) {
-  const params = {
-    product: productId,
-    currency: priceData.currency,
-    unit_amount: Number.parseInt(priceData.unit_amount),
-    billing_scheme: priceData.billing_scheme,
-    recurring: {
-      interval: priceData.recurring.interval,
-      interval_count: Number.parseInt(priceData.recurring.interval_count),
-    },
-    lookup_key: priceData.lookup_key,
-    active: priceData.active,
-    metadata: priceData.metadata,
-    nickname: priceData.nickname,
-    tax_behavior: priceData.tax_behavior,
-  }
-
-  return await stripe.prices.create(params)
-}
-
-/**
- * Import products and prices to Stripe
- *
- * @param {ImportData} importData
- * @param {Stripe} stripe
- * @param {boolean} commit
- * @param {function} trackProgress
- * @returns {Promise<{productsCreated: number, productsSkipped: number, productsErrored: number, pricesCreated: number, pricesErrored: number}>}
- */
-async function importToStripe(importData, stripe, commit, trackProgress) {
-  const results = {
-    productsCreated: 0,
-    productsErrored: 0,
-    pricesCreated: 0,
-    pricesErrored: 0,
-  }
-
-  for (const entry of importData.products) {
-    const { product, prices } = entry
-
-    try {
-      // Create product (Stripe will generate a new ID for this environment)
-      let createdProduct
-      if (commit) {
-        createdProduct = await createProduct(stripe, product)
-        await trackProgress(
-          `Created product: ${createdProduct.id} (${product.name})`
-        )
-        await rateLimitSleep()
-      } else {
-        await trackProgress(`[DRY RUN] Would create product: ${product.name}`)
-      }
-
-      results.productsCreated++
-
-      // Create prices for this product
-      for (const price of prices) {
-        try {
-          if (commit) {
-            const createdPrice = await createPrice(
-              stripe,
-              price,
-              createdProduct.id
-            )
-            await trackProgress(
-              `  Created price: ${createdPrice.id} (${price.currency}, ${price.unit_amount})`
-            )
-            await rateLimitSleep()
-          } else {
-            await trackProgress(
-              `  [DRY RUN] Would create price: ${price.nickname} (${price.currency}, ${price.unit_amount})`
-            )
-          }
-
-          results.pricesCreated++
-        } catch (error) {
-          await trackProgress(
-            `  ERROR creating price ${price.id}: ${error.message}`
-          )
-          results.pricesErrored++
-        }
-      }
-    } catch (error) {
-      await trackProgress(
-        `ERROR creating product ${product.id}: ${error.message}`
-      )
-      results.productsErrored++
-    }
-  }
-
-  return results
-}
-
-async function main(trackProgress) {
-  const parseResult = paramsSchema.safeParse(
-    minimist(process.argv.slice(2), {
-      boolean: ['commit'],
-      string: ['region', 'f'],
-    })
-  )
-
-  if (!parseResult.success) {
-    throw new Error(`Invalid parameters: ${parseResult.error.message}`)
-  }
-
-  const { f: inputFile, region, commit } = parseResult.data
-
-  const mode = commit ? 'COMMIT MODE' : 'DRY RUN MODE'
-  await trackProgress(`Starting import in ${mode} to region: ${region}`)
-
-  await trackProgress(`Loading import data from: ${inputFile}`)
-  const importData = loadImportData(inputFile)
-  await trackProgress(
-    `Loaded ${importData.totalProducts} products and ${importData.totalPrices} prices`
-  )
-
-  const stripe = getRegionClient(region).stripe
-
-  await trackProgress('Processing import...')
-  const results = await importToStripe(
-    importData,
-    stripe,
-    commit,
-    trackProgress
-  )
-
-  await trackProgress('IMPORT SUMMARY')
-  await trackProgress(
-    `Products ${commit ? 'created' : 'would be created'}: ${results.productsCreated}`
-  )
-  await trackProgress(`Products errored: ${results.productsErrored}`)
-  await trackProgress(
-    `Prices ${commit ? 'created' : 'would be created'}: ${results.pricesCreated}`
-  )
-  await trackProgress(`Prices errored: ${results.pricesErrored}`)
-
-  if (results.productsErrored > 0 || results.pricesErrored > 0) {
-    await trackProgress(
-      'WARNING: Some items failed to import. Check the logs above.'
-    )
-  }
-
-  if (!commit) {
-    await trackProgress(
-      'This was a dry run. Use --commit to actually create the items.'
-    )
-  }
-
-  await trackProgress(`Import completed in ${mode}`)
-}
-
-try {
-  await scriptRunner(main)
-  process.exit(0)
-} catch (error) {
-  console.error('Script failed:', error.message)
-  process.exit(1)
-}

+ 0 - 512
services/web/scripts/stripe/rollback-finalized-stripe-migration.mjs

@@ -1,512 +0,0 @@
-#!/usr/bin/env node
-
-/**
- * This script rolls back the cutover of a subscription from Recurly to Stripe.
- *
- * IMPORTANT: This script does NOT cancel the Stripe subscription.
- * Use scripts/stripe/bulk-cancel-subscriptions.mjs to cancel them separately.
- *
- * It undoes everything done by finalize-stripe-subscription-migration.mjs
- *
- * Usage:
- *   node scripts/stripe/rollback-finalized-stripe-migration.mjs [OPTS] [INPUT-FILE]
- *
- * Options:
- *   --output PATH          Output file path (default: /tmp/rollback_output_<timestamp>.csv)
- *   --commit               Apply changes (without this, runs in dry-run mode)
- *   --throttle DURATION    Minimum time between requests in ms (default: 40)
- *   --help                 Show help message
- *
- * CSV Input Format:
- *   recurly_account_code,target_stripe_account,stripe_customer_id
- *   507f1f77bcf86cd799439011,stripe-uk,cus_1234567890abcdef
- *
- * CSV Output Format:
- *   recurly_account_code,target_stripe_account,stripe_customer_id,status,note
- *
- * Note: recurly_account_code is the Overleaf user ID (admin_id)
- */
-
-import fs from 'node:fs'
-import path from 'node:path'
-import * as csv from 'csv'
-import minimist from 'minimist'
-import PQueue from 'p-queue'
-import { z } from '../../app/src/infrastructure/Validation.mjs'
-import { scriptRunner } from '../lib/ScriptRunner.mjs'
-import { getRegionClient } from '../../modules/subscriptions/app/src/StripeClient.mjs'
-import RecurlyWrapper from '../../app/src/Features/Subscription/RecurlyWrapper.mjs'
-import { Subscription } from '../../app/src/models/Subscription.mjs'
-import AnalyticsManager from '../../app/src/Features/Analytics/AnalyticsManager.mjs'
-import CustomerIoHandler from '../../modules/customer-io/app/src/CustomerIoHandler.mjs'
-import { ReportError } from './helpers.mjs'
-import AccountMappingHelper from '../../app/src/Features/Analytics/AccountMappingHelper.mjs'
-import {
-  createRateLimitedApiWrappers,
-  DEFAULT_RECURLY_RATE_LIMIT,
-  DEFAULT_STRIPE_RATE_LIMIT,
-  DEFAULT_RECURLY_API_RETRIES,
-  DEFAULT_RECURLY_RETRY_DELAY_MS,
-  DEFAULT_STRIPE_API_RETRIES,
-  DEFAULT_STRIPE_RETRY_DELAY_MS,
-} from './RateLimiter.mjs'
-
-// rate limiters - initialized in main()
-let rateLimiters
-
-function usage() {
-  console.error(`Usage: node scripts/stripe/rollback-finalized-stripe-migration.mjs [OPTS] [INPUT-FILE]
-
-Options:
-    --output PATH                 Output file path (default: /tmp/rollback_output_<timestamp>.csv)
-    --commit                      Apply changes (without this, runs in dry-run mode)
-    --concurrency N               Number of rollbacks to process concurrently (default: 10)
-    --recurly-rate-limit N        Requests per second for Recurly (default: ${DEFAULT_RECURLY_RATE_LIMIT})
-    --recurly-api-retries N       Number of retries on Recurly 429s (default: ${DEFAULT_RECURLY_API_RETRIES})
-    --recurly-retry-delay-ms N    Delay between Recurly retries in ms (default: ${DEFAULT_RECURLY_RETRY_DELAY_MS})
-    --stripe-rate-limit N         Requests per second for Stripe (default: ${DEFAULT_STRIPE_RATE_LIMIT})
-    --stripe-api-retries N        Number of retries on Stripe 429s (default: ${DEFAULT_STRIPE_API_RETRIES})
-    --stripe-retry-delay-ms N     Delay between Stripe retries in ms (default: ${DEFAULT_STRIPE_RETRY_DELAY_MS})
-    --help                        Show this help message
-
-Note: This script does NOT cancel Stripe subscriptions. Use scripts/stripe/bulk-cancel-subscriptions.mjs separately.
-`)
-}
-
-async function main(trackProgress) {
-  const opts = parseArgs()
-  const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
-  const outputFile = opts.output ?? `/tmp/rollback_output_${timestamp}.csv`
-
-  // initialize rate limiters
-  rateLimiters = createRateLimitedApiWrappers({
-    recurlyRateLimit: opts.recurlyRateLimit,
-    recurlyApiRetries: opts.recurlyApiRetries,
-    recurlyRetryDelayMs: opts.recurlyRetryDelayMs,
-    stripeRateLimit: opts.stripeRateLimit,
-    stripeApiRetries: opts.stripeApiRetries,
-    stripeRetryDelayMs: opts.stripeRetryDelayMs,
-  })
-
-  await trackProgress('Starting Stripe to Recurly rollback')
-  await trackProgress(`Run mode: ${opts.commit ? 'COMMIT' : 'DRY RUN'}`)
-  await trackProgress(
-    'Note: Stripe subscriptions are NOT cancelled by this script'
-  )
-  await trackProgress(
-    `Rate limits: Recurly ${opts.recurlyRateLimit}/s, Stripe ${opts.stripeRateLimit}/s`
-  )
-  await trackProgress(`Concurrency: ${opts.concurrency}`)
-
-  const inputStream = opts.inputFile
-    ? fs.createReadStream(opts.inputFile)
-    : process.stdin
-  const csvReader = getCsvReader(inputStream)
-  const csvWriter = getCsvWriter(outputFile)
-
-  await trackProgress(`Output: ${outputFile}`)
-
-  let processedCount = 0
-  let successCount = 0
-  let errorCount = 0
-
-  const queue = new PQueue({ concurrency: opts.concurrency })
-  const maxQueueSize = opts.concurrency
-
-  try {
-    for await (const input of csvReader) {
-      // throttle input if queue is full
-      if (queue.size >= maxQueueSize) {
-        await queue.onSizeLessThan(maxQueueSize)
-      }
-
-      queue.add(async () => {
-        try {
-          const result = await processRollback(input, opts.commit)
-
-          csvWriter.write({
-            recurly_account_code: input.recurly_account_code,
-            target_stripe_account: input.target_stripe_account,
-            stripe_customer_id: input.stripe_customer_id,
-            status: result.status,
-            note: result.note,
-          })
-
-          if (
-            result.status === 'rolled-back' ||
-            result.status === 'validated' ||
-            result.status === 'already-recurly'
-          ) {
-            successCount++
-          } else {
-            errorCount++
-          }
-        } catch (err) {
-          errorCount++
-          if (err instanceof ReportError) {
-            csvWriter.write({
-              recurly_account_code: input.recurly_account_code,
-              target_stripe_account: input.target_stripe_account,
-              stripe_customer_id: input.stripe_customer_id,
-              status: err.status,
-              note: err.message,
-            })
-          } else {
-            csvWriter.write({
-              recurly_account_code: input.recurly_account_code,
-              target_stripe_account: input.target_stripe_account,
-              stripe_customer_id: input.stripe_customer_id,
-              status: 'error',
-              note: err.message,
-            })
-          }
-        }
-
-        processedCount++
-        if (processedCount % 25 === 0) {
-          await trackProgress(
-            `Progress: ${processedCount} processed, ${successCount} successful, ${errorCount} errors`
-          )
-        }
-      })
-    }
-  } finally {
-    // wait for all queued tasks to complete
-    await queue.onIdle()
-  }
-
-  await trackProgress(`✅ Total processed: ${processedCount}`)
-  if (opts.commit) {
-    await trackProgress(`✅ Successfully rolled back: ${successCount}`)
-  } else {
-    await trackProgress(`✅ Successfully validated: ${successCount}`)
-    await trackProgress('ℹ️  DRY RUN: No changes were applied')
-  }
-  await trackProgress(`❌ Errors: ${errorCount}`)
-  await trackProgress('🎉 Script completed!')
-
-  csvWriter.end()
-  await CustomerIoHandler.closeCustomerIo()
-}
-
-function getCsvReader(inputStream) {
-  const parser = csv.parse({ columns: true })
-  inputStream.pipe(parser)
-  return parser
-}
-
-function getCsvWriter(outputFile) {
-  fs.mkdirSync(path.dirname(outputFile), { recursive: true })
-  const outputStream = fs.createWriteStream(outputFile)
-
-  const writer = csv.stringify({
-    columns: [
-      'recurly_account_code',
-      'target_stripe_account',
-      'stripe_customer_id',
-      'status',
-      'note',
-    ],
-    header: true,
-  })
-
-  writer.on('error', err => {
-    console.error(err)
-    process.exit(1)
-  })
-
-  writer.pipe(outputStream)
-  return writer
-}
-
-async function processRollback(input, commit) {
-  const {
-    recurly_account_code: accountCode,
-    target_stripe_account: targetStripeAccount,
-  } = input
-
-  // Get Stripe client for the target account (strip 'stripe-' prefix if present)
-  const region = targetStripeAccount.replace(/^stripe-/, '')
-  const stripeClient = getRegionClient(region)
-
-  // 1. Fetch Mongo subscription
-  const mongoSubscription = await Subscription.findOne({
-    admin_id: accountCode,
-  }).exec()
-  if (!mongoSubscription) {
-    throw new ReportError(
-      'no-mongo-subscription',
-      'No subscription found in Mongo'
-    )
-  }
-
-  // 2. Check if already using Recurly
-  if (
-    mongoSubscription.recurlySubscription_id &&
-    !mongoSubscription.paymentProvider?.service?.includes('stripe')
-  ) {
-    throw new ReportError(
-      'already-recurly',
-      'Subscription already using Recurly'
-    )
-  }
-
-  // 3. Verify subscription is using Stripe
-  if (!mongoSubscription.paymentProvider?.service?.includes('stripe')) {
-    throw new ReportError(
-      'not-using-stripe',
-      'Subscription is not using Stripe'
-    )
-  }
-
-  const stripeSubscriptionId = mongoSubscription.paymentProvider.subscriptionId
-
-  // 4. Find Recurly subscription ID from Stripe metadata
-  let recurlySubscriptionId
-  try {
-    const stripeSubData = await rateLimiters.requestWithRetries(
-      stripeClient.serviceName,
-      () => stripeClient.stripe.subscriptions.retrieve(stripeSubscriptionId),
-      {
-        operation: 'subscriptions.retrieve',
-        stripeSubscriptionId,
-        region: stripeClient.serviceName,
-      }
-    )
-    recurlySubscriptionId = stripeSubData.metadata?.recurly_subscription_id
-    if (!recurlySubscriptionId) {
-      throw new ReportError(
-        'no-recurly-id-in-metadata',
-        'No recurly_subscription_id found in Stripe metadata'
-      )
-    }
-  } catch (err) {
-    if (err instanceof ReportError) throw err
-    throw new ReportError(
-      'stripe-fetch-error',
-      `Failed to fetch Stripe subscription: ${err.message}`
-    )
-  }
-
-  // 5. Fetch Recurly subscription to get original billing date
-  let recurlySubscription
-  try {
-    recurlySubscription = await rateLimiters.requestWithRetries(
-      'recurly',
-      () => RecurlyWrapper.promises.getSubscription(recurlySubscriptionId, {}),
-      {
-        operation: 'getSubscription',
-        recurlySubscriptionId,
-      }
-    )
-  } catch (err) {
-    throw new ReportError(
-      'no-recurly-subscription',
-      `Recurly subscription not found: ${err.message}`
-    )
-  }
-
-  // 6. If commit mode, perform rollback
-  if (commit) {
-    await performRollback(mongoSubscription, recurlySubscription, stripeClient)
-    return {
-      status: 'rolled-back',
-      note: 'Successfully rolled back to Recurly',
-    }
-  } else {
-    return {
-      status: 'validated',
-      note: 'DRY RUN: Ready to rollback to Recurly',
-    }
-  }
-}
-
-async function performRollback(
-  mongoSubscription,
-  recurlySubscription,
-  stripeClient
-) {
-  const adminUserId = mongoSubscription.admin_id.toString()
-  const recurlySubscriptionId = recurlySubscription.uuid
-  const stripeSubscriptionId = mongoSubscription.paymentProvider.subscriptionId
-
-  // Step 1: Restore Recurly fields in Mongo
-  mongoSubscription.recurlySubscription_id = recurlySubscriptionId
-  mongoSubscription.recurlyStatus = {
-    state: recurlySubscription.state,
-    trialStartedAt: recurlySubscription.trial_started_at,
-    trialEndsAt: recurlySubscription.trial_ends_at,
-  }
-  mongoSubscription.paymentProvider = undefined
-  await mongoSubscription.save()
-
-  // Step 2: Emit rollback analytics event
-  AnalyticsManager.recordEventForUserInBackground(
-    adminUserId,
-    'subscription-rolled-back-from-stripe',
-    {
-      subscriptionId: mongoSubscription._id.toString(),
-      migrationDirection: 'stripe-to-recurly',
-    }
-  )
-
-  // Step 3: Un-postpone Recurly billing by 10 years if next billing period was postponed
-  const currentPeriodEnd = new Date(recurlySubscription.current_period_ends_at)
-  const nineYearsFromNow = new Date()
-  nineYearsFromNow.setFullYear(new Date().getFullYear() + 9)
-
-  if (currentPeriodEnd > nineYearsFromNow) {
-    const nextBillingDate = new Date(currentPeriodEnd)
-    nextBillingDate.setFullYear(currentPeriodEnd.getFullYear() - 10)
-    const targetBillingDateIsInFuture = nextBillingDate.getTime() > Date.now()
-
-    if (targetBillingDateIsInFuture) {
-      try {
-        await rateLimiters.requestWithRetries(
-          'recurly',
-          () =>
-            RecurlyWrapper.promises.apiRequest({
-              url: `subscriptions/${recurlySubscriptionId}/postpone`,
-              qs: { bulk: true, next_bill_date: nextBillingDate },
-              method: 'PUT',
-            }),
-          {
-            operation: 'postpone',
-            recurlySubscriptionId,
-          }
-        )
-      } catch (err) {
-        throw new ReportError(
-          'rolled-back-recurly-restore-failed',
-          `Restored Mongo but failed to restore Recurly billing: ${err.message}`
-        )
-      }
-    } else {
-      throw new ReportError(
-        'rolled-back-recurly-restore-failed',
-        `Restored Mongo and Recurly but failed to restore Recurly billing: target next billing date is in the past (${nextBillingDate.toISOString()})`
-      )
-    }
-  }
-
-  // Step 4: Restore migration metadata to Stripe
-  try {
-    await rateLimiters.requestWithRetries(
-      stripeClient.serviceName,
-      () =>
-        stripeClient.updateSubscriptionMetadata(stripeSubscriptionId, {
-          recurly_to_stripe_migration_status: 'in_progress',
-        }),
-      {
-        operation: 'updateSubscriptionMetadata',
-        stripeSubscriptionId,
-        region: stripeClient.serviceName,
-      }
-    )
-  } catch (err) {
-    throw new ReportError(
-      'rolled-back-metadata-restore-failed',
-      `Restored Mongo and Recurly but failed to restore Stripe metadata: ${err.message}`
-    )
-  }
-
-  // Step 5: Register analytics mapping for the Recurly subscription
-  try {
-    AnalyticsManager.registerAccountMapping(
-      AccountMappingHelper.generateSubscriptionToRecurlyMapping(
-        mongoSubscription._id,
-        recurlySubscriptionId,
-        'recurly'
-      )
-    )
-  } catch (err) {
-    throw new ReportError(
-      'rolled-back-analytics-mapping-failed',
-      `Restored Mongo, Recurly, Stripe but failed to register analytics mapping: ${err.message}`
-    )
-  }
-
-  // Step 5: Remove migration date from customer.io
-  try {
-    CustomerIoHandler.updateUserAttributes(adminUserId, {
-      stripe_migration: {},
-    })
-  } catch (err) {
-    throw new ReportError(
-      'rolled-back-customerio-update-failed',
-      `Restored Mongo, Recurly, Stripe but failed to update user in customer.io: ${err.message}`
-    )
-  }
-}
-
-function parseArgs() {
-  const args = minimist(process.argv.slice(2), {
-    string: [
-      'output',
-      'concurrency',
-      'recurly-rate-limit',
-      'recurly-api-retries',
-      'recurly-retry-delay-ms',
-      'stripe-rate-limit',
-      'stripe-api-retries',
-      'stripe-retry-delay-ms',
-    ],
-    boolean: ['commit', 'help'],
-    default: {
-      commit: false,
-      concurrency: 10,
-      'recurly-rate-limit': DEFAULT_RECURLY_RATE_LIMIT,
-      'recurly-api-retries': DEFAULT_RECURLY_API_RETRIES,
-      'recurly-retry-delay-ms': DEFAULT_RECURLY_RETRY_DELAY_MS,
-      'stripe-rate-limit': DEFAULT_STRIPE_RATE_LIMIT,
-      'stripe-api-retries': DEFAULT_STRIPE_API_RETRIES,
-      'stripe-retry-delay-ms': DEFAULT_STRIPE_RETRY_DELAY_MS,
-    },
-  })
-
-  if (args.help) {
-    usage()
-    process.exit(0)
-  }
-
-  const inputFile = args._[0]
-  const paramsSchema = z.object({
-    output: z.string().optional(),
-    commit: z.boolean(),
-    concurrency: z.number().int().positive(),
-    recurlyRateLimit: z.number().positive(),
-    recurlyApiRetries: z.number().int().nonnegative(),
-    recurlyRetryDelayMs: z.number().int().nonnegative(),
-    stripeRateLimit: z.number().positive(),
-    stripeApiRetries: z.number().int().nonnegative(),
-    stripeRetryDelayMs: z.number().int().nonnegative(),
-    inputFile: z.string().optional(),
-  })
-
-  try {
-    return paramsSchema.parse({
-      output: args.output,
-      commit: args.commit,
-      concurrency: Number(args.concurrency),
-      recurlyRateLimit: Number(args['recurly-rate-limit']),
-      recurlyApiRetries: Number(args['recurly-api-retries']),
-      recurlyRetryDelayMs: Number(args['recurly-retry-delay-ms']),
-      stripeRateLimit: Number(args['stripe-rate-limit']),
-      stripeApiRetries: Number(args['stripe-api-retries']),
-      stripeRetryDelayMs: Number(args['stripe-retry-delay-ms']),
-      inputFile,
-    })
-  } catch (err) {
-    console.error('Invalid arguments:', err.message)
-    usage()
-    process.exit(1)
-  }
-}
-
-try {
-  await scriptRunner(main)
-  process.exit(0)
-} catch (error) {
-  console.error(error)
-  process.exit(1)
-}

+ 0 - 585
services/web/scripts/stripe/rollback_price_changes.mjs

@@ -1,585 +0,0 @@
-#!/usr/bin/env node
-
-/**
- * Rollback pending price changes for Stripe subscriptions
- *
- * This script removes pending subscription schedule changes that were created by the
- * change_existing_subscription_prices.mjs script. It only removes schedules that
- * are purely price changes on an existing plan - if the user has made any other
- * modifications (plan change, add-on changes), the pending change is left untouched.
- *
- * Usage:
- *   node scripts/stripe/rollback_price_changes.mjs [OPTIONS] [INPUT-FILE]
- *
- * Options:
- *   --region REGION        Either 'uk' or 'us' (required)
- *   --output PATH          Output file path (default: /tmp/rollback_prices_output_<timestamp>.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: 100)
- *   --help                 Show a help message
- *
- * CSV Input Format:
- *   The CSV must have the following columns (same format as change_existing_subscription_prices.mjs):
- *   - subscription_id: Stripe subscription id
- *   - current_lookup_key: Current price lookup key
- *   - new_lookup_key: New price lookup key
- *   - current_add_on_lookup_key: Current price lookup key for add-on (optional)
- *   - new_add_on_lookup_key: New price lookup key for add-on (optional)
- *
- * Output:
- *   Writes a CSV with columns:
- *   - subscription_id: The subscription id processed
- *   - status: Result status (rolled-back, skipped, validated, not-found, or error)
- *   - note: Additional information about the status
- *
- * The script will SKIP (not rollback) a subscription if:
- *   - There is no active subscription schedule
- *   - The schedule involves a plan change (user downgrade/upgrade)
- *   - The schedule involves add-on additions/removals
- *   - The schedule involves add-on quantity changes
- *   - The prices don't match what we expect from the CSV
- *
- * 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:
- *      kubectl exec -it <pod-name> -- tail -f /tmp/rollback_prices_output_<timestamp>.csv > local_backup.csv
- *
- *   2. Periodically copy the output file to your laptop:
- *      kubectl cp <pod-name>:/tmp/rollback_prices_output_<timestamp>.csv ./backup.csv
- *
- *   3. Write to stdout and capture locally:
- *      kubectl exec -it <pod-name> -- node scripts/stripe/rollback_price_changes.mjs \
- *        --region us --commit --output - input.csv > output.csv
- *
- * Examples:
- *   # Dry run (preview only)
- *   node scripts/stripe/rollback_price_changes.mjs --region us input.csv
- *
- *   # Actually perform the rollback
- *   node scripts/stripe/rollback_price_changes.mjs --region us --commit input.csv
- */
-
-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 AnalyticsManager from '../../app/src/Features/Analytics/AnalyticsManager.mjs'
-import { z } from '../../app/src/infrastructure/Validation.mjs'
-import { scriptRunner } from '../lib/ScriptRunner.mjs'
-import { getRegionClient } from '../../modules/subscriptions/app/src/StripeClient.mjs'
-import { ReportError, getProductIdFromItem } from './helpers.mjs'
-
-/**
- * @import { CSVSubscriptionChange, StripeClient } from './helpers.mjs'
- * @import Stripe from 'stripe'
- * @import { ReadStream } from 'node:fs'
- * @import { Parser } from 'csv-parse'
- * @import { Stringifier } from 'csv-stringify'
- */
-
-// 100 ms corresponds to 10 requests per second (cautious rate within Stripe's 100 req/s limit)
-const DEFAULT_THROTTLE = 100
-
-/**
- * Print usage information to stderr
- */
-function usage() {
-  console.error(`Usage: node scripts/stripe/rollback_price_changes.mjs [OPTIONS] [INPUT-FILE]
-
-Rollback pending price changes for Stripe subscriptions.
-
-This script only removes pending subscription schedules that are purely price changes on an
-existing plan. If a user has made any other modifications (plan change, add-on
-changes), the subscription is skipped.
-
-Options:
-    --region REGION        Either 'uk' or 'us' (required)
-    --output PATH          Output file path (default: /tmp/rollback_prices_output_<timestamp>.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})
-    --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<void>} 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/rollback_prices_output_${timestamp}.csv`
-
-  const stripeClient = getRegionClient(opts.region)
-
-  await trackProgress('Starting price rollback script for Stripe')
-  await trackProgress(`Region: ${opts.region}`)
-  await trackProgress(`Run mode: ${opts.commit ? 'COMMIT' : 'DRY RUN'}`)
-  await trackProgress(`Throttle: ${opts.throttle}ms between requests`)
-
-  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 skippedCount = 0
-  let errorCount = 0
-
-  let lastLoopTimestamp = 0
-  for await (const record of csvReader) {
-    const timeSinceLastLoop = Date.now() - lastLoopTimestamp
-    if (timeSinceLastLoop < opts.throttle) {
-      await setTimeout(opts.throttle - timeSinceLastLoop)
-    }
-    lastLoopTimestamp = Date.now()
-
-    processedCount++
-
-    try {
-      const result = await processRollback(record, stripeClient, opts.commit)
-
-      if (opts.commit && result.subscription) {
-        try {
-          const userId = result.subscription.customer.metadata?.userId
-          await AnalyticsManager.recordEventForUser(
-            userId,
-            'script_price_change_reversed',
-            {
-              subscriptionId: record.subscription_id,
-            }
-          )
-        } catch (err) {
-          await trackProgress(
-            `Warning: failed to record analytics event after successful price rollback for ${record.subscription_id}: ${err.message}`
-          )
-        }
-      }
-
-      csvWriter.write({
-        subscription_id: record.subscription_id,
-        status: result.status,
-        note: result.note || '',
-      })
-
-      if (result.status === 'skipped') {
-        skippedCount++
-      } else {
-        successCount++
-      }
-
-      if (processedCount % 10 === 0) {
-        await trackProgress(
-          `Processed ${processedCount} subscriptions (${successCount} ${opts.commit ? 'rolled-back' : 'validated'}, ${skippedCount} skipped, ${errorCount} errors)`
-        )
-      }
-    } catch (err) {
-      errorCount++
-      if (err instanceof ReportError) {
-        csvWriter.write({
-          subscription_id: record.subscription_id,
-          status: err.status,
-          note: err.message,
-        })
-      } else {
-        csvWriter.write({
-          subscription_id: record.subscription_id,
-          status: 'error',
-          note: err.message,
-        })
-        await trackProgress(
-          `Error processing ${record.subscription_id}: ${err.message}`
-        )
-      }
-    }
-  }
-
-  await trackProgress('\n✨ FINAL SUMMARY ✨')
-  await trackProgress(`📊 Total processed: ${processedCount}`)
-  if (opts.commit) {
-    await trackProgress(`✅ Successfully rolled back: ${successCount}`)
-  } else {
-    await trackProgress(`✅ Successfully validated: ${successCount}`)
-    await trackProgress('ℹ️  DRY RUN: No changes were applied to Stripe')
-  }
-  await trackProgress(`⏭️  Skipped: ${skippedCount}`)
-  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,
-  })
-  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_id', 'status', 'note'],
-    header: true,
-  })
-  writer.on('error', err => {
-    console.error(err)
-    process.exit(1)
-  })
-  writer.pipe(outputStream)
-  return writer
-}
-
-/**
- * Process a single subscription rollback
- * @param {CSVSubscriptionChange} record - The subscription record to process
- * @param {StripeClient} stripeClient - The Stripe client for the region
- * @param {boolean} commit - Whether to commit changes or run in dry-run mode
- * @returns {Promise<{status: string, note: string, subscription?: Stripe.Subscription}>} The result of the rollback
- */
-async function processRollback(record, stripeClient, commit) {
-  const subscription = await fetchSubscription(
-    record.subscription_id,
-    stripeClient
-  )
-
-  // Validate this is a price-only change that we created
-  const validation = await validatePriceOnlyChange(
-    record,
-    subscription,
-    stripeClient
-  )
-
-  if (!validation.isPriceOnly) {
-    return {
-      status: 'skipped',
-      note: `${validation.reason}: ${validation.detail || 'N/A'}`,
-    }
-  }
-
-  if (!commit) {
-    return {
-      status: 'validated',
-      note: `Would release subscription schedule: ${validation.scheduleId}`,
-    }
-  }
-
-  // Safe to release - this is a price-only change matching our expected values
-  await stripeClient.stripe.subscriptionSchedules.release(validation.scheduleId)
-
-  return {
-    status: 'rolled-back',
-    note: `Released subscription schedule: ${validation.scheduleId}`,
-    subscription,
-  }
-}
-
-/**
- * Fetch a subscription from Stripe
- * @param {string} subscriptionId - The Stripe subscription id
- * @param {StripeClient} stripeClient - The Stripe client for the region
- * @returns {Promise<Stripe.Subscription>} The subscription with expanded schedule
- * @throws {ReportError} If subscription is not found
- */
-async function fetchSubscription(subscriptionId, stripeClient) {
-  try {
-    const subscription = await stripeClient.stripe.subscriptions.retrieve(
-      subscriptionId,
-      {
-        expand: [
-          'schedule',
-          'schedule.phases.items.price',
-          'items.data.price',
-          'customer',
-        ],
-      }
-    )
-    return subscription
-  } catch (err) {
-    if (err.type === 'StripeInvalidRequestError' && err.statusCode === 404) {
-      throw new ReportError('not-found', 'subscription not found')
-    }
-    throw err
-  }
-}
-
-/**
- * Validate that the subscription schedule is a price-only change created by our
- * price increase script, and not a user-initiated plan change or add-on modification.
- *
- * @param {CSVSubscriptionChange} record - The CSV record with expected values
- * @param {Stripe.Subscription} subscription - The Stripe subscription
- * @param {StripeClient} stripeClient - The Stripe client for the region
- * @returns {Promise<{ isPriceOnly: boolean, reason?: string, detail?: string, scheduleId?: string }>}
- */
-async function validatePriceOnlyChange(record, subscription, stripeClient) {
-  // Must have a subscription schedule
-  if (!subscription.schedule) {
-    return {
-      isPriceOnly: false,
-      reason: 'no-pending-change',
-      detail: 'subscription has no schedule to rollback',
-    }
-  }
-
-  // Subscription must be updatable
-  const inactiveStatuses = [
-    'incomplete',
-    'incomplete_expired',
-    'canceled',
-    'trialing',
-  ]
-  if (inactiveStatuses.includes(subscription.status)) {
-    return {
-      isPriceOnly: false,
-      reason: 'inactive',
-      detail: `subscription status: ${subscription.status}`,
-    }
-  }
-
-  // Subscription should not be scheduled to be canceled
-  if (subscription.cancel_at_period_end) {
-    return {
-      isPriceOnly: false,
-      reason: 'inactive',
-      detail: 'subscription is scheduled to be canceled at period end',
-    }
-  }
-
-  // Get the schedule (already expanded in subscription fetch)
-  const schedule =
-    typeof subscription.schedule === 'string' ? null : subscription.schedule
-
-  if (!schedule) {
-    return {
-      isPriceOnly: false,
-      reason: 'no-pending-change',
-      detail: 'subscription schedule not expanded',
-    }
-  }
-
-  const scheduleId = schedule.id
-
-  // Schedule must not be released already
-  if (schedule.status === 'released') {
-    return {
-      isPriceOnly: false,
-      reason: 'schedule-released',
-      detail: 'schedule has already been released',
-    }
-  }
-
-  // Schedule must have exactly 2 phases (current + future)
-  if (schedule.phases.length !== 2) {
-    return {
-      isPriceOnly: false,
-      reason: 'unexpected-schedule-structure',
-      detail: `expected 2 phases, got ${schedule.phases.length}`,
-    }
-  }
-
-  const currentPhase = schedule.phases[0]
-  const nextPhase = schedule.phases[1]
-
-  // All phases must have same number of items (no add-ons added/removed)
-  const currentPhaseItemCount = currentPhase.items.length
-  const subscriptionItemCount = subscription.items.data.length
-  const nextPhaseItemCount = nextPhase.items.length
-
-  if (currentPhaseItemCount !== subscriptionItemCount) {
-    return {
-      isPriceOnly: false,
-      reason: 'item-count-mismatch',
-      detail: `current phase has ${currentPhaseItemCount} items, subscription has ${subscriptionItemCount}`,
-    }
-  }
-
-  if (nextPhaseItemCount !== currentPhaseItemCount) {
-    return {
-      isPriceOnly: false,
-      reason: 'addon-change-detected',
-      detail: `next phase has ${nextPhaseItemCount} items, current has ${currentPhaseItemCount}`,
-    }
-  }
-
-  // Verify current lookup keys match expected
-  const currentLookupKeys = new Set(
-    subscription.items.data.map(item => item.price.lookup_key)
-  )
-
-  if (!currentLookupKeys.has(record.current_lookup_key)) {
-    return {
-      isPriceOnly: false,
-      reason: 'current-price-mismatch',
-      detail: `expected current_lookup_key ${record.current_lookup_key} not found in subscription`,
-    }
-  }
-
-  if (
-    record.current_add_on_lookup_key &&
-    !currentLookupKeys.has(record.current_add_on_lookup_key)
-  ) {
-    return {
-      isPriceOnly: false,
-      reason: 'current-addon-price-mismatch',
-      detail: `expected current_add_on_lookup_key ${record.current_add_on_lookup_key} not found in subscription`,
-    }
-  }
-
-  // Verify next phase price IDs match expected
-  const nextPhaseLookupKeys = new Set(
-    nextPhase.items.map(item => item.price.lookup_key)
-  )
-
-  if (!nextPhaseLookupKeys.has(record.new_lookup_key)) {
-    return {
-      isPriceOnly: false,
-      reason: 'pending-price-mismatch',
-      detail: `expected new_lookup_key ${record.new_lookup_key} not found in next phase`,
-    }
-  }
-
-  if (
-    record.new_add_on_lookup_key &&
-    !nextPhaseLookupKeys.has(record.new_add_on_lookup_key)
-  ) {
-    return {
-      isPriceOnly: false,
-      reason: 'pending-addon-price-mismatch',
-      detail: `expected new_add_on_lookup_key ${record.new_add_on_lookup_key} not found in next phase`,
-    }
-  }
-
-  // Verify quantities remain the same and products match (no plan/add-on changes)
-  for (const currentItem of currentPhase.items) {
-    const currentLookupKey = currentItem.price.lookup_key
-    const currentProductId = getProductIdFromItem(currentItem)
-
-    const nextItem = nextPhase.items.find(item => {
-      const nextLookupKey = item.price.lookup_key
-      const nextProductId = getProductIdFromItem(item)
-      // Match by product ID to handle price changes
-      return (
-        nextLookupKey === currentLookupKey || nextProductId === currentProductId
-      )
-    })
-
-    if (!nextItem) {
-      return {
-        isPriceOnly: false,
-        reason: 'item-mismatch',
-        detail: `current phase item ${currentLookupKey} not found in next phase`,
-      }
-    }
-
-    // Verify quantity hasn't changed
-    if (nextItem.quantity !== currentItem.quantity) {
-      return {
-        isPriceOnly: false,
-        reason: 'quantity-change-detected',
-        detail: `quantity change: ${currentItem.quantity} -> ${nextItem.quantity}`,
-      }
-    }
-
-    // Verify product hasn't changed
-    const nextProductId = getProductIdFromItem(nextItem)
-
-    if (
-      currentProductId &&
-      nextProductId &&
-      currentProductId !== nextProductId
-    ) {
-      return {
-        isPriceOnly: false,
-        reason: 'product-change-detected',
-        detail: `product change: ${currentProductId} -> ${nextProductId}`,
-      }
-    }
-  }
-
-  // All checks passed - this is a price-only change we created
-  return { isPriceOnly: true, scheduleId }
-}
-
-const paramsSchema = z.object({
-  region: z.enum(['uk', 'us']),
-  output: z.string().optional(),
-  commit: 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, commit: boolean, throttle: number, region: 'uk' | 'us'}} Parsed options
- */
-function parseArgs() {
-  const argv = minimist(process.argv.slice(2), {
-    string: ['throttle', 'output', 'region'],
-    boolean: ['help', '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 { region, output, commit, throttle, _ } = parseResult.data
-
-  return {
-    inputFile: _[0],
-    output,
-    commit,
-    throttle,
-    region,
-  }
-}
-
-try {
-  await scriptRunner(main)
-  process.exit(0)
-} catch (error) {
-  console.error(error)
-  process.exit(1)
-}

+ 0 - 550
services/web/scripts/stripe/update_prices_from_csv.mjs

@@ -1,550 +0,0 @@
-#!/usr/bin/env node
-
-/**
- * This script creates new price objects in Stripe from a CSV file of prices
- *
- * Usage:
- *   node scripts/stripe/update_prices_from_csv.mjs -f fileName --region us --nextVersion versionKey [options]
- *   node scripts/stripe/update_prices_from_csv.mjs -f fileName --region uk --nextVersion versionKey [options]
- *
- * Options:
- *   -f                 Path to prices CSV file
- *   --region           Required. Stripe region to process (us or uk)
- *   --nextVersion      Next version key (e.g., 'jul2025')
- *   --commit           Actually perform the updates (default: dry-run mode)
- *
- * Examples:
- *   # Dry run for US region
- *   node scripts/stripe/update_prices_from_csv.mjs -f inputFile --region us --nextVersion jul2025
- *
- *   # Commit changes for UK region
- *   node scripts/stripe/update_prices_from_csv.mjs -f inputFile --region uk --nextVersion jul2025 --commit
- */
-
-import minimist from 'minimist'
-import fs from 'node:fs'
-// https://github.com/import-js/eslint-plugin-import/issues/1810
-// eslint-disable-next-line import/no-unresolved
-import * as csv from 'csv/sync'
-import { z } from '../../app/src/infrastructure/Validation.mjs'
-import { scriptRunner } from '../lib/ScriptRunner.mjs'
-import { getRegionClient } from '../../modules/subscriptions/app/src/StripeClient.mjs'
-import PlansLocator from '../../app/src/Features/Subscription/PlansLocator.mjs'
-import {
-  convertFromMinorUnits,
-  convertToMinorUnits,
-  rateLimitSleep,
-} from './helpers.mjs'
-
-/**
- * @import Stripe from 'stripe'
- * @import { StripeCurrencyCode } from '../../types/subscription/currency'
- */
-
-const paramsSchema = z.object({
-  f: z.string(),
-  region: z.enum(['us', 'uk']),
-  nextVersion: z.string(),
-  commit: z.boolean().default(false),
-})
-
-/**
- * @typedef {object} CsvPrice
- * @property {number} amountInMinorUnits
- * @property {StripeCurrencyCode} currency
- */
-
-/**
- * Parse CSV file with price data
- *
- * @param {string} filePath
- * @param {string} nextVersion
- * @returns {Map<string, CsvPrice>}
- */
-function loadPricesFromCSV(filePath, nextVersion) {
-  const content = fs.readFileSync(filePath, 'utf-8')
-  const records = csv.parse(content, {
-    columns: true,
-  })
-
-  if (records.length === 0) {
-    throw new Error('CSV file is empty')
-  }
-
-  const priceMap = new Map()
-
-  // Get currency codes from the first record's keys (all columns except plan_code)
-  const currencies = Object.keys(records[0])
-    .filter(key => key !== 'plan_code')
-    .map(c => c.toLowerCase())
-
-  // Process each record
-  for (const record of records) {
-    const planCode = record.plan_code
-
-    // Filter out unwanted plan codes
-    if (shouldSkipPlanCode(planCode)) {
-      continue
-    }
-
-    // For each currency column, create lookup keys and store in map
-    for (const currency of currencies) {
-      const unitAmount = parseFloat(
-        record[currency.toUpperCase()] || record[currency]
-      )
-
-      if (!isNaN(unitAmount) && unitAmount > 0) {
-        const minorUnits = convertToMinorUnits(unitAmount, currency)
-        const lookupKey = buildLookupKeyForPlan(planCode, currency, nextVersion)
-
-        if (lookupKey) {
-          priceMap.set(lookupKey, {
-            amountInMinorUnits: minorUnits,
-            currency,
-          })
-        }
-      }
-    }
-  }
-
-  return priceMap
-}
-
-/**
- * Determine if a plan code should be skipped
- *
- * @param {string} planCode
- * @returns {boolean}
- */
-function shouldSkipPlanCode(planCode) {
-  if (planCode.includes('trial') || planCode.includes('paid-personal')) {
-    return true
-  }
-
-  // Skip if matches the specific pattern for non-consolidated group plans
-  const excludePattern =
-    /^group_(collaborator|professional)_\d+_(educational|enterprise)$/
-  if (excludePattern.test(planCode)) {
-    return true
-  }
-
-  return false
-}
-
-/**
- * Build the Stripe lookup key for a plan code, handling discounts and special cases
- *
- * @param {string} planCode
- * @param {string} currency
- * @param {string} version
- * @returns {string | null}
- */
-function buildLookupKeyForPlan(planCode, currency, version) {
-  // rm "enterprise" from plan code, if present
-  const planCodeWithoutEnterprise = planCode.replace('_enterprise', '')
-
-  // Check if this plan code has a discount suffix (e.g., _discount_20)
-  const discountMatch = planCodeWithoutEnterprise.match(/^(.+)_discount_(\d+)$/)
-  const hasDiscount = discountMatch !== null
-  const planCodeWithoutDiscount = hasDiscount
-    ? discountMatch[1]
-    : planCodeWithoutEnterprise
-  const discountAmount = hasDiscount ? discountMatch[2] : null
-
-  // Special case: Nonprofit group plans
-  // These are constructed manually without using PlansLocator (these are not available for sale online)
-  if (planCode.includes('nonprofit')) {
-    let lookupKey = `${planCodeWithoutDiscount}_${version}_${currency}`
-    if (discountAmount) {
-      lookupKey += `_discount_${discountAmount}`
-    }
-    return lookupKey
-  }
-
-  // Standard case: Use PlansLocator to build the lookup key
-  const lookupKey = PlansLocator.buildStripeLookupKey(
-    planCodeWithoutDiscount,
-    currency
-  )
-
-  if (!lookupKey) {
-    return null
-  }
-
-  // Replace the current version with the new version
-  const lookupKeyWithNewVersion = lookupKey.replace(
-    PlansLocator.LATEST_STRIPE_LOOKUP_KEY_VERSION,
-    version
-  )
-
-  // If the plan code had a discount, append it to the lookup key
-  if (discountAmount) {
-    return `${lookupKeyWithNewVersion}_discount_${discountAmount}`
-  }
-
-  return lookupKeyWithNewVersion
-}
-
-/**
- * Copy an existing price and update with pricing data from the CSV, if available
- *
- * @param {Stripe.Price} existingPrice
- * @param {Map<string, CsvPrice>} csvPricesByLookupKey
- * @param {string} nextVersion
- * @returns {Promise<{ success: boolean, price: Stripe.PriceCreateParams | null, error: string | null }>}
- */
-function copyPriceAndUpdate(existingPrice, csvPricesByLookupKey, nextVersion) {
-  try {
-    const nextLookupKey = existingPrice.lookup_key.replace(
-      PlansLocator.LATEST_STRIPE_LOOKUP_KEY_VERSION,
-      nextVersion
-    )
-
-    const csvData = csvPricesByLookupKey.get(nextLookupKey)
-    const unitAmount = csvData
-      ? csvData.amountInMinorUnits
-      : existingPrice.unit_amount
-
-    const nextPrice = getPriceParamsFromPriceObject(existingPrice)
-    nextPrice.unit_amount = unitAmount
-    nextPrice.lookup_key = nextLookupKey
-    // TODO: remove this after the June 2025 prices are archived
-    nextPrice.nickname = nextPrice.nickname.match(/June 2025/)
-      ? ''
-      : nextPrice.nickname
-
-    return { success: true, price: nextPrice, error: null }
-  } catch (error) {
-    return { success: false, price: null, error: error.message }
-  }
-}
-
-/**
- * Returns params for cloning a price in Stripe
- *
- * @param {Stripe.Price} priceData
- * @returns {Stripe.PriceCreateParams}
- */
-function getPriceParamsFromPriceObject(priceData) {
-  return {
-    product: priceData.product,
-    currency: priceData.currency,
-    unit_amount: Number.parseInt(priceData.unit_amount),
-    billing_scheme: priceData.billing_scheme,
-    recurring: {
-      interval: priceData.recurring.interval,
-      interval_count: Number.parseInt(priceData.recurring.interval_count),
-    },
-    lookup_key: priceData.lookup_key,
-    active: priceData.active,
-    metadata: priceData.metadata,
-    nickname: priceData.nickname,
-    tax_behavior: priceData.tax_behavior,
-  }
-}
-
-/**
- * Fetch all current version prices from Stripe
- *
- * @param {Stripe} stripe
- * @returns {Promise<Stripe.PriceCreateParams[]>}
- */
-async function fetchCurrentVersionPrices(stripe) {
-  const currentPrices = []
-  let hasMore = true
-  let startingAfter
-
-  while (hasMore) {
-    const pricesResult = await stripe.prices.list({
-      active: true,
-      limit: 100,
-      starting_after: startingAfter,
-    })
-
-    currentPrices.push(...pricesResult.data)
-    hasMore = pricesResult.has_more
-    if (hasMore) {
-      startingAfter = pricesResult.data[pricesResult.data.length - 1].id
-    }
-  }
-
-  const currentVersionPrices = currentPrices.filter(
-    price =>
-      price.lookup_key &&
-      price.lookup_key.includes(PlansLocator.LATEST_STRIPE_LOOKUP_KEY_VERSION)
-  )
-
-  return currentVersionPrices
-}
-
-/**
- * Compare CSV lookup keys with Stripe lookup keys and show differences
- *
- * @param {Map<string, CsvPrice>} csvPricesByLookupKey
- * @param {Stripe.Price[]} currentVersionPrices
- * @param {string} nextVersion
- * @param {function} trackProgress
- */
-async function compareCsvAndStripeLookupKeys(
-  csvPricesByLookupKey,
-  currentVersionPrices,
-  nextVersion,
-  trackProgress
-) {
-  // Get all CSV lookup keys
-  const csvLookupKeys = new Set(csvPricesByLookupKey.keys())
-
-  // Get all Stripe lookup keys (converted to next version)
-  const stripeLookupKeys = new Set(
-    currentVersionPrices.map(price =>
-      price.lookup_key.replace(
-        PlansLocator.LATEST_STRIPE_LOOKUP_KEY_VERSION,
-        nextVersion
-      )
-    )
-  )
-
-  // Find keys in CSV but not in Stripe
-  const inCsvNotInStripe = [...csvLookupKeys].filter(
-    key => !stripeLookupKeys.has(key)
-  )
-
-  if (inCsvNotInStripe.length > 0) {
-    await trackProgress(
-      `\n⚠️  ${inCsvNotInStripe.length} lookup key(s) in CSV but NOT in Stripe and will NOT be created:`
-    )
-    for (const key of inCsvNotInStripe.sort()) {
-      await trackProgress(
-        `  - ${key.replace(nextVersion, PlansLocator.LATEST_STRIPE_LOOKUP_KEY_VERSION)}`
-      )
-    }
-  }
-}
-
-/**
- * Display a summary of unit amount changes
- *
- * @param {Stripe.Price[]} currentVersionPrices
- * @param {Stripe.PriceCreateParams[]} nextPriceObjects
- * @param {string} nextVersion
- * @param {function} trackProgress
- */
-async function showAmountChanges(
-  currentVersionPrices,
-  nextPriceObjects,
-  nextVersion,
-  trackProgress
-) {
-  const currentMap = new Map(currentVersionPrices.map(p => [p.lookup_key, p]))
-  const changeList = []
-  let changeCount = 0
-  for (const nextPrice of nextPriceObjects) {
-    const currentLookupKey = nextPrice.lookup_key.replace(
-      nextVersion,
-      PlansLocator.LATEST_STRIPE_LOOKUP_KEY_VERSION
-    )
-    const current = currentMap.get(currentLookupKey)
-    if (current) {
-      if (current.unit_amount !== nextPrice.unit_amount) {
-        const oldAmount = convertFromMinorUnits(
-          current.unit_amount,
-          current.currency
-        )
-        const newAmount = convertFromMinorUnits(
-          nextPrice.unit_amount,
-          nextPrice.currency
-        )
-        changeList.push(
-          `${nextPrice.lookup_key}: ${oldAmount} -> ${newAmount} ${nextPrice.currency}`
-        )
-        changeCount++
-      } else {
-        changeList.push(`${nextPrice.lookup_key}: UNCHANGED`)
-      }
-    } else {
-      changeList.push(`New: ${nextPrice.lookup_key}`)
-      changeCount++
-    }
-  }
-  if (changeCount === 0) {
-    await trackProgress('\nNo unit amount changes detected')
-  } else {
-    await trackProgress(`\nUnit amount changes (${changeCount} total changes):`)
-    for (const change of changeList) {
-      await trackProgress(`  ${change}`)
-    }
-  }
-}
-
-/**
- * Create prices in Stripe
- *
- * @param {Stripe.PriceCreateParams[]} pricesToCreate
- * @param {Stripe} stripe
- * @param {function} trackProgress
- * @returns {Promise<Stripe.Price[]>}
- */
-async function createPricesInStripe(pricesToCreate, stripe, trackProgress) {
-  const createdPrices = []
-  let errorCount = 0
-
-  for (const priceObj of pricesToCreate) {
-    const amountDisplay = convertFromMinorUnits(
-      priceObj.unit_amount,
-      priceObj.currency
-    )
-
-    try {
-      const created = await stripe.prices.create(priceObj)
-      await trackProgress(
-        `✓ Created: ${priceObj.lookup_key} (${amountDisplay} ${priceObj.currency}) -> ${created.id}`
-      )
-      createdPrices.push(created)
-      await rateLimitSleep()
-    } catch (error) {
-      await trackProgress(
-        `✗ Error creating ${priceObj.lookup_key}: ${error.message}`
-      )
-      errorCount++
-    }
-  }
-
-  return { createdPrices, errorCount }
-}
-
-async function main(trackProgress) {
-  const parseResult = paramsSchema.safeParse(
-    minimist(process.argv.slice(2), {
-      boolean: ['commit'],
-      string: ['region', 'f', 'nextVersion'],
-    })
-  )
-
-  if (!parseResult.success) {
-    throw new Error(`Invalid parameters: ${parseResult.error.message}`)
-  }
-
-  const { f: inputFile, region, nextVersion, commit } = parseResult.data
-
-  const mode = commit ? 'COMMIT MODE' : 'DRY RUN MODE'
-  await trackProgress(`Starting script in ${mode} for region: ${region}`)
-  await trackProgress(
-    `Current version: ${PlansLocator.LATEST_STRIPE_LOOKUP_KEY_VERSION}`
-  )
-  await trackProgress(`Next version: ${nextVersion}`)
-
-  await trackProgress(`\nLoading prices from: ${inputFile}`)
-  const csvPricesByLookupKey = loadPricesFromCSV(inputFile, nextVersion)
-  await trackProgress(
-    `Loaded ${csvPricesByLookupKey.size} price entries from CSV`
-  )
-
-  const stripe = getRegionClient(region).stripe
-
-  await trackProgress('\nFetching existing prices from Stripe...')
-  const currentVersionPrices = await fetchCurrentVersionPrices(stripe)
-  await trackProgress(
-    `Found ${currentVersionPrices.length} prices with version ${PlansLocator.LATEST_STRIPE_LOOKUP_KEY_VERSION}`
-  )
-
-  await trackProgress('\nProcessing prices...')
-
-  const nextPriceObjects = []
-  let buildPricesErrorCount = 0
-
-  for (const existingPrice of currentVersionPrices) {
-    const result = copyPriceAndUpdate(
-      existingPrice,
-      csvPricesByLookupKey,
-      nextVersion
-    )
-
-    if (result.success) {
-      nextPriceObjects.push(result.price)
-    } else {
-      buildPricesErrorCount++
-      if (result.error) {
-        await trackProgress(
-          `Error cloning ${existingPrice.lookup_key}: ${result.error}`
-        )
-      }
-    }
-  }
-
-  await trackProgress(`Built ${nextPriceObjects.length} price objects`)
-
-  await compareCsvAndStripeLookupKeys(
-    csvPricesByLookupKey,
-    currentVersionPrices,
-    nextVersion,
-    trackProgress
-  )
-
-  let createdPrices = []
-  let commitPricesErrorCount = 0
-
-  if (commit) {
-    await trackProgress('Creating prices in Stripe...')
-    const createResult = await createPricesInStripe(
-      nextPriceObjects,
-      stripe,
-      trackProgress
-    )
-    createdPrices = createResult.createdPrices
-    commitPricesErrorCount += createResult.errorCount
-  } else {
-    await showAmountChanges(
-      currentVersionPrices,
-      nextPriceObjects,
-      nextVersion,
-      trackProgress
-    )
-  }
-
-  await trackProgress('\nFINAL SUMMARY')
-  await trackProgress(
-    `Prices ${commit ? 'created' : 'would be created'}: ${nextPriceObjects.length}`
-  )
-  if (buildPricesErrorCount > 0) {
-    await trackProgress(
-      `⚠️  Errors encountered while building price objects: ${buildPricesErrorCount}`
-    )
-  }
-
-  if (commit) {
-    if (commitPricesErrorCount > 0) {
-      await trackProgress(
-        `⚠️  Errors encountered while creating prices in Stripe: ${commitPricesErrorCount}`
-      )
-    }
-
-    const lookupKeysString =
-      createdPrices.map(price => price.lookup_key).join(', ') || 'n/a'
-    await trackProgress(`Created Price Lookup Keys: ${lookupKeysString}`)
-  } else {
-    await trackProgress(
-      '💡  This was a DRY RUN. To actually create the prices, run with --commit'
-    )
-  }
-
-  if (commit) {
-    await trackProgress('NEXT STEPS:')
-    await trackProgress(
-      `1. Update LATEST_STRIPE_LOOKUP_KEY_VERSION in PlansLocator.mjs to: '${nextVersion}'`
-    )
-    await trackProgress('2. Deploy the updated code to production')
-    await trackProgress(
-      '3. Archive the old prices in Stripe (set active: false)'
-    )
-  }
-
-  await trackProgress(`Script completed successfully in ${mode}`)
-}
-
-try {
-  await scriptRunner(main)
-  process.exit(0)
-} catch (error) {
-  console.error('Script failed:', error.message)
-  process.exit(1)
-}