verify_subscription_prices.mjs 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543
  1. #!/usr/bin/env node
  2. import fs from 'node:fs'
  3. import path from 'node:path'
  4. import { setTimeout } from 'node:timers/promises'
  5. import * as csv from 'csv'
  6. import minimist from 'minimist'
  7. import recurly from 'recurly'
  8. import Settings from '@overleaf/settings'
  9. import {
  10. db,
  11. ObjectId,
  12. READ_PREFERENCE_SECONDARY,
  13. } from '../app/src/infrastructure/mongodb.mjs'
  14. import { z } from '../app/src/infrastructure/Validation.mjs'
  15. import { scriptRunner } from './lib/ScriptRunner.mjs'
  16. import { getRegionClient } from '../modules/subscriptions/app/src/StripeClient.mjs'
  17. import { ReportError, convertFromMinorUnits } from './stripe/helpers.mjs'
  18. const INPUT_COLUMNS = [
  19. 'user_id',
  20. 'subscription_uuid',
  21. 'plan_name',
  22. 'currency',
  23. 'ai_assist',
  24. 'ai_assist_price',
  25. 'seats',
  26. 'original_price_per_seat',
  27. 'new_price_per_seat',
  28. 'original_price',
  29. 'new_price',
  30. 'original_total_price',
  31. 'new_total_price',
  32. 'renewal_date',
  33. ]
  34. const OUTPUT_COLUMNS = [...INPUT_COLUMNS, 'provider', 'status', 'note']
  35. const NUMERIC_COLUMNS = new Set([
  36. 'ai_assist_price',
  37. 'seats',
  38. 'original_price_per_seat',
  39. 'new_price_per_seat',
  40. 'original_price',
  41. 'new_price',
  42. 'original_total_price',
  43. 'new_total_price',
  44. ])
  45. // Empty string → null for these columns
  46. const OPTIONAL_NUMERIC_COLUMNS = new Set([
  47. 'ai_assist_price',
  48. 'seats',
  49. 'original_price_per_seat',
  50. 'new_price_per_seat',
  51. ])
  52. const DEFAULT_THROTTLE = 100
  53. const recurlyClient = new recurly.Client(Settings.apis.recurly.apiKey)
  54. function usage() {
  55. console.error(`Usage: node scripts/verify_subscription_prices.mjs [OPTS] [INPUT-FILE]
  56. Options:
  57. --output PATH Output file path (default: /tmp/verify_prices_output_<timestamp>.csv)
  58. Use '-' to write to stdout
  59. --throttle MS Minimum time between subscriptions processed (default: ${DEFAULT_THROTTLE})
  60. --help Show this help message
  61. `)
  62. }
  63. const paramsSchema = z.object({
  64. output: z.string().optional(),
  65. throttle: z
  66. .string()
  67. .optional()
  68. .transform(val => (val ? parseInt(val, 10) : DEFAULT_THROTTLE)),
  69. _: z.array(z.string()).max(1),
  70. help: z.boolean().optional(),
  71. })
  72. function parseArgs() {
  73. const argv = minimist(process.argv.slice(2), {
  74. string: ['throttle', 'output'],
  75. boolean: ['help'],
  76. })
  77. if (argv.help) {
  78. usage()
  79. process.exit(0)
  80. }
  81. const parseResult = paramsSchema.safeParse(argv)
  82. if (!parseResult.success) {
  83. console.error(`Invalid parameters: ${parseResult.error.message}`)
  84. usage()
  85. process.exit(1)
  86. }
  87. const { output, throttle, _ } = parseResult.data
  88. return { inputFile: _[0], output, throttle }
  89. }
  90. function getCsvReader(inputStream) {
  91. const parser = csv.parse({
  92. columns: true,
  93. cast: (value, context) => {
  94. if (context.header) {
  95. return value
  96. }
  97. const col = context.column
  98. if (!NUMERIC_COLUMNS.has(col)) {
  99. return value
  100. }
  101. if (OPTIONAL_NUMERIC_COLUMNS.has(col) && value === '') {
  102. return null
  103. }
  104. const parsed = parseFloat(value)
  105. if (Number.isNaN(parsed)) {
  106. throw new ReportError(
  107. 'mismatch',
  108. `Invalid number for ${col} at row ${context.lines}: "${value}"`
  109. )
  110. }
  111. return parsed
  112. },
  113. })
  114. inputStream.pipe(parser)
  115. return parser
  116. }
  117. function getCsvWriter(outputFile) {
  118. let outputStream
  119. if (outputFile === '-') {
  120. outputStream = process.stdout
  121. } else {
  122. fs.mkdirSync(path.dirname(outputFile), { recursive: true })
  123. outputStream = fs.createWriteStream(outputFile)
  124. }
  125. const writer = csv.stringify({ columns: OUTPUT_COLUMNS, header: true })
  126. writer.on('error', err => {
  127. console.error(err)
  128. process.exit(1)
  129. })
  130. writer.pipe(outputStream)
  131. return writer
  132. }
  133. async function lookupProvider(row) {
  134. const doc = await db.subscriptions.findOne(
  135. { admin_id: new ObjectId(row.user_id) },
  136. {
  137. projection: {
  138. 'paymentProvider.service': 1,
  139. 'paymentProvider.subscriptionId': 1,
  140. recurlySubscription_id: 1,
  141. },
  142. readPreference: READ_PREFERENCE_SECONDARY,
  143. }
  144. )
  145. if (!doc) {
  146. throw new ReportError('not-found', 'subscription not found in MongoDB')
  147. }
  148. const { service, subscriptionId } = doc.paymentProvider ?? {}
  149. if (service && subscriptionId) {
  150. if (service !== 'stripe-us' && service !== 'stripe-uk') {
  151. throw new ReportError('error', `unknown payment provider: ${service}`)
  152. }
  153. return { provider: service, subscriptionId }
  154. }
  155. if (doc.recurlySubscription_id) {
  156. if (doc.recurlySubscription_id !== row.subscription_uuid) {
  157. throw new ReportError(
  158. 'mismatch',
  159. `MongoDB recurlySubscription_id (${doc.recurlySubscription_id}) != CSV subscription_uuid (${row.subscription_uuid})`
  160. )
  161. }
  162. return { provider: 'recurly', subscriptionId: doc.recurlySubscription_id }
  163. }
  164. throw new ReportError(
  165. 'not-found',
  166. 'no payment provider or recurly ID in MongoDB'
  167. )
  168. }
  169. function pricesMatch(a, b) {
  170. return Math.abs(a - b) < 0.01
  171. }
  172. function isPlanItem(item) {
  173. return (
  174. typeof item.price !== 'string' &&
  175. item.price.lookup_key &&
  176. !item.price.lookup_key.startsWith('assistant_')
  177. )
  178. }
  179. function isAssistantItem(item) {
  180. return (
  181. typeof item.price !== 'string' &&
  182. item.price.lookup_key?.startsWith('assistant_')
  183. )
  184. }
  185. async function fetchStripeSubscription(subscriptionId, stripeClient) {
  186. try {
  187. // Without expansion, phase item prices are string IDs instead of Price objects
  188. return await stripeClient.stripe.subscriptions.retrieve(subscriptionId, {
  189. expand: ['schedule', 'schedule.phases.items.price'],
  190. })
  191. } catch (err) {
  192. if (err.type === 'StripeInvalidRequestError' && err.statusCode === 404) {
  193. throw new ReportError('not-found', 'subscription not found in Stripe')
  194. }
  195. throw err
  196. }
  197. }
  198. async function verifyStripeSubscription(row, subscriptionId, stripeClient) {
  199. const subscription = await fetchStripeSubscription(
  200. subscriptionId,
  201. stripeClient
  202. )
  203. if (
  204. ['incomplete', 'incomplete_expired', 'canceled', 'trialing'].includes(
  205. subscription.status
  206. )
  207. ) {
  208. throw new ReportError(
  209. 'inactive',
  210. `subscription status: ${subscription.status}`
  211. )
  212. }
  213. if (subscription.cancel_at_period_end) {
  214. throw new ReportError(
  215. 'inactive',
  216. 'scheduled for cancellation at period end'
  217. )
  218. }
  219. const planItem = subscription.items.data.find(isPlanItem)
  220. if (!planItem) {
  221. throw new ReportError('mismatch', 'no plan item found in subscription')
  222. }
  223. const currency = planItem.price.currency
  224. const currentUnitPrice = convertFromMinorUnits(
  225. planItem.price.unit_amount,
  226. currency
  227. )
  228. const isGroup = row.seats != null
  229. const expectedOriginal = isGroup
  230. ? row.original_price_per_seat
  231. : row.original_price
  232. const expectedNew = isGroup ? row.new_price_per_seat : row.new_price
  233. if (!pricesMatch(currentUnitPrice, expectedOriginal)) {
  234. if (pricesMatch(currentUnitPrice, expectedNew)) {
  235. if (row.ai_assist) {
  236. verifyStripeAiAssist(subscription, row, currency)
  237. }
  238. return { status: 'changed', note: 'change already applied' }
  239. }
  240. throw new ReportError(
  241. 'mismatch',
  242. `current price (${currentUnitPrice}) != expected original (${expectedOriginal})`
  243. )
  244. }
  245. if (isGroup && (planItem.quantity || 1) !== row.seats) {
  246. throw new ReportError(
  247. 'mismatch',
  248. `quantity (${planItem.quantity}) != expected seats (${row.seats})`
  249. )
  250. }
  251. if (row.ai_assist) {
  252. verifyStripeAiAssist(subscription, row, currency)
  253. }
  254. const { schedule } = subscription
  255. if (
  256. schedule &&
  257. typeof schedule !== 'string' &&
  258. schedule.status !== 'released' &&
  259. schedule.phases?.length >= 2
  260. ) {
  261. return verifyStripeSchedulePhase(
  262. schedule.phases[schedule.phases.length - 1],
  263. currency,
  264. expectedNew
  265. )
  266. }
  267. if (pricesMatch(currentUnitPrice, expectedNew)) {
  268. return { status: 'changed', note: 'change already applied' }
  269. }
  270. throw new ReportError('mismatch', 'no pending schedule found')
  271. }
  272. function verifyStripeAiAssist(subscription, row, currency) {
  273. const aiItem = subscription.items.data.find(isAssistantItem)
  274. if (!aiItem) {
  275. throw new ReportError(
  276. 'mismatch',
  277. 'AI assist expected but no assistant item found'
  278. )
  279. }
  280. const aiPrice = convertFromMinorUnits(aiItem.price.unit_amount, currency)
  281. if (!pricesMatch(aiPrice, row.ai_assist_price)) {
  282. throw new ReportError(
  283. 'mismatch',
  284. `AI assist price (${aiPrice}) != expected (${row.ai_assist_price})`
  285. )
  286. }
  287. }
  288. function verifyStripeSchedulePhase(phase, currency, expectedNewPrice) {
  289. const planItem = phase.items.find(isPlanItem)
  290. if (!planItem) {
  291. throw new ReportError(
  292. 'pending-change',
  293. 'no plan item in schedule next phase'
  294. )
  295. }
  296. const nextPrice = convertFromMinorUnits(planItem.price.unit_amount, currency)
  297. if (!pricesMatch(nextPrice, expectedNewPrice)) {
  298. throw new ReportError(
  299. 'pending-change',
  300. `schedule price (${nextPrice}) != expected (${expectedNewPrice})`
  301. )
  302. }
  303. return { status: 'validated', note: 'pending change verified' }
  304. }
  305. async function fetchRecurlySubscription(uuid) {
  306. try {
  307. return await recurlyClient.getSubscription(`uuid-${uuid}`)
  308. } catch (err) {
  309. if (err instanceof recurly.errors.NotFoundError) {
  310. throw new ReportError('not-found', 'subscription not found in Recurly')
  311. }
  312. throw err
  313. }
  314. }
  315. function additionalLicenseCost(addOns) {
  316. let cost = 0
  317. for (const addOn of addOns) {
  318. if (addOn.addOn?.code === 'additional-license') {
  319. cost += addOn.unitAmount * (addOn.quantity || 1)
  320. }
  321. }
  322. return cost
  323. }
  324. function computeRecurlyTotal(subscription) {
  325. // Recurly quantity is always 1; group pricing uses the additional-license add-on
  326. return (
  327. subscription.unitAmount * (subscription.quantity || 1) +
  328. additionalLicenseCost(subscription.addOns ?? [])
  329. )
  330. }
  331. async function verifyRecurlySubscription(row, subscriptionUuid) {
  332. const subscription = await fetchRecurlySubscription(subscriptionUuid)
  333. if (subscription.state !== 'active') {
  334. throw new ReportError(
  335. 'inactive',
  336. `subscription state: ${subscription.state}`
  337. )
  338. }
  339. if (subscription.currency.toLowerCase() !== row.currency.toLowerCase()) {
  340. throw new ReportError(
  341. 'mismatch',
  342. `currency: ${subscription.currency} != expected ${row.currency}`
  343. )
  344. }
  345. const currentTotal = computeRecurlyTotal(subscription)
  346. if (!pricesMatch(currentTotal, row.original_price)) {
  347. if (pricesMatch(currentTotal, row.new_price)) {
  348. if (row.ai_assist) {
  349. verifyRecurlyAiAssist(subscription, row)
  350. }
  351. return { status: 'changed', note: 'change already applied' }
  352. }
  353. throw new ReportError(
  354. 'mismatch',
  355. `current total (${currentTotal}) != expected original (${row.original_price})`
  356. )
  357. }
  358. if (row.ai_assist) {
  359. verifyRecurlyAiAssist(subscription, row)
  360. }
  361. if (subscription.pendingChange != null) {
  362. return verifyRecurlyPendingChange(subscription, row.new_price)
  363. }
  364. if (pricesMatch(currentTotal, row.new_price)) {
  365. return { status: 'changed', note: 'change already applied' }
  366. }
  367. throw new ReportError('mismatch', 'no pending change found')
  368. }
  369. function verifyRecurlyAiAssist(subscription, row) {
  370. if (!subscription.addOns) {
  371. throw new ReportError('mismatch', 'AI assist expected but no add-ons found')
  372. }
  373. const assistantAddOn = subscription.addOns.find(
  374. a => a.addOn?.code === 'assistant'
  375. )
  376. if (!assistantAddOn) {
  377. throw new ReportError(
  378. 'mismatch',
  379. 'AI assist expected but no assistant add-on found'
  380. )
  381. }
  382. if (!pricesMatch(assistantAddOn.unitAmount, row.ai_assist_price)) {
  383. throw new ReportError(
  384. 'mismatch',
  385. `AI assist price (${assistantAddOn.unitAmount}) != expected (${row.ai_assist_price})`
  386. )
  387. }
  388. }
  389. function verifyRecurlyPendingChange(subscription, expectedNewPrice) {
  390. const { pendingChange } = subscription
  391. // If pending change omits addOns, fall back to current subscription's add-ons
  392. const addOns = pendingChange.addOns ?? subscription.addOns ?? []
  393. const newTotal =
  394. pendingChange.unitAmount * (subscription.quantity || 1) +
  395. additionalLicenseCost(addOns)
  396. if (!pricesMatch(newTotal, expectedNewPrice)) {
  397. throw new ReportError(
  398. 'pending-change',
  399. `pending total (${newTotal}) != expected (${expectedNewPrice})`
  400. )
  401. }
  402. return { status: 'validated', note: 'pending change verified' }
  403. }
  404. async function main(trackProgress) {
  405. const opts = parseArgs()
  406. const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
  407. const outputFile = opts.output ?? `/tmp/verify_prices_output_${timestamp}.csv`
  408. await trackProgress(
  409. `Throttle: ${opts.throttle}ms | Output: ${outputFile === '-' ? 'stdout' : outputFile}`
  410. )
  411. const inputStream = opts.inputFile
  412. ? fs.createReadStream(opts.inputFile)
  413. : process.stdin
  414. const csvReader = getCsvReader(inputStream)
  415. const csvWriter = getCsvWriter(outputFile)
  416. let processed = 0
  417. let validated = 0
  418. let changed = 0
  419. let errors = 0
  420. let lastLoopTimestamp = 0
  421. for await (const row of csvReader) {
  422. const elapsed = Date.now() - lastLoopTimestamp
  423. if (elapsed < opts.throttle) {
  424. await setTimeout(opts.throttle - elapsed)
  425. }
  426. lastLoopTimestamp = Date.now()
  427. processed++
  428. let provider = ''
  429. try {
  430. const lookup = await lookupProvider(row)
  431. provider = lookup.provider
  432. let result
  433. if (provider === 'stripe-us' || provider === 'stripe-uk') {
  434. const region = provider === 'stripe-us' ? 'us' : 'uk'
  435. result = await verifyStripeSubscription(
  436. row,
  437. lookup.subscriptionId,
  438. getRegionClient(region)
  439. )
  440. } else {
  441. result = await verifyRecurlySubscription(row, lookup.subscriptionId)
  442. }
  443. csvWriter.write({
  444. ...row,
  445. provider,
  446. status: result.status,
  447. note: result.note,
  448. })
  449. if (result.status === 'validated') {
  450. validated++
  451. } else if (result.status === 'changed') {
  452. changed++
  453. }
  454. } catch (err) {
  455. errors++
  456. const status = err instanceof ReportError ? err.status : 'error'
  457. csvWriter.write({ ...row, provider, status, note: err.message })
  458. if (!(err instanceof ReportError)) {
  459. await trackProgress(
  460. `Error processing user_id=${row.user_id}: ${err.message}`
  461. )
  462. }
  463. }
  464. if (processed % 10 === 0) {
  465. await trackProgress(
  466. `Processed ${processed} (validated: ${validated}, changed: ${changed}, errors: ${errors})`
  467. )
  468. }
  469. }
  470. await trackProgress(
  471. `\nDone. Total: ${processed}, validated: ${validated}, changed: ${changed}, errors: ${errors}`
  472. )
  473. csvWriter.end()
  474. }
  475. try {
  476. await scriptRunner(main)
  477. process.exit(0)
  478. } catch (error) {
  479. console.error(error)
  480. process.exit(1)
  481. }