Files
overleaf-cep/services/web/scripts/recurly/cleanup-recurly-subscriptions-post-migration.mjs
Kristina 820adf568e [web] add support for migrating paypal subscriptions (#31607)
* support Paypal for migration
* remove sensitive payment info in logging
* add `resolveStripeCustomer`

GitOrigin-RevId: a8fc7de4a4bf3971c5221a0dec3fc279d8d2f67d
2026-02-20 09:06:43 +00:00

351 lines
11 KiB
JavaScript
Executable File
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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)
}