FX Modul
This commit is contained in:
@@ -224,6 +224,13 @@ $mm->registerFunction($moduleName, 'ensure_schema', function () use ($moduleName
|
||||
});
|
||||
|
||||
$mm->registerFunction($moduleName, 'fx_service', static function (): ?object {
|
||||
if (modules()->isEnabled('fx-rates') && modules()->hasFunction('fx-rates', 'service')) {
|
||||
try {
|
||||
return module_fn('fx-rates', 'service');
|
||||
} catch (\Throwable) {
|
||||
}
|
||||
}
|
||||
|
||||
if (!is_dir(dirname(__DIR__) . '/mining-checker')) {
|
||||
return null;
|
||||
}
|
||||
@@ -256,7 +263,7 @@ $mm->registerFunction($moduleName, 'fx_refresh', static function (string $baseCu
|
||||
if (!$service || !method_exists($service, 'ensureFreshLatestRates')) {
|
||||
return [
|
||||
'ok' => false,
|
||||
'message' => 'Mining-Checker FX-Service ist aktuell nicht verfuegbar.',
|
||||
'message' => 'FX-Service ist aktuell nicht verfuegbar.',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -266,7 +273,7 @@ $mm->registerFunction($moduleName, 'fx_refresh', static function (string $baseCu
|
||||
'ok' => true,
|
||||
'message' => !empty($result['reused'])
|
||||
? 'Vorhandene FX-Daten weiterverwendet.'
|
||||
: 'FX-Daten aus dem Mining-Checker aktualisiert.',
|
||||
: 'FX-Daten aktualisiert.',
|
||||
'result' => $result,
|
||||
];
|
||||
} catch (\Throwable $e) {
|
||||
|
||||
7
modules/fx-rates/api/index.php
Normal file
7
modules/fx-rates/api/index.php
Normal file
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/bootstrap.php';
|
||||
|
||||
$service = module_fn('fx-rates', 'service');
|
||||
(new Modules\FxRates\Api\Router($service))->handle($_GET['path'] ?? '');
|
||||
121
modules/fx-rates/bootstrap.php
Normal file
121
modules/fx-rates/bootstrap.php
Normal file
@@ -0,0 +1,121 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\ModuleConfigException;
|
||||
use Modules\FxRates\Domain\FxRatesService;
|
||||
use Modules\FxRates\Infrastructure\FxRatesRepository;
|
||||
|
||||
spl_autoload_register(static function (string $class): void {
|
||||
$prefix = 'Modules\\FxRates\\';
|
||||
if (!str_starts_with($class, $prefix)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$file = __DIR__ . '/src/' . str_replace('\\', '/', substr($class, strlen($prefix))) . '.php';
|
||||
if (is_file($file)) {
|
||||
require_once $file;
|
||||
}
|
||||
});
|
||||
|
||||
$moduleName = 'fx-rates';
|
||||
$mm = isset($modules) && $modules instanceof App\ModuleManager ? $modules : modules();
|
||||
|
||||
$mm->registerFunction($moduleName, 'table', static function (string $name): string {
|
||||
$prefix = 'fxrate_';
|
||||
$sanitized = preg_replace('/[^a-zA-Z0-9_]/', '', $name);
|
||||
return $prefix . $sanitized;
|
||||
});
|
||||
|
||||
$mm->registerFunction($moduleName, 'settings', static function (): array {
|
||||
$saved = modules()->settings('fx-rates');
|
||||
$provider = trim((string) ($saved['provider'] ?? (getenv('FX_RATES_PROVIDER') ?: getenv('MINING_CHECKER_FX_PROVIDER') ?: 'currencyapi')));
|
||||
$apiUrl = rtrim((string) ($saved['api_url'] ?? (getenv('FX_RATES_API_URL') ?: getenv('MINING_CHECKER_FX_URL') ?: 'https://currencyapi.net')), '/');
|
||||
$currenciesUrl = rtrim((string) ($saved['currencies_url'] ?? (getenv('FX_RATES_CURRENCIES_URL') ?: getenv('MINING_CHECKER_FX_CURRENCIES_URL') ?: getenv('MINING_CHECKER_FX_URL') ?: 'https://currencyapi.net')), '/');
|
||||
$apiKey = trim((string) ($saved['api_key'] ?? (getenv('FX_RATES_API_KEY') ?: getenv('MINING_CHECKER_FX_API_KEY') ?: '')));
|
||||
$timeout = max(2, (int) ($saved['timeout_sec'] ?? (getenv('FX_RATES_TIMEOUT') ?: getenv('MINING_CHECKER_FX_TIMEOUT') ?: 10)));
|
||||
$cacheTtl = max(60, (int) ($saved['cache_ttl_sec'] ?? (getenv('FX_RATES_CACHE_TTL') ?: getenv('MINING_CHECKER_FX_CACHE_TTL') ?: 21600)));
|
||||
|
||||
return [
|
||||
'provider' => $provider !== '' ? $provider : 'currencyapi',
|
||||
'api_url' => $apiUrl,
|
||||
'currencies_url' => $currenciesUrl,
|
||||
'api_key' => $apiKey,
|
||||
'timeout_sec' => $timeout,
|
||||
'cache_ttl_sec' => $cacheTtl,
|
||||
'default_base_currency' => strtoupper(trim((string) ($saved['default_base_currency'] ?? 'EUR'))) ?: 'EUR',
|
||||
'daily_refresh_enabled' => array_key_exists('daily_refresh_enabled', $saved) ? (bool) $saved['daily_refresh_enabled'] : true,
|
||||
'daily_refresh_hour' => max(0, min(23, (int) ($saved['daily_refresh_hour'] ?? 18))),
|
||||
'schedule_timezone' => trim((string) ($saved['schedule_timezone'] ?? 'Europe/Berlin')) ?: 'Europe/Berlin',
|
||||
];
|
||||
});
|
||||
|
||||
$mm->registerFunction($moduleName, 'pdo', function () use ($moduleName): PDO {
|
||||
$settings = modules()->settings($moduleName);
|
||||
$useSeparate = !empty($settings['use_separate_db']);
|
||||
|
||||
if ($useSeparate) {
|
||||
$module = modules()->get($moduleName);
|
||||
$fallback = $module['db_defaults'] ?? [];
|
||||
return modules()->modulePdo($moduleName, $fallback);
|
||||
}
|
||||
|
||||
$base = app()->basePdo();
|
||||
if ($base instanceof PDO) {
|
||||
return $base;
|
||||
}
|
||||
|
||||
throw new ModuleConfigException(
|
||||
$moduleName,
|
||||
'Base-DB ist deaktiviert. Bitte Base-DB aktivieren oder eine eigene Modul-DB konfigurieren.'
|
||||
);
|
||||
});
|
||||
|
||||
$mm->registerFunction($moduleName, 'repository', static function (): FxRatesRepository {
|
||||
return new FxRatesRepository(module_fn('fx-rates', 'pdo'), 'fxrate_');
|
||||
});
|
||||
|
||||
$mm->registerFunction($moduleName, 'ensure_schema', static function (): void {
|
||||
module_fn('fx-rates', 'repository')->ensureSchema();
|
||||
});
|
||||
|
||||
$mm->registerFunction($moduleName, 'service', static function (): FxRatesService {
|
||||
module_fn('fx-rates', 'ensure_schema');
|
||||
return new FxRatesService(
|
||||
module_fn('fx-rates', 'repository'),
|
||||
module_fn('fx-rates', 'settings')
|
||||
);
|
||||
});
|
||||
|
||||
$mm->registerFunction($moduleName, 'refresh_latest', static function (?array $currencies = null, ?string $baseCurrency = null): array {
|
||||
return module_fn('fx-rates', 'service')->refreshLatestRates($currencies, $baseCurrency);
|
||||
});
|
||||
|
||||
$mm->registerFunction($moduleName, 'ensure_fresh_latest_rates', static function (float $maxAgeHours = 24.0, ?string $baseCurrency = null, ?array $currencies = null): array {
|
||||
return module_fn('fx-rates', 'service')->ensureFreshLatestRates($maxAgeHours, $baseCurrency, $currencies);
|
||||
});
|
||||
|
||||
$mm->registerFunction($moduleName, 'rate', static function (string $fromCurrency, string $toCurrency, ?string $at = null, ?int $windowMinutes = null): ?array {
|
||||
return module_fn('fx-rates', 'service')->findRate($fromCurrency, $toCurrency, $at, $windowMinutes);
|
||||
});
|
||||
|
||||
$mm->registerFunction($moduleName, 'convert', static function (?float $amount, ?string $fromCurrency, ?string $toCurrency, ?string $at = null, ?int $windowMinutes = null): ?float {
|
||||
return module_fn('fx-rates', 'service')->convert($amount, $fromCurrency, $toCurrency, $at, $windowMinutes);
|
||||
});
|
||||
|
||||
$mm->registerFunction($moduleName, 'snapshot', static function (?string $baseCurrency = null, ?string $at = null, ?array $symbols = null, ?int $windowMinutes = null): ?array {
|
||||
return module_fn('fx-rates', 'service')->snapshot($baseCurrency, $at, $symbols, $windowMinutes);
|
||||
});
|
||||
|
||||
$mm->registerFunction($moduleName, 'scheduled_refresh', static function (array $context = []): array {
|
||||
$result = module_fn('fx-rates', 'service')->runScheduledRefresh($context);
|
||||
if (function_exists('module_debug_push')) {
|
||||
module_debug_push('fx-rates', [
|
||||
'label' => 'Intervall-Aufgabe',
|
||||
'type' => 'scheduler:run',
|
||||
'task' => 'daily_refresh',
|
||||
'context' => $context,
|
||||
'message' => (string) ($result['message'] ?? ''),
|
||||
]);
|
||||
}
|
||||
return $result;
|
||||
});
|
||||
53
modules/fx-rates/module.json
Normal file
53
modules/fx-rates/module.json
Normal file
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"title": "Waehrungskurse",
|
||||
"version": "0.1.0",
|
||||
"description": "Zentrales Modul fuer Waehrungskurse, Historie und API-Abrufe.",
|
||||
"enabled_by_default": true,
|
||||
"setup": {
|
||||
"fields": [
|
||||
{ "name": "use_separate_db", "label": "Eigene Modul-DB nutzen", "type": "checkbox", "required": false, "help": "Wenn aktiv, werden die DB-Daten unten verwendet. Sonst wird die Nexus-Base-DB genutzt." },
|
||||
{ "name": "db.driver", "label": "DB Driver", "type": "text", "required": false, "help": "z.B. pgsql, mysql, sqlite" },
|
||||
{ "name": "db.host", "label": "DB Host", "type": "text", "required": false },
|
||||
{ "name": "db.port", "label": "DB Port", "type": "number", "required": false },
|
||||
{ "name": "db.dbname", "label": "DB Name", "type": "text", "required": false },
|
||||
{ "name": "db.schema", "label": "DB Schema", "type": "text", "required": false },
|
||||
{ "name": "db.user", "label": "DB User", "type": "text", "required": false },
|
||||
{ "name": "db.password", "label": "DB Passwort", "type": "password", "required": false },
|
||||
{ "name": "provider", "label": "FX Provider", "type": "text", "required": false, "help": "Aktuell getestet mit currencyapi." },
|
||||
{ "name": "api_url", "label": "FX API URL", "type": "text", "required": false },
|
||||
{ "name": "currencies_url", "label": "FX Currencies URL", "type": "text", "required": false },
|
||||
{ "name": "api_key", "label": "FX API Key", "type": "password", "required": false },
|
||||
{ "name": "timeout_sec", "label": "Timeout (Sek.)", "type": "number", "required": false },
|
||||
{ "name": "cache_ttl_sec", "label": "Datei-Cache TTL (Sek.)", "type": "number", "required": false },
|
||||
{ "name": "default_base_currency", "label": "Standard-Basiswaehrung", "type": "text", "required": false, "help": "Wird fuer taegliche Abrufe und Snapshot-Abfragen verwendet." },
|
||||
{ "name": "daily_refresh_enabled", "label": "Taeglichen Abruf aktivieren", "type": "checkbox", "required": false },
|
||||
{ "name": "daily_refresh_hour", "label": "Taegliche Abrufstunde", "type": "number", "required": false, "help": "Lokale Stunde fuer den Scheduler, standardmaessig 18." },
|
||||
{ "name": "schedule_timezone", "label": "Scheduler-Zeitzone", "type": "text", "required": false, "help": "z.B. Europe/Berlin" }
|
||||
]
|
||||
},
|
||||
"interval_tasks": [
|
||||
{
|
||||
"name": "daily_refresh",
|
||||
"label": "Taeglicher FX-Abruf",
|
||||
"callback": "scheduled_refresh",
|
||||
"enabled_setting": "daily_refresh_enabled",
|
||||
"default_enabled": true,
|
||||
"default_interval_hours": 24,
|
||||
"lock_minutes": 120
|
||||
}
|
||||
],
|
||||
"db_defaults": {
|
||||
"driver": "pgsql",
|
||||
"host": "localhost",
|
||||
"port": 5432,
|
||||
"dbname": "",
|
||||
"schema": "public",
|
||||
"user": "",
|
||||
"password": ""
|
||||
},
|
||||
"auth": {
|
||||
"required": true,
|
||||
"users": [],
|
||||
"groups": []
|
||||
}
|
||||
}
|
||||
19
modules/fx-rates/pages/index.php
Normal file
19
modules/fx-rates/pages/index.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/bootstrap.php';
|
||||
|
||||
$settings = module_fn('fx-rates', 'settings');
|
||||
$service = module_fn('fx-rates', 'service');
|
||||
$latest = $service->latestStatus();
|
||||
?>
|
||||
<?= module_shell_header('fx-rates', ['title' => 'Waehrungskurse']) ?>
|
||||
<div class="module-copy">
|
||||
<h1>Waehrungskurse</h1>
|
||||
<p>Zentrale Quelle fuer FX-Snapshots, Zeitabfragen und manuelle Aktualisierung.</p>
|
||||
<p><strong>Standard-Basis:</strong> <?= e((string) ($settings['default_base_currency'] ?? 'EUR')) ?></p>
|
||||
<p><strong>Scheduler:</strong> taeglich um <?= e(str_pad((string) ($settings['daily_refresh_hour'] ?? 18), 2, '0', STR_PAD_LEFT)) ?>:00 (<?= e((string) ($settings['schedule_timezone'] ?? 'Europe/Berlin')) ?>)</p>
|
||||
<p><strong>Letzter Abruf:</strong> <?= e((string) ($latest['fetched_at'] ?? 'noch keiner')) ?></p>
|
||||
<p><strong>API:</strong> <code>/api/fx-rates/v1/latest</code>, <code>/api/fx-rates/v1/rate</code>, <code>/api/fx-rates/v1/history</code>, <code>/api/fx-rates/v1/refresh</code></p>
|
||||
</div>
|
||||
<?= module_shell_footer() ?>
|
||||
145
modules/fx-rates/src/Api/Router.php
Normal file
145
modules/fx-rates/src/Api/Router.php
Normal file
@@ -0,0 +1,145 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\FxRates\Api;
|
||||
|
||||
use Modules\FxRates\Domain\FxRatesService;
|
||||
|
||||
final class Router
|
||||
{
|
||||
public function __construct(
|
||||
private FxRatesService $service
|
||||
) {
|
||||
}
|
||||
|
||||
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/status' && $method === 'GET') {
|
||||
$this->respond(['data' => $this->service->latestStatuses()]);
|
||||
}
|
||||
|
||||
if ($path === 'v1/latest' && $method === 'GET') {
|
||||
$symbols = $this->parseCsv($_GET['symbols'] ?? null);
|
||||
$base = $this->stringOrNull($_GET['base'] ?? null);
|
||||
$snapshot = $this->service->snapshot($base, null, $symbols, null);
|
||||
$this->respond(['data' => $snapshot]);
|
||||
}
|
||||
|
||||
if ($path === 'v1/snapshot' && $method === 'GET') {
|
||||
$symbols = $this->parseCsv($_GET['symbols'] ?? null);
|
||||
$base = $this->stringOrNull($_GET['base'] ?? null);
|
||||
$at = $this->stringOrNull($_GET['at'] ?? null);
|
||||
$windowMinutes = $this->intOrNull($_GET['window_minutes'] ?? null);
|
||||
$snapshot = $this->service->snapshot($base, $at, $symbols, $windowMinutes);
|
||||
$this->respond(['data' => $snapshot]);
|
||||
}
|
||||
|
||||
if ($path === 'v1/rate' && $method === 'GET') {
|
||||
$from = $this->stringOrNull($_GET['from'] ?? null);
|
||||
$to = $this->stringOrNull($_GET['to'] ?? null);
|
||||
$at = $this->stringOrNull($_GET['at'] ?? null);
|
||||
$windowMinutes = $this->intOrNull($_GET['window_minutes'] ?? null);
|
||||
$rate = $this->service->findRate($from, $to, $at, $windowMinutes);
|
||||
$this->respond(['data' => $rate]);
|
||||
}
|
||||
|
||||
if ($path === 'v1/history' && $method === 'GET') {
|
||||
$from = $this->stringOrNull($_GET['from'] ?? null);
|
||||
$to = $this->stringOrNull($_GET['to'] ?? null);
|
||||
$fromAt = $this->stringOrNull($_GET['from_at'] ?? null);
|
||||
$toAt = $this->stringOrNull($_GET['to_at'] ?? null);
|
||||
$limit = max(1, min(1000, (int) ($_GET['limit'] ?? 200)));
|
||||
$history = $this->service->history((string) $from, (string) $to, $fromAt, $toAt, $limit);
|
||||
$this->respond(['data' => $history]);
|
||||
}
|
||||
|
||||
if ($path === 'v1/refresh' && $method === 'POST') {
|
||||
$input = $this->input();
|
||||
$base = $this->stringOrNull($input['base'] ?? null);
|
||||
$currencies = $this->parseCsv($input['currencies'] ?? null);
|
||||
$force = !empty($input['force']);
|
||||
$maxAgeHours = is_numeric($input['max_age_hours'] ?? null) ? (float) $input['max_age_hours'] : 24.0;
|
||||
|
||||
$result = $force
|
||||
? $this->service->refreshLatestRates($currencies, $base)
|
||||
: $this->service->ensureFreshLatestRates($maxAgeHours, $base, $currencies);
|
||||
|
||||
$this->respond(['data' => $result], 201);
|
||||
}
|
||||
|
||||
if ($path === 'v1/probe' && $method === 'GET') {
|
||||
$base = $this->stringOrNull($_GET['base'] ?? null);
|
||||
$this->respond(['data' => $this->service->probeLatestRates($base)]);
|
||||
}
|
||||
|
||||
if ($path === 'v1/currencies-probe' && $method === 'GET') {
|
||||
$this->respond(['data' => $this->service->probeCurrencyCatalog()]);
|
||||
}
|
||||
|
||||
$this->respond(['error' => 'Unbekannter API-Pfad.'], 404);
|
||||
} catch (\Throwable $exception) {
|
||||
$this->respond([
|
||||
'error' => 'FX-API Fehler.',
|
||||
'context' => ['message' => $exception->getMessage()],
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
private function respond(array $payload, int $statusCode = 200): never
|
||||
{
|
||||
http_response_code($statusCode);
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
exit;
|
||||
}
|
||||
|
||||
private function input(): array
|
||||
{
|
||||
$raw = file_get_contents('php://input');
|
||||
$decoded = json_decode((string) $raw, true);
|
||||
return is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
|
||||
private function parseCsv(mixed $value): ?array
|
||||
{
|
||||
if (is_array($value)) {
|
||||
$items = $value;
|
||||
} else {
|
||||
$value = trim((string) $value);
|
||||
if ($value === '') {
|
||||
return null;
|
||||
}
|
||||
$items = explode(',', $value);
|
||||
}
|
||||
|
||||
$result = [];
|
||||
foreach ($items as $item) {
|
||||
$item = strtoupper(trim((string) $item));
|
||||
if ($item !== '') {
|
||||
$result[] = $item;
|
||||
}
|
||||
}
|
||||
|
||||
$result = array_values(array_unique($result));
|
||||
return $result !== [] ? $result : null;
|
||||
}
|
||||
|
||||
private function stringOrNull(mixed $value): ?string
|
||||
{
|
||||
$value = trim((string) $value);
|
||||
return $value !== '' ? $value : null;
|
||||
}
|
||||
|
||||
private function intOrNull(mixed $value): ?int
|
||||
{
|
||||
return is_numeric($value) ? (int) $value : null;
|
||||
}
|
||||
}
|
||||
576
modules/fx-rates/src/Domain/FxRatesService.php
Normal file
576
modules/fx-rates/src/Domain/FxRatesService.php
Normal file
@@ -0,0 +1,576 @@
|
||||
<?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->repository->getLatestFetch($this->defaultBaseCurrency());
|
||||
}
|
||||
|
||||
public function latestStatuses(): array
|
||||
{
|
||||
return $this->repository->listLatestFetches();
|
||||
}
|
||||
|
||||
public function snapshot(?string $baseCurrency = null, ?string $at = null, ?array $symbols = null, ?int $windowMinutes = null): ?array
|
||||
{
|
||||
$base = $this->normalizeCurrency($baseCurrency ?: $this->defaultBaseCurrency());
|
||||
if ($base === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($at === null || trim($at) === '') {
|
||||
$latest = $this->repository->getLatestFetch($base);
|
||||
if ($latest === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->repository->getSnapshotByFetchId((int) $latest['id'], $symbols);
|
||||
}
|
||||
|
||||
$atUtc = $this->normalizeTimestamp($at);
|
||||
if ($atUtc === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$nearest = $this->repository->getNearestFetch($base, $atUtc, $windowMinutes);
|
||||
if ($nearest === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$snapshot = $this->repository->getSnapshotByFetchId((int) $nearest['id'], $symbols);
|
||||
if ($snapshot === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $snapshot + [
|
||||
'requested_at' => $atUtc,
|
||||
'distance_seconds' => $nearest['distance_seconds'] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
public function findRate(?string $fromCurrency, ?string $toCurrency, ?string $at = null, ?int $windowMinutes = null): ?array
|
||||
{
|
||||
$from = $this->normalizeCurrency($fromCurrency);
|
||||
$to = $this->normalizeCurrency($toCurrency);
|
||||
if ($from === '' || $to === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($from === $to) {
|
||||
return [
|
||||
'base_currency' => $from,
|
||||
'target_currency' => $to,
|
||||
'rate' => 1.0,
|
||||
'provider' => 'identity',
|
||||
'fetched_at' => $at ? $this->normalizeTimestamp($at) : null,
|
||||
'rate_date' => $at ? substr((string) $this->normalizeTimestamp($at), 0, 10) : gmdate('Y-m-d'),
|
||||
'is_exact_pair' => true,
|
||||
];
|
||||
}
|
||||
|
||||
$cacheKey = implode(':', [$from, $to, $at ?? '', (string) ($windowMinutes ?? 0)]);
|
||||
if (array_key_exists($cacheKey, $this->memoryCache)) {
|
||||
return $this->memoryCache[$cacheKey];
|
||||
}
|
||||
|
||||
$candidates = array_values(array_unique(array_filter([
|
||||
$this->defaultBaseCurrency(),
|
||||
'EUR',
|
||||
'USD',
|
||||
$from,
|
||||
$to,
|
||||
], static fn (?string $value): bool => is_string($value) && trim($value) !== '')));
|
||||
|
||||
foreach ($candidates as $snapshotBase) {
|
||||
$snapshot = $this->snapshot($snapshotBase, $at, [$from, $to], $windowMinutes);
|
||||
if (!is_array($snapshot)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$rates = is_array($snapshot['rates'] ?? null) ? $snapshot['rates'] : [];
|
||||
$resolved = $this->resolveRateFromSnapshot($snapshot, $rates, $from, $to);
|
||||
if ($resolved !== null) {
|
||||
return $this->memoryCache[$cacheKey] = $resolved;
|
||||
}
|
||||
}
|
||||
|
||||
return $this->memoryCache[$cacheKey] = null;
|
||||
}
|
||||
|
||||
public function convert(?float $amount, ?string $fromCurrency, ?string $toCurrency, ?string $at = null, ?int $windowMinutes = null): ?float
|
||||
{
|
||||
if ($amount === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$rate = $this->findRate($fromCurrency, $toCurrency, $at, $windowMinutes);
|
||||
if (!is_numeric($rate['rate'] ?? null)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $amount * (float) $rate['rate'];
|
||||
}
|
||||
|
||||
public function refreshLatestRates(?array $currencies = null, ?string $baseCurrency = null): array
|
||||
{
|
||||
$base = $this->normalizeCurrency($baseCurrency ?: $this->defaultBaseCurrency());
|
||||
$payload = $this->fetchLatestPayload($base, $currencies);
|
||||
$rates = is_array($payload['rates'] ?? null) ? $payload['rates'] : [];
|
||||
$rateDate = $this->normalizeRateDate($payload['date'] ?? null);
|
||||
$saved = $this->repository->saveFetch(
|
||||
$base,
|
||||
$this->provider(),
|
||||
$rateDate,
|
||||
$rates,
|
||||
gmdate('Y-m-d H:i:s')
|
||||
);
|
||||
|
||||
return [
|
||||
'base' => $base,
|
||||
'rate_date' => $rateDate,
|
||||
'updated_count' => count($saved['rates'] ?? []),
|
||||
'rates' => $saved['rates'] ?? [],
|
||||
'fetch' => $saved['fetch'] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
public function ensureFreshLatestRates(float $maxAgeHours = 24.0, ?string $baseCurrency = null, ?array $currencies = null): array
|
||||
{
|
||||
$base = $this->normalizeCurrency($baseCurrency ?: $this->defaultBaseCurrency());
|
||||
$latest = $this->repository->getLatestFetch($base);
|
||||
$maxAgeSeconds = (int) round(max(1.0, $maxAgeHours) * 3600);
|
||||
$fetchedAt = is_array($latest) ? strtotime((string) ($latest['fetched_at'] ?? '')) : false;
|
||||
|
||||
if ($fetchedAt !== false && (time() - $fetchedAt) <= $maxAgeSeconds) {
|
||||
return [
|
||||
'base' => $base,
|
||||
'rate_date' => $latest['rate_date'] ?? null,
|
||||
'updated_count' => 0,
|
||||
'rates' => [],
|
||||
'fetch' => $latest,
|
||||
'reused' => true,
|
||||
];
|
||||
}
|
||||
|
||||
$result = $this->refreshLatestRates($currencies, $base);
|
||||
$result['reused'] = false;
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function history(string $fromCurrency, string $toCurrency, ?string $from = null, ?string $to = null, int $limit = 200): array
|
||||
{
|
||||
$fromCurrency = $this->normalizeCurrency($fromCurrency);
|
||||
$toCurrency = $this->normalizeCurrency($toCurrency);
|
||||
if ($fromCurrency === '' || $toCurrency === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
if ($fromCurrency === $toCurrency) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$direct = $this->repository->listDirectHistory($fromCurrency, $toCurrency, $this->normalizeTimestamp($from), $this->normalizeTimestamp($to), $limit);
|
||||
if ($direct !== []) {
|
||||
return $direct;
|
||||
}
|
||||
|
||||
$inverse = $this->repository->listDirectHistory($toCurrency, $fromCurrency, $this->normalizeTimestamp($from), $this->normalizeTimestamp($to), $limit);
|
||||
if ($inverse === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$result = [];
|
||||
foreach ($inverse as $row) {
|
||||
$inverseRate = is_numeric($row['rate'] ?? null) ? (float) $row['rate'] : null;
|
||||
if ($inverseRate === null || $inverseRate <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$result[] = [
|
||||
'fetch_id' => $row['fetch_id'] ?? null,
|
||||
'base_currency' => $fromCurrency,
|
||||
'target_currency' => $toCurrency,
|
||||
'rate' => 1 / $inverseRate,
|
||||
'rate_date' => $row['rate_date'] ?? null,
|
||||
'provider' => $row['provider'] ?? null,
|
||||
'fetched_at' => $row['fetched_at'] ?? null,
|
||||
'is_exact_pair' => false,
|
||||
];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function runScheduledRefresh(array $context = []): array
|
||||
{
|
||||
if (!$this->dailyRefreshEnabled()) {
|
||||
return ['ok' => true, 'message' => 'Taeglicher FX-Abruf ist deaktiviert.', 'skipped' => true];
|
||||
}
|
||||
|
||||
$timezone = $this->scheduleTimezone();
|
||||
$nowLocal = new DateTimeImmutable('now', $timezone);
|
||||
$targetHour = $this->dailyRefreshHour();
|
||||
|
||||
if ((int) $nowLocal->format('G') !== $targetHour) {
|
||||
return [
|
||||
'ok' => true,
|
||||
'message' => 'Kein FX-Abruf: aktuelles Zeitfenster ist ' . $nowLocal->format('H:i') . ', Ziel ist ' . str_pad((string) $targetHour, 2, '0', STR_PAD_LEFT) . ':00.',
|
||||
'skipped' => true,
|
||||
'context' => $context,
|
||||
];
|
||||
}
|
||||
|
||||
$latest = $this->repository->getLatestFetch($this->defaultBaseCurrency());
|
||||
if (is_array($latest) && trim((string) ($latest['fetched_at'] ?? '')) !== '') {
|
||||
$latestLocal = new DateTimeImmutable((string) $latest['fetched_at'], new DateTimeZone('UTC'));
|
||||
$latestLocal = $latestLocal->setTimezone($timezone);
|
||||
if ($latestLocal->format('Y-m-d') === $nowLocal->format('Y-m-d') && (int) $latestLocal->format('G') >= $targetHour) {
|
||||
return [
|
||||
'ok' => true,
|
||||
'message' => 'Kein FX-Abruf: fuer heute existiert bereits ein Snapshot nach ' . str_pad((string) $targetHour, 2, '0', STR_PAD_LEFT) . ':00.',
|
||||
'skipped' => true,
|
||||
'fetch' => $latest,
|
||||
'context' => $context,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$result = $this->refreshLatestRates(null, $this->defaultBaseCurrency());
|
||||
return [
|
||||
'ok' => true,
|
||||
'message' => 'Taeglicher FX-Abruf ausgefuehrt: ' . (int) ($result['updated_count'] ?? 0) . ' Kurse gespeichert.',
|
||||
'result' => $result,
|
||||
'context' => $context,
|
||||
];
|
||||
}
|
||||
|
||||
public function refreshCurrencyCatalog(): array
|
||||
{
|
||||
$payload = $this->fetchCurrenciesPayload();
|
||||
$currencies = is_array($payload['currencies'] ?? null) ? $payload['currencies'] : [];
|
||||
$items = [];
|
||||
foreach ($currencies as $code => $name) {
|
||||
$code = $this->normalizeCurrency((string) $code);
|
||||
$name = trim((string) $name);
|
||||
if ($code === '' || $name === '') {
|
||||
continue;
|
||||
}
|
||||
$items[] = [
|
||||
'code' => $code,
|
||||
'name' => $name,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'synced_count' => count($items),
|
||||
'currencies' => $items,
|
||||
];
|
||||
}
|
||||
|
||||
public function probeCurrencyCatalog(): array
|
||||
{
|
||||
$payload = $this->fetchCurrenciesPayload();
|
||||
return [
|
||||
'ok' => !empty($payload['currencies']),
|
||||
'provider' => $this->provider(),
|
||||
'currencies_count' => is_array($payload['currencies'] ?? null) ? count($payload['currencies']) : 0,
|
||||
];
|
||||
}
|
||||
|
||||
public function probeLatestRates(?string $baseCurrency = null): array
|
||||
{
|
||||
$base = $this->normalizeCurrency($baseCurrency ?: $this->defaultBaseCurrency());
|
||||
$payload = $this->fetchLatestPayload($base, null);
|
||||
return [
|
||||
'ok' => !empty($payload['rates']),
|
||||
'provider' => $this->provider(),
|
||||
'base' => $base,
|
||||
'rate_count' => is_array($payload['rates'] ?? null) ? count($payload['rates']) : 0,
|
||||
'date' => $payload['date'] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
private function resolveRateFromSnapshot(array $snapshot, array $rates, string $from, string $to): ?array
|
||||
{
|
||||
$base = strtoupper((string) ($snapshot['base_currency'] ?? ''));
|
||||
$rate = null;
|
||||
$isExactPair = false;
|
||||
|
||||
if ($base === $from && is_numeric($rates[$to] ?? null)) {
|
||||
$rate = (float) $rates[$to];
|
||||
$isExactPair = true;
|
||||
} elseif ($base === $to && is_numeric($rates[$from] ?? null) && (float) $rates[$from] > 0) {
|
||||
$rate = 1 / (float) $rates[$from];
|
||||
$isExactPair = true;
|
||||
} elseif (is_numeric($rates[$from] ?? null) && is_numeric($rates[$to] ?? null) && (float) $rates[$from] > 0) {
|
||||
$rate = (float) $rates[$to] / (float) $rates[$from];
|
||||
}
|
||||
|
||||
if ($rate === null || $rate <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'base_currency' => $from,
|
||||
'target_currency' => $to,
|
||||
'rate' => $rate,
|
||||
'provider' => $snapshot['provider'] ?? null,
|
||||
'fetched_at' => $snapshot['fetched_at'] ?? null,
|
||||
'rate_date' => $snapshot['rate_date'] ?? null,
|
||||
'snapshot_base_currency' => $base,
|
||||
'distance_seconds' => $snapshot['distance_seconds'] ?? null,
|
||||
'requested_at' => $snapshot['requested_at'] ?? null,
|
||||
'is_exact_pair' => $isExactPair,
|
||||
];
|
||||
}
|
||||
|
||||
private function fetchLatestPayload(string $baseCurrency, ?array $currencies = null): array
|
||||
{
|
||||
if (!function_exists('curl_init')) {
|
||||
throw new \RuntimeException('curl_init ist nicht verfuegbar.');
|
||||
}
|
||||
|
||||
$url = $this->buildLatestUrl($baseCurrency);
|
||||
if ($url === null) {
|
||||
throw new \RuntimeException('FX-URL oder API-Key fehlt.');
|
||||
}
|
||||
|
||||
$ch = curl_init();
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_URL => $url,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_FOLLOWLOCATION => true,
|
||||
CURLOPT_TIMEOUT => $this->timeoutSeconds(),
|
||||
CURLOPT_HTTPHEADER => ['Accept: application/json'],
|
||||
]);
|
||||
$response = curl_exec($ch);
|
||||
$httpStatus = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
|
||||
$curlError = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
if ($response === false || $curlError !== '' || $httpStatus >= 400) {
|
||||
throw new \RuntimeException('FX-Kurse konnten nicht geladen werden.');
|
||||
}
|
||||
|
||||
$payload = json_decode((string) $response, true);
|
||||
if (!is_array($payload)) {
|
||||
throw new \RuntimeException('FX-Antwort ist kein gueltiges JSON.');
|
||||
}
|
||||
|
||||
$rates = [];
|
||||
if ($this->provider() === 'currencyapi') {
|
||||
if (($payload['valid'] ?? false) !== true || !is_array($payload['rates'] ?? null)) {
|
||||
throw new \RuntimeException($this->extractProviderError($payload, 'FX-Kurse konnten nicht geladen werden.'));
|
||||
}
|
||||
foreach ($payload['rates'] as $code => $rate) {
|
||||
$code = $this->normalizeCurrency((string) $code);
|
||||
if ($code === '' || $code === $baseCurrency || !is_numeric($rate)) {
|
||||
continue;
|
||||
}
|
||||
$rates[$code] = (float) $rate;
|
||||
}
|
||||
} else {
|
||||
$rawRates = is_array($payload['rates'] ?? null) ? $payload['rates'] : [];
|
||||
foreach ($rawRates as $code => $rate) {
|
||||
$code = $this->normalizeCurrency((string) $code);
|
||||
if ($code === '' || $code === $baseCurrency || !is_numeric($rate)) {
|
||||
continue;
|
||||
}
|
||||
$rates[$code] = (float) $rate;
|
||||
}
|
||||
}
|
||||
|
||||
if (is_array($currencies) && $currencies !== []) {
|
||||
$wanted = [];
|
||||
foreach ($currencies as $currency) {
|
||||
$currency = $this->normalizeCurrency((string) $currency);
|
||||
if ($currency !== '' && isset($rates[$currency])) {
|
||||
$wanted[$currency] = $rates[$currency];
|
||||
}
|
||||
}
|
||||
$rates = $wanted;
|
||||
}
|
||||
|
||||
return [
|
||||
'base' => $baseCurrency,
|
||||
'date' => $payload['updated'] ?? $payload['date'] ?? null,
|
||||
'rates' => $rates,
|
||||
];
|
||||
}
|
||||
|
||||
private function fetchCurrenciesPayload(): array
|
||||
{
|
||||
if (!function_exists('curl_init')) {
|
||||
throw new \RuntimeException('curl_init ist nicht verfuegbar.');
|
||||
}
|
||||
|
||||
$apiKey = $this->apiKey();
|
||||
if ($apiKey === '') {
|
||||
throw new \RuntimeException('FX-API-Key fehlt.');
|
||||
}
|
||||
|
||||
$url = sprintf(
|
||||
'%s/api/v2/currencies?output=json&key=%s',
|
||||
$this->currenciesApiUrl(),
|
||||
rawurlencode($apiKey)
|
||||
);
|
||||
|
||||
$ch = curl_init();
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_URL => $url,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_FOLLOWLOCATION => true,
|
||||
CURLOPT_TIMEOUT => $this->timeoutSeconds(),
|
||||
CURLOPT_HTTPHEADER => ['Accept: application/json'],
|
||||
]);
|
||||
$response = curl_exec($ch);
|
||||
$httpStatus = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
|
||||
$curlError = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
if ($response === false || $curlError !== '' || $httpStatus >= 400) {
|
||||
throw new \RuntimeException('Waehrungskatalog konnte nicht geladen werden.');
|
||||
}
|
||||
|
||||
$payload = json_decode((string) $response, true);
|
||||
if (!is_array($payload) || ($payload['valid'] ?? false) !== true) {
|
||||
throw new \RuntimeException($this->extractProviderError(is_array($payload) ? $payload : [], 'Waehrungskatalog konnte nicht geladen werden.'));
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
private function buildLatestUrl(string $baseCurrency): ?string
|
||||
{
|
||||
$apiKey = $this->apiKey();
|
||||
if ($this->provider() === 'currencyapi') {
|
||||
if ($apiKey === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return sprintf(
|
||||
'%s/api/v2/rates?base=%s&output=json&key=%s',
|
||||
$this->apiUrl(),
|
||||
rawurlencode($baseCurrency),
|
||||
rawurlencode($apiKey)
|
||||
);
|
||||
}
|
||||
|
||||
return sprintf('%s/latest?base=%s', $this->apiUrl(), rawurlencode($baseCurrency));
|
||||
}
|
||||
|
||||
private function extractProviderError(array $payload, string $fallback): string
|
||||
{
|
||||
foreach (['error', 'message', 'msg'] as $field) {
|
||||
$value = $payload[$field] ?? null;
|
||||
if (is_string($value) && trim($value) !== '') {
|
||||
return trim($value);
|
||||
}
|
||||
}
|
||||
|
||||
return $fallback;
|
||||
}
|
||||
|
||||
private function normalizeCurrency(?string $currency): string
|
||||
{
|
||||
return strtoupper(trim((string) $currency));
|
||||
}
|
||||
|
||||
private function normalizeTimestamp(?string $value): ?string
|
||||
{
|
||||
$value = trim((string) $value);
|
||||
if ($value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$date = new DateTimeImmutable($value);
|
||||
return $date->setTimezone(new DateTimeZone('UTC'))->format('Y-m-d H:i:s');
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private function normalizeRateDate(mixed $value): string
|
||||
{
|
||||
if (is_string($value) && trim($value) !== '') {
|
||||
$timestamp = strtotime($value);
|
||||
if ($timestamp !== false) {
|
||||
return gmdate('Y-m-d', $timestamp);
|
||||
}
|
||||
}
|
||||
|
||||
if (is_int($value) || is_float($value)) {
|
||||
return gmdate('Y-m-d', (int) $value);
|
||||
}
|
||||
|
||||
return gmdate('Y-m-d');
|
||||
}
|
||||
|
||||
private function provider(): string
|
||||
{
|
||||
$provider = strtolower(trim((string) ($this->settings['provider'] ?? 'currencyapi')));
|
||||
return $provider !== '' ? $provider : 'currencyapi';
|
||||
}
|
||||
|
||||
private function apiUrl(): string
|
||||
{
|
||||
return rtrim((string) ($this->settings['api_url'] ?? 'https://currencyapi.net'), '/');
|
||||
}
|
||||
|
||||
private function currenciesApiUrl(): string
|
||||
{
|
||||
return rtrim((string) ($this->settings['currencies_url'] ?? $this->apiUrl()), '/');
|
||||
}
|
||||
|
||||
private function apiKey(): string
|
||||
{
|
||||
return trim((string) ($this->settings['api_key'] ?? ''));
|
||||
}
|
||||
|
||||
private function timeoutSeconds(): int
|
||||
{
|
||||
return max(2, (int) ($this->settings['timeout_sec'] ?? 10));
|
||||
}
|
||||
|
||||
private function defaultBaseCurrency(): string
|
||||
{
|
||||
return $this->normalizeCurrency((string) ($this->settings['default_base_currency'] ?? 'EUR')) ?: 'EUR';
|
||||
}
|
||||
|
||||
private function dailyRefreshEnabled(): bool
|
||||
{
|
||||
return !empty($this->settings['daily_refresh_enabled']);
|
||||
}
|
||||
|
||||
private function dailyRefreshHour(): int
|
||||
{
|
||||
return max(0, min(23, (int) ($this->settings['daily_refresh_hour'] ?? 18)));
|
||||
}
|
||||
|
||||
private function scheduleTimezone(): DateTimeZone
|
||||
{
|
||||
$timezone = trim((string) ($this->settings['schedule_timezone'] ?? 'Europe/Berlin'));
|
||||
try {
|
||||
return new DateTimeZone($timezone);
|
||||
} catch (\Throwable) {
|
||||
return new DateTimeZone('Europe/Berlin');
|
||||
}
|
||||
}
|
||||
}
|
||||
418
modules/fx-rates/src/Infrastructure/FxRatesRepository.php
Normal file
418
modules/fx-rates/src/Infrastructure/FxRatesRepository.php
Normal file
@@ -0,0 +1,418 @@
|
||||
<?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,
|
||||
base_currency VARCHAR(10) NOT NULL,
|
||||
rate_date DATE NOT NULL,
|
||||
fetched_at TIMESTAMP NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)");
|
||||
$this->pdo->exec("CREATE TABLE IF NOT EXISTS {$rateTable} (
|
||||
id SERIAL PRIMARY KEY,
|
||||
fetch_id INTEGER NOT NULL REFERENCES {$fetchTable}(id) ON DELETE CASCADE,
|
||||
currency_code VARCHAR(10) NOT NULL,
|
||||
current_value NUMERIC(20,10) NOT NULL
|
||||
)");
|
||||
$this->pdo->exec("CREATE INDEX IF NOT EXISTS {$fetchTable}_base_fetch_idx ON {$fetchTable} (base_currency, fetched_at DESC, id DESC)");
|
||||
$this->pdo->exec("CREATE INDEX IF NOT EXISTS {$fetchTable}_rate_date_idx ON {$fetchTable} (rate_date DESC)");
|
||||
$this->pdo->exec("CREATE INDEX IF NOT EXISTS {$rateTable}_fetch_idx ON {$rateTable} (fetch_id)");
|
||||
$this->pdo->exec("CREATE INDEX IF NOT EXISTS {$rateTable}_currency_idx ON {$rateTable} (currency_code)");
|
||||
} elseif ($this->driver === 'mysql') {
|
||||
$this->pdo->exec("CREATE TABLE IF NOT EXISTS {$fetchTable} (
|
||||
id INTEGER PRIMARY KEY AUTO_INCREMENT,
|
||||
provider VARCHAR(64) NOT NULL,
|
||||
base_currency VARCHAR(10) NOT NULL,
|
||||
rate_date DATE NOT NULL,
|
||||
fetched_at DATETIME NOT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
KEY {$fetchTable}_base_fetch_idx (base_currency, fetched_at, id),
|
||||
KEY {$fetchTable}_rate_date_idx (rate_date)
|
||||
)");
|
||||
$this->pdo->exec("CREATE TABLE IF NOT EXISTS {$rateTable} (
|
||||
id INTEGER PRIMARY KEY AUTO_INCREMENT,
|
||||
fetch_id INTEGER NOT NULL,
|
||||
currency_code VARCHAR(10) NOT NULL,
|
||||
current_value DECIMAL(20,10) NOT NULL,
|
||||
KEY {$rateTable}_fetch_idx (fetch_id),
|
||||
KEY {$rateTable}_currency_idx (currency_code)
|
||||
)");
|
||||
} else {
|
||||
$this->pdo->exec("CREATE TABLE IF NOT EXISTS {$fetchTable} (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
provider VARCHAR(64) NOT NULL,
|
||||
base_currency VARCHAR(10) NOT NULL,
|
||||
rate_date DATE NOT NULL,
|
||||
fetched_at DATETIME NOT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)");
|
||||
$this->pdo->exec("CREATE TABLE IF NOT EXISTS {$rateTable} (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
fetch_id INTEGER NOT NULL,
|
||||
currency_code VARCHAR(10) NOT NULL,
|
||||
current_value DECIMAL(20,10) NOT NULL
|
||||
)");
|
||||
$this->pdo->exec("CREATE INDEX IF NOT EXISTS {$fetchTable}_base_fetch_idx ON {$fetchTable} (base_currency, fetched_at DESC, id DESC)");
|
||||
$this->pdo->exec("CREATE INDEX IF NOT EXISTS {$fetchTable}_rate_date_idx ON {$fetchTable} (rate_date DESC)");
|
||||
$this->pdo->exec("CREATE INDEX IF NOT EXISTS {$rateTable}_fetch_idx ON {$rateTable} (fetch_id)");
|
||||
$this->pdo->exec("CREATE INDEX IF NOT EXISTS {$rateTable}_currency_idx ON {$rateTable} (currency_code)");
|
||||
}
|
||||
}
|
||||
|
||||
public function getLatestFetch(?string $baseCurrency = null): ?array
|
||||
{
|
||||
$sql = 'SELECT id, provider, base_currency, rate_date, fetched_at, created_at FROM ' . $this->table('fetches');
|
||||
$params = [];
|
||||
if ($baseCurrency !== null && trim($baseCurrency) !== '') {
|
||||
$sql .= ' WHERE base_currency = :base_currency';
|
||||
$params['base_currency'] = strtoupper(trim($baseCurrency));
|
||||
}
|
||||
$sql .= ' ORDER BY fetched_at DESC, id DESC LIMIT 1';
|
||||
$stmt = $this->pdo->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
return is_array($row) ? $this->normalizeFetch($row) : null;
|
||||
}
|
||||
|
||||
public function listLatestFetches(): array
|
||||
{
|
||||
$stmt = $this->pdo->query(
|
||||
'SELECT id, provider, base_currency, rate_date, fetched_at, created_at
|
||||
FROM ' . $this->table('fetches') . '
|
||||
ORDER BY fetched_at DESC, id DESC'
|
||||
);
|
||||
|
||||
$latestByBase = [];
|
||||
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) ?: [] as $row) {
|
||||
$base = strtoupper(trim((string) ($row['base_currency'] ?? '')));
|
||||
if ($base === '' || isset($latestByBase[$base])) {
|
||||
continue;
|
||||
}
|
||||
$latestByBase[$base] = $this->normalizeFetch($row);
|
||||
}
|
||||
|
||||
ksort($latestByBase);
|
||||
return array_values($latestByBase);
|
||||
}
|
||||
|
||||
public function getSnapshotByFetchId(int $fetchId, ?array $symbols = null): ?array
|
||||
{
|
||||
$fetch = $this->getFetchById($fetchId);
|
||||
if ($fetch === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $fetch + [
|
||||
'rates' => $this->ratesForFetch($fetchId, $symbols),
|
||||
];
|
||||
}
|
||||
|
||||
public function getNearestFetch(string $baseCurrency, string $timestamp, ?int $windowMinutes = null): ?array
|
||||
{
|
||||
$baseCurrency = strtoupper(trim($baseCurrency));
|
||||
if ($baseCurrency === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$before = $this->findNeighborFetch($baseCurrency, $timestamp, '<=');
|
||||
$after = $this->findNeighborFetch($baseCurrency, $timestamp, '>=');
|
||||
$targetTs = strtotime($timestamp);
|
||||
if ($targetTs === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$selected = null;
|
||||
$selectedDiff = null;
|
||||
foreach ([$before, $after] as $candidate) {
|
||||
if (!is_array($candidate)) {
|
||||
continue;
|
||||
}
|
||||
$candidateTs = strtotime((string) ($candidate['fetched_at'] ?? ''));
|
||||
if ($candidateTs === false) {
|
||||
continue;
|
||||
}
|
||||
$diffSeconds = abs($candidateTs - $targetTs);
|
||||
if ($selected === null || $diffSeconds < (int) $selectedDiff) {
|
||||
$selected = $candidate;
|
||||
$selectedDiff = $diffSeconds;
|
||||
}
|
||||
}
|
||||
|
||||
if ($selected === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($windowMinutes !== null && $windowMinutes > 0 && $selectedDiff !== null && $selectedDiff > ($windowMinutes * 60)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $selected + ['distance_seconds' => $selectedDiff];
|
||||
}
|
||||
|
||||
public function listDirectHistory(string $baseCurrency, string $targetCurrency, ?string $from = null, ?string $to = null, int $limit = 200): array
|
||||
{
|
||||
$sql = 'SELECT
|
||||
r.id,
|
||||
f.id AS fetch_id,
|
||||
f.base_currency,
|
||||
r.currency_code AS target_currency,
|
||||
r.current_value AS rate,
|
||||
f.rate_date,
|
||||
f.provider,
|
||||
f.fetched_at
|
||||
FROM ' . $this->table('rates') . ' r
|
||||
INNER JOIN ' . $this->table('fetches') . ' f ON f.id = r.fetch_id
|
||||
WHERE f.base_currency = :base_currency
|
||||
AND r.currency_code = :target_currency';
|
||||
$params = [
|
||||
'base_currency' => strtoupper(trim($baseCurrency)),
|
||||
'target_currency' => strtoupper(trim($targetCurrency)),
|
||||
];
|
||||
|
||||
if ($from !== null && trim($from) !== '') {
|
||||
$sql .= ' AND f.fetched_at >= :from_at';
|
||||
$params['from_at'] = $from;
|
||||
}
|
||||
if ($to !== null && trim($to) !== '') {
|
||||
$sql .= ' AND f.fetched_at <= :to_at';
|
||||
$params['to_at'] = $to;
|
||||
}
|
||||
|
||||
$sql .= ' ORDER BY f.fetched_at ASC, r.id ASC LIMIT :limit';
|
||||
$stmt = $this->pdo->prepare($sql);
|
||||
foreach ($params as $key => $value) {
|
||||
$stmt->bindValue(':' . $key, $value);
|
||||
}
|
||||
$stmt->bindValue(':limit', max(1, $limit), PDO::PARAM_INT);
|
||||
$stmt->execute();
|
||||
|
||||
return array_map(fn (array $row): array => $this->normalizeRate($row), $stmt->fetchAll(PDO::FETCH_ASSOC) ?: []);
|
||||
}
|
||||
|
||||
public function saveFetch(string $baseCurrency, string $provider, string $rateDate, array $rates, ?string $fetchedAt = null): array
|
||||
{
|
||||
$baseCurrency = strtoupper(trim($baseCurrency));
|
||||
$provider = trim($provider) !== '' ? trim($provider) : 'currencyapi';
|
||||
$fetchedAt = trim((string) $fetchedAt) !== '' ? trim((string) $fetchedAt) : gmdate('Y-m-d H:i:s');
|
||||
$normalizedRates = [];
|
||||
foreach ($rates as $currencyCode => $rate) {
|
||||
$currencyCode = strtoupper(trim((string) $currencyCode));
|
||||
if ($currencyCode === '' || $currencyCode === $baseCurrency || !is_numeric($rate)) {
|
||||
continue;
|
||||
}
|
||||
$normalizedRates[$currencyCode] = (float) $rate;
|
||||
}
|
||||
|
||||
$startedTransaction = false;
|
||||
if (!$this->pdo->inTransaction()) {
|
||||
$this->pdo->beginTransaction();
|
||||
$startedTransaction = true;
|
||||
}
|
||||
|
||||
try {
|
||||
if ($this->driver === 'pgsql') {
|
||||
$fetchStmt = $this->pdo->prepare(
|
||||
'INSERT INTO ' . $this->table('fetches') . ' (
|
||||
provider, base_currency, rate_date, fetched_at
|
||||
) VALUES (
|
||||
:provider, :base_currency, :rate_date, :fetched_at
|
||||
)
|
||||
RETURNING *'
|
||||
);
|
||||
$fetchStmt->execute([
|
||||
'provider' => $provider,
|
||||
'base_currency' => $baseCurrency,
|
||||
'rate_date' => $rateDate,
|
||||
'fetched_at' => $fetchedAt,
|
||||
]);
|
||||
$fetch = $this->normalizeFetch($fetchStmt->fetch(PDO::FETCH_ASSOC) ?: []);
|
||||
} else {
|
||||
$fetchStmt = $this->pdo->prepare(
|
||||
'INSERT INTO ' . $this->table('fetches') . ' (
|
||||
provider, base_currency, rate_date, fetched_at
|
||||
) VALUES (
|
||||
:provider, :base_currency, :rate_date, :fetched_at
|
||||
)'
|
||||
);
|
||||
$fetchStmt->execute([
|
||||
'provider' => $provider,
|
||||
'base_currency' => $baseCurrency,
|
||||
'rate_date' => $rateDate,
|
||||
'fetched_at' => $fetchedAt,
|
||||
]);
|
||||
$fetch = $this->getFetchById((int) $this->pdo->lastInsertId()) ?? [];
|
||||
}
|
||||
|
||||
$savedRates = [];
|
||||
if ($normalizedRates !== []) {
|
||||
$placeholders = [];
|
||||
$params = ['fetch_id' => (int) ($fetch['id'] ?? 0)];
|
||||
$index = 0;
|
||||
foreach ($normalizedRates as $currencyCode => $rate) {
|
||||
$codeKey = 'currency_code_' . $index;
|
||||
$valueKey = 'current_value_' . $index;
|
||||
$placeholders[] = "(:fetch_id, :{$codeKey}, :{$valueKey})";
|
||||
$params[$codeKey] = $currencyCode;
|
||||
$params[$valueKey] = $rate;
|
||||
$savedRates[] = [
|
||||
'fetch_id' => $fetch['id'] ?? null,
|
||||
'base_currency' => $baseCurrency,
|
||||
'target_currency' => $currencyCode,
|
||||
'rate' => $rate,
|
||||
'rate_date' => $rateDate,
|
||||
'provider' => $provider,
|
||||
'fetched_at' => $fetchedAt,
|
||||
];
|
||||
$index++;
|
||||
}
|
||||
|
||||
$insert = $this->pdo->prepare(
|
||||
'INSERT INTO ' . $this->table('rates') . ' (fetch_id, currency_code, current_value) VALUES ' . implode(', ', $placeholders)
|
||||
);
|
||||
$insert->execute($params);
|
||||
}
|
||||
|
||||
if ($startedTransaction) {
|
||||
$this->pdo->commit();
|
||||
}
|
||||
|
||||
return [
|
||||
'fetch' => $fetch,
|
||||
'rates' => $savedRates,
|
||||
];
|
||||
} catch (\Throwable $exception) {
|
||||
if ($startedTransaction && $this->pdo->inTransaction()) {
|
||||
$this->pdo->rollBack();
|
||||
}
|
||||
throw $exception;
|
||||
}
|
||||
}
|
||||
|
||||
private function getFetchById(int $fetchId): ?array
|
||||
{
|
||||
$stmt = $this->pdo->prepare(
|
||||
'SELECT id, provider, base_currency, rate_date, fetched_at, created_at
|
||||
FROM ' . $this->table('fetches') . '
|
||||
WHERE id = :id
|
||||
LIMIT 1'
|
||||
);
|
||||
$stmt->execute(['id' => $fetchId]);
|
||||
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
return is_array($row) ? $this->normalizeFetch($row) : null;
|
||||
}
|
||||
|
||||
private function findNeighborFetch(string $baseCurrency, string $timestamp, string $operator): ?array
|
||||
{
|
||||
$order = $operator === '<=' ? 'DESC' : 'ASC';
|
||||
$stmt = $this->pdo->prepare(
|
||||
'SELECT id, provider, base_currency, rate_date, fetched_at, created_at
|
||||
FROM ' . $this->table('fetches') . '
|
||||
WHERE base_currency = :base_currency
|
||||
AND fetched_at ' . $operator . ' :target_at
|
||||
ORDER BY fetched_at ' . $order . ', id ' . $order . '
|
||||
LIMIT 1'
|
||||
);
|
||||
$stmt->execute([
|
||||
'base_currency' => $baseCurrency,
|
||||
'target_at' => $timestamp,
|
||||
]);
|
||||
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
return is_array($row) ? $this->normalizeFetch($row) : null;
|
||||
}
|
||||
|
||||
private function ratesForFetch(int $fetchId, ?array $symbols = null): array
|
||||
{
|
||||
$sql = 'SELECT currency_code, current_value FROM ' . $this->table('rates') . ' WHERE fetch_id = :fetch_id';
|
||||
$params = ['fetch_id' => $fetchId];
|
||||
|
||||
$normalizedSymbols = [];
|
||||
if (is_array($symbols)) {
|
||||
foreach ($symbols as $symbol) {
|
||||
$symbol = strtoupper(trim((string) $symbol));
|
||||
if ($symbol !== '') {
|
||||
$normalizedSymbols[] = $symbol;
|
||||
}
|
||||
}
|
||||
$normalizedSymbols = array_values(array_unique($normalizedSymbols));
|
||||
}
|
||||
|
||||
if ($normalizedSymbols !== []) {
|
||||
$placeholders = [];
|
||||
foreach ($normalizedSymbols as $index => $symbol) {
|
||||
$key = 'symbol_' . $index;
|
||||
$placeholders[] = ':' . $key;
|
||||
$params[$key] = $symbol;
|
||||
}
|
||||
$sql .= ' AND currency_code IN (' . implode(', ', $placeholders) . ')';
|
||||
}
|
||||
|
||||
$sql .= ' ORDER BY currency_code ASC';
|
||||
$stmt = $this->pdo->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
|
||||
$rates = [];
|
||||
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) ?: [] as $row) {
|
||||
$code = strtoupper(trim((string) ($row['currency_code'] ?? '')));
|
||||
$rate = $row['current_value'] ?? null;
|
||||
if ($code === '' || !is_numeric($rate)) {
|
||||
continue;
|
||||
}
|
||||
$rates[$code] = (float) $rate;
|
||||
}
|
||||
|
||||
return $rates;
|
||||
}
|
||||
|
||||
private function normalizeFetch(array $row): array
|
||||
{
|
||||
return [
|
||||
'id' => isset($row['id']) ? (int) $row['id'] : null,
|
||||
'provider' => (string) ($row['provider'] ?? ''),
|
||||
'base_currency' => strtoupper((string) ($row['base_currency'] ?? '')),
|
||||
'rate_date' => (string) ($row['rate_date'] ?? ''),
|
||||
'fetched_at' => (string) ($row['fetched_at'] ?? ''),
|
||||
'created_at' => (string) ($row['created_at'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
private function normalizeRate(array $row): array
|
||||
{
|
||||
return [
|
||||
'id' => isset($row['id']) ? (int) $row['id'] : null,
|
||||
'fetch_id' => isset($row['fetch_id']) ? (int) $row['fetch_id'] : null,
|
||||
'base_currency' => strtoupper((string) ($row['base_currency'] ?? '')),
|
||||
'target_currency' => strtoupper((string) ($row['target_currency'] ?? '')),
|
||||
'rate' => is_numeric($row['rate'] ?? null) ? (float) $row['rate'] : null,
|
||||
'rate_date' => (string) ($row['rate_date'] ?? ''),
|
||||
'provider' => (string) ($row['provider'] ?? ''),
|
||||
'fetched_at' => (string) ($row['fetched_at'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
private function table(string $logicalName): string
|
||||
{
|
||||
return $this->tablePrefix . preg_replace('/[^a-zA-Z0-9_]/', '', $logicalName);
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,12 @@ final class FxService
|
||||
|
||||
public function convert(?float $amount, ?string $from, ?string $to): ?float
|
||||
{
|
||||
$shared = $this->sharedFxService();
|
||||
if ($shared !== null && method_exists($shared, 'convert')) {
|
||||
$converted = $shared->convert($amount, $from, $to, null, null);
|
||||
return is_numeric($converted) ? (float) $converted : null;
|
||||
}
|
||||
|
||||
if ($amount === null || $from === null || $to === null) {
|
||||
return null;
|
||||
}
|
||||
@@ -54,6 +60,12 @@ final class FxService
|
||||
|
||||
public function rate(?string $from, ?string $to): ?float
|
||||
{
|
||||
$shared = $this->sharedFxService();
|
||||
if ($shared !== null && method_exists($shared, 'findRate')) {
|
||||
$resolved = $shared->findRate($from, $to, null, null);
|
||||
return is_array($resolved) && is_numeric($resolved['rate'] ?? null) ? (float) $resolved['rate'] : null;
|
||||
}
|
||||
|
||||
$base = strtoupper(trim((string) $from));
|
||||
$target = strtoupper(trim((string) $to));
|
||||
|
||||
@@ -97,6 +109,11 @@ final class FxService
|
||||
|
||||
public function refreshLatestRates(?array $currencies = null, string $base = 'EUR'): array
|
||||
{
|
||||
$shared = $this->sharedFxService();
|
||||
if ($shared !== null && method_exists($shared, 'refreshLatestRates')) {
|
||||
return $shared->refreshLatestRates($currencies, $base);
|
||||
}
|
||||
|
||||
$normalizedBase = strtoupper(trim($base));
|
||||
$targets = $currencies === null
|
||||
? null
|
||||
@@ -132,6 +149,11 @@ final class FxService
|
||||
|
||||
public function ensureFreshLatestRates(float $maxAgeHours = 3.0, string $base = 'USD'): array
|
||||
{
|
||||
$shared = $this->sharedFxService();
|
||||
if ($shared !== null && method_exists($shared, 'ensureFreshLatestRates')) {
|
||||
return $shared->ensureFreshLatestRates($maxAgeHours, $base, null);
|
||||
}
|
||||
|
||||
$normalizedBase = strtoupper(trim($base));
|
||||
$maxAgeHours = $maxAgeHours > 0 ? $maxAgeHours : 3.0;
|
||||
|
||||
@@ -176,12 +198,22 @@ final class FxService
|
||||
|
||||
public function probeLatestRates(string $base = 'EUR'): array
|
||||
{
|
||||
$shared = $this->sharedFxService();
|
||||
if ($shared !== null && method_exists($shared, 'probeLatestRates')) {
|
||||
return $shared->probeLatestRates($base);
|
||||
}
|
||||
|
||||
$normalizedBase = strtoupper(trim($base));
|
||||
return $this->fetchLatestProbe($normalizedBase);
|
||||
}
|
||||
|
||||
public function refreshCurrencyCatalog(): array
|
||||
{
|
||||
$shared = $this->sharedFxService();
|
||||
if ($shared !== null && method_exists($shared, 'refreshCurrencyCatalog')) {
|
||||
return $shared->refreshCurrencyCatalog();
|
||||
}
|
||||
|
||||
if ($this->repository === null) {
|
||||
return [
|
||||
'synced_count' => 0,
|
||||
@@ -235,6 +267,11 @@ final class FxService
|
||||
|
||||
public function probeCurrencyCatalog(): array
|
||||
{
|
||||
$shared = $this->sharedFxService();
|
||||
if ($shared !== null && method_exists($shared, 'probeCurrencyCatalog')) {
|
||||
return $shared->probeCurrencyCatalog();
|
||||
}
|
||||
|
||||
return $this->fetchCurrenciesProbe();
|
||||
}
|
||||
|
||||
@@ -756,4 +793,18 @@ final class FxService
|
||||
return $matches[1] . substr($key, 0, 6) . '...' . substr($key, -4);
|
||||
}, $url) ?: $url;
|
||||
}
|
||||
|
||||
private function sharedFxService(): ?object
|
||||
{
|
||||
if (!function_exists('modules') || !modules()->isEnabled('fx-rates') || !modules()->hasFunction('fx-rates', 'service')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$service = module_fn('fx-rates', 'service');
|
||||
return is_object($service) ? $service : null;
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user