whisper-money/app/Models/Transaction.php

385 lines
13 KiB
PHP

<?php
namespace App\Models;
use App\Enums\CategorySource;
use App\Enums\CategoryType;
use App\Enums\RuleOrigin;
use App\Enums\TransactionSource;
use App\Events\TransactionCreated;
use App\Events\TransactionDeleted;
use App\Events\TransactionUpdated;
use App\Models\Concerns\BelongsToSpace;
use App\Services\CategoryTree;
use Carbon\Carbon;
use Database\Factories\TransactionFactory;
use Illuminate\Contracts\Database\Query\Expression;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Facades\DB;
/**
* @property Carbon $transaction_date
* @property int|float $total_amount
* @property TransactionSource $source
* @property ?CategorySource $category_source
* @property ?float $ai_confidence
* @property ?string $categorized_by_rule_id
* @property ?string $ai_suggested_category_id
* @property ?Carbon $ai_suggested_category_at
* @property ?string $ai_model
*/
class Transaction extends Model
{
/** @use HasFactory<TransactionFactory> */
use BelongsToSpace, HasFactory, HasUuids, SoftDeletes;
/** @var array<string, class-string> */
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<string>
*/
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<User, $this> */
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
/** @return BelongsTo<Account, $this> */
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<Category, $this> */
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<AutomationRule, $this> */
public function categorizedByRule(): BelongsTo
{
return $this->belongsTo(AutomationRule::class, 'categorized_by_rule_id');
}
/** @return BelongsTo<Category, $this> */
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<bool, never>
*/
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<Label, $this, LabelTransaction, 'pivot'> */
public function labels(): BelongsToMany
{
return $this->belongsToMany(Label::class)
->using(LabelTransaction::class)
->withTimestamps();
}
/** @return HasMany<BudgetTransaction, $this> */
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<Transaction> $query
* @return Builder<Transaction>
*/
public function scopePendingAiCategorization(Builder $query): Builder
{
return $query->whereNull('category_id')->whereNull('description_iv');
}
/**
* The owner's share of an amount held by this transaction's account, for
* the row-by-row PHP paths. Falls back to the full amount when the account
* is not loaded, so a partial select never silently zeroes the figure.
*/
public function ownerShareOf(int $amount): int
{
return $this->account?->shareOfAmount($amount) ?? $amount;
}
/**
* A transaction amount reduced to the owner's share of its account, for
* SQL-side aggregates. Rounds per row, matching {@see Account::shareOfAmount()}
* (MySQL and PHP both round half away from zero).
* Only valid on queries that ran {@see self::scopeJoinOwningAccount()}.
*/
public const OWNED_AMOUNT_SQL = 'round(transactions.amount * accounts.ownership_percentage / 100)';
/**
* Join the owning account so aggregates can weigh each amount by the
* account's ownership percentage. Pair it with {@see self::ownedAmount()}.
*
* `account_id` is NOT NULL and the join deliberately ignores the account's
* soft-delete scope, so the row set is exactly what it was before the
* ownership weighting existed.
*
* @param Builder<Transaction> $query
* @return Builder<Transaction>
*/
public function scopeJoinOwningAccount(Builder $query): Builder
{
return $query->join('accounts', 'accounts.id', '=', 'transactions.account_id');
}
/**
* {@see self::OWNED_AMOUNT_SQL} as an expression, for `sum()` and friends.
*/
public static function ownedAmount(): Expression
{
return DB::raw(self::OWNED_AMOUNT_SQL);
}
/**
* @param Builder<Transaction> $query
* @param array<string, mixed> $filters
* @return Builder<Transaction>
*/
public function scopeApplyFilters(Builder $query, array $filters): Builder
{
$query
->when(isset($filters['date_from']), fn (Builder $q) => $q->whereDate('transaction_date', '>=', $filters['date_from']))
->when(isset($filters['date_to']), fn (Builder $q) => $q->whereDate('transaction_date', '<=', $filters['date_to']))
// Amounts arrive in major units from the UI but are stored in cents.
->when(isset($filters['amount_min']), fn (Builder $q) => $q->where('amount', '>=', $filters['amount_min'] * 100))
->when(isset($filters['amount_max']), fn (Builder $q) => $q->where('amount', '<=', $filters['amount_max'] * 100))
->when(! empty($filters['account_ids']), fn (Builder $q) => $q->whereIn('account_id', $filters['account_ids']))
->when(! empty($filters['category_source']), fn (Builder $q) => $q->where('category_source', $filters['category_source']))
->when(! empty($filters['creditor_name']), fn (Builder $q) => $q->where('creditor_name', 'LIKE', '%'.$filters['creditor_name'].'%'))
->when(! empty($filters['debtor_name']), fn (Builder $q) => $q->where('debtor_name', 'LIKE', '%'.$filters['debtor_name'].'%'))
->when(! empty($filters['search']), fn (Builder $q) => $q->where(
fn (Builder $inner) => $inner
->where('description', 'LIKE', '%'.$filters['search'].'%')
->orWhere('notes', 'LIKE', '%'.$filters['search'].'%')
->orWhere('creditor_name', 'LIKE', '%'.$filters['search'].'%')
->orWhere('debtor_name', 'LIKE', '%'.$filters['search'].'%')
));
$this->applyCategoryAndLabelFilters($query, $filters);
return $query;
}
/**
* Categories and labels are one filter, not two: a transaction matches when it
* sits in a wanted category OR carries a wanted label, so both sides are ORed
* together inside a single group.
*
* @param Builder<Transaction> $query
* @param array<string, mixed> $filters
*/
private function applyCategoryAndLabelFilters(Builder $query, array $filters): void
{
$categoryIds = empty($filters['category_ids']) ? [] : collect($filters['category_ids']);
$labelIds = empty($filters['label_ids']) ? [] : $filters['label_ids'];
if ($categoryIds === [] && $labelIds === []) {
return;
}
// "uncategorized" is a pseudo id the UI sends for transactions with no
// category at all, so it can be picked alongside real categories.
$wantsUncategorized = $categoryIds !== [] && $categoryIds->contains('uncategorized');
$wantedCategoryIds = $categoryIds === []
? []
: $this->expandToDescendants(
$categoryIds->reject(fn ($id) => $id === 'uncategorized')->values()->all(),
$filters['user_id'] ?? null,
);
$query->where(function (Builder $group) use ($wantedCategoryIds, $wantsUncategorized, $labelIds): void {
if ($wantedCategoryIds !== []) {
$group->whereIn('category_id', $wantedCategoryIds);
}
if ($wantsUncategorized) {
$group->orWhereNull('category_id');
}
if ($labelIds !== []) {
$group->orWhereHas('labels', fn (Builder $q) => $q->whereIn('labels.id', $labelIds));
}
});
}
/**
* Picking a category means picking everything under it, so the selection is
* widened to the whole subtree. Left as-is when the owner cannot be resolved.
*
* @param list<string> $categoryIds
* @return list<string>
*/
private function expandToDescendants(array $categoryIds, ?string $userId): array
{
if ($categoryIds === []) {
return [];
}
$userId ??= Category::query()->whereIn('id', $categoryIds)->value('user_id');
return $userId === null
? $categoryIds
: app(CategoryTree::class)->expand($userId, $categoryIds);
}
}