New desktop
Some checks failed
Deploy / deploy-staging (push) Failing after 6s
Deploy / deploy-production (push) Has been skipped

This commit is contained in:
2026-06-06 03:20:50 +02:00
parent 756b4e119b
commit 888981c782
127 changed files with 31275 additions and 0 deletions

View File

@@ -0,0 +1,7 @@
<?php
declare(strict_types=1);
require_once dirname(__DIR__) . '/bootstrap.php';
$service = module_fn('fx-rates', 'service');
(new Modules\FxRates\Api\Router($service))->handle($_GET['path'] ?? '');

View File

@@ -0,0 +1,168 @@
(() => {
const root = document.getElementById('fx-rates-currencies');
if (!root) {
return;
}
const page = JSON.parse(root.dataset.page || '{}');
const currencies = Array.isArray(page.currencies) ? page.currencies : [];
const selected = new Set(
(Array.isArray(page.preferred_currencies) ? page.preferred_currencies : [])
.map((code) => String(code || '').trim().toUpperCase())
.filter(Boolean)
);
let displayBase = String(page.display_base_currency || '').trim().toUpperCase();
const savedDisplayBase = String(page.saved_display_base_currency || displayBase || '').trim().toUpperCase();
const nodes = {
tokenList: root.querySelector('[data-fx-token-list]'),
searchInput: root.querySelector('[data-fx-search-input]'),
suggestions: root.querySelector('[data-fx-suggestions]'),
displayBaseSelect: root.querySelector('[data-fx-display-base-select]'),
displayBaseHidden: root.querySelector('[data-fx-display-base-hidden]'),
hiddenPreferred: root.querySelector('[data-fx-hidden-preferred]'),
};
const escapeHtml = (value) => String(value || '')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
const currencyByCode = new Map(
currencies.map((currency) => [String(currency.code || '').toUpperCase(), currency])
);
const sortedSelectedCodes = () => Array.from(selected).sort((left, right) => left.localeCompare(right));
const ensureDisplayBase = () => {
const available = sortedSelectedCodes();
if (available.length === 0) {
displayBase = '';
return;
}
if (!displayBase || !selected.has(displayBase)) {
displayBase = available[0];
}
};
const renderHiddenInputs = () => {
if (!nodes.hiddenPreferred) {
return;
}
nodes.hiddenPreferred.innerHTML = sortedSelectedCodes()
.map((code) => `<input type="hidden" name="preferred_currencies[]" value="${escapeHtml(code)}">`)
.join('');
if (nodes.displayBaseHidden) {
nodes.displayBaseHidden.value = displayBase;
}
};
const renderDisplayBase = () => {
if (!nodes.displayBaseSelect) {
return;
}
ensureDisplayBase();
const available = sortedSelectedCodes();
nodes.displayBaseSelect.innerHTML = available.length
? available.map((code) => `<option value="${escapeHtml(code)}" ${code === displayBase ? 'selected' : ''}>${escapeHtml(code)}</option>`).join('')
: '<option value="">Keine Waehrungen ausgewaehlt</option>';
nodes.displayBaseSelect.disabled = available.length === 0;
};
const removeCode = (code) => {
selected.delete(code);
renderAll();
};
const renderTokens = () => {
if (!nodes.tokenList) {
return;
}
const selectedCodes = sortedSelectedCodes();
if (selectedCodes.length === 0) {
nodes.tokenList.innerHTML = '<div class="fx-text">Noch keine bevorzugten Waehrungen ausgewaehlt.</div>';
return;
}
nodes.tokenList.innerHTML = selectedCodes.map((code) => {
const currency = currencyByCode.get(code) || { code, name: code };
return `
<button type="button" class="fx-token" data-remove-code="${escapeHtml(code)}" title="${escapeHtml(code)} entfernen">
<span>${escapeHtml(`${code} (${currency.name || code})`)}</span>
<span class="fx-token-close">x</span>
</button>
`;
}).join('');
nodes.tokenList.querySelectorAll('[data-remove-code]').forEach((button) => {
button.addEventListener('click', () => {
removeCode(String(button.getAttribute('data-remove-code') || '').toUpperCase());
});
});
};
const renderSuggestions = () => {
if (!nodes.suggestions || !nodes.searchInput) {
return;
}
const needle = String(nodes.searchInput.value || '').trim().toLowerCase();
if (!needle) {
nodes.suggestions.innerHTML = '';
return;
}
const matches = currencies
.filter((currency) => !selected.has(String(currency.code || '').toUpperCase()))
.filter((currency) => {
const code = String(currency.code || '').toLowerCase();
const name = String(currency.name || '').toLowerCase();
return code.includes(needle) || name.includes(needle);
})
.slice(0, 12);
nodes.suggestions.innerHTML = matches.map((currency) => `
<button type="button" class="fx-suggestion" data-add-code="${escapeHtml(String(currency.code || '').toUpperCase())}">
<strong>${escapeHtml(String(currency.code || '').toUpperCase())}</strong>
<span>${escapeHtml(String(currency.name || ''))}</span>
</button>
`).join('');
nodes.suggestions.querySelectorAll('[data-add-code]').forEach((button) => {
button.addEventListener('click', () => {
const code = String(button.getAttribute('data-add-code') || '').toUpperCase();
if (code) {
selected.add(code);
if (nodes.searchInput) {
nodes.searchInput.value = '';
}
renderAll();
}
});
});
};
const renderAll = () => {
ensureDisplayBase();
renderTokens();
renderDisplayBase();
renderHiddenInputs();
renderSuggestions();
};
nodes.searchInput?.addEventListener('input', renderSuggestions);
nodes.displayBaseSelect?.addEventListener('change', () => {
displayBase = String(nodes.displayBaseSelect?.value || '').trim().toUpperCase();
renderHiddenInputs();
const url = new URL(window.location.href);
if (displayBase) {
url.searchParams.set('base', displayBase);
} else if (savedDisplayBase) {
url.searchParams.set('base', savedDisplayBase);
} else {
url.searchParams.delete('base');
}
window.location.href = url.toString();
});
renderAll();
})();

View File

