Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
100.00% |
22 / 22 |
|
100.00% |
3 / 3 |
CRAP | |
100.00% |
1 / 1 |
| FrankfurterExchangeRateProvider | |
100.00% |
22 / 22 |
|
100.00% |
3 / 3 |
8 | |
100.00% |
1 / 1 |
| __construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| rate | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
3 | |||
| lookup | |
100.00% |
14 / 14 |
|
100.00% |
1 / 1 |
4 | |||
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | namespace App\Currency\Infrastructure\ExchangeRate; |
| 6 | |
| 7 | use App\Currency\Application\ExchangeRateProvider; |
| 8 | use App\Shared\Domain\ValueObject\Currency; |
| 9 | use DateTimeImmutable; |
| 10 | use RuntimeException; |
| 11 | use Symfony\Contracts\Cache\CacheInterface; |
| 12 | use Symfony\Contracts\Cache\ItemInterface; |
| 13 | use Symfony\Contracts\HttpClient\HttpClientInterface; |
| 14 | |
| 15 | use function is_array; |
| 16 | use function is_float; |
| 17 | use function is_int; |
| 18 | use function sprintf; |
| 19 | |
| 20 | /** |
| 21 | * Exchange rates from frankfurter.dev (ECB-backed, no API key), looked up by date |
| 22 | * (`/v1/{YYYY-MM-DD}`) so each expense converts at its spend-date rate, not today's. |
| 23 | * frankfurter returns the latest rate on or before the asked date (ECB has no |
| 24 | * weekend/holiday fixings), so any date resolves. Each (pair, date) is cached; an |
| 25 | * in-process memo keeps one request (e.g. listing a trip) from re-reading the same |
| 26 | * (pair, date) from the pool. |
| 27 | */ |
| 28 | final class FrankfurterExchangeRateProvider implements ExchangeRateProvider |
| 29 | { |
| 30 | /** @var array<string, float> */ |
| 31 | private array $memo = []; |
| 32 | |
| 33 | public function __construct( |
| 34 | private readonly HttpClientInterface $http, |
| 35 | private readonly CacheInterface $cache, |
| 36 | ) { |
| 37 | } |
| 38 | |
| 39 | public function rate(Currency $from, Currency $to, DateTimeImmutable $on): float |
| 40 | { |
| 41 | if ($from->equals($to)) { |
| 42 | return 1.0; |
| 43 | } |
| 44 | |
| 45 | $date = $on->format('Y-m-d'); |
| 46 | |
| 47 | // Frankfurter rounds to 5 decimals, so a sub-1 rate (weak->strong, e.g. JPY->EUR |
| 48 | // ~0.00542) loses precision. Always query the >=1 orientation and invert, so the |
| 49 | // two directions stay exact reciprocals and small rates keep full precision. |
| 50 | $forward = $this->lookup($from, $to, $date); |
| 51 | if ($forward < 1.0) { |
| 52 | return 1.0 / $this->lookup($to, $from, $date); |
| 53 | } |
| 54 | |
| 55 | return $forward; |
| 56 | } |
| 57 | |
| 58 | private function lookup(Currency $from, Currency $to, string $date): float |
| 59 | { |
| 60 | $key = $from->code() . '_' . $to->code() . '_' . $date; |
| 61 | |
| 62 | return $this->memo[$key] ??= $this->cache->get( |
| 63 | 'exchange_rate.' . $key, |
| 64 | function (ItemInterface $item) use ($from, $to, $date): float { |
| 65 | // ponytail: 1h TTL is plenty (rates are daily); a past date is immutable so |
| 66 | // it could cache far longer, but one TTL keeps this simple — bump if quota bites. |
| 67 | $item->expiresAfter(3600); |
| 68 | |
| 69 | $rates = $this->http->request('GET', 'https://api.frankfurter.dev/v1/' . $date, [ |
| 70 | 'query' => ['base' => $from->code(), 'symbols' => $to->code()], |
| 71 | ])->toArray()['rates'] ?? null; |
| 72 | |
| 73 | $rate = is_array($rates) ? ($rates[$to->code()] ?? null) : null; |
| 74 | if (!is_float($rate) && !is_int($rate)) { |
| 75 | throw new RuntimeException(sprintf('No exchange rate %s->%s.', $from->code(), $to->code())); |
| 76 | } |
| 77 | |
| 78 | return (float) $rate; |
| 79 | }, |
| 80 | ); |
| 81 | } |
| 82 | } |