Let everyone on a trip see it, and give payments their scope back
ci / lint-test (push) Successful in 52s
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:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user