Let everyone on a trip see it, and give payments their scope back
ci / lint-test (push) Successful in 52s

Trips were scoped to trips.owner_id, so Sonu saw no trips at all — despite
having paid for 104 of the tagged rows herself. Her own spending was invisible
on the only page organised around it.

A participant is now anyone with a split on, who paid for, or whose payment is
scoped to, a transaction tagged to the trip. Derived, not stored. A
trip_participants table was designed and rejected: the expenses already carry
the fact, and two records of one fact drift apart. Deriving it also excludes
Singapore + Bangkok 2026 from Sonu for free, which a table would have to be kept
in sync to do. Siddharth 4 trips, Sonu 3, Molina 1.

Everything about a trip is shared except delete. Both trip foreign keys are
ON DELETE SET NULL, so deleting Europe 2026 untags 210 transactions and NULLs
the trip scope on 6 payments — where the hand-derived Europe-first allocation
lives, which nothing recomputes. That stays with the owner.

Trip owed now returns both directions and nets neither. An obligation lives on a
row someone else paid for, so a viewer-as-payer figure can never hold it, and
Sonu's Europe read "you are owed $2,408.24" while omitting the $8,004.04 she
owed. Collapsing the two into a signed net is the tempting next step and would
have corrupted the scope allocation: the grouped-payment allocation cleared each
trip against the one-directional gross, so redefining the debt afterwards turns
$8,004.04 already allocated into an $802.75 over-allocation with household
understated by the same amount. Verified byte-identical — Auckland $1,505.64,
Europe Molina -$816.16, Europe Sonu $0.00, Sonu + Sunny $0.00.

getTransactions gained trip_all_rows so a participant sees the whole trip. It is
opt-in and not implied by trip_id, because the same endpoint backs the main
transactions list and its trip filter must keep owner scoping. Participation is
re-checked in SQL, so passing the flag for someone else's trip returns nothing.

Payments can finally say what they settle. trip_id has existed since migration
0022 but POST never read it and GET never returned it, so every payment made in
the app landed on household and the 9 trip-scoped rows were hand-written SQL.
"Both" needs no new shape — one row per scope sharing a linked_transaction_id.

Three write paths had no authorisation at all and were reachable by any
participant: assignTransactionsToTrip checked nothing, DELETE on a payment
deleted by bare id, and POST accepted any from/to pair. All three now check.

Also fixes the test suite, which was pointing at postgres-pantry: container IPs
move on recreation and 172.22.0.47 stopped being postgres-personal. It only
failed safe because the credentials did not match — resetDB now refuses to
truncate anything not named personal_test.

