Files
finance-app/backend/app/routers/dashboard.py

171 lines
6.6 KiB
Python

from datetime import datetime, timedelta
from fastapi import APIRouter, Depends, Query
from fastapi.encoders import jsonable_encoder
from fastapi.responses import JSONResponse
from sqlalchemy import text
from sqlmodel import Session, select
from ..database import get_session
from ..deps import get_current_user
from ..models import Account, Budget, Transaction, User
router = APIRouter(prefix="/api/dashboard", tags=["dashboard"])
def _month_bounds(year: int, month: int) -> tuple[datetime, datetime]:
start = datetime(year, month, 1)
end = (datetime(year + 1, 1, 1) if month == 12 else datetime(year, month + 1, 1)) - timedelta(milliseconds=1)
return start, end
def _month_label(year: int, month: int) -> str:
return datetime(year, month, 1).strftime("%B %Y")
@router.get("")
def get_dashboard(
session: Session = Depends(get_session),
current_user: User = Depends(get_current_user),
month: int = Query(default=0, ge=0, le=12),
year: int = Query(default=0, ge=0),
):
now = datetime.utcnow()
resolved_month = month or now.month
resolved_year = year or now.year
is_current_month = resolved_month == now.month and resolved_year == now.year
start, end = _month_bounds(resolved_year, resolved_month)
user_id = current_user.id
# Cash flow (BANK accounts only, TRANSFER excluded)
cf_rows = session.exec(text(
'SELECT t.type, COALESCE(SUM(t."amountCents"), 0)::bigint AS total '
'FROM "Transaction" t JOIN "Account" a ON t."accountId" = a.id '
'WHERE a."userId" = :uid AND a.type = \'BANK\' AND t.type != \'TRANSFER\' '
'AND t.date >= :start AND t.date <= :end GROUP BY t.type'
).bindparams(uid=user_id, start=start, end=end)).all()
cf_map = {r[0]: int(r[1]) for r in cf_rows}
credits_cents = cf_map.get("CREDIT", 0)
debits_cents = cf_map.get("DEBIT", 0)
# Budgets with spend for selected month
budgets = session.exec(
select(Budget).where(Budget.user_id == user_id, Budget.is_active == True).order_by(Budget.name)
).all()
spend_rows = session.exec(text(
'SELECT t."budgetId", COALESCE(SUM(CASE WHEN t.type = \'DEBIT\' THEN t."amountCents" ELSE -t."amountCents" END), 0)::bigint AS total '
'FROM "Transaction" t JOIN "Account" a ON t."accountId" = a.id '
'WHERE a."userId" = :uid AND t."budgetId" IS NOT NULL '
'AND t.date >= :start AND t.date <= :end GROUP BY t."budgetId"'
).bindparams(uid=user_id, start=start, end=end)).all()
spend_map = {r[0]: int(r[1]) for r in spend_rows}
# Recent transactions for selected month
recent_tx = session.exec(
text(
'SELECT t.id, t.date, t.description, t."amountCents", t.type, a.name AS account_name '
'FROM "Transaction" t JOIN "Account" a ON t."accountId" = a.id '
'WHERE a."userId" = :uid AND t.date >= :start AND t.date <= :end '
'ORDER BY t.date DESC LIMIT 5'
).bindparams(uid=user_id, start=start, end=end)
).all()
# Net worth
if is_current_month:
accounts = session.exec(
select(Account).where(
Account.user_id == user_id,
Account.is_active == True,
Account.type.in_(["BANK", "INVESTMENT"]),
).order_by(Account.name)
).all()
net_worth_accounts = [
{"id": a.id, "name": a.name, "balanceCents": a.current_balance_cents} for a in accounts
]
else:
snap_rows = session.exec(text(
'SELECT bs."accountId", a.name, bs."balanceCents" '
'FROM "BalanceSnapshot" bs JOIN "Account" a ON bs."accountId" = a.id '
'WHERE a."userId" = :uid AND a.type IN (\'BANK\', \'INVESTMENT\') '
'AND bs.year = :year AND bs.month = :month'
).bindparams(uid=user_id, year=resolved_year, month=resolved_month)).all()
net_worth_accounts = [
{"id": r[0], "name": r[1], "balanceCents": int(r[2])} for r in snap_rows
]
net_worth_cents = sum(a["balanceCents"] for a in net_worth_accounts)
return JSONResponse(content=jsonable_encoder({
"monthLabel": _month_label(resolved_year, resolved_month),
"netWorthCents": net_worth_cents,
"bankAccounts": net_worth_accounts,
"cashFlow": {
"creditsCents": credits_cents,
"debitsCents": debits_cents,
"netCents": credits_cents - debits_cents,
},
"budgets": [
{
"id": b.id, "name": b.name, "limitCents": b.limit_cents,
"color": b.color, "spendCents": spend_map.get(b.id, 0),
}
for b in budgets
],
"recentTransactions": [
{
"id": r[0], "date": r[1].isoformat(), "description": r[2],
"amountCents": r[3], "type": r[4], "accountName": r[5],
}
for r in recent_tx
],
}))
@router.get("/budgets")
def get_budget_summary(
session: Session = Depends(get_session),
current_user: User = Depends(get_current_user),
month: int = Query(default=0, ge=0, le=12),
year: int = Query(default=0, ge=0),
):
"""Budgets with spend and rules for a given month — used by the Budgets page."""
from ..models import BudgetRule
now = datetime.utcnow()
resolved_month = month or now.month
resolved_year = year or now.year
start, end = _month_bounds(resolved_year, resolved_month)
user_id = current_user.id
budgets = session.exec(
select(Budget).where(Budget.user_id == user_id).order_by(Budget.created_at)
).all()
spend_rows = session.exec(text(
'SELECT t."budgetId", COALESCE(SUM(CASE WHEN t.type = \'DEBIT\' THEN t."amountCents" ELSE -t."amountCents" END), 0)::bigint AS total '
'FROM "Transaction" t JOIN "Account" a ON t."accountId" = a.id '
'WHERE a."userId" = :uid AND t."budgetId" IS NOT NULL '
'AND t.date >= :start AND t.date <= :end GROUP BY t."budgetId"'
).bindparams(uid=user_id, start=start, end=end)).all()
spend_map = {r[0]: int(r[1]) for r in spend_rows}
all_rules = session.exec(
select(BudgetRule).where(BudgetRule.user_id == user_id).order_by(BudgetRule.created_at)
).all()
rules_map: dict[str, list] = {}
for rule in all_rules:
rules_map.setdefault(rule.budget_id, []).append({"id": rule.id, "pattern": rule.pattern})
return JSONResponse(content=jsonable_encoder([
{
"id": b.id, "name": b.name, "limitCents": b.limit_cents,
"color": b.color, "isActive": b.is_active,
"spendCents": spend_map.get(b.id, 0),
"rules": rules_map.get(b.id, []),
}
for b in budgets
]))