codeburn/src/currency.ts
Resham Joshi daa673449c
Some checks are pending
CI / semgrep (push) Waiting to run
Menubar and CLI hardening from multi-agent audit (#257)
Two passes of validators across CLI accuracy, dashboard UX, menubar Swift,
performance, security, and end-to-end smoke tests on real session data.

Data-correctness fixes:

- parseLocalDate rejects month/day overflow. JS Date silently rolled
  Feb 31 to Mar 3, so --from 2026-02-31 --to 2026-03-15 quietly dropped
  sessions on Feb 28 - Mar 2. Now throws "Invalid date" with a clear
  reason. Leap-day case covered (2024-02-29 valid, 2025-02-29 rejected).

- CSV/JSON exports use the active currency's natural decimal places. The
  previous round2 helper produced ¥412.37 in CSV while the dashboard
  rendered ¥412 — finance teams comparing the two surfaces saw a
  discrepancy. New roundForActiveCurrency consults Intl.NumberFormat for
  the right precision (0 for JPY/KRW/CLP, 2 for USD/EUR, etc).

- Copilot toolRequests is Array.isArray-guarded in both modern and legacy
  event branches. Previously a corrupt session with toolRequests=null or
  a string aborted the whole file's parse loop and silently dropped every
  legitimate call after it.

- Codex token_count dedup uses a null sentinel for prevCumulativeTotal so
  the first event is never confused with a duplicate. Sessions that emit
  only last_token_usage (no total_token_usage) report cumulativeTotal=0
  on every event; with the previous 0-initialized prev, the first event
  matched the dedup guard and was dropped.

- LiteLLM pricing values are clamped to [0, 1] per token via safePerTokenRate.
  Defense in depth against a tampered upstream JSON shipping negative or
  absurdly large per-token costs that would otherwise propagate into all
  cost totals.

Performance:

- Cursor SQLite parse no longer pegs at minutes on multi-GB DBs. Two
  changes: per-conversation user-message buffer uses an index pointer
  instead of Array.shift() (which was O(n) per call); and a real ROWID
  cutoff via subquery limits the scan to the most recent 250k bubbles
  with a stderr warning so power users get a partial report rather than
  a stalled CLI.

- Spawned codeburn CLI subprocesses are terminated when the calling Task
  is cancelled. Without this, rapid period/provider tab clicks in the
  menubar cancelled the Task but left the subprocess running to
  completion, piling up zombie processes.

UX:

- Dashboard period switch flips to loading and clears projects
  synchronously before reloadData runs, eliminating the frame where the
  new period label rendered over the old period's projects.

- Optimize findings tab paginates 3-at-a-time with j/k scroll. With 4
  new detectors plus 7 originals, 8-10 findings * 6 lines was scrolling
  the StatusBar off the alt buffer top.

- Custom --from/--to ranges hide the period tab strip and disable the
  1-5 / arrow keys so a stray period press no longer abandons the user's
  explicit range. A "Custom range: X to Y" banner replaces the tab strip.

- OpenCode storage-format warning is per-table-set, rate-limited to once
  per process, and points the user at OpenCode's migration step or the
  issue tracker. The previous all-or-nothing check fired the generic
  "format not recognized" string for any schema mismatch.

Menubar / OAuth:

- Both Claude and Codex bootstrap (Reconnect button) now honour the
  usageBlockedUntil 429 backoff that refreshIfBootstrapped respects.
  Spamming Reconnect during sustained rate-limit windows previously
  hammered the upstream endpoint on every click.

- Codex Retry-After HTTP header is parsed (delta-seconds plus IMF-fixdate
  fallback) so we don't over-back-off when ChatGPT tells us a shorter
  window than our 5-minute floor.

- Both credential cache files are written via SafeFile.write
  (O_CREAT | O_EXCL | O_NOFOLLOW with explicit 0600) so there is no race
  window where the temp file briefly exists at default umask, and a
  symlink at the destination cannot redirect the write. Reads now route
  through SafeFile.read with a 64 KiB cap, closing the symlink-follow gap
  on Data(contentsOf:).

CI signal:

- TypeScript strict typecheck (tsc --noEmit) is now zero errors. The
  six errors in src/providers/copilot.ts came from a discriminated-union
  catch-all branch whose `data: Record<string, unknown>` shape TS picked
  over the specific event branches when narrowing on `type`. Removed the
  catch-all; runtime falls through unknown event types via the existing
  if/else chain.

Tests added: 16 new (now 555 total)
- date-range-filter: month/day/year overflow rejection, leap-day correctness
- currency-rounding: convertCost no-rounding contract, roundForActiveCurrency
  for USD/JPY/KRW/EUR
- providers/copilot: malformed toolRequests does not abort the parse
- providers/cursor-bubble-dedup: re-parse after token mutation does not
  double-count, single parse yields one call per bubble
- providers/codex: first event with cumulativeTotal=0 not dropped,
  consecutive zero-cumulative duplicates still deduped
2026-05-06 22:15:11 -07:00

177 lines
5.8 KiB
TypeScript

import { readFile, writeFile, mkdir } from 'fs/promises'
import { join } from 'path'
import { homedir } from 'os'
import { readConfig } from './config.js'
type CurrencyState = {
code: string
rate: number
symbol: string
}
const CACHE_TTL_MS = 24 * 60 * 60 * 1000
const FRANKFURTER_URL = 'https://api.frankfurter.app/latest?from=USD&to='
// Defensive bounds on any fetched FX rate. Outside this band the rate is either a parser bug
// or a tampered Frankfurter response, and we refuse to multiply it into displayed costs.
const MIN_VALID_FX_RATE = 0.0001
const MAX_VALID_FX_RATE = 1_000_000
function isValidRate(value: unknown): value is number {
return typeof value === 'number'
&& Number.isFinite(value)
&& value >= MIN_VALID_FX_RATE
&& value <= MAX_VALID_FX_RATE
}
let active: CurrencyState = { code: 'USD', rate: 1, symbol: '$' }
const USD: CurrencyState = { code: 'USD', rate: 1, symbol: '$' }
// Intl.NumberFormat throws on invalid ISO 4217 codes, so we use it as a validator
export function isValidCurrencyCode(code: string): boolean {
try {
new Intl.NumberFormat('en', { style: 'currency', currency: code })
return true
} catch {
return false
}
}
function resolveSymbol(code: string): string {
const parts = new Intl.NumberFormat('en', {
style: 'currency',
currency: code,
currencyDisplay: 'symbol',
}).formatToParts(0)
return parts.find(p => p.type === 'currency')?.value ?? code
}
export function getFractionDigits(code: string): number {
return new Intl.NumberFormat('en', {
style: 'currency',
currency: code,
}).resolvedOptions().maximumFractionDigits ?? 2
}
/// Round a converted cost to the currency's natural decimal places. JPY/KRW/CLP
/// resolve to 0 fraction digits — exporting those with `round2` produced rows
/// like `¥412.37` while the dashboard rendered `¥412`, breaking finance reports
/// that compare the two surfaces.
export function roundForActiveCurrency(value: number): number {
const code = getCurrency().code
const digits = getFractionDigits(code)
const factor = Math.pow(10, digits)
return Math.round(value * factor) / factor
}
function getCacheDir(): string {
return join(homedir(), '.cache', 'codeburn')
}
function getRateCachePath(): string {
return join(getCacheDir(), 'exchange-rate.json')
}
async function fetchRate(code: string): Promise<number> {
const response = await fetch(`${FRANKFURTER_URL}${code}`)
if (!response.ok) throw new Error(`HTTP ${response.status}`)
const data = await response.json() as { rates?: Record<string, unknown> }
const rate = data.rates?.[code]
if (!isValidRate(rate)) throw new Error(`Invalid rate returned for ${code}`)
return rate
}
async function loadCachedRate(code: string): Promise<number | null> {
try {
const raw = await readFile(getRateCachePath(), 'utf-8')
const cached = JSON.parse(raw) as Partial<{ timestamp: number; code: string; rate: number }>
// Validate every field -- a tampered cache file could set rate to a string, null, or
// Infinity and break downstream math silently.
if (typeof cached.code !== 'string' || cached.code !== code) return null
if (typeof cached.timestamp !== 'number' || !Number.isFinite(cached.timestamp)) return null
if (Date.now() - cached.timestamp > CACHE_TTL_MS) return null
if (!isValidRate(cached.rate)) return null
return cached.rate
} catch {
return null
}
}
async function cacheRate(code: string, rate: number): Promise<void> {
await mkdir(getCacheDir(), { recursive: true })
await writeFile(getRateCachePath(), JSON.stringify({ timestamp: Date.now(), code, rate }))
}
async function getExchangeRate(code: string): Promise<number> {
if (code === 'USD') return 1
const cached = await loadCachedRate(code)
if (cached) return cached
let rate: number
try {
rate = await fetchRate(code)
} catch {
return 1
}
// Persist the rate, but never let a cache-write failure (disk full, no
// permissions, etc.) cause us to return the USD-equivalent fallback.
// The original code wrapped fetch + cacheRate in one try/catch, so a
// disk-full at write time would discard a perfectly good rate and silently
// make every cost render as if the user had selected USD.
cacheRate(code, rate).catch(() => {})
return rate
}
export async function loadCurrency(): Promise<void> {
const config = await readConfig()
if (!config.currency) return
const code = config.currency.code.toUpperCase()
const rate = await getExchangeRate(code)
const symbol = config.currency.symbol ?? resolveSymbol(code)
active = { code, rate, symbol }
}
export function getCurrency(): CurrencyState {
return active
}
export async function switchCurrency(code: string): Promise<void> {
if (code === 'USD') {
active = USD
return
}
const rate = await getExchangeRate(code)
const symbol = resolveSymbol(code)
active = { code, rate, symbol }
}
export function getCostColumnHeader(): string {
return `Cost (${active.code})`
}
export function convertCost(costUSD: number): number {
// Return the unrounded converted cost. Rounding here meant zero-fraction
// currencies (JPY, KRW, CLP) clamped every per-session cost to the nearest
// whole unit before aggregation; a project with 1000 sessions averaging
// ¥0.4 each would aggregate to ¥0 instead of ¥400 because each row was
// rounded independently. formatCost (and the export rowsToCsv path) round
// at the display boundary instead.
return costUSD * active.rate
}
export function formatCost(costUSD: number): string {
const { rate, symbol, code } = active
const cost = costUSD * rate
const digits = getFractionDigits(code)
if (digits === 0) return `${symbol}${Math.round(cost)}`
if (cost >= 1) return `${symbol}${cost.toFixed(2)}`
if (cost >= 0.01) return `${symbol}${cost.toFixed(3)}`
if (cost >= 0.0001) return `${symbol}${cost.toFixed(4)}`
return `${symbol}${cost.toFixed(2)}`
}