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

@@ -0,0 +1,45 @@
"""Add finance accounts
Revision ID: 002
Revises: 001
Create Date: 2026-03-09
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "002"
down_revision: Union[str, None] = "001"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"finance_accounts",
sa.Column("id", sa.Integer, primary_key=True),
sa.Column("name", sa.Text, unique=True, nullable=False),
sa.Column("account_type", sa.Text, nullable=False),
sa.Column(
"created_at",
sa.DateTime,
server_default=sa.func.now(),
nullable=False,
),
)
op.add_column(
"finance_transactions",
sa.Column(
"account_id",
sa.Integer,
sa.ForeignKey("finance_accounts.id", ondelete="SET NULL"),
nullable=True,
),
)
def downgrade() -> None:
op.drop_column("finance_transactions", "account_id")
op.drop_table("finance_accounts")

View File

@@ -1,12 +1,13 @@
from app.models.weight import WeightEntry
from app.models.workout import Exercise, Workout, WorkoutSet
from app.models.finance import FinanceTransaction, FinanceSnapshot
from app.models.finance import FinanceAccount, FinanceTransaction, FinanceSnapshot
__all__ = [
"WeightEntry",
"Exercise",
"Workout",
"WorkoutSet",
"FinanceAccount",
"FinanceTransaction",
"FinanceSnapshot",
]

View File

@@ -1,11 +1,24 @@
from datetime import date, datetime
from sqlalchemy import Date, Numeric, Text, func
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy import Date, ForeignKey, Numeric, Text, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class FinanceAccount(Base):
__tablename__ = "finance_accounts"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(Text, unique=True)
account_type: Mapped[str] = mapped_column(Text)
created_at: Mapped[datetime] = mapped_column(server_default=func.now())
transactions: Mapped[list["FinanceTransaction"]] = relationship(
back_populates="account"
)
class FinanceTransaction(Base):
__tablename__ = "finance_transactions"
@@ -14,8 +27,15 @@ class FinanceTransaction(Base):
amount: Mapped[float] = mapped_column(Numeric(12, 2))
category: Mapped[str] = mapped_column(Text)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
account_id: Mapped[int | None] = mapped_column(
ForeignKey("finance_accounts.id", ondelete="SET NULL"), nullable=True
)
created_at: Mapped[datetime] = mapped_column(server_default=func.now())
account: Mapped["FinanceAccount | None"] = relationship(
back_populates="transactions"
)
class FinanceSnapshot(Base):
__tablename__ = "finance_snapshots"

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
]

View File

View File

@@ -0,0 +1,69 @@
import csv
import io
from datetime import datetime
def parse_chase_csv(content: str, account_type: str) -> tuple[list[dict], list[str]]:
"""Parse a Chase CSV file into transaction dicts.
Returns (rows, errors) where rows is a list of dicts with keys:
date, amount, category, description.
"""
content = content.lstrip("\ufeff")
reader = csv.DictReader(io.StringIO(content))
if not reader.fieldnames:
return [], ["Empty CSV file"]
first_field = reader.fieldnames[0].strip()
if first_field == "Details":
return _parse_checking(reader)
elif first_field == "Transaction Date":
return _parse_credit_card(reader)
else:
return [], [f"Unrecognized CSV format (first column: '{first_field}')"]
def _parse_checking(reader: csv.DictReader) -> tuple[list[dict], list[str]]:
"""Chase checking/savings: Details,Posting Date,Description,Amount,Type,Balance,Check or Slip #"""
rows = []
errors = []
for i, row in enumerate(reader, start=2):
try:
date_str = row.get("Posting Date", "").strip()
description = row.get("Description", "").strip()
amount_str = row.get("Amount", "").strip()
if not date_str or not amount_str:
continue
rows.append({
"date": datetime.strptime(date_str, "%m/%d/%Y").date(),
"amount": float(amount_str),
"category": "Uncategorized",
"description": description,
})
except (ValueError, KeyError) as e:
errors.append(f"Row {i}: {e}")
return rows, errors
def _parse_credit_card(reader: csv.DictReader) -> tuple[list[dict], list[str]]:
"""Chase credit card: Transaction Date,Post Date,Description,Category,Type,Amount,Memo"""
rows = []
errors = []
for i, row in enumerate(reader, start=2):
try:
date_str = row.get("Transaction Date", "").strip()
description = row.get("Description", "").strip()
amount_str = row.get("Amount", "").strip()
category = row.get("Category", "").strip() or "Uncategorized"
if not date_str or not amount_str:
continue
rows.append({
"date": datetime.strptime(date_str, "%m/%d/%Y").date(),
"amount": float(amount_str),
"category": category,
"description": description,
})
except (ValueError, KeyError) as e:
errors.append(f"Row {i}: {e}")
return rows, errors