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:
65
src/services/exchange-rates.ts
Normal file
65
src/services/exchange-rates.ts
Normal 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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user