feat: advanced features — dashboard, budgets, history, export
Backend: - GET /api/v1/dashboard?month=YYYY-MM: KPIs, by_category, 6-month trend, budget alerts - GET/POST/PUT/DELETE /api/v1/budgets: budget envelopes with spent_cents/remaining_cents - POST /api/v1/budgets/rollover: copy budgets from M-1 to target month - GET /api/v1/history?year=YYYY: monthly summary for the year - GET /api/v1/export/csv|pdf?month=YYYY-MM: StreamingResponse exports (WeasyPrint PDF) - New schemas: dashboard, budget, history - Services: dashboard_service, budget_service - Routers mounted in main.py Frontend: - DashboardPage: 4 KPI cards, PieChart (expenses by category), BarChart (6-month trend), month navigation, budget alert badges, CSV/PDF export buttons - BudgetsPage: progress bars (green/orange/red), create/edit form, delete, rollover M-1 - HistoryPage: annual table with month click → dashboard, LineChart revenues/expenses - CategoriesPage: list by type with create/edit/delete (was missing from Phase 2) - TransactionsPage: added CSV/PDF export buttons - App.tsx: full routing with ProtectedRoute + Layout for all authenticated pages - New hooks: useDashboard, useBudgets (with mutations), useHistory - API types + client updated for all new endpoints Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -7,7 +7,11 @@ from fastapi.middleware.cors import CORSMiddleware
|
||||
from app.auth.router import router as auth_router
|
||||
from app.config import settings
|
||||
from app.database import engine
|
||||
from app.routers.budgets import router as budgets_router
|
||||
from app.routers.categories import router as categories_router
|
||||
from app.routers.dashboard import router as dashboard_router
|
||||
from app.routers.export import router as export_router
|
||||
from app.routers.history import router as history_router
|
||||
from app.routers.transactions import router as transactions_router
|
||||
|
||||
|
||||
@@ -40,6 +44,10 @@ def create_app() -> FastAPI:
|
||||
app.include_router(auth_router, prefix=api_prefix)
|
||||
app.include_router(transactions_router, prefix=api_prefix)
|
||||
app.include_router(categories_router, prefix=api_prefix)
|
||||
app.include_router(dashboard_router, prefix=api_prefix)
|
||||
app.include_router(budgets_router, prefix=api_prefix)
|
||||
app.include_router(history_router, prefix=api_prefix)
|
||||
app.include_router(export_router, prefix=api_prefix)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth.dependencies import get_current_user
|
||||
from app.database import get_session
|
||||
from app.models.user import User
|
||||
from app.schemas.budget import BudgetCreate, BudgetResponse, BudgetUpdate
|
||||
from app.services import budget_service
|
||||
from app.utils import utcnow
|
||||
|
||||
router = APIRouter(prefix="/budgets", tags=["budgets"])
|
||||
|
||||
|
||||
def _current_month() -> str:
|
||||
now = utcnow()
|
||||
return f"{now.year}-{now.month:02d}"
|
||||
|
||||
|
||||
@router.get("", response_model=list[BudgetResponse])
|
||||
async def list_budgets(
|
||||
month: str = Query(default=None, description="Month YYYY-MM (defaults to current month)"),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> list[BudgetResponse]:
|
||||
if month is None:
|
||||
month = _current_month()
|
||||
return await budget_service.list_budgets(session, current_user.id, month)
|
||||
|
||||
|
||||
@router.post("", response_model=BudgetResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_budget(
|
||||
data: BudgetCreate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> BudgetResponse:
|
||||
return await budget_service.create_budget(session, current_user.id, data)
|
||||
|
||||
|
||||
@router.put("/{budget_id}", response_model=BudgetResponse)
|
||||
async def update_budget(
|
||||
budget_id: uuid.UUID,
|
||||
data: BudgetUpdate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> BudgetResponse:
|
||||
return await budget_service.update_budget(session, current_user.id, budget_id, data)
|
||||
|
||||
|
||||
@router.delete("/{budget_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_budget(
|
||||
budget_id: uuid.UUID,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> None:
|
||||
await budget_service.delete_budget(session, current_user.id, budget_id)
|
||||
|
||||
|
||||
@router.post("/rollover", response_model=list[BudgetResponse], status_code=status.HTTP_201_CREATED)
|
||||
async def rollover_budgets(
|
||||
month: str = Query(..., description="Target month YYYY-MM to copy budgets into"),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> list[BudgetResponse]:
|
||||
"""Copy budgets from month-1 into the given month (skips existing ones)."""
|
||||
return await budget_service.rollover_budgets(session, current_user.id, month)
|
||||
@@ -0,0 +1,27 @@
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth.dependencies import get_current_user
|
||||
from app.database import get_session
|
||||
from app.models.user import User
|
||||
from app.schemas.dashboard import DashboardResponse
|
||||
from app.services import dashboard_service
|
||||
from app.utils import utcnow
|
||||
|
||||
router = APIRouter(prefix="/dashboard", tags=["dashboard"])
|
||||
|
||||
|
||||
def _current_month() -> str:
|
||||
now = utcnow()
|
||||
return f"{now.year}-{now.month:02d}"
|
||||
|
||||
|
||||
@router.get("", response_model=DashboardResponse)
|
||||
async def get_dashboard(
|
||||
month: str = Query(default=None, description="Month YYYY-MM (defaults to current month)"),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> DashboardResponse:
|
||||
if month is None:
|
||||
month = _current_month()
|
||||
return await dashboard_service.get_dashboard(session, current_user.id, month)
|
||||
@@ -0,0 +1,175 @@
|
||||
import csv
|
||||
import io
|
||||
from datetime import date
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.auth.dependencies import get_current_user
|
||||
from app.database import get_session
|
||||
from app.models.transaction import Transaction
|
||||
from app.models.user import User
|
||||
from app.utils import utcnow
|
||||
|
||||
router = APIRouter(prefix="/export", tags=["export"])
|
||||
|
||||
|
||||
def _month_bounds(month: str) -> tuple[date, date]:
|
||||
year, mon = map(int, month.split("-"))
|
||||
start = date(year, mon, 1)
|
||||
if mon == 12:
|
||||
end = date(year + 1, 1, 1)
|
||||
else:
|
||||
end = date(year, mon + 1, 1)
|
||||
return start, end
|
||||
|
||||
|
||||
async def _fetch_transactions(
|
||||
session: AsyncSession,
|
||||
user_id,
|
||||
month: str,
|
||||
) -> list[Transaction]:
|
||||
start, end = _month_bounds(month)
|
||||
result = await session.execute(
|
||||
select(Transaction)
|
||||
.options(selectinload(Transaction.category))
|
||||
.where(
|
||||
Transaction.user_id == user_id,
|
||||
Transaction.deleted_at.is_(None),
|
||||
Transaction.transaction_date >= start,
|
||||
Transaction.transaction_date < end,
|
||||
)
|
||||
.order_by(Transaction.transaction_date, Transaction.created_at)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
@router.get("/csv")
|
||||
async def export_csv(
|
||||
month: str = Query(default=None, description="Month YYYY-MM"),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> StreamingResponse:
|
||||
if month is None:
|
||||
now = utcnow()
|
||||
month = f"{now.year}-{now.month:02d}"
|
||||
|
||||
transactions = await _fetch_transactions(session, current_user.id, month)
|
||||
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output, delimiter=";")
|
||||
writer.writerow(["Date", "Type", "Catégorie", "Description", "Montant (€)"])
|
||||
for tx in transactions:
|
||||
amount_eur = tx.amount_cents / 100
|
||||
if tx.type.value == "expense":
|
||||
amount_eur = -amount_eur
|
||||
writer.writerow([
|
||||
tx.transaction_date.isoformat(),
|
||||
tx.type.value,
|
||||
tx.category.name,
|
||||
tx.description or "",
|
||||
f"{amount_eur:.2f}",
|
||||
])
|
||||
|
||||
output.seek(0)
|
||||
filename = f"transactions_{month}.csv"
|
||||
return StreamingResponse(
|
||||
iter([output.getvalue()]),
|
||||
media_type="text/csv; charset=utf-8",
|
||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/pdf")
|
||||
async def export_pdf(
|
||||
month: str = Query(default=None, description="Month YYYY-MM"),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> StreamingResponse:
|
||||
if month is None:
|
||||
now = utcnow()
|
||||
month = f"{now.year}-{now.month:02d}"
|
||||
|
||||
transactions = await _fetch_transactions(session, current_user.id, month)
|
||||
|
||||
total_income = sum(t.amount_cents for t in transactions if t.type.value == "income")
|
||||
total_expense = sum(t.amount_cents for t in transactions if t.type.value == "expense")
|
||||
|
||||
rows_html = ""
|
||||
for tx in transactions:
|
||||
amount_eur = tx.amount_cents / 100
|
||||
color = "#16a34a" if tx.type.value == "income" else "#dc2626"
|
||||
sign = "+" if tx.type.value == "income" else "-"
|
||||
rows_html += f"""
|
||||
<tr>
|
||||
<td>{tx.transaction_date.isoformat()}</td>
|
||||
<td>{tx.category.name}</td>
|
||||
<td>{tx.description or "—"}</td>
|
||||
<td style="color:{color};text-align:right;font-weight:600">
|
||||
{sign}{amount_eur:.2f} €
|
||||
</td>
|
||||
</tr>"""
|
||||
|
||||
html = f"""<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<style>
|
||||
body {{ font-family: Arial, sans-serif; font-size: 12px; color: #1e293b; margin: 40px; }}
|
||||
h1 {{ font-size: 20px; margin-bottom: 4px; }}
|
||||
.subtitle {{ color: #64748b; margin-bottom: 24px; }}
|
||||
.kpi {{ display: flex; gap: 40px; margin-bottom: 24px; }}
|
||||
.kpi-card {{ background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 8px;
|
||||
padding: 12px 20px; }}
|
||||
.kpi-label {{ font-size: 11px; color: #64748b; }}
|
||||
.kpi-value {{ font-size: 18px; font-weight: 700; margin-top: 4px; }}
|
||||
table {{ width: 100%; border-collapse: collapse; }}
|
||||
th {{ background: #1e293b; color: white; padding: 8px 12px; text-align: left; font-size: 11px; }}
|
||||
td {{ padding: 7px 12px; border-bottom: 1px solid #e2e8f0; }}
|
||||
tr:nth-child(even) td {{ background: #f8fafc; }}
|
||||
.footer {{ margin-top: 24px; font-size: 10px; color: #94a3b8; text-align: right; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Rapport de transactions</h1>
|
||||
<div class="subtitle">Période : {month} | {current_user.full_name or current_user.email}</div>
|
||||
<div class="kpi">
|
||||
<div class="kpi-card">
|
||||
<div class="kpi-label">Revenus</div>
|
||||
<div class="kpi-value" style="color:#16a34a">+{total_income/100:.2f} €</div>
|
||||
</div>
|
||||
<div class="kpi-card">
|
||||
<div class="kpi-label">Dépenses</div>
|
||||
<div class="kpi-value" style="color:#dc2626">-{total_expense/100:.2f} €</div>
|
||||
</div>
|
||||
<div class="kpi-card">
|
||||
<div class="kpi-label">Solde net</div>
|
||||
<div class="kpi-value">{(total_income - total_expense)/100:+.2f} €</div>
|
||||
</div>
|
||||
</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th><th>Catégorie</th><th>Description</th><th style="text-align:right">Montant</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows_html}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="footer">Généré le {utcnow().strftime('%d/%m/%Y')} — Budget Tracker</div>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
import weasyprint # noqa: PLC0415
|
||||
|
||||
pdf_bytes = weasyprint.HTML(string=html).write_pdf()
|
||||
filename = f"transactions_{month}.pdf"
|
||||
return StreamingResponse(
|
||||
iter([pdf_bytes]),
|
||||
media_type="application/pdf",
|
||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||
)
|
||||
@@ -0,0 +1,73 @@
|
||||
from datetime import date
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth.dependencies import get_current_user
|
||||
from app.database import get_session
|
||||
from app.models.transaction import Transaction, TransactionType
|
||||
from app.models.user import User
|
||||
from app.schemas.history import HistoryResponse, MonthSummary
|
||||
from app.utils import utcnow
|
||||
|
||||
router = APIRouter(prefix="/history", tags=["history"])
|
||||
|
||||
|
||||
@router.get("", response_model=HistoryResponse)
|
||||
async def get_history(
|
||||
year: int = Query(default=None, description="Year (defaults to current year)"),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> HistoryResponse:
|
||||
if year is None:
|
||||
year = utcnow().year
|
||||
|
||||
months: list[MonthSummary] = []
|
||||
for mon in range(1, 13):
|
||||
start = date(year, mon, 1)
|
||||
if mon == 12:
|
||||
end = date(year + 1, 1, 1)
|
||||
else:
|
||||
end = date(year, mon + 1, 1)
|
||||
|
||||
result = await session.execute(
|
||||
select(
|
||||
func.coalesce(
|
||||
func.sum(
|
||||
func.case(
|
||||
(Transaction.type == TransactionType.income, Transaction.amount_cents),
|
||||
else_=0,
|
||||
)
|
||||
),
|
||||
0,
|
||||
).label("income"),
|
||||
func.coalesce(
|
||||
func.sum(
|
||||
func.case(
|
||||
(Transaction.type == TransactionType.expense, Transaction.amount_cents),
|
||||
else_=0,
|
||||
)
|
||||
),
|
||||
0,
|
||||
).label("expense"),
|
||||
func.count(Transaction.id).label("count"),
|
||||
).where(
|
||||
Transaction.user_id == current_user.id,
|
||||
Transaction.deleted_at.is_(None),
|
||||
Transaction.transaction_date >= start,
|
||||
Transaction.transaction_date < end,
|
||||
)
|
||||
)
|
||||
row = result.one()
|
||||
months.append(
|
||||
MonthSummary(
|
||||
month=f"{year}-{mon:02d}",
|
||||
income_cents=row.income,
|
||||
expense_cents=row.expense,
|
||||
balance_cents=row.income - row.expense,
|
||||
transaction_count=row.count,
|
||||
)
|
||||
)
|
||||
|
||||
return HistoryResponse(year=year, months=months)
|
||||
@@ -0,0 +1,51 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, field_validator
|
||||
|
||||
|
||||
class BudgetCreate(BaseModel):
|
||||
category_id: uuid.UUID
|
||||
month: str # YYYY-MM
|
||||
limit_cents: int
|
||||
|
||||
@field_validator("limit_cents")
|
||||
@classmethod
|
||||
def limit_positive(cls, v: int) -> int:
|
||||
if v <= 0:
|
||||
raise ValueError("limit_cents must be positive")
|
||||
return v
|
||||
|
||||
@field_validator("month")
|
||||
@classmethod
|
||||
def month_format(cls, v: str) -> str:
|
||||
parts = v.split("-")
|
||||
if len(parts) != 2 or not all(p.isdigit() for p in parts):
|
||||
raise ValueError("month must be in YYYY-MM format")
|
||||
return v
|
||||
|
||||
|
||||
class BudgetUpdate(BaseModel):
|
||||
limit_cents: int
|
||||
|
||||
@field_validator("limit_cents")
|
||||
@classmethod
|
||||
def limit_positive(cls, v: int) -> int:
|
||||
if v <= 0:
|
||||
raise ValueError("limit_cents must be positive")
|
||||
return v
|
||||
|
||||
|
||||
class BudgetResponse(BaseModel):
|
||||
id: uuid.UUID
|
||||
category_id: uuid.UUID
|
||||
category_name: str
|
||||
category_color: str | None
|
||||
month: str
|
||||
limit_cents: int
|
||||
spent_cents: int
|
||||
remaining_cents: int
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
@@ -0,0 +1,37 @@
|
||||
import uuid
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class CategoryExpense(BaseModel):
|
||||
category_id: uuid.UUID
|
||||
category_name: str
|
||||
color: str | None
|
||||
amount_cents: int
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class MonthlyTrend(BaseModel):
|
||||
month: str # YYYY-MM
|
||||
income_cents: int
|
||||
expense_cents: int
|
||||
|
||||
|
||||
class BudgetAlert(BaseModel):
|
||||
budget_id: uuid.UUID
|
||||
category_name: str
|
||||
limit_cents: int
|
||||
spent_cents: int
|
||||
percentage: float
|
||||
|
||||
|
||||
class DashboardResponse(BaseModel):
|
||||
month: str
|
||||
balance_cents: int # all-time cumulative balance
|
||||
total_income_cents: int # month income
|
||||
total_expense_cents: int # month expenses
|
||||
net_cents: int # month net
|
||||
by_category: list[CategoryExpense]
|
||||
monthly_trend: list[MonthlyTrend]
|
||||
budget_alerts: list[BudgetAlert]
|
||||
@@ -0,0 +1,14 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class MonthSummary(BaseModel):
|
||||
month: str # YYYY-MM
|
||||
income_cents: int
|
||||
expense_cents: int
|
||||
balance_cents: int # month net
|
||||
transaction_count: int
|
||||
|
||||
|
||||
class HistoryResponse(BaseModel):
|
||||
year: int
|
||||
months: list[MonthSummary]
|
||||
@@ -0,0 +1,213 @@
|
||||
import uuid
|
||||
from datetime import date
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.models.budget import Budget
|
||||
from app.models.transaction import Transaction, TransactionType
|
||||
from app.schemas.budget import BudgetCreate, BudgetResponse, BudgetUpdate
|
||||
from app.utils import utcnow
|
||||
|
||||
|
||||
def _month_bounds(month: str) -> tuple[date, date]:
|
||||
year, mon = map(int, month.split("-"))
|
||||
start = date(year, mon, 1)
|
||||
if mon == 12:
|
||||
end = date(year + 1, 1, 1)
|
||||
else:
|
||||
end = date(year, mon + 1, 1)
|
||||
return start, end
|
||||
|
||||
|
||||
def _prev_month(month: str) -> str:
|
||||
year, mon = map(int, month.split("-"))
|
||||
if mon == 1:
|
||||
return f"{year - 1}-12"
|
||||
return f"{year}-{mon - 1:02d}"
|
||||
|
||||
|
||||
async def _spent_cents(
|
||||
session: AsyncSession,
|
||||
user_id: uuid.UUID,
|
||||
category_id: uuid.UUID,
|
||||
month: str,
|
||||
) -> int:
|
||||
start, end = _month_bounds(month)
|
||||
result = await session.execute(
|
||||
select(func.coalesce(func.sum(Transaction.amount_cents), 0)).where(
|
||||
Transaction.user_id == user_id,
|
||||
Transaction.deleted_at.is_(None),
|
||||
Transaction.type == TransactionType.expense,
|
||||
Transaction.category_id == category_id,
|
||||
Transaction.transaction_date >= start,
|
||||
Transaction.transaction_date < end,
|
||||
)
|
||||
)
|
||||
return result.scalar_one()
|
||||
|
||||
|
||||
async def _to_response(
|
||||
session: AsyncSession,
|
||||
user_id: uuid.UUID,
|
||||
budget: Budget,
|
||||
) -> BudgetResponse:
|
||||
spent = await _spent_cents(session, user_id, budget.category_id, budget.month)
|
||||
return BudgetResponse(
|
||||
id=budget.id,
|
||||
category_id=budget.category_id,
|
||||
category_name=budget.category.name,
|
||||
category_color=budget.category.color,
|
||||
month=budget.month,
|
||||
limit_cents=budget.limit_cents,
|
||||
spent_cents=spent,
|
||||
remaining_cents=max(0, budget.limit_cents - spent),
|
||||
created_at=budget.created_at,
|
||||
updated_at=budget.updated_at,
|
||||
)
|
||||
|
||||
|
||||
async def list_budgets(
|
||||
session: AsyncSession,
|
||||
user_id: uuid.UUID,
|
||||
month: str,
|
||||
) -> list[BudgetResponse]:
|
||||
result = await session.execute(
|
||||
select(Budget)
|
||||
.options(selectinload(Budget.category))
|
||||
.where(Budget.user_id == user_id, Budget.month == month)
|
||||
.order_by(Budget.created_at)
|
||||
)
|
||||
budgets = result.scalars().all()
|
||||
return [await _to_response(session, user_id, b) for b in budgets]
|
||||
|
||||
|
||||
async def create_budget(
|
||||
session: AsyncSession,
|
||||
user_id: uuid.UUID,
|
||||
data: BudgetCreate,
|
||||
) -> BudgetResponse:
|
||||
# Check for duplicate
|
||||
existing = await session.execute(
|
||||
select(Budget).where(
|
||||
Budget.user_id == user_id,
|
||||
Budget.category_id == data.category_id,
|
||||
Budget.month == data.month,
|
||||
)
|
||||
)
|
||||
if existing.scalar_one_or_none() is not None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="Budget already exists for this category and month",
|
||||
)
|
||||
|
||||
budget = Budget(
|
||||
user_id=user_id,
|
||||
category_id=data.category_id,
|
||||
month=data.month,
|
||||
limit_cents=data.limit_cents,
|
||||
)
|
||||
session.add(budget)
|
||||
await session.commit()
|
||||
|
||||
result = await session.execute(
|
||||
select(Budget)
|
||||
.options(selectinload(Budget.category))
|
||||
.where(Budget.id == budget.id)
|
||||
)
|
||||
budget = result.scalar_one()
|
||||
return await _to_response(session, user_id, budget)
|
||||
|
||||
|
||||
async def update_budget(
|
||||
session: AsyncSession,
|
||||
user_id: uuid.UUID,
|
||||
budget_id: uuid.UUID,
|
||||
data: BudgetUpdate,
|
||||
) -> BudgetResponse:
|
||||
result = await session.execute(
|
||||
select(Budget)
|
||||
.options(selectinload(Budget.category))
|
||||
.where(Budget.id == budget_id, Budget.user_id == user_id)
|
||||
)
|
||||
budget = result.scalar_one_or_none()
|
||||
if budget is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Budget not found")
|
||||
|
||||
budget.limit_cents = data.limit_cents
|
||||
budget.updated_at = utcnow()
|
||||
await session.commit()
|
||||
|
||||
result = await session.execute(
|
||||
select(Budget)
|
||||
.options(selectinload(Budget.category))
|
||||
.where(Budget.id == budget_id)
|
||||
)
|
||||
budget = result.scalar_one()
|
||||
return await _to_response(session, user_id, budget)
|
||||
|
||||
|
||||
async def delete_budget(
|
||||
session: AsyncSession,
|
||||
user_id: uuid.UUID,
|
||||
budget_id: uuid.UUID,
|
||||
) -> None:
|
||||
result = await session.execute(
|
||||
select(Budget).where(Budget.id == budget_id, Budget.user_id == user_id)
|
||||
)
|
||||
budget = result.scalar_one_or_none()
|
||||
if budget is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Budget not found")
|
||||
await session.delete(budget)
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def rollover_budgets(
|
||||
session: AsyncSession,
|
||||
user_id: uuid.UUID,
|
||||
target_month: str,
|
||||
) -> list[BudgetResponse]:
|
||||
"""Copy budgets from previous month to target_month (skip existing)."""
|
||||
source_month = _prev_month(target_month)
|
||||
result = await session.execute(
|
||||
select(Budget)
|
||||
.options(selectinload(Budget.category))
|
||||
.where(Budget.user_id == user_id, Budget.month == source_month)
|
||||
)
|
||||
source_budgets = result.scalars().all()
|
||||
|
||||
created = []
|
||||
for src in source_budgets:
|
||||
existing = await session.execute(
|
||||
select(Budget).where(
|
||||
Budget.user_id == user_id,
|
||||
Budget.category_id == src.category_id,
|
||||
Budget.month == target_month,
|
||||
)
|
||||
)
|
||||
if existing.scalar_one_or_none() is not None:
|
||||
continue
|
||||
new_budget = Budget(
|
||||
user_id=user_id,
|
||||
category_id=src.category_id,
|
||||
month=target_month,
|
||||
limit_cents=src.limit_cents,
|
||||
)
|
||||
session.add(new_budget)
|
||||
created.append(new_budget)
|
||||
|
||||
await session.commit()
|
||||
|
||||
responses = []
|
||||
for b in created:
|
||||
result = await session.execute(
|
||||
select(Budget)
|
||||
.options(selectinload(Budget.category))
|
||||
.where(Budget.id == b.id)
|
||||
)
|
||||
b = result.scalar_one()
|
||||
responses.append(await _to_response(session, user_id, b))
|
||||
|
||||
return responses
|
||||
@@ -0,0 +1,222 @@
|
||||
import uuid
|
||||
from datetime import date
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.models.budget import Budget
|
||||
from app.models.category import Category
|
||||
from app.models.transaction import Transaction, TransactionType
|
||||
from app.schemas.dashboard import (
|
||||
BudgetAlert,
|
||||
CategoryExpense,
|
||||
DashboardResponse,
|
||||
MonthlyTrend,
|
||||
)
|
||||
|
||||
|
||||
def _month_bounds(month: str) -> tuple[date, date]:
|
||||
year, mon = map(int, month.split("-"))
|
||||
start = date(year, mon, 1)
|
||||
if mon == 12:
|
||||
end = date(year + 1, 1, 1)
|
||||
else:
|
||||
end = date(year, mon + 1, 1)
|
||||
return start, end
|
||||
|
||||
|
||||
def _prev_month(month: str) -> str:
|
||||
year, mon = map(int, month.split("-"))
|
||||
if mon == 1:
|
||||
return f"{year - 1}-12"
|
||||
return f"{year}-{mon - 1:02d}"
|
||||
|
||||
|
||||
async def get_dashboard(
|
||||
session: AsyncSession,
|
||||
user_id: uuid.UUID,
|
||||
month: str,
|
||||
) -> DashboardResponse:
|
||||
start, end = _month_bounds(month)
|
||||
|
||||
# --- All-time balance (cumulative since origin) ---
|
||||
balance_result = await session.execute(
|
||||
select(
|
||||
func.coalesce(
|
||||
func.sum(
|
||||
func.case(
|
||||
(Transaction.type == TransactionType.income, Transaction.amount_cents),
|
||||
else_=-Transaction.amount_cents,
|
||||
)
|
||||
),
|
||||
0,
|
||||
)
|
||||
).where(
|
||||
Transaction.user_id == user_id,
|
||||
Transaction.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
balance_cents: int = balance_result.scalar_one()
|
||||
|
||||
# --- Month income & expenses ---
|
||||
month_result = await session.execute(
|
||||
select(
|
||||
func.coalesce(
|
||||
func.sum(
|
||||
func.case(
|
||||
(Transaction.type == TransactionType.income, Transaction.amount_cents),
|
||||
else_=0,
|
||||
)
|
||||
),
|
||||
0,
|
||||
).label("income"),
|
||||
func.coalesce(
|
||||
func.sum(
|
||||
func.case(
|
||||
(Transaction.type == TransactionType.expense, Transaction.amount_cents),
|
||||
else_=0,
|
||||
)
|
||||
),
|
||||
0,
|
||||
).label("expense"),
|
||||
).where(
|
||||
Transaction.user_id == user_id,
|
||||
Transaction.deleted_at.is_(None),
|
||||
Transaction.transaction_date >= start,
|
||||
Transaction.transaction_date < end,
|
||||
)
|
||||
)
|
||||
month_row = month_result.one()
|
||||
total_income_cents: int = month_row.income
|
||||
total_expense_cents: int = month_row.expense
|
||||
|
||||
# --- Expenses by category for the month ---
|
||||
cat_result = await session.execute(
|
||||
select(
|
||||
Transaction.category_id,
|
||||
func.sum(Transaction.amount_cents).label("total"),
|
||||
)
|
||||
.where(
|
||||
Transaction.user_id == user_id,
|
||||
Transaction.deleted_at.is_(None),
|
||||
Transaction.type == TransactionType.expense,
|
||||
Transaction.transaction_date >= start,
|
||||
Transaction.transaction_date < end,
|
||||
)
|
||||
.group_by(Transaction.category_id)
|
||||
)
|
||||
cat_rows = cat_result.all()
|
||||
|
||||
# Fetch category names
|
||||
category_ids = [row.category_id for row in cat_rows]
|
||||
by_category: list[CategoryExpense] = []
|
||||
if category_ids:
|
||||
cats_result = await session.execute(
|
||||
select(Category).where(Category.id.in_(category_ids))
|
||||
)
|
||||
cats_map = {c.id: c for c in cats_result.scalars().all()}
|
||||
for row in cat_rows:
|
||||
cat = cats_map.get(row.category_id)
|
||||
by_category.append(
|
||||
CategoryExpense(
|
||||
category_id=row.category_id,
|
||||
category_name=cat.name if cat else "?",
|
||||
color=cat.color if cat else None,
|
||||
amount_cents=row.total,
|
||||
)
|
||||
)
|
||||
|
||||
# --- Monthly trend: last 6 months ---
|
||||
monthly_trend: list[MonthlyTrend] = []
|
||||
cur = month
|
||||
months_to_fetch = []
|
||||
for _ in range(6):
|
||||
months_to_fetch.append(cur)
|
||||
cur = _prev_month(cur)
|
||||
months_to_fetch.reverse()
|
||||
|
||||
for m in months_to_fetch:
|
||||
m_start, m_end = _month_bounds(m)
|
||||
trend_result = await session.execute(
|
||||
select(
|
||||
func.coalesce(
|
||||
func.sum(
|
||||
func.case(
|
||||
(Transaction.type == TransactionType.income, Transaction.amount_cents),
|
||||
else_=0,
|
||||
)
|
||||
),
|
||||
0,
|
||||
).label("income"),
|
||||
func.coalesce(
|
||||
func.sum(
|
||||
func.case(
|
||||
(Transaction.type == TransactionType.expense, Transaction.amount_cents),
|
||||
else_=0,
|
||||
)
|
||||
),
|
||||
0,
|
||||
).label("expense"),
|
||||
).where(
|
||||
Transaction.user_id == user_id,
|
||||
Transaction.deleted_at.is_(None),
|
||||
Transaction.transaction_date >= m_start,
|
||||
Transaction.transaction_date < m_end,
|
||||
)
|
||||
)
|
||||
trend_row = trend_result.one()
|
||||
monthly_trend.append(
|
||||
MonthlyTrend(
|
||||
month=m,
|
||||
income_cents=trend_row.income,
|
||||
expense_cents=trend_row.expense,
|
||||
)
|
||||
)
|
||||
|
||||
# --- Budget alerts (>= 80% spent) ---
|
||||
budgets_result = await session.execute(
|
||||
select(Budget)
|
||||
.options(selectinload(Budget.category))
|
||||
.where(
|
||||
Budget.user_id == user_id,
|
||||
Budget.month == month,
|
||||
)
|
||||
)
|
||||
budgets = budgets_result.scalars().all()
|
||||
|
||||
budget_alerts: list[BudgetAlert] = []
|
||||
for budget in budgets:
|
||||
spent_result = await session.execute(
|
||||
select(func.coalesce(func.sum(Transaction.amount_cents), 0)).where(
|
||||
Transaction.user_id == user_id,
|
||||
Transaction.deleted_at.is_(None),
|
||||
Transaction.type == TransactionType.expense,
|
||||
Transaction.category_id == budget.category_id,
|
||||
Transaction.transaction_date >= start,
|
||||
Transaction.transaction_date < end,
|
||||
)
|
||||
)
|
||||
spent_cents: int = spent_result.scalar_one()
|
||||
percentage = (spent_cents / budget.limit_cents * 100) if budget.limit_cents > 0 else 0
|
||||
if percentage >= 80:
|
||||
budget_alerts.append(
|
||||
BudgetAlert(
|
||||
budget_id=budget.id,
|
||||
category_name=budget.category.name,
|
||||
limit_cents=budget.limit_cents,
|
||||
spent_cents=spent_cents,
|
||||
percentage=round(percentage, 1),
|
||||
)
|
||||
)
|
||||
|
||||
return DashboardResponse(
|
||||
month=month,
|
||||
balance_cents=balance_cents,
|
||||
total_income_cents=total_income_cents,
|
||||
total_expense_cents=total_expense_cents,
|
||||
net_cents=total_income_cents - total_expense_cents,
|
||||
by_category=by_category,
|
||||
monthly_trend=monthly_trend,
|
||||
budget_alerts=budget_alerts,
|
||||
)
|
||||
Reference in New Issue
Block a user