update(['balance_dirty' => true]); // also delete account balances. AccountBalance::whereNotNull('created_at')->delete(); } $object = new self(); $object->optimizedCalculation(new Collection()); } private function optimizedCalculation(Collection $accounts, ?Carbon $notBefore = null): void { Log::debug('start of optimizedCalculation'); if ($accounts->count() > 0) { Log::debug(sprintf('Limited to %d account(s)', $accounts->count())); } // collect all transactions and the change they make. $balances = []; $count = 0; $query = Transaction::leftJoin('transaction_journals', 'transaction_journals.id', '=', 'transactions.transaction_journal_id') ->whereNull('transactions.deleted_at') ->whereNull('transaction_journals.deleted_at') // this order is the same as GroupCollector, but in the exact reverse. ->orderBy('transaction_journals.date', 'asc') ->orderBy('transaction_journals.order', 'desc') ->orderBy('transaction_journals.id', 'asc') ->orderBy('transaction_journals.description', 'asc') ->orderBy('transactions.amount', 'asc') ; if ($accounts->count() > 0) { $query->whereIn('transactions.account_id', $accounts->pluck('id')->toArray()); } if (null !== $notBefore) { $notBefore->startOfDay(); $query->where('transaction_journals.date', '>=', $notBefore); } $set = $query->get(['transactions.id', 'transactions.balance_dirty', 'transactions.transaction_currency_id', 'transaction_journals.date', 'transactions.account_id', 'transactions.amount']); // the balance value is an array. // first entry is the balance, second is the date. /** @var Transaction $entry */ foreach ($set as $entry) { // start with empty array: $balances[$entry->account_id] ??= []; $balances[$entry->account_id][$entry->transaction_currency_id] ??= [$this->getLatestBalance($entry->account_id, $entry->transaction_currency_id, $notBefore), null]; // before and after are easy: $before = $balances[$entry->account_id][$entry->transaction_currency_id][0]; $after = bcadd($before, $entry->amount); if (true === $entry->balance_dirty || $accounts->count() > 0) { // update the transaction: $entry->balance_before = $before; $entry->balance_after = $after; $entry->balance_dirty = false; $entry->saveQuietly(); // do not observe this change, or we get stuck in a loop. ++$count; } // then update the array: $balances[$entry->account_id][$entry->transaction_currency_id] = [$after, $entry->date]; } Log::debug(sprintf('end of optimizedCalculation, corrected %d balance(s)', $count)); // then update all transactions. // save all collected balances in their respective account objects. $this->storeAccountBalances($balances); } private function getLatestBalance(int $accountId, int $currencyId, ?Carbon $notBefore): string { if (null === $notBefore) { return '0'; } Log::debug(sprintf('getLatestBalance: notBefore date is "%s", calculating', $notBefore->format('Y-m-d'))); $query = Transaction::leftJoin('transaction_journals', 'transaction_journals.id', '=', 'transactions.transaction_journal_id') ->whereNull('transactions.deleted_at') ->where('transaction_journals.transaction_currency_id', $currencyId) ->whereNull('transaction_journals.deleted_at') // this order is the same as GroupCollector ->orderBy('transaction_journals.date', 'DESC') ->orderBy('transaction_journals.order', 'ASC') ->orderBy('transaction_journals.id', 'DESC') ->orderBy('transaction_journals.description', 'DESC') ->orderBy('transactions.amount', 'DESC') ->where('transactions.account_id', $accountId) ; $notBefore->startOfDay(); $query->where('transaction_journals.date', '<', $notBefore); $first = $query->first(['transactions.id', 'transactions.balance_dirty', 'transactions.transaction_currency_id', 'transaction_journals.date', 'transactions.account_id', 'transactions.amount', 'transactions.balance_after']); $balance = (string) ($first->balance_after ?? '0'); Log::debug(sprintf('getLatestBalance: found balance: %s in transaction #%d', $balance, $first->id ?? 0)); return $balance; } private function storeAccountBalances(array $balances): void { /** * @var int $accountId * @var array $currencies */ foreach ($balances as $accountId => $currencies) { /** @var null|Account $account */ $account = Account::find($accountId); if (null === $account) { Log::error(sprintf('Could not find account #%d, will not save account balance.', $accountId)); continue; } /** * @var int $currencyId * @var array $balance */ foreach ($currencies as $currencyId => $balance) { /** @var null|TransactionCurrency $currency */ $currency = TransactionCurrency::find($currencyId); if (null === $currency) { Log::error(sprintf('Could not find currency #%d, will not save account balance.', $currencyId)); continue; } /** @var AccountBalance $object */ $object = $account->accountBalances()->firstOrCreate( [ 'title' => 'running_balance', 'balance' => '0', 'transaction_currency_id' => $currencyId, 'date' => $balance[1], 'date_tz' => $balance[1]?->format('e'), ] ); $object->balance = $balance[0]; $object->date = $balance[1]; $object->date_tz = $balance[1]?->format('e'); $object->saveQuietly(); } } } public static function recalculateForJournal(TransactionJournal $transactionJournal): void { Log::debug(__METHOD__); $object = new self(); // recalculate the involved accounts: $accounts = new Collection(); foreach ($transactionJournal->transactions as $transaction) { $accounts->push($transaction->account); } $object->optimizedCalculation($accounts, $transactionJournal->date); } }