. */ declare(strict_types=1); namespace FireflyIII\Api\V1\Controllers\Chart; use Carbon\Carbon; use FireflyIII\Api\V1\Controllers\Controller; use FireflyIII\Api\V1\Requests\Generic\DateRequest; use FireflyIII\Enums\UserRoleEnum; use FireflyIII\Exceptions\FireflyException; use FireflyIII\Models\Budget; use FireflyIII\Models\BudgetLimit; use FireflyIII\Models\TransactionCurrency; use FireflyIII\Repositories\Budget\BudgetLimitRepositoryInterface; use FireflyIII\Repositories\Budget\BudgetRepositoryInterface; use FireflyIII\Repositories\Budget\OperationsRepositoryInterface; use FireflyIII\Support\Http\Api\CleansChartData; use FireflyIII\Support\Http\Api\ValidatesUserGroupTrait; use Illuminate\Http\JsonResponse; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Log; /** * Class BudgetController */ class BudgetController extends Controller { use CleansChartData; use ValidatesUserGroupTrait; protected array $acceptedRoles = [UserRoleEnum::READ_ONLY]; 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); $userGroup = $this->validateUserGroup($request); $this->repository->setUserGroup($userGroup); $this->opsRepository->setUserGroup($userGroup); $this->blRepository->setUserGroup($userGroup); return $next($request); } ); } /** * TODO see autocomplete/accountcontroller * @throws FireflyException */ public function dashboard(DateRequest $request): JsonResponse { $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($this->clean($data)); } /** * @throws FireflyException */ private function processBudget(Budget $budget, Carbon $start, Carbon $end): array { // get all limits: $limits = $this->blRepository->getBudgetLimits($budget, $start, $end); // 'currency_id' => string '1' (length=1) // 'currency_code' => string 'EUR' (length=3) // 'currency_name' => string 'Euro' (length=4) // 'currency_symbol' => string '€' (length=3) // 'currency_decimal_places' => int 2 // 'start' => string '2025-07-01T00:00:00+02:00' (length=25) // 'end' => string '2025-07-31T23:59:59+02:00' (length=25) // 'budgeted' => string '100.000000000000' (length=16) // 'spent' => string '-421.230000000000' (length=17) // 'left' => string '0' (length=1) // 'overspent' => string '321.230000000000' (length=16) $rows = []; // instead of using the budget limits as a thing to collect all expenses, // use the budget range itself to collect and group them, // AND THEN add budgeted amounts from the limits to the rows. $spent = $this->opsRepository->listExpenses($start, $end, null, new Collection([$budget])); $expenses = $this->processExpenses($budget->id, $spent, $start, $end); /** * @var int $currencyId * @var array $row */ foreach ($expenses as $currencyId => $row) { // budgeted, left and overspent are now 0. $limit = $this->filterLimit($currencyId, $limits); if (null !== $limit) { $row['budgeted'] = $limit->amount; $row['left'] = bcsub($row['budgeted'], bcmul($row['spent'], '-1')); $row['overspent'] = bcmul($row['left'], '-1'); $row['left'] = 1 === bccomp($row['left'], '0') ? $row['left'] : '0'; $row['overspent'] = 1 === bccomp($row['overspent'], '0') ? $row['overspent'] : '0'; } $rows[] = $row; } // if no limits // if (0 === $limits->count()) { // return as a single item in an array // $rows = $this->noBudgetLimits($budget, $start, $end); // } // is always an array $return = []; foreach ($rows as $row) { $current = [ 'label' => $budget->name, 'currency_id' => (string)$row['currency_id'], 'currency_code' => $row['currency_code'], 'currency_name' => $row['currency_name'], 'currency_decimal_places' => $row['currency_decimal_places'], 'period' => null, 'start' => $row['start'], 'end' => $row['end'], 'entries' => [ 'budgeted' => $row['budgeted'], 'spent' => $row['spent'], 'left' => $row['left'], 'overspent' => $row['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. * * @throws FireflyException */ private function noBudgetLimits(Budget $budget, Carbon $start, Carbon $end): array { $spent = $this->opsRepository->listExpenses($start, $end, null, new Collection([$budget])); return $this->processExpenses($budget->id, $spent, $start, $end); } /** * Shared between the "noBudgetLimits" function and "processLimit". Will take a single set of expenses and return * its info. * * @throws FireflyException */ private function processExpenses(int $budgetId, array $spent, Carbon $start, Carbon $end): array { $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 ($spent as $currencyId => $block) { $this->currencies[$currencyId] ??= TransactionCurrency::find($currencyId); $return[$currencyId] ??= [ 'currency_id' => (string)$currencyId, 'currency_code' => $block['currency_code'], 'currency_name' => $block['currency_name'], 'currency_symbol' => $block['currency_symbol'], 'currency_decimal_places' => (int)$block['currency_decimal_places'], 'start' => $start->toAtomString(), 'end' => $end->toAtomString(), 'budgeted' => '0', 'spent' => '0', 'left' => '0', 'overspent' => '0', ]; $currentBudgetArray = $block['budgets'][$budgetId]; // var_dump($return); /** @var array $journal */ foreach ($currentBudgetArray['transaction_journals'] as $journal) { $return[$currencyId]['spent'] = bcadd($return[$currencyId]['spent'], (string)$journal['amount']); } } 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. * * @throws FireflyException */ private function budgetLimits(Budget $budget, Collection $limits): array { 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; } /** * @throws FireflyException */ private function processLimit(Budget $budget, BudgetLimit $limit): array { Log::debug(sprintf('Created new ExchangeRateConverter in %s', __METHOD__)); $end = clone $limit->end_date; $end->endOfDay(); $spent = $this->opsRepository->listExpenses($limit->start_date, $end, null, new Collection([$budget])); $limitCurrencyId = $limit->transaction_currency_id; /** @var array $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 $filtered = array_filter($spent, fn($entry) => $entry['currency_id'] === $limitCurrencyId); $result = $this->processExpenses($budget->id, $filtered, $limit->start_date, $end); if (1 === count($result)) { $compare = bccomp($limit->amount, (string)app('steam')->positive($result[$limitCurrencyId]['spent'])); $result[$limitCurrencyId]['budgeted'] = $limit->amount; if (1 === $compare) { // convert this amount into the native currency: $result[$limitCurrencyId]['left'] = bcadd($limit->amount, (string)$result[$limitCurrencyId]['spent']); } if ($compare <= 0) { $result[$limitCurrencyId]['overspent'] = app('steam')->positive(bcadd($limit->amount, (string)$result[$limitCurrencyId]['spent'])); } } return $result; } private function filterLimit(int $currencyId, Collection $limits): ?BudgetLimit { foreach ($limits as $limit) { if ($limit->transaction_currency_id === $currencyId) { return $limit; } } return null; } }