whisper-money/app/Services/BalanceLookup.php

182 lines
6.9 KiB
PHP

<?php
namespace App\Services;
use App\Models\AccountBalance;
use Carbon\Carbon;
use Illuminate\Support\Collection;
class BalanceLookup
{
/**
* Sorted balance records grouped by account ID.
* Each account maps to a list of ['date' => string, 'balance' => int, 'invested_amount' => ?int].
*
* @var array<string, list<array{date: string, balance: int, invested_amount: ?int}>>
*/
private array $balancesByAccount = [];
/**
* Sorted invested-amount-only records grouped by account ID.
* Filters out null invested_amount entries for carry-forward lookup.
*
* @var array<string, list<array{date: string, invested_amount: int}>>
*/
private array $investedByAccount = [];
/**
* Preload all balance data for a set of accounts covering the given date range.
*
* Executes exactly 3 efficient queries (no correlated subqueries):
* 1. A derived-table join to find the latest balance record before the range start per account.
* 2. A derived-table join to find the latest non-null invested_amount before the range start per account.
* 3. All balance records within the range.
*
* @param Collection<int, string>|array<string> $accountIds
*/
public static function forAccounts(Collection|array $accountIds, Carbon $rangeStart, Carbon $rangeEnd): self
{
$instance = new self;
if (empty($accountIds)) {
return $instance;
}
$accountIdList = $accountIds instanceof Collection ? $accountIds->all() : $accountIds;
$startDate = $rangeStart->toDateString();
$endDate = $rangeEnd->toDateString();
// Query 1: Get the latest balance record before the range start for each account.
// Uses a derived table with GROUP BY + MAX() joined back, avoiding correlated subquery.
$carryForwardRecords = AccountBalance::query()
->whereIn('account_balances.account_id', $accountIdList)
->joinSub(
AccountBalance::query()
->selectRaw('account_id, MAX(balance_date) as max_date')
->whereIn('account_id', $accountIdList)
->where('balance_date', '<', $startDate)
->groupBy('account_id'),
'latest',
function ($join) {
$join->on('account_balances.account_id', '=', 'latest.account_id')
->on('account_balances.balance_date', '=', 'latest.max_date');
}
)
->get(['account_balances.account_id', 'account_balances.balance_date', 'account_balances.balance', 'account_balances.invested_amount']);
// Query 2: Get the latest non-null invested_amount before the range start for each account.
$investedCarryForwardRecords = AccountBalance::query()
->whereIn('account_balances.account_id', $accountIdList)
->joinSub(
AccountBalance::query()
->selectRaw('account_id, MAX(balance_date) as max_date')
->whereIn('account_id', $accountIdList)
->where('balance_date', '<', $startDate)
->whereNotNull('invested_amount')
->groupBy('account_id'),
'latest_invested',
function ($join) {
$join->on('account_balances.account_id', '=', 'latest_invested.account_id')
->on('account_balances.balance_date', '=', 'latest_invested.max_date');
}
)
->get(['account_balances.account_id', 'account_balances.balance_date', 'account_balances.invested_amount']);
// Query 2: All balance records within the range.
$rangeRecords = AccountBalance::query()
->whereIn('account_id', $accountIdList)
->whereBetween('balance_date', [$startDate, $endDate])
->orderBy('balance_date')
->get(['account_id', 'balance_date', 'balance', 'invested_amount']);
// Build the per-account sorted arrays
foreach ($accountIdList as $accountId) {
$entries = [];
$investedEntries = [];
// Add carry-forward seed
$seed = $carryForwardRecords->firstWhere('account_id', $accountId);
if ($seed) {
$entries[] = [
'date' => $seed->balance_date->toDateString(),
'balance' => $seed->balance,
'invested_amount' => $seed->invested_amount,
];
}
// Add invested carry-forward seed
$investedSeed = $investedCarryForwardRecords->firstWhere('account_id', $accountId);
if ($investedSeed) {
$investedEntries[] = [
'date' => $investedSeed->balance_date->toDateString(),
'invested_amount' => $investedSeed->invested_amount,
];
}
// Add range records
foreach ($rangeRecords->where('account_id', $accountId) as $record) {
$dateStr = $record->balance_date->toDateString();
$entries[] = [
'date' => $dateStr,
'balance' => $record->balance,
'invested_amount' => $record->invested_amount,
];
if ($record->invested_amount !== null) {
$investedEntries[] = [
'date' => $dateStr,
'invested_amount' => $record->invested_amount,
];
}
}
$instance->balancesByAccount[$accountId] = $entries;
$instance->investedByAccount[$accountId] = $investedEntries;
}
return $instance;
}
/**
* Get the balance at a given date for an account (carry-forward semantics).
* Returns the most recent balance on or before the given date, or 0 if none exists.
*/
public function getBalanceAt(string $accountId, Carbon $date): int
{
$dateStr = $date->toDateString();
$entries = $this->balancesByAccount[$accountId] ?? [];
$result = 0;
foreach ($entries as $entry) {
if ($entry['date'] <= $dateStr) {
$result = $entry['balance'];
} else {
break;
}
}
return $result;
}
/**
* Get the invested amount at a given date for an account (carry-forward semantics).
* Returns null if no invested amount data is available on or before the given date.
*/
public function getInvestedAmountAt(string $accountId, Carbon $date): ?int
{
$dateStr = $date->toDateString();
$entries = $this->investedByAccount[$accountId] ?? [];
$result = null;
foreach ($entries as $entry) {
if ($entry['date'] <= $dateStr) {
$result = $entry['invested_amount'];
} else {
break;
}
}
return $result;
}
}