localizeFetch($this->repository->getLatestFetch(null)); } public function latestStatuses(): array { return array_map(fn (array $fetch): array => $this->localizeFetch($fetch), $this->repository->listLatestFetches()); } public function recentFetches(int $limit = 20): array { return array_map(fn (array $fetch): array => $this->localizeFetch($fetch), $this->repository->listRecentFetches($limit)); } public function snapshot(?string $baseCurrency = null, ?string $at = null, ?array $symbols = null, ?int $windowMinutes = null): ?array { $base = $this->normalizeCurrency($baseCurrency ?: $this->defaultBaseCurrency()); if ($base === '') { return null; } if ($at === null || trim($at) === '') { $latest = $this->repository->getLatestFetch(null); if ($latest === null) { return null; } $snapshot = $this->repository->getSnapshotByFetchId((int) $latest['id'], null); if ($snapshot === null) { return null; } return $this->localizeSnapshot($this->rebaseSnapshot($snapshot, $base, $symbols)); } $atUtc = $this->normalizeTimestamp($at); if ($atUtc === null) { return null; } $nearest = $this->repository->getNearestFetch($base, $atUtc, $windowMinutes); if ($nearest === null) { return null; } $snapshot = $this->repository->getSnapshotByFetchId((int) $nearest['id'], $symbols); if ($snapshot === null) { return null; } $rebased = $this->rebaseSnapshot($snapshot, $base, $symbols); if ($rebased === null) { return null; } return $this->localizeSnapshot($rebased + [ 'requested_at' => $atUtc, 'distance_seconds' => $nearest['distance_seconds'] ?? null, ]); } public function snapshotByFetchId(int $fetchId, ?string $baseCurrency = null, ?array $symbols = null): ?array { if ($fetchId <= 0) { return null; } $requestedBase = $this->normalizeCurrency($baseCurrency ?: $this->defaultBaseCurrency()); if ($requestedBase === '') { return null; } $snapshot = $this->repository->getSnapshotByFetchId($fetchId, null); if ($snapshot === null) { return null; } return $this->localizeSnapshot($this->rebaseSnapshot($snapshot, $requestedBase, $symbols)); } public function nearestSnapshot(?string $baseCurrency = null, string $at = '', ?array $symbols = null, ?int $windowMinutes = null): ?array { $timestamp = $this->normalizeTimestamp($at); if ($timestamp === null) { return null; } $requestedBase = $this->normalizeCurrency($baseCurrency ?: $this->defaultBaseCurrency()); if ($requestedBase === '') { return null; } $nearest = $this->repository->findNearestFetch(null, $timestamp, $windowMinutes); if ($nearest === null) { return null; } $snapshot = $this->repository->getSnapshotByFetchId((int) ($nearest['id'] ?? 0), null); if ($snapshot === null) { return null; } $rebased = $this->rebaseSnapshot($snapshot, $requestedBase, $symbols); if ($rebased === null) { return null; } return $this->localizeSnapshot($rebased + [ 'requested_at' => $timestamp, 'distance_seconds' => $nearest['distance_seconds'] ?? null, ]); } public function findRate(?string $fromCurrency, ?string $toCurrency, ?string $at = null, ?int $windowMinutes = null): ?array { $from = $this->normalizeCurrency($fromCurrency); $to = $this->normalizeCurrency($toCurrency); if ($from === '' || $to === '') { return null; } if ($from === $to) { return $this->localizeRateResult([ 'base_currency' => $from, 'target_currency' => $to, 'rate' => 1.0, 'provider' => 'identity', 'fetched_at' => $at ? $this->normalizeTimestamp($at) : null, 'rate_date' => $at ? substr((string) $this->normalizeTimestamp($at), 0, 10) : gmdate('Y-m-d'), 'is_exact_pair' => true, ]); } $cacheKey = implode(':', [$from, $to, $at ?? '', (string) ($windowMinutes ?? 0)]); if (array_key_exists($cacheKey, $this->memoryCache)) { return $this->memoryCache[$cacheKey]; } $candidates = array_values(array_unique(array_filter([ $this->defaultBaseCurrency(), 'EUR', 'USD', $from, $to, ], static fn (?string $value): bool => is_string($value) && trim($value) !== ''))); foreach ($candidates as $snapshotBase) { $snapshot = $this->snapshot($snapshotBase, $at, [$from, $to], $windowMinutes); if (!is_array($snapshot)) { continue; } $rates = is_array($snapshot['rates'] ?? null) ? $snapshot['rates'] : []; $resolved = $this->resolveRateFromSnapshot($snapshot, $rates, $from, $to); if ($resolved !== null) { return $this->memoryCache[$cacheKey] = $this->localizeRateResult($resolved); } } return $this->memoryCache[$cacheKey] = null; } public function convert(?float $amount, ?string $fromCurrency, ?string $toCurrency, ?string $at = null, ?int $windowMinutes = null): ?float { if ($amount === null) { return null; } $rate = $this->findRate($fromCurrency, $toCurrency, $at, $windowMinutes); if (!is_numeric($rate['rate'] ?? null)) { return null; } return $amount * (float) $rate['rate']; } public function refreshLatestRates(?array $currencies = null, ?string $baseCurrency = null, string $triggerSource = 'manual'): array { $requestedBase = $this->normalizeCurrency($baseCurrency ?: $this->defaultBaseCurrency()); $payload = $this->fetchLatestPayload($requestedBase, null); $base = $this->normalizeCurrency((string) ($payload['base'] ?? $requestedBase)); if ($base === '') { $base = $requestedBase !== '' ? $requestedBase : 'USD'; } $rates = is_array($payload['rates'] ?? null) ? $payload['rates'] : []; $rateDate = $this->normalizeRateDate($payload['date'] ?? null); $saved = $this->repository->saveFetch( $base, $this->provider(), $rateDate, $rates, gmdate('Y-m-d H:i:s'), $triggerSource ); return [ 'base' => $base, 'requested_base' => $requestedBase, 'rate_date' => $rateDate, 'updated_count' => count($saved['rates'] ?? []), 'rates' => $saved['rates'] ?? [], 'fetch_id' => isset($saved['fetch']['id']) ? (int) $saved['fetch']['id'] : null, 'fetch' => $this->localizeFetch(is_array($saved['fetch'] ?? null) ? $saved['fetch'] : null), ]; } public function ensureFreshLatestRates(float $maxAgeHours = 24.0, ?string $baseCurrency = null, ?array $currencies = null, string $triggerSource = 'manual'): array { $base = $this->normalizeCurrency($baseCurrency ?: $this->defaultBaseCurrency()); $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_id' => isset($latest['id']) ? (int) $latest['id'] : null, 'fetch' => $this->localizeFetch($latest), 'reused' => true, ]; } $result = $this->refreshLatestRates($currencies, $base, $triggerSource); $result['reused'] = false; return $result; } public function autoRefreshLatestRates(?string $baseCurrency = null, ?array $currencies = null, ?int $maxAgeMinutes = null, string $triggerSource = 'api'): array { $minutes = $maxAgeMinutes ?? $this->refreshMaxAgeMinutes(); $hours = max(1, $minutes) / 60; return $this->ensureFreshLatestRates($hours, $baseCurrency, $currencies, $triggerSource); } public function history(string $fromCurrency, string $toCurrency, ?string $from = null, ?string $to = null, int $limit = 200): array { $fromCurrency = $this->normalizeCurrency($fromCurrency); $toCurrency = $this->normalizeCurrency($toCurrency); if ($fromCurrency === '' || $toCurrency === '') { return []; } if ($fromCurrency === $toCurrency) { return []; } $direct = $this->repository->listDirectHistory($fromCurrency, $toCurrency, $this->normalizeTimestamp($from), $this->normalizeTimestamp($to), $limit); if ($direct !== []) { return array_map(fn (array $row): array => $this->localizeRateResult($row), $direct); } $inverse = $this->repository->listDirectHistory($toCurrency, $fromCurrency, $this->normalizeTimestamp($from), $this->normalizeTimestamp($to), $limit); if ($inverse !== []) { $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, ]; } if ($result !== []) { return array_map(fn (array $row): array => $this->localizeRateResult($row), $result); } } return $this->crossHistory($fromCurrency, $toCurrency, $from, $to, $limit); } public function runScheduledRefresh(array $context = []): array { $triggerSource = ($context['trigger'] ?? null) === 'manual_test' ? 'manual' : 'cron'; $result = $this->refreshLatestRates(null, $this->defaultBaseCurrency(), $triggerSource); return [ 'ok' => true, 'message' => 'Geplanter 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 { $request = $this->buildLatestRequest($baseCurrency, $currencies); if ($request === null) { throw new \RuntimeException('FX-URL oder API-Key fehlt.'); } $payload = $this->requestJson($request['url'], $request['headers'] ?? [], 'FX-Kurse konnten nicht geladen werden.'); if ($this->usesApiVersion('v3')) { return $this->normalizeCurrencyApiComLatestPayload($payload, $baseCurrency, $currencies); } $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 { $request = $this->buildCurrenciesRequest(); if ($request === null) { throw new \RuntimeException('FX-API-Key fehlt.'); } $payload = $this->requestJson($request['url'], $request['headers'] ?? [], 'Waehrungskatalog konnte nicht geladen werden.'); if ($this->usesApiVersion('v3')) { return $this->normalizeCurrencyApiComCurrenciesPayload($payload); } if (($payload['valid'] ?? false) !== true || !is_array($payload['currencies'] ?? null)) { throw new \RuntimeException($this->extractProviderError($payload, 'Waehrungskatalog konnte nicht geladen werden.')); } return $payload; } private function buildLatestRequest(string $baseCurrency, ?array $currencies = null): ?array { $apiKey = $this->apiKey(); if ($this->usesApiVersion('v3')) { if ($apiKey === '') { return null; } $query = []; $normalizedCurrencies = []; foreach ($currencies ?? [] as $currency) { $currency = $this->normalizeCurrency((string) $currency); if ($currency !== '' && $currency !== $baseCurrency) { $normalizedCurrencies[] = $currency; } } if ($normalizedCurrencies !== []) { $query[] = 'currencies=' . rawurlencode(implode(',', array_values(array_unique($normalizedCurrencies)))); } return [ 'url' => $this->apiUrl() . '/v3/latest?' . implode('&', $query), 'headers' => [ 'Accept: application/json', 'apikey: ' . $apiKey, ], ]; } if ($this->provider() === 'currencyapi') { if ($apiKey === '') { return null; } return [ 'url' => sprintf( '%s/api/v2/rates?output=json&key=%s', $this->apiUrl(), rawurlencode($apiKey) ), 'headers' => ['Accept: application/json'], ]; } return [ 'url' => sprintf('%s/latest?base=%s', $this->apiUrl(), rawurlencode($baseCurrency)), 'headers' => ['Accept: application/json'], ]; } private function buildCurrenciesRequest(): ?array { $apiKey = $this->apiKey(); if ($apiKey === '') { return null; } if ($this->usesApiVersion('v3')) { return [ 'url' => $this->currenciesApiUrl() . '/v3/currencies', 'headers' => [ 'Accept: application/json', 'apikey: ' . $apiKey, ], ]; } return [ 'url' => sprintf( '%s/api/v2/currencies?output=json&key=%s', $this->currenciesApiUrl(), rawurlencode($apiKey) ), 'headers' => ['Accept: application/json'], ]; } private function requestJson(string $url, array $headers, string $fallbackError): array { if (!function_exists('curl_init')) { throw new \RuntimeException('curl_init ist nicht verfuegbar.'); } $ch = curl_init(); curl_setopt_array($ch, [ CURLOPT_URL => $url, CURLOPT_RETURNTRANSFER => true, CURLOPT_FOLLOWLOCATION => true, CURLOPT_TIMEOUT => $this->timeoutSeconds(), CURLOPT_HTTPHEADER => $headers !== [] ? $headers : ['Accept: application/json'], ]); $response = curl_exec($ch); $httpStatus = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE); $curlError = curl_error($ch); curl_close($ch); if ($response === false || $curlError !== '' || $httpStatus >= 400) { $payload = is_string($response) ? json_decode($response, true) : null; throw new \RuntimeException($this->extractProviderError(is_array($payload) ? $payload : [], $fallbackError)); } $payload = json_decode((string) $response, true); if (!is_array($payload)) { throw new \RuntimeException('FX-Antwort ist kein gueltiges JSON.'); } return $payload; } private function normalizeCurrencyApiComLatestPayload(array $payload, string $baseCurrency, ?array $currencies = null): array { $rawRates = is_array($payload['data'] ?? null) ? $payload['data'] : null; if ($rawRates === null) { throw new \RuntimeException($this->extractProviderError($payload, 'FX-Kurse konnten nicht geladen werden.')); } $resolvedBase = $this->normalizeCurrency((string) ($payload['meta']['base_currency_code'] ?? $payload['base'] ?? $baseCurrency)); if ($resolvedBase === '') { $resolvedBase = $baseCurrency; } $filter = []; foreach ($currencies ?? [] as $currency) { $currency = $this->normalizeCurrency((string) $currency); if ($currency !== '' && $currency !== $resolvedBase) { $filter[$currency] = true; } } $rates = []; foreach ($rawRates as $code => $rateData) { $code = $this->normalizeCurrency((string) $code); if ($code === '' || $code === $resolvedBase) { continue; } if ($filter !== [] && !isset($filter[$code])) { continue; } $value = is_array($rateData) ? ($rateData['value'] ?? null) : null; if (!is_numeric($value)) { continue; } $rates[$code] = (float) $value; } return [ 'base' => $resolvedBase, 'date' => $payload['meta']['last_updated_at'] ?? null, 'rates' => $rates, ]; } private function rebaseSnapshot(array $snapshot, string $requestedBase, ?array $symbols = null): ?array { $snapshotBase = $this->normalizeCurrency((string) ($snapshot['base_currency'] ?? '')); $rates = is_array($snapshot['rates'] ?? null) ? $snapshot['rates'] : []; if ($snapshotBase === '') { return null; } if ($requestedBase === '' || $requestedBase === $snapshotBase) { $filteredRates = $this->filterRates($rates, $symbols); if ($this->symbolsContain($symbols, $requestedBase)) { $filteredRates = [$requestedBase => 1.0] + $filteredRates; } return $snapshot + [ 'base_currency' => $snapshotBase, 'rates' => $filteredRates, ]; } $baseRate = $rates[$requestedBase] ?? null; if (!is_numeric($baseRate) || (float) $baseRate <= 0) { return null; } $rebasedRates = []; foreach ($rates as $code => $rate) { $code = $this->normalizeCurrency((string) $code); if ($code === '' || $code === $requestedBase || !is_numeric($rate)) { continue; } $rebasedRates[$code] = (float) $rate / (float) $baseRate; } $filteredRates = $this->filterRates($rebasedRates, $symbols); if ($this->symbolsContain($symbols, $requestedBase)) { $filteredRates = [$requestedBase => 1.0] + $filteredRates; } return $snapshot + [ 'base_currency' => $requestedBase, 'rates' => $filteredRates, 'snapshot_base_currency' => $snapshotBase, ]; } private function filterRates(array $rates, ?array $symbols = null): array { if (!is_array($symbols) || $symbols === []) { ksort($rates); return $rates; } $filtered = []; foreach ($symbols as $symbol) { $symbol = $this->normalizeCurrency((string) $symbol); if ($symbol !== '' && isset($rates[$symbol])) { $filtered[$symbol] = (float) $rates[$symbol]; } } ksort($filtered); return $filtered; } private function symbolsContain(?array $symbols, string $currency): bool { if (!is_array($symbols) || $symbols === []) { return false; } foreach ($symbols as $symbol) { if ($this->normalizeCurrency((string) $symbol) === $currency) { return true; } } return false; } private function crossHistory(string $fromCurrency, string $toCurrency, ?string $from = null, ?string $to = null, int $limit = 200): array { $fromAt = $this->normalizeTimestamp($from); $toAt = $this->normalizeTimestamp($to); $candidates = array_reverse($this->repository->listRecentFetches(max($limit * 4, 50))); $result = []; foreach ($candidates as $fetch) { $fetchedAt = (string) ($fetch['fetched_at'] ?? ''); if ($fetchedAt === '') { continue; } if ($fromAt !== null && strcmp($fetchedAt, $fromAt) < 0) { continue; } if ($toAt !== null && strcmp($fetchedAt, $toAt) > 0) { continue; } $snapshot = $this->repository->getSnapshotByFetchId((int) ($fetch['id'] ?? 0), [$fromCurrency, $toCurrency]); if ($snapshot === null) { continue; } $rates = is_array($snapshot['rates'] ?? null) ? $snapshot['rates'] : []; $resolved = $this->resolveRateFromSnapshot($snapshot, $rates, $fromCurrency, $toCurrency); if ($resolved === null) { continue; } $result[] = $this->localizeRateResult($resolved + [ 'fetch_id' => $fetch['id'] ?? null, ]); if (count($result) >= $limit) { break; } } return $result; } private function localizeFetch(?array $fetch): ?array { if (!is_array($fetch)) { return null; } $fetch['fetched_at_display'] = $this->formatDisplayTimestamp($fetch['fetched_at'] ?? null); $fetch['created_at_display'] = $this->formatDisplayTimestamp($fetch['created_at'] ?? null); $fetch['trigger_source_label'] = $this->triggerSourceLabel((string) ($fetch['trigger_source'] ?? 'manual')); return $fetch; } private function localizeSnapshot(?array $snapshot): ?array { if (!is_array($snapshot)) { return null; } $snapshot['fetched_at_display'] = $this->formatDisplayTimestamp($snapshot['fetched_at'] ?? null); if (array_key_exists('requested_at', $snapshot)) { $snapshot['requested_at_display'] = $this->formatDisplayTimestamp($snapshot['requested_at']); } return $snapshot; } private function localizeRateResult(array $rate): array { $rate['fetched_at_display'] = $this->formatDisplayTimestamp($rate['fetched_at'] ?? null); if (array_key_exists('requested_at', $rate)) { $rate['requested_at_display'] = $this->formatDisplayTimestamp($rate['requested_at']); } return $rate; } private function formatDisplayTimestamp(mixed $value): string { $raw = trim((string) $value); if ($raw === '') { return ''; } try { $date = DateTimeImmutable::createFromFormat('Y-m-d H:i:s', $raw, new DateTimeZone('UTC')); if (!$date instanceof DateTimeImmutable) { $date = new DateTimeImmutable($raw, new DateTimeZone('UTC')); } return $date->setTimezone($this->displayTimezone())->format('d.m.Y H:i:s'); } catch (\Throwable) { return $raw; } } private function normalizeCurrencyApiComCurrenciesPayload(array $payload): array { $rawCurrencies = is_array($payload['data'] ?? null) ? $payload['data'] : null; if ($rawCurrencies === null) { throw new \RuntimeException($this->extractProviderError($payload, 'Waehrungskatalog konnte nicht geladen werden.')); } $currencies = []; foreach ($rawCurrencies as $code => $currencyData) { $normalizedCode = $this->normalizeCurrency((string) $code); if ($normalizedCode === '') { continue; } $name = ''; if (is_array($currencyData)) { $name = trim((string) ($currencyData['name'] ?? $currencyData['name_plural'] ?? $currencyData['code'] ?? '')); } if ($name === '') { continue; } $currencies[$normalizedCode] = $name; } return [ 'valid' => true, 'currencies' => $currencies, ]; } private function extractProviderError(array $payload, string $fallback): string { $error = $payload['error'] ?? null; if (is_array($error)) { foreach (['message', 'info', 'code'] as $field) { $value = $error[$field] ?? null; if (is_string($value) && trim($value) !== '') { return trim($value); } } } $errors = $payload['errors'] ?? null; if (is_array($errors)) { foreach ($errors as $entry) { if (is_string($entry) && trim($entry) !== '') { return trim($entry); } if (is_array($entry)) { foreach (['message', 'detail', 'title'] as $field) { $value = $entry[$field] ?? null; if (is_string($value) && trim($value) !== '') { return trim($value); } } } } } foreach (['error', 'message', 'msg'] as $field) { $value = $payload[$field] ?? null; if (is_string($value) && trim($value) !== '') { return trim($value); } } return $fallback; } private function normalizeCurrency(?string $currency): string { return strtoupper(trim((string) $currency)); } private function 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 apiVersion(): string { $version = strtolower(trim((string) ($this->settings['api_version'] ?? 'v2'))); return in_array($version, ['v2', 'v3'], true) ? $version : 'v2'; } private function usesApiVersion(string $version): bool { return $this->apiVersion() === strtolower(trim($version)); } private function apiKey(): string { return trim((string) ($this->settings['api_key'] ?? '')); } private function timeoutSeconds(): int { return max(2, (int) ($this->settings['timeout_sec'] ?? 10)); } private function refreshMaxAgeMinutes(): int { return max(1, (int) ($this->settings['refresh_max_age_minutes'] ?? 60)); } private function defaultBaseCurrency(): string { return $this->normalizeCurrency((string) ($this->settings['default_base_currency'] ?? 'EUR')) ?: 'EUR'; } 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'); } } private function displayTimezone(): DateTimeZone { return $this->scheduleTimezone(); } private function triggerSourceLabel(string $source): string { return match (strtolower(trim($source))) { 'cron' => 'Cron', 'api' => 'API', default => 'Manuell', }; } }