forked from getagentseal/codeburn
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathcurrency.ts
More file actions
155 lines (128 loc) · 4.61 KB
/
Copy pathcurrency.ts
File metadata and controls
155 lines (128 loc) · 4.61 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
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
}
function getFractionDigits(code: string): number {
return new Intl.NumberFormat('en', {
style: 'currency',
currency: code,
}).resolvedOptions().maximumFractionDigits ?? 2
}
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
try {
const rate = await fetchRate(code)
await cacheRate(code, rate)
return rate
} catch {
return 1
}
}
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 {
const digits = getFractionDigits(active.code)
const factor = 10 ** digits
return Math.round(costUSD * active.rate * factor) / factor
}
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)}`
return `${symbol}${cost.toFixed(4)}`
}