. */ declare(strict_types=1); namespace FireflyIII\Helpers\Collector; use Carbon\Carbon; use DB; use FireflyIII\Exceptions\FireflyException; use FireflyIII\Helpers\Filter\CountAttachmentsFilter; use FireflyIII\Helpers\Filter\DoubleTransactionFilter; use FireflyIII\Helpers\Filter\FilterInterface; use FireflyIII\Helpers\Filter\InternalTransferFilter; use FireflyIII\Helpers\Filter\NegativeAmountFilter; use FireflyIII\Helpers\Filter\OpposingAccountFilter; use FireflyIII\Helpers\Filter\PositiveAmountFilter; use FireflyIII\Helpers\Filter\SplitIndicatorFilter; use FireflyIII\Helpers\Filter\TransactionViewFilter; use FireflyIII\Helpers\Filter\TransferFilter; use FireflyIII\Models\AccountType; use FireflyIII\Models\Budget; use FireflyIII\Models\Category; use FireflyIII\Models\Tag; use FireflyIII\Models\Transaction; use FireflyIII\Models\TransactionCurrency; use FireflyIII\Repositories\Account\AccountRepositoryInterface; use FireflyIII\Support\CacheProperties; use FireflyIII\User; use Illuminate\Database\Eloquent\Builder as EloquentBuilder; use Illuminate\Database\Query\JoinClause; use Illuminate\Pagination\LengthAwarePaginator; use Illuminate\Support\Collection; use Log; /** * Class TransactionCollector * * @codeCoverageIgnore */ class TransactionCollector implements TransactionCollectorInterface { /** @var array */ private $accountIds = []; /** @var int */ private $count = 0; /** @var array */ private $fields = [ 'transaction_journals.id as journal_id', 'transaction_journals.description', 'transaction_journals.date', 'transaction_journals.encrypted', 'transaction_journals.created_at', 'transaction_journals.updated_at', 'transaction_types.type as transaction_type_type', 'transaction_journals.bill_id', 'transaction_journals.updated_at', 'bills.name as bill_name', 'bills.name_encrypted as bill_name_encrypted', 'transactions.id as id', 'transactions.description as transaction_description', 'transactions.account_id', 'transactions.reconciled', 'transactions.identifier', 'transactions.transaction_journal_id', 'transactions.amount as transaction_amount', 'transactions.transaction_currency_id as transaction_currency_id', 'transaction_currencies.name as transaction_currency_name', 'transaction_currencies.code as transaction_currency_code', 'transaction_currencies.symbol as transaction_currency_symbol', 'transaction_currencies.decimal_places as transaction_currency_dp', 'transactions.foreign_amount as transaction_foreign_amount', 'transactions.foreign_currency_id as foreign_currency_id', 'foreign_currencies.code as foreign_currency_code', 'foreign_currencies.symbol as foreign_currency_symbol', 'foreign_currencies.decimal_places as foreign_currency_dp', 'accounts.name as account_name', 'accounts.encrypted as account_encrypted', 'accounts.iban as account_iban', 'account_types.type as account_type', ]; /** @var array */ private $filters = [InternalTransferFilter::class]; /** @var bool */ private $ignoreCache = false; /** @var bool */ private $joinedBudget = false; /** @var bool */ private $joinedCategory = false; /** @var bool */ private $joinedOpposing = false; /** @var bool */ private $joinedTag = false; /** @var int */ private $limit; /** @var int */ private $offset; /** @var int */ private $page = 1; /** @var EloquentBuilder */ private $query; /** @var bool */ private $run = false; /** @var User */ private $user; /** * Constructor. */ public function __construct() { if ('testing' === config('app.env')) { Log::warning(sprintf('%s should not be instantiated in the TEST environment!', \get_class($this))); } } /** * @param string $filter * * @return TransactionCollectorInterface */ public function addFilter(string $filter): TransactionCollectorInterface { $interfaces = class_implements($filter); if (\in_array(FilterInterface::class, $interfaces, true) && !\in_array($filter, $this->filters, true)) { Log::debug(sprintf('Enabled filter %s', $filter)); $this->filters[] = $filter; } return $this; } /** * @param string $amount * * @return TransactionCollectorInterface */ public function amountIs(string $amount): TransactionCollectorInterface { $this->query->where( function (EloquentBuilder $q) use ($amount) { $q->where('transactions.amount', $amount); $q->orWhere('transactions.amount', bcmul($amount, '-1')); } ); return $this; } /** * @param string $amount * * @return TransactionCollectorInterface */ public function amountLess(string $amount): TransactionCollectorInterface { $this->query->where( function (EloquentBuilder $q1) use ($amount) { $q1->where( function (EloquentBuilder $q2) use ($amount) { // amount < 0 and .amount > -$amount $invertedAmount = bcmul($amount, '-1'); $q2->where('transactions.amount', '<', 0)->where('transactions.amount', '>', $invertedAmount); } ) ->orWhere( function (EloquentBuilder $q3) use ($amount) { // amount > 0 and .amount < $amount $q3->where('transactions.amount', '>', 0)->where('transactions.amount', '<', $amount); } ); } ); return $this; } /** * @param string $amount * * @return TransactionCollectorInterface */ public function amountMore(string $amount): TransactionCollectorInterface { $this->query->where( function (EloquentBuilder $q1) use ($amount) { $q1->where( function (EloquentBuilder $q2) use ($amount) { // amount < 0 and .amount < -$amount $invertedAmount = bcmul($amount, '-1'); $q2->where('transactions.amount', '<', 0)->where('transactions.amount', '<', $invertedAmount); } ) ->orWhere( function (EloquentBuilder $q3) use ($amount) { // amount > 0 and .amount > $amount $q3->where('transactions.amount', '>', 0)->where('transactions.amount', '>', $amount); } ); } ); return $this; } /** * @return int * * @throws FireflyException */ public function count(): int { if (true === $this->run) { throw new FireflyException('Cannot count after run in TransactionCollector.'); } $countQuery = clone $this->query; // dont need some fields: $countQuery->getQuery()->limit = null; $countQuery->getQuery()->offset = null; $countQuery->getQuery()->unionLimit = null; $countQuery->getQuery()->groups = null; $countQuery->getQuery()->orders = null; $countQuery->groupBy('accounts.user_id'); $this->count = (int)$countQuery->count(); return $this->count; } /** * @return LengthAwarePaginator * @throws FireflyException */ public function getPaginatedTransactions(): LengthAwarePaginator { if (true === $this->run) { throw new FireflyException('Cannot getPaginatedTransactions after run in TransactionCollector.'); } $this->count(); $set = $this->getTransactions(); $journals = new LengthAwarePaginator($set, $this->count, $this->limit, $this->page); return $journals; } /** * @return EloquentBuilder */ public function getQuery(): EloquentBuilder { return $this->query; } /** * @return Collection */ public function getTransactions(): Collection { $this->run = true; // find query set in cache. $hash = hash('sha256', $this->query->toSql() . serialize($this->query->getBindings())); $key = 'query-' . substr($hash, -8); $cache = new CacheProperties; $cache->addProperty($key); foreach ($this->filters as $filter) { $cache->addProperty((string)$filter); } if (false === $this->ignoreCache && $cache->has()) { Log::debug(sprintf('Return cache of query with ID "%s".', $key)); return $cache->get(); // @codeCoverageIgnore } /** @var Collection $set */ $set = $this->query->get(array_values($this->fields)); // run all filters: $set = $this->filter($set); // loop for date. $set->each( function (Transaction $transaction) { $transaction->date = new Carbon($transaction->date); } ); Log::debug(sprintf('Cached query with ID "%s".', $key)); $cache->store($set); return $set; } /** * @return TransactionCollectorInterface */ public function ignoreCache(): TransactionCollectorInterface { $this->ignoreCache = true; return $this; } /** * @param string $filter * * @return TransactionCollectorInterface */ public function removeFilter(string $filter): TransactionCollectorInterface { $key = array_search($filter, $this->filters, true); if (!(false === $key)) { Log::debug(sprintf('Removed filter %s', $filter)); unset($this->filters[$key]); } return $this; } /** * @param Collection $accounts * * @return TransactionCollectorInterface */ public function setAccounts(Collection $accounts): TransactionCollectorInterface { if ($accounts->count() > 0) { $accountIds = $accounts->pluck('id')->toArray(); $this->query->whereIn('transactions.account_id', $accountIds); Log::debug(sprintf('setAccounts: %s', implode(', ', $accountIds))); $this->accountIds = $accountIds; } if ($accounts->count() > 1) { $this->addFilter(TransferFilter::class); } return $this; } /** * @param Carbon $after * * @return TransactionCollectorInterface */ public function setAfter(Carbon $after): TransactionCollectorInterface { $afterStr = $after->format('Y-m-d 00:00:00'); $this->query->where('transaction_journals.date', '>=', $afterStr); Log::debug(sprintf('TransactionCollector range is now after %s (inclusive)', $afterStr)); return $this; } /** * @return TransactionCollectorInterface */ public function setAllAssetAccounts(): TransactionCollectorInterface { /** @var AccountRepositoryInterface $repository */ $repository = app(AccountRepositoryInterface::class); $repository->setUser($this->user); $accounts = $repository->getAccountsByType([AccountType::ASSET, AccountType::DEFAULT]); if ($accounts->count() > 0) { $accountIds = $accounts->pluck('id')->toArray(); $this->query->whereIn('transactions.account_id', $accountIds); $this->accountIds = $accountIds; } if ($accounts->count() > 1) { $this->addFilter(TransferFilter::class); } return $this; } /** * @param Carbon $before * * @return TransactionCollectorInterface */ public function setBefore(Carbon $before): TransactionCollectorInterface { $beforeStr = $before->format('Y-m-d 00:00:00'); $this->query->where('transaction_journals.date', '<=', $beforeStr); Log::debug(sprintf('TransactionCollector range is now before %s (inclusive)', $beforeStr)); return $this; } /** * @param Collection $bills * * @return TransactionCollectorInterface */ public function setBills(Collection $bills): TransactionCollectorInterface { if ($bills->count() > 0) { $billIds = $bills->pluck('id')->toArray(); $this->query->whereIn('transaction_journals.bill_id', $billIds); } return $this; } /** * @param Budget $budget * * @return TransactionCollectorInterface */ public function setBudget(Budget $budget): TransactionCollectorInterface { $this->joinBudgetTables(); $this->query->where( function (EloquentBuilder $q) use ($budget) { $q->where('budget_transaction.budget_id', $budget->id); $q->orWhere('budget_transaction_journal.budget_id', $budget->id); } ); return $this; } /** * @param Collection $budgets * * @return TransactionCollectorInterface */ public function setBudgets(Collection $budgets): TransactionCollectorInterface { $budgetIds = $budgets->pluck('id')->toArray(); if (0 !== \count($budgetIds)) { $this->joinBudgetTables(); Log::debug('Journal collector will filter for budgets', $budgetIds); $this->query->where( function (EloquentBuilder $q) use ($budgetIds) { $q->whereIn('budget_transaction.budget_id', $budgetIds); $q->orWhereIn('budget_transaction_journal.budget_id', $budgetIds); } ); } return $this; } /** * @param Collection $categories * * @return TransactionCollectorInterface */ public function setCategories(Collection $categories): TransactionCollectorInterface { $categoryIds = $categories->pluck('id')->toArray(); if (0 !== \count($categoryIds)) { $this->joinCategoryTables(); $this->query->where( function (EloquentBuilder $q) use ($categoryIds) { $q->whereIn('category_transaction.category_id', $categoryIds); $q->orWhereIn('category_transaction_journal.category_id', $categoryIds); } ); } return $this; } /** * @param Category $category * * @return TransactionCollectorInterface */ public function setCategory(Category $category): TransactionCollectorInterface { $this->joinCategoryTables(); $this->query->where( function (EloquentBuilder $q) use ($category) { $q->where('category_transaction.category_id', $category->id); $q->orWhere('category_transaction_journal.category_id', $category->id); } ); return $this; } /** * Set the required currency (local or foreign) * * @param TransactionCurrency $currency * * @return TransactionCollectorInterface */ public function setCurrency(TransactionCurrency $currency): TransactionCollectorInterface { $this->query->where( function (EloquentBuilder $builder) use ($currency) { $builder->where('transactions.transaction_currency_id', $currency->id); $builder->orWhere('transactions.foreign_currency_id', $currency->id); } ); return $this; } /** * @param array $journalIds * * @return TransactionCollectorInterface */ public function setJournalIds(array $journalIds): TransactionCollectorInterface { $this->query->where( function (EloquentBuilder $q) use ($journalIds) { $q->whereIn('transaction_journals.id', $journalIds); } ); return $this; } /** * @param Collection $journals * * @return TransactionCollectorInterface */ public function setJournals(Collection $journals): TransactionCollectorInterface { $ids = $journals->pluck('id')->toArray(); $this->query->where( function (EloquentBuilder $q) use ($ids) { $q->whereIn('transaction_journals.id', $ids); } ); return $this; } /** * @param int $limit * * @return TransactionCollectorInterface */ public function setLimit(int $limit): TransactionCollectorInterface { $this->limit = $limit; $this->query->limit($limit); Log::debug(sprintf('Set limit to %d', $limit)); return $this; } /** * @param int $offset * * @return TransactionCollectorInterface */ public function setOffset(int $offset): TransactionCollectorInterface { $this->offset = $offset; return $this; } /** * @param Collection $accounts * * @return TransactionCollectorInterface */ public function setOpposingAccounts(Collection $accounts): TransactionCollectorInterface { $this->withOpposingAccount(); $this->query->whereIn('opposing.account_id', $accounts->pluck('id')->toArray()); return $this; } /** * @param int $page * * @return TransactionCollectorInterface */ public function setPage(int $page): TransactionCollectorInterface { if ($page < 1) { $page = 1; } $this->page = $page; if ($page > 0) { --$page; } Log::debug(sprintf('Page is %d', $page)); if (null !== $this->limit) { $offset = ($this->limit * $page); $this->offset = $offset; $this->query->skip($offset); Log::debug(sprintf('Changed offset to %d', $offset)); } return $this; } /** * @param Carbon $start * @param Carbon $end * * @return TransactionCollectorInterface */ public function setRange(Carbon $start, Carbon $end): TransactionCollectorInterface { if ($start <= $end) { $startStr = $start->format('Y-m-d 00:00:00'); $endStr = $end->format('Y-m-d 23:59:59'); $this->query->where('transaction_journals.date', '>=', $startStr); $this->query->where('transaction_journals.date', '<=', $endStr); Log::debug(sprintf('TransactionCollector range is now %s - %s (inclusive)', $startStr, $endStr)); } return $this; } /** * @param Tag $tag * * @return TransactionCollectorInterface */ public function setTag(Tag $tag): TransactionCollectorInterface { $this->joinTagTables(); $this->query->where('tag_transaction_journal.tag_id', $tag->id); return $this; } /** * @param Collection $tags * * @return TransactionCollectorInterface */ public function setTags(Collection $tags): TransactionCollectorInterface { $this->joinTagTables(); $tagIds = $tags->pluck('id')->toArray(); $this->query->whereIn('tag_transaction_journal.tag_id', $tagIds); return $this; } /** * @param array $types * * @return TransactionCollectorInterface */ public function setTypes(array $types): TransactionCollectorInterface { if (\count($types) > 0) { Log::debug('Set query collector types', $types); $this->query->whereIn('transaction_types.type', $types); } return $this; } /** * @param User $user */ public function setUser(User $user) { Log::debug(sprintf('Journal collector now collecting for user #%d', $user->id)); $this->user = $user; $this->startQuery(); } /** * */ public function startQuery(): void { Log::debug('TransactionCollector::startQuery'); /** @var EloquentBuilder $query */ $query = Transaction::leftJoin('transaction_journals', 'transaction_journals.id', '=', 'transactions.transaction_journal_id') ->leftJoin('transaction_types', 'transaction_types.id', 'transaction_journals.transaction_type_id') ->leftJoin('bills', 'bills.id', 'transaction_journals.bill_id') ->leftJoin('accounts', 'accounts.id', '=', 'transactions.account_id') ->leftJoin('account_types', 'accounts.account_type_id', 'account_types.id') ->leftJoin('transaction_currencies', 'transaction_currencies.id', 'transactions.transaction_currency_id') ->leftJoin('transaction_currencies as foreign_currencies', 'foreign_currencies.id', 'transactions.foreign_currency_id') ->whereNull('transactions.deleted_at') ->whereNull('transaction_journals.deleted_at') ->where('transaction_journals.user_id', $this->user->id) ->orderBy('transaction_journals.date', 'DESC') ->orderBy('transaction_journals.order', 'ASC') ->orderBy('transaction_journals.id', 'DESC') ->orderBy('transaction_journals.description', 'DESC') ->orderBy('transactions.identifier', 'ASC') ->orderBy('transactions.amount', 'DESC'); $this->query = $query; } /** * @return TransactionCollectorInterface */ public function withBudgetInformation(): TransactionCollectorInterface { $this->joinBudgetTables(); return $this; } /** * @return TransactionCollectorInterface */ public function withCategoryInformation(): TransactionCollectorInterface { $this->joinCategoryTables(); return $this; } /** * @return TransactionCollectorInterface */ public function withOpposingAccount(): TransactionCollectorInterface { $this->joinOpposingTables(); return $this; } /** * @return TransactionCollectorInterface */ public function withoutBudget(): TransactionCollectorInterface { $this->joinBudgetTables(); $this->query->where( function (EloquentBuilder $q) { $q->whereNull('budget_transaction.budget_id'); $q->whereNull('budget_transaction_journal.budget_id'); } ); return $this; } /** * @return TransactionCollectorInterface */ public function withoutCategory(): TransactionCollectorInterface { $this->joinCategoryTables(); $this->query->where( function (EloquentBuilder $q) { $q->whereNull('category_transaction.category_id'); $q->whereNull('category_transaction_journal.category_id'); } ); return $this; } /** * @param Collection $set * * @return Collection */ private function filter(Collection $set): Collection { // create all possible filters: $filters = [ InternalTransferFilter::class => new InternalTransferFilter($this->accountIds), OpposingAccountFilter::class => new OpposingAccountFilter($this->accountIds), TransferFilter::class => new TransferFilter, PositiveAmountFilter::class => new PositiveAmountFilter, NegativeAmountFilter::class => new NegativeAmountFilter, SplitIndicatorFilter::class => new SplitIndicatorFilter, CountAttachmentsFilter::class => new CountAttachmentsFilter, TransactionViewFilter::class => new TransactionViewFilter, DoubleTransactionFilter::class => new DoubleTransactionFilter, ]; Log::debug(sprintf('Will run %d filters on the set.', \count($this->filters))); foreach ($this->filters as $enabled) { if (isset($filters[$enabled])) { Log::debug(sprintf('Before filter %s: %d', $enabled, $set->count())); /** @var Collection $set */ $set = $filters[$enabled]->filter($set); Log::debug(sprintf('After filter %s: %d', $enabled, $set->count())); } } return $set; } /** * */ private function joinBudgetTables(): void { if (!$this->joinedBudget) { // join some extra tables: $this->joinedBudget = true; $this->query->leftJoin('budget_transaction_journal', 'budget_transaction_journal.transaction_journal_id', '=', 'transaction_journals.id'); $this->query->leftJoin('budgets as transaction_journal_budgets', 'transaction_journal_budgets.id', '=', 'budget_transaction_journal.budget_id'); $this->query->leftJoin('budget_transaction', 'budget_transaction.transaction_id', '=', 'transactions.id'); $this->query->leftJoin('budgets as transaction_budgets', 'transaction_budgets.id', '=', 'budget_transaction.budget_id'); $this->query->whereNull('transaction_journal_budgets.deleted_at'); $this->query->whereNull('transaction_budgets.deleted_at'); $this->fields[] = 'budget_transaction_journal.budget_id as transaction_journal_budget_id'; $this->fields[] = 'transaction_journal_budgets.encrypted as transaction_journal_budget_encrypted'; $this->fields[] = 'transaction_journal_budgets.name as transaction_journal_budget_name'; $this->fields[] = 'budget_transaction.budget_id as transaction_budget_id'; $this->fields[] = 'transaction_budgets.encrypted as transaction_budget_encrypted'; $this->fields[] = 'transaction_budgets.name as transaction_budget_name'; } } /** * */ private function joinCategoryTables(): void { if (!$this->joinedCategory) { // join some extra tables: $this->joinedCategory = true; $this->query->leftJoin('category_transaction_journal', 'category_transaction_journal.transaction_journal_id', '=', 'transaction_journals.id'); $this->query->leftJoin( 'categories as transaction_journal_categories', 'transaction_journal_categories.id', '=', 'category_transaction_journal.category_id' ); $this->query->leftJoin('category_transaction', 'category_transaction.transaction_id', '=', 'transactions.id'); $this->query->leftJoin('categories as transaction_categories', 'transaction_categories.id', '=', 'category_transaction.category_id'); $this->query->whereNull('transaction_journal_categories.deleted_at'); $this->query->whereNull('transaction_categories.deleted_at'); $this->fields[] = 'category_transaction_journal.category_id as transaction_journal_category_id'; $this->fields[] = 'transaction_journal_categories.encrypted as transaction_journal_category_encrypted'; $this->fields[] = 'transaction_journal_categories.name as transaction_journal_category_name'; $this->fields[] = 'category_transaction.category_id as transaction_category_id'; $this->fields[] = 'transaction_categories.encrypted as transaction_category_encrypted'; $this->fields[] = 'transaction_categories.name as transaction_category_name'; } } /** * */ private function joinOpposingTables(): void { if (!$this->joinedOpposing) { Log::debug('joinedOpposing is false'); // join opposing transaction (hard): $this->query->leftJoin( 'transactions as opposing', function (JoinClause $join) { $join->on('opposing.transaction_journal_id', '=', 'transactions.transaction_journal_id') ->where('opposing.identifier', '=', DB::raw('transactions.identifier')) ->where('opposing.amount', '=', DB::raw('transactions.amount * -1')); } ); $this->query->leftJoin('accounts as opposing_accounts', 'opposing.account_id', '=', 'opposing_accounts.id'); $this->query->leftJoin('account_types as opposing_account_types', 'opposing_accounts.account_type_id', '=', 'opposing_account_types.id'); $this->query->whereNull('opposing.deleted_at'); $this->fields[] = 'opposing.id as opposing_id'; $this->fields[] = 'opposing.account_id as opposing_account_id'; $this->fields[] = 'opposing_accounts.name as opposing_account_name'; $this->fields[] = 'opposing_accounts.encrypted as opposing_account_encrypted'; $this->fields[] = 'opposing_accounts.iban as opposing_account_iban'; $this->fields[] = 'opposing_account_types.type as opposing_account_type'; $this->joinedOpposing = true; Log::debug('joinedOpposing is now true!'); } } /** * */ private function joinTagTables(): void { if (!$this->joinedTag) { // join some extra tables: $this->joinedTag = true; $this->query->leftJoin('tag_transaction_journal', 'tag_transaction_journal.transaction_journal_id', '=', 'transaction_journals.id'); } } }