diff --git a/app/Services/CurrencyConversionService.php b/app/Services/CurrencyConversionService.php index 1c6724ae..8dfa2997 100644 --- a/app/Services/CurrencyConversionService.php +++ b/app/Services/CurrencyConversionService.php @@ -2,6 +2,7 @@ namespace App\Services; +use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; use RuntimeException; @@ -12,6 +13,8 @@ class CurrencyConversionService private const FALLBACK_URL = 'https://currency-api.pages.dev/v1/'; + private const HISTORICAL_LOOKBACK_DAYS = 7; + /** @var array> Keyed by "{currency}:{date}" */ private array $rateCache = []; @@ -77,25 +80,48 @@ class CurrencyConversionService */ private function fetchRates(string $currency, string $date): array { - $primaryUrl = self::PRIMARY_URL."{$date}/v1/currencies/{$currency}.min.json"; - $fallbackUrl = self::FALLBACK_URL."{$date}/currencies/{$currency}.min.json"; + $lastException = null; - try { - $response = Http::timeout(10)->get($primaryUrl); - $response->throw(); + foreach ($this->candidateDates($date) as $candidateDate) { + foreach ($this->rateUrls($currency, $candidateDate) as $url) { + try { + $response = Http::timeout(10)->get($url); + $response->throw(); - return $response->json($currency) ?? []; - } catch (\Throwable) { - // Primary failed, try fallback + return $response->json($currency) ?? []; + } catch (\Throwable $e) { + $lastException = $e; + } + } } - try { - $response = Http::timeout(10)->get($fallbackUrl); - $response->throw(); + throw new RuntimeException("Failed to fetch currency rates for {$currency} on {$date}: {$lastException?->getMessage()}", 0, $lastException); + } - return $response->json($currency) ?? []; - } catch (\Throwable $e) { - throw new RuntimeException("Failed to fetch currency rates for {$currency} on {$date}: {$e->getMessage()}", 0, $e); + /** + * @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", + ]; } } diff --git a/tests/Feature/CurrencyConversionServiceTest.php b/tests/Feature/CurrencyConversionServiceTest.php index 76d00a1f..94067c47 100644 --- a/tests/Feature/CurrencyConversionServiceTest.php +++ b/tests/Feature/CurrencyConversionServiceTest.php @@ -73,6 +73,24 @@ test('throws when both primary and fallback fail', function () { $service->convert('BTC', 'EUR', 1.0, '2026-01-15'); })->throws(RuntimeException::class); +test('falls back to previous historical date when requested release is missing', function () { + Http::fake([ + 'cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@2025-12-10/*' => Http::response('Not Found', 404), + 'currency-api.pages.dev/v1/2025-12-10/*' => Http::response('Not Found', 404), + 'cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@2025-12-09/*' => Http::response([ + 'usd' => [ + 'eur' => 0.9, + ], + ]), + ]); + + $service = new CurrencyConversionService; + $result = $service->convert('EUR', 'USD', 90.0, '2025-12-10'); + + expect($result)->toBe(100.0); + Http::assertSentCount(3); +}); + test('caches rates so same currency and date makes only one HTTP request', function () { Http::fake([ 'cdn.jsdelivr.net/*currencies/eur*' => Http::response([