asdasd
This commit is contained in:
17
custom/apps/fx-rates/api/index.php
Normal file
17
custom/apps/fx-rates/api/index.php
Normal file
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/bootstrap.php';
|
||||
|
||||
$requestUri = (string) ($_SERVER['REQUEST_URI'] ?? '');
|
||||
$requestPath = (string) (parse_url($requestUri, PHP_URL_PATH) ?: '');
|
||||
$prefix = '/api/fx-rates/index.php/';
|
||||
$relativePath = trim((string) ($_GET['path'] ?? ''), '/');
|
||||
|
||||
if ($relativePath === '') {
|
||||
$relativePath = str_starts_with($requestPath, $prefix)
|
||||
? substr($requestPath, strlen($prefix))
|
||||
: ltrim((string) ($_SERVER['PATH_INFO'] ?? ''), '/');
|
||||
}
|
||||
|
||||
(new Modules\FxRates\Api\Router(dirname(__DIR__)))->handle($relativePath);
|
||||
206
custom/apps/fx-rates/assets/css/app.css
Normal file
206
custom/apps/fx-rates/assets/css/app.css
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
780
custom/apps/fx-rates/assets/js/app.js
Normal file
780
custom/apps/fx-rates/assets/js/app.js
Normal file
@@ -0,0 +1,780 @@
|
||||
(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';
|
||||
}
|
||||
|
||||
let normalized = raw;
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(raw)) {
|
||||
normalized = `${raw}T00:00:00Z`;
|
||||
} else if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(raw)) {
|
||||
normalized = `${raw.replace(' ', 'T')}Z`;
|
||||
} else if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/.test(raw)) {
|
||||
normalized = `${raw}Z`;
|
||||
}
|
||||
|
||||
const parsed = new Date(normalized);
|
||||
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 }));
|
||||
}
|
||||
|
||||
function normalizedTimestamp(value) {
|
||||
const raw = String(value || '').trim();
|
||||
if (!raw) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(raw)) {
|
||||
return `${raw.replace(' ', 'T')}Z`;
|
||||
}
|
||||
|
||||
if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/.test(raw)) {
|
||||
return `${raw}Z`;
|
||||
}
|
||||
|
||||
return raw;
|
||||
}
|
||||
|
||||
function buildHistoryMatrix(historyEntries) {
|
||||
const columns = [];
|
||||
const rowsByKey = new Map();
|
||||
|
||||
historyEntries.forEach((entry) => {
|
||||
const pair = entry && entry.pair ? entry.pair : null;
|
||||
const columnKey = pair ? `${String(pair.from || '').toUpperCase()}/${String(pair.to || '').toUpperCase()}` : '';
|
||||
if (!columnKey) {
|
||||
return;
|
||||
}
|
||||
if (!columns.includes(columnKey)) {
|
||||
columns.push(columnKey);
|
||||
}
|
||||
|
||||
(Array.isArray(entry.rows) ? entry.rows : []).forEach((row) => {
|
||||
const fetchId = Number(row && row.fetch_id);
|
||||
const fetchedAt = normalizedTimestamp(row && row.fetched_at);
|
||||
const rowKey = fetchId > 0 ? `fetch:${fetchId}` : `time:${fetchedAt}`;
|
||||
if (!rowKey || rowKey === 'time:') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!rowsByKey.has(rowKey)) {
|
||||
rowsByKey.set(rowKey, {
|
||||
key: rowKey,
|
||||
fetch_id: fetchId > 0 ? fetchId : null,
|
||||
fetched_at: row && row.fetched_at ? String(row.fetched_at) : '',
|
||||
provider: row && row.provider ? String(row.provider) : '',
|
||||
values: {},
|
||||
});
|
||||
}
|
||||
|
||||
const bucket = rowsByKey.get(rowKey);
|
||||
if (!bucket) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!bucket.fetched_at && row && row.fetched_at) {
|
||||
bucket.fetched_at = String(row.fetched_at);
|
||||
}
|
||||
if (!bucket.provider && row && row.provider) {
|
||||
bucket.provider = String(row.provider);
|
||||
}
|
||||
bucket.values[columnKey] = Number.isFinite(Number(row && row.rate)) ? Number(row.rate) : null;
|
||||
});
|
||||
});
|
||||
|
||||
const rows = Array.from(rowsByKey.values()).sort((left, right) => {
|
||||
const leftTs = new Date(normalizedTimestamp(left.fetched_at) || 0).getTime();
|
||||
const rightTs = new Date(normalizedTimestamp(right.fetched_at) || 0).getTime();
|
||||
return rightTs - leftTs;
|
||||
});
|
||||
|
||||
return {
|
||||
columns,
|
||||
rows,
|
||||
};
|
||||
}
|
||||
|
||||
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 `
|
||||
<aside class="window-app-sidebar">
|
||||
<div class="window-app-brand">
|
||||
<p class="window-app-kicker">Modul</p>
|
||||
<h1>Waehrungs-Checker</h1>
|
||||
<p>Waehrungskurse, Historie, API und Widget-Aktualisierung in einer gemeinsamen Modulanwendung.</p>
|
||||
</div>
|
||||
<div class="window-app-nav-list">
|
||||
${sections.map((section) => `
|
||||
<button class="${cx('window-app-nav-button', state.activeTab === section.key && 'is-active')}" type="button" data-action="tab" data-tab="${escapeHtml(section.key)}">
|
||||
<strong>${escapeHtml(section.label)}</strong>
|
||||
<span class="window-app-meta">${escapeHtml(section.summary)}</span>
|
||||
</button>
|
||||
`).join('')}
|
||||
</div>
|
||||
</aside>
|
||||
`;
|
||||
}
|
||||
|
||||
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 : [];
|
||||
const historyMatrix = buildHistoryMatrix(state.history);
|
||||
|
||||
return `
|
||||
<div class="fx-stack">
|
||||
<section class="window-app-card">
|
||||
<p class="window-app-kicker">Aktualisierung</p>
|
||||
<h3>Externer Kursabruf</h3>
|
||||
<p class="window-app-copy">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.</p>
|
||||
<div class="fx-inline-actions">
|
||||
<button class="fx-button" type="button" data-action="refresh">${state.saving ? 'Aktualisiert ...' : 'Kurse aktualisieren'}</button>
|
||||
<button class="fx-button fx-button--ghost" type="button" data-action="refresh-force">${state.saving ? 'Aktualisiert ...' : 'Erzwungen aktualisieren'}</button>
|
||||
<button class="fx-button fx-button--ghost" type="button" data-action="open-widget-setup">Widget-Auswahl oeffnen</button>
|
||||
</div>
|
||||
<p class="${cx('fx-message', state.error && 'is-error', state.message && !state.error && 'is-success')}">${escapeHtml(state.error || state.message || '')}</p>
|
||||
</section>
|
||||
|
||||
<section class="fx-cards">
|
||||
<article class="fx-card">
|
||||
<p class="fx-kicker">Letzter Abruf</p>
|
||||
<div class="fx-stat-value">${escapeHtml(latest ? fmtDate(latest.fetched_at) : 'Noch keiner')}</div>
|
||||
<p class="fx-copy">${escapeHtml(latest ? `${latest.base_currency} · ${latest.provider} · ${latest.trigger_source_label || latest.trigger_source}` : 'Es wurden noch keine Kurse gespeichert.')}</p>
|
||||
</article>
|
||||
<article class="fx-card">
|
||||
<p class="fx-kicker">Standard-Basis</p>
|
||||
<div class="fx-stat-value">${escapeHtml(base)}</div>
|
||||
<p class="fx-copy">Anzeige-Basis ${escapeHtml(String(settings.display_base_currency || base).toUpperCase())}</p>
|
||||
</article>
|
||||
<article class="fx-card">
|
||||
<p class="fx-kicker">Bevorzugte Waehrungen</p>
|
||||
<div class="fx-stat-value">${escapeHtml(String(preferred.length || 0))}</div>
|
||||
<p class="fx-copy">${escapeHtml(preferred.join(', ') || 'Keine Auswahl')}</p>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="fx-table-grid">
|
||||
<section class="fx-panel">
|
||||
<p class="fx-kicker">Umrechnung</p>
|
||||
<h3>Direkter Kursvergleich</h3>
|
||||
<form class="fx-form-grid" data-form="convert">
|
||||
<div class="fx-field">
|
||||
<label for="fx-amount">Betrag</label>
|
||||
<input id="fx-amount" class="fx-input" type="number" name="amount" min="0" step="0.00000001" value="1">
|
||||
</div>
|
||||
<div class="fx-field">
|
||||
<label for="fx-from">Von</label>
|
||||
<select id="fx-from" class="fx-select" name="from">
|
||||
${preferred.map((currency) => `<option value="${escapeHtml(currency)}">${escapeHtml(currency)}</option>`).join('')}
|
||||
</select>
|
||||
</div>
|
||||
<div class="fx-field">
|
||||
<label for="fx-to">Nach</label>
|
||||
<select id="fx-to" class="fx-select" name="to">
|
||||
${preferred.map((currency, index) => `<option value="${escapeHtml(currency)}" ${index === 1 ? 'selected' : ''}>${escapeHtml(currency)}</option>`).join('')}
|
||||
</select>
|
||||
</div>
|
||||
<div class="fx-form-actions">
|
||||
<button class="fx-button" type="submit">Umrechnen</button>
|
||||
</div>
|
||||
</form>
|
||||
<p class="fx-copy">${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.'}</p>
|
||||
</section>
|
||||
<section class="fx-panel">
|
||||
<p class="fx-kicker">Widget</p>
|
||||
<h3>Desktop-Infobereich</h3>
|
||||
<div class="fx-widget-note">
|
||||
<p class="fx-copy">Das Widget kann im Setup unter Infobereich aktiviert werden und verwendet dieselbe Refresh-Regel wie die App und die API.</p>
|
||||
<button class="fx-button fx-button--ghost" type="button" data-action="open-widget-setup">Setup oeffnen</button>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<section class="fx-panel">
|
||||
<p class="fx-kicker">Verlauf</p>
|
||||
<h3>Neueste Kursverlaeufe</h3>
|
||||
<div class="fx-table-wrap">
|
||||
<table class="fx-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Zeit</th>
|
||||
<th>Quelle</th>
|
||||
${historyMatrix.columns.map((column) => `<th>${escapeHtml(column)}</th>`).join('')}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${historyMatrix.rows.length === 0
|
||||
? `<tr><td colspan="${String(2 + historyMatrix.columns.length)}">Noch keine Verlaufsdaten vorhanden.</td></tr>`
|
||||
: historyMatrix.rows.map((row) => `
|
||||
<tr>
|
||||
<td>${escapeHtml(fmtDate(row.fetched_at))}</td>
|
||||
<td>${escapeHtml(row.provider || 'n/a')}</td>
|
||||
${historyMatrix.columns.map((column) => `<td>${escapeHtml(fmtNumber(row.values[column], 8))}</td>`).join('')}
|
||||
</tr>
|
||||
`).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="fx-panel">
|
||||
<p class="fx-kicker">Abrufe</p>
|
||||
<h3>Letzte gespeicherte Snapshots</h3>
|
||||
<div class="fx-table-wrap">
|
||||
<table class="fx-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Zeit</th>
|
||||
<th>Basis</th>
|
||||
<th>Provider</th>
|
||||
<th>Ausloeser</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${state.recentFetches.length === 0
|
||||
? '<tr><td colspan="4">Noch keine Abrufe gespeichert.</td></tr>'
|
||||
: state.recentFetches.map((fetch) => `
|
||||
<tr>
|
||||
<td>${escapeHtml(fmtDate(fetch.fetched_at))}</td>
|
||||
<td>${escapeHtml(fetch.base_currency || 'n/a')}</td>
|
||||
<td>${escapeHtml(fetch.provider || 'n/a')}</td>
|
||||
<td>${escapeHtml(fetch.trigger_source_label || fetch.trigger_source || 'n/a')}</td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderCurrencies() {
|
||||
const settings = currentSettings();
|
||||
const catalog = Array.isArray(settings.currency_catalog) ? settings.currency_catalog : [];
|
||||
return `
|
||||
<div class="fx-stack">
|
||||
<section class="window-app-card">
|
||||
<p class="window-app-kicker">Katalog</p>
|
||||
<h3>Bevorzugte Waehrungen</h3>
|
||||
<p class="window-app-copy">Der Katalog wird aus dem konfigurierten Provider synchronisiert. Die Anzeige-Basis muss Teil der bevorzugten Waehrungen sein.</p>
|
||||
<div class="fx-inline-actions">
|
||||
<button class="fx-button" type="button" data-action="sync-catalog">${state.syncingCatalog ? 'Synchronisiert ...' : 'Waehrungskatalog synchronisieren'}</button>
|
||||
</div>
|
||||
<p class="fx-copy">Letzte Katalog-Synchronisierung: ${escapeHtml(settings.currency_catalog_synced_at ? fmtDate(settings.currency_catalog_synced_at) : 'noch nie')}</p>
|
||||
</section>
|
||||
|
||||
<form class="fx-panel fx-stack" data-form="settings">
|
||||
<div class="fx-form-grid">
|
||||
<div class="fx-field">
|
||||
<label for="fx-display-base">Anzeige-Basis</label>
|
||||
<input id="fx-display-base" class="fx-input" type="text" name="display_base_currency" value="${escapeHtml(settings.display_base_currency || settings.default_base_currency || 'EUR')}">
|
||||
</div>
|
||||
<div class="fx-field">
|
||||
<label for="fx-default-base">Standard-Basis</label>
|
||||
<input id="fx-default-base" class="fx-input" type="text" name="default_base_currency" value="${escapeHtml(settings.default_base_currency || 'EUR')}">
|
||||
</div>
|
||||
<div class="fx-field">
|
||||
<label for="fx-preferred">Bevorzugte Waehrungen</label>
|
||||
<input id="fx-preferred" class="fx-input" type="text" name="preferred_currencies" value="${escapeHtml((settings.preferred_currencies || []).join(', '))}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="fx-form-actions">
|
||||
<button class="fx-button" type="submit">${state.saving ? 'Speichert ...' : 'Waehrungen speichern'}</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<section class="fx-panel">
|
||||
<p class="fx-kicker">Verfuegbarer Katalog</p>
|
||||
<div class="fx-token-row">
|
||||
${catalog.length === 0
|
||||
? '<span class="fx-token">Noch kein Katalog geladen</span>'
|
||||
: catalog.map((item) => `<span class="fx-token">${escapeHtml(item.code)} · ${escapeHtml(item.name)}</span>`).join('')}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderSettings() {
|
||||
const settings = currentSettings();
|
||||
return `
|
||||
<form class="fx-stack" data-form="settings">
|
||||
<section class="window-app-card">
|
||||
<p class="window-app-kicker">Provider</p>
|
||||
<h3>Externe Quelle und Token</h3>
|
||||
<div class="fx-form-grid">
|
||||
<div class="fx-field">
|
||||
<label for="fx-provider">Provider</label>
|
||||
<input id="fx-provider" class="fx-input" type="text" name="provider" value="${escapeHtml(settings.provider || 'currencyapi')}">
|
||||
</div>
|
||||
<div class="fx-field">
|
||||
<label for="fx-api-version">API Version</label>
|
||||
<select id="fx-api-version" class="fx-select" name="api_version">
|
||||
<option value="v2" ${settings.api_version === 'v2' ? 'selected' : ''}>v2</option>
|
||||
<option value="v3" ${settings.api_version === 'v3' ? 'selected' : ''}>v3</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="fx-field">
|
||||
<label for="fx-api-url">API URL</label>
|
||||
<input id="fx-api-url" class="fx-input" type="text" name="api_url" value="${escapeHtml(settings.api_url || '')}">
|
||||
</div>
|
||||
<div class="fx-field">
|
||||
<label for="fx-currencies-url">Currencies URL</label>
|
||||
<input id="fx-currencies-url" class="fx-input" type="text" name="currencies_url" value="${escapeHtml(settings.currencies_url || settings.api_url || '')}">
|
||||
</div>
|
||||
<div class="fx-field">
|
||||
<label for="fx-api-key">API Key</label>
|
||||
<input id="fx-api-key" class="fx-input" type="password" name="api_key" value="${escapeHtml(settings.api_key || '')}">
|
||||
</div>
|
||||
<div class="fx-field">
|
||||
<label for="fx-timeout">Timeout (Sek.)</label>
|
||||
<input id="fx-timeout" class="fx-input" type="number" name="timeout_sec" min="2" step="1" value="${escapeHtml(String(settings.timeout_sec || 10))}">
|
||||
</div>
|
||||
<div class="fx-field">
|
||||
<label for="fx-refresh-hours">Refresh-Sperre (Std.)</label>
|
||||
<input id="fx-refresh-hours" class="fx-input" type="number" name="refresh_max_age_hours" min="1" step="0.5" value="${escapeHtml(String(settings.refresh_max_age_hours || 6))}">
|
||||
</div>
|
||||
<div class="fx-field">
|
||||
<label for="fx-timezone">Zeitzone</label>
|
||||
<input id="fx-timezone" class="fx-input" type="text" name="schedule_timezone" value="${escapeHtml(settings.schedule_timezone || 'Europe/Berlin')}">
|
||||
</div>
|
||||
<input type="hidden" name="display_base_currency" value="${escapeHtml(settings.display_base_currency || settings.default_base_currency || 'EUR')}">
|
||||
<input type="hidden" name="default_base_currency" value="${escapeHtml(settings.default_base_currency || 'EUR')}">
|
||||
<input type="hidden" name="preferred_currencies" value="${escapeHtml((settings.preferred_currencies || []).join(', '))}">
|
||||
</div>
|
||||
<div class="fx-form-actions">
|
||||
<button class="fx-button" type="submit">${state.saving ? 'Speichert ...' : 'Setup speichern'}</button>
|
||||
</div>
|
||||
<p class="fx-copy">Der importierte Token wird als Default uebernommen, kann hier aber projektweit geaendert werden.</p>
|
||||
</section>
|
||||
</form>
|
||||
`;
|
||||
}
|
||||
|
||||
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 `
|
||||
<main class="window-app-main">
|
||||
<section class="window-app-hero">
|
||||
<div>
|
||||
<p class="window-app-kicker">Waehrungs-Checker</p>
|
||||
<h2 class="window-app-title">${escapeHtml(current.label)}</h2>
|
||||
<p class="window-app-copy">${escapeHtml(current.summary)}</p>
|
||||
</div>
|
||||
<div class="window-app-pill-row">
|
||||
<span class="window-app-pill">API aktiv</span>
|
||||
<span class="window-app-pill">Widget optional</span>
|
||||
<span class="window-app-pill">Provider ${escapeHtml(String((state.settings && state.settings.provider) || 'currencyapi'))}</span>
|
||||
</div>
|
||||
</section>
|
||||
<div class="window-app-panel fx-shell">
|
||||
${content}
|
||||
</div>
|
||||
</main>
|
||||
`;
|
||||
}
|
||||
|
||||
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 = '<div class="window-app-loading"><p class="window-app-copy">Waehrungsdaten werden geladen.</p></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.error && !state.settings) {
|
||||
root.innerHTML = `<div class="window-app-error-state"><p class="window-app-message is-error">${escapeHtml(state.error)}</p></div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
root.innerHTML = `
|
||||
<div class="window-app-shell">
|
||||
<div class="window-app-frame">
|
||||
${renderNav()}
|
||||
${renderMain()}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
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'), {});
|
||||
}
|
||||
})();
|
||||
42
custom/apps/fx-rates/bootstrap.php
Normal file
42
custom/apps/fx-rates/bootstrap.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
spl_autoload_register(static function (string $class): void {
|
||||
$prefix = 'Modules\\FxRates\\';
|
||||
if (!str_starts_with($class, $prefix)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$relative = substr($class, strlen($prefix));
|
||||
$path = __DIR__ . '/src/' . str_replace('\\', '/', $relative) . '.php';
|
||||
if (is_file($path)) {
|
||||
require_once $path;
|
||||
}
|
||||
});
|
||||
|
||||
$moduleName = 'fx-rates';
|
||||
$moduleBasePath = __DIR__;
|
||||
|
||||
if (method_exists(modules(), 'registerFunction')) {
|
||||
modules()->registerFunction($moduleName, 'service', static function () use ($moduleBasePath): \Modules\FxRates\Domain\FxRatesService {
|
||||
static $service = null;
|
||||
|
||||
if ($service instanceof \Modules\FxRates\Domain\FxRatesService) {
|
||||
return $service;
|
||||
}
|
||||
|
||||
$moduleConfig = \Modules\FxRates\Infrastructure\ModuleConfig::load($moduleBasePath);
|
||||
$settingsStore = new \Modules\FxRates\Infrastructure\SettingsStore($moduleConfig);
|
||||
$settings = $settingsStore->load();
|
||||
$pdo = \Modules\FxRates\Infrastructure\ConnectionFactory::make($settingsStore);
|
||||
$repository = new \Modules\FxRates\Infrastructure\FxRatesRepository(
|
||||
$pdo,
|
||||
$moduleConfig->tablePrefix()
|
||||
);
|
||||
$repository->ensureSchema();
|
||||
|
||||
$service = new \Modules\FxRates\Domain\FxRatesService($repository, $settings);
|
||||
|
||||
return $service;
|
||||
});
|
||||
}
|
||||
15
custom/apps/fx-rates/config/example.config.php
Normal file
15
custom/apps/fx-rates/config/example.config.php
Normal file
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
'FX_RATES_PROVIDER' => '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',
|
||||
];
|
||||
22
custom/apps/fx-rates/config/module.php
Normal file
22
custom/apps/fx-rates/config/module.php
Normal file
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
'default_project_key' => 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(),
|
||||
];
|
||||
19
custom/apps/fx-rates/design.json
Normal file
19
custom/apps/fx-rates/design.json
Normal file
@@ -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."
|
||||
}
|
||||
]
|
||||
}
|
||||
60
custom/apps/fx-rates/desktop.php
Normal file
60
custom/apps/fx-rates/desktop.php
Normal file
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$manifest = [];
|
||||
$manifestRaw = is_file(__DIR__ . '/module.json') ? file_get_contents(__DIR__ . '/module.json') : false;
|
||||
if ($manifestRaw !== false) {
|
||||
$decoded = json_decode($manifestRaw, true);
|
||||
$manifest = is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
|
||||
$design = [];
|
||||
$designRaw = is_file(__DIR__ . '/design.json') ? file_get_contents(__DIR__ . '/design.json') : false;
|
||||
if ($designRaw !== false) {
|
||||
$decoded = json_decode($designRaw, true);
|
||||
$design = is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
|
||||
$sections = array_values(array_filter(
|
||||
is_array($design['sections'] ?? null) ? $design['sections'] : [],
|
||||
static fn (mixed $section): bool => 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'))],
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
29
custom/apps/fx-rates/docs/README.md
Normal file
29
custom/apps/fx-rates/docs/README.md
Normal file
@@ -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 `custom/apps/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`
|
||||
90
custom/apps/fx-rates/module.json
Normal file
90
custom/apps/fx-rates/module.json
Normal file
@@ -0,0 +1,90 @@
|
||||
{
|
||||
"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,
|
||||
"app_scope": "module",
|
||||
"installable": true,
|
||||
"desktop": {
|
||||
"available": true,
|
||||
"show_on_desktop": true,
|
||||
"show_in_start_menu": true
|
||||
},
|
||||
"widgets": [
|
||||
{
|
||||
"widget_id": "fx-rates-refresh",
|
||||
"title": "Waehrungskurse",
|
||||
"icon": "FX",
|
||||
"zone": "sidebar",
|
||||
"default_enabled": true,
|
||||
"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"
|
||||
}
|
||||
],
|
||||
"cron_jobs": [
|
||||
{
|
||||
"job_id": "refresh-rates",
|
||||
"label": "FX-Kurse aktualisieren",
|
||||
"description": "Laedt neue Wechselkurse, sofern der letzte Snapshot nicht mehr frisch genug ist.",
|
||||
"endpoint_path": "/api/fx-rates/index.php?path=v1/refresh",
|
||||
"method": "POST",
|
||||
"payload": {
|
||||
"trigger_source": "cron"
|
||||
},
|
||||
"default_enabled": false,
|
||||
"default_cron": "0 */6 * * *",
|
||||
"lock_minutes": 15
|
||||
},
|
||||
{
|
||||
"job_id": "sync-currency-catalog",
|
||||
"label": "FX-Waehrungskatalog synchronisieren",
|
||||
"description": "Aktualisiert den internen Waehrungskatalog des Moduls.",
|
||||
"endpoint_path": "/api/fx-rates/index.php?path=v1/currency-catalog/sync",
|
||||
"method": "POST",
|
||||
"payload": {
|
||||
"trigger_source": "cron"
|
||||
},
|
||||
"default_enabled": false,
|
||||
"default_cron": "30 3 * * 1",
|
||||
"lock_minutes": 30
|
||||
}
|
||||
],
|
||||
"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": []
|
||||
}
|
||||
}
|
||||
59
custom/apps/fx-rates/pages/index.php
Normal file
59
custom/apps/fx-rates/pages/index.php
Normal file
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__, 3) . '/src/App/bootstrap.php';
|
||||
require_once dirname(__DIR__) . '/bootstrap.php';
|
||||
|
||||
use ModulesCore\ModuleHttp;
|
||||
|
||||
session_start();
|
||||
|
||||
$projectRoot = dirname(__DIR__, 3);
|
||||
ModuleHttp::requireDesktopAccess($projectRoot);
|
||||
|
||||
$design = [];
|
||||
$designRaw = is_file(dirname(__DIR__) . '/design.json') ? file_get_contents(dirname(__DIR__) . '/design.json') : false;
|
||||
if ($designRaw !== false) {
|
||||
$decoded = json_decode($designRaw, true);
|
||||
$design = is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
|
||||
$assetVersion = static function (string $relativePath): string {
|
||||
$fullPath = dirname(__DIR__) . '/' . ltrim($relativePath, '/');
|
||||
$mtime = is_file($fullPath) ? filemtime($fullPath) : false;
|
||||
return $mtime === false ? '0' : (string) $mtime;
|
||||
};
|
||||
|
||||
$sections = is_array($design['sections'] ?? null) ? $design['sections'] : [];
|
||||
$activeView = trim((string) ($_GET['view'] ?? 'overview'));
|
||||
$sectionKeys = array_values(array_filter(array_map(
|
||||
static fn (mixed $section): string => 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);
|
||||
?><!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Waehrungs-Checker</title>
|
||||
<link rel="stylesheet" href="/assets/desktop/desktop.css">
|
||||
<link rel="stylesheet" href="/module-assets/index.php?module=fx-rates&path=assets/css/app.css&v=<?= htmlspecialchars($assetVersion('assets/css/app.css'), ENT_QUOTES) ?>">
|
||||
</head>
|
||||
<body>
|
||||
<div
|
||||
id="fx-rates-app"
|
||||
data-api-base="/api/fx-rates/index.php?path=v1"
|
||||
data-active-view="<?= htmlspecialchars($activeView, ENT_QUOTES) ?>"
|
||||
data-sections-json="<?= htmlspecialchars(is_string($sectionsJson) ? $sectionsJson : '[]', ENT_QUOTES) ?>"
|
||||
></div>
|
||||
<script src="/module-assets/index.php?module=fx-rates&path=assets/js/app.js&v=<?= htmlspecialchars($assetVersion('assets/js/app.js'), ENT_QUOTES) ?>"></script>
|
||||
</body>
|
||||
</html>
|
||||
323
custom/apps/fx-rates/src/Api/Router.php
Normal file
323
custom/apps/fx-rates/src/Api/Router.php
Normal file
@@ -0,0 +1,323 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\FxRates\Api;
|
||||
|
||||
use Modules\FxRates\Domain\FxRatesService;
|
||||
use Modules\FxRates\Infrastructure\ConnectionFactory;
|
||||
use Modules\FxRates\Infrastructure\FxRatesRepository;
|
||||
use Modules\FxRates\Infrastructure\ModuleConfig;
|
||||
use Modules\FxRates\Infrastructure\SettingsStore;
|
||||
use RuntimeException;
|
||||
|
||||
final class Router
|
||||
{
|
||||
private ModuleConfig $moduleConfig;
|
||||
private SettingsStore $settingsStore;
|
||||
private ?FxRatesService $service = null;
|
||||
|
||||
public function __construct(
|
||||
private readonly string $moduleBasePath
|
||||
) {
|
||||
$this->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'],
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
991
custom/apps/fx-rates/src/Domain/FxRatesService.php
Normal file
991
custom/apps/fx-rates/src/Domain/FxRatesService.php
Normal file
@@ -0,0 +1,991 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\FxRates\Domain;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use DateTimeZone;
|
||||
use Modules\FxRates\Infrastructure\FxRatesRepository;
|
||||
|
||||
final class FxRatesService
|
||||
{
|
||||
private array $memoryCache = [];
|
||||
|
||||
public function __construct(
|
||||
private FxRatesRepository $repository,
|
||||
private array $settings = []
|
||||
) {
|
||||
}
|
||||
|
||||
public function latestStatus(): ?array
|
||||
{
|
||||
return $this->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',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\FxRates\Infrastructure;
|
||||
|
||||
use App\PdoFactory;
|
||||
use RuntimeException;
|
||||
use PDO;
|
||||
|
||||
final class ConnectionFactory
|
||||
{
|
||||
public static function make(SettingsStore $settingsStore): PDO
|
||||
{
|
||||
$settings = $settingsStore->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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
547
custom/apps/fx-rates/src/Infrastructure/FxRatesRepository.php
Normal file
547
custom/apps/fx-rates/src/Infrastructure/FxRatesRepository.php
Normal file
@@ -0,0 +1,547 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\FxRates\Infrastructure;
|
||||
|
||||
use PDO;
|
||||
|
||||
final class FxRatesRepository
|
||||
{
|
||||
private string $driver;
|
||||
|
||||
public function __construct(
|
||||
private PDO $pdo,
|
||||
private string $tablePrefix = 'fxrate_'
|
||||
) {
|
||||
$this->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);
|
||||
}
|
||||
}
|
||||
38
custom/apps/fx-rates/src/Infrastructure/ModuleConfig.php
Normal file
38
custom/apps/fx-rates/src/Infrastructure/ModuleConfig.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\FxRates\Infrastructure;
|
||||
|
||||
final class ModuleConfig
|
||||
{
|
||||
public function __construct(
|
||||
private array $config
|
||||
) {
|
||||
}
|
||||
|
||||
public static function load(string $moduleBasePath): self
|
||||
{
|
||||
$config = require $moduleBasePath . '/config/module.php';
|
||||
return new self(is_array($config) ? $config : []);
|
||||
}
|
||||
|
||||
public function all(): array
|
||||
{
|
||||
return $this->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');
|
||||
}
|
||||
}
|
||||
118
custom/apps/fx-rates/src/Infrastructure/SettingsStore.php
Normal file
118
custom/apps/fx-rates/src/Infrastructure/SettingsStore.php
Normal file
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\FxRates\Infrastructure;
|
||||
|
||||
final class SettingsStore
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ModuleConfig $moduleConfig
|
||||
) {
|
||||
}
|
||||
|
||||
public function load(): array
|
||||
{
|
||||
$defaults = $this->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'] ?? '')),
|
||||
];
|
||||
}
|
||||
}
|
||||
1
custom/apps/fx-rates/storage/.gitkeep
Normal file
1
custom/apps/fx-rates/storage/.gitkeep
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
Reference in New Issue
Block a user