978 lines
34 KiB
PHP
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',
|
|
};
|
|
}
|
|
}
|