Procházet zdrojové kódy

Merge pull request #32553 from overleaf/oa-verify-prices

[web] Verify upcoming price changes

GitOrigin-RevId: c57af634be7360cf108d987c88307e187a7c3e50
Olzhas Askar před 3 měsíci
rodič
revize
050f00a4b5
1 změnil soubory, kde provedl 543 přidání a 0 odebrání
  1. 543 0
      services/web/scripts/verify_subscription_prices.mjs

+ 543 - 0
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_<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)
+}