A negative trip balance is over-coverage, not a bill
ci / lint-test (push) Successful in 49s

You were right and my label was wrong. A payment is allocated to a trip as a
lump sum, and the grouped-payment allocation gave each trip enough to clear the
payer's gross share. So netting the other side off leaves a fully-paid trip
negative by exactly what the payment over-covered: Europe reads -$802.75 because
Sonu paid $8,004.04 against a net share of $7,201.30. That surplus is already
carried in the overall balance, where she still owes $5,313.38 — nothing is owed
to her, and "you owe them" said the opposite.

The arithmetic never changed; only what the page claims it means. paid_to_me is
the discriminator: negative with a payment into the scope is over-coverage,
negative with no payment is genuinely owed because the viewer's share of the
other person's spending exceeds theirs. Both cases now tested. All three of
today's negatives are the first kind.

The trip table gains an Overall balance column from the unscoped participant
balances, because a single trip's figure cannot tell you whether to pay anyone,
and the footnote now says settlement is always against the overall figure.

278 passing, build clean.
This commit is contained in:
2026-08-02 21:52:51 +10:00
parent 6db2345c49
commit 2d341e24a0
3 changed files with 143 additions and 39 deletions
+41 -6
View File
@@ -962,9 +962,15 @@ describe("trip owed — both directions, never netted", () => {
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 () => {
// Europe 2026's shape exactly, and the case the UI must NOT call a debt.
//
// A payment is allocated to a trip as a lump sum covering the payer's GROSS
// share, so netting the other side off leaves the trip negative by whatever
// the payment over-covered. That surplus is carried in the overall balance,
// not owed to them — which is why the page distinguishes a negative net WITH a
// payment into the scope (over-covered) from one WITHOUT (genuinely owed).
// `paid_to_me` is what makes the two separable, so it must stay non-zero here.
it("goes negative by the over-covered amount when a payment clears the gross", 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.
@@ -987,9 +993,38 @@ describe("trip owed — both directions, never netted", () => {
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
expect(Number(bob.owed)).toBeCloseTo(0); // his gross, fully paid
expect(Number(bob.i_owe)).toBeCloseTo(20); // her share of his spending
expect(Number(bob.owed) - Number(bob.i_owe)).toBeCloseTo(-20);
// The discriminator: he paid into this scope, so the -20 is over-coverage
// rather than a bill. Without paid_to_me the page cannot tell the two apart.
expect(Number(bob.paid_to_me)).toBeGreaterThan(0);
});
it("goes negative with no payment when the viewer's share simply exceeds theirs", async () => {
const { ownerId, otherId } = await seedParticipants(pool);
const trip = await pool.query(
`INSERT INTO trips (owner_id, name) VALUES ($1, 'Unpaid Trip') RETURNING id`,
[ownerId]
);
const tripId = trip.rows[0].id as number;
// Only one row, paid by Bob, with Alice holding half. Nobody has paid anyone.
const bobsTx = await insertTransaction(pool, otherId, { amount: 90, 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) - Number(bob.i_owe)).toBeCloseTo(-45);
// No payment into the scope, so this negative IS Alice's to settle.
expect(Number(bob.paid_to_me)).toBeCloseTo(0);
});
it("reports whether the viewer owns the trip", async () => {
+74 -27
View File
@@ -11,7 +11,7 @@ import {
ResponsiveContainer,
Cell,
} from "recharts";
import { useTripAnalytics, useTrip, useTransactions } from "@/lib/hooks";
import { useTripAnalytics, useTrip, useTransactions, useParticipantBalances } from "@/lib/hooks";
import { CreateTripModal } from "@/components/create-trip-modal";
import { formatCategory } from "@/lib/categories";
import { CATEGORY_COLORS, TOOLTIP_STYLE } from "@/lib/category-colors";
@@ -82,6 +82,11 @@ export default function TripDetailPage({ params }: { params: Promise<{ id: strin
// request, not a grant.
const { data: txData } = useTransactions({ trip_id: id, limit: 500, trip_all_rows: true });
// Unscoped, deliberately: the trip figure alone cannot tell you whether to pay
// anyone, because a trip whose payment over-covered it reads negative while the
// payer is still in debt overall. This is the number to act on.
const { data: balances = [] } = useParticipantBalances();
if (isLoading || !analytics) {
return (
<div className="space-y-6">
@@ -287,10 +292,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", "To settle this trip", "How it adds up"].map((h) => (
{["Person", "This trip", "How it adds up", "Overall balance"].map((h) => (
<th
key={h}
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"}`}
className={`px-5 py-2.5 text-xs text-zinc-500 font-medium ${h === "Person" || h === "How it adds up" ? "text-left" : "text-right"}`}
>
{h}
</th>
@@ -298,24 +303,27 @@ export default function TripDetailPage({ params }: { params: Promise<{ id: strin
</tr>
</thead>
<tbody>
{/* 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.
{/* One net figure per person — but a negative one is NOT a bill.
This is the distinction the page got wrong twice.
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.
A payment is allocated to a scope as a lump sum, and the
grouped-payment allocation assigned each trip enough to clear
the payer's GROSS share. So when the other side of the trip is
netted off, a fully-paid trip goes negative by exactly the
amount the payer over-covered: Europe reads -$802.75 because
Sonu paid $8,004.04 against a net share of $7,201.30.
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.
That surplus is not a debt the viewer must settle. It is
already carried in the overall balance — Sonu still owes
$5,313.38 overall — so "you owe them" was flatly wrong. Scope
nets sum to the overall figure; a negative here just means this
scope was over-covered and the excess sits in another.
So: negative WITH a payment into the scope is an overpayment,
and the overall column is where the actionable number lives.
Negative with NO payment is genuinely owed, because then the
viewer's share of the other person's spending simply exceeds
theirs. All three of today's negatives are the former.
Sign convention matches Shared: positive means they owe you. */}
{participant_splits.map((p) => {
@@ -325,6 +333,7 @@ export default function TripDetailPage({ params }: { params: Promise<{ id: strin
const paidByMe = Number(p.paid_by_me);
const net = Number(p.owed) - Number(p.i_owe);
const square = Math.abs(net) < 0.005;
const overpaid = net < -0.005 && paidToMe > 0.005;
const unconverted = p.unconverted_count + p.i_owe_unconverted_count;
const parts = [
owedGross > 0.005 ? `their share ${fmt(owedGross)}` : null,
@@ -332,15 +341,23 @@ export default function TripDetailPage({ params }: { params: Promise<{ id: strin
iOweGross > 0.005 ? `your share of their spend ${fmt(iOweGross)}` : null,
paidByMe > 0.005 ? `you paid ${fmt(paidByMe)}` : null,
].filter(Boolean);
const overall = balances.find((b) => b.id === p.participant_id);
const overallNet = overall ? Number(overall.total_owed) : null;
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"}>
<span className={square ? "text-zinc-500" : net > 0 ? "text-amber-400" : overpaid ? "text-emerald-400" : "text-blue-400"}>
${Math.abs(net).toFixed(2)}
</span>
<span className="block text-[11px] text-zinc-500 mt-0.5 font-sans">
{square ? "all square" : net > 0 ? "owes you" : "you owe them"}
{square
? "all square"
: net > 0
? "still owed"
: overpaid
? "covered — they paid over"
: "you owe them"}
</span>
{unconverted > 0 && (
<span className="block text-[11px] text-amber-500/80 mt-0.5 font-sans">
@@ -350,6 +367,29 @@ export default function TripDetailPage({ params }: { params: Promise<{ id: strin
</td>
<td className="px-5 py-3 text-[11px] text-zinc-500 leading-relaxed">
{parts.length ? parts.join(" · ") : "no split activity on this trip"}
{overpaid && (
<span className="block text-emerald-500/80 mt-0.5">
trip covered; the {fmt(Math.abs(net))} surplus sits on the overall balance,
not owing to them
</span>
)}
</td>
{/* The only figure anyone should act on. Without it a
over-covered scope reads as "pay them" when they are
still in debt to you overall. */}
<td className="px-5 py-3 text-right tabular-nums font-mono whitespace-nowrap">
{overallNet === null ? (
<span className="text-zinc-600 text-[11px] font-sans"></span>
) : (
<>
<span className={Math.abs(overallNet) < 0.005 ? "text-zinc-500" : overallNet > 0 ? "text-amber-400" : "text-blue-400"}>
${Math.abs(overallNet).toFixed(2)}
</span>
<span className="block text-[11px] text-zinc-500 mt-0.5 font-sans">
{Math.abs(overallNet) < 0.005 ? "all square" : overallNet > 0 ? "owes you" : "you owe them"}
</span>
</>
)}
</td>
</tr>
);
@@ -362,13 +402,20 @@ 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">
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.
<strong className="font-medium text-zinc-400">This trip</strong> is their
share of what you paid, less what they paid you, less your share of what
they paid. A payment is allocated to a trip as a lump sum, so one that
covered someone&rsquo;s full share leaves this column negative by whatever
it over-covered that surplus is carried in{" "}
<strong className="font-medium text-zinc-400">Overall balance</strong>, and
is not money owed to them. Settle against the overall figure, never a
single trip.
<br />
Only payments <em className="not-italic text-zinc-400">scoped to this
trip</em> count here, so a debt settled by a payment left on the household
tab still reads as outstanding 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 full picture.
</p>
</div>
)}