. */ declare(strict_types=1); namespace FireflyIII\Support\Http\Controllers; use Carbon\Carbon; use FireflyIII\Enums\TransactionTypeEnum; use FireflyIII\Exceptions\FireflyException; use FireflyIII\Helpers\Collector\GroupCollectorInterface; use FireflyIII\Models\Account; use FireflyIII\Models\Category; use FireflyIII\Models\PeriodStatistic; use FireflyIII\Models\Tag; use FireflyIII\Repositories\Account\AccountRepositoryInterface; use FireflyIII\Repositories\Category\CategoryRepositoryInterface; use FireflyIII\Repositories\Journal\JournalRepositoryInterface; use FireflyIII\Repositories\PeriodStatistic\PeriodStatisticRepositoryInterface; use FireflyIII\Repositories\Tag\TagRepositoryInterface; use FireflyIII\Support\CacheProperties; use FireflyIII\Support\Facades\Amount; use FireflyIII\Support\Facades\Navigation; use FireflyIII\Support\Facades\Steam; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Log; use Illuminate\Support\Str; /** * Trait PeriodOverview. * * TODO verify this all works as expected. * * - Always request start date and end date. * - Group expenses, income, etc. under this period. * - Returns collection of arrays. Fields * title (string), * route (string) * total_transactions (int) * spent (array), * earned (array), * transferred_away (array) * transferred_in (array) * transferred (array) * * each array has the following format: * currency_id => [ * currency_id : 1, (int) * currency_symbol : X (str) * currency_name: Euro (str) * currency_code: EUR (str) * amount: -1234 (str) * count: 23 * ] */ trait PeriodOverview { protected AccountRepositoryInterface $accountRepository; protected CategoryRepositoryInterface $categoryRepository; protected TagRepositoryInterface $tagRepository; protected JournalRepositoryInterface $journalRepos; protected PeriodStatisticRepositoryInterface $periodStatisticRepo; private Collection $statistics; // temp data holder private array $transactions; // temp data holder /** * This method returns "period entries", so nov-2015, dec-2015, etc. (this depends on the users session range) * and for each period, the amount of money spent and earned. This is a complex operation which is cached for * performance reasons. * * @throws FireflyException */ protected function getAccountPeriodOverview(Account $account, Carbon $start, Carbon $end): array { Log::debug(sprintf('Now in getAccountPeriodOverview(#%d, %s %s)', $account->id, $start->format('Y-m-d H:i:s.u'), $end->format('Y-m-d H:i:s.u'))); $this->accountRepository = app(AccountRepositoryInterface::class); $this->accountRepository->setUser($account->user); $this->periodStatisticRepo = app(PeriodStatisticRepositoryInterface::class); $range = Navigation::getViewRange(true); [$start, $end] = $end < $start ? [$end, $start] : [$start, $end]; /** @var array $dates */ $dates = Navigation::blockPeriods($start, $end, $range); [$start, $end] = $this->getPeriodFromBlocks($dates, $start, $end); $this->statistics = $this->periodStatisticRepo->allInRangeForModel($account, $start, $end); $entries = []; Log::debug(sprintf('Count of loops: %d', count($dates))); foreach ($dates as $currentDate) { $entries[] = $this->getSingleModelPeriod($account, $currentDate['period'], $currentDate['start'], $currentDate['end']); } Log::debug('End of getAccountPeriodOverview()'); return $entries; } private function getPeriodFromBlocks(array $dates, Carbon $start, Carbon $end): array { Log::debug('Filter generated periods to select the oldest and newest date.'); foreach ($dates as $row) { $currentStart = clone $row['start']; $currentEnd = clone $row['end']; if ($currentStart->lt($start)) { Log::debug(sprintf('New start: was %s, now %s', $start->format('Y-m-d'), $currentStart->format('Y-m-d'))); $start = $currentStart; } if ($currentEnd->gt($end)) { Log::debug(sprintf('New end: was %s, now %s', $end->format('Y-m-d'), $currentEnd->format('Y-m-d'))); $end = $currentEnd; } } return [$start, $end]; } /** * Overview for single category. Has been refactored recently. * * @throws FireflyException */ protected function getCategoryPeriodOverview(Category $category, Carbon $start, Carbon $end): array { $this->categoryRepository = app(CategoryRepositoryInterface::class); $this->categoryRepository->setUser($category->user); $this->periodStatisticRepo = app(PeriodStatisticRepositoryInterface::class); $range = Navigation::getViewRange(true); [$start, $end] = $end < $start ? [$end, $start] : [$start, $end]; /** @var array $dates */ $dates = Navigation::blockPeriods($start, $end, $range); $entries = []; [$start, $end] = $this->getPeriodFromBlocks($dates, $start, $end); $this->statistics = $this->periodStatisticRepo->allInRangeForModel($category, $start, $end); Log::debug(sprintf('Count of loops: %d', count($dates))); foreach ($dates as $currentDate) { $entries[] = $this->getSingleModelPeriod($category, $currentDate['period'], $currentDate['start'], $currentDate['end']); } return $entries; } /** * Same as above, but for lists that involve transactions without a budget. * * This method has been refactored recently. * * @throws FireflyException */ protected function getNoModelPeriodOverview(string $model, Carbon $start, Carbon $end): array { Log::debug(sprintf('Now in getNoModelPeriodOverview(%s, %s %s)', $model, $start->format('Y-m-d'), $end->format('Y-m-d'))); $this->periodStatisticRepo = app(PeriodStatisticRepositoryInterface::class); $range = Navigation::getViewRange(true); [$start, $end] = $end < $start ? [$end, $start] : [$start, $end]; /** @var array $dates */ $dates = Navigation::blockPeriods($start, $end, $range); [$start, $end] = $this->getPeriodFromBlocks($dates, $start, $end); $entries = []; $this->statistics = $this->periodStatisticRepo->allInRangeForPrefix(sprintf('no_%s', $model), $start, $end); Log::debug(sprintf('Collected %d stats', $this->statistics->count())); foreach ($dates as $currentDate) { $entries[] = $this->getSingleNoModelPeriodOverview($model, $currentDate['start'], $currentDate['end'], $currentDate['period']); } return $entries; } private function getSingleNoModelPeriodOverview(string $model, Carbon $start, Carbon $end, string $period): array { Log::debug(sprintf('getSingleNoModelPeriodOverview(%s, %s, %s, %s)', $model, $start->format('Y-m-d'), $end->format('Y-m-d'), $period)); $statistics = $this->filterPrefixedStatistics($start, $end, sprintf('no_%s', $model)); $title = Navigation::periodShow($end, $period); if (0 === $statistics->count()) { Log::debug(sprintf('Found no statistics in period %s - %s, regenerating them.', $start->format('Y-m-d'), $end->format('Y-m-d'))); switch ($model) { default: throw new FireflyException(sprintf('Cannot deal with model of type "%s"', $model)); case 'budget': // get all expenses without a budget. /** @var GroupCollectorInterface $collector */ $collector = app(GroupCollectorInterface::class); $collector->setRange($start, $end)->withoutBudget()->withAccountInformation()->setTypes([TransactionTypeEnum::WITHDRAWAL->value]); $spent = $collector->getExtractedJournals(); $earned = []; $transferred = []; break; case 'category': // collect all expenses in this period: /** @var GroupCollectorInterface $collector */ $collector = app(GroupCollectorInterface::class); $collector->withoutCategory(); $collector->setRange($start, $end); $collector->setTypes([TransactionTypeEnum::DEPOSIT->value]); $earned = $collector->getExtractedJournals(); // collect all income in this period: /** @var GroupCollectorInterface $collector */ $collector = app(GroupCollectorInterface::class); $collector->withoutCategory(); $collector->setRange($start, $end); $collector->setTypes([TransactionTypeEnum::WITHDRAWAL->value]); $spent = $collector->getExtractedJournals(); // collect all transfers in this period: /** @var GroupCollectorInterface $collector */ $collector = app(GroupCollectorInterface::class); $collector->withoutCategory(); $collector->setRange($start, $end); $collector->setTypes([TransactionTypeEnum::TRANSFER->value]); $transferred = $collector->getExtractedJournals(); break; } $groupedSpent = $this->groupByCurrency($spent); $groupedEarned = $this->groupByCurrency($earned); $groupedTransferred = $this->groupByCurrency($transferred); $entry = [ 'title' => $title, 'route' => route(sprintf('%s.no-%s', Str::plural($model), $model), [$start->format('Y-m-d'), $end->format('Y-m-d')]), 'total_transactions' => count($spent), 'spent' => $groupedSpent, 'earned' => $groupedEarned, 'transferred' => $groupedTransferred, ]; $this->saveGroupedForPrefix(sprintf('no_%s', $model), $start, $end, 'spent', $groupedSpent); $this->saveGroupedForPrefix(sprintf('no_%s', $model), $start, $end, 'earned', $groupedEarned); $this->saveGroupedForPrefix(sprintf('no_%s', $model), $start, $end, 'transferred', $groupedTransferred); return $entry; } Log::debug(sprintf('Found %d statistics in period %s - %s.', count($statistics), $start->format('Y-m-d'), $end->format('Y-m-d'))); $entry = [ 'title' => $title, 'route' => route(sprintf('%s.no-%s', Str::plural($model), $model), [$start->format('Y-m-d'), $end->format('Y-m-d')]), 'total_transactions' => 0, 'spent' => [], 'earned' => [], 'transferred' => [], ]; $grouped = []; /** @var PeriodStatistic $statistic */ foreach ($statistics as $statistic) { $type = str_replace(sprintf('no_%s_', $model), '', $statistic->type); $id = (int)$statistic->transaction_currency_id; $currency = Amount::getTransactionCurrencyById($id); $grouped[$type]['count'] ??= 0; $grouped[$type][$id] = [ 'amount' => (string)$statistic->amount, 'count' => (int)$statistic->count, 'currency_id' => $currency->id, 'currency_name' => $currency->name, 'currency_code' => $currency->code, 'currency_symbol' => $currency->symbol, 'currency_decimal_places' => $currency->decimal_places, ]; $grouped[$type]['count'] += (int)$statistic->count; } $types = ['spent', 'earned', 'transferred']; foreach ($types as $type) { if (array_key_exists($type, $grouped)) { $entry['total_transactions'] += $grouped[$type]['count']; unset($grouped[$type]['count']); $entry[$type] = $grouped[$type]; } } return $entry; } protected function getSingleModelPeriod(Model $model, string $period, Carbon $start, Carbon $end): array { Log::debug(sprintf('Now in getSingleModelPeriod(%s #%d, %s %s)', $model::class, $model->id, $start->format('Y-m-d'), $end->format('Y-m-d'))); $types = ['spent', 'earned', 'transferred_in', 'transferred_away']; $return = [ 'title' => Navigation::periodShow($start, $period), 'route' => route(sprintf('%s.show', strtolower(Str::plural(class_basename($model)))), [$model->id, $start->format('Y-m-d'), $end->format('Y-m-d')]), 'total_transactions' => 0, ]; $this->transactions = []; foreach ($types as $type) { $set = $this->getSingleModelPeriodByType($model, $start, $end, $type); $return['total_transactions'] += $set['count']; unset($set['count']); $return[$type] = $set; } return $return; } private function filterStatistics(Carbon $start, Carbon $end, string $type): Collection { if (0 === $this->statistics->count()) { Log::debug('Have no statistic to filter!'); return new Collection(); } return $this->statistics->filter( fn (PeriodStatistic $statistic) => $statistic->start->eq($start) && $statistic->end->eq($end) && $statistic->type === $type ); } private function filterPrefixedStatistics(Carbon $start, Carbon $end, string $prefix): Collection { if (0 === $this->statistics->count()) { Log::debug('Have no statistic to filter!'); return new Collection(); } return $this->statistics->filter( fn (PeriodStatistic $statistic) => $statistic->start->eq($start) && $statistic->end->eq($end) && str_starts_with($statistic->type, $prefix) ); } private function getSingleModelPeriodByType(Model $model, Carbon $start, Carbon $end, string $type): array { Log::debug(sprintf('Now in getSingleModelPeriodByType(%s #%d, %s %s, %s)', $model::class, $model->id, $start->format('Y-m-d'), $end->format('Y-m-d'), $type)); $statistics = $this->filterStatistics($start, $end, $type); // nothing found, regenerate them. if (0 === $statistics->count()) { Log::debug(sprintf('Found nothing in this period for type "%s"', $type)); if (0 === count($this->transactions)) { switch ($model::class) { default: throw new FireflyException(sprintf('Cannot deal with model of type "%s"', $model::class)); case Category::class: $this->transactions = $this->categoryRepository->periodCollection($model, $start, $end); break; case Account::class: $this->transactions = $this->accountRepository->periodCollection($model, $start, $end); break; case Tag::class: $this->transactions = $this->tagRepository->periodCollection($model, $start, $end); break; } } switch ($type) { default: throw new FireflyException(sprintf('Cannot deal with category period type %s', $type)); case 'spent': $result = $this->filterTransactionsByType(TransactionTypeEnum::WITHDRAWAL, $start, $end); break; case 'earned': $result = $this->filterTransactionsByType(TransactionTypeEnum::DEPOSIT, $start, $end); break; case 'transferred_in': $result = $this->filterTransfers('in', $start, $end); break; case 'transferred_away': $result = $this->filterTransfers('away', $start, $end); break; } // each result must be grouped by currency, then saved as period statistic. Log::debug(sprintf('Going to group %d found journal(s)', count($result))); $grouped = $this->groupByCurrency($result); $this->saveGroupedAsStatistics($model, $start, $end, $type, $grouped); return $grouped; } $grouped = [ 'count' => 0, ]; /** @var PeriodStatistic $statistic */ foreach ($statistics as $statistic) { $id = (int)$statistic->transaction_currency_id; $currency = Amount::getTransactionCurrencyById($id); $grouped[$id] = [ 'amount' => (string)$statistic->amount, 'count' => (int)$statistic->count, 'currency_id' => $currency->id, 'currency_name' => $currency->name, 'currency_code' => $currency->code, 'currency_symbol' => $currency->symbol, 'currency_decimal_places' => $currency->decimal_places, ]; $grouped['count'] += (int)$statistic->count; } return $grouped; } /** * This shows a period overview for a tag. It goes back in time and lists all relevant transactions and sums. * * @throws FireflyException */ protected function getTagPeriodOverview(Tag $tag, Carbon $start, Carbon $end): array // period overview for tags. { $this->tagRepository = app(TagRepositoryInterface::class); $this->tagRepository->setUser($tag->user); $this->periodStatisticRepo = app(PeriodStatisticRepositoryInterface::class); $range = Navigation::getViewRange(true); [$start, $end] = $end < $start ? [$end, $start] : [$start, $end]; /** @var array $dates */ $dates = Navigation::blockPeriods($start, $end, $range); $entries = []; [$start, $end] = $this->getPeriodFromBlocks($dates, $start, $end); $this->statistics = $this->periodStatisticRepo->allInRangeForModel($tag, $start, $end); Log::debug(sprintf('Count of loops: %d', count($dates))); foreach ($dates as $currentDate) { $entries[] = $this->getSingleModelPeriod($tag, $currentDate['period'], $currentDate['start'], $currentDate['end']); } return $entries; } /** * @throws FireflyException */ protected function getTransactionPeriodOverview(string $transactionType, Carbon $start, Carbon $end): array { $range = Navigation::getViewRange(true); $types = config(sprintf('firefly.transactionTypesByType.%s', $transactionType)); [$start, $end] = $end < $start ? [$end, $start] : [$start, $end]; // properties for cache $cache = new CacheProperties(); $cache->addProperty($start); $cache->addProperty($end); $cache->addProperty('transactions-period-entries'); $cache->addProperty($transactionType); if ($cache->has()) { return $cache->get(); } /** @var array $dates */ $dates = Navigation::blockPeriods($start, $end, $range); $entries = []; $spent = []; $earned = []; $transferred = []; // collect all journals in this period (regardless of type) $collector = app(GroupCollectorInterface::class); $collector->setTypes($types)->setRange($start, $end); $genericSet = $collector->getExtractedJournals(); $loops = 0; foreach ($dates as $currentDate) { $title = Navigation::periodShow($currentDate['end'], $currentDate['period']); if ($loops < 10) { // set to correct array if ('expenses' === $transactionType || 'withdrawal' === $transactionType) { $spent = $this->filterJournalsByDate($genericSet, $currentDate['start'], $currentDate['end']); } if ('revenue' === $transactionType || 'deposit' === $transactionType) { $earned = $this->filterJournalsByDate($genericSet, $currentDate['start'], $currentDate['end']); } if ('transfer' === $transactionType || 'transfers' === $transactionType) { $transferred = $this->filterJournalsByDate($genericSet, $currentDate['start'], $currentDate['end']); } } $entries[] = [ 'title' => $title, 'route' => route('transactions.index', [$transactionType, $currentDate['start']->format('Y-m-d'), $currentDate['end']->format('Y-m-d')]), 'total_transactions' => count($spent) + count($earned) + count($transferred), 'spent' => $this->groupByCurrency($spent), 'earned' => $this->groupByCurrency($earned), 'transferred' => $this->groupByCurrency($transferred), ]; ++$loops; } return $entries; } private function saveGroupedAsStatistics(Model $model, Carbon $start, Carbon $end, string $type, array $array): void { unset($array['count']); Log::debug(sprintf('saveGroupedAsStatistics(%s #%d, %s, %s, "%s", array(%d))', $model::class, $model->id, $start->format('Y-m-d'), $end->format('Y-m-d'), $type, count($array))); foreach ($array as $entry) { $this->periodStatisticRepo->saveStatistic($model, $entry['currency_id'], $start, $end, $type, $entry['count'], $entry['amount']); } if (0 === count($array)) { Log::debug('Save empty statistic.'); $this->periodStatisticRepo->saveStatistic($model, $this->primaryCurrency->id, $start, $end, $type, 0, '0'); } } private function saveGroupedForPrefix(string $prefix, Carbon $start, Carbon $end, string $type, array $array): void { unset($array['count']); Log::debug(sprintf('saveGroupedForPrefix("%s", %s, %s, "%s", array(%d))', $prefix, $start->format('Y-m-d'), $end->format('Y-m-d'), $type, count($array))); foreach ($array as $entry) { $this->periodStatisticRepo->savePrefixedStatistic($prefix, $entry['currency_id'], $start, $end, $type, $entry['count'], $entry['amount']); } if (0 === count($array)) { Log::debug('Save empty statistic.'); $this->periodStatisticRepo->savePrefixedStatistic($prefix, $this->primaryCurrency->id, $start, $end, $type, 0, '0'); } } /** * Filter a list of journals by a set of dates, and then group them by currency. */ private function filterJournalsByDate(array $array, Carbon $start, Carbon $end): array { $result = []; /** @var array $journal */ foreach ($array as $journal) { if ($journal['date'] <= $end && $journal['date'] >= $start) { $result[] = $journal; } } return $result; } private function filterTransactionsByType(TransactionTypeEnum $type, Carbon $start, Carbon $end): array { $result = []; /** * @var int $index * @var array $item */ foreach ($this->transactions as $item) { $date = Carbon::parse($item['date']); $fits = $item['type'] === $type->value && $date >= $start && $date <= $end; if ($fits) { // if type is withdrawal, negative amount: if (TransactionTypeEnum::WITHDRAWAL === $type && 1 === bccomp((string)$item['amount'], '0')) { $item['amount'] = Steam::negative($item['amount']); } $result[] = $item; } } return $result; } private function filterTransfers(string $direction, Carbon $start, Carbon $end): array { $result = []; /** * @var int $index * @var array $item */ foreach ($this->transactions as $item) { $date = Carbon::parse($item['date']); if ($date >= $start && $date <= $end) { if ('Transfer' === $item['type'] && 'away' === $direction && -1 === bccomp((string)$item['amount'], '0')) { $result[] = $item; continue; } if ('Transfer' === $item['type'] && 'in' === $direction && 1 === bccomp((string)$item['amount'], '0')) { $result[] = $item; } } } return $result; } private function groupByCurrency(array $journals): array { Log::debug('groupByCurrency()'); $return = [ 'count' => 0, ]; if (0 === count($journals)) { return $return; } /** @var array $journal */ foreach ($journals as $journal) { $currencyId = (int)$journal['currency_id']; $currencyCode = $journal['currency_code']; $currencyName = $journal['currency_name']; $currencySymbol = $journal['currency_symbol']; $currencyDecimalPlaces = $journal['currency_decimal_places']; $foreignCurrencyId = $journal['foreign_currency_id']; $amount = $journal['amount'] ?? '0'; if ($this->convertToPrimary && $currencyId !== $this->primaryCurrency->id && $foreignCurrencyId !== $this->primaryCurrency->id) { $amount = $journal['pc_amount'] ?? '0'; $currencyId = $this->primaryCurrency->id; $currencyCode = $this->primaryCurrency->code; $currencyName = $this->primaryCurrency->name; $currencySymbol = $this->primaryCurrency->symbol; $currencyDecimalPlaces = $this->primaryCurrency->decimal_places; } if ($this->convertToPrimary && $currencyId !== $this->primaryCurrency->id && $foreignCurrencyId === $this->primaryCurrency->id) { $currencyId = (int)$foreignCurrencyId; $currencyCode = $journal['foreign_currency_code']; $currencyName = $journal['foreign_currency_name']; $currencySymbol = $journal['foreign_currency_symbol']; $currencyDecimalPlaces = $journal['foreign_currency_decimal_places']; $amount = $journal['foreign_amount'] ?? '0'; } $return[$currencyId] ??= [ 'amount' => '0', 'count' => 0, 'currency_id' => $currencyId, 'currency_name' => $currencyName, 'currency_code' => $currencyCode, 'currency_symbol' => $currencySymbol, 'currency_decimal_places' => $currencyDecimalPlaces, ]; $return[$currencyId]['amount'] = bcadd((string)$return[$currencyId]['amount'], $amount); ++$return[$currencyId]['count']; ++$return['count']; } return $return; } }