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/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']);
}
);