@@ -0,0 +1,287 @@
#fx-rates-app,
#fx-rates-currencies {
display: contents;
}
.fx-section-head {
display: flex;
justify-content: space-between;
gap: 16px;
align-items: flex-start;
flex-wrap: wrap;
}
.fx-section-head h2,
.section-box h2,
.card-box h2 {
margin: 0 0 0.5rem;
}
.fx-section-head p,
.section-box p,
.card-box p {
margin: 0 0 0.75rem;
color: var(--muted);
}
.fx-card-head {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: flex-start;
flex-wrap: wrap;
}
.fx-actions {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
}
.fx-form-grid {
display: grid;
gap: 0.75rem;
}
.fx-form-grid {
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
}
.fx-form-grid label,
.fx-block {
display: grid;
gap: 0.35rem;
}
.fx-form-grid span,
.fx-block span {
font-size: 0.9rem;
color: var(--muted);
}
.fx-form-grid input,
.fx-form-grid select,
.fx-block input {
width: 100%;
border: 1px solid var(--line);
border-radius: 12px;
padding: 0.7rem 0.8rem;
background: var(--surface-strong);
color: var(--text);
}
.fx-message {
color: var(--text);
}
.fx-message.is-error {
color: #d92d20;
}
.fx-message.is-success {
color: color-mix(in srgb, var(--accent-green) 78%, var(--text));
}
.fx-table-wrap {
overflow-x: auto;
}
.fx-table {
width: 100%;
border-collapse: collapse;
}
.fx-table th,
.fx-table td {
text-align: left;
border-bottom: 1px solid var(--line);
padding: 0.65rem 0.4rem;
}
.fx-api-note {
margin-top: 0.75rem;
font-size: 0.95rem;
}
.fx-convert-result {
margin-top: 1rem;
min-height: 1.5rem;
font-size: 1rem;
font-weight: 700;
color: var(--text);
}
.fx-card-meta {
display: grid;
gap: 0.35rem;
color: var(--muted);
font-size: 0.95rem;
text-align: right;
}
.fx-action-row {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
}
.fx-action-row form {
margin: 0;
}
.fx-save-row {
margin-top: 1rem;
}
.fx-save-row form {
margin: 0;
}
.fx-mini-grid {
display: grid;
gap: 1rem;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
}
.fx-card-grid {
display: grid;
gap: 16px;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
}
.fx-mini-label,
.fx-field-label {
font-size: 0.9rem;
color: var(--muted);
letter-spacing: 0.18em;
text-transform: uppercase;
}
.fx-card-value {
font-size: 1.2rem;
font-weight: 700;
}
.fx-currency-selection-row {
display: flex;
flex-wrap: wrap;
gap: 1rem;
align-items: flex-start;
}
.fx-token-list {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
}
.fx-token-list--inline {
flex: 1 1 auto;
}
.fx-currency-search {
flex: 0 1 360px;
min-width: 260px;
}
.fx-input,
.fx-select {
width: 100%;
border: 1px solid var(--line);
border-radius: 18px;
padding: 0.8rem 1rem;
background: var(--surface-strong);
color: var(--text);
}
.fx-field {
display: grid;
gap: 0.45rem;
margin-top: 1rem;
}
.fx-token,
.fx-suggestion {
display: inline-flex;
align-items: center;
gap: 0.6rem;
border: 1px solid var(--line);
border-radius: 999px;
padding: 0.7rem 1rem;
background: var(--surface-strong);
color: var(--text);
}
.fx-token {
cursor: pointer;
}
.fx-token:hover,
.fx-suggestion:hover {
border-color: color-mix(in srgb, var(--brand-accent-3) 45%, transparent);
background: color-mix(in srgb, var(--brand-accent) 6%, var(--surface-strong));
}
.fx-token-close {
color: var(--brand-accent-3);
font-weight: 700;
text-transform: uppercase;
}
.fx-suggestion-list {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
margin-top: 0.9rem;
}
.fx-suggestion {
cursor: pointer;
}
.fx-suggestion strong {
color: var(--text);
}
.fx-text {
color: var(--muted);
}
.fx-history-date {
display: inline-flex;
align-items: center;
gap: 0.45rem;
}
.fx-info-button {
width: 1.4rem;
height: 1.4rem;
border: 1px solid var(--line);
border-radius: 999px;
background: var(--surface-strong);
color: var(--muted);
font-size: 0.78rem;
font-weight: 700;
line-height: 1;
cursor: help;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
}
@media (max-width: 860px) {
.fx-currency-selection-row {
flex-direction: column;
}
.fx-card-head {
align-items: flex-start;
}
.fx-currency-search {
flex: 1 1 auto;
width: 100%;
min-width: 0;
}
}

View File

