Next
All checks were successful
Deploy / deploy-staging (push) Successful in 24s
Deploy / deploy-production (push) Has been skipped

adas
This commit is contained in:
2026-06-22 01:44:06 +02:00
parent f83a64b854
commit f9e41380b5
29 changed files with 3433 additions and 17 deletions

View File

@@ -0,0 +1,323 @@
<?php
declare(strict_types=1);
namespace Modules\FxRates\Api;
use Modules\FxRates\Domain\FxRatesService;
use Modules\FxRates\Infrastructure\ConnectionFactory;
use Modules\FxRates\Infrastructure\FxRatesRepository;
use Modules\FxRates\Infrastructure\ModuleConfig;
use Modules\FxRates\Infrastructure\SettingsStore;
use RuntimeException;
final class Router
{
private ModuleConfig $moduleConfig;
private SettingsStore $settingsStore;
private ?FxRatesService $service = null;
public function __construct(
private readonly string $moduleBasePath
) {
$this->moduleConfig = ModuleConfig::load($moduleBasePath);
$this->settingsStore = new SettingsStore($this->moduleConfig);
}
public function handle(string $relativePath): never
{
$method = strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET'));
$path = trim($relativePath, '/');
try {
if ($path === 'v1/health' && $method === 'GET') {
$this->respond(['ok' => true, 'module' => 'fx-rates']);
}
if ($path === 'v1/endpoints' && $method === 'GET') {
$this->respond(['data' => $this->endpointCatalog()]);
}
if ($path === 'v1/status' && $method === 'GET') {
$this->respond(['data' => $this->service()->latestStatuses()]);
}
if ($path === 'v1/recent-fetches' && $method === 'GET') {
$limit = max(1, min(50, (int) ($_GET['limit'] ?? 12)));
$this->respond(['data' => $this->service()->recentFetches($limit)]);
}
if ($path === 'v1/latest' && $method === 'GET') {
$symbols = $this->parseCsv($_GET['symbols'] ?? null);
$base = $this->stringOrNull($_GET['base'] ?? null);
if ($symbols === null) {
$settings = $this->settingsStore->load();
$symbols = is_array($settings['preferred_currencies'] ?? null) ? $settings['preferred_currencies'] : null;
}
$snapshot = $this->service()->snapshot($base, null, $symbols, null);
$this->respond(['data' => $snapshot]);
}
if ($path === 'v1/fetch' && $method === 'GET') {
$fetchId = max(0, (int) ($_GET['fetch_id'] ?? 0));
$base = $this->stringOrNull($_GET['base'] ?? null);
$symbols = $this->parseCsv($_GET['symbols'] ?? null);
$snapshot = $this->service()->snapshotByFetchId($fetchId, $base, $symbols);
$this->respond(['data' => $snapshot]);
}
if ($path === 'v1/nearest' && $method === 'GET') {
$base = $this->stringOrNull($_GET['base'] ?? null);
$symbols = $this->parseCsv($_GET['symbols'] ?? null);
$at = $this->stringOrNull($_GET['at'] ?? null);
$windowMinutes = $this->intOrNull($_GET['window_minutes'] ?? null);
$snapshot = $this->service()->nearestSnapshot($base, (string) $at, $symbols, $windowMinutes);
$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);
$force = !empty($input['force']);
$maxAgeMinutes = is_numeric($input['max_age_minutes'] ?? null)
? (int) $input['max_age_minutes']
: (is_numeric($input['max_age_hours'] ?? null) ? (int) round(((float) $input['max_age_hours']) * 60) : null);
$result = $force
? $this->service()->refreshLatestRates(null, $base, (string) ($input['trigger_source'] ?? 'manual'))
: $this->service()->autoRefreshLatestRates($base, null, $maxAgeMinutes, (string) ($input['trigger_source'] ?? 'api'));
$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/currency-catalog/probe' && $method === 'GET') {
$this->respond(['data' => $this->service()->probeCurrencyCatalog()]);
}
if ($path === 'v1/currency-catalog/sync' && $method === 'POST') {
$result = $this->service()->refreshCurrencyCatalog();
$current = $this->settingsStore->load();
$catalog = [];
foreach (is_array($result['currencies'] ?? null) ? $result['currencies'] : [] as $item) {
if (!is_array($item)) {
continue;
}
$code = strtoupper(trim((string) ($item['code'] ?? '')));
$name = trim((string) ($item['name'] ?? ''));
if ($code === '' || $name === '') {
continue;
}
$catalog[$code] = ['code' => $code, 'name' => $name];
}
foreach ([
(string) ($current['default_base_currency'] ?? ''),
(string) ($current['display_base_currency'] ?? ''),
...((is_array($current['preferred_currencies'] ?? null) ? $current['preferred_currencies'] : [])),
] as $code) {
$code = strtoupper(trim((string) $code));
if ($code !== '' && !isset($catalog[$code])) {
$catalog[$code] = ['code' => $code, 'name' => $code];
}
}
ksort($catalog);
$settings = $this->settingsStore->save([
'currency_catalog' => array_values($catalog),
'currency_catalog_synced_at' => gmdate(DATE_ATOM),
]);
$this->reloadService();
$this->respond(['data' => [
'sync' => $result,
'settings' => $settings,
]], 201);
}
if ($path === 'v1/settings' && $method === 'GET') {
$this->respond(['data' => $this->settingsStore->load()]);
}
if ($path === 'v1/settings' && $method === 'PUT') {
$payload = $this->normalizeSettingsPayload($this->input());
$settings = $this->settingsStore->save($payload);
$this->reloadService();
$this->respond(['data' => $settings]);
}
$this->respond(['error' => 'Unbekannter API-Pfad.'], 404);
} catch (\Throwable $exception) {
$this->respond([
'error' => 'FX-API Fehler.',
'context' => ['message' => $exception->getMessage()],
], $exception instanceof RuntimeException ? 500 : 500);
}
}
private function service(): FxRatesService
{
if ($this->service instanceof FxRatesService) {
return $this->service;
}
$repository = new FxRatesRepository(
ConnectionFactory::make($this->settingsStore),
$this->moduleConfig->tablePrefix()
);
$repository->ensureSchema();
return $this->service = new FxRatesService($repository, $this->settingsStore->load());
}
private function reloadService(): void
{
$this->service = null;
}
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;
}
private function normalizeSettingsPayload(array $input): array
{
$payload = [];
foreach (['provider', 'api_version', 'api_url', 'currencies_url', 'api_key', 'default_base_currency', 'display_base_currency', 'schedule_timezone'] as $key) {
if (array_key_exists($key, $input)) {
$payload[$key] = $input[$key];
}
}
if (array_key_exists('timeout_sec', $input)) {
$payload['timeout_sec'] = (int) $input['timeout_sec'];
}
if (array_key_exists('refresh_max_age_hours', $input)) {
$payload['refresh_max_age_hours'] = (float) $input['refresh_max_age_hours'];
}
if (array_key_exists('refresh_max_age_minutes', $input) && !array_key_exists('refresh_max_age_hours', $input)) {
$payload['refresh_max_age_hours'] = ((float) $input['refresh_max_age_minutes']) / 60;
}
if (array_key_exists('preferred_currencies', $input)) {
$payload['preferred_currencies'] = $input['preferred_currencies'];
}
if (array_key_exists('currency_catalog', $input)) {
$payload['currency_catalog'] = $input['currency_catalog'];
}
if (array_key_exists('currency_catalog_synced_at', $input)) {
$payload['currency_catalog_synced_at'] = $input['currency_catalog_synced_at'];
}
if (array_key_exists('use_separate_db', $input)) {
$payload['use_separate_db'] = !empty($input['use_separate_db']);
}
if (array_key_exists('db', $input) && is_array($input['db'])) {
$payload['db'] = $input['db'];
}
return $payload;
}
private function endpointCatalog(): array
{
return [
'module' => 'fx-rates',
'version' => 'v1',
'languages' => ['de', 'en'],
'endpoints' => [
['path' => '/api/fx-rates/v1/endpoints', 'method' => 'GET'],
['path' => '/api/fx-rates/v1/latest', 'method' => 'GET', 'params' => ['base', 'symbols']],
['path' => '/api/fx-rates/v1/fetch', 'method' => 'GET', 'params' => ['fetch_id', 'base', 'symbols']],
['path' => '/api/fx-rates/v1/nearest', 'method' => 'GET', 'params' => ['at', 'base', 'symbols', 'window_minutes']],
['path' => '/api/fx-rates/v1/snapshot', 'method' => 'GET', 'params' => ['at', 'base', 'symbols', 'window_minutes']],
['path' => '/api/fx-rates/v1/rate', 'method' => 'GET', 'params' => ['from', 'to', 'at', 'window_minutes']],
['path' => '/api/fx-rates/v1/history', 'method' => 'GET', 'params' => ['from', 'to', 'from_at', 'to_at', 'limit']],
['path' => '/api/fx-rates/v1/refresh', 'method' => 'POST', 'body' => ['base', 'force', 'max_age_minutes', 'max_age_hours']],
['path' => '/api/fx-rates/v1/status', 'method' => 'GET'],
['path' => '/api/fx-rates/v1/recent-fetches', 'method' => 'GET', 'params' => ['limit']],
['path' => '/api/fx-rates/v1/probe', 'method' => 'GET', 'params' => ['base']],
['path' => '/api/fx-rates/v1/currency-catalog/probe', 'method' => 'GET'],
['path' => '/api/fx-rates/v1/currency-catalog/sync', 'method' => 'POST'],
['path' => '/api/fx-rates/v1/settings', 'method' => 'GET'],
['path' => '/api/fx-rates/v1/settings', 'method' => 'PUT'],
],
];
}
}

