Preload exchange rates (#362)

## Summary
- preload dashboard exchange-rate ranges in one query
- memoize exchange-rate lookups per request
- cover Sentry N+1 regression

Fixes PHP-LARAVEL-1G

## Tests
- vendor/bin/pint --dirty --format agent
- php artisan test --compact tests/Feature/ExchangeRateServiceTest.php
- php artisan test --compact tests/Feature/DashboardAnalyticsTest.php
- php artisan test --compact tests/Performance/ApiQueryCountTest.php
--filter='net worth evolution API'
- php artisan test --compact tests/Performance/PageQueryCountTest.php
--filter='dashboard'
This commit is contained in:
Víctor Falcón 2026-05-06 16:35:49 +01:00 committed by GitHub
parent 17d952435b
commit caae0e8918
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 226 additions and 12 deletions

View File

@ -2,11 +2,14 @@
namespace App\Models;
use Carbon\Carbon;
use Database\Factories\ExchangeRateFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
/**
* @property string $base_currency
* @property Carbon $date
* @property array<string, float> $rates
*/
class ExchangeRate extends Model

View File

@ -27,6 +27,7 @@ class AccountMetricsService
$accountIds = $accounts->pluck('id');
$lookup = BalanceLookup::forAccounts($accountIds, $rangeStart, $now->copy());
$this->preloadExchangeRates($userCurrency, $accounts, $this->monthlyEndDateStrings($rangeStart, $now));
$metrics = [];
@ -90,6 +91,7 @@ class AccountMetricsService
$lookupEnd = Carbon::now()->gt($end) ? Carbon::now() : $end->copy();
$lookup = BalanceLookup::forAccounts($accountIds, $start->copy()->startOfMonth(), $lookupEnd);
$this->preloadExchangeRates($userCurrency, $accounts, $this->monthlyEndDateStrings($start, $end));
$points = [];
$current = $start->copy()->startOfMonth();
@ -173,6 +175,7 @@ class AccountMetricsService
{
$accountIds = $accounts->pluck('id');
$lookup = BalanceLookup::forAccounts($accountIds, $start, $end);
$this->preloadExchangeRates($userCurrency, $accounts, $this->dailyDateStrings($start, $end));
$points = [];
$current = $start->copy();
@ -240,6 +243,56 @@ class AccountMetricsService
return $this->exchangeRateService->convert($sourceCurrency, $targetCurrency, $balance, $date);
}
/**
* @param Collection<int, Account> $accounts
* @param list<string> $dates
*/
private function preloadExchangeRates(string $userCurrency, Collection $accounts, array $dates): void
{
$hasForeignCurrencyAccount = $accounts->contains(
fn (Account $account): bool => strcasecmp($account->currency_code, $userCurrency) !== 0,
);
if (! $hasForeignCurrencyAccount) {
return;
}
$this->exchangeRateService->preloadRates($userCurrency, $dates);
}
/**
* @return list<string>
*/
private function monthlyEndDateStrings(Carbon $start, Carbon $end): array
{
$dates = [];
$current = $start->copy()->startOfMonth();
$endMonth = $end->copy()->startOfMonth();
while ($current->lte($endMonth)) {
$dates[] = $current->copy()->endOfMonth()->toDateString();
$current->addMonth();
}
return $dates;
}
/**
* @return list<string>
*/
private function dailyDateStrings(Carbon $start, Carbon $end): array
{
$dates = [];
$current = $start->copy();
while ($current->lte($end)) {
$dates[] = $current->toDateString();
$current->addDay();
}
return $dates;
}
/**
* Format a month label: "M" for current year, "M 'y" for previous years.
*/

View File

@ -8,6 +8,12 @@ use Illuminate\Support\Facades\Log;
class ExchangeRateService
{
/** @var array<string, array<string, float>> */
private array $ratesCache = [];
/** @var array<string, true> */
private array $databaseMissCache = [];
public function __construct(private CurrencyConversionService $currencyApi) {}
/**
@ -25,7 +31,7 @@ class ExchangeRateService
$source = strtolower($source);
$target = strtolower($target);
if ($source === $target) {
if ($source === $target || $amountInCents === 0) {
return $amountInCents;
}
@ -55,20 +61,22 @@ class ExchangeRateService
public function getRates(string $baseCurrency, string $date): array
{
$baseCurrency = strtolower($baseCurrency);
$date = $this->normalizeDate($date);
$cacheKey = $this->cacheKey($baseCurrency, $date);
// Cap future dates to today — the API only has rates up to the current date.
$today = Carbon::today()->toDateString();
if ($date > $today) {
$date = $today;
if (array_key_exists($cacheKey, $this->ratesCache)) {
return $this->ratesCache[$cacheKey];
}
$cached = ExchangeRate::query()
->where('base_currency', $baseCurrency)
->where('date', $date)
->first();
if (! isset($this->databaseMissCache[$cacheKey])) {
$cached = ExchangeRate::query()
->where('base_currency', $baseCurrency)
->where('date', $date)
->first();
if ($cached) {
return $cached->rates;
if ($cached) {
return $this->ratesCache[$cacheKey] = $cached->rates;
}
}
$rates = $this->currencyApi->getRatesForCurrency($baseCurrency, $date);
@ -81,6 +89,52 @@ class ExchangeRateService
]);
}
return $rates;
return $this->ratesCache[$cacheKey] = $rates;
}
/**
* Preload cached exchange rates for many dates in one query.
*
* @param iterable<int, string> $dates
*/
public function preloadRates(string $baseCurrency, iterable $dates): void
{
$baseCurrency = strtolower($baseCurrency);
$dates = collect($dates)
->map(fn (string $date): string => $this->normalizeDate($date))
->unique()
->reject(fn (string $date): bool => array_key_exists($this->cacheKey($baseCurrency, $date), $this->ratesCache))
->values();
if ($dates->isEmpty()) {
return;
}
ExchangeRate::query()
->where('base_currency', $baseCurrency)
->whereIn('date', $dates->all())
->get(['base_currency', 'date', 'rates'])
->each(function (ExchangeRate $exchangeRate): void {
$date = $exchangeRate->date->toDateString();
$this->ratesCache[$this->cacheKey($exchangeRate->base_currency, $date)] = $exchangeRate->rates;
});
$dates
->reject(fn (string $date): bool => array_key_exists($this->cacheKey($baseCurrency, $date), $this->ratesCache))
->each(function (string $date) use ($baseCurrency): void {
$this->databaseMissCache[$this->cacheKey($baseCurrency, $date)] = true;
});
}
private function normalizeDate(string $date): string
{
$today = Carbon::today()->toDateString();
return $date > $today ? $today : $date;
}
private function cacheKey(string $baseCurrency, string $date): string
{
return strtolower($baseCurrency).'|'.$date;
}
}

View File

@ -10,6 +10,7 @@ use App\Models\LoanDetail;
use App\Models\RealEstateDetail;
use App\Models\Transaction;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
beforeEach(function () {
@ -364,6 +365,54 @@ test('net worth evolution converts foreign currency accounts using cached exchan
Http::assertNothingSent();
});
test('net worth evolution preloads exchange rates for the requested date range', function () {
$eurAccount = Account::factory()->create([
'user_id' => $this->user->id,
'type' => AccountType::Savings,
'currency_code' => 'EUR',
]);
$end = now()->subMonthNoOverflow()->endOfMonth();
$start = $end->copy()->subMonthsNoOverflow(11)->startOfMonth();
$current = $start->copy();
while ($current->lte($end)) {
$date = $current->copy()->endOfMonth();
AccountBalance::factory()->create([
'account_id' => $eurAccount->id,
'balance_date' => $date,
'balance' => 100000,
]);
ExchangeRate::factory()->create([
'base_currency' => 'usd',
'date' => $date->toDateString(),
'rates' => ['eur' => 0.90],
]);
$current->addMonth();
}
$exchangeRateSelects = [];
DB::listen(function ($query) use (&$exchangeRateSelects): void {
if (str_starts_with(strtolower($query->sql), 'select') && str_contains($query->sql, 'exchange_rates')) {
$exchangeRateSelects[] = $query->sql;
}
});
$response = $this->getJson('/api/dashboard/net-worth-evolution?'.http_build_query([
'from' => $start->toDateString(),
'to' => $end->toDateString(),
]));
$response->assertOk();
expect($response->json('data'))->toHaveCount(12);
expect($exchangeRateSelects)->toHaveCount(1);
Http::assertNothingSent();
});
test('net worth evolution uses last balance of each month per account', function () {
$account = Account::factory()->create([
'user_id' => $this->user->id,

View File

@ -2,6 +2,7 @@
use App\Models\ExchangeRate;
use App\Services\ExchangeRateService;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
test('convert returns same amount when source equals target', function () {
@ -86,6 +87,60 @@ test('convert uses database cache on second call for same currency and date', fu
Http::assertSentCount(1);
});
test('convert memoizes exchange rates during service lifetime', function () {
Http::fake();
ExchangeRate::factory()->create([
'base_currency' => 'usd',
'date' => '2026-01-15',
'rates' => [
'eur' => 0.85,
'gbp' => 0.72,
],
]);
$exchangeRateSelects = [];
DB::listen(function ($query) use (&$exchangeRateSelects): void {
if (str_starts_with(strtolower($query->sql), 'select') && str_contains($query->sql, 'exchange_rates')) {
$exchangeRateSelects[] = $query->sql;
}
});
$service = app(ExchangeRateService::class);
$service->convert('EUR', 'USD', 100000, '2026-01-15');
$service->convert('GBP', 'USD', 200000, '2026-01-15');
$service->getRates('USD', '2026-01-15');
expect($exchangeRateSelects)->toHaveCount(1);
Http::assertNothingSent();
});
test('preloaded missing rates do not trigger per-date database lookups', function () {
Http::fake([
'cdn.jsdelivr.net/*currencies/usd*' => Http::response([
'usd' => [
'eur' => 0.90,
'gbp' => 0.75,
],
]),
]);
$exchangeRateSelects = [];
DB::listen(function ($query) use (&$exchangeRateSelects): void {
if (str_starts_with(strtolower($query->sql), 'select') && str_contains($query->sql, 'exchange_rates')) {
$exchangeRateSelects[] = $query->sql;
}
});
$service = app(ExchangeRateService::class);
$service->preloadRates('USD', ['2026-02-10', '2026-02-11']);
$service->convert('EUR', 'USD', 100000, '2026-02-10');
$service->convert('GBP', 'USD', 200000, '2026-02-11');
expect($exchangeRateSelects)->toHaveCount(1);
Http::assertSentCount(2);
});
test('convert returns unconverted amount when rate is missing', function () {
ExchangeRate::factory()->create([
'base_currency' => 'usd',