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>
320 lines
12 KiB
Python
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"),
|
|
)
|