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:
@@ -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
|
||||
|
||||
@@ -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
@@ -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’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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user