Files
firefly-iii/app/Support/Models/AccountBalanceCalculator.php

266 lines
11 KiB
PHP
Raw Permalink Normal View History

<?php
/*
* AccountBalanceCalculator.php
* Copyright (c) 2024 james@firefly-iii.org.
*
* This file is part of Firefly III (https://github.com/firefly-iii).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see https://www.gnu.org/licenses/.
*/
declare(strict_types=1);
namespace FireflyIII\Support\Models;
use Carbon\Carbon;
2025-09-07 14:54:44 +02:00
use FireflyIII\Exceptions\FireflyException;
use FireflyIII\Models\Account;
use FireflyIII\Models\AccountBalance;
use FireflyIII\Models\Transaction;
2024-05-12 18:24:38 +02:00
use FireflyIII\Models\TransactionJournal;
2025-09-07 14:49:49 +02:00
use FireflyIII\Support\Facades\Amount;
use FireflyIII\Support\Facades\FireflyConfig;
2026-01-18 06:07:50 +01:00
use FireflyIII\Support\Facades\Steam;
2024-07-29 19:51:04 +02:00
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
/**
* Class AccountBalanceCalculator
*
* This class started as a piece of code to create and calculate "account balance" objects, but they
* are at the moment unused. Instead, each transaction gets a before/after balance and an indicator if this
* balance is up-to-date. This class now contains some methods to recalculate those amounts.
*/
class AccountBalanceCalculator
{
private function __construct()
{
2024-05-16 07:14:44 +02:00
// no-op
}
2024-07-31 13:09:55 +02:00
/**
* Recalculate all account and transaction balances.
2024-07-31 13:09:55 +02:00
*/
public static function recalculateAll(bool $forced): void
{
if ($forced) {
Transaction::whereNull('deleted_at')->update(['balance_dirty' => true]);
// also delete account balances.
AccountBalance::whereNotNull('created_at')->delete();
}
$object = new self();
2026-01-25 10:47:30 +01:00
self::optimizedCalculation(new Collection());
2024-05-13 20:31:52 +02:00
}
2025-09-26 06:05:37 +02:00
public static function recalculateForJournal(TransactionJournal $transactionJournal): void
{
if (false === FireflyConfig::get('use_running_balance', config('firefly.feature_flags.running_balance_column'))->data) {
return;
}
2025-09-26 06:05:37 +02:00
Log::debug(__METHOD__);
$object = new self();
2025-09-26 06:05:37 +02:00
$set = [];
2025-09-26 06:05:37 +02:00
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()));
}
2026-01-25 10:47:30 +01:00
self::optimizedCalculation($accounts, $date);
2025-09-26 06:05:37 +02:00
}
2026-01-25 10:47:30 +01:00
public static function getLatestBalance(int $accountId, int $currencyId, ?Carbon $notBefore): string
2025-09-26 06:05:37 +02:00
{
if (!$notBefore instanceof Carbon) {
2026-01-25 17:27:27 +01:00
// Log::debug(sprintf('Start balance for account #%d and currency #%d is 0.', $accountId, $currencyId));
2025-09-26 06:05:37 +02:00
return '0';
}
$query = Transaction::leftJoin('transaction_journals', 'transaction_journals.id', '=', 'transactions.transaction_journal_id')
->whereNull('transactions.deleted_at')
2026-01-18 06:07:50 +01:00
->where('transactions.transaction_currency_id', $currencyId)
->whereNull('transaction_journals.deleted_at')
2025-09-26 06:05:37 +02:00
// 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)
;
2025-09-26 06:05:37 +02:00
$query->where('transaction_journals.date', '<', $notBefore);
$first = $query->first([
2026-01-23 15:09:50 +01:00
'transactions.id',
'transactions.balance_dirty',
'transactions.transaction_currency_id',
'transaction_journals.date',
'transactions.account_id',
'transactions.amount',
'transactions.balance_after',
2026-01-23 15:09:50 +01:00
]);
2026-01-18 06:07:50 +01:00
if (null === $first) {
2026-01-18 06:07:50 +01:00
Log::debug(sprintf('Found no transactions for currency #%d and account #%d, return 0.', $currencyId, $accountId));
2026-01-18 06:07:50 +01:00
return '0';
}
2026-01-23 15:09:50 +01:00
$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')
));
2025-09-26 06:05:37 +02:00
return $balance;
}
2026-01-25 10:47:30 +01:00
public static function optimizedCalculation(Collection $accounts, ?Carbon $notBefore = null): void
2024-07-29 19:51:04 +02:00
{
2026-01-18 06:07:50 +01:00
if ($notBefore instanceof Carbon) {
$notBefore->startOfDay();
}
Log::debug(sprintf('start of optimizedCalculation with date "%s"', $notBefore?->format('Y-m-d H:i:s')));
2024-07-29 19:51:04 +02:00
if ($accounts->count() > 0) {
Log::debug(sprintf('Limited to %d account(s): %s', $accounts->count(), implode(', ', $accounts->pluck('id')->toArray())));
2024-07-29 19:51:04 +02:00
}
// 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')
2024-07-29 19:51:04 +02:00
// 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')
;
2024-07-31 20:19:17 +02:00
if ($accounts->count() > 0) {
2024-07-29 19:51:04 +02:00
$query->whereIn('transactions.account_id', $accounts->pluck('id')->toArray());
}
2025-05-27 17:06:15 +02:00
if ($notBefore instanceof Carbon) {
$query->where('transaction_journals.date', '>=', $notBefore);
}
2024-07-29 19:51:04 +02:00
$set = $query->get([
2026-01-23 15:09:50 +01:00
'transactions.id',
'transactions.balance_dirty',
'transactions.transaction_currency_id',
'transaction_journals.date',
'transactions.account_id',
'transactions.amount',
2026-01-23 15:09:50 +01:00
]);
2026-01-18 06:07:50 +01:00
Log::debug(sprintf('Found %d transaction(s)', $set->count()));
// the balance value is an array.
// first entry is the balance, second is the date.
2024-07-29 19:51:04 +02:00
/** @var Transaction $entry */
foreach ($set as $entry) {
2026-01-21 19:42:27 +01:00
// Log::debug(sprintf('Processing transaction #%d with currency #%d and amount %s', $entry->id, $entry->transaction_currency_id, Steam::bcround($entry->amount, 2)));
2024-07-29 19:51:04 +02:00
// start with empty array:
$balances[$entry->account_id] ??= [];
2026-01-23 15:09:50 +01:00
$balances[$entry->account_id][$entry->transaction_currency_id] ??= [
2026-01-25 10:47:30 +01:00
self::getLatestBalance($entry->account_id, $entry->transaction_currency_id, $notBefore),
null,
2026-01-23 15:09:50 +01:00
];
2024-07-29 19:51:04 +02:00
// before and after are easy:
$before = $balances[$entry->account_id][$entry->transaction_currency_id][0];
$after = bcadd($before, (string) $entry->amount);
2026-01-18 06:07:50 +01:00
2026-01-21 19:42:27 +01:00
// Log::debug(sprintf('Before:%s, after:%s', Steam::bcround($before, 2), Steam::bcround($after, 2)));
2026-01-18 06:07:50 +01:00
2024-07-31 20:19:17 +02:00
if (true === $entry->balance_dirty || $accounts->count() > 0) {
2024-07-29 19:51:04 +02:00
// update the transaction:
$entry->balance_before = $before;
$entry->balance_after = $after;
$entry->balance_dirty = false;
2024-07-29 19:51:04 +02:00
$entry->saveQuietly(); // do not observe this change, or we get stuck in a loop.
2024-07-31 08:31:20 +02:00
++$count;
2024-07-29 19:51:04 +02:00
}
// then update the array:
$balances[$entry->account_id][$entry->transaction_currency_id] = [$after, $entry->date];
2024-07-29 19:51:04 +02:00
}
Log::debug(sprintf('end of optimizedCalculation, corrected %d balance(s)', $count));
2026-01-23 15:09:50 +01:00
// then update all transactions.
// save all collected balances in their respective account objects.
2025-12-19 16:30:39 +01:00
// $this->storeAccountBalances($balances);
2024-07-29 19:51:04 +02:00
}
private function storeAccountBalances(array $balances): void
{
/**
* @var int $accountId
* @var array $currencies
*/
foreach ($balances as $accountId => $currencies) {
2025-01-05 07:31:26 +01:00
/** @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;
2024-06-18 19:44:22 +02:00
}
2024-05-13 20:31:52 +02:00
/**
* @var int $currencyId
* @var array $balance
*/
foreach ($currencies as $currencyId => $balance) {
2025-09-07 14:54:44 +02:00
try {
$currency = Amount::getTransactionCurrencyById($currencyId);
} catch (FireflyException) {
Log::error(sprintf('Could not find currency #%d, will not save account balance.', $currencyId));
continue;
2024-06-18 19:44:22 +02:00
}
/** @var AccountBalance $object */
$object = $account
2026-01-23 15:09:50 +01:00
->accountBalances()
->firstOrCreate([
2024-11-06 11:57:12 +01:00
'title' => 'running_balance',
'balance' => '0',
2024-11-06 11:12:12 +01:00
'transaction_currency_id' => $currencyId,
2024-11-06 11:57:12 +01:00
'date' => $balance[1],
'date_tz' => $balance[1]?->format('e'),
])
;
$object->balance = $balance[0];
$object->date = $balance[1];
2024-11-08 21:02:36 +01:00
$object->date_tz = $balance[1]?->format('e');
2024-12-21 07:12:11 +01:00
$object->saveQuietly();
2024-05-13 20:31:52 +02:00
}
}
2024-05-16 07:14:44 +02:00
}
}