update(['balance_dirty' => true]); // also delete account balances. AccountBalance::whereNotNull('created_at')->delete(); } $object = new self(); self::optimizedCalculation(new Collection()); } public static function recalculateForJournal(TransactionJournal $transactionJournal): void { if (false === FireflyConfig::get('use_running_balance', config('firefly.feature_flags.running_balance_column'))->data) { return; } Log::debug(__METHOD__); $object = new self(); $set = []; foreach ($transactionJournal->transactions as $transaction) { $set[$transaction->account_id] = $transaction->account; } $accounts = new Collection()->push(...$set); // find meta value: $date = $transactionJournal->date; $meta = $transactionJournal->transactionJournalMeta()->where('name', '_internal_previous_date')->where('data', '!=', '')->first(); Log::debug(sprintf('Date used is "%s"', $date->toW3cString())); if (null !== $meta) { $date = Carbon::parse($meta->data); Log::debug(sprintf('Date is overruled with "%s"', $date->toW3cString())); } self::optimizedCalculation($accounts, $date); } public static function getLatestBalance(int $accountId, int $currencyId, ?Carbon $notBefore): string { if (!$notBefore instanceof Carbon) { // Log::debug(sprintf('Start balance for account #%d and currency #%d is 0.', $accountId, $currencyId)); return '0'; } $query = Transaction::leftJoin('transaction_journals', 'transaction_journals.id', '=', 'transactions.transaction_journal_id') ->whereNull('transactions.deleted_at') ->where('transactions.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) ; $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', ]); if (null === $first) { Log::debug(sprintf('Found no transactions for currency #%d and account #%d, return 0.', $currencyId, $accountId)); return '0'; } $balance = (string) ($first->balance_after ?? '0'); Log::debug(sprintf( 'getLatestBalance: found balance: %s in transaction #%d on moment %s', Steam::bcround($balance, 2), $first->id ?? 0, $notBefore->format('Y-m-d H:i:s') )); return $balance; } public static function optimizedCalculation(Collection $accounts, ?Carbon $notBefore = null): void { if ($notBefore instanceof Carbon) { $notBefore->startOfDay(); } Log::debug(sprintf('start of optimizedCalculation with date "%s"', $notBefore?->format('Y-m-d H:i:s'))); if ($accounts->count() > 0) { Log::debug(sprintf('Limited to %d account(s): %s', $accounts->count(), implode(', ', $accounts->pluck('id')->toArray()))); } // 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 ($notBefore instanceof Carbon) { $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', ]); Log::debug(sprintf('Found %d transaction(s)', $set->count())); // the balance value is an array. // first entry is the balance, second is the date. /** @var Transaction $entry */ foreach ($set as $entry) { // Log::debug(sprintf('Processing transaction #%d with currency #%d and amount %s', $entry->id, $entry->transaction_currency_id, Steam::bcround($entry->amount, 2))); // start with empty array: $balances[$entry->account_id] ??= []; $balances[$entry->account_id][$entry->transaction_currency_id] ??= [ self::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, (string) $entry->amount); // Log::debug(sprintf('Before:%s, after:%s', Steam::bcround($before, 2), Steam::bcround($after, 2))); 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 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) { try { $currency = Amount::getTransactionCurrencyById($currencyId); } catch (FireflyException) { 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(); } } } }