diff --git a/app/Api/V1/Controllers/Models/AvailableBudget/ShowController.php b/app/Api/V1/Controllers/Models/AvailableBudget/ShowController.php index 582290ec21..3cecd9c500 100644 --- a/app/Api/V1/Controllers/Models/AvailableBudget/ShowController.php +++ b/app/Api/V1/Controllers/Models/AvailableBudget/ShowController.php @@ -28,6 +28,7 @@ use FireflyIII\Api\V1\Controllers\Controller; use FireflyIII\Exceptions\FireflyException; use FireflyIII\Models\AvailableBudget; use FireflyIII\Repositories\Budget\AvailableBudgetRepositoryInterface; +use FireflyIII\Support\JsonApi\Enrichments\AvailableBudgetEnrichment; use FireflyIII\Transformers\AvailableBudgetTransformer; use FireflyIII\User; use Illuminate\Http\JsonResponse; @@ -71,28 +72,36 @@ class ShowController extends Controller */ public function index(): JsonResponse { - $manager = $this->getManager(); + $manager = $this->getManager(); // types to get, page size: - $pageSize = $this->parameters->get('limit'); - - $start = $this->parameters->get('start'); - $end = $this->parameters->get('end'); + $pageSize = $this->parameters->get('limit'); + $start = $this->parameters->get('start'); + $end = $this->parameters->get('end'); // get list of available budgets. Count it and split it. $collection = $this->abRepository->getAvailableBudgetsByDate($start, $end); $count = $collection->count(); $availableBudgets = $collection->slice(($this->parameters->get('page') - 1) * $pageSize, $pageSize); + // enrich + /** @var User $admin */ + $admin = auth()->user(); + $enrichment = new AvailableBudgetEnrichment(); + $enrichment->setUser($admin); + $enrichment->setStart($start); + $enrichment->setEnd($end); + $availableBudgets = $enrichment->enrich($availableBudgets); + // make paginator: - $paginator = new LengthAwarePaginator($availableBudgets, $count, $pageSize, $this->parameters->get('page')); - $paginator->setPath(route('api.v1.available-budgets.index').$this->buildParams()); + $paginator = new LengthAwarePaginator($availableBudgets, $count, $pageSize, $this->parameters->get('page')); + $paginator->setPath(route('api.v1.available-budgets.index') . $this->buildParams()); /** @var AvailableBudgetTransformer $transformer */ - $transformer = app(AvailableBudgetTransformer::class); + $transformer = app(AvailableBudgetTransformer::class); $transformer->setParameters($this->parameters); - $resource = new FractalCollection($availableBudgets, $transformer, 'available_budgets'); + $resource = new FractalCollection($availableBudgets, $transformer, 'available_budgets'); $resource->setPaginator(new IlluminatePaginatorAdapter($paginator)); return response()->json($manager->createData($resource)->toArray())->header('Content-Type', self::CONTENT_TYPE); @@ -106,13 +115,25 @@ class ShowController extends Controller */ public function show(AvailableBudget $availableBudget): JsonResponse { - $manager = $this->getManager(); + $manager = $this->getManager(); + $start = $this->parameters->get('start'); + $end = $this->parameters->get('end'); /** @var AvailableBudgetTransformer $transformer */ $transformer = app(AvailableBudgetTransformer::class); $transformer->setParameters($this->parameters); - $resource = new Item($availableBudget, $transformer, 'available_budgets'); + // enrich + /** @var User $admin */ + $admin = auth()->user(); + $enrichment = new AvailableBudgetEnrichment(); + $enrichment->setUser($admin); + $enrichment->setStart($start); + $enrichment->setEnd($end); + $availableBudget = $enrichment->enrichSingle($availableBudget); + + + $resource = new Item($availableBudget, $transformer, 'available_budgets'); return response()->json($manager->createData($resource)->toArray())->header('Content-Type', self::CONTENT_TYPE); } diff --git a/app/Repositories/Budget/OperationsRepository.php b/app/Repositories/Budget/OperationsRepository.php index 7c8b1c5217..a2fc46ca2a 100644 --- a/app/Repositories/Budget/OperationsRepository.php +++ b/app/Repositories/Budget/OperationsRepository.php @@ -57,17 +57,17 @@ class OperationsRepository implements OperationsRepositoryInterface, UserGroupIn $total = '0'; $count = 0; foreach ($budget->budgetlimits as $limit) { - $diff = (int)$limit->start_date->diffInDays($limit->end_date, true); + $diff = (int) $limit->start_date->diffInDays($limit->end_date, true); $diff = 0 === $diff ? 1 : $diff; $amount = $limit->amount; - $perDay = bcdiv((string)$amount, (string)$diff); + $perDay = bcdiv((string) $amount, (string) $diff); $total = bcadd($total, $perDay); ++$count; app('log')->debug(sprintf('Found %d budget limits. Per day is %s, total is %s', $count, $perDay, $total)); } - $avg = $total; + $avg = $total; if ($count > 0) { - $avg = bcdiv($total, (string)$count); + $avg = bcdiv($total, (string) $count); } app('log')->debug(sprintf('%s / %d = %s = average.', $total, $count, $avg)); @@ -86,21 +86,21 @@ class OperationsRepository implements OperationsRepositoryInterface, UserGroupIn // get all transactions: /** @var GroupCollectorInterface $collector */ - $collector = app(GroupCollectorInterface::class); + $collector = app(GroupCollectorInterface::class); $collector->setAccounts($accounts)->setRange($start, $end); $collector->setBudgets($budgets); - $journals = $collector->getExtractedJournals(); + $journals = $collector->getExtractedJournals(); // loop transactions: /** @var array $journal */ foreach ($journals as $journal) { // prep data array for currency: - $budgetId = (int)$journal['budget_id']; - $budgetName = $journal['budget_name']; - $currencyId = (int)$journal['currency_id']; - $key = sprintf('%d-%d', $budgetId, $currencyId); + $budgetId = (int) $journal['budget_id']; + $budgetName = $journal['budget_name']; + $currencyId = (int) $journal['currency_id']; + $key = sprintf('%d-%d', $budgetId, $currencyId); - $data[$key] ??= [ + $data[$key] ??= [ 'id' => $budgetId, 'name' => sprintf('%s (%s)', $budgetName, $journal['currency_name']), 'sum' => '0', @@ -112,7 +112,7 @@ class OperationsRepository implements OperationsRepositoryInterface, UserGroupIn 'entries' => [], ]; $date = $journal['date']->format($carbonFormat); - $data[$key]['entries'][$date] = bcadd($data[$key]['entries'][$date] ?? '0', (string)$journal['amount']); + $data[$key]['entries'][$date] = bcadd($data[$key]['entries'][$date] ?? '0', (string) $journal['amount']); } return $data; @@ -126,7 +126,7 @@ class OperationsRepository implements OperationsRepositoryInterface, UserGroupIn public function listExpenses(Carbon $start, Carbon $end, ?Collection $accounts = null, ?Collection $budgets = null): array { /** @var GroupCollectorInterface $collector */ - $collector = app(GroupCollectorInterface::class); + $collector = app(GroupCollectorInterface::class); $collector->setUser($this->user)->setRange($start, $end)->setTypes([TransactionTypeEnum::WITHDRAWAL->value]); if ($accounts instanceof Collection && $accounts->count() > 0) { $collector->setAccounts($accounts); @@ -138,8 +138,8 @@ class OperationsRepository implements OperationsRepositoryInterface, UserGroupIn $collector->setBudgets($this->getBudgets()); } $collector->withBudgetInformation()->withAccountInformation()->withCategoryInformation(); - $journals = $collector->getExtractedJournals(); - $array = []; + $journals = $collector->getExtractedJournals(); + $array = []; // if needs conversion to primary. $convertToPrimary = Amount::convertToPrimary($this->user); @@ -155,8 +155,8 @@ class OperationsRepository implements OperationsRepositoryInterface, UserGroupIn ]; foreach ($journals as $journal) { - $amount = app('steam')->negative($journal['amount']); - $journalCurrencyId = (int)$journal['currency_id']; + $amount = app('steam')->negative($journal['amount']); + $journalCurrencyId = (int) $journal['currency_id']; if (false === $convertToPrimary) { $currencyId = $journalCurrencyId; $currencyName = $journal['currency_name']; @@ -166,11 +166,11 @@ class OperationsRepository implements OperationsRepositoryInterface, UserGroupIn } if (true === $convertToPrimary && $journalCurrencyId !== $currencyId) { $currencies[$journalCurrencyId] ??= TransactionCurrency::find($journalCurrencyId); - $amount = $converter->convert($currencies[$journalCurrencyId], $primaryCurrency, $journal['date'], $amount); + $amount = $converter->convert($currencies[$journalCurrencyId], $primaryCurrency, $journal['date'], $amount); } - $budgetId = (int)$journal['budget_id']; - $budgetName = (string)$journal['budget_name']; + $budgetId = (int) $journal['budget_id']; + $budgetName = (string) $journal['budget_name']; // catch "no budget" entries. if (0 === $budgetId) { @@ -178,7 +178,7 @@ class OperationsRepository implements OperationsRepositoryInterface, UserGroupIn } // info about the currency: - $array[$currencyId] ??= [ + $array[$currencyId] ??= [ 'budgets' => [], 'currency_id' => $currencyId, 'currency_name' => $currencyName, @@ -196,7 +196,7 @@ class OperationsRepository implements OperationsRepositoryInterface, UserGroupIn // add journal to array: // only a subset of the fields. - $journalId = (int)$journal['transaction_journal_id']; + $journalId = (int) $journal['transaction_journal_id']; $array[$currencyId]['budgets'][$budgetId]['transaction_journals'][$journalId] = [ 'amount' => $amount, 'destination_account_id' => $journal['destination_account_id'], @@ -231,7 +231,8 @@ class OperationsRepository implements OperationsRepositoryInterface, UserGroupIn ?Collection $budgets = null, ?TransactionCurrency $currency = null, bool $convertToPrimary = false - ): array { + ): array + { Log::debug(sprintf('Start of %s(date, date, array, array, "%s", %s).', __METHOD__, $currency?->code, var_export($convertToPrimary, true))); // this collector excludes all transfers TO liabilities (which are also withdrawals) // because those expenses only become expenses once they move from the liability to the friend. @@ -239,8 +240,8 @@ class OperationsRepository implements OperationsRepositoryInterface, UserGroupIn $repository = app(AccountRepositoryInterface::class); $repository->setUser($this->user); - $subset = $repository->getAccountsByType(config('firefly.valid_liabilities')); - $selection = new Collection(); + $subset = $repository->getAccountsByType(config('firefly.valid_liabilities')); + $selection = new Collection(); /** @var Account $account */ foreach ($subset as $account) { @@ -250,12 +251,11 @@ class OperationsRepository implements OperationsRepositoryInterface, UserGroupIn } /** @var GroupCollectorInterface $collector */ - $collector = app(GroupCollectorInterface::class); + $collector = app(GroupCollectorInterface::class); $collector->setUser($this->user) - ->setRange($start, $end) + ->setRange($start, $end) // ->excludeDestinationAccounts($selection) - ->setTypes([TransactionTypeEnum::WITHDRAWAL->value]) - ; + ->setTypes([TransactionTypeEnum::WITHDRAWAL->value]); if ($accounts instanceof Collection) { $collector->setAccounts($accounts); @@ -270,7 +270,7 @@ class OperationsRepository implements OperationsRepositoryInterface, UserGroupIn if ($budgets->count() > 0) { $collector->setBudgets($budgets); } - $journals = $collector->getExtractedJournals(); + $journals = $collector->getExtractedJournals(); // same but for transactions in the foreign currency: if ($currency instanceof TransactionCurrency) { @@ -282,4 +282,61 @@ class OperationsRepository implements OperationsRepositoryInterface, UserGroupIn return $summarizer->groupByCurrencyId($journals, 'negative', false); } + + public function sumCollectedExpenses(array $expenses, Carbon $start, Carbon $end, bool $convertToPrimary = false): array + { + Log::debug(sprintf('Start of %s.', __METHOD__)); + $summarizer = new TransactionSummarizer($this->user); + // 2025-04-21 overrule "convertToPrimary" because in this particular view, we never want to do this. + $summarizer->setConvertToPrimary($convertToPrimary); + + // filter $journals by range. + $expenses = array_filter($expenses, static function (array $expense) use ($start, $end): bool { + return $expense['date']->between($start, $end); + }); + + return $summarizer->groupByCurrencyId($expenses, 'negative', false); + } + + #[\Override] public function collectExpenses(Carbon $start, Carbon $end, ?Collection $accounts = null, ?Collection $budgets = null, ?TransactionCurrency $currency = null): array + { + Log::debug(sprintf('Start of %s(date, date, array, array, "%s").', __METHOD__, $currency?->code)); + // this collector excludes all transfers TO liabilities (which are also withdrawals) + // because those expenses only become expenses once they move from the liability to the friend. + // 2024-12-24 disable the exclusion for now. + + $repository = app(AccountRepositoryInterface::class); + $repository->setUser($this->user); + $subset = $repository->getAccountsByType(config('firefly.valid_liabilities')); + $selection = new Collection(); + + /** @var Account $account */ + foreach ($subset as $account) { + if ('credit' === $repository->getMetaValue($account, 'liability_direction')) { + $selection->push($account); + } + } + + /** @var GroupCollectorInterface $collector */ + $collector = app(GroupCollectorInterface::class); + $collector->setUser($this->user) + ->setRange($start, $end) + // ->excludeDestinationAccounts($selection) + ->setTypes([TransactionTypeEnum::WITHDRAWAL->value]); + + if ($accounts instanceof Collection) { + $collector->setAccounts($accounts); + } + if (!$budgets instanceof Collection) { + $budgets = $this->getBudgets(); + } + if ($currency instanceof TransactionCurrency) { + Log::debug(sprintf('Limit to normal currency %s', $currency->code)); + $collector->setNormalCurrency($currency); + } + if ($budgets->count() > 0) { + $collector->setBudgets($budgets); + } + return $collector->getExtractedJournals(); + } } diff --git a/app/Repositories/Budget/OperationsRepositoryInterface.php b/app/Repositories/Budget/OperationsRepositoryInterface.php index 2329e4142a..487ae5a26b 100644 --- a/app/Repositories/Budget/OperationsRepositoryInterface.php +++ b/app/Repositories/Budget/OperationsRepositoryInterface.php @@ -24,8 +24,8 @@ declare(strict_types=1); namespace FireflyIII\Repositories\Budget; -use Deprecated; use Carbon\Carbon; +use Deprecated; use FireflyIII\Enums\UserRoleEnum; use FireflyIII\Models\Budget; use FireflyIII\Models\TransactionCurrency; @@ -73,4 +73,8 @@ interface OperationsRepositoryInterface ?TransactionCurrency $currency = null, bool $convertToPrimary = false ): array; + + public function sumCollectedExpenses(array $expenses, Carbon $start, Carbon $end, 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/AccountEnrichment.php b/app/Support/JsonApi/Enrichments/AccountEnrichment.php index 1c1337edfa..9336a11791 100644 --- a/app/Support/JsonApi/Enrichments/AccountEnrichment.php +++ b/app/Support/JsonApi/Enrichments/AccountEnrichment.php @@ -62,6 +62,9 @@ class AccountEnrichment implements EnrichmentInterface private UserGroup $userGroup; private array $lastActivities; + /** + * TODO Set primary currency using Amount::method, not through setter. + */ public function __construct() { $this->accountIds = []; diff --git a/app/Support/JsonApi/Enrichments/AvailableBudgetEnrichment.php b/app/Support/JsonApi/Enrichments/AvailableBudgetEnrichment.php new file mode 100644 index 0000000000..0500ad2863 --- /dev/null +++ b/app/Support/JsonApi/Enrichments/AvailableBudgetEnrichment.php @@ -0,0 +1,157 @@ +primaryCurrency = Amount::getPrimaryCurrency(); + $this->convertToPrimary = Amount::convertToPrimary(); + $this->noBudgetRepository = app(NoBudgetRepositoryInterface::class); + $this->opsRepository = app(OperationsRepositoryInterface::class); + $this->repository = app(BudgetRepositoryInterface::class); + } + + #[\Override] public function enrich(Collection $collection): Collection + { + $this->collection = $collection; + $this->collectIds(); + $this->collectSpentInfo(); + $this->appendCollectedData(); + + return $this->collection; + } + + #[\Override] public function enrichSingle(Model | array $model): array | Model + { + Log::debug(__METHOD__); + $collection = new Collection([$model]); + $collection = $this->enrich($collection); + + return $collection->first(); + } + + #[\Override] public function setUser(User $user): void + { + $this->user = $user; + $this->setUserGroup($user->userGroup); + } + + #[\Override] public function setUserGroup(UserGroup $userGroup): void + { + $this->userGroup = $userGroup; + $this->noBudgetRepository->setUserGroup($userGroup); + $this->opsRepository->setUserGroup($userGroup); + $this->repository->setUserGroup($userGroup); + } + + private function collectIds(): void + { + /** @var AvailableBudget $availableBudget */ + foreach ($this->collection as $availableBudget) { + $this->ids[] = (int) $availableBudget->id; + } + $this->ids = array_unique($this->ids); + } + + public function setStart(?Carbon $start): void + { + $this->start = $start; + } + + public function setEnd(?Carbon $end): void + { + $this->end = $end; + } + + private function collectSpentInfo(): void { + $start = $this->collection->min('start_date'); + $end = $this->collection->max('end_date'); + $allActive = $this->repository->getActiveBudgets(); + $spentInBudgets = $this->opsRepository->collectExpenses($start, $end, null, $allActive, null); + foreach($this->collection as $availableBudget) { + $filteredSpentInBudgets = $this->opsRepository->sumCollectedExpenses($spentInBudgets, $availableBudget->start_date, $availableBudget->end_date, $this->convertToPrimary); + $id = (int) $availableBudget->id; + $this->spentInBudgets[$id] = array_values($filteredSpentInBudgets); + // filter arrays on date. + // send them to sumCollection thing. + // save. + } + + // first collect, then filter and append. + } + + private function appendCollectedData(): void + { + $spentInsideBudgets = $this->spentInBudgets; + $spentOutsideBudgets = $this->spentOutsideBudgets; + $pcSpentInBudgets = $this->pcSpentInBudgets; + $pcSpentOutsideBudgets = $this->pcSpentOutsideBudgets; + $this->collection = $this->collection->map(function (AvailableBudget $item) use ($spentInsideBudgets, $spentOutsideBudgets, $pcSpentInBudgets, $pcSpentOutsideBudgets) { + $id = (int) $item->id; + $meta = [ + 'spent_in_budgets' => $spentInsideBudgets[$id] ?? [], + 'spent_outside_budgets' => $spentOutsideBudgets[$id] ?? [], + 'pc_spent_in_budgets' => $pcSpentInBudgets[$id] ?? [], + 'pc_spent_outside_budgets' => $pcSpentOutsideBudgets ?? [], + ]; + $item->meta = $meta; + return $item; + }); + } + + +} diff --git a/app/Transformers/AvailableBudgetTransformer.php b/app/Transformers/AvailableBudgetTransformer.php index cb8a52b161..648dac8d17 100644 --- a/app/Transformers/AvailableBudgetTransformer.php +++ b/app/Transformers/AvailableBudgetTransformer.php @@ -60,42 +60,51 @@ class AvailableBudgetTransformer extends AbstractTransformer public function transform(AvailableBudget $availableBudget): array { $this->repository->setUser($availableBudget->user); - $currency = $availableBudget->transactionCurrency; - $primary = $this->primary; - if (!$this->convertToPrimary) { - $primary = null; + $amount = app('steam')->bcround($availableBudget->amount, $currency->decimal_places); + $pcAmount = null; + + if ($this->convertToPrimary) { + $pcAmount = app('steam')->bcround($availableBudget->native_amount, $this->primary->decimal_places); } - $data = [ - 'id' => (string)$availableBudget->id, - 'created_at' => $availableBudget->created_at->toAtomString(), - 'updated_at' => $availableBudget->updated_at->toAtomString(), - 'currency_id' => (string)$currency->id, - 'currency_code' => $currency->code, - '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, - 'amount' => app('steam')->bcround($availableBudget->amount, $currency->decimal_places), - 'pc_amount' => $this->convertToPrimary ? app('steam')->bcround($availableBudget->native_amount, $currency->decimal_places) : null, - 'start' => $availableBudget->start_date->toAtomString(), - 'end' => $availableBudget->end_date->endOfDay()->toAtomString(), - 'spent_in_budgets' => [], - 'spent_no_budget' => [], - 'links' => [ + + $data = [ + 'id' => (string) $availableBudget->id, + 'created_at' => $availableBudget->created_at->toAtomString(), + 'updated_at' => $availableBudget->updated_at->toAtomString(), + + // currencies according to 6.3.0 + 'currency_id' => (string) $currency->id, + 'currency_code' => $currency->code, + 'currency_symbol' => $currency->symbol, + 'currency_decimal_places' => $currency->decimal_places, + + 'primary_currency_id' => (string) $this->primary->id, + '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(), + 'end' => $availableBudget->end_date->endOfDay()->toAtomString(), + 'spent_in_budgets' => $availableBudget->meta['spent_in_budgets'], + 'pc_spent_in_budgets' => $availableBudget->meta['pc_spent_in_budgets'], + 'spent_outside_budgets' => $availableBudget->meta['spent_outside_budgets'], + 'pc_spent_outside_budgets' => $availableBudget->meta['pc_spent_outside_budgets'], + 'links' => [ [ 'rel' => 'self', - 'uri' => '/available_budgets/'.$availableBudget->id, + 'uri' => '/available_budgets/' . $availableBudget->id, ], ], ]; - $start = $this->parameters->get('start'); - $end = $this->parameters->get('end'); + $start = $this->parameters->get('start'); + $end = $this->parameters->get('end'); if (null !== $start && null !== $end) { - $data['spent_in_budgets'] = $this->getSpentInBudgets(); - $data['spent_no_budget'] = $this->spentOutsideBudgets(); + $data['old_spent_in_budgets'] = $this->getSpentInBudgets(); + $data['old_spent_no_budget'] = $this->spentOutsideBudgets(); } return $data;