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.
|
||||
|
||||
**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
|
||||
*shared* items are split. `utilities` sits at 69% yours because Globird, OVO, GWW
|
||||
|
||||
Reference in New Issue
Block a user