> Keyed by "{currency}:{date}" */ private array $rateCache = []; /** * Convert a quantity from one currency to another on a given date. * * @param string $source Source currency code (e.g., "btc", "eth", "usd") * @param string $target Target currency code (e.g., "eur", "usd") * @param float $quantity Amount to convert * @param string $date Date string (YYYY-MM-DD) or "latest" */ public function convert(string $source, string $target, float $quantity, string $date = 'latest'): float { $source = strtolower($source); $target = strtolower($target); if ($source === $target) { return $quantity; } $rates = $this->getRatesForCurrency($target, $date); if (! isset($rates[$source]) || $rates[$source] == 0) { Log::debug('Currency rate not found', [ 'source' => $source, 'target' => $target, 'date' => $date, ]); return 0.0; } return $quantity / $rates[$source]; } /** * Fetch all rates for a base currency on a given date. * * Returns a map of currency code => rate relative to the base currency. * Results are cached in-memory for the duration of the request. * * @return array */ public function getRatesForCurrency(string $currency, string $date): array { $currency = strtolower($currency); $cacheKey = "{$currency}:{$date}"; if (isset($this->rateCache[$cacheKey])) { return $this->rateCache[$cacheKey]; } $persistentKey = "currency-rates:{$cacheKey}"; $rates = Cache::get($persistentKey); if ($rates === null) { $rates = $this->fetchRates($currency, $date); Cache::put($persistentKey, $rates, $this->cacheTtlFor($date, $rates)); } $this->rateCache[$cacheKey] = $rates; return $rates; } /** * Fetch rates from CDN with fallback. * * A missing release (404) walks back to earlier historical dates, but an * unreachable source (connection refused or timeout) aborts the walk: the * same timeout would repeat for every candidate date and risk exhausting * the request's execution time. Failures degrade to an empty rate map * rather than throwing, so a slow CDN never crashes the calling endpoint. * * @return array */ private function fetchRates(string $currency, string $date): array { $sourceUnreachable = false; foreach ($this->candidateDates($date) as $candidateDate) { foreach ($this->rateUrls($currency, $candidateDate) as $url) { try { $response = Http::connectTimeout(self::HTTP_CONNECT_TIMEOUT_SECONDS) ->timeout(self::HTTP_TIMEOUT_SECONDS) ->get($url); } catch (ConnectionException $e) { $sourceUnreachable = true; Log::warning('Currency rate source unreachable', [ 'currency' => $currency, 'date' => $candidateDate, 'url' => $url, 'error' => $e->getMessage(), ]); continue; } if ($response->notFound()) { continue; } if ($response->successful()) { return $response->json($currency) ?? []; } Log::warning('Currency rate source returned an error', [ 'currency' => $currency, 'date' => $candidateDate, 'status' => $response->status(), ]); } if ($sourceUnreachable) { break; } } Log::warning('Currency rates unavailable', [ 'currency' => $currency, 'date' => $date, ]); return []; } /** * Resolve the cache lifetime for a fetched rate map. * * Historical releases are immutable, so cache them long. The "latest" * release changes daily. An empty result means the sources were missing or * unreachable; cache it briefly so a transient outage recovers quickly. * * @param array $rates */ private function cacheTtlFor(string $date, array $rates): int { if ($rates === []) { return self::CACHE_TTL_UNAVAILABLE_SECONDS; } return $date === 'latest' ? self::CACHE_TTL_LATEST_SECONDS : self::CACHE_TTL_HISTORICAL_SECONDS; } /** * @return array */ private function candidateDates(string $date): array { if ($date === 'latest') { return [$date]; } $parsedDate = Carbon::createFromFormat('Y-m-d', $date); return collect(range(0, self::HISTORICAL_LOOKBACK_DAYS)) ->map(fn (int $days): string => $parsedDate->copy()->subDays($days)->toDateString()) ->all(); } /** * @return array */ private function rateUrls(string $currency, string $date): array { return [ self::PRIMARY_URL."{$date}/v1/currencies/{$currency}.min.json", self::FALLBACK_URL."{$date}/currencies/{$currency}.min.json", ]; } }