From d4ab69ebe66614bbc327570a5bc266e7fe334b39 Mon Sep 17 00:00:00 2001 From: James Cole Date: Wed, 6 Aug 2025 09:15:54 +0200 Subject: [PATCH] Expand piggy bank enrichment. --- .../Models/PiggyBank/ShowController.php | 8 + .../Enrichments/PiggyBankEnrichment.php | 267 ++++++++++++++++++ .../Enrichments/PiggyBankEventEnrichment.php | 1 + .../Enrichments/SubscriptionEnrichment.php | 2 + app/Transformers/AccountTransformer.php | 2 + app/Transformers/BudgetTransformer.php | 2 + app/Transformers/PiggyBankTransformer.php | 124 ++++---- 7 files changed, 336 insertions(+), 70 deletions(-) create mode 100644 app/Support/JsonApi/Enrichments/PiggyBankEnrichment.php diff --git a/app/Api/V1/Controllers/Models/PiggyBank/ShowController.php b/app/Api/V1/Controllers/Models/PiggyBank/ShowController.php index 2a1cfd36ad..b156b6c5db 100644 --- a/app/Api/V1/Controllers/Models/PiggyBank/ShowController.php +++ b/app/Api/V1/Controllers/Models/PiggyBank/ShowController.php @@ -28,6 +28,7 @@ use FireflyIII\Api\V1\Controllers\Controller; use FireflyIII\Exceptions\FireflyException; use FireflyIII\Models\PiggyBank; use FireflyIII\Repositories\PiggyBank\PiggyBankRepositoryInterface; +use FireflyIII\Support\JsonApi\Enrichments\PiggyBankEnrichment; use FireflyIII\Transformers\PiggyBankTransformer; use Illuminate\Http\JsonResponse; use Illuminate\Pagination\LengthAwarePaginator; @@ -101,6 +102,13 @@ class ShowController extends Controller { $manager = $this->getManager(); + // enrich + /** @var User $admin */ + $admin = auth()->user(); + $enrichment = new PiggyBankEnrichment(); + $enrichment->setUser($admin); + $piggyBank = $enrichment->enrichSingle($piggyBank); + /** @var PiggyBankTransformer $transformer */ $transformer = app(PiggyBankTransformer::class); $transformer->setParameters($this->parameters); diff --git a/app/Support/JsonApi/Enrichments/PiggyBankEnrichment.php b/app/Support/JsonApi/Enrichments/PiggyBankEnrichment.php new file mode 100644 index 0000000000..b0aa9176a4 --- /dev/null +++ b/app/Support/JsonApi/Enrichments/PiggyBankEnrichment.php @@ -0,0 +1,267 @@ +primaryCurrency = Amount::getPrimaryCurrency(); + } + + public function enrich(Collection $collection): Collection + { + $this->collection = $collection; + $this->collectIds(); + $this->collectObjectGroups(); + $this->collectNotes(); + $this->collectCurrentAmounts(); + + + $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 PiggyBank $piggy */ + foreach ($this->collection as $piggy) { + $id = (int)$piggy->id; + $this->ids[] = $id; + $this->currencyIds[$id] = (int)$piggy->transaction_currency_id; + } + $this->ids = array_unique($this->ids); + + // collect currencies. + $currencies = TransactionCurrency::whereIn('id', $this->currencyIds)->get(); + foreach ($currencies as $currency) { + $this->currencies[(int)$currency->id] = $currency; + } + + // collect accounts + $set = DB::table('account_piggy_bank')->whereIn('piggy_bank_id', $this->ids)->get(['piggy_bank_id', 'account_id', 'current_amount', 'native_current_amount']); + foreach ($set as $item) { + $id = (int)$item->piggy_bank_id; + $accountId = (int)$item->account_id; + $this->amounts[$id] ??= []; + if (!array_key_exists($id, $this->accountIds)) { + $this->accountIds[$id] = (int)$item->account_id; + } + if (!array_key_exists($accountId, $this->amounts[$id])) { + $this->amounts[$id][$accountId] = [ + 'current_amount' => '0', + 'pc_current_amount' => '0', + ]; + } + $this->amounts[$id][$accountId]['current_amount'] = bcadd($this->amounts[$id][$accountId]['current_amount'], $item->current_amount); + $this->amounts[$id][$accountId]['pc_current_amount'] = bcadd($this->amounts[$id][$accountId]['pc_current_amount'], $item->native_current_amount); + } + + // get account currency preference for ALL. + $set = AccountMeta::whereIn('account_id', array_values($this->accountIds))->where('name', 'currency_id')->get(); + /** @var AccountMeta $item */ + foreach ($set as $item) { + $accountId = (int)$item->account_id; + $currencyId = (int)$item->data; + if (!array_key_exists($currencyId, $this->currencies)) { + $this->currencies[$currencyId] = TransactionCurrency::find($currencyId); + } + $this->accountCurrencies[$accountId] = $this->currencies[$currencyId]; + } + + // get account info. + $set = Account::whereIn('id', array_values($this->accountIds))->get(); + /** @var Account $item */ + foreach ($set as $item) { + $id = (int)$item->id; + $this->accounts[$id] = [ + 'id' => $id, + 'name' => $item->name, + ]; + } + } + + private function appendCollectedData(): void + { + $this->collection = $this->collection->map(function (PiggyBank $item) { + $id = (int)$item->id; + $currencyId = (int)$item->transaction_currency_id; + $currency = $this->currencies[$currencyId] ?? $this->primaryCurrency; + $targetAmount = null; + if (0 !== bccomp($item->target_amount, '0')) { + $targetAmount = $item->target_amount; + } + $meta = [ + 'notes' => $this->notes[$id] ?? null, + 'currency' => $this->currencies[$currencyId] ?? null, + // 'auto_budget' => $this->autoBudgets[$id] ?? null, + // 'spent' => $this->spent[$id] ?? null, + // 'pc_spent' => $this->pcSpent[$id] ?? null, + 'object_group_id' => null, + 'object_group_order' => null, + 'object_group_title' => null, + 'current_amount' => null, + 'pc_current_amount' => null, + 'target_amount' => null === $targetAmount ? null : Steam::bcround($targetAmount, $currency->decimal_places), + 'pc_target_amount' => null === $item->native_target_amount ? null : Steam::bcround($item->native_target_amount, $this->primaryCurrency->decimal_places), + 'left_to_save' => null, + 'pc_left_to_save' => null, + 'save_per_month' => null, + 'pc_save_per_month' => null, + 'accounts' => [], + ]; + + // add object group if available + if (array_key_exists($id, $this->mappedObjects)) { + $key = $this->mappedObjects[$id]; + $meta['object_group_id'] = $this->objectGroups[$key]['id']; + $meta['object_group_title'] = $this->objectGroups[$key]['title']; + $meta['object_group_order'] = $this->objectGroups[$key]['order']; + } + // add current amount(s). + foreach ($this->amounts[$id] as $accountId => $row) { + $meta['accounts'][] = [ + 'account_id' => (string)$accountId, + 'name' => $this->accounts[$accountId]['name'] ?? '', + 'current_amount' => Steam::bcround($row['current_amount'], $currency->decimal_places), + 'pc_current_amount' => Steam::bcround($row['pc_current_amount'], $this->primaryCurrency->decimal_places), + ]; + $meta['current_amount'] = bcadd($meta['current_amount'], $row['current_amount']); + // only add pc_current_amount when the pc_current_amount is set + $meta['pc_current_amount'] = null === $row['pc_current_amount'] ? null : bcadd($meta['pc_current_amount'], $row['pc_current_amount']); + } + $meta['current_amount'] = Steam::bcround($meta['current_amount'], $currency->decimal_places); + // only round this number when pc_current_amount is set. + $meta['pc_current_amount'] = null === $meta['pc_current_amount'] ? null : Steam::bcround($meta['pc_current_amount'], $this->primaryCurrency->decimal_places); + + // calculate left to save, only when there is a target amount. + if (null !== $targetAmount) { + $meta['left_to_save'] = bcsub($meta['target_amount'], $meta['current_amount']); + $meta['pc_left_to_save'] = null === $meta['pc_target_amount'] ? null : bcsub($meta['pc_target_amount'], $meta['pc_current_amount']); + } + + // get suggested per month. + $meta['save_per_month'] = Steam::bcround($this->getSuggestedMonthlyAmount($item->start_date, $item->target_date, $meta['target_amount'], $meta['current_amount']), $currency->decimal_places); + $meta['pc_save_per_month'] = Steam::bcround($this->getSuggestedMonthlyAmount($item->start_date, $item->target_date, $meta['pc_target_amount'], $meta['pc_current_amount']), $currency->decimal_places); + + $item->meta = $meta; + + return $item; + }); + } + + private function collectNotes(): void + { + $notes = Note::query()->whereIn('noteable_id', $this->ids) + ->whereNotNull('notes.text') + ->where('notes.text', '!=', '') + ->where('noteable_type', PiggyBank::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 collectObjectGroups(): void + { + $set = DB::table('object_groupables') + ->whereIn('object_groupable_id', $this->ids) + ->where('object_groupable_type', PiggyBank::class) + ->get(['object_groupable_id', 'object_group_id']); + + $ids = array_unique($set->pluck('object_group_id')->toArray()); + + foreach ($set as $entry) { + $this->mappedObjects[(int)$entry->object_groupable_id] = (int)$entry->object_group_id; + } + + $groups = ObjectGroup::whereIn('id', $ids)->get(['id', 'title', 'order'])->toArray(); + foreach ($groups as $group) { + $group['id'] = (int)$group['id']; + $group['order'] = (int)$group['order']; + $this->objectGroups[(int)$group['id']] = $group; + } + } + + private function collectCurrentAmounts(): void + { + } + + /** + * Returns the suggested amount the user should save per month, or "". + */ + private function getSuggestedMonthlyAmount(?Carbon $startDate, ?Carbon $targetDate, ?string $targetAmount, string $currentAmount): string + { + if (null === $targetAmount || null === $targetDate || null === $startDate) { + return '0'; + } + $savePerMonth = '0'; + if (1 === bccomp($targetAmount, $currentAmount)) { + $now = today(config('app.timezone')); + $diffInMonths = (int)$startDate->diffInMonths($targetDate); + $remainingAmount = bcsub($targetAmount, $currentAmount); + + // more than 1 month to go and still need money to save: + if ($diffInMonths > 0 && 1 === bccomp($remainingAmount, '0')) { + $savePerMonth = bcdiv($remainingAmount, (string)$diffInMonths); + } + + // less than 1 month to go but still need money to save: + if (0 === $diffInMonths && 1 === bccomp($remainingAmount, '0')) { + $savePerMonth = $remainingAmount; + } + } + + return $savePerMonth; + } +} diff --git a/app/Support/JsonApi/Enrichments/PiggyBankEventEnrichment.php b/app/Support/JsonApi/Enrichments/PiggyBankEventEnrichment.php index d92cf8f14b..df03bbef5f 100644 --- a/app/Support/JsonApi/Enrichments/PiggyBankEventEnrichment.php +++ b/app/Support/JsonApi/Enrichments/PiggyBankEventEnrichment.php @@ -91,6 +91,7 @@ class PiggyBankEventEnrichment implements EnrichmentInterface } // get account currency preference for ALL. + // TODO This method does a find in a loop. $set = AccountMeta::whereIn('account_id', array_values($this->accountIds))->where('name', 'currency_id')->get(); /** @var AccountMeta $item */ foreach ($set as $item) { diff --git a/app/Support/JsonApi/Enrichments/SubscriptionEnrichment.php b/app/Support/JsonApi/Enrichments/SubscriptionEnrichment.php index 96a1ad0255..4df4e7a66b 100644 --- a/app/Support/JsonApi/Enrichments/SubscriptionEnrichment.php +++ b/app/Support/JsonApi/Enrichments/SubscriptionEnrichment.php @@ -56,6 +56,8 @@ class SubscriptionEnrichment implements EnrichmentInterface $this->collectPaidDates(); $this->collectPayDates(); + // TODO clean me up. + $notes = $this->notes; $objectGroups = $this->objectGroups; $paidDates = $this->paidDates; diff --git a/app/Transformers/AccountTransformer.php b/app/Transformers/AccountTransformer.php index 14bcb14e76..c02879e4c9 100644 --- a/app/Transformers/AccountTransformer.php +++ b/app/Transformers/AccountTransformer.php @@ -108,6 +108,8 @@ class AccountTransformer extends AbstractTransformer 'type' => strtolower($accountType), 'account_role' => $accountRole, + // TODO object group + // currency information, structured for 6.3.0. 'object_has_currency_setting' => $hasCurrencySettings, diff --git a/app/Transformers/BudgetTransformer.php b/app/Transformers/BudgetTransformer.php index 35c4d6c6a9..cb1d73ea99 100644 --- a/app/Transformers/BudgetTransformer.php +++ b/app/Transformers/BudgetTransformer.php @@ -87,6 +87,8 @@ class BudgetTransformer extends AbstractTransformer 'auto_budget_type' => $abType, 'auto_budget_period' => $abPeriod, + // TODO object group + // new currency settings. 'object_has_currency_setting' => null !== $budget->meta['currency'], 'currency_id' => null === $currency ? null : (string)$currency->id, diff --git a/app/Transformers/PiggyBankTransformer.php b/app/Transformers/PiggyBankTransformer.php index 84c9c64186..c1adcee44f 100644 --- a/app/Transformers/PiggyBankTransformer.php +++ b/app/Transformers/PiggyBankTransformer.php @@ -27,8 +27,10 @@ namespace FireflyIII\Transformers; use FireflyIII\Exceptions\FireflyException; use FireflyIII\Models\ObjectGroup; use FireflyIII\Models\PiggyBank; +use FireflyIII\Models\TransactionCurrency; use FireflyIII\Repositories\Account\AccountRepositoryInterface; use FireflyIII\Repositories\PiggyBank\PiggyBankRepositoryInterface; +use FireflyIII\Support\Facades\Amount; use FireflyIII\Support\Facades\Steam; /** @@ -36,16 +38,14 @@ use FireflyIII\Support\Facades\Steam; */ class PiggyBankTransformer extends AbstractTransformer { - private readonly AccountRepositoryInterface $accountRepos; - private readonly PiggyBankRepositoryInterface $piggyRepos; + private TransactionCurrency $primaryCurrency; /** * PiggyBankTransformer constructor. */ public function __construct() { - $this->accountRepos = app(AccountRepositoryInterface::class); - $this->piggyRepos = app(PiggyBankRepositoryInterface::class); + $this->primaryCurrency = Amount::getPrimaryCurrency(); } /** @@ -55,74 +55,58 @@ class PiggyBankTransformer extends AbstractTransformer */ public function transform(PiggyBank $piggyBank): array { - $user = $piggyBank->accounts()->first()->user; - - // set up repositories - $this->accountRepos->setUser($user); - $this->piggyRepos->setUser($user); - - // note - $notes = $this->piggyRepos->getNoteText($piggyBank); - $notes = '' === $notes ? null : $notes; - - $objectGroupId = null; - $objectGroupOrder = null; - $objectGroupTitle = null; - - /** @var null|ObjectGroup $objectGroup */ - $objectGroup = $piggyBank->objectGroups->first(); - if (null !== $objectGroup) { - $objectGroupId = $objectGroup->id; - $objectGroupOrder = $objectGroup->order; - $objectGroupTitle = $objectGroup->title; - } - - // get currently saved amount: - $currency = $piggyBank->transactionCurrency; - $currentAmount = $this->piggyRepos->getCurrentAmount($piggyBank); - // Amounts, depending on 0.0 state of target amount - $percentage = null; - $targetAmount = $piggyBank->target_amount; - $leftToSave = null; - $savePerMonth = null; - if (0 !== bccomp($targetAmount, '0')) { // target amount is not 0.00 - $leftToSave = bcsub($piggyBank->target_amount, $currentAmount); - $percentage = (int) bcmul(bcdiv($currentAmount, $targetAmount), '100'); - $targetAmount = Steam::bcround($targetAmount, $currency->decimal_places); - $leftToSave = Steam::bcround($leftToSave, $currency->decimal_places); - $savePerMonth = Steam::bcround($this->piggyRepos->getSuggestedMonthlyAmount($piggyBank), $currency->decimal_places); + $percentage = null; + if (null !== $piggyBank->meta['target_amount'] && 0 !== bccomp($piggyBank->meta['current_amount'], '0')) { // target amount is not 0.00 + $percentage = (int)bcmul(bcdiv($piggyBank->meta['current_amount'], $piggyBank->meta['target_amount']), '100'); } - $startDate = $piggyBank->start_date?->format('Y-m-d'); - $targetDate = $piggyBank->target_date?->format('Y-m-d'); + $startDate = $piggyBank->start_date?->format('Y-m-d'); + $targetDate = $piggyBank->target_date?->format('Y-m-d'); return [ - 'id' => (string) $piggyBank->id, - 'created_at' => $piggyBank->created_at->toAtomString(), - 'updated_at' => $piggyBank->updated_at->toAtomString(), - 'name' => $piggyBank->name, - 'accounts' => $this->renderAccounts($piggyBank), - 'currency_id' => (string) $currency->id, - 'currency_code' => $currency->code, - 'currency_symbol' => $currency->symbol, - 'currency_decimal_places' => $currency->decimal_places, - 'target_amount' => $targetAmount, - 'percentage' => $percentage, - 'current_amount' => $currentAmount, - 'left_to_save' => $leftToSave, - 'save_per_month' => $savePerMonth, - 'start_date' => $startDate, - 'target_date' => $targetDate, - 'order' => $piggyBank->order, - 'active' => true, - 'notes' => $notes, - 'object_group_id' => null !== $objectGroupId ? (string) $objectGroupId : null, - 'object_group_order' => $objectGroupOrder, - 'object_group_title' => $objectGroupTitle, - 'links' => [ + 'id' => (string)$piggyBank->id, + 'created_at' => $piggyBank->created_at->toAtomString(), + 'updated_at' => $piggyBank->updated_at->toAtomString(), + 'name' => $piggyBank->name, + 'percentage' => $percentage, + 'start_date' => $startDate, + 'target_date' => $targetDate, + 'order' => $piggyBank->order, + 'active' => true, + 'notes' => $piggyBank->meta['notes'], + 'object_group_id' => $piggyBank->meta['object_group_id'], + 'object_group_order' => $piggyBank->meta['object_group_order'], + 'object_group_title' => $piggyBank->meta['object_group_title'], + 'accounts' => $piggyBank->meta['accounts'], + + // currency settings, 6.3.0. + 'object_has_currency_setting' => true, + 'currency_id' => (string)$piggyBank->meta['currency']->id, + 'currency_name' => $piggyBank->meta['currency']->name, + 'currency_code' => $piggyBank->meta['currency']->code, + 'currency_symbol' => $piggyBank->meta['currency']->symbol, + 'currency_decimal_places' => $piggyBank->meta['currency']->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' => (int)$this->primaryCurrency->decimal_places, + + + 'target_amount' => $piggyBank->meta['target_amount'], + 'pc_target_amount' => $piggyBank->meta['pc_target_amount'], + 'current_amount' => $piggyBank->meta['current_amount'], + 'pc_current_amount' => $piggyBank->meta['pc_current_amount'], + 'left_to_save' => $piggyBank->meta['left_to_save'], + 'pc_left_to_save' => $piggyBank->meta['pc_left_to_save'], + 'save_per_month' => $piggyBank->meta['save_per_month'], + 'pc_save_per_month' => $piggyBank->meta['pc_save_per_month'], + + 'links' => [ [ 'rel' => 'self', - 'uri' => '/piggy-banks/'.$piggyBank->id, + 'uri' => sprintf('/piggy-banks/%d', $piggyBank->id), ], ], ]; @@ -133,10 +117,10 @@ class PiggyBankTransformer extends AbstractTransformer $return = []; foreach ($piggyBank->accounts()->get() as $account) { $return[] = [ - 'id' => (string) $account->id, - 'name' => $account->name, - 'current_amount' => (string) $account->pivot->current_amount, - 'pc_current_amount' => (string) $account->pivot->native_current_amount, + 'id' => (string)$account->id, + 'name' => $account->name, + 'current_amount' => (string)$account->pivot->current_amount, + 'pc_current_amount' => (string)$account->pivot->native_current_amount, // TODO add balance, add left to save. ]; }