diff --git a/config/widgets.php b/config/widgets.php index 9df460e9..c65d3b52 100644 --- a/config/widgets.php +++ b/config/widgets.php @@ -55,4 +55,19 @@ return [ 'summary' => 'Platz fuer persoenliche Hinweise und Notizen je Workspace.', 'content' => 'Dieser Bereich ist vorbereitet, aber standardmaessig noch nicht eingeblendet.', ], + [ + 'widget_id' => 'fx-rates-refresh', + 'title' => 'Waehrungskurse', + 'icon' => 'FX', + 'zone' => 'sidebar', + 'default_enabled' => false, + 'supports_public_home' => false, + 'summary' => 'Aktuelle FX-Kurse pruefen und bei Bedarf manuell aktualisieren.', + 'content' => 'Ein API-Abruf wird nur ausgefuehrt, wenn der letzte Snapshot alt genug ist, ausser bei explizitem Force.', + 'launch_app_id' => 'fx-rates', + 'action_label' => 'App oeffnen', + 'widget_type' => 'fx-rates-refresh', + 'status_api' => '/api/fx-rates/index.php?path=v1/status', + 'refresh_api' => '/api/fx-rates/index.php?path=v1/refresh', + ], ]; diff --git a/docs/ANLEITUNG.md b/docs/ANLEITUNG.md index ce303003..36922e38 100644 --- a/docs/ANLEITUNG.md +++ b/docs/ANLEITUNG.md @@ -67,9 +67,19 @@ Klassische Module liegen unter `modules//` und koennen als normale `App` Aktuell gilt: - der `Mining-Checker` ist das erste echte Modul in dieser Form +- der `Waehrungs-Checker` ist das zweite echte Modul in dieser Form - Modul-Businesslogik bleibt im Modul und wird nicht in den Desktop-Core verschoben - gemeinsame Desktop-Mechaniken wie Fenster, Asset-Einbindung und Zugriffsschutz werden global bereitgestellt +## Waehrungs-Checker + +Der `Waehrungs-Checker` verwaltet gespeicherte Wechselkurse, Kurs-Historie, Umrechnungen und manuelle Aktualisierung. + +- das Oeffnen der App loest keinen externen Abruf aus +- ein manueller Abruf ist nur erlaubt, wenn die letzten gespeicherten Kurse aelter als die konfigurierte Sperrzeit sind +- mit `force` kann ein Abruf bewusst erzwungen werden +- das zugehoerige Widget im `Widget-Bereich` nutzt dieselbe Regel wie App und API + ## Benutzerdaten Aktuell gelten folgende Regeln: diff --git a/docs/CONTENT.md b/docs/CONTENT.md index a0a73b58..711cf8b8 100644 --- a/docs/CONTENT.md +++ b/docs/CONTENT.md @@ -85,6 +85,7 @@ Stand dieser Datei: - `User Self Management` als Setup-App - erster echter Modulpfad fuer klassische Module mit Modul-Discovery aus `modules//` - `Mining-Checker` als erstes angebundenes klassisches Modul +- `Waehrungs-Checker` als zweites angebundenes klassisches Modul mit Desktop-Widget fuer Kursaktualisierung - versionierte API auf demselben Host unter `(staging.)desktop.kusche.berlin/api/v1/...` - Benutzereinstellungen fuer Desktop-Skin, App-Auswahl, Infobereich und Profildaten in einer eigenen Datenbanktabelle mit `_user_data`-Suffix - vorhandene JSON-Dateien dienen nur noch als Fallback oder Uebergang fuer lokale Entwicklung und Altbestaende @@ -118,6 +119,7 @@ Aus [modules/README.md](/home/lars/Schreibtisch/Projekte/desktop.kusche.berlin/m ### Aktueller Modulstand - `Mining-Checker` ist das erste Modul, das als echte Desktop-App aus `modules/mining-checker/` angebunden wird +- `Waehrungs-Checker` ist das zweite Modul, das als echte Desktop-App aus `modules/fx-rates/` angebunden wird - die Desktop-Shell kann Moduldefinitionen zentral erkennen und als `App` bereitstellen - wiederverwendbare Modul-Helfer liegen im globalen Kern, die Fachlogik bleibt im Modul diff --git a/docs/README.md b/docs/README.md index cdbd159f..ee1fd737 100644 --- a/docs/README.md +++ b/docs/README.md @@ -50,6 +50,9 @@ Es gilt: Aktuell wichtig: - der Mining-Checker ist das erste als echtes Desktop-Modul angebundene Modul unter `modules/mining-checker/` +- der Waehrungs-Checker ist als zweites echtes Desktop-Modul unter `modules/fx-rates/` angebunden - Modul-Assets koennen ueber einen gemeinsamen Auslieferungsweg aus dem Modul selbst geladen werden - die API bleibt vorerst auf demselben Host und wird nicht auf `api.desktop.kusche.berlin` ausgelagert - das gemeinsame Admin-Debug-Widget neben der Uhr ist der Standard fuer Live-Debugging in Desktop und Native-Apps +- der Waehrungs-Checker stellt zusaetzlich ein Desktop-Widget fuer manuelle Kursaktualisierung bereit +- das Oeffnen des Waehrungs-Checkers darf keinen externen Kursabruf ausloesen; Refreshes laufen nur nach Altersregel oder mit `force` diff --git a/docs/Umsetzungsanweisung/START_HIER.md b/docs/Umsetzungsanweisung/START_HIER.md index a60afccc..8f031df1 100644 --- a/docs/Umsetzungsanweisung/START_HIER.md +++ b/docs/Umsetzungsanweisung/START_HIER.md @@ -33,6 +33,8 @@ Bitte diese Dateien in genau dieser Reihenfolge lesen: - Die Umsetzung erfolgt **nicht** mehr im alten Nexus-Projekt. - Die bestehenden Module aus Nexus dienen als Basis und Referenz, sollen aber in `desktop.kusche.berlin` separat weiterentwickelt werden. - Der neue UI-Layer ist eine neue Anwendungsschicht, kein Theme-Umbau des alten Systems. +- Importierte Module sollen im neuen Projekt als echte Desktop-Module mit eigener `desktop.php`, eigener `api/`-Struktur und eigenen `public/apps/...` beziehungsweise `public/api/...`-Einstiegspunkten angebunden werden. +- Gemeinsame Regeln wie Debugging, Zugriffsschutz, Fensterstandard und Widget-Verhalten sollen zentral loesbar sein und nicht pro importierter App neu erfunden werden. ## Erwartetes Vorgehen im neuen Projekt diff --git a/docs/WEITERENTWICKLUNG.md b/docs/WEITERENTWICKLUNG.md index af0f48b0..e5c1b5d3 100644 --- a/docs/WEITERENTWICKLUNG.md +++ b/docs/WEITERENTWICKLUNG.md @@ -35,6 +35,9 @@ Verbindlich ist: - `README.md`-Dateien in Teilbereichen aktuell halten - zentrale Doku bei jeder relevanten Struktur- oder Begriffsanpassung mitpflegen - API-Pfade versioniert und host-konsistent unter `(staging.)desktop.kusche.berlin/api/v1/...` halten +- importierte Module mit eigener API ueber konsistente `public/api//index.php`-Shims an die gemeinsame Desktop-Auth anbinden +- importierte Module mit eigener Fensterseite ueber konsistente `public/apps//index.php`-Shims anbinden +- Widget-Logik einer App muss dieselben fachlichen Regeln wie die App-API verwenden, nicht eigene Sonderwege ## NoGo's @@ -47,6 +50,7 @@ Verbindlich ist: - keine Hilfeinhalte nur in Chatverlaeufen oder Ad-hoc-Notizen belassen - keine produktive Architekturabhaengigkeit zu `Old-Nexus/` oder vergleichbaren Altbestaenden - keine neue Standard-API-Domain wie `api.desktop.kusche.berlin`, solange keine ausdrueckliche Architekturentscheidung dafuer getroffen wurde +- keine stillen externen API-Abrufe beim blossen Oeffnen einer App, wenn die Fachregel explizite Refresh-Sperren vorsieht ## Architekturregeln @@ -55,6 +59,7 @@ Verbindlich ist: - globale Modul-Helfer duerfen im gemeinsamen Kern liegen, Fachlogik aber nicht - globale Debug-Steuerung fuer Admins liegt im Desktop-Core; Aktivierung erfolgt ueber das gemeinsame Debug-Widget neben der Uhr - API und Desktop teilen sich im aktuellen Zielbild denselben Host; offizielle Basis ist `/api/v1/...` +- App, API und Widget einer Fachfunktion muessen dieselben Sperr- und Force-Regeln fuer externe Abrufe nutzen - Skins definieren Darstellung und Interaktionsdetails, nicht die Fachlogik - Hilfe- und Inhaltsdateien sollen spaeter maschinenlesbar oder zumindest klar strukturierbar in einen Hilfebereich ueberfuehrt werden koennen diff --git a/modules/README.md b/modules/README.md index 6aa8f387..ea89677b 100644 --- a/modules/README.md +++ b/modules/README.md @@ -12,6 +12,7 @@ Ein Desktop-Modul kann zusaetzlich diese Projektdateien besitzen: - `pages/` fuer Standalone-Seiten oder iframe/native Einstiegspunkte - `api/` fuer modulinterne HTTP-Endpunkte - `assets/` fuer modulnahe CSS- und JS-Dateien +- `docs/README.md` fuer modulspezifische Hinweise, API und Sonderregeln Module-Assets werden in diesem Projekt nicht direkt aus `public/assets/apps/...` dupliziert, sondern ueber den generischen Endpoint `/module-assets/index.php` aus dem jeweiligen Modul ausgeliefert. diff --git a/modules/fx-rates/api/index.php b/modules/fx-rates/api/index.php new file mode 100644 index 00000000..a6e824f9 --- /dev/null +++ b/modules/fx-rates/api/index.php @@ -0,0 +1,17 @@ +handle($relativePath); diff --git a/modules/fx-rates/assets/css/app.css b/modules/fx-rates/assets/css/app.css new file mode 100644 index 00000000..8fbbbc1a --- /dev/null +++ b/modules/fx-rates/assets/css/app.css @@ -0,0 +1,206 @@ +#fx-rates-app { + height: 100%; +} + +#fx-rates-app .fx-shell { + height: 100%; +} + +#fx-rates-app .fx-stack { + display: grid; + gap: 18px; +} + +#fx-rates-app .fx-grid, +#fx-rates-app .fx-cards, +#fx-rates-app .fx-table-grid { + display: grid; + gap: 18px; +} + +#fx-rates-app .fx-grid { + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); +} + +#fx-rates-app .fx-cards { + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); +} + +#fx-rates-app .fx-table-grid { + grid-template-columns: minmax(0, 1.2fr) minmax(0, 0.8fr); +} + +#fx-rates-app .fx-card, +#fx-rates-app .fx-panel { + padding: 22px; + border-radius: 20px; + background: rgba(255, 255, 255, 0.9); + border: 1px solid rgba(148, 163, 184, 0.2); + box-shadow: 0 14px 32px rgba(15, 23, 42, 0.06); +} + +#fx-rates-app .fx-card h3, +#fx-rates-app .fx-panel h3, +#fx-rates-app .fx-panel h4 { + margin: 0 0 10px; +} + +#fx-rates-app .fx-copy, +#fx-rates-app .fx-meta, +#fx-rates-app .fx-label, +#fx-rates-app .fx-note { + margin: 0; + color: #475569; + line-height: 1.5; +} + +#fx-rates-app .fx-kicker { + margin: 0 0 10px; + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.18em; + color: #4338ca; + font-weight: 700; +} + +#fx-rates-app .fx-stat-value { + font-size: 1.45rem; + font-weight: 700; + color: #0f172a; +} + +#fx-rates-app .fx-inline-actions, +#fx-rates-app .fx-form-actions { + display: flex; + gap: 12px; + flex-wrap: wrap; + align-items: center; +} + +#fx-rates-app .fx-button { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 42px; + padding: 0 18px; + border-radius: 999px; + border: 1px solid rgba(79, 70, 229, 0.18); + background: linear-gradient(135deg, #4f46e5, #2563eb); + color: #ffffff; + font: inherit; + font-weight: 600; + cursor: pointer; +} + +#fx-rates-app .fx-button:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +#fx-rates-app .fx-button--ghost { + background: rgba(255, 255, 255, 0.88); + color: #1e1b4b; +} + +#fx-rates-app .fx-message { + min-height: 1.5rem; + color: #1e293b; +} + +#fx-rates-app .fx-message.is-error { + color: #b91c1c; +} + +#fx-rates-app .fx-message.is-success { + color: #0f766e; +} + +#fx-rates-app .fx-form-grid { + display: grid; + gap: 14px; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); +} + +#fx-rates-app .fx-field { + display: grid; + gap: 8px; +} + +#fx-rates-app .fx-field label { + font-size: 13px; + color: #475569; + font-weight: 600; +} + +#fx-rates-app .fx-input, +#fx-rates-app .fx-select { + width: 100%; + min-height: 44px; + padding: 0 14px; + border-radius: 14px; + border: 1px solid rgba(148, 163, 184, 0.3); + background: #ffffff; + color: #0f172a; + font: inherit; +} + +#fx-rates-app .fx-input:focus, +#fx-rates-app .fx-select:focus { + outline: none; + border-color: rgba(79, 70, 229, 0.5); + box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.12); +} + +#fx-rates-app .fx-token-row { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +#fx-rates-app .fx-token { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + border-radius: 999px; + background: rgba(79, 70, 229, 0.08); + color: #312e81; + border: 1px solid rgba(79, 70, 229, 0.14); + font-size: 14px; +} + +#fx-rates-app .fx-table-wrap { + overflow: auto; +} + +#fx-rates-app .fx-table { + width: 100%; + border-collapse: collapse; +} + +#fx-rates-app .fx-table th, +#fx-rates-app .fx-table td { + padding: 12px 10px; + text-align: left; + border-bottom: 1px solid rgba(148, 163, 184, 0.2); + vertical-align: top; +} + +#fx-rates-app .fx-table th { + color: #475569; + font-size: 13px; +} + +#fx-rates-app .fx-widget-note { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; +} + +@media (max-width: 1100px) { + #fx-rates-app .fx-table-grid { + grid-template-columns: 1fr; + } +} diff --git a/modules/fx-rates/assets/js/app.js b/modules/fx-rates/assets/js/app.js new file mode 100644 index 00000000..6328e79e --- /dev/null +++ b/modules/fx-rates/assets/js/app.js @@ -0,0 +1,698 @@ +(function () { + function parseSections(root, options) { + const configured = Array.isArray(options.sections) ? options.sections : null; + if (configured) { + return configured; + } + + try { + const parsed = JSON.parse(root.dataset.sectionsJson || '[]'); + return Array.isArray(parsed) ? parsed : []; + } catch (_error) { + return []; + } + } + + function cx() { + return Array.from(arguments).filter(Boolean).join(' '); + } + + function escapeHtml(value) { + return String(value || '') + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); + } + + function fmtNumber(value, digits) { + if (value === null || value === undefined || value === '' || !Number.isFinite(Number(value))) { + return 'n/a'; + } + return Number(value).toLocaleString('de-DE', { + minimumFractionDigits: 0, + maximumFractionDigits: digits === undefined ? 6 : digits, + }); + } + + function fmtDate(value) { + const raw = String(value || '').trim(); + if (!raw) { + return 'n/a'; + } + const parsed = new Date(raw.replace(' ', 'T')); + if (Number.isNaN(parsed.getTime())) { + return raw; + } + return parsed.toLocaleString('de-DE'); + } + + function readMessage(error) { + return error && error.message ? error.message : 'Unbekannter Fehler'; + } + + function createFxRatesApp(root, options) { + const apiBase = options.apiBase || root.dataset.apiBase || '/api/fx-rates/index.php?path=v1'; + const sectionDefs = parseSections(root, options); + const fallbackSections = [ + { key: 'overview', label: 'Uebersicht', summary: 'Letzte Abrufe, Umrechnung, Verlauf und manuelle Aktualisierung.' }, + { key: 'currencies', label: 'Waehrungen', summary: 'Bevorzugte Waehrungen, Anzeige-Basis und Katalog verwalten.' }, + { key: 'settings', label: 'Setup', summary: 'Provider, Token, Refresh-Regeln und Laufzeitparameter pflegen.' }, + ]; + const sectionMap = new Map(); + sectionDefs.concat(fallbackSections).forEach((section) => { + const key = String(section.key || '').trim(); + if (!key || sectionMap.has(key)) { + return; + } + sectionMap.set(key, { + key, + label: String(section.label || key), + summary: String(section.summary || ''), + }); + }); + const sections = Array.from(sectionMap.values()); + const initialTab = String(options.activeView || root.dataset.activeView || 'overview').trim() || 'overview'; + + const state = { + activeTab: sectionMap.has(initialTab) ? initialTab : sections[0].key, + loading: true, + saving: false, + syncingCatalog: false, + message: '', + error: '', + settings: null, + statuses: [], + recentFetches: [], + conversion: null, + history: [], + currencyProbe: null, + }; + + function setState(patch) { + Object.assign(state, patch); + render(); + } + + async function request(path, options) { + const buildApiUrl = (relativePath) => { + const url = new URL(apiBase, window.location.origin); + const [resourcePath, queryString] = String(relativePath || '').split('?'); + const currentPath = url.searchParams.get('path') || ''; + url.searchParams.set('path', `${currentPath.replace(/\/+$/, '')}/${String(resourcePath || '').replace(/^\/+/, '')}`.replace(/^\/+/, '')); + if (queryString) { + const extraParams = new URLSearchParams(queryString); + extraParams.forEach((value, key) => { + url.searchParams.set(key, value); + }); + } + return url.toString(); + }; + + const response = await fetch(buildApiUrl(path), { + method: options && options.method ? options.method : 'GET', + credentials: 'same-origin', + headers: { + Accept: 'application/json', + ...(options && options.body ? { 'Content-Type': 'application/json' } : {}), + }, + body: options && options.body ? JSON.stringify(options.body) : undefined, + }); + const payload = await response.json().catch(() => ({})); + if (!response.ok) { + throw new Error(payload?.context?.message || payload?.error || `HTTP ${response.status}`); + } + return payload.data; + } + + function currentSettings() { + return state.settings || { + preferred_currencies: ['EUR', 'USD'], + default_base_currency: 'EUR', + display_base_currency: 'EUR', + refresh_max_age_hours: 6, + refresh_max_age_minutes: 360, + currency_catalog: [], + }; + } + + function historyPairs() { + const settings = currentSettings(); + const base = String(settings.display_base_currency || settings.default_base_currency || 'EUR').toUpperCase(); + const preferred = Array.isArray(settings.preferred_currencies) ? settings.preferred_currencies : []; + return preferred + .map((code) => String(code || '').toUpperCase()) + .filter((code) => code && code !== base) + .slice(0, 4) + .map((code) => ({ from: base, to: code })); + } + + async function loadOverviewData() { + const [statuses, recentFetches] = await Promise.all([ + request('status'), + request('recent-fetches?limit=15'), + ]); + const pairs = historyPairs(); + const historyRows = await Promise.all(pairs.map(async (pair) => ({ + pair, + rows: await request(`history?from=${encodeURIComponent(pair.from)}&to=${encodeURIComponent(pair.to)}&limit=12`), + }))); + + setState({ + statuses: Array.isArray(statuses) ? statuses : [], + recentFetches: Array.isArray(recentFetches) ? recentFetches : [], + history: historyRows, + }); + } + + async function loadBootstrap() { + try { + const [settings, currencyProbe] = await Promise.all([ + request('settings'), + request('currency-catalog/probe').catch(() => null), + ]); + + setState({ + settings, + currencyProbe, + loading: false, + error: '', + }); + + await loadOverviewData(); + } catch (error) { + setState({ + loading: false, + error: readMessage(error), + }); + } + } + + async function runRefresh(force) { + setState({ + saving: true, + error: '', + message: '', + }); + + try { + const result = await request('refresh', { + method: 'POST', + body: { + force: force === true, + trigger_source: 'manual', + }, + }); + + await loadOverviewData(); + setState({ + saving: false, + message: result && result.reused + ? `Kein neuer Abruf. Letzter Snapshot ist noch innerhalb von ${fmtNumber(currentSettings().refresh_max_age_hours, 2)} Stunden.` + : `Aktuelle Kurse gespeichert. ${fmtNumber(result?.updated_count, 0)} Werte aktualisiert.`, + }); + } catch (error) { + setState({ + saving: false, + error: readMessage(error), + }); + } + } + + async function runConversion(event) { + event.preventDefault(); + const form = event.currentTarget; + const formData = new FormData(form); + const amount = Number(formData.get('amount') || 0); + const from = String(formData.get('from') || '').toUpperCase(); + const to = String(formData.get('to') || '').toUpperCase(); + + try { + const rate = await request(`rate?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`); + const resolved = Number(rate?.rate || 0); + if (!Number.isFinite(resolved) || resolved <= 0) { + throw new Error('Kein passender Kurs verfuegbar.'); + } + setState({ + conversion: { + amount, + from, + to, + rate: resolved, + converted: amount * resolved, + }, + error: '', + message: '', + }); + } catch (error) { + setState({ + conversion: null, + error: readMessage(error), + }); + } + } + + async function saveSettings(event) { + event.preventDefault(); + const form = event.currentTarget; + const formData = new FormData(form); + const preferred = String(formData.get('preferred_currencies') || '') + .split(',') + .map((item) => String(item || '').trim().toUpperCase()) + .filter(Boolean); + + setState({ saving: true, error: '', message: '' }); + + try { + const body = {}; + ['provider', 'api_version', 'api_url', 'currencies_url', 'api_key', 'default_base_currency', 'display_base_currency', 'schedule_timezone'].forEach((key) => { + if (formData.has(key)) { + body[key] = formData.get(key); + } + }); + if (formData.has('timeout_sec')) { + body.timeout_sec = Number(formData.get('timeout_sec') || 10); + } + if (formData.has('refresh_max_age_hours')) { + body.refresh_max_age_hours = Number(formData.get('refresh_max_age_hours') || 6); + } + if (formData.has('preferred_currencies')) { + body.preferred_currencies = preferred; + } + + const settings = await request('settings', { + method: 'PUT', + body, + }); + + setState({ + saving: false, + settings, + message: 'Setup gespeichert.', + }); + await loadOverviewData(); + } catch (error) { + setState({ + saving: false, + error: readMessage(error), + }); + } + } + + async function syncCatalog() { + setState({ syncingCatalog: true, error: '', message: '' }); + try { + const result = await request('currency-catalog/sync', { + method: 'POST', + body: {}, + }); + setState({ + syncingCatalog: false, + settings: result?.settings || state.settings, + message: `Waehrungskatalog synchronisiert. ${fmtNumber(result?.sync?.synced_count, 0)} Eintraege geladen.`, + }); + } catch (error) { + setState({ + syncingCatalog: false, + error: readMessage(error), + }); + } + } + + function openWidgetSetup() { + window.dispatchEvent(new CustomEvent('desktop:open-app', { + detail: { + appId: 'user-self-management', + }, + })); + } + + function renderNav() { + return ` + + `; + } + + function renderOverview() { + const settings = currentSettings(); + const latest = state.statuses[0] || null; + const base = String(settings.default_base_currency || 'EUR').toUpperCase(); + const preferred = Array.isArray(settings.preferred_currencies) ? settings.preferred_currencies : []; + + return ` +
+
+

