diff --git a/app/Api/V2/Controllers/Autocomplete/AccountController.php b/app/Api/V2/Controllers/Autocomplete/AccountController.php index 01d2f4fa03..53fde1fc89 100644 --- a/app/Api/V2/Controllers/Autocomplete/AccountController.php +++ b/app/Api/V2/Controllers/Autocomplete/AccountController.php @@ -24,14 +24,16 @@ declare(strict_types=1); namespace FireflyIII\Api\V2\Controllers\Autocomplete; +use Carbon\Carbon; use FireflyIII\Api\V2\Controllers\Controller; use FireflyIII\Api\V2\Request\Autocomplete\AutocompleteRequest; use FireflyIII\Exceptions\FireflyException; use FireflyIII\Models\Account; +use FireflyIII\Models\AccountBalance; use FireflyIII\Models\AccountType; -use FireflyIII\Repositories\Account\AccountRepositoryInterface; +use FireflyIII\Models\TransactionCurrency; use FireflyIII\Repositories\UserGroups\Account\AccountRepositoryInterface as AdminAccountRepositoryInterface; -use FireflyIII\Support\Http\Api\AccountFilter; +use FireflyIII\Support\Http\Api\ExchangeRateConverter; use Illuminate\Http\JsonResponse; /** @@ -39,11 +41,14 @@ use Illuminate\Http\JsonResponse; */ class AccountController extends Controller { - use AccountFilter; + // use AccountFilter; private AdminAccountRepositoryInterface $adminRepository; - private array $balanceTypes; - private AccountRepositoryInterface $repository; + private TransactionCurrency $default; + private ExchangeRateConverter $converter; + +// private array $balanceTypes; +// private AccountRepositoryInterface $repository; /** * AccountController constructor. @@ -53,14 +58,20 @@ class AccountController extends Controller parent::__construct(); $this->middleware( function ($request, $next) { - $this->repository = app(AccountRepositoryInterface::class); + // new way of user group validation + $userGroup = $this->validateUserGroup($request); $this->adminRepository = app(AdminAccountRepositoryInterface::class); - $this->adminRepository->setUserGroup($this->validateUserGroup($request)); + $this->adminRepository->setUserGroup($userGroup); + $this->default = app('amount')->getDefaultCurrency(); + $this->converter = app(ExchangeRateConverter::class); + +// $this->repository = app(AccountRepositoryInterface::class); + // $this->adminRepository->setUserGroup($this->validateUserGroup($request)); return $next($request); } ); - $this->balanceTypes = [AccountType::ASSET, AccountType::LOAN, AccountType::DEBT, AccountType::MORTGAGE]; +// $this->balanceTypes = [AccountType::ASSET, AccountType::LOAN, AccountType::DEBT, AccountType::MORTGAGE]; } /** @@ -73,59 +84,66 @@ class AccountController extends Controller * 5. Collector uses user_group_id * * @throws FireflyException - * @throws FireflyException */ public function accounts(AutocompleteRequest $request): JsonResponse { - $data = $request->getData(); - $types = $data['types']; - $query = $data['query']; - $date = $this->parameters->get('date') ?? today(config('app.timezone')); - $result = $this->adminRepository->searchAccount((string) $query, $types, $data['limit']); - $defaultCurrency = app('amount')->getDefaultCurrency(); - $groupedResult = []; - $allItems = []; + $queryParameters = $request->getParameters(); + $result = $this->adminRepository->searchAccount((string) $queryParameters['query'], $queryParameters['account_types'], $queryParameters['size']); + $return = []; /** @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)); - } - $type = (string) trans(sprintf('firefly.%s', $account->accountType->type)); - $groupedResult[$type] ??= [ - 'group ' => $type, - 'items' => [], - ]; - $allItems[] = [ - 'id' => (string) $account->id, - 'value' => (string) $account->id, - 'name' => $account->name, - 'name_with_balance' => $nameWithBalance, - 'label' => $nameWithBalance, - 'type' => $account->accountType->type, - 'currency_id' => (string) $currency->id, - 'currency_name' => $currency->name, - 'currency_code' => $currency->code, - 'currency_symbol' => $currency->symbol, - 'currency_decimal_places' => $currency->decimal_places, - ]; + $return[] = $this->parseAccount($account); } - usort( - $allItems, - static function (array $left, array $right): int { - $order = [AccountType::ASSET, AccountType::REVENUE, AccountType::EXPENSE]; - $posLeft = (int) array_search($left['type'], $order, true); - $posRight = (int) array_search($right['type'], $order, true); + return response()->json($return); + } - return $posLeft - $posRight; - } - ); + private function parseAccount(Account $account): array + { + return [ + 'id' => (string) $account->id, + 'title' => $account->name, + 'meta' => [ + 'type' => $account->accountType->type, + 'account_balances' => $this->getAccountBalances($account), + ], + ]; + } + + private function getAccountBalances(Account $account): array + { + $return = []; + $balances = $this->adminRepository->getAccountBalances($account); + /** @var AccountBalance $balance */ + foreach ($balances as $balance) { + $return[] = $this->parseAccountBalance($balance); + } + return $return; + } + + /** + * @param AccountBalance $balance + * + * @return array + */ + private function parseAccountBalance(AccountBalance $balance): array + { + $currency = $balance->transactionCurrency; + return [ + 'title' => $balance->title, + 'native_amount' => $this->converter->convert($currency, $this->default, today(), $balance->balance), + 'amount' => app('steam')->bcround($balance->balance, $currency->decimal_places), + 'currency_id' => (string) $currency->id, + 'currency_code' => $currency->code, + 'currency_symbol' => $currency->symbol, + 'currency_decimal_places' => $currency->decimal_places, + 'native_currency_id' => (string) $this->default->id, + 'native_currency_code' => $this->default->code, + 'native_currency_symbol' => $this->default->symbol, + 'native_currency_decimal' => $this->default->decimal_places, + + ]; - return response()->json($allItems); } } diff --git a/app/Api/V2/Request/Autocomplete/AutocompleteRequest.php b/app/Api/V2/Request/Autocomplete/AutocompleteRequest.php index 8e5daf6b28..5c22f9737b 100644 --- a/app/Api/V2/Request/Autocomplete/AutocompleteRequest.php +++ b/app/Api/V2/Request/Autocomplete/AutocompleteRequest.php @@ -23,11 +23,16 @@ declare(strict_types=1); namespace FireflyIII\Api\V2\Request\Autocomplete; -use FireflyIII\Enums\UserRoleEnum; +use Carbon\Carbon; +use FireflyIII\JsonApi\Rules\IsValidFilter; +use FireflyIII\JsonApi\Rules\IsValidPage; use FireflyIII\Models\AccountType; +use FireflyIII\Support\Http\Api\AccountFilter; use FireflyIII\Support\Request\ChecksLogin; use FireflyIII\Support\Request\ConvertsDataTypes; use Illuminate\Foundation\Http\FormRequest; +use LaravelJsonApi\Core\Query\QueryParameters; +use LaravelJsonApi\Validation\Rule as JsonApiRule; /** * Class AutocompleteRequest @@ -36,11 +41,39 @@ class AutocompleteRequest extends FormRequest { use ChecksLogin; use ConvertsDataTypes; + use AccountFilter; - protected array $acceptedRoles = [UserRoleEnum::MANAGE_TRANSACTIONS]; + /** + * Loops over all possible query parameters (these are shared over ALL auto complete requests) + * and returns a validated array of parameters. + * + * The advantage is a single class. But you may also submit "account types" to an endpoint that doesn't use these. + * + * @return array + */ + public function getParameters(): array + { + $queryParameters = QueryParameters::cast($this->all()); + $date = Carbon::createFromFormat('Y-m-d', $queryParameters->filter()->value('date', date('Y-m-d')), config('app.timezone')); + $query = $queryParameters->filter()->value('query', ''); + $size = (int) ($queryParameters->page()['size'] ?? 50); + $accountTypes = $this->getAccountTypeParameter($queryParameters->filter()->value('account_types', '')); + + + return [ + 'date' => $date, + 'query' => $query, + 'size' => $size, + 'account_types' => $accountTypes, + ]; + + } public function getData(): array { + + + return []; $types = $this->convertString('types'); $array = []; if ('' !== $types) { @@ -62,8 +95,28 @@ class AutocompleteRequest extends FormRequest public function rules(): array { + return [ - 'limit' => 'min:0|max:1337', + 'fields' => JsonApiRule::notSupported(), + 'filter' => ['nullable', 'array', new IsValidFilter(['query', 'date', 'account_types']),], + 'include' => JsonApiRule::notSupported(), + 'page' => ['nullable', 'array', new IsValidPage(['size']),], + 'sort' => JsonApiRule::notSupported(), ]; } + + private function getAccountTypeParameter(mixed $types): array + { + if (is_string($types) && str_contains($types, ',')) { + $types = explode(',', $types); + } + if (!is_iterable($types)) { + $types = [$types]; + } + $return = []; + foreach ($types as $type) { + $return = array_merge($return, $this->mapAccountTypes($type)); + } + return array_unique($return); + } } diff --git a/app/Console/Commands/Correction/CorrectAccountBalance.php b/app/Console/Commands/Correction/CorrectAccountBalance.php index 64cfd5e132..b4f25c9fd0 100644 --- a/app/Console/Commands/Correction/CorrectAccountBalance.php +++ b/app/Console/Commands/Correction/CorrectAccountBalance.php @@ -3,8 +3,10 @@ namespace FireflyIII\Console\Commands\Correction; use DB; +use FireflyIII\Console\Commands\ShowsFriendlyMessages; use FireflyIII\Models\AccountBalance; use FireflyIII\Models\Transaction; +use FireflyIII\Support\Models\AccountBalanceCalculator; use Illuminate\Console\Command; use stdClass; @@ -16,6 +18,7 @@ class CorrectAccountBalance extends Command protected $description = 'Recalculate all account balance amounts'; protected $signature = 'firefly-iii:correct-account-balance'; + use ShowsFriendlyMessages; /** * @return int @@ -30,19 +33,6 @@ class CorrectAccountBalance extends Command private function correctBalanceAmounts(): void { - $result = Transaction - ::groupBy(['account_id', 'transaction_currency_id']) - ->get(['account_id', 'transaction_currency_id', DB::raw('SUM(amount) as amount_sum')]); - /** @var stdClass $entry */ - foreach ($result as $entry) { - $account = (int) $entry->account_id; - $currency = (int) $entry->transaction_currency_id; - $sum = $entry->amount_sum; - - AccountBalance::updateOrCreate( - ['title' => 'balance', 'account_id' => $account, 'transaction_currency_id' => $currency], - ['balance' => $sum] - ); - } + AccountBalanceCalculator::recalculate(null); } } diff --git a/app/Handlers/Observer/TransactionObserver.php b/app/Handlers/Observer/TransactionObserver.php index 4d5e0ef3fa..2462331ae9 100644 --- a/app/Handlers/Observer/TransactionObserver.php +++ b/app/Handlers/Observer/TransactionObserver.php @@ -26,6 +26,7 @@ namespace FireflyIII\Handlers\Observer; use DB; use FireflyIII\Models\AccountBalance; use FireflyIII\Models\Transaction; +use FireflyIII\Support\Models\AccountBalanceCalculator; use stdClass; /** @@ -42,16 +43,6 @@ class TransactionObserver public function updated(Transaction $transaction): void { app('log')->debug('Observe "updated" of a transaction.'); - // refresh account balance: - /** @var stdClass $result */ - $result = Transaction::groupBy(['account_id', 'transaction_currency_id'])->where('account_id', $transaction->account_id)->first(['account_id', 'transaction_currency_id', DB::raw('SUM(amount) as amount_sum')]); - if (null !== $result) { - $account = (int) $result->account_id; - $currency = (int) $result->transaction_currency_id; - $sum = $result->amount_sum; - - AccountBalance::updateOrCreate(['title' => 'balance', 'account_id' => $account, 'transaction_currency_id' => $currency], ['balance' => $sum]); - } - + AccountBalanceCalculator::recalculate($transaction->account); } } diff --git a/app/JsonApi/Rules/IsValidFilter.php b/app/JsonApi/Rules/IsValidFilter.php new file mode 100644 index 0000000000..ff82c36958 --- /dev/null +++ b/app/JsonApi/Rules/IsValidFilter.php @@ -0,0 +1,55 @@ +allowed = $keys; + } + + /** + * @inheritDoc + */ + #[\Override] public function validate(string $attribute, mixed $value, Closure $fail): void + { + if ('filter' !== $attribute) { + $fail('validation.bad_api_filter')->translate(); + } + if (!is_array($value)) { + $value = explode(',', $value); + } + foreach ($value as $key => $val) { + if (!in_array($key, $this->allowed, true)) { + $fail('validation.bad_api_filter')->translate(); + } + } + } +} diff --git a/app/JsonApi/Rules/IsValidPage.php b/app/JsonApi/Rules/IsValidPage.php new file mode 100644 index 0000000000..b29b52478c --- /dev/null +++ b/app/JsonApi/Rules/IsValidPage.php @@ -0,0 +1,55 @@ +allowed = $keys; + } + + /** + * @inheritDoc + */ + #[\Override] public function validate(string $attribute, mixed $value, Closure $fail): void + { + if ('page' !== $attribute) { + $fail('validation.bad_api_filter')->translate(); + } + if (!is_array($value)) { + $value = explode(',', $value); + } + foreach ($value as $key => $val) { + if (!in_array($key, $this->allowed, true)) { + $fail('validation.bad_api_page')->translate(); + } + } + } +} diff --git a/app/Models/AccountBalance.php b/app/Models/AccountBalance.php index f3a180d728..c0fa557eb3 100644 --- a/app/Models/AccountBalance.php +++ b/app/Models/AccountBalance.php @@ -8,7 +8,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; class AccountBalance extends Model { - protected $fillable = ['account_id', 'transaction_currency_id', 'balance']; + protected $fillable = ['account_id', 'title', 'transaction_currency_id', 'balance']; use HasFactory; @@ -16,4 +16,9 @@ class AccountBalance extends Model { return $this->belongsTo(Account::class); } + + public function transactionCurrency(): BelongsTo + { + return $this->belongsTo(TransactionCurrency::class); + } } diff --git a/app/Repositories/UserGroups/Account/AccountRepository.php b/app/Repositories/UserGroups/Account/AccountRepository.php index 9098bbc98d..d5e322b5c5 100644 --- a/app/Repositories/UserGroups/Account/AccountRepository.php +++ b/app/Repositories/UserGroups/Account/AccountRepository.php @@ -302,6 +302,7 @@ class AccountRepository implements AccountRepositoryInterface ; if ('' !== $query) { // split query on spaces just in case: + // TODO this will always fail because it searches for AND. $parts = explode(' ', $query); foreach ($parts as $part) { $search = sprintf('%%%s%%', $part); @@ -384,4 +385,9 @@ class AccountRepository implements AccountRepositoryInterface return $return; } + + #[\Override] public function getAccountBalances(Account $account): Collection + { + return $account->accountBalances; + } } diff --git a/app/Repositories/UserGroups/Account/AccountRepositoryInterface.php b/app/Repositories/UserGroups/Account/AccountRepositoryInterface.php index 8f4dd586e0..0374ed2174 100644 --- a/app/Repositories/UserGroups/Account/AccountRepositoryInterface.php +++ b/app/Repositories/UserGroups/Account/AccountRepositoryInterface.php @@ -53,6 +53,8 @@ interface AccountRepositoryInterface public function getAccountCurrency(Account $account): ?TransactionCurrency; + public function getAccountBalances(Account $account): Collection; + public function getAccountsById(array $accountIds): Collection; public function getAccountsByType(array $types, ?array $sort = [], ?array $filters = []): Collection; diff --git a/app/Support/Models/AccountBalanceCalculator.php b/app/Support/Models/AccountBalanceCalculator.php new file mode 100644 index 0000000000..b02bfccd5b --- /dev/null +++ b/app/Support/Models/AccountBalanceCalculator.php @@ -0,0 +1,100 @@ +get(['account_id', 'transaction_currency_id', 'foreign_currency_id', DB::raw('SUM(amount) as sum_amount'), DB::raw('SUM(foreign_amount) as sum_foreign_amount')]); + + // reset account balances: + self::resetAccountBalances($account); + + /** @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 = $row->sum_amount; + $sumForeignAmount = $row->sum_foreign_amount; + + // first create for normal currency: + $entry = self::getBalance('balance', $account, $transactionCurrency); + $entry->balance = bcadd($entry->balance, $sumAmount); + $entry->save(); + Log::debug(sprintf('Set balance entry #%d to amount %s', $entry->id, $entry->balance)); + + // then do foreign amount, if present: + if ($foreignCurrency > 0) { + $entry = self::getBalance('balance', $account, $foreignCurrency); + $entry->balance = bcadd($entry->balance, $sumForeignAmount); + $entry->save(); + Log::debug(sprintf('Set balance entry #%d to amount %s', $entry->id, $entry->balance)); + } + } + return; + } + private static function getBalance(string $title, int $account, int $currency): AccountBalance + { + $entry = AccountBalance::where('title', $title)->where('account_id', $account)->where('transaction_currency_id', $currency)->first(); + if (null !== $entry) { + Log::debug(sprintf('Found account balance for account #%d and currency #%d: %s', $account, $currency, $entry->balance)); + return $entry; + } + $entry = new AccountBalance; + $entry->title = $title; + $entry->account_id = $account; + $entry->transaction_currency_id = $currency; + $entry->balance = '0'; + $entry->save(); + Log::debug(sprintf('Created new account balance for account #%d and currency #%d: %s', $account, $currency, $entry->balance)); + return $entry; + } + + private static function resetAccountBalances(?Account $account): void + { + if (null === $account) { + AccountBalance::whereNotNull('updated_at')->update(['balance' => '0']); + Log::debug('Set ALL balances to zero.'); + return; + } + AccountBalance::where('account_id', $account->id)->update(['balance' => '0']); + Log::debug(sprintf('Set balances of account #%d to zero.', $account->id)); + } + + +} diff --git a/routes/api.php b/routes/api.php index f4bbcc9fe4..4122a92422 100644 --- a/routes/api.php +++ b/routes/api.php @@ -29,22 +29,44 @@ use LaravelJsonApi\Laravel\Routing\ActionRegistrar; use LaravelJsonApi\Laravel\Routing\Relationships; use LaravelJsonApi\Laravel\Routing\ResourceRegistrar; +/** + * V2 auto complete controller(s) + */ +Route::group( + [ + 'namespace' => 'FireflyIII\Api\V2\Controllers\Autocomplete', + 'prefix' => 'v2/autocomplete', + 'as' => 'api.v2.autocomplete.', + ], + static function (): void { + // Auto complete routes + Route::get('accounts', ['uses' => 'AccountController@accounts', 'as' => 'accounts']); + Route::get('transaction-descriptions', ['uses' => 'TransactionController@transactionDescriptions', 'as' => 'transaction-descriptions']); + Route::get('categories', ['uses' => 'CategoryController@categories', 'as' => 'categories']); + Route::get('tags', ['uses' => 'TagController@tags', 'as' => 'tags']); + } +); + + + + + +//JsonApiRoute::server('v3') +// ->prefix('v3') +// ->resources(function (ResourceRegistrar $server) { +// $server->resource('accounts', AccountController::class)->readOnly()->relationships(function (Relationships $relations) { +// $relations->hasOne('user')->readOnly(); +// //$relations->hasMany('account_balances')->readOnly(); +// }) +// ->actions(function (ActionRegistrar $actions) { +// $actions->withId()->get('account-balances', 'readAccountBalances'); // non-eloquent pseudo relation +// }); +// $server->resource('users', JsonApiController::class)->readOnly()->relationships(function (Relationships $relations) { +// $relations->hasMany('accounts')->readOnly(); +// }); +// $server->resource('account-balances', JsonApiController::class); +// }); -JsonApiRoute::server('v3') - ->prefix('v3') - ->resources(function (ResourceRegistrar $server) { - $server->resource('accounts', AccountController::class)->readOnly()->relationships(function (Relationships $relations) { - $relations->hasOne('user')->readOnly(); - //$relations->hasMany('account_balances')->readOnly(); - }) - ->actions(function (ActionRegistrar $actions) { - $actions->withId()->get('account-balances', 'readAccountBalances'); // non-eloquent pseudo relation - }); - $server->resource('users', JsonApiController::class)->readOnly()->relationships(function (Relationships $relations) { - $relations->hasMany('accounts')->readOnly(); - }); - $server->resource('account-balances', JsonApiController::class); - }); // V2 API route for Summary boxes @@ -79,20 +101,7 @@ Route::group( ); // V2 API routes for auto complete -Route::group( - [ - 'namespace' => 'FireflyIII\Api\V2\Controllers\Autocomplete', - 'prefix' => 'v2/autocomplete', - 'as' => 'api.v2.autocomplete.', - ], - static function (): void { - // Auto complete routes - Route::get('accounts', ['uses' => 'AccountController@accounts', 'as' => 'accounts']); - Route::get('transaction-descriptions', ['uses' => 'TransactionController@transactionDescriptions', 'as' => 'transaction-descriptions']); - Route::get('categories', ['uses' => 'CategoryController@categories', 'as' => 'categories']); - Route::get('tags', ['uses' => 'TagController@tags', 'as' => 'tags']); - } -); + // V2 API route for net worth endpoint(s); Route::group(