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>
654 lines
21 KiB
Python
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"]}
|