From faeb17f319d9e1b5c3ee986e2f717425ca9edd73 Mon Sep 17 00:00:00 2001 From: James Cole Date: Tue, 25 Mar 2025 17:27:59 +0100 Subject: [PATCH] Recreate API endpoints. --- .../Controllers/Chart/AccountController.php | 72 +++++ .../V1/Controllers/Chart/BudgetController.php | 260 ++++++++++++++++++ .../Controllers/Chart/CategoryController.php | 128 +++++++++ app/Api/V1/Requests/Chart/ChartRequest.php | 91 ++++++ app/Api/V1/Requests/Generic/DateRequest.php | 62 +++++ .../V1/Requests/Generic/SingleDateRequest.php | 59 ++++ 6 files changed, 672 insertions(+) create mode 100644 app/Api/V1/Controllers/Chart/BudgetController.php create mode 100644 app/Api/V1/Controllers/Chart/CategoryController.php create mode 100644 app/Api/V1/Requests/Chart/ChartRequest.php create mode 100644 app/Api/V1/Requests/Generic/DateRequest.php create mode 100644 app/Api/V1/Requests/Generic/SingleDateRequest.php diff --git a/app/Api/V1/Controllers/Chart/AccountController.php b/app/Api/V1/Controllers/Chart/AccountController.php index e6ceb7d015..44f809cbad 100644 --- a/app/Api/V1/Controllers/Chart/AccountController.php +++ b/app/Api/V1/Controllers/Chart/AccountController.php @@ -27,13 +27,16 @@ namespace FireflyIII\Api\V1\Controllers\Chart; use Carbon\Carbon; use FireflyIII\Api\V1\Controllers\Controller; use FireflyIII\Api\V1\Requests\Data\DateRequest; +use FireflyIII\Api\V1\Requests\Chart\ChartRequest; use FireflyIII\Enums\AccountTypeEnum; use FireflyIII\Exceptions\FireflyException; use FireflyIII\Models\Account; use FireflyIII\Models\Preference; use FireflyIII\Repositories\Account\AccountRepositoryInterface; +use FireflyIII\Support\Chart\ChartData; use FireflyIII\Support\Facades\Steam; use FireflyIII\Support\Http\Api\ApiSupport; +use FireflyIII\Support\Http\Api\CollectsAccountsFromFilter; use FireflyIII\User; use Illuminate\Http\JsonResponse; @@ -43,8 +46,10 @@ use Illuminate\Http\JsonResponse; class AccountController extends Controller { use ApiSupport; + use CollectsAccountsFromFilter; private AccountRepositoryInterface $repository; + private ChartData $chartData; /** * AccountController constructor. @@ -56,6 +61,7 @@ class AccountController extends Controller function ($request, $next) { /** @var User $user */ $user = auth()->user(); + $this->chartData = new ChartData(); $this->repository = app(AccountRepositoryInterface::class); $this->repository->setUser($user); @@ -64,6 +70,30 @@ class AccountController extends Controller ); } + + /** + * TODO fix documentation + * + * @throws FireflyException + */ + public function dashboard(ChartRequest $request): JsonResponse + { + $queryParameters = $request->getParameters(); + $accounts = $this->getAccountList($queryParameters); + + // move date to end of day + $queryParameters['start']->startOfDay(); + $queryParameters['end']->endOfDay(); + + // loop each account, and collect info: + /** @var Account $account */ + foreach ($accounts as $account) { + $this->renderAccountData($queryParameters, $account); + } + + return response()->json($this->chartData->render()); + } + /** * This endpoint is documented at: * https://api-docs.firefly-iii.org/?urls.primaryName=2.0.0%20(v1)#/charts/getChartAccountOverview @@ -133,4 +163,46 @@ class AccountController extends Controller return response()->json($chartData); } + + + /** + * @throws FireflyException + */ + private function renderAccountData(array $params, Account $account): void + { + $currency = $this->repository->getAccountCurrency($account); + if (null === $currency) { + $currency = $this->default; + } + $currentSet = [ + 'label' => $account->name, + + // the currency that belongs to the account. + 'currency_id' => (string) $currency->id, + 'currency_code' => $currency->code, + 'currency_symbol' => $currency->symbol, + 'currency_decimal_places' => $currency->decimal_places, + + // the default currency of the user (could be the same!) + 'date' => $params['start']->toAtomString(), + 'start' => $params['start']->toAtomString(), + 'end' => $params['end']->toAtomString(), + 'period' => '1D', + 'entries' => [], + ]; + $currentStart = clone $params['start']; + $range = Steam::finalAccountBalanceInRange($account, $params['start'], clone $params['end'], $this->convertToNative); + + $previous = array_values($range)[0]['balance']; + while ($currentStart <= $params['end']) { + $format = $currentStart->format('Y-m-d'); + $label = $currentStart->toAtomString(); + $balance = array_key_exists($format, $range) ? $range[$format]['balance'] : $previous; + $previous = $balance; + + $currentStart->addDay(); + $currentSet['entries'][$label] = $balance; + } + $this->chartData->add($currentSet); + } } diff --git a/app/Api/V1/Controllers/Chart/BudgetController.php b/app/Api/V1/Controllers/Chart/BudgetController.php new file mode 100644 index 0000000000..385d4e1f38 --- /dev/null +++ b/app/Api/V1/Controllers/Chart/BudgetController.php @@ -0,0 +1,260 @@ +. + */ + +declare(strict_types=1); + +namespace FireflyIII\Api\V1\Controllers\Chart; + +use Carbon\Carbon; +use FireflyIII\Api\V1\Controllers\Controller; +use FireflyIII\Api\V1\Requests\Generic\DateRequest; +use FireflyIII\Enums\UserRoleEnum; +use FireflyIII\Exceptions\FireflyException; +use FireflyIII\Models\Budget; +use FireflyIII\Models\BudgetLimit; +use FireflyIII\Models\TransactionCurrency; +use FireflyIII\Repositories\Budget\BudgetLimitRepositoryInterface; +use FireflyIII\Repositories\Budget\BudgetRepositoryInterface; +use FireflyIII\Repositories\Budget\OperationsRepositoryInterface; +use FireflyIII\Support\Http\Api\CleansChartData; +use FireflyIII\Support\Http\Api\ValidatesUserGroupTrait; +use Illuminate\Http\JsonResponse; +use Illuminate\Support\Collection; +use Illuminate\Support\Facades\Log; + +/** + * Class BudgetController + */ +class BudgetController extends Controller +{ + use CleansChartData; + use ValidatesUserGroupTrait; + + protected array $acceptedRoles = [UserRoleEnum::READ_ONLY]; + + protected OperationsRepositoryInterface $opsRepository; + private BudgetLimitRepositoryInterface $blRepository; + private array $currencies = []; + private TransactionCurrency $currency; + private BudgetRepositoryInterface $repository; + + public function __construct() + { + parent::__construct(); + $this->middleware( + function ($request, $next) { + $this->repository = app(BudgetRepositoryInterface::class); + $this->blRepository = app(BudgetLimitRepositoryInterface::class); + $this->opsRepository = app(OperationsRepositoryInterface::class); + $userGroup = $this->validateUserGroup($request); + $this->repository->setUserGroup($userGroup); + $this->opsRepository->setUserGroup($userGroup); + $this->blRepository->setUserGroup($userGroup); + + return $next($request); + } + ); + } + + /** + * TODO see autocomplete/accountcontroller + */ + public function dashboard(DateRequest $request): JsonResponse + { + $params = $request->getAll(); + + /** @var Carbon $start */ + $start = $params['start']; + + /** @var Carbon $end */ + $end = $params['end']; + + // code from FrontpageChartGenerator, but not in separate class + $budgets = $this->repository->getActiveBudgets(); + $data = []; + + /** @var Budget $budget */ + foreach ($budgets as $budget) { + // could return multiple arrays, so merge. + $data = array_merge($data, $this->processBudget($budget, $start, $end)); + } + + return response()->json($this->clean($data)); + } + + /** + * @throws FireflyException + */ + private function processBudget(Budget $budget, Carbon $start, Carbon $end): array + { + // get all limits: + $limits = $this->blRepository->getBudgetLimits($budget, $start, $end); + $rows = []; + + // if no limits + if (0 === $limits->count()) { + // return as a single item in an array + $rows = $this->noBudgetLimits($budget, $start, $end); + } + if ($limits->count() > 0) { + $rows = $this->budgetLimits($budget, $limits); + } + // is always an array + $return = []; + foreach ($rows as $row) { + $current = [ + 'label' => $budget->name, + 'currency_id' => (string) $row['currency_id'], + 'currency_code' => $row['currency_code'], + 'currency_name' => $row['currency_name'], + 'currency_decimal_places' => $row['currency_decimal_places'], + 'period' => null, + 'start' => $row['start'], + 'end' => $row['end'], + 'entries' => [ + 'spent' => $row['spent'], + 'left' => $row['left'], + 'overspent' => $row['overspent'], + ], + ]; + $return[] = $current; + } + + return $return; + } + + /** + * When no budget limits are present, the expenses of the whole period are collected and grouped. + * This is grouped per currency. Because there is no limit set, "left to spend" and "overspent" are empty. + * + * @throws FireflyException + */ + private function noBudgetLimits(Budget $budget, Carbon $start, Carbon $end): array + { + $spent = $this->opsRepository->listExpenses($start, $end, null, new Collection([$budget])); + + return $this->processExpenses($budget->id, $spent, $start, $end); + } + + /** + * Shared between the "noBudgetLimits" function and "processLimit". Will take a single set of expenses and return + * its info. + * + * @param array> $array + * + * @throws FireflyException + */ + private function processExpenses(int $budgetId, array $array, Carbon $start, Carbon $end): array + { + $return = []; + + /** + * This array contains the expenses in this budget. Grouped per currency. + * The grouping is on the main currency only. + * + * @var int $currencyId + * @var array $block + */ + foreach ($array as $currencyId => $block) { + $this->currencies[$currencyId] ??= TransactionCurrency::find($currencyId); + $return[$currencyId] ??= [ + 'currency_id' => (string) $currencyId, + 'currency_code' => $block['currency_code'], + 'currency_name' => $block['currency_name'], + 'currency_symbol' => $block['currency_symbol'], + 'currency_decimal_places' => (int) $block['currency_decimal_places'], + 'start' => $start->toAtomString(), + 'end' => $end->toAtomString(), + 'spent' => '0', + 'left' => '0', + 'overspent' => '0', + ]; + $currentBudgetArray = $block['budgets'][$budgetId]; + + // var_dump($return); + /** @var array $journal */ + foreach ($currentBudgetArray['transaction_journals'] as $journal) { + $return[$currencyId]['spent'] = bcadd($return[$currencyId]['spent'], $journal['amount']); + } + } + + return $return; + } + + /** + * Function that processes each budget limit (per budget). + * + * If you have a budget limit in EUR, only transactions in EUR will be considered. + * If you have a budget limit in GBP, only transactions in GBP will be considered. + * + * If you have a budget limit in EUR, and a transaction in GBP, it will not be considered for the EUR budget limit. + * + * @throws FireflyException + */ + private function budgetLimits(Budget $budget, Collection $limits): array + { + app('log')->debug(sprintf('Now in budgetLimits(#%d)', $budget->id)); + $data = []; + + /** @var BudgetLimit $limit */ + foreach ($limits as $limit) { + $data = array_merge($data, $this->processLimit($budget, $limit)); + } + + return $data; + } + + /** + * @throws FireflyException + */ + private function processLimit(Budget $budget, BudgetLimit $limit): array + { + Log::debug(sprintf('Created new ExchangeRateConverter in %s', __METHOD__)); + $end = clone $limit->end_date; + $end->endOfDay(); + $spent = $this->opsRepository->listExpenses($limit->start_date, $end, null, new Collection([$budget])); + $limitCurrencyId = $limit->transaction_currency_id; + $filtered = []; + + /** @var array $entry */ + foreach ($spent as $currencyId => $entry) { + // only spent the entry where the entry's currency matches the budget limit's currency + // so $filtered will only have 1 or 0 entries + if ($entry['currency_id'] === $limitCurrencyId) { + $filtered[$currencyId] = $entry; + } + } + $result = $this->processExpenses($budget->id, $filtered, $limit->start_date, $end); + if (1 === count($result)) { + $compare = bccomp($limit->amount, app('steam')->positive($result[$limitCurrencyId]['spent'])); + if (1 === $compare) { + // convert this amount into the native currency: + $result[$limitCurrencyId]['left'] = bcadd($limit->amount, $result[$limitCurrencyId]['spent']); + } + if ($compare <= 0) { + $result[$limitCurrencyId]['overspent'] = app('steam')->positive(bcadd($limit->amount, $result[$limitCurrencyId]['spent'])); + } + } + + return $result; + } +} diff --git a/app/Api/V1/Controllers/Chart/CategoryController.php b/app/Api/V1/Controllers/Chart/CategoryController.php new file mode 100644 index 0000000000..7dac060685 --- /dev/null +++ b/app/Api/V1/Controllers/Chart/CategoryController.php @@ -0,0 +1,128 @@ +. + */ + +declare(strict_types=1); + +namespace FireflyIII\Api\V1\Controllers\Chart; + +use Carbon\Carbon; +use FireflyIII\Api\V2\Controllers\Controller; +use FireflyIII\Api\V2\Request\Generic\DateRequest; +use FireflyIII\Enums\AccountTypeEnum; +use FireflyIII\Enums\TransactionTypeEnum; +use FireflyIII\Exceptions\FireflyException; +use FireflyIII\Helpers\Collector\GroupCollectorInterface; +use FireflyIII\Repositories\Account\AccountRepositoryInterface; +use FireflyIII\Repositories\Currency\CurrencyRepositoryInterface; +use FireflyIII\Support\Http\Api\CleansChartData; +use FireflyIII\Support\Http\Api\ValidatesUserGroupTrait; +use Illuminate\Http\JsonResponse; + +/** + * Class BudgetController + */ +class CategoryController extends Controller +{ + use CleansChartData; + use ValidatesUserGroupTrait; + + private AccountRepositoryInterface $accountRepos; + private CurrencyRepositoryInterface $currencyRepos; + + public function __construct() + { + parent::__construct(); + $this->middleware( + function ($request, $next) { + $this->accountRepos = app(AccountRepositoryInterface::class); + $this->currencyRepos = app(CurrencyRepositoryInterface::class); + $userGroup = $this->validateUserGroup($request); + $this->accountRepos->setUserGroup($userGroup); + $this->currencyRepos->setUserGroup($userGroup); + + return $next($request); + } + ); + } + + /** + * TODO may be worth to move to a handler but the data is simple enough. + * TODO see autoComplete/account controller + * + * @throws FireflyException + * + * @SuppressWarnings("PHPMD.UnusedFormalParameter") + */ + public function dashboard(DateRequest $request): JsonResponse + { + /** @var Carbon $start */ + $start = $this->parameters->get('start'); + + /** @var Carbon $end */ + $end = $this->parameters->get('end'); + $accounts = $this->accountRepos->getAccountsByType([AccountTypeEnum::DEBT->value, AccountTypeEnum::LOAN->value, AccountTypeEnum::MORTGAGE->value, AccountTypeEnum::ASSET->value, AccountTypeEnum::DEFAULT->value]); + $currencies = []; + $return = []; + + // get journals for entire period: + /** @var GroupCollectorInterface $collector */ + $collector = app(GroupCollectorInterface::class); + $collector->setRange($start, $end)->withAccountInformation(); + $collector->setXorAccounts($accounts)->withCategoryInformation(); + $collector->setTypes([TransactionTypeEnum::WITHDRAWAL->value, TransactionTypeEnum::RECONCILIATION->value]); + $journals = $collector->getExtractedJournals(); + + /** @var array $journal */ + foreach ($journals as $journal) { + $currencyId = (int) $journal['currency_id']; + $currency = $currencies[$currencyId] ?? $this->currencyRepos->find($currencyId); + $currencies[$currencyId] = $currency; + $categoryName = null === $journal['category_name'] ? (string) trans('firefly.no_category') : $journal['category_name']; + $amount = app('steam')->positive($journal['amount']); + $key = sprintf('%s-%s', $categoryName, $currency->code); + // create arrays + $return[$key] ??= [ + 'label' => $categoryName, + 'currency_id' => (string) $currency->id, + 'currency_code' => $currency->code, + 'currency_name' => $currency->name, + 'currency_symbol' => $currency->symbol, + 'currency_decimal_places' => $currency->decimal_places, + 'period' => null, + 'start' => $start->toAtomString(), + 'end' => $end->toAtomString(), + 'amount' => '0', + ]; + + // add monies + $return[$key]['amount'] = bcadd($return[$key]['amount'], $amount); + } + $return = array_values($return); + + // order by amount + usort($return, static function (array $a, array $b) { + return (float) $a['amount'] < (float) $b['amount'] ? 1 : -1; + }); + + return response()->json($this->clean($return)); + } +} diff --git a/app/Api/V1/Requests/Chart/ChartRequest.php b/app/Api/V1/Requests/Chart/ChartRequest.php new file mode 100644 index 0000000000..a52c2e8245 --- /dev/null +++ b/app/Api/V1/Requests/Chart/ChartRequest.php @@ -0,0 +1,91 @@ +. + */ + +declare(strict_types=1); + +namespace FireflyIII\Api\V1\Requests\Chart; + +use FireflyIII\Enums\UserRoleEnum; +use FireflyIII\Support\Http\Api\ValidatesUserGroupTrait; +use FireflyIII\Support\Request\ChecksLogin; +use FireflyIII\Support\Request\ConvertsDataTypes; +use Illuminate\Foundation\Http\FormRequest; +use Illuminate\Support\Facades\Log; +use Illuminate\Validation\Validator; + +/** + * Class ChartRequest + */ +class ChartRequest extends FormRequest +{ + use ChecksLogin; + use ConvertsDataTypes; + use ValidatesUserGroupTrait; + + protected array $acceptedRoles = [UserRoleEnum::READ_ONLY]; + + public function getParameters(): array + { + return [ + 'start' => $this->convertDateTime('start')?->startOfDay(), + 'end' => $this->convertDateTime('end')?->endOfDay(), + 'preselected' => $this->convertString('preselected', 'empty'), + 'period' => $this->convertString('period', '1M'), + 'accounts' => $this->arrayFromValue($this->get('accounts')), + ]; + } + + /** + * The rules that the incoming request must be matched against. + */ + public function rules(): array + { + return [ + 'start' => 'required|date|after:1900-01-01|before:2099-12-31|before_or_equal:end', + 'end' => 'required|date|after:1900-01-01|before:2099-12-31|after_or_equal:start', + 'preselected' => sprintf('nullable|in:%s', implode(',', config('firefly.preselected_accounts'))), + 'period' => sprintf('nullable|in:%s', implode(',', config('firefly.valid_view_ranges'))), + 'accounts.*' => 'exists:accounts,id', + ]; + + } + + public function withValidator(Validator $validator): void + { + $validator->after( + static function (Validator $validator): void { + // validate transaction query data. + $data = $validator->getData(); + if (!array_key_exists('accounts', $data)) { + // $validator->errors()->add('accounts', trans('validation.filled', ['attribute' => 'accounts'])); + return; + } + if (!is_array($data['accounts'])) { + $validator->errors()->add('accounts', trans('validation.filled', ['attribute' => 'accounts'])); + } + } + ); + if ($validator->fails()) { + Log::channel('audit')->error(sprintf('Validation errors in %s', __CLASS__), $validator->errors()->toArray()); + } + } +} diff --git a/app/Api/V1/Requests/Generic/DateRequest.php b/app/Api/V1/Requests/Generic/DateRequest.php new file mode 100644 index 0000000000..c3840ac76b --- /dev/null +++ b/app/Api/V1/Requests/Generic/DateRequest.php @@ -0,0 +1,62 @@ +. + */ + +declare(strict_types=1); + +namespace FireflyIII\Api\V1\Requests\Generic; + +use FireflyIII\Support\Request\ChecksLogin; +use FireflyIII\Support\Request\ConvertsDataTypes; +use Illuminate\Foundation\Http\FormRequest; + +/** + * Request class for end points that require date parameters. + * + * Class DateRequest + */ +class DateRequest extends FormRequest +{ + use ChecksLogin; + use ConvertsDataTypes; + + /** + * Get all data from the request. + */ + public function getAll(): array + { + return [ + 'start' => $this->getCarbonDate('start')->startOfDay(), + 'end' => $this->getCarbonDate('end')->endOfDay(), + ]; + } + + /** + * The rules that the incoming request must be matched against. + */ + public function rules(): array + { + return [ + 'start' => 'required|date|after:1900-01-01|before:2099-12-31', + 'end' => 'required|date|after_or_equal:start|before:2099-12-31|after:1900-01-01', + ]; + } +} diff --git a/app/Api/V1/Requests/Generic/SingleDateRequest.php b/app/Api/V1/Requests/Generic/SingleDateRequest.php new file mode 100644 index 0000000000..2e4842433b --- /dev/null +++ b/app/Api/V1/Requests/Generic/SingleDateRequest.php @@ -0,0 +1,59 @@ +. + */ + +declare(strict_types=1); + +namespace FireflyIII\Api\V1\Requests\Generic; + +use Carbon\Carbon; +use FireflyIII\Support\Request\ChecksLogin; +use FireflyIII\Support\Request\ConvertsDataTypes; +use Illuminate\Foundation\Http\FormRequest; + +/** + * Request class for end points that require a date parameter. + * + * Class SingleDateRequest + */ +class SingleDateRequest extends FormRequest +{ + use ChecksLogin; + use ConvertsDataTypes; + + /** + * Get all data from the request. + */ + public function getDate(): Carbon + { + return $this->getCarbonDate('date'); + } + + /** + * The rules that the incoming request must be matched against. + */ + public function rules(): array + { + return [ + 'date' => 'required|date|after:1900-01-01|before:2099-12-31', + ]; + } +}