feat: CNB exchange rates, multi-currency KPI stats, invoice PDF VAT in CZK

- ČNB exchange rate service with date-specific rates and caching
- Invoice/received invoice stats convert foreign currencies to CZK
- Dashboard revenue converts all currencies to CZK
- Invoice PDF: VAT recap table always in CZK with CNB rate footer
- Inline styles replaced with utility classes (step 4 cleanup)
- Spinner animation exempt from prefers-reduced-motion

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-03-27 13:44:53 +01:00
parent a3ef37d0d2
commit 8cdf057ab3
13 changed files with 173 additions and 117 deletions

View File

@@ -0,0 +1,65 @@
/**
* 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;
}
const rateCache: Record<string, Record<string, number>> = {};
async function fetchRatesForDate(
date?: string,
): Promise<Record<string, number>> {
const key = date || "today";
if (rateCache[key]) return rateCache[key];
try {
let url = "https://api.cnb.cz/cnbapi/exrates/daily?lang=EN";
if (date) url += `&date=${date}`;
const response = await fetch(url);
if (!response.ok) throw new Error(`CNB API: ${response.status}`);
const data = (await response.json()) as { rates: CnbRate[] };
const rates: Record<string, number> = { CZK: 1 };
for (const r of data.rates) {
rates[r.currencyCode] = r.rate / r.amount;
}
rateCache[key] = rates;
return rates;
} catch (err) {
console.error("Failed to fetch CNB exchange rates:", err);
if (rateCache["today"]) return rateCache["today"];
return { CZK: 1, EUR: 25, USD: 22, GBP: 28 };
}
}
/** 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) return amount;
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);
return rates[currency] || 1;
}

View File

@@ -1,4 +1,5 @@
import prisma from "../config/database";
import { toCzk } from "./exchange-rates";
// Status transition rules matching PHP
const VALID_TRANSITIONS: Record<string, string[]> = {
@@ -186,10 +187,11 @@ export async function getInvoiceStats(queryMonth?: number, queryYear?: number) {
}));
};
const sumCzk = (invoices: typeof allInvoices) => {
const sumCzk = async (invoices: typeof allInvoices) => {
let total = 0;
for (const inv of invoices) {
total += invoiceTotalWithVat(inv); // Simplified: no real FX conversion
const amount = invoiceTotalWithVat(inv);
total += await toCzk(amount, inv.currency || "CZK");
}
return Math.round(total * 100) / 100;
};
@@ -224,18 +226,24 @@ export async function getInvoiceStats(queryMonth?: number, queryYear?: number) {
let vatCzk = 0;
for (const [, v] of Object.entries(vatMap)) vatCzk += v;
// VAT also needs conversion
let vatCzkConverted = 0;
for (const [cur, amount] of Object.entries(vatMap)) {
vatCzkConverted += await toCzk(amount, cur);
}
return {
paid_month: aggregateByCurrency(paidInvoices),
paid_month_czk: sumCzk(paidInvoices),
paid_month_czk: await sumCzk(paidInvoices),
paid_month_count: paidInvoices.length,
awaiting: aggregateByCurrency(awaitingInvoices),
awaiting_czk: sumCzk(awaitingInvoices),
awaiting_czk: await sumCzk(awaitingInvoices),
awaiting_count: awaitingInvoices.length,
overdue: aggregateByCurrency(overdueInvoices),
overdue_czk: sumCzk(overdueInvoices),
overdue_czk: await sumCzk(overdueInvoices),
overdue_count: overdueInvoices.length,
vat_month: vatAmounts,
vat_month_czk: Math.round(vatCzk * 100) / 100,
vat_month_czk: Math.round(vatCzkConverted * 100) / 100,
month,
year,
};