14 KiB
14 KiB
Finance App — Claude Context
Personal finance web app for tracking bank transactions, monitoring net worth, and visualizing spending. Single-user, self-hosted via Docker Compose.
Tech Stack
| Layer | Choice |
|---|---|
| Framework | Next.js 16+ App Router + TypeScript |
| Database | PostgreSQL + Prisma ORM |
| Auth | NextAuth.js v5 (Credentials provider, bcrypt, JWT sessions) |
| UI | Tailwind CSS + shadcn/ui |
| Charts | Recharts |
| CSV Parsing | Papa Parse (server-side only — never client-side) |
| Validation | Zod on every API route before any DB access |
| Deployment | Docker Compose (postgres:16-alpine + Next.js app) |
Build Status
Phases 1–6 complete.
Implementation Phases
| # | Phase | Status |
|---|---|---|
| 1 | Scaffold: create-next-app, shadcn, Prisma schema, Docker, .env | Done |
| 2 | Auth: NextAuth, middleware, login page, seed script | Done |
| 3 | Accounts CRUD: API routes + AccountList UI | Done |
| 4 | CSV Upload: bank profiles, parser, normalizer, upload API + UI | Done |
| 5 | Transaction browsing: paginated API, filters, edit dialog | Done |
| 6 | Budgets: CRUD API, UI, assign transactions to budgets | Done |
| 7 | Dashboard: aggregate API, NetWorthCard, CashFlowCard, BudgetSummary | Not started |
| 8 | Graphs: Recharts components, BalanceSnapshot read path | Not started |
| 9 | Security hardening: HTTP headers, rate limiting, Origin check | Not started |
| 10 | Docker polish: multi-stage Dockerfile, health checks, README | Not started |
Prisma 7 Notes
- Generator provider is
prisma-client(notprisma-client-js) - Client output:
src/generated/prisma/— import as@/generated/prisma/client - Requires Driver Adapter — no URL in constructor. Use
@prisma/adapter-pgwithpg:import { Pool } from 'pg' import { PrismaPg } from '@prisma/adapter-pg' const pool = new Pool({ connectionString: process.env.DATABASE_URL }) const adapter = new PrismaPg(pool) new PrismaClient({ adapter }) - Datasource URL configured in
prisma.config.ts(for CLI) and viaPoolat runtime - Singleton:
src/lib/prisma.ts - Seed:
npx prisma db seed(runstsx prisma/seed.ts)
Database Schema
Store all money as integer cents (never floats). 100.10 → 10010.
// prisma/schema.prisma
generator client {
provider = "prisma-client"
output = "../src/generated/prisma"
}
datasource db {
provider = "postgresql"
}
model User {
id String @id @default(cuid())
email String @unique
passwordHash String // bcrypt hash, cost factor 12
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
accounts Account[]
budgets Budget[]
}
enum AccountType {
BANK // Checking, savings, investment — counts toward net worth
CREDIT_CARD // Tracking only — never counted in net worth or cash flow
}
model Account {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
name String
institution String?
type AccountType
currency String @default("USD")
currentBalanceCents Int @default(0)
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
transactions Transaction[]
uploads CsvUpload[]
@@index([userId])
}
enum TransactionType {
DEBIT // Money out (spending, withdrawals, purchases)
CREDIT // Money in (deposits, payments received, refunds)
}
model Transaction {
id String @id @default(cuid())
accountId String
account Account @relation(fields: [accountId], references: [id], onDelete: Cascade)
uploadId String?
upload CsvUpload? @relation(fields: [uploadId], references: [id])
budgetId String?
budget Budget? @relation(fields: [budgetId], references: [id], onDelete: SetNull)
date DateTime
description String
amountCents Int // Always positive; direction from `type` field
type TransactionType
category String?
notes String?
dedupeHash String // SHA-256(accountId|date|description|amountCents) — prevents re-upload duplicates
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([dedupeHash])
@@index([accountId, date])
@@index([date])
@@index([budgetId])
}
// User-defined spending groups, primarily for CC transactions
model Budget {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
name String // e.g. "Groceries", "Entertainment"
limitCents Int? // Optional monthly cap
color String? // Hex color for UI, e.g. "#6366f1"
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
transactions Transaction[]
@@index([userId])
}
model CsvUpload {
id String @id @default(cuid())
accountId String
account Account @relation(fields: [accountId], references: [id])
fileName String
rowCount Int
importedCount Int
skippedCount Int
status String // PENDING | SUCCESS | PARTIAL | FAILED
errorMessage String?
uploadedAt DateTime @default(now())
transactions Transaction[]
@@index([accountId])
}
// Monthly balance snapshots for net worth trend graphs.
// No FK to Account intentionally — snapshots survive account deletion.
model BalanceSnapshot {
id String @id @default(cuid())
accountId String
year Int
month Int // 1–12
balanceCents Int
computedAt DateTime @default(now())
@@unique([accountId, year, month])
@@index([year, month])
}
Key schema decisions
currentBalanceCentsis always recomputed by summing all transactions after each upload — never incremented by delta — to handle out-of-order historical importsdedupeHashunique constraint letscreateMany({ skipDuplicates: true })silently handle re-uploadsbudgetIdusesonDelete: SetNull— deleting a budget unlinks its transactions, never deletes themBalanceSnapshothas no FK so historical net worth graphs survive account deletion
CSV Bank Profiles
All 4 user banks with exact column mappings. Implement in src/lib/csv/bank-profiles.ts.
Discover High Yield Savings → BANK
- Strategy: B (split debit/credit columns)
date←Transaction Datedescription←Transaction DescriptiondebitAmount←DebitcreditAmount←Creditbalance←Balance(reference only)- Ignore:
Transaction Type
Discover Credit Card → CREDIT_CARD
- Strategy: A (single signed column)
date←Trans. Datedescription←Descriptionamount←Amount— positive = DEBIT (charge), negative = CREDIT (payment/refund)- Ignore:
Post Date,Category
Huntington Checking → BANK
- Strategy: A (single signed column)
date←Datedescription←Descriptionamount←Amount— negative = DEBIT, positive = CREDIT- Ignore:
Category,Split,Tags
Fidelity → BANK (investment — cash activity only)
- Strategy: A (single signed column)
date←Run Datedescription←Descriptionamount←Amount($)— negative = DEBIT (purchase), positive = CREDIT (dividend/sale)balance←Cash Balance ($)(reference only)- Ignore:
Action,Symbol,Type,Price($),Quantity,Commission ($),Fees ($),Accrued Interest ($),Settlement Date - Known limitation: tracks cash activity only, not total portfolio value (securities not included)
Amount parsing strategies
// Strategy A — single signed column
// Sign convention varies per bank; each profile has invertAmountSign: boolean
function parseStrategyA(raw: string, invert: boolean): { amountCents: number; type: TransactionType }
// Strategy B — separate debit/credit columns (Discover Savings)
// The non-empty/non-zero column determines type and amount
function parseStrategyB(debitRaw: string, creditRaw: string): { amountCents: number; type: TransactionType }
// All amounts via:
function parseCents(raw: string): number {
return Math.round(parseFloat(raw.replace(/[$,\s]/g, '')) * 100)
}
Upload flow
- Server parses header row → attempts profile auto-detection by matching column names
- Match found: auto-proceeds; client shows "Detected: [Bank Name]"
- No match: returns
{ requiresMapping: true, headers, sampleRows }→ client rendersColumnMapperfor manual column assignment - After mapping resolved: normalize all rows → compute
dedupeHash→createMany({ skipDuplicates: true })→ recomputecurrentBalanceCents(full sum) → write/updateBalanceSnapshotfor each affected month
Key Features
Account types
- BANK (Discover Savings, Huntington Checking, Fidelity): counted in net worth and cash flow
- CREDIT_CARD (Discover CC): tracked only — never in net worth or cash flow calculations
Budgets (for CC transactions primarily)
- User creates named budgets with optional monthly spending caps and a hex color
- Any transaction can be assigned to a budget via
budgetId BudgetCardshows: name, current-month DEBIT total, limit, progress bar (green <75% / yellow 75-100% / red >100%)- Dashboard has a
BudgetSummarysection with all active budgets - Deleting a budget sets
budgetId = nullon all its transactions (transactions not deleted)
Net worth calculation
-- Always filter type = 'BANK'; credit card accounts never appear here
SELECT SUM(currentBalanceCents) FROM Account WHERE userId = ? AND type = 'BANK' AND isActive = true
Security Requirements
- All API routes: validate with Zod before any DB access; scope every Prisma query by
userId - Auth: bcrypt cost 12; JWT sessions 1-hour expiry;
middleware.tsprotects all/(app)routes and/api/*except/api/auth - CSV upload: max 10 MB; check MIME type + file extension server-side; never write file to disk; parse in memory then discard
- Raw SQL (aggregation queries only): use
Prisma.sqltagged templates — never string concatenation - HTTP security headers in
next.config.ts:Content-Security-Policy,X-Frame-Options: DENY,X-Content-Type-Options: nosniff,Referrer-Policy: strict-origin-when-cross-origin - Docker: Next.js runs as non-root user (
USER node); PostgreSQL binds to127.0.0.1only
.env Variables Required
# Database
DATABASE_URL="postgresql://financeapp:password@localhost:5432/financeapp"
# NextAuth
NEXTAUTH_SECRET="<generate with: openssl rand -base64 32>"
NEXTAUTH_URL="http://localhost:3000"
# Seed user (used by prisma/seed.ts only)
SEED_EMAIL="your@email.com"
SEED_PASSWORD="your-secure-password"
# Postgres (used by docker-compose.yml)
POSTGRES_USER="financeapp"
POSTGRES_PASSWORD="password"
POSTGRES_DB="financeapp"
Project File Structure (target)
finance-app/
├── .env # Never committed
├── .env.example
├── .gitignore
├── docker-compose.yml
├── Dockerfile
├── next.config.ts
├── tailwind.config.ts
├── components.json # shadcn/ui config
├── prisma/
│ ├── schema.prisma
│ ├── seed.ts
│ └── migrations/
└── src/
├── middleware.ts
├── app/
│ ├── (auth)/login/page.tsx
│ ├── (app)/
│ │ ├── layout.tsx
│ │ ├── dashboard/page.tsx
│ │ ├── accounts/page.tsx
│ │ ├── accounts/[id]/page.tsx
│ │ ├── transactions/page.tsx
│ │ ├── upload/page.tsx
│ │ ├── budgets/page.tsx
│ │ └── graphs/page.tsx
│ └── api/
│ ├── auth/[...nextauth]/route.ts
│ ├── accounts/route.ts + [id]/route.ts
│ ├── transactions/route.ts + [id]/route.ts
│ ├── upload/route.ts
│ ├── budgets/route.ts + [id]/route.ts
│ └── dashboard/route.ts
├── components/
│ ├── ui/ # shadcn/ui generated
│ ├── layout/ # Sidebar, TopNav, PageHeader
│ ├── accounts/ # AccountList, AccountCard, CreateAccountDialog, AccountBadge
│ ├── transactions/ # TransactionTable, TransactionFilters, EditTransactionDialog
│ ├── upload/ # UploadDropzone, UploadForm, ColumnMapper, UploadPreview
│ ├── budgets/ # BudgetList, BudgetCard, CreateBudgetDialog, BudgetProgress
│ ├── dashboard/ # NetWorthCard, CashFlowCard, MonthlyBalanceCard, BudgetSummary
│ └── graphs/ # MonthlySpendingChart, NetWorthTrendChart, CategoryBreakdownChart, CashFlowChart, BudgetChart
└── lib/
├── auth.ts # NextAuth config (imported by route + middleware)
├── prisma.ts # Singleton PrismaClient
├── csv/
│ ├── bank-profiles.ts # All 4 bank profiles
│ ├── parser.ts # Profile detection + Papa Parse wrapper
│ └── normalizer.ts # Row → Transaction, cents parsing, dedupeHash
├── validations/ # account.ts, transaction.ts, upload.ts, budget.ts (Zod schemas)
└── utils/
├── currency.ts # cents → display string formatter
├── dates.ts # month boundary helpers
└── cn.ts # shadcn class merge utility