diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 485d393..d3516cd 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -13,9 +13,10 @@ model User {
passwordHash String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
- accounts Account[]
- budgets Budget[]
- budgetRules BudgetRule[]
+ accounts Account[]
+ budgets Budget[]
+ budgetRules BudgetRule[]
+ transferRules TransferRule[]
}
enum AccountType {
@@ -45,6 +46,7 @@ model Account {
enum TransactionType {
DEBIT
CREDIT
+ TRANSFER
}
model Transaction {
@@ -100,6 +102,16 @@ model BudgetRule {
@@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 {
id String @id @default(cuid())
accountId String
diff --git a/src/app/(app)/dashboard/page.tsx b/src/app/(app)/dashboard/page.tsx
index ed91244..b606d72 100644
--- a/src/app/(app)/dashboard/page.tsx
+++ b/src/app/(app)/dashboard/page.tsx
@@ -32,6 +32,7 @@ export default async function DashboardPage({ searchParams }: { searchParams: Se
JOIN "Account" a ON t."accountId" = a.id
WHERE a."userId" = ${userId}
AND a.type = 'BANK'
+ AND t.type != 'TRANSFER'
AND t.date >= ${start}
AND t.date <= ${end}
GROUP BY t.type
diff --git a/src/app/(app)/transactions/page.tsx b/src/app/(app)/transactions/page.tsx
index db10df0..0e5385b 100644
--- a/src/app/(app)/transactions/page.tsx
+++ b/src/app/(app)/transactions/page.tsx
@@ -3,6 +3,7 @@ import { prisma } from '@/lib/prisma'
import { Prisma } from '@/generated/prisma/client'
import { TransactionFilters } from '@/components/transactions/TransactionFilters'
import { TransactionTable } from '@/components/transactions/TransactionTable'
+import { TransferRulesButton } from '@/components/transactions/TransferRulesButton'
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({
where,
include: {
@@ -57,6 +58,11 @@ export default async function TransactionsPage({ searchParams }: { searchParams:
select: { id: true, name: true, color: true },
orderBy: { name: 'asc' },
}),
+ prisma.transferRule.findMany({
+ where: { userId: session.user.id },
+ orderBy: { createdAt: 'asc' },
+ select: { id: true, pattern: true },
+ }),
])
// Serialize dates for client components
@@ -69,7 +75,10 @@ export default async function TransactionsPage({ searchParams }: { searchParams:
return (
-
Transactions
+
+
Transactions
+
+
}
+
+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 })
+}
diff --git a/src/app/api/transfer-rules/route.ts b/src/app/api/transfer-rules/route.ts
new file mode 100644
index 0000000..baa42bb
--- /dev/null
+++ b/src/app/api/transfer-rules/route.ts
@@ -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 })
+}
diff --git a/src/app/api/upload/route.ts b/src/app/api/upload/route.ts
index aa49955..23913ba 100644
--- a/src/app/api/upload/route.ts
+++ b/src/app/api/upload/route.ts
@@ -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 }
diff --git a/src/components/transactions/TransactionTable.tsx b/src/components/transactions/TransactionTable.tsx
index 6d10ad3..b7841e7 100644
--- a/src/components/transactions/TransactionTable.tsx
+++ b/src/components/transactions/TransactionTable.tsx
@@ -6,7 +6,7 @@ import { usePathname, useSearchParams, useRouter } from 'next/navigation'
import {
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
} 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 { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
import { Textarea } from '@/components/ui/textarea'
@@ -53,7 +53,7 @@ export function TransactionTable({
const router = useRouter()
const [editing, setEditing] = useState(null)
const [selected, setSelected] = useState>(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('__none__')
const [notesValue, setNotesValue] = useState('')
const [working, setWorking] = useState(false)
@@ -125,6 +125,10 @@ export function TransactionTable({
await bulkPost({ action: 'addNotes', ids: [...selected], notes: notesValue })
}
+ async function handleMarkTransfer() {
+ await bulkPost({ action: 'markTransfer', ids: [...selected] })
+ }
+
if (transactions.length === 0 && !someSelected) {
return (
@@ -156,6 +160,13 @@ export function TransactionTable({
>
Budget
+