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