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:
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
|
||||
16
src/app/api/budget-rules/[id]/route.ts
Normal file
16
src/app/api/budget-rules/[id]/route.ts
Normal 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 })
|
||||
}
|
||||
37
src/app/api/budget-rules/route.ts
Normal file
37
src/app/api/budget-rules/route.ts
Normal 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 })
|
||||
}
|
||||
@@ -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 }]
|
||||
|
||||
@@ -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 ?? []}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
96
src/components/budgets/BudgetRulesDialog.tsx
Normal file
96
src/components/budgets/BudgetRulesDialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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'],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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 {
|
||||
|
||||
6
src/lib/validations/budget-rule.ts
Normal file
6
src/lib/validations/budget-rule.ts
Normal 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(),
|
||||
})
|
||||
Reference in New Issue
Block a user