Build administration-compatible budget chart.

This commit is contained in:
James Cole 2023-08-01 19:38:53 +02:00
parent 5f9f621fa6
commit 0c087f33c2
No known key found for this signature in database
GPG Key ID: B49A324B7EAD6D80
23 changed files with 785 additions and 59 deletions

View File

@ -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": [],

View File

@ -0,0 +1,308 @@
<?php
declare(strict_types=1);
/*
* BudgetController.php
* Copyright (c) 2023 james@firefly-iii.org
*
* This file is part of Firefly III (https://github.com/firefly-iii).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
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;
}
}

View File

@ -1,4 +1,6 @@
<?php
declare(strict_types=1);
/*
* BalanceChartRequest.php
* Copyright (c) 2023 james@firefly-iii.org

View File

@ -47,7 +47,7 @@ class DateRequest extends FormRequest
{
return [
'start' => $this->getCarbonDate('start'),
'end' => $this->getCarbonDate('end'),
'end' => $this->getCarbonDate('end')->endOfDay(),
];
}

View File

@ -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();

View File

@ -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,

View File

@ -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;
}

View File

@ -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 '<pre>';
print_r($this->query->getBindings());
echo '</pre>';
@ -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.
*

View File

@ -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
*

View File

@ -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'];

View File

@ -130,4 +130,12 @@ class TransactionGroup extends Model
{
return $this->hasMany(TransactionJournal::class);
}
/**
* @return BelongsTo
*/
public function userGroup(): BelongsTo
{
return $this->belongsTo(UserGroup::class);
}
}

View File

@ -148,6 +148,7 @@ class TransactionJournal extends Model
protected $fillable
= [
'user_id',
'user_group_id',
'transaction_type_id',
'bill_id',
'tag_count',

View File

@ -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);
}
}

View File

@ -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;
}
);

View File

@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
/*
* BudgetRepository.php
* Copyright (c) 2023 james@firefly-iii.org
*
* This file is part of Firefly III (https://github.com/firefly-iii).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
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();
}
}

View File

@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
/*
* BudgetRepositoryInterface.php
* Copyright (c) 2023 james@firefly-iii.org
*
* This file is part of Firefly III (https://github.com/firefly-iii).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
namespace FireflyIII\Repositories\Administration\Budget;
use Illuminate\Support\Collection;
/**
* Interface BudgetRepositoryInterface
*/
interface BudgetRepositoryInterface
{
/**
* @return Collection
*/
public function getActiveBudgets(): Collection;
}

View File

@ -0,0 +1,136 @@
<?php
declare(strict_types=1);
/*
* OperationsRepository.php
* Copyright (c) 2023 james@firefly-iii.org
*
* This file is part of Firefly III (https://github.com/firefly-iii).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
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();
}
}

View File

@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
/*
* OperationsRepositoryInterface.php
* Copyright (c) 2023 james@firefly-iii.org
*
* This file is part of Firefly III (https://github.com/firefly-iii).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
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;
}

View File

@ -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) {

View File

@ -1,4 +1,6 @@
<?php
declare(strict_types=1);
/*
* CleansChartData.php
* Copyright (c) 2023 james@firefly-iii.org

View File

@ -57,5 +57,4 @@ class ExchangeRateConverter
return '0' === $rate ? '1' : $rate;
}
}

View File

@ -89,10 +89,10 @@ class Navigation
if (!array_key_exists($repeatFreq, $functionMap)) {
Log::error(sprintf(
'The periodicity %s is unknown. Choose one of available periodicity: %s',
$repeatFreq,
join(', ', array_keys($functionMap))
));
'The periodicity %s is unknown. Choose one of available periodicity: %s',
$repeatFreq,
join(', ', array_keys($functionMap))
));
return $theDate;
}

View File

@ -88,6 +88,7 @@ Route::group(
],
static function () {
Route::get('account/dashboard', ['uses' => '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']);
}
);