"""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"]}