From 5a795d7e93321a689a7a1354ef94916366b59f71 Mon Sep 17 00:00:00 2001 From: jerick Date: Tue, 21 Apr 2026 16:30:24 -0400 Subject: [PATCH] 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 --- .../api/admin/recalculate-balances/route.ts | 42 ++++++++++++ src/app/api/transactions/bulk/route.ts | 64 ++++++++++++++----- 2 files changed, 91 insertions(+), 15 deletions(-) create mode 100644 src/app/api/admin/recalculate-balances/route.ts diff --git a/src/app/api/admin/recalculate-balances/route.ts b/src/app/api/admin/recalculate-balances/route.ts new file mode 100644 index 0000000..620215b --- /dev/null +++ b/src/app/api/admin/recalculate-balances/route.ts @@ -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 }) +} diff --git a/src/app/api/transactions/bulk/route.ts b/src/app/api/transactions/bulk/route.ts index 5ca66b3..5e27fba 100644 --- a/src/app/api/transactions/bulk/route.ts +++ b/src/app/api/transactions/bulk/route.ts @@ -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) { const session = await auth() 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 const owned = await prisma.transaction.findMany({ where: { id: { in: ids }, account: { userId } }, - select: { id: true, accountId: 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() + 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 currentBalanceCents for each affected account - for (const accountId of accountIds) { - 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) }, - }) - } + // 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 }) }