171 lines
6.6 KiB
Python
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
|
|
]))
|