feat(33-01): add currency conversion service with exchange rate caching

- Create currency.service.ts with frankfurter.app ECB rate fetching
- 24h in-memory cache with stale-serve fallback on fetch failure
- convertPrice() handles EUR-base cross-currency conversion
- CURRENCY_MARKET_MAP maps currencies to market regions
- 12 unit tests covering conversion, rounding, unknowns, and mapping

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-13 18:02:06 +02:00
parent 298fa6d586
commit 50bc11c7ed
2 changed files with 199 additions and 0 deletions

View File

@@ -0,0 +1,112 @@
export interface ExchangeRates {
base: string;
date: string;
rates: Record<string, number>;
}
export const CURRENCY_MARKET_MAP: Record<string, string> = {
EUR: "EU",
USD: "US",
GBP: "UK",
JPY: "JP",
CAD: "CA",
AUD: "AU",
};
export function getMarketForCurrency(currency: string): string {
return CURRENCY_MARKET_MAP[currency] ?? currency;
}
// In-memory cache
let cachedRates: ExchangeRates | null = null;
let cacheExpiry = 0;
const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
const FRANKFURTER_URL = "https://api.frankfurter.app/latest?from=EUR";
export async function getExchangeRates(): Promise<ExchangeRates> {
const now = Date.now();
// Return cached rates if still valid
if (cachedRates && now < cacheExpiry) {
return cachedRates;
}
try {
const response = await fetch(FRANKFURTER_URL);
if (!response.ok) {
throw new Error(`frankfurter.app returned ${response.status}`);
}
const data = (await response.json()) as {
base: string;
date: string;
rates: Record<string, number>;
};
// Validate response shape
if (!data.base || !data.rates || typeof data.rates !== "object") {
throw new Error("Invalid exchange rate response shape");
}
// Reject if any rate is negative or zero
for (const [currency, rate] of Object.entries(data.rates)) {
if (typeof rate !== "number" || rate <= 0) {
throw new Error(`Invalid rate for ${currency}: ${rate}`);
}
}
// Include base currency in rates for conversion math
const rates: ExchangeRates = {
base: data.base,
date: data.date,
rates: { ...data.rates, [data.base]: 1 },
};
cachedRates = rates;
cacheExpiry = now + CACHE_TTL_MS;
return rates;
} catch (error) {
// Stale-serve: return cached rates if available
if (cachedRates) {
return cachedRates;
}
throw error;
}
}
/**
* Convert price in cents from one currency to another.
* All conversions go through EUR as the base currency.
*/
export function convertPrice(
cents: number,
from: string,
to: string,
rates: ExchangeRates,
): number {
if (from === to) return cents;
const fromRate = rates.rates[from];
const toRate = rates.rates[to];
if (fromRate == null || toRate == null) {
// Unknown currency — return original
return cents;
}
// Convert: from → EUR → to
const centsInEur = cents / fromRate;
const result = centsInEur * toRate;
return Math.round(result);
}
/**
* Reset cache — for testing only.
*/
export function resetCache(): void {
cachedRates = null;
cacheExpiry = 0;
}