Files
nexus/modules/fx-rates/src/Domain/FxRatesService.php
Lars Gebhardt-Kusche 6ce45f6d23
All checks were successful
Deploy / deploy-staging (push) Successful in 6s
Deploy / deploy-production (push) Has been skipped
ddfsdfdf
2026-05-01 02:49:22 +02:00

978 lines
34 KiB
PHP

<?php
declare(strict_types=1);
namespace Modules\FxRates\Domain;
use DateTimeImmutable;
use DateTimeZone;
use Modules\FxRates\Infrastructure\FxRatesRepository;
final class FxRatesService
{
private array $memoryCache = [];
public function __construct(
private FxRatesRepository $repository,
private array $settings = []
) {
}
public function latestStatus(): ?array
{
return $this->localizeFetch($this->repository->getLatestFetch(null));
}
public function latestStatuses(): array
{
return array_map(fn (array $fetch): array => $this->localizeFetch($fetch), $this->repository->listLatestFetches());
}
public function recentFetches(int $limit = 20): array
{
return array_map(fn (array $fetch): array => $this->localizeFetch($fetch), $this->repository->listRecentFetches($limit));
}
public function snapshot(?string $baseCurrency = null, ?string $at = null, ?array $symbols = null, ?int $windowMinutes = null): ?array
{
$base = $this->normalizeCurrency($baseCurrency ?: $this->defaultBaseCurrency());
if ($base === '') {
return null;
}
if ($at === null || trim($at) === '') {
$latest = $this->repository->getLatestFetch(null);
if ($latest === null) {
return null;
}
$snapshot = $this->repository->getSnapshotByFetchId((int) $latest['id'], null);
if ($snapshot === null) {
return null;
}
return $this->localizeSnapshot($this->rebaseSnapshot($snapshot, $base, $symbols));
}
$atUtc = $this->normalizeTimestamp($at);
if ($atUtc === null) {
return null;
}
$nearest = $this->repository->getNearestFetch($base, $atUtc, $windowMinutes);
if ($nearest === null) {
return null;
}
$snapshot = $this->repository->getSnapshotByFetchId((int) $nearest['id'], $symbols);
if ($snapshot === null) {
return null;
}
$rebased = $this->rebaseSnapshot($snapshot, $base, $symbols);
if ($rebased === null) {
return null;
}
return $this->localizeSnapshot($rebased + [
'requested_at' => $atUtc,
'distance_seconds' => $nearest['distance_seconds'] ?? null,
]);
}
public function snapshotByFetchId(int $fetchId, ?string $baseCurrency = null, ?array $symbols = null): ?array
{
if ($fetchId <= 0) {
return null;
}
$requestedBase = $this->normalizeCurrency($baseCurrency ?: $this->defaultBaseCurrency());
if ($requestedBase === '') {
return null;
}
$snapshot = $this->repository->getSnapshotByFetchId($fetchId, null);
if ($snapshot === null) {
return null;
}
return $this->localizeSnapshot($this->rebaseSnapshot($snapshot, $requestedBase, $symbols));
}
public function nearestSnapshot(?string $baseCurrency = null, string $at = '', ?array $symbols = null, ?int $windowMinutes = null): ?array
{
$timestamp = $this->normalizeTimestamp($at);
if ($timestamp === null) {
return null;
}
$requestedBase = $this->normalizeCurrency($baseCurrency ?: $this->defaultBaseCurrency());
if ($requestedBase === '') {
return null;
}
$nearest = $this->repository->findNearestFetch(null, $timestamp, $windowMinutes);
if ($nearest === null) {
return null;
}
$snapshot = $this->repository->getSnapshotByFetchId((int) ($nearest['id'] ?? 0), null);
if ($snapshot === null) {
return null;
}
$rebased = $this->rebaseSnapshot($snapshot, $requestedBase, $symbols);
if ($rebased === null) {
return null;
}
return $this->localizeSnapshot($rebased + [
'requested_at' => $timestamp,
'distance_seconds' => $nearest['distance_seconds'] ?? null,
]);
}
public function findRate(?string $fromCurrency, ?string $toCurrency, ?string $at = null, ?int $windowMinutes = null): ?array
{
$from = $this->normalizeCurrency($fromCurrency);
$to = $this->normalizeCurrency($toCurrency);
if ($from === '' || $to === '') {
return null;
}
if ($from === $to) {
return $this->localizeRateResult([
'base_currency' => $from,
'target_currency' => $to,
'rate' => 1.0,
'provider' => 'identity',
'fetched_at' => $at ? $this->normalizeTimestamp($at) : null,
'rate_date' => $at ? substr((string) $this->normalizeTimestamp($at), 0, 10) : gmdate('Y-m-d'),
'is_exact_pair' => true,
]);
}
$cacheKey = implode(':', [$from, $to, $at ?? '', (string) ($windowMinutes ?? 0)]);
if (array_key_exists($cacheKey, $this->memoryCache)) {
return $this->memoryCache[$cacheKey];
}
$candidates = array_values(array_unique(array_filter([
$this->defaultBaseCurrency(),
'EUR',
'USD',
$from,
$to,
], static fn (?string $value): bool => is_string($value) && trim($value) !== '')));
foreach ($candidates as $snapshotBase) {
$snapshot = $this->snapshot($snapshotBase, $at, [$from, $to], $windowMinutes);
if (!is_array($snapshot)) {
continue;
}
$rates = is_array($snapshot['rates'] ?? null) ? $snapshot['rates'] : [];
$resolved = $this->resolveRateFromSnapshot($snapshot, $rates, $from, $to);
if ($resolved !== null) {
return $this->memoryCache[$cacheKey] = $this->localizeRateResult($resolved);
}
}
return $this->memoryCache[$cacheKey] = null;
}
public function convert(?float $amount, ?string $fromCurrency, ?string $toCurrency, ?string $at = null, ?int $windowMinutes = null): ?float
{
if ($amount === null) {
return null;
}
$rate = $this->findRate($fromCurrency, $toCurrency, $at, $windowMinutes);
if (!is_numeric($rate['rate'] ?? null)) {
return null;
}
return $amount * (float) $rate['rate'];
}
public function refreshLatestRates(?array $currencies = null, ?string $baseCurrency = null, string $triggerSource = 'manual'): array
{
$requestedBase = $this->normalizeCurrency($baseCurrency ?: $this->defaultBaseCurrency());
$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',
};
}
}