From 27586a7ec26a55643bb7cb99190282054b3c88be Mon Sep 17 00:00:00 2001 From: James Cole Date: Tue, 30 Dec 2025 16:13:28 +0100 Subject: [PATCH] Allow transactions to be moved to the future and still have the running balance calculated correctly. --- app/Factory/TransactionJournalMetaFactory.php | 8 +-- app/Handlers/Observer/TransactionObserver.php | 17 ++++-- .../AttachmentRepositoryInterface.php | 1 - .../Internal/Update/JournalUpdateService.php | 35 ++++++----- .../Models/AccountBalanceCalculator.php | 59 +++++++++++-------- .../TransactionGroupTransformer.php | 2 +- 6 files changed, 72 insertions(+), 50 deletions(-) diff --git a/app/Factory/TransactionJournalMetaFactory.php b/app/Factory/TransactionJournalMetaFactory.php index 31e9fe4888..9d2c1f1a80 100644 --- a/app/Factory/TransactionJournalMetaFactory.php +++ b/app/Factory/TransactionJournalMetaFactory.php @@ -36,10 +36,10 @@ class TransactionJournalMetaFactory public function updateOrCreate(array $data): ?TransactionJournalMeta { // Log::debug('In updateOrCreate()'); - $value = $data['data']; + $value = $data['data']; /** @var null|TransactionJournalMeta $entry */ - $entry = $data['journal']->transactionJournalMeta()->where('name', $data['name'])->first(); + $entry = $data['journal']->transactionJournalMeta()->where('name', $data['name'])->first(); if (null === $value && null !== $entry) { // Log::debug('Value is empty, delete meta value.'); $entry->delete(); @@ -51,7 +51,7 @@ class TransactionJournalMetaFactory Log::debug('Is a carbon object.'); $value = $data['data']->toW3cString(); } - if ('' === (string) $value) { + if ('' === (string)$value) { // Log::debug('Is an empty string.'); // don't store blank strings. if (null !== $entry) { @@ -65,7 +65,7 @@ class TransactionJournalMetaFactory if (null === $entry) { // Log::debug('Will create new object.'); Log::debug(sprintf('Going to create new meta-data entry to store "%s".', $data['name'])); - $entry = new TransactionJournalMeta(); + $entry = new TransactionJournalMeta(); $entry->transactionJournal()->associate($data['journal']); $entry->name = $data['name']; } diff --git a/app/Handlers/Observer/TransactionObserver.php b/app/Handlers/Observer/TransactionObserver.php index 1ab0953d6c..b44c56c124 100644 --- a/app/Handlers/Observer/TransactionObserver.php +++ b/app/Handlers/Observer/TransactionObserver.php @@ -24,11 +24,12 @@ declare(strict_types=1); namespace FireflyIII\Handlers\Observer; use FireflyIII\Models\Transaction; +use FireflyIII\Models\TransactionJournal; use FireflyIII\Support\Facades\Amount; +use FireflyIII\Support\Facades\FireflyConfig; use FireflyIII\Support\Http\Api\ExchangeRateConverter; use FireflyIII\Support\Models\AccountBalanceCalculator; use Illuminate\Support\Facades\Log; -use FireflyIII\Support\Facades\FireflyConfig; /** * Class TransactionObserver @@ -42,7 +43,10 @@ class TransactionObserver Log::debug('Observe "created" of a transaction.'); if (true === FireflyConfig::get('use_running_balance', config('firefly.feature_flags.running_balance_column'))->data && (1 === bccomp($transaction->amount, '0') && self::$recalculate)) { Log::debug('Trigger recalculateForJournal'); - AccountBalanceCalculator::recalculateForJournal($transaction->transactionJournal); + $journal = $transaction->transactionJournal; + if ($journal instanceof TransactionJournal) { + AccountBalanceCalculator::recalculateForJournal($journal); + } } $this->updatePrimaryCurrencyAmount($transaction); } @@ -56,15 +60,18 @@ class TransactionObserver $transaction->native_amount = null; $transaction->native_foreign_amount = null; // first normal amount - if ($transaction->transactionCurrency->id !== $userCurrency->id && (null === $transaction->foreign_currency_id || (null !== $transaction->foreign_currency_id && $transaction->foreign_currency_id !== $userCurrency->id))) { - $converter = new ExchangeRateConverter(); + if ($transaction->transactionCurrency->id !== $userCurrency->id && + (null === $transaction->foreign_currency_id || + (null !== $transaction->foreign_currency_id && + $transaction->foreign_currency_id !== $userCurrency->id))) { + $converter = new ExchangeRateConverter(); $converter->setUserGroup($transaction->transactionJournal->user->userGroup); $converter->setIgnoreSettings(true); $transaction->native_amount = $converter->convert($transaction->transactionCurrency, $userCurrency, $transaction->transactionJournal->date, $transaction->amount); } // then foreign amount if ($transaction->foreignCurrency?->id !== $userCurrency->id && null !== $transaction->foreign_amount && null !== $transaction->foreignCurrency) { - $converter = new ExchangeRateConverter(); + $converter = new ExchangeRateConverter(); $converter->setUserGroup($transaction->transactionJournal->user->userGroup); $converter->setIgnoreSettings(true); $transaction->native_foreign_amount = $converter->convert($transaction->foreignCurrency, $userCurrency, $transaction->transactionJournal->date, $transaction->foreign_amount); diff --git a/app/Repositories/Attachment/AttachmentRepositoryInterface.php b/app/Repositories/Attachment/AttachmentRepositoryInterface.php index 01f43cdb3b..756219f108 100644 --- a/app/Repositories/Attachment/AttachmentRepositoryInterface.php +++ b/app/Repositories/Attachment/AttachmentRepositoryInterface.php @@ -38,7 +38,6 @@ use Illuminate\Support\Collection; * @method getUserGroup() * @method getUser() * @method checkUserGroupAccess(UserRoleEnum $role) - * @method setUser(null|Authenticatable|User $user) * @method setUserGroupById(int $userGroupId) */ interface AttachmentRepositoryInterface diff --git a/app/Services/Internal/Update/JournalUpdateService.php b/app/Services/Internal/Update/JournalUpdateService.php index c528cd2e40..0e7b19ba34 100644 --- a/app/Services/Internal/Update/JournalUpdateService.php +++ b/app/Services/Internal/Update/JournalUpdateService.php @@ -68,8 +68,7 @@ class JournalUpdateService private ?Account $destinationAccount = null; private ?Transaction $destinationTransaction = null; private array $metaDate - = ['interest_date', 'book_date', 'process_date', 'due_date', 'payment_date', - 'invoice_date', ]; + = ['interest_date', 'book_date', 'process_date', 'due_date', 'payment_date', 'invoice_date', '_internal_previous_date']; private array $metaString = [ 'sepa_cc', @@ -205,11 +204,9 @@ class JournalUpdateService $validator->setUser($this->transactionJournal->user); $result = $validator->validateSource(['id' => $sourceId, 'name' => $sourceName]); - Log::debug( - sprintf('hasValidSourceAccount(%d, "%s") will return %s', $sourceId, $sourceName, var_export($result, true)) - ); + Log::debug(sprintf('hasValidSourceAccount(%d, "%s") will return %s', $sourceId, $sourceName, var_export($result, true))); - // TODO typeoverrule the account validator may have a different opinion on the transaction type. + // TODO type overrule the account validator may have a different opinion on the transaction type. // validate submitted info: return $result; @@ -283,14 +280,7 @@ class JournalUpdateService $validator->setUser($this->transactionJournal->user); $validator->source = $this->getValidSourceAccount(); $result = $validator->validateDestination(['id' => $destId, 'name' => $destName]); - Log::debug( - sprintf( - 'hasValidDestinationAccount(%d, "%s") will return %s', - $destId, - $destName, - var_export($result, true) - ) - ); + Log::debug(sprintf('hasValidDestinationAccount(%d, "%s") will return %s', $destId, $destName, var_export($result, true))); // TODO typeOverrule: the account validator may have another opinion on the transaction type. @@ -494,6 +484,23 @@ class JournalUpdateService // do some parsing. Log::debug(sprintf('Create date value from string "%s".', $value)); $this->transactionJournal->date_tz = $value->format('e'); + $res = $value->gt($this->transactionJournal->date); + Log::debug(sprintf('Old date: %s, new date: %s', $this->transactionJournal->date->toW3cString(), $value->toW3cString())); + /** @var TransactionJournalMetaFactory $factory */ + $factory = app(TransactionJournalMetaFactory::class); + $set = [ + 'journal' => $this->transactionJournal, + 'name' => '_internal_previous_date', + 'data' => null, + ]; + if($res) { + Log::debug('Transaction is set to be AFTER its current date. Save also the "_internal_previous_date"-field.'); + $set['data'] = clone $this->transactionJournal->date; + } + if(!$res) { + Log::debug('Transaction is NOT set to be AFTER its current date. Remove the "_internal_previous_date"-field.'); + } + $factory->updateOrCreate($set); } event(new TriggeredAuditLog($this->transactionJournal->user, $this->transactionJournal, sprintf('update_%s', $fieldName), $this->transactionJournal->{$fieldName}, $value)); diff --git a/app/Support/Models/AccountBalanceCalculator.php b/app/Support/Models/AccountBalanceCalculator.php index ddb91a412b..b77496a7eb 100644 --- a/app/Support/Models/AccountBalanceCalculator.php +++ b/app/Support/Models/AccountBalanceCalculator.php @@ -65,14 +65,25 @@ class AccountBalanceCalculator public static function recalculateForJournal(TransactionJournal $transactionJournal): void { Log::debug(__METHOD__); - $object = new self(); + $object = new self(); - $set = []; + $set = []; foreach ($transactionJournal->transactions as $transaction) { $set[$transaction->account_id] = $transaction->account; } $accounts = new Collection()->push(...$set); - $object->optimizedCalculation($accounts, $transactionJournal->date); + + // 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())); + } + + + $object->optimizedCalculation($accounts, $date); } private function getLatestBalance(int $accountId, int $currencyId, ?Carbon $notBefore): string @@ -83,18 +94,17 @@ class AccountBalanceCalculator 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') + $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 - ->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) - ; + ->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); $notBefore->startOfDay(); $query->where('transaction_journals.date', '<', $notBefore); @@ -115,15 +125,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()); } @@ -132,7 +141,7 @@ 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']); Log::debug(sprintf('Counted %d transaction(s)', $set->count())); // the balance value is an array. @@ -145,8 +154,8 @@ class AccountBalanceCalculator $balances[$entry->account_id][$entry->transaction_currency_id] ??= [$this->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); + $before = $balances[$entry->account_id][$entry->transaction_currency_id][0]; + $after = bcadd($before, (string)$entry->amount); if (true === $entry->balance_dirty || $accounts->count() > 0) { // update the transaction: $entry->balance_before = $before; diff --git a/app/Transformers/TransactionGroupTransformer.php b/app/Transformers/TransactionGroupTransformer.php index 8f2152f973..2d420a6f9d 100644 --- a/app/Transformers/TransactionGroupTransformer.php +++ b/app/Transformers/TransactionGroupTransformer.php @@ -75,7 +75,7 @@ class TransactionGroupTransformer extends AbstractTransformer 'recurrence_count', 'recurrence_total', ]; - $this->metaDateFields = ['interest_date', 'book_date', 'process_date', 'due_date', 'payment_date', 'invoice_date']; + $this->metaDateFields = ['interest_date', 'book_date', 'process_date', 'due_date', 'payment_date', 'invoice_date', '_internal_previous_date']; } public function transform(array $group): array