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:
@@ -11,6 +11,7 @@ import {
|
||||
useDeletePayment,
|
||||
useCurrentUser,
|
||||
useTags,
|
||||
useTrips,
|
||||
type SplitPayment,
|
||||
} from "@/lib/hooks";
|
||||
import type { SharedTransactionRow } from "@/lib/queries";
|
||||
@@ -147,6 +148,7 @@ function RecordPaymentModal({
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const record = useRecordPayment();
|
||||
const { data: trips = [] } = useTrips();
|
||||
const theyOweMe = currentBalance > 0;
|
||||
|
||||
// Default direction matches the debt direction
|
||||
@@ -155,6 +157,8 @@ function RecordPaymentModal({
|
||||
const [notes, setNotes] = useState("");
|
||||
// direction: "received" = they paid me, "sent" = I paid them
|
||||
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("");
|
||||
|
||||
async function handleSave() {
|
||||
@@ -168,6 +172,7 @@ function RecordPaymentModal({
|
||||
amount: amt,
|
||||
payment_date: date,
|
||||
notes: notes || undefined,
|
||||
trip_id: tripId ? Number(tripId) : null,
|
||||
});
|
||||
onClose();
|
||||
} catch (e) {
|
||||
@@ -216,6 +221,24 @@ function RecordPaymentModal({
|
||||
</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>
|
||||
<label className="block text-xs text-zinc-500 mb-1">Notes (optional)</label>
|
||||
<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)}
|
||||
</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>}
|
||||
<button
|
||||
onClick={() => deletePayment.mutate(p.id)}
|
||||
|
||||
Reference in New Issue
Block a user