diff --git a/app/Factory/AccountFactory.php b/app/Factory/AccountFactory.php index 40336ccaa4..0a976bd8d3 100644 --- a/app/Factory/AccountFactory.php +++ b/app/Factory/AccountFactory.php @@ -24,6 +24,7 @@ declare(strict_types=1); namespace FireflyIII\Factory; +use FireflyIII\Events\StoredAccount; use FireflyIII\Exceptions\FireflyException; use FireflyIII\Models\Account; use FireflyIII\Models\AccountType; @@ -48,6 +49,7 @@ class AccountFactory protected array $validCCFields; protected array $validFields; private array $canHaveVirtual; + private array $canHaveOpeningBalance; private User $user; /** @@ -57,11 +59,12 @@ class AccountFactory */ public function __construct() { - $this->accountRepository = app(AccountRepositoryInterface::class); - $this->canHaveVirtual = config('firefly.can_have_virtual_amounts'); - $this->validAssetFields = config('firefly.valid_asset_fields'); - $this->validCCFields = config('firefly.valid_cc_fields'); - $this->validFields = config('firefly.valid_account_fields'); + $this->accountRepository = app(AccountRepositoryInterface::class); + $this->canHaveVirtual = config('firefly.can_have_virtual_amounts'); + $this->canHaveOpeningBalance = config('firefly.can_have_opening_balance'); + $this->validAssetFields = config('firefly.valid_asset_fields'); + $this->validCCFields = config('firefly.valid_cc_fields'); + $this->validFields = config('firefly.valid_account_fields'); } /** @@ -113,10 +116,14 @@ class AccountFactory // account may exist already: $return = $this->find($data['name'], $type->type); - if (null === $return) { - $return = $this->createAccount($type, $data); + if (null !== $return) { + return $return; } + $return = $this->createAccount($type, $data); + + event(new StoredAccount($return)); + return $return; } @@ -203,7 +210,18 @@ class AccountFactory $this->storeMetaData($account, $data); // create opening balance - $this->storeOpeningBalance($account, $data); + try { + $this->storeOpeningBalance($account, $data); + } catch (FireflyException $e) { + Log::error($e->getMessage()); + } + + // create credit liability data (if relevant) + try { + $this->storeCreditLiability($account, $data); + } catch (FireflyException $e) { + Log::error($e->getMessage()); + } // create notes $notes = array_key_exists('notes', $data) ? $data['notes'] : ''; @@ -242,7 +260,6 @@ class AccountFactory if (array_key_exists('liability_direction', $data) && !in_array($account->accountType->type, config('firefly.valid_liabilities'), true)) { $data['liability_direction'] = null; } - $data['account_role'] = $accountRole; $data['currency_id'] = $currency->id; @@ -287,15 +304,18 @@ class AccountFactory /** * @param Account $account * @param array $data + * + * @throws FireflyException */ private function storeOpeningBalance(Account $account, array $data) { $accountType = $account->accountType->type; - // if it can have a virtual balance, it can also have an opening balance. - if (in_array($accountType, $this->canHaveVirtual, true)) { + if (in_array($accountType, $this->canHaveOpeningBalance, true)) { if ($this->validOBData($data)) { - $this->updateOBGroup($account, $data); + $openingBalance = $data['opening_balance']; + $openingBalanceDate = $data['opening_balance_date']; + $this->updateOBGroupV2($account, $openingBalance, $openingBalanceDate); } if (!$this->validOBData($data)) { $this->deleteOBGroup($account); @@ -303,6 +323,30 @@ class AccountFactory } } + /** + * @param Account $account + * @param array $data + * + * @throws FireflyException + */ + private function storeCreditLiability(Account $account, array $data) + { + $account->refresh(); + $accountType = $account->accountType->type; + $direction = $this->accountRepository->getMetaValue($account, 'liability_direction'); + $valid = config('firefly.valid_liabilities'); + if (in_array($accountType, $valid, true) && 'credit' === $direction) { + if ($this->validOBData($data)) { + $openingBalance = $data['opening_balance']; + $openingBalanceDate = $data['opening_balance_date']; + $this->updateCreditTransaction($account, $openingBalance, $openingBalanceDate); + } + if (!$this->validOBData($data)) { + $this->deleteCreditTransaction($account); + } + } + } + /** * @param Account $account * @param array $data diff --git a/app/Http/Controllers/Account/CreateController.php b/app/Http/Controllers/Account/CreateController.php index 392e03cb2b..a01febc004 100644 --- a/app/Http/Controllers/Account/CreateController.php +++ b/app/Http/Controllers/Account/CreateController.php @@ -80,14 +80,14 @@ class CreateController extends Controller */ public function create(Request $request, string $objectType = null) { - $objectType = $objectType ?? 'asset'; - $defaultCurrency = app('amount')->getDefaultCurrency(); - $subTitleIcon = config(sprintf('firefly.subIconsByIdentifier.%s', $objectType)); - $subTitle = (string)trans(sprintf('firefly.make_new_%s_account', $objectType)); - $roles = $this->getRoles(); - $liabilityTypes = $this->getLiabilityTypes(); - $hasOldInput = null !== $request->old('_token'); - $locations = [ + $objectType = $objectType ?? 'asset'; + $defaultCurrency = app('amount')->getDefaultCurrency(); + $subTitleIcon = config(sprintf('firefly.subIconsByIdentifier.%s', $objectType)); + $subTitle = (string)trans(sprintf('firefly.make_new_%s_account', $objectType)); + $roles = $this->getRoles(); + $liabilityTypes = $this->getLiabilityTypes(); + $hasOldInput = null !== $request->old('_token'); + $locations = [ 'location' => [ 'latitude' => $hasOldInput ? old('location_latitude') : config('firefly.default_location.latitude'), 'longitude' => $hasOldInput ? old('location_longitude') : config('firefly.default_location.longitude'), @@ -95,6 +95,10 @@ class CreateController extends Controller 'has_location' => $hasOldInput ? 'true' === old('location_has_location') : false, ], ]; + $liabilityDirections = [ + 'debit' => trans('firefly.liability_direction_debit'), + 'credit' => trans('firefly.liability_direction_credit'), + ]; // interest calculation periods: $interestPeriods = [ @@ -119,7 +123,10 @@ class CreateController extends Controller $request->session()->forget('accounts.create.fromStore'); Log::channel('audit')->info('Creating new account.'); - return prefixView('accounts.create', compact('subTitleIcon', 'locations', 'objectType', 'interestPeriods', 'subTitle', 'roles', 'liabilityTypes')); + return prefixView( + 'accounts.create', + compact('subTitleIcon', 'liabilityDirections', 'locations', 'objectType', 'interestPeriods', 'subTitle', 'roles', 'liabilityTypes') + ); } /** @@ -156,7 +163,7 @@ class CreateController extends Controller } if (count($this->attachments->getMessages()->get('attachments')) > 0) { - $request->session()->flash('info', $this->attachments->getMessages()->get('attachments')); + $request->session()->flash('info', $this->attachments->getMessages()->get('attachments')); } // redirect to previous URL. diff --git a/app/Http/Requests/AccountFormRequest.php b/app/Http/Requests/AccountFormRequest.php index ff6bc71ed6..86370a1440 100644 --- a/app/Http/Requests/AccountFormRequest.php +++ b/app/Http/Requests/AccountFormRequest.php @@ -62,6 +62,7 @@ class AccountFormRequest extends FormRequest 'interest' => $this->string('interest'), 'interest_period' => $this->string('interest_period'), 'include_net_worth' => '1', + 'liability_direction' => $this->string('liability_direction'), ]; $data = $this->appendLocationData($data, 'location'); @@ -74,6 +75,7 @@ class AccountFormRequest extends FormRequest if ('liabilities' === $data['account_type_name']) { $data['account_type_name'] = null; $data['account_type_id'] = $this->integer('liability_type_id'); + $data['opening_balance'] = app('steam')->negative($data['opening_balance']); } return $data; diff --git a/app/Repositories/Account/AccountRepository.php b/app/Repositories/Account/AccountRepository.php index 06ed6e8dd6..2717239829 100644 --- a/app/Repositories/Account/AccountRepository.php +++ b/app/Repositories/Account/AccountRepository.php @@ -125,7 +125,7 @@ class AccountRepository implements AccountRepositoryInterface { $query = $this->user->accounts()->where('iban', '!=', '')->whereNotNull('iban'); - if (0!==count($types)) { + if (0 !== count($types)) { $query->leftJoin('account_types', 'accounts.account_type_id', '=', 'account_types.id'); $query->whereIn('account_types.type', $types); } @@ -747,4 +747,21 @@ class AccountRepository implements AccountRepositoryInterface return $service->update($account, $data); } + + /** + * @inheritDoc + */ + public function getCreditTransactionGroup(Account $account): ?TransactionGroup + { + $journal = TransactionJournal + ::leftJoin('transactions', 'transactions.transaction_journal_id', '=', 'transaction_journals.id') + ->where('transactions.account_id', $account->id) + ->transactionTypes([TransactionType::LIABILITY_CREDIT]) + ->first(['transaction_journals.*']); + if (null === $journal) { + return null; + } + + return $journal->transactionGroup; + } } diff --git a/app/Repositories/Account/AccountRepositoryInterface.php b/app/Repositories/Account/AccountRepositoryInterface.php index 99385c33e4..79172efbf0 100644 --- a/app/Repositories/Account/AccountRepositoryInterface.php +++ b/app/Repositories/Account/AccountRepositoryInterface.php @@ -198,6 +198,13 @@ interface AccountRepositoryInterface */ public function getOpeningBalanceDate(Account $account): ?string; + /** + * @param Account $account + * + * @return TransactionGroup|null + */ + public function getCreditTransactionGroup(Account $account): ?TransactionGroup; + /** * @param Account $account * diff --git a/app/Services/Internal/Support/AccountServiceTrait.php b/app/Services/Internal/Support/AccountServiceTrait.php index 5926f6fcf7..2b35d1713a 100644 --- a/app/Services/Internal/Support/AccountServiceTrait.php +++ b/app/Services/Internal/Support/AccountServiceTrait.php @@ -23,7 +23,9 @@ declare(strict_types=1); namespace FireflyIII\Services\Internal\Support; +use Carbon\Carbon; use Exception; +use FireflyIII\Exceptions\DuplicateTransactionException; use FireflyIII\Exceptions\FireflyException; use FireflyIII\Factory\AccountMetaFactory; use FireflyIII\Factory\TransactionCurrencyFactory; @@ -37,6 +39,7 @@ use FireflyIII\Models\TransactionGroup; use FireflyIII\Models\TransactionJournal; use FireflyIII\Repositories\Account\AccountRepositoryInterface; use FireflyIII\Services\Internal\Destroy\TransactionGroupDestroyService; +use JsonException; use Log; use Validator; @@ -126,7 +129,7 @@ trait AccountServiceTrait } if ($account->accountType->type === AccountType::ASSET && array_key_exists('account_role', $data) && 'ccAsset' === $data['account_role']) { - $fields = $this->validCCFields; + $fields = $this->validCCFields; } /** @var AccountMetaFactory $factory */ $factory = app(AccountMetaFactory::class); @@ -137,10 +140,10 @@ trait AccountServiceTrait // convert boolean value: if (is_bool($data[$field]) && false === $data[$field]) { - $data[$field] = 0; + $data[$field] = 0; } if (is_bool($data[$field]) && true === $data[$field]) { - $data[$field] = 1; + $data[$field] = 1; } $factory->crud($account, $field, (string)$data[$field]); @@ -191,9 +194,10 @@ trait AccountServiceTrait { $data['opening_balance'] = (string)($data['opening_balance'] ?? ''); if ('' !== $data['opening_balance'] && 0 === bccomp($data['opening_balance'], '0')) { - $data['opening_balance'] = ''; + $data['opening_balance'] = ''; } - if ('' !== $data['opening_balance'] && array_key_exists('opening_balance_date', $data) && '' !== $data['opening_balance_date']) { + if ('' !== $data['opening_balance'] && array_key_exists('opening_balance_date', $data) && '' !== $data['opening_balance_date'] + && $data['opening_balance_date'] instanceof Carbon) { Log::debug('Array has valid opening balance data.'); return true; @@ -234,11 +238,26 @@ trait AccountServiceTrait return $this->accountRepository->getOpeningBalanceGroup($account); } + /** + * Returns the credit transaction group, or NULL if it does not exist. + * + * @param Account $account + * + * @return TransactionGroup|null + */ + protected function getCreditTransaction(Account $account): ?TransactionGroup + { + Log::debug(sprintf('Now at %s', __METHOD__)); + + return $this->accountRepository->getCreditTransactionGroup($account); + } + /** * @param int $currencyId * @param string $currencyCode * * @return TransactionCurrency + * @throws JsonException */ protected function getCurrency(int $currencyId, string $currencyCode): TransactionCurrency { @@ -259,59 +278,57 @@ trait AccountServiceTrait } /** - * Update or create the opening balance group. Assumes valid data in $data. - * - * Returns null if this fails. + * Update or create the opening balance group. + * Since opening balance and date can still be empty strings, it may fail. * * @param Account $account - * @param array $data + * @param string $openingBalance + * @param Carbon $openingBalanceDate * - * @return TransactionGroup|null + * @return TransactionGroup + * @throws FireflyException */ - protected function updateOBGroup(Account $account, array $data): ?TransactionGroup + protected function updateOBGroupV2(Account $account, string $openingBalance, Carbon $openingBalanceDate): TransactionGroup { + Log::debug(sprintf('Now in %s', __METHOD__)); + // create if not exists: $obGroup = $this->getOBGroup($account); if (null === $obGroup) { - return $this->createOBGroup($account, $data); + return $this->createOBGroupV2($account, $openingBalance, $openingBalanceDate); } - // $data['currency_id'] is empty so creating a new journal may break. - if (!array_key_exists('currency_id', $data)) { - $currency = $this->accountRepository->getAccountCurrency($account); - if (null === $currency) { - $currency = app('default')->getDefaultCurrencyByUser($account->user); - } - $data['currency_id'] = $currency->id; + // if exists, update: + $currency = $this->accountRepository->getAccountCurrency($account); + if (null === $currency) { + $currency = app('default')->getDefaultCurrencyByUser($account->user); } - /** @var TransactionJournal $journal */ - $journal = $obGroup->transactionJournals()->first(); - $journal->date = $data['opening_balance_date'] ?? $journal->date; - $journal->transaction_currency_id = $data['currency_id']; + // simply grab the first journal and change it: + $journal = $this->getObJournal($obGroup); + $obTransaction = $this->getOBTransaction($journal, $account); + $accountTransaction = $this->getNotOBTransaction($journal, $account); + $journal->date = $openingBalanceDate; + $journal->transactionCurrency()->associate($currency); - /** @var Transaction $obTransaction */ - $obTransaction = $journal->transactions()->where('account_id', '!=', $account->id)->first(); - /** @var Transaction $accountTransaction */ - $accountTransaction = $journal->transactions()->where('account_id', $account->id)->first(); // if amount is negative: - if (1 === bccomp('0', $data['opening_balance'])) { + if (1 === bccomp('0', $openingBalance)) { // account transaction loses money: - $accountTransaction->amount = app('steam')->negative($data['opening_balance']); - $accountTransaction->transaction_currency_id = $data['currency_id']; + $accountTransaction->amount = app('steam')->negative($openingBalance); + $accountTransaction->transaction_currency_id = $currency->id; // OB account transaction gains money - $obTransaction->amount = app('steam')->positive($data['opening_balance']); - $obTransaction->transaction_currency_id = $data['currency_id']; + $obTransaction->amount = app('steam')->positive($openingBalance); + $obTransaction->transaction_currency_id = $currency->id; } - if (-1 === bccomp('0', $data['opening_balance'])) { + if (-1 === bccomp('0', $openingBalance)) { // account gains money: - $accountTransaction->amount = app('steam')->positive($data['opening_balance']); - $accountTransaction->transaction_currency_id = $data['currency_id']; + $accountTransaction->amount = app('steam')->positive($openingBalance); + $accountTransaction->transaction_currency_id = $currency->id; // OB account loses money: - $obTransaction->amount = app('steam')->negative($data['opening_balance']); - $obTransaction->transaction_currency_id = $data['currency_id']; + $obTransaction->amount = app('steam')->negative($openingBalance); + $obTransaction->transaction_currency_id = $currency->id; } // save both $accountTransaction->save(); @@ -323,12 +340,105 @@ trait AccountServiceTrait } /** - * @param Account $account - * @param array $data + * Create the opposing "credit liability" transaction for credit liabilities. * - * @return TransactionGroup|null + * @param Account $account + * @param string $openingBalance + * @param Carbon $openingBalanceDate + * + * @return TransactionGroup + * @throws FireflyException */ - protected function createOBGroup(Account $account, array $data): ?TransactionGroup + protected function updateCreditTransaction(Account $account, string $openingBalance, Carbon $openingBalanceDate): TransactionGroup + { + Log::debug(sprintf('Now in %s', __METHOD__)); + + if (0 === bccomp($openingBalance, '0')) { + Log::debug('Amount is zero, so will not update liability credit group.'); + throw new FireflyException('Amount for update liability credit was unexpectedly 0.'); + } + + // create if not exists: + $clGroup = $this->getCreditTransaction($account); + if (null === $clGroup) { + return $this->createCreditTransaction($account, $openingBalance, $openingBalanceDate); + } + // if exists, update: + $currency = $this->accountRepository->getAccountCurrency($account); + if (null === $currency) { + $currency = app('default')->getDefaultCurrencyByUser($account->user); + } + + // simply grab the first journal and change it: + $journal = $this->getObJournal($clGroup); + $clTransaction = $this->getOBTransaction($journal, $account); + $accountTransaction = $this->getNotOBTransaction($journal, $account); + $journal->date = $openingBalanceDate; + $journal->transactionCurrency()->associate($currency); + + // account always gains money: + $accountTransaction->amount = app('steam')->positive($openingBalance); + $accountTransaction->transaction_currency_id = $currency->id; + + // CL account always loses money: + $clTransaction->amount = app('steam')->negative($openingBalance); + $clTransaction->transaction_currency_id = $currency->id; + // save both + $accountTransaction->save(); + $clTransaction->save(); + $journal->save(); + $clGroup->refresh(); + + return $clGroup; + } + + /** + * TODO rename to "getOpposingTransaction" + * + * @param TransactionJournal $journal + * @param Account $account + * + * @return Transaction + * @throws FireflyException + */ + private function getOBTransaction(TransactionJournal $journal, Account $account): Transaction + { + /** @var Transaction $transaction */ + $transaction = $journal->transactions()->where('account_id', '!=', $account->id)->first(); + if (null === $transaction) { + throw new FireflyException(sprintf('Could not get OB transaction for journal #%d', $journal->id)); + } + + return $transaction; + } + + /** + * @param TransactionJournal $journal + * @param Account $account + * + * @return Transaction + * @throws FireflyException + */ + private function getNotOBTransaction(TransactionJournal $journal, Account $account): Transaction + { + /** @var Transaction $transaction */ + $transaction = $journal->transactions()->where('account_id', $account->id)->first(); + if (null === $transaction) { + throw new FireflyException(sprintf('Could not get non-OB transaction for journal #%d', $journal->id)); + } + + return $transaction; + } + + /** + * @param Account $account + * @param string $openingBalance + * @param Carbon $openingBalanceDate + * + * @return TransactionGroup + * @throws FireflyException + */ + protected function createOBGroupV2(Account $account, string $openingBalance, Carbon $openingBalanceDate): TransactionGroup { Log::debug('Now going to create an OB group.'); $language = app('preferences')->getForUser($account->user, 'language', 'en_US')->data; @@ -336,48 +446,48 @@ trait AccountServiceTrait $sourceName = null; $destId = null; $destName = null; - $amount = $data['opening_balance']; - if (1 === bccomp($amount, '0')) { - Log::debug(sprintf('Amount is %s, which is positive. Source is a new IB account, destination is #%d', $amount, $account->id)); - // amount is positive. + + // amount is positive. + if (1 === bccomp($openingBalance, '0')) { + Log::debug(sprintf('Amount is %s, which is positive. Source is a new IB account, destination is #%d', $openingBalance, $account->id)); $sourceName = trans('firefly.initial_balance_description', ['account' => $account->name], $language); $destId = $account->id; } - if (-1 === bccomp($amount, '0')) { - Log::debug(sprintf('Amount is %s, which is negative. Destination is a new IB account, source is #%d', $amount, $account->id)); - // amount is not positive + // amount is not positive + if (-1 === bccomp($openingBalance, '0')) { + Log::debug(sprintf('Amount is %s, which is negative. Destination is a new IB account, source is #%d', $openingBalance, $account->id)); $destName = trans('firefly.initial_balance_account', ['account' => $account->name], $language); $sourceId = $account->id; } - if (0 === bccomp($amount, '0')) { - + // amount is 0 + if (0 === bccomp($openingBalance, '0')) { Log::debug('Amount is zero, so will not make an OB group.'); - - return null; - - } - $amount = app('steam')->positive($amount); - if (!array_key_exists('currency_id', $data)) { - $currency = $this->accountRepository->getAccountCurrency($account); - if (null === $currency) { - $currency = app('default')->getDefaultCurrencyByUser($account->user); - } - $data['currency_id'] = $currency->id; + throw new FireflyException('Amount for new opening balance was unexpectedly 0.'); } + // make amount positive, regardless: + $amount = app('steam')->positive($openingBalance); + + // get or grab currency: + $currency = $this->accountRepository->getAccountCurrency($account); + if (null === $currency) { + $currency = app('default')->getDefaultCurrencyByUser($account->user); + } + + // submit to factory: $submission = [ 'group_title' => null, 'user' => $account->user_id, 'transactions' => [ [ 'type' => 'Opening balance', - 'date' => $data['opening_balance_date'], + 'date' => $openingBalanceDate, 'source_id' => $sourceId, 'source_name' => $sourceName, 'destination_id' => $destId, 'destination_name' => $destName, 'user' => $account->user_id, - 'currency_id' => $data['currency_id'], + 'currency_id' => $currency->id, 'order' => 0, 'amount' => $amount, 'foreign_amount' => null, @@ -395,18 +505,204 @@ trait AccountServiceTrait ], ]; Log::debug('Going for submission', $submission); - $group = null; + /** @var TransactionGroupFactory $factory */ $factory = app(TransactionGroupFactory::class); $factory->setUser($account->user); try { $group = $factory->create($submission); - - } catch (FireflyException $e) { + } catch (DuplicateTransactionException $e) { Log::error($e->getMessage()); Log::error($e->getTraceAsString()); + throw new FireflyException($e->getMessage(), 0, $e); } + return $group; } + + /** + * @param Account $account + * @param string $openingBalance + * @param Carbon $openingBalanceDate + * + * @return TransactionGroup + * @throws FireflyException + */ + protected function createCreditTransaction(Account $account, string $openingBalance, Carbon $openingBalanceDate): TransactionGroup + { + Log::debug('Now going to create an createCreditTransaction.'); + + if (0 === bccomp($openingBalance, '0')) { + Log::debug('Amount is zero, so will not make an liability credit group.'); + throw new FireflyException('Amount for new liability credit was unexpectedly 0.'); + } + + $language = app('preferences')->getForUser($account->user, 'language', 'en_US')->data; + $amount = app('steam')->positive($openingBalance); + + // get or grab currency: + $currency = $this->accountRepository->getAccountCurrency($account); + if (null === $currency) { + $currency = app('default')->getDefaultCurrencyByUser($account->user); + } + + // submit to factory: + $submission = [ + 'group_title' => null, + 'user' => $account->user_id, + 'transactions' => [ + [ + 'type' => 'Liability credit', + 'date' => $openingBalanceDate, + 'source_id' => null, + 'source_name' => trans('firefly.liability_credit_description', ['account' => $account->name], $language), + 'destination_id' => $account->id, + 'destination_name' => null, + 'user' => $account->user_id, + 'currency_id' => $currency->id, + 'order' => 0, + 'amount' => $amount, + 'foreign_amount' => null, + 'description' => trans('firefly.liability_credit_description', ['account' => $account->name]), + 'budget_id' => null, + 'budget_name' => null, + 'category_id' => null, + 'category_name' => null, + 'piggy_bank_id' => null, + 'piggy_bank_name' => null, + 'reconciled' => false, + 'notes' => null, + 'tags' => [], + ], + ], + ]; + Log::debug('Going for submission', $submission); + + /** @var TransactionGroupFactory $factory */ + $factory = app(TransactionGroupFactory::class); + $factory->setUser($account->user); + + try { + $group = $factory->create($submission); + } catch (DuplicateTransactionException $e) { + Log::error($e->getMessage()); + Log::error($e->getTraceAsString()); + throw new FireflyException($e->getMessage(), 0, $e); + } + + return $group; + } + + + /** + * @param Account $account + * @param array $data + * + * @return TransactionGroup + * @throws FireflyException + * @deprecated + */ + protected function createOBGroup(Account $account, array $data): TransactionGroup + { + Log::debug('Now going to create an OB group.'); + $language = app('preferences')->getForUser($account->user, 'language', 'en_US')->data; + $sourceId = null; + $sourceName = null; + $destId = null; + $destName = null; + $amount = array_key_exists('opening_balance', $data) ? $data['opening_balance'] : '0'; + + // amount is positive. + if (1 === bccomp($amount, '0')) { + Log::debug(sprintf('Amount is %s, which is positive. Source is a new IB account, destination is #%d', $amount, $account->id)); + $sourceName = trans('firefly.initial_balance_description', ['account' => $account->name], $language); + $destId = $account->id; + } + // amount is not positive + if (-1 === bccomp($amount, '0')) { + Log::debug(sprintf('Amount is %s, which is negative. Destination is a new IB account, source is #%d', $amount, $account->id)); + $destName = trans('firefly.initial_balance_account', ['account' => $account->name], $language); + $sourceId = $account->id; + } + // amount is 0 + if (0 === bccomp($amount, '0')) { + Log::debug('Amount is zero, so will not make an OB group.'); + throw new FireflyException('Amount for new opening balance was unexpectedly 0.'); + } + + // make amount positive, regardless: + $amount = app('steam')->positive($amount); + + // get or grab currency: + $currency = $this->accountRepository->getAccountCurrency($account); + if (null === $currency) { + $currency = app('default')->getDefaultCurrencyByUser($account->user); + } + + // submit to factory: + $submission = [ + 'group_title' => null, + 'user' => $account->user_id, + 'transactions' => [ + [ + 'type' => 'Opening balance', + 'date' => $data['opening_balance_date'], + 'source_id' => $sourceId, + 'source_name' => $sourceName, + 'destination_id' => $destId, + 'destination_name' => $destName, + 'user' => $account->user_id, + 'currency_id' => $currency->id, + 'order' => 0, + 'amount' => $amount, + 'foreign_amount' => null, + 'description' => trans('firefly.initial_balance_description', ['account' => $account->name]), + 'budget_id' => null, + 'budget_name' => null, + 'category_id' => null, + 'category_name' => null, + 'piggy_bank_id' => null, + 'piggy_bank_name' => null, + 'reconciled' => false, + 'notes' => null, + 'tags' => [], + ], + ], + ]; + Log::debug('Going for submission', $submission); + + /** @var TransactionGroupFactory $factory */ + $factory = app(TransactionGroupFactory::class); + $factory->setUser($account->user); + + try { + $group = $factory->create($submission); + } catch (DuplicateTransactionException $e) { + Log::error($e->getMessage()); + Log::error($e->getTraceAsString()); + throw new FireflyException($e->getMessage(), 0, $e); + } + + return $group; + } + + /** + * TODO Refactor to "getFirstJournal" + * + * @param TransactionGroup $group + * + * @return TransactionJournal + * @throws FireflyException + */ + private function getObJournal(TransactionGroup $group): TransactionJournal + { + /** @var TransactionJournal $journal */ + $journal = $group->transactionJournals()->first(); + if (null === $journal) { + throw new FireflyException(sprintf('Group #%d has no OB journal', $group->id)); + } + + return $journal; + } } diff --git a/app/Services/Internal/Support/CreditRecalculateService.php b/app/Services/Internal/Support/CreditRecalculateService.php index 2cbc5d6279..2f925730d0 100644 --- a/app/Services/Internal/Support/CreditRecalculateService.php +++ b/app/Services/Internal/Support/CreditRecalculateService.php @@ -23,43 +23,115 @@ namespace FireflyIII\Services\Internal\Support; use FireflyIII\Exceptions\FireflyException; +use FireflyIII\Factory\AccountMetaFactory; use FireflyIII\Models\Account; -use FireflyIII\Models\AccountType; use FireflyIII\Models\Transaction; use FireflyIII\Models\TransactionGroup; use FireflyIII\Models\TransactionJournal; -use FireflyIII\Models\TransactionType; use FireflyIII\Repositories\Account\AccountRepositoryInterface; use Log; class CreditRecalculateService { - private TransactionGroup $group; + private ?Account $account; + private ?TransactionGroup $group; + private array $work; + private AccountRepositoryInterface $repository; + + /** + * CreditRecalculateService constructor. + */ + public function __construct() + { + $this->group = null; + $this->account = null; + $this->work = []; + } /** * */ public function recalculate(): void { + Log::debug(sprintf('Now in %s', __METHOD__)); if (true !== config('firefly.feature_flags.handle_debts')) { Log::debug('handle_debts is disabled.'); return; } - Log::error('TODO'); + if (null !== $this->group && null === $this->account) { + $this->processGroup(); + } + if (null !== $this->account && null === $this->group) { + // work based on account. + $this->processAccount(); + } + if (0 === count($this->work)) { + Log::debug('No work accounts, do not do CreditRecalculationService'); - return; + return; + } + $this->processWork(); + + + Log::debug('Will now do CreditRecalculationService'); + // do something + } + + /** + * + */ + private function processWork(): void + { + $this->repository = app(AccountRepositoryInterface::class); + Log::debug(sprintf('Now in %s', __METHOD__)); + foreach ($this->work as $account) { + $this->processWorkAccount($account); + } + Log::debug(sprintf('Done with %s', __METHOD__)); + } + + /** + * @param Account $account + */ + private function processWorkAccount(Account $account): void + { + Log::debug(sprintf('Now in %s(#%d)', __METHOD__, $account->id)); + + // get opening balance (if present) + $this->repository->setUser($account->user); + $startOfDebt = $this->repository->getOpeningBalanceAmount($account) ?? '0'; + + /** @var AccountMetaFactory $factory */ + $factory = app(AccountMetaFactory::class); + $factory->crud($account, 'start_of_debt', $startOfDebt); + $factory->crud($account, 'current_debt', $startOfDebt); + + // update meta data: + + + Log::debug(sprintf('Done with %s(#%d)', __METHOD__, $account->id)); + } + + + /** + * + */ + private function processGroup(): void + { Log::debug(sprintf('Now in %s', __METHOD__)); /** @var TransactionJournal $journal */ foreach ($this->group->transactionJournals as $journal) { - try { - $this->recalculateJournal($journal); - } catch (FireflyException $e) { - Log::error($e->getTraceAsString()); - Log::error('Could not recalculate'); + if (0 === count($this->work)) { + try { + $this->findByJournal($journal); + } catch (FireflyException $e) { + Log::error($e->getTraceAsString()); + Log::error(sprintf('Could not find work account for transaction group #%d.', $this->group->id)); + } } } - Log::debug(sprintf('Done with %s', __METHOD__));; + Log::debug(sprintf('Done with %s', __METHOD__)); } /** @@ -67,37 +139,22 @@ class CreditRecalculateService * * @throws FireflyException */ - private function recalculateJournal(TransactionJournal $journal): void + private function findByJournal(TransactionJournal $journal): void { - if (TransactionType::DEPOSIT !== $journal->transactionType->type) { - Log::debug('Journal is not a deposit.'); - - return; - } + Log::debug(sprintf('Now in %s', __METHOD__)); $source = $this->getSourceAccount($journal); $destination = $this->getDestinationAccount($journal); - // destination must be liability, source must be expense. - if (AccountType::REVENUE !== $source->accountType->type) { - Log::debug('Source is not a revenue account.'); - return; + // destination or source must be liability. + $valid = config('firefly.valid_liabilities'); + if (in_array($destination->accountType->type, $valid)) { + Log::debug(sprintf('Dest account type is "%s", include it.', $destination->accountType->type)); + $this->work[] = $destination; } - if (!in_array($destination->accountType->type, config('firefly.valid_liabilities'))) { - Log::debug('Destination is not a liability.'); - - return; + if (in_array($source->accountType->type, $valid)) { + Log::debug(sprintf('Src account type is "%s", include it.', $source->accountType->type)); + $this->work[] = $source; } - $repository = app(AccountRepositoryInterface::class); - $repository->setUser($destination->user); - $direction = $repository->getMetaValue($destination, 'liability_direction'); - if ('credit' !== $direction) { - Log::debug(sprintf('Destination liabiltiy direction is "%s", do nothing.', $direction)); - } - /* - * This destination is a liability and an incoming debt. The amount paid into the liability changes the original debt amount. - * - */ - Log::debug('Do something!'); } /** @@ -108,18 +165,7 @@ class CreditRecalculateService */ private function getSourceAccount(TransactionJournal $journal): Account { - return $this->getAccount($journal, '<'); - } - - /** - * @param TransactionJournal $journal - * - * @return Account - * @throws FireflyException - */ - private function getDestinationAccount(TransactionJournal $journal): Account - { - return $this->getAccount($journal, '>'); + return $this->getAccountByDirection($journal, '<'); } /** @@ -129,7 +175,7 @@ class CreditRecalculateService * @return Account * @throws FireflyException */ - private function getAccount(TransactionJournal $journal, string $direction): Account + private function getAccountByDirection(TransactionJournal $journal, string $direction): Account { /** @var Transaction $transaction */ $transaction = $journal->transactions()->where('amount', $direction, '0')->first(); @@ -144,6 +190,38 @@ class CreditRecalculateService return $account; } + /** + * @param TransactionJournal $journal + * + * @return Account + * @throws FireflyException + */ + private function getDestinationAccount(TransactionJournal $journal): Account + { + return $this->getAccountByDirection($journal, '>'); + } + + /** + * + */ + private function processAccount(): void + { + Log::debug(sprintf('Now in %s', __METHOD__)); + $valid = config('firefly.valid_liabilities'); + if (in_array($this->account->accountType->type, $valid)) { + Log::debug(sprintf('Account type is "%s", include it.', $this->account->accountType->type)); + $this->work[] = $this->account; + } + } + + /** + * @param Account|null $account + */ + public function setAccount(?Account $account): void + { + $this->account = $account; + } + /** * @param TransactionGroup $group */ diff --git a/app/Services/Internal/Update/AccountUpdateService.php b/app/Services/Internal/Update/AccountUpdateService.php index 09ca4f9833..e3b293899a 100644 --- a/app/Services/Internal/Update/AccountUpdateService.php +++ b/app/Services/Internal/Update/AccountUpdateService.php @@ -23,6 +23,8 @@ declare(strict_types=1); namespace FireflyIII\Services\Internal\Update; +use FireflyIII\Events\UpdatedAccount; +use FireflyIII\Exceptions\FireflyException; use FireflyIII\Models\Account; use FireflyIII\Models\AccountType; use FireflyIII\Models\Location; @@ -44,6 +46,7 @@ class AccountUpdateService protected array $validCCFields; protected array $validFields; private array $canHaveVirtual; + private array $canHaveOpeningBalance; private User $user; /** @@ -51,11 +54,12 @@ class AccountUpdateService */ public function __construct() { - $this->canHaveVirtual = config('firefly.can_have_virtual_amounts'); - $this->validAssetFields = config('firefly.valid_asset_fields'); - $this->validCCFields = config('firefly.valid_cc_fields'); - $this->validFields = config('firefly.valid_account_fields'); - $this->accountRepository = app(AccountRepositoryInterface::class); + $this->canHaveVirtual = config('firefly.can_have_virtual_amounts'); + $this->canHaveOpeningBalance = config('firefly.can_have_opening_balance'); + $this->validAssetFields = config('firefly.valid_asset_fields'); + $this->validCCFields = config('firefly.valid_cc_fields'); + $this->validFields = config('firefly.valid_account_fields'); + $this->accountRepository = app(AccountRepositoryInterface::class); } /** @@ -98,6 +102,9 @@ class AccountUpdateService // update opening balance. $this->updateOpeningBalance($account, $data); + // update opening balance. + $this->updateCreditLiability($account, $data); + // update note: if (array_key_exists('notes', $data) && null !== $data['notes']) { $this->updateNote($account, (string)$data['notes']); @@ -106,6 +113,8 @@ class AccountUpdateService // update preferences if inactive: $this->updatePreferences($account); + event(new UpdatedAccount($account)); + return $account; } @@ -266,19 +275,21 @@ class AccountUpdateService /** * @param Account $account * @param array $data + * + * @throws FireflyException */ private function updateOpeningBalance(Account $account, array $data): void { // has valid initial balance (IB) data? $type = $account->accountType; - // if it can have a virtual balance, it can also have an opening balance. - - if (in_array($type->type, $this->canHaveVirtual, true)) { - + if (in_array($type->type, $this->canHaveOpeningBalance, true)) { // check if is submitted as empty, that makes it valid: if ($this->validOBData($data) && !$this->isEmptyOBData($data)) { - $this->updateOBGroup($account, $data); + $openingBalance = $data['opening_balance']; + $openingBalanceDate = $data['opening_balance_date']; + + $this->updateOBGroupV2($account, $openingBalance, $openingBalanceDate); } if (!$this->validOBData($data) && $this->isEmptyOBData($data)) { @@ -287,6 +298,31 @@ class AccountUpdateService } } + /** + * @param Account $account + * @param array $data + * + * @throws FireflyException + */ + private function updateCreditLiability(Account $account, array $data): void + { + $type = $account->accountType; + $valid = config('firefly.valid_liabilities'); + if (in_array($type->type, $valid, true)) { + // check if is submitted as empty, that makes it valid: + if ($this->validOBData($data) && !$this->isEmptyOBData($data)) { + $openingBalance = $data['opening_balance']; + $openingBalanceDate = $data['opening_balance_date']; + + $this->updateCreditTransaction($account, $openingBalance, $openingBalanceDate); + } + + if (!$this->validOBData($data) && $this->isEmptyOBData($data)) { + $this->deleteCreditTransaction($account); + } + } + } + /** * @param Account $account */ diff --git a/app/Support/Http/Controllers/UserNavigation.php b/app/Support/Http/Controllers/UserNavigation.php index 455e1b2c54..c0696bdace 100644 --- a/app/Support/Http/Controllers/UserNavigation.php +++ b/app/Support/Http/Controllers/UserNavigation.php @@ -110,7 +110,7 @@ trait UserNavigation final protected function redirectAccountToAccount(Account $account) { $type = $account->accountType->type; - if (AccountType::RECONCILIATION === $type || AccountType::INITIAL_BALANCE === $type) { + if (AccountType::RECONCILIATION === $type || AccountType::INITIAL_BALANCE === $type || AccountType::LIABILITY_CREDIT === $type) { // reconciliation must be stored somewhere in this account's transactions. /** @var Transaction|null $transaction */