feat(shared): give a payment a tab to settle
A payment has only ever recorded from, to, amount and date. That is why the per-trip owed figure did not exist — getTripAnalytics said so where the number should have been: "split_payments carries no trip attribution, so a payment cannot be assigned to a trip. Settlement is a property of the whole relationship." Every trip therefore read 100% unsettled, including trips paid in full. It is also why the Shared page silently drops payments under a tag filter. With one global pool there was nothing honest to subtract, so it showed gross splits under the same label. 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 archived, and transaction_overrides.trip_id already decides membership. A second grouping beside it would be two unsynchronised scopes over the same rows, with no invariant saying which governs. NULL means the ongoing household tab, which never closes. settled answers a different question and the two must not be collapsed: trip_id is which tab, settled is whether the obligation is still live. Critically, a live obligation is NOT settled by flipping the flag — it is settled by recording the payment, and the balance nets to zero on its own. Doing both would subtract the settlement twice. So settled is written only by the historical import, for repayments made on a platform we no longer run, and there is deliberately no "mark settled" action. Both owed figures now exclude settled splits and the trip figure nets its own payments. Spend analytics (myShare/mySplitOf) deliberately still count settled rows: my half of a 2025 grocery shop is my spend whether or not the other half was ever repaid, and filtering them would re-inflate exactly the figures importing settled history exists to correct. Also drops /api/participants/[id]/balance. It had no consumers, no owner scoping, no debit/credit signs and no EXCLUDE_RECONCILED_SOURCE — a fourth balance implementation that disagreed with the others and would have imported three bugs if anything had aligned to it. getTripAnalytics had no test at all. It has five now, including the one that matters: a household payment must not make a trip look paid. Verified by mutation — neutering the settled filter fails three, and dropping the trip filter on payments fails that one.
This commit is contained in:
@@ -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`;
|
||||
|
||||
+50
-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 } 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]),
|
||||
]);
|
||||
|
||||
Reference in New Issue
Block a user