Fix missing historical currency rates (#344)

## Summary
- fall back to previous historical currency rate dates when exact API
release is missing
- keep latest rate lookups unchanged
- add regression coverage for missing historical release

Fixes PHP-LARAVEL-18

## Tests
- vendor/bin/pint --dirty --format agent
- php artisan test --compact
tests/Feature/CurrencyConversionServiceTest.php
This commit is contained in:
Víctor Falcón 2026-05-04 11:26:45 +01:00 committed by GitHub
parent f6c20576b5
commit fe3b32395c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 58 additions and 14 deletions

View File

@ -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<string, array<string, float>> 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<int, string>
*/
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<int, string>
*/
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",
];
}
}

View File

@ -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([