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

@@ -16,6 +16,7 @@ model User {
accounts Account[] accounts Account[]
budgets Budget[] budgets Budget[]
budgetRules BudgetRule[] budgetRules BudgetRule[]
transferRules TransferRule[]
} }
enum AccountType { enum AccountType {
@@ -45,6 +46,7 @@ model Account {
enum TransactionType { enum TransactionType {
DEBIT DEBIT
CREDIT CREDIT
TRANSFER
} }
model Transaction { model Transaction {
@@ -100,6 +102,16 @@ model BudgetRule {
@@index([budgetId]) @@index([budgetId])
} }
model TransferRule {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
pattern String
createdAt DateTime @default(now())
@@index([userId])
}
model CsvUpload { model CsvUpload {
id String @id @default(cuid()) id String @id @default(cuid())
accountId String accountId String

View File

@@ -32,6 +32,7 @@ export default async function DashboardPage({ searchParams }: { searchParams: Se
JOIN "Account" a ON t."accountId" = a.id JOIN "Account" a ON t."accountId" = a.id
WHERE a."userId" = ${userId} WHERE a."userId" = ${userId}
AND a.type = 'BANK' AND a.type = 'BANK'
AND t.type != 'TRANSFER'
AND t.date >= ${start} AND t.date >= ${start}
AND t.date <= ${end} AND t.date <= ${end}
GROUP BY t.type GROUP BY t.type

View File

@@ -3,6 +3,7 @@ import { prisma } from '@/lib/prisma'
import { Prisma } from '@/generated/prisma/client' import { Prisma } from '@/generated/prisma/client'
import { TransactionFilters } from '@/components/transactions/TransactionFilters' import { TransactionFilters } from '@/components/transactions/TransactionFilters'
import { TransactionTable } from '@/components/transactions/TransactionTable' import { TransactionTable } from '@/components/transactions/TransactionTable'
import { TransferRulesButton } from '@/components/transactions/TransferRulesButton'
const PAGE_LIMIT = 50 const PAGE_LIMIT = 50
@@ -35,7 +36,7 @@ export default async function TransactionsPage({ searchParams }: { searchParams:
}), }),
} }
const [transactions, total, accounts, budgets] = await Promise.all([ const [transactions, total, accounts, budgets, transferRules] = await Promise.all([
prisma.transaction.findMany({ prisma.transaction.findMany({
where, where,
include: { include: {
@@ -57,6 +58,11 @@ export default async function TransactionsPage({ searchParams }: { searchParams:
select: { id: true, name: true, color: true }, select: { id: true, name: true, color: true },
orderBy: { name: 'asc' }, orderBy: { name: 'asc' },
}), }),
prisma.transferRule.findMany({
where: { userId: session.user.id },
orderBy: { createdAt: 'asc' },
select: { id: true, pattern: true },
}),
]) ])
// Serialize dates for client components // Serialize dates for client components
@@ -69,7 +75,10 @@ export default async function TransactionsPage({ searchParams }: { searchParams:
return ( return (
<div className="p-6"> <div className="p-6">
<h1 className="text-2xl font-bold mb-4">Transactions</h1> <div className="flex items-center justify-between mb-4">
<h1 className="text-2xl font-bold">Transactions</h1>
<TransferRulesButton rules={transferRules} />
</div>
<TransactionFilters accounts={accounts} /> <TransactionFilters accounts={accounts} />
<TransactionTable <TransactionTable
transactions={rows} transactions={rows}

View File

@@ -18,6 +18,10 @@ const bulkSchema = z.discriminatedUnion('action', [
ids: z.array(z.string()).min(1), ids: z.array(z.string()).min(1),
notes: z.string().max(500), notes: z.string().max(500),
}), }),
z.object({
action: z.literal('markTransfer'),
ids: z.array(z.string()).min(1),
}),
]) ])
async function recomputeAccount(accountId: string) { async function recomputeAccount(accountId: string) {
@@ -110,11 +114,19 @@ export async function POST(req: Request) {
return NextResponse.json({ updated: ids.length }) return NextResponse.json({ updated: ids.length })
} }
// addNotes if (action === 'addNotes') {
const { notes } = result.data const { notes } = result.data
await prisma.transaction.updateMany({ await prisma.transaction.updateMany({
where: { id: { in: ids } }, where: { id: { in: ids } },
data: { notes: notes || null }, data: { notes: notes || null },
}) })
return NextResponse.json({ updated: ids.length }) return NextResponse.json({ updated: ids.length })
}
// markTransfer
await prisma.transaction.updateMany({
where: { id: { in: ids } },
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) const normalized = normalizeRows(allRows, accountId, config)
// Apply budget auto-assign rules (first match wins) // Apply transfer rules first, then budget rules (transfer takes priority)
const budgetRules = await prisma.budgetRule.findMany({ const [budgetRules, transferRules] = await Promise.all([
prisma.budgetRule.findMany({
where: { userId: session.user.id }, where: { userId: session.user.id },
orderBy: { createdAt: 'asc' }, orderBy: { createdAt: 'asc' },
select: { pattern: true, budgetId: true }, 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 rowsWithBudgets = normalized.map((row) => {
const desc = row.description.toLowerCase() 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) { for (const rule of budgetRules) {
if (desc.includes(rule.pattern.toLowerCase())) { if (desc.includes(rule.pattern.toLowerCase())) {
return { ...row, budgetId: rule.budgetId } return { ...row, budgetId: rule.budgetId }

View File

@@ -6,7 +6,7 @@ import { usePathname, useSearchParams, useRouter } from 'next/navigation'
import { import {
Table, TableBody, TableCell, TableHead, TableHeader, TableRow, Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
} from '@/components/ui/table' } from '@/components/ui/table'
import { Pencil, ChevronLeft, ChevronRight, Trash2, Tag, StickyNote, X } from 'lucide-react' import { Pencil, ChevronLeft, ChevronRight, Trash2, Tag, StickyNote, X, ArrowLeftRight } from 'lucide-react'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog' import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
@@ -53,7 +53,7 @@ export function TransactionTable({
const router = useRouter() const router = useRouter()
const [editing, setEditing] = useState<TransactionRow | null>(null) const [editing, setEditing] = useState<TransactionRow | null>(null)
const [selected, setSelected] = useState<Set<string>>(new Set()) const [selected, setSelected] = useState<Set<string>>(new Set())
const [bulkDialog, setBulkDialog] = useState<'delete' | 'budget' | 'notes' | null>(null) const [bulkDialog, setBulkDialog] = useState<'delete' | 'budget' | 'notes' | 'transfer' | null>(null)
const [budgetTarget, setBudgetTarget] = useState<string>('__none__') const [budgetTarget, setBudgetTarget] = useState<string>('__none__')
const [notesValue, setNotesValue] = useState('') const [notesValue, setNotesValue] = useState('')
const [working, setWorking] = useState(false) const [working, setWorking] = useState(false)
@@ -125,6 +125,10 @@ export function TransactionTable({
await bulkPost({ action: 'addNotes', ids: [...selected], notes: notesValue }) await bulkPost({ action: 'addNotes', ids: [...selected], notes: notesValue })
} }
async function handleMarkTransfer() {
await bulkPost({ action: 'markTransfer', ids: [...selected] })
}
if (transactions.length === 0 && !someSelected) { if (transactions.length === 0 && !someSelected) {
return ( return (
<div className="rounded-lg border border-dashed p-12 text-center text-muted-foreground"> <div className="rounded-lg border border-dashed p-12 text-center text-muted-foreground">
@@ -156,6 +160,13 @@ export function TransactionTable({
> >
<Tag className="h-3.5 w-3.5 mr-1.5" />Budget <Tag className="h-3.5 w-3.5 mr-1.5" />Budget
</Button> </Button>
<Button
size="sm"
variant="outline"
onClick={() => setBulkDialog('transfer')}
>
<ArrowLeftRight className="h-3.5 w-3.5 mr-1.5" />Transfer
</Button>
<Button <Button
size="sm" size="sm"
variant="destructive" variant="destructive"
@@ -195,6 +206,7 @@ export function TransactionTable({
<TableBody> <TableBody>
{transactions.map((tx) => { {transactions.map((tx) => {
const isCredit = tx.type === 'CREDIT' const isCredit = tx.type === 'CREDIT'
const isTransfer = tx.type === 'TRANSFER'
const isSelected = selected.has(tx.id) const isSelected = selected.has(tx.id)
const dateStr = new Date(tx.date).toLocaleDateString('en-US', { const dateStr = new Date(tx.date).toLocaleDateString('en-US', {
month: 'short', day: 'numeric', year: 'numeric', month: 'short', day: 'numeric', year: 'numeric',
@@ -225,8 +237,8 @@ export function TransactionTable({
</TableCell> </TableCell>
)} )}
<TableCell className="text-right tabular-nums font-medium"> <TableCell className="text-right tabular-nums font-medium">
<span className={cn('text-sm', isCredit ? 'text-green-600' : '')}> <span className={cn('text-sm', isTransfer ? 'text-muted-foreground' : isCredit ? 'text-green-600' : '')}>
{isCredit ? '+' : '-'}{formatCents(tx.amountCents)} {isTransfer ? '⇄ ' : isCredit ? '+' : '-'}{formatCents(tx.amountCents)}
</span> </span>
</TableCell> </TableCell>
<TableCell className="text-sm text-muted-foreground"> <TableCell className="text-sm text-muted-foreground">
@@ -356,6 +368,25 @@ export function TransactionTable({
</DialogContent> </DialogContent>
</Dialog> </Dialog>
{/* Mark as transfer dialog */}
<Dialog open={bulkDialog === 'transfer'} onOpenChange={(o) => { if (!o) setBulkDialog(null) }}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>Mark as transfer?</DialogTitle>
</DialogHeader>
<p className="text-sm text-muted-foreground">
{selected.size} transaction{selected.size !== 1 ? 's' : ''} will be marked as transfers
and excluded from cash flow calculations.
</p>
<DialogFooter>
<Button variant="outline" onClick={() => setBulkDialog(null)}>Cancel</Button>
<Button onClick={handleMarkTransfer} disabled={working}>
{working ? 'Saving…' : 'Mark as transfer'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Add notes dialog */} {/* Add notes dialog */}
<Dialog open={bulkDialog === 'notes'} onOpenChange={(o) => { if (!o) setBulkDialog(null) }}> <Dialog open={bulkDialog === 'notes'} onOpenChange={(o) => { if (!o) setBulkDialog(null) }}>
<DialogContent className="max-w-sm"> <DialogContent className="max-w-sm">

View File

@@ -0,0 +1,24 @@
'use client'
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { ArrowLeftRight } from 'lucide-react'
import { TransferRulesDialog } from './TransferRulesDialog'
interface Rule {
id: string
pattern: string
}
export function TransferRulesButton({ rules }: { rules: Rule[] }) {
const [open, setOpen] = useState(false)
return (
<>
<Button variant="outline" size="sm" onClick={() => setOpen(true)}>
<ArrowLeftRight className="h-4 w-4 mr-2" />
Transfer rules
</Button>
<TransferRulesDialog open={open} onOpenChange={setOpen} rules={rules} />
</>
)
}

View File

@@ -0,0 +1,94 @@
'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
rules: Rule[]
}
export function TransferRulesDialog({ open, onOpenChange, 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/transfer-rules', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pattern: pattern.trim() }),
})
setPattern('')
setAdding(false)
router.refresh()
}
async function handleDelete(id: string) {
await fetch(`/api/transfer-rules/${id}`, { method: 'DELETE' })
router.refresh()
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Transfer rules</DialogTitle>
</DialogHeader>
<p className="text-sm text-muted-foreground">
Transactions whose description matches a pattern (case-insensitive) are automatically
marked as transfers on upload and excluded from cash flow.
</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. Fidelity Transfer, Schwab"
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,7 +4,7 @@ export const transactionQuerySchema = z.object({
accountId: z.string().optional(), accountId: z.string().optional(),
dateFrom: z.string().optional(), dateFrom: z.string().optional(),
dateTo: z.string().optional(), dateTo: z.string().optional(),
type: z.enum(['DEBIT', 'CREDIT']).optional(), type: z.enum(['DEBIT', 'CREDIT', 'TRANSFER']).optional(),
search: z.string().optional(), search: z.string().optional(),
budgetId: z.string().optional(), budgetId: z.string().optional(),
page: z.coerce.number().min(1).default(1), page: z.coerce.number().min(1).default(1),