2024-05-12 13:31:33 +02:00
< ? 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 ;
2024-06-18 19:44:22 +02:00
use FireflyIII\Exceptions\FireflyException ;
2024-05-12 13:31:33 +02:00
use FireflyIII\Models\Account ;
use FireflyIII\Models\AccountBalance ;
use FireflyIII\Models\Transaction ;
2024-05-12 18:24:38 +02:00
use FireflyIII\Models\TransactionJournal ;
2024-07-29 19:51:04 +02:00
use Illuminate\Support\Collection ;
2024-05-12 13:31:33 +02:00
use Illuminate\Support\Facades\Log ;
2024-07-31 08:20:19 +02:00
/**
* 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 .
*/
2024-05-12 13:31:33 +02:00
class AccountBalanceCalculator
{
2024-05-16 07:22:12 +02:00
private function __construct ()
{
2024-05-16 07:14:44 +02:00
// no-op
}
2024-07-31 13:09:55 +02:00
/**
* Recalculate all balances .
*/
public static function forceRecalculateAll () : void
{
Transaction :: whereNull ( 'deleted_at' ) -> update ([ 'balance_dirty' => true ]);
$object = new self ();
$object -> optimizedCalculation ( new Collection ());
}
2024-05-13 20:31:52 +02:00
/**
2024-07-31 08:20:19 +02:00
* Recalculate all balances .
2024-05-13 20:31:52 +02:00
*/
2024-05-16 07:14:44 +02:00
public static function recalculateAll () : void
2024-05-12 13:31:33 +02:00
{
2024-05-16 07:22:12 +02:00
$object = new self ();
2024-07-29 19:51:04 +02:00
$object -> optimizedCalculation ( new Collection ());
2024-05-13 20:31:52 +02:00
}
2024-05-12 13:31:33 +02:00
2024-05-16 07:14:44 +02:00
public static function recalculateForJournal ( TransactionJournal $transactionJournal ) : void
2024-05-13 20:31:52 +02:00
{
2024-07-31 08:20:19 +02:00
Log :: debug ( __METHOD__ );
2024-07-31 08:31:20 +02:00
$object = new self ();
2024-07-29 19:51:04 +02:00
2024-07-31 08:20:19 +02:00
// recalculate the involved accounts:
2024-07-31 08:31:20 +02:00
$accounts = new Collection ();
foreach ( $transactionJournal -> transactions as $transaction ) {
2024-07-31 08:20:19 +02:00
$accounts -> push ( $transaction -> account );
2024-05-12 17:50:54 +02:00
}
2024-07-31 08:20:19 +02:00
$object -> optimizedCalculation ( $accounts );
2024-05-13 20:31:52 +02:00
}
2024-05-12 17:50:54 +02:00
2024-05-16 07:14:44 +02:00
private function getAccountBalanceByAccount ( int $account , int $currency ) : AccountBalance
2024-05-13 20:31:52 +02:00
{
2024-07-31 08:31:20 +02:00
$query = AccountBalance :: where ( 'title' , 'balance' ) -> where ( 'account_id' , $account ) -> where ( 'transaction_currency_id' , $currency );
2024-05-12 13:31:33 +02:00
2024-07-31 08:31:20 +02:00
$entry = $query -> first ();
2024-05-13 20:31:52 +02:00
if ( null !== $entry ) {
2024-05-16 05:10:41 +02:00
// Log::debug(sprintf('Found account balance "balance" for account #%d and currency #%d: %s', $account, $currency, $entry->balance));
2024-05-13 20:31:52 +02:00
return $entry ;
2024-05-12 13:31:33 +02:00
}
2024-05-13 20:31:52 +02:00
$entry = new AccountBalance ();
$entry -> title = 'balance' ;
$entry -> account_id = $account ;
$entry -> transaction_currency_id = $currency ;
$entry -> balance = '0' ;
$entry -> save ();
2024-05-16 05:10:41 +02:00
// Log::debug(sprintf('Created new account balance for account #%d and currency #%d: %s', $account, $currency, $entry->balance));
2024-05-13 20:31:52 +02:00
return $entry ;
2024-05-12 13:31:33 +02:00
}
2024-05-12 17:50:54 +02:00
2024-07-29 19:51:04 +02:00
private function optimizedCalculation ( Collection $accounts ) : 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' )
// 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' )
2024-07-31 08:31:20 +02:00
-> orderBy ( 'transactions.amount' , 'asc' )
;
2024-07-29 19:51:04 +02:00
if ( count ( $accounts ) > 0 ) {
$query -> whereIn ( 'transactions.account_id' , $accounts -> pluck ( 'id' ) -> toArray ());
}
2024-07-31 08:31:20 +02:00
$set = $query -> get ([ 'transactions.id' , 'transactions.balance_dirty' , 'transactions.transaction_currency_id' , 'transaction_journals.date' , 'transactions.account_id' , 'transactions.amount' ]);
2024-07-29 19:51:04 +02:00
/** @var Transaction $entry */
foreach ( $set as $entry ) {
// start with empty array:
$balances [ $entry -> account_id ] ? ? = [];
$balances [ $entry -> account_id ][ $entry -> transaction_currency_id ] ? ? = '0' ;
// before and after are easy:
2024-07-31 08:31:20 +02:00
$before = $balances [ $entry -> account_id ][ $entry -> transaction_currency_id ];
$after = bcadd ( $before , $entry -> amount );
2024-07-29 19:51:04 +02:00
if ( true === $entry -> balance_dirty ) {
// 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.
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 ;
}
Log :: debug ( sprintf ( 'end of optimizedCalculation, corrected %d balance(s)' , $count ));
// then update all transactions.
// ?? something with accounts?
}
2024-05-16 07:14:44 +02:00
private function getAccountBalanceByJournal ( string $title , int $account , int $journal , int $currency ) : AccountBalance
2024-05-12 13:31:33 +02:00
{
2024-07-31 08:31:20 +02:00
$query = AccountBalance :: where ( 'title' , $title ) -> where ( 'account_id' , $account ) -> where ( 'transaction_journal_id' , $journal ) -> where ( 'transaction_currency_id' , $currency );
2024-05-12 18:24:38 +02:00
2024-07-31 08:31:20 +02:00
$entry = $query -> first ();
2024-05-12 13:31:33 +02:00
if ( null !== $entry ) {
return $entry ;
}
2024-05-13 05:10:16 +02:00
$entry = new AccountBalance ();
2024-05-12 13:31:33 +02:00
$entry -> title = $title ;
$entry -> account_id = $account ;
2024-05-12 18:24:38 +02:00
$entry -> transaction_journal_id = $journal ;
2024-05-13 20:31:52 +02:00
$entry -> transaction_currency_id = $currency ;
2024-05-12 13:31:33 +02:00
$entry -> balance = '0' ;
$entry -> save ();
2024-05-13 05:10:16 +02:00
2024-05-12 13:31:33 +02:00
return $entry ;
}
2024-05-16 07:14:44 +02:00
private function recalculateLatest ( ? Account $account ) : void
2024-05-12 13:31:33 +02:00
{
2024-07-31 08:31:20 +02:00
$query = Transaction :: groupBy ([ 'transactions.account_id' , 'transactions.transaction_currency_id' , 'transactions.foreign_currency_id' ]);
2024-05-13 05:10:16 +02:00
2024-05-13 20:31:52 +02:00
if ( null !== $account ) {
$query -> where ( 'transactions.account_id' , $account -> id );
2024-05-12 13:31:33 +02:00
}
2024-05-13 20:31:52 +02:00
$result = $query -> get ([ 'transactions.account_id' , 'transactions.transaction_currency_id' , 'transactions.foreign_currency_id' , \DB :: raw ( 'SUM(transactions.amount) as sum_amount' ), \DB :: raw ( 'SUM(transactions.foreign_amount) as sum_foreign_amount' )]);
// reset account balances:
2024-05-16 07:14:44 +02:00
$this -> resetAccountBalancesByAccount ( 'balance' , $account );
2024-05-13 20:31:52 +02:00
2024-05-16 05:10:41 +02:00
/** @var \stdClass $row */
2024-05-13 20:31:52 +02:00
foreach ( $result as $row ) {
$account = ( int ) $row -> account_id ;
$transactionCurrency = ( int ) $row -> transaction_currency_id ;
$foreignCurrency = ( int ) $row -> foreign_currency_id ;
2024-05-21 17:36:05 +02:00
$sumAmount = ( string ) $row -> sum_amount ;
$sumForeignAmount = ( string ) $row -> sum_foreign_amount ;
2024-06-16 19:27:38 +02:00
$sumAmount = '' === $sumAmount ? '0' : $sumAmount ;
$sumForeignAmount = '' === $sumForeignAmount ? '0' : $sumForeignAmount ;
2024-05-13 20:31:52 +02:00
2024-07-24 14:57:51 +02:00
// at this point SQLite may return scientific notation because why not. Terrible.
2024-07-31 08:31:20 +02:00
$sumAmount = app ( 'steam' ) -> floatalize ( $sumAmount );
$sumForeignAmount = app ( 'steam' ) -> floatalize ( $sumForeignAmount );
2024-07-24 14:57:51 +02:00
2024-05-13 20:31:52 +02:00
// first create for normal currency:
2024-07-31 08:31:20 +02:00
$entry = $this -> getAccountBalanceByAccount ( $account , $transactionCurrency );
2024-06-24 05:07:31 +02:00
2024-06-18 19:44:22 +02:00
try {
$entry -> balance = bcadd (( string ) $entry -> balance , $sumAmount );
2024-06-24 05:07:31 +02:00
} catch ( \ValueError $e ) {
2024-06-18 19:44:22 +02:00
$message = sprintf ( '[a] Could not add "%s" to "%s": %s' , $entry -> balance , $sumAmount , $e -> getMessage ());
Log :: error ( $message );
2024-06-24 05:07:31 +02:00
2024-06-18 19:44:22 +02:00
throw new FireflyException ( $message , 0 , $e );
}
2024-05-13 20:31:52 +02:00
$entry -> save ();
// then do foreign amount, if present:
if ( $foreignCurrency > 0 ) {
2024-06-24 05:07:31 +02:00
$entry = $this -> getAccountBalanceByAccount ( $account , $foreignCurrency );
2024-06-18 19:44:22 +02:00
try {
2024-06-24 05:07:31 +02:00
$entry -> balance = bcadd (( string ) $entry -> balance , $sumForeignAmount );
} catch ( \ValueError $e ) {
2024-06-18 19:44:22 +02:00
$message = sprintf ( '[b] Could not add "%s" to "%s": %s' , $entry -> balance , $sumForeignAmount , $e -> getMessage ());
Log :: error ( $message );
2024-06-24 05:07:31 +02:00
2024-06-18 19:44:22 +02:00
throw new FireflyException ( $message , 0 , $e );
}
2024-05-13 20:31:52 +02:00
$entry -> save ();
}
}
2024-05-16 07:14:44 +02:00
Log :: debug ( sprintf ( 'Recalculated %d account balance(s)' , $result -> count ()));
2024-05-13 20:31:52 +02:00
}
2024-05-16 07:14:44 +02:00
private function resetAccountBalancesByAccount ( string $title , ? Account $account ) : void
2024-05-13 20:31:52 +02:00
{
if ( null === $account ) {
2024-05-16 07:14:44 +02:00
$count = AccountBalance :: whereNotNull ( 'updated_at' ) -> where ( 'title' , $title ) -> update ([ 'balance' => '0' ]);
Log :: debug ( sprintf ( 'Set %d account balance(s) to zero.' , $count ));
2024-05-13 05:10:16 +02:00
2024-05-12 18:24:38 +02:00
return ;
}
2024-05-16 07:14:44 +02:00
$count = AccountBalance :: where ( 'account_id' , $account -> id ) -> where ( 'title' , $title ) -> update ([ 'balance' => '0' ]);
Log :: debug ( sprintf ( 'Set %d account balance(s) of account #%d to zero.' , $count , $account -> id ));
2024-05-12 18:24:38 +02:00
}
2024-05-16 07:14:44 +02:00
/**
2024-05-21 17:36:05 +02:00
* Als je alles opnieuw doet , verzamel je alle transactions en het bedrag en zet je dat neer als " balance after
* journal " . Dat betekent, netjes op volgorde van datum en doorrekenen.
2024-05-16 07:14:44 +02:00
*
* Zodra je een transaction journal verplaatst ( datum ) moet je dat journal en alle latere journals opnieuw doen .
* Maar dan moet je van de account wel een beginnetje hebben , namelijk de balance tot en met dat moment .
*
* 1. Dus dan search je eerst naar die SUM , som alle transactions eerder dan ( niet inclusief ) de journal .
* 2. En vanaf daar pak je alle journals op of na de journal ( dus ook de journal zelf ) en begin je door te tellen .
* 3. Elke voorbij gaande journal entry " balance_after_journal " geef je een update of voeg je toe .
*/
private function recalculateJournals ( ? Account $account , ? TransactionJournal $transactionJournal ) : void
2024-05-13 20:31:52 +02:00
{
2024-07-31 08:31:20 +02:00
$query = Transaction :: groupBy ([ 'transactions.account_id' , 'transaction_journals.id' , 'transactions.transaction_currency_id' , 'transactions.foreign_currency_id' ]);
2024-05-13 20:31:52 +02:00
$query -> leftJoin ( 'transaction_journals' , 'transaction_journals.id' , '=' , 'transactions.transaction_journal_id' );
$query -> orderBy ( 'transaction_journals.date' , 'asc' );
2024-05-16 07:14:44 +02:00
$amounts = [];
2024-05-13 20:31:52 +02:00
if ( null !== $account ) {
$query -> where ( 'transactions.account_id' , $account -> id );
}
2024-05-16 07:22:12 +02:00
if ( null !== $account && null !== $transactionJournal ) {
2024-05-16 07:14:44 +02:00
$query -> where ( 'transaction_journals.date' , '>=' , $transactionJournal -> date );
$amounts = $this -> getStartAmounts ( $account , $transactionJournal );
}
2024-07-31 08:31:20 +02:00
$result = $query -> get ([ 'transactions.account_id' , 'transaction_journals.id' , 'transactions.transaction_currency_id' , 'transactions.foreign_currency_id' , \DB :: raw ( 'SUM(transactions.amount) as sum_amount' ), \DB :: raw ( 'SUM(transactions.foreign_amount) as sum_foreign_amount' )]);
2024-05-13 20:31:52 +02:00
2024-05-16 05:10:41 +02:00
/** @var \stdClass $row */
2024-05-13 20:31:52 +02:00
foreach ( $result as $row ) {
2024-07-31 08:31:20 +02:00
$account = ( int ) $row -> account_id ;
$transactionCurrency = ( int ) $row -> transaction_currency_id ;
$foreignCurrency = ( int ) $row -> foreign_currency_id ;
$sumAmount = ( string ) $row -> sum_amount ;
$sumForeignAmount = ( string ) $row -> sum_foreign_amount ;
$journalId = ( int ) $row -> id ;
2024-05-21 17:36:05 +02:00
// check for empty strings
2024-07-31 08:31:20 +02:00
$sumAmount = '' === $sumAmount ? '0' : $sumAmount ;
$sumForeignAmount = '' === $sumForeignAmount ? '0' : $sumForeignAmount ;
2024-05-13 20:31:52 +02:00
// new amounts:
2024-05-21 17:36:05 +02:00
$amounts [ $account ][ $transactionCurrency ] = bcadd ( $amounts [ $account ][ $transactionCurrency ] ? ? '0' , $sumAmount );
$amounts [ $account ][ $foreignCurrency ] = bcadd ( $amounts [ $account ][ $foreignCurrency ] ? ? '0' , $sumForeignAmount );
2024-05-13 20:31:52 +02:00
// first create for normal currency:
2024-07-31 08:31:20 +02:00
$entry = self :: getAccountBalanceByJournal ( 'balance_after_journal' , $account , $journalId , $transactionCurrency );
$entry -> balance = $amounts [ $account ][ $transactionCurrency ];
2024-05-13 20:31:52 +02:00
$entry -> save ();
// then do foreign amount, if present:
if ( $foreignCurrency > 0 ) {
$entry = self :: getAccountBalanceByJournal ( 'balance_after_journal' , $account , $journalId , $foreignCurrency );
$entry -> balance = $amounts [ $account ][ $foreignCurrency ];
$entry -> save ();
}
}
// select transactions.account_id, transaction_journals.id, transactions.transaction_currency_id, transactions.foreign_currency_id, sum(transactions.amount), sum(transactions.foreign_amount)
//
2024-05-16 05:10:41 +02:00
// from transactions
2024-05-13 20:31:52 +02:00
//
2024-05-16 05:10:41 +02:00
// left join transaction_journals ON transaction_journals.id = transactions.transaction_journal_id
2024-05-13 20:31:52 +02:00
//
2024-05-16 05:10:41 +02:00
// group by account_id, transaction_journals.id, transaction_currency_id, foreign_currency_id
// order by transaction_journals.date desc
2024-05-13 20:31:52 +02:00
}
2024-05-16 07:14:44 +02:00
2024-05-16 07:22:12 +02:00
private function getStartAmounts ( Account $account , TransactionJournal $journal ) : array
{
exit ( 'here we are' );
2024-05-16 07:14:44 +02:00
return [];
}
2024-05-12 13:31:33 +02:00
}