import enum from datetime import date, datetime from decimal import Decimal from typing import Annotated, Optional from pydantic import PlainSerializer from sqlalchemy import JSON, Column, Text, UniqueConstraint from sqlalchemy.dialects.postgresql import JSONB from sqlmodel import Field, Relationship, SQLModel from app.timeutil import utcnow # Live DBs store usersettings.data as jsonb; keep plain JSON elsewhere and on # SQLite (tests). JSON_OR_JSONB = JSON().with_variant(JSONB(), "postgresql") # Monetary values: exact NUMERIC storage, but serialized to JSON as plain # numbers so the API contract (frontend expects `number`) is unchanged. # Pinned by tests/test_models_serialization.py. Money = Annotated[ Decimal, PlainSerializer(float, return_type=float, when_used="json") ] class RecurringItemType(str, enum.Enum): INCOME = "INCOME" EXPENSE = "EXPENSE" SAVINGS = "SAVINGS" class RecurringFrequency(str, enum.Enum): WEEKLY = "WEEKLY" MONTHLY = "MONTHLY" QUARTERLY = "QUARTERLY" BIANNUAL = "BIANNUAL" YEARLY = "YEARLY" class TransactionType(str, enum.Enum): COMPRA = "COMPRA" DEVOLUCION = "DEVOLUCION" DEPOSITO = "DEPOSITO" SALARY = "SALARY" class TransactionSource(str, enum.Enum): CREDIT_CARD = "CREDIT_CARD" CASH = "CASH" TRANSFER = "TRANSFER" class Currency(str, enum.Enum): CRC = "CRC" USD = "USD" EUR = "EUR" BTC = "BTC" XMR = "XMR" class Bank(str, enum.Enum): BAC = "BAC" BCR = "BCR" DAVIVIENDA = "DAVIVIENDA" FCL = "FCL" ROP = "ROP" VOL = "VOL" MEMP = "MEMP" MPAT = "MPAT" MORTGAGE = "MORTGAGE" class AccountType(str, enum.Enum): BANK = "BANK" PENSION = "PENSION" CRYPTO = "CRYPTO" SAVINGS = "SAVINGS" LIABILITY = "LIABILITY" # --- Category --- class CategoryBase(SQLModel): name: str = Field(index=True, unique=True) icon: str = "tag" auto_match_patterns: Optional[str] = Field( default=None, description="Comma-separated merchant substrings for auto-matching", ) class Category(CategoryBase, table=True): id: Optional[int] = Field(default=None, primary_key=True) transactions: list["Transaction"] = Relationship(back_populates="category") class CategoryCreate(CategoryBase): pass class CategoryRead(CategoryBase): id: int class CategoryUpdate(SQLModel): name: Optional[str] = None icon: Optional[str] = None auto_match_patterns: Optional[str] = None # --- Account --- class AccountBase(SQLModel): bank: Bank currency: Currency label: str balance: Money = Field(default=Decimal("0"), max_digits=15, decimal_places=2) account_type: AccountType = AccountType.BANK next_payment: Optional[Money] = Field(default=None, max_digits=15, decimal_places=2) class Account(AccountBase, table=True): id: Optional[int] = Field(default=None, primary_key=True) updated_at: datetime = Field(default_factory=utcnow) class AccountCreate(AccountBase): pass class AccountRead(AccountBase): id: int updated_at: datetime class AccountUpdate(SQLModel): balance: Optional[Money] = None label: Optional[str] = None next_payment: Optional[Money] = None # --- Transaction --- class TransactionBase(SQLModel): amount: Money = Field(max_digits=15, decimal_places=2) currency: Currency = Currency.CRC merchant: str city: Optional[str] = None date: datetime card_type: Optional[str] = None card_last4: Optional[str] = None authorization_code: Optional[str] = None reference: Optional[str] = Field(default=None, index=True) transaction_type: TransactionType = TransactionType.COMPRA source: TransactionSource = TransactionSource.CREDIT_CARD bank: Bank = Bank.BAC notes: Optional[str] = None category_id: Optional[int] = Field( default=None, foreign_key="category.id", ondelete="SET NULL" ) deferred_to_next_cycle: bool = Field( default=False, sa_column_kwargs={"server_default": "false"} ) class Transaction(TransactionBase, table=True): id: Optional[int] = Field(default=None, primary_key=True) created_at: datetime = Field(default_factory=utcnow) category: Optional[Category] = Relationship(back_populates="transactions") # Tasa Cero: set on generated cuota rows. Plain int (no DB FK) to avoid a # circular transaction<->installmentplan dependency; cascades are handled # explicitly in app.services.installments (SQLite tests don't enforce FKs # anyway). Table-class only so TransactionCreate/n8n payloads can't set it. installment_plan_id: Optional[int] = Field(default=None, index=True) # Tasa Cero: True on the original purchase once converted to a plan; the # anchor stays visible in listings but is excluded from budget/analytics # aggregates (its generated cuotas are counted instead). is_installment_anchor: bool = Field( default=False, sa_column_kwargs={"server_default": "false"} ) class TransactionCreate(TransactionBase): pass class TransactionRead(TransactionBase): id: int created_at: datetime category: Optional[CategoryRead] = None installment_plan_id: Optional[int] = None is_installment_anchor: bool = False class TransactionUpdate(SQLModel): amount: Optional[Money] = None currency: Optional[Currency] = None merchant: Optional[str] = None city: Optional[str] = None date: Optional[datetime] = None transaction_type: Optional[TransactionType] = None source: Optional[TransactionSource] = None notes: Optional[str] = None category_id: Optional[int] = None deferred_to_next_cycle: Optional[bool] = None # --- Installment Plan (Tasa Cero) --- class InstallmentPlanBase(SQLModel): num_installments: int first_installment_date: datetime total_amount: Money = Field(max_digits=15, decimal_places=2) # All cuotas but the last; the last absorbs the rounding remainder. installment_amount: Money = Field(max_digits=15, decimal_places=2) currency: Currency = Currency.CRC notes: Optional[str] = None class InstallmentPlan(InstallmentPlanBase, table=True): id: Optional[int] = Field(default=None, primary_key=True) anchor_transaction_id: int = Field( foreign_key="transaction.id", ondelete="CASCADE", unique=True, index=True ) created_at: datetime = Field(default_factory=utcnow) class InstallmentPlanCreate(SQLModel): transaction_id: int num_installments: int = 3 first_installment_date: Optional[datetime] = None # default: anchor.date class InstallmentPlanUpdate(SQLModel): num_installments: Optional[int] = None first_installment_date: Optional[datetime] = None notes: Optional[str] = None # --- Exchange Rate --- class ExchangeRate(SQLModel, table=True): id: Optional[int] = Field(default=None, primary_key=True) date: datetime buy_rate: Decimal = Field(max_digits=15, decimal_places=6) sell_rate: Decimal = Field(max_digits=15, decimal_places=6) fetched_at: datetime = Field(default_factory=utcnow) class ExchangeRateRead(SQLModel): buy_rate: Money sell_rate: Money date: datetime fetched_at: datetime # --- API Token --- class APIToken(SQLModel, table=True): id: Optional[int] = Field(default=None, primary_key=True) name: str token_hash: str = Field(index=True) created_at: datetime = Field(default_factory=utcnow) expires_at: Optional[datetime] = None is_active: bool = True class APITokenCreate(SQLModel): name: str expires_days: Optional[int] = None class APITokenRead(SQLModel): id: int name: str created_at: datetime expires_at: Optional[datetime] is_active: bool # --- User Settings --- class UserSettings(SQLModel, table=True): id: Optional[int] = Field(default=None, primary_key=True) key: str = Field(index=True, unique=True, default="default") data: dict = Field( default_factory=dict, sa_column=Column(JSON_OR_JSONB, nullable=False, server_default="{}"), ) updated_at: datetime = Field(default_factory=utcnow) class UserSettingsRead(SQLModel): key: str data: dict updated_at: datetime class UserSettingsUpdate(SQLModel): data: dict # --- Recurring Item --- class RecurringItemBase(SQLModel): name: str amount: Money = Field(max_digits=15, decimal_places=2) currency: Currency = Currency.CRC item_type: RecurringItemType frequency: RecurringFrequency = RecurringFrequency.MONTHLY day_of_month: Optional[int] = None month_of_year: Optional[int] = None override_amounts: Optional[dict] = Field( default=None, sa_column=Column(JSON, nullable=True), ) category_id: Optional[int] = Field( default=None, foreign_key="category.id", ondelete="SET NULL" ) is_active: bool = True notes: Optional[str] = None class RecurringItem(RecurringItemBase, table=True): id: Optional[int] = Field(default=None, primary_key=True) created_at: datetime = Field(default_factory=utcnow) category: Optional[Category] = Relationship() class RecurringItemCreate(RecurringItemBase): pass class RecurringItemRead(RecurringItemBase): id: int created_at: datetime category: Optional[CategoryRead] = None class RecurringItemUpdate(SQLModel): name: Optional[str] = None amount: Optional[Money] = None currency: Optional[Currency] = None item_type: Optional[RecurringItemType] = None frequency: Optional[RecurringFrequency] = None day_of_month: Optional[int] = None month_of_year: Optional[int] = None override_amounts: Optional[dict] = None category_id: Optional[int] = None is_active: Optional[bool] = None notes: Optional[str] = None # --- Push Subscription --- class PushSubscription(SQLModel, table=True): id: Optional[int] = Field(default=None, primary_key=True) endpoint: str = Field(unique=True) p256dh: str auth: str created_at: datetime = Field(default_factory=utcnow) class PushSubscriptionCreate(SQLModel): endpoint: str keys: dict # {"p256dh": "...", "auth": "..."} # --- Pension Snapshot --- class PensionSnapshotBase(SQLModel): fund: Bank contract_number: str period_start: date period_end: date saldo_anterior: Money = Field(max_digits=15, decimal_places=2) aportes: Money = Field(max_digits=15, decimal_places=2) rendimientos: Money = Field(max_digits=15, decimal_places=2) retiros: Money = Field(max_digits=15, decimal_places=2) traslados: Money = Field(max_digits=15, decimal_places=2) comision: Money = Field(max_digits=15, decimal_places=2) correccion: Money = Field(max_digits=15, decimal_places=2) bonificacion: Money = Field(max_digits=15, decimal_places=2) saldo_final: Money = Field(max_digits=15, decimal_places=2) source_filename: str class PensionSnapshot(PensionSnapshotBase, table=True): __table_args__ = ( UniqueConstraint("fund", "period_start", "period_end"), ) id: Optional[int] = Field(default=None, primary_key=True) created_at: datetime = Field(default_factory=utcnow) class PensionSnapshotRead(PensionSnapshotBase): id: int created_at: datetime # --- Balance Override --- class BalanceOverride(SQLModel, table=True): __table_args__ = (UniqueConstraint("year", "month"),) id: Optional[int] = Field(default=None, primary_key=True) year: int month: int override_balance: Money = Field(max_digits=15, decimal_places=2) created_at: datetime = Field(default_factory=utcnow) updated_at: datetime = Field(default_factory=utcnow) class BalanceOverrideCreate(SQLModel): override_balance: Money class BalanceOverrideRead(SQLModel): id: int year: int month: int override_balance: Money updated_at: datetime # --- Savings Accrual --- class SavingsAccrualBase(SQLModel): year: int month: int memp_amount: Money = Field(default=Decimal("200000"), max_digits=15, decimal_places=2) mpat_amount: Money = Field(default=Decimal("200000"), max_digits=15, decimal_places=2) trigger_transaction_id: Optional[int] = None notes: Optional[str] = None class SavingsAccrual(SavingsAccrualBase, table=True): __table_args__ = (UniqueConstraint("year", "month"),) id: Optional[int] = Field(default=None, primary_key=True) applied_at: datetime = Field(default_factory=utcnow) class SavingsAccrualCreate(SavingsAccrualBase): pass class SavingsAccrualRead(SavingsAccrualBase): id: int applied_at: datetime class SavingsAccrualUpdate(SQLModel): memp_amount: Optional[Money] = None mpat_amount: Optional[Money] = None notes: Optional[str] = None # --- Municipal Receipt --- class MunicipalReceiptBase(SQLModel): receipt_date: date due_date: date period: str # "YYYY-MM" account: str finca: str holder_name: str holder_cedula: str holder_address: str subtotal: Money = Field(max_digits=15, decimal_places=2) interests: Money = Field(max_digits=15, decimal_places=2) iva: Money = Field(max_digits=15, decimal_places=2) total: Money = Field(max_digits=15, decimal_places=2) raw_charges: list[dict] = Field( default_factory=list, sa_column=Column(JSON, nullable=False, server_default="[]"), ) source_filename: str class MunicipalReceipt(MunicipalReceiptBase, table=True): __table_args__ = (UniqueConstraint("account", "period"),) id: Optional[int] = Field(default=None, primary_key=True) created_at: datetime = Field(default_factory=utcnow) water_readings: list["WaterMeterReading"] = Relationship( back_populates="receipt", ) class MunicipalReceiptCreate(MunicipalReceiptBase): pass class MunicipalReceiptRead(MunicipalReceiptBase): id: int created_at: datetime # --- Water Meter Reading --- class WaterMeterReadingBase(SQLModel): meter_id: str period: str # "YYYY-MM" reading_previous: Money = Field(default=Decimal("0"), max_digits=15, decimal_places=2) reading_current: Money = Field(default=Decimal("0"), max_digits=15, decimal_places=2) consumption_m3: Money = Field(max_digits=15, decimal_places=2) agua_potable: Money = Field(default=Decimal("0"), max_digits=15, decimal_places=2) serv_ambientales: Money = Field(default=Decimal("0"), max_digits=15, decimal_places=2) alcant_sanitario: Money = Field(default=Decimal("0"), max_digits=15, decimal_places=2) iva: Money = Field(default=Decimal("0"), max_digits=15, decimal_places=2) is_historical: bool = False receipt_id: Optional[int] = Field( default=None, foreign_key="municipalreceipt.id", ondelete="CASCADE" ) class WaterMeterReading(WaterMeterReadingBase, table=True): __table_args__ = (UniqueConstraint("meter_id", "period", "is_historical"),) id: Optional[int] = Field(default=None, primary_key=True) created_at: datetime = Field(default_factory=utcnow) receipt: Optional[MunicipalReceipt] = Relationship( back_populates="water_readings", ) class WaterMeterReadingRead(WaterMeterReadingBase): id: int created_at: datetime # --- Assistant Chat Thread --- class ChatThreadSnapshot(SQLModel, table=True): """Latest AG-UI thread snapshot per (scope, thread_id). Written by app/agent/snapshot_store.py: MAF overwrites the full cumulative message list at each run end — one row per thread, not an append log. """ __table_args__ = (UniqueConstraint("scope", "thread_id"),) id: Optional[int] = Field(default=None, primary_key=True) scope: str = Field(index=True) thread_id: str messages: list = Field( default_factory=list, sa_column=Column(JSON_OR_JSONB, nullable=False, server_default="[]"), ) state: Optional[dict] = Field( default=None, sa_column=Column(JSON_OR_JSONB, nullable=True) ) interrupt: Optional[list] = Field( default=None, sa_column=Column(JSON_OR_JSONB, nullable=True) ) updated_at: datetime = Field(default_factory=utcnow) # --- Assistant Chat Run Audit --- class ChatRunLog(SQLModel, table=True): """Append-only diagnostic record for an AG-UI agent/run request. Thread snapshots are mutable conversation state. This table instead keeps the request, model, tool activity, and final emitted answer for a bounded retention period so individual assistant runs can be investigated later. """ id: Optional[int] = Field(default=None, primary_key=True) request_id: str = Field(index=True, unique=True) agui_run_id: Optional[str] = Field(default=None, index=True) requested_thread_id: Optional[str] = Field(default=None, index=True) response_thread_id: Optional[str] = Field(default=None, index=True) model: str client_ip: Optional[str] = Field(default=None, sa_column=Column(Text, nullable=True)) user_agent: Optional[str] = Field(default=None, sa_column=Column(Text, nullable=True)) browser: Optional[str] = Field(default=None, sa_column=Column(Text, nullable=True)) user_prompt: Optional[str] = Field(default=None, sa_column=Column(Text, nullable=True)) tool_calls: list = Field( default_factory=list, sa_column=Column(JSON_OR_JSONB, nullable=False, server_default="[]"), ) assistant_response: Optional[str] = Field( default=None, sa_column=Column(Text, nullable=True) ) status: str = Field(default="started", index=True) error: Optional[str] = Field(default=None, sa_column=Column(Text, nullable=True)) started_at: datetime = Field(default_factory=utcnow) completed_at: Optional[datetime] = Field(default=None) expires_at: datetime = Field(index=True)