diff --git a/app/Api/V2/Controllers/Autocomplete/AccountController.php b/app/Api/V2/Controllers/Autocomplete/AccountController.php index eac0044f95..b05425a549 100644 --- a/app/Api/V2/Controllers/Autocomplete/AccountController.php +++ b/app/Api/V2/Controllers/Autocomplete/AccountController.php @@ -25,10 +25,105 @@ declare(strict_types=1); namespace FireflyIII\Api\V2\Controllers\Autocomplete; use FireflyIII\Api\V2\Controllers\Controller; +use FireflyIII\Api\V2\Request\Autocomplete\AutocompleteRequest; +use FireflyIII\Exceptions\FireflyException; +use FireflyIII\Models\Account; +use FireflyIII\Models\AccountType; +use FireflyIII\Repositories\Administration\Account\AccountRepositoryInterface as AdminAccountRepositoryInterface; +use FireflyIII\Repositories\Account\AccountRepositoryInterface; +use FireflyIII\Support\Http\Api\AccountFilter; +use FireflyIII\User; +use Illuminate\Http\JsonResponse; +use JsonException; /** * Class AccountController */ class AccountController extends Controller { + use AccountFilter; + + private array $balanceTypes; + private AdminAccountRepositoryInterface $adminRepository; + private AccountRepositoryInterface $repository; + + /** + * AccountController constructor. + */ + public function __construct() + { + parent::__construct(); + $this->middleware( + function ($request, $next) { + /** @var User $user */ + $user = auth()->user(); + $this->repository = app(AccountRepositoryInterface::class); + $this->adminRepository = app(AdminAccountRepositoryInterface::class); + + return $next($request); + } + ); + $this->balanceTypes = [AccountType::ASSET, AccountType::LOAN, AccountType::DEBT, AccountType::MORTGAGE,]; + } + + /** + * Documentation for this endpoint: + * TODO endpoint is not documented. + * + * @param AutocompleteRequest $request + * + * @return JsonResponse + * @throws JsonException + * @throws FireflyException + * @throws FireflyException + */ + public function accounts(AutocompleteRequest $request): JsonResponse + { + $data = $request->getData(); + $types = $data['types']; + $query = $data['query']; + $date = $data['date'] ?? today(config('app.timezone')); + $this->adminRepository->setAdministrationId($data['administration_id']); + + $return = []; + $result = $this->adminRepository->searchAccount((string)$query, $types, $data['limit']); + $defaultCurrency = app('amount')->getDefaultCurrency(); + + /** @var Account $account */ + foreach ($result as $account) { + $nameWithBalance = $account->name; + $currency = $this->repository->getAccountCurrency($account) ?? $defaultCurrency; + + if (in_array($account->accountType->type, $this->balanceTypes, true)) { + $balance = app('steam')->balance($account, $date); + $nameWithBalance = sprintf('%s (%s)', $account->name, app('amount')->formatAnything($currency, $balance, false)); + } + + $return[] = [ + 'id' => (string)$account->id, + 'name' => $account->name, + 'name_with_balance' => $nameWithBalance, + 'type' => $account->accountType->type, + 'currency_id' => $currency->id, + 'currency_name' => $currency->name, + 'currency_code' => $currency->code, + 'currency_symbol' => $currency->symbol, + 'currency_decimal_places' => $currency->decimal_places, + ]; + } + + // custom order. + $order = [AccountType::ASSET, AccountType::REVENUE, AccountType::EXPENSE]; + usort( + $return, + function ($a, $b) use ($order) { + $pos_a = array_search($a['type'], $order, true); + $pos_b = array_search($b['type'], $order, true); + + return $pos_a - $pos_b; + } + ); + + return response()->json($return); + } } diff --git a/app/Api/V2/Controllers/Controller.php b/app/Api/V2/Controllers/Controller.php index d34d2c3c0b..e36a16f263 100644 --- a/app/Api/V2/Controllers/Controller.php +++ b/app/Api/V2/Controllers/Controller.php @@ -79,7 +79,7 @@ class Controller extends BaseController $page = 1; } - $integers = ['limit']; + $integers = ['limit', 'administration']; $dates = ['start', 'end', 'date']; if ($page < 1) { diff --git a/app/Api/V2/Request/Autocomplete/AutocompleteRequest.php b/app/Api/V2/Request/Autocomplete/AutocompleteRequest.php new file mode 100644 index 0000000000..8a382c6792 --- /dev/null +++ b/app/Api/V2/Request/Autocomplete/AutocompleteRequest.php @@ -0,0 +1,98 @@ +. + */ + +declare(strict_types=1); + +namespace FireflyIII\Api\V2\Request\Autocomplete; + +use FireflyIII\Exceptions\FireflyException; +use FireflyIII\Models\AccountType; +use FireflyIII\Models\UserRole; +use FireflyIII\Support\Request\ChecksLogin; +use FireflyIII\Support\Request\ConvertsDataTypes; +use FireflyIII\User; +use FireflyIII\Validation\Administration\ValidatesAdministrationAccess; +use Illuminate\Foundation\Http\FormRequest; +use Illuminate\Validation\Validator; + +/** + * Class AutocompleteRequest + */ +class AutocompleteRequest extends FormRequest +{ + use ConvertsDataTypes; + use ChecksLogin; + use ValidatesAdministrationAccess; + + /** + * @return array + * @throws FireflyException + */ + public function getData(): array + { + $types = $this->convertString('types'); + $array = []; + if ('' !== $types) { + $array = explode(',', $types); + } + $limit = $this->convertInteger('limit'); + $limit = 0 === $limit ? 10 : $limit; + + // remove 'initial balance' and another from allowed types. its internal + $array = array_diff($array, [AccountType::INITIAL_BALANCE, AccountType::RECONCILIATION]); + /** @var User $user */ + $user = auth()->user(); + return [ + 'types' => $array, + 'query' => $this->convertString('query'), + 'date' => $this->getCarbonDate('date'), + 'limit' => $limit, + 'administration_id' => (int)($this->get('administration_id', null) ?? $user->getAdministrationId()), + ]; + } + + /** + * @return array + */ + public function rules(): array + { + return [ + 'limit' => 'min:0|max:1337', + ]; + } + + /** + * Configure the validator instance with special rules for after the basic validation rules. + * + * @param Validator $validator + * + * @return void + */ + public function withValidator(Validator $validator): void + { + $validator->after( + function (Validator $validator) { + // validate if the account can access this administration + $this->validateAdministration($validator, [UserRole::CHANGE_TRANSACTIONS]); + } + ); + } +} diff --git a/app/Models/UserGroup.php b/app/Models/UserGroup.php index a5e36294d2..2852213961 100644 --- a/app/Models/UserGroup.php +++ b/app/Models/UserGroup.php @@ -63,4 +63,14 @@ class UserGroup extends Model { return $this->hasMany(GroupMembership::class); } + + /** + * Link to accounts. + * + * @return HasMany + */ + public function accounts(): HasMany + { + return $this->hasMany(Account::class); + } } diff --git a/app/Providers/AccountServiceProvider.php b/app/Providers/AccountServiceProvider.php index a6539fe8d8..d7fb61298e 100644 --- a/app/Providers/AccountServiceProvider.php +++ b/app/Providers/AccountServiceProvider.php @@ -25,6 +25,8 @@ namespace FireflyIII\Providers; use FireflyIII\Repositories\Account\AccountRepository; use FireflyIII\Repositories\Account\AccountRepositoryInterface; +use FireflyIII\Repositories\Administration\Account\AccountRepository as AdminAccountRepository; +use FireflyIII\Repositories\Administration\Account\AccountRepositoryInterface as AdminAccountRepositoryInterface; use FireflyIII\Repositories\Account\AccountTasker; use FireflyIII\Repositories\Account\AccountTaskerInterface; use FireflyIII\Repositories\Account\OperationsRepository; @@ -73,6 +75,22 @@ class AccountServiceProvider extends ServiceProvider } ); + $this->app->bind( + AdminAccountRepositoryInterface::class, + function (Application $app) { + /** @var AdminAccountRepositoryInterface $repository */ + $repository = app(AdminAccountRepository::class); + + // phpstan thinks auth does not exist. + if ($app->auth->check()) { // @phpstan-ignore-line + $repository->setUser(auth()->user()); + $repository->setAdministrationId((int) auth()->user()->user_group_id); + } + + return $repository; + } + ); + $this->app->bind( OperationsRepositoryInterface::class, static function (Application $app) { diff --git a/app/Repositories/Administration/Account/AccountRepository.php b/app/Repositories/Administration/Account/AccountRepository.php new file mode 100644 index 0000000000..1976d97b49 --- /dev/null +++ b/app/Repositories/Administration/Account/AccountRepository.php @@ -0,0 +1,61 @@ +. + */ + +namespace FireflyIII\Repositories\Administration\Account; + +use FireflyIII\Support\Repositories\Administration\AdministrationTrait; +use Illuminate\Support\Collection; + +/** + * Class AccountRepository + */ +class AccountRepository implements AccountRepositoryInterface +{ + use AdministrationTrait; + + /** + * @inheritDoc + */ + public function searchAccount(string $query, array $types, int $limit): Collection + { + // search by group, not by user + $dbQuery = $this->userGroup->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.*']); + } +} diff --git a/app/Repositories/Administration/Account/AccountRepositoryInterface.php b/app/Repositories/Administration/Account/AccountRepositoryInterface.php new file mode 100644 index 0000000000..fbdf6c81a3 --- /dev/null +++ b/app/Repositories/Administration/Account/AccountRepositoryInterface.php @@ -0,0 +1,39 @@ +. + */ + +namespace FireflyIII\Repositories\Administration\Account; + +use Illuminate\Support\Collection; + +/** + * Interface AccountRepositoryInterface + */ +interface AccountRepositoryInterface +{ + /** + * @param string $query + * @param array $types + * @param int $limit + * + * @return Collection + */ + public function searchAccount(string $query, array $types, int $limit): Collection; +} diff --git a/app/Support/Repositories/Administration/AdministrationTrait.php b/app/Support/Repositories/Administration/AdministrationTrait.php new file mode 100644 index 0000000000..4028304522 --- /dev/null +++ b/app/Support/Repositories/Administration/AdministrationTrait.php @@ -0,0 +1,82 @@ +. + */ + +namespace FireflyIII\Support\Repositories\Administration; + +use FireflyIII\Exceptions\FireflyException; +use FireflyIII\Models\GroupMembership; +use FireflyIII\Models\UserGroup; +use FireflyIII\User; +use Illuminate\Contracts\Auth\Authenticatable; + +/** + * Trait AdministrationTrait + */ +trait AdministrationTrait +{ + protected User $user; + protected ?int $administrationId = null; + protected ?UserGroup $userGroup = null; + + /** + * @return int + */ + public function getAdministrationId(): int + { + return $this->administrationId; + } + + /** + * @param int $administrationId + * @throws FireflyException + */ + public function setAdministrationId(int $administrationId): void + { + $this->administrationId = $administrationId; + $this->refreshAdministration(); + } + + /** + * @return void + */ + private function refreshAdministration(): void + { + if (null !== $this->administrationId) { + $memberships = GroupMembership::where('user_id', $this->user->id) + ->where('user_group_id', $this->administrationId) + ->count(); + if (0 === $memberships) { + throw new FireflyException(sprintf('User #%d has no access to administration #%d', $this->user->id, $this->administrationId)); + } + $this->userGroup = UserGroup::find($this->administrationId); + return; + } + throw new FireflyException(sprintf('Cannot validate administration for user #%d', $this->user->id)); + } + + + public function setUser(Authenticatable|User|null $user): void + { + if(null !== $user) { + $this->user = $user; + } + } +} diff --git a/frontend/src/api/v2/autocomplete/accounts.js b/frontend/src/api/v2/autocomplete/accounts.js new file mode 100644 index 0000000000..83756b128e --- /dev/null +++ b/frontend/src/api/v2/autocomplete/accounts.js @@ -0,0 +1,35 @@ +/* + * get.js + * Copyright (c) 2022 james@firefly-iii.org + * + * This file is part of Firefly III (https://github.com/firefly-iii). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import {api} from "boot/axios"; + +export default class Accounts { + + /** + * + * @param types + * @returns {Promise>} + */ + get(types, query) { + let url = 'api/v2/autocomplete/accounts'; + return api.get(url, {params: {types: types, query: query, limit: 25}}) + } + +} diff --git a/frontend/src/boot/axios.js b/frontend/src/boot/axios.js index 6cc571832e..40bb2c27dc 100644 --- a/frontend/src/boot/axios.js +++ b/frontend/src/boot/axios.js @@ -34,7 +34,7 @@ const cache = setupCache({ // "export default () => {}" function below (which runs individually // for each client) -const url = process.env.DEBUGGING ? 'https://firefly.sd.home' : '/'; +const url = process.env.DEBUGGING ? 'https://firefly.sd.local' : '/'; const api = axios.create({baseURL: url, withCredentials: true, adapter: cache.adapter}); export default boot(({app}) => { diff --git a/frontend/src/components/transactions/Split.vue b/frontend/src/components/transactions/Split.vue new file mode 100644 index 0000000000..c224df3a38 --- /dev/null +++ b/frontend/src/components/transactions/Split.vue @@ -0,0 +1,316 @@ + + + + + + + Info for {{ $route.params.type }} {{ index }} + + + + + + + + Main info + + + + + + + + + + + + + + + + + + + + + + + Optional + + + Foreign amount + + + + + + + + + + More meta info + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + More meta info + + + + + Extra opts + + + + + + + + Date info + + + + + Date fields + + + + + + + + + + + + + diff --git a/frontend/src/components/transactions/form/SourceAccount.vue b/frontend/src/components/transactions/form/SourceAccount.vue new file mode 100644 index 0000000000..83e91dcbd8 --- /dev/null +++ b/frontend/src/components/transactions/form/SourceAccount.vue @@ -0,0 +1,167 @@ + + + + + + + + + {{ scope.opt.label }} + {{ scope.opt.description }} + + + + + + + + No results + + + + + + + + + + + + diff --git a/frontend/src/components/transactions/form/TransactionDescription.vue b/frontend/src/components/transactions/form/TransactionDescription.vue new file mode 100644 index 0000000000..1529ef9462 --- /dev/null +++ b/frontend/src/components/transactions/form/TransactionDescription.vue @@ -0,0 +1,70 @@ + + + + + + + + + + + diff --git a/frontend/src/pages/transactions/Create.vue b/frontend/src/pages/transactions/Create.vue index b89a581e0c..23a89b2147 100644 --- a/frontend/src/pages/transactions/Create.vue +++ b/frontend/src/pages/transactions/Create.vue @@ -30,7 +30,6 @@ - - - - Info for {{ $route.params.type }} {{ index }} - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +