from datetime import date from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile from pydantic import BaseModel from sqlalchemy import func, select from sqlalchemy.orm import Session from app.auth import get_current_user from app.database import get_db from app.models.finance import FinanceAccount, FinanceSnapshot, FinanceTransaction from app.services.chase_csv import parse_chase_csv router = APIRouter( prefix="/api/finances", tags=["finances"], dependencies=[Depends(get_current_user)] ) # --- Schemas --- class AccountCreate(BaseModel): name: str account_type: str class AccountRead(AccountCreate): id: int model_config = {"from_attributes": True} class TransactionCreate(BaseModel): date: date amount: float category: str description: str | None = None account_id: int | None = None class TransactionRead(TransactionCreate): id: int model_config = {"from_attributes": True} class SnapshotCreate(BaseModel): date: date net_worth: float notes: str | None = None class SnapshotRead(SnapshotCreate): id: int model_config = {"from_attributes": True} class CategorySummary(BaseModel): category: str total: float class AccountSpendingSummary(BaseModel): account_id: int | None account_name: str | None total: float class CSVUploadResult(BaseModel): imported: int skipped: int errors: list[str] # --- Account endpoints --- @router.get("/accounts", response_model=list[AccountRead]) def list_accounts(db: Session = Depends(get_db)): return db.scalars(select(FinanceAccount).order_by(FinanceAccount.name)).all() @router.post("/accounts", response_model=AccountRead, status_code=201) def create_account(body: AccountCreate, db: Session = Depends(get_db)): acct = FinanceAccount(**body.model_dump()) db.add(acct) db.commit() db.refresh(acct) return acct @router.delete("/accounts/{account_id}", status_code=204) def delete_account(account_id: int, db: Session = Depends(get_db)): acct = db.get(FinanceAccount, account_id) if not acct: raise HTTPException(status_code=404, detail="Account not found") db.delete(acct) db.commit() @router.post("/accounts/{account_id}/upload-csv", response_model=CSVUploadResult) async def upload_csv( account_id: int, file: UploadFile, db: Session = Depends(get_db), ): acct = db.get(FinanceAccount, account_id) if not acct: raise HTTPException(status_code=404, detail="Account not found") content = (await file.read()).decode("utf-8") rows, errors = parse_chase_csv(content, acct.account_type) transactions = [ FinanceTransaction( date=r["date"], amount=r["amount"], category=r["category"], description=r["description"], account_id=account_id, ) for r in rows ] db.add_all(transactions) db.commit() return CSVUploadResult(imported=len(transactions), skipped=0, errors=errors) # --- Transaction endpoints --- @router.get("/transactions", response_model=list[TransactionRead]) def list_transactions( from_date: date | None = Query(None, alias="from"), to_date: date | None = Query(None, alias="to"), category: str | None = None, account_id: int | None = Query(None), db: Session = Depends(get_db), ): q = select(FinanceTransaction).order_by(FinanceTransaction.date.desc()) if from_date: q = q.where(FinanceTransaction.date >= from_date) if to_date: q = q.where(FinanceTransaction.date <= to_date) if category: q = q.where(FinanceTransaction.category == category) if account_id is not None: q = q.where(FinanceTransaction.account_id == account_id) return db.scalars(q).all() @router.post("/transactions", response_model=TransactionRead, status_code=201) def create_transaction(body: TransactionCreate, db: Session = Depends(get_db)): txn = FinanceTransaction(**body.model_dump()) db.add(txn) db.commit() db.refresh(txn) return txn @router.put("/transactions/{txn_id}", response_model=TransactionRead) def update_transaction( txn_id: int, body: TransactionCreate, db: Session = Depends(get_db) ): txn = db.get(FinanceTransaction, txn_id) if not txn: raise HTTPException(status_code=404, detail="Transaction not found") for key, val in body.model_dump().items(): setattr(txn, key, val) db.commit() db.refresh(txn) return txn @router.delete("/transactions/{txn_id}", status_code=204) def delete_transaction(txn_id: int, db: Session = Depends(get_db)): txn = db.get(FinanceTransaction, txn_id) if not txn: raise HTTPException(status_code=404, detail="Transaction not found") db.delete(txn) db.commit() # --- Snapshot endpoints --- @router.get("/snapshots", response_model=list[SnapshotRead]) def list_snapshots(db: Session = Depends(get_db)): return db.scalars( select(FinanceSnapshot).order_by(FinanceSnapshot.date.desc()) ).all() @router.post("/snapshots", response_model=SnapshotRead, status_code=201) def create_snapshot(body: SnapshotCreate, db: Session = Depends(get_db)): snap = FinanceSnapshot(**body.model_dump()) db.add(snap) db.commit() db.refresh(snap) return snap # --- Summary endpoints --- @router.get("/summary", response_model=list[CategorySummary]) def spending_summary( period: str = Query("month", pattern="^(month|year)$"), account_id: int | None = Query(None), db: Session = Depends(get_db), ): today = date.today() if period == "month": start = today.replace(day=1) else: start = today.replace(month=1, day=1) q = ( select( FinanceTransaction.category, func.sum(FinanceTransaction.amount).label("total"), ) .where(FinanceTransaction.date >= start) .where(FinanceTransaction.amount < 0) .group_by(FinanceTransaction.category) .order_by(func.sum(FinanceTransaction.amount)) ) if account_id is not None: q = q.where(FinanceTransaction.account_id == account_id) rows = db.execute(q).all() return [CategorySummary(category=r.category, total=float(r.total)) for r in rows] @router.get("/summary/by-account", response_model=list[AccountSpendingSummary]) def spending_by_account( period: str = Query("month", pattern="^(month|year)$"), db: Session = Depends(get_db), ): today = date.today() if period == "month": start = today.replace(day=1) else: start = today.replace(month=1, day=1) rows = db.execute( select( FinanceTransaction.account_id, FinanceAccount.name.label("account_name"), func.sum(FinanceTransaction.amount).label("total"), ) .outerjoin(FinanceAccount, FinanceTransaction.account_id == FinanceAccount.id) .where(FinanceTransaction.date >= start) .where(FinanceTransaction.amount < 0) .group_by(FinanceTransaction.account_id, FinanceAccount.name) .order_by(func.sum(FinanceTransaction.amount)) ).all() return [ AccountSpendingSummary( account_id=r.account_id, account_name=r.account_name or "Untagged", total=float(r.total), ) for r in rows ]