Commit Graph
8 Commits
Author SHA1 Message Date
siddharthd 788219b9fd fix(splits): the cutover date, not a boolean, is what gates a balance
ci / lint-test (push) Successful in 46s
Nothing dated before 2026-01-09 can be owed, because carryover transaction 2348
already carries the entire pre-cutover balance as a single figure. ACTIVE_OBLIGATION
now says so directly.

This inverts which mechanism is load-bearing, and that is the point. Until now
the only thing keeping $37,233.28 of paid debt out of the balances was
transaction_splits.settled -- a boolean that any delete-and-recreate write path
resets to false, as the split modal did until commit 6add958. Losing the flag on
a pre-cutover row now costs nothing: the date still excludes it. The flag matters
only on or after the cutover, marking the few settled outside this app.

It also makes splitting history safe to do freely. A split on a 2024 grocery
shop can now describe how the expense was shared -- which is what stops it
inflating spend -- without asserting a debt that was settled years ago. That was
the whole reason not to split pre-2026 expenses, and it no longer applies.

The bound is inclusive because transaction 2348 is itself dated 2026-01-09; an
exclusive one would drop the carryover and with it the entire pre-cutover
balance.

Corrects one live figure: a Woolworths on 2026-01-06 was split 50/50 three days
before the cutover, double-counting $7.55 against the carryover. Sonu
$5,428.08 -> $5,420.53.

The test fixture default moved to 2026-06-15 -- it was 2024-06-15, which is now
pre-cutover and made every balance fixture read zero. That the suite caught this
is the guard working.
2026-07-28 13:52:30 +10:00
siddharthd dbfbd5196d fix(transactions): supersede rows imported twice instead of deleting them
ci / lint-test (push) Successful in 48s
Statements 107, 142 and 143 bill overlapping periods on one ANZ account, so 31
transactions -- $42,040.68 -- are in the ledger twice.

They are marked superseded, not deleted. Every child of transactions is ON
DELETE CASCADE (splits, tags, overrides, expense_metadata, order_reviews), so
deleting "the duplicate" destroys whatever curation sits on it, and which
member of a pair holds that curation is an accident of import order: here 1
pair carries splits and 6 carry overrides, all on the surviving side, but
nothing guarantees that. Superseding keeps the row, keeps its children, and
makes a mistake one UPDATE to undo rather than a restore from backup.

reconciled_with_id could not be reused. Its predicate is scoped to
statement_id IS NULL on purpose -- a statement line pointing at something else
is the survivor, not the duplicate -- and here both rows are statement lines.

The exclusion goes into EXCLUDE_RECONCILED_SOURCE rather than into a new
fragment, so every query already asking "count each purchase once" gets it
without being edited. The trip cost queries did not use that fragment at all
and now do; verified a no-op on current data (0 trip-tagged rows are either
reconciled sources or duplicates), but they were one import away from
double-counting.

Most of the $42k is transfers and investments, which spend already excludes.
The damage was elsewhere: duplicated rows in the list, and rules re-splitting a
duplicate -- txn 3807 is one of these 31 and was a candidate for splitting
earlier today.

Balances are unchanged: no duplicate carried a split.
2026-07-28 11:54:25 +10:00
siddharthd d5589b2980 feat(statements): flag billing periods that overlap another statement
ci / lint-test (push) Successful in 46s
An account cannot be billed twice for the same day, so an overlap means those
transactions are in the ledger twice. ANZ statements 107 and 143 overlap by 118
days and put roughly $42,000 of duplicate rows in; nothing anywhere said so.

Two details decide whether this catches the real case:

  - Account numbers compare with non-digits stripped. The duplicate got in
    because the existing key compared raw text and ANZ wrote the same account
    as 408556264 on one statement and 4085-56264 on the other.
  - The range is half-open. These statements are issued back-to-back with one
    period ending the day the next starts, so inclusive bounds flagged 5 pairs
    of which 3 were consecutive and fine. Half-open leaves exactly the 2 real
    ones.

NULL bounds are excluded rather than handed to daterange, where NULL means
unbounded and an undated statement would overlap all of history.

Detection only. It does not refuse the import or touch the duplicate rows --
cleaning those is separate, and must supersede rather than delete because every
child of transactions is ON DELETE CASCADE and the curation sits on the
duplicate side.

