diff --git a/.ci/php-cs-fixer/composer.lock b/.ci/php-cs-fixer/composer.lock index 14cc84dcb3..0ad960ff0b 100644 --- a/.ci/php-cs-fixer/composer.lock +++ b/.ci/php-cs-fixer/composer.lock @@ -745,16 +745,16 @@ }, { "name": "symfony/console", - "version": "v6.3.0", + "version": "v6.3.2", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "8788808b07cf0bdd6e4b7fdd23d8ddb1470c83b7" + "reference": "aa5d64ad3f63f2e48964fc81ee45cb318a723898" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/8788808b07cf0bdd6e4b7fdd23d8ddb1470c83b7", - "reference": "8788808b07cf0bdd6e4b7fdd23d8ddb1470c83b7", + "url": "https://api.github.com/repos/symfony/console/zipball/aa5d64ad3f63f2e48964fc81ee45cb318a723898", + "reference": "aa5d64ad3f63f2e48964fc81ee45cb318a723898", "shasum": "" }, "require": { @@ -815,7 +815,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v6.3.0" + "source": "https://github.com/symfony/console/tree/v6.3.2" }, "funding": [ { @@ -831,7 +831,7 @@ "type": "tidelift" } ], - "time": "2023-05-29T12:49:39+00:00" + "time": "2023-07-19T20:17:28+00:00" }, { "name": "symfony/deprecation-contracts", @@ -902,16 +902,16 @@ }, { "name": "symfony/event-dispatcher", - "version": "v6.3.0", + "version": "v6.3.2", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher.git", - "reference": "3af8ac1a3f98f6dbc55e10ae59c9e44bfc38dfaa" + "reference": "adb01fe097a4ee930db9258a3cc906b5beb5cf2e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/3af8ac1a3f98f6dbc55e10ae59c9e44bfc38dfaa", - "reference": "3af8ac1a3f98f6dbc55e10ae59c9e44bfc38dfaa", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/adb01fe097a4ee930db9258a3cc906b5beb5cf2e", + "reference": "adb01fe097a4ee930db9258a3cc906b5beb5cf2e", "shasum": "" }, "require": { @@ -962,7 +962,7 @@ "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/event-dispatcher/tree/v6.3.0" + "source": "https://github.com/symfony/event-dispatcher/tree/v6.3.2" }, "funding": [ { @@ -978,7 +978,7 @@ "type": "tidelift" } ], - "time": "2023-04-21T14:41:17+00:00" + "time": "2023-07-06T06:56:43+00:00" }, { "name": "symfony/event-dispatcher-contracts", @@ -1121,16 +1121,16 @@ }, { "name": "symfony/finder", - "version": "v6.3.0", + "version": "v6.3.3", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "d9b01ba073c44cef617c7907ce2419f8d00d75e2" + "reference": "9915db259f67d21eefee768c1abcf1cc61b1fc9e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/d9b01ba073c44cef617c7907ce2419f8d00d75e2", - "reference": "d9b01ba073c44cef617c7907ce2419f8d00d75e2", + "url": "https://api.github.com/repos/symfony/finder/zipball/9915db259f67d21eefee768c1abcf1cc61b1fc9e", + "reference": "9915db259f67d21eefee768c1abcf1cc61b1fc9e", "shasum": "" }, "require": { @@ -1165,7 +1165,7 @@ "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v6.3.0" + "source": "https://github.com/symfony/finder/tree/v6.3.3" }, "funding": [ { @@ -1181,7 +1181,7 @@ "type": "tidelift" } ], - "time": "2023-04-02T01:25:41+00:00" + "time": "2023-07-31T08:31:44+00:00" }, { "name": "symfony/options-resolver", @@ -1744,16 +1744,16 @@ }, { "name": "symfony/process", - "version": "v6.3.0", + "version": "v6.3.2", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "8741e3ed7fe2e91ec099e02446fb86667a0f1628" + "reference": "c5ce962db0d9b6e80247ca5eb9af6472bd4d7b5d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/8741e3ed7fe2e91ec099e02446fb86667a0f1628", - "reference": "8741e3ed7fe2e91ec099e02446fb86667a0f1628", + "url": "https://api.github.com/repos/symfony/process/zipball/c5ce962db0d9b6e80247ca5eb9af6472bd4d7b5d", + "reference": "c5ce962db0d9b6e80247ca5eb9af6472bd4d7b5d", "shasum": "" }, "require": { @@ -1785,7 +1785,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v6.3.0" + "source": "https://github.com/symfony/process/tree/v6.3.2" }, "funding": [ { @@ -1801,7 +1801,7 @@ "type": "tidelift" } ], - "time": "2023-05-19T08:06:44+00:00" + "time": "2023-07-12T16:00:22+00:00" }, { "name": "symfony/service-contracts", @@ -1949,16 +1949,16 @@ }, { "name": "symfony/string", - "version": "v6.3.0", + "version": "v6.3.2", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "f2e190ee75ff0f5eced645ec0be5c66fac81f51f" + "reference": "53d1a83225002635bca3482fcbf963001313fb68" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/f2e190ee75ff0f5eced645ec0be5c66fac81f51f", - "reference": "f2e190ee75ff0f5eced645ec0be5c66fac81f51f", + "url": "https://api.github.com/repos/symfony/string/zipball/53d1a83225002635bca3482fcbf963001313fb68", + "reference": "53d1a83225002635bca3482fcbf963001313fb68", "shasum": "" }, "require": { @@ -2015,7 +2015,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v6.3.0" + "source": "https://github.com/symfony/string/tree/v6.3.2" }, "funding": [ { @@ -2031,7 +2031,7 @@ "type": "tidelift" } ], - "time": "2023-03-21T21:06:29+00:00" + "time": "2023-07-05T08:41:27+00:00" } ], "packages-dev": [], diff --git a/app/Api/V2/Controllers/Chart/AccountController.php b/app/Api/V2/Controllers/Chart/AccountController.php index 868a446ecd..4101cf5d72 100644 --- a/app/Api/V2/Controllers/Chart/AccountController.php +++ b/app/Api/V2/Controllers/Chart/AccountController.php @@ -27,6 +27,7 @@ namespace FireflyIII\Api\V2\Controllers\Chart; use Carbon\Carbon; use FireflyIII\Api\V2\Controllers\Controller; use FireflyIII\Api\V2\Request\Generic\DateRequest; +use FireflyIII\Exceptions\FireflyException; use FireflyIII\Models\Account; use FireflyIII\Models\AccountType; use FireflyIII\Models\TransactionCurrency; @@ -67,11 +68,15 @@ class AccountController extends Controller * * The native currency is the preferred currency on the page /currencies. * + * If a transaction has foreign currency = native currency, the foreign amount will be used, no conversion + * will take place. + * * @param DateRequest $request * * @return JsonResponse * @throws ContainerExceptionInterface * @throws NotFoundExceptionInterface + * @throws FireflyException */ public function dashboard(DateRequest $request): JsonResponse { diff --git a/app/Api/V2/Controllers/Chart/BalanceController.php b/app/Api/V2/Controllers/Chart/BalanceController.php index 0a6a4cd4c0..31c74f51ce 100644 --- a/app/Api/V2/Controllers/Chart/BalanceController.php +++ b/app/Api/V2/Controllers/Chart/BalanceController.php @@ -24,6 +24,7 @@ namespace FireflyIII\Api\V2\Controllers\Chart; use Carbon\Carbon; use FireflyIII\Api\V2\Controllers\Controller; use FireflyIII\Api\V2\Request\Chart\BalanceChartRequest; +use FireflyIII\Exceptions\FireflyException; use FireflyIII\Helpers\Collector\GroupCollectorInterface; use FireflyIII\Models\TransactionCurrency; use FireflyIII\Models\TransactionType; @@ -63,11 +64,13 @@ class BalanceController extends Controller * Currency is up to the account/transactions in question, but conversion to the default * currency is possible. * - * + * If the transaction being processed is already in native currency OR if the + * foreign amount is in the native currency, the amount will not be converted. * * @param BalanceChartRequest $request * * @return JsonResponse + * @throws FireflyException */ public function balance(BalanceChartRequest $request): JsonResponse { @@ -175,6 +178,12 @@ class BalanceController extends Controller $rate = $converter->getCurrencyRate($currency, $default, $journal['date']); $amountConverted = bcmul($amount, $rate); + // perhaps transaction already has the foreign amount in the native currency. + if ((int)$journal['foreign_currency_id'] === (int)$default->id) { + $amountConverted = $journal['foreign_amount'] ?? '0'; + $amountConverted = 'earned' === $key ? app('steam')->positive($amountConverted) : app('steam')->negative($amountConverted); + } + // add normal entry $data[$currencyId][$period][$key] = bcadd($data[$currencyId][$period][$key], $amount); @@ -188,7 +197,7 @@ class BalanceController extends Controller foreach ($data as $currency) { // income and expense array prepped: $income = [ - 'label' => sprintf('earned-%s', $currency['currency_code']), + 'label' => 'earned', 'currency_id' => $currency['currency_id'], 'currency_symbol' => $currency['currency_symbol'], 'currency_code' => $currency['currency_code'], @@ -204,7 +213,7 @@ class BalanceController extends Controller 'native_entries' => [], ]; $expense = [ - 'label' => sprintf('spent-%s', $currency['currency_code']), + 'label' => 'spent', 'currency_id' => $currency['currency_id'], 'currency_symbol' => $currency['currency_symbol'], 'currency_code' => $currency['currency_code'], @@ -230,8 +239,8 @@ class BalanceController extends Controller $expense['entries'][$label] = app('steam')->bcround(($currency[$key]['spent'] ?? '0'), $currency['currency_decimal_places']); // converted entries - $income['converted_entries'][$label] = app('steam')->bcround(($currency[$key]['converted_earned'] ?? '0'), $currency['native_decimal_places']); - $expense['converted_entries'][$label] = app('steam')->bcround(($currency[$key]['converted_spent'] ?? '0'), $currency['native_decimal_places']); + $income['native_entries'][$label] = app('steam')->bcround(($currency[$key]['native_earned'] ?? '0'), $currency['native_decimal_places']); + $expense['native_entries'][$label] = app('steam')->bcround(($currency[$key]['native_spent'] ?? '0'), $currency['native_decimal_places']); // next loop $currentStart = app('navigation')->addPeriod($currentStart, $preferredRange, 0); diff --git a/app/Api/V2/Controllers/Chart/BudgetController.php b/app/Api/V2/Controllers/Chart/BudgetController.php new file mode 100644 index 0000000000..12886cd4a5 --- /dev/null +++ b/app/Api/V2/Controllers/Chart/BudgetController.php @@ -0,0 +1,308 @@ +. + */ + +namespace FireflyIII\Api\V2\Controllers\Chart; + +use Carbon\Carbon; +use FireflyIII\Api\V2\Controllers\Controller; +use FireflyIII\Api\V2\Request\Generic\DateRequest; +use FireflyIII\Exceptions\FireflyException; +use FireflyIII\Models\Budget; +use FireflyIII\Models\BudgetLimit; +use FireflyIII\Models\TransactionCurrency; +use FireflyIII\Repositories\Administration\Budget\BudgetRepositoryInterface; +use FireflyIII\Repositories\Administration\Budget\OperationsRepositoryInterface; +use FireflyIII\Repositories\Budget\BudgetLimitRepositoryInterface; +use FireflyIII\Support\Http\Api\ExchangeRateConverter; +use FireflyIII\User; +use Illuminate\Http\JsonResponse; +use Illuminate\Support\Collection; + +/** + * Class BudgetController + */ +class BudgetController extends Controller +{ + 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); + $this->currency = app('amount')->getDefaultCurrency(); + return $next($request); + } + ); + } + + /** + * @param DateRequest $request + * + * @return JsonResponse + * @throws FireflyException + */ + public function dashboard(DateRequest $request): JsonResponse + { + // get user. + /** @var User $user */ + $user = auth()->user(); + // group ID + $administrationId = $user->getAdministrationId(); + $this->repository->setAdministrationId($administrationId); + $this->opsRepository->setAdministrationId($administrationId); + + $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($data); + } + + /** + * @param Budget $budget + * @param Carbon $start + * @param Carbon $end + * + * @return array + * @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' => $row['currency_id'], + 'currency_code' => $row['currency_code'], + 'currency_name' => $row['currency_name'], + 'currency_decimal_places' => $row['currency_decimal_places'], + 'native_id' => $row['native_id'], + 'native_code' => $row['native_code'], + 'native_name' => $row['native_name'], + 'native_decimal_places' => $row['native_decimal_places'], + 'period' => null, + 'start' => $row['start'], + 'end' => $row['end'], + 'entries' => [ + 'spent' => $row['spent'], + 'left' => $row['left'], + 'overspent' => $row['overspent'], + ], + 'native_entries' => [ + 'spent' => $row['native_spent'], + 'left' => $row['native_left'], + 'overspent' => $row['native_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. + * + * @param Budget $budget + * @param Carbon $start + * @param Carbon $end + * + * @return array + * @throws FireflyException + */ + private function noBudgetLimits(Budget $budget, Carbon $start, Carbon $end): array + { + $budgetId = (int)$budget->id; + $spent = $this->opsRepository->listExpenses($start, $end, null, new Collection([$budget])); + return $this->processExpenses($budgetId, $spent, $start, $end); + } + + /** + * Shared between the "noBudgetLimits" function and "processLimit". + * + * Will take a single set of expenses and return its info. + * + * @param int $budgetId + * @param array $array + * + * @return array + * @throws FireflyException + */ + private function processExpenses(int $budgetId, array $array, Carbon $start, Carbon $end): array + { + $converter = new ExchangeRateConverter(); + $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] = $this->currencies[$currencyId] ?? TransactionCurrency::find($currencyId); + $return[$currencyId] = $return[$currencyId] ?? [ + 'currency_id' => $currencyId, + 'currency_code' => $block['currency_code'], + 'currency_name' => $block['currency_name'], + 'currency_symbol' => $block['currency_symbol'], + 'currency_decimal_places' => (int)$block['currency_decimal_places'], + 'native_id' => (int)$this->currency->id, + 'native_code' => $this->currency->code, + 'native_name' => $this->currency->name, + 'native_symbol' => $this->currency->symbol, + 'native_decimal_places' => (int)$this->currency->decimal_places, + 'start' => $start->toAtomString(), + 'end' => $end->toAtomString(), + 'spent' => '0', + 'native_spent' => '0', + 'left' => '0', + 'native_left' => '0', + 'overspent' => '0', + 'native_overspent' => '0', + + ]; + $currentBudgetArray = $block['budgets'][$budgetId]; + //var_dump($return); + /** @var array $journal */ + foreach ($currentBudgetArray['transaction_journals'] as $journal) { + + // convert the amount to the native currency. + $rate = $converter->getCurrencyRate($this->currencies[$currencyId], $this->currency, $journal['date']); + $convertedAmount = bcmul($journal['amount'], $rate); + if ($journal['foreign_currency_id'] === $this->currency->id) { + $convertedAmount = $journal['foreign_amount']; + } + + $return[$currencyId]['spent'] = bcadd($return[$currencyId]['spent'], $journal['amount']); + $return[$currencyId]['native_spent'] = bcadd($return[$currencyId]['native_spent'], $convertedAmount); + } + } + 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. + * + * @param Budget $budget + * @param Collection $limits + * + * @return array + * @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; + } + + /** + * @param Budget $budget + * @param BudgetLimit $limit + * + * @return array + * @throws FireflyException + */ + private function processLimit(Budget $budget, BudgetLimit $limit): array + { + $budgetId = (int)$budget->id; + $end = clone $limit->end_date; + $end->endOfDay(); + $spent = $this->opsRepository->listExpenses($limit->start_date, $end, null, new Collection([$budget])); + $limitCurrencyId = (int)$limit->transaction_currency_id; + $limitCurrency = $limit->transactionCurrency; + $converter = new ExchangeRateConverter(); + $filtered = []; + $rate = $converter->getCurrencyRate($limitCurrency, $this->currency, $limit->start_date); + $convertedLimitAmount = bcmul($limit->amount, $rate); + + + /** @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($budgetId, $filtered, $limit->start_date, $end); + if (1 === count($result)) { + $compare = bccomp((string)$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']); + $result[$limitCurrencyId]['native_left'] = bcadd($convertedLimitAmount, $result[$limitCurrencyId]['native_spent']); + } + if ($compare <= 0) { + $result[$limitCurrencyId]['overspent'] = app('steam')->positive(bcadd($limit->amount, $result[$limitCurrencyId]['spent'])); + $result[$limitCurrencyId]['native_overspent'] = app('steam')->positive(bcadd($convertedLimitAmount, $result[$limitCurrencyId]['native_spent'])); + } + } + return $result; + } + + +} diff --git a/app/Api/V2/Request/Chart/BalanceChartRequest.php b/app/Api/V2/Request/Chart/BalanceChartRequest.php index 73ad17a4f9..48703b997d 100644 --- a/app/Api/V2/Request/Chart/BalanceChartRequest.php +++ b/app/Api/V2/Request/Chart/BalanceChartRequest.php @@ -1,4 +1,6 @@ $this->getCarbonDate('start'), - 'end' => $this->getCarbonDate('end'), + 'end' => $this->getCarbonDate('end')->endOfDay(), ]; } diff --git a/app/Factory/TransactionGroupFactory.php b/app/Factory/TransactionGroupFactory.php index ea505122fe..7f677f865f 100644 --- a/app/Factory/TransactionGroupFactory.php +++ b/app/Factory/TransactionGroupFactory.php @@ -81,6 +81,7 @@ class TransactionGroupFactory $group = new TransactionGroup(); $group->user()->associate($this->user); + $group->userGroup()->associate($this->user->userGroup); $group->title = $title; $group->save(); diff --git a/app/Factory/TransactionJournalFactory.php b/app/Factory/TransactionJournalFactory.php index 491536ea33..4a4dca29e8 100644 --- a/app/Factory/TransactionJournalFactory.php +++ b/app/Factory/TransactionJournalFactory.php @@ -225,6 +225,7 @@ class TransactionJournalFactory $journal = TransactionJournal::create( [ 'user_id' => $this->user->id, + 'user_group_id' => $this->user->user_group_id, 'transaction_type_id' => $type->id, 'bill_id' => $billId, 'transaction_currency_id' => $currency->id, diff --git a/app/Helpers/Collector/Extensions/CollectorProperties.php b/app/Helpers/Collector/Extensions/CollectorProperties.php index e89974c29e..bfdb78349b 100644 --- a/app/Helpers/Collector/Extensions/CollectorProperties.php +++ b/app/Helpers/Collector/Extensions/CollectorProperties.php @@ -24,6 +24,7 @@ declare(strict_types=1); namespace FireflyIII\Helpers\Collector\Extensions; +use FireflyIII\Models\UserGroup; use FireflyIII\User; use Illuminate\Database\Eloquent\Relations\HasMany; @@ -33,28 +34,29 @@ use Illuminate\Database\Eloquent\Relations\HasMany; trait CollectorProperties { public const TEST = 'Test'; - private bool $expandGroupSearch; - private array $fields; - private bool $hasAccountInfo; - private bool $hasBillInformation; - private bool $hasBudgetInformation; - private bool $hasCatInformation; - private bool $hasJoinedAttTables; - private bool $hasJoinedMetaTables; - private bool $hasJoinedTagTables; - private bool $hasNotesInformation; - private array $integerFields; - private ?int $limit; - private ?int $page; - private array $postFilters; + private bool $expandGroupSearch; + private array $fields; + private bool $hasAccountInfo; + private bool $hasBillInformation; + private bool $hasBudgetInformation; + private bool $hasCatInformation; + private bool $hasJoinedAttTables; + private bool $hasJoinedMetaTables; + private bool $hasJoinedTagTables; + private bool $hasNotesInformation; + private array $integerFields; + private ?int $limit; + private ?int $page; + private array $postFilters; private HasMany $query; - private array $stringFields; + private array $stringFields; /* * This array is used to collect ALL tags the user may search for (using 'setTags'). * This way the user can call 'setTags' multiple times and get a joined result. * */ private array $tags; - private int $total; + private int $total; private ?User $user; + private ?UserGroup $userGroup; } diff --git a/app/Helpers/Collector/GroupCollector.php b/app/Helpers/Collector/GroupCollector.php index 032f60453d..8ad641805b 100644 --- a/app/Helpers/Collector/GroupCollector.php +++ b/app/Helpers/Collector/GroupCollector.php @@ -38,6 +38,7 @@ use FireflyIII\Models\TransactionCurrency; use FireflyIII\Models\TransactionGroup; use FireflyIII\Models\TransactionJournal; use FireflyIII\Models\TransactionType; +use FireflyIII\Models\UserGroup; use FireflyIII\User; use Illuminate\Database\Eloquent\Builder as EloquentBuilder; use Illuminate\Database\Query\JoinClause; @@ -67,6 +68,7 @@ class GroupCollector implements GroupCollectorInterface $this->postFilters = []; $this->tags = []; $this->user = null; + $this->userGroup = null; $this->limit = null; $this->page = null; @@ -82,6 +84,7 @@ class GroupCollector implements GroupCollectorInterface $this->integerFields = [ 'transaction_group_id', 'user_id', + 'user_group_id', 'transaction_journal_id', 'transaction_type_id', 'order', @@ -102,6 +105,7 @@ class GroupCollector implements GroupCollectorInterface # group 'transaction_groups.id as transaction_group_id', 'transaction_groups.user_id as user_id', + 'transaction_groups.user_group_id as user_group_id', 'transaction_groups.created_at as created_at', 'transaction_groups.updated_at as updated_at', 'transaction_groups.title as transaction_group_title', @@ -300,7 +304,20 @@ class GroupCollector implements GroupCollectorInterface */ public function dumpQuery(): void { - echo $this->query->select($this->fields)->toSql(); + $query = $this->query->select($this->fields)->toSql(); + $params = $this->query->getBindings(); + foreach ($params as $param) { + $replace = sprintf('"%s"', $param); + if (is_int($param)) { + $replace = (string)$param; + } + $pos = strpos($query, '?'); + if ($pos !== false) { + $query = substr_replace($query, $replace, $pos, 1); + } + } + echo $query; + echo '
'; print_r($this->query->getBindings()); echo ''; @@ -548,6 +565,7 @@ class GroupCollector implements GroupCollectorInterface $groupArray = [ 'id' => (int)$augumentedJournal->transaction_group_id, 'user_id' => (int)$augumentedJournal->user_id, + 'user_group_id' => (int)$augumentedJournal->user_group_id, // Field transaction_group_title was added by the query. 'title' => $augumentedJournal->transaction_group_title, // @phpstan-ignore-line 'transaction_type' => $parsedGroup['transaction_type_type'], @@ -1087,6 +1105,64 @@ class GroupCollector implements GroupCollectorInterface ->orderBy('source.amount', 'DESC'); } + /** + * Set the user object and start the query. + * + * @param User $user + * + * @return GroupCollectorInterface + */ + public function setUserGroup(UserGroup $userGroup): GroupCollectorInterface + { + if (null === $this->userGroup) { + $this->userGroup = $userGroup; + $this->startQueryForGroup(); + } + + return $this; + } + + /** + * Build the query. + */ + private function startQueryForGroup(): void + { + //app('log')->debug('GroupCollector::startQuery'); + $this->query = $this->userGroup + ->transactionJournals() + ->leftJoin('transaction_groups', 'transaction_journals.transaction_group_id', 'transaction_groups.id') + + // join source transaction. + ->leftJoin( + 'transactions as source', + function (JoinClause $join) { + $join->on('source.transaction_journal_id', '=', 'transaction_journals.id') + ->where('source.amount', '<', 0); + } + ) + // join destination transaction + ->leftJoin( + 'transactions as destination', + function (JoinClause $join) { + $join->on('destination.transaction_journal_id', '=', 'transaction_journals.id') + ->where('destination.amount', '>', 0); + } + ) + // left join transaction type. + ->leftJoin('transaction_types', 'transaction_types.id', '=', 'transaction_journals.transaction_type_id') + ->leftJoin('transaction_currencies as currency', 'currency.id', '=', 'source.transaction_currency_id') + ->leftJoin('transaction_currencies as foreign_currency', 'foreign_currency.id', '=', 'source.foreign_currency_id') + ->whereNull('transaction_groups.deleted_at') + ->whereNull('transaction_journals.deleted_at') + ->whereNull('source.deleted_at') + ->whereNull('destination.deleted_at') + ->orderBy('transaction_journals.date', 'DESC') + ->orderBy('transaction_journals.order', 'ASC') + ->orderBy('transaction_journals.id', 'DESC') + ->orderBy('transaction_journals.description', 'DESC') + ->orderBy('source.amount', 'DESC'); + } + /** * Automatically include all stuff required to make API calls work. * diff --git a/app/Helpers/Collector/GroupCollectorInterface.php b/app/Helpers/Collector/GroupCollectorInterface.php index 02f0aba24c..a6bae8b31b 100644 --- a/app/Helpers/Collector/GroupCollectorInterface.php +++ b/app/Helpers/Collector/GroupCollectorInterface.php @@ -30,6 +30,7 @@ use FireflyIII\Models\Category; use FireflyIII\Models\Tag; use FireflyIII\Models\TransactionCurrency; use FireflyIII\Models\TransactionGroup; +use FireflyIII\Models\UserGroup; use FireflyIII\User; use Illuminate\Pagination\LengthAwarePaginator; use Illuminate\Support\Collection; @@ -1315,6 +1316,15 @@ interface GroupCollectorInterface */ public function setUser(User $user): GroupCollectorInterface; + /** + * Set the user group object and start the query. + * + * @param UserGroup $userGroup + * + * @return GroupCollectorInterface + */ + public function setUserGroup(UserGroup $userGroup): GroupCollectorInterface; + /** * Only when does not have these tags * diff --git a/app/Models/Budget.php b/app/Models/Budget.php index aa0716ff15..88e6a30138 100644 --- a/app/Models/Budget.php +++ b/app/Models/Budget.php @@ -99,7 +99,7 @@ class Budget extends Model 'encrypted' => 'boolean', ]; /** @var array Fields that can be filled */ - protected $fillable = ['user_id', 'name', 'active', 'order']; + protected $fillable = ['user_id', 'name', 'active', 'order', 'user_group_id']; /** @var array Hidden from view */ protected $hidden = ['encrypted']; diff --git a/app/Models/TransactionGroup.php b/app/Models/TransactionGroup.php index 0b6e998270..cdd3662600 100644 --- a/app/Models/TransactionGroup.php +++ b/app/Models/TransactionGroup.php @@ -130,4 +130,12 @@ class TransactionGroup extends Model { return $this->hasMany(TransactionJournal::class); } + + /** + * @return BelongsTo + */ + public function userGroup(): BelongsTo + { + return $this->belongsTo(UserGroup::class); + } } diff --git a/app/Models/TransactionJournal.php b/app/Models/TransactionJournal.php index a27efef9af..2d8e44ea97 100644 --- a/app/Models/TransactionJournal.php +++ b/app/Models/TransactionJournal.php @@ -148,6 +148,7 @@ class TransactionJournal extends Model protected $fillable = [ 'user_id', + 'user_group_id', 'transaction_type_id', 'bill_id', 'tag_count', diff --git a/app/Models/UserGroup.php b/app/Models/UserGroup.php index e5faeb6419..d3a4e584ac 100644 --- a/app/Models/UserGroup.php +++ b/app/Models/UserGroup.php @@ -67,6 +67,16 @@ class UserGroup extends Model return $this->hasMany(Account::class); } + /** + * Link to budgets. + * + * @return HasMany + */ + public function budgets(): HasMany + { + return $this->hasMany(Budget::class); + } + /** * * @return HasMany @@ -75,4 +85,14 @@ class UserGroup extends Model { return $this->hasMany(GroupMembership::class); } + + /** + * Link to transaction journals. + * + * @return HasMany + */ + public function transactionJournals(): HasMany + { + return $this->hasMany(TransactionJournal::class); + } } diff --git a/app/Providers/BudgetServiceProvider.php b/app/Providers/BudgetServiceProvider.php index f0aad0da78..61c67711fa 100644 --- a/app/Providers/BudgetServiceProvider.php +++ b/app/Providers/BudgetServiceProvider.php @@ -29,10 +29,14 @@ use FireflyIII\Repositories\Budget\BudgetLimitRepository; use FireflyIII\Repositories\Budget\BudgetLimitRepositoryInterface; use FireflyIII\Repositories\Budget\BudgetRepository; use FireflyIII\Repositories\Budget\BudgetRepositoryInterface; +use FireflyIII\Repositories\Administration\Budget\BudgetRepository as AdminBudgetRepository; +use FireflyIII\Repositories\Administration\Budget\BudgetRepositoryInterface as AdminBudgetRepositoryInterface; use FireflyIII\Repositories\Budget\NoBudgetRepository; use FireflyIII\Repositories\Budget\NoBudgetRepositoryInterface; use FireflyIII\Repositories\Budget\OperationsRepository; use FireflyIII\Repositories\Budget\OperationsRepositoryInterface; +use FireflyIII\Repositories\Administration\Budget\OperationsRepository as AdminOperationsRepository; +use FireflyIII\Repositories\Administration\Budget\OperationsRepositoryInterface as AdminOperationsRepositoryInterface; use Illuminate\Foundation\Application; use Illuminate\Support\ServiceProvider; @@ -54,7 +58,6 @@ class BudgetServiceProvider extends ServiceProvider public function register(): void { // reference to auth is not understood by phpstan. - $this->app->bind( BudgetRepositoryInterface::class, static function (Application $app) { @@ -68,6 +71,19 @@ class BudgetServiceProvider extends ServiceProvider } ); + $this->app->bind( + AdminBudgetRepositoryInterface::class, + static function (Application $app) { + /** @var AdminBudgetRepositoryInterface $repository */ + $repository = app(AdminBudgetRepository::class); + if ($app->auth->check()) { // @phpstan-ignore-line + $repository->setUser(auth()->user()); + } + + return $repository; + } + ); + // available budget repos $this->app->bind( AvailableBudgetRepositoryInterface::class, @@ -120,6 +136,18 @@ class BudgetServiceProvider extends ServiceProvider $repository->setUser(auth()->user()); } + return $repository; + } + ); + $this->app->bind( + AdminOperationsRepositoryInterface::class, + static function (Application $app) { + /** @var AdminOperationsRepositoryInterface $repository */ + $repository = app(AdminOperationsRepository::class); + if ($app->auth->check()) { // @phpstan-ignore-line + $repository->setUser(auth()->user()); + } + return $repository; } ); diff --git a/app/Repositories/Administration/Budget/BudgetRepository.php b/app/Repositories/Administration/Budget/BudgetRepository.php new file mode 100644 index 0000000000..b981341e0e --- /dev/null +++ b/app/Repositories/Administration/Budget/BudgetRepository.php @@ -0,0 +1,46 @@ +. + */ + +namespace FireflyIII\Repositories\Administration\Budget; + +use FireflyIII\Support\Repositories\Administration\AdministrationTrait; +use Illuminate\Support\Collection; + +/** + * Class BudgetRepository + */ +class BudgetRepository implements BudgetRepositoryInterface +{ + use AdministrationTrait; + + /** + * @inheritDoc + */ + public function getActiveBudgets(): Collection + { + return $this->userGroup->budgets()->where('active', true) + ->orderBy('order', 'ASC') + ->orderBy('name', 'ASC') + ->get(); + } +} diff --git a/app/Repositories/Administration/Budget/BudgetRepositoryInterface.php b/app/Repositories/Administration/Budget/BudgetRepositoryInterface.php new file mode 100644 index 0000000000..74f503ab5c --- /dev/null +++ b/app/Repositories/Administration/Budget/BudgetRepositoryInterface.php @@ -0,0 +1,37 @@ +. + */ + +namespace FireflyIII\Repositories\Administration\Budget; + +use Illuminate\Support\Collection; + +/** + * Interface BudgetRepositoryInterface + */ +interface BudgetRepositoryInterface +{ + /** + * @return Collection + */ + public function getActiveBudgets(): Collection; +} diff --git a/app/Repositories/Administration/Budget/OperationsRepository.php b/app/Repositories/Administration/Budget/OperationsRepository.php new file mode 100644 index 0000000000..45f1696256 --- /dev/null +++ b/app/Repositories/Administration/Budget/OperationsRepository.php @@ -0,0 +1,136 @@ +. + */ + +namespace FireflyIII\Repositories\Administration\Budget; + +use Carbon\Carbon; +use FireflyIII\Exceptions\FireflyException; +use FireflyIII\Helpers\Collector\GroupCollectorInterface; +use FireflyIII\Models\TransactionType; +use FireflyIII\Support\Repositories\Administration\AdministrationTrait; +use Illuminate\Support\Collection; + +/** + * Class OperationsRepository + */ +class OperationsRepository implements OperationsRepositoryInterface +{ + use AdministrationTrait; + + /** + * @inheritDoc + * @throws FireflyException + */ + public function listExpenses(Carbon $start, Carbon $end, ?Collection $accounts = null, ?Collection $budgets = null): array + { + /** @var GroupCollectorInterface $collector */ + $collector = app(GroupCollectorInterface::class); + $collector->setUserGroup($this->userGroup)->setRange($start, $end)->setTypes([TransactionType::WITHDRAWAL]); + if (null !== $accounts && $accounts->count() > 0) { + $collector->setAccounts($accounts); + } + if (null !== $budgets && $budgets->count() > 0) { + $collector->setBudgets($budgets); + } + if (null === $budgets || (0 === $budgets->count())) { + $collector->setBudgets($this->getBudgets()); + } + $collector->withBudgetInformation()->withAccountInformation()->withCategoryInformation(); + $journals = $collector->getExtractedJournals(); + $array = []; + + foreach ($journals as $journal) { + $currencyId = (int)$journal['currency_id']; + $budgetId = (int)$journal['budget_id']; + $budgetName = (string)$journal['budget_name']; + + // catch "no budget" entries. + if (0 === $budgetId) { + continue; + } + + // info about the currency: + $array[$currencyId] = $array[$currencyId] ?? [ + 'budgets' => [], + 'currency_id' => $currencyId, + 'currency_name' => $journal['currency_name'], + 'currency_symbol' => $journal['currency_symbol'], + 'currency_code' => $journal['currency_code'], + 'currency_decimal_places' => $journal['currency_decimal_places'], + ]; + + // info about the budgets: + $array[$currencyId]['budgets'][$budgetId] = $array[$currencyId]['budgets'][$budgetId] ?? [ + 'id' => $budgetId, + 'name' => $budgetName, + 'transaction_journals' => [], + ]; + + // add journal to array: + // only a subset of the fields. + $journalId = (int)$journal['transaction_journal_id']; + $final = [ + 'amount' => app('steam')->negative($journal['amount']), + 'foreign_amount' => null, + 'foreign_currency_id' => null, + 'foreign_currency_code' => null, + 'foreign_currency_symbol' => null, + 'foreign_currency_name' => null, + 'foreign_currency_decimal_places' => null, + 'destination_account_id' => $journal['destination_account_id'], + 'destination_account_name' => $journal['destination_account_name'], + 'source_account_id' => $journal['source_account_id'], + 'source_account_name' => $journal['source_account_name'], + 'category_name' => $journal['category_name'], + 'description' => $journal['description'], + 'transaction_group_id' => $journal['transaction_group_id'], + 'date' => $journal['date'], + ]; + if (null !== $journal['foreign_amount']) { + $final['foreign_amount'] = app('steam')->negative($journal['foreign_amount']); + $final['foreign_currency_id'] = $journal['foreign_currency_id']; + $final['foreign_currency_code'] = $journal['foreign_currency_code']; + $final['foreign_currency_symbol'] = $journal['foreign_currency_symbol']; + $final['foreign_currency_name'] = $journal['foreign_currency_name']; + $final['foreign_currency_decimal_places'] = $journal['foreign_currency_decimal_places']; + } + + $array[$currencyId]['budgets'][$budgetId]['transaction_journals'][$journalId] = $final; + } + + return $array; + } + + /** + * @return Collection + * @throws FireflyException + */ + private function getBudgets(): Collection + { + /** @var BudgetRepositoryInterface $repos */ + $repos = app(BudgetRepositoryInterface::class); + $repos->setAdministrationId($this->getAdministrationId()); + + return $repos->getActiveBudgets(); + } +} diff --git a/app/Repositories/Administration/Budget/OperationsRepositoryInterface.php b/app/Repositories/Administration/Budget/OperationsRepositoryInterface.php new file mode 100644 index 0000000000..8c7d522386 --- /dev/null +++ b/app/Repositories/Administration/Budget/OperationsRepositoryInterface.php @@ -0,0 +1,47 @@ +. + */ + +namespace FireflyIII\Repositories\Administration\Budget; + +use Carbon\Carbon; +use Illuminate\Support\Collection; + +/** + * Interface OperationsRepositoryInterface + */ +interface OperationsRepositoryInterface +{ + /** + * This method returns a list of all the withdrawal transaction journals (as arrays) set in that period + * which have the specified budget set to them. It's grouped per currency, with as few details in the array + * as possible. Amounts are always negative. + * + * @param Carbon $start + * @param Carbon $end + * @param Collection|null $accounts + * @param Collection|null $budgets + * + * @return array + */ + public function listExpenses(Carbon $start, Carbon $end, ?Collection $accounts = null, ?Collection $budgets = null): array; +} diff --git a/app/Repositories/Budget/BudgetRepository.php b/app/Repositories/Budget/BudgetRepository.php index 7033dbf413..7c3ae8dd94 100644 --- a/app/Repositories/Budget/BudgetRepository.php +++ b/app/Repositories/Budget/BudgetRepository.php @@ -798,10 +798,11 @@ class BudgetRepository implements BudgetRepositoryInterface try { $newBudget = Budget::create( [ - 'user_id' => $this->user->id, - 'name' => $data['name'], - 'order' => $order + 1, - 'active' => array_key_exists('active', $data) ? $data['active'] : true, + 'user_id' => $this->user->id, + 'user_group_id' => $this->user->user_group_id, + 'name' => $data['name'], + 'order' => $order + 1, + 'active' => array_key_exists('active', $data) ? $data['active'] : true, ] ); } catch (QueryException $e) { diff --git a/app/Support/Http/Api/CleansChartData.php b/app/Support/Http/Api/CleansChartData.php index 3d969a5ad9..220bcfdd82 100644 --- a/app/Support/Http/Api/CleansChartData.php +++ b/app/Support/Http/Api/CleansChartData.php @@ -1,4 +1,6 @@ 'AccountController@dashboard', 'as' => 'account.dashboard']); + Route::get('budget/dashboard', ['uses' => 'BudgetController@dashboard', 'as' => 'budget.dashboard']); Route::get('balance/balance', ['uses' => 'BalanceController@balance', 'as' => 'balance.balance']); } );