Files
finance-app/CLAUDE.md
2026-04-19 00:44:43 -04:00

357 lines
14 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 16 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` (not `prisma-client-js`)
- Client output: `src/generated/prisma/` — import as `@/generated/prisma/client`
- **Requires Driver Adapter** — no URL in constructor. Use `@prisma/adapter-pg` with `pg`:
```ts
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 via `Pool` at runtime
- Singleton: `src/lib/prisma.ts`
- Seed: `npx prisma db seed` (runs `tsx prisma/seed.ts`)
---
## Database Schema
Store all money as **integer cents** (never floats). `100.10` → `10010`.
```prisma
// 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 // 112
balanceCents Int
computedAt DateTime @default(now())
@@unique([accountId, year, month])
@@index([year, month])
}
```
### Key schema decisions
- `currentBalanceCents` is always **recomputed by summing all transactions** after each upload — never incremented by delta — to handle out-of-order historical imports
- `dedupeHash` unique constraint lets `createMany({ skipDuplicates: true })` silently handle re-uploads
- `budgetId` uses `onDelete: SetNull` — deleting a budget unlinks its transactions, never deletes them
- `BalanceSnapshot` has 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 Date`
- `description` ← `Transaction Description`
- `debitAmount` ← `Debit`
- `creditAmount` ← `Credit`
- `balance` ← `Balance` (reference only)
- Ignore: `Transaction Type`
### Discover Credit Card → `CREDIT_CARD`
- **Strategy**: A (single signed column)
- `date` ← `Trans. Date`
- `description` ← `Description`
- `amount` ← `Amount` — **positive = DEBIT (charge), negative = CREDIT (payment/refund)**
- Ignore: `Post Date`, `Category`
### Huntington Checking → `BANK`
- **Strategy**: A (single signed column)
- `date` ← `Date`
- `description` ← `Description`
- `amount` ← `Amount` — **negative = DEBIT, positive = CREDIT**
- Ignore: `Category`, `Split`, `Tags`
### Fidelity → `BANK` (investment — cash activity only)
- **Strategy**: A (single signed column)
- `date` ← `Run Date`
- `description` ← `Description`
- `amount` ← `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
```typescript
// 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
1. Server parses header row → attempts profile auto-detection by matching column names
2. **Match found**: auto-proceeds; client shows "Detected: [Bank Name]"
3. **No match**: returns `{ requiresMapping: true, headers, sampleRows }` → client renders `ColumnMapper` for manual column assignment
4. After mapping resolved: normalize all rows → compute `dedupeHash` → `createMany({ skipDuplicates: true })` → recompute `currentBalanceCents` (full sum) → write/update `BalanceSnapshot` for 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`
- `BudgetCard` shows: name, current-month DEBIT total, limit, progress bar (green <75% / yellow 75-100% / red >100%)
- Dashboard has a `BudgetSummary` section with all active budgets
- Deleting a budget sets `budgetId = null` on all its transactions (transactions not deleted)
### Net worth calculation
```sql
-- 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.ts` protects 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.sql` tagged 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 to `127.0.0.1` only
---
## .env Variables Required
```bash
# 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
```