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:
@@ -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 Mar–12 Apr), Auckland 2026 (id 2),
|
**Trips:** Europe 2026 (id 1, 19 Mar–12 Apr), Auckland 2026 (id 2),
|
||||||
Europe — Sonu + Sunny (id 3, 12–28 Apr, created 2026-07-28 from tag 5).
|
Europe — Sonu + Sunny (id 3, 12–28 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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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
@@ -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[]> {
|
||||||
|
|||||||
Reference in New Issue
Block a user