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:
42
src/app/api/admin/recalculate-balances/route.ts
Normal file
42
src/app/api/admin/recalculate-balances/route.ts
Normal 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 })
|
||||
}
|
||||
@@ -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<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 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 })
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user