mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-05-23 17:19:37 +02:00
[web] Verify upcoming price changes GitOrigin-RevId: c57af634be7360cf108d987c88307e187a7c3e50
544 lines
14 KiB
JavaScript
544 lines
14 KiB
JavaScript
#!/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_<timestamp>.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)
|
|
}
|