*/ use HasFactory, HasUuids, SoftDeletes; /** @var array */ protected $dispatchesEvents = [ 'created' => TransactionCreated::class, 'updated' => TransactionUpdated::class, 'deleted' => TransactionDeleted::class, ]; protected $fillable = [ 'user_id', 'account_id', 'category_id', 'category_source', 'ai_confidence', 'categorized_by_rule_id', 'description', 'description_iv', 'original_description', 'transaction_date', 'amount', 'currency_code', 'notes', 'notes_iv', 'source', 'external_transaction_id', 'dedup_fingerprint', 'raw_data', 'creditor_name', 'debtor_name', ]; /** * Internal columns that must never reach the frontend (raw bank payloads, * dedup metadata and the pre-formatting description). * * @var list */ protected $hidden = [ 'original_description', 'external_transaction_id', 'dedup_fingerprint', 'raw_data', 'categorized_by_rule_id', 'deleted_at', ]; protected function casts(): array { return [ 'transaction_date' => 'date:Y-m-d', 'amount' => 'integer', 'source' => TransactionSource::class, 'category_source' => CategorySource::class, 'ai_confidence' => 'float', 'raw_data' => 'array', ]; } /** @return BelongsTo */ public function user(): BelongsTo { return $this->belongsTo(User::class); } /** @return BelongsTo */ public function account(): BelongsTo { return $this->belongsTo(Account::class); } /** @return BelongsTo */ public function category(): BelongsTo { return $this->belongsTo(Category::class); } /** @return BelongsTo */ public function categorizedByRule(): BelongsTo { return $this->belongsTo(AutomationRule::class, 'categorized_by_rule_id'); } /** * Whether AI assigned this transaction's category — either directly or via an * AI-owned rule. Not appended by default; surfaces opt in (e.g. the index * controller eager-loads `categorizedByRule:id,origin` and appends this) so * the rule-origin check never triggers a lazy load. * * @return Attribute */ protected function aiCategorized(): Attribute { return Attribute::make(get: function (): bool { if ($this->category_source === CategorySource::Ai) { return true; } if (! $this->relationLoaded('categorizedByRule')) { return false; } return $this->categorizedByRule?->origin === RuleOrigin::Ai; }); } /** @return BelongsToMany */ public function labels(): BelongsToMany { return $this->belongsToMany(Label::class) ->using(LabelTransaction::class) ->withTimestamps(); } /** @return HasMany */ public function budgetTransactions(): HasMany { return $this->hasMany(BudgetTransaction::class); } /** * @param Builder $query * @param array $filters * @return Builder */ public function scopeApplyFilters(Builder $query, array $filters): Builder { if (isset($filters['date_from'])) { $query->whereDate('transaction_date', '>=', $filters['date_from']); } if (isset($filters['date_to'])) { $query->whereDate('transaction_date', '<=', $filters['date_to']); } if (isset($filters['amount_min'])) { $query->where('amount', '>=', $filters['amount_min'] * 100); } if (isset($filters['amount_max'])) { $query->where('amount', '<=', $filters['amount_max'] * 100); } $hasCategoryFilter = ! empty($filters['category_ids']); $hasLabelFilter = ! empty($filters['label_ids']); if ($hasCategoryFilter || $hasLabelFilter) { $realIds = []; $hasUncategorized = false; if ($hasCategoryFilter) { $ids = collect($filters['category_ids']); $hasUncategorized = $ids->contains('uncategorized'); $realIds = $ids->reject(fn ($id) => $id === 'uncategorized')->values()->all(); if ($realIds !== []) { $userId = $filters['user_id'] ?? Category::query()->whereIn('id', $realIds)->value('user_id'); if ($userId !== null) { $realIds = app(CategoryTree::class)->expand($userId, $realIds); } } } $labelIds = $filters['label_ids'] ?? []; $query->where(function (Builder $outer) use ($hasCategoryFilter, $realIds, $hasUncategorized, $hasLabelFilter, $labelIds) { if ($hasCategoryFilter) { $outer->where(function (Builder $q) use ($realIds, $hasUncategorized) { if (! empty($realIds)) { $q->whereIn('category_id', $realIds); } if ($hasUncategorized) { $q->orWhereNull('category_id'); } }); } if ($hasLabelFilter) { $outer->orWhereHas('labels', fn (Builder $q) => $q->whereIn('labels.id', $labelIds)); } }); } if (! empty($filters['account_ids'])) { $query->whereIn('account_id', $filters['account_ids']); } if (! empty($filters['category_source'])) { $query->where('category_source', $filters['category_source']); } if (! empty($filters['creditor_name'])) { $term = '%'.$filters['creditor_name'].'%'; $query->where('creditor_name', 'LIKE', $term); } if (! empty($filters['debtor_name'])) { $term = '%'.$filters['debtor_name'].'%'; $query->where('debtor_name', 'LIKE', $term); } if (! empty($filters['search'])) { $term = '%'.$filters['search'].'%'; $query->where(fn (Builder $q) => $q ->where('description', 'LIKE', $term) ->orWhere('notes', 'LIKE', $term) ->orWhere('creditor_name', 'LIKE', $term) ->orWhere('debtor_name', 'LIKE', $term)); } return $query; } }