Compare commits

..

17 Commits

Author SHA1 Message Date
1400aa99d6 Enforce positive=CREDIT for all BANK account uploads
Instead of relying on per-profile invertAmountSign settings, the upload
route now overrides invertAmountSign: true for any BANK account using
Strategy A. This ensures positive CSV amounts always map to CREDIT
(deposits) regardless of which bank or whether profile was auto-detected.
Credit card logic is unchanged.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 09:39:14 -04:00
948ac2afe6 Fix Huntington profile detection: remove optional Split/Tags columns
Huntington CSVs don't always include Split and Tags headers, causing
detection to fail and fall back to the manual mapper with wrong defaults.
Detect on Date, Description, Amount which are always present.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 09:37:28 -04:00
705a23c520 Revert bank profiles to invertAmountSign: true
Huntington and Fidelity CSVs use positive for deposits (CREDIT) and
negative for withdrawals (DEBIT). The previous change to false was wrong.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 09:32:36 -04:00
a472749b21 Fix bank account sign convention: positive = DEBIT (withdrawal)
Huntington and Fidelity CSVs use positive amounts for withdrawals/
purchases and negative for deposits/credits. Change invertAmountSign
to false so the normalizer correctly maps them.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 22:38:13 -04:00
99e41aab78 Fix pagination resetting to page 1 on navigation
push() depended on searchParams, causing the search debounce effect to
re-fire on every page change and delete the page param. Store searchParams
in a ref so push() is stable and only the search value triggers it.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 21:20:22 -04:00
038539c191 Remove dedupeHash and duplicate skipping from CSV upload
Drop dedupeHash field and unique constraint from Transaction model.
Remove skipDuplicates from createMany. All rows in every upload are
now inserted unconditionally.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 21:02:54 -04:00
decfb19ec6 Add restart: unless-stopped to db and app services
Automatically restarts both containers on crash or server reboot,
unless manually stopped with docker compose down.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 20:56:33 -04:00
3c13ae3597 Fix budget SelectValue showing ID instead of name in edit dialog
Pass budget name as explicit children to SelectValue so the selected
label renders correctly. Also handle TRANSFER type display in the header.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 20:28:36 -04:00
6f1376cc53 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>
2026-04-21 20:15:07 -04:00
34bf24b35d Fix Discover CC sign convention: negative CSV = charge (DEBIT)
Discover CC exports charges as negative amounts and payments/refunds
as positive. invertAmountSign: true maps negative -> DEBIT (charge)
and positive -> CREDIT (payment/refund), which is correct.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 16:57:19 -04:00
587ac19b18 Revert budget to net DEBIT-CREDIT formula, remove fix-cc-types endpoint
Budget formula correctly adds DEBIT (CC charges stored from positive CSV
values) and subtracts CREDIT (refunds from negative CSV values).
Remove the fix-cc-types endpoint which was flipping CC transaction types
and causing charges to appear as CREDIT.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 16:46:49 -04:00
00d4796008 Fix budget totals going negative
Budget formula now sums DEBIT transactions only, matching the intended
"current-month DEBIT total" behavior. Previously, CREDIT transactions
(CC payments) assigned to a budget would subtract and push totals negative.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 16:40:03 -04:00
5a795d7e93 Fix stale balances after transaction delete
- Bulk delete now recomputes currentBalanceCents and BalanceSnapshot
  records for every affected account+month after deletion
- Add POST /api/admin/recalculate-balances to fix currently stale
  accounts (zeros out balances and removes orphaned snapshots)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 16:30:24 -04:00
60dabb6264 Recompute account balance after bulk transaction delete
After deleting transactions, recalculate currentBalanceCents for each
affected account so the account card and net worth dashboard stay accurate.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 16:24:59 -04:00
da938c1fcf Fix account delete blocked by CsvUpload FK constraint
Add onDelete: Cascade to CsvUpload.accountId so deleting an account
cascades to its upload records. Also explicitly delete uploads before
the account in the API route so existing deployed DBs without the
constraint don't error.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 16:18:25 -04:00
0cf4612106 Add INVESTMENT account type with manual portfolio value recording 2026-04-21 11:32:50 -04:00
0ea6a7c698 Fix account select showing cuid instead of name in transaction filters 2026-04-21 11:05:11 -04:00
25 changed files with 537 additions and 101 deletions

