Add chase accounts + chase csv upload

This commit is contained in:
YOUNG
2026-03-09 16:05:38 -05:00
parent ab85502415
commit 4802d9a6d3
9 changed files with 567 additions and 18 deletions

View File

@@ -1,13 +1,14 @@
from datetime import date
from fastapi import APIRouter, Depends, HTTPException, Query
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 FinanceSnapshot, FinanceTransaction
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)]
@@ -17,11 +18,22 @@ router = APIRouter(
# --- 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):
@@ -45,6 +57,73 @@ class CategorySummary(BaseModel):
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 ---
@@ -53,6 +132,7 @@ 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())
@@ -62,6 +142,8 @@ def list_transactions(
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()
@@ -116,11 +198,40 @@ def create_snapshot(body: SnapshotCreate, db: Session = Depends(get_db)):
return snap
# --- Summary endpoint ---
# --- 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),
):
@@ -132,12 +243,21 @@ def spending_summary(
rows = db.execute(
select(
FinanceTransaction.category,
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.category)
.group_by(FinanceTransaction.account_id, FinanceAccount.name)
.order_by(func.sum(FinanceTransaction.amount))
).all()
return [CategorySummary(category=r.category, total=float(r.total)) for r in rows]
return [
AccountSpendingSummary(
account_id=r.account_id,
account_name=r.account_name or "Untagged",
total=float(r.total),
)
for r in rows
]