Răsfoiți Sursa

[web] add scripts to finalize recurly -> stripe migration (#30925)

GitOrigin-RevId: 2149aa516a00b18927fea46e9241496b74478152
Kristina 6 luni în urmă
părinte
comite
49591a5190

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

@@ -0,0 +1,262 @@
+#!/usr/bin/env node
+
+/**
+ * This script CLEANS UP Recurly subscriptions after migration to Stripe is finalized.
+ *
+ * IMPORTANT: Only run this AFTER the cutover is complete, verified, and
+ * we've confirmed that Stripe is working correctly.
+ *
+ * WARNING: After running this script, rollback is NO LONGER POSSIBLE.
+ *
+ * NOTE: 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)
+ *   --throttle DURATION    Minimum time between requests in ms (default: 100)
+ *   --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 { setTimeout } from 'node:timers/promises'
+import * as csv from 'csv'
+import minimist from 'minimist'
+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'
+
+const DEFAULT_THROTTLE = 100
+
+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)
+    --throttle DURATION    Minimum time between requests in ms (default: ${DEFAULT_THROTTLE})
+    --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`
+
+  await trackProgress('Starting Recurly subscription termination')
+  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}`)
+
+  let processedCount = 0
+  let successCount = 0
+  let errorCount = 0
+
+  let lastLoopTimestamp = 0
+  for await (const input of csvReader) {
+    const timeSinceLastLoop = Date.now() - lastLoopTimestamp
+    if (timeSinceLastLoop < opts.throttle) {
+      await setTimeout(opts.throttle - timeSinceLastLoop)
+    }
+    lastLoopTimestamp = Date.now()
+
+    processedCount++
+
+    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++
+      }
+
+      if (processedCount % 25 === 0) {
+        await trackProgress(
+          `Progress: ${processedCount} processed, ${successCount} successful, ${errorCount} errors`
+        )
+      }
+    } 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,
+        })
+      }
+    }
+  }
+
+  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. If commit mode, terminate the subscription
+  if (commit) {
+    try {
+      await RecurlyClient.promises.terminateSubscriptionByUuid(subscriptionUuid)
+
+      return {
+        status: 'terminated',
+        note: 'Successfully terminated Recurly subscription',
+      }
+    } catch (err) {
+      throw new ReportError(
+        'terminate-failed',
+        `Failed to terminate: ${err.message}`
+      )
+    }
+  } else {
+    return {
+      status: 'validated',
+      note: 'DRY RUN: Ready to terminate',
+    }
+  }
+}
+
+function parseArgs() {
+  const args = minimist(process.argv.slice(2), {
+    string: ['output'],
+    number: ['throttle'],
+    boolean: ['commit', 'help'],
+    default: { commit: false, throttle: DEFAULT_THROTTLE },
+  })
+
+  if (args.help) {
+    usage()
+    process.exit(0)
+  }
+
+  const inputFile = args._[0]
+  const paramsSchema = z.object({
+    output: z.string().optional(),
+    commit: z.boolean(),
+    throttle: z.number().int().positive(),
+    inputFile: z.string().optional(),
+  })
+
+  try {
+    return paramsSchema.parse({
+      output: args.output,
+      commit: args.commit,
+      throttle: args.throttle,
+      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)
+}

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

@@ -0,0 +1,303 @@
+#!/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
+ *
+ * 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)
+ *   --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:
+ *   - 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 { setTimeout } from 'node:timers/promises'
+import * as csv from 'csv'
+import minimist from 'minimist'
+import { scriptRunner } from '../lib/ScriptRunner.mjs'
+import { getRegionClient } from '../../modules/subscriptions/app/src/StripeClient.mjs'
+import { ReportError } from './helpers.mjs'
+
+const DEFAULT_THROTTLE = 40
+
+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)
+    --throttle DURATION    Minimum time between requests in ms (default: ${DEFAULT_THROTTLE})
+    --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`
+
+  await trackProgress('Starting bulk subscription cancellation for Stripe')
+  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 errorCount = 0
+
+  let lastLoopTimestamp = 0
+  for await (const input of csvReader) {
+    const timeSinceLastLoop = Date.now() - lastLoopTimestamp
+    if (timeSinceLastLoop < opts.throttle) {
+      await setTimeout(opts.throttle - timeSinceLastLoop)
+    }
+    lastLoopTimestamp = Date.now()
+
+    processedCount++
+
+    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++
+      }
+
+      if (processedCount % 10 === 0) {
+        await trackProgress(
+          `Processed ${processedCount} customers (${successCount} ${opts.commit ? 'cancelled' : 'validated'}, ${errorCount} errors)`
+        )
+      }
+    } 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}`
+        )
+      }
+    }
+  }
+
+  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', 'throttle'],
+    boolean: ['commit', 'help'],
+    default: {
+      throttle: DEFAULT_THROTTLE.toString(),
+    },
+    unknown: arg => {
+      if (arg.startsWith('-')) {
+        console.error(`Unknown option: ${arg}`)
+        usage()
+        process.exit(1)
+      }
+      return true
+    },
+  })
+
+  if (args.help) {
+    usage()
+    process.exit(0)
+  }
+
+  const throttle = parseInt(args.throttle, 10)
+  if (isNaN(throttle) || throttle < 0) {
+    console.error('Error: --throttle must be a non-negative integer')
+    usage()
+    process.exit(1)
+  }
+
+  return {
+    output: args.output,
+    commit: args.commit,
+    throttle,
+    inputFile: args._[0],
+  }
+}
+
+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 stripeClient.getCustomerById(customerId, ['subscriptions'])
+  } 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 stripeClient.terminateSubscription(migrationSubscription.id)
+
+    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)
+}

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

@@ -0,0 +1,519 @@
+#!/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)
+ *   --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,previous_recurly_status,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 { setTimeout } from 'node:timers/promises'
+import * as csv from 'csv'
+import minimist from 'minimist'
+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 AnalyticsManager from '../../app/src/Features/Analytics/AnalyticsManager.mjs'
+import AccountMappingHelper from '../../app/src/Features/Analytics/AccountMappingHelper.mjs'
+import { ReportError } from './helpers.mjs'
+
+const DEFAULT_THROTTLE = 40
+
+const preloadedProductMetadata = new Map()
+
+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)
+    --throttle DURATION    Minimum time between requests in ms (default: ${DEFAULT_THROTTLE})
+    --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`
+
+  await trackProgress('Starting Recurly to Stripe migration cutover')
+  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('Populating product metadata cache...')
+  await preloadProductMetadata('uk')
+  await preloadProductMetadata('us')
+  await trackProgress('Product metadata cache populated')
+
+  await trackProgress(`Output: ${outputFile}`)
+
+  let processedCount = 0
+  let successCount = 0
+  let errorCount = 0
+
+  let lastLoopTimestamp = 0
+  for await (const input of csvReader) {
+    const timeSinceLastLoop = Date.now() - lastLoopTimestamp
+    if (timeSinceLastLoop < opts.throttle) {
+      await setTimeout(opts.throttle - timeSinceLastLoop)
+    }
+    lastLoopTimestamp = Date.now()
+
+    processedCount++
+
+    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 || '',
+        status: result.status,
+        note: result.note,
+      })
+
+      if (result.status === 'migrated' || result.status === 'validated') {
+        successCount++
+      } else {
+        errorCount++
+      }
+
+      if (processedCount % 25 === 0) {
+        await trackProgress(
+          `Progress: ${processedCount} processed, ${successCount} successful, ${errorCount} errors`
+        )
+      }
+    } 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: '',
+          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: '',
+          status: 'error',
+          note: err.message,
+        })
+      }
+    }
+  }
+
+  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()
+}
+
+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',
+      '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 stripeClient.stripe.products.list({
+    active: true,
+    limit: 100,
+  })
+
+  const cache = new Map()
+  for (const product of products.data) {
+    cache.set(product.id, product.metadata)
+  }
+
+  preloadedProductMetadata.set(region, cache)
+}
+
+async function processMigration(input, commit) {
+  const {
+    recurly_account_code: accountCode,
+    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: accountCode,
+  }).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 stripeClient.getCustomerById(stripeCustomerId, [
+      'subscriptions',
+    ])
+    if (
+      !stripeCustomer.subscriptions ||
+      stripeCustomer.subscriptions.data.length === 0
+    ) {
+      throw new ReportError(
+        'no-stripe-subscription',
+        'No Stripe subscriptions found for customer'
+      )
+    }
+    // find the 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-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
+  let recurlySubscription
+  try {
+    recurlySubscription = await RecurlyWrapper.promises.getSubscription(
+      previousRecurlySubscriptionId,
+      {}
+    )
+  } catch (err) {
+    throw new ReportError(
+      'no-recurly-subscription',
+      `Recurly subscription not found: ${err.message}`
+    )
+  }
+
+  // 6. Detect changes between Recurly and Stripe
+  const changes = detectChanges(recurlySubscription, stripeSubscription, region)
+  if (changes.length > 0) {
+    return {
+      status: 'changes-detected',
+      note: `Changes found: ${changes.join('; ')}`,
+      previousRecurlyStatus,
+      previousRecurlySubscriptionId,
+      email: stripeCustomer.email,
+    }
+  }
+
+  // 7. If commit mode, perform migration
+  if (commit) {
+    await performCutover(
+      mongoSubscription,
+      stripeSubscription,
+      recurlySubscription,
+      stripeClient
+    )
+    return {
+      status: 'migrated',
+      note: 'Successfully migrated to Stripe',
+      previousRecurlyStatus,
+      previousRecurlySubscriptionId,
+      email: stripeCustomer.email,
+    }
+  } else {
+    return {
+      status: 'validated',
+      note: 'DRY RUN: Ready to migrate',
+      previousRecurlyStatus,
+      previousRecurlySubscriptionId,
+      email: stripeCustomer.email,
+    }
+  }
+}
+
+// TODO: add other plan codes as needed
+const RECURLY_PLAN_CODE_TO_STRIPE_PLAN_CODE = {
+  student_free_trial_7_days: 'student',
+  collaborator_free_trial_7_days: 'collaborator',
+  student: 'student',
+  collaborator: 'collaborator',
+  'collaborator-annual': 'collaborator-annual',
+  'collaborator-annual_free_trial_7_days': 'collaborator-annual',
+  professional_free_trial_7_days: 'professional',
+  professional: 'professional',
+  'professional-annual': 'professional-annual',
+  'student-annual': 'student-annual',
+}
+
+function detectChanges(recurlySubscription, stripeSubscription, region) {
+  const changes = []
+
+  // Extract item codes from Recurly subscription (excluding additional-license
+  // add-on, which is not a separate add-on in Stripe)
+  const planCode = recurlySubscription.plan.plan_code
+  const recurlyItemCodes = JSON.stringify(
+    [
+      RECURLY_PLAN_CODE_TO_STRIPE_PLAN_CODE[planCode] || planCode,
+      ...(recurlySubscription.subscription_add_ons || [])
+        .filter(addOn => addOn.add_on_code !== 'additional-license')
+        .map(addOn => addOn.add_on_code),
+    ].sort()
+  )
+
+  // Extract item codes from Stripe subscription
+  const cache = preloadedProductMetadata.get(region)
+  const stripeItemCodes = JSON.stringify(
+    stripeSubscription.items.data
+      .map(item => {
+        const productMetadata = cache.get(item.price.product)
+        return productMetadata?.planCode || productMetadata?.addOnCode || null
+      })
+      .filter(code => code !== null)
+      .sort()
+  )
+
+  // Compare item codes
+  if (recurlyItemCodes !== stripeItemCodes) {
+    changes.push(
+      `Items: Recurly=[${recurlyItemCodes}], Stripe=[${stripeItemCodes}]`
+    )
+  }
+
+  // TODO: compare quantities for each item, taking additional-license add-ons into account
+
+  // Compare states
+  const recurlyState = recurlySubscription.state
+  const stripeState = convertStripeStatusToSubscriptionState(stripeSubscription)
+  if (recurlyState !== stripeState) {
+    changes.push(`State: Recurly=${recurlyState}, Stripe=${stripeState}`)
+  }
+
+  // Verify no changes have been scheduled in Recurly
+  if (recurlySubscription.pending_subscription != null) {
+    changes.push('Pending change now exists in Recurly subscription')
+  }
+
+  return changes
+}
+
+async function performCutover(
+  mongoSubscription,
+  stripeSubscription,
+  recurlySubscription,
+  stripeClient
+) {
+  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
+
+  await mongoSubscription.save()
+
+  // 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
+  const currentBillingDate = new Date(
+    recurlySubscription.current_period_ends_at
+  )
+  const postponedDate = new Date(currentBillingDate)
+  postponedDate.setFullYear(currentBillingDate.getFullYear() + 10)
+
+  try {
+    await RecurlyWrapper.promises.apiRequest({
+      url: `subscriptions/${recurlySubscription.uuid}/postpone`,
+      qs: { bulk: true, next_bill_date: postponedDate },
+      method: 'PUT',
+    })
+  } catch (err) {
+    throw new Error(`Failed to postpone Recurly billing: ${err.message}`)
+  }
+
+  // Step 4: Remove migration metadata from Stripe
+  try {
+    await stripeClient.updateSubscriptionMetadata(stripeSubscription.id, {
+      recurly_to_stripe_migration_status: '',
+    })
+  } 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,
+        stripeSubscription.service
+      )
+    )
+  } catch (err) {
+    throw new ReportError(
+      'analytics-mapping-failed',
+      `Successfully migrated to Stripe but failed to register analytics mapping: ${err.message}`
+    )
+  }
+}
+
+function parseArgs() {
+  const args = minimist(process.argv.slice(2), {
+    string: ['output'],
+    number: ['throttle'],
+    boolean: ['commit', 'help'],
+    default: { commit: false, throttle: DEFAULT_THROTTLE },
+  })
+
+  if (args.help) {
+    usage()
+    process.exit(0)
+  }
+
+  const inputFile = args._[0]
+  const paramsSchema = z.object({
+    output: z.string().optional(),
+    commit: z.boolean(),
+    throttle: z.number().int().positive(),
+    inputFile: z.string().optional(),
+  })
+
+  try {
+    return paramsSchema.parse({
+      output: args.output,
+      commit: args.commit,
+      throttle: args.throttle,
+      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)
+}

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

@@ -0,0 +1,403 @@
+#!/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 { setTimeout } from 'node:timers/promises'
+import * as csv from 'csv'
+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 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 { ReportError } from './helpers.mjs'
+import AccountMappingHelper from '../../app/src/Features/Analytics/AccountMappingHelper.mjs'
+
+const DEFAULT_THROTTLE = 40
+
+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)
+    --throttle DURATION    Minimum time between requests in ms (default: ${DEFAULT_THROTTLE})
+    --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`
+
+  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(`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}`)
+
+  let processedCount = 0
+  let successCount = 0
+  let errorCount = 0
+
+  let lastLoopTimestamp = 0
+  for await (const input 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(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++
+      }
+
+      if (processedCount % 25 === 0) {
+        await trackProgress(
+          `Progress: ${processedCount} processed, ${successCount} successful, ${errorCount} errors`
+        )
+      }
+    } 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,
+        })
+      }
+    }
+  }
+
+  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()
+}
+
+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 stripeClient.stripe.subscriptions.retrieve(stripeSubscriptionId)
+    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 RecurlyWrapper.promises.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
+  const currentPeriodEnd = new Date(recurlySubscription.current_period_ends_at)
+  const nextBillingDate = new Date(currentPeriodEnd)
+  nextBillingDate.setFullYear(currentPeriodEnd.getFullYear() - 10)
+  const targetBillingDateIsInFuture = nextBillingDate.getTime() > Date.now()
+
+  if (targetBillingDateIsInFuture) {
+    try {
+      await RecurlyWrapper.promises.apiRequest({
+        url: `subscriptions/${recurlySubscriptionId}/postpone`,
+        qs: { bulk: true, next_bill_date: nextBillingDate },
+        method: 'PUT',
+      })
+    } 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 stripeClient.updateSubscriptionMetadata(stripeSubscriptionId, {
+      recurly_to_stripe_migration_status: 'in_progress',
+    })
+  } 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}`
+    )
+  }
+}
+
+function parseArgs() {
+  const args = minimist(process.argv.slice(2), {
+    string: ['output'],
+    number: ['throttle'],
+    boolean: ['commit', 'help'],
+    default: {
+      commit: false,
+      throttle: DEFAULT_THROTTLE,
+    },
+  })
+
+  if (args.help) {
+    usage()
+    process.exit(0)
+  }
+
+  const inputFile = args._[0]
+  const paramsSchema = z.object({
+    output: z.string().optional(),
+    commit: z.boolean(),
+    throttle: z.number().int().positive(),
+    inputFile: z.string().optional(),
+  })
+
+  try {
+    return paramsSchema.parse({
+      output: args.output,
+      commit: args.commit,
+      throttle: args.throttle,
+      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)
+}