first build commit

This commit is contained in:
2026-04-19 00:44:43 -04:00
parent bc271b7ce1
commit 55debd082b
82 changed files with 6217 additions and 97 deletions

357
CLAUDE.md
View File

@@ -1 +1,356 @@
@AGENTS.md
# 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
```