diff --git a/app/Api/V2/Controllers/Chart/BalanceController.php b/app/Api/V2/Controllers/Chart/BalanceController.php new file mode 100644 index 0000000000..4d21b0bb79 --- /dev/null +++ b/app/Api/V2/Controllers/Chart/BalanceController.php @@ -0,0 +1,218 @@ +. + */ + +namespace FireflyIII\Api\V2\Controllers\Chart; + +use Carbon\Carbon; +use FireflyIII\Api\V2\Controllers\Controller; +use FireflyIII\Api\V2\Request\Chart\BalanceChartRequest; +use FireflyIII\Helpers\Collector\GroupCollectorInterface; +use FireflyIII\Models\TransactionCurrency; +use FireflyIII\Models\TransactionType; +use FireflyIII\Repositories\Administration\Account\AccountRepositoryInterface; +use FireflyIII\Support\Http\Api\ConvertsExchangeRates; +use FireflyIII\Support\Http\Api\ExchangeRateConverter; +use Illuminate\Support\Collection; + +/** + * Class BalanceController + */ +class BalanceController extends Controller +{ + use ConvertsExchangeRates; + + private AccountRepositoryInterface $repository; + + /** + * + */ + public function __construct() + { + parent::__construct(); + $this->middleware( + function ($request, $next) { + $this->repository = app(AccountRepositoryInterface::class); + + return $next($request); + } + ); + } + + /** + * The code is practically a duplicate of ReportController::operations. + * + * Currency is up to the account/transactions in question, but conversion to the default + * currency is possible. + * + * + * + * @param BalanceChartRequest $request + * + * @return string + */ + public function balance(BalanceChartRequest $request): string + { + $params = $request->getAll(); + /** @var Carbon $start */ + $start = $params['start']; + /** @var Carbon $end */ + $end = $params['end']; + /** @var Collection $accounts */ + $accounts = $params['accounts']; + $preferredRange = $params['period']; + $convert = $params['convert']; + + // set some formats, based on input parameters. + $format = app('navigation')->preferredCarbonFormatByPeriod($preferredRange); + $titleFormat = app('navigation')->preferredCarbonLocalizedFormatByPeriod($preferredRange); + + // prepare for currency conversion and data collection: + $ids = $accounts->pluck('id')->toArray(); + /** @var TransactionCurrency $default */ + $default = app('amount')->getDefaultCurrency(); + $converter = new ExchangeRateConverter(); + $currencies = [(int)$default->id => $default,]; // currency cache + $data = []; + $chartData = []; + + // get journals for entire period: + /** @var GroupCollectorInterface $collector */ + $collector = app(GroupCollectorInterface::class); + $collector->setRange($start, $end)->withAccountInformation(); + $collector->setXorAccounts($accounts); + $collector->setTypes([TransactionType::WITHDRAWAL, TransactionType::DEPOSIT, TransactionType::RECONCILIATION, TransactionType::TRANSFER]); + $journals = $collector->getExtractedJournals(); + + // set array for default currency (even if unused later on) + $defaultCurrencyId = (int)$default->id; + $data[$defaultCurrencyId] = [ + 'currency_id' => $defaultCurrencyId, + 'currency_symbol' => $default->symbol, + 'currency_code' => $default->code, + 'currency_name' => $default->name, + 'currency_decimal_places' => (int)$default->decimal_places, + ]; + + + // loop. group by currency and by period. + /** @var array $journal */ + foreach ($journals as $journal) { + // format the date according to the period + $period = $journal['date']->format($format); + + // collect (and cache) currency information for this journal. + $currencyId = (int)$journal['currency_id']; + $currency = $currencies[$currencyId] ?? TransactionCurrency::find($currencyId); + $currencies[$currencyId] = $currency; // may just re-assign itself, don't mind. + + // set the array with monetary info, if it does not exist. + $data[$currencyId] = $data[$currencyId] ?? [ + 'currency_id' => $currencyId, + 'currency_symbol' => $currencySymbol, + 'currency_code' => $currencyCode, + 'currency_name' => $currencyName, + 'currency_decimal_places' => $currencyDecimalPlaces, + ]; + + // set the array with spent/earned in this $period, if it does not exist. + $data[$currencyId][$period] = $data[$currencyId][$period] ?? [ + 'period' => $period, + 'spent' => '0', + 'earned' => '0', + 'spent_converted' => '0', + 'earned_converted' => '0', + ]; + // is this amount in- our outgoing? + $key = 'spent'; + $amount = app('steam')->negative($journal['amount']); + $amountConverted = $amount; + // deposit = incoming + // transfer or reconcile or opening balance, and these accounts are the destination. + if ( + TransactionType::DEPOSIT === $journal['transaction_type_type'] + || + + ( + ( + TransactionType::TRANSFER === $journal['transaction_type_type'] + || TransactionType::RECONCILIATION === $journal['transaction_type_type'] + || TransactionType::OPENING_BALANCE === $journal['transaction_type_type'] + ) + && in_array($journal['destination_account_id'], $ids, true) + ) + ) { + $key = 'earned'; + $amount = app('steam')->positive($journal['amount']); + $amountConverted = $amount; + } + // if configured convert the amount, convert the amount to $default + if ($convert) { + $rate = $converter->getCurrencyRate($currency, $default, $journal['date']); + $amountConverted = bcmul($amount, $rate); + } + + $data[$currencyId][$period][$key] = bcadd($data[$currencyId][$period][$key], $amount); + $convertedKey = sprintf('%s_converted', $key); + $data[$currencyId][$period][$convertedKey] = bcadd($data[$currencyId][$period][$convertedKey], $amountConverted); + } + + // loop this data, make chart bars for each currency: + /** @var array $currency */ + foreach ($data as $currency) { + $income = [ + 'label' => 'earned', + 'currency_id' => $currency['currency_id'], + 'currency_symbol' => $currency['currency_symbol'], + 'currency_code' => $currency['currency_code'], + 'entries' => [], + 'converted_entries' => [], + ]; + $expense = [ + 'label' => 'spent', + 'currency_id' => $currency['currency_id'], + 'currency_symbol' => $currency['currency_symbol'], + 'currency_code' => $currency['currency_code'], + 'entries' => [], + 'converted_entries' => [], + + ]; + // loop all possible periods between $start and $end, and add them to the correct dataset. + $currentStart = clone $start; + while ($currentStart <= $end) { + $key = $currentStart->format($format); + $title = $currentStart->isoFormat($titleFormat); + $income['entries'][$title] = app('steam')->bcround(($currency[$key]['earned'] ?? '0'), $currency['currency_decimal_places']); + $expense['entries'][$title] = app('steam')->bcround(($currency[$key]['spent'] ?? '0'), $currency['currency_decimal_places']); + $currentStart = app('navigation')->addPeriod($currentStart, $preferredRange, 0); + } + + $chartData[] = $income; + $chartData[] = $expense; + } + var_dump($chartData); + exit; + + //$data = $this->generator->multiSet($chartData); + + return response()->json($data); + } + +} diff --git a/app/Api/V2/Request/Chart/BalanceChartRequest.php b/app/Api/V2/Request/Chart/BalanceChartRequest.php new file mode 100644 index 0000000000..ee2db10b65 --- /dev/null +++ b/app/Api/V2/Request/Chart/BalanceChartRequest.php @@ -0,0 +1,87 @@ +. + */ + +namespace FireflyIII\Api\V2\Request\Chart; + +use FireflyIII\Support\Request\ChecksLogin; +use FireflyIII\Support\Request\ConvertsDataTypes; +use Illuminate\Foundation\Http\FormRequest; +use Illuminate\Validation\Validator; + +class BalanceChartRequest extends FormRequest +{ + use ConvertsDataTypes; + use ChecksLogin; + + /** + * Get all data from the request. + * + * @return array + */ + public function getAll(): array + { + return [ + 'start' => $this->getCarbonDate('start'), + 'end' => $this->getCarbonDate('end'), + 'accounts' => $this->getAccountList(), + 'convert' => $this->boolean('convert'), + 'period' => $this->string('period'), + ]; + } + + /** + * The rules that the incoming request must be matched against. + * + * @return array + */ + 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', + 'convert' => 'nullable|between:0,1|numeric', + 'accounts.*' => 'required|exists:accounts,id', + 'period' => sprintf('required|in:%s', join(',', config('firefly.valid_view_ranges'))), + ]; + } + + /** + * @param Validator $validator + * + * @return void + */ + public function withValidator(Validator $validator): void + { + $validator->after( + function (Validator $validator) { + // 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'])); + } + } + ); + } +} diff --git a/app/Repositories/Administration/Account/AccountRepository.php b/app/Repositories/Administration/Account/AccountRepository.php index c70e665eb0..cc0c58281d 100644 --- a/app/Repositories/Administration/Account/AccountRepository.php +++ b/app/Repositories/Administration/Account/AccountRepository.php @@ -39,82 +39,6 @@ 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.*']); - } - - /** - * @inheritDoc - */ - public function getAccountsByType(array $types, ?array $sort = []): Collection - { - $res = array_intersect([AccountType::ASSET, AccountType::MORTGAGE, AccountType::LOAN, AccountType::DEBT], $types); - $query = $this->userGroup->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.*']); - } - - /** - * @param array $accountIds - * - * @return Collection - */ - public function getAccountsById(array $accountIds): Collection - { - $query = $this->userGroup->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 Account $account * @@ -141,7 +65,7 @@ class AccountRepository implements AccountRepositoryInterface * Return meta value for account. Null if not found. * * @param Account $account - * @param string $field + * @param string $field * * @return null|string */ @@ -161,4 +85,93 @@ class AccountRepository implements AccountRepositoryInterface return null; } + + /** + * @param int $accountId + * + * @return Account|null + */ + public function find(int $accountId): ?Account + { + $account = $this->user->accounts()->find($accountId); + if (null === $account) { + $account = $this->userGroup->accounts()->find($accountId); + } + return $account; + } + + /** + * @param array $accountIds + * + * @return Collection + */ + public function getAccountsById(array $accountIds): Collection + { + $query = $this->userGroup->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.*']); + } + + /** + * @inheritDoc + */ + public function getAccountsByType(array $types, ?array $sort = []): Collection + { + $res = array_intersect([AccountType::ASSET, AccountType::MORTGAGE, AccountType::LOAN, AccountType::DEBT], $types); + $query = $this->userGroup->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.*']); + } + + /** + * @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 index 483bdb43e0..a209c05355 100644 --- a/app/Repositories/Administration/Account/AccountRepositoryInterface.php +++ b/app/Repositories/Administration/Account/AccountRepositoryInterface.php @@ -35,28 +35,11 @@ use Illuminate\Support\Collection; interface AccountRepositoryInterface { /** - * @param string $query - * @param array $types - * @param int $limit + * @param int $accountId * - * @return Collection + * @return Account|null */ - public function searchAccount(string $query, array $types, int $limit): Collection; - - /** - * @param array $types - * @param array|null $sort - * - * @return Collection - */ - public function getAccountsByType(array $types, ?array $sort = []): Collection; - - /** - * @param array $accountIds - * - * @return Collection - */ - public function getAccountsById(array $accountIds): Collection; + public function find(int $accountId): ?Account; /** * @param Account $account @@ -65,13 +48,38 @@ interface AccountRepositoryInterface */ public function getAccountCurrency(Account $account): ?TransactionCurrency; + /** + * @param array $accountIds + * + * @return Collection + */ + public function getAccountsById(array $accountIds): Collection; + + /** + * @param array $types + * @param array|null $sort + * + * @return Collection + */ + public function getAccountsByType(array $types, ?array $sort = []): Collection; + /** * Return meta value for account. Null if not found. * * @param Account $account - * @param string $field + * @param string $field * * @return null|string */ public function getMetaValue(Account $account, string $field): ?string; + + /** + * @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/Navigation.php b/app/Support/Navigation.php index 63c9125954..27b1ec2fde 100644 --- a/app/Support/Navigation.php +++ b/app/Support/Navigation.php @@ -89,10 +89,10 @@ class Navigation if (!array_key_exists($repeatFreq, $functionMap)) { Log::error(sprintf( - 'The periodicity %s is unknown. Choose one of available periodicity: %s', - $repeatFreq, - join(', ', array_keys($functionMap)) - )); + 'The periodicity %s is unknown. Choose one of available periodicity: %s', + $repeatFreq, + join(', ', array_keys($functionMap)) + )); return $theDate; } @@ -515,6 +515,25 @@ class Navigation return $date->format('Y-m-d'); } + /** + * Same as preferredCarbonFormat but by string + * + * @param string $period + * + * @return string + */ + public function preferredCarbonFormatByPeriod(string $period): string + { + return match ($period) { + default => 'Y-m-d', + //'1D' => 'Y-m-d', + '1W' => '\WW,Y', + '1M' => 'Y-m', + '3M', '6M' => '\QQ,Y', + '1Y' => 'Y', + }; + } + /** * If the date difference between start and end is less than a month, method returns trans(config.month_and_day). * If the difference is less than a year, method returns "config.month". If the date difference is larger, method @@ -540,6 +559,25 @@ class Navigation return $format; } + /** + * Same as preferredCarbonLocalizedFormat but based on the period. + * + * @param string $period + * + * @return string + */ + public function preferredCarbonLocalizedFormatByPeriod(string $period): string + { + $locale = app('steam')->getLocale(); + return match ($period) { + default => (string)trans('config.month_and_day_js', [], $locale), + '1W' => (string)trans('config.week_in_year_js', [], $locale), + '1M' => (string)trans('config.month_js', [], $locale), + '3M', '6M' => (string)trans('config.half_year_js', [], $locale), + '1Y' => (string)trans('config.year_js', [], $locale), + }; + } + /** * If the date difference between start and end is less than a month, method returns "endOfDay". If the difference * is less than a year, method returns "endOfMonth". If the date difference is larger, method returns "endOfYear". diff --git a/app/Support/Request/ConvertsDataTypes.php b/app/Support/Request/ConvertsDataTypes.php index c8bccbeed7..88f1979819 100644 --- a/app/Support/Request/ConvertsDataTypes.php +++ b/app/Support/Request/ConvertsDataTypes.php @@ -26,6 +26,8 @@ namespace FireflyIII\Support\Request; use Carbon\Carbon; use Carbon\Exceptions\InvalidDateException; use Carbon\Exceptions\InvalidFormatException; +use FireflyIII\Repositories\Administration\Account\AccountRepositoryInterface; +use Illuminate\Support\Collection; use Illuminate\Support\Facades\Log; /** @@ -153,6 +155,38 @@ trait ConvertsDataTypes return trim($string); } + /** + * TODO duplicate, see SelectTransactionsRequest + * + * Validate list of accounts. This one is for V2 endpoints, so it searches for groups, not users. + * + * @return Collection + */ + public function getAccountList(): Collection + { + // fixed + /** @var AccountRepositoryInterface $repository */ + $repository = app(AccountRepositoryInterface::class); + + // set administration ID + // group ID + $administrationId = auth()->user()->getAdministrationId(); + $repository->setAdministrationId($administrationId); + + $set = $this->get('accounts'); + $collection = new Collection(); + if (is_array($set)) { + foreach ($set as $accountId) { + $account = $repository->find((int)$accountId); + if (null !== $account) { + $collection->push($account); + } + } + } + + return $collection; + } + /** * Return string value with newlines. * diff --git a/routes/api.php b/routes/api.php index 03b14f7dc6..aa234b584d 100644 --- a/routes/api.php +++ b/routes/api.php @@ -87,7 +87,8 @@ Route::group( 'as' => 'api.v1.chart.', ], static function () { - Route::get('account/dashboard', ['uses' => 'AccountController@dashboard', 'as' => 'dashboard']); + Route::get('account/dashboard', ['uses' => 'AccountController@dashboard', 'as' => 'account.dashboard']); + Route::get('balance/balance', ['uses' => 'BalanceController@balance', 'as' => 'balance.balance']); } );