diff --git a/modules/boersenchecker/bootstrap.php b/modules/boersenchecker/bootstrap.php
index 653ae0b..0fc7ffd 100644
--- a/modules/boersenchecker/bootstrap.php
+++ b/modules/boersenchecker/bootstrap.php
@@ -224,6 +224,13 @@ $mm->registerFunction($moduleName, 'ensure_schema', function () use ($moduleName
});
$mm->registerFunction($moduleName, 'fx_service', static function (): ?object {
+ if (modules()->isEnabled('fx-rates') && modules()->hasFunction('fx-rates', 'service')) {
+ try {
+ return module_fn('fx-rates', 'service');
+ } catch (\Throwable) {
+ }
+ }
+
if (!is_dir(dirname(__DIR__) . '/mining-checker')) {
return null;
}
@@ -256,7 +263,7 @@ $mm->registerFunction($moduleName, 'fx_refresh', static function (string $baseCu
if (!$service || !method_exists($service, 'ensureFreshLatestRates')) {
return [
'ok' => false,
- 'message' => 'Mining-Checker FX-Service ist aktuell nicht verfuegbar.',
+ 'message' => 'FX-Service ist aktuell nicht verfuegbar.',
];
}
@@ -266,7 +273,7 @@ $mm->registerFunction($moduleName, 'fx_refresh', static function (string $baseCu
'ok' => true,
'message' => !empty($result['reused'])
? 'Vorhandene FX-Daten weiterverwendet.'
- : 'FX-Daten aus dem Mining-Checker aktualisiert.',
+ : 'FX-Daten aktualisiert.',
'result' => $result,
];
} catch (\Throwable $e) {
diff --git a/modules/fx-rates/api/index.php b/modules/fx-rates/api/index.php
new file mode 100644
index 0000000..9a9f8bc
--- /dev/null
+++ b/modules/fx-rates/api/index.php
@@ -0,0 +1,7 @@
+handle($_GET['path'] ?? '');
diff --git a/modules/fx-rates/bootstrap.php b/modules/fx-rates/bootstrap.php
new file mode 100644
index 0000000..badfb0c
--- /dev/null
+++ b/modules/fx-rates/bootstrap.php
@@ -0,0 +1,121 @@
+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')));
+ $apiUrl = rtrim((string) ($saved['api_url'] ?? (getenv('FX_RATES_API_URL') ?: getenv('MINING_CHECKER_FX_URL') ?: 'https://currencyapi.net')), '/');
+ $currenciesUrl = rtrim((string) ($saved['currencies_url'] ?? (getenv('FX_RATES_CURRENCIES_URL') ?: getenv('MINING_CHECKER_FX_CURRENCIES_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)));
+ $cacheTtl = max(60, (int) ($saved['cache_ttl_sec'] ?? (getenv('FX_RATES_CACHE_TTL') ?: getenv('MINING_CHECKER_FX_CACHE_TTL') ?: 21600)));
+
+ return [
+ 'provider' => $provider !== '' ? $provider : 'currencyapi',
+ 'api_url' => $apiUrl,
+ 'currencies_url' => $currenciesUrl,
+ 'api_key' => $apiKey,
+ 'timeout_sec' => $timeout,
+ 'cache_ttl_sec' => $cacheTtl,
+ 'default_base_currency' => strtoupper(trim((string) ($saved['default_base_currency'] ?? 'EUR'))) ?: 'EUR',
+ 'daily_refresh_enabled' => array_key_exists('daily_refresh_enabled', $saved) ? (bool) $saved['daily_refresh_enabled'] : true,
+ 'daily_refresh_hour' => max(0, min(23, (int) ($saved['daily_refresh_hour'] ?? 18))),
+ 'schedule_timezone' => trim((string) ($saved['schedule_timezone'] ?? 'Europe/Berlin')) ?: 'Europe/Berlin',
+ ];
+});
+
+$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, '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, '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;
+});
diff --git a/modules/fx-rates/module.json b/modules/fx-rates/module.json
new file mode 100644
index 0000000..a6cf510
--- /dev/null
+++ b/modules/fx-rates/module.json
@@ -0,0 +1,53 @@
+{
+ "title": "Waehrungskurse",
+ "version": "0.1.0",
+ "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": "Aktuell getestet mit currencyapi." },
+ { "name": "api_url", "label": "FX API URL", "type": "text", "required": false },
+ { "name": "currencies_url", "label": "FX Currencies URL", "type": "text", "required": false },
+ { "name": "api_key", "label": "FX API Key", "type": "password", "required": false },
+ { "name": "timeout_sec", "label": "Timeout (Sek.)", "type": "number", "required": false },
+ { "name": "cache_ttl_sec", "label": "Datei-Cache TTL (Sek.)", "type": "number", "required": false },
+ { "name": "default_base_currency", "label": "Standard-Basiswaehrung", "type": "text", "required": false, "help": "Wird fuer taegliche Abrufe und Snapshot-Abfragen verwendet." },
+ { "name": "daily_refresh_enabled", "label": "Taeglichen Abruf aktivieren", "type": "checkbox", "required": false },
+ { "name": "daily_refresh_hour", "label": "Taegliche Abrufstunde", "type": "number", "required": false, "help": "Lokale Stunde fuer den Scheduler, standardmaessig 18." },
+ { "name": "schedule_timezone", "label": "Scheduler-Zeitzone", "type": "text", "required": false, "help": "z.B. Europe/Berlin" }
+ ]
+ },
+ "interval_tasks": [
+ {
+ "name": "daily_refresh",
+ "label": "Taeglicher FX-Abruf",
+ "callback": "scheduled_refresh",
+ "enabled_setting": "daily_refresh_enabled",
+ "default_enabled": true,
+ "default_interval_hours": 24,
+ "lock_minutes": 120
+ }
+ ],
+ "db_defaults": {
+ "driver": "pgsql",
+ "host": "localhost",
+ "port": 5432,
+ "dbname": "",
+ "schema": "public",
+ "user": "",
+ "password": ""
+ },
+ "auth": {
+ "required": true,
+ "users": [],
+ "groups": []
+ }
+}
diff --git a/modules/fx-rates/pages/index.php b/modules/fx-rates/pages/index.php
new file mode 100644
index 0000000..9229532
--- /dev/null
+++ b/modules/fx-rates/pages/index.php
@@ -0,0 +1,19 @@
+latestStatus();
+?>
+= module_shell_header('fx-rates', ['title' => 'Waehrungskurse']) ?>
+
+
Waehrungskurse
+
Zentrale Quelle fuer FX-Snapshots, Zeitabfragen und manuelle Aktualisierung.
+
Standard-Basis: = e((string) ($settings['default_base_currency'] ?? 'EUR')) ?>
+
Scheduler: taeglich um = e(str_pad((string) ($settings['daily_refresh_hour'] ?? 18), 2, '0', STR_PAD_LEFT)) ?>:00 (= e((string) ($settings['schedule_timezone'] ?? 'Europe/Berlin')) ?>)
+
Letzter Abruf: = e((string) ($latest['fetched_at'] ?? 'noch keiner')) ?>
+
API: /api/fx-rates/v1/latest, /api/fx-rates/v1/rate, /api/fx-rates/v1/history, /api/fx-rates/v1/refresh
+
+= module_shell_footer() ?>
diff --git a/modules/fx-rates/src/Api/Router.php b/modules/fx-rates/src/Api/Router.php
new file mode 100644
index 0000000..f81c798
--- /dev/null
+++ b/modules/fx-rates/src/Api/Router.php
@@ -0,0 +1,145 @@
+respond(['ok' => true, 'module' => 'fx-rates']);
+ }
+
+ if ($path === 'v1/status' && $method === 'GET') {
+ $this->respond(['data' => $this->service->latestStatuses()]);
+ }
+
+ if ($path === 'v1/latest' && $method === 'GET') {
+ $symbols = $this->parseCsv($_GET['symbols'] ?? null);
+ $base = $this->stringOrNull($_GET['base'] ?? null);
+ $snapshot = $this->service->snapshot($base, null, $symbols, null);
+ $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);
+ $currencies = $this->parseCsv($input['currencies'] ?? null);
+ $force = !empty($input['force']);
+ $maxAgeHours = is_numeric($input['max_age_hours'] ?? null) ? (float) $input['max_age_hours'] : 24.0;
+
+ $result = $force
+ ? $this->service->refreshLatestRates($currencies, $base)
+ : $this->service->ensureFreshLatestRates($maxAgeHours, $base, $currencies);
+
+ $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/currencies-probe' && $method === 'GET') {
+ $this->respond(['data' => $this->service->probeCurrencyCatalog()]);
+ }
+
+ $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;
+ }
+}
diff --git a/modules/fx-rates/src/Domain/FxRatesService.php b/modules/fx-rates/src/Domain/FxRatesService.php
new file mode 100644
index 0000000..c57e727
--- /dev/null
+++ b/modules/fx-rates/src/Domain/FxRatesService.php
@@ -0,0 +1,576 @@
+repository->getLatestFetch($this->defaultBaseCurrency());
+ }
+
+ public function latestStatuses(): array
+ {
+ return $this->repository->listLatestFetches();
+ }
+
+ 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($base);
+ if ($latest === null) {
+ return null;
+ }
+
+ return $this->repository->getSnapshotByFetchId((int) $latest['id'], $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;
+ }
+
+ return $snapshot + [
+ 'requested_at' => $atUtc,
+ '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 [
+ '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] = $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): array
+ {
+ $base = $this->normalizeCurrency($baseCurrency ?: $this->defaultBaseCurrency());
+ $payload = $this->fetchLatestPayload($base, $currencies);
+ $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')
+ );
+
+ return [
+ 'base' => $base,
+ 'rate_date' => $rateDate,
+ 'updated_count' => count($saved['rates'] ?? []),
+ 'rates' => $saved['rates'] ?? [],
+ 'fetch' => $saved['fetch'] ?? null,
+ ];
+ }
+
+ public function ensureFreshLatestRates(float $maxAgeHours = 24.0, ?string $baseCurrency = null, ?array $currencies = null): array
+ {
+ $base = $this->normalizeCurrency($baseCurrency ?: $this->defaultBaseCurrency());
+ $latest = $this->repository->getLatestFetch($base);
+ $maxAgeSeconds = (int) round(max(1.0, $maxAgeHours) * 3600);
+ $fetchedAt = is_array($latest) ? strtotime((string) ($latest['fetched_at'] ?? '')) : false;
+
+ if ($fetchedAt !== false && (time() - $fetchedAt) <= $maxAgeSeconds) {
+ return [
+ 'base' => $base,
+ 'rate_date' => $latest['rate_date'] ?? null,
+ 'updated_count' => 0,
+ 'rates' => [],
+ 'fetch' => $latest,
+ 'reused' => true,
+ ];
+ }
+
+ $result = $this->refreshLatestRates($currencies, $base);
+ $result['reused'] = false;
+ return $result;
+ }
+
+ 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 [];
+ }
+
+ $direct = $this->repository->listDirectHistory($fromCurrency, $toCurrency, $this->normalizeTimestamp($from), $this->normalizeTimestamp($to), $limit);
+ if ($direct !== []) {
+ return $direct;
+ }
+
+ $inverse = $this->repository->listDirectHistory($toCurrency, $fromCurrency, $this->normalizeTimestamp($from), $this->normalizeTimestamp($to), $limit);
+ if ($inverse === []) {
+ return [];
+ }
+
+ $result = [];
+ foreach ($inverse as $row) {
+ $inverseRate = is_numeric($row['rate'] ?? null) ? (float) $row['rate'] : null;
+ if ($inverseRate === null || $inverseRate <= 0) {
+ continue;
+ }
+
+ $result[] = [
+ 'fetch_id' => $row['fetch_id'] ?? null,
+ 'base_currency' => $fromCurrency,
+ 'target_currency' => $toCurrency,
+ 'rate' => 1 / $inverseRate,
+ 'rate_date' => $row['rate_date'] ?? null,
+ 'provider' => $row['provider'] ?? null,
+ 'fetched_at' => $row['fetched_at'] ?? null,
+ 'is_exact_pair' => false,
+ ];
+ }
+
+ return $result;
+ }
+
+ public function runScheduledRefresh(array $context = []): array
+ {
+ if (!$this->dailyRefreshEnabled()) {
+ return ['ok' => true, 'message' => 'Taeglicher FX-Abruf ist deaktiviert.', 'skipped' => true];
+ }
+
+ $timezone = $this->scheduleTimezone();
+ $nowLocal = new DateTimeImmutable('now', $timezone);
+ $targetHour = $this->dailyRefreshHour();
+
+ if ((int) $nowLocal->format('G') !== $targetHour) {
+ return [
+ 'ok' => true,
+ 'message' => 'Kein FX-Abruf: aktuelles Zeitfenster ist ' . $nowLocal->format('H:i') . ', Ziel ist ' . str_pad((string) $targetHour, 2, '0', STR_PAD_LEFT) . ':00.',
+ 'skipped' => true,
+ 'context' => $context,
+ ];
+ }
+
+ $latest = $this->repository->getLatestFetch($this->defaultBaseCurrency());
+ if (is_array($latest) && trim((string) ($latest['fetched_at'] ?? '')) !== '') {
+ $latestLocal = new DateTimeImmutable((string) $latest['fetched_at'], new DateTimeZone('UTC'));
+ $latestLocal = $latestLocal->setTimezone($timezone);
+ if ($latestLocal->format('Y-m-d') === $nowLocal->format('Y-m-d') && (int) $latestLocal->format('G') >= $targetHour) {
+ return [
+ 'ok' => true,
+ 'message' => 'Kein FX-Abruf: fuer heute existiert bereits ein Snapshot nach ' . str_pad((string) $targetHour, 2, '0', STR_PAD_LEFT) . ':00.',
+ 'skipped' => true,
+ 'fetch' => $latest,
+ 'context' => $context,
+ ];
+ }
+ }
+
+ $result = $this->refreshLatestRates(null, $this->defaultBaseCurrency());
+ return [
+ 'ok' => true,
+ 'message' => 'Taeglicher FX-Abruf ausgefuehrt: ' . (int) ($result['updated_count'] ?? 0) . ' Kurse gespeichert.',
+ 'result' => $result,
+ 'context' => $context,
+ ];
+ }
+
+ 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
+ {
+ if (!function_exists('curl_init')) {
+ throw new \RuntimeException('curl_init ist nicht verfuegbar.');
+ }
+
+ $url = $this->buildLatestUrl($baseCurrency);
+ if ($url === null) {
+ throw new \RuntimeException('FX-URL oder API-Key fehlt.');
+ }
+
+ $ch = curl_init();
+ curl_setopt_array($ch, [
+ CURLOPT_URL => $url,
+ CURLOPT_RETURNTRANSFER => true,
+ CURLOPT_FOLLOWLOCATION => true,
+ CURLOPT_TIMEOUT => $this->timeoutSeconds(),
+ CURLOPT_HTTPHEADER => ['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) {
+ throw new \RuntimeException('FX-Kurse konnten nicht geladen werden.');
+ }
+
+ $payload = json_decode((string) $response, true);
+ if (!is_array($payload)) {
+ throw new \RuntimeException('FX-Antwort ist kein gueltiges JSON.');
+ }
+
+ $rates = [];
+ if ($this->provider() === 'currencyapi') {
+ 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 === $baseCurrency || !is_numeric($rate)) {
+ continue;
+ }
+ $rates[$code] = (float) $rate;
+ }
+ } else {
+ $rawRates = is_array($payload['rates'] ?? null) ? $payload['rates'] : [];
+ foreach ($rawRates as $code => $rate) {
+ $code = $this->normalizeCurrency((string) $code);
+ if ($code === '' || $code === $baseCurrency || !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' => $baseCurrency,
+ 'date' => $payload['updated'] ?? $payload['date'] ?? null,
+ 'rates' => $rates,
+ ];
+ }
+
+ private function fetchCurrenciesPayload(): array
+ {
+ if (!function_exists('curl_init')) {
+ throw new \RuntimeException('curl_init ist nicht verfuegbar.');
+ }
+
+ $apiKey = $this->apiKey();
+ if ($apiKey === '') {
+ throw new \RuntimeException('FX-API-Key fehlt.');
+ }
+
+ $url = sprintf(
+ '%s/api/v2/currencies?output=json&key=%s',
+ $this->currenciesApiUrl(),
+ rawurlencode($apiKey)
+ );
+
+ $ch = curl_init();
+ curl_setopt_array($ch, [
+ CURLOPT_URL => $url,
+ CURLOPT_RETURNTRANSFER => true,
+ CURLOPT_FOLLOWLOCATION => true,
+ CURLOPT_TIMEOUT => $this->timeoutSeconds(),
+ CURLOPT_HTTPHEADER => ['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) {
+ throw new \RuntimeException('Waehrungskatalog konnte nicht geladen werden.');
+ }
+
+ $payload = json_decode((string) $response, true);
+ if (!is_array($payload) || ($payload['valid'] ?? false) !== true) {
+ throw new \RuntimeException($this->extractProviderError(is_array($payload) ? $payload : [], 'Waehrungskatalog konnte nicht geladen werden.'));
+ }
+
+ return $payload;
+ }
+
+ private function buildLatestUrl(string $baseCurrency): ?string
+ {
+ $apiKey = $this->apiKey();
+ if ($this->provider() === 'currencyapi') {
+ if ($apiKey === '') {
+ return null;
+ }
+
+ return sprintf(
+ '%s/api/v2/rates?base=%s&output=json&key=%s',
+ $this->apiUrl(),
+ rawurlencode($baseCurrency),
+ rawurlencode($apiKey)
+ );
+ }
+
+ return sprintf('%s/latest?base=%s', $this->apiUrl(), rawurlencode($baseCurrency));
+ }
+
+ private function extractProviderError(array $payload, string $fallback): string
+ {
+ 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 normalizeTimestamp(?string $value): ?string
+ {
+ $value = trim((string) $value);
+ if ($value === '') {
+ return null;
+ }
+
+ try {
+ $date = new DateTimeImmutable($value);
+ return $date->setTimezone(new DateTimeZone('UTC'))->format('Y-m-d H:i:s');
+ } 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 apiKey(): string
+ {
+ return trim((string) ($this->settings['api_key'] ?? ''));
+ }
+
+ private function timeoutSeconds(): int
+ {
+ return max(2, (int) ($this->settings['timeout_sec'] ?? 10));
+ }
+
+ private function defaultBaseCurrency(): string
+ {
+ return $this->normalizeCurrency((string) ($this->settings['default_base_currency'] ?? 'EUR')) ?: 'EUR';
+ }
+
+ private function dailyRefreshEnabled(): bool
+ {
+ return !empty($this->settings['daily_refresh_enabled']);
+ }
+
+ private function dailyRefreshHour(): int
+ {
+ return max(0, min(23, (int) ($this->settings['daily_refresh_hour'] ?? 18)));
+ }
+
+ private function scheduleTimezone(): DateTimeZone
+ {
+ $timezone = trim((string) ($this->settings['schedule_timezone'] ?? 'Europe/Berlin'));
+ try {
+ return new DateTimeZone($timezone);
+ } catch (\Throwable) {
+ return new DateTimeZone('Europe/Berlin');
+ }
+ }
+}
diff --git a/modules/fx-rates/src/Infrastructure/FxRatesRepository.php b/modules/fx-rates/src/Infrastructure/FxRatesRepository.php
new file mode 100644
index 0000000..715b981
--- /dev/null
+++ b/modules/fx-rates/src/Infrastructure/FxRatesRepository.php
@@ -0,0 +1,418 @@
+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,
+ 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("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,
+ 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->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,
+ 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->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, 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, 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 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 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 ASC, r.id ASC 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): 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');
+ $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, base_currency, rate_date, fetched_at
+ ) VALUES (
+ :provider, :base_currency, :rate_date, :fetched_at
+ )
+ RETURNING *'
+ );
+ $fetchStmt->execute([
+ 'provider' => $provider,
+ '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, base_currency, rate_date, fetched_at
+ ) VALUES (
+ :provider, :base_currency, :rate_date, :fetched_at
+ )'
+ );
+ $fetchStmt->execute([
+ 'provider' => $provider,
+ '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;
+ }
+ }
+
+ private function getFetchById(int $fetchId): ?array
+ {
+ $stmt = $this->pdo->prepare(
+ 'SELECT id, provider, 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, 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'] ?? ''),
+ '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 normalizeRate(array $row): array
+ {
+ return [
+ 'id' => isset($row['id']) ? (int) $row['id'] : null,
+ 'fetch_id' => isset($row['fetch_id']) ? (int) $row['fetch_id'] : null,
+ 'base_currency' => strtoupper((string) ($row['base_currency'] ?? '')),
+ 'target_currency' => strtoupper((string) ($row['target_currency'] ?? '')),
+ 'rate' => is_numeric($row['rate'] ?? null) ? (float) $row['rate'] : null,
+ 'rate_date' => (string) ($row['rate_date'] ?? ''),
+ 'provider' => (string) ($row['provider'] ?? ''),
+ 'fetched_at' => (string) ($row['fetched_at'] ?? ''),
+ ];
+ }
+
+ private function table(string $logicalName): string
+ {
+ return $this->tablePrefix . preg_replace('/[^a-zA-Z0-9_]/', '', $logicalName);
+ }
+}
diff --git a/modules/mining-checker/src/Domain/FxService.php b/modules/mining-checker/src/Domain/FxService.php
index 8a8ef65..761bb09 100644
--- a/modules/mining-checker/src/Domain/FxService.php
+++ b/modules/mining-checker/src/Domain/FxService.php
@@ -44,6 +44,12 @@ final class FxService
public function convert(?float $amount, ?string $from, ?string $to): ?float
{
+ $shared = $this->sharedFxService();
+ if ($shared !== null && method_exists($shared, 'convert')) {
+ $converted = $shared->convert($amount, $from, $to, null, null);
+ return is_numeric($converted) ? (float) $converted : null;
+ }
+
if ($amount === null || $from === null || $to === null) {
return null;
}
@@ -54,6 +60,12 @@ final class FxService
public function rate(?string $from, ?string $to): ?float
{
+ $shared = $this->sharedFxService();
+ if ($shared !== null && method_exists($shared, 'findRate')) {
+ $resolved = $shared->findRate($from, $to, null, null);
+ return is_array($resolved) && is_numeric($resolved['rate'] ?? null) ? (float) $resolved['rate'] : null;
+ }
+
$base = strtoupper(trim((string) $from));
$target = strtoupper(trim((string) $to));
@@ -97,6 +109,11 @@ final class FxService
public function refreshLatestRates(?array $currencies = null, string $base = 'EUR'): array
{
+ $shared = $this->sharedFxService();
+ if ($shared !== null && method_exists($shared, 'refreshLatestRates')) {
+ return $shared->refreshLatestRates($currencies, $base);
+ }
+
$normalizedBase = strtoupper(trim($base));
$targets = $currencies === null
? null
@@ -132,6 +149,11 @@ final class FxService
public function ensureFreshLatestRates(float $maxAgeHours = 3.0, string $base = 'USD'): array
{
+ $shared = $this->sharedFxService();
+ if ($shared !== null && method_exists($shared, 'ensureFreshLatestRates')) {
+ return $shared->ensureFreshLatestRates($maxAgeHours, $base, null);
+ }
+
$normalizedBase = strtoupper(trim($base));
$maxAgeHours = $maxAgeHours > 0 ? $maxAgeHours : 3.0;
@@ -176,12 +198,22 @@ final class FxService
public function probeLatestRates(string $base = 'EUR'): array
{
+ $shared = $this->sharedFxService();
+ if ($shared !== null && method_exists($shared, 'probeLatestRates')) {
+ return $shared->probeLatestRates($base);
+ }
+
$normalizedBase = strtoupper(trim($base));
return $this->fetchLatestProbe($normalizedBase);
}
public function refreshCurrencyCatalog(): array
{
+ $shared = $this->sharedFxService();
+ if ($shared !== null && method_exists($shared, 'refreshCurrencyCatalog')) {
+ return $shared->refreshCurrencyCatalog();
+ }
+
if ($this->repository === null) {
return [
'synced_count' => 0,
@@ -235,6 +267,11 @@ final class FxService
public function probeCurrencyCatalog(): array
{
+ $shared = $this->sharedFxService();
+ if ($shared !== null && method_exists($shared, 'probeCurrencyCatalog')) {
+ return $shared->probeCurrencyCatalog();
+ }
+
return $this->fetchCurrenciesProbe();
}
@@ -756,4 +793,18 @@ final class FxService
return $matches[1] . substr($key, 0, 6) . '...' . substr($key, -4);
}, $url) ?: $url;
}
+
+ private function sharedFxService(): ?object
+ {
+ if (!function_exists('modules') || !modules()->isEnabled('fx-rates') || !modules()->hasFunction('fx-rates', 'service')) {
+ return null;
+ }
+
+ try {
+ $service = module_fn('fx-rates', 'service');
+ return is_object($service) ? $service : null;
+ } catch (\Throwable) {
+ return null;
+ }
+ }
}
diff --git a/public/index.php b/public/index.php
index 56e8602..db98d62 100755
--- a/public/index.php
+++ b/public/index.php
@@ -160,6 +160,35 @@ if (preg_match('~^api/mining-checker(?:/(.*))?$~', $uriPath, $apiMatches)) {
}
}
+if (preg_match('~^api/fx-rates(?:/(.*))?$~', $uriPath, $apiMatches)) {
+ $moduleMeta = app()->modules()->get('fx-rates') ?? ['auth' => ['required' => false]];
+ if (!$auth->canAccessModule($moduleMeta)) {
+ http_response_code($auth->isAuthenticated() ? 403 : 401);
+ header('Content-Type: application/json; charset=utf-8');
+ echo json_encode([
+ 'error' => $auth->isAuthenticated() ? 'forbidden' : 'auth_required',
+ 'login_url' => '/auth/login',
+ ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
+ exit;
+ }
+
+ require_once $projectRoot . '/modules/fx-rates/bootstrap.php';
+ app()->modules()->runDueIntervalTasks('fx-rates');
+
+ try {
+ $service = module_fn('fx-rates', 'service');
+ (new \Modules\FxRates\Api\Router($service))->handle($apiMatches[1] ?? '');
+ } catch (Throwable $exception) {
+ http_response_code(500);
+ header('Content-Type: application/json; charset=utf-8');
+ echo json_encode([
+ 'error' => 'Unerwarteter FX-Module Fehler.',
+ 'context' => ['message' => $exception->getMessage()],
+ ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
+ exit;
+ }
+}
+
if (preg_match('~^module-assets/([a-zA-Z0-9_-]+)/(.*)$~', $uriPath, $assetMatches)) {
$module = $assetMatches[1];
$relativeAssetPath = trim($assetMatches[2], '/');