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:
+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" });
|
||||
}
|
||||
|
||||
function fmt(n: number) {
|
||||
return `$${n.toLocaleString("en-AU", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||||
}
|
||||
|
||||
function StatCard({
|
||||
label,
|
||||
value,
|
||||
@@ -73,7 +77,10 @@ export default function TripDetailPage({ params }: { params: Promise<{ id: strin
|
||||
const [tab, setTab] = useState<"overview" | "transactions">("overview");
|
||||
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) {
|
||||
return (
|
||||
@@ -280,7 +287,7 @@ export default function TripDetailPage({ params }: { params: Promise<{ id: strin
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-zinc-800">
|
||||
{["Person", "Outstanding on this trip"].map((h) => (
|
||||
{["Person", "They owe you", "You owe them"].map((h) => (
|
||||
<th
|
||||
key={h}
|
||||
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>
|
||||
</thead>
|
||||
<tbody>
|
||||
{/* A negative outstanding means they have paid more towards this
|
||||
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
|
||||
way Shared does it, so the two pages agree on what a direction
|
||||
means. */}
|
||||
{/* Two columns, never one net figure.
|
||||
|
||||
The two directions are separate positions, not halves of a
|
||||
sum: the grouped-payment allocation cleared each trip against
|
||||
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) => {
|
||||
const owed = Number(p.owed);
|
||||
const square = Math.abs(owed) < 0.005;
|
||||
const theyOweMe = owed > 0;
|
||||
const iOwe = Number(p.i_owe);
|
||||
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 (
|
||||
<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 text-right tabular-nums font-mono">
|
||||
<span className={square ? "text-zinc-500" : theyOweMe ? "text-amber-400" : "text-blue-400"}>
|
||||
${Math.abs(owed).toFixed(2)}
|
||||
</span>
|
||||
<span className="block text-[11px] text-zinc-500 mt-0.5 font-sans">
|
||||
{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>
|
||||
)}
|
||||
{cell(owed, Number(p.owed_gross), Number(p.paid_to_me), p.unconverted_count, "text-amber-400")}
|
||||
</td>
|
||||
<td className="px-5 py-3 text-right tabular-nums font-mono">
|
||||
{cell(iOwe, Number(p.i_owe_gross), Number(p.paid_by_me), p.i_owe_unconverted_count, "text-blue-400")}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user