diff --git a/app/Models/ExchangeRate.php b/app/Models/ExchangeRate.php index 495a390e..970a7a49 100644 --- a/app/Models/ExchangeRate.php +++ b/app/Models/ExchangeRate.php @@ -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 $rates */ class ExchangeRate extends Model diff --git a/app/Services/AccountMetricsService.php b/app/Services/AccountMetricsService.php index 0f98f1c4..588e0fc7 100644 --- a/app/Services/AccountMetricsService.php +++ b/app/Services/AccountMetricsService.php @@ -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 $accounts + * @param list $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 + */ + 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 + */ + 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. */ diff --git a/app/Services/ExchangeRateService.php b/app/Services/ExchangeRateService.php index 4dc5f74a..7c3b4d3c 100644 --- a/app/Services/ExchangeRateService.php +++ b/app/Services/ExchangeRateService.php @@ -8,6 +8,12 @@ use Illuminate\Support\Facades\Log; class ExchangeRateService { + /** @var array> */ + private array $ratesCache = []; + + /** @var array */ + 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 $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; } } diff --git a/tests/Feature/DashboardAnalyticsTest.php b/tests/Feature/DashboardAnalyticsTest.php index 4b84c93d..823d0cf7 100644 --- a/tests/Feature/DashboardAnalyticsTest.php +++ b/tests/Feature/DashboardAnalyticsTest.php @@ -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, diff --git a/tests/Feature/ExchangeRateServiceTest.php b/tests/Feature/ExchangeRateServiceTest.php index 514fe561..3c418aaf 100644 --- a/tests/Feature/ExchangeRateServiceTest.php +++ b/tests/Feature/ExchangeRateServiceTest.php @@ -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',