From 49591a5190d782b4dcb0cd35713b91a19ffeb889 Mon Sep 17 00:00:00 2001 From: Kristina <7614497+khjrtbrg@users.noreply.github.com> Date: Fri, 23 Jan 2026 17:37:39 +0100 Subject: [PATCH] [web] add scripts to finalize recurly -> stripe migration (#30925) GitOrigin-RevId: 2149aa516a00b18927fea46e9241496b74478152 --- ...p-recurly-subscriptions-post-migration.mjs | 262 +++++++++ .../stripe/bulk-cancel-subscriptions.mjs | 303 ++++++++++ ...finalize-stripe-subscription-migration.mjs | 519 ++++++++++++++++++ .../rollback-finalized-stripe-migration.mjs | 403 ++++++++++++++ 4 files changed, 1487 insertions(+) create mode 100755 services/web/scripts/recurly/cleanup-recurly-subscriptions-post-migration.mjs create mode 100755 services/web/scripts/stripe/bulk-cancel-subscriptions.mjs create mode 100755 services/web/scripts/stripe/finalize-stripe-subscription-migration.mjs create mode 100755 services/web/scripts/stripe/rollback-finalized-stripe-migration.mjs diff --git a/services/web/scripts/recurly/cleanup-recurly-subscriptions-post-migration.mjs b/services/web/scripts/recurly/cleanup-recurly-subscriptions-post-migration.mjs new file mode 100755 index 0000000000..2dd6b4217d --- /dev/null +++ b/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_.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_.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) +} diff --git a/services/web/scripts/stripe/bulk-cancel-subscriptions.mjs b/services/web/scripts/stripe/bulk-cancel-subscriptions.mjs new file mode 100755 index 0000000000..b25fd424f8 --- /dev/null +++ b/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//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_.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_.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) +} diff --git a/services/web/scripts/stripe/finalize-stripe-subscription-migration.mjs b/services/web/scripts/stripe/finalize-stripe-subscription-migration.mjs new file mode 100755 index 0000000000..bac0a2c853 --- /dev/null +++ b/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//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_.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_.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) +} diff --git a/services/web/scripts/stripe/rollback-finalized-stripe-migration.mjs b/services/web/scripts/stripe/rollback-finalized-stripe-migration.mjs new file mode 100755 index 0000000000..db46f188af --- /dev/null +++ b/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_.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_.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) +}