Net the trip debt to one figure, and show payer, category and search on Shared
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:
2026-08-02 21:19:40 +10:00
parent cb7665ded1
commit 6db2345c49
4 changed files with 227 additions and 81 deletions
+47 -13
View File
@@ -291,19 +291,53 @@ 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 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. 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 **The API returns both halves whole; the trip page nets them for display.** One
step and it silently corrupts the scope allocation. The grouped-payment figure per person answers the only question the column exists for — what do we
allocation (memory case `allocate_grouped_payments`) cleared Sonu's transfers transfer to close this trip. The breakdown renders beside it, because a net
against the trip debts chronologically, Europe first with the remainder to nobody can decompose is how a wrong figure survives.
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 **On whether to net — the reasoning reversed once, and the second answer is the
already allocated into an $802.75 over-allocation with household understated by right one.** The first objection was that the grouped-payment allocation (memory
the same amount. The total stays right; the split between scopes stops being. case `allocate_grouped_payments`) cleared each trip against the *one-directional*
Both halves therefore carry their gross and payments too (`owed_gross`, gross, Europe first with the remainder to household, so netting would redefine
`paid_to_me`, `i_owe_gross`, `paid_by_me`) so a paid-up trip reads "settled" that debt after the fact. Checking the underlying rows overturned it: Europe's
rather than a bare `0.00`. Verified byte-identical across the change: Auckland $802.75 is **56 real transactions Sonu paid** across Rome, Venice, the Dolomites,
Sonu $1,505.64, Europe Molina $816.16, Europe Sonu $0.00, Sonu + Sunny Sonu Bellagio, Lucerne and Paris on which Siddharth holds 25% — and `paid_by_me` is
$0.00. **$0.00 on every row of every trip**, because nothing has ever been recorded
going from him to her. The one-directional view was concealing a live obligation,
not protecting an allocation. Her side was paid in full and looked settled only
because the allocation derived her payment split *from* her gross, so it lands on
zero by construction.
Current nets: Auckland Sonu **+$1,077.25** (1,505.64 428.39), Europe Sonu
**$802.75**, Sonu + Sunny **$936.34**, Europe Molina **$816.16**. The `owed`
column itself was verified byte-identical when the mirror was added — $1,505.64,
$816.16, $0.00, $0.00.
**A payment left on the household tab makes a settled trip debt read as
outstanding.** Payment 5 (Molina → Sonu, $1,605.49) discharged the Europe debt
between those two but carries `trip_id IS NULL`, so a trip-scoped net cannot see
it and Europe still shows it owing. That is the cost of scope being optional, and
the reason the Record Payment modal now asks. Fixable per row with
`UPDATE split_payments SET trip_id = 1 WHERE id = 5` — not done, it is a data
decision.
### The Shared view shows payer and category, and is searchable (2026-08-02)
`getSharedTransactions` already returned `owner_name` and `effective_category`;
the table simply never rendered them. **Paid by** sits next to **Splits**
deliberately — together they are the two halves of the question the page exists
to answer, whose money went out and whose share it was. It shows the *effective*
owner (`COALESCE(t.owner_id, s.owner_id)`), which is the account the spend left,
and the same figure every balance on the page is computed from. Category uses the
override-first COALESCE, so a correction made anywhere shows here.
Search is **client-side**, unlike the transactions page. This endpoint returns
every split row in one request (1,267 today) with no pagination, so there is
nothing for a server round-trip to narrow, and the sort was already client-side.
It matches description, merchant, notes, category and payer — deliberately **not**
participant names, because the participant dropdown already does that and typing
"sonu" matching every row she is split on would read as broken.
### Payment scope reaches the API (2026-08-02) ### Payment scope reaches the API (2026-08-02)
+36 -5
View File
@@ -935,10 +935,11 @@ describe("trip owed — both directions, never netted", () => {
expect(Number(alice.paid_by_me)).toBeCloseTo(100); expect(Number(alice.paid_by_me)).toBeCloseTo(100);
}); });
// Netting the two would redefine the debt the grouped-payment allocation was // The API returns both halves whole; the trip page nets them for display. The
// computed against, turning a settled trip into an overpayment and leaving // halves must stay separately available so that net is decomposable — a net
// household understated by the same amount. // nobody can audit is how a wrong figure survives, and it is what let Europe
it("does not net the two directions against each other", async () => { // read "settled" while concealing 56 rows Sonu had paid.
it("returns each direction whole rather than pre-netted", async () => {
const { ownerId, otherId } = await seedParticipants(pool); const { ownerId, otherId } = await seedParticipants(pool);
const tripId = await seed(ownerId, otherId, ownerId); const tripId = await seed(ownerId, otherId, ownerId);
// A second row, paid the other way, so both directions are live at once. // A second row, paid the other way, so both directions are live at once.
@@ -957,10 +958,40 @@ describe("trip owed — both directions, never netted", () => {
const bob = participant_splits.find((r) => r.participant_id === otherId)!; const bob = participant_splits.find((r) => r.participant_id === otherId)!;
expect(Number(bob.owed)).toBeCloseTo(100); expect(Number(bob.owed)).toBeCloseTo(100);
expect(Number(bob.i_owe)).toBeCloseTo(30); expect(Number(bob.i_owe)).toBeCloseTo(30);
// Emphatically not 70. // What the page displays as the single settle-up figure.
expect(Number(bob.owed) - Number(bob.i_owe)).toBeCloseTo(70); expect(Number(bob.owed) - Number(bob.i_owe)).toBeCloseTo(70);
}); });
// Europe 2026's shape exactly: her side paid in full, his side never paid at
// all. The one-directional view called that "settled"; the net must not.
it("nets a fully-paid side against an unpaid opposite side", async () => {
const { ownerId, otherId } = await seedParticipants(pool);
const tripId = await seed(ownerId, otherId, ownerId);
// Bob pays his $100 share in full, scoped to the trip.
await pool.query(
`INSERT INTO split_payments (from_participant_id, to_participant_id, amount, payment_date, trip_id)
VALUES ($1, $2, 100, '2026-06-20', $3)`,
[otherId, ownerId, tripId]
);
// But Alice holds a share of something Bob paid for, and never settled it.
const bobsTx = await insertTransaction(pool, otherId, { amount: 40, category: "travel" });
await pool.query(
`INSERT INTO transaction_overrides (transaction_id, trip_id) VALUES ($1, $2)`,
[bobsTx, tripId]
);
await pool.query(
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent)
VALUES ($1, $2, 50)`,
[bobsTx, ownerId]
);
const { participant_splits } = await getTripAnalytics(tripId, ownerId);
const bob = participant_splits.find((r) => r.participant_id === otherId)!;
expect(Number(bob.owed)).toBeCloseTo(0); // his side: settled
expect(Number(bob.i_owe)).toBeCloseTo(20); // her side: never paid
expect(Number(bob.owed) - Number(bob.i_owe)).toBeCloseTo(-20); // net: you owe them
});
it("reports whether the viewer owns the trip", async () => { it("reports whether the viewer owns the trip", async () => {
const { ownerId, otherId } = await seedParticipants(pool); const { ownerId, otherId } = await seedParticipants(pool);
const tripId = await seed(ownerId, otherId, ownerId); const tripId = await seed(ownerId, otherId, ownerId);
+98 -10
View File
@@ -16,6 +16,8 @@ import {
} from "@/lib/hooks"; } from "@/lib/hooks";
import type { SharedTransactionRow } from "@/lib/queries"; import type { SharedTransactionRow } from "@/lib/queries";
import { EditTransactionModal } from "@/components/edit-transaction-modal"; import { EditTransactionModal } from "@/components/edit-transaction-modal";
import { formatCategory } from "@/lib/categories";
import { CATEGORY_COLORS } from "@/lib/category-colors";
function formatDate(d: string) { function formatDate(d: string) {
return new Date(d).toLocaleDateString("en-AU", { day: "numeric", month: "short", year: "numeric" }); return new Date(d).toLocaleDateString("en-AU", { day: "numeric", month: "short", year: "numeric" });
@@ -311,15 +313,37 @@ export default function SharedPage() {
const [participantId, setParticipantId] = useState<number | undefined>(undefined); const [participantId, setParticipantId] = useState<number | undefined>(undefined);
const [sortCol, setSortCol] = useState<SortCol>("transaction_date"); const [sortCol, setSortCol] = useState<SortCol>("transaction_date");
const [sortDir, setSortDir] = useState<"asc" | "desc">("desc"); const [sortDir, setSortDir] = useState<"asc" | "desc">("desc");
const [search, setSearch] = useState("");
const realTagIds = tagIds.filter((id) => id !== "untagged"); const realTagIds = tagIds.filter((id) => id !== "untagged");
const { data: participants = [] } = useParticipants(); const { data: participants = [] } = useParticipants();
const { data: rawTransactions = [], isLoading: txLoading } = useSharedTransactions(tagIds, participantId); const { data: rawTransactions = [], isLoading: txLoading } = useSharedTransactions(tagIds, participantId);
const transactions = [...rawTransactions].sort((a, b) => { // Filtered client-side, like the sort above and unlike the transactions page.
const av = sortCol === "amount" ? Number(a.amount) : new Date(a[sortCol]).getTime(); // This endpoint returns every split row in one go (1,267 today) with no
const bv = sortCol === "amount" ? Number(b.amount) : new Date(b[sortCol]).getTime(); // pagination, so there is nothing for a server round-trip to narrow — and a
return sortDir === "desc" ? bv - av : av - bv; // server search would have to be added to a query the balance cards share.
}); //
// Deliberately does NOT match participant names: the participant dropdown
// already does that properly, and typing "sonu" matching every row she is
// split on would make the box look broken. The payer IS matched, because
// nothing else on the page filters by who paid.
const transactions = [...rawTransactions]
.filter((tx) => {
const q = search.trim().toLowerCase();
if (!q) return true;
return [
tx.description,
tx.effective_merchant,
tx.notes,
tx.effective_category ? formatCategory(tx.effective_category) : null,
tx.owner_name,
].some((f) => f?.toLowerCase().includes(q));
})
.sort((a, b) => {
const av = sortCol === "amount" ? Number(a.amount) : new Date(a[sortCol]).getTime();
const bv = sortCol === "amount" ? Number(b.amount) : new Date(b[sortCol]).getTime();
return sortDir === "desc" ? bv - av : av - bv;
});
function toggleSort(col: SortCol) { function toggleSort(col: SortCol) {
if (sortCol === col) setSortDir((d) => (d === "desc" ? "asc" : "desc")); if (sortCol === col) setSortDir((d) => (d === "desc" ? "asc" : "desc"));
@@ -352,6 +376,17 @@ export default function SharedPage() {
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<h2 className="text-2xl font-display">Shared Expenses</h2> <h2 className="text-2xl font-display">Shared Expenses</h2>
<div className="flex items-center gap-2 ml-auto flex-wrap"> <div className="flex items-center gap-2 ml-auto flex-wrap">
<div className="relative">
<input
type="search"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search description, merchant, category, payer…"
aria-label="Search split transactions"
className="w-64 bg-zinc-800 border border-zinc-700 rounded-lg pl-8 pr-2 py-1.5 text-sm placeholder:text-zinc-600 focus:outline-none focus:border-zinc-500"
/>
<span className="absolute left-2.5 top-1/2 -translate-y-1/2 text-zinc-500 text-sm pointer-events-none"></span>
</div>
<select <select
value={participantId ?? ""} value={participantId ?? ""}
onChange={(e) => setParticipantId(e.target.value ? Number(e.target.value) : undefined)} onChange={(e) => setParticipantId(e.target.value ? Number(e.target.value) : undefined)}
@@ -444,17 +479,36 @@ export default function SharedPage() {
{/* Transaction list */} {/* Transaction list */}
<div className="bg-zinc-900 border border-zinc-700 rounded-xl overflow-x-auto"> <div className="bg-zinc-900 border border-zinc-700 rounded-xl overflow-x-auto">
<div className="px-4 py-3 border-b border-zinc-800"> <div className="px-4 py-3 border-b border-zinc-800 flex items-center gap-2">
<h3 className="text-sm font-medium">Split Transactions</h3> <h3 className="text-sm font-medium">Split Transactions</h3>
{search.trim() && !txLoading && (
<span className="text-xs text-zinc-500">
{transactions.length} of {rawTransactions.length} match {search.trim()}
</span>
)}
</div> </div>
{txLoading ? ( {txLoading ? (
<p className="text-zinc-500 text-sm px-4 py-6">Loading...</p> <p className="text-zinc-500 text-sm px-4 py-6">Loading...</p>
) : transactions.length === 0 ? ( ) : transactions.length === 0 ? (
<p className="text-zinc-500 text-sm px-4 py-6"> // "None yet" is wrong when a search simply matched nothing, and it reads
No split transactions yet. Use the Split button on any transaction. // as though the splits were lost.
</p> search.trim() ? (
<p className="text-zinc-500 text-sm px-4 py-6">
Nothing matches {search.trim()}.{" "}
<button onClick={() => setSearch("")} className="text-zinc-400 hover:text-zinc-200 underline">
Clear search
</button>
</p>
) : (
<p className="text-zinc-500 text-sm px-4 py-6">
No split transactions yet. Use the Split button on any transaction.
</p>
)
) : ( ) : (
<table className="w-full text-sm min-w-[520px]"> <table className="w-full text-sm min-w-[760px]">
{/* min-w raised from 520px with the Category and Paid-by columns: the
wrapper scrolls horizontally, so a too-small minimum crushes cells
rather than letting them scroll. */}
<thead> <thead>
<tr className="border-b border-zinc-800"> <tr className="border-b border-zinc-800">
<th <th
@@ -470,12 +524,17 @@ export default function SharedPage() {
Imported <SortIcon col="created_at" /> Imported <SortIcon col="created_at" />
</th> </th>
<th className="text-left px-4 py-2 text-xs text-zinc-500 font-medium sticky left-0 z-10 bg-zinc-900 border-r border-zinc-800/80">Description</th> <th className="text-left px-4 py-2 text-xs text-zinc-500 font-medium sticky left-0 z-10 bg-zinc-900 border-r border-zinc-800/80">Description</th>
<th className="text-left px-4 py-2 text-xs text-zinc-500 font-medium">Category</th>
<th <th
className="text-right px-4 py-2 text-xs text-zinc-500 font-medium cursor-pointer hover:text-white" className="text-right px-4 py-2 text-xs text-zinc-500 font-medium cursor-pointer hover:text-white"
onClick={() => toggleSort("amount")} onClick={() => toggleSort("amount")}
> >
Amount <SortIcon col="amount" /> Amount <SortIcon col="amount" />
</th> </th>
{/* Paid by sits next to Splits deliberately: together they are the
two halves of the question this page exists to answer — whose
money went out, and whose share it was. */}
<th className="text-left px-4 py-2 text-xs text-zinc-500 font-medium whitespace-nowrap">Paid by</th>
<th className="text-left px-4 py-2 text-xs text-zinc-500 font-medium">Splits</th> <th className="text-left px-4 py-2 text-xs text-zinc-500 font-medium">Splits</th>
<th className="px-4 py-2"></th> <th className="px-4 py-2"></th>
</tr> </tr>
@@ -496,6 +555,25 @@ export default function SharedPage() {
<p className="text-xs text-zinc-500 italic mt-0.5 break-words">{tx.notes}</p> <p className="text-xs text-zinc-500 italic mt-0.5 break-words">{tx.notes}</p>
)} )}
</td> </td>
{/* Category is the effective one — the override wins over the
extracted value, the same COALESCE every other view uses,
so a correction made elsewhere shows up here too. */}
<td className="px-4 py-3 whitespace-nowrap">
{tx.effective_category ? (
<span
className="inline-flex items-center gap-1.5 text-xs text-zinc-300"
title={formatCategory(tx.effective_category)}
>
<span
className="w-1.5 h-1.5 rounded-full flex-shrink-0"
style={{ background: CATEGORY_COLORS[tx.effective_category] ?? "#71717a" }}
/>
{formatCategory(tx.effective_category)}
</span>
) : (
<span className="text-xs text-zinc-600 italic">uncategorised</span>
)}
</td>
<td className={`px-4 py-3 text-right font-medium tabular-nums ${SPEND_TYPES.has(tx.transaction_type) ? "" : "text-green-400"}`}> <td className={`px-4 py-3 text-right font-medium tabular-nums ${SPEND_TYPES.has(tx.transaction_type) ? "" : "text-green-400"}`}>
{formatAmount(tx.amount, tx.transaction_type, tx.currency)} {formatAmount(tx.amount, tx.transaction_type, tx.currency)}
{tx.currency !== "AUD" && ( {tx.currency !== "AUD" && (
@@ -508,6 +586,16 @@ export default function SharedPage() {
</span> </span>
)} )}
</td> </td>
{/* Whose money actually left. This is the effective owner —
COALESCE(t.owner_id, s.owner_id) — so it is the account the
spend came out of, which is what every balance on this page
is computed from. "Me" matches the split chips rather than
printing your own name twice in one row. */}
<td className="px-4 py-3 whitespace-nowrap">
<span className={`text-xs ${tx.owner_id === me?.id ? "text-zinc-400" : "text-indigo-300"}`}>
{tx.owner_id === me?.id ? "Me" : tx.owner_name}
</span>
</td>
<td className="px-4 py-3"> <td className="px-4 py-3">
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
{splits.map((s) => ( {splits.map((s) => (
+46 -53
View File
@@ -287,10 +287,10 @@ export default function TripDetailPage({ params }: { params: Promise<{ id: strin
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="border-b border-zinc-800"> <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 <th
key={h} 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} {h}
</th> </th>
@@ -298,68 +298,58 @@ export default function TripDetailPage({ params }: { params: Promise<{ id: strin
</tr> </tr>
</thead> </thead>
<tbody> <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 I argued against netting first, on the grounds that the
sum: the grouped-payment allocation cleared each trip against grouped-payment allocation cleared each trip against the
the one-directional debt in the left column, Europe first with one-directional gross, so netting would redefine that debt
the remainder to household. Netting them here would redefine after the fact. Checking the rows settled it the other way: the
that debt after the fact and turn a settled trip into an $802.75 on Europe is 56 transactions Sonu paid that Siddharth
overpayment, with household understated by the same amount. 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 The breakdown stays visible in the next column so the net is
right-hand column is the figure Sonu could never see: her own auditable rather than asserted — the two halves are still what
obligation lives on rows someone else paid for, so a the API returns, and a net that nobody can decompose is how a
viewer-as-payer figure can never contain it. wrong figure survives.
A negative outstanding still means they have paid more towards Sign convention matches Shared: positive means they owe you. */}
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) => { {participant_splits.map((p) => {
const owed = Number(p.owed); const owedGross = Number(p.owed_gross);
const iOwe = Number(p.i_owe); const paidToMe = Number(p.paid_to_me);
const cell = ( const iOweGross = Number(p.i_owe_gross);
net: number, const paidByMe = Number(p.paid_by_me);
gross: number, const net = Number(p.owed) - Number(p.i_owe);
paid: number, const square = Math.abs(net) < 0.005;
unconverted: number, const unconverted = p.unconverted_count + p.i_owe_unconverted_count;
colour: string, const parts = [
) => { owedGross > 0.005 ? `their share ${fmt(owedGross)}` : null,
if (gross < 0.005 && Math.abs(net) < 0.005) { paidToMe > 0.005 ? `they paid ${fmt(paidToMe)}` : null,
return <span className="text-zinc-600"></span>; iOweGross > 0.005 ? `your share of their spend ${fmt(iOweGross)}` : null,
} paidByMe > 0.005 ? `you paid ${fmt(paidByMe)}` : null,
const square = Math.abs(net) < 0.005; ].filter(Boolean);
return ( return (
<> <tr key={p.participant_id} className="border-b border-zinc-800/50 last:border-0">
<span className={square ? "text-zinc-500" : net > 0 ? colour : "text-emerald-400"}> <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)} ${Math.abs(net).toFixed(2)}
</span> </span>
<span className="block text-[11px] text-zinc-500 mt-0.5 font-sans"> <span className="block text-[11px] text-zinc-500 mt-0.5 font-sans">
{square {square ? "all square" : net > 0 ? "owes you" : "you owe them"}
? "settled"
: net < 0
? "overpaid"
: paid > 0.005
? `${fmt(gross)} less ${fmt(paid)} paid`
: "outstanding"}
</span> </span>
{unconverted > 0 && ( {unconverted > 0 && (
<span className="block text-[11px] text-amber-500/80 mt-0.5 font-sans"> <span className="block text-[11px] text-amber-500/80 mt-0.5 font-sans">
approx · {unconverted} unconverted approx · {unconverted} unconverted
</span> </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>
<td className="px-5 py-3 text-right tabular-nums font-mono"> <td className="px-5 py-3 text-[11px] text-zinc-500 leading-relaxed">
{cell(iOwe, Number(p.i_owe_gross), Number(p.paid_by_me), p.i_owe_unconverted_count, "text-blue-400")} {parts.length ? parts.join(" · ") : "no split activity on this trip"}
</td> </td>
</tr> </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 are net of payments scoped to this trip. What the note has to say
instead is which payments are NOT in them. */} 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"> <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 One figure per person: their share of what you paid, less what they
household tab are not counted here paid you, less your share of what they paid. Only payments
see <Link href="/shared" className="text-zinc-400 hover:text-zinc-200 underline">Shared</Link> for <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. the overall balance.
</p> </p>
</div> </div>