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
+89 -1
View File
@@ -249,7 +249,95 @@ a *cancelled* booking has both legs untagged from the trip by hand, because a
trip never incurred a cost it cancelled. trip never incurred a cost it cancelled.
**Trips:** Europe 2026 (id 1, 19 Mar12 Apr), Auckland 2026 (id 2), **Trips:** Europe 2026 (id 1, 19 Mar12 Apr), Auckland 2026 (id 2),
Europe — Sonu + Sunny (id 3, 1228 Apr, created 2026-07-28 from tag 5). Europe — Sonu + Sunny (id 3, 1228 Apr, created 2026-07-28 from tag 5),
Singapore + Bangkok 2026 (id 4).
### Trip participation is derived, and a trip is shared
Rebuilt 2026-08-02. Trips were scoped to `trips.owner_id`, so Sonu saw **no
trips at all** despite paying for 104 of the tagged rows herself — her own
spending was invisible on the only page organised around it.
**A participant is anyone with a split on, who paid for, or whose payment is
scoped to, a transaction tagged to the trip.** Derived (`TRIP_PARTICIPANT` in
`queries.ts`), never stored. A membership table was designed and rejected: it
would be a second record of a fact the expenses already carry, and two records
of one fact drift — the same reason sharing is a real split rather than a flag.
The derivation also gets the exclusions right for free, which a table has to be
kept in sync to do: Singapore + Bangkok 2026 has no Sonu split and no Sonu
payment, so she is not a participant and never sees it. Live result is
Siddharth 4 trips, Sonu 3, Molina 1.
**Everything is shared except delete.** Read, edit and assign are open to any
participant. `deleteTrip` stays `owner_id`-only because both trip foreign keys
are `ON DELETE SET NULL`, so deleting Europe 2026 untags 210 transactions *and*
NULLs the trip scope on 6 payments — which is where the hand-derived
Europe-first allocation lives, and nothing recomputes it. The route returns 403
with the reason rather than a 404 that pretends the trip is missing.
**`getTransactions` gained `trip_all_rows`, and it is opt-in for a reason.** A
participant sees every row on a trip, not only their own — the trip total
already counts every payer. It must NOT be implied by `trip_id` being present:
`GET /api/transactions` is also the main transactions list, and its trip filter
has to keep owner scoping or filtering your own ledger by "Europe 2026" would
quietly fill it with someone else's rows. Participation is re-checked in SQL, so
passing the flag for a trip you are not on returns nothing rather than
everything. Only `trips/[id]/page.tsx` sets it.
**Trip owed is pairwise and returns BOTH directions, never netted.** `owed` is
unchanged — their share of rows *the viewer* paid. `i_owe` is the mirror: the
viewer's share of rows *that participant* paid. Rendering the pair from the
viewer's side is the whole fix; an obligation lives on a row someone else paid
for, so a viewer-as-payer figure can never contain it, and Sonu's Europe 2026
read "you are owed $2,408.24" while omitting the $8,004.04 she owed.
**Do not collapse the two into one signed net.** It looks like the obvious next
step and it silently corrupts the scope allocation. The grouped-payment
allocation (memory case `allocate_grouped_payments`) cleared Sonu's transfers
against the trip debts chronologically, Europe first with the remainder to
household — and the debt it cleared was the one-directional gross. Netting
redefines Europe's debt as $7,201.30 after the fact, turning the $8,004.04
already allocated into an $802.75 over-allocation with household understated by
the same amount. The total stays right; the split between scopes stops being.
Both halves therefore carry their gross and payments too (`owed_gross`,
`paid_to_me`, `i_owe_gross`, `paid_by_me`) so a paid-up trip reads "settled"
rather than a bare `0.00`. Verified byte-identical across the change: Auckland
Sonu $1,505.64, Europe Molina $816.16, Europe Sonu $0.00, Sonu + Sunny Sonu
$0.00.
### Payment scope reaches the API (2026-08-02)
`split_payments.trip_id` has existed since migration 0022, but `POST
/api/split-payments` never read it and `GET` never returned it — so **every
payment recorded through the app landed on the household tab**, and the 9
trip-scoped rows had to be written by hand in SQL. A $11k Europe settlement was
silently reducing the ongoing household balance.
Both fixed. The modal has a "Settles" selector (Household or a trip) and history
shows each payment's scope as a chip. **"Both" needs no new shape:** one
transfer becomes one row per scope sharing a `linked_transaction_id`, which is
why there is deliberately no unique constraint on it — tx 4121's $4,794.06 sits
as $1,145.52 against Europe — Sonu + Sunny and $3,648.54 against household, and
tx 4111's $3,779.33 spans two trips. All six linked transfers reconcile to the
cent.
### Three write paths that had no authorisation
All closed 2026-08-02. Each was reachable by any authenticated participant:
- **`assignTransactionsToTrip`** took no caller and checked nothing, so
`PATCH /api/trips/[id]/transactions` and `POST /api/transactions/bulk`
(`assign_trip`) let anyone move any transaction id into any trip id. Not being
able to *see* a trip was no obstacle, because the write path never read one.
Now: only rows the caller can already see move, and a non-null destination must
be a trip they participate in — enforced in the query, not the route, so
neither caller can bypass it. Returns the count actually moved.
- **`DELETE /api/split-payments?id=`** deleted by id with no check at all. Erasing
a settlement silently resurrects a discharged debt — the same class of damage
as the split rewrite that reset `settled`. Now limited to the two people the
payment is between.
- **`POST /api/split-payments`** accepted any `from`/`to` pair. Now the payment
must involve the caller, and a trip scope must be a trip they are on.
**Partial split coverage inside a category is usually correct, not a gap.** Only **Partial split coverage inside a category is usually correct, not a gap.** Only
*shared* items are split. `utilities` sits at 69% yours because Globird, OVO, GWW *shared* items are split. `utilities` sits at 69% yours because Globird, OVO, GWW
+28
View File
@@ -27,8 +27,36 @@ export function mockDbWithPool(p: Pool) {
})); }));
} }
/**
* Refuse to truncate anything that is not the test database.
*
* `DATABASE_URL` in `.env.test` names the Postgres container by IP, and
* container IPs move on recreation: 172.22.0.47 stopped being
* `postgres-personal` and became `postgres-pantry`, so the suite spent a while
* pointing its TRUNCATE at another app's database. It only failed safe because
* the credentials happened not to match — had they matched, this would have
* wiped pantry-app.
*
* Checked once per process, before the first truncate.
*/
let targetVerified = false;
async function assertTestDatabase(pool: Pool) {
if (targetVerified) return;
const { rows } = await pool.query<{ db: string }>(`SELECT current_database() AS db`);
const db = rows[0]?.db;
if (db !== "personal_test") {
throw new Error(
`Refusing to truncate: connected to "${db}", expected "personal_test". ` +
`Check DATABASE_URL in .env.test — the Postgres container IP may have changed ` +
`(docker inspect postgres-personal --format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}').`
);
}
targetVerified = true;
}
/** Wipe all data tables and restart sequences between tests. */ /** Wipe all data tables and restart sequences between tests. */
export async function resetDB(pool: Pool) { export async function resetDB(pool: Pool) {
await assertTestDatabase(pool);
await pool.query(` await pool.query(`
TRUNCATE TRUNCATE
split_payments, split_payments,
+370 -1
View File
@@ -8,7 +8,10 @@ mockDbWithPool(pool);
// Dynamic import AFTER the mock ensures getTransactions / getParticipantBalances // Dynamic import AFTER the mock ensures getTransactions / getParticipantBalances
// use the test pool rather than Prisma's singleton. // 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 () => { beforeEach(async () => {
await resetDB(pool); await resetDB(pool);
@@ -778,3 +781,369 @@ describe("the split cutover gates every balance", () => {
expect(Number(bob?.total_owed ?? 0)).toBeCloseTo(0); 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();
});
});
+40 -2
View File
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import { getCurrentUser } from "@/lib/auth"; import { getCurrentUser } from "@/lib/auth";
import { queryRaw } from "@/lib/db"; import { queryRaw } from "@/lib/db";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { isTripParticipant } from "@/lib/queries";
export async function GET(req: NextRequest) { export async function GET(req: NextRequest) {
const user = await getCurrentUser(req); const user = await getCurrentUser(req);
@@ -21,15 +22,22 @@ export async function GET(req: NextRequest) {
payment_date: string; payment_date: string;
notes: string | null; notes: string | null;
linked_transaction_id: number | null; linked_transaction_id: number | null;
trip_id: number | null;
trip_name: string | null;
created_at: string; created_at: string;
}>( }>(
// trip_id was stored but never returned, so history could not show which tab
// a payment settled — and a grouped transfer looks like a duplicate until you
// can see that its rows carry different scopes.
`SELECT sp.id, sp.from_participant_id, pf.name as from_name, `SELECT sp.id, sp.from_participant_id, pf.name as from_name,
sp.to_participant_id, pt.name as to_name, sp.to_participant_id, pt.name as to_name,
sp.amount, sp.payment_date, sp.notes, sp.amount, sp.payment_date, sp.notes,
sp.linked_transaction_id, sp.created_at sp.linked_transaction_id, sp.trip_id, tr.name as trip_name,
sp.created_at
FROM split_payments sp FROM split_payments sp
JOIN participants pf ON pf.id = sp.from_participant_id JOIN participants pf ON pf.id = sp.from_participant_id
JOIN participants pt ON pt.id = sp.to_participant_id JOIN participants pt ON pt.id = sp.to_participant_id
LEFT JOIN trips tr ON tr.id = sp.trip_id
WHERE (sp.from_participant_id = $1 OR sp.to_participant_id = $1) WHERE (sp.from_participant_id = $1 OR sp.to_participant_id = $1)
AND (sp.from_participant_id = $2 OR sp.to_participant_id = $2) AND (sp.from_participant_id = $2 OR sp.to_participant_id = $2)
ORDER BY sp.payment_date DESC, sp.created_at DESC`, ORDER BY sp.payment_date DESC, sp.created_at DESC`,
@@ -50,9 +58,10 @@ export async function POST(req: NextRequest) {
payment_date: string; payment_date: string;
notes?: string; notes?: string;
linked_transaction_id?: number; linked_transaction_id?: number;
trip_id?: number | null;
}; };
const { from_participant_id, to_participant_id, amount, payment_date, notes, linked_transaction_id } = body; const { from_participant_id, to_participant_id, amount, payment_date, notes, linked_transaction_id, trip_id } = body;
if (!from_participant_id || !to_participant_id || !amount || !payment_date) { if (!from_participant_id || !to_participant_id || !amount || !payment_date) {
return NextResponse.json({ error: "Missing required fields" }, { status: 400 }); return NextResponse.json({ error: "Missing required fields" }, { status: 400 });
@@ -60,6 +69,24 @@ export async function POST(req: NextRequest) {
if (amount <= 0) { if (amount <= 0) {
return NextResponse.json({ error: "Amount must be positive" }, { status: 400 }); return NextResponse.json({ error: "Amount must be positive" }, { status: 400 });
} }
if (from_participant_id !== user.id && to_participant_id !== user.id) {
return NextResponse.json({ error: "A payment must involve you" }, { status: 403 });
}
// Scope. `trip_id` existed in the schema from migration 0022 but this route
// never read it, so every payment recorded in the app landed on the household
// tab and the 9 trip-scoped rows had to be written by hand in SQL.
//
// "Both" needs no extra shape: one transfer becomes one row per scope, all
// carrying the same linked_transaction_id — there is deliberately no unique
// constraint on it. That is how tx 4121's $4,794.06 sits as $1,145.52 against
// Europe — Sonu + Sunny and $3,648.54 against household.
if (trip_id != null && !(await isTripParticipant(trip_id, user.id))) {
return NextResponse.json(
{ error: "Cannot scope a payment to a trip you are not on" },
{ status: 403 }
);
}
const payment = await prisma.split_payments.create({ const payment = await prisma.split_payments.create({
data: { data: {
@@ -69,6 +96,7 @@ export async function POST(req: NextRequest) {
payment_date: new Date(payment_date), payment_date: new Date(payment_date),
notes: notes || null, notes: notes || null,
linked_transaction_id: linked_transaction_id || null, linked_transaction_id: linked_transaction_id || null,
trip_id: trip_id ?? null,
}, },
}); });
@@ -83,6 +111,16 @@ export async function DELETE(req: NextRequest) {
const id = Number(sp.get("id")); const id = Number(sp.get("id"));
if (!id) return NextResponse.json({ error: "id required" }, { status: 400 }); if (!id) return NextResponse.json({ error: "id required" }, { status: 400 });
// This deleted by id with no check at all: any authenticated participant could
// erase any settlement, which silently resurrects a discharged debt — the same
// class of damage as the split rewrite that reset `settled`. Deleting a payment
// must be limited to the two people it is between.
const existing = await prisma.split_payments.findUnique({ where: { id } });
if (!existing) return NextResponse.json({ error: "Not found" }, { status: 404 });
if (existing.from_participant_id !== user.id && existing.to_participant_id !== user.id) {
return NextResponse.json({ error: "Not your payment to delete" }, { status: 403 });
}
await prisma.split_payments.delete({ where: { id } }); await prisma.split_payments.delete({ where: { id } });
return NextResponse.json({ ok: true }); return NextResponse.json({ ok: true });
} }
@@ -28,10 +28,14 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
}); });
// Assign all transactions with this tag to the new trip // Assign all transactions with this tag to the new trip
// The creator owns the new trip, so they participate in it by definition and
// the assignment's participation gate passes. `assigned` is what actually
// moved: rows the creator cannot see are skipped, so a tag spanning someone
// else's transactions converts to a trip holding only the creator's.
const transactionIds = await getTagTransactionIds(tagId); const transactionIds = await getTagTransactionIds(tagId);
if (transactionIds.length > 0) { const assigned = transactionIds.length > 0
await assignTransactionsToTrip(trip.id, transactionIds); ? await assignTransactionsToTrip(trip.id, transactionIds, user.id)
} : 0;
return NextResponse.json({ trip, assigned: transactionIds.length }, { status: 201 }); return NextResponse.json({ trip, assigned, tagged: transactionIds.length }, { status: 201 });
} }
+11 -2
View File
@@ -122,8 +122,17 @@ export async function POST(req: NextRequest) {
if (action === "assign_trip") { if (action === "assign_trip") {
const { trip_id } = body as { ids: number[]; trip_id: number | null }; const { trip_id } = body as { ids: number[]; trip_id: number | null };
await assignTransactionsToTrip(trip_id, ids); try {
return NextResponse.json({ updated: ids.length }); // `updated` is what actually moved, not what was asked for — ids the
// caller cannot see are skipped rather than silently applied.
const updated = await assignTransactionsToTrip(trip_id, ids, user.id);
return NextResponse.json({ updated, requested: ids.length });
} catch (e) {
return NextResponse.json(
{ error: e instanceof Error ? e.message : "Failed to assign" },
{ status: 403 }
);
}
} }
return NextResponse.json({ error: "Invalid action" }, { status: 400 }); return NextResponse.json({ error: "Invalid action" }, { status: 400 });
+1
View File
@@ -28,6 +28,7 @@ export async function GET(req: NextRequest) {
amount_max: sp.get("amount_max") ? Number(sp.get("amount_max")) : undefined, amount_max: sp.get("amount_max") ? Number(sp.get("amount_max")) : undefined,
has_split: sp.get("has_split") || undefined, has_split: sp.get("has_split") || undefined,
trip_id: sp.get("trip_id") || undefined, trip_id: sp.get("trip_id") || undefined,
trip_all_rows: sp.get("trip_all_rows") === "1" || undefined,
}); });
return NextResponse.json(result); return NextResponse.json(result);
+13
View File
@@ -21,10 +21,23 @@ export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id
return NextResponse.json(trip); return NextResponse.json(trip);
} }
// Everything else about a trip is shared; delete is not. Both trip foreign keys
// are ON DELETE SET NULL, so this untags every transaction on the trip and drops
// the trip scope from its payments — including the hand-derived Europe-first
// allocation, which nothing recomputes. A participant gets a 403 that says so
// rather than a 404 that pretends the trip is not there.
export async function DELETE(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { export async function DELETE(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const user = await getCurrentUser(req); const user = await getCurrentUser(req);
if (!user) return NextResponse.json({ error: "Forbidden" }, { status: 403 }); if (!user) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
const { id } = await params; const { id } = await params;
const trip = await getTripById(Number(id), user.id);
if (!trip) return NextResponse.json({ error: "Not found" }, { status: 404 });
if (trip.owner_id !== user.id) {
return NextResponse.json(
{ error: "Only the trip owner can delete a trip. Deleting it would untag every transaction on it and unscope its payments." },
{ status: 403 }
);
}
await deleteTrip(Number(id), user.id); await deleteTrip(Number(id), user.id);
return new NextResponse(null, { status: 204 }); return new NextResponse(null, { status: 204 });
} }
+9 -2
View File
@@ -10,6 +10,13 @@ export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id
if (!Array.isArray(transactionIds) || !transactionIds.length) { if (!Array.isArray(transactionIds) || !transactionIds.length) {
return NextResponse.json({ error: "transactionIds must be a non-empty array" }, { status: 400 }); return NextResponse.json({ error: "transactionIds must be a non-empty array" }, { status: 400 });
} }
await assignTransactionsToTrip(Number(id), transactionIds); try {
return NextResponse.json({ ok: true }); const assigned = await assignTransactionsToTrip(Number(id), transactionIds, user.id);
return NextResponse.json({ ok: true, assigned, requested: transactionIds.length });
} catch (e) {
return NextResponse.json(
{ error: e instanceof Error ? e.message : "Failed to assign" },
{ status: 403 }
);
}
} }
+29
View File
@@ -11,6 +11,7 @@ import {
useDeletePayment, useDeletePayment,
useCurrentUser, useCurrentUser,
useTags, useTags,
useTrips,
type SplitPayment, type SplitPayment,
} from "@/lib/hooks"; } from "@/lib/hooks";
import type { SharedTransactionRow } from "@/lib/queries"; import type { SharedTransactionRow } from "@/lib/queries";
@@ -147,6 +148,7 @@ function RecordPaymentModal({
onClose: () => void; onClose: () => void;
}) { }) {
const record = useRecordPayment(); const record = useRecordPayment();
const { data: trips = [] } = useTrips();
const theyOweMe = currentBalance > 0; const theyOweMe = currentBalance > 0;
// Default direction matches the debt direction // Default direction matches the debt direction
@@ -155,6 +157,8 @@ function RecordPaymentModal({
const [notes, setNotes] = useState(""); const [notes, setNotes] = useState("");
// direction: "received" = they paid me, "sent" = I paid them // direction: "received" = they paid me, "sent" = I paid them
const [direction, setDirection] = useState<"received" | "sent">(theyOweMe ? "received" : "sent"); const [direction, setDirection] = useState<"received" | "sent">(theyOweMe ? "received" : "sent");
// Which tab this settles. "" = the ongoing household tab (trip_id NULL).
const [tripId, setTripId] = useState("");
const [error, setError] = useState(""); const [error, setError] = useState("");
async function handleSave() { async function handleSave() {
@@ -168,6 +172,7 @@ function RecordPaymentModal({
amount: amt, amount: amt,
payment_date: date, payment_date: date,
notes: notes || undefined, notes: notes || undefined,
trip_id: tripId ? Number(tripId) : null,
}); });
onClose(); onClose();
} catch (e) { } catch (e) {
@@ -216,6 +221,24 @@ function RecordPaymentModal({
</div> </div>
</div> </div>
{/* Scope. Until now every payment recorded here landed on the household
tab, because the API dropped trip_id — so a $11k Europe settlement
silently reduced the ongoing household balance instead. */}
<div>
<label className="block text-xs text-zinc-500 mb-1">Settles</label>
<select value={tripId} onChange={(e) => setTripId(e.target.value)}
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm">
<option value="">Household (ongoing)</option>
{trips.filter((t) => !t.archived).map((t) => (
<option key={t.id} value={t.id}>{t.name}</option>
))}
</select>
<p className="text-[11px] text-zinc-600 mt-1">
Covering more than one tab? Record it once per tab the parts add back
up to the transfer.
</p>
</div>
<div> <div>
<label className="block text-xs text-zinc-500 mb-1">Notes (optional)</label> <label className="block text-xs text-zinc-500 mb-1">Notes (optional)</label>
<input value={notes} onChange={(e) => setNotes(e.target.value)} <input value={notes} onChange={(e) => setNotes(e.target.value)}
@@ -259,6 +282,12 @@ function PaymentHistory({ participantId, currentUserId }: { participantId: numbe
{theyPaidMe ? "+" : "-"}${Number(p.amount).toFixed(2)} {theyPaidMe ? "+" : "-"}${Number(p.amount).toFixed(2)}
</span> </span>
<span className="text-zinc-500">{formatDate(p.payment_date)}</span> <span className="text-zinc-500">{formatDate(p.payment_date)}</span>
{/* Scope, so a grouped transfer stops looking like a duplicate: two
rows of the same amount and date differ only by which tab they
settle, and that was invisible until the API returned trip_id. */}
<span className="text-[11px] px-1.5 py-0.5 rounded bg-zinc-800 text-zinc-400 flex-shrink-0">
{p.trip_name ?? "Household"}
</span>
{p.notes && <span className="text-zinc-600 truncate flex-1">{p.notes}</span>} {p.notes && <span className="text-zinc-600 truncate flex-1">{p.notes}</span>}
<button <button
onClick={() => deletePayment.mutate(p.id)} onClick={() => deletePayment.mutate(p.id)}
+65 -20
View File
@@ -21,6 +21,10 @@ function fmtDate(d: string | null) {
return new Date(d).toLocaleDateString("en-AU", { day: "2-digit", month: "short", year: "numeric" }); return new Date(d).toLocaleDateString("en-AU", { day: "2-digit", month: "short", year: "numeric" });
} }
function fmt(n: number) {
return `$${n.toLocaleString("en-AU", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
}
function StatCard({ function StatCard({
label, label,
value, value,
@@ -73,7 +77,10 @@ export default function TripDetailPage({ params }: { params: Promise<{ id: strin
const [tab, setTab] = useState<"overview" | "transactions">("overview"); const [tab, setTab] = useState<"overview" | "transactions">("overview");
const [editModal, setEditModal] = useState(false); const [editModal, setEditModal] = useState(false);
const { data: txData } = useTransactions({ trip_id: id, limit: 500 }); // A trip is all the expenses on one trip, so a participant sees every row on
// it, not only their own. The server re-checks participation — this flag is a
// request, not a grant.
const { data: txData } = useTransactions({ trip_id: id, limit: 500, trip_all_rows: true });
if (isLoading || !analytics) { if (isLoading || !analytics) {
return ( return (
@@ -280,7 +287,7 @@ export default function TripDetailPage({ params }: { params: Promise<{ id: strin
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="border-b border-zinc-800"> <tr className="border-b border-zinc-800">
{["Person", "Outstanding on this trip"].map((h) => ( {["Person", "They owe you", "You owe them"].map((h) => (
<th <th
key={h} key={h}
className={`px-5 py-2.5 text-xs text-zinc-500 font-medium ${h === "Person" ? "text-left" : "text-right"}`} className={`px-5 py-2.5 text-xs text-zinc-500 font-medium ${h === "Person" ? "text-left" : "text-right"}`}
@@ -291,30 +298,68 @@ export default function TripDetailPage({ params }: { params: Promise<{ id: strin
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{/* A negative outstanding means they have paid more towards this {/* Two columns, never one net figure.
trip than their share of it — which reads as a typo unless the
sign is spelled out. Shown as a magnitude plus a word, the same The two directions are separate positions, not halves of a
way Shared does it, so the two pages agree on what a direction sum: the grouped-payment allocation cleared each trip against
means. */} the one-directional debt in the left column, Europe first with
the remainder to household. Netting them here would redefine
that debt after the fact and turn a settled trip into an
overpayment, with household understated by the same amount.
It is also what makes this page true for a non-owner. The
right-hand column is the figure Sonu could never see: her own
obligation lives on rows someone else paid for, so a
viewer-as-payer figure can never contain it.
A negative outstanding still means they have paid more towards
this trip than their share, which reads as a typo unless the
sign is spelled out — so it stays a magnitude plus a word, the
same way Shared does it. */}
{participant_splits.map((p) => { {participant_splits.map((p) => {
const owed = Number(p.owed); const owed = Number(p.owed);
const square = Math.abs(owed) < 0.005; const iOwe = Number(p.i_owe);
const theyOweMe = owed > 0; const cell = (
net: number,
gross: number,
paid: number,
unconverted: number,
colour: string,
) => {
if (gross < 0.005 && Math.abs(net) < 0.005) {
return <span className="text-zinc-600"></span>;
}
const square = Math.abs(net) < 0.005;
return (
<>
<span className={square ? "text-zinc-500" : net > 0 ? colour : "text-emerald-400"}>
${Math.abs(net).toFixed(2)}
</span>
<span className="block text-[11px] text-zinc-500 mt-0.5 font-sans">
{square
? "settled"
: net < 0
? "overpaid"
: paid > 0.005
? `${fmt(gross)} less ${fmt(paid)} paid`
: "outstanding"}
</span>
{unconverted > 0 && (
<span className="block text-[11px] text-amber-500/80 mt-0.5 font-sans">
approx · {unconverted} unconverted
</span>
)}
</>
);
};
return ( return (
<tr key={p.participant_id} className="border-b border-zinc-800/50 last:border-0"> <tr key={p.participant_id} className="border-b border-zinc-800/50 last:border-0">
<td className="px-5 py-3 font-medium">{p.name}</td> <td className="px-5 py-3 font-medium">{p.name}</td>
<td className="px-5 py-3 text-right tabular-nums font-mono"> <td className="px-5 py-3 text-right tabular-nums font-mono">
<span className={square ? "text-zinc-500" : theyOweMe ? "text-amber-400" : "text-blue-400"}> {cell(owed, Number(p.owed_gross), Number(p.paid_to_me), p.unconverted_count, "text-amber-400")}
${Math.abs(owed).toFixed(2)} </td>
</span> <td className="px-5 py-3 text-right tabular-nums font-mono">
<span className="block text-[11px] text-zinc-500 mt-0.5 font-sans"> {cell(iOwe, Number(p.i_owe_gross), Number(p.paid_by_me), p.i_owe_unconverted_count, "text-blue-400")}
{square ? "all square" : theyOweMe ? "owes you" : "ahead — you owe them"}
</span>
{p.unconverted_count > 0 && (
<span className="block text-[11px] text-amber-500/80 mt-0.5 font-sans">
approx · {p.unconverted_count} unconverted
</span>
)}
</td> </td>
</tr> </tr>
); );
+16
View File
@@ -30,6 +30,8 @@ interface TransactionFilters {
amount_max?: number; amount_max?: number;
has_split?: string; has_split?: string;
trip_id?: string; trip_id?: string;
/** Only the trip detail view sets this — see the note on the server-side filter. */
trip_all_rows?: boolean;
} }
function buildParams(filters: TransactionFilters): string { function buildParams(filters: TransactionFilters): string {
@@ -38,6 +40,9 @@ function buildParams(filters: TransactionFilters): string {
if (val === undefined || val === "") return; if (val === undefined || val === "") return;
if (Array.isArray(val)) { if (Array.isArray(val)) {
if (val.length > 0) params.set(key, val.join(",")); if (val.length > 0) params.set(key, val.join(","));
} else if (typeof val === "boolean") {
// The route reads "1", not "true" — String(true) would silently not match.
if (val) params.set(key, "1");
} else { } else {
params.set(key, String(val)); params.set(key, String(val));
} }
@@ -446,6 +451,9 @@ export interface SplitPayment {
payment_date: string; payment_date: string;
notes: string | null; notes: string | null;
linked_transaction_id: number | null; linked_transaction_id: number | null;
/** Which tab this payment settles. null = the ongoing household tab. */
trip_id: number | null;
trip_name: string | null;
created_at: string; created_at: string;
} }
@@ -471,17 +479,25 @@ export function useRecordPayment() {
payment_date: string; payment_date: string;
notes?: string; notes?: string;
linked_transaction_id?: number; linked_transaction_id?: number;
/** Which tab this settles. null/omitted = the ongoing household tab. */
trip_id?: number | null;
}) => { }) => {
const res = await fetch("/api/split-payments", { const res = await fetch("/api/split-payments", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(body), body: JSON.stringify(body),
}); });
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.error || "Failed to record payment");
}
return res.json(); return res.json();
}, },
onSuccess: () => { onSuccess: () => {
qc.invalidateQueries({ queryKey: ["participant-balances"] }); qc.invalidateQueries({ queryKey: ["participant-balances"] });
qc.invalidateQueries({ queryKey: ["split-payments"] }); qc.invalidateQueries({ queryKey: ["split-payments"] });
// A trip-scoped payment changes that trip's owed figures too.
qc.invalidateQueries({ queryKey: ["trip-analytics"] });
}, },
}); });
} }
+225 -46
View File
@@ -137,13 +137,33 @@ interface TransactionFilters {
amount_max?: number; amount_max?: number;
has_split?: string; has_split?: string;
trip_id?: string; trip_id?: string;
/**
* With a `trip_id` set, show every row on the trip rather than only the
* viewer's own. A trip is all the expenses on one trip, so a participant sees
* the whole thing — the trip total already counts every payer.
*
* Opt-in, and deliberately NOT implied by `trip_id` being present, because
* `GET /api/transactions` is also the main transactions list and its trip
* filter must keep owner scoping — otherwise filtering your own ledger by
* "Europe 2026" would quietly fill it with someone else's rows and skew every
* total on the page. Only the trip detail view sets this.
*/
trip_all_rows?: boolean;
} }
export async function getTransactions(ownerId: number, filters: TransactionFilters) { export async function getTransactions(ownerId: number, filters: TransactionFilters) {
const conditions: string[] = [ // A real trip id, not "unassigned" — there is no trip to participate in for
`(COALESCE(t.owner_id, s.owner_id) = $1 OR EXISTS (SELECT 1 FROM transaction_splits ts_me WHERE ts_me.transaction_id = t.id AND ts_me.participant_id = $1))`, // rows that belong to none, so the owner scoping has to stand there.
EXCLUDE_RECONCILED_SOURCE, const tripAllRows = Boolean(
]; filters.trip_all_rows && filters.trip_id && filters.trip_id !== "unassigned"
);
const conditions: string[] = [EXCLUDE_RECONCILED_SOURCE];
if (!tripAllRows) {
conditions.push(
`(COALESCE(t.owner_id, s.owner_id) = $1 OR EXISTS (SELECT 1 FROM transaction_splits ts_me WHERE ts_me.transaction_id = t.id AND ts_me.participant_id = $1))`
);
}
const params: unknown[] = [ownerId]; const params: unknown[] = [ownerId];
let paramIdx = 2; let paramIdx = 2;
@@ -231,8 +251,15 @@ export async function getTransactions(ownerId: number, filters: TransactionFilte
if (filters.trip_id === "unassigned") { if (filters.trip_id === "unassigned") {
conditions.push(`o.trip_id IS NULL`); conditions.push(`o.trip_id IS NULL`);
} else if (filters.trip_id) { } else if (filters.trip_id) {
conditions.push(`o.trip_id = $${paramIdx++}`); const tripParam = paramIdx++;
conditions.push(`o.trip_id = $${tripParam}`);
params.push(Number(filters.trip_id)); params.push(Number(filters.trip_id));
if (tripAllRows) {
// The gate for having dropped the owner filter above. Enforced in SQL
// rather than trusted from the route, so passing trip_all_rows with a
// trip you are not on returns nothing instead of everything.
conditions.push(TRIP_PARTICIPANT(`$${tripParam}`, `$1`));
}
} }
const where = `WHERE ${conditions.join(" AND ")}`; const where = `WHERE ${conditions.join(" AND ")}`;
@@ -904,11 +931,69 @@ export interface TripAnalytics {
participant_splits: { participant_splits: {
participant_id: number; participant_id: number;
name: string; name: string;
/** Their share of this trip, net of payments scoped to it. */ /** Their share of rows the VIEWER paid, net of payments scoped to this trip. */
owed: number; owed: number;
/** That same figure before payments, so the UI can say "settled" rather than just "0.00". */
owed_gross: number;
/** Payments from them to the viewer, scoped to this trip. */
paid_to_me: number;
/** The VIEWER's share of rows THIS PARTICIPANT paid, net of the viewer's payments to them. */
i_owe: number;
/** Ditto, before payments. */
i_owe_gross: number;
/** Payments from the viewer to them, scoped to this trip. */
paid_by_me: number;
/** Splits counted at a non-AUD figure because no converted amount exists. */ /** Splits counted at a non-AUD figure because no converted amount exists. */
unconverted_count: number; unconverted_count: number;
i_owe_unconverted_count: number;
}[]; }[];
/** True when the viewer is a participant but not the trip's owner. */
viewer_is_owner: boolean;
}
/**
* Who counts as a participant on a trip. Derived, never stored.
*
* A trip is *all the expenses on one trip*, so being a participant is a fact
* about those expenses: you hold a split on one, you paid for one, or a payment
* of yours is scoped to the trip. Storing it as a membership list would be a
* second record of the same fact, and two records of one fact drift — the same
* reason `CLAUDE.md` insists sharing is a real split rather than a flag, and the
* reason the 2026-07-27 settlement design concluded an extra party on a trip
* "needs no schema at all".
*
* It also gets the exclusions right for free. Singapore + Bangkok 2026 has no
* Sonu split and no Sonu payment, so she is not a participant and never sees it,
* with no backfill to keep in sync as transactions are assigned and unassigned.
*
* Aliased `p*` throughout so it can be inlined anywhere without colliding with
* the `t`/`s`/`o` aliases the shared fragments assume.
*/
const TRIP_PARTICIPANT = (tripIdExpr: string, participantExpr: string) => `(
EXISTS (
SELECT 1 FROM transaction_overrides po
JOIN transactions pt ON pt.id = po.transaction_id
LEFT JOIN statements ps ON ps.id = pt.statement_id
LEFT JOIN transaction_splits pts ON pts.transaction_id = pt.id
WHERE po.trip_id = ${tripIdExpr}
AND (COALESCE(pt.owner_id, ps.owner_id) = ${participantExpr}
OR pts.participant_id = ${participantExpr})
)
OR EXISTS (
SELECT 1 FROM split_payments psp
WHERE psp.trip_id = ${tripIdExpr}
AND (psp.from_participant_id = ${participantExpr}
OR psp.to_participant_id = ${participantExpr})
)
)`;
/** Exported for the routes that gate a write on participation. */
export async function isTripParticipant(tripId: number, participantId: number): Promise<boolean> {
const rows = await queryRaw<{ ok: boolean }>(`
SELECT (tr.owner_id = $2 OR ${TRIP_PARTICIPANT('tr.id', '$2')}) AS ok
FROM trips tr WHERE tr.id = $1
`, [tripId, participantId]);
return rows[0]?.ok === true;
} }
// `total_spend` is the headline figure on the trips list and the trip header, // `total_spend` is the headline figure on the trips list and the trip header,
@@ -922,7 +1007,11 @@ const TRIP_TOTAL_SPEND = `COALESCE(SUM(
THEN ${SPEND_SIGNED} ELSE 0 END THEN ${SPEND_SIGNED} ELSE 0 END
), 0)::float AS total_spend`; ), 0)::float AS total_spend`;
export async function getTrips(ownerId: number): Promise<TripRow[]> { // A trip is visible to its owner and to anyone who participates in it. Scoping
// visibility to `owner_id` alone meant Sonu could not see a single trip despite
// paying for 104 of the tagged rows herself — her own spending was invisible on
// the only page organised around it.
export async function getTrips(viewerId: number): Promise<TripRow[]> {
return queryRaw<TripRow>(` return queryRaw<TripRow>(`
SELECT SELECT
tr.*, tr.*,
@@ -931,13 +1020,13 @@ export async function getTrips(ownerId: number): Promise<TripRow[]> {
FROM trips tr FROM trips tr
LEFT JOIN transaction_overrides o ON o.trip_id = tr.id LEFT JOIN transaction_overrides o ON o.trip_id = tr.id
LEFT JOIN transactions t ON t.id = o.transaction_id LEFT JOIN transactions t ON t.id = o.transaction_id
WHERE tr.owner_id = $1 WHERE tr.owner_id = $1 OR ${TRIP_PARTICIPANT('tr.id', '$1')}
GROUP BY tr.id GROUP BY tr.id
ORDER BY tr.created_at DESC ORDER BY tr.created_at DESC
`, [ownerId]); `, [viewerId]);
} }
export async function getTripById(id: number, ownerId: number): Promise<TripRow | null> { export async function getTripById(id: number, viewerId: number): Promise<TripRow | null> {
const rows = await queryRaw<TripRow>(` const rows = await queryRaw<TripRow>(`
SELECT SELECT
tr.*, tr.*,
@@ -946,14 +1035,14 @@ export async function getTripById(id: number, ownerId: number): Promise<TripRow
FROM trips tr FROM trips tr
LEFT JOIN transaction_overrides o ON o.trip_id = tr.id LEFT JOIN transaction_overrides o ON o.trip_id = tr.id
LEFT JOIN transactions t ON t.id = o.transaction_id LEFT JOIN transactions t ON t.id = o.transaction_id
WHERE tr.id = $1 AND tr.owner_id = $2 WHERE tr.id = $1 AND (tr.owner_id = $2 OR ${TRIP_PARTICIPANT('tr.id', '$2')})
GROUP BY tr.id GROUP BY tr.id
`, [id, ownerId]); `, [id, viewerId]);
return rows[0] ?? null; return rows[0] ?? null;
} }
export async function getTripAnalytics(tripId: number, ownerId: number): Promise<TripAnalytics> { export async function getTripAnalytics(tripId: number, viewerId: number): Promise<TripAnalytics> {
const trip = await getTripById(tripId, ownerId); const trip = await getTripById(tripId, viewerId);
if (!trip) throw new Error("Trip not found"); if (!trip) throw new Error("Trip not found");
// What the trip cost, with refunds subtracted. // What the trip cost, with refunds subtracted.
@@ -1059,7 +1148,7 @@ export async function getTripAnalytics(tripId: number, ownerId: number): Promise
// so netting a EUR figure against AUD ones silently is most likely to // so netting a EUR figure against AUD ones silently is most likely to
// bite exactly here. // bite exactly here.
// //
// A fourth, and it is what "owed" actually means: only rows THIS owner paid // A fourth, and it is what "owed" actually means: only rows THIS viewer paid
// for. Without ${OWNER_SCOPE} the figure sums every split on every trip // for. Without ${OWNER_SCOPE} the figure sums every split on every trip
// transaction regardless of who paid, so it silently mixes debts owed to // transaction regardless of who paid, so it silently mixes debts owed to
// different people. On Europe 2026 that put $1,605.49 of Molina's share of // different people. On Europe 2026 that put $1,605.49 of Molina's share of
@@ -1072,43 +1161,87 @@ export async function getTripAnalytics(tripId: number, ownerId: number): Promise
// `transactions` is aliased `t` so the shared fragments apply directly — // `transactions` is aliased `t` so the shared fragments apply directly —
// they assume that alias, and hand-inlining a copy is what let the // they assume that alias, and hand-inlining a copy is what let the
// reconciled-row exclusion drift out of the analytics routes to begin with. // reconciled-row exclusion drift out of the analytics routes to begin with.
queryRaw<{ participant_id: number; name: string; owed: number; unconverted_count: number }>(` //
WITH owed AS ( // ── Both directions, and deliberately NOT netted ──
SELECT ts.participant_id AS pid, //
SUM(ts.share_percent / 100.0 * COALESCE(t.amount_aud, t.amount)) AS gross, // `owed` is unchanged: their share of rows the viewer paid. `i_owe` is the
SUM(CASE WHEN ${AMOUNT_UNCONVERTED} THEN 1 ELSE 0 END) AS unconverted // mirror: the viewer's share of rows THAT participant paid. Rendering the
// pair from the viewer's side is the whole fix — a non-owner used to get a
// page where their own obligation could not appear, so Sonu's Europe 2026
// read "you are owed $2,408.24" while omitting the $8,004.04 she owed.
//
// Collapsing the two into one signed net was the obvious next step and is
// WRONG. The grouped-payment allocation (memory case `allocate_grouped_payments`)
// cleared Sonu's transfers against the trip debts chronologically, Europe
// first, remainder to household — and the debt it cleared was this
// one-directional gross. Netting redefines Europe's debt as $7,201.30 after
// the fact, which turns the $8,004.04 already allocated into an $802.75
// over-allocation and leaves household understated by the same amount. The
// total stays right and the split between scopes silently stops being. So
// both halves are returned whole, with their gross and payments, and the UI
// states them separately.
queryRaw<{
participant_id: number; name: string;
owed: number; owed_gross: number; paid_to_me: number;
i_owe: number; i_owe_gross: number; paid_by_me: number;
unconverted_count: number; i_owe_unconverted_count: number;
}>(`
WITH scoped AS (
SELECT ts.participant_id AS split_pid,
${OWNER_SCOPE} AS payer_pid,
ts.share_percent / 100.0 * COALESCE(t.amount_aud, t.amount) AS amt,
CASE WHEN ${AMOUNT_UNCONVERTED} THEN 1 ELSE 0 END AS unconverted
FROM transaction_overrides o FROM transaction_overrides o
JOIN transactions t ON t.id = o.transaction_id JOIN transactions t ON t.id = o.transaction_id
${STATEMENTS_JOIN} ${STATEMENTS_JOIN}
JOIN transaction_splits ts ON ts.transaction_id = t.id JOIN transaction_splits ts ON ts.transaction_id = t.id
WHERE o.trip_id = $1 WHERE o.trip_id = $1
AND ${OWNER_SCOPE} = $2
AND ts.participant_id <> $2
AND t.transaction_type IN ('debit','fee','interest') AND t.transaction_type IN ('debit','fee','interest')
AND COALESCE(o.category_override, t.category, 'other') NOT IN ('transfers', 'investment') AND COALESCE(o.category_override, t.category, 'other') NOT IN ('transfers', 'investment')
AND ${ACTIVE_OBLIGATION} AND ${ACTIVE_OBLIGATION}
AND ${EXCLUDE_RECONCILED_SOURCE} AND ${EXCLUDE_RECONCILED_SOURCE}
GROUP BY ts.participant_id
), ),
-- Only payments made TO this owner. Payment 5 on Europe is Molina -> Sonu: owed AS (
-- a real settlement, but of a debt between those two, so it must not SELECT split_pid AS pid, SUM(amt) AS gross, SUM(unconverted) AS unconverted
-- reduce what Molina owes here. Symmetrical with the owner scoping above. FROM scoped WHERE payer_pid = $2 AND split_pid <> $2 GROUP BY 1
paid AS ( ),
mine AS (
SELECT payer_pid AS pid, SUM(amt) AS gross, SUM(unconverted) AS unconverted
FROM scoped WHERE split_pid = $2 AND payer_pid <> $2 GROUP BY 1
),
-- Only payments made TO the viewer. Payment 5 is Molina -> Sonu: a real
-- settlement, but of a debt between those two, so it must not reduce what
-- Molina owes the viewer. Symmetrical with the payer scoping above.
paid_to_me AS (
SELECT sp.from_participant_id AS pid, SUM(sp.amount) AS amt SELECT sp.from_participant_id AS pid, SUM(sp.amount) AS amt
FROM split_payments sp FROM split_payments sp
WHERE sp.trip_id = $1 WHERE sp.trip_id = $1 AND sp.to_participant_id = $2
AND sp.to_participant_id = $2 GROUP BY 1
GROUP BY sp.from_participant_id ),
paid_by_me AS (
SELECT sp.to_participant_id AS pid, SUM(sp.amount) AS amt
FROM split_payments sp
WHERE sp.trip_id = $1 AND sp.from_participant_id = $2
GROUP BY 1
) )
SELECT p.id AS participant_id, p.name, SELECT p.id AS participant_id, p.name,
(COALESCE(owed.gross, 0) - COALESCE(paid.amt, 0))::float AS owed, (COALESCE(owed.gross, 0) - COALESCE(paid_to_me.amt, 0))::float AS owed,
COALESCE(owed.unconverted, 0)::int AS unconverted_count COALESCE(owed.gross, 0)::float AS owed_gross,
COALESCE(paid_to_me.amt, 0)::float AS paid_to_me,
(COALESCE(mine.gross, 0) - COALESCE(paid_by_me.amt, 0))::float AS i_owe,
COALESCE(mine.gross, 0)::float AS i_owe_gross,
COALESCE(paid_by_me.amt, 0)::float AS paid_by_me,
COALESCE(owed.unconverted, 0)::int AS unconverted_count,
COALESCE(mine.unconverted, 0)::int AS i_owe_unconverted_count
FROM participants p FROM participants p
LEFT JOIN owed ON owed.pid = p.id LEFT JOIN owed ON owed.pid = p.id
LEFT JOIN paid ON paid.pid = p.id LEFT JOIN mine ON mine.pid = p.id
WHERE owed.pid IS NOT NULL OR paid.pid IS NOT NULL LEFT JOIN paid_to_me ON paid_to_me.pid = p.id
LEFT JOIN paid_by_me ON paid_by_me.pid = p.id
WHERE owed.pid IS NOT NULL OR mine.pid IS NOT NULL
OR paid_to_me.pid IS NOT NULL OR paid_by_me.pid IS NOT NULL
ORDER BY 3 DESC ORDER BY 3 DESC
`, [tripId, ownerId]), `, [tripId, viewerId]),
]); ]);
const num_days = (trip.start_date && trip.end_date) const num_days = (trip.start_date && trip.end_date)
@@ -1126,6 +1259,7 @@ export async function getTripAnalytics(tripId: number, ownerId: number): Promise
top_merchants: merchantRows, top_merchants: merchantRows,
tag_breakdown: tagRows, tag_breakdown: tagRows,
participant_splits: splitRows, participant_splits: splitRows,
viewer_is_owner: trip.owner_id === viewerId,
}; };
} }
@@ -1141,9 +1275,11 @@ export async function createTrip(
return rows[0]; return rows[0];
} }
// Editable by any participant: a trip is a shared record of a shared journey,
// and dates or a colour are not the owner's private property.
export async function updateTrip( export async function updateTrip(
id: number, id: number,
ownerId: number, viewerId: number,
data: Partial<{ name: string; description: string | null; start_date: string | null; end_date: string | null; color: string; archived: boolean }> data: Partial<{ name: string; description: string | null; start_date: string | null; end_date: string | null; color: string; archived: boolean }>
): Promise<TripRow | null> { ): Promise<TripRow | null> {
const setClauses: string[] = []; const setClauses: string[] = [];
@@ -1155,31 +1291,74 @@ export async function updateTrip(
if ('end_date' in data) { setClauses.push(`end_date = $${idx++}`); params.push(data.end_date ?? null); } if ('end_date' in data) { setClauses.push(`end_date = $${idx++}`); params.push(data.end_date ?? null); }
if (data.color !== undefined) { setClauses.push(`color = $${idx++}`); params.push(data.color); } if (data.color !== undefined) { setClauses.push(`color = $${idx++}`); params.push(data.color); }
if (data.archived !== undefined) { setClauses.push(`archived = $${idx++}`); params.push(data.archived); } if (data.archived !== undefined) { setClauses.push(`archived = $${idx++}`); params.push(data.archived); }
if (!setClauses.length) return getTripById(id, ownerId); if (!setClauses.length) return getTripById(id, viewerId);
params.push(id, ownerId); const idParam = idx++;
const viewerParam = idx;
params.push(id, viewerId);
const rows = await queryRaw<TripRow>(` const rows = await queryRaw<TripRow>(`
UPDATE trips SET ${setClauses.join(', ')} UPDATE trips SET ${setClauses.join(', ')}
WHERE id = $${idx++} AND owner_id = $${idx} WHERE id = $${idParam}
AND (owner_id = $${viewerParam} OR ${TRIP_PARTICIPANT(`$${idParam}`, `$${viewerParam}`)})
RETURNING *, 0::float AS total_spend, 0::int AS transaction_count RETURNING *, 0::float AS total_spend, 0::int AS transaction_count
`, params); `, params);
return rows[0] ?? null; return rows[0] ?? null;
} }
/**
* Delete stays OWNER-ONLY, deliberately, even though everything else about a
* trip is now shared.
*
* Both trip foreign keys are ON DELETE SET NULL, so deleting Europe 2026 untags
* 210 transactions *and* NULLs the trip scope on 6 payments. That scope is where
* the Europe-first allocation of Sonu's grouped transfers lives, and it was
* derived by hand — nothing in the app recomputes it. Handing that to any
* participant makes an unrecoverable loss one click away.
*/
export async function deleteTrip(id: number, ownerId: number): Promise<void> { export async function deleteTrip(id: number, ownerId: number): Promise<void> {
await queryRaw(`DELETE FROM trips WHERE id = $1 AND owner_id = $2`, [id, ownerId]); await queryRaw(`DELETE FROM trips WHERE id = $1 AND owner_id = $2`, [id, ownerId]);
} }
/**
* Assign transactions to a trip, or to none when `tripId` is null.
*
* Both the scoping clauses here are new and both closed a live hole: this took
* no viewer at all and checked nothing, so `PATCH /api/trips/[id]/transactions`
* let any authenticated participant move any transaction id into any trip id.
* Not being able to *see* a trip was no obstacle, because the write path never
* read one.
*
* - Only rows the viewer can already see may be moved (`owner OR split`, the
* same test getTransactions applies).
* - A non-null destination must be a trip the viewer participates in. Checked
* here rather than only in the route so the guarantee cannot be bypassed by
* the other caller (`POST /api/transactions/bulk`, `assign_trip`).
*
* Returns the number of rows actually moved, which is how a caller detects that
* some ids were silently out of reach.
*/
export async function assignTransactionsToTrip( export async function assignTransactionsToTrip(
tripId: number | null, tripId: number | null,
transactionIds: number[] transactionIds: number[],
): Promise<void> { viewerId: number
if (!transactionIds.length) return; ): Promise<number> {
await queryRaw(` if (!transactionIds.length) return 0;
if (tripId !== null && !(await isTripParticipant(tripId, viewerId))) {
throw new Error("Not a participant on that trip");
}
const rows = await queryRaw<{ transaction_id: number }>(`
INSERT INTO transaction_overrides (transaction_id, trip_id) INSERT INTO transaction_overrides (transaction_id, trip_id)
SELECT unnest($1::int[]), $2 SELECT t.id, $2
FROM transactions t
LEFT JOIN statements s ON s.id = t.statement_id
WHERE t.id = ANY($1::int[])
AND (COALESCE(t.owner_id, s.owner_id) = $3
OR EXISTS (SELECT 1 FROM transaction_splits ts
WHERE ts.transaction_id = t.id AND ts.participant_id = $3))
ON CONFLICT (transaction_id) ON CONFLICT (transaction_id)
DO UPDATE SET trip_id = EXCLUDED.trip_id DO UPDATE SET trip_id = EXCLUDED.trip_id
`, [transactionIds, tripId]); RETURNING transaction_id
`, [transactionIds, tripId, viewerId]);
return rows.length;
} }
export async function getTagTransactionIds(tagId: number): Promise<number[]> { export async function getTagTransactionIds(tagId: number): Promise<number[]> {