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