. */ declare(strict_types=1); namespace FireflyIII\Repositories\Account; use Carbon\Carbon; use FireflyIII\Exceptions\FireflyException; use FireflyIII\Factory\AccountFactory; use FireflyIII\Models\Account; use FireflyIII\Models\AccountMeta; use FireflyIII\Models\AccountType; use FireflyIII\Models\Attachment; use FireflyIII\Models\Location; use FireflyIII\Models\TransactionCurrency; use FireflyIII\Models\TransactionGroup; use FireflyIII\Models\TransactionJournal; use FireflyIII\Models\TransactionType; use FireflyIII\Services\Internal\Destroy\AccountDestroyService; use FireflyIII\Services\Internal\Update\AccountUpdateService; use FireflyIII\User; use Illuminate\Contracts\Auth\Authenticatable; use Illuminate\Database\Eloquent\Builder as EloquentBuilder; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Support\Collection; use JsonException; use Storage; /** * Class AccountRepository. * */ class AccountRepository implements AccountRepositoryInterface { private User $user; /** * Moved here from account CRUD. * * @param Account $account * @param Account|null $moveTo * * @return bool * */ public function destroy(Account $account, ?Account $moveTo): bool { /** @var AccountDestroyService $service */ $service = app(AccountDestroyService::class); $service->destroy($account, $moveTo); return true; } /** * Find account with same name OR same IBAN or both, but not the same type or ID. * * @param Collection $accounts * * @return Collection */ public function expandWithDoubles(Collection $accounts): Collection { $result = new Collection(); /** @var Account $account */ foreach ($accounts as $account) { $byName = $this->user->accounts()->where('name', $account->name) ->where('id', '!=', $account->id)->first(); if (null !== $byName) { $result->push($account); $result->push($byName); continue; } if (null !== $account->iban) { $byIban = $this->user->accounts()->where('iban', $account->iban) ->where('id', '!=', $account->id)->first(); if (null !== $byIban) { $result->push($account); $result->push($byIban); continue; } } $result->push($account); } return $result; } /** * @inheritDoc */ public function findByAccountNumber(string $number, array $types): ?Account { $dbQuery = $this->user ->accounts() ->leftJoin('account_meta', 'accounts.id', '=', 'account_meta.account_id') ->where('accounts.active', true) ->where( static function (EloquentBuilder $q1) use ($number) { /** @phpstan-ignore-line */ $json = json_encode($number); $q1->where('account_meta.name', '=', 'account_number'); $q1->where('account_meta.data', '=', $json); } ); if (0 !== count($types)) { $dbQuery->leftJoin('account_types', 'accounts.account_type_id', '=', 'account_types.id'); $dbQuery->whereIn('account_types.type', $types); } /** @var Account|null */ return $dbQuery->first(['accounts.*']); } /** * @param string $iban * @param array $types * * @return Account|null */ public function findByIbanNull(string $iban, array $types): ?Account { $query = $this->user->accounts()->where('iban', '!=', '')->whereNotNull('iban'); if (0 !== count($types)) { $query->leftJoin('account_types', 'accounts.account_type_id', '=', 'account_types.id'); $query->whereIn('account_types.type', $types); } /** @var Account|null */ return $query->where('iban', $iban)->first(['accounts.*']); } /** * @param string $name * @param array $types * * @return Account|null */ public function findByName(string $name, array $types): ?Account { $query = $this->user->accounts(); if (0 !== count($types)) { $query->leftJoin('account_types', 'accounts.account_type_id', '=', 'account_types.id'); $query->whereIn('account_types.type', $types); } app('log')->debug(sprintf('Searching for account named "%s" (of user #%d) of the following type(s)', $name, $this->user->id), ['types' => $types]); $query->where('accounts.name', $name); /** @var Account|null $account */ $account = $query->first(['accounts.*']); if (null === $account) { app('log')->debug(sprintf('There is no account with name "%s" of types', $name), $types); return null; } app('log')->debug(sprintf('Found #%d (%s) with type id %d', $account->id, $account->name, $account->account_type_id)); return $account; } /** * Return account type or null if not found. * * @param string $type * * @return AccountType|null */ public function getAccountTypeByType(string $type): ?AccountType { return AccountType::whereType(ucfirst($type))->first(); } /** * @param array $accountIds * * @return Collection */ public function getAccountsById(array $accountIds): Collection { $query = $this->user->accounts(); if (0 !== count($accountIds)) { $query->whereIn('accounts.id', $accountIds); } $query->orderBy('accounts.order', 'ASC'); $query->orderBy('accounts.active', 'DESC'); $query->orderBy('accounts.name', 'ASC'); return $query->get(['accounts.*']); } /** * @param array $types * * @return Collection */ public function getActiveAccountsByType(array $types): Collection { $query = $this->user->accounts()->with( [ 'accountmeta' => static function (HasMany $query) { $query->where('name', 'account_role'); }, 'attachments', ] ); if (0 !== count($types)) { $query->accountTypeIn($types); } $query->where('active', true); $query->orderBy('accounts.account_type_id', 'ASC'); $query->orderBy('accounts.order', 'ASC'); $query->orderBy('accounts.name', 'ASC'); return $query->get(['accounts.*']); } /** * @inheritDoc */ public function getAttachments(Account $account): Collection { $set = $account->attachments()->get(); /** @var Storage $disk */ $disk = Storage::disk('upload'); return $set->each( static function (Attachment $attachment) use ($disk) { $notes = $attachment->notes()->first(); $attachment->file_exists = $disk->exists($attachment->fileName()); $attachment->notes_text = null !== $notes ? $notes->text : ''; return $attachment; } ); } /** * @return Account * * @throws FireflyException * @throws JsonException */ public function getCashAccount(): Account { /** @var AccountType $type */ $type = AccountType::where('type', AccountType::CASH)->first(); /** @var AccountFactory $factory */ $factory = app(AccountFactory::class); $factory->setUser($this->user); return $factory->findOrCreate('Cash account', $type->type); } /** * @param User|Authenticatable|null $user */ public function setUser(User | Authenticatable | null $user): void { if ($user instanceof User) { $this->user = $user; } } /** * @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.*']); return $journal?->transactionGroup; } /** * @param array $types * * @return Collection */ public function getInactiveAccountsByType(array $types): Collection { $query = $this->user->accounts()->with( [ 'accountmeta' => static function (HasMany $query) { $query->where('name', 'account_role'); }, ] ); if (0 !== count($types)) { $query->accountTypeIn($types); } $query->where('active', 0); $query->orderBy('accounts.account_type_id', 'ASC'); $query->orderBy('accounts.order', 'ASC'); $query->orderBy('accounts.name', 'ASC'); return $query->get(['accounts.*']); } /** * @inheritDoc */ public function getLocation(Account $account): ?Location { /** @var Location|null */ return $account->locations()->first(); } /** * Get note text or null. * * @param Account $account * * @return null|string */ public function getNoteText(Account $account): ?string { return $account->notes()->first()?->text; } /** * Returns the amount of the opening balance for this account. * * @param Account $account * * @return string|null */ public function getOpeningBalanceAmount(Account $account): ?string { $journal = TransactionJournal::leftJoin('transactions', 'transactions.transaction_journal_id', '=', 'transaction_journals.id') ->where('transactions.account_id', $account->id) ->transactionTypes([TransactionType::OPENING_BALANCE, TransactionType::LIABILITY_CREDIT]) ->first(['transaction_journals.*']); if (null === $journal) { return null; } $transaction = $journal->transactions()->where('account_id', $account->id)->first(); if (null === $transaction) { return null; } return $transaction->amount; } /** * Return date of opening balance as string or null. * * @param Account $account * * @return null|string */ public function getOpeningBalanceDate(Account $account): ?string { return TransactionJournal::leftJoin('transactions', 'transactions.transaction_journal_id', '=', 'transaction_journals.id') ->where('transactions.account_id', $account->id) ->transactionTypes([TransactionType::OPENING_BALANCE, TransactionType::LIABILITY_CREDIT]) ->first(['transaction_journals.*'])?->date->format('Y-m-d H:i:s'); } /** * @param Account $account * * @return TransactionGroup|null */ public function getOpeningBalanceGroup(Account $account): ?TransactionGroup { $journal = $this->getOpeningBalance($account); return $journal?->transactionGroup; } /** * @param Account $account * * @return TransactionJournal|null */ public function getOpeningBalance(Account $account): ?TransactionJournal { return TransactionJournal::leftJoin('transactions', 'transactions.transaction_journal_id', '=', 'transaction_journals.id') ->where('transactions.account_id', $account->id) ->transactionTypes([TransactionType::OPENING_BALANCE]) ->first(['transaction_journals.*']); } /** * @param Account $account * * @return Collection */ public function getPiggyBanks(Account $account): Collection { return $account->piggyBanks()->get(); } /** * @param Account $account * * @return Account|null * * @throws FireflyException * @throws JsonException */ public function getReconciliation(Account $account): ?Account { if (AccountType::ASSET !== $account->accountType->type) { throw new FireflyException(sprintf('%s is not an asset account.', $account->name)); } $currency = $this->getAccountCurrency($account) ?? app('amount')->getDefaultCurrency(); $name = trans('firefly.reconciliation_account_name', ['name' => $account->name, 'currency' => $currency->code]); /** @var AccountType $type */ $type = AccountType::where('type', AccountType::RECONCILIATION)->first(); /** @var Account|null $current */ $current = $this->user->accounts()->where('account_type_id', $type->id) ->where('name', $name) ->first(); if (null !== $current) { return $current; } $data = [ 'account_type_id' => null, 'account_type_name' => AccountType::RECONCILIATION, 'active' => true, 'name' => $name, 'currency_id' => $currency->id, 'currency_code' => $currency->code, ]; /** @var AccountFactory $factory */ $factory = app(AccountFactory::class); $factory->setUser($account->user); return $factory->create($data); } /** * @param Account $account * * @return TransactionCurrency|null */ public function getAccountCurrency(Account $account): ?TransactionCurrency { $type = $account->accountType->type; $list = config('firefly.valid_currency_account_types'); // return null if not in this list. if (!in_array($type, $list, true)) { return null; } $currencyId = (int)$this->getMetaValue($account, 'currency_id'); if ($currencyId > 0) { return TransactionCurrency::find($currencyId); } return null; } /** * Return meta value for account. Null if not found. * * @param Account $account * @param string $field * * @return null|string */ public function getMetaValue(Account $account, string $field): ?string { $result = $account->accountMeta->filter( static function (AccountMeta $meta) use ($field) { return strtolower($meta->name) === strtolower($field); } ); if (0 === $result->count()) { return null; } if (1 === $result->count()) { return (string)$result->first()->data; } return null; } /** * @param array $types * * @return int */ public function count(array $types): int { return $this->user->accounts()->accountTypeIn($types)->count(); } /** * @param int $accountId * * @return Account|null */ public function find(int $accountId): ?Account { return $this->user->accounts()->find($accountId); } /** * @inheritDoc */ public function getUsedCurrencies(Account $account): Collection { $info = $account->transactions()->get(['transaction_currency_id', 'foreign_currency_id'])->toArray(); $currencyIds = []; foreach ($info as $entry) { $currencyIds[] = (int)$entry['transaction_currency_id']; $currencyIds[] = (int)$entry['foreign_currency_id']; } $currencyIds = array_unique($currencyIds); return TransactionCurrency::whereIn('id', $currencyIds)->get(); } /** * @param Account $account * * @return bool */ public function isLiability(Account $account): bool { return in_array($account->accountType->type, [AccountType::CREDITCARD, AccountType::LOAN, AccountType::DEBT, AccountType::MORTGAGE], true); } /** * @inheritDoc */ public function maxOrder(string $type): int { $sets = [ AccountType::ASSET => [AccountType::DEFAULT, AccountType::ASSET], AccountType::EXPENSE => [AccountType::EXPENSE, AccountType::BENEFICIARY], AccountType::REVENUE => [AccountType::REVENUE], AccountType::LOAN => [AccountType::LOAN, AccountType::DEBT, AccountType::CREDITCARD, AccountType::MORTGAGE], AccountType::DEBT => [AccountType::LOAN, AccountType::DEBT, AccountType::CREDITCARD, AccountType::MORTGAGE], AccountType::MORTGAGE => [AccountType::LOAN, AccountType::DEBT, AccountType::CREDITCARD, AccountType::MORTGAGE], ]; if (array_key_exists(ucfirst($type), $sets)) { $order = (int)$this->getAccountsByType($sets[ucfirst($type)])->max('order'); app('log')->debug(sprintf('Return max order of "%s" set: %d', $type, $order)); return $order; } $specials = [AccountType::CASH, AccountType::INITIAL_BALANCE, AccountType::IMPORT, AccountType::RECONCILIATION]; $order = (int)$this->getAccountsByType($specials)->max('order'); app('log')->debug(sprintf('Return max order of "%s" set (specials!): %d', $type, $order)); return $order; } /** * @param array $types * @param array|null $sort * * @return Collection */ public function getAccountsByType(array $types, ?array $sort = []): Collection { $res = array_intersect([AccountType::ASSET, AccountType::MORTGAGE, AccountType::LOAN, AccountType::DEBT], $types); $query = $this->user->accounts(); if (0 !== count($types)) { $query->accountTypeIn($types); } // add sort parameters. At this point they're filtered to allowed fields to sort by: if (0 !== count($sort)) { foreach ($sort as $param) { $query->orderBy($param[0], $param[1]); } } if (0 === count($sort)) { if (0 !== count($res)) { $query->orderBy('accounts.order', 'ASC'); } $query->orderBy('accounts.active', 'DESC'); $query->orderBy('accounts.name', 'ASC'); } return $query->get(['accounts.*']); } /** * Returns the date of the very first transaction in this account. * * @param Account $account * * @return Carbon|null */ public function oldestJournalDate(Account $account): ?Carbon { $journal = $this->oldestJournal($account); return $journal?->date; } /** * Returns the date of the very first transaction in this account. * * @param Account $account * * @return TransactionJournal|null */ public function oldestJournal(Account $account): ?TransactionJournal { /** @var TransactionJournal|null $first */ $first = $account->transactions() ->leftJoin('transaction_journals', 'transaction_journals.id', '=', 'transactions.transaction_journal_id') ->orderBy('transaction_journals.date', 'ASC') ->orderBy('transaction_journals.order', 'DESC') ->where('transaction_journals.user_id', $this->user->id) ->orderBy('transaction_journals.id', 'ASC') ->first(['transaction_journals.id']); if (null !== $first) { return TransactionJournal::find($first->id); } return null; } /** * @inheritDoc */ public function resetAccountOrder(): void { $sets = [ [AccountType::DEFAULT, AccountType::ASSET], //[AccountType::EXPENSE, AccountType::BENEFICIARY], //[AccountType::REVENUE], [AccountType::LOAN, AccountType::DEBT, AccountType::CREDITCARD, AccountType::MORTGAGE], //[AccountType::CASH, AccountType::INITIAL_BALANCE, AccountType::IMPORT, AccountType::RECONCILIATION], ]; foreach ($sets as $set) { $list = $this->getAccountsByType($set); $index = 1; foreach ($list as $account) { if (false === $account->active) { $account->order = 0; continue; } if ($index !== (int)$account->order) { app('log')->debug(sprintf('Account #%d ("%s"): order should %d be but is %d.', $account->id, $account->name, $index, $account->order)); $account->order = $index; $account->save(); } $index++; } } } /** * @param string $query * @param array $types * @param int $limit * * @return Collection */ public function searchAccount(string $query, array $types, int $limit): Collection { $dbQuery = $this->user->accounts() ->where('active', true) ->orderBy('accounts.order', 'ASC') ->orderBy('accounts.account_type_id', 'ASC') ->orderBy('accounts.name', 'ASC') ->with(['accountType']); if ('' !== $query) { // split query on spaces just in case: $parts = explode(' ', $query); foreach ($parts as $part) { $search = sprintf('%%%s%%', $part); $dbQuery->where('name', 'LIKE', $search); } } if (0 !== count($types)) { $dbQuery->leftJoin('account_types', 'accounts.account_type_id', '=', 'account_types.id'); $dbQuery->whereIn('account_types.type', $types); } return $dbQuery->take($limit)->get(['accounts.*']); } /** * @inheritDoc */ public function searchAccountNr(string $query, array $types, int $limit): Collection { $dbQuery = $this->user->accounts()->distinct() ->leftJoin('account_meta', 'accounts.id', '=', 'account_meta.account_id') ->where('accounts.active', true) ->orderBy('accounts.order', 'ASC') ->orderBy('accounts.account_type_id', 'ASC') ->orderBy('accounts.name', 'ASC') ->with(['accountType', 'accountMeta']); if ('' !== $query) { // split query on spaces just in case: $parts = explode(' ', $query); foreach ($parts as $part) { $search = sprintf('%%%s%%', $part); $dbQuery->where( static function (EloquentBuilder $q1) use ($search) { // @phpstan-ignore-line $q1->where('accounts.iban', 'LIKE', $search); $q1->orWhere( static function (EloquentBuilder $q2) use ($search) { $q2->where('account_meta.name', '=', 'account_number'); $q2->where('account_meta.data', 'LIKE', $search); } ); } ); } } if (0 !== count($types)) { $dbQuery->leftJoin('account_types', 'accounts.account_type_id', '=', 'account_types.id'); $dbQuery->whereIn('account_types.type', $types); } return $dbQuery->take($limit)->get(['accounts.*']); } /** * @param array $data * * @return Account * @throws FireflyException * @throws JsonException */ public function store(array $data): Account { /** @var AccountFactory $factory */ $factory = app(AccountFactory::class); $factory->setUser($this->user); return $factory->create($data); } /** * @param Account $account * @param array $data * * @return Account * @throws FireflyException * @throws JsonException */ public function update(Account $account, array $data): Account { /** @var AccountUpdateService $service */ $service = app(AccountUpdateService::class); return $service->update($account, $data); } }