Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
3 / 3
CRAP
100.00% covered (success)
100.00%
1 / 1
FrankfurterExchangeRateProvider
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
3 / 3
8
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 rate
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 lookup
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
4
1<?php
2
3declare(strict_types=1);
4
5namespace App\Currency\Infrastructure\ExchangeRate;
6
7use App\Currency\Application\ExchangeRateProvider;
8use App\Shared\Domain\ValueObject\Currency;
9use DateTimeImmutable;
10use RuntimeException;
11use Symfony\Contracts\Cache\CacheInterface;
12use Symfony\Contracts\Cache\ItemInterface;
13use Symfony\Contracts\HttpClient\HttpClientInterface;
14
15use function is_array;
16use function is_float;
17use function is_int;
18use 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 */
28final 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}