repository = $repository; $this->provider = trim(strtolower($provider)) !== '' ? trim(strtolower($provider)) : 'currencyapi'; $this->apiBaseUrl = rtrim($apiBaseUrl, '/'); $this->currenciesApiBaseUrl = rtrim($currenciesApiBaseUrl, '/'); $this->apiKey = trim($apiKey); $this->timeout = max(2, $timeout); $this->cacheTtl = max(60, $cacheTtl); $this->autoFetchOnMiss = $autoFetchOnMiss; $this->debug = $debug; } 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; } $rate = $this->rate($from, $to); 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; } $base = strtoupper(trim((string) $from)); $target = strtoupper(trim((string) $to)); if ($base === '' || $target === '') { return null; } if ($base === $target) { return 1.0; } $cacheKey = $base . ':' . $target; if (array_key_exists($cacheKey, $this->memoryCache)) { return $this->memoryCache[$cacheKey]; } $stored = $this->storedRate($base, $target); if ($stored !== null) { $this->memoryCache[$cacheKey] = $stored; return $stored; } $cached = $this->readFileCache($cacheKey); if ($cached !== null) { $this->memoryCache[$cacheKey] = $cached; return $cached; } if (!$this->autoFetchOnMiss) { return null; } $rate = $this->fetchAndPersistRate($base, $target); $this->memoryCache[$cacheKey] = $rate; if ($rate !== null) { $this->writeFileCache($cacheKey, $rate); } return $rate; } 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 : array_values(array_unique(array_filter(array_map( static fn ($code): string => strtoupper(trim((string) $code)), $currencies ), static fn (string $code): bool => $code !== '' && $code !== $normalizedBase))); $payload = $this->fetchLatestPayload($normalizedBase, $targets); $rates = is_array($payload['rates'] ?? null) ? $payload['rates'] : []; $rateDate = $this->normalizeRateDate($payload['date'] ?? null); $forwardRates = []; foreach ($rates as $target => $rate) { if (!is_numeric($rate)) { continue; } $targetCode = strtoupper((string) $target); if ($targetCode === '' || $targetCode === $normalizedBase) { continue; } $forwardRates[$targetCode] = (float) $rate; } $updated = $this->persistRateSet($normalizedBase, $forwardRates, $rateDate); return [ 'base' => $normalizedBase, 'rate_date' => $rateDate, 'updated_count' => count($updated), 'rates' => $updated, ]; } 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; if ($this->repository === null) { return $this->refreshLatestRates(null, $normalizedBase); } $latestFetch = $this->repository->getLatestFxFetch($normalizedBase); $latestFetchedAt = is_array($latestFetch) ? strtotime((string) ($latestFetch['fetched_at'] ?? '')) : false; $ageSeconds = $latestFetchedAt ? (time() - $latestFetchedAt) : null; $maxAgeSeconds = (int) round($maxAgeHours * 3600); if ($ageSeconds !== null && $ageSeconds >= 0 && $ageSeconds <= $maxAgeSeconds) { $this->debug?->add('fx.latest.reuse', [ 'base' => $normalizedBase, 'fetched_at' => $latestFetch['fetched_at'] ?? null, 'age_seconds' => $ageSeconds, 'max_age_seconds' => $maxAgeSeconds, ]); return [ 'base' => $normalizedBase, 'rate_date' => $latestFetch['rate_date'] ?? null, 'updated_count' => 0, 'rates' => [], 'reused' => true, 'fetched_at' => $latestFetch['fetched_at'] ?? null, ]; } $this->debug?->add('fx.latest.refresh_required', [ 'base' => $normalizedBase, 'previous_fetched_at' => $latestFetch['fetched_at'] ?? null, 'age_seconds' => $ageSeconds, 'max_age_seconds' => $maxAgeSeconds, ]); $result = $this->refreshLatestRates(null, $normalizedBase); $result['reused'] = false; return $result; } 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, 'currencies' => [], ]; } $payload = $this->fetchCurrenciesPayload(); $items = is_array($payload['currencies'] ?? null) ? $payload['currencies'] : []; if ($items === []) { return [ 'synced_count' => 0, 'currencies' => [], ]; } $synced = []; $sortOrder = 1000; foreach ($items as $code => $name) { $normalizedCode = strtoupper(trim((string) $code)); $normalizedName = trim((string) $name); if ($normalizedCode === '' || $normalizedName === '') { continue; } $currency = [ 'code' => substr($normalizedCode, 0, 10), 'name' => function_exists('mb_substr') ? mb_substr($normalizedName, 0, 64) : substr($normalizedName, 0, 64), 'symbol' => substr($normalizedCode, 0, 8), 'is_active' => 1, 'is_crypto' => $this->isCryptoCode($normalizedCode) ? 1 : 0, 'sort_order' => $this->catalogSortOrder($normalizedCode, $sortOrder), ]; $synced[] = $currency; $sortOrder++; } $this->repository->saveCurrencies($synced); usort($synced, static function (array $left, array $right): int { return [$left['sort_order'], $left['code']] <=> [$right['sort_order'], $right['code']]; }); return [ 'synced_count' => count($synced), 'currencies' => $synced, ]; } public function probeCurrencyCatalog(): array { $shared = $this->sharedFxService(); if ($shared !== null && method_exists($shared, 'probeCurrencyCatalog')) { return $shared->probeCurrencyCatalog(); } return $this->fetchCurrenciesProbe(); } private function fetchAndPersistRate(string $base, string $target): ?float { $payload = $this->fetchLatestPayload($base, [$target]); $rates = is_array($payload['rates'] ?? null) ? $payload['rates'] : []; $rate = $rates[$target] ?? null; if (!is_numeric($rate)) { return null; } $numericRate = (float) $rate; $rateDate = $this->normalizeRateDate($payload['date'] ?? null); $this->persistRateSet($base, [$target => $numericRate], $rateDate); return $numericRate; } private function fetchLatestPayload(string $base, ?array $targets = null): array { if (!function_exists('curl_init')) { return []; } $url = $this->buildLatestUrl($base, $targets); if ($url === null) { $this->debug?->add('fx.latest.skip', ['reason' => 'missing_url_or_key', 'base' => $base]); return []; } $this->debug?->add('fx.latest.request', [ 'base' => $base, 'url' => $this->maskUrl($url), 'targets' => $targets, ]); $ch = curl_init(); curl_setopt_array($ch, [ CURLOPT_URL => $url, CURLOPT_RETURNTRANSFER => true, CURLOPT_FOLLOWLOCATION => true, CURLOPT_TIMEOUT => $this->timeout, CURLOPT_HTTPHEADER => ['Accept: application/json'], ]); $response = curl_exec($ch); $httpStatus = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE); $curlError = curl_error($ch); curl_close($ch); $this->debug?->add('fx.latest.response', [ 'http_status' => $httpStatus, 'curl_error' => $curlError, 'response_bytes' => is_string($response) ? strlen($response) : 0, 'response_preview' => is_string($response) ? substr($response, 0, 1200) : null, ]); if ($response === false || $curlError !== '' || $httpStatus >= 400) { return []; } $payload = json_decode((string) $response, true); return $this->normalizePayload($payload, $base, $targets); } private function fetchLatestProbe(string $base): array { if (!function_exists('curl_init')) { return ['ok' => false, 'message' => 'curl_init ist nicht verfuegbar.']; } $url = $this->buildLatestUrl($base, null); if ($url === null) { return ['ok' => false, 'message' => 'FX-URL oder API-Key fehlt.']; } $this->debug?->add('fx.latest.probe.request', [ 'base' => $base, 'url' => $this->maskUrl($url), ]); $ch = curl_init(); curl_setopt_array($ch, [ CURLOPT_URL => $url, CURLOPT_RETURNTRANSFER => true, CURLOPT_FOLLOWLOCATION => true, CURLOPT_TIMEOUT => $this->timeout, CURLOPT_HEADER => true, CURLOPT_HTTPHEADER => ['Accept: application/json'], ]); $response = curl_exec($ch); $httpStatus = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE); $headerSize = (int) curl_getinfo($ch, CURLINFO_HEADER_SIZE); $curlError = curl_error($ch); curl_close($ch); $rawHeaders = is_string($response) ? substr($response, 0, $headerSize) : ''; $body = is_string($response) ? substr($response, $headerSize) : ''; $result = [ 'ok' => $response !== false && $curlError === '' && $httpStatus < 400, 'url' => $this->maskUrl($url), 'http_status' => $httpStatus, 'curl_error' => $curlError, 'response_headers' => $rawHeaders, 'response_body' => substr($body, 0, 4000), ]; $this->debug?->add('fx.latest.probe.response', $result); return $result; } private function fetchCurrenciesPayload(): array { if (!function_exists('curl_init') || $this->apiKey === '') { return []; } $url = sprintf( '%s/api/v2/currencies?output=json&key=%s', $this->currenciesApiBaseUrl, rawurlencode($this->apiKey) ); $this->debug?->add('fx.currencies.request', [ 'url' => $this->maskUrl($url), ]); $ch = curl_init(); curl_setopt_array($ch, [ CURLOPT_URL => $url, CURLOPT_RETURNTRANSFER => true, CURLOPT_FOLLOWLOCATION => true, CURLOPT_TIMEOUT => $this->timeout, CURLOPT_HTTPHEADER => ['Accept: application/json'], ]); $response = curl_exec($ch); $httpStatus = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE); $curlError = curl_error($ch); curl_close($ch); $this->debug?->add('fx.currencies.response', [ 'http_status' => $httpStatus, 'curl_error' => $curlError, 'response_bytes' => is_string($response) ? strlen($response) : 0, 'response_preview' => is_string($response) ? substr($response, 0, 1200) : null, ]); if ($response === false || $curlError !== '' || $httpStatus >= 400) { return []; } $payload = json_decode((string) $response, true); if (!is_array($payload)) { throw new \RuntimeException('Waehrungskatalog konnte nicht gelesen werden.'); } if (($payload['valid'] ?? false) !== true || !is_array($payload['currencies'] ?? null)) { throw new \RuntimeException($this->extractProviderError($payload, 'Waehrungskatalog konnte nicht geladen werden.')); } return $payload; } private function fetchCurrenciesProbe(): array { if (!function_exists('curl_init') || $this->apiKey === '') { return ['ok' => false, 'message' => 'curl_init oder API-Key fehlt.']; } $url = sprintf( '%s/api/v2/currencies?output=json&key=%s', $this->currenciesApiBaseUrl, rawurlencode($this->apiKey) ); $this->debug?->add('fx.currencies.probe.request', [ 'url' => $this->maskUrl($url), ]); $ch = curl_init(); curl_setopt_array($ch, [ CURLOPT_URL => $url, CURLOPT_RETURNTRANSFER => true, CURLOPT_FOLLOWLOCATION => true, CURLOPT_TIMEOUT => $this->timeout, CURLOPT_HEADER => true, CURLOPT_HTTPHEADER => ['Accept: application/json'], ]); $response = curl_exec($ch); $httpStatus = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE); $headerSize = (int) curl_getinfo($ch, CURLINFO_HEADER_SIZE); $curlError = curl_error($ch); curl_close($ch); $rawHeaders = is_string($response) ? substr($response, 0, $headerSize) : ''; $body = is_string($response) ? substr($response, $headerSize) : ''; $result = [ 'ok' => $response !== false && $curlError === '' && $httpStatus < 400, 'url' => $this->maskUrl($url), 'http_status' => $httpStatus, 'curl_error' => $curlError, 'response_headers' => $rawHeaders, 'response_body' => substr($body, 0, 4000), ]; $this->debug?->add('fx.currencies.probe.response', $result); return $result; } private function storedRate(string $base, string $target): ?float { if ($this->repository === null) { return null; } try { $direct = $this->repository->getLatestFxRate($base, $target); if (is_array($direct) && is_numeric($direct['rate'] ?? null)) { return (float) $direct['rate']; } $inverse = $this->repository->getLatestFxRate($target, $base); if (is_array($inverse) && is_numeric($inverse['rate'] ?? null) && (float) $inverse['rate'] > 0) { return 1 / (float) $inverse['rate']; } $measurementRate = $this->repository->getLatestMeasurementRate($base, $target); if (is_array($measurementRate) && is_numeric($measurementRate['rate'] ?? null)) { return (float) $measurementRate['rate']; } $inverseMeasurementRate = $this->repository->getLatestMeasurementRate($target, $base); if ( is_array($inverseMeasurementRate) && is_numeric($inverseMeasurementRate['rate'] ?? null) && (float) $inverseMeasurementRate['rate'] > 0 ) { return 1 / (float) $inverseMeasurementRate['rate']; } foreach (['USD', 'EUR'] as $viaBase) { if ($base === $viaBase || $target === $viaBase) { continue; } $fromVia = $this->repository->getLatestFxRate($viaBase, $base); $toVia = $this->repository->getLatestFxRate($viaBase, $target); if ( is_array($fromVia) && is_numeric($fromVia['rate'] ?? null) && is_array($toVia) && is_numeric($toVia['rate'] ?? null) && (float) $fromVia['rate'] > 0 ) { return (float) $toVia['rate'] / (float) $fromVia['rate']; } $fromViaInverse = $this->repository->getLatestFxRate($base, $viaBase); $toViaInverse = $this->repository->getLatestFxRate($target, $viaBase); if ( is_array($fromViaInverse) && is_numeric($fromViaInverse['rate'] ?? null) && is_array($toViaInverse) && is_numeric($toViaInverse['rate'] ?? null) && (float) $toViaInverse['rate'] > 0 ) { return (1 / (float) $fromViaInverse['rate']) / (1 / (float) $toViaInverse['rate']); } } } catch (\Throwable) { return null; } return null; } private function persistRateSet(string $base, array $rates, string $rateDate): array { $normalizedBase = strtoupper($base); $normalizedRates = []; foreach ($rates as $target => $rate) { if (!is_numeric($rate)) { continue; } $normalizedTarget = strtoupper((string) $target); $normalizedRates[$normalizedTarget] = (float) $rate; $this->memoryCache[$normalizedBase . ':' . $normalizedTarget] = (float) $rate; $this->writeFileCache($normalizedBase . ':' . $normalizedTarget, (float) $rate); } if ($this->repository === null) { $result = []; foreach ($normalizedRates as $target => $rate) { $result[] = [ 'base_currency' => $normalizedBase, 'target_currency' => $target, 'rate' => $rate, 'rate_date' => $rateDate, 'provider' => $this->provider, ]; } return $result; } try { $saved = $this->repository->saveFxFetch($normalizedBase, $this->provider, $rateDate, $normalizedRates); return is_array($saved['rates'] ?? null) ? $saved['rates'] : []; } catch (\Throwable) { $result = []; foreach ($normalizedRates as $target => $rate) { $result[] = [ 'base_currency' => $normalizedBase, 'target_currency' => $target, 'rate' => $rate, 'rate_date' => $rateDate, 'provider' => $this->provider, ]; } return $result; } } private function buildLatestUrl(string $base, ?array $targets = null): ?string { if ($this->provider === 'currencyapi') { if ($this->apiKey === '') { return null; } return sprintf( '%s/api/v2/rates?base=%s&output=json&key=%s', $this->apiBaseUrl, rawurlencode($base), rawurlencode($this->apiKey) ); } $targets = $targets ?? $this->defaultCurrencies(); return sprintf( '%s/latest?base=%s&symbols=%s', $this->apiBaseUrl, rawurlencode($base), rawurlencode(implode(',', $targets)) ); } private function normalizePayload(mixed $payload, string $base, ?array $targets = null): array { if (!is_array($payload)) { return []; } 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.')); } $allRates = $payload['rates']; $filteredRates = []; if ($targets === null) { foreach ($allRates as $target => $rate) { $targetCode = strtoupper((string) $target); if ($targetCode === $base || !is_numeric($rate)) { continue; } $filteredRates[$targetCode] = (float) $rate; } } else { foreach ($targets as $target) { $targetCode = strtoupper((string) $target); if ($targetCode === $base) { continue; } $rate = $allRates[$targetCode] ?? null; if (is_numeric($rate)) { $filteredRates[$targetCode] = (float) $rate; } } } return [ 'base' => strtoupper((string) ($payload['base'] ?? $base)), 'date' => $payload['updated'] ?? null, 'rates' => $filteredRates, ]; } if (!is_array($payload['rates'] ?? null)) { return []; } if (array_key_exists('success', $payload) && $payload['success'] !== true) { return []; } return $payload; } 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); } } $errors = $payload['errors'] ?? null; if (is_array($errors)) { $flat = []; array_walk_recursive($errors, static function ($value) use (&$flat): void { if (is_string($value) && trim($value) !== '') { $flat[] = trim($value); } }); if ($flat !== []) { return implode(' | ', array_values(array_unique($flat))); } } return $fallback; } private function defaultCurrencies(): array { if ($this->repository === null) { return ['EUR', 'USD']; } try { $currencies = $this->repository->listActiveFiatCurrencies(); return array_map(static fn (array $currency): string => (string) $currency['code'], $currencies); } catch (\Throwable) { return ['EUR', 'USD']; } } private function normalizeRateDate(mixed $value): string { if (is_int($value) || is_float($value) || (is_string($value) && ctype_digit(trim($value)))) { $timestamp = (int) $value; if ($timestamp > 0) { return date('Y-m-d', $timestamp); } } if (is_string($value) && trim($value) !== '') { $timestamp = strtotime($value); if ($timestamp !== false) { return date('Y-m-d', $timestamp); } if (preg_match('/^\d{4}-\d{2}-\d{2}/', $value, $matches) === 1) { return $matches[0]; } } return date('Y-m-d'); } private function catalogSortOrder(string $code, int $fallback): int { return match (strtoupper($code)) { 'EUR' => 10, 'USD' => 20, 'DOGE' => 30, 'BTC' => 40, 'ETH' => 50, 'USDT' => 60, 'USDC' => 70, default => $fallback, }; } private function isCryptoCode(string $code): bool { return in_array(strtoupper($code), [ 'ADA', 'ARB', 'BNB', 'BTC', 'DAI', 'DOGE', 'DOT', 'ETH', 'LINK', 'LTC', 'SOL', 'USDC', 'USDT', 'XRP', ], true); } private function cacheFile(string $cacheKey): string { return rtrim(sys_get_temp_dir(), '/') . '/mining-checker-fx-' . md5($cacheKey) . '.json'; } private function readFileCache(string $cacheKey): ?float { $file = $this->cacheFile($cacheKey); if (!is_file($file) || (time() - filemtime($file)) > $this->cacheTtl) { return null; } $payload = json_decode((string) file_get_contents($file), true); $rate = $payload['rate'] ?? null; return is_numeric($rate) ? (float) $rate : null; } private function writeFileCache(string $cacheKey, float $rate): void { @file_put_contents($this->cacheFile($cacheKey), json_encode([ 'rate' => $rate, 'cached_at' => time(), ], JSON_UNESCAPED_UNICODE)); } private function maskUrl(string $url): string { return preg_replace_callback('/([?&]key=)([^&]+)/i', static function (array $matches): string { $key = $matches[2] ?? ''; if (strlen($key) <= 8) { return $matches[1] . $key; } 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; } } }