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) {
|
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 })
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user