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:
112
src/server/services/currency.service.ts
Normal file
112
src/server/services/currency.service.ts
Normal 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;
|
||||
}
|
||||
87
tests/services/currency.service.test.ts
Normal file
87
tests/services/currency.service.test.ts
Normal file
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user