import { readFile, writeFile, mkdir } from 'fs/promises' import { join } from 'path' import { homedir } from 'os' import { readConfig } from './config.js' import { fetchWithTimeout } from './fetch-utils.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: '$' } const SYMBOL_OVERRIDES: Record = { CNY: '¥', RON: 'lei', } // 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 { if (SYMBOL_OVERRIDES[code]) return SYMBOL_OVERRIDES[code] 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 { // Honor the same relocation override every other cache module uses // (session-cache, daily-cache, codex-cache, models); this was the one // straggler still hardcoding the default path. return process.env['CODEBURN_CACHE_DIR'] ?? join(homedir(), '.cache', 'codeburn') } function getRateCachePath(): string { return join(getCacheDir(), 'exchange-rate.json') } async function fetchRate(code: string): Promise { // Bounded so a stalled network can't hang the daily refresh for non-USD users // (same wedge as the pricing fetch); callers fall back to USD / cached rate. const response = await fetchWithTimeout(`${FRANKFURTER_URL}${code}`) if (!response.ok) throw new Error(`HTTP ${response.status}`) const data = await response.json() as { rates?: Record } const rate = data.rates?.[code] if (!isValidRate(rate)) throw new Error(`Invalid rate returned for ${code}`) return rate } async function loadCachedRate(code: string): Promise { 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 { await mkdir(getCacheDir(), { recursive: true }) await writeFile(getRateCachePath(), JSON.stringify({ timestamp: Date.now(), code, rate })) } async function getExchangeRate(code: string): Promise { 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 { 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 { 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)}` }