From 788219b9fd8705094fde90bfee497ac1a321ac67 Mon Sep 17 00:00:00 2001 From: siddharthd Date: Tue, 28 Jul 2026 13:52:30 +1000 Subject: [PATCH] fix(splits): the cutover date, not a boolean, is what gates a balance Nothing dated before 2026-01-09 can be owed, because carryover transaction 2348 already carries the entire pre-cutover balance as a single figure. ACTIVE_OBLIGATION now says so directly. This inverts which mechanism is load-bearing, and that is the point. Until now the only thing keeping $37,233.28 of paid debt out of the balances was transaction_splits.settled -- a boolean that any delete-and-recreate write path resets to false, as the split modal did until commit 6add958. Losing the flag on a pre-cutover row now costs nothing: the date still excludes it. The flag matters only on or after the cutover, marking the few settled outside this app. It also makes splitting history safe to do freely. A split on a 2024 grocery shop can now describe how the expense was shared -- which is what stops it inflating spend -- without asserting a debt that was settled years ago. That was the whole reason not to split pre-2026 expenses, and it no longer applies. The bound is inclusive because transaction 2348 is itself dated 2026-01-09; an exclusive one would drop the carryover and with it the entire pre-cutover balance. Corrects one live figure: a Woolworths on 2026-01-06 was split 50/50 three days before the cutover, double-counting $7.55 against the carryover. Sonu $5,428.08 -> $5,420.53. The test fixture default moved to 2026-06-15 -- it was 2024-06-15, which is now pre-cutover and made every balance fixture read zero. That the suite caught this is the guard working. --- src/__tests__/integration/helpers.ts | 5 ++- src/__tests__/integration/queries.test.ts | 55 +++++++++++++++++++++++ src/lib/analytics-sql.ts | 24 +++++++++- 3 files changed, 81 insertions(+), 3 deletions(-) diff --git a/src/__tests__/integration/helpers.ts b/src/__tests__/integration/helpers.ts index 0a7205e..c06745b 100644 --- a/src/__tests__/integration/helpers.ts +++ b/src/__tests__/integration/helpers.ts @@ -73,7 +73,10 @@ export async function insertTransaction( VALUES ($1, NULL, $2, $3, $4, $5, $6, 0) RETURNING id`, [ ownerId, - overrides.transaction_date ?? "2024-06-15", + // Post-cutover by default: a split on an older transaction never counts + // towards a balance (ACTIVE_OBLIGATION), so a pre-cutover default would + // make every balance fixture silently read zero. + overrides.transaction_date ?? "2026-06-15", overrides.description ?? "Test transaction", overrides.amount ?? 100, overrides.transaction_type ?? "debit", diff --git a/src/__tests__/integration/queries.test.ts b/src/__tests__/integration/queries.test.ts index 4ff8693..ef8ea0e 100644 --- a/src/__tests__/integration/queries.test.ts +++ b/src/__tests__/integration/queries.test.ts @@ -666,3 +666,58 @@ describe("superseded duplicates are excluded but kept", () => { ).rejects.toThrow(); }); }); + +// Nothing before the cutover can be owed: carryover transaction 2348 already +// carries the entire pre-cutover balance as one figure. Splits on older +// transactions exist to describe how an expense was shared -- which keeps it +// out of spend -- without asserting a debt. +describe("the split cutover gates every balance", () => { + it("ignores a split on a transaction before the cutover", async () => { + const { ownerId, otherId } = await seedParticipants(pool); + const txId = await insertTransaction(pool, ownerId, { + amount: 100, transaction_date: "2026-01-08", + }); + await pool.query( + `INSERT INTO transaction_splits (transaction_id, participant_id, share_percent) VALUES ($1, $2, 50)`, + [txId, otherId] + ); + + const balances = await getParticipantBalances(ownerId); + const bob = balances.find((b) => b.id === otherId); + expect(Number(bob?.total_owed ?? 0)).toBeCloseTo(0); + }); + + // Inclusive: transaction 2348, which carries the whole pre-cutover balance, + // is itself dated 2026-01-09. An exclusive bound would drop it. + it("counts a split dated exactly on the cutover", async () => { + const { ownerId, otherId } = await seedParticipants(pool); + const txId = await insertTransaction(pool, ownerId, { + amount: 100, transaction_date: "2026-01-09", + }); + await pool.query( + `INSERT INTO transaction_splits (transaction_id, participant_id, share_percent) VALUES ($1, $2, 50)`, + [txId, otherId] + ); + + const balances = await getParticipantBalances(ownerId); + const bob = balances.find((b) => b.id === otherId); + expect(Number(bob!.total_owed)).toBeCloseTo(50); + }); + + // The point of the date guard: it does not depend on `settled` surviving. + it("still ignores a pre-cutover split whose settled flag was lost", async () => { + const { ownerId, otherId } = await seedParticipants(pool); + const txId = await insertTransaction(pool, ownerId, { + amount: 200, transaction_date: "2025-06-01", + }); + await pool.query( + `INSERT INTO transaction_splits (transaction_id, participant_id, share_percent, settled) + VALUES ($1, $2, 50, false)`, + [txId, otherId] + ); + + const balances = await getParticipantBalances(ownerId); + const bob = balances.find((b) => b.id === otherId); + expect(Number(bob?.total_owed ?? 0)).toBeCloseTo(0); + }); +}); diff --git a/src/lib/analytics-sql.ts b/src/lib/analytics-sql.ts index baa05f9..f30cb69 100644 --- a/src/lib/analytics-sql.ts +++ b/src/lib/analytics-sql.ts @@ -217,9 +217,29 @@ END`; * deliberately no "mark settled" action anywhere: settling up is recording a * payment, and this column is only ever written by the historical import. * - * Assumes the `transaction_splits` alias is `ts`. + * **The date is the real guard, and the flag is only a refinement of it.** + * Nothing before SPLIT_CUTOVER can be owed, because carryover transaction 2348 + * already carries the entire pre-cutover balance as a single figure. A split on + * an older transaction is therefore free to describe *how an expense was shared* + * — which is what stops it inflating spend — without ever asserting a debt. + * + * That separation is what makes splitting history safe. Before it, the only + * thing keeping $37,233.28 of paid debt out of the balances was a boolean that + * any delete-and-recreate write path silently reset to false. Now losing the + * flag on a pre-cutover row costs nothing: the date still excludes it. The flag + * matters only for rows on or after the cutover, where it marks the handful + * settled outside this app. + * + * The boundary is inclusive because transaction 2348 is itself dated + * 2026-01-09 — an exclusive bound would drop the carryover and with it the + * entire pre-cutover balance. + * + * Assumes the `transaction_splits` alias is `ts` and `transactions` is `t`. */ -export const ACTIVE_OBLIGATION = `ts.settled = false`; +export const SPLIT_CUTOVER = "2026-01-09"; + +export const ACTIVE_OBLIGATION = `ts.settled = false + AND t.transaction_date >= '${SPLIT_CUTOVER}'`; /** * The tab a split belongs to: its transaction's trip, else the household.