. */ 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\Journal\JournalRepositoryInterface; use FireflyIII\Repositories\PeriodStatistic\PeriodStatisticRepositoryInterface; use FireflyIII\Support\CacheProperties; use FireflyIII\Support\Facades\Amount; use FireflyIII\Support\Facades\Navigation; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Log; /** * 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 JournalRepositoryInterface $journalRepos; protected PeriodStatisticRepositoryInterface $periodStatisticRepo; private Collection $statistics; /** * 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->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); // TODO needs to be re-arranged: // get all period stats for entire range. // loop blocks, an loop the types, and select the missing ones. // create new ones, or use collected. $entries = []; Log::debug(sprintf('Count of loops: %d', count($dates))); foreach ($dates as $currentDate) { $entries[] = $this->getSingleAccountPeriod($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 { $range = Navigation::getViewRange(true); [$start, $end] = $end < $start ? [$end, $start] : [$start, $end]; // properties for entries with their amounts. $cache = new CacheProperties(); $cache->addProperty($start); $cache->addProperty($end); $cache->addProperty($range); $cache->addProperty('category-show-period-entries'); $cache->addProperty($category->id); if ($cache->has()) { return $cache->get(); } /** @var array $dates */ $dates = Navigation::blockPeriods($start, $end, $range); $entries = []; // collect all expenses in this period: /** @var GroupCollectorInterface $collector */ $collector = app(GroupCollectorInterface::class); $collector->setCategory($category); $collector->setRange($start, $end); $collector->setTypes([TransactionTypeEnum::DEPOSIT->value]); $earnedSet = $collector->getExtractedJournals(); // collect all income in this period: /** @var GroupCollectorInterface $collector */ $collector = app(GroupCollectorInterface::class); $collector->setCategory($category); $collector->setRange($start, $end); $collector->setTypes([TransactionTypeEnum::WITHDRAWAL->value]); $spentSet = $collector->getExtractedJournals(); // collect all transfers in this period: /** @var GroupCollectorInterface $collector */ $collector = app(GroupCollectorInterface::class); $collector->setCategory($category); $collector->setRange($start, $end); $collector->setTypes([TransactionTypeEnum::TRANSFER->value]); $transferSet = $collector->getExtractedJournals(); foreach ($dates as $currentDate) { $spent = $this->filterJournalsByDate($spentSet, $currentDate['start'], $currentDate['end']); $earned = $this->filterJournalsByDate($earnedSet, $currentDate['start'], $currentDate['end']); $transferred = $this->filterJournalsByDate($transferSet, $currentDate['start'], $currentDate['end']); $title = Navigation::periodShow($currentDate['end'], $currentDate['period']); $entries[] = [ 'transactions' => 0, 'title' => $title, 'route' => route( 'categories.show', [$category->id, $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), ]; } $cache->store($entries); return $entries; } /** * Same as above, but for lists that involve transactions without a budget. * * This method has been refactored recently. * * @throws FireflyException */ protected function getNoBudgetPeriodOverview(Carbon $start, Carbon $end): array { $range = Navigation::getViewRange(true); [$start, $end] = $end < $start ? [$end, $start] : [$start, $end]; $cache = new CacheProperties(); $cache->addProperty($start); $cache->addProperty($end); $cache->addProperty($this->convertToPrimary); $cache->addProperty('no-budget-period-entries'); if ($cache->has()) { return $cache->get(); } /** @var array $dates */ $dates = Navigation::blockPeriods($start, $end, $range); $entries = []; // get all expenses without a budget. /** @var GroupCollectorInterface $collector */ $collector = app(GroupCollectorInterface::class); $collector->setRange($start, $end)->withoutBudget()->withAccountInformation()->setTypes([TransactionTypeEnum::WITHDRAWAL->value]); $journals = $collector->getExtractedJournals(); foreach ($dates as $currentDate) { $set = $this->filterJournalsByDate($journals, $currentDate['start'], $currentDate['end']); $title = Navigation::periodShow($currentDate['end'], $currentDate['period']); $entries[] = [ 'title' => $title, 'route' => route('budgets.no-budget', [$currentDate['start']->format('Y-m-d'), $currentDate['end']->format('Y-m-d')]), 'total_transactions' => count($set), 'spent' => $this->groupByCurrency($set), 'earned' => [], 'transferred_away' => [], 'transferred_in' => [], ]; } $cache->store($entries); return $entries; } /** * TODO fix the date. * * Show period overview for no category view. * * @throws FireflyException */ protected function getNoCategoryPeriodOverview(Carbon $theDate): array { Log::debug(sprintf('Now in getNoCategoryPeriodOverview(%s)', $theDate->format('Y-m-d'))); $range = Navigation::getViewRange(true); $first = $this->journalRepos->firstNull(); $start = null === $first ? new Carbon() : $first->date; $end = clone $theDate; $end = Navigation::endOfPeriod($end, $range); Log::debug(sprintf('Start for getNoCategoryPeriodOverview() is %s', $start->format('Y-m-d'))); Log::debug(sprintf('End for getNoCategoryPeriodOverview() is %s', $end->format('Y-m-d'))); // properties for cache $dates = Navigation::blockPeriods($start, $end, $range); $entries = []; // collect all expenses in this period: /** @var GroupCollectorInterface $collector */ $collector = app(GroupCollectorInterface::class); $collector->withoutCategory(); $collector->setRange($start, $end); $collector->setTypes([TransactionTypeEnum::DEPOSIT->value]); $earnedSet = $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]); $spentSet = $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]); $transferSet = $collector->getExtractedJournals(); /** @var array $currentDate */ foreach ($dates as $currentDate) { $spent = $this->filterJournalsByDate($spentSet, $currentDate['start'], $currentDate['end']); $earned = $this->filterJournalsByDate($earnedSet, $currentDate['start'], $currentDate['end']); $transferred = $this->filterJournalsByDate($transferSet, $currentDate['start'], $currentDate['end']); $title = Navigation::periodShow($currentDate['end'], $currentDate['period']); $entries[] = [ 'title' => $title, 'route' => route('categories.no-category', [$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), ]; } Log::debug('End of loops'); return $entries; } protected function getSingleAccountPeriod(Account $account, string $period, Carbon $start, Carbon $end): array { Log::debug(sprintf('Now in getSingleAccountPeriod(#%d, %s %s)', $account->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('accounts.show', [$account->id, $start->format('Y-m-d'), $start->format('Y-m-d')]), 'total_transactions' => 0, ]; foreach ($types as $type) { $set = $this->getSingleAccountPeriodByType($account, $start, $end, $type); $return['total_transactions'] += $set['count']; unset($set['count']); $return[$type] = $set; } return $return; } protected function filterStatistics(Carbon $start, Carbon $end, string $type): Collection { return $this->statistics->filter( function (PeriodStatistic $statistic) use ($start, $end, $type) { if ( !$statistic->end->equalTo($end) && $statistic->end->format('Y-m-d H:i:s') === $end->format('Y-m-d H:i:s') ) { echo sprintf('End: "%s" vs "%s": %s', $statistic->end->toW3cString(), $end->toW3cString(), var_export($statistic->end->eq($end), true)); var_dump($statistic->end); var_dump($end); exit; } return $statistic->start->eq($start) && $statistic->end->eq($end) && $statistic->type === $type; } ); } protected function getSingleAccountPeriodByType(Account $account, Carbon $start, Carbon $end, string $type): array { Log::debug(sprintf('Now in getSingleAccountPeriodByType(#%d, %s %s, %s)', $account->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)); $transactions = $this->accountRepository->periodCollection($account, $start, $end); switch ($type) { default: throw new FireflyException(sprintf('Cannot deal with account period type %s', $type)); case 'spent': $result = $this->filterTransactionsByType(TransactionTypeEnum::WITHDRAWAL, $transactions, $start, $end); break; case 'earned': $result = $this->filterTransactionsByType(TransactionTypeEnum::DEPOSIT, $transactions, $start, $end); break; case 'transferred_in': $result = $this->filterTransfers('in', $transactions, $start, $end); break; case 'transferred_away': $result = $this->filterTransfers('away', $transactions, $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($account, $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. { $range = Navigation::getViewRange(true); [$start, $end] = $end < $start ? [$end, $start] : [$start, $end]; // properties for cache $cache = new CacheProperties(); $cache->addProperty($start); $cache->addProperty($end); $cache->addProperty('tag-period-entries'); $cache->addProperty($tag->id); if ($cache->has()) { return $cache->get(); } /** @var array $dates */ $dates = Navigation::blockPeriods($start, $end, $range); $entries = []; // collect all expenses in this period: /** @var GroupCollectorInterface $collector */ $collector = app(GroupCollectorInterface::class); $collector->setTag($tag); $collector->setRange($start, $end); $collector->setTypes([TransactionTypeEnum::DEPOSIT->value]); $earnedSet = $collector->getExtractedJournals(); // collect all income in this period: /** @var GroupCollectorInterface $collector */ $collector = app(GroupCollectorInterface::class); $collector->setTag($tag); $collector->setRange($start, $end); $collector->setTypes([TransactionTypeEnum::WITHDRAWAL->value]); $spentSet = $collector->getExtractedJournals(); // collect all transfers in this period: /** @var GroupCollectorInterface $collector */ $collector = app(GroupCollectorInterface::class); $collector->setTag($tag); $collector->setRange($start, $end); $collector->setTypes([TransactionTypeEnum::TRANSFER->value]); $transferSet = $collector->getExtractedJournals(); // filer all of them: $earnedSet = $this->filterJournalsByTag($earnedSet, $tag); $spentSet = $this->filterJournalsByTag($spentSet, $tag); $transferSet = $this->filterJournalsByTag($transferSet, $tag); foreach ($dates as $currentDate) { $spent = $this->filterJournalsByDate($spentSet, $currentDate['start'], $currentDate['end']); $earned = $this->filterJournalsByDate($earnedSet, $currentDate['start'], $currentDate['end']); $transferred = $this->filterJournalsByDate($transferSet, $currentDate['start'], $currentDate['end']); $title = Navigation::periodShow($currentDate['end'], $currentDate['period']); $entries[] = [ 'transactions' => 0, 'title' => $title, 'route' => route( 'tags.show', [$tag->id, $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), ]; } 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; } protected function saveGroupedAsStatistics(Account $account, Carbon $start, Carbon $end, string $type, array $array): void { unset($array['count']); Log::debug(sprintf('saveGroupedAsStatistics(#%d, %s, %s, "%s", array(%d))', $account->id, $start->format('Y-m-d'), $end->format('Y-m-d'), $type, count($array))); foreach ($array as $entry) { $this->periodStatisticRepo->saveStatistic($account, $entry['currency_id'], $start, $end, $type, $entry['count'], $entry['amount']); } if (0 === count($array)) { Log::debug('Save empty statistic.'); $this->periodStatisticRepo->saveStatistic($account, $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 filterJournalsByTag(array $set, Tag $tag): array { $return = []; foreach ($set as $entry) { $found = false; /** @var array $localTag */ foreach ($entry['tags'] as $localTag) { if ($localTag['id'] === $tag->id) { $found = true; } } if (false === $found) { continue; } $return[] = $entry; } return $return; } private function filterTransactionsByType(TransactionTypeEnum $type, array $transactions, Carbon $start, Carbon $end): array { $result = []; /** * @var int $index * @var array $item */ foreach ($transactions as $index => $item) { $date = Carbon::parse($item['date']); $fits = $item['type'] === $type->value && $date >= $start && $date <= $end; if ($fits) { $result[] = $item; unset($transactions[$index]); } } return $result; } /** * Return only transactions where $account is the source. */ private function filterTransferredAway(Account $account, array $journals): array { $return = []; /** @var array $journal */ foreach ($journals as $journal) { if ($account->id === (int)$journal['source_account_id']) { $return[] = $journal; } } return $return; } /** * Return only transactions where $account is the source. */ private function filterTransferredIn(Account $account, array $journals): array { $return = []; /** @var array $journal */ foreach ($journals as $journal) { if ($account->id === (int)$journal['destination_account_id']) { $return[] = $journal; } } return $return; } private function filterTransfers(string $direction, array $transactions, Carbon $start, Carbon $end): array { $result = []; /** * @var int $index * @var array $item */ foreach ($transactions as $index => $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; continue; } } } return $result; } private function groupByCurrency(array $journals): array { Log::debug('groupByCurrency()'); $return = [ 'count' => 0, ]; /** @var array $journal */ foreach ($journals as $journal) { if (!array_key_exists('currency_id', $journal)) { Log::debug('very strange!'); var_dump($journals); exit; } $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($return[$currencyId]['amount'], $amount); ++$return[$currencyId]['count']; ++$return['count']; } return $return; } }