diff --git a/src/server/services/currency.service.ts b/src/server/services/currency.service.ts new file mode 100644 index 0000000..e90e675 --- /dev/null +++ b/src/server/services/currency.service.ts @@ -0,0 +1,112 @@ +export interface ExchangeRates { + base: string; + date: string; + rates: Record; +} + +export const CURRENCY_MARKET_MAP: Record = { + 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 { + 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; + }; + + // 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; +} diff --git a/tests/services/currency.service.test.ts b/tests/services/currency.service.test.ts new file mode 100644 index 0000000..635fbdb --- /dev/null +++ b/tests/services/currency.service.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from "bun:test"; +import { + CURRENCY_MARKET_MAP, + type ExchangeRates, + convertPrice, + getMarketForCurrency, +} from "../../src/server/services/currency.service"; + +const mockRates: ExchangeRates = { + base: "EUR", + date: "2026-04-13", + rates: { + EUR: 1, + USD: 1.08, + GBP: 0.86, + JPY: 163.5, + CAD: 1.47, + AUD: 1.65, + }, +}; + +describe("convertPrice", () => { + it("returns same amount when from === to", () => { + expect(convertPrice(1000, "EUR", "EUR", mockRates)).toBe(1000); + expect(convertPrice(2500, "USD", "USD", mockRates)).toBe(2500); + }); + + it("converts EUR to USD correctly", () => { + // 1000 EUR cents * 1.08 = 1080 USD cents + expect(convertPrice(1000, "EUR", "USD", mockRates)).toBe(1080); + }); + + it("converts USD to EUR correctly", () => { + // 1080 USD cents / 1.08 * 1 = 1000 EUR cents + expect(convertPrice(1080, "USD", "EUR", mockRates)).toBe(1000); + }); + + it("converts between non-EUR currencies via EUR base", () => { + // 1000 USD cents → EUR: 1000 / 1.08 = 925.926 → GBP: 925.926 * 0.86 = 796.296 → rounded 796 + expect(convertPrice(1000, "USD", "GBP", mockRates)).toBe(796); + }); + + it("rounds to nearest integer", () => { + // 100 EUR cents * 1.08 = 108 USD cents (exact) + expect(convertPrice(100, "EUR", "USD", mockRates)).toBe(108); + // 100 EUR cents * 0.86 = 86 GBP cents (exact) + expect(convertPrice(100, "EUR", "GBP", mockRates)).toBe(86); + }); + + it("returns original for unknown currency", () => { + expect(convertPrice(1000, "EUR", "XYZ", mockRates)).toBe(1000); + expect(convertPrice(1000, "XYZ", "EUR", mockRates)).toBe(1000); + }); +}); + +describe("CURRENCY_MARKET_MAP", () => { + it("maps EUR to EU", () => { + expect(CURRENCY_MARKET_MAP.EUR).toBe("EU"); + }); + + it("maps USD to US", () => { + expect(CURRENCY_MARKET_MAP.USD).toBe("US"); + }); + + it("maps GBP to UK", () => { + expect(CURRENCY_MARKET_MAP.GBP).toBe("UK"); + }); + + it("maps all 6 supported currencies", () => { + expect(Object.keys(CURRENCY_MARKET_MAP)).toHaveLength(6); + expect(CURRENCY_MARKET_MAP.JPY).toBe("JP"); + expect(CURRENCY_MARKET_MAP.CAD).toBe("CA"); + expect(CURRENCY_MARKET_MAP.AUD).toBe("AU"); + }); +}); + +describe("getMarketForCurrency", () => { + it("returns mapped market for known currency", () => { + expect(getMarketForCurrency("EUR")).toBe("EU"); + expect(getMarketForCurrency("USD")).toBe("US"); + expect(getMarketForCurrency("GBP")).toBe("UK"); + }); + + it("returns currency itself for unknown currency", () => { + expect(getMarketForCurrency("XYZ")).toBe("XYZ"); + }); +});