From 2d341e24a03350b03e2d599bd2ae95c4a5b40e97 Mon Sep 17 00:00:00 2001 From: siddharthd Date: Sun, 2 Aug 2026 21:52:51 +1000 Subject: [PATCH] A negative trip balance is over-coverage, not a bill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- CLAUDE.md | 34 ++++++-- src/__tests__/integration/queries.test.ts | 47 ++++++++-- src/app/trips/[id]/page.tsx | 101 ++++++++++++++++------ 3 files changed, 143 insertions(+), 39 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index f203978..2224c78 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -292,9 +292,30 @@ 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. **The API returns both halves whole; the trip page nets them for display.** One -figure per person answers the only question the column exists for — what do we -transfer to close this trip. The breakdown renders beside it, because a net -nobody can decompose is how a wrong figure survives. +figure per person, with the breakdown beside it, because a net nobody can +decompose is how a wrong figure survives. + +**A NEGATIVE trip net is not a bill — this is the trap, and it was got wrong +twice.** A payment is allocated to a scope 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 — +**she still owes $5,313.38 overall** — so labelling it "you owe them" was flatly +wrong. Scope nets sum to the overall figure; a negative simply means this scope +was over-covered and the excess sits in another. + +The discriminator is `paid_to_me`: + +- negative **with** a payment into the scope → over-covered, nothing to pay +- negative **with no** payment → genuinely owed, because the viewer's share of + the other person's spending exceeds theirs + +All three of today's negatives are the first kind (Europe Sonu −$802.75, +Sonu + Sunny −$936.34, Europe Molina −$816.16). Both cases are tested. The trip +table therefore carries an **Overall balance** column from the unscoped +`getParticipantBalances` — the trip figure alone cannot tell you whether to pay +anyone, and **settlement is always against the overall figure, never one trip.** **On whether to net — the reasoning reversed once, and the second answer is the right one.** The first objection was that the grouped-payment allocation (memory @@ -310,9 +331,10 @@ because the allocation derived her payment split *from* her gross, so it lands o 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. +**−$802.75**, Sonu + Sunny **−$936.34**, Europe Molina **−$816.16** — the three +negatives all being over-coverage, per the rule above. 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 diff --git a/src/__tests__/integration/queries.test.ts b/src/__tests__/integration/queries.test.ts index c61a4df..b865caf 100644 --- a/src/__tests__/integration/queries.test.ts +++ b/src/__tests__/integration/queries.test.ts @@ -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 () => { diff --git a/src/app/trips/[id]/page.tsx b/src/app/trips/[id]/page.tsx index d8b3342..f617808 100644 --- a/src/app/trips/[id]/page.tsx +++ b/src/app/trips/[id]/page.tsx @@ -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 (
@@ -287,10 +292,10 @@ export default function TripDetailPage({ params }: { params: Promise<{ id: strin - {["Person", "To settle this trip", "How it adds up"].map((h) => ( + {["Person", "This trip", "How it adds up", "Overall balance"].map((h) => ( @@ -298,24 +303,27 @@ export default function TripDetailPage({ params }: { params: Promise<{ id: strin - {/* 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 ( + {/* 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. */} + ); @@ -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. */}

- One figure per person: their share of what you paid, less what they - paid you, less your share of what they paid. Only payments - scoped to this trip count - — a debt settled by a payment left on the household tab still reads as - outstanding here, so set the scope when recording one. + This trip 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’s full share leaves this column negative by whatever + it over-covered — that surplus is carried in{" "} + Overall balance, and + is not money owed to them. Settle against the overall figure, never a + single trip. +
+ Only payments scoped to this + trip 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 Shared for - the overall balance. + the full picture.

)}
{h}
{p.name} - 0 ? "text-amber-400" : "text-blue-400"}> + 0 ? "text-amber-400" : overpaid ? "text-emerald-400" : "text-blue-400"}> ${Math.abs(net).toFixed(2)} - {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"} {unconverted > 0 && ( @@ -350,6 +367,29 @@ export default function TripDetailPage({ params }: { params: Promise<{ id: strin {parts.length ? parts.join(" · ") : "no split activity on this trip"} + {overpaid && ( + + trip covered; the {fmt(Math.abs(net))} surplus sits on the overall balance, + not owing to them + + )} + + {overallNet === null ? ( + + ) : ( + <> + 0 ? "text-amber-400" : "text-blue-400"}> + ${Math.abs(overallNet).toFixed(2)} + + + {Math.abs(overallNet) < 0.005 ? "all square" : overallNet > 0 ? "owes you" : "you owe them"} + + + )}