Add budget transformer and enrichment.

This commit is contained in:
James Cole
2025-08-03 20:17:50 +02:00
parent e55fc483bd
commit 6a49918707
12 changed files with 249 additions and 70 deletions

View File

@@ -29,7 +29,9 @@ use FireflyIII\Exceptions\FireflyException;
use FireflyIII\Models\Budget; use FireflyIII\Models\Budget;
use FireflyIII\Repositories\Budget\BudgetLimitRepositoryInterface; use FireflyIII\Repositories\Budget\BudgetLimitRepositoryInterface;
use FireflyIII\Repositories\Budget\BudgetRepositoryInterface; use FireflyIII\Repositories\Budget\BudgetRepositoryInterface;
use FireflyIII\Support\JsonApi\Enrichments\BudgetEnrichment;
use FireflyIII\Transformers\BudgetTransformer; use FireflyIII\Transformers\BudgetTransformer;
use FireflyIII\User;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Pagination\LengthAwarePaginator; use Illuminate\Pagination\LengthAwarePaginator;
use League\Fractal\Pagination\IlluminatePaginatorAdapter; use League\Fractal\Pagination\IlluminatePaginatorAdapter;
@@ -82,6 +84,15 @@ class ShowController extends Controller
$count = $collection->count(); $count = $collection->count();
$budgets = $collection->slice(($this->parameters->get('page') - 1) * $pageSize, $pageSize); $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: // make paginator:
$paginator = new LengthAwarePaginator($budgets, $count, $pageSize, $this->parameters->get('page')); $paginator = new LengthAwarePaginator($budgets, $count, $pageSize, $this->parameters->get('page'));
$paginator->setPath(route('api.v1.budgets.index').$this->buildParams()); $paginator->setPath(route('api.v1.budgets.index').$this->buildParams());
@@ -103,6 +114,15 @@ class ShowController extends Controller
{ {
$manager = $this->getManager(); $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 */ /** @var BudgetTransformer $transformer */
$transformer = app(BudgetTransformer::class); $transformer = app(BudgetTransformer::class);
$transformer->setParameters($this->parameters); $transformer->setParameters($this->parameters);

View File

@@ -95,6 +95,7 @@ class EditController extends Controller
$preFilled = [ $preFilled = [
'active' => $hasOldInput ? (bool) $request->old('active') : $budget->active, 'active' => $hasOldInput ? (bool) $request->old('active') : $budget->active,
'auto_budget_currency_id' => $hasOldInput ? (int) $request->old('auto_budget_currency_id') : $this->primaryCurrency->id, 'auto_budget_currency_id' => $hasOldInput ? (int) $request->old('auto_budget_currency_id') : $this->primaryCurrency->id,
'notes' => $this->repository->getNoteText($budget),
]; ];
if ($autoBudget instanceof AutoBudget) { if ($autoBudget instanceof AutoBudget) {
$amount = $hasOldInput ? $request->old('auto_budget_amount') : $autoBudget->amount; $amount = $hasOldInput ? $request->old('auto_budget_amount') : $autoBudget->amount;

View File

@@ -53,6 +53,7 @@ class BudgetFormUpdateRequest extends FormRequest
'currency_id' => $this->convertInteger('auto_budget_currency_id'), 'currency_id' => $this->convertInteger('auto_budget_currency_id'),
'auto_budget_amount' => $this->convertString('auto_budget_amount'), 'auto_budget_amount' => $this->convertString('auto_budget_amount'),
'auto_budget_period' => $this->convertString('auto_budget_period'), 'auto_budget_period' => $this->convertString('auto_budget_period'),
'notes' => $this->stringWithNewlines('notes'),
]; ];
} }

View File

