feat: category import from CSV + budget auto-assign rules

Category:
- Add categoryColumn to NormalizerConfig and NormalizedRow
- Map 'Category' column for Discover CC profile
- Write category to Transaction on upload

Budget rules:
- Add BudgetRule model (userId, budgetId, pattern)
- API: GET/POST /api/budget-rules, DELETE /api/budget-rules/:id
- Apply rules during upload (first case-insensitive match wins)
- Budgets page fetches and passes rules per budget
- BudgetCard 'Rules' menu item opens BudgetRulesDialog for add/delete

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
jerick
2026-04-20 22:56:41 -04:00
parent efe42ac366
commit 60fc836b73
10 changed files with 234 additions and 10 deletions

View File

@@ -8,13 +8,14 @@ datasource db {
}
model User {
id String @id @default(cuid())
email String @unique
id String @id @default(cuid())
email String @unique
passwordHash String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
accounts Account[]
budgets Budget[]
budgetRules BudgetRule[]
}
enum AccountType {
@@ -80,10 +81,24 @@ model Budget {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
transactions Transaction[]
rules BudgetRule[]
@@index([userId])
}
model BudgetRule {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
budgetId String
budget Budget @relation(fields: [budgetId], references: [id], onDelete: Cascade)
pattern String
createdAt DateTime @default(now())
@@index([userId])
@@index([budgetId])
}
model CsvUpload {
id String @id @default(cuid())
accountId String

View File

@@ -6,29 +6,41 @@ export default async function BudgetsPage() {
const session = await auth()
if (!session?.user?.id) return null
const userId = session.user.id
const now = new Date()
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1)
const monthEnd = new Date(now.getFullYear(), now.getMonth() + 1, 0, 23, 59, 59, 999)
const [budgets, spendRows] = await Promise.all([
const [budgets, spendRows, rules] = await Promise.all([
prisma.budget.findMany({
where: { userId: session.user.id },
where: { userId },
orderBy: { createdAt: 'asc' },
}),
prisma.$queryRaw<{ budgetId: string; total: bigint }[]>`
SELECT t."budgetId", COALESCE(SUM(t."amountCents"), 0)::bigint AS total
FROM "Transaction" t
JOIN "Account" a ON t."accountId" = a.id
WHERE a."userId" = ${session.user.id}
WHERE a."userId" = ${userId}
AND t."budgetId" IS NOT NULL
AND t.type = 'DEBIT'
AND t.date >= ${monthStart}
AND t.date <= ${monthEnd}
GROUP BY t."budgetId"
`,
prisma.budgetRule.findMany({
where: { userId },
orderBy: { createdAt: 'asc' },
select: { id: true, budgetId: true, pattern: true },
}),
])
const spendMap = new Map(spendRows.map((r) => [r.budgetId, Number(r.total)]))
const rulesMap = new Map<string, { id: string; pattern: string }[]>()
for (const rule of rules) {
const existing = rulesMap.get(rule.budgetId) ?? []
existing.push({ id: rule.id, pattern: rule.pattern })
rulesMap.set(rule.budgetId, existing)
}
const budgetsWithSpend = budgets.map((b) => ({
id: b.id,
@@ -37,6 +49,7 @@ export default async function BudgetsPage() {
color: b.color,
isActive: b.isActive,
spendCents: spendMap.get(b.id) ?? 0,
rules: rulesMap.get(b.id) ?? [],
}))
return (

View File

@@ -0,0 +1,16 @@
import { NextResponse } from 'next/server'
import { auth } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
export async function DELETE(_req: Request, { params }: { params: Promise<{ id: string }> }) {
const session = await auth()
if (!session?.user?.id) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const { id } = await params
const rule = await prisma.budgetRule.findFirst({ where: { id, userId: session.user.id } })
if (!rule) return NextResponse.json({ error: 'Not found' }, { status: 404 })
await prisma.budgetRule.delete({ where: { id } })
return new NextResponse(null, { status: 204 })
}

View File

@@ -0,0 +1,37 @@
import { NextResponse } from 'next/server'
import { auth } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { createBudgetRuleSchema } from '@/lib/validations/budget-rule'
export async function GET() {
const session = await auth()
if (!session?.user?.id) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const rules = await prisma.budgetRule.findMany({
where: { userId: session.user.id },
orderBy: { createdAt: 'asc' },
select: { id: true, budgetId: true, pattern: true },
})
return NextResponse.json(rules)
}
export async function POST(req: Request) {
const session = await auth()
if (!session?.user?.id) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const body = await req.json()
const parsed = createBudgetRuleSchema.safeParse(body)
if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 })
const { budgetId, pattern } = parsed.data
// Verify budget belongs to this user
const budget = await prisma.budget.findFirst({ where: { id: budgetId, userId: session.user.id } })
if (!budget) return NextResponse.json({ error: 'Budget not found' }, { status: 404 })
const rule = await prisma.budgetRule.create({
data: { userId: session.user.id, budgetId, pattern },
select: { id: true, budgetId: true, pattern: true },
})
return NextResponse.json(rule, { status: 201 })
}

View File

@@ -76,6 +76,22 @@ export async function POST(req: Request) {
const normalized = normalizeRows(allRows, accountId, config)
// Apply budget auto-assign rules (first match wins)
const budgetRules = await prisma.budgetRule.findMany({
where: { userId: session.user.id },
orderBy: { createdAt: 'asc' },
select: { pattern: true, budgetId: true },
})
const rowsWithBudgets = normalized.map((row) => {
const desc = row.description.toLowerCase()
for (const rule of budgetRules) {
if (desc.includes(rule.pattern.toLowerCase())) {
return { ...row, budgetId: rule.budgetId }
}
}
return { ...row, budgetId: null }
})
const upload = await prisma.csvUpload.create({
data: {
accountId,
@@ -89,13 +105,15 @@ export async function POST(req: Request) {
try {
const { count: importedCount } = await prisma.transaction.createMany({
data: normalized.map((r) => ({
data: rowsWithBudgets.map((r) => ({
accountId,
uploadId: upload.id,
date: r.date,
description: r.description,
amountCents: r.amountCents,
type: r.type,
category: r.category ?? null,
budgetId: r.budgetId,
dedupeHash: r.dedupeHash,
})),
skipDuplicates: true,
@@ -118,7 +136,7 @@ export async function POST(req: Request) {
// Upsert balance snapshots for each affected month
const months = [
...new Map(
normalized.map((r) => {
rowsWithBudgets.map((r) => {
const y = r.date.getFullYear()
const m = r.date.getMonth() + 1
return [`${y}-${m}`, { year: y, month: m }]

View File

@@ -7,11 +7,17 @@ import {
DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { MoreHorizontal, Pencil, Trash2, EyeOff, Eye } from 'lucide-react'
import { MoreHorizontal, Pencil, Trash2, EyeOff, Eye, ListFilter } from 'lucide-react'
import { BudgetProgress } from './BudgetProgress'
import { CreateBudgetDialog } from './CreateBudgetDialog'
import { BudgetRulesDialog } from './BudgetRulesDialog'
import { formatCents } from '@/lib/utils/currency'
export interface BudgetRule {
id: string
pattern: string
}
export interface BudgetWithSpend {
id: string
name: string
@@ -19,11 +25,13 @@ export interface BudgetWithSpend {
color: string | null
isActive: boolean
spendCents: number
rules?: BudgetRule[]
}
export function BudgetCard({ budget }: { budget: BudgetWithSpend }) {
const router = useRouter()
const [editOpen, setEditOpen] = useState(false)
const [rulesOpen, setRulesOpen] = useState(false)
async function handleToggle() {
await fetch(`/api/budgets/${budget.id}`, {
@@ -62,6 +70,9 @@ export function BudgetCard({ budget }: { budget: BudgetWithSpend }) {
<DropdownMenuItem onClick={() => setEditOpen(true)}>
<Pencil className="h-4 w-4 mr-2" />Edit
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setRulesOpen(true)}>
<ListFilter className="h-4 w-4 mr-2" />Rules
</DropdownMenuItem>
<DropdownMenuItem onClick={handleToggle}>
{budget.isActive
? <><EyeOff className="h-4 w-4 mr-2" />Deactivate</>
@@ -102,6 +113,13 @@ export function BudgetCard({ budget }: { budget: BudgetWithSpend }) {
</Card>
<CreateBudgetDialog open={editOpen} onOpenChange={setEditOpen} budget={budget} />
<BudgetRulesDialog
open={rulesOpen}
onOpenChange={setRulesOpen}
budgetId={budget.id}
budgetName={budget.name}
rules={budget.rules ?? []}
/>
</>
)
}

View File

@@ -0,0 +1,96 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Trash2, Plus } from 'lucide-react'
interface Rule {
id: string
pattern: string
}
interface Props {
open: boolean
onOpenChange: (open: boolean) => void
budgetId: string
budgetName: string
rules: Rule[]
}
export function BudgetRulesDialog({ open, onOpenChange, budgetId, budgetName, rules }: Props) {
const router = useRouter()
const [pattern, setPattern] = useState('')
const [adding, setAdding] = useState(false)
async function handleAdd(e: React.FormEvent) {
e.preventDefault()
if (!pattern.trim()) return
setAdding(true)
await fetch('/api/budget-rules', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ budgetId, pattern: pattern.trim() }),
})
setPattern('')
setAdding(false)
router.refresh()
}
async function handleDelete(id: string) {
await fetch(`/api/budget-rules/${id}`, { method: 'DELETE' })
router.refresh()
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Auto-assign rules {budgetName}</DialogTitle>
</DialogHeader>
<p className="text-sm text-muted-foreground">
Transactions whose description contains a pattern (case-insensitive) are automatically
assigned to this budget on upload. First matching rule wins.
</p>
{rules.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-2">No rules yet.</p>
) : (
<ul className="space-y-1.5">
{rules.map((rule) => (
<li
key={rule.id}
className="flex items-center justify-between gap-2 rounded-md border px-3 py-2 text-sm"
>
<span className="font-mono truncate">{rule.pattern}</span>
<button
onClick={() => handleDelete(rule.id)}
className="shrink-0 text-muted-foreground hover:text-destructive transition-colors"
aria-label="Delete rule"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</li>
))}
</ul>
)}
<form onSubmit={handleAdd} className="flex gap-2">
<Input
placeholder="e.g. Amazon, Netflix, Starbucks"
value={pattern}
onChange={(e) => setPattern(e.target.value)}
className="flex-1"
/>
<Button type="submit" disabled={adding || !pattern.trim()} size="sm">
<Plus className="h-4 w-4 mr-1" />
Add
</Button>
</form>
</DialogContent>
</Dialog>
)
}

View File

@@ -4,6 +4,7 @@ export interface NormalizerConfig {
strategy: ParseStrategy
dateColumn: string
descriptionColumn: string
categoryColumn?: string
// Strategy A
amountColumn?: string
invertAmountSign?: boolean
@@ -40,6 +41,7 @@ export const bankProfiles: BankProfile[] = [
descriptionColumn: 'Description',
amountColumn: 'Amount',
invertAmountSign: false, // positive = DEBIT (charge), negative = CREDIT (payment)
categoryColumn: 'Category',
detectColumns: ['Trans. Date', 'Post Date', 'Description', 'Amount', 'Category'],
},
{

View File

@@ -6,6 +6,7 @@ export interface NormalizedRow {
description: string
amountCents: number
type: 'DEBIT' | 'CREDIT'
category?: string
dedupeHash: string
}
@@ -92,11 +93,13 @@ export function normalizeRows(
if (amountCents === 0) continue
const rawCategory = config.categoryColumn ? (row[config.categoryColumn] ?? '').trim() : ''
out.push({
date,
description,
amountCents,
type,
category: rawCategory || undefined,
dedupeHash: dedupeHash(accountId, date, description, amountCents),
})
} catch {

View File

@@ -0,0 +1,6 @@
import { z } from 'zod'
export const createBudgetRuleSchema = z.object({
budgetId: z.string().cuid(),
pattern: z.string().min(1).max(200).trim(),
})