mirror of
https://github.com/firefly-iii/firefly-iii.git
synced 2026-01-30 17:05:31 +00:00
266 lines
11 KiB
PHP
266 lines
11 KiB
PHP
<?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;
|
|
use FireflyIII\Exceptions\FireflyException;
|
|
use FireflyIII\Models\Account;
|
|
use FireflyIII\Models\AccountBalance;
|
|
use FireflyIII\Models\Transaction;
|
|
use FireflyIII\Models\TransactionJournal;
|
|
use FireflyIII\Support\Facades\Amount;
|
|
use FireflyIII\Support\Facades\FireflyConfig;
|
|
use FireflyIII\Support\Facades\Steam;
|
|
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()
|
|
{
|
|
// no-op
|
|
}
|
|
|
|
/**
|
|
* Recalculate all account and transaction balances.
|
|
*/
|
|
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();
|
|
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();
|
|
}
|
|
}
|
|
}
|
|
}
|