Files
finance-app/src/app/(app)/dashboard/page.tsx
jerick dc45f489a6 Fix budget spend showing negative when TRANSFER transactions assigned
TRANSFER type fell into the ELSE branch of the spend CASE expression,
treating transfers as negative spend like refunds. Explicitly handle
each type: DEBIT=positive, CREDIT=negative, TRANSFER=0.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 17:25:42 +00:00

143 lines
5.0 KiB
TypeScript

import { auth } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { monthBounds, monthLabel } from '@/lib/utils/dates'
import { NetWorthCard } from '@/components/dashboard/NetWorthCard'
import { CashFlowCard } from '@/components/dashboard/CashFlowCard'
import { RecentTransactions } from '@/components/dashboard/RecentTransactions'
import { BudgetSummary } from '@/components/dashboard/BudgetSummary'
import { MonthYearPicker } from '@/components/dashboard/MonthYearPicker'
type SearchParams = Promise<Record<string, string | string[] | undefined>>
export default async function DashboardPage({ searchParams }: { searchParams: SearchParams }) {
const session = await auth()
if (!session?.user?.id) return null
const userId = session.user.id
const sp = await searchParams
const get = (k: string) => (Array.isArray(sp[k]) ? sp[k][0] : sp[k]) ?? ''
const now = new Date()
const month = Number(get('month')) || (now.getMonth() + 1)
const year = Number(get('year')) || now.getFullYear()
const isCurrentMonth = month === (now.getMonth() + 1) && year === now.getFullYear()
const selectedDate = new Date(year, month - 1, 1)
const { start, end } = monthBounds(selectedDate)
const [cashFlowRows, budgets, spendRows, recentTx] = await Promise.all([
prisma.$queryRaw<{ type: string; total: bigint }[]>`
SELECT t.type, COALESCE(SUM(t."amountCents"), 0)::bigint AS total
FROM "Transaction" t
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
`,
prisma.budget.findMany({
where: { userId, isActive: true },
orderBy: { name: 'asc' },
}),
prisma.$queryRaw<{ budgetId: string; total: bigint }[]>`
SELECT t."budgetId",
COALESCE(SUM(CASE WHEN t.type = 'DEBIT' THEN t."amountCents" WHEN t.type = 'CREDIT' THEN -t."amountCents" ELSE 0 END), 0)::bigint AS total
FROM "Transaction" t
JOIN "Account" a ON t."accountId" = a.id
WHERE a."userId" = ${userId}
AND t."budgetId" IS NOT NULL
AND t.date >= ${start}
AND t.date <= ${end}
GROUP BY t."budgetId"
`,
prisma.transaction.findMany({
where: {
account: { userId },
date: { gte: start, lte: end },
},
include: { account: { select: { name: true } } },
orderBy: { date: 'desc' },
take: 5,
}),
])
// Net worth: live balances for current month, snapshots for past months
let netWorthCents: number
let netWorthAccounts: { id: string; name: string; balanceCents: number }[]
if (isCurrentMonth) {
const accounts = await prisma.account.findMany({
where: { userId, isActive: true, type: { in: ['BANK', 'INVESTMENT'] } },
select: { id: true, name: true, currentBalanceCents: true },
orderBy: { name: 'asc' },
})
netWorthAccounts = accounts.map((a) => ({ id: a.id, name: a.name, balanceCents: a.currentBalanceCents }))
netWorthCents = netWorthAccounts.reduce((s, a) => s + a.balanceCents, 0)
} else {
const snapshots = await prisma.$queryRaw<{ accountId: string; name: string; balanceCents: bigint }[]>`
SELECT bs."accountId", a.name, bs."balanceCents"
FROM "BalanceSnapshot" bs
JOIN "Account" a ON bs."accountId" = a.id
WHERE a."userId" = ${userId}
AND a.type IN ('BANK', 'INVESTMENT')
AND bs.year = ${year}
AND bs.month = ${month}
`
netWorthAccounts = snapshots.map((s) => ({
id: s.accountId,
name: s.name,
balanceCents: Number(s.balanceCents),
}))
netWorthCents = netWorthAccounts.reduce((s, a) => s + a.balanceCents, 0)
}
const cfMap = Object.fromEntries(cashFlowRows.map((r) => [r.type, Number(r.total)]))
const creditsCents = cfMap['CREDIT'] ?? 0
const debitsCents = cfMap['DEBIT'] ?? 0
const spendMap = new Map(spendRows.map((r) => [r.budgetId, Number(r.total)]))
const recentTransactions = recentTx.map((t) => ({
id: t.id,
date: t.date.toISOString(),
description: t.description,
amountCents: t.amountCents,
type: t.type,
accountName: t.account.name,
}))
const budgetsWithSpend = budgets.map((b) => ({
id: b.id,
name: b.name,
limitCents: b.limitCents,
color: b.color,
spendCents: spendMap.get(b.id) ?? 0,
}))
return (
<div className="p-6 space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">Dashboard</h1>
<MonthYearPicker />
</div>
<div className="grid gap-4 sm:grid-cols-2">
<NetWorthCard netWorthCents={netWorthCents} bankAccounts={netWorthAccounts} />
<CashFlowCard
monthLabel={monthLabel(selectedDate)}
creditsCents={creditsCents}
debitsCents={debitsCents}
netCents={creditsCents - debitsCents}
/>
</div>
<div className="grid gap-4 lg:grid-cols-2">
<RecentTransactions transactions={recentTransactions} />
<BudgetSummary budgets={budgetsWithSpend} />
</div>
</div>
)
}