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

@@ -15,6 +15,7 @@ model User {
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
accounts Account[] accounts Account[]
budgets Budget[] budgets Budget[]
budgetRules BudgetRule[]
} }
enum AccountType { enum AccountType {
@@ -80,10 +81,24 @@ model Budget {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
transactions Transaction[] transactions Transaction[]
rules BudgetRule[]
@@index([userId]) @@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 { model CsvUpload {
id String @id @default(cuid()) id String @id @default(cuid())
accountId String accountId String

View File

@@ -6,29 +6,41 @@ export default async function BudgetsPage() {
const session = await auth() const session = await auth()
if (!session?.user?.id) return null if (!session?.user?.id) return null
const userId = session.user.id
const now = new Date() const now = new Date()
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1) const monthStart = new Date(now.getFullYear(), now.getMonth(), 1)
const monthEnd = new Date(now.getFullYear(), now.getMonth() + 1, 0, 23, 59, 59, 999) 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({ prisma.budget.findMany({
where: { userId: session.user.id }, where: { userId },
orderBy: { createdAt: 'asc' }, orderBy: { createdAt: 'asc' },
}), }),
prisma.$queryRaw<{ budgetId: string; total: bigint }[]>` prisma.$queryRaw<{ budgetId: string; total: bigint }[]>`
SELECT t."budgetId", COALESCE(SUM(t."amountCents"), 0)::bigint AS total SELECT t."budgetId", COALESCE(SUM(t."amountCents"), 0)::bigint AS total
FROM "Transaction" t FROM "Transaction" t
JOIN "Account" a ON t."accountId" = a.id 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."budgetId" IS NOT NULL
AND t.type = 'DEBIT' AND t.type = 'DEBIT'
AND t.date >= ${monthStart} AND t.date >= ${monthStart}
AND t.date <= ${monthEnd} AND t.date <= ${monthEnd}
GROUP BY t."budgetId" 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 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) => ({ const budgetsWithSpend = budgets.map((b) => ({
id: b.id, id: b.id,
@@ -37,6 +49,7 @@ export default async function BudgetsPage() {
color: b.color, color: b.color,
isActive: b.isActive, isActive: b.isActive,
spendCents: spendMap.get(b.id) ?? 0, spendCents: spendMap.get(b.id) ?? 0,
rules: rulesMap.get(b.id) ?? [],
})) }))
return ( 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) 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({ const upload = await prisma.csvUpload.create({
data: { data: {
accountId, accountId,
@@ -89,13 +105,15 @@ export async function POST(req: Request) {
try { try {
const { count: importedCount } = await prisma.transaction.createMany({ const { count: importedCount } = await prisma.transaction.createMany({
data: normalized.map((r) => ({ data: rowsWithBudgets.map((r) => ({
accountId, accountId,
uploadId: upload.id, uploadId: upload.id,
date: r.date, date: r.date,
description: r.description, description: r.description,
amountCents: r.amountCents, amountCents: r.amountCents,
type: r.type, type: r.type,
category: r.category ?? null,
budgetId: r.budgetId,
dedupeHash: r.dedupeHash, dedupeHash: r.dedupeHash,
})), })),
skipDuplicates: true, skipDuplicates: true,
@@ -118,7 +136,7 @@ export async function POST(req: Request) {
// Upsert balance snapshots for each affected month // Upsert balance snapshots for each affected month
const months = [ const months = [
...new Map( ...new Map(
normalized.map((r) => { rowsWithBudgets.map((r) => {
const y = r.date.getFullYear() const y = r.date.getFullYear()
const m = r.date.getMonth() + 1 const m = r.date.getMonth() + 1
return [`${y}-${m}`, { year: y, month: m }] return [`${y}-${m}`, { year: y, month: m }]

View File

@@ -7,11 +7,17 @@ import {
DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu' } 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 { BudgetProgress } from './BudgetProgress'
import { CreateBudgetDialog } from './CreateBudgetDialog' import { CreateBudgetDialog } from './CreateBudgetDialog'
import { BudgetRulesDialog } from './BudgetRulesDialog'
import { formatCents } from '@/lib/utils/currency' import { formatCents } from '@/lib/utils/currency'
export interface BudgetRule {
id: string
pattern: string
}
export interface BudgetWithSpend { export interface BudgetWithSpend {
id: string id: string
name: string name: string
@@ -19,11 +25,13 @@ export interface BudgetWithSpend {
color: string | null color: string | null
isActive: boolean isActive: boolean
spendCents: number spendCents: number
rules?: BudgetRule[]
} }
export function BudgetCard({ budget }: { budget: BudgetWithSpend }) { export function BudgetCard({ budget }: { budget: BudgetWithSpend }) {
const router = useRouter() const router = useRouter()
const [editOpen, setEditOpen] = useState(false) const [editOpen, setEditOpen] = useState(false)
const [rulesOpen, setRulesOpen] = useState(false)
async function handleToggle() { async function handleToggle() {
await fetch(`/api/budgets/${budget.id}`, { await fetch(`/api/budgets/${budget.id}`, {
@@ -62,6 +70,9 @@ export function BudgetCard({ budget }: { budget: BudgetWithSpend }) {
<DropdownMenuItem onClick={() => setEditOpen(true)}> <DropdownMenuItem onClick={() => setEditOpen(true)}>
<Pencil className="h-4 w-4 mr-2" />Edit <Pencil className="h-4 w-4 mr-2" />Edit
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onClick={() => setRulesOpen(true)}>
<ListFilter className="h-4 w-4 mr-2" />Rules
</DropdownMenuItem>
<DropdownMenuItem onClick={handleToggle}> <DropdownMenuItem onClick={handleToggle}>
{budget.isActive {budget.isActive
? <><EyeOff className="h-4 w-4 mr-2" />Deactivate</> ? <><EyeOff className="h-4 w-4 mr-2" />Deactivate</>
@@ -102,6 +113,13 @@ export function BudgetCard({ budget }: { budget: BudgetWithSpend }) {
</Card> </Card>
<CreateBudgetDialog open={editOpen} onOpenChange={setEditOpen} budget={budget} /> <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 strategy: ParseStrategy
dateColumn: string dateColumn: string
descriptionColumn: string descriptionColumn: string
categoryColumn?: string
// Strategy A // Strategy A
amountColumn?: string amountColumn?: string
invertAmountSign?: boolean invertAmountSign?: boolean
@@ -40,6 +41,7 @@ export const bankProfiles: BankProfile[] = [
descriptionColumn: 'Description', descriptionColumn: 'Description',
amountColumn: 'Amount', amountColumn: 'Amount',
invertAmountSign: false, // positive = DEBIT (charge), negative = CREDIT (payment) invertAmountSign: false, // positive = DEBIT (charge), negative = CREDIT (payment)
categoryColumn: 'Category',
detectColumns: ['Trans. Date', 'Post Date', 'Description', 'Amount', 'Category'], detectColumns: ['Trans. Date', 'Post Date', 'Description', 'Amount', 'Category'],
}, },
{ {

View File

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