diff --git a/modules/mining-checker/assets/js/app.js b/modules/mining-checker/assets/js/app.js index 7f1897e..49302fe 100644 --- a/modules/mining-checker/assets/js/app.js +++ b/modules/mining-checker/assets/js/app.js @@ -321,6 +321,7 @@ measurements: Array.isArray(normalized.measurements) ? normalized.measurements : [], targets: Array.isArray(normalized.targets) ? normalized.targets : [], dashboards: Array.isArray(normalized.dashboards) ? normalized.dashboards : [], + fx_snapshots: normalized.fx_snapshots && typeof normalized.fx_snapshots === 'object' ? normalized.fx_snapshots : {}, summary: normalized.summary || { latest_measurement: null, baseline: normalized.settings || null, @@ -897,28 +898,50 @@ return 1; } - const measurementRates = Array.isArray(currentSettings.measurement_rates) ? currentSettings.measurement_rates : []; - const direct = measurementRates.find((row) => - Number(row.measurement_id) === Number(measurementId) - && String(row.base_currency || '').toUpperCase() === from - && String(row.target_currency || row.quote_currency || '').toUpperCase() === to - ); - if (direct) { - const value = Number(direct.rate); - if (Number.isFinite(value) && value > 0) { - return value; + const measurement = measurements.find((row) => Number(row.id) === Number(measurementId)); + const fetchId = measurement && measurement.fx_fetch_id !== null && measurement.fx_fetch_id !== undefined + ? String(measurement.fx_fetch_id) + : ''; + const snapshots = payload && payload.fx_snapshots && typeof payload.fx_snapshots === 'object' + ? payload.fx_snapshots + : {}; + const snapshot = fetchId && snapshots[fetchId] && typeof snapshots[fetchId] === 'object' + ? snapshots[fetchId] + : null; + + if (snapshot) { + const baseCurrency = String(snapshot.base_currency || '').toUpperCase(); + const rates = snapshot.rates && typeof snapshot.rates === 'object' ? snapshot.rates : {}; + const directRate = from === baseCurrency ? rates[to] : null; + if (Number.isFinite(Number(directRate)) && Number(directRate) > 0) { + return Number(directRate); + } + + const inverseRate = to === baseCurrency ? rates[from] : null; + if (Number.isFinite(Number(inverseRate)) && Number(inverseRate) > 0) { + return 1 / Number(inverseRate); + } + + const fromRate = from === baseCurrency ? 1 : Number(rates[from]); + const toRate = to === baseCurrency ? 1 : Number(rates[to]); + if (Number.isFinite(fromRate) && Number.isFinite(toRate) && fromRate > 0 && toRate > 0) { + return toRate / fromRate; } } - const inverse = measurementRates.find((row) => - Number(row.measurement_id) === Number(measurementId) - && String(row.base_currency || '').toUpperCase() === to - && String(row.target_currency || row.quote_currency || '').toUpperCase() === from - ); - if (inverse) { - const value = Number(inverse.rate); - if (Number.isFinite(value) && value > 0) { - return 1 / value; + const priceQuotes = measurement && measurement.price_quotes && typeof measurement.price_quotes === 'object' + ? measurement.price_quotes + : null; + if (priceQuotes && from === 'DOGE') { + const directQuote = Number(priceQuotes[to]); + if (Number.isFinite(directQuote) && directQuote > 0) { + return directQuote; + } + } + if (priceQuotes && to === 'DOGE') { + const inverseQuote = Number(priceQuotes[from]); + if (Number.isFinite(inverseQuote) && inverseQuote > 0) { + return 1 / inverseQuote; } } diff --git a/modules/mining-checker/docs/README.md b/modules/mining-checker/docs/README.md index 5d11405..1052e63 100644 --- a/modules/mining-checker/docs/README.md +++ b/modules/mining-checker/docs/README.md @@ -89,7 +89,7 @@ Laut OCR.space-Doku wird `POST https://api.ocr.space/parse/image` mit `file`, He ## Wechselkurse -Der Endpunkt `POST /api/mining-checker/v1/projects/{projectKey}/fx-refresh` holt aktuelle Fiat-Wechselkurse von `currencyapi.net` und speichert sie in `miningcheck_fx_rates`. +Der Endpunkt `POST /api/mining-checker/v1/projects/{projectKey}/fx-refresh` delegiert den Abruf an das Modul `fx-rates`. Der Mining-Checker speichert dabei keine eigenen FX-Snapshots mehr, sondern referenziert die `fetch_id` aus `fx-rates`. Empfohlene Umgebungsvariablen: @@ -114,11 +114,11 @@ Beispiel: } ``` -`currencyapi.net` wird ueber `GET /api/v2/rates?base=...&output=json&key=...` abgefragt. Aus dem Response werden `base`, `rates` und `updated` uebernommen; `valid` muss `true` sein. Die API liefert mehr Waehrungen als benoetigt, der Mining-Checker filtert lokal auf die angeforderten Zielwaehrungen und speichert die Kurse danach normalisiert in `miningcheck_fx_fetches` und `miningcheck_fx_rates`. +`currencyapi.net` wird ueber das Modul `fx-rates` abgefragt. Aus dem Response werden `base`, `rates` und `updated` uebernommen; `valid` muss `true` sein. Die eigentlichen Fetches und Raten liegen im Modul `fx-rates`. -Pro Abruf entsteht genau ein Datensatz in `miningcheck_fx_fetches` mit Basiswaehrung, Provider und Stichtag. Alle Einzelkurse dieses Abrufs liegen darunter in `miningcheck_fx_rates` und teilen sich dieselbe `fetch_id`. Dadurch lassen sich Kurse innerhalb desselben Abrufs konsistent gegeneinander umrechnen. +Pro Abruf entsteht genau ein Datensatz in `fx-rates` mit Basiswaehrung, Provider und Stichtag. Neue Mining-Messpunkte pruefen beim Speichern, ob ein neuer FX-Fetch noetig ist; falls nicht, wird die letzte passende `fetch_id` wiederverwendet. -Wenn fuer eine benoetigte Umrechnung noch kein passender FIAT-Fetch gespeichert ist, faellt der Mining-Checker auf vorhandene Kurs-Snapshots aus den Mining-Messpunkten (`measurement_rates`) zurueck. +Fuer Auswertungen, Berichte und Listen speichert der Mining-Checker pro Messpunkt die damals passende `fx_fetch_id`. Historische Umrechnungen laufen damit gegen genau den zugeordneten `fx-rates`-Snapshot. Mit `POST /api/mining-checker/v1/projects/{projectKey}/currencies-refresh` kann die Waehrungstabelle einmalig oder bei Bedarf aus `GET /api/v2/currencies?output=json&key=...` synchronisiert werden. Dabei werden Code, Name, Symbol und Sortierung in `miningcheck_currencies` gespeichert. diff --git a/modules/mining-checker/sql/schema.mysql.sql b/modules/mining-checker/sql/schema.mysql.sql index f0ea87a..5bb1667 100644 --- a/modules/mining-checker/sql/schema.mysql.sql +++ b/modules/mining-checker/sql/schema.mysql.sql @@ -77,6 +77,7 @@ CREATE TABLE IF NOT EXISTS miningcheck_measurements ( coins_total DECIMAL(20,6) NOT NULL, price_per_coin DECIMAL(20,8) NULL, price_currency VARCHAR(10) NULL, + fx_fetch_id BIGINT UNSIGNED NULL, note TEXT NULL, source ENUM('manual', 'image_ocr', 'seed_import') NOT NULL, image_path VARCHAR(255) NULL, @@ -93,6 +94,9 @@ CREATE TABLE IF NOT EXISTS miningcheck_measurements ( CREATE INDEX idx_miningcheck_measurements_project_measured_at ON miningcheck_measurements(project_key, measured_at); +CREATE INDEX idx_miningcheck_measurements_fx_fetch + ON miningcheck_measurements(fx_fetch_id); + CREATE TABLE IF NOT EXISTS miningcheck_measurement_rates ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, measurement_id BIGINT UNSIGNED NOT NULL, diff --git a/modules/mining-checker/sql/schema.pgsql.sql b/modules/mining-checker/sql/schema.pgsql.sql index 17f6153..2a1ca85 100644 --- a/modules/mining-checker/sql/schema.pgsql.sql +++ b/modules/mining-checker/sql/schema.pgsql.sql @@ -80,6 +80,7 @@ CREATE TABLE IF NOT EXISTS miningcheck_measurements ( coins_total NUMERIC(20,6) NOT NULL, price_per_coin NUMERIC(20,8), price_currency VARCHAR(10), + fx_fetch_id BIGINT, note TEXT, source VARCHAR(16) NOT NULL CHECK (source IN ('manual', 'image_ocr', 'seed_import')), image_path VARCHAR(255), @@ -96,6 +97,9 @@ CREATE TABLE IF NOT EXISTS miningcheck_measurements ( CREATE INDEX IF NOT EXISTS idx_miningcheck_measurements_project_measured_at ON miningcheck_measurements(project_key, owner_sub, measured_at); +CREATE INDEX IF NOT EXISTS idx_miningcheck_measurements_fx_fetch + ON miningcheck_measurements(fx_fetch_id); + CREATE TABLE IF NOT EXISTS miningcheck_measurement_rates ( id BIGSERIAL PRIMARY KEY, measurement_id BIGINT NOT NULL, diff --git a/modules/mining-checker/sql/schema.sql b/modules/mining-checker/sql/schema.sql index f0ea87a..5bb1667 100644 --- a/modules/mining-checker/sql/schema.sql +++ b/modules/mining-checker/sql/schema.sql @@ -77,6 +77,7 @@ CREATE TABLE IF NOT EXISTS miningcheck_measurements ( coins_total DECIMAL(20,6) NOT NULL, price_per_coin DECIMAL(20,8) NULL, price_currency VARCHAR(10) NULL, + fx_fetch_id BIGINT UNSIGNED NULL, note TEXT NULL, source ENUM('manual', 'image_ocr', 'seed_import') NOT NULL, image_path VARCHAR(255) NULL, @@ -93,6 +94,9 @@ CREATE TABLE IF NOT EXISTS miningcheck_measurements ( CREATE INDEX idx_miningcheck_measurements_project_measured_at ON miningcheck_measurements(project_key, measured_at); +CREATE INDEX idx_miningcheck_measurements_fx_fetch + ON miningcheck_measurements(fx_fetch_id); + CREATE TABLE IF NOT EXISTS miningcheck_measurement_rates ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, measurement_id BIGINT UNSIGNED NOT NULL, diff --git a/modules/mining-checker/src/Api/Router.php b/modules/mining-checker/src/Api/Router.php index 7449bee..1156212 100644 --- a/modules/mining-checker/src/Api/Router.php +++ b/modules/mining-checker/src/Api/Router.php @@ -293,6 +293,7 @@ final class Router $measurements = $this->measurements($projectKey); $targets = $this->targets($projectKey); $dashboards = $this->dashboards($projectKey); + $fxSnapshots = $this->measurementFxSnapshots($measurements); return [ 'project' => $this->repository()->getProject($projectKey), @@ -300,6 +301,7 @@ final class Router 'measurements' => $measurements, 'targets' => $targets, 'dashboards' => $dashboards, + 'fx_snapshots' => $fxSnapshots, 'summary' => $this->analytics()->buildSummary($measurements, $settings, $targets), ]; } @@ -691,7 +693,42 @@ final class Router private function fxHistory(): array { - return $this->repository()->listFxRates(30); + if (!modules()->isEnabled('fx-rates') || !modules()->hasFunction('fx-rates', 'recent_fetches')) { + return []; + } + + $fetches = module_fn('fx-rates', 'recent_fetches', 30); + if (!is_array($fetches)) { + return []; + } + + $rows = []; + foreach ($fetches as $fetch) { + $fetchId = is_numeric($fetch['id'] ?? null) ? (int) $fetch['id'] : 0; + if ($fetchId <= 0) { + continue; + } + + $snapshot = $this->fx()->snapshotByFetchId($fetchId, (string) ($fetch['base_currency'] ?? ''), null); + $rates = is_array($snapshot['rates'] ?? null) ? $snapshot['rates'] : []; + foreach ($rates as $currencyCode => $rate) { + if (!is_numeric($rate)) { + continue; + } + + $rows[] = [ + 'fetch_id' => $fetchId, + 'base_currency' => strtoupper((string) ($snapshot['base_currency'] ?? $fetch['base_currency'] ?? '')), + 'target_currency' => strtoupper((string) $currencyCode), + 'rate' => (float) $rate, + 'rate_date' => (string) ($snapshot['rate_date'] ?? $fetch['rate_date'] ?? ''), + 'provider' => (string) ($snapshot['provider'] ?? $fetch['provider'] ?? ''), + 'fetched_at' => (string) ($snapshot['fetched_at'] ?? $fetch['fetched_at'] ?? ''), + ]; + } + } + + return $rows; } private function settings(string $projectKey): array @@ -729,7 +766,7 @@ final class Router $base['payouts'] = $this->payouts($projectKey); $base['miner_offers'] = $this->minerOffers($projectKey); $base['purchased_miners'] = $this->purchasedMiners($projectKey); - $base['measurement_rates'] = $this->measurementRates($projectKey); + $base['measurement_rates'] = []; return $base; } @@ -765,6 +802,7 @@ final class Router { $settings = $this->settings($projectKey); $rows = $this->repository()->listMeasurements($projectKey, 500); + $rows = $this->ensureMeasurementFxReferences($projectKey, $rows); return $this->analytics()->enrichMeasurements($rows, $settings); } @@ -785,6 +823,7 @@ final class Router 'ocr_raw_text' => $this->optionalString($input['ocr_raw_text'] ?? null, 65535), 'ocr_confidence' => $this->optionalDecimal($input['ocr_confidence'] ?? null), 'ocr_flags' => $this->optionalArray($input['ocr_flags'] ?? null), + 'fx_fetch_id' => null, ]; if (($payload['price_per_coin'] === null) xor ($payload['price_currency'] === null)) { @@ -792,8 +831,8 @@ final class Router } $this->syncCurrencyCatalogForMeasurement($payload); + $payload['fx_fetch_id'] = $this->resolveMeasurementFxFetchId($projectKey, $payload, true); $created = $this->repository()->createMeasurement($projectKey, $payload); - $this->captureMeasurementRates($projectKey, $created); $measurements = $this->measurements($projectKey); return $measurements[array_key_last($measurements)]; } @@ -823,11 +862,11 @@ final class Router try { $payload = $this->parseImportLine($trimmed, $defaultCurrency, $defaultSource, $this->projectTimezone($projectKey)); $this->syncCurrencyCatalogForMeasurement($payload); + $payload['fx_fetch_id'] = $this->resolveMeasurementFxFetchId($projectKey, $payload, false); $result = $this->repository()->createMeasurementIfNotExists($projectKey, $payload); if ($result === null) { $duplicates++; } else { - $this->captureMeasurementRates($projectKey, $result); $imported++; } } catch (\Throwable $exception) { @@ -954,7 +993,7 @@ final class Router private function measurementRates(string $projectKey): array { - return $this->repository()->listMeasurementRates($projectKey); + return []; } private function currencies(): array @@ -1544,80 +1583,98 @@ final class Router return $result; } - private function captureMeasurementRates(string $projectKey, array $measurement): void + private function resolveMeasurementFxFetchId(string $projectKey, array $payload, bool $allowRefresh): ?int { - $measurementId = (int) ($measurement['id'] ?? 0); - $price = is_numeric($measurement['price_per_coin'] ?? null) ? (float) $measurement['price_per_coin'] : null; - $priceCurrency = strtoupper(trim((string) ($measurement['price_currency'] ?? ''))); - if ($measurementId <= 0) { - return; - } - - $this->ensureFreshFxForMeasurement($projectKey, $priceCurrency !== '' ? $priceCurrency : 'USD'); - - $rates = []; - if ($price !== null && $price > 0 && $priceCurrency !== '') { - $rates[] = ['base_currency' => 'DOGE', 'quote_currency' => $priceCurrency, 'rate' => $price, 'provider' => 'measurement']; - $rates[] = ['base_currency' => $priceCurrency, 'quote_currency' => 'DOGE', 'rate' => 1 / $price, 'provider' => 'measurement']; - - foreach (['USD', 'EUR'] as $fiatCurrency) { - if ($fiatCurrency === $priceCurrency) { - continue; - } - - $convertedPrice = $this->priceForCurrency($price, $priceCurrency, $fiatCurrency); - if ($convertedPrice === null || $convertedPrice <= 0) { - continue; - } - - $rates[] = ['base_currency' => 'DOGE', 'quote_currency' => $fiatCurrency, 'rate' => $convertedPrice, 'provider' => 'derived']; - $rates[] = ['base_currency' => $fiatCurrency, 'quote_currency' => 'DOGE', 'rate' => 1 / $convertedPrice, 'provider' => 'derived']; - } - } else { - foreach (['USD', 'EUR'] as $fiatCurrency) { - $fxPrice = $this->fx()->rate('DOGE', $fiatCurrency); - if ($fxPrice === null || $fxPrice <= 0) { - continue; - } - - $rates[] = ['base_currency' => 'DOGE', 'quote_currency' => $fiatCurrency, 'rate' => $fxPrice, 'provider' => 'fx']; - $rates[] = ['base_currency' => $fiatCurrency, 'quote_currency' => 'DOGE', 'rate' => 1 / $fxPrice, 'provider' => 'fx']; - } - } - - $eurUsd = $this->fx()->rate('EUR', 'USD'); - if ($eurUsd !== null) { - $rates[] = ['base_currency' => 'EUR', 'quote_currency' => 'USD', 'rate' => $eurUsd, 'provider' => 'fx']; - $rates[] = ['base_currency' => 'USD', 'quote_currency' => 'EUR', 'rate' => 1 / $eurUsd, 'provider' => 'fx']; - } - - if ($rates !== []) { - $this->repository()->replaceMeasurementRates($measurementId, $projectKey, $rates); - } - } - - private function priceForCurrency(float $price, string $fromCurrency, string $toCurrency): ?float - { - $from = strtoupper(trim($fromCurrency)); - $to = strtoupper(trim($toCurrency)); - if ($from === $to) { - return $price; - } - - $converted = $this->fx()->convert($price, $from, $to); - return is_numeric($converted) ? (float) $converted : null; - } - - private function ensureFreshFxForMeasurement(string $projectKey, string $priceCurrency): void - { - $normalizedCurrency = strtoupper(trim($priceCurrency)); - if ($normalizedCurrency === '') { - return; - } - + $measuredAt = trim((string) ($payload['measured_at'] ?? '')); $settings = $this->settings($projectKey); $maxAgeHours = is_numeric($settings['fx_max_age_hours'] ?? null) ? (float) $settings['fx_max_age_hours'] : 3.0; - $this->fx()->ensureFreshLatestRates($maxAgeHours, 'USD'); + + if ($allowRefresh && $measuredAt !== '' && $this->isRecentTimestamp($measuredAt, $maxAgeHours)) { + $fresh = $this->fx()->ensureFreshLatestRates($maxAgeHours, 'USD'); + if (is_array($fresh) && is_numeric($fresh['fetch_id'] ?? null)) { + return (int) $fresh['fetch_id']; + } + } + + if ($measuredAt !== '') { + $nearest = $this->fx()->nearestSnapshot('USD', $measuredAt, null, null); + if (is_array($nearest) && is_numeric($nearest['id'] ?? null)) { + return (int) $nearest['id']; + } + } + + $latest = $this->fx()->latestSnapshot('USD', null); + if (is_array($latest) && is_numeric($latest['id'] ?? null)) { + return (int) $latest['id']; + } + + if ($allowRefresh) { + $fresh = $this->fx()->refreshLatestRates(null, 'USD'); + if (is_array($fresh) && is_numeric($fresh['fetch_id'] ?? null)) { + return (int) $fresh['fetch_id']; + } + } + + return null; + } + + private function ensureMeasurementFxReferences(string $projectKey, array $rows): array + { + $resolved = []; + foreach ($rows as $row) { + if (!is_array($row)) { + continue; + } + + $fetchId = is_numeric($row['fx_fetch_id'] ?? null) ? (int) $row['fx_fetch_id'] : 0; + if ($fetchId <= 0) { + $resolvedFetchId = $this->resolveMeasurementFxFetchId($projectKey, $row, false); + if ($resolvedFetchId !== null) { + $row = $this->repository()->setMeasurementFxFetchId($projectKey, (int) ($row['id'] ?? 0), $resolvedFetchId) ?? array_merge($row, ['fx_fetch_id' => $resolvedFetchId]); + } + } + + $resolved[] = $row; + } + + return $resolved; + } + + private function measurementFxSnapshots(array $measurements): array + { + $snapshots = []; + foreach ($measurements as $measurement) { + $fetchId = is_numeric($measurement['fx_fetch_id'] ?? null) ? (int) $measurement['fx_fetch_id'] : 0; + if ($fetchId <= 0 || isset($snapshots[$fetchId])) { + continue; + } + + $snapshot = $this->fx()->snapshotByFetchId($fetchId, null, null); + if (!is_array($snapshot)) { + continue; + } + + $snapshots[(string) $fetchId] = [ + 'id' => is_numeric($snapshot['id'] ?? null) ? (int) $snapshot['id'] : $fetchId, + 'base_currency' => (string) ($snapshot['base_currency'] ?? ''), + 'rate_date' => (string) ($snapshot['rate_date'] ?? ''), + 'provider' => (string) ($snapshot['provider'] ?? ''), + 'fetched_at' => (string) ($snapshot['fetched_at'] ?? ''), + 'rates' => is_array($snapshot['rates'] ?? null) ? $snapshot['rates'] : [], + ]; + } + + return $snapshots; + } + + private function isRecentTimestamp(string $timestamp, float $maxAgeHours): bool + { + $parsed = strtotime($timestamp); + if ($parsed === false) { + return false; + } + + return abs(time() - $parsed) <= (int) round(max(0.25, $maxAgeHours) * 3600); } private function resolveOfferPurchaseCost(array $offer): float diff --git a/modules/mining-checker/src/Domain/AnalyticsService.php b/modules/mining-checker/src/Domain/AnalyticsService.php index 4ce1bf6..5c16448 100644 --- a/modules/mining-checker/src/Domain/AnalyticsService.php +++ b/modules/mining-checker/src/Domain/AnalyticsService.php @@ -20,7 +20,12 @@ final class AnalyticsService $baselineAt = (string) ($settings['baseline_measured_at'] ?? ''); $costPlans = is_array($settings['cost_plans'] ?? null) ? $settings['cost_plans'] : []; $payouts = is_array($settings['payouts'] ?? null) ? $settings['payouts'] : []; - $measurementRates = is_array($settings['measurement_rates'] ?? null) ? $settings['measurement_rates'] : []; + $preferredPriceCurrencies = array_values(array_unique(array_filter([ + strtoupper(trim((string) ($settings['report_currency'] ?? ''))), + 'USD', + 'EUR', + 'DOGE', + ]))); $baselineTs = $this->utcTimestamp($baselineAt); $previous = null; @@ -79,7 +84,7 @@ final class AnalyticsService $latestPriceByCurrency[(string) $rawPriceCurrency] = $rawPrice; } - $measurementDerivedPrices = $this->measurementDerivedPrices($measurementRates, (int) ($row['id'] ?? 0)); + $measurementDerivedPrices = $this->measurementDerivedPrices($row, $preferredPriceCurrencies); foreach ($measurementDerivedPrices as $derivedCurrency => $derivedPrice) { $latestPriceByCurrency[$derivedCurrency] = $derivedPrice; } @@ -96,7 +101,7 @@ final class AnalyticsService } if ($price === null) { foreach (['USD', 'EUR'] as $fallbackCurrency) { - $fxPrice = $this->convertAmount(1.0, 'DOGE', $fallbackCurrency); + $fxPrice = $this->convertAmount(1.0, 'DOGE', $fallbackCurrency, $row); if ($fxPrice !== null && $fxPrice > 0) { $latestPriceByCurrency[$fallbackCurrency] = $fxPrice; } @@ -107,7 +112,7 @@ final class AnalyticsService } } - $effectiveDailyCost = $this->effectiveDailyCost($costPlans, $measuredTs, $priceCurrency); + $effectiveDailyCost = $this->effectiveDailyCost($costPlans, $measuredTs, $priceCurrency, $row); $currentValue = $price !== null ? $visibleCoinsTotal * $price : null; $currentValueEffective = $price !== null ? $effectiveCoinsTotal * $price : null; $theoreticalDailyRevenue = ($price !== null && $perDayInterval !== null) ? $perDayInterval * $price : null; @@ -148,6 +153,7 @@ final class AnalyticsService 'effective_price_per_coin' => $this->roundOrNull($price, 8), 'effective_price_currency' => $priceCurrency, 'price_is_fallback' => $rawPrice === null && $price !== null, + 'price_quotes' => $measurementDerivedPrices, 'current_value' => $this->roundOrNull($currentValue, 8), 'current_value_effective' => $this->roundOrNull($currentValueEffective, 8), 'effective_daily_cost' => $this->roundOrNull($effectiveDailyCost, 8), @@ -183,10 +189,18 @@ final class AnalyticsService $latest = $measurements[array_key_last($measurements)]; $latestPriceByCurrency = []; foreach ($measurements as $measurement) { - if ($measurement['price_per_coin'] !== null && $measurement['price_currency'] !== null) { - $latestPriceByCurrency[(string) $measurement['price_currency']] = (float) $measurement['price_per_coin']; + $quotes = is_array($measurement['price_quotes'] ?? null) ? $measurement['price_quotes'] : []; + foreach ($quotes as $currency => $price) { + $normalizedCurrency = strtoupper(trim((string) $currency)); + if ($normalizedCurrency !== '' && is_numeric($price)) { + $latestPriceByCurrency[$normalizedCurrency] = (float) $price; + } } } + $latestEffectiveCurrency = strtoupper(trim((string) ($latest['effective_price_currency'] ?? $latest['price_currency'] ?? ''))); + if ($latestEffectiveCurrency !== '' && is_numeric($latest['effective_price_per_coin'] ?? null)) { + $latestPriceByCurrency[$latestEffectiveCurrency] = (float) $latest['effective_price_per_coin']; + } $payouts = is_array($settings['payouts'] ?? null) ? $settings['payouts'] : []; $purchasedMiners = is_array($settings['purchased_miners'] ?? null) ? $settings['purchased_miners'] : []; @@ -221,7 +235,7 @@ final class AnalyticsService : (is_numeric($linkedOffer['effective_price_amount'] ?? null) ? (float) $linkedOffer['effective_price_amount'] : $targetAmount); } - $price = $latestPriceByCurrency[$currency] ?? $this->convertLatestPrice($latestPriceByCurrency, $currency); + $price = $latestPriceByCurrency[$currency] ?? $this->convertLatestPrice($latestPriceByCurrency, $currency, $latest); $requiredDoge = ($price && $targetAmount !== null) ? $targetAmount / $price : null; $remainingDoge = $requiredDoge !== null ? $requiredDoge - (float) ($latest['coins_total_effective'] ?? $latest['coins_total']) : null; $remainingDays = ( @@ -264,7 +278,8 @@ final class AnalyticsService is_array($settings['cost_plans'] ?? null) ? $settings['cost_plans'] : [], $purchasedMiners, $this->utcTimestamp((string) ($latest['measured_at'] ?? '')), - $latestCurrency + $latestCurrency, + $latest ) : null; $currentDailyRevenue = is_numeric($latest['theoretical_daily_revenue'] ?? null) ? (float) $latest['theoretical_daily_revenue'] : null; @@ -399,7 +414,7 @@ final class AnalyticsService return $value === null ? null : round($value, $precision); } - private function effectiveDailyCost(array $costPlans, int $measurementTs, ?string $currency): ?float + private function effectiveDailyCost(array $costPlans, int $measurementTs, ?string $currency, ?array $fxContext = null): ?float { if ($currency === null) { return null; @@ -429,7 +444,8 @@ final class AnalyticsService $convertedDailyCost = $this->convertAmount( $planDailyCost, (string) ($plan['currency'] ?? ''), - $currency + $currency, + $fxContext ); if ($convertedDailyCost === null) { continue; @@ -442,10 +458,10 @@ final class AnalyticsService return $matched ? $dailyTotal : null; } - private function convertLatestPrice(array $latestPriceByCurrency, string $targetCurrency): ?float + private function convertLatestPrice(array $latestPriceByCurrency, string $targetCurrency, ?array $fxContext = null): ?float { foreach ($latestPriceByCurrency as $sourceCurrency => $price) { - $converted = $this->convertAmount((float) $price, (string) $sourceCurrency, $targetCurrency); + $converted = $this->convertAmount((float) $price, (string) $sourceCurrency, $targetCurrency, $fxContext); if ($converted !== null) { return $converted; } @@ -474,33 +490,32 @@ final class AnalyticsService return is_string($derivedFirst) ? $derivedFirst : null; } - private function measurementDerivedPrices(array $measurementRates, int $measurementId): array + private function measurementDerivedPrices(array $measurement, array $targetCurrencies): array { - if ($measurementId <= 0 || $measurementRates === []) { + $rawPrice = is_numeric($measurement['price_per_coin'] ?? null) ? (float) $measurement['price_per_coin'] : null; + $rawCurrency = strtoupper(trim((string) ($measurement['price_currency'] ?? ''))); + + if ($rawPrice === null || $rawPrice <= 0 || $rawCurrency === '') { return []; } - $prices = []; - foreach ($measurementRates as $row) { - if ((int) ($row['measurement_id'] ?? 0) !== $measurementId) { + $prices = [$rawCurrency => $rawPrice]; + foreach ($targetCurrencies as $targetCurrency) { + $targetCurrency = strtoupper(trim((string) $targetCurrency)); + if ($targetCurrency === '' || isset($prices[$targetCurrency])) { continue; } - $baseCurrency = strtoupper(trim((string) ($row['base_currency'] ?? ''))); - $quoteCurrency = strtoupper(trim((string) ($row['target_currency'] ?? $row['quote_currency'] ?? ''))); - $rate = is_numeric($row['rate'] ?? null) ? (float) $row['rate'] : null; - - if ($baseCurrency !== 'DOGE' || $quoteCurrency === '' || $rate === null || $rate <= 0) { - continue; + $converted = $this->convertAmount($rawPrice, $rawCurrency, $targetCurrency, $measurement); + if ($converted !== null && $converted > 0) { + $prices[$targetCurrency] = $converted; } - - $prices[$quoteCurrency] = $rate; } return $prices; } - private function convertAmount(?float $amount, ?string $fromCurrency, ?string $toCurrency): ?float + private function convertAmount(?float $amount, ?string $fromCurrency, ?string $toCurrency, ?array $fxContext = null): ?float { if ($amount === null || $fromCurrency === null || $toCurrency === null) { return null; @@ -520,7 +535,12 @@ final class AnalyticsService return null; } - return $this->fx->convert($amount, $from, $to); + $fetchId = isset($fxContext['fx_fetch_id']) && is_numeric($fxContext['fx_fetch_id']) + ? (int) $fxContext['fx_fetch_id'] + : null; + $at = is_string($fxContext['measured_at'] ?? null) ? (string) $fxContext['measured_at'] : null; + + return $this->fx->convertAt($amount, $from, $to, $at, null, $fetchId); } private function totalHashrateMh(array $entries): float @@ -541,7 +561,7 @@ final class AnalyticsService return $total; } - private function totalPurchasedMinerCost(array $purchasedMiners, string $targetCurrency, ?int $measurementTs = null): ?float + private function totalPurchasedMinerCost(array $purchasedMiners, string $targetCurrency, ?int $measurementTs = null, ?array $fxContext = null): ?float { $target = strtoupper(trim($targetCurrency)); if ($target === '') { @@ -564,7 +584,7 @@ final class AnalyticsService continue; } - $converted = $this->convertAmount($amount, $currency, $target); + $converted = $this->convertAmount($amount, $currency, $target, $fxContext); if ($converted === null) { continue; } @@ -576,7 +596,7 @@ final class AnalyticsService return $matched ? $total : null; } - private function totalInvestmentBasis(array $costPlans, array $purchasedMiners, int $measurementTs, string $targetCurrency): ?float + private function totalInvestmentBasis(array $costPlans, array $purchasedMiners, int $measurementTs, string $targetCurrency, ?array $fxContext = null): ?float { $target = strtoupper(trim($targetCurrency)); if ($target === '') { @@ -586,7 +606,7 @@ final class AnalyticsService $total = 0.0; $matched = false; - $purchasedTotal = $this->totalPurchasedMinerCost($purchasedMiners, $target, $measurementTs); + $purchasedTotal = $this->totalPurchasedMinerCost($purchasedMiners, $target, $measurementTs, $fxContext); if ($purchasedTotal !== null) { $matched = true; $total += $purchasedTotal; @@ -616,7 +636,7 @@ final class AnalyticsService continue; } - $converted = $this->convertAmount($amount, $currency, $target); + $converted = $this->convertAmount($amount, $currency, $target, $fxContext); if ($converted === null) { continue; } @@ -688,7 +708,7 @@ final class AnalyticsService : $basePriceCurrency; $effectivePriceAmount = $basePriceAmount; if ($basePriceAmount !== null && $basePriceAmount > 0 && $basePriceCurrency !== '' && $effectivePriceCurrency !== '' && strtoupper($basePriceCurrency) !== strtoupper($effectivePriceCurrency)) { - $convertedReference = $this->convertAmount($basePriceAmount, $basePriceCurrency, $effectivePriceCurrency); + $convertedReference = $this->convertAmount($basePriceAmount, $basePriceCurrency, $effectivePriceCurrency, $latest); if ($convertedReference !== null && $convertedReference > 0) { $effectivePriceAmount = $convertedReference; } @@ -701,7 +721,7 @@ final class AnalyticsService $expectedDogePerDay = ((float) $latest['doge_per_day_interval'] / $currentHashrateMh) * $offerHashrateMh; } - $offerCurrencyPrice = $effectivePriceCurrency !== '' ? ($latestPriceByCurrency[$effectivePriceCurrency] ?? $this->convertLatestPrice($latestPriceByCurrency, $effectivePriceCurrency)) : null; + $offerCurrencyPrice = $effectivePriceCurrency !== '' ? ($latestPriceByCurrency[$effectivePriceCurrency] ?? $this->convertLatestPrice($latestPriceByCurrency, $effectivePriceCurrency, $latest)) : null; $expectedDailyRevenue = ($expectedDogePerDay !== null && $offerCurrencyPrice !== null) ? $expectedDogePerDay * $offerCurrencyPrice : null; @@ -752,7 +772,7 @@ final class AnalyticsService $offerPriceAmount !== null && $offerPriceCurrency !== '' && $latestCurrency !== '' - ) ? $this->convertAmount($offerPriceAmount, $offerPriceCurrency, $latestCurrency) : null; + ) ? $this->convertAmount($offerPriceAmount, $offerPriceCurrency, $latestCurrency, $latest) : null; $scenarioDogePerDay = ( $currentDogePerDay !== null && $expectedDogePerDay !== null @@ -864,7 +884,7 @@ final class AnalyticsService $runtimeMonths = (int) ($plan['runtime_months'] ?? 0); if ($runtimeMonths > 0 && is_numeric($plan['total_cost_amount'] ?? null)) { $runtimeDays = $runtimeMonths * 30.4375; - $dailyCost = $this->convertAmount((float) $plan['total_cost_amount'] / $runtimeDays, (string) ($plan['currency'] ?? ''), $currency); + $dailyCost = $this->convertAmount((float) $plan['total_cost_amount'] / $runtimeDays, (string) ($plan['currency'] ?? ''), $currency, $latest); if ($dailyCost !== null) { $cost += $dailyCost; } @@ -882,7 +902,7 @@ final class AnalyticsService $runtimeMonths = (int) ($miner['runtime_months'] ?? 0); if ($runtimeMonths > 0 && is_numeric($miner['total_cost_amount'] ?? null)) { $runtimeDays = $runtimeMonths * 30.4375; - $dailyCost = $this->convertAmount((float) $miner['total_cost_amount'] / $runtimeDays, (string) ($miner['currency'] ?? ''), $currency); + $dailyCost = $this->convertAmount((float) $miner['total_cost_amount'] / $runtimeDays, (string) ($miner['currency'] ?? ''), $currency, $latest); if ($dailyCost !== null) { $cost += $dailyCost; } diff --git a/modules/mining-checker/src/Domain/FxService.php b/modules/mining-checker/src/Domain/FxService.php index 761bb09..199eb53 100644 --- a/modules/mining-checker/src/Domain/FxService.php +++ b/modules/mining-checker/src/Domain/FxService.php @@ -44,30 +44,46 @@ 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; - } + return $this->convertAt($amount, $from, $to, null, null, null); + } + public function convertAt(?float $amount, ?string $from, ?string $to, ?string $at = null, ?int $windowMinutes = null, ?int $fetchId = null): ?float + { if ($amount === null || $from === null || $to === null) { return null; } - $rate = $this->rate($from, $to); + $normalizedFetchId = $fetchId !== null && $fetchId > 0 ? $fetchId : null; + $shared = $this->sharedFxService(); + if ($shared !== null && $normalizedFetchId !== null && method_exists($shared, 'snapshotByFetchId')) { + $snapshot = $shared->snapshotByFetchId($normalizedFetchId, strtoupper(trim((string) $from)), [strtoupper(trim((string) $to))]); + if (is_array($snapshot)) { + $resolved = $this->resolveRateFromSnapshot($snapshot, strtoupper(trim((string) $from)), strtoupper(trim((string) $to))); + if ($resolved !== null) { + return $amount * $resolved; + } + } + } + + if ($shared !== null && method_exists($shared, 'convert')) { + $converted = $shared->convert($amount, $from, $to, $at, $windowMinutes); + return is_numeric($converted) ? (float) $converted : null; + } + + $rate = $this->rateAt($from, $to, $at, $windowMinutes, $normalizedFetchId); return $rate === null ? null : $amount * $rate; } 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; - } + return $this->rateAt($from, $to, null, null, null); + } + public function rateAt(?string $from, ?string $to, ?string $at = null, ?int $windowMinutes = null, ?int $fetchId = null): ?float + { $base = strtoupper(trim((string) $from)); $target = strtoupper(trim((string) $to)); + $normalizedFetchId = $fetchId !== null && $fetchId > 0 ? $fetchId : null; if ($base === '' || $target === '') { return null; @@ -77,7 +93,22 @@ final class FxService return 1.0; } - $cacheKey = $base . ':' . $target; + $shared = $this->sharedFxService(); + if ($shared !== null && $normalizedFetchId !== null && method_exists($shared, 'snapshotByFetchId')) { + $snapshot = $shared->snapshotByFetchId($normalizedFetchId, $base, [$target]); + if (is_array($snapshot)) { + $resolved = $this->resolveRateFromSnapshot($snapshot, $base, $target); + if ($resolved !== null) { + return $resolved; + } + } + } + if ($shared !== null && method_exists($shared, 'findRate')) { + $resolved = $shared->findRate($from, $to, $at, $windowMinutes); + return is_array($resolved) && is_numeric($resolved['rate'] ?? null) ? (float) $resolved['rate'] : null; + } + + $cacheKey = implode(':', [$base, $target, $at ?? '', (string) ($windowMinutes ?? 0), (string) ($normalizedFetchId ?? 0)]); if (array_key_exists($cacheKey, $this->memoryCache)) { return $this->memoryCache[$cacheKey]; } @@ -107,6 +138,43 @@ final class FxService return $rate; } + public function snapshotByFetchId(int $fetchId, ?string $baseCurrency = null, ?array $symbols = null): ?array + { + if ($fetchId <= 0) { + return null; + } + + $shared = $this->sharedFxService(); + if ($shared !== null && method_exists($shared, 'snapshotByFetchId')) { + $snapshot = $shared->snapshotByFetchId($fetchId, $baseCurrency, $symbols); + return is_array($snapshot) ? $snapshot : null; + } + + return null; + } + + public function latestSnapshot(?string $baseCurrency = null, ?array $symbols = null): ?array + { + $shared = $this->sharedFxService(); + if ($shared !== null && method_exists($shared, 'snapshot')) { + $snapshot = $shared->snapshot($baseCurrency, null, $symbols, null); + return is_array($snapshot) ? $snapshot : null; + } + + return null; + } + + public function nearestSnapshot(?string $baseCurrency, string $at, ?array $symbols = null, ?int $windowMinutes = null): ?array + { + $shared = $this->sharedFxService(); + if ($shared !== null && method_exists($shared, 'nearestSnapshot')) { + $snapshot = $shared->nearestSnapshot($baseCurrency, $at, $symbols, $windowMinutes); + return is_array($snapshot) ? $snapshot : null; + } + + return null; + } + public function refreshLatestRates(?array $currencies = null, string $base = 'EUR'): array { $shared = $this->sharedFxService(); @@ -807,4 +875,28 @@ final class FxService return null; } } + + private function resolveRateFromSnapshot(array $snapshot, string $from, string $to): ?float + { + $base = strtoupper(trim((string) ($snapshot['base_currency'] ?? ''))); + $rates = is_array($snapshot['rates'] ?? null) ? $snapshot['rates'] : []; + + if ($base === '' || $from === '' || $to === '') { + return null; + } + + if ($base === $from && is_numeric($rates[$to] ?? null)) { + return (float) $rates[$to]; + } + + if ($base === $to && is_numeric($rates[$from] ?? null) && (float) $rates[$from] > 0) { + return 1 / (float) $rates[$from]; + } + + if (is_numeric($rates[$from] ?? null) && is_numeric($rates[$to] ?? null) && (float) $rates[$from] > 0) { + return (float) $rates[$to] / (float) $rates[$from]; + } + + return null; + } } diff --git a/modules/mining-checker/src/Infrastructure/MiningRepository.php b/modules/mining-checker/src/Infrastructure/MiningRepository.php index cf8fb57..2a61a3d 100644 --- a/modules/mining-checker/src/Infrastructure/MiningRepository.php +++ b/modules/mining-checker/src/Infrastructure/MiningRepository.php @@ -430,6 +430,7 @@ final class MiningRepository 'project_key' => $projectKey, 'measured_at' => $payload['measured_at'] ?? null, 'price_currency' => $payload['price_currency'] ?? null, + 'fx_fetch_id' => $payload['fx_fetch_id'] ?? null, ]); $params = [ 'project_key' => $projectKey, @@ -438,6 +439,7 @@ final class MiningRepository 'coins_total' => $payload['coins_total'], 'price_per_coin' => $payload['price_per_coin'], 'price_currency' => $payload['price_currency'], + 'fx_fetch_id' => $payload['fx_fetch_id'] ?? null, 'note' => $payload['note'], 'source' => $payload['source'], 'image_path' => $payload['image_path'], @@ -449,10 +451,10 @@ final class MiningRepository if ($this->driver === 'pgsql') { $stmt = $this->pdo->prepare( 'INSERT INTO ' . $this->table('measurements') . ' ( - project_key, owner_sub, measured_at, coins_total, price_per_coin, price_currency, note, + project_key, owner_sub, measured_at, coins_total, price_per_coin, price_currency, fx_fetch_id, note, source, image_path, ocr_raw_text, ocr_confidence, ocr_flags ) VALUES ( - :project_key, :owner_sub, :measured_at, :coins_total, :price_per_coin, :price_currency, :note, + :project_key, :owner_sub, :measured_at, :coins_total, :price_per_coin, :price_currency, :fx_fetch_id, :note, :source, :image_path, :ocr_raw_text, :ocr_confidence, CAST(:ocr_flags AS jsonb) ) RETURNING *' @@ -465,10 +467,10 @@ final class MiningRepository $stmt = $this->pdo->prepare( 'INSERT INTO ' . $this->table('measurements') . ' ( - project_key, owner_sub, measured_at, coins_total, price_per_coin, price_currency, note, + project_key, owner_sub, measured_at, coins_total, price_per_coin, price_currency, fx_fetch_id, note, source, image_path, ocr_raw_text, ocr_confidence, ocr_flags ) VALUES ( - :project_key, :owner_sub, :measured_at, :coins_total, :price_per_coin, :price_currency, :note, + :project_key, :owner_sub, :measured_at, :coins_total, :price_per_coin, :price_currency, :fx_fetch_id, :note, :source, :image_path, :ocr_raw_text, :ocr_confidence, :ocr_flags )' ); @@ -497,6 +499,38 @@ final class MiningRepository } } + public function setMeasurementFxFetchId(string $projectKey, int $measurementId, ?int $fxFetchId): ?array + { + if ($measurementId <= 0) { + return null; + } + + $stmt = $this->pdo->prepare( + 'UPDATE ' . $this->table('measurements') . ' + SET fx_fetch_id = :fx_fetch_id + WHERE project_key = :project_key AND owner_sub = :owner_sub AND id = :id' + ); + $stmt->execute([ + 'fx_fetch_id' => $fxFetchId, + 'project_key' => $projectKey, + 'owner_sub' => $this->ownerSub, + 'id' => $measurementId, + ]); + + $fetch = $this->pdo->prepare( + 'SELECT * FROM ' . $this->table('measurements') . ' + WHERE project_key = :project_key AND owner_sub = :owner_sub AND id = :id LIMIT 1' + ); + $fetch->execute([ + 'project_key' => $projectKey, + 'owner_sub' => $this->ownerSub, + 'id' => $measurementId, + ]); + + $row = $fetch->fetch(); + return is_array($row) ? $this->normalizeRow($row) : null; + } + public function replaceMeasurementRates(int $measurementId, string $projectKey, array $rates): void { $delete = $this->pdo->prepare('DELETE FROM ' . $this->table('measurement_rates') . ' WHERE measurement_id = :measurement_id AND owner_sub = :owner_sub'); diff --git a/modules/mining-checker/src/Infrastructure/SchemaManager.php b/modules/mining-checker/src/Infrastructure/SchemaManager.php index 4b103d8..9cc79f3 100644 --- a/modules/mining-checker/src/Infrastructure/SchemaManager.php +++ b/modules/mining-checker/src/Infrastructure/SchemaManager.php @@ -184,6 +184,10 @@ final class SchemaManager $this->upgradeSettingsPreferredCurrenciesColumn(); $applied[] = 'settings_preferences'; } + if ($this->tableExists($this->prefix . 'measurements') && !$this->columnExists($this->prefix . 'measurements', 'fx_fetch_id')) { + $this->ensureMeasurementFxReferenceColumn(); + $applied[] = 'measurement_fx_reference'; + } if (!$this->tableExists($this->prefix . 'measurement_rates')) { $this->ensureMeasurementRatesTable(); @@ -298,6 +302,10 @@ final class SchemaManager $this->upgradeSettingsPreferredCurrenciesColumn(); $applied[] = 'settings_preferences'; } + if ($this->tableExists($this->prefix . 'measurements') && !$this->columnExists($this->prefix . 'measurements', 'fx_fetch_id')) { + $this->ensureMeasurementFxReferenceColumn(); + $applied[] = 'measurement_fx_reference'; + } if (!$this->tableExists($this->prefix . 'fx_fetches') || !$this->tableExists($this->prefix . 'fx_rates')) { $this->ensureFxRatesTable(); @@ -404,6 +412,40 @@ final class SchemaManager $this->ensureLegacyMinerOfferImportColumns(); } + private function ensureMeasurementFxReferenceColumn(): void + { + $table = $this->prefix . 'measurements'; + if (!$this->tableExists($table)) { + return; + } + + $statements = $this->driver === 'pgsql' + ? [ + 'ALTER TABLE ' . $table . ' ADD COLUMN IF NOT EXISTS fx_fetch_id BIGINT', + 'CREATE INDEX IF NOT EXISTS idx_miningcheck_measurements_fx_fetch ON ' . $table . ' (fx_fetch_id)', + ] + : [ + 'ALTER TABLE `' . $table . '` ADD COLUMN fx_fetch_id BIGINT UNSIGNED NULL', + 'ALTER TABLE `' . $table . '` ADD INDEX idx_miningcheck_measurements_fx_fetch (fx_fetch_id)', + ]; + + foreach ($statements as $statement) { + try { + $this->executeUpgradeStatements([$statement], 'Messpunkt-FX-Referenz konnte nicht angelegt werden.'); + } catch (\Throwable $exception) { + $message = strtolower($exception->getMessage()); + if ( + ($this->driver === 'mysql' && (str_contains($message, 'duplicate column') || str_contains($message, 'duplicate key name'))) || + ($this->driver === 'pgsql' && str_contains($message, 'already exists')) + ) { + continue; + } + + throw $exception; + } + } + } + private function ensureLegacyMinerOfferImportColumns(): void { $table = $this->prefix . 'miner_offers'; @@ -579,6 +621,10 @@ final class SchemaManager } } + if ($this->tableExists($this->prefix . 'measurements') && !$this->columnExists($this->prefix . 'measurements', 'fx_fetch_id')) { + $upgrades[] = 'measurement_fx_reference'; + } + if (!$this->tableExists($this->prefix . 'fx_fetches') || !$this->tableExists($this->prefix . 'fx_rates')) { $upgrades[] = 'fx_rates_table'; }