@@ -0,0 +1,286 @@
(() => {
const root = document.getElementById('fx-rates-app');
if (!root) {
return;
}
const page = JSON.parse(root.dataset.page || '{}');
const settings = page.settings || {};
const nodes = {
historyHead: root.querySelector('[data-bind="history-head"]'),
historyBody: root.querySelector('[data-bind="history-body"]'),
convertResult: root.querySelector('[data-bind="convert-result"]'),
convertFrom: root.querySelector('select[name="convert_from"]'),
convertTo: root.querySelector('select[name="convert_to"]'),
convertAmount: root.querySelector('input[name="convert_amount"]'),
};
const apiBase = '/api/fx-rates/v1';
const preferredCurrencies = Array.isArray(page.preferred_currencies)
? page.preferred_currencies
.map((item) => String(item || '').trim().toUpperCase())
.filter(Boolean)
: [];
const refreshMaxAgeMinutes = Math.max(1, Number(settings.refresh_max_age_minutes || 60));
const parseDateValue = (value) => {
const raw = String(value || '').trim();
if (!raw) {
return null;
}
let normalized = raw.replace(' ', 'T');
normalized = normalized.replace(/([+-]\d{2})$/, '$1:00');
const parsed = new Date(normalized);
return Number.isNaN(parsed.getTime()) ? null : parsed;
};
const latestFetchedAt = () => {
const latest = page.latest && typeof page.latest === 'object' ? page.latest : null;
const direct = parseDateValue(latest?.fetched_at);
if (direct) {
return direct;
}
const recentFetches = Array.isArray(page.recent_fetches) ? page.recent_fetches : [];
for (const entry of recentFetches) {
const parsed = parseDateValue(entry?.fetched_at);
if (parsed) {
return parsed;
}
}
return null;
};
const bindManualRefreshAction = () => {
const refreshLink = Array.from(document.querySelectorAll('a[href]')).find((link) => {
try {
const url = new URL(link.href, window.location.origin);
return url.pathname === '/module/fx-rates' && url.searchParams.get('refresh') === '1';
} catch (_error) {
return false;
}
});
if (!refreshLink) {
return;
}
refreshLink.addEventListener('click', (event) => {
const url = new URL(refreshLink.href, window.location.origin);
if (url.searchParams.get('force') === '1') {
return;
}
const lastFetch = latestFetchedAt();
if (!lastFetch) {
return;
}
const ageMinutes = (Date.now() - lastFetch.getTime()) / 60000;
if (!Number.isFinite(ageMinutes) || ageMinutes >= refreshMaxAgeMinutes) {
return;
}
event.preventDefault();
const confirmed = window.confirm(
`Der letzte gespeicherte Abruf ist juenger als ${refreshMaxAgeMinutes} Minuten. ` +
'Ein manueller Abruf wuerde die externe API trotzdem erneut aufrufen. Jetzt trotzdem abrufen?'
);
if (!confirmed) {
return;
}
url.searchParams.set('force', '1');
window.location.href = url.toString();
});
};
const escapeHtml = (value) => String(value || '')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
const renderHistory = (rows, currencies) => {
if (!nodes.historyHead || !nodes.historyBody) {
return;
}
const series = Array.isArray(currencies) ? currencies : [];
if (!series.length) {
nodes.historyHead.innerHTML = '<tr><th>Datum</th><th>Kurse</th></tr>';
nodes.historyBody.innerHTML = '<tr><td colspan="2">Keine bevorzugten Waehrungen fuer den Verlauf vorhanden.</td></tr>';
return;
}
nodes.historyHead.innerHTML = `
<tr>
<th>Datum</th>
${series.map((currency) => `<th>${currency}</th>`).join('')}
</tr>
`;
const entries = Array.isArray(rows) ? rows : [];
if (!entries.length) {
nodes.historyBody.innerHTML = `<tr><td colspan="${series.length + 1}">Noch keine Verlaufsdaten vorhanden.</td></tr>`;
return;
}
nodes.historyBody.innerHTML = entries.map((entry) => `
<tr>
<td>
<div class="fx-history-date">
<span>${entry.label}</span>
${entry.fetch ? `
<button
type="button"
class="fx-info-button"
title="${escapeHtml(`Basis: ${entry.fetch.base_currency || '-'} | Provider: ${entry.fetch.provider || '-'} | Ausloeser: ${entry.fetch.trigger_source_label || entry.fetch.trigger_source || '-'}`)}"
aria-label="${escapeHtml(`Abrufinfo fuer ${entry.label}`)}"
>i</button>
` : ''}
</div>
</td>
${series.map((currency) => {
const value = entry.rates?.[currency];
if (typeof value !== 'number' || !Number.isFinite(value)) {
return '<td></td>';
}
return `<td>${value.toLocaleString('de-DE', { maximumFractionDigits: 8 })}</td>`;
}).join('')}
</tr>
`).join('');
};
const request = async (path, options = {}) => {
const response = await fetch(`${apiBase}${path}`, {
credentials: 'same-origin',
headers: {
Accept: 'application/json',
...(options.body ? { 'Content-Type': 'application/json' } : {}),
...(options.headers || {}),
},
...options,
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(payload?.context?.message || payload?.error || `HTTP ${response.status}`);
}
return payload.data;
};
const loadHistory = async () => {
const base = String(
settings.display_base_currency || settings.default_base_currency || 'EUR'
).trim().toUpperCase();
const selectedCurrencies = preferredCurrencies.length
? preferredCurrencies
: [base];
const historyCurrencies = selectedCurrencies.filter((currency) => currency !== base);
if (!selectedCurrencies.length) {
renderHistory([], []);
return;
}
const histories = await Promise.all(historyCurrencies.map(async (currency) => {
const query = new URLSearchParams({ from: base, to: currency, limit: '20' });
const rows = await request(`/history?${query.toString()}`);
return { currency, rows: Array.isArray(rows) ? rows : [] };
}));
const recentFetches = Array.isArray(page.recent_fetches) ? page.recent_fetches : [];
const byDate = new Map();
recentFetches.forEach((fetch) => {
const key = String(fetch?.fetched_at || '').trim();
if (!key || byDate.has(key)) {
return;
}
byDate.set(key, {
sortKey: key,
label: fetch?.fetched_at_display || fetch?.fetched_at || key,
fetch,
rates: base !== '' ? { [base]: 1 } : {},
});
});
histories.forEach(({ currency, rows }) => {
rows.forEach((row) => {
const key = String(row?.fetched_at || row?.rate_date || '').trim();
if (!key) {
return;
}
if (!byDate.has(key)) {
byDate.set(key, {
sortKey: key,
label: row?.fetched_at_display || row?.fetched_at || row?.rate_date || key,
fetch: recentFetches.find((fetch) => String(fetch?.fetched_at || '').trim() === key) || null,
rates: base !== '' ? { [base]: 1 } : {},
});
}
const entry = byDate.get(key);
if (entry && typeof row?.rate === 'number' && Number.isFinite(row.rate)) {
entry.rates[currency] = row.rate;
}
});
});
const mergedRows = Array.from(byDate.values())
.sort((left, right) => String(right.sortKey).localeCompare(String(left.sortKey)))
.slice(0, 15);
renderHistory(mergedRows, selectedCurrencies);
};
const calculateConversion = async () => {
if (!nodes.convertFrom || !nodes.convertTo || !nodes.convertAmount || !nodes.convertResult) {
return;
}
const from = String(nodes.convertFrom.value || '').trim().toUpperCase();
const to = String(nodes.convertTo.value || '').trim().toUpperCase();
const amount = Number(nodes.convertAmount.value || '0');
if (!from || !to || !Number.isFinite(amount)) {
nodes.convertResult.textContent = 'Bitte Quellwaehrung, Zielwaehrung und Betrag angeben.';
return;
}
if (from === to) {
nodes.convertResult.textContent = `${amount.toLocaleString('de-DE', { maximumFractionDigits: 8 })} ${from} = ${amount.toLocaleString('de-DE', { maximumFractionDigits: 8 })} ${to}`;
return;
}
try {
const query = new URLSearchParams({ from, to });
const data = await request(`/rate?${query.toString()}`);
const rate = Number(data?.rate || 0);
if (!Number.isFinite(rate) || rate <= 0) {
throw new Error('Kein Kurs verfuegbar.');
}
const converted = amount * rate;
nodes.convertResult.textContent = `${amount.toLocaleString('de-DE', { maximumFractionDigits: 8 })} ${from} = ${converted.toLocaleString('de-DE', { maximumFractionDigits: 8 })} ${to} | Kurs ${rate.toLocaleString('de-DE', { maximumFractionDigits: 8 })}`;
} catch (error) {
nodes.convertResult.textContent = error.message || 'Umrechnung konnte nicht berechnet werden.';
}
};
[nodes.convertFrom, nodes.convertTo, nodes.convertAmount].forEach((node) => {
node?.addEventListener('change', () => {
calculateConversion().catch(() => {});
});
node?.addEventListener('input', () => {
calculateConversion().catch(() => {});
});
});
bindManualRefreshAction();
loadHistory().catch(() => {
renderHistory([], preferredCurrencies);
});
calculateConversion().catch(() => {});
})();

View File

@@ -0,0 +1,269 @@
<?php
declare(strict_types=1);
use App\ModuleConfigException;
use Modules\FxRates\Domain\FxRatesService;
use Modules\FxRates\Infrastructure\FxRatesRepository;
spl_autoload_register(static function (string $class): void {
$prefix = 'Modules\\FxRates\\';
if (!str_starts_with($class, $prefix)) {
return;
}
$file = __DIR__ . '/src/' . str_replace('\\', '/', substr($class, strlen($prefix))) . '.php';
if (is_file($file)) {
require_once $file;
}
});
$moduleName = 'fx-rates';
$mm = isset($modules) && $modules instanceof App\ModuleManager ? $modules : modules();
$mm->registerFunction($moduleName, 'table', static function (string $name): string {
$prefix = 'fxrate_';
$sanitized = preg_replace('/[^a-zA-Z0-9_]/', '', $name);
return $prefix . $sanitized;
});
$mm->registerFunction($moduleName, 'settings', static function (): array {
$saved = modules()->settings('fx-rates');
$provider = trim((string) ($saved['provider'] ?? (getenv('FX_RATES_PROVIDER') ?: getenv('MINING_CHECKER_FX_PROVIDER') ?: 'currencyapi')));
$apiVersion = strtolower(trim((string) ($saved['api_version'] ?? 'v2')));
$apiUrl = rtrim((string) ($saved['api_url'] ?? (getenv('FX_RATES_API_URL') ?: getenv('MINING_CHECKER_FX_URL') ?: 'https://currencyapi.net')), '/');
$apiKey = trim((string) ($saved['api_key'] ?? (getenv('FX_RATES_API_KEY') ?: getenv('MINING_CHECKER_FX_API_KEY') ?: '')));
$timeout = max(2, (int) ($saved['timeout_sec'] ?? (getenv('FX_RATES_TIMEOUT') ?: getenv('MINING_CHECKER_FX_TIMEOUT') ?: 10)));
$preferredCurrencies = $saved['preferred_currencies'] ?? ['EUR', 'USD', 'DOGE'];
if (is_string($preferredCurrencies)) {
$preferredCurrencies = preg_split('/[\s,;]+/', $preferredCurrencies) ?: [];
}
$preferredCurrencies = array_values(array_unique(array_filter(array_map(
static fn (mixed $code): string => strtoupper(trim((string) $code)),
is_array($preferredCurrencies) ? $preferredCurrencies : []
), static fn (string $code): bool => $code !== '')));
$currencyCatalog = $saved['currency_catalog'] ?? [];
$currencyCatalog = 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($currencyCatalog) ? $currencyCatalog : [])));
return [
'provider' => $provider !== '' ? $provider : 'currencyapi',
'api_version' => in_array($apiVersion, ['v2', 'v3'], true) ? $apiVersion : 'v2',
'api_url' => $apiUrl,
'api_key' => $apiKey,
'timeout_sec' => $timeout,
'refresh_max_age_minutes' => max(1, (int) ($saved['refresh_max_age_minutes'] ?? 60)),
'default_base_currency' => strtoupper(trim((string) ($saved['default_base_currency'] ?? 'EUR'))) ?: 'EUR',
'display_base_currency' => strtoupper(trim((string) ($saved['display_base_currency'] ?? ($saved['default_base_currency'] ?? 'EUR')))) ?: 'EUR',
'preferred_currencies' => $preferredCurrencies,
'currency_catalog' => $currencyCatalog,
'currency_catalog_synced_at' => trim((string) ($saved['currency_catalog_synced_at'] ?? '')),
'schedule_timezone' => trim((string) ($saved['schedule_timezone'] ?? nexus_cron_timezone_name())) ?: nexus_cron_timezone_name(),
];
});
$mm->registerFunction($moduleName, 'save_runtime_settings', static function (array $payload): array {
$current = modules()->settings('fx-rates');
$normalized = module_fn('fx-rates', 'settings');
if (array_key_exists('default_base_currency', $payload)) {
$normalized['default_base_currency'] = strtoupper(trim((string) $payload['default_base_currency'])) ?: $normalized['default_base_currency'];
}
if (array_key_exists('display_base_currency', $payload)) {
$normalized['display_base_currency'] = strtoupper(trim((string) $payload['display_base_currency'])) ?: $normalized['display_base_currency'];
}
if (array_key_exists('preferred_currencies', $payload)) {
$preferredCurrencies = $payload['preferred_currencies'];
if (is_string($preferredCurrencies)) {
$preferredCurrencies = preg_split('/[\s,;]+/', $preferredCurrencies) ?: [];
}
$normalized['preferred_currencies'] = array_values(array_unique(array_filter(array_map(
static fn (mixed $code): string => strtoupper(trim((string) $code)),
is_array($preferredCurrencies) ? $preferredCurrencies : []
), static fn (string $code): bool => $code !== '')));
}
$catalogCodes = [];
foreach (($normalized['currency_catalog'] ?? []) as $currency) {
if (is_array($currency)) {
$code = strtoupper(trim((string) ($currency['code'] ?? '')));
if ($code !== '') {
$catalogCodes[$code] = true;
}
}
}
if ($catalogCodes !== [] && !isset($catalogCodes[$normalized['display_base_currency']])) {
$normalized['display_base_currency'] = $normalized['default_base_currency'];
}
if ($catalogCodes !== []) {
$normalized['preferred_currencies'] = array_values(array_filter(
$normalized['preferred_currencies'],
static fn (string $code): bool => isset($catalogCodes[$code])
));
}
$toSave = array_merge($current, [
'default_base_currency' => $normalized['default_base_currency'],
'display_base_currency' => $normalized['display_base_currency'],
'preferred_currencies' => $normalized['preferred_currencies'],
]);
modules()->saveSettings('fx-rates', $toSave);
return module_fn('fx-rates', 'settings');
});
$mm->registerFunction($moduleName, 'pdo', function () use ($moduleName): PDO {
$settings = modules()->settings($moduleName);
$useSeparate = !empty($settings['use_separate_db']);
if ($useSeparate) {
$module = modules()->get($moduleName);
$fallback = $module['db_defaults'] ?? [];
return modules()->modulePdo($moduleName, $fallback);
}
$base = app()->basePdo();
if ($base instanceof PDO) {
return $base;
}
throw new ModuleConfigException(
$moduleName,
'Base-DB ist deaktiviert. Bitte Base-DB aktivieren oder eine eigene Modul-DB konfigurieren.'
);
});
$mm->registerFunction($moduleName, 'repository', static function (): FxRatesRepository {
return new FxRatesRepository(module_fn('fx-rates', 'pdo'), 'fxrate_');
});
$mm->registerFunction($moduleName, 'ensure_schema', static function (): void {
module_fn('fx-rates', 'repository')->ensureSchema();
});
$mm->registerFunction($moduleName, 'service', static function (): FxRatesService {
module_fn('fx-rates', 'ensure_schema');
return new FxRatesService(
module_fn('fx-rates', 'repository'),
module_fn('fx-rates', 'settings')
);
});
$mm->registerFunction($moduleName, 'setup_actions', static function (): array {
return [
[
'name' => 'sync_currency_catalog',
'label' => 'Waehrungskatalog synchronisieren',
'help' => 'Laedt die verfuegbaren Waehrungen einmalig aus dem konfigurierten FX-Provider.',
],
];
});
$mm->registerFunction($moduleName, 'run_setup_action', static function (string $action): array {
return match ($action) {
'sync_currency_catalog' => (static function (): array {
$result = module_fn('fx-rates', 'service')->refreshCurrencyCatalog();
$current = modules()->settings('fx-rates');
$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];
}
$latest = module_fn('fx-rates', 'service')->latestStatus();
if (is_array($latest) && !empty($latest['id'])) {
$snapshot = module_fn('fx-rates', 'snapshot', null, null, null, null);
$rates = is_array($snapshot['rates'] ?? null) ? $snapshot['rates'] : [];
foreach (array_keys($rates) as $code) {
$code = strtoupper(trim((string) $code));
if ($code !== '' && !isset($catalog[$code])) {
$catalog[$code] = ['code' => $code, 'name' => $code];
}
}
$baseCode = strtoupper(trim((string) ($snapshot['base_currency'] ?? '')));
if ($baseCode !== '' && !isset($catalog[$baseCode])) {
$catalog[$baseCode] = ['code' => $baseCode, 'name' => $baseCode];
}
}
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);
$current['currency_catalog'] = array_values($catalog);
$current['currency_catalog_synced_at'] = gmdate('Y-m-d H:i:s');
modules()->saveSettings('fx-rates', $current);
return $result + [
'currencies' => array_values($catalog),
'synced_count' => count($catalog),
'message' => 'Waehrungskatalog synchronisiert. ' . count($catalog) . ' Waehrungen verarbeitet.',
];
})(),
default => throw new \RuntimeException('Unbekannte Setup-Aktion.'),
};
});
$mm->registerFunction($moduleName, 'refresh_latest', static function (?array $currencies = null, ?string $baseCurrency = null): array {
return module_fn('fx-rates', 'service')->refreshLatestRates($currencies, $baseCurrency);
});
$mm->registerFunction($moduleName, 'ensure_fresh_latest_rates', static function (float $maxAgeHours = 24.0, ?string $baseCurrency = null, ?array $currencies = null): array {
return module_fn('fx-rates', 'service')->ensureFreshLatestRates($maxAgeHours, $baseCurrency, $currencies);
});
$mm->registerFunction($moduleName, 'rate', static function (string $fromCurrency, string $toCurrency, ?string $at = null, ?int $windowMinutes = null): ?array {
return module_fn('fx-rates', 'service')->findRate($fromCurrency, $toCurrency, $at, $windowMinutes);
});
$mm->registerFunction($moduleName, 'convert', static function (?float $amount, ?string $fromCurrency, ?string $toCurrency, ?string $at = null, ?int $windowMinutes = null): ?float {
return module_fn('fx-rates', 'service')->convert($amount, $fromCurrency, $toCurrency, $at, $windowMinutes);
});
$mm->registerFunction($moduleName, 'snapshot', static function (?string $baseCurrency = null, ?string $at = null, ?array $symbols = null, ?int $windowMinutes = null): ?array {
return module_fn('fx-rates', 'service')->snapshot($baseCurrency, $at, $symbols, $windowMinutes);
});
$mm->registerFunction($moduleName, 'recent_fetches', static function (int $limit = 20): array {
return module_fn('fx-rates', 'service')->recentFetches($limit);
});
$mm->registerFunction($moduleName, 'scheduled_refresh', static function (array $context = []): array {
$result = module_fn('fx-rates', 'service')->runScheduledRefresh($context);
if (function_exists('module_debug_push')) {
module_debug_push('fx-rates', [
'label' => 'Intervall-Aufgabe',
'type' => 'scheduler:run',
'task' => 'daily_refresh',
'context' => $context,
'message' => (string) ($result['message'] ?? ''),
]);
}
return $result;
});

