Add chase accounts + chase csv upload
This commit is contained in:
45
backend/alembic/versions/002_add_finance_accounts.py
Normal file
45
backend/alembic/versions/002_add_finance_accounts.py
Normal 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")
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
]
|
||||
|
||||
0
backend/app/services/__init__.py
Normal file
0
backend/app/services/__init__.py
Normal file
69
backend/app/services/chase_csv.py
Normal file
69
backend/app/services/chase_csv.py
Normal 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
|
||||
Reference in New Issue
Block a user