diff --git a/prisma/migrations/0022_settlement_scope/migration.sql b/prisma/migrations/0022_settlement_scope/migration.sql new file mode 100644 index 0000000..9cbd039 --- /dev/null +++ b/prisma/migrations/0022_settlement_scope/migration.sql @@ -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.'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 2328620..8777beb 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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 { diff --git a/src/__tests__/integration/helpers.ts b/src/__tests__/integration/helpers.ts index 6ef611a..0a7205e 100644 --- a/src/__tests__/integration/helpers.ts +++ b/src/__tests__/integration/helpers.ts @@ -35,6 +35,7 @@ export async function resetDB(pool: Pool) { transactions, statements, tags, + trips, participants RESTART IDENTITY CASCADE `); diff --git a/src/__tests__/integration/queries.test.ts b/src/__tests__/integration/queries.test.ts index 64e41c9..8879421 100644 --- a/src/__tests__/integration/queries.test.ts +++ b/src/__tests__/integration/queries.test.ts @@ -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); + }); +}); diff --git a/src/app/api/participants/[id]/balance/route.ts b/src/app/api/participants/[id]/balance/route.ts deleted file mode 100644 index 111d9b2..0000000 --- a/src/app/api/participants/[id]/balance/route.ts +++ /dev/null @@ -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( - `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 } - ); -} diff --git a/src/lib/analytics-sql.ts b/src/lib/analytics-sql.ts index 7d89f48..a2da1f8 100644 --- a/src/lib/analytics-sql.ts +++ b/src/lib/analytics-sql.ts @@ -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`; diff --git a/src/lib/queries.ts b/src/lib/queries.ts index 5b646d4..563afcf 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -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 } 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} @@ -929,27 +938,47 @@ 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). + // 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. + // + // Two exclusions, both 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. 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 - 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 - 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 + WITH owed AS ( + SELECT ts.participant_id AS pid, + SUM(ts.share_percent / 100.0 * COALESCE(tx.amount_aud, tx.amount)) AS gross + FROM transaction_overrides o + JOIN transactions tx ON tx.id = o.transaction_id + LEFT JOIN statements s ON s.id = tx.statement_id + JOIN transaction_splits ts ON ts.transaction_id = tx.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') + AND ${ACTIVE_OBLIGATION} + AND NOT (tx.statement_id IS NULL AND tx.reconciled_with_id IS NOT NULL) + 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 + 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]), ]);