From 46412bdc6665cd921ad481779083d00162214b2a Mon Sep 17 00:00:00 2001 From: James Cole Date: Sun, 6 Aug 2023 07:03:39 +0200 Subject: [PATCH 1/2] Fix #7810 --- .../Support/CreditRecalculateService.php | 62 ++++++++++++++++--- 1 file changed, 53 insertions(+), 9 deletions(-) diff --git a/app/Services/Internal/Support/CreditRecalculateService.php b/app/Services/Internal/Support/CreditRecalculateService.php index 27865b92eb..ff04cd8426 100644 --- a/app/Services/Internal/Support/CreditRecalculateService.php +++ b/app/Services/Internal/Support/CreditRecalculateService.php @@ -182,11 +182,14 @@ class CreditRecalculateService */ private function processWorkAccount(Account $account): void { + app('log')->debug(sprintf('Now processing account #%d ("%s")', $account->id, $account->name)); // get opening balance (if present) $this->repository->setUser($account->user); $startOfDebt = $this->repository->getOpeningBalanceAmount($account) ?? '0'; $leftOfDebt = app('steam')->positive($startOfDebt); + app('log')->debug(sprintf('Start of debt is "%s", so initial left of debt is "%s"', $startOfDebt, $leftOfDebt)); + /** @var AccountMetaFactory $factory */ $factory = app(AccountMetaFactory::class); @@ -196,13 +199,22 @@ class CreditRecalculateService // get direction of liability: $direction = (string)$this->repository->getMetaValue($account, 'liability_direction'); + app('log')->debug(sprintf('Debt direction is "%s"', $direction)); + // now loop all transactions (except opening balance and credit thing) - $transactions = $account->transactions()->get(); + $transactions = $account->transactions() + ->leftJoin('transaction_journals', 'transaction_journals.id', '=', 'transactions.transaction_journal_id') + ->orderBy('transaction_journals.date', 'ASC') + ->get(['transactions.*']); + $total = $transactions->count(); + app('log')->debug(sprintf('Found %d transaction(s) to process.', $total)); /** @var Transaction $transaction */ - foreach ($transactions as $transaction) { + foreach ($transactions as $index => $transaction) { + app('log')->debug(sprintf('[%d/%d] Processing transaction.', $index + 1, $total)); $leftOfDebt = $this->processTransaction($account, $direction, $transaction, $leftOfDebt); } $factory->crud($account, 'current_debt', $leftOfDebt); + app('log')->debug(sprintf('Done processing account #%d ("%s")', $account->id, $account->name)); } /** @@ -215,6 +227,7 @@ class CreditRecalculateService */ private function processTransaction(Account $account, string $direction, Transaction $transaction, string $leftOfDebt): string { + app('log')->debug(sprintf('Left of debt is: %s', $leftOfDebt)); $journal = $transaction->transactionJournal; $foreignCurrency = $transaction->foreignCurrency; $accountCurrency = $this->repository->getAccountCurrency($account); @@ -226,16 +239,20 @@ class CreditRecalculateService $sourceTransaction = $journal->transactions()->where('amount', '<', '0')->first(); if ('' === $direction) { + app('log')->warning('Direction is empty, so do nothing.'); return $leftOfDebt; } if (TransactionType::LIABILITY_CREDIT === $type || TransactionType::OPENING_BALANCE === $type) { + app('log')->warning(sprintf('Transaction type is "%s", so do nothing.', $type)); return $leftOfDebt; } // amount to use depends on the currency: $usedAmount = $transaction->amount; + app('log')->debug(sprintf('Amount of transaction is %s', $usedAmount)); if (null !== $foreignCurrency && $foreignCurrency->id === $accountCurrency->id) { $usedAmount = $transaction->foreign_amount; + app('log')->debug(sprintf('Overruled by foreign amount. Amount of transaction is now %s', $usedAmount)); } // Case 1 @@ -248,7 +265,10 @@ class CreditRecalculateService && 1 === bccomp($usedAmount, '0') && 'credit' === $direction ) { - return bcadd($leftOfDebt, app('steam')->positive($usedAmount)); + $usedAmount = app('steam')->positive($usedAmount); + $result = bcadd($leftOfDebt, $usedAmount); + app('log')->debug(sprintf('Case 1 (withdrawal into credit liability): %s + %s = %s', $leftOfDebt, $usedAmount, $result)); + return $result; } // Case 2 @@ -261,7 +281,10 @@ class CreditRecalculateService && -1 === bccomp($usedAmount, '0') && 'credit' === $direction ) { - return bcsub($leftOfDebt, app('steam')->positive($usedAmount)); + $usedAmount = app('steam')->positive($usedAmount); + $result = bcsub($leftOfDebt, $usedAmount); + app('log')->debug(sprintf('Case 2 (withdrawal away from liability): %s - %s = %s', $leftOfDebt, $usedAmount, $result)); + return $result; } // case 3 @@ -274,7 +297,10 @@ class CreditRecalculateService && -1 === bccomp($usedAmount, '0') && 'credit' === $direction ) { - return bcsub($leftOfDebt, app('steam')->positive($usedAmount)); + $usedAmount = app('steam')->positive($usedAmount); + $result = bcsub($leftOfDebt, $usedAmount); + app('log')->debug(sprintf('Case 3 (deposit away from liability): %s - %s = %s', $leftOfDebt, $usedAmount, $result)); + return $result; } // case 4 @@ -287,14 +313,32 @@ class CreditRecalculateService && 1 === bccomp($usedAmount, '0') && 'credit' === $direction ) { - $newLeftOfDebt = bcadd($leftOfDebt, app('steam')->positive($usedAmount)); - return $newLeftOfDebt; + $usedAmount = app('steam')->positive($usedAmount); + $result = bcadd($leftOfDebt, $usedAmount); + app('log')->debug(sprintf('Case 4 (deposit into credit liability): %s + %s = %s', $leftOfDebt, $usedAmount, $result)); + return $result; + } + // case 5: transfer into loan (from other loan). + // if it's a credit ("I am owed") this increases the amount due, + // because the person has to pay more back. + if ( + $type === TransactionType::TRANSFER + && (int)$account->id === (int)$destTransaction->account_id + && 1 === bccomp($usedAmount, '0') + && 'credit' === $direction + ) { + $usedAmount = app('steam')->positive($usedAmount); + $result = bcadd($leftOfDebt, $usedAmount); + app('log')->debug(sprintf('Case 5 (transfer into credit liability): %s + %s = %s', $leftOfDebt, $usedAmount, $result)); + return $result; } // in any other case, remove amount from left of debt. if (in_array($type, [TransactionType::WITHDRAWAL, TransactionType::DEPOSIT, TransactionType::TRANSFER], true)) { - $newLeftOfDebt = bcadd($leftOfDebt, bcmul($usedAmount, '-1')); - return $newLeftOfDebt; + $usedAmount = app('steam')->negative($usedAmount); + $result = bcadd($leftOfDebt, $usedAmount); + app('log')->debug(sprintf('Case X (all other cases): %s + %s = %s', $leftOfDebt, $usedAmount, $result)); + return $result; } Log::warning(sprintf('[6] Catch-all, should not happen. Left of debt = %s', $leftOfDebt)); From ffd8aef35f23301c8d8b01710e4c50b2472e48a4 Mon Sep 17 00:00:00 2001 From: James Cole Date: Sun, 6 Aug 2023 07:04:09 +0200 Subject: [PATCH 2/2] Expand API with v2 summary endpoint. --- .../Controllers/Chart/BalanceController.php | 2 + .../Controllers/Summary/BasicController.php | 550 ++++++++++++++++++ app/Http/Controllers/Auth/LoginController.php | 2 +- app/Models/UserGroup.php | 20 + app/Providers/BillServiceProvider.php | 18 + app/Providers/BudgetServiceProvider.php | 19 + .../Administration/Bill/BillRepository.php | 221 +++++++ .../Bill/BillRepositoryInterface.php | 85 +++ .../Budget/AvailableBudgetRepository.php | 75 +++ .../AvailableBudgetRepositoryInterface.php | 41 ++ .../Budget/OperationsRepository.php | 1 + .../Http/Api/ConvertsExchangeRates.php | 17 +- .../Http/Api/ExchangeRateConverter.php | 15 + routes/api.php | 14 + 14 files changed, 1073 insertions(+), 7 deletions(-) create mode 100644 app/Api/V2/Controllers/Summary/BasicController.php create mode 100644 app/Repositories/Administration/Bill/BillRepository.php create mode 100644 app/Repositories/Administration/Bill/BillRepositoryInterface.php create mode 100644 app/Repositories/Administration/Budget/AvailableBudgetRepository.php create mode 100644 app/Repositories/Administration/Budget/AvailableBudgetRepositoryInterface.php diff --git a/app/Api/V2/Controllers/Chart/BalanceController.php b/app/Api/V2/Controllers/Chart/BalanceController.php index 31c74f51ce..e70793fd52 100644 --- a/app/Api/V2/Controllers/Chart/BalanceController.php +++ b/app/Api/V2/Controllers/Chart/BalanceController.php @@ -1,4 +1,6 @@ . + */ + +declare(strict_types=1); + +namespace FireflyIII\Api\V2\Controllers\Summary; + +use Carbon\Carbon; +use Exception; +use FireflyIII\Api\V2\Controllers\Controller; +use FireflyIII\Api\V2\Request\Generic\DateRequest; +use FireflyIII\Exceptions\FireflyException; +use FireflyIII\Helpers\Collector\GroupCollectorInterface; +use FireflyIII\Helpers\Report\NetWorthInterface; +use FireflyIII\Models\Account; +use FireflyIII\Models\AccountType; +use FireflyIII\Models\TransactionCurrency; +use FireflyIII\Models\TransactionType; +use FireflyIII\Repositories\Administration\Account\AccountRepositoryInterface; +use FireflyIII\Repositories\Administration\Bill\BillRepositoryInterface; +use FireflyIII\Repositories\Administration\Budget\AvailableBudgetRepositoryInterface; +use FireflyIII\Repositories\Administration\Budget\BudgetRepositoryInterface; +use FireflyIII\Repositories\Administration\Budget\OperationsRepositoryInterface; +use FireflyIII\Repositories\Currency\CurrencyRepositoryInterface; +use FireflyIII\Support\Http\Api\ExchangeRateConverter; +use FireflyIII\User; +use Illuminate\Http\JsonResponse; + +/** + * Class BasicController + */ +class BasicController extends Controller +{ + private AvailableBudgetRepositoryInterface $abRepository; + private AccountRepositoryInterface $accountRepository; + private BillRepositoryInterface $billRepository; + private BudgetRepositoryInterface $budgetRepository; + private CurrencyRepositoryInterface $currencyRepos; + private OperationsRepositoryInterface $opsRepository; + + /** + * BasicController constructor. + * + + */ + public function __construct() + { + parent::__construct(); + $this->middleware( + function ($request, $next) { + /** @var User $user */ + $user = auth()->user(); + $this->abRepository = app(AvailableBudgetRepositoryInterface::class); + $this->accountRepository = app(AccountRepositoryInterface::class); + $this->billRepository = app(BillRepositoryInterface::class); + $this->budgetRepository = app(BudgetRepositoryInterface::class); + $this->currencyRepos = app(CurrencyRepositoryInterface::class); + $this->opsRepository = app(OperationsRepositoryInterface::class); + + $this->abRepository->setAdministrationId($user->user_group_id); + $this->accountRepository->setAdministrationId($user->user_group_id); + $this->billRepository->setAdministrationId($user->user_group_id); + $this->budgetRepository->setAdministrationId($user->user_group_id); + $this->currencyRepos->setUser($user); + $this->opsRepository->setAdministrationId($user->user_group_id); + + return $next($request); + } + ); + } + + /** + * This endpoint is documented at: + * https://api-docs.firefly-iii.org/?urls.primaryName=2.0.0%20(v2)#/summary/getBasicSummary + * + * @param DateRequest $request + * + * @return JsonResponse + * @throws Exception + */ + public function basic(DateRequest $request): JsonResponse + { + // parameters for boxes: + $dates = $request->getAll(); + $start = $dates['start']; + $end = $dates['end']; + + // balance information: + $balanceData = []; + $billData = []; + $spentData = []; + $netWorthData = []; + $balanceData = $this->getBalanceInformation($start, $end); + $billData = $this->getBillInformation($start, $end); + $spentData = $this->getLeftToSpendInfo($start, $end); + // $netWorthData = $this->getNetWorthInfo($start, $end); + $total = array_merge($balanceData, $billData, $spentData, $netWorthData); + + // give new keys + // $return = []; + // foreach ($total as $entry) { + // if (null === $code || ($code === $entry['currency_code'])) { + // $return[$entry['key']] = $entry; + // } + // } + + return response()->json($total); + } + + /** + * @param Carbon $start + * @param Carbon $end + * + * @return array + * @throws FireflyException + */ + private function getBalanceInformation(Carbon $start, Carbon $end): array + { + // prep some arrays: + $incomes = []; + $expenses = []; + $sums = []; + $return = []; + $currencies = []; + $converter = new ExchangeRateConverter(); + $default = app('amount')->getDefaultCurrency(); + /** @var User $user */ + $user = auth()->user(); + + // collect income of user using the new group collector. + /** @var GroupCollectorInterface $collector */ + $collector = app(GroupCollectorInterface::class); + $collector + ->setRange($start, $end) + ->setUserGroup($user->userGroup) + // set page to retrieve + ->setPage($this->parameters->get('page')) + // set types of transactions to return. + ->setTypes([TransactionType::DEPOSIT]); + + $set = $collector->getExtractedJournals(); + /** @var array $transactionJournal */ + foreach ($set as $transactionJournal) { + // transaction info: + $currencyId = (int)$transactionJournal['currency_id']; + $amount = bcmul($transactionJournal['amount'], '-1'); + $currency = $currencies[$currencyId] ?? TransactionCurrency::find($currencyId); + $currencies[$currencyId] = $currency; + $nativeAmount = $converter->convert($currency, $default, $transactionJournal['date'], $amount); + if ((int)$transactionJournal['foreign_currency_id'] === (int)$default->id) { + // use foreign amount instead + $nativeAmount = $transactionJournal['foreign_amount']; + } + // prep the arrays + $incomes[$currencyId] = $incomes[$currencyId] ?? '0'; + $incomes['native'] = $incomes['native'] ?? '0'; + $sums[$currencyId] = $sums[$currencyId] ?? '0'; + $sums['native'] = $sums['native'] ?? '0'; + + // add values: + $incomes[$currencyId] = bcadd($incomes[$currencyId], $amount); + $sums[$currencyId] = bcadd($sums[$currencyId], $amount); + $incomes['native'] = bcadd($incomes['native'], $nativeAmount); + $sums['native'] = bcadd($sums['native'], $nativeAmount); + } + + // collect expenses of user using the new group collector. + /** @var GroupCollectorInterface $collector */ + $collector = app(GroupCollectorInterface::class); + $collector + ->setRange($start, $end) + ->setUserGroup($user->userGroup) + // set page to retrieve + ->setPage($this->parameters->get('page')) + // set types of transactions to return. + ->setTypes([TransactionType::WITHDRAWAL]); + $set = $collector->getExtractedJournals(); + + /** @var array $transactionJournal */ + foreach ($set as $transactionJournal) { + // transaction info + $currencyId = (int)$transactionJournal['currency_id']; + $amount = $transactionJournal['amount']; + $currency = $currencies[$currencyId] ?? $this->currencyRepos->find($currencyId); + $currencies[$currencyId] = $currency; + $nativeAmount = $converter->convert($currency, $default, $transactionJournal['date'], $amount); + if ((int)$transactionJournal['foreign_currency_id'] === (int)$default->id) { + // use foreign amount instead + $nativeAmount = $transactionJournal['foreign_amount']; + } + + // prep arrays + $expenses[$currencyId] = $expenses[$currencyId] ?? '0'; + $expenses['native'] = $expenses['native'] ?? '0'; + $sums[$currencyId] = $sums[$currencyId] ?? '0'; + $sums['native'] = $sums['native'] ?? '0'; + + // add values + $expenses[$currencyId] = bcadd($expenses[$currencyId], $amount); + $sums[$currencyId] = bcadd($sums[$currencyId], $amount); + $expenses['native'] = bcadd($expenses['native'], $nativeAmount); + $sums['native'] = bcadd($sums['native'], $nativeAmount); + } + + // create special array for native currency: + $return[] = [ + 'key' => 'balance-in-native', + 'value' => $sums['native'], + 'currency_id' => $default->id, + 'currency_code' => $default->code, + 'currency_symbol' => $default->symbol, + 'currency_decimal_places' => $default->decimal_places, + ]; + $return[] = [ + 'key' => 'spent-in-native', + 'value' => $expenses['native'], + 'currency_id' => $default->id, + 'currency_code' => $default->code, + 'currency_symbol' => $default->symbol, + 'currency_decimal_places' => $default->decimal_places, + ]; + $return[] = [ + 'key' => 'earned-in-native', + 'value' => $incomes['native'], + 'currency_id' => $default->id, + 'currency_code' => $default->code, + 'currency_symbol' => $default->symbol, + 'currency_decimal_places' => $default->decimal_places, + ]; + + // format amounts: + $keys = array_keys($sums); + foreach ($keys as $currencyId) { + if ('native' === $currencyId) { + // skip native entries. + continue; + } + $currency = $currencies[$currencyId] ?? $this->currencyRepos->find($currencyId); + $currencies[$currencyId] = $currency; + // create objects for big array. + $return[] = [ + 'key' => sprintf('balance-in-%s', $currency->code), + 'value' => $sums[$currencyId] ?? '0', + 'currency_id' => $currency->id, + 'currency_code' => $currency->code, + 'currency_symbol' => $currency->symbol, + 'currency_decimal_places' => $currency->decimal_places, + ]; + $return[] = [ + 'key' => sprintf('spent-in-%s', $currency->code), + 'value' => $expenses[$currencyId] ?? '0', + 'currency_id' => $currency->id, + 'currency_code' => $currency->code, + 'currency_symbol' => $currency->symbol, + 'currency_decimal_places' => $currency->decimal_places, + ]; + $return[] = [ + 'key' => sprintf('earned-in-%s', $currency->code), + 'value' => $incomes[$currencyId] ?? '0', + 'currency_id' => $currency->id, + 'currency_code' => $currency->code, + 'currency_symbol' => $currency->symbol, + 'currency_decimal_places' => $currency->decimal_places, + ]; + } + return $return; + } + + /** + * @param Carbon $start + * @param Carbon $end + * + * @return array + */ + private function getBillInformation(Carbon $start, Carbon $end): array + { + /* + * Since both this method and the chart use the exact same data, we can suffice + * with calling the one method in the bill repository that will get this amount. + */ + $paidAmount = $this->billRepository->sumPaidInRange($start, $end); + $unpaidAmount = $this->billRepository->sumUnpaidInRange($start, $end); + + $return = []; + /** + * @var array $info + */ + foreach ($paidAmount as $info) { + $amount = bcmul($info['sum'], '-1'); + $nativeAmount = bcmul($info['native_sum'], '-1'); + $return[] = [ + 'key' => sprintf('bills-paid-in-%s', $info['currency_code']), + 'value' => $amount, + 'currency_id' => $info['currency_id'], + 'currency_code' => $info['currency_code'], + 'currency_symbol' => $info['currency_symbol'], + 'currency_decimal_places' => $info['currency_decimal_places'], + ]; + $return[] = [ + 'key' => 'bills-paid-in-native', + 'value' => $nativeAmount, + 'currency_id' => $info['native_id'], + 'currency_code' => $info['native_code'], + 'currency_symbol' => $info['native_symbol'], + 'currency_decimal_places' => $info['native_decimal_places'], + ]; + } + + /** + * @var array $info + */ + foreach ($unpaidAmount as $info) { + $amount = bcmul($info['sum'], '-1'); + $nativeAmount = bcmul($info['native_sum'], '-1'); + $return[] = [ + 'key' => sprintf('bills-unpaid-in-%s', $info['currency_code']), + 'value' => $amount, + 'currency_id' => $info['currency_id'], + 'currency_code' => $info['currency_code'], + 'currency_symbol' => $info['currency_symbol'], + 'currency_decimal_places' => $info['currency_decimal_places'], + ]; + $return[] = [ + 'key' => 'bills-unpaid-in-native', + 'value' => $nativeAmount, + 'currency_id' => $info['native_id'], + 'currency_code' => $info['native_code'], + 'currency_symbol' => $info['native_symbol'], + 'currency_decimal_places' => $info['native_decimal_places'], + ]; + } + + return $return; + } + + /** + * @param Carbon $start + * @param Carbon $end + * + * @return array + * @throws Exception + */ + private function getLeftToSpendInfo(Carbon $start, Carbon $end): array + { + $return = []; + $today = today(config('app.timezone')); + $available = $this->abRepository->getAvailableBudgetWithCurrency($start, $end); + $budgets = $this->budgetRepository->getActiveBudgets(); + $spent = $this->opsRepository->listExpenses($start, $end, null, $budgets); + $default = app('amount')->getDefaultCurrency(); + $currencies = []; + $converter = new ExchangeRateConverter(); + + // native info: + $nativeLeft = [ + 'key' => 'left-to-spend-in-native', + 'monetary_value' => '0', + 'currency_id' => (int)$default->id, + 'currency_code' => $default->code, + 'currency_symbol' => $default->symbol, + 'currency_decimal_places' => (int)$default->decimal_places, + ]; + $nativePerDay = [ + 'key' => 'left-per-day-to-spend-in-native', + 'monetary_value' => '0', + 'currency_id' => (int)$default->id, + 'currency_code' => $default->code, + 'currency_symbol' => $default->symbol, + 'currency_decimal_places' => (int)$default->decimal_places, + ]; + + /** + * @var int $currencyId + * @var array $row + */ + foreach ($spent as $currencyId => $row) { + $spent = '0'; + $spentNative = '0'; + // get the sum from the array of transactions (double loop but who cares) + /** @var array $budget */ + foreach ($row['budgets'] as $budget) { + /** @var array $journal */ + foreach ($budget['transaction_journals'] as $journal) { + $journalCurrencyId = $journal['currency_id']; + + $currency = $currencies[$journalCurrencyId] ?? $this->currencyRepos->find($journalCurrencyId); + $currencies[$currencyId] = $currency; + $amount = bcmul($journal['amount'], '-1'); + $amountNative = $converter->convert($default, $currency, $start, $amount); + if ((int)$journal['foreign_currency_id'] === (int)$default->id) { + $amountNative = $journal['foreign_amount']; + } + $spent = bcadd($spent, $amount); + $spentNative = bcadd($spentNative, $amountNative); + } + } + + // either an amount was budgeted or 0 is available. + $currency = $currencies[$currencyId] ?? $this->currencyRepos->find($currencyId); + $currencies[$currencyId] = $currency; + $amount = $available[$currencyId] ?? '0'; + $amountNative = $converter->convert($default, $currency, $start, $amount); + $left = bcadd($amount, $spent); + $leftNative = bcadd($amountNative, $spentNative); + + // how much left per day? + $days = $today->diffInDays($end) + 1; + $perDay = '0'; + $perDayNative = '0'; + if (0 !== $days && bccomp($left, '0') > -1) { + $perDay = bcdiv($left, (string)$days); + } + if (0 !== $days && bccomp($leftNative, '0') > -1) { + $perDayNative = bcdiv($leftNative, (string)$days); + } + + // left + $return[] = [ + 'key' => sprintf('left-to-spend-in-%s', $row['currency_code']), + 'monetary_value' => $left, + 'currency_id' => $row['currency_id'], + 'currency_code' => $row['currency_code'], + 'currency_symbol' => $row['currency_symbol'], + 'currency_decimal_places' => $row['currency_decimal_places'], + ]; + // left (native) + $nativeLeft['monetary_value'] = $leftNative; + + // left per day: + $return[] = [ + 'key' => sprintf('left-per-day-to-spend-in-%s', $row['currency_code']), + 'monetary_value' => $perDay, + 'currency_id' => $row['currency_id'], + 'currency_code' => $row['currency_code'], + 'currency_symbol' => $row['currency_symbol'], + 'currency_decimal_places' => $row['currency_decimal_places'], + ]; + + // left per day (native) + $nativePerDay['monetary_value'] = $perDayNative; + } + $return[] = $nativeLeft; + $return[] = $nativePerDay; + + return $return; + } + + /** + * @param Carbon $start + * @param Carbon $end + * + * @return array + */ + private function getNetWorthInfo(Carbon $start, Carbon $end): array + { + /** @var User $user */ + $user = auth()->user(); + $date = today(config('app.timezone'))->startOfDay(); + // start and end in the future? use $end + if ($this->notInDateRange($date, $start, $end)) { + /** @var Carbon $date */ + $date = session('end', today(config('app.timezone'))->endOfMonth()); + } + + /** @var NetWorthInterface $netWorthHelper */ + $netWorthHelper = app(NetWorthInterface::class); + $netWorthHelper->setUser($user); + $allAccounts = $this->accountRepository->getActiveAccountsByType( + [AccountType::ASSET, AccountType::DEFAULT, AccountType::LOAN, AccountType::MORTGAGE, AccountType::DEBT] + ); + + // filter list on preference of being included. + $filtered = $allAccounts->filter( + function (Account $account) { + $includeNetWorth = $this->accountRepository->getMetaValue($account, 'include_net_worth'); + + return null === $includeNetWorth || '1' === $includeNetWorth; + } + ); + + $netWorthSet = $netWorthHelper->getNetWorthByCurrency($filtered, $date); + $return = []; + foreach ($netWorthSet as $data) { + /** @var TransactionCurrency $currency */ + $currency = $data['currency']; + $amount = $data['balance']; + if (0 === bccomp($amount, '0')) { + continue; + } + // return stuff + $return[] = [ + 'key' => sprintf('net-worth-in-%s', $currency->code), + 'title' => trans('firefly.box_net_worth_in_currency', ['currency' => $currency->symbol]), + 'monetary_value' => $amount, + 'currency_id' => $currency->id, + 'currency_code' => $currency->code, + 'currency_symbol' => $currency->symbol, + 'currency_decimal_places' => $currency->decimal_places, + 'value_parsed' => app('amount')->formatAnything($currency, $data['balance'], false), + 'local_icon' => 'line-chart', + 'sub_title' => '', + ]; + } + + return $return; + } + + /** + * Check if date is outside session range. + * + * @param Carbon $date + * + * @param Carbon $start + * @param Carbon $end + * + * @return bool + */ + protected function notInDateRange(Carbon $date, Carbon $start, Carbon $end): bool // Validate a preference + { + $result = false; + if ($start->greaterThanOrEqualTo($date) && $end->greaterThanOrEqualTo($date)) { + $result = true; + } + // start and end in the past? use $end + if ($start->lessThanOrEqualTo($date) && $end->lessThanOrEqualTo($date)) { + $result = true; + } + + return $result; + } +} diff --git a/app/Http/Controllers/Auth/LoginController.php b/app/Http/Controllers/Auth/LoginController.php index dbaf4cfbd6..7a1892799d 100644 --- a/app/Http/Controllers/Auth/LoginController.php +++ b/app/Http/Controllers/Auth/LoginController.php @@ -90,7 +90,7 @@ class LoginController extends Controller Log::info('User is trying to login.'); $this->validateLogin($request); - Log::debug('Login data is valid.'); + Log::debug('Login data is present.'); /** Copied directly from AuthenticatesUsers, but with logging added: */ // If the class is using the ThrottlesLogins trait, we can automatically throttle diff --git a/app/Models/UserGroup.php b/app/Models/UserGroup.php index d3a4e584ac..af28168786 100644 --- a/app/Models/UserGroup.php +++ b/app/Models/UserGroup.php @@ -67,6 +67,26 @@ class UserGroup extends Model return $this->hasMany(Account::class); } + /** + * Link to bills. + * + * @return HasMany + */ + public function availableBudgets(): HasMany + { + return $this->hasMany(AvailableBudget::class); + } + + /** + * Link to bills. + * + * @return HasMany + */ + public function bills(): HasMany + { + return $this->hasMany(Bill::class); + } + /** * Link to budgets. * diff --git a/app/Providers/BillServiceProvider.php b/app/Providers/BillServiceProvider.php index 3ac8ed9f3b..57ac73960f 100644 --- a/app/Providers/BillServiceProvider.php +++ b/app/Providers/BillServiceProvider.php @@ -25,6 +25,8 @@ namespace FireflyIII\Providers; use FireflyIII\Repositories\Bill\BillRepository; use FireflyIII\Repositories\Bill\BillRepositoryInterface; +use FireflyIII\Repositories\Administration\Bill\BillRepository as AdminBillRepository; +use FireflyIII\Repositories\Administration\Bill\BillRepositoryInterface as AdminBillRepositoryInterface; use Illuminate\Foundation\Application; use Illuminate\Support\ServiceProvider; @@ -59,5 +61,21 @@ class BillServiceProvider extends ServiceProvider return $repository; } ); + + // administration variant + $this->app->bind( + AdminBillRepositoryInterface::class, + function (Application $app) { + /** @var AdminBillRepositoryInterface $repository */ + $repository = app(AdminBillRepository::class); + + // reference to auth is not understood by phpstan. + if ($app->auth->check()) { // @phpstan-ignore-line + $repository->setUser(auth()->user()); + } + + return $repository; + } + ); } } diff --git a/app/Providers/BudgetServiceProvider.php b/app/Providers/BudgetServiceProvider.php index 61c67711fa..de223bdd14 100644 --- a/app/Providers/BudgetServiceProvider.php +++ b/app/Providers/BudgetServiceProvider.php @@ -25,6 +25,8 @@ namespace FireflyIII\Providers; use FireflyIII\Repositories\Budget\AvailableBudgetRepository; use FireflyIII\Repositories\Budget\AvailableBudgetRepositoryInterface; +use FireflyIII\Repositories\Administration\Budget\AvailableBudgetRepository as AdminAbRepository; +use FireflyIII\Repositories\Administration\Budget\AvailableBudgetRepositoryInterface as AdminAbRepositoryInterface; use FireflyIII\Repositories\Budget\BudgetLimitRepository; use FireflyIII\Repositories\Budget\BudgetLimitRepositoryInterface; use FireflyIII\Repositories\Budget\BudgetRepository; @@ -78,6 +80,7 @@ class BudgetServiceProvider extends ServiceProvider $repository = app(AdminBudgetRepository::class); if ($app->auth->check()) { // @phpstan-ignore-line $repository->setUser(auth()->user()); + $repository->setAdministrationId(auth()->user()->user_group_id); } return $repository; @@ -98,6 +101,21 @@ class BudgetServiceProvider extends ServiceProvider } ); + // available budget repos + $this->app->bind( + AdminAbRepositoryInterface::class, + static function (Application $app) { + /** @var AdminAbRepositoryInterface $repository */ + $repository = app(AdminAbRepository::class); + if ($app->auth->check()) { // @phpstan-ignore-line + $repository->setUser(auth()->user()); + $repository->setAdministrationId(auth()->user()->user_group_id); + } + + return $repository; + } + ); + // budget limit repository. $this->app->bind( BudgetLimitRepositoryInterface::class, @@ -146,6 +164,7 @@ class BudgetServiceProvider extends ServiceProvider $repository = app(AdminOperationsRepository::class); if ($app->auth->check()) { // @phpstan-ignore-line $repository->setUser(auth()->user()); + $repository->setAdministrationId(auth()->user()->user_group_id); } return $repository; diff --git a/app/Repositories/Administration/Bill/BillRepository.php b/app/Repositories/Administration/Bill/BillRepository.php new file mode 100644 index 0000000000..c0d8442555 --- /dev/null +++ b/app/Repositories/Administration/Bill/BillRepository.php @@ -0,0 +1,221 @@ +. + */ + +namespace FireflyIII\Repositories\Administration\Bill; + +use Carbon\Carbon; +use FireflyIII\Models\Bill; +use FireflyIII\Models\Transaction; +use FireflyIII\Models\TransactionJournal; +use FireflyIII\Support\CacheProperties; +use FireflyIII\Support\Http\Api\ExchangeRateConverter; +use FireflyIII\Support\Repositories\Administration\AdministrationTrait; +use Illuminate\Support\Collection; + +/** + * Class BillRepository + */ +class BillRepository implements BillRepositoryInterface +{ + use AdministrationTrait; + + /** + * @inheritDoc + */ + public function sumPaidInRange(Carbon $start, Carbon $end): array + { + $bills = $this->getActiveBills(); + $default = app('amount')->getDefaultCurrency(); + $return = []; + $converter = new ExchangeRateConverter(); + /** @var Bill $bill */ + foreach ($bills as $bill) { + /** @var Collection $set */ + $set = $bill->transactionJournals()->after($start)->before($end)->get(['transaction_journals.*']); + $currency = $bill->transactionCurrency; + $currencyId = (int)$bill->transaction_currency_id; + + $return[$currencyId] = $return[$currencyId] ?? [ + 'currency_id' => (string)$currency->id, + 'currency_name' => $currency->name, + 'currency_symbol' => $currency->symbol, + 'currency_code' => $currency->code, + 'currency_decimal_places' => (int)$currency->decimal_places, + 'native_id' => (string)$default->id, + 'native_name' => $default->name, + 'native_symbol' => $default->symbol, + 'native_code' => $default->code, + 'native_decimal_places' => (int)$default->decimal_places, + 'sum' => '0', + 'native_sum' => '0', + ]; + + /** @var TransactionJournal $transactionJournal */ + foreach ($set as $transactionJournal) { + /** @var Transaction|null $sourceTransaction */ + $sourceTransaction = $transactionJournal->transactions()->where('amount', '<', 0)->first(); + if (null !== $sourceTransaction) { + $amount = (string)$sourceTransaction->amount; + if ((int)$sourceTransaction->foreign_currency_id === (int)$currency->id) { + // use foreign amount instead! + $amount = (string)$sourceTransaction->foreign_amount; + } + // convert to native currency + $nativeAmount = $amount; + if ($currencyId !== (int)$default->id) { + // get rate and convert. + $nativeAmount = $converter->convert($currency, $default, $transactionJournal->date, $amount); + } + if ((int)$sourceTransaction->foreign_currency_id === (int)$default->id) { + // ignore conversion, use foreign amount + $nativeAmount = (string)$sourceTransaction->foreign_amount; + } + $return[$currencyId]['sum'] = bcadd($return[$currencyId]['sum'], $amount); + $return[$currencyId]['native_sum'] = bcadd($return[$currencyId]['native_sum'], $nativeAmount); + } + } + } + return $return; + } + + /** + * @return Collection + */ + public function getActiveBills(): Collection + { + return $this->userGroup->bills() + ->where('active', true) + ->orderBy('bills.name', 'ASC') + ->get(['bills.*']); + } + + + /** + * @inheritDoc + */ + public function sumUnpaidInRange(Carbon $start, Carbon $end): array + { + $bills = $this->getActiveBills(); + $return = []; + $default = app('amount')->getDefaultCurrency(); + $converter = new ExchangeRateConverter(); + /** @var Bill $bill */ + foreach ($bills as $bill) { + $dates = $this->getPayDatesInRange($bill, $start, $end); + $count = $bill->transactionJournals()->after($start)->before($end)->count(); + $total = $dates->count() - $count; + + if ($total > 0) { + $currency = $bill->transactionCurrency; + $currencyId = (int)$bill->transaction_currency_id; + $average = bcdiv(bcadd($bill->amount_max, $bill->amount_min), '2'); + $nativeAverage = $converter->convert($currency, $default, $start, $average); + $return[$currencyId] = $return[$currencyId] ?? [ + 'currency_id' => (string)$currency->id, + 'currency_name' => $currency->name, + 'currency_symbol' => $currency->symbol, + 'currency_code' => $currency->code, + 'currency_decimal_places' => (int)$currency->decimal_places, + 'native_id' => (string)$default->id, + 'native_name' => $default->name, + 'native_symbol' => $default->symbol, + 'native_code' => $default->code, + 'native_decimal_places' => (int)$default->decimal_places, + 'sum' => '0', + 'native_sum' => '0', + ]; + $return[$currencyId]['sum'] = bcadd($return[$currencyId]['sum'], bcmul($average, (string)$total)); + $return[$currencyId]['native_sum'] = bcadd($return[$currencyId]['native_sum'], bcmul($nativeAverage, (string)$total)); + } + } + + return $return; + } + + /** + * Between start and end, tells you on which date(s) the bill is expected to hit. + * TODO duplicate of function in other billrepositoryinterface + * + * @param Bill $bill + * @param Carbon $start + * @param Carbon $end + * + * @return Collection + */ + public function getPayDatesInRange(Bill $bill, Carbon $start, Carbon $end): Collection + { + $set = new Collection(); + $currentStart = clone $start; + //Log::debug(sprintf('Now at bill "%s" (%s)', $bill->name, $bill->repeat_freq)); + //Log::debug(sprintf('First currentstart is %s', $currentStart->format('Y-m-d'))); + + while ($currentStart <= $end) { + //Log::debug(sprintf('Currentstart is now %s.', $currentStart->format('Y-m-d'))); + $nextExpectedMatch = $this->nextDateMatch($bill, $currentStart); + //Log::debug(sprintf('Next Date match after %s is %s', $currentStart->format('Y-m-d'), $nextExpectedMatch->format('Y-m-d'))); + if ($nextExpectedMatch > $end) {// If nextExpectedMatch is after end, we continue + break; + } + $set->push(clone $nextExpectedMatch); + //Log::debug(sprintf('Now %d dates in set.', $set->count())); + $nextExpectedMatch->addDay(); + + //Log::debug(sprintf('Currentstart (%s) has become %s.', $currentStart->format('Y-m-d'), $nextExpectedMatch->format('Y-m-d'))); + + $currentStart = clone $nextExpectedMatch; + } + + return $set; + } + + /** + * Given a bill and a date, this method will tell you at which moment this bill expects its next + * transaction. Whether it is there already, is not relevant. + * + * TODO duplicate of other repos + * + * @param Bill $bill + * @param Carbon $date + * + * @return Carbon + */ + public function nextDateMatch(Bill $bill, Carbon $date): Carbon + { + $cache = new CacheProperties(); + $cache->addProperty($bill->id); + $cache->addProperty('nextDateMatch'); + $cache->addProperty($date); + if ($cache->has()) { + return $cache->get(); + } + // find the most recent date for this bill NOT in the future. Cache this date: + $start = clone $bill->date; + + while ($start < $date) { + $start = app('navigation')->addPeriod($start, $bill->repeat_freq, $bill->skip); + } + $cache->store($start); + + return $start; + } +} diff --git a/app/Repositories/Administration/Bill/BillRepositoryInterface.php b/app/Repositories/Administration/Bill/BillRepositoryInterface.php new file mode 100644 index 0000000000..80ff3b15fa --- /dev/null +++ b/app/Repositories/Administration/Bill/BillRepositoryInterface.php @@ -0,0 +1,85 @@ +. + */ + +namespace FireflyIII\Repositories\Administration\Bill; + +use Carbon\Carbon; +use FireflyIII\Models\Bill; +use Illuminate\Support\Collection; + +/** + * Interface BillRepositoryInterface + */ +interface BillRepositoryInterface +{ + /** + * @return Collection + */ + public function getActiveBills(): Collection; + + /** + * Between start and end, tells you on which date(s) the bill is expected to hit. + * + * TODO duplicate of method in other billrepositoryinterface + * + * @param Bill $bill + * @param Carbon $start + * @param Carbon $end + * + * @return Collection + */ + public function getPayDatesInRange(Bill $bill, Carbon $start, Carbon $end): Collection; + + /** + * Given a bill and a date, this method will tell you at which moment this bill expects its next + * transaction. Whether it is there already, is not relevant. + * + * TODO duplicate of method in other bill repos + * + * @param Bill $bill + * @param Carbon $date + * + * @return Carbon + */ + public function nextDateMatch(Bill $bill, Carbon $date): Carbon; + + /** + * Collect multi-currency of sum of bills already paid. + * + * @param Carbon $start + * @param Carbon $end + * + * @return array + */ + public function sumPaidInRange(Carbon $start, Carbon $end): array; + + /** + * Collect multi-currency of sum of bills yet to pay. + * + * @param Carbon $start + * @param Carbon $end + * + * @return array + */ + public function sumUnpaidInRange(Carbon $start, Carbon $end): array; +} diff --git a/app/Repositories/Administration/Budget/AvailableBudgetRepository.php b/app/Repositories/Administration/Budget/AvailableBudgetRepository.php new file mode 100644 index 0000000000..b78672a86e --- /dev/null +++ b/app/Repositories/Administration/Budget/AvailableBudgetRepository.php @@ -0,0 +1,75 @@ +. + */ + +namespace FireflyIII\Repositories\Administration\Budget; + +use Carbon\Carbon; +use FireflyIII\Models\AvailableBudget; +use FireflyIII\Support\Http\Api\ExchangeRateConverter; +use FireflyIII\Support\Repositories\Administration\AdministrationTrait; + +/** + * Class AvailableBudgetRepository + */ +class AvailableBudgetRepository implements AvailableBudgetRepositoryInterface +{ + use AdministrationTrait; + + /** + * @param Carbon $start + * @param Carbon $end + * + * @return array + */ + public function getAvailableBudgetWithCurrency(Carbon $start, Carbon $end): array + { + $return = []; + $converter = new ExchangeRateConverter(); + $default = app('amount')->getDefaultCurrency(); + $availableBudgets = $this->userGroup->availableBudgets() + ->where('start_date', $start->format('Y-m-d')) + ->where('end_date', $end->format('Y-m-d'))->get(); + /** @var AvailableBudget $availableBudget */ + foreach ($availableBudgets as $availableBudget) { + $currencyId = (int)$availableBudget->transaction_currency_id; + $return[$currencyId] = $return[$currencyId] ?? [ + 'currency_id' => $currencyId, + 'currency_code' => $availableBudget->transactionCurrency->code, + 'currency_symbol' => $availableBudget->transactionCurrency->symbol, + 'currency_name' => $availableBudget->transactionCurrency->name, + 'currency_decimal_places' => (int)$availableBudget->transactionCurrency->decimal_places, + 'native_id' => $default->id, + 'native_code' => $default->code, + 'native_symbol' => $default->symbol, + 'native_name' => $default->name, + 'native_decimal_places' => (int)$default->decimal_places, + 'amount' => '0', + 'native_amount' => '0', + ]; + $nativeAmount = $converter->convert($availableBudget->transactionCurrency, $default, $availableBudget->start_date, $availableBudget->amount); + $return[$currencyId]['amount'] = bcadd($return[$currencyId]['amount'], $availableBudget->amount); + $return[$currencyId]['native_amount'] = bcadd($return[$currencyId]['native_amount'], $nativeAmount); + } + return $return; + } +} diff --git a/app/Repositories/Administration/Budget/AvailableBudgetRepositoryInterface.php b/app/Repositories/Administration/Budget/AvailableBudgetRepositoryInterface.php new file mode 100644 index 0000000000..92ac772661 --- /dev/null +++ b/app/Repositories/Administration/Budget/AvailableBudgetRepositoryInterface.php @@ -0,0 +1,41 @@ +. + */ + +namespace FireflyIII\Repositories\Administration\Budget; + +use Carbon\Carbon; + +/** + * Interface AvailableBudgetRepositoryInterface + */ +interface AvailableBudgetRepositoryInterface +{ + /** + * @param Carbon $start + * @param Carbon $end + * + * @return array + */ + public function getAvailableBudgetWithCurrency(Carbon $start, Carbon $end): array; + +} diff --git a/app/Repositories/Administration/Budget/OperationsRepository.php b/app/Repositories/Administration/Budget/OperationsRepository.php index 45f1696256..ddf845dfa2 100644 --- a/app/Repositories/Administration/Budget/OperationsRepository.php +++ b/app/Repositories/Administration/Budget/OperationsRepository.php @@ -91,6 +91,7 @@ class OperationsRepository implements OperationsRepositoryInterface $journalId = (int)$journal['transaction_journal_id']; $final = [ 'amount' => app('steam')->negative($journal['amount']), + 'currency_id' => $journal['currency_id'], 'foreign_amount' => null, 'foreign_currency_id' => null, 'foreign_currency_code' => null, diff --git a/app/Support/Http/Api/ConvertsExchangeRates.php b/app/Support/Http/Api/ConvertsExchangeRates.php index 7c80220e46..42fe3119de 100644 --- a/app/Support/Http/Api/ConvertsExchangeRates.php +++ b/app/Support/Http/Api/ConvertsExchangeRates.php @@ -148,8 +148,13 @@ trait ConvertsExchangeRates $cache = new CacheProperties(); $cache->addProperty($key); if ($cache->has()) { - return $cache->get(); + $rate = $cache->get(); + if ('' === $rate) { + return null; + } + return $rate; } + app('log')->debug(sprintf('Going to get rate #%d->#%d (%s) from DB.', $from, $to, $date)); /** @var CurrencyExchangeRate $result */ $result = auth()->user() @@ -159,12 +164,12 @@ trait ConvertsExchangeRates ->where('date', '<=', $date) ->orderBy('date', 'DESC') ->first(); - if (null !== $result) { - $rate = (string)$result->rate; - $cache->store($rate); - return $rate; + $rate = (string)$result?->rate; + $cache->store($rate); + if ('' === $rate) { + return null; } - return null; + return $rate; } /** diff --git a/app/Support/Http/Api/ExchangeRateConverter.php b/app/Support/Http/Api/ExchangeRateConverter.php index 8e0268d802..3996e5c394 100644 --- a/app/Support/Http/Api/ExchangeRateConverter.php +++ b/app/Support/Http/Api/ExchangeRateConverter.php @@ -34,6 +34,21 @@ class ExchangeRateConverter { use ConvertsExchangeRates; + /** + * @param TransactionCurrency $from + * @param TransactionCurrency $to + * @param Carbon $date + * @param string $amount + * + * @return string + * @throws FireflyException + */ + public function convert(TransactionCurrency $from, TransactionCurrency $to, Carbon $date, string $amount): string + { + $rate = $this->getCurrencyRate($from, $to, $date); + return bcmul($amount, $rate); + } + /** * @param TransactionCurrency $from * @param TransactionCurrency $to diff --git a/routes/api.php b/routes/api.php index 89a725b9a6..65cc115820 100644 --- a/routes/api.php +++ b/routes/api.php @@ -34,6 +34,20 @@ declare(strict_types=1); // } //); +/** + * V2 API route for Summary boxes + */ +// BASIC +Route::group( + [ + 'namespace' => 'FireflyIII\Api\V2\Controllers\Summary', + 'prefix' => 'v2/summary', + 'as' => 'api.v2.summary.', + ], + static function () { + Route::get('basic', ['uses' => 'BasicController@basic', 'as' => 'basic']); + } +); /** * V2 API route for TransactionList API endpoints */