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>
108 lines
3.4 KiB
TypeScript
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;
|
|
}
|