Add TRANSFER transaction type with bulk action and auto-rules

- Add TRANSFER to TransactionType enum; excluded from cash flow queries
- Add TransferRule model: description patterns that auto-mark transactions
  as transfers on upload (takes priority over budget rules)
- Bulk action "Mark as transfer" in transaction table
- Transfer Rules button/dialog on transactions page for managing patterns
- Transfers shown with ⇄ prefix and muted color in transaction list

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-21 20:15:07 -04:00
parent 34bf24b35d
commit 6f1376cc53
11 changed files with 267 additions and 19 deletions

View File

@@ -18,6 +18,10 @@ const bulkSchema = z.discriminatedUnion('action', [
ids: z.array(z.string()).min(1),
notes: z.string().max(500),
}),
z.object({
action: z.literal('markTransfer'),
ids: z.array(z.string()).min(1),
}),
])
async function recomputeAccount(accountId: string) {
@@ -110,11 +114,19 @@ export async function POST(req: Request) {
return NextResponse.json({ updated: ids.length })
}
// addNotes
const { notes } = result.data
if (action === 'addNotes') {
const { notes } = result.data
await prisma.transaction.updateMany({
where: { id: { in: ids } },
data: { notes: notes || null },
})
return NextResponse.json({ updated: ids.length })
}
// markTransfer
await prisma.transaction.updateMany({
where: { id: { in: ids } },
data: { notes: notes || null },
data: { type: 'TRANSFER' },
})
return NextResponse.json({ updated: ids.length })
}

View File

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

View File

@@ -0,0 +1,34 @@
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { auth } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
const schema = z.object({
pattern: z.string().min(1).max(200).trim(),
})
export async function GET() {
const session = await auth()
if (!session?.user?.id) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const rules = await prisma.transferRule.findMany({
where: { userId: session.user.id },
orderBy: { createdAt: 'asc' },
select: { id: 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 result = schema.safeParse(await req.json())
if (!result.success) return NextResponse.json({ error: result.error.flatten() }, { status: 400 })
const rule = await prisma.transferRule.create({
data: { userId: session.user.id, pattern: result.data.pattern },
select: { id: true, pattern: true },
})
return NextResponse.json(rule, { status: 201 })
}

View File

@@ -76,14 +76,26 @@ 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 },
})
// Apply transfer rules first, then budget rules (transfer takes priority)
const [budgetRules, transferRules] = await Promise.all([
prisma.budgetRule.findMany({
where: { userId: session.user.id },
orderBy: { createdAt: 'asc' },
select: { pattern: true, budgetId: true },
}),
prisma.transferRule.findMany({
where: { userId: session.user.id },
orderBy: { createdAt: 'asc' },
select: { pattern: true },
}),
])
const rowsWithBudgets = normalized.map((row) => {
const desc = row.description.toLowerCase()
for (const rule of transferRules) {
if (desc.includes(rule.pattern.toLowerCase())) {
return { ...row, type: 'TRANSFER' as const, budgetId: null }
}
}
for (const rule of budgetRules) {
if (desc.includes(rule.pattern.toLowerCase())) {
return { ...row, budgetId: rule.budgetId }