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:
parent
17d952435b
commit
caae0e8918
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Reference in New Issue