diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 648600f..5af1580 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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 diff --git a/src/app/(app)/budgets/page.tsx b/src/app/(app)/budgets/page.tsx index ffe97a7..545076b 100644 --- a/src/app/(app)/budgets/page.tsx +++ b/src/app/(app)/budgets/page.tsx @@ -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() + 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 ( diff --git a/src/app/api/budget-rules/[id]/route.ts b/src/app/api/budget-rules/[id]/route.ts new file mode 100644 index 0000000..34670fd --- /dev/null +++ b/src/app/api/budget-rules/[id]/route.ts @@ -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 }) +} diff --git a/src/app/api/budget-rules/route.ts b/src/app/api/budget-rules/route.ts new file mode 100644 index 0000000..00a8da4 --- /dev/null +++ b/src/app/api/budget-rules/route.ts @@ -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 }) +} diff --git a/src/app/api/upload/route.ts b/src/app/api/upload/route.ts index 8dfd463..9e728ad 100644 --- a/src/app/api/upload/route.ts +++ b/src/app/api/upload/route.ts @@ -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 }] diff --git a/src/components/budgets/BudgetCard.tsx b/src/components/budgets/BudgetCard.tsx index a37f604..9f4f3a3 100644 --- a/src/components/budgets/BudgetCard.tsx +++ b/src/components/budgets/BudgetCard.tsx @@ -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 }) { setEditOpen(true)}> Edit + setRulesOpen(true)}> + Rules + {budget.isActive ? <>Deactivate @@ -102,6 +113,13 @@ export function BudgetCard({ budget }: { budget: BudgetWithSpend }) { + ) } diff --git a/src/components/budgets/BudgetRulesDialog.tsx b/src/components/budgets/BudgetRulesDialog.tsx new file mode 100644 index 0000000..500dab2 --- /dev/null +++ b/src/components/budgets/BudgetRulesDialog.tsx @@ -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 ( + + + + Auto-assign rules — {budgetName} + + +

+ Transactions whose description contains a pattern (case-insensitive) are automatically + assigned to this budget on upload. First matching rule wins. +

+ + {rules.length === 0 ? ( +

No rules yet.

+ ) : ( +
    + {rules.map((rule) => ( +
  • + {rule.pattern} + +
  • + ))} +
+ )} + +
+ setPattern(e.target.value)} + className="flex-1" + /> + +
+
+
+ ) +} diff --git a/src/lib/csv/bank-profiles.ts b/src/lib/csv/bank-profiles.ts index f086259..8170b00 100644 --- a/src/lib/csv/bank-profiles.ts +++ b/src/lib/csv/bank-profiles.ts @@ -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'], }, { diff --git a/src/lib/csv/normalizer.ts b/src/lib/csv/normalizer.ts index 908d4e9..4ddd5f1 100644 --- a/src/lib/csv/normalizer.ts +++ b/src/lib/csv/normalizer.ts @@ -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 { diff --git a/src/lib/validations/budget-rule.ts b/src/lib/validations/budget-rule.ts new file mode 100644 index 0000000..d698c06 --- /dev/null +++ b/src/lib/validations/budget-rule.ts @@ -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(), +})