View File

@@ -0,0 +1,991 @@
<?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());
$requestedCurrencies = $this->normalizeRequestedCurrencies($currencies, $requestedBase);
$payload = $this->fetchLatestPayload($requestedBase, $requestedCurrencies);
$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());
$requestedCurrencies = $this->normalizeRequestedCurrencies($currencies, $base);
$latest = $this->repository->getLatestFetch($base);
$maxAgeSeconds = (int) round(max(1.0, $maxAgeHours) * 3600);
$fetchedAt = is_array($latest) ? $this->parseStoredUtcTimestamp((string) ($latest['fetched_at'] ?? '')) : null;
if (
$fetchedAt !== null
&& (time() - $fetchedAt) <= $maxAgeSeconds
&& $this->latestFetchCoversCurrencies($latest, $requestedCurrencies)
) {
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($requestedCurrencies, $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 [];
}
return $this->crossHistory($fromCurrency, $toCurrency, $from, $to, $limit);
}
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);
}
$resolvedBase = $baseCurrency;
$rates = [];
if ($this->provider() === 'currencyapi') {
$resolvedBase = $this->normalizeCurrency((string) ($payload['base'] ?? $payload['base_currency'] ?? ($payload['query']['base_currency'] ?? 'USD')));
if ($resolvedBase === '') {
$resolvedBase = 'USD';
}
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 === $resolvedBase || !is_numeric($rate)) {
continue;
}
$rates[$code] = (float) $rate;
}
} else {
$resolvedBase = $this->normalizeCurrency((string) ($payload['base'] ?? $payload['base_currency'] ?? $baseCurrency));
if ($resolvedBase === '') {
$resolvedBase = $baseCurrency;
}
$rawRates = is_array($payload['rates'] ?? null) ? $payload['rates'] : [];
foreach ($rawRates as $code => $rate) {
$code = $this->normalizeCurrency((string) $code);
if ($code === '' || $code === $resolvedBase || !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' => $resolvedBase,
'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;
}
if ($snapshotBase !== '' && $snapshotBase !== $requestedBase) {
$rebasedRates[$snapshotBase] = 1 / (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 = $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 normalizeRequestedCurrencies(?array $currencies, string $baseCurrency): ?array
{
if (!is_array($currencies)) {
return null;
}
$base = $this->normalizeCurrency($baseCurrency);
$normalized = array_values(array_unique(array_filter(array_map(
fn (mixed $currency): string => $this->normalizeCurrency((string) $currency),
$currencies
), fn (string $currency): bool => $currency !== '' && $currency !== $base)));
return $normalized === [] ? null : $normalized;
}
private function latestFetchCoversCurrencies(?array $latestFetch, ?array $currencies): bool
{
if (!is_array($latestFetch) || !is_numeric($latestFetch['id'] ?? null) || !is_array($currencies) || $currencies === []) {
return true;
}
$snapshot = $this->repository->getSnapshotByFetchId((int) $latestFetch['id'], $currencies);
$rates = is_array($snapshot['rates'] ?? null) ? $snapshot['rates'] : [];
foreach ($currencies as $currency) {
if (!array_key_exists($currency, $rates)) {
return false;
}
}
return true;
}
private function normalizeTimestamp(?string $value): ?string
{
$value = trim((string) $value);
if ($value === '') {
return null;
}
try {
if (preg_match('/^\d{4}-\d{2}-\d{2}(?:[ T]\d{2}:\d{2}(?::\d{2})?)?$/', $value) === 1) {
$date = new DateTimeImmutable(str_replace(' ', 'T', $value), new DateTimeZone('UTC'));
} else {
$date = new DateTimeImmutable($value);
}
return $date->setTimezone(new DateTimeZone('UTC'))->format('Y-m-d H:i:s');
} catch (\Throwable) {
return null;
}
}
private function parseStoredUtcTimestamp(string $value): ?int
{
$normalized = $this->normalizeTimestamp($value);
if ($normalized === null) {
return null;
}
try {
return (new DateTimeImmutable($normalized, new DateTimeZone('UTC')))->getTimestamp();
} 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'] ?? 360));
}
private function defaultBaseCurrency(): string
{
return $this->normalizeCurrency((string) ($this->settings['default_base_currency'] ?? 'EUR')) ?: 'EUR';
}
private function displayTimezone(): DateTimeZone
{
$timezone = trim((string) ($this->settings['schedule_timezone'] ?? nexus_display_timezone_name()));
try {
return new DateTimeZone($timezone);
} catch (\Throwable) {
return new DateTimeZone(nexus_display_timezone_name());
}
}
private function triggerSourceLabel(string $source): string
{
return match (strtolower(trim($source))) {
'cron' => 'Cron',
'api' => 'API',
'widget' => 'Widget',
default => 'Manuell',
};
}
}

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace Modules\FxRates\Infrastructure;
use App\PdoFactory;
use RuntimeException;
use PDO;
final class ConnectionFactory
{
public static function make(SettingsStore $settingsStore): PDO
{
$settings = $settingsStore->load();
$useSeparateDb = !empty($settings['use_separate_db']);
if ($useSeparateDb) {
$dbConfig = is_array($settings['db'] ?? null) ? $settings['db'] : [];
if ($dbConfig === []) {
throw new RuntimeException('Custom-Datenbank ist aktiviert, aber nicht vollstaendig konfiguriert.');
}
self::assertSupportedDriver($dbConfig);
return PdoFactory::createFromArray($dbConfig);
}
$dbConfig = app()->config()->dbConfig;
if ($dbConfig === []) {
throw new RuntimeException('Projekt-Datenbankkonfiguration fehlt in config/db_settings_basic.php.');
}
self::assertSupportedDriver($dbConfig);
return PdoFactory::createFromArray($dbConfig);
}
private static function assertSupportedDriver(array $dbConfig): void
{
$driver = strtolower((string) ($dbConfig['driver'] ?? ($dbConfig['dsn'] ?? '')));
if ($driver !== '' && !in_array($driver, ['mysql', 'pgsql', 'sqlite'], true) && !str_starts_with($driver, 'mysql:') && !str_starts_with($driver, 'pgsql:') && !str_starts_with($driver, 'sqlite:')) {
throw new RuntimeException('FX-Rates unterstuetzt aktuell MySQL/MariaDB, PostgreSQL und SQLite.');
}
}
}

View File

@@ -0,0 +1,547 @@
<?php
declare(strict_types=1);
namespace Modules\FxRates\Infrastructure;
use PDO;
final class FxRatesRepository
{
private string $driver;
public function __construct(
private PDO $pdo,
private string $tablePrefix = 'fxrate_'
) {
$this->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,
trigger_source VARCHAR(32) NOT NULL DEFAULT 'manual',
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("ALTER TABLE {$fetchTable} ADD COLUMN IF NOT EXISTS trigger_source VARCHAR(32) NOT NULL DEFAULT 'manual'");
$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)");
return;
}
if ($this->driver === 'mysql') {
$this->pdo->exec("CREATE TABLE IF NOT EXISTS {$fetchTable} (
id INTEGER PRIMARY KEY AUTO_INCREMENT,
provider VARCHAR(64) NOT NULL,
trigger_source VARCHAR(32) NOT NULL DEFAULT 'manual',
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->ensureColumn($fetchTable, 'trigger_source', "ALTER TABLE {$fetchTable} ADD COLUMN trigger_source VARCHAR(32) NOT NULL DEFAULT 'manual'");
$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)
)");
return;
}
$this->pdo->exec("CREATE TABLE IF NOT EXISTS {$fetchTable} (
id INTEGER PRIMARY KEY AUTOINCREMENT,
provider VARCHAR(64) NOT NULL,
trigger_source VARCHAR(32) NOT NULL DEFAULT 'manual',
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->ensureColumn($fetchTable, 'trigger_source', "ALTER TABLE {$fetchTable} ADD COLUMN trigger_source VARCHAR(32) NOT NULL DEFAULT 'manual'");
$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, trigger_source, 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, trigger_source, 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 listRecentFetches(int $limit = 20): array
{
$stmt = $this->pdo->prepare(
'SELECT id, provider, trigger_source, base_currency, rate_date, fetched_at, created_at
FROM ' . $this->table('fetches') . '
ORDER BY fetched_at DESC, id DESC
LIMIT :limit'
);
$stmt->bindValue(':limit', max(1, $limit), PDO::PARAM_INT);
$stmt->execute();
return array_map(
fn (array $row): array => $this->normalizeFetch($row),
$stmt->fetchAll(PDO::FETCH_ASSOC) ?: []
);
}
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 findNearestFetch(?string $baseCurrency, string $timestamp, ?int $windowMinutes = null): ?array
{
$targetTs = strtotime($timestamp);
if ($targetTs === false) {
return null;
}
if ($baseCurrency !== null && trim($baseCurrency) !== '') {
return $this->getNearestFetch(strtoupper(trim($baseCurrency)), $timestamp, $windowMinutes);
}
$candidates = [];
foreach (['<=', '>='] as $operator) {
$order = $operator === '<=' ? 'DESC' : 'ASC';
$stmt = $this->pdo->prepare(
'SELECT id, provider, trigger_source, base_currency, rate_date, fetched_at, created_at
FROM ' . $this->table('fetches') . '
WHERE fetched_at ' . $operator . ' :target_at
ORDER BY fetched_at ' . $order . ', id ' . $order . '
LIMIT 1'
);
$stmt->execute(['target_at' => $timestamp]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
if (is_array($row)) {
$candidate = $this->normalizeFetch($row);
$candidateTs = strtotime((string) ($candidate['fetched_at'] ?? ''));
if ($candidateTs !== false) {
$candidate['distance_seconds'] = abs($candidateTs - $targetTs);
$candidates[] = $candidate;
}
}
}
if ($candidates === []) {
return null;
}
usort($candidates, static fn (array $left, array $right): int => ((int) ($left['distance_seconds'] ?? PHP_INT_MAX)) <=> ((int) ($right['distance_seconds'] ?? PHP_INT_MAX)));
$selected = $candidates[0];
if ($windowMinutes !== null && $windowMinutes > 0 && (int) ($selected['distance_seconds'] ?? 0) > ($windowMinutes * 60)) {
return null;
}
return $selected;
}
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 DESC, r.id DESC 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, string $triggerSource = 'manual'): 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');
$triggerSource = $this->normalizeTriggerSource($triggerSource);
$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, trigger_source, base_currency, rate_date, fetched_at
) VALUES (
:provider, :trigger_source, :base_currency, :rate_date, :fetched_at
)
RETURNING *'
);
$fetchStmt->execute([
'provider' => $provider,
'trigger_source' => $triggerSource,
'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, trigger_source, base_currency, rate_date, fetched_at
) VALUES (
:provider, :trigger_source, :base_currency, :rate_date, :fetched_at
)'
);
$fetchStmt->execute([
'provider' => $provider,
'trigger_source' => $triggerSource,
'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;
}
}
public function findFetchByBaseAndFetchedAt(string $baseCurrency, string $fetchedAt): ?array
{
$baseCurrency = strtoupper(trim($baseCurrency));
$fetchedAt = trim($fetchedAt);
if ($baseCurrency === '' || $fetchedAt === '') {
return null;
}
$stmt = $this->pdo->prepare(
'SELECT id, provider, trigger_source, base_currency, rate_date, fetched_at, created_at
FROM ' . $this->table('fetches') . '
WHERE base_currency = :base_currency
AND fetched_at = :fetched_at
ORDER BY id ASC
LIMIT 1'
);
$stmt->execute([
'base_currency' => $baseCurrency,
'fetched_at' => $fetchedAt,
]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
return is_array($row) ? $this->normalizeFetch($row) : null;
}
private function getFetchById(int $fetchId): ?array
{
$stmt = $this->pdo->prepare(
'SELECT id, provider, trigger_source, 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, trigger_source, 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'] ?? ''),
'trigger_source' => (string) ($row['trigger_source'] ?? 'manual'),
'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 ensureColumn(string $table, string $column, string $alterSql): void
{
try {
$stmt = $this->pdo->query('SELECT * FROM ' . $table . ' LIMIT 1');
if ($stmt instanceof \PDOStatement) {
$row = $stmt->fetch(PDO::FETCH_ASSOC) ?: [];
if (in_array(strtolower($column), array_map('strtolower', array_keys($row)), true)) {
return;
}
}
} catch (\Throwable) {
}
try {
$this->pdo->exec($alterSql);
} catch (\Throwable) {
}
}
private function normalizeTriggerSource(string $source): string
{
$source = strtolower(trim($source));
return match ($source) {
'cron', 'manual', 'api', 'migration', 'widget' => $source,
default => 'manual',
};
}
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);
}
}

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace Modules\FxRates\Infrastructure;
final class ModuleConfig
{
public function __construct(
private array $config
) {
}
public static function load(string $moduleBasePath): self
{
$config = require $moduleBasePath . '/config/module.php';
return new self(is_array($config) ? $config : []);
}
public function all(): array
{
return $this->config;
}
public function useProjectDatabase(): bool
{
return (bool) ($this->config['use_project_database'] ?? true);
}
public function tablePrefix(): string
{
return (string) ($this->config['table_prefix'] ?? 'fxrate_');
}
public function settingsFile(): string
{
return (string) ($this->config['settings_file'] ?? dirname(__DIR__, 4) . '/data/fx-rates/settings.json');
}
}

