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 (
| {h} | @@ -298,24 +303,27 @@ export default function TripDetailPage({ params }: { params: Promise<{ id: strin|||
|---|---|---|---|
| {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 + + )} + | + {/* 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. */} ++ {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"} + + > + )} |