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

14 KiB
Raw Blame History

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:
    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.1010010.

// 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)
  • dateTransaction Date
  • descriptionTransaction Description
  • debitAmountDebit
  • creditAmountCredit
  • balanceBalance (reference only)
  • Ignore: Transaction Type

Discover Credit Card → CREDIT_CARD

  • Strategy: A (single signed column)
  • dateTrans. Date
  • descriptionDescription
  • amountAmountpositive = DEBIT (charge), negative = CREDIT (payment/refund)
  • Ignore: Post Date, Category

Huntington Checking → BANK

  • Strategy: A (single signed column)
  • dateDate
  • descriptionDescription
  • amountAmountnegative = DEBIT, positive = CREDIT
  • Ignore: Category, Split, Tags

Fidelity → BANK (investment — cash activity only)

  • Strategy: A (single signed column)
  • dateRun Date
  • descriptionDescription
  • amountAmount($)negative = DEBIT (purchase), positive = CREDIT (dividend/sale)
  • balanceCash 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

  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 dedupeHashcreateMany({ 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

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

# 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