View File

@@ -1,6 +1,7 @@
services:
db:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
@@ -17,6 +18,7 @@ services:
app:
build: .
restart: unless-stopped
ports:
- "0.0.0.0:3000:3000"
environment:

View File

@@ -13,14 +13,16 @@ 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 {
BANK
CREDIT_CARD
INVESTMENT
}
model Account {
@@ -44,6 +46,7 @@ model Account {
enum TransactionType {
DEBIT
CREDIT
TRANSFER
}
model Transaction {
@@ -60,11 +63,9 @@ model Transaction {
type TransactionType
category String?
notes String?
dedupeHash String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([dedupeHash])
@@index([accountId, date])
@@index([date])
@@index([budgetId])
@@ -99,10 +100,20 @@ 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
account Account @relation(fields: [accountId], references: [id])
account Account @relation(fields: [accountId], references: [id], onDelete: Cascade)
fileName String
rowCount Int
importedCount Int

View File

@@ -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
@@ -68,7 +69,7 @@ export default async function DashboardPage({ searchParams }: { searchParams: Se
if (isCurrentMonth) {
const accounts = await prisma.account.findMany({
where: { userId, isActive: true, type: 'BANK' },
where: { userId, isActive: true, type: { in: ['BANK', 'INVESTMENT'] } },
select: { id: true, name: true, currentBalanceCents: true },
orderBy: { name: 'asc' },
})
@@ -80,7 +81,7 @@ export default async function DashboardPage({ searchParams }: { searchParams: Se
FROM "BalanceSnapshot" bs
JOIN "Account" a ON bs."accountId" = a.id
WHERE a."userId" = ${userId}
AND a.type = 'BANK'
AND a.type IN ('BANK', 'INVESTMENT')
AND bs.year = ${year}
AND bs.month = ${month}
`

View File

@@ -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 (
<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} />
<TransactionTable
transactions={rows}

View File

@@ -0,0 +1,44 @@
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { auth } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
const schema = z.object({
valueCents: z.number().int(),
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Date must be YYYY-MM-DD'),
})
export async function POST(
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 account = await prisma.account.findFirst({
where: { id, userId: session.user.id, type: 'INVESTMENT' },
})
if (!account) return NextResponse.json({ error: 'Account not found' }, { status: 404 })
const result = schema.safeParse(await req.json())
if (!result.success) return NextResponse.json({ error: result.error.flatten() }, { status: 400 })
const { valueCents, date } = result.data
const d = new Date(date + 'T12:00:00')
const year = d.getFullYear()
const month = d.getMonth() + 1
await prisma.account.update({
where: { id },
data: { currentBalanceCents: valueCents },
})
await prisma.balanceSnapshot.upsert({
where: { accountId_year_month: { accountId: id, year, month } },
update: { balanceCents: valueCents, computedAt: new Date() },
create: { accountId: id, year, month, balanceCents: valueCents },
})
return NextResponse.json({ ok: true })
}

View File

@@ -58,6 +58,7 @@ export async function DELETE(_req: Request, { params }: Params) {
})
if (!existing) return NextResponse.json({ error: 'Not found' }, { status: 404 })
await prisma.csvUpload.deleteMany({ where: { accountId: id } })
await prisma.account.delete({ where: { id } })
return new NextResponse(null, { status: 204 })
}

View File

@@ -1,35 +0,0 @@
import { NextResponse } from 'next/server'
import { auth } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
// One-time migration: flips DEBIT↔CREDIT on all CREDIT_CARD account transactions.
// CC CSVs have inverted sign semantics (CREDIT = charge, DEBIT = refund), but we now
// normalise them to standard semantics (DEBIT = spend) at upload time. Existing rows
// uploaded before that fix need their type flipped.
export async function POST() {
const session = await auth()
if (!session?.user?.id) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const userId = session.user.id
const ccAccounts = await prisma.account.findMany({
where: { userId, type: 'CREDIT_CARD' },
select: { id: true },
})
if (ccAccounts.length === 0) {
return NextResponse.json({ updated: 0, message: 'No credit card accounts found' })
}
const ccIds = ccAccounts.map((a) => a.id)
// Flip both directions in one raw statement to avoid stepping on our own update
const result = await prisma.$executeRaw`
UPDATE "Transaction"
SET type = CASE WHEN type = 'DEBIT' THEN 'CREDIT'::"TransactionType"
ELSE 'DEBIT'::"TransactionType" END
WHERE "accountId" = ANY(${ccIds}::text[])
`
return NextResponse.json({ updated: result })
}

View File

@@ -0,0 +1,42 @@
import { NextResponse } from 'next/server'
import { auth } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
export async function POST() {
const session = await auth()
if (!session?.user?.id) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const userId = session.user.id
const accounts = await prisma.account.findMany({
where: { userId },
select: { id: true },
})
for (const account of accounts) {
const [balRow] = await prisma.$queryRaw<[{ balance: bigint }]>`
SELECT COALESCE(SUM(
CASE WHEN type = 'CREDIT' THEN "amountCents" ELSE -"amountCents" END
), 0)::bigint AS balance
FROM "Transaction"
WHERE "accountId" = ${account.id}
`
await prisma.account.update({
where: { id: account.id },
data: { currentBalanceCents: Number(balRow.balance) },
})
}
// Remove all snapshots for accounts that now have no transactions
await prisma.$executeRaw`
DELETE FROM "BalanceSnapshot"
WHERE "accountId" IN (
SELECT id FROM "Account" WHERE "userId" = ${userId}
)
AND NOT EXISTS (
SELECT 1 FROM "Transaction" WHERE "accountId" = "BalanceSnapshot"."accountId"
)
`
return NextResponse.json({ ok: true, accounts: accounts.length })
}

View File

@@ -18,8 +18,50 @@ 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) {
const [balRow] = await prisma.$queryRaw<[{ balance: bigint }]>`
SELECT COALESCE(SUM(
CASE WHEN type = 'CREDIT' THEN "amountCents" ELSE -"amountCents" END
), 0)::bigint AS balance
FROM "Transaction"
WHERE "accountId" = ${accountId}
`
await prisma.account.update({
where: { id: accountId },
data: { currentBalanceCents: Number(balRow.balance) },
})
}
async function recomputeSnapshot(accountId: string, year: number, month: number) {
const endOfMonth = new Date(year, month, 0, 23, 59, 59, 999)
const [snap] = await prisma.$queryRaw<[{ balance: bigint }]>`
SELECT COALESCE(SUM(
CASE WHEN type = 'CREDIT' THEN "amountCents" ELSE -"amountCents" END
), 0)::bigint AS balance
FROM "Transaction"
WHERE "accountId" = ${accountId}
AND date <= ${endOfMonth}
`
const balanceCents = Number(snap.balance)
if (balanceCents === 0) {
await prisma.balanceSnapshot.deleteMany({
where: { accountId, year, month },
})
} else {
await prisma.balanceSnapshot.upsert({
where: { accountId_year_month: { accountId, year, month } },
update: { balanceCents, computedAt: new Date() },
create: { accountId, year, month, balanceCents },
})
}
}
export async function POST(req: Request) {
const session = await auth()
if (!session?.user?.id) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
@@ -34,14 +76,28 @@ export async function POST(req: Request) {
// Verify all transaction IDs belong to this user
const owned = await prisma.transaction.findMany({
where: { id: { in: ids }, account: { userId } },
select: { id: true },
select: { id: true, accountId: true, date: true },
})
if (owned.length !== ids.length) {
return NextResponse.json({ error: 'One or more transactions not found' }, { status: 404 })
}
if (action === 'delete') {
// Collect affected account+month combos before deleting
const accountIds = [...new Set(owned.map((t) => t.accountId))]
const monthKeys = new Map<string, { accountId: string; year: number; month: number }>()
for (const t of owned) {
const y = t.date.getFullYear()
const m = t.date.getMonth() + 1
monthKeys.set(`${t.accountId}-${y}-${m}`, { accountId: t.accountId, year: y, month: m })
}
await prisma.transaction.deleteMany({ where: { id: { in: ids } } })
// Recompute current balance and snapshots for affected accounts/months
await Promise.all(accountIds.map(recomputeAccount))
await Promise.all([...monthKeys.values()].map((k) => recomputeSnapshot(k.accountId, k.year, k.month)))
return NextResponse.json({ deleted: ids.length })
}
@@ -58,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

@@ -74,16 +74,34 @@ export async function POST(req: Request) {
config = result.data
}
// For all BANK accounts using a single amount column, positive = deposit (CREDIT),
// negative = withdrawal (DEBIT). Override whatever the profile or manual mapping says.
if (account.type === 'BANK' && config.strategy === 'A') {
config = { ...config, invertAmountSign: true }
}
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 }
@@ -114,9 +132,7 @@ export async function POST(req: Request) {
type: r.type,
category: r.category ?? null,
budgetId: r.budgetId,
dedupeHash: r.dedupeHash,
})),
skipDuplicates: true,
})
const skippedCount = normalized.length - importedCount

View File

@@ -2,9 +2,7 @@ import { Badge } from '@/components/ui/badge'
import { AccountType } from '@/generated/prisma/client'
export function AccountBadge({ type }: { type: AccountType }) {
return type === 'BANK' ? (
<Badge variant="secondary">Bank</Badge>
) : (
<Badge variant="outline">Credit Card</Badge>
)
if (type === 'BANK') return <Badge variant="secondary">Bank</Badge>
if (type === 'INVESTMENT') return <Badge variant="secondary" className="bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">Investment</Badge>
return <Badge variant="outline">Credit Card</Badge>
}

View File

@@ -9,14 +9,16 @@ 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, TrendingUp } from 'lucide-react'
import { AccountBadge } from './AccountBadge'
import { CreateAccountDialog } from './CreateAccountDialog'
import { RecordValueDialog } from './RecordValueDialog'
import { formatCents } from '@/lib/utils/currency'
export function AccountCard({ account }: { account: Account }) {
const router = useRouter()
const [editOpen, setEditOpen] = useState(false)
const [recordOpen, setRecordOpen] = useState(false)
async function handleToggleActive() {
await fetch(`/api/accounts/${account.id}`, {
@@ -56,9 +58,13 @@ export function AccountCard({ account }: { account: Account }) {
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setEditOpen(true)}>
<Pencil className="h-4 w-4 mr-2" />
Edit
<Pencil className="h-4 w-4 mr-2" />Edit
</DropdownMenuItem>
{account.type === 'INVESTMENT' && (
<DropdownMenuItem onClick={() => setRecordOpen(true)}>
<TrendingUp className="h-4 w-4 mr-2" />Record value
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={handleToggleActive}>
{account.isActive
? <><EyeOff className="h-4 w-4 mr-2" />Deactivate</>
@@ -87,11 +93,15 @@ export function AccountCard({ account }: { account: Account }) {
</CardContent>
</Card>
<CreateAccountDialog
open={editOpen}
onOpenChange={setEditOpen}
account={account}
/>
<CreateAccountDialog open={editOpen} onOpenChange={setEditOpen} account={account} />
{account.type === 'INVESTMENT' && (
<RecordValueDialog
open={recordOpen}
onOpenChange={setRecordOpen}
accountId={account.id}
accountName={account.name}
/>
)}
</>
)
}

View File

@@ -93,6 +93,7 @@ export function CreateAccountDialog({ open, onOpenChange, account }: Props) {
<SelectContent>
<SelectItem value="BANK">Bank</SelectItem>
<SelectItem value="CREDIT_CARD">Credit Card</SelectItem>
<SelectItem value="INVESTMENT">Investment</SelectItem>
</SelectContent>
</Select>
</div>

View File

@@ -0,0 +1,77 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Button } from '@/components/ui/button'
interface Props {
open: boolean
onOpenChange: (open: boolean) => void
accountId: string
accountName: string
}
export function RecordValueDialog({ open, onOpenChange, accountId, accountName }: Props) {
const router = useRouter()
const [value, setValue] = useState('')
const [date, setDate] = useState(() => new Date().toISOString().split('T')[0])
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
const num = parseFloat(value.replace(/[$,]/g, ''))
if (isNaN(num)) { setError('Enter a valid dollar amount'); return }
setError('')
setSaving(true)
const res = await fetch(`/api/accounts/${accountId}/record-value`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ valueCents: Math.round(num * 100), date }),
})
setSaving(false)
if (!res.ok) { setError('Failed to save. Please try again.'); return }
onOpenChange(false)
router.refresh()
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>Record portfolio value {accountName}</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4 pt-1">
<div className="space-y-2">
<Label htmlFor="rv-value">Portfolio value</Label>
<Input
id="rv-value"
placeholder="e.g. 12500.00"
value={value}
onChange={(e) => setValue(e.target.value)}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="rv-date">As of date</Label>
<Input
id="rv-date"
type="date"
value={date}
onChange={(e) => setDate(e.target.value)}
required
/>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>Cancel</Button>
<Button type="submit" disabled={saving}>{saving ? 'Saving…' : 'Save'}</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}

View File

@@ -80,8 +80,8 @@ export function EditTransactionDialog({ transaction, onOpenChange, budgets }: Pr
{transaction.description}
</DialogTitle>
<p className="text-sm text-muted-foreground">
{date} · <span className={transaction.type === 'CREDIT' ? 'text-green-600' : ''}>
{transaction.type === 'CREDIT' ? '+' : '-'}{formatCents(transaction.amountCents)}
{date} · <span className={transaction.type === 'CREDIT' ? 'text-green-600' : transaction.type === 'TRANSFER' ? 'text-muted-foreground' : ''}>
{transaction.type === 'TRANSFER' ? '⇄ ' : transaction.type === 'CREDIT' ? '+' : '-'}{formatCents(transaction.amountCents)}
</span>
</p>
</DialogHeader>
@@ -101,7 +101,11 @@ export function EditTransactionDialog({ transaction, onOpenChange, budgets }: Pr
<Label htmlFor="budget">Budget</Label>
<Select value={budgetId} onValueChange={(v) => setBudgetId(v ?? '')}>
<SelectTrigger id="budget">
<SelectValue placeholder="No budget" />
<SelectValue placeholder="No budget">
{budgetId
? (budgets.find((b) => b.id === budgetId)?.name ?? 'No budget')
: 'No budget'}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="">No budget</SelectItem>

View File

@@ -1,6 +1,6 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useRouter, useSearchParams, usePathname } from 'next/navigation'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
@@ -20,17 +20,19 @@ export function TransactionFilters({ accounts }: { accounts: AccountOption[] })
const sp = (key: string) => searchParams.get(key) ?? ''
const [search, setSearch] = useState(sp('search'))
const searchParamsRef = useRef(searchParams)
useEffect(() => { searchParamsRef.current = searchParams }, [searchParams])
const push = useCallback(
(updates: Record<string, string>) => {
const params = new URLSearchParams(searchParams.toString())
const params = new URLSearchParams(searchParamsRef.current.toString())
for (const [k, v] of Object.entries(updates)) {
if (v) params.set(k, v); else params.delete(k)
}
params.delete('page')
router.replace(`${pathname}?${params.toString()}`)
},
[searchParams, pathname, router],
[pathname, router],
)
// Debounce search → URL
@@ -52,7 +54,9 @@ export function TransactionFilters({ accounts }: { accounts: AccountOption[] })
<Label className="text-xs">Account</Label>
<Select value={sp('accountId')} onValueChange={(v) => push({ accountId: v ?? '' })}>
<SelectTrigger className="h-8 w-44 text-sm">
<SelectValue placeholder="All accounts" />
<SelectValue placeholder="All accounts">
{accounts.find((a) => a.id === sp('accountId'))?.name ?? 'All accounts'}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="">All accounts</SelectItem>

View File

@@ -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<TransactionRow | null>(null)
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 [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 (
<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
</Button>
<Button
size="sm"
variant="outline"
onClick={() => setBulkDialog('transfer')}
>
<ArrowLeftRight className="h-3.5 w-3.5 mr-1.5" />Transfer
</Button>
<Button
size="sm"
variant="destructive"
@@ -195,6 +206,7 @@ export function TransactionTable({
<TableBody>
{transactions.map((tx) => {
const isCredit = tx.type === 'CREDIT'
const isTransfer = tx.type === 'TRANSFER'
const isSelected = selected.has(tx.id)
const dateStr = new Date(tx.date).toLocaleDateString('en-US', {
month: 'short', day: 'numeric', year: 'numeric',
@@ -225,8 +237,8 @@ export function TransactionTable({
</TableCell>
)}
<TableCell className="text-right tabular-nums font-medium">
<span className={cn('text-sm', isCredit ? 'text-green-600' : '')}>
{isCredit ? '+' : '-'}{formatCents(tx.amountCents)}
<span className={cn('text-sm', isTransfer ? 'text-muted-foreground' : isCredit ? 'text-green-600' : '')}>
{isTransfer ? '⇄ ' : isCredit ? '+' : '-'}{formatCents(tx.amountCents)}
</span>
</TableCell>
<TableCell className="text-sm text-muted-foreground">
@@ -356,6 +368,25 @@ export function TransactionTable({
</DialogContent>
</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 */}
<Dialog open={bulkDialog === 'notes'} onOpenChange={(o) => { if (!o) setBulkDialog(null) }}>
<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

@@ -40,7 +40,7 @@ export const bankProfiles: BankProfile[] = [
dateColumn: 'Trans. Date',
descriptionColumn: 'Description',
amountColumn: 'Amount',
invertAmountSign: false, // positive = DEBIT (charge), negative = CREDIT (payment)
invertAmountSign: true, // negative = DEBIT (charge), positive = CREDIT (payment/refund)
categoryColumn: 'Category',
detectColumns: ['Trans. Date', 'Post Date', 'Description', 'Amount', 'Category'],
},
@@ -52,7 +52,7 @@ export const bankProfiles: BankProfile[] = [
dateColumn: 'Date',
descriptionColumn: 'Description',
amountColumn: 'Amount',
invertAmountSign: true, // negative = DEBIT, positive = CREDIT
invertAmountSign: true, // negative = DEBIT (withdrawal), positive = CREDIT (deposit)
detectColumns: ['Date', 'Description', 'Amount', 'Split', 'Tags'],
},
{
@@ -63,7 +63,7 @@ export const bankProfiles: BankProfile[] = [
dateColumn: 'Run Date',
descriptionColumn: 'Description',
amountColumn: 'Amount($)',
invertAmountSign: true, // negative = DEBIT (purchase), positive = CREDIT
invertAmountSign: true, // negative = DEBIT (purchase/withdrawal), positive = CREDIT (deposit/dividend)
detectColumns: ['Run Date', 'Description', 'Amount($)'],
},
]

View File

@@ -1,4 +1,3 @@
import { createHash } from 'crypto'
import type { NormalizerConfig } from './bank-profiles'
export interface NormalizedRow {
@@ -7,7 +6,6 @@ export interface NormalizedRow {
amountCents: number
type: 'DEBIT' | 'CREDIT'
category?: string
dedupeHash: string
}
export function parseCents(raw: string): number {
@@ -51,18 +49,6 @@ function strategyB(
return { amountCents: Math.abs(parseCents(creditRaw)), type: 'CREDIT' }
}
function dedupeHash(
accountId: string,
date: Date,
description: string,
amountCents: number,
): string {
const dateStr = date.toISOString().split('T')[0]
return createHash('sha256')
.update(`${accountId}|${dateStr}|${description}|${amountCents}`)
.digest('hex')
}
export function normalizeRows(
rows: Record<string, string>[],
accountId: string,
@@ -100,7 +86,6 @@ export function normalizeRows(
amountCents,
type,
category: rawCategory || undefined,
dedupeHash: dedupeHash(accountId, date, description, amountCents),
})
} catch {
// skip unparseable rows

View File

@@ -3,7 +3,7 @@ import { z } from 'zod'
export const createAccountSchema = z.object({
name: z.string().min(1, 'Name is required').max(100),
institution: z.string().max(100).optional(),
type: z.enum(['BANK', 'CREDIT_CARD']),
type: z.enum(['BANK', 'CREDIT_CARD', 'INVESTMENT']),
currency: z.string().length(3).default('USD'),
})

View File

@@ -4,7 +4,7 @@ export const transactionQuerySchema = z.object({
accountId: z.string().optional(),
dateFrom: z.string().optional(),
dateTo: z.string().optional(),
type: z.enum(['DEBIT', 'CREDIT']).optional(),
type: z.enum(['DEBIT', 'CREDIT', 'TRANSFER']).optional(),
search: z.string().optional(),
budgetId: z.string().optional(),
page: z.coerce.number().min(1).default(1),