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(); +?> + 'Waehrungskurse']) ?> +
+

Waehrungskurse

+

Zentrale Quelle fuer FX-Snapshots, Zeitabfragen und manuelle Aktualisierung.

+

Standard-Basis:

+

Scheduler: taeglich um :00 ()

+

Letzter Abruf:

+

API: /api/fx-rates/v1/latest, /api/fx-rates/v1/rate, /api/fx-rates/v1/history, /api/fx-rates/v1/refresh

+
+ 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], '/');