@@ -290,7 +290,7 @@ class OperationsRepository implements OperationsRepositoryInterface, UserGroupIn
$summarizer = new TransactionSummarizer($this->user); $summarizer = new TransactionSummarizer($this->user);
$summarizer->setConvertToPrimary($convertToPrimary); $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 { $expenses = array_filter($expenses, static function (array $expense) use ($start, $end, $transactionCurrency): bool {
return $expense['date']->between($start, $end) && $expense['currency_id'] === $transactionCurrency->id; 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); 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] #[Override]
public function collectExpenses(Carbon $start, Carbon $end, ?Collection $accounts = null, ?Collection $budgets = null, ?TransactionCurrency $currency = null): array public function collectExpenses(Carbon $start, Carbon $end, ?Collection $accounts = null, ?Collection $budgets = null, ?TransactionCurrency $currency = null): array
{ {

View File

@@ -75,6 +75,7 @@ interface OperationsRepositoryInterface
): array; ): array;
public function sumCollectedExpenses(array $expenses, Carbon $start, Carbon $end, TransactionCurrency $transactionCurrency, bool $convertToPrimary = false): 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; public function collectExpenses(Carbon $start, Carbon $end, ?Collection $accounts = null, ?Collection $budgets = null, ?TransactionCurrency $currency = null): array;
} }

View File

@@ -0,0 +1,154 @@
<?php
namespace FireflyIII\Support\JsonApi\Enrichments;
use Carbon\Carbon;
use FireflyIII\Models\AutoBudget;
use FireflyIII\Models\Budget;
use FireflyIII\Models\Note;
use FireflyIII\Models\TransactionCurrency;
use FireflyIII\Models\UserGroup;
use FireflyIII\Repositories\Budget\OperationsRepositoryInterface;
use FireflyIII\Support\Facades\Amount;
use FireflyIII\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
class BudgetEnrichment implements EnrichmentInterface
{
private Collection $collection;
private bool $convertToPrimary = true;
private TransactionCurrency $primaryCurrency;
private User $user;
private UserGroup $userGroup;
private array $ids = [];
private array $notes = [];
private array $autoBudgets = [];
private array $currencies = [];
private ?Carbon $start = null;
private ?Carbon $end = null;
private array $spent = [];
private array $pcSpent = [];
public function __construct()
{
$this->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;
}
}

View File

@@ -123,7 +123,7 @@ class BudgetLimitEnrichment implements EnrichmentInterface
$this->pcExpenses[$id] = array_values($pcFilteredExpenses); $this->pcExpenses[$id] = array_values($pcFilteredExpenses);
} }
if (true === $this->convertToPrimary && $budgetLimit->transactionCurrency->id === $this->primaryCurrency->id) { if (true === $this->convertToPrimary && $budgetLimit->transactionCurrency->id === $this->primaryCurrency->id) {
$this->pcExpenses[$id] = $this->expenses[$id]; $this->pcExpenses[$id] = $this->expenses[$id] ?? [];
} }
} }
} }

View File

@@ -113,10 +113,13 @@ class AccountTransformer extends AbstractTransformer
// currency is object specific or primary, already determined above. // currency is object specific or primary, already determined above.
'currency_id' => (string) $currency['id'], 'currency_id' => (string) $currency['id'],
'currency_name' => $currency['name'],
'currency_code' => $currency['code'], 'currency_code' => $currency['code'],
'currency_symbol' => $currency['symbol'], 'currency_symbol' => $currency['symbol'],
'currency_decimal_places' => $currency['decimal_places'], 'currency_decimal_places' => $currency['decimal_places'],
'primary_currency_id' => (string) $this->primary->id, 'primary_currency_id' => (string) $this->primary->id,
'primary_currency_name' => $this->primary->name,
'primary_currency_code' => $this->primary->code, 'primary_currency_code' => $this->primary->code,
'primary_currency_symbol' => $this->primary->symbol, 'primary_currency_symbol' => $this->primary->symbol,
'primary_currency_decimal_places' => $this->primary->decimal_places, 'primary_currency_decimal_places' => $this->primary->decimal_places,

View File

