Files
Verso/services/web/scripts/recurly/list_recurly_accounts.mjs
T
Simon GardnerandCopybot b517df6672 Merge pull request #30958 from overleaf/slg-recurly-stripe-migration-30684
add script for migrating customer metadata from recurly to stripe

GitOrigin-RevId: 019413eda20cef2e09c9cc278a8806fa244fe019
2026-01-27 09:06:03 +00:00

294 lines
8.7 KiB
JavaScript

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