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>
502 lines
12 KiB
Python
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
|