@@ -66,16 +66,17 @@ class AvailableBudgetTransformer extends AbstractTransformer
// currencies according to 6.3.0 // currencies according to 6.3.0
'object_has_currency_setting' => true, 'object_has_currency_setting' => true,
'currency_id' => (string) $currency->id, 'currency_id' => (string) $currency->id,
'currency_name' => $currency->name,
'currency_code' => $currency->code, 'currency_code' => $currency->code,
'currency_symbol' => $currency->symbol, 'currency_symbol' => $currency->symbol,
'currency_decimal_places' => $currency->decimal_places, 'currency_decimal_places' => $currency->decimal_places,
'primary_currency_id' => (string) $this->primary->id, 'primary_currency_id' => (string) $this->primary->id,
'primary_currency_name' => $this->primary->name,
'primary_currency_code' => $this->primary->code, 'primary_currency_code' => $this->primary->code,
'primary_currency_symbol' => $this->primary->symbol, 'primary_currency_symbol' => $this->primary->symbol,
'primary_currency_decimal_places' => $this->primary->decimal_places, 'primary_currency_decimal_places' => $this->primary->decimal_places,
'amount' => $amount, 'amount' => $amount,
'pc_amount' => $pcAmount, 'pc_amount' => $pcAmount,
'start' => $availableBudget->start_date->toAtomString(), 'start' => $availableBudget->start_date->toAtomString(),

View File

@@ -60,11 +60,13 @@ class BillTransformer extends AbstractTransformer
// currencies according to 6.3.0 // currencies according to 6.3.0
'object_has_currency_setting' => true, 'object_has_currency_setting' => true,
'currency_id' => (string) $bill->transaction_currency_id, 'currency_id' => (string) $bill->transaction_currency_id,
'currency_name' => $currency->name,
'currency_code' => $currency->code, 'currency_code' => $currency->code,
'currency_symbol' => $currency->symbol, 'currency_symbol' => $currency->symbol,
'currency_decimal_places' => $currency->decimal_places, 'currency_decimal_places' => $currency->decimal_places,
'primary_currency_id' => (string) $this->primary->id, 'primary_currency_id' => (string) $this->primary->id,
'primary_currency_name' => $this->primary->name,
'primary_currency_code' => $this->primary->code, 'primary_currency_code' => $this->primary->code,
'primary_currency_symbol' => $this->primary->symbol, 'primary_currency_symbol' => $this->primary->symbol,
'primary_currency_decimal_places' => $this->primary->decimal_places, 'primary_currency_decimal_places' => $this->primary->decimal_places,

View File

