Give a payment a tab to settle #1

Closed
siddharthd wants to merge 0 commits from feat/split-scopes into main
Owner

Why

A payment has only ever recorded from, to, amount and date. getTripAnalytics said what that cost, in a comment 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.

So every trip 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.

What

One nullable column: split_payments.trip_id. NULL means the ongoing household tab.

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.

settled answers a different question and the two are kept orthogonal: trip_id is which tab, settled is whether the obligation is live. 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 subtracts the settlement twice. So settled is written only by the historical import, and there is deliberately no "mark settled" action.

Spend analytics still count settled rows on purpose: my half of a 2025 grocery shop is my spend whether or not the other half was repaid.

Also drops /api/participants/[id]/balance — no consumers, no owner scoping, no debit/credit signs, no reconciled-source exclusion. A fourth balance implementation that disagreed with the others.

Second commit

a4ab543 landed six hours ago and this branch rewrote one of the queries it had just fixed. It restores both guarantees to the trip figure: the shared EXCLUDE_RECONCILED_SOURCE fragment instead of a hand-inlined copy, and unconverted-row counting so a Europe total does not silently mix EUR into AUD.

Verification

  • 77 unit + 75 integration tests pass; lint unchanged from main's 24-problem baseline
  • Migration applied to a scratch clone of prod: global balances byte-identical to prod, confirming the settled filter is correctly a no-op today
  • On that clone, scoping the two payments making up the real $11,016.98 "Europe trip" credit drops Sonu's Europe figure from $8,793.10 to $1,873.67
  • getTripAnalytics previously had no test at all — five now, including that a household payment must not make a trip look paid
  • Mutation-tested: neutering the settled filter fails 3, dropping the payment trip filter fails 1

Not in scope

Per-scope balances on the Shared page, the payment→bank-leg backfill, and the historical CSV import.

## Why A payment has only ever recorded from, to, amount and date. `getTripAnalytics` said what that cost, in a comment 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. So every trip 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. ## What One nullable column: `split_payments.trip_id`. NULL means the ongoing household tab. 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. `settled` answers a different question and the two are kept orthogonal: `trip_id` is *which tab*, `settled` is *whether the obligation is live*. 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 subtracts the settlement twice. So `settled` is written only by the historical import, and there is deliberately no "mark settled" action. Spend analytics still count settled rows on purpose: my half of a 2025 grocery shop is my spend whether or not the other half was repaid. Also drops `/api/participants/[id]/balance` — no consumers, no owner scoping, no debit/credit signs, no reconciled-source exclusion. A fourth balance implementation that disagreed with the others. ## Second commit `a4ab543` landed six hours ago and this branch rewrote one of the queries it had just fixed. It restores both guarantees to the trip figure: the shared `EXCLUDE_RECONCILED_SOURCE` fragment instead of a hand-inlined copy, and unconverted-row counting so a Europe total does not silently mix EUR into AUD. ## Verification - 77 unit + 75 integration tests pass; lint unchanged from main's 24-problem baseline - Migration applied to a scratch clone of prod: global balances **byte-identical** to prod, confirming the `settled` filter is correctly a no-op today - On that clone, scoping the two payments making up the real $11,016.98 "Europe trip" credit drops Sonu's Europe figure from $8,793.10 to $1,873.67 - `getTripAnalytics` previously had **no test at all** — five now, including that a household payment must not make a trip look paid - Mutation-tested: neutering the settled filter fails 3, dropping the payment trip filter fails 1 ## Not in scope Per-scope balances on the Shared page, the payment→bank-leg backfill, and the historical CSV import.
siddharthd added 2 commits 2026-07-27 23:16:22 +10:00
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.
fix(trips): carry the currency and reconcile rules into the trip figure
ci / lint-test (push) Failing after 43s
ci / lint-test (pull_request) Failing after 43s
ae23b03d5d
a4ab543 landed six hours ago and this branch rewrote one of the queries it
had just fixed, quietly dropping both of its guarantees.

That commit made EXCLUDE_RECONCILED_SOURCE "one fragment both sides import"
because an inlined copy is how the reconciled-row exclusion drifted out of
the analytics routes and double-counted 48 rows / $4,474.79. The trip owed
query here had hand-inlined its own copy — the fragment assumes the alias
`t` and this query used `tx`, so the path of least resistance was to
re-create exactly the divergence that was being removed. Aliased to `t` so
the fragments apply directly.

The same commit made balances count rows whose AUD value is unknown rather
than netting a foreign figure against AUD ones. The trip figure had no
equivalent — on the query where it matters most, because a trip is where
foreign rows actually live. A Europe total silently mixing EUR into AUD is
the whole failure that fix was written to prevent.

The column header still read "Share of this trip" while the number is now
net of payments, which is the same class of drift a4ab543 set out to fix.
It reads "Outstanding on this trip", carries the approx/unconverted caveat
the Shared cards use, and greys a settled zero.
siddharthd closed this pull request 2026-07-27 23:21:08 +10:00

Pull request closed

Please reopen this pull request to perform a merge.
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: siddharthd/finance-app#1