*/ use BelongsToSpace, HasFactory, HasUuids, SoftDeletes; /** @var array */ protected $dispatchesEvents = [ 'created' => TransactionCreated::class, 'updated' => TransactionUpdated::class, 'deleted' => TransactionDeleted::class, ]; protected $fillable = [ 'user_id', 'space_id', 'account_id', 'category_id', 'category_source', 'ai_confidence', 'categorized_by_rule_id', 'ai_suggested_category_id', 'ai_suggested_category_at', 'ai_model', '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 = [ 'space_id', 'original_description', 'external_transaction_id', 'dedup_fingerprint', 'raw_data', 'categorized_by_rule_id', 'ai_model', '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', 'ai_suggested_category_at' => 'datetime', '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); } /** * A transaction always lives in its account's space (the account is the * tenant anchor), so bank-sync inserts land in the right space regardless of * whichever space the syncing user is currently viewing. */ protected function resolveDefaultSpaceId(): ?string { $accountId = $this->getAttribute('account_id'); if ($accountId !== null) { $spaceId = Account::query()->whereKey($accountId)->value('space_id'); if ($spaceId !== null) { return $spaceId; } } return $this->spaceIdFromUser(); } /** @return BelongsTo */ public function category(): BelongsTo { return $this->belongsTo(Category::class); } /** * The type of the assigned category, resilient to phantom categories that * are force-filled with a raw string type (e.g. the synthetic * "uncategorized" rows the analytics controllers build). */ public function categoryType(): ?CategoryType { $type = $this->category?->getAttribute('type'); if ($type instanceof CategoryType) { return $type; } return is_string($type) ? CategoryType::tryFrom($type) : null; } /** * Whether this transaction sits on the income side of a cashflow split: * booked to an income category (a reversal there nets back out) or an * uncategorized inflow. Internal movements (transfer, savings, investment) * belong to neither side. * * Reads categoryType(), so callers should eager-load the category relation * when classifying a collection to avoid an N+1. */ public function isIncomeSide(): bool { return $this->categoryType() === CategoryType::Income || ($this->category_id === null && $this->amount > 0); } /** * Whether this transaction sits on the expense side: booked to an expense * category (a refund there nets back out) or an uncategorized outflow. * * Reads categoryType(), so callers should eager-load the category relation * when classifying a collection to avoid an N+1. */ public function isExpenseSide(): bool { return $this->categoryType() === CategoryType::Expense || ($this->category_id === null && $this->amount < 0); } /** @return BelongsTo */ public function categorizedByRule(): BelongsTo { return $this->belongsTo(AutomationRule::class, 'categorized_by_rule_id'); } /** @return BelongsTo */ public function suggestedCategory(): BelongsTo { return $this->belongsTo(Category::class, 'ai_suggested_category_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); } /** * Transactions the AI backfill can act on: still uncategorized and stored * in plaintext (encrypted descriptions are never sent to the AI provider). * * @param Builder $query * @return Builder */ public function scopePendingAiCategorization(Builder $query): Builder { return $query->whereNull('category_id')->whereNull('description_iv'); } /** * @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; } }