Compare commits
2
Commits
a4ab543a6c
...
ae23b03d5d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ae23b03d5d | ||
|
|
689fadc8b9 |
@@ -0,0 +1,48 @@
|
||||
-- Settlement scope: which tab a payment settles.
|
||||
--
|
||||
-- `split_payments` has carried from/to/amount/date since it was written and
|
||||
-- nothing else. That is the whole reason a per-trip balance has never been
|
||||
-- computable — `getTripAnalytics` says so in a comment where the figure should
|
||||
-- be: "split_payments carries no trip attribution, so a payment cannot be
|
||||
-- assigned to a trip. Settlement is a property of the whole relationship."
|
||||
--
|
||||
-- It is also the reason the Shared page silently drops payments the moment a
|
||||
-- tag filter is applied (`getParticipantBalances`): with one global payments
|
||||
-- pool there is no honest way to show a filtered balance, so it showed gross
|
||||
-- splits under the same label instead. A tag is a view; a scope is a ledger.
|
||||
--
|
||||
-- The scope is a *trip*, not a new `settlement_contexts` table. `trips` already
|
||||
-- has owner_id, dates and an archived flag, and `transaction_overrides.trip_id`
|
||||
-- already decides which transactions belong to it. A second grouping beside it
|
||||
-- would be two unsynchronised scopes over the same rows — a trip could hold a
|
||||
-- mix of contexts and a context could span trips, with no invariant saying
|
||||
-- which one governs.
|
||||
--
|
||||
-- NULL means the ongoing household tab. That tab never closes, which is why
|
||||
-- this is nullable rather than defaulted to some "general" row: absence is the
|
||||
-- honest representation of "not attached to a trip", and it keeps every
|
||||
-- existing payment correct without a backfill.
|
||||
|
||||
ALTER TABLE split_payments
|
||||
ADD COLUMN IF NOT EXISTS trip_id integer REFERENCES trips(id) ON DELETE SET NULL;
|
||||
|
||||
COMMENT ON COLUMN split_payments.trip_id IS
|
||||
'The trip this payment settles. NULL = the ongoing household tab.';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_split_payments_trip
|
||||
ON split_payments (trip_id)
|
||||
WHERE trip_id IS NOT NULL;
|
||||
|
||||
-- `settled` answers a different question and the two must not be collapsed:
|
||||
-- trip_id is *which tab*, settled is *is this obligation still live*. A
|
||||
-- pre-2026 historical split is settled with no tab; a Europe split becomes
|
||||
-- settled when Europe's payment lands; a household split stays unsettled and
|
||||
-- open indefinitely.
|
||||
--
|
||||
-- Nothing writes `settled` today. The comment in queries.ts claims
|
||||
-- /api/splits/settle does — that route does not exist, and the column is false
|
||||
-- on all 1,279 rows, which is why every trip has always reported 100%
|
||||
-- unsettled including trips paid in full.
|
||||
|
||||
COMMENT ON COLUMN transaction_splits.settled IS
|
||||
'Obligation discharged. Excluded from owed figures; still counted in spend analytics.';
|
||||
@@ -18,6 +18,7 @@ model trips {
|
||||
archived Boolean @default(false)
|
||||
created_at DateTime @default(now())
|
||||
overrides transaction_overrides[]
|
||||
payments split_payments[]
|
||||
}
|
||||
|
||||
model transaction_overrides {
|
||||
@@ -75,9 +76,13 @@ model split_payments {
|
||||
payment_date DateTime @db.Date
|
||||
notes String?
|
||||
linked_transaction_id Int?
|
||||
trip_id Int?
|
||||
created_at DateTime @default(now())
|
||||
from_participant participants @relation("payments_from", fields: [from_participant_id], references: [id])
|
||||
to_participant participants @relation("payments_to", fields: [to_participant_id], references: [id])
|
||||
trip trips? @relation(fields: [trip_id], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([trip_id])
|
||||
}
|
||||
|
||||
model tags {
|
||||
|
||||
@@ -35,6 +35,7 @@ export async function resetDB(pool: Pool) {
|
||||
transactions,
|
||||
statements,
|
||||
tags,
|
||||
trips,
|
||||
participants
|
||||
RESTART IDENTITY CASCADE
|
||||
`);
|
||||
|
||||
@@ -8,7 +8,7 @@ mockDbWithPool(pool);
|
||||
|
||||
// Dynamic import AFTER the mock ensures getTransactions / getParticipantBalances
|
||||
// use the test pool rather than Prisma's singleton.
|
||||
const { getTransactions, getParticipantBalances } = await import("@/lib/queries");
|
||||
const { getTransactions, getParticipantBalances, getTripAnalytics } = await import("@/lib/queries");
|
||||
|
||||
beforeEach(async () => {
|
||||
await resetDB(pool);
|
||||
@@ -305,3 +305,109 @@ describe("getTransactions — order provenance for the description sub-line", ()
|
||||
expect(row.order_platform).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── settlement scope: settled + split_payments.trip_id (migration 0022) ───────
|
||||
|
||||
describe("getParticipantBalances — settled", () => {
|
||||
it("excludes a settled split from what is owed", async () => {
|
||||
const { ownerId, otherId } = await seedParticipants(pool);
|
||||
const txId = await insertTransaction(pool, ownerId, { amount: 100 });
|
||||
await pool.query(
|
||||
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent, settled)
|
||||
VALUES ($1, $2, 50, true)`,
|
||||
[txId, otherId]
|
||||
);
|
||||
|
||||
const balances = await getParticipantBalances(ownerId);
|
||||
const bob = balances.find((b) => b.id === otherId);
|
||||
expect(Number(bob!.total_owed)).toBeCloseTo(0);
|
||||
});
|
||||
|
||||
it("still counts an unsettled split alongside a settled one", async () => {
|
||||
const { ownerId, otherId } = await seedParticipants(pool);
|
||||
const settledTx = await insertTransaction(pool, ownerId, { amount: 100 });
|
||||
const liveTx = await insertTransaction(pool, ownerId, { amount: 40 });
|
||||
await pool.query(
|
||||
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent, settled)
|
||||
VALUES ($1, $2, 50, true), ($3, $2, 50, false)`,
|
||||
[settledTx, otherId, liveTx]
|
||||
);
|
||||
|
||||
const balances = await getParticipantBalances(ownerId);
|
||||
const bob = balances.find((b) => b.id === otherId);
|
||||
// Only the live split counts: 50% of 40.
|
||||
expect(Number(bob!.total_owed)).toBeCloseTo(20);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getTripAnalytics — per-trip settlement", () => {
|
||||
async function seedTrip(ownerId: number, otherId: number) {
|
||||
const trip = await pool.query(
|
||||
`INSERT INTO trips (owner_id, name, start_date, end_date)
|
||||
VALUES ($1, 'Test Trip', '2026-03-01', '2026-03-10') RETURNING id`,
|
||||
[ownerId]
|
||||
);
|
||||
const tripId = trip.rows[0].id as number;
|
||||
const txId = await insertTransaction(pool, ownerId, { amount: 200, category: "travel" });
|
||||
await pool.query(
|
||||
`INSERT INTO transaction_overrides (transaction_id, trip_id) VALUES ($1, $2)`,
|
||||
[txId, tripId]
|
||||
);
|
||||
await pool.query(
|
||||
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent)
|
||||
VALUES ($1, $2, 50)`,
|
||||
[txId, otherId]
|
||||
);
|
||||
return tripId;
|
||||
}
|
||||
|
||||
it("reports the gross share before any payment", async () => {
|
||||
const { ownerId, otherId } = await seedParticipants(pool);
|
||||
const tripId = await seedTrip(ownerId, otherId);
|
||||
|
||||
const { participant_splits } = await getTripAnalytics(tripId, ownerId);
|
||||
const bob = participant_splits.find((r) => r.participant_id === otherId);
|
||||
expect(Number(bob!.owed)).toBeCloseTo(100);
|
||||
});
|
||||
|
||||
it("nets off a payment scoped to that trip", async () => {
|
||||
const { ownerId, otherId } = await seedParticipants(pool);
|
||||
const tripId = await seedTrip(ownerId, otherId);
|
||||
await pool.query(
|
||||
`INSERT INTO split_payments (from_participant_id, to_participant_id, amount, payment_date, trip_id)
|
||||
VALUES ($1, $2, 60, '2026-03-15', $3)`,
|
||||
[otherId, ownerId, tripId]
|
||||
);
|
||||
|
||||
const { participant_splits } = await getTripAnalytics(tripId, ownerId);
|
||||
const bob = participant_splits.find((r) => r.participant_id === otherId);
|
||||
expect(Number(bob!.owed)).toBeCloseTo(40);
|
||||
});
|
||||
|
||||
// The point of the whole scope column: settling the household tab must not
|
||||
// make a trip look paid. Before trip_id existed there was one global pool and
|
||||
// this distinction could not be expressed.
|
||||
it("ignores a household payment when reporting the trip", async () => {
|
||||
const { ownerId, otherId } = await seedParticipants(pool);
|
||||
const tripId = await seedTrip(ownerId, otherId);
|
||||
await pool.query(
|
||||
`INSERT INTO split_payments (from_participant_id, to_participant_id, amount, payment_date, trip_id)
|
||||
VALUES ($1, $2, 60, '2026-03-15', NULL)`,
|
||||
[otherId, ownerId]
|
||||
);
|
||||
|
||||
const { participant_splits } = await getTripAnalytics(tripId, ownerId);
|
||||
const bob = participant_splits.find((r) => r.participant_id === otherId);
|
||||
expect(Number(bob!.owed)).toBeCloseTo(100);
|
||||
});
|
||||
|
||||
it("drops a settled split from the trip figure", async () => {
|
||||
const { ownerId, otherId } = await seedParticipants(pool);
|
||||
const tripId = await seedTrip(ownerId, otherId);
|
||||
await pool.query(`UPDATE transaction_splits SET settled = true WHERE participant_id = $1`, [otherId]);
|
||||
|
||||
const { participant_splits } = await getTripAnalytics(tripId, ownerId);
|
||||
const bob = participant_splits.find((r) => r.participant_id === otherId);
|
||||
expect(bob === undefined || Number(bob.owed) === 0).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { queryRaw } from "@/lib/db";
|
||||
import { getCurrentUser } from "@/lib/auth";
|
||||
|
||||
interface BalanceRow {
|
||||
participant_id: number;
|
||||
name: string;
|
||||
total_owed: number;
|
||||
transaction_count: number;
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const user = await getCurrentUser(req);
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
||||
const { id } = await params;
|
||||
|
||||
const rows = await queryRaw<BalanceRow>(
|
||||
`SELECT ts.participant_id, p.name,
|
||||
SUM(COALESCE(t.amount_aud, t.amount) * ts.share_percent / 100)::numeric(12,2) as total_owed,
|
||||
COUNT(*)::int as transaction_count
|
||||
FROM transaction_splits ts
|
||||
JOIN transactions t ON t.id = ts.transaction_id
|
||||
JOIN participants p ON p.id = ts.participant_id
|
||||
WHERE ts.participant_id = $1 AND ts.settled = false
|
||||
GROUP BY ts.participant_id, p.name`,
|
||||
[Number(id)]
|
||||
);
|
||||
|
||||
return NextResponse.json(
|
||||
rows[0] ?? { participant_id: Number(id), total_owed: 0, transaction_count: 0 }
|
||||
);
|
||||
}
|
||||
@@ -277,7 +277,7 @@ export default function TripDetailPage({ params }: { params: Promise<{ id: strin
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-zinc-800">
|
||||
{["Person", "Share of this trip"].map((h) => (
|
||||
{["Person", "Outstanding on this trip"].map((h) => (
|
||||
<th
|
||||
key={h}
|
||||
className={`px-5 py-2.5 text-xs text-zinc-500 font-medium ${h === "Person" ? "text-left" : "text-right"}`}
|
||||
@@ -291,7 +291,16 @@ export default function TripDetailPage({ params }: { params: Promise<{ id: strin
|
||||
{participant_splits.map((p) => (
|
||||
<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">${Number(p.owed).toFixed(2)}</td>
|
||||
<td className="px-5 py-3 text-right tabular-nums font-mono">
|
||||
<span className={Math.abs(Number(p.owed)) < 0.005 ? "text-zinc-500" : ""}>
|
||||
${Number(p.owed).toFixed(2)}
|
||||
</span>
|
||||
{p.unconverted_count > 0 && (
|
||||
<span className="block text-[11px] text-amber-500/80 mt-0.5 font-sans">
|
||||
approx · {p.unconverted_count} unconverted
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
|
||||
@@ -171,3 +171,42 @@ export const SPEND_SIGNED = `CASE
|
||||
WHEN t.transaction_type IN ('refund', 'credit') THEN -(${SPEND_BASE})
|
||||
ELSE (${SPEND_BASE})
|
||||
END`;
|
||||
|
||||
/**
|
||||
* A split that still counts towards what someone owes.
|
||||
*
|
||||
* This is the line between the two questions the same table answers, and
|
||||
* conflating them is what made three different "owed" figures disagree:
|
||||
*
|
||||
* - **Owed** — what is still outstanding between two people. Must apply this.
|
||||
* - **Spend** — what a purchase cost me. Must NOT apply this.
|
||||
*
|
||||
* A settled split is still a real expense: my half of a 2025 grocery shop is my
|
||||
* spend whether or not the other half was ever repaid. Filtering settled rows
|
||||
* out of `myShare`/`mySplitOf` would re-inflate exactly the figures that
|
||||
* importing settled history exists to correct.
|
||||
*
|
||||
* `settled` marks obligations discharged OUTSIDE this app — imported
|
||||
* SplitMyExpenses history, whose repayments happened on a platform we no longer
|
||||
* run and which therefore has no `split_payments` row here.
|
||||
*
|
||||
* A live obligation is NOT settled by flipping this. It is settled by recording
|
||||
* the payment, and the balance nets to zero on its own. Doing both would
|
||||
* subtract the settlement twice — the splits leave the sum AND the payment is
|
||||
* deducted — driving the balance negative by the amount repaid. So there is
|
||||
* deliberately no "mark settled" action anywhere: settling up is recording a
|
||||
* payment, and this column is only ever written by the historical import.
|
||||
*
|
||||
* Assumes the `transaction_splits` alias is `ts`.
|
||||
*/
|
||||
export const ACTIVE_OBLIGATION = `ts.settled = false`;
|
||||
|
||||
/**
|
||||
* The tab a split belongs to: its transaction's trip, else the household.
|
||||
*
|
||||
* Membership already lives on `transaction_overrides.trip_id`, so this is a
|
||||
* read of existing data rather than a new grouping key. Assumes an
|
||||
* `transaction_overrides` alias `o` is joined (LEFT — a transaction with no
|
||||
* override row has no trip, which is the common case and means household).
|
||||
*/
|
||||
export const SPLIT_SCOPE = `o.trip_id`;
|
||||
|
||||
+69
-21
@@ -1,5 +1,5 @@
|
||||
import { queryRaw } from "./db";
|
||||
import { EXCLUDE_RECONCILED_SOURCE, NATIVE_CURRENCY, AMOUNT_UNCONVERTED } from "./analytics-sql";
|
||||
import { EXCLUDE_RECONCILED_SOURCE, NATIVE_CURRENCY, AMOUNT_UNCONVERTED, ACTIVE_OBLIGATION, STATEMENTS_JOIN } from "./analytics-sql";
|
||||
|
||||
export interface RoutePointRow {
|
||||
label: string;
|
||||
@@ -448,6 +448,13 @@ export async function getParticipantBalances(ownerId: number, tagIds?: number[])
|
||||
// Payments settle the total relationship between two people, not a specific tag.
|
||||
// Only subtract payments when viewing the unfiltered total; with a tag filter
|
||||
// active, show the raw split amount for that tag context only.
|
||||
//
|
||||
// That asymmetry is a symptom, not a design: a tag is a view and has no
|
||||
// payments, so a tag-filtered balance had nothing honest to subtract. A trip
|
||||
// does have payments (`split_payments.trip_id`, migration 0022), which is why
|
||||
// the per-trip figure in getTripAnalytics can be netted and this one cannot.
|
||||
// The fix for the tag case is to stop showing a balance there, not to invent
|
||||
// one — see docs/shared-expenses-design.md.
|
||||
const paymentsJoin = tagIds?.length ? "" : `
|
||||
LEFT JOIN (
|
||||
SELECT
|
||||
@@ -484,6 +491,7 @@ export async function getParticipantBalances(ownerId: number, tagIds?: number[])
|
||||
LEFT JOIN statements s ON s.id = t.statement_id
|
||||
WHERE COALESCE(t.owner_id, s.owner_id) = $1 AND ts.participant_id != $1
|
||||
AND ${EXCLUDE_RECONCILED_SOURCE}
|
||||
AND ${ACTIVE_OBLIGATION}
|
||||
${tagFilter}
|
||||
|
||||
UNION ALL
|
||||
@@ -498,6 +506,7 @@ export async function getParticipantBalances(ownerId: number, tagIds?: number[])
|
||||
LEFT JOIN statements s ON s.id = t.statement_id
|
||||
WHERE ts.participant_id = $1 AND COALESCE(t.owner_id, s.owner_id) != $1
|
||||
AND ${EXCLUDE_RECONCILED_SOURCE}
|
||||
AND ${ACTIVE_OBLIGATION}
|
||||
${tagFilter}
|
||||
) splits ON splits.pid = p.id
|
||||
${paymentsJoin}
|
||||
@@ -825,7 +834,14 @@ export interface TripAnalytics {
|
||||
daily_spend: { date: string; amount: number }[];
|
||||
top_merchants: { merchant: string; amount: number; count: number }[];
|
||||
tag_breakdown: { tag_id: number; name: string; color: string; amount: number; count: number }[];
|
||||
participant_splits: { participant_id: number; name: string; owed: number }[];
|
||||
participant_splits: {
|
||||
participant_id: number;
|
||||
name: string;
|
||||
/** Their share of this trip, net of payments scoped to it. */
|
||||
owed: number;
|
||||
/** Splits counted at a non-AUD figure because no converted amount exists. */
|
||||
unconverted_count: number;
|
||||
}[];
|
||||
}
|
||||
|
||||
export async function getTrips(ownerId: number): Promise<TripRow[]> {
|
||||
@@ -929,27 +945,59 @@ export async function getTripAnalytics(tripId: number, ownerId: number): Promise
|
||||
ORDER BY 4 DESC
|
||||
`, [tripId]),
|
||||
|
||||
// No settled/unsettled breakdown here. It was computed from
|
||||
// transaction_splits.settled, which only /api/splits/settle writes and
|
||||
// nothing in the UI calls — so it is false on all 673 splits and every trip
|
||||
// reported 100% unsettled, including trips paid in full. A real per-trip
|
||||
// figure is not computable either: split_payments carries no trip
|
||||
// attribution, so a payment cannot be assigned to a trip. Settlement is a
|
||||
// property of the whole relationship until settlement contexts exist
|
||||
// (see docs/shared-expenses-design.md).
|
||||
queryRaw<{ participant_id: number; name: string; owed: number }>(`
|
||||
SELECT
|
||||
p.id AS participant_id,
|
||||
p.name,
|
||||
SUM(ts.share_percent / 100.0 * COALESCE(tx.amount_aud, tx.amount))::float AS owed
|
||||
// Owed per participant for THIS trip, net of payments made against it.
|
||||
//
|
||||
// This was gross splits, with a comment explaining why it could not be
|
||||
// anything better: `split_payments` carried no trip attribution, so a
|
||||
// payment could not be assigned to a trip and every trip reported 100%
|
||||
// unsettled including trips paid in full. `split_payments.trip_id`
|
||||
// (migration 0022) closes that, so the figure is now real.
|
||||
//
|
||||
// Three exclusions, all load-bearing:
|
||||
// - ACTIVE_OBLIGATION drops settled splits, so a closed trip reads zero
|
||||
// rather than its original gross.
|
||||
// - EXCLUDE_RECONCILED_SOURCE drops the manual row a statement line has
|
||||
// superseded. The trip queries never applied it, so a reconciled trip
|
||||
// expense was counted twice here.
|
||||
// - AMOUNT_UNCONVERTED counts rows whose AUD value is unknown, the same
|
||||
// way getParticipantBalances does. They are still summed (at their
|
||||
// native figure), so a non-zero count means this total is approximate
|
||||
// and the UI has to say so. A trip is where foreign rows actually live,
|
||||
// so netting a EUR figure against AUD ones silently is most likely to
|
||||
// bite exactly here.
|
||||
//
|
||||
// `transactions` is aliased `t` so the shared fragments apply directly —
|
||||
// they assume that alias, and hand-inlining a copy is what let the
|
||||
// reconciled-row exclusion drift out of the analytics routes to begin with.
|
||||
queryRaw<{ participant_id: number; name: string; owed: number; unconverted_count: number }>(`
|
||||
WITH owed AS (
|
||||
SELECT ts.participant_id AS pid,
|
||||
SUM(ts.share_percent / 100.0 * COALESCE(t.amount_aud, t.amount)) AS gross,
|
||||
SUM(CASE WHEN ${AMOUNT_UNCONVERTED} THEN 1 ELSE 0 END) AS unconverted
|
||||
FROM transaction_overrides o
|
||||
JOIN transactions tx ON tx.id = o.transaction_id
|
||||
JOIN transaction_splits ts ON ts.transaction_id = tx.id
|
||||
JOIN participants p ON p.id = ts.participant_id
|
||||
JOIN transactions t ON t.id = o.transaction_id
|
||||
${STATEMENTS_JOIN}
|
||||
JOIN transaction_splits ts ON ts.transaction_id = t.id
|
||||
WHERE o.trip_id = $1
|
||||
AND tx.transaction_type IN ('debit','fee','interest')
|
||||
AND COALESCE(o.category_override, tx.category, 'other') NOT IN ('transfers', 'investment')
|
||||
GROUP BY p.id
|
||||
AND t.transaction_type IN ('debit','fee','interest')
|
||||
AND COALESCE(o.category_override, t.category, 'other') NOT IN ('transfers', 'investment')
|
||||
AND ${ACTIVE_OBLIGATION}
|
||||
AND ${EXCLUDE_RECONCILED_SOURCE}
|
||||
GROUP BY ts.participant_id
|
||||
),
|
||||
paid AS (
|
||||
SELECT sp.from_participant_id AS pid, SUM(sp.amount) AS amt
|
||||
FROM split_payments sp
|
||||
WHERE sp.trip_id = $1
|
||||
GROUP BY sp.from_participant_id
|
||||
)
|
||||
SELECT p.id AS participant_id, p.name,
|
||||
(COALESCE(owed.gross, 0) - COALESCE(paid.amt, 0))::float AS owed,
|
||||
COALESCE(owed.unconverted, 0)::int AS unconverted_count
|
||||
FROM participants p
|
||||
LEFT JOIN owed ON owed.pid = p.id
|
||||
LEFT JOIN paid ON paid.pid = p.id
|
||||
WHERE owed.pid IS NOT NULL OR paid.pid IS NOT NULL
|
||||
ORDER BY 3 DESC
|
||||
`, [tripId]),
|
||||
]);
|
||||
|
||||
Reference in New Issue
Block a user