From 1197f65589b76c89a5c4984251b8a9054aa80ab1 Mon Sep 17 00:00:00 2001 From: James Cole Date: Wed, 6 Aug 2025 10:46:23 +0200 Subject: [PATCH] Start work on recurring transaction enrichment. --- .../Models/Recurrence/ShowController.php | 16 +- .../Enrichments/RecurringEnrichment.php | 199 ++++++++++++++++++ app/Transformers/PiggyBankTransformer.php | 4 +- app/Transformers/RecurrenceTransformer.php | 87 ++++---- resources/lang/en_US/firefly.php | 1 + 5 files changed, 259 insertions(+), 48 deletions(-) create mode 100644 app/Support/JsonApi/Enrichments/RecurringEnrichment.php diff --git a/app/Api/V1/Controllers/Models/Recurrence/ShowController.php b/app/Api/V1/Controllers/Models/Recurrence/ShowController.php index cc3b334136..7440af4404 100644 --- a/app/Api/V1/Controllers/Models/Recurrence/ShowController.php +++ b/app/Api/V1/Controllers/Models/Recurrence/ShowController.php @@ -28,7 +28,10 @@ use FireflyIII\Api\V1\Controllers\Controller; use FireflyIII\Exceptions\FireflyException; use FireflyIII\Models\Recurrence; use FireflyIII\Repositories\Recurring\RecurringRepositoryInterface; +use FireflyIII\Support\JsonApi\Enrichments\AccountEnrichment; +use FireflyIII\Support\JsonApi\Enrichments\RecurringEnrichment; use FireflyIII\Transformers\RecurrenceTransformer; +use FireflyIII\User; use Illuminate\Http\JsonResponse; use Illuminate\Pagination\LengthAwarePaginator; use League\Fractal\Pagination\IlluminatePaginatorAdapter; @@ -76,17 +79,24 @@ class ShowController extends Controller // get list of budgets. Count it and split it. $collection = $this->repository->get(); $count = $collection->count(); - $piggyBanks = $collection->slice(($this->parameters->get('page') - 1) * $pageSize, $pageSize); + $recurrences = $collection->slice(($this->parameters->get('page') - 1) * $pageSize, $pageSize); + + // enrich + /** @var User $admin */ + $admin = auth()->user(); + $enrichment = new RecurringEnrichment(); + $enrichment->setUser($admin); + $recurrences = $enrichment->enrich($recurrences); // make paginator: - $paginator = new LengthAwarePaginator($piggyBanks, $count, $pageSize, $this->parameters->get('page')); + $paginator = new LengthAwarePaginator($recurrences, $count, $pageSize, $this->parameters->get('page')); $paginator->setPath(route('api.v1.recurrences.index').$this->buildParams()); /** @var RecurrenceTransformer $transformer */ $transformer = app(RecurrenceTransformer::class); $transformer->setParameters($this->parameters); - $resource = new FractalCollection($piggyBanks, $transformer, 'recurrences'); + $resource = new FractalCollection($recurrences, $transformer, 'recurrences'); $resource->setPaginator(new IlluminatePaginatorAdapter($paginator)); return response()->json($manager->createData($resource)->toArray())->header('Content-Type', self::CONTENT_TYPE); diff --git a/app/Support/JsonApi/Enrichments/RecurringEnrichment.php b/app/Support/JsonApi/Enrichments/RecurringEnrichment.php new file mode 100644 index 0000000000..36809c9ac7 --- /dev/null +++ b/app/Support/JsonApi/Enrichments/RecurringEnrichment.php @@ -0,0 +1,199 @@ +collection = $collection; + $this->collectIds(); + $this->collectRepetitions(); + $this->collectTransactions(); + + $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); + $this->getLanguage(); + } + + public function setUserGroup(UserGroup $userGroup): void + { + $this->userGroup = $userGroup; + } + + private function collectIds(): void + { + /** @var Recurrence $recurrence */ + foreach ($this->collection as $recurrence) { + $id = (int)$recurrence->id; + $typeId = (int)$recurrence->transaction_type_id; + $this->ids[] = $id; + $this->transactionTypeIds[$id] = $typeId; + } + $this->ids = array_unique($this->ids); + + // collect transaction types. + $transactionTypes = TransactionType::whereIn('id', array_unique($this->transactionTypeIds))->get(); + foreach ($transactionTypes as $transactionType) { + $id = (int)$transactionType->id; + $this->transactionTypes[$id] = TransactionTypeEnum::from($transactionType->type); + } + } + + private function collectRepetitions(): void + { + $repository = app(RecurringRepositoryInterface::class); + $repository->setUserGroup($this->userGroup); + $set = RecurrenceRepetition::whereIn('recurrence_id', $this->ids)->get(); + /** @var RecurrenceRepetition $repetition */ + foreach ($set as $repetition) { + $recurrence = $this->collection->filter(function (Recurrence $item) use ($repetition) { + return (int)$item->id === (int)$repetition->recurrence_id; + })->first(); + $fromDate = $recurrence->latest_date ?? $recurrence->first_date; + $id = (int)$repetition->recurrence_id; + $repId = (int)$repetition->id; + $this->repetitions[$id] ??= []; + + // get the (future) occurrences for this specific type of repetition: + $amount = 'daily' === $repetition->repetition_type ? 9 : 5; + $set = $repository->getXOccurrencesSince($repetition, $fromDate, now(config('app.timezone')), $amount); + /** @var Carbon $carbon */ + foreach ($set as $carbon) { + $occurrences[] = $carbon->toAtomString(); + } + + $this->repetitions[$id][$repId] = [ + 'id' => (string)$repId, + 'created_at' => $repetition->created_at->toAtomString(), + 'updated_at' => $repetition->updated_at->toAtomString(), + 'type' => $repetition->repetition_type, + 'moment' => (string)$repetition->moment, + 'skip' => (int)$repetition->skip, + 'weekend' => RecurrenceRepetitionWeekend::from((int)$repetition->weekend), + 'description' => $this->getRepetitionDescription($repetition), + 'occurrences' => $occurrences, + ]; + } + } + + private function collectTransactions(): void + { + } + + private function appendCollectedData(): void + { + $this->collection = $this->collection->map(function (Recurrence $item) { + $id = (int)$item->id; + $meta = [ + 'repetitions' => array_values($this->repetitions[$id] ?? []), + ]; + + $item->meta = $meta; + + return $item; + }); + } + + /** + * Parse the repetition in a string that is user readable. + * TODO duplicate with repository. + */ + public function getRepetitionDescription(RecurrenceRepetition $repetition): string + { + if ('daily' === $repetition->repetition_type) { + return (string)trans('firefly.recurring_daily', [], $this->language); + } + if ('weekly' === $repetition->repetition_type) { + $dayOfWeek = trans(sprintf('config.dow_%s', $repetition->repetition_moment), [], $this->language); + if ($repetition->repetition_skip > 0) { + return (string)trans('firefly.recurring_weekly_skip', ['weekday' => $dayOfWeek, 'skip' => $repetition->repetition_skip + 1], $this->language); + } + + return (string)trans('firefly.recurring_weekly', ['weekday' => $dayOfWeek], $this->language); + } + if ('monthly' === $repetition->repetition_type) { + if ($repetition->repetition_skip > 0) { + return (string)trans('firefly.recurring_monthly_skip', ['dayOfMonth' => $repetition->repetition_moment, 'skip' => $repetition->repetition_skip + 1], $this->language); + } + + return (string)trans('firefly.recurring_monthly', ['dayOfMonth' => $repetition->repetition_moment, 'skip' => $repetition->repetition_skip - 1], $this->language); + } + if ('ndom' === $repetition->repetition_type) { + $parts = explode(',', $repetition->repetition_moment); + // first part is number of week, second is weekday. + $dayOfWeek = trans(sprintf('config.dow_%s', $parts[1]), [], $this->language); + if ($repetition->repetition_skip > 0) { + return (string)trans('firefly.recurring_ndom_skip', ['skip' => $repetition->repetition_skip, 'weekday' => $dayOfWeek, 'dayOfMonth' => $parts[0]], $this->language); + } + + return (string)trans('firefly.recurring_ndom', ['weekday' => $dayOfWeek, 'dayOfMonth' => $parts[0]], $this->language); + } + if ('yearly' === $repetition->repetition_type) { + $today = today(config('app.timezone'))->endOfYear(); + $repDate = Carbon::createFromFormat('Y-m-d', $repetition->repetition_moment); + if (!$repDate instanceof Carbon) { + $repDate = clone $today; + } + // $diffInYears = (int)$today->diffInYears($repDate, true); + //$repDate->addYears($diffInYears); // technically not necessary. + $string = $repDate->isoFormat((string)trans('config.month_and_day_no_year_js')); + + return (string)trans('firefly.recurring_yearly', ['date' => $string], $this->language); + } + + return ''; + } + + private function getLanguage(): void + { + /** @var Preference $preference */ + $preference = Preferences::getForUser($this->user, 'language', config('firefly.default_language', 'en_US')); + $language = $preference->data; + if (is_array($language)) { + $language = 'en_US'; + } + $language = (string)$language; + $this->language = $language; + } +} diff --git a/app/Transformers/PiggyBankTransformer.php b/app/Transformers/PiggyBankTransformer.php index c1adcee44f..2fd436e457 100644 --- a/app/Transformers/PiggyBankTransformer.php +++ b/app/Transformers/PiggyBankTransformer.php @@ -60,8 +60,8 @@ class PiggyBankTransformer extends AbstractTransformer 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?->toAtomString(); + $targetDate = $piggyBank->target_date?->toAtomString(); return [ 'id' => (string)$piggyBank->id, diff --git a/app/Transformers/RecurrenceTransformer.php b/app/Transformers/RecurrenceTransformer.php index 6a3ffea5ea..8428882e70 100644 --- a/app/Transformers/RecurrenceTransformer.php +++ b/app/Transformers/RecurrenceTransformer.php @@ -38,7 +38,6 @@ use FireflyIII\Repositories\PiggyBank\PiggyBankRepositoryInterface; use FireflyIII\Repositories\Recurring\RecurringRepositoryInterface; use FireflyIII\Support\Facades\Steam; use Illuminate\Support\Facades\Log; - use function Safe\json_decode; /** @@ -75,35 +74,37 @@ class RecurrenceTransformer extends AbstractTransformer $this->repository->setUser($recurrence->user); $this->piggyRepos->setUser($recurrence->user); $this->factory->setUser($recurrence->user); + $this->budgetRepos->setUser($recurrence->user); Log::debug('Set user.'); - $shortType = (string) config(sprintf('firefly.transactionTypesToShort.%s', $recurrence->transactionType->type)); + $shortType = (string)config(sprintf('firefly.transactionTypesToShort.%s', $recurrence->transactionType->type)); $notes = $this->repository->getNoteText($recurrence); - $reps = 0 === (int) $recurrence->repetitions ? null : (int) $recurrence->repetitions; + $reps = 0 === (int)$recurrence->repetitions ? null : (int)$recurrence->repetitions; Log::debug('Get basic data.'); // basic data. return [ - 'id' => (string) $recurrence->id, + 'id' => (string)$recurrence->id, 'created_at' => $recurrence->created_at->toAtomString(), 'updated_at' => $recurrence->updated_at->toAtomString(), 'type' => $shortType, 'title' => $recurrence->title, 'description' => $recurrence->description, - 'first_date' => $recurrence->first_date->format('Y-m-d'), - 'latest_date' => $recurrence->latest_date?->format('Y-m-d'), - 'repeat_until' => $recurrence->repeat_until?->format('Y-m-d'), + 'first_date' => $recurrence->first_date->toAtomString(), + 'latest_date' => $recurrence->latest_date?->toAtomString(), + 'repeat_until' => $recurrence->repeat_until?->toAtomString(), 'apply_rules' => $recurrence->apply_rules, 'active' => $recurrence->active, 'nr_of_repetitions' => $reps, 'notes' => '' === $notes ? null : $notes, + 'new_repetitions' => $recurrence->meta['repetitions'], 'repetitions' => $this->getRepetitions($recurrence), 'transactions' => $this->getTransactions($recurrence), 'links' => [ [ 'rel' => 'self', - 'uri' => '/recurring/'.$recurrence->id, + 'uri' => '/recurring/' . $recurrence->id, ], ], ]; @@ -121,7 +122,7 @@ class RecurrenceTransformer extends AbstractTransformer /** @var RecurrenceRepetition $repetition */ foreach ($recurrence->recurrenceRepetitions as $repetition) { $repetitionArray = [ - 'id' => (string) $repetition->id, + 'id' => (string)$repetition->id, 'created_at' => $repetition->created_at->toAtomString(), 'updated_at' => $repetition->updated_at->toAtomString(), 'type' => $repetition->repetition_type, @@ -133,15 +134,15 @@ class RecurrenceTransformer extends AbstractTransformer ]; // get the (future) occurrences for this specific type of repetition: - $amount = 'daily' === $repetition->repetition_type ? 9 : 5; - $occurrences = $this->repository->getXOccurrencesSince($repetition, $fromDate, now(), $amount); + $amount = 'daily' === $repetition->repetition_type ? 9 : 5; + $occurrences = $this->repository->getXOccurrencesSince($repetition, $fromDate, now(), $amount); /** @var Carbon $carbon */ foreach ($occurrences as $carbon) { $repetitionArray['occurrences'][] = $carbon->toAtomString(); } - $return[] = $repetitionArray; + $return[] = $repetitionArray; } return $return; @@ -159,7 +160,7 @@ class RecurrenceTransformer extends AbstractTransformer /** @var RecurrenceTransaction $transaction */ foreach ($recurrence->recurrenceTransactions()->get() as $transaction) { /** @var null|Account $sourceAccount */ - $sourceAccount = $transaction->sourceAccount; + $sourceAccount = $transaction->sourceAccount; /** @var null|Account $destinationAccount */ $destinationAccount = $transaction->destinationAccount; @@ -168,53 +169,53 @@ class RecurrenceTransformer extends AbstractTransformer $foreignCurrencyDp = null; $foreignCurrencyId = null; if (null !== $transaction->foreign_currency_id) { - $foreignCurrencyId = (int) $transaction->foreign_currency_id; + $foreignCurrencyId = (int)$transaction->foreign_currency_id; $foreignCurrencyCode = $transaction->foreignCurrency->code; $foreignCurrencySymbol = $transaction->foreignCurrency->symbol; $foreignCurrencyDp = $transaction->foreignCurrency->decimal_places; } // source info: - $sourceName = ''; - $sourceId = null; - $sourceType = null; - $sourceIban = null; + $sourceName = ''; + $sourceId = null; + $sourceType = null; + $sourceIban = null; if (null !== $sourceAccount) { $sourceName = $sourceAccount->name; $sourceId = $sourceAccount->id; $sourceType = $sourceAccount->accountType->type; $sourceIban = $sourceAccount->iban; } - $destinationName = ''; - $destinationId = null; - $destinationType = null; - $destinationIban = null; + $destinationName = ''; + $destinationId = null; + $destinationType = null; + $destinationIban = null; if (null !== $destinationAccount) { $destinationName = $destinationAccount->name; $destinationId = $destinationAccount->id; $destinationType = $destinationAccount->accountType->type; $destinationIban = $destinationAccount->iban; } - $amount = Steam::bcround($transaction->amount, $transaction->transactionCurrency->decimal_places); - $foreignAmount = null; + $amount = Steam::bcround($transaction->amount, $transaction->transactionCurrency->decimal_places); + $foreignAmount = null; if (null !== $transaction->foreign_currency_id && null !== $transaction->foreign_amount) { $foreignAmount = Steam::bcround($transaction->foreign_amount, $foreignCurrencyDp); } - $transactionArray = [ - 'id' => (string) $transaction->id, - 'currency_id' => (string) $transaction->transaction_currency_id, + $transactionArray = [ + 'id' => (string)$transaction->id, + 'currency_id' => (string)$transaction->transaction_currency_id, 'currency_code' => $transaction->transactionCurrency->code, 'currency_symbol' => $transaction->transactionCurrency->symbol, 'currency_decimal_places' => $transaction->transactionCurrency->decimal_places, - 'foreign_currency_id' => null === $foreignCurrencyId ? null : (string) $foreignCurrencyId, + 'foreign_currency_id' => null === $foreignCurrencyId ? null : (string)$foreignCurrencyId, 'foreign_currency_code' => $foreignCurrencyCode, 'foreign_currency_symbol' => $foreignCurrencySymbol, 'foreign_currency_decimal_places' => $foreignCurrencyDp, - 'source_id' => (string) $sourceId, + 'source_id' => (string)$sourceId, 'source_name' => $sourceName, 'source_iban' => $sourceIban, 'source_type' => $sourceType, - 'destination_id' => (string) $destinationId, + 'destination_id' => (string)$destinationId, 'destination_name' => $destinationName, 'destination_iban' => $destinationIban, 'destination_type' => $destinationType, @@ -222,7 +223,7 @@ class RecurrenceTransformer extends AbstractTransformer 'foreign_amount' => $foreignAmount, 'description' => $transaction->description, ]; - $transactionArray = $this->getTransactionMeta($transaction, $transactionArray); + $transactionArray = $this->getTransactionMeta($transaction, $transactionArray); if (null !== $transaction->foreign_currency_id) { $transactionArray['foreign_currency_code'] = $transaction->foreignCurrency->code; $transactionArray['foreign_currency_symbol'] = $transaction->foreignCurrency->symbol; @@ -230,7 +231,7 @@ class RecurrenceTransformer extends AbstractTransformer } // store transaction in recurrence array. - $return[] = $transactionArray; + $return[] = $transactionArray; } return $return; @@ -259,50 +260,50 @@ class RecurrenceTransformer extends AbstractTransformer throw new FireflyException(sprintf('Recurrence transformer cant handle field "%s"', $transactionMeta->name)); case 'bill_id': - $bill = $this->billRepos->find((int) $transactionMeta->value); + $bill = $this->billRepos->find((int)$transactionMeta->value); if (null !== $bill) { - $array['bill_id'] = (string) $bill->id; + $array['bill_id'] = (string)$bill->id; $array['bill_name'] = $bill->name; } break; case 'tags': - $array['tags'] = json_decode((string) $transactionMeta->value); + $array['tags'] = json_decode((string)$transactionMeta->value); break; case 'piggy_bank_id': - $piggy = $this->piggyRepos->find((int) $transactionMeta->value); + $piggy = $this->piggyRepos->find((int)$transactionMeta->value); if (null !== $piggy) { - $array['piggy_bank_id'] = (string) $piggy->id; + $array['piggy_bank_id'] = (string)$piggy->id; $array['piggy_bank_name'] = $piggy->name; } break; case 'category_id': - $category = $this->factory->findOrCreate((int) $transactionMeta->value, null); + $category = $this->factory->findOrCreate((int)$transactionMeta->value, null); if (null !== $category) { - $array['category_id'] = (string) $category->id; + $array['category_id'] = (string)$category->id; $array['category_name'] = $category->name; } break; case 'category_name': - $category = $this->factory->findOrCreate(null, $transactionMeta->value); + $category = $this->factory->findOrCreate(null, $transactionMeta->value); if (null !== $category) { - $array['category_id'] = (string) $category->id; + $array['category_id'] = (string)$category->id; $array['category_name'] = $category->name; } break; case 'budget_id': - $budget = $this->budgetRepos->find((int) $transactionMeta->value); + $budget = $this->budgetRepos->find((int)$transactionMeta->value); if (null !== $budget) { - $array['budget_id'] = (string) $budget->id; + $array['budget_id'] = (string)$budget->id; $array['budget_name'] = $budget->name; } diff --git a/resources/lang/en_US/firefly.php b/resources/lang/en_US/firefly.php index bea221f2eb..66d0e6e6b9 100644 --- a/resources/lang/en_US/firefly.php +++ b/resources/lang/en_US/firefly.php @@ -2793,6 +2793,7 @@ return [ 'recurring_monthly' => 'Every month on the :dayOfMonth(st/nd/rd/th) day', 'recurring_monthly_skip' => 'Every :skip(st/nd/rd/th) month on the :dayOfMonth(st/nd/rd/th) day', 'recurring_ndom' => 'Every month on the :dayOfMonth(st/nd/rd/th) :weekday', + 'recurring_ndom_skip' => 'Every :skip(st/nd/rd/th) month on the :dayOfMonth(st/nd/rd/th) :weekday', 'recurring_yearly' => 'Every year on :date', 'overview_for_recurrence' => 'Overview for recurring transaction ":title"', 'warning_duplicates_repetitions' => 'In rare instances, dates appear twice in this list. This can happen when multiple repetitions collide. Firefly III will always generate one transaction per day.',