From 6add95813248aa7d03f36d6a4a8283e89c456ad4 Mon Sep 17 00:00:00 2001 From: siddharthd Date: Tue, 28 Jul 2026 13:40:48 +1000 Subject: [PATCH] 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. --- src/app/api/transactions/[id]/splits/route.ts | 37 +++++++++++++++++-- 1 file changed, 33 insertions(+), 4 deletions(-) diff --git a/src/app/api/transactions/[id]/splits/route.ts b/src/app/api/transactions/[id]/splits/route.ts index 1326952..28587b5 100644 --- a/src/app/api/transactions/[id]/splits/route.ts +++ b/src/app/api/transactions/[id]/splits/route.ts @@ -66,18 +66,47 @@ export async function POST( ); } + // 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) => - prisma.transaction_splits.create({ + ...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(