Batch net-worth balance lookups to kill the per-account N+1

DashboardAnalyticsController::calculateNetWorthAt ran one balance query per
account, and was called once per compared period, so the net-worth endpoint
issued ~2*N balance queries. It also re-fetched the account list on every call.

Fetch the accounts once and build a single BalanceLookup::forAccounts() over
the compared date range (the same batch loader already used by the balance
evolution endpoints), then read each balance from memory. Carry-forward
semantics match the previous 'latest balance on or before the date' query, so
results are unchanged; a query-count regression test locks the balance queries
to a fixed number regardless of account count.
This commit is contained in:
Víctor Falcón 2026-07-04 20:25:52 +02:00
parent 0f08c0c662
commit 2208e38071
2 changed files with 66 additions and 11 deletions

View File

@ -6,7 +6,6 @@ use App\Enums\AccountType;
use App\Enums\CategoryType;
use App\Http\Controllers\Controller;
use App\Models\Account;
use App\Models\AccountBalance;
use App\Models\Transaction;
use App\Services\AccountMetricsService;
use App\Services\BalanceLookup;
@ -15,6 +14,7 @@ use App\Services\ExchangeRateService;
use App\Services\LoanAmortizationService;
use App\Services\PeriodComparator;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Http\Request;
class DashboardAnalyticsController extends Controller
@ -38,9 +38,21 @@ class DashboardAnalyticsController extends Controller
$userCurrency = $request->user()->currency_code;
$accounts = Account::query()
->where('user_id', $request->user()->id)
->get();
// Load every account's balance history for the compared range in a
// fixed number of queries instead of one balance lookup per account.
$lookup = BalanceLookup::forAccounts(
$accounts->pluck('id'),
$previousPeriod->to,
$period->to,
);
return response()->json([
'current' => $this->calculateNetWorthAt($period->to, $userCurrency),
'previous' => $this->calculateNetWorthAt($previousPeriod->to, $userCurrency),
'current' => $this->calculateNetWorthAt($accounts, $lookup, $period->to, $userCurrency),
'previous' => $this->calculateNetWorthAt($accounts, $lookup, $previousPeriod->to, $userCurrency),
'currency_code' => $userCurrency,
]);
}
@ -462,18 +474,15 @@ class DashboardAnalyticsController extends Controller
return response()->json($top);
}
private function calculateNetWorthAt(Carbon $date, string $userCurrency): int
/**
* @param Collection<int, Account> $accounts
*/
private function calculateNetWorthAt(Collection $accounts, BalanceLookup $lookup, Carbon $date, string $userCurrency): int
{
$accounts = Account::where('user_id', request()->user()->id)->get();
$total = 0;
foreach ($accounts as $account) {
$balance = AccountBalance::query()
->where('account_id', $account->id)
->where('balance_date', '<=', $date->toDateString())
->orderBy('balance_date', 'desc')
->value('balance') ?? 0;
$balance = $lookup->getBalanceAt($account->id, $date);
$convertedBalance = $this->exchangeRateService->convert(
$account->currency_code,

View File

@ -71,6 +71,52 @@ test('net worth calculates assets minus liabilities', function () {
]);
});
test('net worth queries account balances a fixed number of times regardless of account count', function () {
// Six accounts, each with a current and a prior-period balance. The old
// implementation ran one balance query per account per compared period,
// so this would scale with the account count; BalanceLookup keeps it flat.
for ($i = 0; $i < 6; $i++) {
$account = Account::factory()->create([
'user_id' => $this->user->id,
'type' => AccountType::Checking,
'currency_code' => 'USD',
]);
AccountBalance::factory()->create([
'account_id' => $account->id,
'balance_date' => now(),
'balance' => 100000,
]);
AccountBalance::factory()->create([
'account_id' => $account->id,
'balance_date' => now()->subDays(30),
'balance' => 90000,
]);
}
$balanceQueries = 0;
DB::listen(function ($query) use (&$balanceQueries): void {
if (str_starts_with(strtolower($query->sql), 'select') && str_contains($query->sql, 'account_balances')) {
$balanceQueries++;
}
});
$response = $this->getJson('/api/dashboard/net-worth?'.http_build_query([
'from' => now()->subDays(29)->toDateString(),
'to' => now()->toDateString(),
]));
$response->assertOk()
->assertJson([
'current' => 600000, // 6 x 100000
'previous' => 540000, // 6 x 90000
'currency_code' => 'USD',
]);
// BalanceLookup issues at most three balance queries for the whole request.
expect($balanceQueries)->toBeLessThanOrEqual(3);
});
test('net worth response includes currency_code', function () {
$response = $this->getJson('/api/dashboard/net-worth?'.http_build_query([
'from' => now()->subDays(29)->toDateString(),