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:
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user