diff --git a/modules/fx-rates/bootstrap.php b/modules/fx-rates/bootstrap.php
index f1a921b..e7b9672 100644
--- a/modules/fx-rates/bootstrap.php
+++ b/modules/fx-rates/bootstrap.php
@@ -62,6 +62,7 @@ $mm->registerFunction($moduleName, 'settings', static function (): array {
'api_url' => $apiUrl,
'api_key' => $apiKey,
'timeout_sec' => $timeout,
+ 'refresh_max_age_minutes' => max(1, (int) ($saved['refresh_max_age_minutes'] ?? 60)),
'default_base_currency' => strtoupper(trim((string) ($saved['default_base_currency'] ?? 'EUR'))) ?: 'EUR',
'display_base_currency' => strtoupper(trim((string) ($saved['display_base_currency'] ?? ($saved['default_base_currency'] ?? 'EUR')))) ?: 'EUR',
'preferred_currencies' => $preferredCurrencies,
diff --git a/modules/fx-rates/module.json b/modules/fx-rates/module.json
index 9f85972..508e36b 100644
--- a/modules/fx-rates/module.json
+++ b/modules/fx-rates/module.json
@@ -1,6 +1,6 @@
{
"title": "Waehrungskurse",
- "version": "0.1.4",
+ "version": "0.1.5",
"description": "Zentrales Modul fuer Waehrungskurse, Historie und API-Abrufe.",
"enabled_by_default": true,
"setup": {
@@ -18,6 +18,7 @@
{ "name": "api_url", "label": "FX API URL", "type": "text", "required": false, "help": "Nur die Basis-URL eintragen, z.B. https://api.currencyapi.com oder https://currencyapi.net." },
{ "name": "api_key", "label": "FX API Key", "type": "password", "required": false },
{ "name": "timeout_sec", "label": "Timeout (Sek.)", "type": "number", "required": false },
+ { "name": "refresh_max_age_minutes", "label": "Max. Alter fuer API-Refresh (Min.)", "type": "number", "required": false, "help": "Refresh aktualisiert nur, wenn der letzte gespeicherte Abruf aelter ist." },
{ "name": "default_base_currency", "label": "Standard-Basiswaehrung", "type": "text", "required": false, "help": "Wird fuer taegliche Abrufe und Snapshot-Abfragen verwendet." },
{ "name": "display_base_currency", "label": "Anzeige-Basiswaehrung", "type": "select", "required": false, "help": "Basis fuer die Anzeige der zuletzt gespeicherten Kurse im Modul." },
{ "name": "preferred_currencies", "label": "Bevorzugte Waehrungen", "type": "multiselect", "required": false, "help": "Auswahl aus dem synchronisierten Waehrungskatalog." },
diff --git a/modules/fx-rates/src/Api/Router.php b/modules/fx-rates/src/Api/Router.php
index b1ee7f0..350e907 100644
--- a/modules/fx-rates/src/Api/Router.php
+++ b/modules/fx-rates/src/Api/Router.php
@@ -42,6 +42,23 @@ final class Router
$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);
@@ -74,11 +91,11 @@ final class Router
$input = $this->input();
$base = $this->stringOrNull($input['base'] ?? null);
$force = !empty($input['force']);
- $maxAgeHours = is_numeric($input['max_age_hours'] ?? null) ? (float) $input['max_age_hours'] : 24.0;
+ $maxAgeMinutes = is_numeric($input['max_age_minutes'] ?? null) ? (int) $input['max_age_minutes'] : null;
$result = $force
? $this->service->refreshLatestRates(null, $base)
- : $this->service->ensureFreshLatestRates($maxAgeHours, $base, null);
+ : $this->service->autoRefreshLatestRates($base, null, $maxAgeMinutes);
$this->respond(['data' => $result], 201);
}
diff --git a/modules/fx-rates/src/Domain/FxRatesService.php b/modules/fx-rates/src/Domain/FxRatesService.php
index c8d4199..00e2e2e 100644
--- a/modules/fx-rates/src/Domain/FxRatesService.php
+++ b/modules/fx-rates/src/Domain/FxRatesService.php
@@ -79,6 +79,58 @@ final class FxRatesService
]);
}
+ 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);
@@ -193,6 +245,13 @@ final class FxRatesService
return $result;
}
+ public function autoRefreshLatestRates(?string $baseCurrency = null, ?array $currencies = null, ?int $maxAgeMinutes = null): array
+ {
+ $minutes = $maxAgeMinutes ?? $this->refreshMaxAgeMinutes();
+ $hours = max(1, $minutes) / 60;
+ return $this->ensureFreshLatestRates($hours, $baseCurrency, $currencies);
+ }
+
public function history(string $fromCurrency, string $toCurrency, ?string $from = null, ?string $to = null, int $limit = 200): array
{
$fromCurrency = $this->normalizeCurrency($fromCurrency);
@@ -600,9 +659,14 @@ final class FxRatesService
}
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' => $this->filterRates($rates, $symbols),
+ 'rates' => $filteredRates,
];
}
@@ -620,9 +684,14 @@ final class FxRatesService
$rebasedRates[$code] = (float) $rate / (float) $baseRate;
}
+ $filteredRates = $this->filterRates($rebasedRates, $symbols);
+ if ($this->symbolsContain($symbols, $requestedBase)) {
+ $filteredRates = [$requestedBase => 1.0] + $filteredRates;
+ }
+
return $snapshot + [
'base_currency' => $requestedBase,
- 'rates' => $this->filterRates($rebasedRates, $symbols),
+ 'rates' => $filteredRates,
'snapshot_base_currency' => $snapshotBase,
];
}
@@ -646,6 +715,21 @@ final class FxRatesService
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);
@@ -884,6 +968,11 @@ final class FxRatesService
return max(2, (int) ($this->settings['timeout_sec'] ?? 10));
}
+ private function refreshMaxAgeMinutes(): int
+ {
+ return max(1, (int) ($this->settings['refresh_max_age_minutes'] ?? 60));
+ }
+
private function defaultBaseCurrency(): string
{
return $this->normalizeCurrency((string) ($this->settings['default_base_currency'] ?? 'EUR')) ?: 'EUR';
diff --git a/modules/fx-rates/src/Infrastructure/FxRatesRepository.php b/modules/fx-rates/src/Infrastructure/FxRatesRepository.php
index 0c8d4fa..a340062 100644
--- a/modules/fx-rates/src/Infrastructure/FxRatesRepository.php
+++ b/modules/fx-rates/src/Infrastructure/FxRatesRepository.php
@@ -146,6 +146,55 @@ final class FxRatesRepository
];
}
+ 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, 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 function (array $left, array $right): int {
+ return ((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));
diff --git a/partials/landingpages/modules/setup.php b/partials/landingpages/modules/setup.php
index 969b8d7..85f61d5 100644
--- a/partials/landingpages/modules/setup.php
+++ b/partials/landingpages/modules/setup.php
@@ -544,6 +544,11 @@ $activeDbGroup = $testGroup !== null && array_key_exists($testGroup, $dbGroups)
Timeout (Sek.)
+