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.
49 lines
2.5 KiB
SQL
49 lines
2.5 KiB
SQL
-- 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.';
|