diff --git a/.env.example b/.env.example index daf8e8b501..d21ce656ae 100644 --- a/.env.example +++ b/.env.example @@ -312,6 +312,12 @@ PUSHER_ID= DEMO_USERNAME= DEMO_PASSWORD= +# +# Disable or enable the running balance column data +# Please disable this. It's a very experimental feature. +# +USE_RUNNING_BALANCE=false + # # The v2 layout is very experimental. If it breaks you get to keep both parts. # Be wary of data loss. diff --git a/app/Support/Models/AccountBalanceCalculator.php b/app/Support/Models/AccountBalanceCalculator.php index a0ac3e6c63..e0bbec6796 100644 --- a/app/Support/Models/AccountBalanceCalculator.php +++ b/app/Support/Models/AccountBalanceCalculator.php @@ -68,7 +68,7 @@ class AccountBalanceCalculator public static function recalculateForJournal(TransactionJournal $transactionJournal): void { Log::debug(__METHOD__); - $object = new self(); + $object = new self(); // recalculate the involved accounts: $accounts = new Collection(); @@ -78,11 +78,38 @@ class AccountBalanceCalculator $object->optimizedCalculation($accounts, $transactionJournal->date); } + private function getLatestBalance(int $accountId, int $currencyId, ?Carbon $notBefore): string + { + if (null === $notBefore) { + Log::debug('getLatestBalance: no notBefore date, returning 0'); + 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, 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') + ->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 = $first->balance_after ?? '0'; + Log::debug(sprintf('getLatestBalance: found balance: %s in transaction #%d', $balance, $first->id ?? 0)); + return $balance; + } + private function getAccountBalanceByAccount(int $account, int $currency): AccountBalance { - $query = AccountBalance::where('title', 'balance')->where('account_id', $account)->where('transaction_currency_id', $currency); + $query = AccountBalance::where('title', 'balance')->where('account_id', $account)->where('transaction_currency_id', $currency); - $entry = $query->first(); + $entry = $query->first(); if (null !== $entry) { // Log::debug(sprintf('Found account balance "balance" for account #%d and currency #%d: %s', $account, $currency, $entry->balance)); @@ -102,6 +129,11 @@ class AccountBalanceCalculator private function optimizedCalculation(Collection $accounts, ?Carbon $notBefore = null): void { Log::debug('start of optimizedCalculation'); + if(false === config('firefly.feature_flags.running_balance_column')) { + Log::debug('optimizedCalculation is disabled, return.'); + return; + } + if ($accounts->count() > 0) { Log::debug(sprintf('Limited to %d account(s)', $accounts->count())); } @@ -109,15 +141,14 @@ class AccountBalanceCalculator $balances = []; $count = 0; $query = Transaction::leftJoin('transaction_journals', 'transaction_journals.id', '=', 'transactions.transaction_journal_id') - ->whereNull('transactions.deleted_at') - ->whereNull('transaction_journals.deleted_at') + ->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') - ; + ->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()); } @@ -126,17 +157,17 @@ class AccountBalanceCalculator $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']); + $set = $query->get(['transactions.id', 'transactions.balance_dirty', 'transactions.transaction_currency_id', 'transaction_journals.date', 'transactions.account_id', 'transactions.amount']); /** @var Transaction $entry */ foreach ($set as $entry) { // start with empty array: $balances[$entry->account_id] ??= []; - $balances[$entry->account_id][$entry->transaction_currency_id] ??= '0'; + $balances[$entry->account_id][$entry->transaction_currency_id] ??= $this->getLatestBalance($entry->account_id, $entry->transaction_currency_id, $notBefore); // before and after are easy: - $before = $balances[$entry->account_id][$entry->transaction_currency_id]; - $after = bcadd($before, $entry->amount); + $before = $balances[$entry->account_id][$entry->transaction_currency_id]; + $after = bcadd($before, $entry->amount); if (true === $entry->balance_dirty || $accounts->count() > 0) { // update the transaction: $entry->balance_before = $before; @@ -157,9 +188,9 @@ class AccountBalanceCalculator private function getAccountBalanceByJournal(string $title, int $account, int $journal, int $currency): AccountBalance { - $query = AccountBalance::where('title', $title)->where('account_id', $account)->where('transaction_journal_id', $journal)->where('transaction_currency_id', $currency); + $query = AccountBalance::where('title', $title)->where('account_id', $account)->where('transaction_journal_id', $journal)->where('transaction_currency_id', $currency); - $entry = $query->first(); + $entry = $query->first(); if (null !== $entry) { return $entry; } @@ -176,7 +207,7 @@ class AccountBalanceCalculator private function recalculateLatest(?Account $account): void { - $query = Transaction::groupBy(['transactions.account_id', 'transactions.transaction_currency_id', 'transactions.foreign_currency_id']); + $query = Transaction::groupBy(['transactions.account_id', 'transactions.transaction_currency_id', 'transactions.foreign_currency_id']); if (null !== $account) { $query->where('transactions.account_id', $account->id); @@ -197,11 +228,11 @@ class AccountBalanceCalculator $sumForeignAmount = '' === $sumForeignAmount ? '0' : $sumForeignAmount; // at this point SQLite may return scientific notation because why not. Terrible. - $sumAmount = app('steam')->floatalize($sumAmount); - $sumForeignAmount = app('steam')->floatalize($sumForeignAmount); + $sumAmount = app('steam')->floatalize($sumAmount); + $sumForeignAmount = app('steam')->floatalize($sumForeignAmount); // first create for normal currency: - $entry = $this->getAccountBalanceByAccount($account, $transactionCurrency); + $entry = $this->getAccountBalanceByAccount($account, $transactionCurrency); try { $entry->balance = bcadd((string) $entry->balance, $sumAmount); @@ -256,7 +287,7 @@ class AccountBalanceCalculator */ private function recalculateJournals(?Account $account, ?TransactionJournal $transactionJournal): void { - $query = Transaction::groupBy(['transactions.account_id', 'transaction_journals.id', 'transactions.transaction_currency_id', 'transactions.foreign_currency_id']); + $query = Transaction::groupBy(['transactions.account_id', 'transaction_journals.id', 'transactions.transaction_currency_id', 'transactions.foreign_currency_id']); $query->leftJoin('transaction_journals', 'transaction_journals.id', '=', 'transactions.transaction_journal_id'); $query->orderBy('transaction_journals.date', 'asc'); $amounts = []; @@ -267,28 +298,28 @@ class AccountBalanceCalculator $query->where('transaction_journals.date', '>=', $transactionJournal->date); $amounts = $this->getStartAmounts($account, $transactionJournal); } - $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')]); + $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')]); /** @var \stdClass $row */ foreach ($result as $row) { - $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; + $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; // check for empty strings - $sumAmount = '' === $sumAmount ? '0' : $sumAmount; - $sumForeignAmount = '' === $sumForeignAmount ? '0' : $sumForeignAmount; + $sumAmount = '' === $sumAmount ? '0' : $sumAmount; + $sumForeignAmount = '' === $sumForeignAmount ? '0' : $sumForeignAmount; // new amounts: $amounts[$account][$transactionCurrency] = bcadd($amounts[$account][$transactionCurrency] ?? '0', $sumAmount); $amounts[$account][$foreignCurrency] = bcadd($amounts[$account][$foreignCurrency] ?? '0', $sumForeignAmount); // first create for normal currency: - $entry = self::getAccountBalanceByJournal('balance_after_journal', $account, $journalId, $transactionCurrency); - $entry->balance = $amounts[$account][$transactionCurrency]; + $entry = self::getAccountBalanceByJournal('balance_after_journal', $account, $journalId, $transactionCurrency); + $entry->balance = $amounts[$account][$transactionCurrency]; $entry->save(); // then do foreign amount, if present: diff --git a/app/Support/Twig/General.php b/app/Support/Twig/General.php index 6bdaac592c..e7bebdcd19 100644 --- a/app/Support/Twig/General.php +++ b/app/Support/Twig/General.php @@ -29,7 +29,6 @@ use FireflyIII\Repositories\Account\AccountRepositoryInterface; use FireflyIII\Repositories\User\UserRepositoryInterface; use FireflyIII\Support\Search\OperatorQuerySearch; use League\CommonMark\GithubFlavoredMarkdownConverter; -use Route; use Twig\Extension\AbstractExtension; use Twig\TwigFilter; use Twig\TwigFunction; @@ -63,8 +62,15 @@ class General extends AbstractExtension } /** @var Carbon $date */ - $date = session('end', today(config('app.timezone'))->endOfMonth()); - $info = app('steam')->balanceByTransactions($account, $date, null); + $date = session('end', today(config('app.timezone'))->endOfMonth()); + $runningBalance = config('firefly.feature_flags.running_balance_column'); + $info = []; + if (true === $runningBalance) { + $info = app('steam')->balanceByTransactions($account, $date, null); + } + if (false === $runningBalance) { + $info[] = app('steam')->balance($account, $date); + } $strings = []; foreach ($info as $currencyId => $balance) { @@ -87,15 +93,15 @@ class General extends AbstractExtension static function (int $size): string { // less than one GB, more than one MB if ($size < (1024 * 1024 * 2014) && $size >= (1024 * 1024)) { - return round($size / (1024 * 1024), 2).' MB'; + return round($size / (1024 * 1024), 2) . ' MB'; } // less than one MB if ($size < (1024 * 1024)) { - return round($size / 1024, 2).' KB'; + return round($size / 1024, 2) . ' KB'; } - return $size.' bytes'; + return $size . ' bytes'; } ); } @@ -117,7 +123,7 @@ class General extends AbstractExtension case 'application/pdf': return 'fa-file-pdf-o'; - // image + // image case 'image/png': case 'image/jpeg': case 'image/svg+xml': @@ -126,7 +132,7 @@ class General extends AbstractExtension case 'application/vnd.oasis.opendocument.image': return 'fa-file-image-o'; - // MS word + // MS word case 'application/msword': case 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': case 'application/vnd.openxmlformats-officedocument.wordprocessingml.template': @@ -142,7 +148,7 @@ class General extends AbstractExtension case 'application/vnd.oasis.opendocument.text-master': return 'fa-file-word-o'; - // MS excel + // MS excel case 'application/vnd.ms-excel': case 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': case 'application/vnd.openxmlformats-officedocument.spreadsheetml.template': @@ -153,7 +159,7 @@ class General extends AbstractExtension case 'application/vnd.oasis.opendocument.spreadsheet-template': return 'fa-file-excel-o'; - // MS powerpoint + // MS powerpoint case 'application/vnd.ms-powerpoint': case 'application/vnd.openxmlformats-officedocument.presentationml.presentation': case 'application/vnd.openxmlformats-officedocument.presentationml.template': @@ -165,7 +171,7 @@ class General extends AbstractExtension case 'application/vnd.oasis.opendocument.presentation-template': return 'fa-file-powerpoint-o'; - // calc + // calc case 'application/vnd.sun.xml.draw': case 'application/vnd.sun.xml.draw.template': case 'application/vnd.stardivision.draw': @@ -198,7 +204,7 @@ class General extends AbstractExtension ] ); - return (string)$converter->convert($text); + return (string) $converter->convert($text); }, ['is_safe' => ['html']] ); @@ -212,8 +218,8 @@ class General extends AbstractExtension return new TwigFilter( 'phphost', static function (string $string): string { - $proto = (string)parse_url($string, PHP_URL_SCHEME); - $host = (string)parse_url($string, PHP_URL_HOST); + $proto = (string) parse_url($string, PHP_URL_SCHEME); + $host = (string) parse_url($string, PHP_URL_HOST); return e(sprintf('%s://%s', $proto, $host)); } @@ -301,7 +307,7 @@ class General extends AbstractExtension 'activeRoutePartialObjectType', static function ($context): string { [, $route, $objectType] = func_get_args(); - $activeObjectType = $context['objectType'] ?? false; + $activeObjectType = $context['objectType'] ?? false; if ($objectType === $activeObjectType && false !== stripos( diff --git a/config/firefly.php b/config/firefly.php index 6a7a0b3c1d..54c2595e48 100644 --- a/config/firefly.php +++ b/config/firefly.php @@ -65,10 +65,6 @@ use FireflyIII\Support\Binder\UserGroupAccount; use FireflyIII\Support\Binder\UserGroupBill; use FireflyIII\Support\Binder\UserGroupTransaction; use FireflyIII\TransactionRules\Actions\AddTag; -use FireflyIII\TransactionRules\Actions\AppendDescription; -use FireflyIII\TransactionRules\Actions\AppendDescriptionToNotes; -use FireflyIII\TransactionRules\Actions\AppendNotes; -use FireflyIII\TransactionRules\Actions\AppendNotesToDescription; use FireflyIII\TransactionRules\Actions\ClearBudget; use FireflyIII\TransactionRules\Actions\ClearCategory; use FireflyIII\TransactionRules\Actions\ClearNotes; @@ -77,10 +73,6 @@ use FireflyIII\TransactionRules\Actions\ConvertToTransfer; use FireflyIII\TransactionRules\Actions\ConvertToWithdrawal; use FireflyIII\TransactionRules\Actions\DeleteTransaction; use FireflyIII\TransactionRules\Actions\LinkToBill; -use FireflyIII\TransactionRules\Actions\MoveDescriptionToNotes; -use FireflyIII\TransactionRules\Actions\MoveNotesToDescription; -use FireflyIII\TransactionRules\Actions\PrependDescription; -use FireflyIII\TransactionRules\Actions\PrependNotes; use FireflyIII\TransactionRules\Actions\RemoveAllTags; use FireflyIII\TransactionRules\Actions\RemoveTag; use FireflyIII\TransactionRules\Actions\SetAmount; @@ -110,11 +102,12 @@ return [ ], // some feature flags: 'feature_flags' => [ - 'export' => true, - 'telemetry' => false, - 'webhooks' => true, - 'handle_debts' => true, - 'expression_engine' => true, + 'export' => true, + 'telemetry' => false, + 'webhooks' => true, + 'handle_debts' => true, + 'expression_engine' => true, + 'running_balance_column' => env('USE_RUNNING_BALANCE', false), // see cer.php for exchange rates feature flag. ], 'version' => 'develop/2024-09-28', @@ -436,7 +429,7 @@ return [ 'transfers' => 'fa-exchange', ], - 'bindables' => [ + 'bindables' => [ // models 'account' => Account::class, 'attachment' => Attachment::class, @@ -494,7 +487,7 @@ return [ 'userGroupBill' => UserGroupBill::class, 'userGroup' => UserGroup::class, ], - 'rule-actions' => [ + 'rule-actions' => [ 'set_category' => SetCategory::class, 'clear_category' => ClearCategory::class, 'set_budget' => SetBudget::class, @@ -528,7 +521,7 @@ return [ // 'set_foreign_amount' => SetForeignAmount::class, // 'set_foreign_currency' => SetForeignCurrency::class, ], - 'context-rule-actions' => [ + 'context-rule-actions' => [ 'set_category', 'set_budget', 'add_tag', @@ -547,13 +540,13 @@ return [ 'convert_transfer', ], - 'test-triggers' => [ + 'test-triggers' => [ 'limit' => 10, 'range' => 200, ], // expected source types for each transaction type, in order of preference. - 'expected_source_types' => [ + 'expected_source_types' => [ 'source' => [ TransactionTypeModel::WITHDRAWAL => [AccountType::ASSET, AccountType::LOAN, AccountType::DEBT, AccountType::MORTGAGE], TransactionTypeEnum::DEPOSIT->value => [AccountType::LOAN, AccountType::DEBT, AccountType::MORTGAGE, AccountType::REVENUE, AccountType::CASH], @@ -598,7 +591,7 @@ return [ TransactionTypeModel::LIABILITY_CREDIT => [AccountType::LIABILITY_CREDIT, AccountType::LOAN, AccountType::DEBT, AccountType::MORTGAGE], ], ], - 'allowed_opposing_types' => [ + 'allowed_opposing_types' => [ 'source' => [ AccountType::ASSET => [ AccountType::ASSET, @@ -688,7 +681,7 @@ return [ ], ], // depending on the account type, return the allowed transaction types: - 'allowed_transaction_types' => [ + 'allowed_transaction_types' => [ 'source' => [ AccountType::ASSET => [ TransactionTypeModel::WITHDRAWAL, @@ -757,7 +750,7 @@ return [ ], // having the source + dest will tell you the transaction type. - 'account_to_transaction' => [ + 'account_to_transaction' => [ AccountType::ASSET => [ AccountType::ASSET => TransactionTypeModel::TRANSFER, AccountType::CASH => TransactionTypeModel::WITHDRAWAL, @@ -822,7 +815,7 @@ return [ ], // allowed source -> destination accounts. - 'source_dests' => [ + 'source_dests' => [ TransactionTypeModel::WITHDRAWAL => [ AccountType::ASSET => [AccountType::EXPENSE, AccountType::LOAN, AccountType::DEBT, AccountType::MORTGAGE, AccountType::CASH], AccountType::LOAN => [AccountType::EXPENSE, AccountType::CASH], @@ -861,7 +854,7 @@ return [ ], ], // if you add fields to this array, don't forget to update the export routine (ExportDataGenerator). - 'journal_meta_fields' => [ + 'journal_meta_fields' => [ // sepa 'sepa_cc', 'sepa_ct_op', @@ -895,28 +888,28 @@ return [ 'recurrence_count', 'recurrence_date', ], - 'webhooks' => [ + 'webhooks' => [ 'max_attempts' => env('WEBHOOK_MAX_ATTEMPTS', 3), ], - 'can_have_virtual_amounts' => [AccountType::ASSET], - 'can_have_opening_balance' => [AccountType::ASSET, AccountType::LOAN, AccountType::DEBT, AccountType::MORTGAGE], - 'dynamic_creation_allowed' => [ + 'can_have_virtual_amounts' => [AccountType::ASSET], + 'can_have_opening_balance' => [AccountType::ASSET, AccountType::LOAN, AccountType::DEBT, AccountType::MORTGAGE], + 'dynamic_creation_allowed' => [ AccountType::EXPENSE, AccountType::REVENUE, AccountType::INITIAL_BALANCE, AccountType::RECONCILIATION, AccountType::LIABILITY_CREDIT, ], - 'valid_asset_fields' => ['account_role', 'account_number', 'currency_id', 'BIC', 'include_net_worth'], - 'valid_cc_fields' => ['account_role', 'cc_monthly_payment_date', 'cc_type', 'account_number', 'currency_id', 'BIC', 'include_net_worth'], - 'valid_account_fields' => ['account_number', 'currency_id', 'BIC', 'interest', 'interest_period', 'include_net_worth', 'liability_direction'], + 'valid_asset_fields' => ['account_role', 'account_number', 'currency_id', 'BIC', 'include_net_worth'], + 'valid_cc_fields' => ['account_role', 'cc_monthly_payment_date', 'cc_type', 'account_number', 'currency_id', 'BIC', 'include_net_worth'], + 'valid_account_fields' => ['account_number', 'currency_id', 'BIC', 'interest', 'interest_period', 'include_net_worth', 'liability_direction'], // dynamic date ranges are as follows: - 'dynamic_date_ranges' => ['last7', 'last30', 'last90', 'last365', 'MTD', 'QTD', 'YTD'], + 'dynamic_date_ranges' => ['last7', 'last30', 'last90', 'last365', 'MTD', 'QTD', 'YTD'], // only used in v1 - 'allowed_sort_parameters' => ['order', 'name', 'iban'], + 'allowed_sort_parameters' => ['order', 'name', 'iban'], // preselected account lists possibilities: - 'preselected_accounts' => ['all', 'assets', 'liabilities'], + 'preselected_accounts' => ['all', 'assets', 'liabilities'], ];