From 79057ea603593b6fcaae82f3f83a7956e1c22609 Mon Sep 17 00:00:00 2001 From: jubnl <66769052+jubnl@users.noreply.github.com> Date: Tue, 16 Jun 2026 20:59:08 +0200 Subject: [PATCH] chore: move to Frankfurter API for exchange rate (#1214) --- client/src/hooks/useExchangeRates.ts | 17 +++++++++++------ client/src/pages/DashboardPage.test.tsx | 7 +++++-- client/src/pages/DashboardPage.tsx | 10 ++++++++-- server/src/middleware/globalMiddleware.ts | 2 +- server/src/services/exchangeRateService.ts | 15 +++++++++++---- shared/package.json | 2 +- wiki/Dashboard-Widgets.md | 2 +- 7 files changed, 38 insertions(+), 17 deletions(-) diff --git a/client/src/hooks/useExchangeRates.ts b/client/src/hooks/useExchangeRates.ts index 6470b549..f1f5c3fd 100644 --- a/client/src/hooks/useExchangeRates.ts +++ b/client/src/hooks/useExchangeRates.ts @@ -2,7 +2,7 @@ import { useCallback, useEffect, useState } from 'react' /** * Live FX rates for the Costs panel, used to convert every amount into the user's - * display currency. Fetches exchangerate-api.com (no key, already CSP-allowlisted + * display currency. Fetches api.frankfurter.dev (no key, already CSP-allowlisted * for the dashboard widget) for the given base and caches per base in memory + * localStorage for a few hours. rates[X] = units of X per 1 base, so an amount in * currency C converts to base as `amount / rates[C]`. @@ -33,14 +33,19 @@ export function useExchangeRates(base: string) { if (cached) setRates(cached.rates) if (cached && Date.now() - cached.ts < TTL_MS) return let cancelled = false - fetch(`https://api.exchangerate-api.com/v4/latest/${encodeURIComponent(upper)}`) + fetch(`https://api.frankfurter.dev/v2/rates?base=${encodeURIComponent(upper)}`) .then(r => r.json()) - .then((d: { rates?: Record }) => { - if (cancelled || !d?.rates) return - const entry = { rates: d.rates, ts: Date.now() } + .then((d: Array<{ quote?: string; rate?: number }>) => { + if (cancelled || !Array.isArray(d)) return + // Frankfurter omits the base's own self-rate, so seed it with `base = 1`. + const rates: Record = { [upper]: 1 } + for (const r of d) { + if (r && typeof r.quote === 'string' && typeof r.rate === 'number') rates[r.quote] = r.rate + } + const entry = { rates, ts: Date.now() } mem.set(upper, entry) try { localStorage.setItem('trek_fx_' + upper, JSON.stringify(entry)) } catch { /* ignore */ } - setRates(d.rates) + setRates(rates) }) .catch(() => { /* offline → keep cached/identity */ }) return () => { cancelled = true } diff --git a/client/src/pages/DashboardPage.test.tsx b/client/src/pages/DashboardPage.test.tsx index 36f7e28f..73a5e36f 100644 --- a/client/src/pages/DashboardPage.test.tsx +++ b/client/src/pages/DashboardPage.test.tsx @@ -20,8 +20,11 @@ beforeEach(() => { } as any); // Intercept CurrencyWidget's external fetch so it resolves before teardown server.use( - http.get('https://api.exchangerate-api.com/v4/latest/:currency', () => { - return HttpResponse.json({ rates: { USD: 1.08, EUR: 1, CHF: 0.97 } }); + http.get('https://api.frankfurter.dev/v2/rates', () => { + return HttpResponse.json([ + { date: '2026-06-16', base: 'EUR', quote: 'USD', rate: 1.08 }, + { date: '2026-06-16', base: 'EUR', quote: 'CHF', rate: 0.97 }, + ]); }), ); }); diff --git a/client/src/pages/DashboardPage.tsx b/client/src/pages/DashboardPage.tsx index 3c837e49..532efca7 100644 --- a/client/src/pages/DashboardPage.tsx +++ b/client/src/pages/DashboardPage.tsx @@ -461,9 +461,15 @@ function CurrencyTool(): React.ReactElement { const [rates, setRates] = useState | null>(null) const fetchRate = React.useCallback(() => { - fetch(`https://api.exchangerate-api.com/v4/latest/${from}`) + fetch(`https://api.frankfurter.dev/v2/rates?base=${from}`) .then(r => r.json()) - .then(d => setRates(d.rates ?? null)) + .then((d: Array<{ quote: string; rate: number }>) => { + if (!Array.isArray(d)) { setRates(null); return } + // Frankfurter omits the base's own self-rate; seed it so `from` stays selectable. + const map: Record = { [from]: 1 } + for (const r of d) map[r.quote] = r.rate + setRates(map) + }) .catch(() => setRates(null)) }, [from]) diff --git a/server/src/middleware/globalMiddleware.ts b/server/src/middleware/globalMiddleware.ts index d02f752f..5def1bd4 100644 --- a/server/src/middleware/globalMiddleware.ts +++ b/server/src/middleware/globalMiddleware.ts @@ -96,7 +96,7 @@ export function applyGlobalMiddleware( "https://en.wikipedia.org", "https://commons.wikimedia.org", "https://*.basemaps.cartocdn.com", "https://*.tile.openstreetmap.org", "https://unpkg.com", "https://open-meteo.com", "https://api.open-meteo.com", - "https://geocoding-api.open-meteo.com", "https://api.exchangerate-api.com", + "https://geocoding-api.open-meteo.com", "https://api.frankfurter.dev", "https://router.project-osrm.org/route/v1/", "https://routing.openstreetmap.de/", "https://api.mapbox.com", "https://*.tiles.mapbox.com", "https://events.mapbox.com" ], diff --git a/server/src/services/exchangeRateService.ts b/server/src/services/exchangeRateService.ts index bcbf6d0f..18443133 100644 --- a/server/src/services/exchangeRateService.ts +++ b/server/src/services/exchangeRateService.ts @@ -1,7 +1,7 @@ /** * Live exchange rates for the Costs/Budget money conversion. * - * Fetches from exchangerate-api.com (no key, already CSP-allowlisted for the + * Fetches from api.frankfurter.dev (no key, already CSP-allowlisted for the * dashboard widget) and caches per base currency in-memory for a few hours so a * settlement request never hammers the upstream. Rates are "units of X per 1 * base", so an amount in currency C converts to base as `amount / rates[C]`. @@ -17,10 +17,17 @@ const inflight = new Map | null>>(); async function fetchRates(base: string): Promise | null> { try { - const res = await fetch(`https://api.exchangerate-api.com/v4/latest/${encodeURIComponent(base)}`); + const res = await fetch(`https://api.frankfurter.dev/v2/rates?base=${encodeURIComponent(base)}`); if (!res.ok) return null; - const data = (await res.json()) as { rates?: Record }; - return data.rates && typeof data.rates === 'object' ? data.rates : null; + // Frankfurter returns an array of { date, base, quote, rate } and omits the + // base's own self-rate, so seed the map with `base = 1` then index by quote. + const data = (await res.json()) as Array<{ quote?: string; rate?: number }>; + if (!Array.isArray(data)) return null; + const rates: Record = { [base.toUpperCase()]: 1 }; + for (const r of data) { + if (r && typeof r.quote === 'string' && typeof r.rate === 'number') rates[r.quote] = r.rate; + } + return Object.keys(rates).length > 1 ? rates : null; } catch { return null; } diff --git a/shared/package.json b/shared/package.json index ba0c9794..cfb26ff2 100644 --- a/shared/package.json +++ b/shared/package.json @@ -51,7 +51,7 @@ }, "scripts": { "build": "tsdown", - "build:watch": "tsdown --watch", + "build:watch": "tsdown --watch --no-clean", "test": "vitest run", "test:watch": "vitest", "typecheck": "tsc --noEmit", diff --git a/wiki/Dashboard-Widgets.md b/wiki/Dashboard-Widgets.md index 98705a11..58ba21dd 100644 --- a/wiki/Dashboard-Widgets.md +++ b/wiki/Dashboard-Widgets.md @@ -31,7 +31,7 @@ The currency converter lets you quickly convert an amount between two currencies You can also click the swap arrow to reverse source and target. -**Exchange rates** are fetched from [exchangerate-api.com](https://www.exchangerate-api.com) using the `https://api.exchangerate-api.com/v4/latest/{from}` endpoint. Rates are refreshed each time you change a currency or click the refresh icon. +**Exchange rates** are fetched from [Frankfurter](https://frankfurter.dev) using the `https://api.frankfurter.dev/v2/rates?base={from}` endpoint. Rates are refreshed each time you change a currency or click the refresh icon. **Supported currencies:** 162 currencies are available in the selector, including all major fiat currencies (USD, EUR, GBP, JPY, etc.) and many minor ones.