"""Finance module tables (datamodel-finance.md §2), portable SQLite/PostgreSQL. Deviations from the normative DDL, imposed by the finished backend core ("real code wins"): `users.id` and the central `import_runs.id` are integers, so every FK to those tables is an int. Import rollback is centralised: any column referencing `import_runs.id` declares ondelete="CASCADE" (CONVENTIONS C8.5). The pg_trgm GIN index of §2.5 is created on PostgreSQL only, best effort — see `_TRGM_DDL` at the bottom of this module. Deduplication follows §4.3 of the datamodel (which supersedes the generic CONVENTIONS C2.4 pair for this module): UNIQUE(account_id, dedup_hash) plus a partial UNIQUE(account_id, external_id) — the finance scope is the account, not the user, and the hash embeds an occurrence counter so that two identical purchases of the same day both survive. """ import uuid from datetime import date, datetime from decimal import Decimal from typing import Any from sqlalchemy import ( DDL, CheckConstraint, Date, DateTime, Enum, ForeignKey, Index, Numeric, String, Text, UniqueConstraint, Uuid, event, text, ) from sqlalchemy.orm import Mapped, mapped_column from app.core.database import Base from app.core.mixins import JSONB_V, TimestampMixin from app.modules.finance.enums import ( AccountKind, CategoryKind, CategorySource, ImportStatus, SourceKind, ) def _enum(enum_cls: type, name: str) -> Enum: """Portable enum column: VARCHAR + CHECK, never a native PG ENUM (C8.1). `create_constraint=True` is explicit: SQLAlchemy defaults it to False, which would degrade the column to a bare VARCHAR with no integrity check at all on PostgreSQL as well as on SQLite. """ return Enum( enum_cls, name=name, native_enum=False, create_constraint=True, length=20, values_callable=lambda e: [m.value for m in e], ) class FinAccount(TimestampMixin, Base): __tablename__ = "fin_accounts" __table_args__ = ( UniqueConstraint("user_id", "name", name="uq_fin_accounts_user_name"), ) id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4) user_id: Mapped[int] = mapped_column( ForeignKey("users.id", ondelete="CASCADE"), index=True ) name: Mapped[str] = mapped_column(String(100)) kind: Mapped[AccountKind] = mapped_column( _enum(AccountKind, "fin_account_kind"), default=AccountKind.CHECKING ) currency: Mapped[str] = mapped_column(String(3), default="EUR") institution: Mapped[str | None] = mapped_column(String(100), default=None) # Never the full IBAN: "FR76 **** **** **** 1234" at most. iban_masked: Mapped[str | None] = mapped_column(String(34), default=None) initial_balance: Mapped[Decimal] = mapped_column( Numeric(12, 2), default=Decimal("0.00") ) is_archived: Mapped[bool] = mapped_column(default=False) class FinCategory(TimestampMixin, Base): __tablename__ = "fin_categories" __table_args__ = ( UniqueConstraint( "user_id", "parent_id", "name", name="uq_fin_categories_sibling" ), ) id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4) user_id: Mapped[int] = mapped_column( ForeignKey("users.id", ondelete="CASCADE"), index=True ) parent_id: Mapped[uuid.UUID | None] = mapped_column( ForeignKey("fin_categories.id", ondelete="CASCADE"), index=True, default=None ) name: Mapped[str] = mapped_column(String(80)) icon: Mapped[str | None] = mapped_column(String(50), default=None) color: Mapped[str | None] = mapped_column(String(7), default=None) kind: Mapped[CategoryKind] = mapped_column( _enum(CategoryKind, "fin_category_kind"), default=CategoryKind.EXPENSE ) is_system: Mapped[bool] = mapped_column(default=False) sort_order: Mapped[int] = mapped_column(default=0) class FinSourceProfile(TimestampMixin, Base): __tablename__ = "fin_source_profiles" __table_args__ = ( CheckConstraint( "(is_builtin = true AND user_id IS NULL)" " OR (is_builtin = false AND user_id IS NOT NULL)", name="ck_fin_source_profiles_builtin", ), Index( "uq_fin_source_profiles_builtin_name", "name", unique=True, sqlite_where=text("user_id IS NULL"), postgresql_where=text("user_id IS NULL"), ), Index( "uq_fin_source_profiles_user_name", "user_id", "name", unique=True, sqlite_where=text("user_id IS NOT NULL"), postgresql_where=text("user_id IS NOT NULL"), ), ) id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4) # NULL = built-in preset, visible by every user, read-only through the API. user_id: Mapped[int | None] = mapped_column( ForeignKey("users.id", ondelete="CASCADE"), default=None ) name: Mapped[str] = mapped_column(String(100)) kind: Mapped[SourceKind] = mapped_column(_enum(SourceKind, "fin_source_kind")) config: Mapped[dict[str, Any]] = mapped_column(JSONB_V, default=dict) is_builtin: Mapped[bool] = mapped_column(default=False) class FinImportRun(TimestampMixin, Base): """Finance-side record of an import run. One-to-one with the central `import_runs` row (which owns the rollback cascade); carries the finance-specific metadata and rich stats of §2.4. """ __tablename__ = "fin_import_runs" __table_args__ = ( Index("ix_fin_import_runs_user_started", "user_id", "started_at"), ) id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4) user_id: Mapped[int] = mapped_column( ForeignKey("users.id", ondelete="CASCADE"), index=True ) import_run_id: Mapped[int] = mapped_column( ForeignKey("import_runs.id", ondelete="CASCADE"), unique=True, index=True ) source_profile_id: Mapped[uuid.UUID | None] = mapped_column( ForeignKey("fin_source_profiles.id", ondelete="SET NULL"), default=None ) account_id: Mapped[uuid.UUID] = mapped_column( ForeignKey("fin_accounts.id", ondelete="CASCADE") ) filename: Mapped[str] = mapped_column(String(255)) file_sha256: Mapped[str] = mapped_column(String(64)) status: Mapped[ImportStatus] = mapped_column( _enum(ImportStatus, "fin_import_status"), default=ImportStatus.PENDING ) started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True)) finished_at: Mapped[datetime | None] = mapped_column( DateTime(timezone=True), default=None ) stats: Mapped[dict[str, Any]] = mapped_column(JSONB_V, default=dict) error_message: Mapped[str | None] = mapped_column(Text, default=None) class FinTransaction(TimestampMixin, Base): __tablename__ = "fin_transactions" __table_args__ = ( UniqueConstraint("account_id", "dedup_hash", name="uq_fin_transactions_dedup"), Index( "uq_fin_transactions_external", "account_id", "external_id", unique=True, sqlite_where=text("external_id IS NOT NULL"), postgresql_where=text("external_id IS NOT NULL"), ), Index("ix_fin_transactions_user_date", "user_id", "booked_date"), Index("ix_fin_transactions_account_date", "account_id", "booked_date"), ) id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4) user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE")) account_id: Mapped[uuid.UUID] = mapped_column( ForeignKey("fin_accounts.id", ondelete="CASCADE") ) booked_date: Mapped[date] = mapped_column(Date) # civil date, never tz-shifted value_date: Mapped[date | None] = mapped_column(Date, default=None) amount: Mapped[Decimal] = mapped_column(Numeric(12, 2)) # signed: debit < 0 currency: Mapped[str] = mapped_column(String(3), default="EUR") label_raw: Mapped[str] = mapped_column(Text) label_clean: Mapped[str] = mapped_column(Text) counterparty: Mapped[str | None] = mapped_column(String(150), default=None) category_id: Mapped[uuid.UUID | None] = mapped_column( ForeignKey("fin_categories.id", ondelete="SET NULL"), index=True, default=None ) category_source: Mapped[CategorySource | None] = mapped_column( _enum(CategorySource, "fin_category_source"), default=None ) # Logical FK to fin_rules.id (no constraint: rules are freely deletable). applied_rule_id: Mapped[uuid.UUID | None] = mapped_column(Uuid, default=None) notes: Mapped[str | None] = mapped_column(Text, default=None) # Central import run (int id); NULL = manual entry. CASCADE = C8.5 rollback. import_run_id: Mapped[int | None] = mapped_column( ForeignKey("import_runs.id", ondelete="CASCADE"), index=True, default=None ) external_id: Mapped[str | None] = mapped_column(String(255), default=None) dedup_hash: Mapped[str] = mapped_column(String(64)) transfer_group_id: Mapped[uuid.UUID | None] = mapped_column( Uuid, index=True, default=None ) class FinRule(TimestampMixin, Base): __tablename__ = "fin_rules" __table_args__ = ( Index("ix_fin_rules_user_priority", "user_id", "priority", "created_at"), ) id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4) user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE")) name: Mapped[str] = mapped_column(String(100)) priority: Mapped[int] = mapped_column(default=100) # ascending = first enabled: Mapped[bool] = mapped_column(default=True) stop: Mapped[bool] = mapped_column(default=True) matchers: Mapped[dict[str, Any]] = mapped_column(JSONB_V) actions: Mapped[dict[str, Any]] = mapped_column(JSONB_V) hit_count: Mapped[int] = mapped_column(default=0) last_applied_at: Mapped[datetime | None] = mapped_column( DateTime(timezone=True), default=None ) class FinBudget(TimestampMixin, Base): __tablename__ = "fin_budgets" __table_args__ = ( CheckConstraint("monthly_amount > 0", name="ck_fin_budgets_positive"), Index("ix_fin_budgets_user_cat", "user_id", "category_id"), ) id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4) user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE")) category_id: Mapped[uuid.UUID] = mapped_column( ForeignKey("fin_categories.id", ondelete="CASCADE") ) monthly_amount: Mapped[Decimal] = mapped_column(Numeric(12, 2)) start_month: Mapped[date] = mapped_column(Date) # always the 1st of the month end_month: Mapped[date | None] = mapped_column(Date, default=None) # -------------------------------------------------------------------------- # pg_trgm — trigram GIN index of datamodel-finance §2.5 (PostgreSQL only). # # `GET /api/finance/transactions?q=` searches with ILIKE '%…%' on label_clean; # without a trigram index PostgreSQL can only answer with a sequential scan. # The index cannot be declared as a plain `Index(...)`: `gin_trgm_ops` does not # exist on SQLite (the test engine) and does not exist on a PostgreSQL where the # extension was never installed. # # So it is emitted as an `after_create` DDL hook restricted to the PostgreSQL # dialect, written so that it can never break `create_all` (§9.4 has no # migration step in v1, the API creates its own schema at startup): # - `CREATE EXTENSION` is attempted in a nested block: pg_trgm is a *trusted* # extension since PostgreSQL 13, so the database owner (the compose # POSTGRES_USER) can install it; if the deployment forbids it the exception # is swallowed instead of aborting the whole transaction; # - the index itself is only created when the extension really is present. # `docker/postgres-init/10-extensions.sql` installs it at initdb time as well, # for deployments where the API user is not the database owner. # -------------------------------------------------------------------------- _TRGM_DDL = DDL( """ DO $$ BEGIN BEGIN CREATE EXTENSION IF NOT EXISTS pg_trgm; EXCEPTION WHEN OTHERS THEN RAISE NOTICE 'pg_trgm indisponible : recherche de libelles non indexee'; END; IF EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'pg_trgm') THEN CREATE INDEX IF NOT EXISTS ix_fin_transactions_label_trgm ON fin_transactions USING gin (label_clean gin_trgm_ops); END IF; END $$; """ ) event.listen( FinTransaction.__table__, "after_create", _TRGM_DDL.execute_if(dialect="postgresql"), )