811 lines
28 KiB
PHP
811 lines
28 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
namespace Modules\MiningChecker\Domain;
|
|
|
|
use Modules\MiningChecker\Infrastructure\MiningRepository;
|
|
use Modules\MiningChecker\Support\DebugTrace;
|
|
|
|
final class FxService
|
|
{
|
|
private ?MiningRepository $repository;
|
|
private string $provider;
|
|
private string $apiBaseUrl;
|
|
private string $currenciesApiBaseUrl;
|
|
private string $apiKey;
|
|
private int $timeout;
|
|
private int $cacheTtl;
|
|
private bool $autoFetchOnMiss;
|
|
private array $memoryCache = [];
|
|
private ?DebugTrace $debug;
|
|
|
|
public function __construct(
|
|
?MiningRepository $repository = null,
|
|
string $apiBaseUrl = 'https://currencyapi.net',
|
|
string $currenciesApiBaseUrl = 'https://currencyapi.net',
|
|
int $timeout = 10,
|
|
int $cacheTtl = 21600,
|
|
bool $autoFetchOnMiss = false,
|
|
string $provider = 'currencyapi',
|
|
string $apiKey = '',
|
|
?DebugTrace $debug = null
|
|
)
|
|
{
|
|
$this->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;
|
|
}
|
|
}
|
|
}
|