diff --git a/backend/alembic/versions/002_add_finance_accounts.py b/backend/alembic/versions/002_add_finance_accounts.py new file mode 100644 index 0000000..3da1c8c --- /dev/null +++ b/backend/alembic/versions/002_add_finance_accounts.py @@ -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") diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 5b0dd85..123e896 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -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", ] diff --git a/backend/app/models/finance.py b/backend/app/models/finance.py index 30c292c..e3ec08a 100644 --- a/backend/app/models/finance.py +++ b/backend/app/models/finance.py @@ -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" diff --git a/backend/app/routers/finance.py b/backend/app/routers/finance.py index 13dcfed..211e797 100644 --- a/backend/app/routers/finance.py +++ b/backend/app/routers/finance.py @@ -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 + ] diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/services/chase_csv.py b/backend/app/services/chase_csv.py new file mode 100644 index 0000000..9906429 --- /dev/null +++ b/backend/app/services/chase_csv.py @@ -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 diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index a2ac5c4..658b330 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -53,4 +53,26 @@ export const api = { put: (path: string, data: unknown) => request(path, { method: "PUT", body: JSON.stringify(data) }), delete: (path: string) => request(path, { method: "DELETE" }), + upload: async (path: string, file: File): Promise => { + const formData = new FormData(); + formData.append("file", file); + const token = getToken(); + const headers: Record = {}; + if (token) headers["Authorization"] = `Bearer ${token}`; + const res = await fetch(`${API_BASE}${path}`, { + method: "POST", + headers, + body: formData, + }); + if (res.status === 401) { + clearToken(); + window.location.href = "/login"; + throw new Error("Unauthorized"); + } + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.detail || `Request failed: ${res.status}`); + } + return res.json(); + }, }; diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index b980450..5750d80 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -29,12 +29,31 @@ export interface Workout { sets: WorkoutSet[]; } +export interface FinanceAccount { + id: number; + name: string; + account_type: string; +} + export interface Transaction { id: number; date: string; amount: number; category: string; description: string | null; + account_id: number | null; +} + +export interface CSVUploadResult { + imported: number; + skipped: number; + errors: string[]; +} + +export interface AccountSpendingSummary { + account_id: number | null; + account_name: string | null; + total: number; } export interface Snapshot { diff --git a/frontend/src/pages/Finances.tsx b/frontend/src/pages/Finances.tsx index 72de99c..c764856 100644 --- a/frontend/src/pages/Finances.tsx +++ b/frontend/src/pages/Finances.tsx @@ -10,10 +10,19 @@ import { XAxis, YAxis, CartesianGrid, + BarChart, + Bar, } from "recharts"; -import { Trash2 } from "lucide-react"; +import { Trash2, Upload } from "lucide-react"; import { api } from "@/api/client"; -import type { Transaction, Snapshot, CategorySummary } from "@/api/types"; +import type { + Transaction, + Snapshot, + CategorySummary, + FinanceAccount, + CSVUploadResult, + AccountSpendingSummary, +} from "@/api/types"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -34,28 +43,47 @@ export default function Finances() { const [transactions, setTransactions] = useState([]); const [snapshots, setSnapshots] = useState([]); const [summary, setSummary] = useState([]); - const [tab, setTab] = useState<"transactions" | "networth">("transactions"); + const [accounts, setAccounts] = useState([]); + const [accountSummary, setAccountSummary] = useState([]); + const [tab, setTab] = useState<"transactions" | "networth" | "accounts">("transactions"); + const [filterAccountId, setFilterAccountId] = useState(""); // Transaction form const [tDate, setTDate] = useState(today()); const [tAmount, setTAmount] = useState(""); const [tCategory, setTCategory] = useState(""); const [tDesc, setTDesc] = useState(""); + const [tAccountId, setTAccountId] = useState(""); // Snapshot form const [sDate, setSDate] = useState(today()); const [sNetWorth, setSNetWorth] = useState(""); const [sNotes, setSNotes] = useState(""); + // Account form + const [acctName, setAcctName] = useState(""); + const [acctType, setAcctType] = useState("checking"); + + // CSV upload + const [uploadAccountId, setUploadAccountId] = useState(null); + const [csvFile, setCsvFile] = useState(null); + const [uploadResult, setUploadResult] = useState(null); + const [uploading, setUploading] = useState(false); + const load = () => { - api.get("/finances/transactions").then(setTransactions); + const acctFilter = filterAccountId ? `&account_id=${filterAccountId}` : ""; + api.get(`/finances/transactions?${acctFilter}`).then(setTransactions); api.get("/finances/snapshots").then(setSnapshots); - api.get("/finances/summary?period=month").then(setSummary); + api.get(`/finances/summary?period=month${acctFilter}`).then(setSummary); + api.get("/finances/accounts").then(setAccounts); + api.get("/finances/summary/by-account?period=month").then(setAccountSummary); }; useEffect(() => { load(); - }, []); + }, [filterAccountId]); + + const accountMap = Object.fromEntries(accounts.map((a) => [a.id, a.name])); const handleAddTransaction = async (e: React.FormEvent) => { e.preventDefault(); @@ -64,6 +92,7 @@ export default function Finances() { amount: parseFloat(tAmount), category: tCategory, description: tDesc || null, + account_id: tAccountId ? parseInt(tAccountId) : null, }); setTAmount(""); setTCategory(""); @@ -88,11 +117,48 @@ export default function Finances() { load(); }; + const handleAddAccount = async (e: React.FormEvent) => { + e.preventDefault(); + await api.post("/finances/accounts", { name: acctName, account_type: acctType }); + setAcctName(""); + setAcctType("checking"); + load(); + }; + + const handleDeleteAccount = async (id: number) => { + await api.delete(`/finances/accounts/${id}`); + load(); + }; + + const handleUploadCSV = async () => { + if (!uploadAccountId || !csvFile) return; + setUploading(true); + setUploadResult(null); + try { + const result = await api.upload( + `/finances/accounts/${uploadAccountId}/upload-csv`, + csvFile + ); + setUploadResult(result); + setCsvFile(null); + load(); + } catch (err: any) { + setUploadResult({ imported: 0, skipped: 0, errors: [err.message] }); + } finally { + setUploading(false); + } + }; + const pieData = summary.map((s) => ({ name: s.category, value: Math.abs(s.total), })); + const acctBarData = accountSummary.map((s) => ({ + name: s.account_name || "Untagged", + total: Math.abs(s.total), + })); + const nwData = [...snapshots].reverse(); return ( @@ -115,6 +181,13 @@ export default function Finances() { > Net Worth + {tab === "transactions" && ( @@ -143,12 +216,25 @@ export default function Finances() { setTDesc(e.target.value)} /> +
+ + +
- {/* Category pie chart */} + {/* Charts */} Monthly Spending by Category @@ -179,10 +265,42 @@ export default function Finances() { - {/* Transactions table */} + {/* Per-account spending bar chart */} + {acctBarData.length > 0 && ( + + + Monthly Spending by Account + + + + + + + + `$${v.toFixed(2)}`} /> + + + + + + )} + + {/* Account filter + Transactions table */} - Recent Transactions +
+ Recent Transactions + +
@@ -193,6 +311,7 @@ export default function Finances() { Amount Category Description + Account @@ -205,6 +324,9 @@ export default function Finances() { {t.category} {t.description || "—"} + + {t.account_id ? accountMap[t.account_id] || "—" : "—"} +
)} + + {tab === "accounts" && ( +
+
+ {/* Create account form */} + + + Add Account + + +
+
+ + setAcctName(e.target.value)} + placeholder="e.g. Chase Checking" + required + /> +
+
+ + +
+ +
+
+
+ + {/* Accounts list */} + + + Your Accounts + + + {accounts.length > 0 ? ( + + + + + + + + + + {accounts.map((a) => ( + + + + + + ))} + +
NameType
{a.name}{a.account_type} + + +
+ ) : ( +

No accounts yet. Create one to start importing CSVs.

+ )} +
+
+
+ + {/* CSV Upload section */} + {uploadAccountId && ( + + + + Upload CSV to {accountMap[uploadAccountId] || "Account"} + + + +
+ + setCsvFile(e.target.files?.[0] || null)} + /> +
+
+ + +
+ {uploadResult && ( +
+

+ Imported {uploadResult.imported} transactions +

+ {uploadResult.skipped > 0 && ( +

Skipped: {uploadResult.skipped}

+ )} + {uploadResult.errors.length > 0 && ( +
+ {uploadResult.errors.map((err, i) => ( +

{err}

+ ))} +
+ )} +
+ )} +
+
+ )} +
+ )} ); }