Aktualisierung

+

Externer Kursabruf

+

Das Oeffnen der App loest keinen Abruf aus. Manuell und per API wird nur aktualisiert, wenn die bestehenden Kurse aelter als ${fmtNumber(settings.refresh_max_age_hours, 2)} Stunden sind, ausser mit force.

+
+ + + +
+

${escapeHtml(state.error || state.message || '')}

+
+ +
+
+

Letzter Abruf

+
${escapeHtml(latest ? fmtDate(latest.fetched_at_display || latest.fetched_at) : 'Noch keiner')}
+

${escapeHtml(latest ? `${latest.base_currency} · ${latest.provider} · ${latest.trigger_source_label || latest.trigger_source}` : 'Es wurden noch keine Kurse gespeichert.')}

+
+
+

Standard-Basis

+
${escapeHtml(base)}
+

Anzeige-Basis ${escapeHtml(String(settings.display_base_currency || base).toUpperCase())}

+
+
+

Bevorzugte Waehrungen

+
${escapeHtml(String(preferred.length || 0))}
+

${escapeHtml(preferred.join(', ') || 'Keine Auswahl')}

+
+
+ +
+
+

Umrechnung

+

Direkter Kursvergleich

+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+

${state.conversion + ? `${fmtNumber(state.conversion.amount, 8)} ${state.conversion.from} = ${fmtNumber(state.conversion.converted, 8)} ${state.conversion.to} bei Kurs ${fmtNumber(state.conversion.rate, 8)}` + : 'Noch keine Umrechnung berechnet.'}

+
+
+

Widget

+

Desktop-Infobereich

+
+

Das Widget kann im Setup unter Infobereich aktiviert werden und verwendet dieselbe Refresh-Regel wie die App und die API.

+ +
+
+
+ +
+

Verlauf

+

Neueste Kursverlaeufe

+
+ + + + + + + + + + + ${state.history.length === 0 || state.history.every((entry) => !Array.isArray(entry.rows) || entry.rows.length === 0) + ? '' + : state.history.map((entry) => { + const pair = `${entry.pair.from}/${entry.pair.to}`; + return (Array.isArray(entry.rows) ? entry.rows.slice(0, 4) : []).map((row, index) => ` + + + + + + + `).join(''); + }).join('')} + +
PaarZeitKursQuelle
Noch keine Verlaufsdaten vorhanden.
${index === 0 ? escapeHtml(pair) : ''}${escapeHtml(fmtDate(row.fetched_at_display || row.fetched_at))}${escapeHtml(fmtNumber(row.rate, 8))}${escapeHtml(row.provider || 'n/a')}
+
+
+ +
+

Abrufe

+

Letzte gespeicherte Snapshots

+
+ + + + + + + + + + + ${state.recentFetches.length === 0 + ? '' + : state.recentFetches.map((fetch) => ` + + + + + + + `).join('')} + +
ZeitBasisProviderAusloeser
Noch keine Abrufe gespeichert.
${escapeHtml(fmtDate(fetch.fetched_at_display || fetch.fetched_at))}${escapeHtml(fetch.base_currency || 'n/a')}${escapeHtml(fetch.provider || 'n/a')}${escapeHtml(fetch.trigger_source_label || fetch.trigger_source || 'n/a')}
+
+
+
+ `; + } + + function renderCurrencies() { + const settings = currentSettings(); + const catalog = Array.isArray(settings.currency_catalog) ? settings.currency_catalog : []; + return ` +
+
+

Katalog

+

Bevorzugte Waehrungen

+

Der Katalog wird aus dem konfigurierten Provider synchronisiert. Die Anzeige-Basis muss Teil der bevorzugten Waehrungen sein.

+
+ +
+

Letzte Katalog-Synchronisierung: ${escapeHtml(settings.currency_catalog_synced_at ? fmtDate(settings.currency_catalog_synced_at) : 'noch nie')}

+
+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+
+ +
+

Verfuegbarer Katalog

+
+ ${catalog.length === 0 + ? 'Noch kein Katalog geladen' + : catalog.map((item) => `${escapeHtml(item.code)} · ${escapeHtml(item.name)}`).join('')} +
+
+
+ `; + } + + function renderSettings() { + const settings = currentSettings(); + return ` +
+
+

Provider

+

Externe Quelle und Token

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + + +
+
+ +
+

Der importierte Token wird als Default uebernommen, kann hier aber projektweit geaendert werden.

+
+
+ `; + } + + function renderMain() { + const current = sectionMap.get(state.activeTab) || sections[0]; + let content = ''; + if (state.activeTab === 'currencies') { + content = renderCurrencies(); + } else if (state.activeTab === 'settings') { + content = renderSettings(); + } else { + content = renderOverview(); + } + + return ` +
+
+
+

Waehrungs-Checker

+

${escapeHtml(current.label)}

+

${escapeHtml(current.summary)}

+
+
+ API aktiv + Widget optional + Provider ${escapeHtml(String((state.settings && state.settings.provider) || 'currencyapi'))} +
+
+
+ ${content} +
+
+ `; + } + + function bind(rootNode) { + rootNode.querySelectorAll('[data-action="tab"]').forEach((button) => { + button.addEventListener('click', () => { + setState({ activeTab: String(button.getAttribute('data-tab') || 'overview') }); + }); + }); + + rootNode.querySelectorAll('[data-action="refresh"]').forEach((button) => { + button.addEventListener('click', () => runRefresh(false)); + }); + rootNode.querySelectorAll('[data-action="refresh-force"]').forEach((button) => { + button.addEventListener('click', () => { + const confirmed = window.confirm('Der Abruf wird jetzt unabhaengig vom Alter der gespeicherten Kurse erzwungen. Wirklich fortfahren?'); + if (confirmed) { + runRefresh(true); + } + }); + }); + rootNode.querySelectorAll('[data-action="sync-catalog"]').forEach((button) => { + button.addEventListener('click', () => syncCatalog()); + }); + rootNode.querySelectorAll('[data-action="open-widget-setup"]').forEach((button) => { + button.addEventListener('click', () => openWidgetSetup()); + }); + + rootNode.querySelectorAll('[data-form="convert"]').forEach((form) => { + form.addEventListener('submit', runConversion); + }); + rootNode.querySelectorAll('[data-form="settings"]').forEach((form) => { + form.addEventListener('submit', saveSettings); + }); + } + + function render() { + if (state.loading) { + root.innerHTML = '

Waehrungsdaten werden geladen.

'; + return; + } + + if (state.error && !state.settings) { + root.innerHTML = `