View File

@@ -0,0 +1,12 @@
{
"eyebrow": "Modul",
"title": "Waehrungskurse",
"description": "Zentrale Verwaltung fuer Waehrungskurse, Snapshots und FX-API-Abrufe.",
"actions": [
{ "label": "Setup", "href": "/modules/setup/fx-rates", "variant": "secondary" }
],
"tabs": [
{ "label": "Ueberblick", "href": "/module/fx-rates" },
{ "label": "Waehrungen", "href": "/module/fx-rates/currencies", "match_prefixes": ["/module/fx-rates/currencies"] }
]
}

View File

@@ -0,0 +1,58 @@
{
"title": "Waehrungskurse",
"version": "0.1.5",
"description": "Zentrales Modul fuer Waehrungskurse, Historie und API-Abrufe.",
"enabled_by_default": true,
"setup": {
"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 Nexus-Base-DB genutzt." },
{ "name": "db.driver", "label": "DB Driver", "type": "text", "required": false, "help": "z.B. pgsql, mysql, sqlite" },
{ "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": "api_key", "label": "FX API Key", "type": "password", "required": false },
{ "name": "timeout_sec", "label": "Timeout (Sek.)", "type": "number", "required": false },
{ "name": "refresh_max_age_minutes", "label": "Max. Alter fuer API-Refresh (Min.)", "type": "number", "required": false, "help": "Blockiert neue API-Refresh-Aufrufe, solange der letzte gespeicherte Abruf juenger ist. Manuelle Abrufe koennen nach Hinweis trotzdem erzwungen werden; Cron ignoriert diesen Wert." },
{ "name": "default_base_currency", "label": "Standard-Basiswaehrung", "type": "text", "required": false, "help": "Wird fuer taegliche Abrufe und Snapshot-Abfragen verwendet." },
{ "name": "schedule_timezone", "label": "Scheduler-Zeitzone", "type": "text", "required": false, "help": "z.B. Europe/Berlin" }
]
},
"scheduler_jobs": [
{
"name": "rates_refresh",
"label": "Kursabruf",
"callback": "scheduled_refresh",
"mode": "multi",
"default_enabled": true,
"default_cron": "0 18 * * *",
"default_timezone": "Europe/Berlin",
"timezone_setting": "schedule_timezone",
"lock_minutes": 120,
"help": "Zeitgesteuerter Abruf und das Speichern neuer FX-Snapshots.",
"builder": {
"allow_manual": true,
"presets": ["daily", "every_x_days", "weekly", "monthly_day", "every_x_hours"]
}
}
],
"db_defaults": {
"driver": "pgsql",
"host": "localhost",
"port": 5432,
"dbname": "",
"schema": "public",
"user": "",
"password": ""
},
"auth": {
"required": true,
"users": [],
"groups": []
}
}

