Files
app/src/services/exchange-rates.ts
BOHA 1ee59b54bc chore(release): v2.4.35 — lint cleanup (68→0) + duplicate-Zavřít modal fix
UI fix:
- Close-only modals showing a redundant second close button now use
  hideCancel: ReceivedInvoices paid-detail modal (was two identical "Zavřít"
  buttons) and PlanCellModal "Den je součástí rozsahu".
- Add modal-duplicate-close test enforcing close-only modals set hideCancel.

Lint: cleared all 68 warnings → 0.
- preserve-caught-error: attach { cause } in ai.service / exchange-rates.
- no-require-imports: package.json version read via fs (APP_VERSION) instead
  of require(), avoiding a rootDir-expanding static JSON import.
- react-hooks/exhaustive-deps (11): ref-in-cleanup copies, derived-value
  useMemo wrapping, PlanGrid field extraction, stable nextKey useCallback,
  AuthContext documented cycle-break.
- no-explicit-any (53): precise route param/Prisma types, generic enrich*()
  preserving payload shape, minimal vite module type, frontend body/query-key
  types, SystemInfo for Settings.

Refactor (test enablement): shift-form types moved to dependency-free
shiftFormTypes.ts so the print-HTML builders are unit-testable without the
component graph; characterization test pins their output.

Gates: 649 tests pass, tsc -b clean, lint 0. Verified touched flows live
via Playwright (PlanWork CRUD + optimistic cache, warehouse form keys,
Settings system info, invoice detail).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 18:35:31 +02:00

108 lines
3.4 KiB
TypeScript

/**
* 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<string, number>; 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<string, Promise<Record<string, number>>>();
async function fetchRatesForDate(
date?: string,
): Promise<Record<string, number>> {
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<string, number> = { 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<number> {
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<number> {
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;
}