Files
finance-app/src/app/api/transactions/reconcile/route.ts
T
siddharthd b8cd1b0f89 fix(reconcile): prevent split/tag double-counting on reconciled transactions
Move splits, tags and overrides from manual to statement side on reconcile
(delete from manual after copying) instead of just copying. Add read-time
filter to exclude reconciled manual transactions from balance and shared
transaction queries. Also adds participant filter to shared expenses page.
2026-05-11 19:15:41 +10:00

92 lines
3.3 KiB
TypeScript

import { NextRequest, NextResponse } from "next/server";
import { getCurrentUser } from "@/lib/auth";
import { prisma, queryRaw } from "@/lib/db";
export async function POST(req: NextRequest) {
const user = await getCurrentUser(req);
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
const body = await req.json() as {
matches: { manual_id: number; statement_tx_id: number }[];
};
if (!Array.isArray(body.matches) || body.matches.length === 0) {
return NextResponse.json({ error: "No matches provided" }, { status: 400 });
}
// Verify all manual_ids belong to this user
const manualIds = body.matches.map((m) => m.manual_id);
const owned = await queryRaw<{ id: number }>(
`SELECT id FROM transactions WHERE id = ANY($1::int[]) AND statement_id IS NULL AND owner_id = $2`,
[manualIds, user.id]
);
if (owned.length !== manualIds.length) {
return NextResponse.json({ error: "One or more transactions not found" }, { status: 404 });
}
let reconciled = 0;
for (const { manual_id, statement_tx_id } of body.matches) {
await prisma.$transaction(async (tx) => {
// Copy overrides: manual → statement tx
const override = await tx.transaction_overrides.findUnique({
where: { transaction_id: manual_id },
});
if (override) {
await tx.transaction_overrides.upsert({
where: { transaction_id: statement_tx_id },
update: {
category_override: override.category_override,
merchant_normalized: override.merchant_normalized,
notes: override.notes,
my_share_percent: override.my_share_percent,
updated_at: new Date(),
},
create: {
transaction_id: statement_tx_id,
category_override: override.category_override,
merchant_normalized: override.merchant_normalized,
notes: override.notes,
my_share_percent: override.my_share_percent,
},
});
await tx.transaction_overrides.deleteMany({ where: { transaction_id: manual_id } });
}
// Move tags: manual → statement tx
const tags = await tx.transaction_tags.findMany({ where: { transaction_id: manual_id } });
if (tags.length) {
await tx.transaction_tags.createMany({
data: tags.map((t) => ({ transaction_id: statement_tx_id, tag_id: t.tag_id })),
skipDuplicates: true,
});
await tx.transaction_tags.deleteMany({ where: { transaction_id: manual_id } });
}
// Move splits: manual → statement tx
const splits = await tx.transaction_splits.findMany({ where: { transaction_id: manual_id } });
if (splits.length) {
await tx.transaction_splits.createMany({
data: splits.map((s) => ({
transaction_id: statement_tx_id,
participant_id: s.participant_id,
share_percent: s.share_percent,
})),
skipDuplicates: true,
});
await tx.transaction_splits.deleteMany({ where: { transaction_id: manual_id } });
}
// Mark manual tx as reconciled (link to statement tx)
await tx.$executeRawUnsafe(
`UPDATE transactions SET reconciled_with_id = $1 WHERE id = $2`,
statement_tx_id,
manual_id
);
});
reconciled++;
}
return NextResponse.json({ reconciled });
}