Files
lifetrack/apps/api/app/modules/finance/models.py
T
MeeJayandClaude Opus 5 93f0689c1e Initial import: LifeTrack v1 (santé, vape, finances)
Tracker de vie auto-hébergé : suivi poids/calories/sport avec planning de
pesées, sevrage tabac (vape) avec modèle de coût DIY et économies, et
finances personnelles avec import de relevés bancaires.

Architecture : FastAPI + SQLAlchemy 2.0 + PostgreSQL 16, React 18 + TS +
Vite + Tailwind + ECharts, déploiement Docker Compose. Modules
auto-découverts des deux côtés (pkgutil / import.meta.glob) et framework
de connecteurs à deux voies (importeurs de fichiers + ingestion JSON)
pour brancher de nouvelles sources sans toucher au noyau.

Validé : 292 tests pytest, tsc + vite build, contrat API/web vérifié
contre le schéma OpenAPI, et déploiement Docker réel sur PostgreSQL 16
(28 tables, SPA servie par nginx, wizard de premier démarrage).

Documentation : README.md, docs/GUIDE.md, CONVENTIONS.md, et les
documents de conception et de recherche dans docs/.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 10:48:57 +02:00

320 lines
12 KiB
Python

"""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"),
)