22 new tests, 276 passing, build clean.
This commit is contained in:
2026-08-02 19:11:19 +10:00
parent 6e179d3a0a
commit cb7665ded1
13 changed files with 904 additions and 78 deletions
+370 -1
View File
@@ -8,7 +8,10 @@ mockDbWithPool(pool);
// Dynamic import AFTER the mock ensures getTransactions / getParticipantBalances
// use the test pool rather than Prisma's singleton.
const { getTransactions, getParticipantBalances, getTripAnalytics, getTripById, getStatements } = await import("@/lib/queries");
const {
getTransactions, getParticipantBalances, getTripAnalytics, getTripById, getStatements,
getTrips, isTripParticipant, assignTransactionsToTrip, deleteTrip,
} = await import("@/lib/queries");
beforeEach(async () => {
await resetDB(pool);
@@ -778,3 +781,369 @@ describe("the split cutover gates every balance", () => {
expect(Number(bob?.total_owed ?? 0)).toBeCloseTo(0);
});
});
// ── Trip participation ────────────────────────────────────────────────────────
//
// Trips were scoped to `trips.owner_id`, so a co-traveller saw nothing: Sonu
// could not open a single trip despite paying for 104 of the tagged rows
// herself. Participation is DERIVED from the expenses rather than stored as a
// membership list, because a trip is all the expenses on one trip — and two
// records of one fact drift apart.
describe("trip participation — visibility", () => {
/** A trip owned by `ownerId` with one row `ownerId` paid for. */
async function tripWithOwnerRow(ownerId: number, name = "Owned Trip") {
const trip = await pool.query(
`INSERT INTO trips (owner_id, name) VALUES ($1, $2) RETURNING id`,
[ownerId, name]
);
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]
);
return { tripId, txId };
}
it("shows a trip to its owner", async () => {
const { ownerId } = await seedParticipants(pool);
const { tripId } = await tripWithOwnerRow(ownerId);
const trips = await getTrips(ownerId);
expect(trips.map((t) => t.id)).toContain(tripId);
});
it("shows a trip to someone holding a split on one of its rows", async () => {
const { ownerId, otherId } = await seedParticipants(pool);
const { tripId, txId } = await tripWithOwnerRow(ownerId);
await pool.query(
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent)
VALUES ($1, $2, 50)`,
[txId, otherId]
);
const trips = await getTrips(otherId);
expect(trips.map((t) => t.id)).toContain(tripId);
expect(await isTripParticipant(tripId, otherId)).toBe(true);
});
it("shows a trip to someone who paid for one of its rows but holds no split", async () => {
const { ownerId, otherId } = await seedParticipants(pool);
const { tripId } = await tripWithOwnerRow(ownerId);
const theirTx = await insertTransaction(pool, otherId, { amount: 80, category: "travel" });
await pool.query(
`INSERT INTO transaction_overrides (transaction_id, trip_id) VALUES ($1, $2)`,
[theirTx, tripId]
);
expect((await getTrips(otherId)).map((t) => t.id)).toContain(tripId);
});
it("shows a trip to someone whose payment is scoped to it", async () => {
const { ownerId, otherId } = await seedParticipants(pool);
const { tripId } = await tripWithOwnerRow(ownerId);
await pool.query(
`INSERT INTO split_payments (from_participant_id, to_participant_id, amount, payment_date, trip_id)
VALUES ($1, $2, 50, '2026-06-20', $3)`,
[otherId, ownerId, tripId]
);
expect((await getTrips(otherId)).map((t) => t.id)).toContain(tripId);
});
// The Singapore + Bangkok 2026 case. A trip nobody else took must not appear
// just because trips became shareable — this is the whole reason
// participation is derived from the expenses rather than granted.
it("HIDES a trip from someone with no split, no row and no payment on it", async () => {
const { ownerId, otherId } = await seedParticipants(pool);
const { tripId } = await tripWithOwnerRow(ownerId, "Solo Trip");
expect((await getTrips(otherId)).map((t) => t.id)).not.toContain(tripId);
expect(await isTripParticipant(tripId, otherId)).toBe(false);
expect(await getTripById(tripId, otherId)).toBeNull();
});
});
describe("trip owed — both directions, never netted", () => {
/** `payerId` paid a $200 travel row on the trip; `splitId` holds 50% of it. */
async function seed(payerId: number, splitId: number, ownerId: number) {
const trip = await pool.query(
`INSERT INTO trips (owner_id, name) VALUES ($1, 'Pair Trip') RETURNING id`,
[ownerId]
);
const tripId = trip.rows[0].id as number;
const txId = await insertTransaction(pool, payerId, { 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, splitId]
);
return tripId;
}
it("the payer sees it as owed to them, with nothing on the mirror", async () => {
const { ownerId, otherId } = await seedParticipants(pool);
const tripId = await seed(ownerId, 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);
expect(Number(bob.i_owe)).toBeCloseTo(0);
expect(Number(bob.i_owe_gross)).toBeCloseTo(0);
});
// The figure that could not exist before. An obligation lives on a row someone
// ELSE paid for, so a viewer-as-payer query can never contain it — which is
// why Sonu's Europe page read "you are owed $2,408.24" while omitting the
// $8,004.04 she owed.
it("the split holder sees the same figure as owed BY them", async () => {
const { ownerId, otherId } = await seedParticipants(pool);
const tripId = await seed(ownerId, otherId, ownerId);
const { participant_splits } = await getTripAnalytics(tripId, otherId);
const alice = participant_splits.find((r) => r.participant_id === ownerId)!;
expect(Number(alice.i_owe)).toBeCloseTo(100);
expect(Number(alice.owed)).toBeCloseTo(0);
});
// Europe 2026: paid in full, so the net is zero but the gross is not — the UI
// needs both to say "settled" rather than a bare "0.00".
it("keeps gross and paid alongside the net so a paid-up trip reads as settled", async () => {
const { ownerId, otherId } = await seedParticipants(pool);
const tripId = await seed(ownerId, otherId, ownerId);
await pool.query(
`INSERT INTO split_payments (from_participant_id, to_participant_id, amount, payment_date, trip_id)
VALUES ($1, $2, 100, '2026-06-20', $3)`,
[otherId, ownerId, tripId]
);
const asPayer = await getTripAnalytics(tripId, ownerId);
const bob = asPayer.participant_splits.find((r) => r.participant_id === otherId)!;
expect(Number(bob.owed)).toBeCloseTo(0);
expect(Number(bob.owed_gross)).toBeCloseTo(100);
expect(Number(bob.paid_to_me)).toBeCloseTo(100);
const asDebtor = await getTripAnalytics(tripId, otherId);
const alice = asDebtor.participant_splits.find((r) => r.participant_id === ownerId)!;
expect(Number(alice.i_owe)).toBeCloseTo(0);
expect(Number(alice.i_owe_gross)).toBeCloseTo(100);
expect(Number(alice.paid_by_me)).toBeCloseTo(100);
});
// Netting the two would redefine the debt the grouped-payment allocation was
// computed against, turning a settled trip into an overpayment and leaving
// household understated by the same amount.
it("does not net the two directions against each other", async () => {
const { ownerId, otherId } = await seedParticipants(pool);
const tripId = await seed(ownerId, otherId, ownerId);
// A second row, paid the other way, so both directions are live at once.
const theirTx = await insertTransaction(pool, otherId, { amount: 60, category: "travel" });
await pool.query(
`INSERT INTO transaction_overrides (transaction_id, trip_id) VALUES ($1, $2)`,
[theirTx, tripId]
);
await pool.query(
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent)
VALUES ($1, $2, 50)`,
[theirTx, ownerId]
);
const { participant_splits } = await getTripAnalytics(tripId, ownerId);
const bob = participant_splits.find((r) => r.participant_id === otherId)!;
expect(Number(bob.owed)).toBeCloseTo(100);
expect(Number(bob.i_owe)).toBeCloseTo(30);
// Emphatically not 70.
expect(Number(bob.owed) - Number(bob.i_owe)).toBeCloseTo(70);
});
it("reports whether the viewer owns the trip", async () => {
const { ownerId, otherId } = await seedParticipants(pool);
const tripId = await seed(ownerId, otherId, ownerId);
expect((await getTripAnalytics(tripId, ownerId)).viewer_is_owner).toBe(true);
expect((await getTripAnalytics(tripId, otherId)).viewer_is_owner).toBe(false);
});
});
describe("getTransactions — trip_all_rows", () => {
async function seedSharedTrip() {
const { ownerId, otherId } = await seedParticipants(pool);
const trip = await pool.query(
`INSERT INTO trips (owner_id, name) VALUES ($1, 'Shared Trip') RETURNING id`,
[ownerId]
);
const tripId = trip.rows[0].id as number;
// One row Bob holds a split on — this is what makes him a participant.
const shared = await insertTransaction(pool, ownerId, { description: "Shared hotel", category: "travel" });
// One row Bob has no stake in whatsoever.
const solo = await insertTransaction(pool, ownerId, { description: "Alice solo museum", category: "travel" });
await pool.query(
`INSERT INTO transaction_overrides (transaction_id, trip_id) VALUES ($1, $2), ($3, $2)`,
[shared, tripId, solo]
);
await pool.query(
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent)
VALUES ($1, $2, 50)`,
[shared, otherId]
);
return { ownerId, otherId, tripId };
}
it("gives a participant every row on the trip", async () => {
const { otherId, tripId } = await seedSharedTrip();
const { data } = await getTransactions(otherId, {
trip_id: String(tripId), trip_all_rows: true, limit: 50, offset: 0,
});
expect(data.map((r) => r.description).sort()).toEqual(["Alice solo museum", "Shared hotel"]);
});
// The main transactions page filters by trip through this same endpoint. If
// the widening were implied by trip_id, filtering your own ledger by a trip
// would silently fill it with someone else's rows and skew its totals.
it("keeps owner scoping when the flag is absent", async () => {
const { otherId, tripId } = await seedSharedTrip();
const { data } = await getTransactions(otherId, {
trip_id: String(tripId), limit: 50, offset: 0,
});
expect(data.map((r) => r.description)).toEqual(["Shared hotel"]);
});
it("returns nothing to a non-participant who passes the flag", async () => {
const { ownerId } = await seedSharedTrip();
const stranger = await pool.query(
`INSERT INTO participants (name) VALUES ('Carol') RETURNING id`
);
const carolId = stranger.rows[0].id as number;
const trip = await pool.query(`SELECT id FROM trips LIMIT 1`);
const { data } = await getTransactions(carolId, {
trip_id: String(trip.rows[0].id), trip_all_rows: true, limit: 50, offset: 0,
});
expect(data).toHaveLength(0);
expect(ownerId).toBeGreaterThan(0);
});
it("does not widen anything when trip_id is 'unassigned'", async () => {
const { ownerId, otherId } = await seedParticipants(pool);
await insertTransaction(pool, ownerId, { description: "Alice untripped" });
await insertTransaction(pool, otherId, { description: "Bob untripped" });
const { data } = await getTransactions(otherId, {
trip_id: "unassigned", trip_all_rows: true, limit: 50, offset: 0,
});
expect(data.map((r) => r.description)).toEqual(["Bob untripped"]);
});
});
describe("assignTransactionsToTrip — authorisation", () => {
it("refuses a trip the caller does not participate in", async () => {
const { ownerId, otherId } = await seedParticipants(pool);
const trip = await pool.query(
`INSERT INTO trips (owner_id, name) VALUES ($1, 'Private Trip') RETURNING id`,
[ownerId]
);
const tripId = trip.rows[0].id as number;
const bobsTx = await insertTransaction(pool, otherId, { description: "Bob lunch" });
await expect(assignTransactionsToTrip(tripId, [bobsTx], otherId)).rejects.toThrow(/participant/i);
});
// The hole this closed: the function took no caller at all, so any
// authenticated participant could move any transaction id into any trip.
it("silently skips transactions the caller cannot see", async () => {
const { ownerId, otherId } = await seedParticipants(pool);
const trip = await pool.query(
`INSERT INTO trips (owner_id, name) VALUES ($1, 'Alice Trip') RETURNING id`,
[ownerId]
);
const tripId = trip.rows[0].id as number;
const mine = await insertTransaction(pool, ownerId, { description: "Alice flight" });
const theirs = await insertTransaction(pool, otherId, { description: "Bob private" });
const moved = await assignTransactionsToTrip(tripId, [mine, theirs], ownerId);
expect(moved).toBe(1);
const rows = await pool.query(
`SELECT transaction_id FROM transaction_overrides WHERE trip_id = $1`, [tripId]
);
expect(rows.rows.map((r) => r.transaction_id)).toEqual([mine]);
});
it("lets a participant assign their own transaction to the trip", async () => {
const { ownerId, otherId } = await seedParticipants(pool);
const trip = await pool.query(
`INSERT INTO trips (owner_id, name) VALUES ($1, 'Joint Trip') RETURNING id`,
[ownerId]
);
const tripId = trip.rows[0].id as number;
// Make Bob a participant first.
const seedTx = await insertTransaction(pool, ownerId, { category: "travel" });
await pool.query(
`INSERT INTO transaction_overrides (transaction_id, trip_id) VALUES ($1, $2)`,
[seedTx, tripId]
);
await pool.query(
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent)
VALUES ($1, $2, 50)`,
[seedTx, otherId]
);
const bobsTx = await insertTransaction(pool, otherId, { description: "Bob taxi" });
expect(await assignTransactionsToTrip(tripId, [bobsTx], otherId)).toBe(1);
});
});
// Delete is the one thing that stayed owner-only. Both trip foreign keys are
// ON DELETE SET NULL, so deleting a trip untags every transaction on it and
// drops the trip scope from its payments — including a hand-derived allocation
// that nothing recomputes.
describe("deleteTrip — owner only", () => {
it("does not delete when a non-owner participant asks", async () => {
const { ownerId, otherId } = await seedParticipants(pool);
const trip = await pool.query(
`INSERT INTO trips (owner_id, name) VALUES ($1, 'Precious Trip') RETURNING id`,
[ownerId]
);
const tripId = trip.rows[0].id as number;
const txId = await insertTransaction(pool, ownerId, { 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]
);
// Bob can see it...
expect(await getTripById(tripId, otherId)).not.toBeNull();
await deleteTrip(tripId, otherId);
// ...and still cannot remove it, nor untag its transaction.
expect(await getTripById(tripId, ownerId)).not.toBeNull();
const still = await pool.query(
`SELECT trip_id FROM transaction_overrides WHERE transaction_id = $1`, [txId]
);
expect(still.rows[0].trip_id).toBe(tripId);
});
it("deletes when the owner asks", async () => {
const { ownerId } = await seedParticipants(pool);
const trip = await pool.query(
`INSERT INTO trips (owner_id, name) VALUES ($1, 'Doomed Trip') RETURNING id`,
[ownerId]
);
const tripId = trip.rows[0].id as number;
await deleteTrip(tripId, ownerId);
expect(await getTripById(tripId, ownerId)).toBeNull();
});
});