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

654 lines
21 KiB
Python

"""HTTP contract of the finance module (datamodel-finance.md §9).
Mounted automatically under `/api/finance` by the module loader; every endpoint
depends on the JWT user and filters by `user.id` (CONVENTIONS C2.7).
"""
import uuid
from datetime import date
from decimal import Decimal
from typing import Annotated, Any, Literal
from fastapi import APIRouter, Depends, File, Form, Query, UploadFile
from sqlalchemy.orm import Session
from app.core.config import get_settings
from app.core.database import get_db
from app.core.dependencies import get_current_user
from app.core.errors import PayloadTooLargeError
from app.core.pagination import Page, PageParams, paginate
from app.core.timeutils import resolve_tz, utcnow
from app.modules.auth.models import User
from app.modules.finance import categorize, pipeline, service
from app.modules.finance import stats as stats_service
from app.modules.finance.schemas import (
AccountCreate,
AccountRead,
AccountUpdate,
BudgetCreate,
BudgetProgressResponse,
BudgetRead,
BudgetUpdate,
BulkCategorizeRequest,
BulkCategorizeResponse,
CashflowResponse,
CategoryCreate,
CategoryRead,
CategoryUpdate,
DashboardResponse,
ImportPreviewResponse,
ImportRunRead,
MonthlyByCategoryResponse,
RecurringResponse,
RuleApplyRequest,
RuleApplyResponse,
RuleCreate,
RulePreviewRequest,
RulePreviewResponse,
RuleRead,
RuleReorderRequest,
RuleUpdate,
SankeyResponse,
SourceProfileCreate,
SourceProfileRead,
SourceProfileUpdate,
TopMerchantsResponse,
TransactionCreate,
TransactionRead,
TransactionUpdate,
TransferDetectRequest,
TransferDetectResponse,
TransferLinkRequest,
TransferLinkResponse,
)
router = APIRouter(prefix="/finance", tags=["finance"])
DbDep = Annotated[Session, Depends(get_db)]
def seeded_user(db: DbDep, user: Annotated[User, Depends(get_current_user)]) -> User:
"""Lazy seed of the built-in profiles and of the user's category tree."""
service.ensure_seed(db, user.id)
return user
UserDep = Annotated[User, Depends(seeded_user)]
AccountFilter = Annotated[list[uuid.UUID] | None, Query(alias="account_id")]
def _today() -> date:
"""Local civil day (Europe/Paris by default) used by month-based stats."""
return utcnow().astimezone(resolve_tz(get_settings().timezone)).date()
# ---------------------------------------------------------------------------
# Accounts
# ---------------------------------------------------------------------------
@router.get("/accounts", response_model=list[AccountRead])
def list_accounts(
db: DbDep,
user: UserDep,
include_archived: Annotated[bool, Query()] = False,
) -> list[AccountRead]:
return [
AccountRead.model_validate(item)
for item in service.list_accounts(db, user.id, include_archived)
]
@router.post("/accounts", response_model=AccountRead, status_code=201)
def create_account(payload: AccountCreate, db: DbDep, user: UserDep) -> AccountRead:
account = service.create_account(db, user.id, payload)
return AccountRead.model_validate(service.account_detail(db, user.id, account.id))
@router.patch("/accounts/{account_id}", response_model=AccountRead)
def update_account(
account_id: uuid.UUID, payload: AccountUpdate, db: DbDep, user: UserDep
) -> AccountRead:
service.update_account(db, user.id, account_id, payload)
return AccountRead.model_validate(service.account_detail(db, user.id, account_id))
@router.delete("/accounts/{account_id}", status_code=204)
def delete_account(account_id: uuid.UUID, db: DbDep, user: UserDep) -> None:
service.delete_account(db, user.id, account_id)
# ---------------------------------------------------------------------------
# Categories
# ---------------------------------------------------------------------------
@router.get("/categories", response_model=list[CategoryRead])
def list_categories(db: DbDep, user: UserDep) -> list[CategoryRead]:
return [
CategoryRead.model_validate(node) for node in service.category_tree(db, user.id)
]
@router.post("/categories", response_model=CategoryRead, status_code=201)
def create_category(payload: CategoryCreate, db: DbDep, user: UserDep) -> CategoryRead:
return CategoryRead.model_validate(service.create_category(db, user.id, payload))
@router.patch("/categories/{category_id}", response_model=CategoryRead)
def update_category(
category_id: uuid.UUID, payload: CategoryUpdate, db: DbDep, user: UserDep
) -> CategoryRead:
return CategoryRead.model_validate(
service.update_category(db, user.id, category_id, payload)
)
@router.delete("/categories/{category_id}", status_code=204)
def delete_category(category_id: uuid.UUID, db: DbDep, user: UserDep) -> None:
service.delete_category(db, user.id, category_id)
# ---------------------------------------------------------------------------
# Source profiles
# ---------------------------------------------------------------------------
@router.get("/source-profiles", response_model=list[SourceProfileRead])
def list_source_profiles(db: DbDep, user: UserDep) -> list[SourceProfileRead]:
return [
SourceProfileRead.model_validate(profile)
for profile in service.list_source_profiles(db, user.id)
]
@router.post("/source-profiles", response_model=SourceProfileRead, status_code=201)
def create_source_profile(
payload: SourceProfileCreate, db: DbDep, user: UserDep
) -> SourceProfileRead:
return SourceProfileRead.model_validate(
service.create_source_profile(db, user.id, payload)
)
@router.post(
"/source-profiles/{profile_id}/clone",
response_model=SourceProfileRead,
status_code=201,
)
def clone_source_profile(
profile_id: uuid.UUID, db: DbDep, user: UserDep
) -> SourceProfileRead:
return SourceProfileRead.model_validate(
service.clone_source_profile(db, user.id, profile_id)
)
@router.patch("/source-profiles/{profile_id}", response_model=SourceProfileRead)
def update_source_profile(
profile_id: uuid.UUID, payload: SourceProfileUpdate, db: DbDep, user: UserDep
) -> SourceProfileRead:
return SourceProfileRead.model_validate(
service.update_source_profile(db, user.id, profile_id, payload)
)
@router.delete("/source-profiles/{profile_id}", status_code=204)
def delete_source_profile(profile_id: uuid.UUID, db: DbDep, user: UserDep) -> None:
service.delete_source_profile(db, user.id, profile_id)
# ---------------------------------------------------------------------------
# Transactions
# ---------------------------------------------------------------------------
@router.get("/transactions", response_model=Page[TransactionRead])
def list_transactions(
db: DbDep,
user: UserDep,
params: Annotated[PageParams, Depends()],
date_from: Annotated[date | None, Query()] = None,
date_to: Annotated[date | None, Query()] = None,
account_id: AccountFilter = None,
category_id: Annotated[list[str] | None, Query()] = None,
q: Annotated[str | None, Query()] = None,
direction: Annotated[Literal["debit", "credit"] | None, Query()] = None,
amount_min: Annotated[Decimal | None, Query()] = None,
amount_max: Annotated[Decimal | None, Query()] = None,
is_transfer: Annotated[bool | None, Query()] = None,
import_run_id: Annotated[int | None, Query()] = None,
sort: Annotated[str | None, Query()] = None,
) -> Page[TransactionRead]:
stmt = service.transactions_query(
db,
user.id,
date_from=date_from,
date_to=date_to,
account_ids=account_id,
category_ids=category_id,
q=q,
direction=direction,
amount_min=amount_min,
amount_max=amount_max,
is_transfer=is_transfer,
import_run_id=import_run_id,
sort=sort,
)
items, total = paginate(db, stmt, params)
return Page(
items=[
TransactionRead.model_validate(row)
for row in service.serialize_transactions(db, user.id, items)
],
total=total,
page=params.page,
page_size=params.page_size,
)
@router.post("/transactions", response_model=TransactionRead, status_code=201)
def create_transaction(
payload: TransactionCreate, db: DbDep, user: UserDep
) -> TransactionRead:
tx = service.create_transaction(db, user.id, payload)
return TransactionRead.model_validate(
service.serialize_transactions(db, user.id, [tx])[0]
)
@router.patch("/transactions/{transaction_id}", response_model=TransactionRead)
def update_transaction(
transaction_id: uuid.UUID, payload: TransactionUpdate, db: DbDep, user: UserDep
) -> TransactionRead:
tx = service.update_transaction(db, user.id, transaction_id, payload)
return TransactionRead.model_validate(
service.serialize_transactions(db, user.id, [tx])[0]
)
@router.delete("/transactions/{transaction_id}", status_code=204)
def delete_transaction(transaction_id: uuid.UUID, db: DbDep, user: UserDep) -> None:
service.delete_transaction(db, user.id, transaction_id)
@router.post("/transactions/bulk-categorize", response_model=BulkCategorizeResponse)
def bulk_categorize(
payload: BulkCategorizeRequest, db: DbDep, user: UserDep
) -> BulkCategorizeResponse:
updated = service.bulk_categorize(
db, user.id, payload.transaction_ids, payload.category_id
)
return BulkCategorizeResponse(updated=updated)
# ---------------------------------------------------------------------------
# Rules
# ---------------------------------------------------------------------------
@router.get("/rules", response_model=list[RuleRead])
def list_rules(db: DbDep, user: UserDep) -> list[RuleRead]:
return [RuleRead.model_validate(rule) for rule in service.list_rules(db, user.id)]
@router.post("/rules", response_model=RuleRead, status_code=201)
def create_rule(payload: RuleCreate, db: DbDep, user: UserDep) -> RuleRead:
return RuleRead.model_validate(service.create_rule(db, user.id, payload))
@router.patch("/rules/{rule_id}", response_model=RuleRead)
def update_rule(
rule_id: uuid.UUID, payload: RuleUpdate, db: DbDep, user: UserDep
) -> RuleRead:
return RuleRead.model_validate(service.update_rule(db, user.id, rule_id, payload))
@router.delete("/rules/{rule_id}", status_code=204)
def delete_rule(rule_id: uuid.UUID, db: DbDep, user: UserDep) -> None:
service.delete_rule(db, user.id, rule_id)
@router.post("/rules/reorder", response_model=list[RuleRead])
def reorder_rules(
payload: RuleReorderRequest, db: DbDep, user: UserDep
) -> list[RuleRead]:
service.reorder_rules(db, user.id, payload.ordered_ids)
return [RuleRead.model_validate(rule) for rule in service.list_rules(db, user.id)]
@router.post("/rules/apply", response_model=RuleApplyResponse)
def apply_rules(
payload: RuleApplyRequest, db: DbDep, user: UserDep
) -> RuleApplyResponse:
result = categorize.apply_rules(
db,
user.id,
scope=payload.scope,
date_from=payload.date_from,
date_to=payload.date_to,
account_id=payload.account_id,
rule_id=payload.rule_id,
dry_run=payload.dry_run,
force=payload.force,
)
return RuleApplyResponse.model_validate(
{
"scanned": result.scanned,
"matched": result.matched,
"updated": result.updated,
"dry_run": result.dry_run,
"by_rule": result.by_rule,
}
)
@router.post("/rules/preview", response_model=RulePreviewResponse)
def preview_rule(
payload: RulePreviewRequest, db: DbDep, user: UserDep
) -> RulePreviewResponse:
matches, total = categorize.preview_rule(db, user.id, payload.matchers)
return RulePreviewResponse(
items=[
TransactionRead.model_validate(row)
for row in service.serialize_transactions(db, user.id, matches)
],
total_matched=total,
)
# ---------------------------------------------------------------------------
# Budgets
# ---------------------------------------------------------------------------
@router.get("/budgets", response_model=list[BudgetRead])
def list_budgets(
db: DbDep,
user: UserDep,
month: Annotated[str | None, Query()] = None,
) -> list[BudgetRead]:
target = service.parse_month_param(month, _today())
return [
BudgetRead.model_validate(item)
for item in service.list_budgets(db, user.id, target)
]
@router.post("/budgets", response_model=BudgetRead, status_code=201)
def create_budget(payload: BudgetCreate, db: DbDep, user: UserDep) -> BudgetRead:
budget = service.create_budget(db, user.id, payload)
return BudgetRead.model_validate(budget)
@router.patch("/budgets/{budget_id}", response_model=list[BudgetRead])
def update_budget(
budget_id: uuid.UUID, payload: BudgetUpdate, db: DbDep, user: UserDep
) -> list[BudgetRead]:
budgets = service.update_budget(db, user.id, budget_id, payload)
return [BudgetRead.model_validate(budget) for budget in budgets]
@router.delete("/budgets/{budget_id}", status_code=204)
def delete_budget(budget_id: uuid.UUID, db: DbDep, user: UserDep) -> None:
service.delete_budget(db, user.id, budget_id)
# ---------------------------------------------------------------------------
# Imports
# ---------------------------------------------------------------------------
def _read_upload(file: UploadFile) -> bytes:
data = file.file.read()
if len(data) > get_settings().max_upload_bytes:
raise PayloadTooLargeError("Fichier trop volumineux (limite : 20 Mio).")
return data
@router.post("/imports/preview", response_model=ImportPreviewResponse)
def preview_import(
db: DbDep,
user: UserDep,
file: Annotated[UploadFile, File()],
account_id: Annotated[uuid.UUID, Form()],
source_profile_id: Annotated[uuid.UUID | None, Form()] = None,
) -> ImportPreviewResponse:
account = service.get_account(db, user.id, account_id)
profile = service.resolve_profile(db, user.id, source_profile_id)
result = pipeline.preview_import(db, user.id, account, profile, _read_upload(file))
return ImportPreviewResponse.model_validate(
{
"rows_preview": result.rows_preview,
"rows_total": result.rows_total,
"rows_error": result.rows_error,
"rows_skipped_filtered": result.rows_skipped_filtered,
"would_skip_duplicates": result.would_skip_duplicates,
"date_min": result.date_min,
"date_max": result.date_max,
"errors": result.errors,
"duplicate_file_of": result.duplicate_file_of,
}
)
@router.post("/imports", response_model=ImportRunRead, status_code=201)
def run_import(
db: DbDep,
user: UserDep,
file: Annotated[UploadFile, File()],
account_id: Annotated[uuid.UUID, Form()],
source_profile_id: Annotated[uuid.UUID | None, Form()] = None,
) -> ImportRunRead:
account = service.get_account(db, user.id, account_id)
profile = service.resolve_profile(db, user.id, source_profile_id)
run = pipeline.run_import(
db,
user.id,
account,
profile,
file.filename or "import.csv",
_read_upload(file),
)
return ImportRunRead.model_validate(run)
@router.get("/imports", response_model=Page[ImportRunRead])
def list_imports(
db: DbDep,
user: UserDep,
params: Annotated[PageParams, Depends()],
) -> Page[ImportRunRead]:
items, total = paginate(db, service.import_runs_query(user.id), params)
return Page(
items=[ImportRunRead.model_validate(run) for run in items],
total=total,
page=params.page,
page_size=params.page_size,
)
@router.get("/imports/{run_id}", response_model=ImportRunRead)
def get_import(run_id: uuid.UUID, db: DbDep, user: UserDep) -> ImportRunRead:
return ImportRunRead.model_validate(service.get_import_run(db, user.id, run_id))
@router.delete("/imports/{run_id}", status_code=204)
def rollback_import(
run_id: uuid.UUID,
db: DbDep,
user: UserDep,
force: Annotated[bool, Query()] = False,
) -> None:
service.rollback_import(db, user.id, run_id, force=force)
# ---------------------------------------------------------------------------
# Transfers
# ---------------------------------------------------------------------------
@router.post("/transfers/detect", response_model=TransferDetectResponse)
def detect_transfers(
db: DbDep,
user: UserDep,
payload: TransferDetectRequest | None = None,
) -> TransferDetectResponse:
payload = payload or TransferDetectRequest()
created = categorize.detect_transfers(
db, user.id, payload.date_from, payload.date_to
)
db.commit()
return TransferDetectResponse(pairs_created=created)
@router.post("/transfers/link", response_model=TransferLinkResponse)
def link_transfer(
payload: TransferLinkRequest, db: DbDep, user: UserDep
) -> TransferLinkResponse:
group_id = categorize.link_transfer(
db, user.id, payload.transaction_id_a, payload.transaction_id_b
)
return TransferLinkResponse(transfer_group_id=group_id)
@router.delete("/transfers/{transfer_group_id}", status_code=204)
def unlink_transfer(transfer_group_id: uuid.UUID, db: DbDep, user: UserDep) -> None:
categorize.unlink_transfer(db, user.id, transfer_group_id)
# ---------------------------------------------------------------------------
# Stats
# ---------------------------------------------------------------------------
@router.get("/stats/monthly-by-category", response_model=MonthlyByCategoryResponse)
def stats_monthly_by_category(
db: DbDep,
user: UserDep,
months: Annotated[int, Query(ge=1, le=60)] = 12,
level: Annotated[Literal["root", "child"], Query()] = "root",
direction: Annotated[Literal["debit", "credit"], Query()] = "debit",
account_id: AccountFilter = None,
) -> MonthlyByCategoryResponse:
return MonthlyByCategoryResponse.model_validate(
stats_service.monthly_by_category(
db,
user.id,
months=months,
level=level,
direction=direction,
account_ids=account_id,
today=_today(),
)
)
@router.get("/stats/cashflow", response_model=CashflowResponse)
def stats_cashflow(
db: DbDep,
user: UserDep,
months: Annotated[int, Query(ge=1, le=60)] = 12,
account_id: AccountFilter = None,
) -> CashflowResponse:
return CashflowResponse.model_validate(
stats_service.cashflow(
db, user.id, months=months, account_ids=account_id, today=_today()
)
)
@router.get("/stats/top-merchants", response_model=TopMerchantsResponse)
def stats_top_merchants(
db: DbDep,
user: UserDep,
months: Annotated[int, Query(ge=1, le=60)] = 3,
limit: Annotated[int, Query(ge=1, le=100)] = 15,
direction: Annotated[Literal["debit", "credit"], Query()] = "debit",
account_id: AccountFilter = None,
) -> TopMerchantsResponse:
data = stats_service.top_merchants(
db,
user.id,
months=months,
limit=limit,
direction=direction,
account_ids=account_id,
today=_today(),
)
return TopMerchantsResponse.model_validate(
{"period": _period(data["period"]), "items": data["items"]}
)
@router.get("/stats/recurring", response_model=RecurringResponse)
def stats_recurring(
db: DbDep,
user: UserDep,
direction: Annotated[Literal["debit", "credit"], Query()] = "debit",
include_inactive: Annotated[bool, Query()] = False,
) -> RecurringResponse:
return RecurringResponse.model_validate(
stats_service.recurring(
db,
user.id,
direction=direction,
include_inactive=include_inactive,
today=_today(),
)
)
@router.get("/stats/budget-progress", response_model=BudgetProgressResponse)
def stats_budget_progress(
db: DbDep,
user: UserDep,
month: Annotated[str | None, Query()] = None,
account_id: AccountFilter = None,
) -> BudgetProgressResponse:
today = _today()
target = service.parse_month_param(month, today)
return BudgetProgressResponse.model_validate(
stats_service.budget_progress(
db, user.id, target, account_ids=account_id, today=today
)
)
@router.get("/stats/sankey", response_model=SankeyResponse)
def stats_sankey(
db: DbDep,
user: UserDep,
month: Annotated[str | None, Query()] = None,
months: Annotated[int, Query(ge=1, le=60)] = 1,
account_id: AccountFilter = None,
) -> SankeyResponse:
today = _today()
target = service.parse_month_param(month, today) if month else None
data = stats_service.sankey(
db,
user.id,
month=target,
months=months,
account_ids=account_id,
today=today,
)
return SankeyResponse.model_validate(
{
"period": _period(data["period"]),
"nodes": data["nodes"],
"links": data["links"],
}
)
@router.get("/dashboard", response_model=DashboardResponse)
def dashboard(db: DbDep, user: UserDep) -> DashboardResponse:
return DashboardResponse.model_validate(
stats_service.dashboard(db, user.id, today=_today())
)
def _period(period: dict[str, Any]) -> dict[str, Any]:
return {"from": period["from"], "to": period["to"]}