fix(splits): the cutover date, not a boolean, is what gates a balance
ci / lint-test (push) Successful in 46s
ci / lint-test (push) Successful in 46s
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.
This commit is contained in:
@@ -73,7 +73,10 @@ export async function insertTransaction(
|
|||||||
VALUES ($1, NULL, $2, $3, $4, $5, $6, 0) RETURNING id`,
|
VALUES ($1, NULL, $2, $3, $4, $5, $6, 0) RETURNING id`,
|
||||||
[
|
[
|
||||||
ownerId,
|
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.description ?? "Test transaction",
|
||||||
overrides.amount ?? 100,
|
overrides.amount ?? 100,
|
||||||
overrides.transaction_type ?? "debit",
|
overrides.transaction_type ?? "debit",
|
||||||
|
|||||||
@@ -666,3 +666,58 @@ describe("superseded duplicates are excluded but kept", () => {
|
|||||||
).rejects.toThrow();
|
).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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -217,9 +217,29 @@ END`;
|
|||||||
* deliberately no "mark settled" action anywhere: settling up is recording a
|
* deliberately no "mark settled" action anywhere: settling up is recording a
|
||||||
* payment, and this column is only ever written by the historical import.
|
* 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.
|
* The tab a split belongs to: its transaction's trip, else the household.
|
||||||
|
|||||||
Reference in New Issue
Block a user