${escapeHtml(state.error)}

`; + return; + } + + root.innerHTML = ` +
+
+ ${renderNav()} + ${renderMain()} +
+
+ `; + bind(root); + } + + render(); + loadBootstrap(); + } + + window.initFxRatesApp = function initFxRatesApp(rootNode, options) { + const root = rootNode || document.getElementById('fx-rates-app'); + if (!root || root.dataset.moduleInitialized === '1') { + return; + } + root.dataset.moduleInitialized = '1'; + createFxRatesApp(root, options && typeof options === 'object' ? options : {}); + }; + + if (document.getElementById('fx-rates-app')) { + window.initFxRatesApp(document.getElementById('fx-rates-app'), {}); + } +})(); diff --git a/modules/fx-rates/bootstrap.php b/modules/fx-rates/bootstrap.php new file mode 100644 index 00000000..bacd6fe2 --- /dev/null +++ b/modules/fx-rates/bootstrap.php @@ -0,0 +1,15 @@ + 'currencyapi', + 'FX_RATES_API_VERSION' => 'v2', + 'FX_RATES_API_URL' => 'https://currencyapi.net', + 'FX_RATES_CURRENCIES_URL' => 'https://currencyapi.net', + 'FX_RATES_API_KEY' => 'eb18ce459ffb0461c59229b478f2e00388d1', + 'FX_RATES_TIMEOUT' => '10', + 'FX_RATES_REFRESH_MAX_AGE_HOURS' => '6', + 'FX_RATES_DEFAULT_BASE_CURRENCY' => 'EUR', + 'FX_RATES_DISPLAY_BASE_CURRENCY' => 'EUR', + 'FX_RATES_SCHEDULE_TIMEZONE' => 'Europe/Berlin', +]; diff --git a/modules/fx-rates/config/module.php b/modules/fx-rates/config/module.php new file mode 100644 index 00000000..55289c41 --- /dev/null +++ b/modules/fx-rates/config/module.php @@ -0,0 +1,22 @@ + getenv('FX_RATES_DEFAULT_PROJECT_KEY') ?: 'fx-main', + 'use_project_database' => true, + 'table_prefix' => 'fxrate_', + 'settings_file' => dirname(__DIR__, 3) . '/data/fx-rates/settings.json', + 'provider' => getenv('FX_RATES_PROVIDER') ?: (getenv('MINING_CHECKER_FX_PROVIDER') ?: 'currencyapi'), + 'api_version' => getenv('FX_RATES_API_VERSION') ?: 'v2', + 'api_url' => getenv('FX_RATES_API_URL') ?: (getenv('MINING_CHECKER_FX_URL') ?: 'https://currencyapi.net'), + 'currencies_url' => getenv('FX_RATES_CURRENCIES_URL') ?: (getenv('MINING_CHECKER_FX_CURRENCIES_URL') ?: (getenv('FX_RATES_API_URL') ?: (getenv('MINING_CHECKER_FX_URL') ?: 'https://currencyapi.net'))), + 'api_key' => getenv('FX_RATES_API_KEY') ?: (getenv('MINING_CHECKER_FX_API_KEY') ?: 'eb18ce459ffb0461c59229b478f2e00388d1'), + 'timeout_sec' => (int) (getenv('FX_RATES_TIMEOUT') ?: (getenv('MINING_CHECKER_FX_TIMEOUT') ?: 10)), + 'refresh_max_age_hours' => (float) (getenv('FX_RATES_REFRESH_MAX_AGE_HOURS') ?: 6), + 'default_base_currency' => getenv('FX_RATES_DEFAULT_BASE_CURRENCY') ?: 'EUR', + 'display_base_currency' => getenv('FX_RATES_DISPLAY_BASE_CURRENCY') ?: (getenv('FX_RATES_DEFAULT_BASE_CURRENCY') ?: 'EUR'), + 'preferred_currencies' => ['EUR', 'USD', 'DOGE', 'BTC'], + 'currency_catalog' => [], + 'currency_catalog_synced_at' => '', + 'schedule_timezone' => getenv('FX_RATES_SCHEDULE_TIMEZONE') ?: nexus_display_timezone_name(), +]; diff --git a/modules/fx-rates/design.json b/modules/fx-rates/design.json new file mode 100644 index 00000000..86386f54 --- /dev/null +++ b/modules/fx-rates/design.json @@ -0,0 +1,19 @@ +{ + "sections": [ + { + "key": "overview", + "label": "Uebersicht", + "summary": "Letzte Abrufe, Umrechnung, Verlauf und manuelle Aktualisierung." + }, + { + "key": "currencies", + "label": "Waehrungen", + "summary": "Bevorzugte Waehrungen, Anzeige-Basis und Katalog verwalten." + }, + { + "key": "settings", + "label": "Setup", + "summary": "Provider, Token, Refresh-Regeln und Laufzeitparameter pflegen." + } + ] +} diff --git a/modules/fx-rates/desktop.php b/modules/fx-rates/desktop.php new file mode 100644 index 00000000..df9b8e5a --- /dev/null +++ b/modules/fx-rates/desktop.php @@ -0,0 +1,60 @@ + is_array($section) +)); + +$assetVersion = static function (string $relativePath): string { + $fullPath = __DIR__ . '/' . ltrim($relativePath, '/'); + $mtime = is_file($fullPath) ? filemtime($fullPath) : false; + + return $mtime === false ? '0' : (string) $mtime; +}; + +return [ + 'app_id' => 'fx-rates', + 'title' => (string) ($manifest['title'] ?? 'Waehrungs-Checker'), + 'icon' => 'FX', + 'entry_route' => '/apps/fx-rates', + 'window_mode' => 'window', + 'content_mode' => 'native-module', + 'default_width' => 1120, + 'default_height' => 760, + 'supports_widget' => true, + 'supports_tray' => true, + 'module_name' => 'finance', + 'summary' => (string) ($manifest['description'] ?? 'Waehrungskurse, Verlauf, API und Desktop-Widget fuer manuelle Aktualisierung.'), + 'native_module' => [ + 'initializer' => 'initFxRatesApp', + 'options' => [ + 'apiBase' => '/api/fx-rates/index.php?path=v1', + 'activeView' => 'overview', + 'sections' => $sections, + ], + 'assets' => [ + 'styles' => [ + ['href' => '/module-assets/index.php?module=fx-rates&path=assets/css/app.css&v=' . rawurlencode($assetVersion('assets/css/app.css'))], + ], + 'scripts' => [ + ['src' => '/module-assets/index.php?module=fx-rates&path=assets/js/app.js&v=' . rawurlencode($assetVersion('assets/js/app.js'))], + ], + ], + ], +]; diff --git a/modules/fx-rates/docs/README.md b/modules/fx-rates/docs/README.md new file mode 100644 index 00000000..178435a3 --- /dev/null +++ b/modules/fx-rates/docs/README.md @@ -0,0 +1,29 @@ +# Waehrungs-Checker Modul + +Das Modul `fx-rates` stellt gespeicherte Waehrungskurse, Historie, Umrechnung und manuelle Refresh-Aktionen bereit. + +## Kernpunkte + +- Desktop-App unter `modules/fx-rates/desktop.php` +- API unter `/api/fx-rates/...` +- Widget fuer den rechten Desktop-Infobereich ueber `config/widgets.php` +- externer Provider-Token wird aus dem alten Bestand als Default uebernommen +- oeffentliche Einstiege laufen ueber `public/apps/fx-rates/index.php` und `public/api/fx-rates/index.php` + +## Refresh-Regeln + +- das Oeffnen der App loest keinen externen Abruf aus +- `POST /api/fx-rates/.../refresh` aktualisiert nur, wenn der letzte Snapshot aelter als die konfigurierte Sperrzeit ist +- mit `force=true` kann ein Abruf explizit erzwungen werden +- das Desktop-Widget nutzt dieselbe Logik + +## API + +Wichtige Endpunkte: + +- `GET /api/fx-rates/v1/latest` +- `GET /api/fx-rates/v1/rate` +- `GET /api/fx-rates/v1/history` +- `POST /api/fx-rates/v1/refresh` +- `GET /api/fx-rates/v1/settings` +- `PUT /api/fx-rates/v1/settings` diff --git a/modules/fx-rates/module.json b/modules/fx-rates/module.json new file mode 100644 index 00000000..f8c674d9 --- /dev/null +++ b/modules/fx-rates/module.json @@ -0,0 +1,38 @@ +{ + "name": "fx-rates", + "title": "Waehrungs-Checker", + "version": "0.1.0", + "description": "Zentrales Modul fuer Waehrungskurse, Historie, API-Abrufe und Desktop-Widget.", + "enabled_by_default": true, + "setup": { + "sections": { + "database": true + }, + "fields": [ + { "name": "use_separate_db", "label": "Eigene Modul-DB nutzen", "type": "checkbox", "required": false, "help": "Wenn aktiv, werden die DB-Daten unten verwendet. Sonst wird die Projekt-DB genutzt." }, + { "name": "db.driver", "label": "DB Driver", "type": "text", "required": false, "help": "z.B. pgsql oder mysql" }, + { "name": "db.host", "label": "DB Host", "type": "text", "required": false }, + { "name": "db.port", "label": "DB Port", "type": "number", "required": false }, + { "name": "db.dbname", "label": "DB Name", "type": "text", "required": false }, + { "name": "db.schema", "label": "DB Schema", "type": "text", "required": false }, + { "name": "db.user", "label": "DB User", "type": "text", "required": false }, + { "name": "db.password", "label": "DB Passwort", "type": "password", "required": false }, + { "name": "provider", "label": "FX Provider", "type": "text", "required": false, "help": "Unterstuetzt legacy currencyapi.net und currencyapi.com v3." }, + { "name": "api_version", "label": "FX API Version", "type": "select", "required": false, "help": "Steuert die Endpoint-Version unabhaengig von der Domain." }, + { "name": "api_url", "label": "FX API URL", "type": "text", "required": false, "help": "Nur die Basis-URL eintragen, z.B. https://api.currencyapi.com oder https://currencyapi.net." }, + { "name": "currencies_url", "label": "FX Waehrungs-URL", "type": "text", "required": false, "help": "Optional separate Basis-URL fuer den Waehrungskatalog." }, + { "name": "api_key", "label": "FX API Key", "type": "password", "required": false }, + { "name": "timeout_sec", "label": "Timeout (Sek.)", "type": "number", "required": false }, + { "name": "refresh_max_age_hours", "label": "Max. Alter fuer API-Refresh (Std.)", "type": "number", "required": false, "help": "Blockiert neue manuelle/API-Refreshes, solange der letzte gespeicherte Abruf juenger ist. Nur mit force darf frueher aktualisiert werden." }, + { "name": "default_base_currency", "label": "Standard-Basiswaehrung", "type": "text", "required": false }, + { "name": "display_base_currency", "label": "Anzeige-Basiswaehrung", "type": "text", "required": false }, + { "name": "preferred_currencies", "label": "Bevorzugte Waehrungen", "type": "text", "required": false, "help": "Kommagetrennte Liste, z.B. EUR,USD,DOGE,BTC." }, + { "name": "schedule_timezone", "label": "Zeitzone", "type": "text", "required": false, "help": "z.B. Europe/Berlin" } + ] + }, + "auth": { + "required": true, + "users": [], + "groups": [] + } +} diff --git a/modules/fx-rates/pages/index.php b/modules/fx-rates/pages/index.php new file mode 100644 index 00000000..9347fa09 --- /dev/null +++ b/modules/fx-rates/pages/index.php @@ -0,0 +1,59 @@ + is_array($section) ? trim((string) ($section['key'] ?? '')) : '', + $sections +))); +if ($sectionKeys === []) { + $sectionKeys = ['overview']; +} +if (!in_array($activeView, $sectionKeys, true)) { + $activeView = $sectionKeys[0]; +} +$sectionsJson = json_encode($sections, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); +?> + + + + + Waehrungs-Checker + + + + +
+ + + diff --git a/modules/fx-rates/src/Api/Router.php b/modules/fx-rates/src/Api/Router.php new file mode 100644 index 00000000..772d6797 --- /dev/null +++ b/modules/fx-rates/src/Api/Router.php @@ -0,0 +1,323 @@ +moduleConfig = ModuleConfig::load($moduleBasePath); + $this->settingsStore = new SettingsStore($this->moduleConfig); + } + + public function handle(string $relativePath): never + { + $method = strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET')); + $path = trim($relativePath, '/'); + + try { + if ($path === 'v1/health' && $method === 'GET') { + $this->respond(['ok' => true, 'module' => 'fx-rates']); + } + + if ($path === 'v1/endpoints' && $method === 'GET') { + $this->respond(['data' => $this->endpointCatalog()]); + } + + if ($path === 'v1/status' && $method === 'GET') { + $this->respond(['data' => $this->service()->latestStatuses()]); + } + + if ($path === 'v1/recent-fetches' && $method === 'GET') { + $limit = max(1, min(50, (int) ($_GET['limit'] ?? 12))); + $this->respond(['data' => $this->service()->recentFetches($limit)]); + } + + if ($path === 'v1/latest' && $method === 'GET') { + $symbols = $this->parseCsv($_GET['symbols'] ?? null); + $base = $this->stringOrNull($_GET['base'] ?? null); + if ($symbols === null) { + $settings = $this->settingsStore->load(); + $symbols = is_array($settings['preferred_currencies'] ?? null) ? $settings['preferred_currencies'] : null; + } + $snapshot = $this->service()->snapshot($base, null, $symbols, null); + $this->respond(['data' => $snapshot]); + } + + if ($path === 'v1/fetch' && $method === 'GET') { + $fetchId = max(0, (int) ($_GET['fetch_id'] ?? 0)); + $base = $this->stringOrNull($_GET['base'] ?? null); + $symbols = $this->parseCsv($_GET['symbols'] ?? null); + $snapshot = $this->service()->snapshotByFetchId($fetchId, $base, $symbols); + $this->respond(['data' => $snapshot]); + } + + if ($path === 'v1/nearest' && $method === 'GET') { + $base = $this->stringOrNull($_GET['base'] ?? null); + $symbols = $this->parseCsv($_GET['symbols'] ?? null); + $at = $this->stringOrNull($_GET['at'] ?? null); + $windowMinutes = $this->intOrNull($_GET['window_minutes'] ?? null); + $snapshot = $this->service()->nearestSnapshot($base, (string) $at, $symbols, $windowMinutes); + $this->respond(['data' => $snapshot]); + } + + if ($path === 'v1/snapshot' && $method === 'GET') { + $symbols = $this->parseCsv($_GET['symbols'] ?? null); + $base = $this->stringOrNull($_GET['base'] ?? null); + $at = $this->stringOrNull($_GET['at'] ?? null); + $windowMinutes = $this->intOrNull($_GET['window_minutes'] ?? null); + $snapshot = $this->service()->snapshot($base, $at, $symbols, $windowMinutes); + $this->respond(['data' => $snapshot]); + } + + if ($path === 'v1/rate' && $method === 'GET') { + $from = $this->stringOrNull($_GET['from'] ?? null); + $to = $this->stringOrNull($_GET['to'] ?? null); + $at = $this->stringOrNull($_GET['at'] ?? null); + $windowMinutes = $this->intOrNull($_GET['window_minutes'] ?? null); + $rate = $this->service()->findRate($from, $to, $at, $windowMinutes); + $this->respond(['data' => $rate]); + } + + if ($path === 'v1/history' && $method === 'GET') { + $from = $this->stringOrNull($_GET['from'] ?? null); + $to = $this->stringOrNull($_GET['to'] ?? null); + $fromAt = $this->stringOrNull($_GET['from_at'] ?? null); + $toAt = $this->stringOrNull($_GET['to_at'] ?? null); + $limit = max(1, min(1000, (int) ($_GET['limit'] ?? 200))); + $history = $this->service()->history((string) $from, (string) $to, $fromAt, $toAt, $limit); + $this->respond(['data' => $history]); + } + + if ($path === 'v1/refresh' && $method === 'POST') { + $input = $this->input(); + $base = $this->stringOrNull($input['base'] ?? null); + $force = !empty($input['force']); + $maxAgeMinutes = is_numeric($input['max_age_minutes'] ?? null) + ? (int) $input['max_age_minutes'] + : (is_numeric($input['max_age_hours'] ?? null) ? (int) round(((float) $input['max_age_hours']) * 60) : null); + + $result = $force + ? $this->service()->refreshLatestRates(null, $base, (string) ($input['trigger_source'] ?? 'manual')) + : $this->service()->autoRefreshLatestRates($base, null, $maxAgeMinutes, (string) ($input['trigger_source'] ?? 'api')); + + $this->respond(['data' => $result], 201); + } + + if ($path === 'v1/probe' && $method === 'GET') { + $base = $this->stringOrNull($_GET['base'] ?? null); + $this->respond(['data' => $this->service()->probeLatestRates($base)]); + } + + if ($path === 'v1/currency-catalog/probe' && $method === 'GET') { + $this->respond(['data' => $this->service()->probeCurrencyCatalog()]); + } + + if ($path === 'v1/currency-catalog/sync' && $method === 'POST') { + $result = $this->service()->refreshCurrencyCatalog(); + $current = $this->settingsStore->load(); + $catalog = []; + foreach (is_array($result['currencies'] ?? null) ? $result['currencies'] : [] as $item) { + if (!is_array($item)) { + continue; + } + $code = strtoupper(trim((string) ($item['code'] ?? ''))); + $name = trim((string) ($item['name'] ?? '')); + if ($code === '' || $name === '') { + continue; + } + $catalog[$code] = ['code' => $code, 'name' => $name]; + } + + foreach ([ + (string) ($current['default_base_currency'] ?? ''), + (string) ($current['display_base_currency'] ?? ''), + ...((is_array($current['preferred_currencies'] ?? null) ? $current['preferred_currencies'] : [])), + ] as $code) { + $code = strtoupper(trim((string) $code)); + if ($code !== '' && !isset($catalog[$code])) { + $catalog[$code] = ['code' => $code, 'name' => $code]; + } + } + ksort($catalog); + + $settings = $this->settingsStore->save([ + 'currency_catalog' => array_values($catalog), + 'currency_catalog_synced_at' => gmdate(DATE_ATOM), + ]); + + $this->reloadService(); + $this->respond(['data' => [ + 'sync' => $result, + 'settings' => $settings, + ]], 201); + } + + if ($path === 'v1/settings' && $method === 'GET') { + $this->respond(['data' => $this->settingsStore->load()]); + } + + if ($path === 'v1/settings' && $method === 'PUT') { + $payload = $this->normalizeSettingsPayload($this->input()); + $settings = $this->settingsStore->save($payload); + $this->reloadService(); + $this->respond(['data' => $settings]); + } + + $this->respond(['error' => 'Unbekannter API-Pfad.'], 404); + } catch (\Throwable $exception) { + $this->respond([ + 'error' => 'FX-API Fehler.', + 'context' => ['message' => $exception->getMessage()], + ], $exception instanceof RuntimeException ? 500 : 500); + } + } + + private function service(): FxRatesService + { + if ($this->service instanceof FxRatesService) { + return $this->service; + } + + $repository = new FxRatesRepository( + ConnectionFactory::make($this->settingsStore), + $this->moduleConfig->tablePrefix() + ); + $repository->ensureSchema(); + + return $this->service = new FxRatesService($repository, $this->settingsStore->load()); + } + + private function reloadService(): void + { + $this->service = null; + } + + private function respond(array $payload, int $statusCode = 200): never + { + http_response_code($statusCode); + header('Content-Type: application/json; charset=utf-8'); + echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + exit; + } + + private function input(): array + { + $raw = file_get_contents('php://input'); + $decoded = json_decode((string) $raw, true); + return is_array($decoded) ? $decoded : []; + } + + private function parseCsv(mixed $value): ?array + { + if (is_array($value)) { + $items = $value; + } else { + $value = trim((string) $value); + if ($value === '') { + return null; + } + $items = explode(',', $value); + } + + $result = []; + foreach ($items as $item) { + $item = strtoupper(trim((string) $item)); + if ($item !== '') { + $result[] = $item; + } + } + + $result = array_values(array_unique($result)); + return $result !== [] ? $result : null; + } + + private function stringOrNull(mixed $value): ?string + { + $value = trim((string) $value); + return $value !== '' ? $value : null; + } + + private function intOrNull(mixed $value): ?int + { + return is_numeric($value) ? (int) $value : null; + } + + private function normalizeSettingsPayload(array $input): array + { + $payload = []; + + foreach (['provider', 'api_version', 'api_url', 'currencies_url', 'api_key', 'default_base_currency', 'display_base_currency', 'schedule_timezone'] as $key) { + if (array_key_exists($key, $input)) { + $payload[$key] = $input[$key]; + } + } + + if (array_key_exists('timeout_sec', $input)) { + $payload['timeout_sec'] = (int) $input['timeout_sec']; + } + if (array_key_exists('refresh_max_age_hours', $input)) { + $payload['refresh_max_age_hours'] = (float) $input['refresh_max_age_hours']; + } + if (array_key_exists('refresh_max_age_minutes', $input) && !array_key_exists('refresh_max_age_hours', $input)) { + $payload['refresh_max_age_hours'] = ((float) $input['refresh_max_age_minutes']) / 60; + } + + if (array_key_exists('preferred_currencies', $input)) { + $payload['preferred_currencies'] = $input['preferred_currencies']; + } + if (array_key_exists('currency_catalog', $input)) { + $payload['currency_catalog'] = $input['currency_catalog']; + } + if (array_key_exists('currency_catalog_synced_at', $input)) { + $payload['currency_catalog_synced_at'] = $input['currency_catalog_synced_at']; + } + if (array_key_exists('use_separate_db', $input)) { + $payload['use_separate_db'] = !empty($input['use_separate_db']); + } + if (array_key_exists('db', $input) && is_array($input['db'])) { + $payload['db'] = $input['db']; + } + + return $payload; + } + + private function endpointCatalog(): array + { + return [ + 'module' => 'fx-rates', + 'version' => 'v1', + 'languages' => ['de', 'en'], + 'endpoints' => [ + ['path' => '/api/fx-rates/v1/endpoints', 'method' => 'GET'], + ['path' => '/api/fx-rates/v1/latest', 'method' => 'GET', 'params' => ['base', 'symbols']], + ['path' => '/api/fx-rates/v1/fetch', 'method' => 'GET', 'params' => ['fetch_id', 'base', 'symbols']], + ['path' => '/api/fx-rates/v1/nearest', 'method' => 'GET', 'params' => ['at', 'base', 'symbols', 'window_minutes']], + ['path' => '/api/fx-rates/v1/snapshot', 'method' => 'GET', 'params' => ['at', 'base', 'symbols', 'window_minutes']], + ['path' => '/api/fx-rates/v1/rate', 'method' => 'GET', 'params' => ['from', 'to', 'at', 'window_minutes']], + ['path' => '/api/fx-rates/v1/history', 'method' => 'GET', 'params' => ['from', 'to', 'from_at', 'to_at', 'limit']], + ['path' => '/api/fx-rates/v1/refresh', 'method' => 'POST', 'body' => ['base', 'force', 'max_age_minutes', 'max_age_hours']], + ['path' => '/api/fx-rates/v1/status', 'method' => 'GET'], + ['path' => '/api/fx-rates/v1/recent-fetches', 'method' => 'GET', 'params' => ['limit']], + ['path' => '/api/fx-rates/v1/probe', 'method' => 'GET', 'params' => ['base']], + ['path' => '/api/fx-rates/v1/currency-catalog/probe', 'method' => 'GET'], + ['path' => '/api/fx-rates/v1/currency-catalog/sync', 'method' => 'POST'], + ['path' => '/api/fx-rates/v1/settings', 'method' => 'GET'], + ['path' => '/api/fx-rates/v1/settings', 'method' => 'PUT'], + ], + ]; + } +} diff --git a/modules/fx-rates/src/Domain/FxRatesService.php b/modules/fx-rates/src/Domain/FxRatesService.php new file mode 100644 index 00000000..ffde4358 --- /dev/null +++ b/modules/fx-rates/src/Domain/FxRatesService.php @@ -0,0 +1,991 @@ +localizeFetch($this->repository->getLatestFetch(null)); + } + + public function latestStatuses(): array + { + return array_map(fn (array $fetch): array => $this->localizeFetch($fetch), $this->repository->listLatestFetches()); + } + + public function recentFetches(int $limit = 20): array + { + return array_map(fn (array $fetch): array => $this->localizeFetch($fetch), $this->repository->listRecentFetches($limit)); + } + + public function snapshot(?string $baseCurrency = null, ?string $at = null, ?array $symbols = null, ?int $windowMinutes = null): ?array + { + $base = $this->normalizeCurrency($baseCurrency ?: $this->defaultBaseCurrency()); + if ($base === '') { + return null; + } + + if ($at === null || trim($at) === '') { + $latest = $this->repository->getLatestFetch(null); + if ($latest === null) { + return null; + } + + $snapshot = $this->repository->getSnapshotByFetchId((int) $latest['id'], null); + if ($snapshot === null) { + return null; + } + + return $this->localizeSnapshot($this->rebaseSnapshot($snapshot, $base, $symbols)); + } + + $atUtc = $this->normalizeTimestamp($at); + if ($atUtc === null) { + return null; + } + + $nearest = $this->repository->getNearestFetch($base, $atUtc, $windowMinutes); + if ($nearest === null) { + return null; + } + + $snapshot = $this->repository->getSnapshotByFetchId((int) $nearest['id'], $symbols); + if ($snapshot === null) { + return null; + } + + $rebased = $this->rebaseSnapshot($snapshot, $base, $symbols); + if ($rebased === null) { + return null; + } + + return $this->localizeSnapshot($rebased + [ + 'requested_at' => $atUtc, + 'distance_seconds' => $nearest['distance_seconds'] ?? null, + ]); + } + + public function snapshotByFetchId(int $fetchId, ?string $baseCurrency = null, ?array $symbols = null): ?array + { + if ($fetchId <= 0) { + return null; + } + + $requestedBase = $this->normalizeCurrency($baseCurrency ?: $this->defaultBaseCurrency()); + if ($requestedBase === '') { + return null; + } + + $snapshot = $this->repository->getSnapshotByFetchId($fetchId, null); + if ($snapshot === null) { + return null; + } + + return $this->localizeSnapshot($this->rebaseSnapshot($snapshot, $requestedBase, $symbols)); + } + + public function nearestSnapshot(?string $baseCurrency = null, string $at = '', ?array $symbols = null, ?int $windowMinutes = null): ?array + { + $timestamp = $this->normalizeTimestamp($at); + if ($timestamp === null) { + return null; + } + + $requestedBase = $this->normalizeCurrency($baseCurrency ?: $this->defaultBaseCurrency()); + if ($requestedBase === '') { + return null; + } + + $nearest = $this->repository->findNearestFetch(null, $timestamp, $windowMinutes); + if ($nearest === null) { + return null; + } + + $snapshot = $this->repository->getSnapshotByFetchId((int) ($nearest['id'] ?? 0), null); + if ($snapshot === null) { + return null; + } + + $rebased = $this->rebaseSnapshot($snapshot, $requestedBase, $symbols); + if ($rebased === null) { + return null; + } + + return $this->localizeSnapshot($rebased + [ + 'requested_at' => $timestamp, + 'distance_seconds' => $nearest['distance_seconds'] ?? null, + ]); + } + + public function findRate(?string $fromCurrency, ?string $toCurrency, ?string $at = null, ?int $windowMinutes = null): ?array + { + $from = $this->normalizeCurrency($fromCurrency); + $to = $this->normalizeCurrency($toCurrency); + if ($from === '' || $to === '') { + return null; + } + + if ($from === $to) { + return $this->localizeRateResult([ + 'base_currency' => $from, + 'target_currency' => $to, + 'rate' => 1.0, + 'provider' => 'identity', + 'fetched_at' => $at ? $this->normalizeTimestamp($at) : null, + 'rate_date' => $at ? substr((string) $this->normalizeTimestamp($at), 0, 10) : gmdate('Y-m-d'), + 'is_exact_pair' => true, + ]); + } + + $cacheKey = implode(':', [$from, $to, $at ?? '', (string) ($windowMinutes ?? 0)]); + if (array_key_exists($cacheKey, $this->memoryCache)) { + return $this->memoryCache[$cacheKey]; + } + + $candidates = array_values(array_unique(array_filter([ + $this->defaultBaseCurrency(), + 'EUR', + 'USD', + $from, + $to, + ], static fn (?string $value): bool => is_string($value) && trim($value) !== ''))); + + foreach ($candidates as $snapshotBase) { + $snapshot = $this->snapshot($snapshotBase, $at, [$from, $to], $windowMinutes); + if (!is_array($snapshot)) { + continue; + } + + $rates = is_array($snapshot['rates'] ?? null) ? $snapshot['rates'] : []; + $resolved = $this->resolveRateFromSnapshot($snapshot, $rates, $from, $to); + if ($resolved !== null) { + return $this->memoryCache[$cacheKey] = $this->localizeRateResult($resolved); + } + } + + return $this->memoryCache[$cacheKey] = null; + } + + public function convert(?float $amount, ?string $fromCurrency, ?string $toCurrency, ?string $at = null, ?int $windowMinutes = null): ?float + { + if ($amount === null) { + return null; + } + + $rate = $this->findRate($fromCurrency, $toCurrency, $at, $windowMinutes); + if (!is_numeric($rate['rate'] ?? null)) { + return null; + } + + return $amount * (float) $rate['rate']; + } + + public function refreshLatestRates(?array $currencies = null, ?string $baseCurrency = null, string $triggerSource = 'manual'): array + { + $requestedBase = $this->normalizeCurrency($baseCurrency ?: $this->defaultBaseCurrency()); + $requestedCurrencies = $this->normalizeRequestedCurrencies($currencies, $requestedBase); + $payload = $this->fetchLatestPayload($requestedBase, $requestedCurrencies); + $base = $this->normalizeCurrency((string) ($payload['base'] ?? $requestedBase)); + if ($base === '') { + $base = $requestedBase !== '' ? $requestedBase : 'USD'; + } + $rates = is_array($payload['rates'] ?? null) ? $payload['rates'] : []; + $rateDate = $this->normalizeRateDate($payload['date'] ?? null); + $saved = $this->repository->saveFetch( + $base, + $this->provider(), + $rateDate, + $rates, + gmdate('Y-m-d H:i:s'), + $triggerSource + ); + + return [ + 'base' => $base, + 'requested_base' => $requestedBase, + 'rate_date' => $rateDate, + 'updated_count' => count($saved['rates'] ?? []), + 'rates' => $saved['rates'] ?? [], + 'fetch_id' => isset($saved['fetch']['id']) ? (int) $saved['fetch']['id'] : null, + 'fetch' => $this->localizeFetch(is_array($saved['fetch'] ?? null) ? $saved['fetch'] : null), + ]; + } + + public function ensureFreshLatestRates(float $maxAgeHours = 24.0, ?string $baseCurrency = null, ?array $currencies = null, string $triggerSource = 'manual'): array + { + $base = $this->normalizeCurrency($baseCurrency ?: $this->defaultBaseCurrency()); + $requestedCurrencies = $this->normalizeRequestedCurrencies($currencies, $base); + $latest = $this->repository->getLatestFetch($base); + $maxAgeSeconds = (int) round(max(1.0, $maxAgeHours) * 3600); + $fetchedAt = is_array($latest) ? $this->parseStoredUtcTimestamp((string) ($latest['fetched_at'] ?? '')) : null; + + if ( + $fetchedAt !== null + && (time() - $fetchedAt) <= $maxAgeSeconds + && $this->latestFetchCoversCurrencies($latest, $requestedCurrencies) + ) { + return [ + 'base' => $base, + 'rate_date' => $latest['rate_date'] ?? null, + 'updated_count' => 0, + 'rates' => [], + 'fetch_id' => isset($latest['id']) ? (int) $latest['id'] : null, + 'fetch' => $this->localizeFetch($latest), + 'reused' => true, + ]; + } + + $result = $this->refreshLatestRates($requestedCurrencies, $base, $triggerSource); + $result['reused'] = false; + return $result; + } + + public function autoRefreshLatestRates(?string $baseCurrency = null, ?array $currencies = null, ?int $maxAgeMinutes = null, string $triggerSource = 'api'): array + { + $minutes = $maxAgeMinutes ?? $this->refreshMaxAgeMinutes(); + $hours = max(1, $minutes) / 60; + return $this->ensureFreshLatestRates($hours, $baseCurrency, $currencies, $triggerSource); + } + + public function history(string $fromCurrency, string $toCurrency, ?string $from = null, ?string $to = null, int $limit = 200): array + { + $fromCurrency = $this->normalizeCurrency($fromCurrency); + $toCurrency = $this->normalizeCurrency($toCurrency); + if ($fromCurrency === '' || $toCurrency === '') { + return []; + } + + if ($fromCurrency === $toCurrency) { + return []; + } + + return $this->crossHistory($fromCurrency, $toCurrency, $from, $to, $limit); + } + + public function refreshCurrencyCatalog(): array + { + $payload = $this->fetchCurrenciesPayload(); + $currencies = is_array($payload['currencies'] ?? null) ? $payload['currencies'] : []; + $items = []; + foreach ($currencies as $code => $name) { + $code = $this->normalizeCurrency((string) $code); + $name = trim((string) $name); + if ($code === '' || $name === '') { + continue; + } + $items[] = [ + 'code' => $code, + 'name' => $name, + ]; + } + + return [ + 'synced_count' => count($items), + 'currencies' => $items, + ]; + } + + public function probeCurrencyCatalog(): array + { + $payload = $this->fetchCurrenciesPayload(); + return [ + 'ok' => !empty($payload['currencies']), + 'provider' => $this->provider(), + 'currencies_count' => is_array($payload['currencies'] ?? null) ? count($payload['currencies']) : 0, + ]; + } + + public function probeLatestRates(?string $baseCurrency = null): array + { + $base = $this->normalizeCurrency($baseCurrency ?: $this->defaultBaseCurrency()); + $payload = $this->fetchLatestPayload($base, null); + return [ + 'ok' => !empty($payload['rates']), + 'provider' => $this->provider(), + 'base' => $base, + 'rate_count' => is_array($payload['rates'] ?? null) ? count($payload['rates']) : 0, + 'date' => $payload['date'] ?? null, + ]; + } + + private function resolveRateFromSnapshot(array $snapshot, array $rates, string $from, string $to): ?array + { + $base = strtoupper((string) ($snapshot['base_currency'] ?? '')); + $rate = null; + $isExactPair = false; + + if ($base === $from && is_numeric($rates[$to] ?? null)) { + $rate = (float) $rates[$to]; + $isExactPair = true; + } elseif ($base === $to && is_numeric($rates[$from] ?? null) && (float) $rates[$from] > 0) { + $rate = 1 / (float) $rates[$from]; + $isExactPair = true; + } elseif (is_numeric($rates[$from] ?? null) && is_numeric($rates[$to] ?? null) && (float) $rates[$from] > 0) { + $rate = (float) $rates[$to] / (float) $rates[$from]; + } + + if ($rate === null || $rate <= 0) { + return null; + } + + return [ + 'base_currency' => $from, + 'target_currency' => $to, + 'rate' => $rate, + 'provider' => $snapshot['provider'] ?? null, + 'fetched_at' => $snapshot['fetched_at'] ?? null, + 'rate_date' => $snapshot['rate_date'] ?? null, + 'snapshot_base_currency' => $base, + 'distance_seconds' => $snapshot['distance_seconds'] ?? null, + 'requested_at' => $snapshot['requested_at'] ?? null, + 'is_exact_pair' => $isExactPair, + ]; + } + + private function fetchLatestPayload(string $baseCurrency, ?array $currencies = null): array + { + $request = $this->buildLatestRequest($baseCurrency, $currencies); + if ($request === null) { + throw new \RuntimeException('FX-URL oder API-Key fehlt.'); + } + + $payload = $this->requestJson($request['url'], $request['headers'] ?? [], 'FX-Kurse konnten nicht geladen werden.'); + + if ($this->usesApiVersion('v3')) { + return $this->normalizeCurrencyApiComLatestPayload($payload, $baseCurrency, $currencies); + } + + $resolvedBase = $baseCurrency; + $rates = []; + if ($this->provider() === 'currencyapi') { + $resolvedBase = $this->normalizeCurrency((string) ($payload['base'] ?? $payload['base_currency'] ?? ($payload['query']['base_currency'] ?? 'USD'))); + if ($resolvedBase === '') { + $resolvedBase = 'USD'; + } + if (($payload['valid'] ?? false) !== true || !is_array($payload['rates'] ?? null)) { + throw new \RuntimeException($this->extractProviderError($payload, 'FX-Kurse konnten nicht geladen werden.')); + } + foreach ($payload['rates'] as $code => $rate) { + $code = $this->normalizeCurrency((string) $code); + if ($code === '' || $code === $resolvedBase || !is_numeric($rate)) { + continue; + } + $rates[$code] = (float) $rate; + } + } else { + $resolvedBase = $this->normalizeCurrency((string) ($payload['base'] ?? $payload['base_currency'] ?? $baseCurrency)); + if ($resolvedBase === '') { + $resolvedBase = $baseCurrency; + } + $rawRates = is_array($payload['rates'] ?? null) ? $payload['rates'] : []; + foreach ($rawRates as $code => $rate) { + $code = $this->normalizeCurrency((string) $code); + if ($code === '' || $code === $resolvedBase || !is_numeric($rate)) { + continue; + } + $rates[$code] = (float) $rate; + } + } + + if (is_array($currencies) && $currencies !== []) { + $wanted = []; + foreach ($currencies as $currency) { + $currency = $this->normalizeCurrency((string) $currency); + if ($currency !== '' && isset($rates[$currency])) { + $wanted[$currency] = $rates[$currency]; + } + } + $rates = $wanted; + } + + return [ + 'base' => $resolvedBase, + 'date' => $payload['updated'] ?? $payload['date'] ?? null, + 'rates' => $rates, + ]; + } + + private function fetchCurrenciesPayload(): array + { + $request = $this->buildCurrenciesRequest(); + if ($request === null) { + throw new \RuntimeException('FX-API-Key fehlt.'); + } + + $payload = $this->requestJson($request['url'], $request['headers'] ?? [], 'Waehrungskatalog konnte nicht geladen werden.'); + + if ($this->usesApiVersion('v3')) { + return $this->normalizeCurrencyApiComCurrenciesPayload($payload); + } + + if (($payload['valid'] ?? false) !== true || !is_array($payload['currencies'] ?? null)) { + throw new \RuntimeException($this->extractProviderError($payload, 'Waehrungskatalog konnte nicht geladen werden.')); + } + + return $payload; + } + + private function buildLatestRequest(string $baseCurrency, ?array $currencies = null): ?array + { + $apiKey = $this->apiKey(); + if ($this->usesApiVersion('v3')) { + if ($apiKey === '') { + return null; + } + + $query = []; + $normalizedCurrencies = []; + foreach ($currencies ?? [] as $currency) { + $currency = $this->normalizeCurrency((string) $currency); + if ($currency !== '' && $currency !== $baseCurrency) { + $normalizedCurrencies[] = $currency; + } + } + if ($normalizedCurrencies !== []) { + $query[] = 'currencies=' . rawurlencode(implode(',', array_values(array_unique($normalizedCurrencies)))); + } + + return [ + 'url' => $this->apiUrl() . '/v3/latest?' . implode('&', $query), + 'headers' => [ + 'Accept: application/json', + 'apikey: ' . $apiKey, + ], + ]; + } + + if ($this->provider() === 'currencyapi') { + if ($apiKey === '') { + return null; + } + + return [ + 'url' => sprintf('%s/api/v2/rates?output=json&key=%s', $this->apiUrl(), rawurlencode($apiKey)), + 'headers' => ['Accept: application/json'], + ]; + } + + return [ + 'url' => sprintf('%s/latest?base=%s', $this->apiUrl(), rawurlencode($baseCurrency)), + 'headers' => ['Accept: application/json'], + ]; + } + + private function buildCurrenciesRequest(): ?array + { + $apiKey = $this->apiKey(); + if ($apiKey === '') { + return null; + } + + if ($this->usesApiVersion('v3')) { + return [ + 'url' => $this->currenciesApiUrl() . '/v3/currencies', + 'headers' => [ + 'Accept: application/json', + 'apikey: ' . $apiKey, + ], + ]; + } + + return [ + 'url' => sprintf('%s/api/v2/currencies?output=json&key=%s', $this->currenciesApiUrl(), rawurlencode($apiKey)), + 'headers' => ['Accept: application/json'], + ]; + } + + private function requestJson(string $url, array $headers, string $fallbackError): array + { + if (!function_exists('curl_init')) { + throw new \RuntimeException('curl_init ist nicht verfuegbar.'); + } + + $ch = curl_init(); + curl_setopt_array($ch, [ + CURLOPT_URL => $url, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_TIMEOUT => $this->timeoutSeconds(), + CURLOPT_HTTPHEADER => $headers !== [] ? $headers : ['Accept: application/json'], + ]); + $response = curl_exec($ch); + $httpStatus = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE); + $curlError = curl_error($ch); + curl_close($ch); + + if ($response === false || $curlError !== '' || $httpStatus >= 400) { + $payload = is_string($response) ? json_decode($response, true) : null; + throw new \RuntimeException($this->extractProviderError(is_array($payload) ? $payload : [], $fallbackError)); + } + + $payload = json_decode((string) $response, true); + if (!is_array($payload)) { + throw new \RuntimeException('FX-Antwort ist kein gueltiges JSON.'); + } + + return $payload; + } + + private function normalizeCurrencyApiComLatestPayload(array $payload, string $baseCurrency, ?array $currencies = null): array + { + $rawRates = is_array($payload['data'] ?? null) ? $payload['data'] : null; + if ($rawRates === null) { + throw new \RuntimeException($this->extractProviderError($payload, 'FX-Kurse konnten nicht geladen werden.')); + } + + $resolvedBase = $this->normalizeCurrency((string) ($payload['meta']['base_currency_code'] ?? $payload['base'] ?? $baseCurrency)); + if ($resolvedBase === '') { + $resolvedBase = $baseCurrency; + } + + $filter = []; + foreach ($currencies ?? [] as $currency) { + $currency = $this->normalizeCurrency((string) $currency); + if ($currency !== '' && $currency !== $resolvedBase) { + $filter[$currency] = true; + } + } + + $rates = []; + foreach ($rawRates as $code => $rateData) { + $code = $this->normalizeCurrency((string) $code); + if ($code === '' || $code === $resolvedBase) { + continue; + } + if ($filter !== [] && !isset($filter[$code])) { + continue; + } + + $value = is_array($rateData) ? ($rateData['value'] ?? null) : null; + if (!is_numeric($value)) { + continue; + } + + $rates[$code] = (float) $value; + } + + return [ + 'base' => $resolvedBase, + 'date' => $payload['meta']['last_updated_at'] ?? null, + 'rates' => $rates, + ]; + } + + private function rebaseSnapshot(array $snapshot, string $requestedBase, ?array $symbols = null): ?array + { + $snapshotBase = $this->normalizeCurrency((string) ($snapshot['base_currency'] ?? '')); + $rates = is_array($snapshot['rates'] ?? null) ? $snapshot['rates'] : []; + + if ($snapshotBase === '') { + return null; + } + + if ($requestedBase === '' || $requestedBase === $snapshotBase) { + $filteredRates = $this->filterRates($rates, $symbols); + if ($this->symbolsContain($symbols, $requestedBase)) { + $filteredRates = [$requestedBase => 1.0] + $filteredRates; + } + + return $snapshot + [ + 'base_currency' => $snapshotBase, + 'rates' => $filteredRates, + ]; + } + + $baseRate = $rates[$requestedBase] ?? null; + if (!is_numeric($baseRate) || (float) $baseRate <= 0) { + return null; + } + + $rebasedRates = []; + foreach ($rates as $code => $rate) { + $code = $this->normalizeCurrency((string) $code); + if ($code === '' || $code === $requestedBase || !is_numeric($rate)) { + continue; + } + $rebasedRates[$code] = (float) $rate / (float) $baseRate; + } + + if ($snapshotBase !== '' && $snapshotBase !== $requestedBase) { + $rebasedRates[$snapshotBase] = 1 / (float) $baseRate; + } + + $filteredRates = $this->filterRates($rebasedRates, $symbols); + if ($this->symbolsContain($symbols, $requestedBase)) { + $filteredRates = [$requestedBase => 1.0] + $filteredRates; + } + + return $snapshot + [ + 'base_currency' => $requestedBase, + 'rates' => $filteredRates, + 'snapshot_base_currency' => $snapshotBase, + ]; + } + + private function filterRates(array $rates, ?array $symbols = null): array + { + if (!is_array($symbols) || $symbols === []) { + ksort($rates); + return $rates; + } + + $filtered = []; + foreach ($symbols as $symbol) { + $symbol = $this->normalizeCurrency((string) $symbol); + if ($symbol !== '' && isset($rates[$symbol])) { + $filtered[$symbol] = (float) $rates[$symbol]; + } + } + + ksort($filtered); + return $filtered; + } + + private function symbolsContain(?array $symbols, string $currency): bool + { + if (!is_array($symbols) || $symbols === []) { + return false; + } + + foreach ($symbols as $symbol) { + if ($this->normalizeCurrency((string) $symbol) === $currency) { + return true; + } + } + + return false; + } + + private function crossHistory(string $fromCurrency, string $toCurrency, ?string $from = null, ?string $to = null, int $limit = 200): array + { + $fromAt = $this->normalizeTimestamp($from); + $toAt = $this->normalizeTimestamp($to); + $candidates = $this->repository->listRecentFetches(max($limit * 4, 50)); + $result = []; + + foreach ($candidates as $fetch) { + $fetchedAt = (string) ($fetch['fetched_at'] ?? ''); + if ($fetchedAt === '') { + continue; + } + + if ($fromAt !== null && strcmp($fetchedAt, $fromAt) < 0) { + continue; + } + if ($toAt !== null && strcmp($fetchedAt, $toAt) > 0) { + continue; + } + + $snapshot = $this->repository->getSnapshotByFetchId((int) ($fetch['id'] ?? 0), [$fromCurrency, $toCurrency]); + if ($snapshot === null) { + continue; + } + + $rates = is_array($snapshot['rates'] ?? null) ? $snapshot['rates'] : []; + $resolved = $this->resolveRateFromSnapshot($snapshot, $rates, $fromCurrency, $toCurrency); + if ($resolved === null) { + continue; + } + + $result[] = $this->localizeRateResult($resolved + [ + 'fetch_id' => $fetch['id'] ?? null, + ]); + + if (count($result) >= $limit) { + break; + } + } + + return $result; + } + + private function localizeFetch(?array $fetch): ?array + { + if (!is_array($fetch)) { + return null; + } + + $fetch['fetched_at_display'] = $this->formatDisplayTimestamp($fetch['fetched_at'] ?? null); + $fetch['created_at_display'] = $this->formatDisplayTimestamp($fetch['created_at'] ?? null); + $fetch['trigger_source_label'] = $this->triggerSourceLabel((string) ($fetch['trigger_source'] ?? 'manual')); + return $fetch; + } + + private function localizeSnapshot(?array $snapshot): ?array + { + if (!is_array($snapshot)) { + return null; + } + + $snapshot['fetched_at_display'] = $this->formatDisplayTimestamp($snapshot['fetched_at'] ?? null); + if (array_key_exists('requested_at', $snapshot)) { + $snapshot['requested_at_display'] = $this->formatDisplayTimestamp($snapshot['requested_at']); + } + return $snapshot; + } + + private function localizeRateResult(array $rate): array + { + $rate['fetched_at_display'] = $this->formatDisplayTimestamp($rate['fetched_at'] ?? null); + if (array_key_exists('requested_at', $rate)) { + $rate['requested_at_display'] = $this->formatDisplayTimestamp($rate['requested_at']); + } + return $rate; + } + + private function formatDisplayTimestamp(mixed $value): string + { + $raw = trim((string) $value); + if ($raw === '') { + return ''; + } + + try { + $date = DateTimeImmutable::createFromFormat('Y-m-d H:i:s', $raw, new DateTimeZone('UTC')); + if (!$date instanceof DateTimeImmutable) { + $date = new DateTimeImmutable($raw, new DateTimeZone('UTC')); + } + return $date->setTimezone($this->displayTimezone())->format('d.m.Y H:i:s'); + } catch (\Throwable) { + return $raw; + } + } + + private function normalizeCurrencyApiComCurrenciesPayload(array $payload): array + { + $rawCurrencies = is_array($payload['data'] ?? null) ? $payload['data'] : null; + if ($rawCurrencies === null) { + throw new \RuntimeException($this->extractProviderError($payload, 'Waehrungskatalog konnte nicht geladen werden.')); + } + + $currencies = []; + foreach ($rawCurrencies as $code => $currencyData) { + $normalizedCode = $this->normalizeCurrency((string) $code); + if ($normalizedCode === '') { + continue; + } + + $name = ''; + if (is_array($currencyData)) { + $name = trim((string) ($currencyData['name'] ?? $currencyData['name_plural'] ?? $currencyData['code'] ?? '')); + } + + if ($name === '') { + continue; + } + + $currencies[$normalizedCode] = $name; + } + + return [ + 'valid' => true, + 'currencies' => $currencies, + ]; + } + + private function extractProviderError(array $payload, string $fallback): string + { + $error = $payload['error'] ?? null; + if (is_array($error)) { + foreach (['message', 'info', 'code'] as $field) { + $value = $error[$field] ?? null; + if (is_string($value) && trim($value) !== '') { + return trim($value); + } + } + } + + $errors = $payload['errors'] ?? null; + if (is_array($errors)) { + foreach ($errors as $entry) { + if (is_string($entry) && trim($entry) !== '') { + return trim($entry); + } + if (is_array($entry)) { + foreach (['message', 'detail', 'title'] as $field) { + $value = $entry[$field] ?? null; + if (is_string($value) && trim($value) !== '') { + return trim($value); + } + } + } + } + } + + foreach (['error', 'message', 'msg'] as $field) { + $value = $payload[$field] ?? null; + if (is_string($value) && trim($value) !== '') { + return trim($value); + } + } + + return $fallback; + } + + private function normalizeCurrency(?string $currency): string + { + return strtoupper(trim((string) $currency)); + } + + private function normalizeRequestedCurrencies(?array $currencies, string $baseCurrency): ?array + { + if (!is_array($currencies)) { + return null; + } + + $base = $this->normalizeCurrency($baseCurrency); + $normalized = array_values(array_unique(array_filter(array_map( + fn (mixed $currency): string => $this->normalizeCurrency((string) $currency), + $currencies + ), fn (string $currency): bool => $currency !== '' && $currency !== $base))); + + return $normalized === [] ? null : $normalized; + } + + private function latestFetchCoversCurrencies(?array $latestFetch, ?array $currencies): bool + { + if (!is_array($latestFetch) || !is_numeric($latestFetch['id'] ?? null) || !is_array($currencies) || $currencies === []) { + return true; + } + + $snapshot = $this->repository->getSnapshotByFetchId((int) $latestFetch['id'], $currencies); + $rates = is_array($snapshot['rates'] ?? null) ? $snapshot['rates'] : []; + foreach ($currencies as $currency) { + if (!array_key_exists($currency, $rates)) { + return false; + } + } + + return true; + } + + private function normalizeTimestamp(?string $value): ?string + { + $value = trim((string) $value); + if ($value === '') { + return null; + } + + try { + if (preg_match('/^\d{4}-\d{2}-\d{2}(?:[ T]\d{2}:\d{2}(?::\d{2})?)?$/', $value) === 1) { + $date = new DateTimeImmutable(str_replace(' ', 'T', $value), new DateTimeZone('UTC')); + } else { + $date = new DateTimeImmutable($value); + } + return $date->setTimezone(new DateTimeZone('UTC'))->format('Y-m-d H:i:s'); + } catch (\Throwable) { + return null; + } + } + + private function parseStoredUtcTimestamp(string $value): ?int + { + $normalized = $this->normalizeTimestamp($value); + if ($normalized === null) { + return null; + } + + try { + return (new DateTimeImmutable($normalized, new DateTimeZone('UTC')))->getTimestamp(); + } catch (\Throwable) { + return null; + } + } + + private function normalizeRateDate(mixed $value): string + { + if (is_string($value) && trim($value) !== '') { + $timestamp = strtotime($value); + if ($timestamp !== false) { + return gmdate('Y-m-d', $timestamp); + } + } + + if (is_int($value) || is_float($value)) { + return gmdate('Y-m-d', (int) $value); + } + + return gmdate('Y-m-d'); + } + + private function provider(): string + { + $provider = strtolower(trim((string) ($this->settings['provider'] ?? 'currencyapi'))); + return $provider !== '' ? $provider : 'currencyapi'; + } + + private function apiUrl(): string + { + return rtrim((string) ($this->settings['api_url'] ?? 'https://currencyapi.net'), '/'); + } + + private function currenciesApiUrl(): string + { + return rtrim((string) ($this->settings['currencies_url'] ?? $this->apiUrl()), '/'); + } + + private function apiVersion(): string + { + $version = strtolower(trim((string) ($this->settings['api_version'] ?? 'v2'))); + return in_array($version, ['v2', 'v3'], true) ? $version : 'v2'; + } + + private function usesApiVersion(string $version): bool + { + return $this->apiVersion() === strtolower(trim($version)); + } + + private function apiKey(): string + { + return trim((string) ($this->settings['api_key'] ?? '')); + } + + private function timeoutSeconds(): int + { + return max(2, (int) ($this->settings['timeout_sec'] ?? 10)); + } + + private function refreshMaxAgeMinutes(): int + { + return max(1, (int) ($this->settings['refresh_max_age_minutes'] ?? 360)); + } + + private function defaultBaseCurrency(): string + { + return $this->normalizeCurrency((string) ($this->settings['default_base_currency'] ?? 'EUR')) ?: 'EUR'; + } + + private function displayTimezone(): DateTimeZone + { + $timezone = trim((string) ($this->settings['schedule_timezone'] ?? nexus_display_timezone_name())); + try { + return new DateTimeZone($timezone); + } catch (\Throwable) { + return new DateTimeZone(nexus_display_timezone_name()); + } + } + + private function triggerSourceLabel(string $source): string + { + return match (strtolower(trim($source))) { + 'cron' => 'Cron', + 'api' => 'API', + 'widget' => 'Widget', + default => 'Manuell', + }; + } +} diff --git a/modules/fx-rates/src/Infrastructure/ConnectionFactory.php b/modules/fx-rates/src/Infrastructure/ConnectionFactory.php new file mode 100644 index 00000000..af3c4ace --- /dev/null +++ b/modules/fx-rates/src/Infrastructure/ConnectionFactory.php @@ -0,0 +1,42 @@ +load(); + $useSeparateDb = !empty($settings['use_separate_db']); + + if ($useSeparateDb) { + $dbConfig = is_array($settings['db'] ?? null) ? $settings['db'] : []; + if ($dbConfig === []) { + throw new RuntimeException('Custom-Datenbank ist aktiviert, aber nicht vollstaendig konfiguriert.'); + } + self::assertSupportedDriver($dbConfig); + return PdoFactory::createFromArray($dbConfig); + } + + $dbConfig = app()->config()->dbConfig; + if ($dbConfig === []) { + throw new RuntimeException('Projekt-Datenbankkonfiguration fehlt in config/db_settings_basic.php.'); + } + + self::assertSupportedDriver($dbConfig); + return PdoFactory::createFromArray($dbConfig); + } + + private static function assertSupportedDriver(array $dbConfig): void + { + $driver = strtolower((string) ($dbConfig['driver'] ?? ($dbConfig['dsn'] ?? ''))); + if ($driver !== '' && !in_array($driver, ['mysql', 'pgsql', 'sqlite'], true) && !str_starts_with($driver, 'mysql:') && !str_starts_with($driver, 'pgsql:') && !str_starts_with($driver, 'sqlite:')) { + throw new RuntimeException('FX-Rates unterstuetzt aktuell MySQL/MariaDB, PostgreSQL und SQLite.'); + } + } +} diff --git a/modules/fx-rates/src/Infrastructure/FxRatesRepository.php b/modules/fx-rates/src/Infrastructure/FxRatesRepository.php new file mode 100644 index 00000000..1daee42a --- /dev/null +++ b/modules/fx-rates/src/Infrastructure/FxRatesRepository.php @@ -0,0 +1,547 @@ +driver = strtolower((string) $this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME)); + } + + public function ensureSchema(): void + { + $fetchTable = $this->table('fetches'); + $rateTable = $this->table('rates'); + + if ($this->driver === 'pgsql') { + $this->pdo->exec("CREATE TABLE IF NOT EXISTS {$fetchTable} ( + id SERIAL PRIMARY KEY, + provider VARCHAR(64) NOT NULL, + trigger_source VARCHAR(32) NOT NULL DEFAULT 'manual', + base_currency VARCHAR(10) NOT NULL, + rate_date DATE NOT NULL, + fetched_at TIMESTAMP NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + )"); + $this->pdo->exec("ALTER TABLE {$fetchTable} ADD COLUMN IF NOT EXISTS trigger_source VARCHAR(32) NOT NULL DEFAULT 'manual'"); + $this->pdo->exec("CREATE TABLE IF NOT EXISTS {$rateTable} ( + id SERIAL PRIMARY KEY, + fetch_id INTEGER NOT NULL REFERENCES {$fetchTable}(id) ON DELETE CASCADE, + currency_code VARCHAR(10) NOT NULL, + current_value NUMERIC(20,10) NOT NULL + )"); + $this->pdo->exec("CREATE INDEX IF NOT EXISTS {$fetchTable}_base_fetch_idx ON {$fetchTable} (base_currency, fetched_at DESC, id DESC)"); + $this->pdo->exec("CREATE INDEX IF NOT EXISTS {$fetchTable}_rate_date_idx ON {$fetchTable} (rate_date DESC)"); + $this->pdo->exec("CREATE INDEX IF NOT EXISTS {$rateTable}_fetch_idx ON {$rateTable} (fetch_id)"); + $this->pdo->exec("CREATE INDEX IF NOT EXISTS {$rateTable}_currency_idx ON {$rateTable} (currency_code)"); + return; + } + + if ($this->driver === 'mysql') { + $this->pdo->exec("CREATE TABLE IF NOT EXISTS {$fetchTable} ( + id INTEGER PRIMARY KEY AUTO_INCREMENT, + provider VARCHAR(64) NOT NULL, + trigger_source VARCHAR(32) NOT NULL DEFAULT 'manual', + base_currency VARCHAR(10) NOT NULL, + rate_date DATE NOT NULL, + fetched_at DATETIME NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY {$fetchTable}_base_fetch_idx (base_currency, fetched_at, id), + KEY {$fetchTable}_rate_date_idx (rate_date) + )"); + $this->ensureColumn($fetchTable, 'trigger_source', "ALTER TABLE {$fetchTable} ADD COLUMN trigger_source VARCHAR(32) NOT NULL DEFAULT 'manual'"); + $this->pdo->exec("CREATE TABLE IF NOT EXISTS {$rateTable} ( + id INTEGER PRIMARY KEY AUTO_INCREMENT, + fetch_id INTEGER NOT NULL, + currency_code VARCHAR(10) NOT NULL, + current_value DECIMAL(20,10) NOT NULL, + KEY {$rateTable}_fetch_idx (fetch_id), + KEY {$rateTable}_currency_idx (currency_code) + )"); + return; + } + + $this->pdo->exec("CREATE TABLE IF NOT EXISTS {$fetchTable} ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + provider VARCHAR(64) NOT NULL, + trigger_source VARCHAR(32) NOT NULL DEFAULT 'manual', + base_currency VARCHAR(10) NOT NULL, + rate_date DATE NOT NULL, + fetched_at DATETIME NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + )"); + $this->ensureColumn($fetchTable, 'trigger_source', "ALTER TABLE {$fetchTable} ADD COLUMN trigger_source VARCHAR(32) NOT NULL DEFAULT 'manual'"); + $this->pdo->exec("CREATE TABLE IF NOT EXISTS {$rateTable} ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + fetch_id INTEGER NOT NULL, + currency_code VARCHAR(10) NOT NULL, + current_value DECIMAL(20,10) NOT NULL + )"); + $this->pdo->exec("CREATE INDEX IF NOT EXISTS {$fetchTable}_base_fetch_idx ON {$fetchTable} (base_currency, fetched_at DESC, id DESC)"); + $this->pdo->exec("CREATE INDEX IF NOT EXISTS {$fetchTable}_rate_date_idx ON {$fetchTable} (rate_date DESC)"); + $this->pdo->exec("CREATE INDEX IF NOT EXISTS {$rateTable}_fetch_idx ON {$rateTable} (fetch_id)"); + $this->pdo->exec("CREATE INDEX IF NOT EXISTS {$rateTable}_currency_idx ON {$rateTable} (currency_code)"); + } + + public function getLatestFetch(?string $baseCurrency = null): ?array + { + $sql = 'SELECT id, provider, trigger_source, base_currency, rate_date, fetched_at, created_at FROM ' . $this->table('fetches'); + $params = []; + if ($baseCurrency !== null && trim($baseCurrency) !== '') { + $sql .= ' WHERE base_currency = :base_currency'; + $params['base_currency'] = strtoupper(trim($baseCurrency)); + } + $sql .= ' ORDER BY fetched_at DESC, id DESC LIMIT 1'; + $stmt = $this->pdo->prepare($sql); + $stmt->execute($params); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + return is_array($row) ? $this->normalizeFetch($row) : null; + } + + public function listLatestFetches(): array + { + $stmt = $this->pdo->query( + 'SELECT id, provider, trigger_source, base_currency, rate_date, fetched_at, created_at + FROM ' . $this->table('fetches') . ' + ORDER BY fetched_at DESC, id DESC' + ); + + $latestByBase = []; + foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) ?: [] as $row) { + $base = strtoupper(trim((string) ($row['base_currency'] ?? ''))); + if ($base === '' || isset($latestByBase[$base])) { + continue; + } + $latestByBase[$base] = $this->normalizeFetch($row); + } + + ksort($latestByBase); + return array_values($latestByBase); + } + + public function listRecentFetches(int $limit = 20): array + { + $stmt = $this->pdo->prepare( + 'SELECT id, provider, trigger_source, base_currency, rate_date, fetched_at, created_at + FROM ' . $this->table('fetches') . ' + ORDER BY fetched_at DESC, id DESC + LIMIT :limit' + ); + $stmt->bindValue(':limit', max(1, $limit), PDO::PARAM_INT); + $stmt->execute(); + + return array_map( + fn (array $row): array => $this->normalizeFetch($row), + $stmt->fetchAll(PDO::FETCH_ASSOC) ?: [] + ); + } + + public function getSnapshotByFetchId(int $fetchId, ?array $symbols = null): ?array + { + $fetch = $this->getFetchById($fetchId); + if ($fetch === null) { + return null; + } + + return $fetch + [ + 'rates' => $this->ratesForFetch($fetchId, $symbols), + ]; + } + + public function findNearestFetch(?string $baseCurrency, string $timestamp, ?int $windowMinutes = null): ?array + { + $targetTs = strtotime($timestamp); + if ($targetTs === false) { + return null; + } + + if ($baseCurrency !== null && trim($baseCurrency) !== '') { + return $this->getNearestFetch(strtoupper(trim($baseCurrency)), $timestamp, $windowMinutes); + } + + $candidates = []; + foreach (['<=', '>='] as $operator) { + $order = $operator === '<=' ? 'DESC' : 'ASC'; + $stmt = $this->pdo->prepare( + 'SELECT id, provider, trigger_source, base_currency, rate_date, fetched_at, created_at + FROM ' . $this->table('fetches') . ' + WHERE fetched_at ' . $operator . ' :target_at + ORDER BY fetched_at ' . $order . ', id ' . $order . ' + LIMIT 1' + ); + $stmt->execute(['target_at' => $timestamp]); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + if (is_array($row)) { + $candidate = $this->normalizeFetch($row); + $candidateTs = strtotime((string) ($candidate['fetched_at'] ?? '')); + if ($candidateTs !== false) { + $candidate['distance_seconds'] = abs($candidateTs - $targetTs); + $candidates[] = $candidate; + } + } + } + + if ($candidates === []) { + return null; + } + + usort($candidates, static fn (array $left, array $right): int => ((int) ($left['distance_seconds'] ?? PHP_INT_MAX)) <=> ((int) ($right['distance_seconds'] ?? PHP_INT_MAX))); + $selected = $candidates[0]; + if ($windowMinutes !== null && $windowMinutes > 0 && (int) ($selected['distance_seconds'] ?? 0) > ($windowMinutes * 60)) { + return null; + } + + return $selected; + } + + public function getNearestFetch(string $baseCurrency, string $timestamp, ?int $windowMinutes = null): ?array + { + $baseCurrency = strtoupper(trim($baseCurrency)); + if ($baseCurrency === '') { + return null; + } + + $before = $this->findNeighborFetch($baseCurrency, $timestamp, '<='); + $after = $this->findNeighborFetch($baseCurrency, $timestamp, '>='); + $targetTs = strtotime($timestamp); + if ($targetTs === false) { + return null; + } + + $selected = null; + $selectedDiff = null; + foreach ([$before, $after] as $candidate) { + if (!is_array($candidate)) { + continue; + } + $candidateTs = strtotime((string) ($candidate['fetched_at'] ?? '')); + if ($candidateTs === false) { + continue; + } + $diffSeconds = abs($candidateTs - $targetTs); + if ($selected === null || $diffSeconds < (int) $selectedDiff) { + $selected = $candidate; + $selectedDiff = $diffSeconds; + } + } + + if ($selected === null) { + return null; + } + + if ($windowMinutes !== null && $windowMinutes > 0 && $selectedDiff !== null && $selectedDiff > ($windowMinutes * 60)) { + return null; + } + + return $selected + ['distance_seconds' => $selectedDiff]; + } + + public function listDirectHistory(string $baseCurrency, string $targetCurrency, ?string $from = null, ?string $to = null, int $limit = 200): array + { + $sql = 'SELECT + r.id, + f.id AS fetch_id, + f.base_currency, + r.currency_code AS target_currency, + r.current_value AS rate, + f.rate_date, + f.provider, + f.fetched_at + FROM ' . $this->table('rates') . ' r + INNER JOIN ' . $this->table('fetches') . ' f ON f.id = r.fetch_id + WHERE f.base_currency = :base_currency + AND r.currency_code = :target_currency'; + $params = [ + 'base_currency' => strtoupper(trim($baseCurrency)), + 'target_currency' => strtoupper(trim($targetCurrency)), + ]; + + if ($from !== null && trim($from) !== '') { + $sql .= ' AND f.fetched_at >= :from_at'; + $params['from_at'] = $from; + } + if ($to !== null && trim($to) !== '') { + $sql .= ' AND f.fetched_at <= :to_at'; + $params['to_at'] = $to; + } + + $sql .= ' ORDER BY f.fetched_at DESC, r.id DESC LIMIT :limit'; + $stmt = $this->pdo->prepare($sql); + foreach ($params as $key => $value) { + $stmt->bindValue(':' . $key, $value); + } + $stmt->bindValue(':limit', max(1, $limit), PDO::PARAM_INT); + $stmt->execute(); + + return array_map(fn (array $row): array => $this->normalizeRate($row), $stmt->fetchAll(PDO::FETCH_ASSOC) ?: []); + } + + public function saveFetch(string $baseCurrency, string $provider, string $rateDate, array $rates, ?string $fetchedAt = null, string $triggerSource = 'manual'): array + { + $baseCurrency = strtoupper(trim($baseCurrency)); + $provider = trim($provider) !== '' ? trim($provider) : 'currencyapi'; + $fetchedAt = trim((string) $fetchedAt) !== '' ? trim((string) $fetchedAt) : gmdate('Y-m-d H:i:s'); + $triggerSource = $this->normalizeTriggerSource($triggerSource); + $normalizedRates = []; + foreach ($rates as $currencyCode => $rate) { + $currencyCode = strtoupper(trim((string) $currencyCode)); + if ($currencyCode === '' || $currencyCode === $baseCurrency || !is_numeric($rate)) { + continue; + } + $normalizedRates[$currencyCode] = (float) $rate; + } + + $startedTransaction = false; + if (!$this->pdo->inTransaction()) { + $this->pdo->beginTransaction(); + $startedTransaction = true; + } + + try { + if ($this->driver === 'pgsql') { + $fetchStmt = $this->pdo->prepare( + 'INSERT INTO ' . $this->table('fetches') . ' ( + provider, trigger_source, base_currency, rate_date, fetched_at + ) VALUES ( + :provider, :trigger_source, :base_currency, :rate_date, :fetched_at + ) + RETURNING *' + ); + $fetchStmt->execute([ + 'provider' => $provider, + 'trigger_source' => $triggerSource, + 'base_currency' => $baseCurrency, + 'rate_date' => $rateDate, + 'fetched_at' => $fetchedAt, + ]); + $fetch = $this->normalizeFetch($fetchStmt->fetch(PDO::FETCH_ASSOC) ?: []); + } else { + $fetchStmt = $this->pdo->prepare( + 'INSERT INTO ' . $this->table('fetches') . ' ( + provider, trigger_source, base_currency, rate_date, fetched_at + ) VALUES ( + :provider, :trigger_source, :base_currency, :rate_date, :fetched_at + )' + ); + $fetchStmt->execute([ + 'provider' => $provider, + 'trigger_source' => $triggerSource, + 'base_currency' => $baseCurrency, + 'rate_date' => $rateDate, + 'fetched_at' => $fetchedAt, + ]); + $fetch = $this->getFetchById((int) $this->pdo->lastInsertId()) ?? []; + } + + $savedRates = []; + if ($normalizedRates !== []) { + $placeholders = []; + $params = ['fetch_id' => (int) ($fetch['id'] ?? 0)]; + $index = 0; + foreach ($normalizedRates as $currencyCode => $rate) { + $codeKey = 'currency_code_' . $index; + $valueKey = 'current_value_' . $index; + $placeholders[] = "(:fetch_id, :{$codeKey}, :{$valueKey})"; + $params[$codeKey] = $currencyCode; + $params[$valueKey] = $rate; + $savedRates[] = [ + 'fetch_id' => $fetch['id'] ?? null, + 'base_currency' => $baseCurrency, + 'target_currency' => $currencyCode, + 'rate' => $rate, + 'rate_date' => $rateDate, + 'provider' => $provider, + 'fetched_at' => $fetchedAt, + ]; + $index++; + } + + $insert = $this->pdo->prepare( + 'INSERT INTO ' . $this->table('rates') . ' (fetch_id, currency_code, current_value) VALUES ' . implode(', ', $placeholders) + ); + $insert->execute($params); + } + + if ($startedTransaction) { + $this->pdo->commit(); + } + + return [ + 'fetch' => $fetch, + 'rates' => $savedRates, + ]; + } catch (\Throwable $exception) { + if ($startedTransaction && $this->pdo->inTransaction()) { + $this->pdo->rollBack(); + } + throw $exception; + } + } + + public function findFetchByBaseAndFetchedAt(string $baseCurrency, string $fetchedAt): ?array + { + $baseCurrency = strtoupper(trim($baseCurrency)); + $fetchedAt = trim($fetchedAt); + if ($baseCurrency === '' || $fetchedAt === '') { + return null; + } + + $stmt = $this->pdo->prepare( + 'SELECT id, provider, trigger_source, base_currency, rate_date, fetched_at, created_at + FROM ' . $this->table('fetches') . ' + WHERE base_currency = :base_currency + AND fetched_at = :fetched_at + ORDER BY id ASC + LIMIT 1' + ); + $stmt->execute([ + 'base_currency' => $baseCurrency, + 'fetched_at' => $fetchedAt, + ]); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + return is_array($row) ? $this->normalizeFetch($row) : null; + } + + private function getFetchById(int $fetchId): ?array + { + $stmt = $this->pdo->prepare( + 'SELECT id, provider, trigger_source, base_currency, rate_date, fetched_at, created_at + FROM ' . $this->table('fetches') . ' + WHERE id = :id + LIMIT 1' + ); + $stmt->execute(['id' => $fetchId]); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + return is_array($row) ? $this->normalizeFetch($row) : null; + } + + private function findNeighborFetch(string $baseCurrency, string $timestamp, string $operator): ?array + { + $order = $operator === '<=' ? 'DESC' : 'ASC'; + $stmt = $this->pdo->prepare( + 'SELECT id, provider, trigger_source, base_currency, rate_date, fetched_at, created_at + FROM ' . $this->table('fetches') . ' + WHERE base_currency = :base_currency + AND fetched_at ' . $operator . ' :target_at + ORDER BY fetched_at ' . $order . ', id ' . $order . ' + LIMIT 1' + ); + $stmt->execute([ + 'base_currency' => $baseCurrency, + 'target_at' => $timestamp, + ]); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + return is_array($row) ? $this->normalizeFetch($row) : null; + } + + private function ratesForFetch(int $fetchId, ?array $symbols = null): array + { + $sql = 'SELECT currency_code, current_value FROM ' . $this->table('rates') . ' WHERE fetch_id = :fetch_id'; + $params = ['fetch_id' => $fetchId]; + + $normalizedSymbols = []; + if (is_array($symbols)) { + foreach ($symbols as $symbol) { + $symbol = strtoupper(trim((string) $symbol)); + if ($symbol !== '') { + $normalizedSymbols[] = $symbol; + } + } + $normalizedSymbols = array_values(array_unique($normalizedSymbols)); + } + + if ($normalizedSymbols !== []) { + $placeholders = []; + foreach ($normalizedSymbols as $index => $symbol) { + $key = 'symbol_' . $index; + $placeholders[] = ':' . $key; + $params[$key] = $symbol; + } + $sql .= ' AND currency_code IN (' . implode(', ', $placeholders) . ')'; + } + + $sql .= ' ORDER BY currency_code ASC'; + $stmt = $this->pdo->prepare($sql); + $stmt->execute($params); + + $rates = []; + foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) ?: [] as $row) { + $code = strtoupper(trim((string) ($row['currency_code'] ?? ''))); + $rate = $row['current_value'] ?? null; + if ($code === '' || !is_numeric($rate)) { + continue; + } + $rates[$code] = (float) $rate; + } + + return $rates; + } + + private function normalizeFetch(array $row): array + { + return [ + 'id' => isset($row['id']) ? (int) $row['id'] : null, + 'provider' => (string) ($row['provider'] ?? ''), + 'trigger_source' => (string) ($row['trigger_source'] ?? 'manual'), + 'base_currency' => strtoupper((string) ($row['base_currency'] ?? '')), + 'rate_date' => (string) ($row['rate_date'] ?? ''), + 'fetched_at' => (string) ($row['fetched_at'] ?? ''), + 'created_at' => (string) ($row['created_at'] ?? ''), + ]; + } + + private function ensureColumn(string $table, string $column, string $alterSql): void + { + try { + $stmt = $this->pdo->query('SELECT * FROM ' . $table . ' LIMIT 1'); + if ($stmt instanceof \PDOStatement) { + $row = $stmt->fetch(PDO::FETCH_ASSOC) ?: []; + if (in_array(strtolower($column), array_map('strtolower', array_keys($row)), true)) { + return; + } + } + } catch (\Throwable) { + } + + try { + $this->pdo->exec($alterSql); + } catch (\Throwable) { + } + } + + private function normalizeTriggerSource(string $source): string + { + $source = strtolower(trim($source)); + return match ($source) { + 'cron', 'manual', 'api', 'migration', 'widget' => $source, + default => 'manual', + }; + } + + private function normalizeRate(array $row): array + { + return [ + 'id' => isset($row['id']) ? (int) $row['id'] : null, + 'fetch_id' => isset($row['fetch_id']) ? (int) $row['fetch_id'] : null, + 'base_currency' => strtoupper((string) ($row['base_currency'] ?? '')), + 'target_currency' => strtoupper((string) ($row['target_currency'] ?? '')), + 'rate' => is_numeric($row['rate'] ?? null) ? (float) $row['rate'] : null, + 'rate_date' => (string) ($row['rate_date'] ?? ''), + 'provider' => (string) ($row['provider'] ?? ''), + 'fetched_at' => (string) ($row['fetched_at'] ?? ''), + ]; + } + + private function table(string $logicalName): string + { + return $this->tablePrefix . preg_replace('/[^a-zA-Z0-9_]/', '', $logicalName); + } +} diff --git a/modules/fx-rates/src/Infrastructure/ModuleConfig.php b/modules/fx-rates/src/Infrastructure/ModuleConfig.php new file mode 100644 index 00000000..4ed86b70 --- /dev/null +++ b/modules/fx-rates/src/Infrastructure/ModuleConfig.php @@ -0,0 +1,38 @@ +config; + } + + public function useProjectDatabase(): bool + { + return (bool) ($this->config['use_project_database'] ?? true); + } + + public function tablePrefix(): string + { + return (string) ($this->config['table_prefix'] ?? 'fxrate_'); + } + + public function settingsFile(): string + { + return (string) ($this->config['settings_file'] ?? dirname(__DIR__, 4) . '/data/fx-rates/settings.json'); + } +} diff --git a/modules/fx-rates/src/Infrastructure/SettingsStore.php b/modules/fx-rates/src/Infrastructure/SettingsStore.php new file mode 100644 index 00000000..7d8d683b --- /dev/null +++ b/modules/fx-rates/src/Infrastructure/SettingsStore.php @@ -0,0 +1,118 @@ +normalize($this->moduleConfig->all()); + $file = $this->settingsFile(); + if (!is_file($file)) { + return $defaults; + } + + $raw = file_get_contents($file); + if ($raw === false || trim($raw) === '') { + return $defaults; + } + + $decoded = json_decode($raw, true); + $saved = is_array($decoded) ? $decoded : []; + + return $this->normalize(array_replace_recursive($defaults, $saved)); + } + + public function save(array $patch): array + { + $current = $this->load(); + $next = $this->normalize(array_replace_recursive($current, $patch)); + $next['updated_at'] = gmdate(DATE_ATOM); + + $file = $this->settingsFile(); + $dir = dirname($file); + if (!is_dir($dir)) { + mkdir($dir, 0775, true); + } + + file_put_contents($file, json_encode($next, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)); + + return $next; + } + + private function settingsFile(): string + { + return $this->moduleConfig->settingsFile(); + } + + private function normalize(array $settings): array + { + $preferredCurrencies = $settings['preferred_currencies'] ?? ['EUR', 'USD', 'DOGE', 'BTC']; + if (is_string($preferredCurrencies)) { + $preferredCurrencies = preg_split('/[\s,;]+/', $preferredCurrencies) ?: []; + } + $preferredCurrencies = array_values(array_unique(array_filter(array_map( + static fn (mixed $value): string => strtoupper(trim((string) $value)), + is_array($preferredCurrencies) ? $preferredCurrencies : [] + )))); + + $catalog = $settings['currency_catalog'] ?? []; + $catalog = array_values(array_filter(array_map(static function (mixed $item): ?array { + if (!is_array($item)) { + return null; + } + $code = strtoupper(trim((string) ($item['code'] ?? ''))); + $name = trim((string) ($item['name'] ?? '')); + if ($code === '' || $name === '') { + return null; + } + + return [ + 'code' => $code, + 'name' => $name, + ]; + }, is_array($catalog) ? $catalog : []))); + + $refreshHours = (float) ($settings['refresh_max_age_hours'] ?? 6); + if ($refreshHours <= 0) { + $refreshHours = 6; + } + + $displayBase = strtoupper(trim((string) ($settings['display_base_currency'] ?? ($settings['default_base_currency'] ?? 'EUR')))); + $defaultBase = strtoupper(trim((string) ($settings['default_base_currency'] ?? 'EUR'))); + if ($defaultBase === '') { + $defaultBase = 'EUR'; + } + if ($displayBase === '') { + $displayBase = $defaultBase; + } + + return [ + 'use_separate_db' => !empty($settings['use_separate_db']), + 'db' => is_array($settings['db'] ?? null) ? $settings['db'] : [], + 'provider' => trim((string) ($settings['provider'] ?? 'currencyapi')) ?: 'currencyapi', + 'api_version' => in_array(strtolower(trim((string) ($settings['api_version'] ?? 'v2'))), ['v2', 'v3'], true) + ? strtolower(trim((string) ($settings['api_version'] ?? 'v2'))) + : 'v2', + 'api_url' => rtrim((string) ($settings['api_url'] ?? 'https://currencyapi.net'), '/'), + 'currencies_url' => rtrim((string) ($settings['currencies_url'] ?? ($settings['api_url'] ?? 'https://currencyapi.net')), '/'), + 'api_key' => trim((string) ($settings['api_key'] ?? '')), + 'timeout_sec' => max(2, (int) ($settings['timeout_sec'] ?? 10)), + 'refresh_max_age_hours' => $refreshHours, + 'refresh_max_age_minutes' => max(1, (int) round($refreshHours * 60)), + 'default_base_currency' => $defaultBase, + 'display_base_currency' => $displayBase, + 'preferred_currencies' => $preferredCurrencies, + 'currency_catalog' => $catalog, + 'currency_catalog_synced_at' => trim((string) ($settings['currency_catalog_synced_at'] ?? '')), + 'schedule_timezone' => trim((string) ($settings['schedule_timezone'] ?? nexus_display_timezone_name())) ?: nexus_display_timezone_name(), + 'updated_at' => trim((string) ($settings['updated_at'] ?? '')), + ]; + } +} diff --git a/modules/fx-rates/storage/.gitkeep b/modules/fx-rates/storage/.gitkeep new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/modules/fx-rates/storage/.gitkeep @@ -0,0 +1 @@ + diff --git a/public/api/fx-rates/index.php b/public/api/fx-rates/index.php new file mode 100644 index 00000000..59fc514b --- /dev/null +++ b/public/api/fx-rates/index.php @@ -0,0 +1,14 @@ + -
- - `; - } - - if (definition.content_mode === 'native-module' && definition.app_id === 'user-self-management') { - return ` -
-
+
`; } @@ -1434,6 +1427,82 @@ if (payloadNode) { }); }; + const renderFxRatesWidget = async (card, widget) => { + const statusNode = card.querySelector('[data-widget-status]'); + const messageNode = card.querySelector('[data-widget-message]'); + const refreshButton = card.querySelector('[data-widget-refresh]'); + const forceButton = card.querySelector('[data-widget-force-refresh]'); + + const loadStatus = async () => { + if (!(statusNode instanceof HTMLElement)) { + return; + } + + statusNode.textContent = 'Kursstatus wird geladen ...'; + try { + const response = await fetch(widget.status_api, { + credentials: 'same-origin', + headers: { Accept: 'application/json' }, + }); + const payload = await response.json().catch(() => ({})); + const rows = Array.isArray(payload?.data) ? payload.data : []; + const latest = rows.slice().sort((left, right) => { + const leftTs = new Date(left?.fetched_at || 0).getTime(); + const rightTs = new Date(right?.fetched_at || 0).getTime(); + return rightTs - leftTs; + })[0] || null; + statusNode.textContent = latest + ? `Letzter Abruf: ${latest.fetched_at_display || latest.fetched_at} · ${latest.base_currency} · ${latest.provider}` + : 'Noch keine gespeicherten Kurse vorhanden.'; + } catch (_error) { + statusNode.textContent = 'Kursstatus konnte nicht geladen werden.'; + } + }; + + const runRefresh = async (force) => { + if (!(messageNode instanceof HTMLElement)) { + return; + } + + messageNode.textContent = 'Aktualisierung laeuft ...'; + try { + const response = await fetch(widget.refresh_api, { + method: 'POST', + credentials: 'same-origin', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + force: force === true, + trigger_source: 'widget', + }), + }); + const payload = await response.json().catch(() => ({})); + if (!response.ok) { + throw new Error(payload?.context?.message || payload?.error || `HTTP ${response.status}`); + } + const result = payload?.data || {}; + messageNode.textContent = result.reused + ? 'Kein neuer API-Abruf. Der letzte gespeicherte Snapshot ist noch jung genug.' + : `Kurse aktualisiert. ${result.updated_count || 0} Werte gespeichert.`; + await loadStatus(); + } catch (error) { + messageNode.textContent = error instanceof Error ? error.message : 'Aktualisierung fehlgeschlagen.'; + } + }; + + refreshButton?.addEventListener('click', () => runRefresh(false)); + forceButton?.addEventListener('click', () => { + const confirmed = window.confirm('Der Kursabruf wird jetzt trotz jungem Snapshot erzwungen. Wirklich fortfahren?'); + if (confirmed) { + runRefresh(true); + } + }); + + await loadStatus(); + }; + const renderWidgets = () => { if (!widgetZoneRoot) { return; @@ -1455,6 +1524,17 @@ if (payloadNode) { ` : ''; + const widgetBodyMarkup = widget.widget_type === 'fx-rates-refresh' + ? ` +
Wird geladen ...
+

${widget.content || ''}

+
+ + + ${widget.launch_app_id ? `` : ''} +
+ ` + : `

${widget.content || ''}

${actionMarkup}`; card.innerHTML = `
@@ -1463,21 +1543,23 @@ if (payloadNode) {
${widget.icon || 'WG'}
-

${widget.content || ''}

- ${actionMarkup} + ${widgetBodyMarkup} `; - const actionButton = card.querySelector('.widget-card-action'); - if (actionButton instanceof HTMLButtonElement && widget.launch_app_id) { - actionButton.addEventListener('click', () => { - const app = findAppById(widget.launch_app_id); + card.querySelectorAll('[data-app-id]').forEach((button) => { + button.addEventListener('click', () => { + const appId = button.getAttribute('data-app-id') || ''; + const app = findAppById(appId); if (app) { openApp(app); } }); - } + }); widgetZoneRoot.appendChild(card); + if (widget.widget_type === 'fx-rates-refresh') { + renderFxRatesWidget(card, widget); + } }); }; @@ -1758,6 +1840,18 @@ if (payloadNode) { } }); + window.addEventListener('desktop:open-app', (event) => { + const appId = typeof event.detail?.appId === 'string' ? event.detail.appId : ''; + if (!appId) { + return; + } + + const app = findAppById(appId); + if (app) { + openApp(app); + } + }); + renderWidgets(); renderStartMenu(); updateDebugToggleUi();