@@ -27,11 +27,8 @@ namespace FireflyIII\Transformers;
use FireflyIII\Enums\AutoBudgetType; use FireflyIII\Enums\AutoBudgetType;
use FireflyIII\Models\Budget; use FireflyIII\Models\Budget;
use FireflyIII\Models\TransactionCurrency; use FireflyIII\Models\TransactionCurrency;
use FireflyIII\Repositories\Budget\BudgetRepositoryInterface;
use FireflyIII\Repositories\Budget\OperationsRepositoryInterface;
use FireflyIII\Support\Facades\Amount; use FireflyIII\Support\Facades\Amount;
use FireflyIII\Support\Facades\Steam; use FireflyIII\Support\Facades\Steam;
use Illuminate\Support\Collection;
use Symfony\Component\HttpFoundation\ParameterBag; use Symfony\Component\HttpFoundation\ParameterBag;
/** /**
@@ -40,20 +37,22 @@ use Symfony\Component\HttpFoundation\ParameterBag;
class BudgetTransformer extends AbstractTransformer class BudgetTransformer extends AbstractTransformer
{ {
private readonly bool $convertToPrimary; private readonly bool $convertToPrimary;
private readonly TransactionCurrency $primary; private readonly TransactionCurrency $primaryCurrency;
private readonly OperationsRepositoryInterface $opsRepository; private array $types;
private readonly BudgetRepositoryInterface $repository;
/** /**
* BudgetTransformer constructor. * BudgetTransformer constructor.
*/ */
public function __construct() public function __construct()
{ {
$this->opsRepository = app(OperationsRepositoryInterface::class);
$this->repository = app(BudgetRepositoryInterface::class);
$this->parameters = new ParameterBag(); $this->parameters = new ParameterBag();
$this->primary = Amount::getPrimaryCurrency(); $this->primaryCurrency = Amount::getPrimaryCurrency();
$this->convertToPrimary = Amount::convertToPrimary(); $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 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. // info for auto budget.
$abType = null; $abType = null;
$abAmount = null; $abAmount = null;
$abPrimary = null; $abPrimary = null;
$abPeriod = null; $abPeriod = null;
$notes = $this->repository->getNoteText($budget);
$types = [ $currency = $budget->meta['currency'] ?? null;
AutoBudgetType::AUTO_BUDGET_RESET->value => 'reset',
AutoBudgetType::AUTO_BUDGET_ROLLOVER->value => 'rollover', if (null !== $budget->meta['auto_budget']) {
AutoBudgetType::AUTO_BUDGET_ADJUSTED->value => 'adjusted', $abType = $this->types[$budget->meta['auto_budget']['type']];
]; $abAmount = Steam::bcround($budget->meta['auto_budget']['amount'], $currency->decimal_places);
$currency = $autoBudget?->transactionCurrency; $abPrimary = $this->convertToPrimary ? Steam::bcround($budget->meta['auto_budget']['pc_amount'], $this->primaryCurrency->decimal_places) : null;
$primary = $this->primary; $abPeriod = $budget->meta['auto_budget']['period'];
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;
} }
return [ return [
'id' => (string) $budget->id, 'id' => (string)$budget->id,
'created_at' => $budget->created_at->toAtomString(), 'created_at' => $budget->created_at->toAtomString(),
'updated_at' => $budget->updated_at->toAtomString(), 'updated_at' => $budget->updated_at->toAtomString(),
'active' => $budget->active, 'active' => $budget->active,
'name' => $budget->name, 'name' => $budget->name,
'order' => $budget->order, 'order' => $budget->order,
'notes' => $notes, 'notes' => $budget->meta['notes'],
'auto_budget_type' => $abType, 'auto_budget_type' => $abType,
'auto_budget_period' => $abPeriod, 'auto_budget_period' => $abPeriod,
'currency_id' => null === $autoBudget ? null : (string) $autoBudget->transactionCurrency->id, // new currency settings.
'currency_code' => $autoBudget?->transactionCurrency->code, 'object_has_currency_setting' => null !== $budget->meta['currency'],
'currency_name' => $autoBudget?->transactionCurrency->name, 'currency_id' => null === $currency ? null : (string)$currency->id,
'currency_decimal_places' => $autoBudget?->transactionCurrency->decimal_places, 'currency_code' => $currency?->code,
'currency_symbol' => $autoBudget?->transactionCurrency->symbol, '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_id' => (string)$this->primaryCurrency->id,
'primary_currency_code' => $primary?->code, 'primary_currency_name' => $this->primaryCurrency->name,
'primary_currency_symbol' => $primary?->symbol, 'primary_currency_code' => $this->primaryCurrency->code,
'primary_currency_decimal_places' => $primary?->decimal_places, '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, 'auto_budget_amount' => $abAmount,
'pc_auto_budget_amount' => $abPrimary, 'pc_auto_budget_amount' => $abPrimary,
'spent' => $spent, // always in primary currency. 'spent' => $this->beautify($budget->meta['spent']), // always in primary currency.
'pc_spent' => $this->beautify($budget->meta['pc_spent']), // always in primary currency.
'links' => [ 'links' => [
[ [
'rel' => 'self', 'rel' => 'self',
'uri' => '/budgets/'.$budget->id, 'uri' => '/budgets/' . $budget->id,
], ],
], ],
]; ];
@@ -137,7 +118,7 @@ class BudgetTransformer extends AbstractTransformer
{ {
$return = []; $return = [];
foreach ($array as $data) { 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; $return[] = $data;
} }

View File

@@ -34,6 +34,7 @@
{{ CurrencyForm.currencyList('auto_budget_currency_id', autoBudget.transaction_currency_id) }} {{ CurrencyForm.currencyList('auto_budget_currency_id', autoBudget.transaction_currency_id) }}
{{ ExpandedForm.amountNoCurrency('auto_budget_amount', preFilled.auto_budget_amount) }} {{ ExpandedForm.amountNoCurrency('auto_budget_amount', preFilled.auto_budget_amount) }}
{{ ExpandedForm.select('auto_budget_period', autoBudgetPeriods, autoBudget.period) }} {{ 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}) }) }} {{ ExpandedForm.file('attachments[]', {'multiple': 'multiple','helpText': trans('firefly.upload_max_file_size', {'size': uploadSize|filesize}) }) }}
</div> </div>
</div> </div>