View File

@@ -0,0 +1,31 @@
<?php
$file = (string) ($_GET['file'] ?? '');
$base = realpath(__DIR__ . '/../assets');
$map = [
'fx-rates.css' => $base . '/fx-rates.css',
'fx-rates.js' => $base . '/fx-rates.js',
'fx-rates-currencies.js' => $base . '/fx-rates-currencies.js',
];
if (!isset($map[$file])) {
http_response_code(404);
exit('Not found');
}
$path = $map[$file];
if (!$base || !is_file($path) || !str_starts_with($path, $base)) {
http_response_code(404);
exit('Not found');
}
$ext = pathinfo($path, PATHINFO_EXTENSION);
if ($ext === 'css') {
header('Content-Type: text/css; charset=utf-8');
} elseif ($ext === 'js') {
header('Content-Type: application/javascript; charset=utf-8');
} else {
header('Content-Type: application/octet-stream');
}
readfile($path);
exit;

View File

@@ -0,0 +1,280 @@
<?php
declare(strict_types=1);
require_once dirname(__DIR__) . '/bootstrap.php';
$assets = app()->assets();
if ($assets) {
$assets->addStyle('/module/fx-rates/asset?file=fx-rates.css');
$assets->addScript('/module/fx-rates/asset?file=fx-rates-currencies.js', 'footer', true);
}
$settings = module_fn('fx-rates', 'settings');
$service = module_fn('fx-rates', 'service');
$notice = trim((string) ($_GET['notice'] ?? ''));
$error = trim((string) ($_GET['error'] ?? ''));
if (strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET')) === 'POST') {
try {
$action = trim((string) ($_POST['fx_action'] ?? ''));
if ($action === 'save_selection') {
$payload = [
'display_base_currency' => (string) ($_POST['display_base_currency'] ?? ''),
'preferred_currencies' => $_POST['preferred_currencies'] ?? [],
];
$saved = module_fn('fx-rates', 'save_runtime_settings', $payload);
$params = ['notice' => 'Waehrungs-Auswahl gespeichert.'];
if (is_array($saved) && !empty($saved['display_base_currency'])) {
$params['base'] = (string) $saved['display_base_currency'];
}
redirect('/module/fx-rates/currencies?' . http_build_query($params));
}
if ($action === 'sync_catalog') {
$result = module_fn('fx-rates', 'run_setup_action', 'sync_currency_catalog');
redirect('/module/fx-rates/currencies?' . http_build_query([
'notice' => sprintf('Waehrungskatalog synchronisiert. %d Waehrungen verarbeitet.', (int) ($result['synced_count'] ?? 0)),
]));
}
if ($action === 'refresh_rates') {
$result = $service->refreshLatestRates(null, (string) ($settings['default_base_currency'] ?? ''), 'manual');
redirect('/module/fx-rates/currencies?' . http_build_query([
'notice' => sprintf('Alle Wechselkurse aktualisiert. %d Werte gespeichert.', (int) ($result['updated_count'] ?? 0)),
]));
}
} catch (\Throwable $exception) {
redirect('/module/fx-rates/currencies?' . http_build_query([
'error' => $exception->getMessage() !== '' ? $exception->getMessage() : 'Aktion konnte nicht ausgefuehrt werden.',
]));
}
}
$catalog = is_array($settings['currency_catalog'] ?? null) ? $settings['currency_catalog'] : [];
$preferredCurrencies = is_array($settings['preferred_currencies'] ?? null) ? $settings['preferred_currencies'] : [];
$savedDisplayBaseCurrency = strtoupper(trim((string) ($settings['display_base_currency'] ?? $settings['default_base_currency'] ?? 'EUR')));
$requestedDisplayBaseCurrency = strtoupper(trim((string) ($_GET['base'] ?? '')));
$latest = $service->latestStatus();
$recentFetches = $service->recentFetches(15);
$currencies = [];
foreach ($catalog as $item) {
if (!is_array($item)) {
continue;
}
$code = strtoupper(trim((string) ($item['code'] ?? '')));
$name = trim((string) ($item['name'] ?? ''));
if ($code === '' || $name === '') {
continue;
}
$currencies[] = [
'code' => $code,
'name' => $name,
];
}
$cryptoCodes = array_fill_keys([
'ADA', 'ARB', 'BNB', 'BTC', 'DAI', 'DOGE', 'DOT', 'ETH', 'LINK', 'LTC',
'SOL', 'USDC', 'USDT', 'XAG', 'XAU', 'XRP',
], true);
$fiatCount = 0;
$cryptoCount = 0;
foreach ($currencies as $currency) {
if (isset($cryptoCodes[$currency['code']])) {
$cryptoCount++;
} else {
$fiatCount++;
}
}
$catalogCodes = [];
foreach ($currencies as $currency) {
$catalogCodes[(string) $currency['code']] = true;
}
$displayBaseCurrency = $requestedDisplayBaseCurrency !== '' ? $requestedDisplayBaseCurrency : $savedDisplayBaseCurrency;
if ($displayBaseCurrency === '' || (!isset($catalogCodes[$displayBaseCurrency]) && $preferredCurrencies !== [])) {
$displayBaseCurrency = $savedDisplayBaseCurrency !== '' ? $savedDisplayBaseCurrency : (string) ($preferredCurrencies[0] ?? 'EUR');
}
$tableCurrencies = [];
foreach ([$displayBaseCurrency, ...$preferredCurrencies] as $currency) {
$currency = strtoupper(trim((string) $currency));
if ($currency !== '' && !in_array($currency, $tableCurrencies, true)) {
$tableCurrencies[] = $currency;
}
}
$currencyPageData = json_encode([
'currencies' => $currencies,
'preferred_currencies' => array_values(array_unique(array_map(static fn (mixed $code): string => strtoupper(trim((string) $code)), $preferredCurrencies))),
'display_base_currency' => $displayBaseCurrency,
'saved_display_base_currency' => $savedDisplayBaseCurrency,
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$tabs = [
['label' => 'Ueberblick', 'href' => '/module/fx-rates'],
['label' => 'Waehrungen', 'href' => '/module/fx-rates/currencies', 'active' => true],
];
?>
<?= module_shell_header('fx-rates', [
'title' => 'Waehrungen',
'tabs' => $tabs,
'actions' => [
['label' => 'Nexus Übersicht', 'href' => '/', 'variant' => 'secondary', 'size' => 'sm'],
['label' => 'Setup', 'href' => '/modules/setup/fx-rates', 'variant' => 'secondary', 'size' => 'sm'],
],
]) ?>
<div id="fx-rates-currencies" data-page='<?= e(is_string($currencyPageData) ? $currencyPageData : '{}') ?>'>
<?php if ($notice !== ''): ?>
<section class="section-box">
<div class="fx-message is-success"><?= e($notice) ?></div>
</section>
<?php elseif ($error !== ''): ?>
<section class="section-box">
<div class="fx-message is-error"><?= e($error) ?></div>
</section>
<?php endif; ?>
<section class="section-box">
<div class="fx-section-head">
<div>
<h2>Waehrungs-Update</h2>
<p>Auswahl wird in den Waehrungskurs-Einstellungen gespeichert und steht damit auf Handy und Desktop gleich zur Verfuegung.</p>
</div>
</div>
<div class="fx-action-row">
<form method="post">
<input type="hidden" name="fx_action" value="refresh_rates">
<button type="submit" class="module-button module-button--primary">Alle Wechselkurse aktualisieren</button>
</form>
<form method="post">
<input type="hidden" name="fx_action" value="sync_catalog">
<button type="submit" class="module-button module-button--ghost">Waehrungskatalog sync</button>
</form>
</div>
</section>
<div class="fx-card-grid">
<section class="card-box">
<div class="fx-mini-label">Fiat</div>
<div class="fx-card-value"><?= e((string) $fiatCount) ?> Waehrungen</div>
</section>
<section class="card-box">
<div class="fx-mini-label">Krypto</div>
<div class="fx-card-value"><?= e((string) $cryptoCount) ?> Waehrungen</div>
</section>
</div>
<section class="section-box">
<div class="fx-field-label">Bevorzugte Waehrungen fuer Anzeige</div>
<div class="fx-currency-selection-row">
<div class="fx-token-list fx-token-list--inline" data-fx-token-list></div>
<div class="fx-currency-search">
<input type="text" class="fx-input" value="" placeholder="Waehrung hinzufuegen: EUR, USD, DOGE oder Euro" autocomplete="off" data-fx-search-input>
</div>
</div>
<div class="fx-suggestion-list" data-fx-suggestions></div>
<div class="fx-field fx-currency-search">
<label class="fx-field-label" for="fx-display-base-select">Darstellung auf Basis von</label>
<select id="fx-display-base-select" class="fx-select" data-fx-display-base-select></select>
</div>
<div class="fx-save-row">
<form method="post">
<input type="hidden" name="fx_action" value="save_selection">
<input type="hidden" name="display_base_currency" value="<?= e($displayBaseCurrency) ?>" data-fx-display-base-hidden>
<div data-fx-hidden-preferred></div>
<button type="submit" class="module-button module-button--ghost">Auswahl speichern</button>
</form>
</div>
</section>
<section class="section-box">
<div class="fx-card-head">
<div>
<h2>Letzte 15 Kurs-Uploads</h2>
<p>Zeigt die zuletzt gespeicherten Wechselkurse aus der Datenbank.</p>
</div>
<div class="fx-card-meta">
<div><strong>Anzeige-Basis:</strong> <?= e($displayBaseCurrency) ?></div>
<div><strong>Letzter Abruf:</strong> <?= e((string) ($latest['fetched_at_display'] ?? $latest['fetched_at'] ?? 'noch keiner')) ?></div>
</div>
</div>
<div class="fx-table-wrap">
<table class="fx-table">
<thead>
<tr>
<th>Zeit</th>
<?php foreach ($tableCurrencies as $currency): ?>
<th><?= e((string) $currency) ?></th>
<?php endforeach; ?>
</tr>
</thead>
<tbody>
<?php if ($recentFetches === []): ?>
<tr><td colspan="<?= 1 + count($tableCurrencies) ?>">Noch keine Abrufe vorhanden.</td></tr>
<?php else: ?>
<?php foreach ($recentFetches as $fetch): ?>
<?php
$fetchBaseCurrency = strtoupper(trim((string) ($fetch['base_currency'] ?? '')));
$snapshot = $service->snapshotByFetchId((int) ($fetch['id'] ?? 0), $fetchBaseCurrency, $tableCurrencies);
$originalRates = is_array($snapshot['rates'] ?? null) ? $snapshot['rates'] : [];
$displayBaseRate = $displayBaseCurrency === $fetchBaseCurrency
? 1.0
: (is_numeric($originalRates[$displayBaseCurrency] ?? null) ? (float) $originalRates[$displayBaseCurrency] : null);
$tableRates = [];
foreach ($tableCurrencies as $currency) {
$currency = strtoupper(trim((string) $currency));
if ($currency === '') {
continue;
}
if ($currency === $displayBaseCurrency) {
$tableRates[$currency] = 1.0;
continue;
}
if ($displayBaseRate === null || $displayBaseRate <= 0) {
$tableRates[$currency] = null;
continue;
}
if ($currency === $fetchBaseCurrency) {
$tableRates[$currency] = 1 / $displayBaseRate;
continue;
}
$rawRate = $originalRates[$currency] ?? null;
$tableRates[$currency] = is_numeric($rawRate) ? ((float) $rawRate / $displayBaseRate) : null;
}
$infoTitle = sprintf(
'Basis: %s | Provider: %s | Ausloeser: %s',
(string) ($fetch['base_currency'] ?? '-'),
(string) ($fetch['provider'] ?? '-'),
(string) ($fetch['trigger_source_label'] ?? $fetch['trigger_source'] ?? '-')
);
?>
<tr>
<td>
<div class="fx-history-date">
<span><?= e((string) ($fetch['fetched_at_display'] ?? $fetch['fetched_at'] ?? '')) ?></span>
<button
type="button"
class="fx-info-button"
title="<?= e($infoTitle) ?>"
aria-label="<?= e('Abrufinfo fuer ' . (string) ($fetch['fetched_at_display'] ?? $fetch['fetched_at'] ?? '')) ?>"
>i</button>
</div>
</td>
<?php foreach ($tableCurrencies as $currency): ?>
<?php $value = $tableRates[(string) $currency] ?? null; ?>
<td><?= is_numeric($value) ? e(number_format((float) $value, 8, ',', '')) : '' ?></td>
<?php endforeach; ?>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
</section>
</div>
<?= module_shell_footer() ?>

View File

@@ -0,0 +1,149 @@
<?php
declare(strict_types=1);
require_once dirname(__DIR__) . '/bootstrap.php';
$assets = app()->assets();
if ($assets) {
$assets->addStyle('/module/fx-rates/asset?file=fx-rates.css');
$assets->addScript('/module/fx-rates/asset?file=fx-rates.js', 'footer', true);
}
$settings = module_fn('fx-rates', 'settings');
$service = module_fn('fx-rates', 'service');
$preferredCurrencies = is_array($settings['preferred_currencies'] ?? null) ? $settings['preferred_currencies'] : [];
$apiDescribeUrl = APP_API_BASE . '/fx-rates/v1/endpoints';
$notice = trim((string) ($_GET['notice'] ?? ''));
$error = trim((string) ($_GET['error'] ?? ''));
if ((string) ($_GET['refresh'] ?? '') === '1') {
try {
$force = !empty($_GET['force']);
if ($force) {
$result = $service->refreshLatestRates(null, (string) ($settings['default_base_currency'] ?? ''), 'manual');
} else {
$result = $service->autoRefreshLatestRates(
(string) ($settings['default_base_currency'] ?? ''),
null,
(int) ($settings['refresh_max_age_minutes'] ?? 60),
'manual'
);
}
$params = !empty($result['reused'])
? [
'notice' => sprintf(
'Kein neuer API-Abruf. Der letzte gespeicherte Snapshot ist juenger als %d Minuten. Fuer einen erzwungenen Abruf bitte bestaetigen.',
(int) ($settings['refresh_max_age_minutes'] ?? 60)
),
]
: [
'notice' => sprintf(
'Aktuelle Kurse gespeichert. %d Werte aktualisiert.',
(int) ($result['updated_count'] ?? 0)
),
];
} catch (\Throwable $exception) {
$params = ['error' => $exception->getMessage() !== '' ? $exception->getMessage() : 'Kurse konnten nicht aktualisiert werden.'];
}
redirect('/module/fx-rates?' . http_build_query($params));
}
$latest = $service->latestStatus();
$recentFetches = $service->recentFetches(15);
$pageData = json_encode([
'settings' => $settings,
'latest' => $latest,
'preferred_currencies' => $preferredCurrencies,
'recent_fetches' => $recentFetches,
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$tabs = [
['label' => 'Ueberblick', 'href' => '/module/fx-rates', 'active' => true],
['label' => 'Waehrungen', 'href' => '/module/fx-rates/currencies'],
];
?>
<?= module_shell_header('fx-rates', [
'title' => 'Ueberblick',
'tabs' => $tabs,
'actions' => [
['label' => 'Nexus Übersicht', 'href' => '/', 'variant' => 'secondary', 'size' => 'sm'],
['label' => 'Setup', 'href' => '/modules/setup/fx-rates', 'variant' => 'secondary', 'size' => 'sm'],
['label' => 'Aktuelle Kurse abrufen', 'href' => '/module/fx-rates?refresh=1', 'variant' => 'secondary', 'size' => 'sm'],
],
]) ?>
<div id="fx-rates-app" data-page='<?= e(is_string($pageData) ? $pageData : '{}') ?>'>
<?php if ($notice !== ''): ?>
<section class="section-box">
<div class="fx-message is-success"><?= e($notice) ?></div>
</section>
<?php elseif ($error !== ''): ?>
<section class="section-box">
<div class="fx-message is-error"><?= e($error) ?></div>
</section>
<?php endif; ?>
<section class="section-box">
<div class="fx-section-head">
<div>
<h2>Umrechnung</h2>
<p>Umrechnung auf Basis des letzten verfuegbaren Kurses zwischen den bevorzugten Waehrungen.</p>
</div>
</div>
<p class="fx-api-note">
API-Self-Describe-Endpoint:
<a href="<?= e($apiDescribeUrl) ?>" target="_blank" rel="noopener noreferrer"><?= e($apiDescribeUrl) ?></a>
</p>
<div class="fx-form-grid">
<label>
<span>Quellwaehrung</span>
<select name="convert_from">
<?php foreach ($preferredCurrencies as $currency): ?>
<option value="<?= e((string) $currency) ?>"><?= e((string) $currency) ?></option>
<?php endforeach; ?>
</select>
</label>
<label>
<span>Zielwaehrung</span>
<select name="convert_to">
<?php foreach ($preferredCurrencies as $currency): ?>
<option value="<?= e((string) $currency) ?>" <?= (string) $currency === (string) ($preferredCurrencies[1] ?? $preferredCurrencies[0] ?? '') ? 'selected' : '' ?>><?= e((string) $currency) ?></option>
<?php endforeach; ?>
</select>
</label>
<label>
<span>Betrag</span>
<input type="number" name="convert_amount" min="0" step="0.00000001" value="1">
</label>
</div>
<div class="fx-convert-result" data-bind="convert-result">Noch keine Umrechnung berechnet.</div>
</section>
<section class="section-box">
<div class="fx-card-head">
<div>
<h2>Kursverlauf</h2>
<p>Neueste Abrufe zuerst. Verlauf der bevorzugten Waehrungen relativ zur Anzeige-Basiswaehrung.</p>
</div>
<div class="fx-card-meta">
<div><strong>Anzeige-Basis:</strong> <?= e((string) ($settings['display_base_currency'] ?? $settings['default_base_currency'] ?? '')) ?></div>
<div><strong>Letzter Abruf:</strong> <?= e((string) ($latest['fetched_at_display'] ?? $latest['fetched_at'] ?? 'noch keiner')) ?></div>
</div>
</div>
<div class="fx-table-wrap">
<table class="fx-table">
<thead data-bind="history-head">
<tr>
<th>Datum</th>
<th>Kurse</th>
</tr>
</thead>
<tbody data-bind="history-body">
<tr><td colspan="2">Noch keine Verlaufsdaten geladen.</td></tr>
</tbody>
</table>
</div>
</section>
</div>
<?= module_shell_footer() ?>

View File

@@ -0,0 +1,270 @@
<?php
declare(strict_types=1);
namespace Modules\FxRates\Api;
use Modules\FxRates\Domain\FxRatesService;
final class Router
{
public function __construct(
private FxRatesService $service
) {
}
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 = module_fn('fx-rates', 'settings');
$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'] : null;
$result = $force
? $this->service->refreshLatestRates(null, $base, 'api')
: $this->service->autoRefreshLatestRates($base, null, $maxAgeMinutes, '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/settings' && $method === 'GET') {
$this->respond(['data' => module_fn('fx-rates', 'settings')]);
}
if ($path === 'v1/settings' && $method === 'PUT') {
$this->respond(['data' => module_fn('fx-rates', 'save_runtime_settings', $this->input())]);
}
$this->respond(['error' => 'Unbekannter API-Pfad.'], 404);
} catch (\Throwable $exception) {
$this->respond([
'error' => 'FX-API Fehler.',
'context' => ['message' => $exception->getMessage()],
], 500);
}
}
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 endpointCatalog(): array
{
return [
'module' => 'fx-rates',
'version' => 'v1',
'languages' => ['de', 'en'],
'endpoints' => [
[
'path' => '/api/fx-rates/v1/endpoints',
'method' => 'GET',
'description_de' => 'Gibt alle verfuegbaren FX-API-Endpunkte mit deutscher und englischer Erklaerung zurueck.',
'description_en' => 'Returns all available FX API endpoints with German and English explanations.',
],
[
'path' => '/api/fx-rates/v1/latest',
'method' => 'GET',
'params' => ['base', 'symbols'],
'description_de' => 'Liefert den neuesten gespeicherten Snapshot, optional auf eine Zielbasis umgerechnet und auf ausgewaehlte Waehrungen gefiltert.',
'description_en' => 'Returns the latest stored snapshot, optionally rebased to a target currency and filtered to selected symbols.',
],
[
'path' => '/api/fx-rates/v1/fetch',
'method' => 'GET',
'params' => ['fetch_id', 'base', 'symbols'],
'description_de' => 'Liefert einen gespeicherten Snapshot anhand der fetch_id, optional umgerechnet auf eine Zielbasis und gefiltert auf einzelne Waehrungen.',
'description_en' => 'Returns a stored snapshot by fetch_id, optionally rebased to a target currency and filtered to selected symbols.',
],
[
'path' => '/api/fx-rates/v1/nearest',
'method' => 'GET',
'params' => ['at', 'base', 'symbols', 'window_minutes'],
'description_de' => 'Liefert den zeitlich naechsten gespeicherten Snapshot zu einem Datum/Uhrzeit-Wert.',
'description_en' => 'Returns the stored snapshot nearest to a given date/time value.',
],
[
'path' => '/api/fx-rates/v1/snapshot',
'method' => 'GET',
'params' => ['at', 'base', 'symbols', 'window_minutes'],
'description_de' => 'Liefert einen Snapshot zur Zielbasis und sucht fuer einen Zeitpunkt den naechsten passenden gespeicherten Kurs.',
'description_en' => 'Returns a snapshot for the requested base and finds the nearest matching stored rate for a given timestamp.',
],
[
'path' => '/api/fx-rates/v1/rate',
'method' => 'GET',
'params' => ['from', 'to', 'at', 'window_minutes'],
'description_de' => 'Liefert einen Einzelkurs zwischen zwei Waehrungen, direkt oder als Kreuzkurs aus gespeicherten Snapshots.',
'description_en' => 'Returns a single rate between two currencies, directly or as a cross-rate from stored snapshots.',
],
[
'path' => '/api/fx-rates/v1/history',
'method' => 'GET',
'params' => ['from', 'to', 'from_at', 'to_at', 'limit'],
'description_de' => 'Liefert den gespeicherten Kursverlauf zwischen zwei Waehrungen fuer einen Zeitraum.',
'description_en' => 'Returns the stored rate history between two currencies for a given time range.',
],
[
'path' => '/api/fx-rates/v1/refresh',
'method' => 'POST',
'body' => ['base', 'force', 'max_age_minutes'],
'description_de' => 'Aktualisiert Kurse nur dann neu, wenn der letzte Abruf aelter als die erlaubte Zeitspanne ist. Die Antwort enthaelt immer die fetch_id des verwendeten Snapshots.',
'description_en' => 'Refreshes rates only if the last fetch is older than the allowed age. The response always includes the fetch_id of the snapshot used.',
],
[
'path' => '/api/fx-rates/v1/status',
'method' => 'GET',
'description_de' => 'Liefert den neuesten gespeicherten Abruf je Basiswaehrung.',
'description_en' => 'Returns the most recent stored fetch per base currency.',
],
[
'path' => '/api/fx-rates/v1/recent-fetches',
'method' => 'GET',
'params' => ['limit'],
'description_de' => 'Liefert die zuletzt gespeicherten Abrufe mit fetch_id und Zeitstempel.',
'description_en' => 'Returns the most recently stored fetches including fetch_id and timestamp.',
],
[
'path' => '/api/fx-rates/v1/probe',
'method' => 'GET',
'params' => ['base'],
'description_de' => 'Prueft, ob der konfigurierte Provider aktuelle Kurse liefern kann.',
'description_en' => 'Checks whether the configured provider can return current rates.',
],
[
'path' => '/api/fx-rates/v1/settings',
'method' => 'GET',
'description_de' => 'Liefert die aktuellen Modul-Settings.',
'description_en' => 'Returns the current module settings.',
],
],
];
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,546 @@
<?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)");
} elseif ($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)
)");
} else {
$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 function (array $left, array $right): int {
return ((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' => $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);
}
}