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.
|
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
|
**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
|
figure per person, with the breakdown beside it, because a net nobody can
|
||||||
transfer to close this trip. The breakdown renders beside it, because a net
|
decompose is how a wrong figure survives.
|
||||||
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
|
**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
|
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.
|
zero by construction.
|
||||||
|
|
||||||
Current nets: Auckland Sonu **+$1,077.25** (1,505.64 − 428.39), Europe Sonu
|
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`
|
**−$802.75**, Sonu + Sunny **−$936.34**, Europe Molina **−$816.16** — the three
|
||||||
column itself was verified byte-identical when the mirror was added — $1,505.64,
|
negatives all being over-coverage, per the rule above. The `owed` column itself
|
||||||
−$816.16, $0.00, $0.00.
|
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
|
**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
|
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);
|
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
|
// Europe 2026's shape exactly, and the case the UI must NOT call a debt.
|
||||||
// all. The one-directional view called that "settled"; the net must not.
|
//
|
||||||
it("nets a fully-paid side against an unpaid opposite side", async () => {
|
// 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 { ownerId, otherId } = await seedParticipants(pool);
|
||||||
const tripId = await seed(ownerId, otherId, ownerId);
|
const tripId = await seed(ownerId, otherId, ownerId);
|
||||||
// Bob pays his $100 share in full, scoped to the trip.
|
// 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 { participant_splits } = await getTripAnalytics(tripId, ownerId);
|
||||||
const bob = participant_splits.find((r) => r.participant_id === otherId)!;
|
const bob = participant_splits.find((r) => r.participant_id === otherId)!;
|
||||||
expect(Number(bob.owed)).toBeCloseTo(0); // his side: settled
|
expect(Number(bob.owed)).toBeCloseTo(0); // his gross, fully paid
|
||||||
expect(Number(bob.i_owe)).toBeCloseTo(20); // her side: never paid
|
expect(Number(bob.i_owe)).toBeCloseTo(20); // her share of his spending
|
||||||
expect(Number(bob.owed) - Number(bob.i_owe)).toBeCloseTo(-20); // net: you owe them
|
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 () => {
|
it("reports whether the viewer owns the trip", async () => {
|
||||||
|
|||||||
+74
-27
@@ -11,7 +11,7 @@ import {
|
|||||||
ResponsiveContainer,
|
ResponsiveContainer,
|
||||||
Cell,
|
Cell,
|
||||||
} from "recharts";
|
} 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 { CreateTripModal } from "@/components/create-trip-modal";
|
||||||
import { formatCategory } from "@/lib/categories";
|
import { formatCategory } from "@/lib/categories";
|
||||||
import { CATEGORY_COLORS, TOOLTIP_STYLE } from "@/lib/category-colors";
|
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.
|
// request, not a grant.
|
||||||
const { data: txData } = useTransactions({ trip_id: id, limit: 500, trip_all_rows: true });
|
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) {
|
if (isLoading || !analytics) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -287,10 +292,10 @@ export default function TripDetailPage({ params }: { params: Promise<{ id: strin
|
|||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-zinc-800">
|
<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
|
<th
|
||||||
key={h}
|
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}
|
{h}
|
||||||
</th>
|
</th>
|
||||||
@@ -298,24 +303,27 @@ export default function TripDetailPage({ params }: { params: Promise<{ id: strin
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{/* ONE net figure per person, because the point of the column is
|
{/* One net figure per person — but a negative one is NOT a bill.
|
||||||
"what do we transfer to close this trip" and two figures make
|
This is the distinction the page got wrong twice.
|
||||||
that a subtraction the reader has to do.
|
|
||||||
|
|
||||||
I argued against netting first, on the grounds that the
|
A payment is allocated to a scope as a lump sum, and the
|
||||||
grouped-payment allocation cleared each trip against the
|
grouped-payment allocation assigned each trip enough to clear
|
||||||
one-directional gross, so netting would redefine that debt
|
the payer's GROSS share. So when the other side of the trip is
|
||||||
after the fact. Checking the rows settled it the other way: the
|
netted off, a fully-paid trip goes negative by exactly the
|
||||||
$802.75 on Europe is 56 transactions Sonu paid that Siddharth
|
amount the payer over-covered: Europe reads -$802.75 because
|
||||||
holds 25% of, and nothing has EVER been recorded going from him
|
Sonu paid $8,004.04 against a net share of $7,201.30.
|
||||||
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.
|
|
||||||
|
|
||||||
The breakdown stays visible in the next column so the net is
|
That surplus is not a debt the viewer must settle. It is
|
||||||
auditable rather than asserted — the two halves are still what
|
already carried in the overall balance — Sonu still owes
|
||||||
the API returns, and a net that nobody can decompose is how a
|
$5,313.38 overall — so "you owe them" was flatly wrong. Scope
|
||||||
wrong figure survives.
|
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. */}
|
Sign convention matches Shared: positive means they owe you. */}
|
||||||
{participant_splits.map((p) => {
|
{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 paidByMe = Number(p.paid_by_me);
|
||||||
const net = Number(p.owed) - Number(p.i_owe);
|
const net = Number(p.owed) - Number(p.i_owe);
|
||||||
const square = Math.abs(net) < 0.005;
|
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 unconverted = p.unconverted_count + p.i_owe_unconverted_count;
|
||||||
const parts = [
|
const parts = [
|
||||||
owedGross > 0.005 ? `their share ${fmt(owedGross)}` : null,
|
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,
|
iOweGross > 0.005 ? `your share of their spend ${fmt(iOweGross)}` : null,
|
||||||
paidByMe > 0.005 ? `you paid ${fmt(paidByMe)}` : null,
|
paidByMe > 0.005 ? `you paid ${fmt(paidByMe)}` : null,
|
||||||
].filter(Boolean);
|
].filter(Boolean);
|
||||||
|
const overall = balances.find((b) => b.id === p.participant_id);
|
||||||
|
const overallNet = overall ? Number(overall.total_owed) : null;
|
||||||
return (
|
return (
|
||||||
<tr key={p.participant_id} className="border-b border-zinc-800/50 last:border-0">
|
<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 font-medium">{p.name}</td>
|
||||||
<td className="px-5 py-3 text-right tabular-nums font-mono whitespace-nowrap">
|
<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)}
|
${Math.abs(net).toFixed(2)}
|
||||||
</span>
|
</span>
|
||||||
<span className="block text-[11px] text-zinc-500 mt-0.5 font-sans">
|
<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>
|
</span>
|
||||||
{unconverted > 0 && (
|
{unconverted > 0 && (
|
||||||
<span className="block text-[11px] text-amber-500/80 mt-0.5 font-sans">
|
<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>
|
||||||
<td className="px-5 py-3 text-[11px] text-zinc-500 leading-relaxed">
|
<td className="px-5 py-3 text-[11px] text-zinc-500 leading-relaxed">
|
||||||
{parts.length ? parts.join(" · ") : "no split activity on this trip"}
|
{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>
|
</td>
|
||||||
</tr>
|
</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
|
are net of payments scoped to this trip. What the note has to say
|
||||||
instead is which payments are NOT in them. */}
|
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">
|
<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
|
<strong className="font-medium text-zinc-400">This trip</strong> is their
|
||||||
paid you, less your share of what they paid. Only payments
|
share of what you paid, less what they paid you, less your share of what
|
||||||
<em className="not-italic text-zinc-400"> scoped to this trip</em> count
|
they paid. A payment is allocated to a trip as a lump sum, so one that
|
||||||
— a debt settled by a payment left on the household tab still reads as
|
covered someone’s full share leaves this column negative by whatever
|
||||||
outstanding here, so set the scope when recording one.
|
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
|
See <Link href="/shared" className="text-zinc-400 hover:text-zinc-200 underline">Shared</Link> for
|
||||||
the overall balance.
|
the full picture.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user