From 6a49918707c9b45a68cb36777047c7055e96e37c Mon Sep 17 00:00:00 2001 From: James Cole Date: Sun, 3 Aug 2025 20:17:50 +0200 Subject: [PATCH] Add budget transformer and enrichment. --- .../Models/Budget/ShowController.php | 20 +++ .../Controllers/Budget/EditController.php | 1 + app/Http/Requests/BudgetFormUpdateRequest.php | 1 + .../Budget/OperationsRepository.php | 16 +- .../Budget/OperationsRepositoryInterface.php | 1 + .../JsonApi/Enrichments/BudgetEnrichment.php | 154 ++++++++++++++++++ .../Enrichments/BudgetLimitEnrichment.php | 2 +- app/Transformers/AccountTransformer.php | 3 + .../AvailableBudgetTransformer.php | 3 +- app/Transformers/BillTransformer.php | 2 + app/Transformers/BudgetTransformer.php | 115 ++++++------- resources/views/budgets/edit.twig | 1 + 12 files changed, 249 insertions(+), 70 deletions(-) create mode 100644 app/Support/JsonApi/Enrichments/BudgetEnrichment.php diff --git a/app/Api/V1/Controllers/Models/Budget/ShowController.php b/app/Api/V1/Controllers/Models/Budget/ShowController.php index beb2d31cea..3b0b603fe2 100644 --- a/app/Api/V1/Controllers/Models/Budget/ShowController.php +++ b/app/Api/V1/Controllers/Models/Budget/ShowController.php @@ -29,7 +29,9 @@ use FireflyIII\Exceptions\FireflyException; use FireflyIII\Models\Budget; use FireflyIII\Repositories\Budget\BudgetLimitRepositoryInterface; use FireflyIII\Repositories\Budget\BudgetRepositoryInterface; +use FireflyIII\Support\JsonApi\Enrichments\BudgetEnrichment; use FireflyIII\Transformers\BudgetTransformer; +use FireflyIII\User; use Illuminate\Http\JsonResponse; use Illuminate\Pagination\LengthAwarePaginator; use League\Fractal\Pagination\IlluminatePaginatorAdapter; @@ -82,6 +84,15 @@ class ShowController extends Controller $count = $collection->count(); $budgets = $collection->slice(($this->parameters->get('page') - 1) * $pageSize, $pageSize); + // enrich + /** @var User $admin */ + $admin = auth()->user(); + $enrichment = new BudgetEnrichment(); + $enrichment->setUser($admin); + $enrichment->setStart($this->parameters->get('start')); + $enrichment->setEnd($this->parameters->get('end')); + $budgets = $enrichment->enrich($budgets); + // make paginator: $paginator = new LengthAwarePaginator($budgets, $count, $pageSize, $this->parameters->get('page')); $paginator->setPath(route('api.v1.budgets.index').$this->buildParams()); @@ -103,6 +114,15 @@ class ShowController extends Controller { $manager = $this->getManager(); + // enrich + /** @var User $admin */ + $admin = auth()->user(); + $enrichment = new BudgetEnrichment(); + $enrichment->setUser($admin); + $enrichment->setStart($this->parameters->get('start')); + $enrichment->setEnd($this->parameters->get('end')); + $budget = $enrichment->enrichSingle($budget); + /** @var BudgetTransformer $transformer */ $transformer = app(BudgetTransformer::class); $transformer->setParameters($this->parameters); diff --git a/app/Http/Controllers/Budget/EditController.php b/app/Http/Controllers/Budget/EditController.php index d030cc8f52..a3969e6d7d 100644 --- a/app/Http/Controllers/Budget/EditController.php +++ b/app/Http/Controllers/Budget/EditController.php @@ -95,6 +95,7 @@ class EditController extends Controller $preFilled = [ 'active' => $hasOldInput ? (bool) $request->old('active') : $budget->active, 'auto_budget_currency_id' => $hasOldInput ? (int) $request->old('auto_budget_currency_id') : $this->primaryCurrency->id, + 'notes' => $this->repository->getNoteText($budget), ]; if ($autoBudget instanceof AutoBudget) { $amount = $hasOldInput ? $request->old('auto_budget_amount') : $autoBudget->amount; diff --git a/app/Http/Requests/BudgetFormUpdateRequest.php b/app/Http/Requests/BudgetFormUpdateRequest.php index 4fc1ec8f47..265e9686c9 100644 --- a/app/Http/Requests/BudgetFormUpdateRequest.php +++ b/app/Http/Requests/BudgetFormUpdateRequest.php @@ -53,6 +53,7 @@ class BudgetFormUpdateRequest extends FormRequest 'currency_id' => $this->convertInteger('auto_budget_currency_id'), 'auto_budget_amount' => $this->convertString('auto_budget_amount'), 'auto_budget_period' => $this->convertString('auto_budget_period'), + 'notes' => $this->stringWithNewlines('notes'), ]; } diff --git a/app/Repositories/Budget/OperationsRepository.php b/app/Repositories/Budget/OperationsRepository.php index 9387f4fe3c..a807252708 100644 --- a/app/Repositories/Budget/OperationsRepository.php +++ b/app/Repositories/Budget/OperationsRepository.php @@ -290,7 +290,7 @@ class OperationsRepository implements OperationsRepositoryInterface, UserGroupIn $summarizer = new TransactionSummarizer($this->user); $summarizer->setConvertToPrimary($convertToPrimary); - // filter $journals by range. + // filter $journals by range AND currency if it is present. $expenses = array_filter($expenses, static function (array $expense) use ($start, $end, $transactionCurrency): bool { return $expense['date']->between($start, $end) && $expense['currency_id'] === $transactionCurrency->id; }); @@ -298,6 +298,20 @@ class OperationsRepository implements OperationsRepositoryInterface, UserGroupIn return $summarizer->groupByCurrencyId($expenses, 'negative', false); } +public function sumCollectedExpensesByBudget(array $expenses, Budget $budget, bool $convertToPrimary = false): array + { + Log::debug(sprintf('Start of %s.', __METHOD__)); + $summarizer = new TransactionSummarizer($this->user); + $summarizer->setConvertToPrimary($convertToPrimary); + + // filter $journals by range AND currency if it is present. + $expenses = array_filter($expenses, static function (array $expense) use ($budget): bool { + return $expense['budget_id'] === $budget->id; + }); + + return $summarizer->groupByCurrencyId($expenses, 'negative', false); + } + #[Override] public function collectExpenses(Carbon $start, Carbon $end, ?Collection $accounts = null, ?Collection $budgets = null, ?TransactionCurrency $currency = null): array { diff --git a/app/Repositories/Budget/OperationsRepositoryInterface.php b/app/Repositories/Budget/OperationsRepositoryInterface.php index 0f58e73b64..c0d1a0e7eb 100644 --- a/app/Repositories/Budget/OperationsRepositoryInterface.php +++ b/app/Repositories/Budget/OperationsRepositoryInterface.php @@ -75,6 +75,7 @@ interface OperationsRepositoryInterface ): array; public function sumCollectedExpenses(array $expenses, Carbon $start, Carbon $end, TransactionCurrency $transactionCurrency, bool $convertToPrimary = false): array; + public function sumCollectedExpensesByBudget(array $expenses, Budget $budget, bool $convertToPrimary = false): array; public function collectExpenses(Carbon $start, Carbon $end, ?Collection $accounts = null, ?Collection $budgets = null, ?TransactionCurrency $currency = null): array; } diff --git a/app/Support/JsonApi/Enrichments/BudgetEnrichment.php b/app/Support/JsonApi/Enrichments/BudgetEnrichment.php new file mode 100644 index 0000000000..f663168fc1 --- /dev/null +++ b/app/Support/JsonApi/Enrichments/BudgetEnrichment.php @@ -0,0 +1,154 @@ +convertToPrimary = Amount::convertToPrimary(); + $this->primaryCurrency = Amount::getPrimaryCurrency(); + } + + + public function enrich(Collection $collection): Collection + { + $this->collection = $collection; + $this->collectIds(); + $this->collectNotes(); + $this->collectAutoBudgets(); + $this->collectExpenses(); + $this->appendCollectedData(); + + return $this->collection; + } + + public function enrichSingle(Model|array $model): array|Model + { + Log::debug(__METHOD__); + $collection = new Collection([$model]); + $collection = $this->enrich($collection); + + return $collection->first(); + } + + public function setUser(User $user): void + { + $this->user = $user; + $this->setUserGroup($user->userGroup); + } + + public function setUserGroup(UserGroup $userGroup): void + { + $this->userGroup = $userGroup; + } + + private function collectIds(): void + { + /** @var Budget $budget */ + foreach ($this->collection as $budget) { + $this->ids[] = (int)$budget->id; + } + } + + private function collectNotes(): void + { + $notes = Note::query()->whereIn('noteable_id', $this->ids) + ->whereNotNull('notes.text') + ->where('notes.text', '!=', '') + ->where('noteable_type', Budget::class)->get(['notes.noteable_id', 'notes.text'])->toArray(); + foreach ($notes as $note) { + $this->notes[(int)$note['noteable_id']] = (string)$note['text']; + } + Log::debug(sprintf('Enrich with %d note(s)', count($this->notes))); + } + + private function appendCollectedData(): void + { + $this->collection = $this->collection->map(function (Budget $item) { + $id = (int)$item->id; + $meta = [ + 'notes' => $this->notes[$id] ?? null, + 'currency' => $this->currencies[$id] ?? null, + 'auto_budget' => $this->autoBudgets[$id] ?? null, + 'spent' => $this->spent[$id] ?? null, + 'pc_spent' => $this->pcSpent[$id] ?? null, + ]; + $item->meta = $meta; + return $item; + }); + } + + private function collectAutoBudgets(): void + { + $set = AutoBudget::whereIn('budget_id', $this->ids)->with(['transactionCurrency'])->get(); + /** @var AutoBudget $autoBudget */ + foreach ($set as $autoBudget) { + $budgetId = (int)$autoBudget->budget_id; + $this->currencies[$budgetId] = $autoBudget->transactionCurrency; + $this->autoBudgets[$budgetId] = [ + 'type' => (int)$autoBudget->auto_budget_type, + 'period' => $autoBudget->period, + 'amount' => $autoBudget->amount, + 'pc_amount' => $autoBudget->native_amount, + ]; + } + } + + private function collectExpenses(): void + { + if (null !== $this->start && null !== $this->end) { + /** @var OperationsRepositoryInterface $opsRepository */ + $opsRepository = app(OperationsRepositoryInterface::class); + $opsRepository->setUser($this->user); + $opsRepository->setUserGroup($this->userGroup); + // $spent = $this->beautify(); + // $set = $this->opsRepository->sumExpenses($start, $end, null, new Collection([$budget])) + $expenses = $opsRepository->collectExpenses($this->start, $this->end, null, $this->collection, null); + foreach ($this->collection as $item) { + $id = (int)$item->id; + $this->spent[$id] = array_values($opsRepository->sumCollectedExpensesByBudget($expenses, $item, false)); + $this->pcSpent[$id] = array_values($opsRepository->sumCollectedExpensesByBudget($expenses, $item, true)); + } + } + } + + public function setEnd(?Carbon $end): void + { + $this->end = $end; + } + + public function setStart(?Carbon $start): void + { + $this->start = $start; + } + + +} diff --git a/app/Support/JsonApi/Enrichments/BudgetLimitEnrichment.php b/app/Support/JsonApi/Enrichments/BudgetLimitEnrichment.php index 22fa9426d9..e545a2712b 100644 --- a/app/Support/JsonApi/Enrichments/BudgetLimitEnrichment.php +++ b/app/Support/JsonApi/Enrichments/BudgetLimitEnrichment.php @@ -123,7 +123,7 @@ class BudgetLimitEnrichment implements EnrichmentInterface $this->pcExpenses[$id] = array_values($pcFilteredExpenses); } if (true === $this->convertToPrimary && $budgetLimit->transactionCurrency->id === $this->primaryCurrency->id) { - $this->pcExpenses[$id] = $this->expenses[$id]; + $this->pcExpenses[$id] = $this->expenses[$id] ?? []; } } } diff --git a/app/Transformers/AccountTransformer.php b/app/Transformers/AccountTransformer.php index 5a53a4ca82..14bcb14e76 100644 --- a/app/Transformers/AccountTransformer.php +++ b/app/Transformers/AccountTransformer.php @@ -113,10 +113,13 @@ class AccountTransformer extends AbstractTransformer // currency is object specific or primary, already determined above. 'currency_id' => (string) $currency['id'], + 'currency_name' => $currency['name'], 'currency_code' => $currency['code'], 'currency_symbol' => $currency['symbol'], 'currency_decimal_places' => $currency['decimal_places'], + 'primary_currency_id' => (string) $this->primary->id, + 'primary_currency_name' => $this->primary->name, 'primary_currency_code' => $this->primary->code, 'primary_currency_symbol' => $this->primary->symbol, 'primary_currency_decimal_places' => $this->primary->decimal_places, diff --git a/app/Transformers/AvailableBudgetTransformer.php b/app/Transformers/AvailableBudgetTransformer.php index 2355693c2d..a8248a9f93 100644 --- a/app/Transformers/AvailableBudgetTransformer.php +++ b/app/Transformers/AvailableBudgetTransformer.php @@ -66,16 +66,17 @@ class AvailableBudgetTransformer extends AbstractTransformer // currencies according to 6.3.0 'object_has_currency_setting' => true, 'currency_id' => (string) $currency->id, + 'currency_name' => $currency->name, 'currency_code' => $currency->code, 'currency_symbol' => $currency->symbol, 'currency_decimal_places' => $currency->decimal_places, 'primary_currency_id' => (string) $this->primary->id, + 'primary_currency_name' => $this->primary->name, 'primary_currency_code' => $this->primary->code, 'primary_currency_symbol' => $this->primary->symbol, 'primary_currency_decimal_places' => $this->primary->decimal_places, - 'amount' => $amount, 'pc_amount' => $pcAmount, 'start' => $availableBudget->start_date->toAtomString(), diff --git a/app/Transformers/BillTransformer.php b/app/Transformers/BillTransformer.php index 16e7e4b76d..7beb2201a3 100644 --- a/app/Transformers/BillTransformer.php +++ b/app/Transformers/BillTransformer.php @@ -60,11 +60,13 @@ class BillTransformer extends AbstractTransformer // currencies according to 6.3.0 'object_has_currency_setting' => true, 'currency_id' => (string) $bill->transaction_currency_id, + 'currency_name' => $currency->name, 'currency_code' => $currency->code, 'currency_symbol' => $currency->symbol, 'currency_decimal_places' => $currency->decimal_places, 'primary_currency_id' => (string) $this->primary->id, + 'primary_currency_name' => $this->primary->name, 'primary_currency_code' => $this->primary->code, 'primary_currency_symbol' => $this->primary->symbol, 'primary_currency_decimal_places' => $this->primary->decimal_places, diff --git a/app/Transformers/BudgetTransformer.php b/app/Transformers/BudgetTransformer.php index 6c7d8e2718..5e64d561b7 100644 --- a/app/Transformers/BudgetTransformer.php +++ b/app/Transformers/BudgetTransformer.php @@ -27,11 +27,8 @@ namespace FireflyIII\Transformers; use FireflyIII\Enums\AutoBudgetType; use FireflyIII\Models\Budget; use FireflyIII\Models\TransactionCurrency; -use FireflyIII\Repositories\Budget\BudgetRepositoryInterface; -use FireflyIII\Repositories\Budget\OperationsRepositoryInterface; use FireflyIII\Support\Facades\Amount; use FireflyIII\Support\Facades\Steam; -use Illuminate\Support\Collection; use Symfony\Component\HttpFoundation\ParameterBag; /** @@ -39,21 +36,23 @@ use Symfony\Component\HttpFoundation\ParameterBag; */ class BudgetTransformer extends AbstractTransformer { - private readonly bool $convertToPrimary; - private readonly TransactionCurrency $primary; - private readonly OperationsRepositoryInterface $opsRepository; - private readonly BudgetRepositoryInterface $repository; + private readonly bool $convertToPrimary; + private readonly TransactionCurrency $primaryCurrency; + private array $types; /** * BudgetTransformer constructor. */ public function __construct() { - $this->opsRepository = app(OperationsRepositoryInterface::class); - $this->repository = app(BudgetRepositoryInterface::class); $this->parameters = new ParameterBag(); - $this->primary = Amount::getPrimaryCurrency(); + $this->primaryCurrency = Amount::getPrimaryCurrency(); $this->convertToPrimary = Amount::convertToPrimary(); + $this->types = [ + AutoBudgetType::AUTO_BUDGET_RESET->value => 'reset', + AutoBudgetType::AUTO_BUDGET_ROLLOVER->value => 'rollover', + AutoBudgetType::AUTO_BUDGET_ADJUSTED->value => 'adjusted', + ]; } /** @@ -61,73 +60,55 @@ class BudgetTransformer extends AbstractTransformer */ public function transform(Budget $budget): array { - $this->opsRepository->setUser($budget->user); - $start = $this->parameters->get('start'); - $end = $this->parameters->get('end'); - $autoBudget = $this->repository->getAutoBudget($budget); - $spent = []; - if (null !== $start && null !== $end) { - $spent = $this->beautify($this->opsRepository->sumExpenses($start, $end, null, new Collection([$budget]))); - } // info for auto budget. - $abType = null; - $abAmount = null; - $abPrimary = null; - $abPeriod = null; - $notes = $this->repository->getNoteText($budget); + $abType = null; + $abAmount = null; + $abPrimary = null; + $abPeriod = null; - $types = [ - AutoBudgetType::AUTO_BUDGET_RESET->value => 'reset', - AutoBudgetType::AUTO_BUDGET_ROLLOVER->value => 'rollover', - AutoBudgetType::AUTO_BUDGET_ADJUSTED->value => 'adjusted', - ]; - $currency = $autoBudget?->transactionCurrency; - $primary = $this->primary; - if (!$this->convertToPrimary) { - $primary = null; - } - if (null === $autoBudget) { - $currency = $primary; - } - if (null !== $autoBudget) { - $abType = $types[$autoBudget->auto_budget_type]; - $abAmount = Steam::bcround($autoBudget->amount, $currency->decimal_places); - $abPrimary = $this->convertToPrimary ? Steam::bcround($autoBudget->native_amount, $primary->decimal_places) : null; - $abPeriod = $autoBudget->period; + $currency = $budget->meta['currency'] ?? null; + + if (null !== $budget->meta['auto_budget']) { + $abType = $this->types[$budget->meta['auto_budget']['type']]; + $abAmount = Steam::bcround($budget->meta['auto_budget']['amount'], $currency->decimal_places); + $abPrimary = $this->convertToPrimary ? Steam::bcround($budget->meta['auto_budget']['pc_amount'], $this->primaryCurrency->decimal_places) : null; + $abPeriod = $budget->meta['auto_budget']['period']; } return [ - 'id' => (string) $budget->id, - 'created_at' => $budget->created_at->toAtomString(), - 'updated_at' => $budget->updated_at->toAtomString(), - 'active' => $budget->active, - 'name' => $budget->name, - 'order' => $budget->order, - 'notes' => $notes, - 'auto_budget_type' => $abType, - 'auto_budget_period' => $abPeriod, + 'id' => (string)$budget->id, + 'created_at' => $budget->created_at->toAtomString(), + 'updated_at' => $budget->updated_at->toAtomString(), + 'active' => $budget->active, + 'name' => $budget->name, + 'order' => $budget->order, + 'notes' => $budget->meta['notes'], + 'auto_budget_type' => $abType, + 'auto_budget_period' => $abPeriod, - 'currency_id' => null === $autoBudget ? null : (string) $autoBudget->transactionCurrency->id, - 'currency_code' => $autoBudget?->transactionCurrency->code, - 'currency_name' => $autoBudget?->transactionCurrency->name, - 'currency_decimal_places' => $autoBudget?->transactionCurrency->decimal_places, - 'currency_symbol' => $autoBudget?->transactionCurrency->symbol, + // new currency settings. + 'object_has_currency_setting' => null !== $budget->meta['currency'], + 'currency_id' => null === $currency ? null : (string)$currency->id, + 'currency_code' => $currency?->code, + 'currency_name' => $currency?->name, + 'currency_symbol' => $currency?->symbol, + 'currency_decimal_places' => $currency?->decimal_places, - 'primary_currency_id' => $primary instanceof TransactionCurrency ? (string) $primary->id : null, - 'primary_currency_code' => $primary?->code, - 'primary_currency_symbol' => $primary?->symbol, - 'primary_currency_decimal_places' => $primary?->decimal_places, + 'primary_currency_id' => (string)$this->primaryCurrency->id, + 'primary_currency_name' => $this->primaryCurrency->name, + 'primary_currency_code' => $this->primaryCurrency->code, + 'primary_currency_symbol' => $this->primaryCurrency->symbol, + 'primary_currency_decimal_places' => $this->primaryCurrency->decimal_places, - // amount and primary currency amount if present. - - 'auto_budget_amount' => $abAmount, - 'pc_auto_budget_amount' => $abPrimary, - 'spent' => $spent, // always in primary currency. - 'links' => [ + 'auto_budget_amount' => $abAmount, + 'pc_auto_budget_amount' => $abPrimary, + 'spent' => $this->beautify($budget->meta['spent']), // always in primary currency. + 'pc_spent' => $this->beautify($budget->meta['pc_spent']), // always in primary currency. + 'links' => [ [ 'rel' => 'self', - 'uri' => '/budgets/'.$budget->id, + 'uri' => '/budgets/' . $budget->id, ], ], ]; @@ -137,7 +118,7 @@ class BudgetTransformer extends AbstractTransformer { $return = []; foreach ($array as $data) { - $data['sum'] = Steam::bcround($data['sum'], (int) $data['currency_decimal_places']); + $data['sum'] = Steam::bcround($data['sum'], (int)$data['currency_decimal_places']); $return[] = $data; } diff --git a/resources/views/budgets/edit.twig b/resources/views/budgets/edit.twig index a68fe8a6c7..ebe8d604b7 100644 --- a/resources/views/budgets/edit.twig +++ b/resources/views/budgets/edit.twig @@ -34,6 +34,7 @@ {{ CurrencyForm.currencyList('auto_budget_currency_id', autoBudget.transaction_currency_id) }} {{ ExpandedForm.amountNoCurrency('auto_budget_amount', preFilled.auto_budget_amount) }} {{ ExpandedForm.select('auto_budget_period', autoBudgetPeriods, autoBudget.period) }} + {{ ExpandedForm.textarea('notes',preFilled.notes,{helpText: trans('firefly.field_supports_markdown')}) }} {{ ExpandedForm.file('attachments[]', {'multiple': 'multiple','helpText': trans('firefly.upload_max_file_size', {'size': uploadSize|filesize}) }) }}