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

View File

@@ -53,4 +53,26 @@ export const api = {
put: <T>(path: string, data: unknown) =>
request<T>(path, { method: "PUT", body: JSON.stringify(data) }),
delete: <T>(path: string) => request<T>(path, { method: "DELETE" }),
upload: async <T>(path: string, file: File): Promise<T> => {
const formData = new FormData();
formData.append("file", file);
const token = getToken();
const headers: Record<string, string> = {};
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();
},
};

View File

@@ -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 {

View File

@@ -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<Transaction[]>([]);
const [snapshots, setSnapshots] = useState<Snapshot[]>([]);
const [summary, setSummary] = useState<CategorySummary[]>([]);
const [tab, setTab] = useState<"transactions" | "networth">("transactions");
const [accounts, setAccounts] = useState<FinanceAccount[]>([]);
const [accountSummary, setAccountSummary] = useState<AccountSpendingSummary[]>([]);
const [tab, setTab] = useState<"transactions" | "networth" | "accounts">("transactions");
const [filterAccountId, setFilterAccountId] = useState<string>("");
// Transaction form
const [tDate, setTDate] = useState(today());
const [tAmount, setTAmount] = useState("");
const [tCategory, setTCategory] = useState("");
const [tDesc, setTDesc] = useState("");
const [tAccountId, setTAccountId] = useState<string>("");
// 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<number | null>(null);
const [csvFile, setCsvFile] = useState<File | null>(null);
const [uploadResult, setUploadResult] = useState<CSVUploadResult | null>(null);
const [uploading, setUploading] = useState(false);
const load = () => {
api.get<Transaction[]>("/finances/transactions").then(setTransactions);
const acctFilter = filterAccountId ? `&account_id=${filterAccountId}` : "";
api.get<Transaction[]>(`/finances/transactions?${acctFilter}`).then(setTransactions);
api.get<Snapshot[]>("/finances/snapshots").then(setSnapshots);
api.get<CategorySummary[]>("/finances/summary?period=month").then(setSummary);
api.get<CategorySummary[]>(`/finances/summary?period=month${acctFilter}`).then(setSummary);
api.get<FinanceAccount[]>("/finances/accounts").then(setAccounts);
api.get<AccountSpendingSummary[]>("/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<CSVUploadResult>(
`/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
</Button>
<Button
variant={tab === "accounts" ? "default" : "outline"}
size="sm"
onClick={() => setTab("accounts")}
>
Accounts
</Button>
</div>
{tab === "transactions" && (
@@ -143,12 +216,25 @@ export default function Finances() {
<Label>Description</Label>
<Input value={tDesc} onChange={(e) => setTDesc(e.target.value)} />
</div>
<div className="space-y-2">
<Label>Account</Label>
<select
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm"
value={tAccountId}
onChange={(e) => setTAccountId(e.target.value)}
>
<option value="">No Account</option>
{accounts.map((a) => (
<option key={a.id} value={a.id}>{a.name}</option>
))}
</select>
</div>
<Button type="submit" className="w-full">Save</Button>
</form>
</CardContent>
</Card>
{/* Category pie chart */}
{/* Charts */}
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle>Monthly Spending by Category</CardTitle>
@@ -179,10 +265,42 @@ export default function Finances() {
</Card>
</div>
{/* Transactions table */}
{/* Per-account spending bar chart */}
{acctBarData.length > 0 && (
<Card>
<CardHeader>
<CardTitle>Monthly Spending by Account</CardTitle>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={300}>
<BarChart data={acctBarData}>
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
<XAxis dataKey="name" className="text-xs" />
<YAxis className="text-xs" />
<Tooltip formatter={(v: number) => `$${v.toFixed(2)}`} />
<Bar dataKey="total" fill="hsl(220 90% 56%)" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
)}
{/* Account filter + Transactions table */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>Recent Transactions</CardTitle>
<select
className="h-8 rounded-md border border-input bg-transparent px-2 text-sm"
value={filterAccountId}
onChange={(e) => setFilterAccountId(e.target.value)}
>
<option value="">All Accounts</option>
{accounts.map((a) => (
<option key={a.id} value={a.id}>{a.name}</option>
))}
</select>
</div>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
@@ -193,6 +311,7 @@ export default function Finances() {
<th className="text-left py-2 px-2">Amount</th>
<th className="text-left py-2 px-2">Category</th>
<th className="text-left py-2 px-2">Description</th>
<th className="text-left py-2 px-2">Account</th>
<th className="py-2 px-2"></th>
</tr>
</thead>
@@ -205,6 +324,9 @@ export default function Finances() {
</td>
<td className="py-2 px-2">{t.category}</td>
<td className="py-2 px-2 text-muted-foreground">{t.description || "—"}</td>
<td className="py-2 px-2 text-muted-foreground">
{t.account_id ? accountMap[t.account_id] || "—" : "—"}
</td>
<td className="py-2 px-2">
<Button variant="ghost" size="icon" onClick={() => handleDeleteTransaction(t.id)}>
<Trash2 className="h-4 w-4" />
@@ -269,6 +391,137 @@ export default function Finances() {
</Card>
</div>
)}
{tab === "accounts" && (
<div className="space-y-6">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Create account form */}
<Card>
<CardHeader>
<CardTitle>Add Account</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleAddAccount} className="space-y-4">
<div className="space-y-2">
<Label>Account Name</Label>
<Input
value={acctName}
onChange={(e) => setAcctName(e.target.value)}
placeholder="e.g. Chase Checking"
required
/>
</div>
<div className="space-y-2">
<Label>Account Type</Label>
<select
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm"
value={acctType}
onChange={(e) => setAcctType(e.target.value)}
>
<option value="checking">Checking</option>
<option value="savings">Savings</option>
<option value="credit_card">Credit Card</option>
</select>
</div>
<Button type="submit" className="w-full">Add Account</Button>
</form>
</CardContent>
</Card>
{/* Accounts list */}
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle>Your Accounts</CardTitle>
</CardHeader>
<CardContent>
{accounts.length > 0 ? (
<table className="w-full text-sm">
<thead>
<tr className="border-b">
<th className="text-left py-2 px-2">Name</th>
<th className="text-left py-2 px-2">Type</th>
<th className="py-2 px-2"></th>
</tr>
</thead>
<tbody>
{accounts.map((a) => (
<tr key={a.id} className="border-b">
<td className="py-2 px-2 font-medium">{a.name}</td>
<td className="py-2 px-2 text-muted-foreground">{a.account_type}</td>
<td className="py-2 px-2 flex gap-1 justify-end">
<Button
variant="outline"
size="sm"
onClick={() => {
setUploadAccountId(a.id);
setUploadResult(null);
setCsvFile(null);
}}
>
<Upload className="h-3 w-3 mr-1" /> CSV
</Button>
<Button variant="ghost" size="icon" onClick={() => handleDeleteAccount(a.id)}>
<Trash2 className="h-4 w-4" />
</Button>
</td>
</tr>
))}
</tbody>
</table>
) : (
<p className="text-muted-foreground text-sm">No accounts yet. Create one to start importing CSVs.</p>
)}
</CardContent>
</Card>
</div>
{/* CSV Upload section */}
{uploadAccountId && (
<Card>
<CardHeader>
<CardTitle>
Upload CSV to {accountMap[uploadAccountId] || "Account"}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label>Chase CSV File</Label>
<Input
type="file"
accept=".csv"
onChange={(e) => setCsvFile(e.target.files?.[0] || null)}
/>
</div>
<div className="flex gap-2">
<Button onClick={handleUploadCSV} disabled={!csvFile || uploading}>
{uploading ? "Uploading..." : "Upload & Import"}
</Button>
<Button variant="ghost" onClick={() => setUploadAccountId(null)}>
Cancel
</Button>
</div>
{uploadResult && (
<div className="rounded-md border p-3 text-sm space-y-1">
<p className="font-medium">
Imported {uploadResult.imported} transactions
</p>
{uploadResult.skipped > 0 && (
<p className="text-muted-foreground">Skipped: {uploadResult.skipped}</p>
)}
{uploadResult.errors.length > 0 && (
<div className="text-red-500">
{uploadResult.errors.map((err, i) => (
<p key={i}>{err}</p>
))}
</div>
)}
</div>
)}
</CardContent>
</Card>
)}
</div>
)}
</div>
);
}