From 6db2345c498e2916bf5a9c19958abb77d3c271ad Mon Sep 17 00:00:00 2001 From: siddharthd Date: Sun, 2 Aug 2026 21:19:40 +1000 Subject: [PATCH] Net the trip debt to one figure, and show payer, category and search on Shared MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- CLAUDE.md | 60 +++++++++--- src/__tests__/integration/queries.test.ts | 41 +++++++- src/app/shared/page.tsx | 108 ++++++++++++++++++++-- src/app/trips/[id]/page.tsx | 99 +++++++++----------- 4 files changed, 227 insertions(+), 81 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index e416a87..f203978 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 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 -step and it silently corrupts the scope allocation. The grouped-payment -allocation (memory case `allocate_grouped_payments`) cleared Sonu's transfers -against the trip debts chronologically, Europe first with the remainder to -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 -already allocated into an $802.75 over-allocation with household understated by -the same amount. The total stays right; the split between scopes stops being. -Both halves therefore carry their gross and payments too (`owed_gross`, -`paid_to_me`, `i_owe_gross`, `paid_by_me`) so a paid-up trip reads "settled" -rather than a bare `0.00`. Verified byte-identical across the change: Auckland -Sonu $1,505.64, Europe Molina −$816.16, Europe Sonu $0.00, Sonu + Sunny Sonu -$0.00. +**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. + +**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 +case `allocate_grouped_payments`) cleared each trip against the *one-directional* +gross, Europe first with the remainder to household, so netting would redefine +that debt after the fact. Checking the underlying rows overturned it: Europe's +$802.75 is **56 real transactions Sonu paid** across Rome, Venice, the Dolomites, +Bellagio, Lucerne and Paris on which Siddharth holds 25% — and `paid_by_me` is +**$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) diff --git a/src/__tests__/integration/queries.test.ts b/src/__tests__/integration/queries.test.ts index 4f2c83d..c61a4df 100644 --- a/src/__tests__/integration/queries.test.ts +++ b/src/__tests__/integration/queries.test.ts @@ -935,10 +935,11 @@ describe("trip owed — both directions, never netted", () => { expect(Number(alice.paid_by_me)).toBeCloseTo(100); }); - // Netting the two would redefine the debt the grouped-payment allocation was - // computed against, turning a settled trip into an overpayment and leaving - // household understated by the same amount. - it("does not net the two directions against each other", async () => { + // The API returns both halves whole; the trip page nets them for display. The + // halves must stay separately available so that net is decomposable — a net + // nobody can audit is how a wrong figure survives, and it is what let Europe + // 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 tripId = await seed(ownerId, otherId, ownerId); // 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)!; expect(Number(bob.owed)).toBeCloseTo(100); 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); }); + // 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 () => { const { ownerId, otherId } = await seedParticipants(pool); const tripId = await seed(ownerId, otherId, ownerId); diff --git a/src/app/shared/page.tsx b/src/app/shared/page.tsx index a579c20..186447e 100644 --- a/src/app/shared/page.tsx +++ b/src/app/shared/page.tsx @@ -16,6 +16,8 @@ import { } from "@/lib/hooks"; import type { SharedTransactionRow } from "@/lib/queries"; import { EditTransactionModal } from "@/components/edit-transaction-modal"; +import { formatCategory } from "@/lib/categories"; +import { CATEGORY_COLORS } from "@/lib/category-colors"; function formatDate(d: string) { 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(undefined); const [sortCol, setSortCol] = useState("transaction_date"); const [sortDir, setSortDir] = useState<"asc" | "desc">("desc"); + const [search, setSearch] = useState(""); const realTagIds = tagIds.filter((id) => id !== "untagged"); const { data: participants = [] } = useParticipants(); const { data: rawTransactions = [], isLoading: txLoading } = useSharedTransactions(tagIds, participantId); - const transactions = [...rawTransactions].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; - }); + // Filtered client-side, like the sort above and unlike the transactions page. + // This endpoint returns every split row in one go (1,267 today) with no + // pagination, so there is nothing for a server round-trip to narrow — and a + // 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) { if (sortCol === col) setSortDir((d) => (d === "desc" ? "asc" : "desc")); @@ -352,6 +376,17 @@ export default function SharedPage() {

Shared Expenses

+
+ 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" + /> + +