Files
lifetrack/apps/api/app/modules/finance/schemas.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

502 lines
12 KiB
Python

"""Pydantic v2 schemas of the finance module (datamodel-finance.md §9).
Amounts travel as JSON numbers with 2 decimals (`Money`), dates as
`YYYY-MM-DD`, months as `YYYY-MM`.
"""
import uuid
from datetime import date
from decimal import Decimal
from typing import Annotated, Any, Literal
from pydantic import BaseModel, ConfigDict, Field, PlainSerializer, field_validator
from app.core.timeutils import StoredUtcDatetime
from app.modules.finance.enums import (
AccountKind,
CategoryKind,
CategorySource,
ImportStatus,
SourceKind,
)
Money = Annotated[
Decimal,
PlainSerializer(
lambda v: float(round(Decimal(v), 2)), return_type=float, when_used="json"
),
]
MAX_BULK_IDS = 500
class ORMModel(BaseModel):
model_config = ConfigDict(from_attributes=True)
# ---------------------------------------------------------------------------
# Accounts
# ---------------------------------------------------------------------------
class AccountCreate(BaseModel):
name: str = Field(min_length=1, max_length=100)
kind: AccountKind = AccountKind.CHECKING
currency: str = Field(default="EUR", min_length=3, max_length=3)
institution: str | None = Field(default=None, max_length=100)
iban_masked: str | None = Field(default=None, max_length=34)
initial_balance: Money = Decimal("0.00")
class AccountUpdate(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=100)
kind: AccountKind | None = None
currency: str | None = Field(default=None, min_length=3, max_length=3)
institution: str | None = Field(default=None, max_length=100)
iban_masked: str | None = Field(default=None, max_length=34)
initial_balance: Money | None = None
is_archived: bool | None = None
class AccountRead(ORMModel):
id: uuid.UUID
name: str
kind: AccountKind
currency: str
institution: str | None
iban_masked: str | None
initial_balance: Money
is_archived: bool
balance: Money
transaction_count: int
last_transaction_date: date | None = None
# ---------------------------------------------------------------------------
# Categories
# ---------------------------------------------------------------------------
class CategoryCreate(BaseModel):
name: str = Field(min_length=1, max_length=80)
parent_id: uuid.UUID | None = None
icon: str | None = Field(default=None, max_length=50)
color: str | None = Field(default=None, max_length=7)
kind: CategoryKind = CategoryKind.EXPENSE
sort_order: int = 0
class CategoryUpdate(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=80)
parent_id: uuid.UUID | None = None
icon: str | None = Field(default=None, max_length=50)
color: str | None = Field(default=None, max_length=7)
sort_order: int | None = None
class CategoryRead(ORMModel):
id: uuid.UUID
parent_id: uuid.UUID | None
name: str
icon: str | None
color: str | None
kind: CategoryKind
is_system: bool
sort_order: int
transaction_count: int = 0
children: list["CategoryRead"] = Field(default_factory=list)
# ---------------------------------------------------------------------------
# Source profiles
# ---------------------------------------------------------------------------
class SourceProfileCreate(BaseModel):
name: str = Field(min_length=1, max_length=100)
kind: SourceKind
config: dict[str, Any] = Field(default_factory=dict)
class SourceProfileUpdate(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=100)
config: dict[str, Any] | None = None
class SourceProfileRead(ORMModel):
id: uuid.UUID
name: str
kind: SourceKind
config: dict[str, Any]
is_builtin: bool
# ---------------------------------------------------------------------------
# Transactions
# ---------------------------------------------------------------------------
class TransactionRead(BaseModel):
id: uuid.UUID
account_id: uuid.UUID
account_name: str | None = None
booked_date: date
value_date: date | None = None
amount: Money
currency: str
label_raw: str
label_clean: str
counterparty: str | None = None
category_id: uuid.UUID | None = None
category_name: str | None = None
category_color: str | None = None
category_source: CategorySource | None = None
notes: str | None = None
transfer_group_id: uuid.UUID | None = None
external_id: str | None = None
import_run_id: int | None = None
class TransactionCreate(BaseModel):
account_id: uuid.UUID
booked_date: date
amount: Money
label_clean: str = Field(min_length=1)
value_date: date | None = None
currency: str | None = Field(default=None, min_length=3, max_length=3)
category_id: uuid.UUID | None = None
counterparty: str | None = Field(default=None, max_length=150)
notes: str | None = None
class TransactionUpdate(BaseModel):
model_config = ConfigDict(extra="forbid")
label_clean: str | None = Field(default=None, min_length=1)
counterparty: str | None = Field(default=None, max_length=150)
category_id: uuid.UUID | None = None
notes: str | None = None
booked_date: date | None = None
amount: Money | None = None
class BulkCategorizeRequest(BaseModel):
transaction_ids: list[uuid.UUID] = Field(min_length=1, max_length=MAX_BULK_IDS)
category_id: uuid.UUID | None = None
class BulkCategorizeResponse(BaseModel):
updated: int
# ---------------------------------------------------------------------------
# Rules
# ---------------------------------------------------------------------------
class RuleCreate(BaseModel):
name: str = Field(min_length=1, max_length=100)
priority: int = 100
enabled: bool = True
stop: bool = True
matchers: dict[str, Any]
actions: dict[str, Any]
class RuleUpdate(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=100)
priority: int | None = None
enabled: bool | None = None
stop: bool | None = None
matchers: dict[str, Any] | None = None
actions: dict[str, Any] | None = None
class RuleRead(ORMModel):
id: uuid.UUID
name: str
priority: int
enabled: bool
stop: bool
matchers: dict[str, Any]
actions: dict[str, Any]
hit_count: int
# Stored instants: aware on PostgreSQL, naive on SQLite -> always UTC "Z".
last_applied_at: StoredUtcDatetime | None
class RuleReorderRequest(BaseModel):
ordered_ids: list[uuid.UUID] = Field(min_length=1)
class RuleApplyRequest(BaseModel):
scope: Literal["uncategorized", "all_non_manual", "all"] = "uncategorized"
date_from: date | None = None
date_to: date | None = None
account_id: uuid.UUID | None = None
rule_id: uuid.UUID | None = None
dry_run: bool = False
force: bool = False
class RuleApplyByRule(BaseModel):
rule_id: uuid.UUID
name: str
matched: int
class RuleApplyResponse(BaseModel):
scanned: int
matched: int
updated: int
dry_run: bool
by_rule: list[RuleApplyByRule]
class RulePreviewRequest(BaseModel):
matchers: dict[str, Any]
class RulePreviewResponse(BaseModel):
items: list[TransactionRead]
total_matched: int
# ---------------------------------------------------------------------------
# Budgets
# ---------------------------------------------------------------------------
def _first_of_month(value: date | None) -> date | None:
if value is None:
return None
return value.replace(day=1)
class BudgetCreate(BaseModel):
category_id: uuid.UUID
monthly_amount: Money = Field(gt=0)
start_month: date
end_month: date | None = None
@field_validator("start_month", "end_month")
@classmethod
def normalize_month(cls, value: date | None) -> date | None:
return _first_of_month(value)
class BudgetUpdate(BaseModel):
monthly_amount: Money | None = Field(default=None, gt=0)
end_month: date | None = None
effective_from: date | None = None
@field_validator("end_month", "effective_from")
@classmethod
def normalize_month(cls, value: date | None) -> date | None:
return _first_of_month(value)
class BudgetRead(ORMModel):
id: uuid.UUID
category_id: uuid.UUID
category_name: str | None = None
category_color: str | None = None
monthly_amount: Money
start_month: date
end_month: date | None
actual: Money = Decimal("0.00")
remaining: Money = Decimal("0.00")
progress_pct: float = 0.0
projected_eom: Money | None = None
status: str = "ok"
# ---------------------------------------------------------------------------
# Imports
# ---------------------------------------------------------------------------
class NormalizedRowRead(ORMModel):
booked_date: date
value_date: date | None
amount: Money
currency: str
label_raw: str
counterparty: str | None
external_id: str | None
source_row_index: int
class ImportPreviewResponse(BaseModel):
rows_preview: list[NormalizedRowRead]
rows_total: int
rows_error: int
rows_skipped_filtered: int
would_skip_duplicates: int
date_min: date | None
date_max: date | None
errors: list[dict[str, Any]]
duplicate_file_of: int | None = None
class ImportRunRead(ORMModel):
id: uuid.UUID
import_run_id: int
account_id: uuid.UUID
source_profile_id: uuid.UUID | None
filename: str
file_sha256: str
status: ImportStatus
# Stored instants: aware on PostgreSQL, naive on SQLite -> always UTC "Z".
started_at: StoredUtcDatetime
finished_at: StoredUtcDatetime | None
stats: dict[str, Any]
error_message: str | None
# ---------------------------------------------------------------------------
# Transfers
# ---------------------------------------------------------------------------
class TransferDetectRequest(BaseModel):
date_from: date | None = None
date_to: date | None = None
class TransferDetectResponse(BaseModel):
pairs_created: int
class TransferLinkRequest(BaseModel):
transaction_id_a: uuid.UUID
transaction_id_b: uuid.UUID
class TransferLinkResponse(BaseModel):
transfer_group_id: uuid.UUID
# ---------------------------------------------------------------------------
# Stats (ECharts-ready)
# ---------------------------------------------------------------------------
class MonthlySeries(BaseModel):
category_id: uuid.UUID | None
name: str
color: str
data: list[Money]
class MonthlyByCategoryResponse(BaseModel):
months: list[str]
series: list[MonthlySeries]
totals: list[Money]
class CashflowResponse(BaseModel):
months: list[str]
income: list[Money]
expenses: list[Money]
net: list[Money]
cumulative_net: list[Money]
class PeriodRead(BaseModel):
from_: date = Field(alias="from")
to: date
model_config = ConfigDict(populate_by_name=True)
class MerchantRead(BaseModel):
merchant: str
total: Money
count: int
average: Money
category_name: str | None = None
category_color: str | None = None
class TopMerchantsResponse(BaseModel):
period: PeriodRead
items: list[MerchantRead]
class RecurringItem(BaseModel):
model_config = ConfigDict(from_attributes=True)
merchant_key: str
label_display: str
category_id: uuid.UUID | None
category_name: str | None
periodicity: str
occurrences: int
average_amount: Money
expected_amount: Money
last_date: date
next_date_predicted: date
is_active: bool
class RecurringResponse(BaseModel):
items: list[RecurringItem]
monthly_total_estimate: Money
class BudgetProgressItem(BaseModel):
budget_id: uuid.UUID
category_id: uuid.UUID
category_name: str
category_color: str
budget: Money
actual: Money
remaining: Money
progress_pct: float
projected_eom: Money | None
status: str
class BudgetProgressTotals(BaseModel):
budget: Money
actual: Money
progress_pct: float
class BudgetProgressResponse(BaseModel):
month: str
items: list[BudgetProgressItem]
totals: BudgetProgressTotals
class SankeyNode(BaseModel):
name: str
color: str
class SankeyLink(BaseModel):
source: str
target: str
value: Money
class SankeyResponse(BaseModel):
period: PeriodRead
nodes: list[SankeyNode]
links: list[SankeyLink]
class DashboardResponse(BaseModel):
month: str
total_balance: Money
accounts_count: int
month_expenses: Money
month_income: Money
month_net: Money
average_expenses_6m: Money
budget_total: Money
budget_actual: Money
budget_progress_pct: float
uncategorized_count: int