#!/usr/bin/env node 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 recurly from 'recurly' import Settings from '@overleaf/settings' import { db, ObjectId, READ_PREFERENCE_SECONDARY, } from '../app/src/infrastructure/mongodb.mjs' import { z } from '../app/src/infrastructure/Validation.mjs' import { scriptRunner } from './lib/ScriptRunner.mjs' import { getRegionClient } from '../modules/subscriptions/app/src/StripeClient.mjs' import { ReportError, convertFromMinorUnits } from './stripe/helpers.mjs' const INPUT_COLUMNS = [ 'user_id', 'subscription_uuid', 'plan_name', 'currency', 'ai_assist', 'ai_assist_price', 'seats', 'original_price_per_seat', 'new_price_per_seat', 'original_price', 'new_price', 'original_total_price', 'new_total_price', 'renewal_date', ] const OUTPUT_COLUMNS = [...INPUT_COLUMNS, 'provider', 'status', 'note'] const NUMERIC_COLUMNS = new Set([ 'ai_assist_price', 'seats', 'original_price_per_seat', 'new_price_per_seat', 'original_price', 'new_price', 'original_total_price', 'new_total_price', ]) // Empty string → null for these columns const OPTIONAL_NUMERIC_COLUMNS = new Set([ 'ai_assist_price', 'seats', 'original_price_per_seat', 'new_price_per_seat', ]) const DEFAULT_THROTTLE = 100 const recurlyClient = new recurly.Client(Settings.apis.recurly.apiKey) function usage() { console.error(`Usage: node scripts/verify_subscription_prices.mjs [OPTS] [INPUT-FILE] Options: --output PATH Output file path (default: /tmp/verify_prices_output_.csv) Use '-' to write to stdout --throttle MS Minimum time between subscriptions processed (default: ${DEFAULT_THROTTLE}) --help Show this help message `) } const paramsSchema = z.object({ output: z.string().optional(), throttle: z .string() .optional() .transform(val => (val ? parseInt(val, 10) : DEFAULT_THROTTLE)), _: z.array(z.string()).max(1), help: z.boolean().optional(), }) function parseArgs() { const argv = minimist(process.argv.slice(2), { string: ['throttle', 'output'], boolean: ['help'], }) if (argv.help) { usage() process.exit(0) } const parseResult = paramsSchema.safeParse(argv) if (!parseResult.success) { console.error(`Invalid parameters: ${parseResult.error.message}`) usage() process.exit(1) } const { output, throttle, _ } = parseResult.data return { inputFile: _[0], output, throttle } } function getCsvReader(inputStream) { const parser = csv.parse({ columns: true, cast: (value, context) => { if (context.header) { return value } const col = context.column if (!NUMERIC_COLUMNS.has(col)) { return value } if (OPTIONAL_NUMERIC_COLUMNS.has(col) && value === '') { return null } const parsed = parseFloat(value) if (Number.isNaN(parsed)) { throw new ReportError( 'mismatch', `Invalid number for ${col} at row ${context.lines}: "${value}"` ) } return parsed }, }) inputStream.pipe(parser) return parser } function getCsvWriter(outputFile) { let outputStream if (outputFile === '-') { outputStream = process.stdout } else { fs.mkdirSync(path.dirname(outputFile), { recursive: true }) outputStream = fs.createWriteStream(outputFile) } const writer = csv.stringify({ columns: OUTPUT_COLUMNS, header: true }) writer.on('error', err => { console.error(err) process.exit(1) }) writer.pipe(outputStream) return writer } async function lookupProvider(row) { const doc = await db.subscriptions.findOne( { admin_id: new ObjectId(row.user_id) }, { projection: { 'paymentProvider.service': 1, 'paymentProvider.subscriptionId': 1, recurlySubscription_id: 1, }, readPreference: READ_PREFERENCE_SECONDARY, } ) if (!doc) { throw new ReportError('not-found', 'subscription not found in MongoDB') } const { service, subscriptionId } = doc.paymentProvider ?? {} if (service && subscriptionId) { if (service !== 'stripe-us' && service !== 'stripe-uk') { throw new ReportError('error', `unknown payment provider: ${service}`) } return { provider: service, subscriptionId } } if (doc.recurlySubscription_id) { if (doc.recurlySubscription_id !== row.subscription_uuid) { throw new ReportError( 'mismatch', `MongoDB recurlySubscription_id (${doc.recurlySubscription_id}) != CSV subscription_uuid (${row.subscription_uuid})` ) } return { provider: 'recurly', subscriptionId: doc.recurlySubscription_id } } throw new ReportError( 'not-found', 'no payment provider or recurly ID in MongoDB' ) } function pricesMatch(a, b) { return Math.abs(a - b) < 0.01 } function isPlanItem(item) { return ( typeof item.price !== 'string' && item.price.lookup_key && !item.price.lookup_key.startsWith('assistant_') ) } function isAssistantItem(item) { return ( typeof item.price !== 'string' && item.price.lookup_key?.startsWith('assistant_') ) } async function fetchStripeSubscription(subscriptionId, stripeClient) { try { // Without expansion, phase item prices are string IDs instead of Price objects return await stripeClient.stripe.subscriptions.retrieve(subscriptionId, { expand: ['schedule', 'schedule.phases.items.price'], }) } catch (err) { if (err.type === 'StripeInvalidRequestError' && err.statusCode === 404) { throw new ReportError('not-found', 'subscription not found in Stripe') } throw err } } async function verifyStripeSubscription(row, subscriptionId, stripeClient) { const subscription = await fetchStripeSubscription( subscriptionId, stripeClient ) if ( ['incomplete', 'incomplete_expired', 'canceled', 'trialing'].includes( subscription.status ) ) { throw new ReportError( 'inactive', `subscription status: ${subscription.status}` ) } if (subscription.cancel_at_period_end) { throw new ReportError( 'inactive', 'scheduled for cancellation at period end' ) } const planItem = subscription.items.data.find(isPlanItem) if (!planItem) { throw new ReportError('mismatch', 'no plan item found in subscription') } const currency = planItem.price.currency const currentUnitPrice = convertFromMinorUnits( planItem.price.unit_amount, currency ) const isGroup = row.seats != null const expectedOriginal = isGroup ? row.original_price_per_seat : row.original_price const expectedNew = isGroup ? row.new_price_per_seat : row.new_price if (!pricesMatch(currentUnitPrice, expectedOriginal)) { if (pricesMatch(currentUnitPrice, expectedNew)) { if (row.ai_assist) { verifyStripeAiAssist(subscription, row, currency) } return { status: 'changed', note: 'change already applied' } } throw new ReportError( 'mismatch', `current price (${currentUnitPrice}) != expected original (${expectedOriginal})` ) } if (isGroup && (planItem.quantity || 1) !== row.seats) { throw new ReportError( 'mismatch', `quantity (${planItem.quantity}) != expected seats (${row.seats})` ) } if (row.ai_assist) { verifyStripeAiAssist(subscription, row, currency) } const { schedule } = subscription if ( schedule && typeof schedule !== 'string' && schedule.status !== 'released' && schedule.phases?.length >= 2 ) { return verifyStripeSchedulePhase( schedule.phases[schedule.phases.length - 1], currency, expectedNew ) } if (pricesMatch(currentUnitPrice, expectedNew)) { return { status: 'changed', note: 'change already applied' } } throw new ReportError('mismatch', 'no pending schedule found') } function verifyStripeAiAssist(subscription, row, currency) { const aiItem = subscription.items.data.find(isAssistantItem) if (!aiItem) { throw new ReportError( 'mismatch', 'AI assist expected but no assistant item found' ) } const aiPrice = convertFromMinorUnits(aiItem.price.unit_amount, currency) if (!pricesMatch(aiPrice, row.ai_assist_price)) { throw new ReportError( 'mismatch', `AI assist price (${aiPrice}) != expected (${row.ai_assist_price})` ) } } function verifyStripeSchedulePhase(phase, currency, expectedNewPrice) { const planItem = phase.items.find(isPlanItem) if (!planItem) { throw new ReportError( 'pending-change', 'no plan item in schedule next phase' ) } const nextPrice = convertFromMinorUnits(planItem.price.unit_amount, currency) if (!pricesMatch(nextPrice, expectedNewPrice)) { throw new ReportError( 'pending-change', `schedule price (${nextPrice}) != expected (${expectedNewPrice})` ) } return { status: 'validated', note: 'pending change verified' } } async function fetchRecurlySubscription(uuid) { try { return await recurlyClient.getSubscription(`uuid-${uuid}`) } catch (err) { if (err instanceof recurly.errors.NotFoundError) { throw new ReportError('not-found', 'subscription not found in Recurly') } throw err } } function additionalLicenseCost(addOns) { let cost = 0 for (const addOn of addOns) { if (addOn.addOn?.code === 'additional-license') { cost += addOn.unitAmount * (addOn.quantity || 1) } } return cost } function computeRecurlyTotal(subscription) { // Recurly quantity is always 1; group pricing uses the additional-license add-on return ( subscription.unitAmount * (subscription.quantity || 1) + additionalLicenseCost(subscription.addOns ?? []) ) } async function verifyRecurlySubscription(row, subscriptionUuid) { const subscription = await fetchRecurlySubscription(subscriptionUuid) if (subscription.state !== 'active') { throw new ReportError( 'inactive', `subscription state: ${subscription.state}` ) } if (subscription.currency.toLowerCase() !== row.currency.toLowerCase()) { throw new ReportError( 'mismatch', `currency: ${subscription.currency} != expected ${row.currency}` ) } const currentTotal = computeRecurlyTotal(subscription) if (!pricesMatch(currentTotal, row.original_price)) { if (pricesMatch(currentTotal, row.new_price)) { if (row.ai_assist) { verifyRecurlyAiAssist(subscription, row) } return { status: 'changed', note: 'change already applied' } } throw new ReportError( 'mismatch', `current total (${currentTotal}) != expected original (${row.original_price})` ) } if (row.ai_assist) { verifyRecurlyAiAssist(subscription, row) } if (subscription.pendingChange != null) { return verifyRecurlyPendingChange(subscription, row.new_price) } if (pricesMatch(currentTotal, row.new_price)) { return { status: 'changed', note: 'change already applied' } } throw new ReportError('mismatch', 'no pending change found') } function verifyRecurlyAiAssist(subscription, row) { if (!subscription.addOns) { throw new ReportError('mismatch', 'AI assist expected but no add-ons found') } const assistantAddOn = subscription.addOns.find( a => a.addOn?.code === 'assistant' ) if (!assistantAddOn) { throw new ReportError( 'mismatch', 'AI assist expected but no assistant add-on found' ) } if (!pricesMatch(assistantAddOn.unitAmount, row.ai_assist_price)) { throw new ReportError( 'mismatch', `AI assist price (${assistantAddOn.unitAmount}) != expected (${row.ai_assist_price})` ) } } function verifyRecurlyPendingChange(subscription, expectedNewPrice) { const { pendingChange } = subscription // If pending change omits addOns, fall back to current subscription's add-ons const addOns = pendingChange.addOns ?? subscription.addOns ?? [] const newTotal = pendingChange.unitAmount * (subscription.quantity || 1) + additionalLicenseCost(addOns) if (!pricesMatch(newTotal, expectedNewPrice)) { throw new ReportError( 'pending-change', `pending total (${newTotal}) != expected (${expectedNewPrice})` ) } return { status: 'validated', note: 'pending change verified' } } async function main(trackProgress) { const opts = parseArgs() const timestamp = new Date().toISOString().replace(/[:.]/g, '-') const outputFile = opts.output ?? `/tmp/verify_prices_output_${timestamp}.csv` await trackProgress( `Throttle: ${opts.throttle}ms | Output: ${outputFile === '-' ? 'stdout' : outputFile}` ) const inputStream = opts.inputFile ? fs.createReadStream(opts.inputFile) : process.stdin const csvReader = getCsvReader(inputStream) const csvWriter = getCsvWriter(outputFile) let processed = 0 let validated = 0 let changed = 0 let errors = 0 let lastLoopTimestamp = 0 for await (const row of csvReader) { const elapsed = Date.now() - lastLoopTimestamp if (elapsed < opts.throttle) { await setTimeout(opts.throttle - elapsed) } lastLoopTimestamp = Date.now() processed++ let provider = '' try { const lookup = await lookupProvider(row) provider = lookup.provider let result if (provider === 'stripe-us' || provider === 'stripe-uk') { const region = provider === 'stripe-us' ? 'us' : 'uk' result = await verifyStripeSubscription( row, lookup.subscriptionId, getRegionClient(region) ) } else { result = await verifyRecurlySubscription(row, lookup.subscriptionId) } csvWriter.write({ ...row, provider, status: result.status, note: result.note, }) if (result.status === 'validated') { validated++ } else if (result.status === 'changed') { changed++ } } catch (err) { errors++ const status = err instanceof ReportError ? err.status : 'error' csvWriter.write({ ...row, provider, status, note: err.message }) if (!(err instanceof ReportError)) { await trackProgress( `Error processing user_id=${row.user_id}: ${err.message}` ) } } if (processed % 10 === 0) { await trackProgress( `Processed ${processed} (validated: ${validated}, changed: ${changed}, errors: ${errors})` ) } } await trackProgress( `\nDone. Total: ${processed}, validated: ${validated}, changed: ${changed}, errors: ${errors}` ) csvWriter.end() } try { await scriptRunner(main) process.exit(0) } catch (error) { console.error(error) process.exit(1) }