129 lines
5.1 KiB
Python
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,
|
|
}))
|