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

129 lines
5.1 KiB
Python

from datetime import datetime, timedelta
from fastapi import APIRouter, Depends
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 Budget, User
router = APIRouter(prefix="/api/graphs", tags=["graphs"])
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 _format_year_month(year: int, month: int) -> str:
return datetime(year, month, 1).strftime("%b %y") # "Jan 25"
def _last_n_months_start(n: int) -> datetime:
now = datetime.utcnow()
month = now.month - (n - 1)
year = now.year
while month <= 0:
month += 12
year -= 1
return datetime(year, month, 1)
@router.get("")
def get_graphs(
session: Session = Depends(get_session),
current_user: User = Depends(get_current_user),
):
now = datetime.utcnow()
month_start, month_end = _month_bounds(now.year, now.month)
six_months_ago = _last_n_months_start(6)
user_id = current_user.id
# Net worth trend (all time, BANK accounts from BalanceSnapshot, last 24 points)
nw_rows = session.exec(text(
'SELECT bs.year, bs.month, COALESCE(SUM(bs."balanceCents"), 0)::bigint AS total '
'FROM "BalanceSnapshot" bs JOIN "Account" a ON bs."accountId" = a.id '
'WHERE a."userId" = :uid AND a.type = \'BANK\' '
'GROUP BY bs.year, bs.month ORDER BY bs.year, bs.month'
).bindparams(uid=user_id)).all()
net_worth_data = [
{"label": _format_year_month(r[0], r[1]), "totalCents": int(r[2])}
for r in nw_rows[-24:]
]
# Cash flow trend (last 6 months, BANK only)
cf_rows = session.exec(text(
'SELECT EXTRACT(YEAR FROM t.date)::int AS year, EXTRACT(MONTH FROM t.date)::int AS month, '
'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.date >= :six_ago '
'GROUP BY year, month, t.type ORDER BY year, month'
).bindparams(uid=user_id, six_ago=six_months_ago)).all()
cf_map: dict[str, dict] = {}
for r in cf_rows:
key = f"{r[0]}-{r[1]}"
if key not in cf_map:
cf_map[key] = {"label": _format_year_month(r[0], r[1]), "creditsCents": 0, "debitsCents": 0}
if r[2] == "CREDIT":
cf_map[key]["creditsCents"] = int(r[3])
else:
cf_map[key]["debitsCents"] = int(r[3])
cash_flow_data = list(cf_map.values())
# Monthly spending (last 6 months, all accounts, DEBIT only)
spend_rows = session.exec(text(
'SELECT EXTRACT(YEAR FROM t.date)::int AS year, EXTRACT(MONTH FROM t.date)::int AS month, '
'COALESCE(SUM(t."amountCents"), 0)::bigint AS total '
'FROM "Transaction" t JOIN "Account" a ON t."accountId" = a.id '
'WHERE a."userId" = :uid AND t.type = \'DEBIT\' AND t.date >= :six_ago '
'GROUP BY year, month ORDER BY year, month'
).bindparams(uid=user_id, six_ago=six_months_ago)).all()
monthly_spend_data = [
{"label": _format_year_month(r[0], r[1]), "totalCents": int(r[2])} for r in spend_rows
]
# Category breakdown (current month, all accounts, DEBIT)
cat_rows = session.exec(text(
'SELECT COALESCE(t.category, \'Uncategorized\') AS category, '
'COALESCE(SUM(t."amountCents"), 0)::bigint AS total '
'FROM "Transaction" t JOIN "Account" a ON t."accountId" = a.id '
'WHERE a."userId" = :uid AND t.type = \'DEBIT\' '
'AND t.date >= :start AND t.date <= :end '
'GROUP BY category ORDER BY total DESC'
).bindparams(uid=user_id, start=month_start, end=month_end)).all()
category_data = [{"category": r[0], "totalCents": int(r[1])} for r in cat_rows]
# Budget performance (current month, DEBIT)
budgets = session.exec(
select(Budget).where(Budget.user_id == user_id, Budget.is_active == True).order_by(Budget.name)
).all()
budget_spend_rows = session.exec(text(
'SELECT t."budgetId", COALESCE(SUM(t."amountCents"), 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.type = \'DEBIT\' '
'AND t.date >= :start AND t.date <= :end GROUP BY t."budgetId"'
).bindparams(uid=user_id, start=month_start, end=month_end)).all()
budget_spend_map = {r[0]: int(r[1]) for r in budget_spend_rows}
budget_data = [
{
"name": b.name,
"spendCents": budget_spend_map.get(b.id, 0),
"limitCents": b.limit_cents or 0,
"color": b.color,
}
for b in budgets
]
return JSONResponse(content=jsonable_encoder({
"netWorthData": net_worth_data,
"cashFlowData": cash_flow_data,
"monthlySpendData": monthly_spend_data,
"categoryData": category_data,
"budgetData": budget_data,
}))