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:
@@ -30,6 +30,8 @@ interface TransactionFilters {
|
||||
amount_max?: number;
|
||||
has_split?: 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 {
|
||||
@@ -38,6 +40,9 @@ function buildParams(filters: TransactionFilters): string {
|
||||
if (val === undefined || val === "") return;
|
||||
if (Array.isArray(val)) {
|
||||
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 {
|
||||
params.set(key, String(val));
|
||||
}
|
||||
@@ -446,6 +451,9 @@ export interface SplitPayment {
|
||||
payment_date: string;
|
||||
notes: string | 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;
|
||||
}
|
||||
|
||||
@@ -471,17 +479,25 @@ export function useRecordPayment() {
|
||||
payment_date: string;
|
||||
notes?: string;
|
||||
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", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
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();
|
||||
},
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["participant-balances"] });
|
||||
qc.invalidateQueries({ queryKey: ["split-payments"] });
|
||||
// A trip-scoped payment changes that trip's owed figures too.
|
||||
qc.invalidateQueries({ queryKey: ["trip-analytics"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user