/** * Czech National Bank (ČNB) exchange rate service. * Fetches daily rates and caches them. * API: https://api.cnb.cz/cnbapi/exrates/daily */ interface CnbRate { currencyCode: string; rate: number; amount: number; } // Per-key cache: each key carries its OWN fetch timestamp so a hit on one date // never refreshes the TTL of another. A stale entry is only re-fetched (and // only that key is checked for the stale fallback). const rateCache = new Map< string, { rates: Record; time: number } >(); const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours const FETCH_TIMEOUT_MS = 8000; // abort a hung ČNB endpoint const inflight = new Map>>(); async function fetchRatesForDate( date?: string, ): Promise> { const key = date || "today"; const cached = rateCache.get(key); if (cached && Date.now() - cached.time <= CACHE_TTL_MS) return cached.rates; if (inflight.has(key)) return inflight.get(key)!; const promise = (async () => { try { let url = "https://api.cnb.cz/cnbapi/exrates/daily?lang=EN"; if (date) url += `&date=${date}`; const response = await fetch(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), }); if (!response.ok) throw new Error(`CNB API: ${response.status}`); const data = (await response.json()) as { rates?: CnbRate[] }; if (!Array.isArray(data.rates)) { throw new Error("CNB API: neočekávaný formát odpovědi (rates)"); } const rates: Record = { CZK: 1 }; for (const r of data.rates) { rates[r.currencyCode] = r.rate / r.amount; } rateCache.set(key, { rates, time: Date.now() }); return rates; } catch (err) { console.error("Failed to fetch CNB exchange rates:", err); // Stale-cache fallback for the SAME key (e.g. a hung/aborted refresh of // an entry that is past TTL — better to serve yesterday's rate than fail). const stale = rateCache.get(key); if (stale) return stale.rates; throw new Error("Nepodařilo se získat aktuální kurzy z ČNB", { cause: err, }); } finally { inflight.delete(key); } })(); inflight.set(key, promise); return promise; } /** * Test-only: clear the module-level rate cache (and any in-flight fetches). * The cache persists for the process lifetime, so tests that assert on * specific fetched rates must reset it between runs to avoid order-dependent * results (a populated "today" entry would otherwise short-circuit the mock). * Not used by runtime code. */ export function __resetRateCacheForTest(): void { rateCache.clear(); inflight.clear(); } /** Convert an amount from a given currency to CZK using CNB rates */ export async function toCzk( amount: number, currency: string, date?: string, ): Promise { if (currency === "CZK") return amount; const rates = await fetchRatesForDate(date); const rate = rates[currency]; if (!rate) throw new Error(`Neznámá měna: ${currency}`); return Math.round(amount * rate * 100) / 100; } /** Get CNB rate for a currency (CZK per 1 unit), optionally for a specific date */ export async function getRate( currency: string, date?: string, ): Promise { if (currency === "CZK") return 1; const rates = await fetchRatesForDate(date); const rate = rates[currency]; if (!rate) throw new Error(`Neznámá měna: ${currency}`); return rate; }