diff --git a/services/web/scripts/verify_subscription_prices.mjs b/services/web/scripts/verify_subscription_prices.mjs new file mode 100644 index 0000000000..be7b0dbfe9 --- /dev/null +++ b/services/web/scripts/verify_subscription_prices.mjs @@ -0,0 +1,543 @@ +#!/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) +}