Both subtleties have a test, and both fail if you undo them.
2026-07-28 11:46:45 +10:00
siddharthd 8c21893cc2 fix(trips): money that came back is not what the trip cost
ci / lint-test (push) Successful in 1m28s
Every trip figure filtered on transaction_type IN ('debit','fee','interest'),
which drops refunds and credits outright. A partly-refunded booking therefore
read at its full price and the refund subtracted nothing, anywhere: the
headline total_spend, the category breakdown, the daily chart, top merchants
and the tag breakdown were all gross.

This is the same defect the general analytics fixed once already, which is why
NET_SPEND_ROWS and SPEND_SIGNED exist -- a refunded Expedia purchase read as
$2,888.92 of spend until they did. Trip analytics never adopted them. Doing so
now costs one predicate and one expression per query.

getTrips/getTripById needed the trips alias moved to `tr`: the fragments assume
`t` is `transactions`, and hand-inlining a copy rather than renaming is exactly
how the reconciled-row exclusion drifted out of the analytics routes before.

On Europe 2026 this is $821.12 -- a LuxuryEscapes booking with two part-credits
against it, and a FreeNow hold adjustment. Fully cancelled bookings are a
different case and are handled by untagging both legs from the trip by hand,
because a trip never incurred a cost it cancelled.

No balance moves: the owed query already excludes credits and a refund carries
no split. There is a test asserting exactly that, and it passes with or without
this change -- it is a guard, not a proof. The three that do prove it fail
without it.
2026-07-28 11:25:03 +10:00
siddharthd 4fcb135805 fix(trips): a trip figure must only count what the owner is owed
ci / lint-test (push) Successful in 46s
The per-trip owed number shipped in 689fadc counted every split on every
trip transaction regardless of who paid, so it silently mixed debts owed to
different people under one label.

On Europe 2026 that meant Molina "owed" $21,572.12, of which $1,605.49 was
her share of rows Sonu paid for — a real debt, but between the other two
participants, and one they had already settled directly (split_payments id
5, Molina -> Sonu, exactly $1,605.49). A participant's own share of a row
they themselves paid for was in there too, which is nobody's debt at all.

Both sides needed scoping, not just one: the owed side to rows this owner
paid for, and the paid side to payments made to this owner. Scoping only
the first would have let a Molina -> Sonu payment reduce what Molina owes
the owner.

The corrected figures reproduce a number derived independently, months of
data apart: Molina now reads -$816.16 on Europe, matching her known
overpayment to the cent ($19,966.63 of splits against $20,782.79 paid).
Sonu goes from $8,793.10 to $1,084.61, and the owner correctly disappears
from a list of people who owe the owner.

Found by checking a household total against what the app had been showing
all along — the query was gross, gave a number about twice the real one,
and I had quoted it as "owed". Worth stating plainly: the defect was not in
the number the app displayed, it was in the number I computed to explain it.
2026-07-27 23:40:58 +10:00
siddharthd 689fadc8b9 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.
2026-07-27 23:12:05 +10:00
siddharthd 3bb67f370d feat(orders): show where an Uber trip went, in the list
ci / lint-test (push) Failing after 41s
Five rows all reading "Order - Uber Trip" are indistinguishable — the list gives
you a date and an amount and nothing to tell one ride from another (user,
2026-07-27). Where the trip went is exactly what separates them, and it was
already stored on expense_metadata.route since this morning; nothing in the list
read it.

getTransactions now joins the receipt (both directions — transaction_id OR
matched_transaction_id, since a card-settled order points at the statement line
instead) and the description cell renders "Terminal 2, Melbourne Airport (MEL)
→ 19 Lady Penrhyn Dr" in the same italic sub-line notes use.

Two deliberate limits:

- **A note the user wrote always wins.** This only fills an empty sub-line; it
  never occupies the notes field, which is theirs.
- **Deliveries are excluded.** Their merchant already identifies them, so the
  restaurant's street address would be clutter on every food order. Gated on
  platform = 'uber'.

The summary keeps the first two comma-segments of each address — a truncation,
not a guess about geography. Uber puts the venue or street first, which is the
identifying part; the full stops with their times stay in the title attribute.
2026-07-27 11:43:55 +10:00
siddharthd 1296555f17 test: add unit and integration test suites
- Extract evaluateCondition + rule types into src/lib/rules.ts for testability
- 48 unit tests for evaluateCondition (all fields/operators) and formatCategory
- 21 integration tests for getTransactions filters and getParticipantBalances
- Vitest configs for unit (vitest.config.ts) and integration (vitest.integration.config.ts)
- setup-test-db.sh creates personal_test DB from production schema via pg_dump
- Use vi.doMock + dynamic import pattern to isolate test DB from Prisma singleton
2026-04-01 19:59:29 +11:00