Files
finance-app/src/app/api/transactions/[id]/splits/route.ts
T
siddharthd 6add958132
ci / lint-test (push) Successful in 47s
fix(splits): editing a split must not resurrect a settled debt
The route replaces every split for a transaction rather than editing in place,
so the recreated rows took the column default settled=false. Opening the split
modal on a historical transaction and saving it therefore converted a
discharged obligation into a live one, with nothing on screen saying so.

That is not theoretical. 657 pre-2026 transactions now carry settled splits
imported from SplitMyExpenses -- $37,233.28 of balance that the carryover
(transaction 2348) already accounts for. Editing one would double-count its
share against a debt that was paid years ago.

Now carries settled and settled_at across the rewrite, per participant, the
same way the rules revert route already does. Changing someone's percentage
does not re-open the obligation: it was settled outside this app and stays
settled. A participant who was not on the transaction before is a genuinely new
obligation and correctly starts unsettled.

rule-actions.ts was already safe here -- it upserts ON CONFLICT DO UPDATE SET
share_percent, so it never touches the flag.
2026-07-28 13:40:48 +10:00

121 lines
4.0 KiB
TypeScript

import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { queryRaw } from "@/lib/db";
import { getCurrentUser } from "@/lib/auth";
import { canAccessTransactions } from "@/lib/queries";
interface SplitInput {
participant_id: number;
share_percent: number;
}
interface SplitRow {
id: number;
transaction_id: number;
participant_id: number;
name: string;
share_percent: number;
settled: boolean;
settled_at: string | null;
created_at: string;
}
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const user = await getCurrentUser(req);
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
const { id } = await params;
if (!(await canAccessTransactions(user.id, [Number(id)]))) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
const splits = await queryRaw<SplitRow>(
`SELECT ts.*, p.name
FROM transaction_splits ts
JOIN participants p ON p.id = ts.participant_id
WHERE ts.transaction_id = $1
ORDER BY p.name`,
[Number(id)]
);
return NextResponse.json(splits);
}
export async function POST(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const user = await getCurrentUser(req);
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
const { id } = await params;
const transactionId = Number(id);
if (!(await canAccessTransactions(user.id, [transactionId]))) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
const { splits } = (await req.json()) as { splits: SplitInput[] };
if (!splits || !Array.isArray(splits) || splits.length === 0) {
return NextResponse.json({ error: "splits array required" }, { status: 400 });
}
const total = splits.reduce((sum, s) => sum + Number(s.share_percent), 0);
if (Math.abs(total - 100) > 0.01) {
return NextResponse.json(
{ error: `Shares must sum to 100%, got ${total}%` },
{ status: 400 }
);
}
// Carry `settled` across the rewrite.
//
// This replaces every split rather than editing in place, so without this the
// recreated rows take the column default of false — silently converting a
// discharged historical obligation into a live debt. That is not theoretical:
// 657 pre-2026 transactions carry settled splits imported from
// SplitMyExpenses, $37,233.28 of balance that the carryover (transaction
// 2348) already accounts for. Editing one would double-count its share, and
// nothing on screen would say so.
//
// Changing someone's percentage does not re-open the obligation — it was
// settled outside this app and stays settled. A participant added who was not
// there before is a genuinely new obligation and correctly starts unsettled.
const previous = await queryRaw<{
participant_id: number;
settled: boolean;
settled_at: string | null;
}>(
`SELECT participant_id, settled, settled_at
FROM transaction_splits WHERE transaction_id = $1`,
[transactionId]
);
const settledBefore = new Map(
previous.map((p) => [p.participant_id, { settled: p.settled, settled_at: p.settled_at }])
);
// Replace all splits for this transaction atomically
await prisma.$transaction([
prisma.transaction_splits.deleteMany({ where: { transaction_id: transactionId } }),
...splits.map((s) => {
const before = settledBefore.get(s.participant_id);
return prisma.transaction_splits.create({
data: {
transaction_id: transactionId,
participant_id: s.participant_id,
share_percent: s.share_percent,
settled: before?.settled ?? false,
settled_at: before?.settled_at ? new Date(before.settled_at) : null,
},
});
}),
]);
const result = await queryRaw<SplitRow>(
`SELECT ts.*, p.name FROM transaction_splits ts
JOIN participants p ON p.id = ts.participant_id
WHERE ts.transaction_id = $1 ORDER BY p.name`,
[transactionId]
);
return NextResponse.json(result);
}