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>
This commit is contained in:
2026-04-21 16:30:24 -04:00
parent 60dabb6264
commit 5a795d7e93
2 changed files with 91 additions and 15 deletions

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

@@ -20,6 +20,44 @@ const bulkSchema = z.discriminatedUnion('action', [
}), }),
]) ])
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) { export async function POST(req: Request) {
const session = await auth() const session = await auth()
if (!session?.user?.id) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) if (!session?.user?.id) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
@@ -34,31 +72,27 @@ export async function POST(req: Request) {
// Verify all transaction IDs belong to this user // Verify all transaction IDs belong to this user
const owned = await prisma.transaction.findMany({ const owned = await prisma.transaction.findMany({
where: { id: { in: ids }, account: { userId } }, where: { id: { in: ids }, account: { userId } },
select: { id: true, accountId: true }, select: { id: true, accountId: true, date: true },
}) })
if (owned.length !== ids.length) { if (owned.length !== ids.length) {
return NextResponse.json({ error: 'One or more transactions not found' }, { status: 404 }) return NextResponse.json({ error: 'One or more transactions not found' }, { status: 404 })
} }
if (action === 'delete') { if (action === 'delete') {
// Collect affected account+month combos before deleting
const accountIds = [...new Set(owned.map((t) => t.accountId))] 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 } } }) await prisma.transaction.deleteMany({ where: { id: { in: ids } } })
// Recompute currentBalanceCents for each affected account // Recompute current balance and snapshots for affected accounts/months
for (const accountId of accountIds) { await Promise.all(accountIds.map(recomputeAccount))
const [balRow] = await prisma.$queryRaw<[{ balance: bigint }]>` await Promise.all([...monthKeys.values()].map((k) => recomputeSnapshot(k.accountId, k.year, k.month)))
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) },
})
}
return NextResponse.json({ deleted: ids.length }) return NextResponse.json({ deleted: ids.length })
} }