View File

@@ -0,0 +1,118 @@
<?php
declare(strict_types=1);
namespace Modules\FxRates\Infrastructure;
final class SettingsStore
{
public function __construct(
private readonly ModuleConfig $moduleConfig
) {
}
public function load(): array
{
$defaults = $this->normalize($this->moduleConfig->all());
$file = $this->settingsFile();
if (!is_file($file)) {
return $defaults;
}
$raw = file_get_contents($file);
if ($raw === false || trim($raw) === '') {
return $defaults;
}
$decoded = json_decode($raw, true);
$saved = is_array($decoded) ? $decoded : [];
return $this->normalize(array_replace_recursive($defaults, $saved));
}
public function save(array $patch): array
{
$current = $this->load();
$next = $this->normalize(array_replace_recursive($current, $patch));
$next['updated_at'] = gmdate(DATE_ATOM);
$file = $this->settingsFile();
$dir = dirname($file);
if (!is_dir($dir)) {
mkdir($dir, 0775, true);
}
file_put_contents($file, json_encode($next, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
return $next;
}
private function settingsFile(): string
{
return $this->moduleConfig->settingsFile();
}
private function normalize(array $settings): array
{
$preferredCurrencies = $settings['preferred_currencies'] ?? ['EUR', 'USD', 'DOGE', 'BTC'];
if (is_string($preferredCurrencies)) {
$preferredCurrencies = preg_split('/[\s,;]+/', $preferredCurrencies) ?: [];
}
$preferredCurrencies = array_values(array_unique(array_filter(array_map(
static fn (mixed $value): string => strtoupper(trim((string) $value)),
is_array($preferredCurrencies) ? $preferredCurrencies : []
))));
$catalog = $settings['currency_catalog'] ?? [];
$catalog = array_values(array_filter(array_map(static function (mixed $item): ?array {
if (!is_array($item)) {
return null;
}
$code = strtoupper(trim((string) ($item['code'] ?? '')));
$name = trim((string) ($item['name'] ?? ''));
if ($code === '' || $name === '') {
return null;
}
return [
'code' => $code,
'name' => $name,
];
}, is_array($catalog) ? $catalog : [])));
$refreshHours = (float) ($settings['refresh_max_age_hours'] ?? 6);
if ($refreshHours <= 0) {
$refreshHours = 6;
}
$displayBase = strtoupper(trim((string) ($settings['display_base_currency'] ?? ($settings['default_base_currency'] ?? 'EUR'))));
$defaultBase = strtoupper(trim((string) ($settings['default_base_currency'] ?? 'EUR')));
if ($defaultBase === '') {
$defaultBase = 'EUR';
}
if ($displayBase === '') {
$displayBase = $defaultBase;
}
return [
'use_separate_db' => !empty($settings['use_separate_db']),
'db' => is_array($settings['db'] ?? null) ? $settings['db'] : [],
'provider' => trim((string) ($settings['provider'] ?? 'currencyapi')) ?: 'currencyapi',
'api_version' => in_array(strtolower(trim((string) ($settings['api_version'] ?? 'v2'))), ['v2', 'v3'], true)
? strtolower(trim((string) ($settings['api_version'] ?? 'v2')))
: 'v2',
'api_url' => rtrim((string) ($settings['api_url'] ?? 'https://currencyapi.net'), '/'),
'currencies_url' => rtrim((string) ($settings['currencies_url'] ?? ($settings['api_url'] ?? 'https://currencyapi.net')), '/'),
'api_key' => trim((string) ($settings['api_key'] ?? '')),
'timeout_sec' => max(2, (int) ($settings['timeout_sec'] ?? 10)),
'refresh_max_age_hours' => $refreshHours,
'refresh_max_age_minutes' => max(1, (int) round($refreshHours * 60)),
'default_base_currency' => $defaultBase,
'display_base_currency' => $displayBase,
'preferred_currencies' => $preferredCurrencies,
'currency_catalog' => $catalog,
'currency_catalog_synced_at' => trim((string) ($settings['currency_catalog_synced_at'] ?? '')),
'schedule_timezone' => trim((string) ($settings['schedule_timezone'] ?? nexus_display_timezone_name())) ?: nexus_display_timezone_name(),
'updated_at' => trim((string) ($settings['updated_at'] ?? '')),
];
}
}