Net the trip debt to one figure, and show payer, category and search on Shared
ci / lint-test (push) Successful in 48s
ci / lint-test (push) Successful in 48s
Shared view: the query already returned owner_name and effective_category, the table just never rendered them. Paid by sits next to Splits because together they are whose money went out and whose share it was. Search is client-side — this endpoint returns all 1,267 split rows in one request with no pagination, so there is nothing for a round-trip to narrow, and the sort was already client-side. It matches description, merchant, notes, category and payer, but not participant names: the dropdown does that, and "sonu" matching every row she is split on would read as broken. Trip owed collapses to one settle-up figure per person, with the breakdown beside it so the net is auditable rather than asserted. I argued against netting a few hours ago and was wrong. The claim was that the grouped-payment allocation cleared each trip against the one-directional gross, so netting would redefine that debt after the fact. The rows say otherwise: Europe's $802.75 is 56 transactions Sonu actually paid across Rome, Venice, the Dolomites, Bellagio, Lucerne and Paris on which I hold 25%, and paid_by_me is $0.00 on every row of every trip because nothing has ever been recorded going from me to her. Her side looked settled only because the allocation derived her payment split from her gross, so it lands on zero by construction. The one-directional view was hiding a live obligation, not protecting an allocation. Nets now: Auckland Sonu +$1,077.25, Europe Sonu -$802.75, Sonu + Sunny -$936.34, Europe Molina -$816.16. Also correcting an error in my own reporting: I said Auckland's mirror was $0.00. It is $428.39 — 17 Auckland rows Sonu paid that I hold a split on. Two ad-hoc verification queries mis-joined on a nullable scope column and under-reported the mirror side. The app code was never affected and the owed column is still byte-identical. The footnote now states the trap the netting exposes: a debt settled by a payment left on the household tab still reads as outstanding on the trip. Payment 5 (Molina to Sonu, $1,605.49) is exactly that case and is left alone as a data decision. 277 passing, build clean.
This commit is contained in:
+46
-53
@@ -287,10 +287,10 @@ export default function TripDetailPage({ params }: { params: Promise<{ id: strin
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-zinc-800">
|
||||
{["Person", "They owe you", "You owe them"].map((h) => (
|
||||
{["Person", "To settle this trip", "How it adds up"].map((h) => (
|
||||
<th
|
||||
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" : h === "How it adds up" ? "text-left" : "text-right"}`}
|
||||
>
|
||||
{h}
|
||||
</th>
|
||||
@@ -298,68 +298,58 @@ export default function TripDetailPage({ params }: { params: Promise<{ id: strin
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{/* Two columns, never one net figure.
|
||||
{/* ONE net figure per person, because the point of the column is
|
||||
"what do we transfer to close this trip" and two figures make
|
||||
that a subtraction the reader has to do.
|
||||
|
||||
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.
|
||||
I argued against netting first, on the grounds that the
|
||||
grouped-payment allocation cleared each trip against the
|
||||
one-directional gross, so netting would redefine that debt
|
||||
after the fact. Checking the rows settled it the other way: the
|
||||
$802.75 on Europe is 56 transactions Sonu paid that Siddharth
|
||||
holds 25% of, and nothing has EVER been recorded going from him
|
||||
to her (`paid_by_me` is 0.00 on every row). The one-directional
|
||||
view was concealing a real obligation, not protecting an
|
||||
allocation. Netting surfaces it.
|
||||
|
||||
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.
|
||||
The breakdown stays visible in the next column so the net is
|
||||
auditable rather than asserted — the two halves are still what
|
||||
the API returns, and a net that nobody can decompose is how a
|
||||
wrong figure survives.
|
||||
|
||||
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. */}
|
||||
Sign convention matches Shared: positive means they owe you. */}
|
||||
{participant_splits.map((p) => {
|
||||
const owed = Number(p.owed);
|
||||
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"}>
|
||||
const owedGross = Number(p.owed_gross);
|
||||
const paidToMe = Number(p.paid_to_me);
|
||||
const iOweGross = Number(p.i_owe_gross);
|
||||
const paidByMe = Number(p.paid_by_me);
|
||||
const net = Number(p.owed) - Number(p.i_owe);
|
||||
const square = Math.abs(net) < 0.005;
|
||||
const unconverted = p.unconverted_count + p.i_owe_unconverted_count;
|
||||
const parts = [
|
||||
owedGross > 0.005 ? `their share ${fmt(owedGross)}` : null,
|
||||
paidToMe > 0.005 ? `they paid ${fmt(paidToMe)}` : null,
|
||||
iOweGross > 0.005 ? `your share of their spend ${fmt(iOweGross)}` : null,
|
||||
paidByMe > 0.005 ? `you paid ${fmt(paidByMe)}` : null,
|
||||
].filter(Boolean);
|
||||
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 whitespace-nowrap">
|
||||
<span className={square ? "text-zinc-500" : net > 0 ? "text-amber-400" : "text-blue-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"}
|
||||
{square ? "all square" : net > 0 ? "owes you" : "you owe them"}
|
||||
</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">
|
||||
{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 className="px-5 py-3 text-[11px] text-zinc-500 leading-relaxed">
|
||||
{parts.length ? parts.join(" · ") : "no split activity on this trip"}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
@@ -372,9 +362,12 @@ export default function TripDetailPage({ params }: { params: Promise<{ id: strin
|
||||
are net of payments scoped to this trip. What the note has to say
|
||||
instead is which payments are NOT in them. */}
|
||||
<p className="px-5 py-2.5 text-xs text-zinc-500 border-t border-zinc-800">
|
||||
Net of payments recorded against this trip. Payments on the ongoing
|
||||
household tab are not counted here —
|
||||
see <Link href="/shared" className="text-zinc-400 hover:text-zinc-200 underline">Shared</Link> for
|
||||
One figure per person: their share of what you paid, less what they
|
||||
paid you, less your share of what they paid. Only payments
|
||||
<em className="not-italic text-zinc-400"> scoped to this trip</em> count
|
||||
— a debt settled by a payment left on the household tab still reads as
|
||||
outstanding here, so set the scope when recording one.
|
||||
See <Link href="/shared" className="text-zinc-400 hover:text-zinc-200 underline">Shared</Link> for
|
||||
the overall balance.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user