feat(shared): give a payment a tab to settle

A payment has only ever recorded from, to, amount and date. That is why
the per-trip owed figure did not exist — getTripAnalytics said so where
the number should have been: "split_payments carries no trip attribution,
so a payment cannot be assigned to a trip. Settlement is a property of the
whole relationship." Every trip therefore read 100% unsettled, including
trips paid in full.

It is also why the Shared page silently drops payments under a tag filter.
With one global pool there was nothing honest to subtract, so it showed
gross splits under the same label. A tag is a view; a scope is a ledger.

The scope is a trip, not a new settlement_contexts table. trips already
has owner_id, dates and archived, and transaction_overrides.trip_id
already decides membership. A second grouping beside it would be two
unsynchronised scopes over the same rows, with no invariant saying which
governs. NULL means the ongoing household tab, which never closes.

settled answers a different question and the two must not be collapsed:
trip_id is which tab, settled is whether the obligation is still live.
Critically, a live obligation is NOT settled by flipping the flag — it is
settled by recording the payment, and the balance nets to zero on its own.
Doing both would subtract the settlement twice. So settled is written only
by the historical import, for repayments made on a platform we no longer
run, and there is deliberately no "mark settled" action.

Both owed figures now exclude settled splits and the trip figure nets its
own payments. Spend analytics (myShare/mySplitOf) deliberately still count
settled rows: my half of a 2025 grocery shop is my spend whether or not the
other half was ever repaid, and filtering them would re-inflate exactly the
figures importing settled history exists to correct.

Also drops /api/participants/[id]/balance. It had no consumers, no owner
scoping, no debit/credit signs and no EXCLUDE_RECONCILED_SOURCE — a fourth
balance implementation that disagreed with the others and would have
imported three bugs if anything had aligned to it.

getTripAnalytics had no test at all. It has five now, including the one
that matters: a household payment must not make a trip look paid. Verified
by mutation — neutering the settled filter fails three, and dropping the
trip filter on payments fails that one.
This commit is contained in:
2026-07-27 23:12:05 +10:00
parent a4ab543a6c
commit 689fadc8b9
7 changed files with 250 additions and 57 deletions
+1
View File
@@ -35,6 +35,7 @@ export async function resetDB(pool: Pool) {
transactions,
statements,
tags,
trips,
participants
RESTART IDENTITY CASCADE
`);
+107 -1
View File
@@ -8,7 +8,7 @@ mockDbWithPool(pool);
// Dynamic import AFTER the mock ensures getTransactions / getParticipantBalances
// use the test pool rather than Prisma's singleton.
const { getTransactions, getParticipantBalances } = await import("@/lib/queries");
const { getTransactions, getParticipantBalances, getTripAnalytics } = await import("@/lib/queries");
beforeEach(async () => {
await resetDB(pool);
@@ -305,3 +305,109 @@ describe("getTransactions — order provenance for the description sub-line", ()
expect(row.order_platform).toBeNull();
});
});
// ── settlement scope: settled + split_payments.trip_id (migration 0022) ───────
describe("getParticipantBalances — settled", () => {
it("excludes a settled split from what is owed", async () => {
const { ownerId, otherId } = await seedParticipants(pool);
const txId = await insertTransaction(pool, ownerId, { amount: 100 });
await pool.query(
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent, settled)
VALUES ($1, $2, 50, true)`,
[txId, otherId]
);
const balances = await getParticipantBalances(ownerId);
const bob = balances.find((b) => b.id === otherId);
expect(Number(bob!.total_owed)).toBeCloseTo(0);
});
it("still counts an unsettled split alongside a settled one", async () => {
const { ownerId, otherId } = await seedParticipants(pool);
const settledTx = await insertTransaction(pool, ownerId, { amount: 100 });
const liveTx = await insertTransaction(pool, ownerId, { amount: 40 });
await pool.query(
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent, settled)
VALUES ($1, $2, 50, true), ($3, $2, 50, false)`,
[settledTx, otherId, liveTx]
);
const balances = await getParticipantBalances(ownerId);
const bob = balances.find((b) => b.id === otherId);
// Only the live split counts: 50% of 40.
expect(Number(bob!.total_owed)).toBeCloseTo(20);
});
});
describe("getTripAnalytics — per-trip settlement", () => {
async function seedTrip(ownerId: number, otherId: number) {
const trip = await pool.query(
`INSERT INTO trips (owner_id, name, start_date, end_date)
VALUES ($1, 'Test Trip', '2026-03-01', '2026-03-10') RETURNING id`,
[ownerId]
);
const tripId = trip.rows[0].id as number;
const txId = await insertTransaction(pool, ownerId, { amount: 200, category: "travel" });
await pool.query(
`INSERT INTO transaction_overrides (transaction_id, trip_id) VALUES ($1, $2)`,
[txId, tripId]
);
await pool.query(
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent)
VALUES ($1, $2, 50)`,
[txId, otherId]
);
return tripId;
}
it("reports the gross share before any payment", async () => {
const { ownerId, otherId } = await seedParticipants(pool);
const tripId = await seedTrip(ownerId, otherId);
const { participant_splits } = await getTripAnalytics(tripId, ownerId);
const bob = participant_splits.find((r) => r.participant_id === otherId);
expect(Number(bob!.owed)).toBeCloseTo(100);
});
it("nets off a payment scoped to that trip", async () => {
const { ownerId, otherId } = await seedParticipants(pool);
const tripId = await seedTrip(ownerId, otherId);
await pool.query(
`INSERT INTO split_payments (from_participant_id, to_participant_id, amount, payment_date, trip_id)
VALUES ($1, $2, 60, '2026-03-15', $3)`,
[otherId, ownerId, tripId]
);
const { participant_splits } = await getTripAnalytics(tripId, ownerId);
const bob = participant_splits.find((r) => r.participant_id === otherId);
expect(Number(bob!.owed)).toBeCloseTo(40);
});
// The point of the whole scope column: settling the household tab must not
// make a trip look paid. Before trip_id existed there was one global pool and
// this distinction could not be expressed.
it("ignores a household payment when reporting the trip", async () => {
const { ownerId, otherId } = await seedParticipants(pool);
const tripId = await seedTrip(ownerId, otherId);
await pool.query(
`INSERT INTO split_payments (from_participant_id, to_participant_id, amount, payment_date, trip_id)
VALUES ($1, $2, 60, '2026-03-15', NULL)`,
[otherId, ownerId]
);
const { participant_splits } = await getTripAnalytics(tripId, ownerId);
const bob = participant_splits.find((r) => r.participant_id === otherId);
expect(Number(bob!.owed)).toBeCloseTo(100);
});
it("drops a settled split from the trip figure", async () => {
const { ownerId, otherId } = await seedParticipants(pool);
const tripId = await seedTrip(ownerId, otherId);
await pool.query(`UPDATE transaction_splits SET settled = true WHERE participant_id = $1`, [otherId]);
const { participant_splits } = await getTripAnalytics(tripId, ownerId);
const bob = participant_splits.find((r) => r.participant_id === otherId);
expect(bob === undefined || Number(bob.owed) === 0).toBe(true);
});
});