Commit Graph
141 Commits
Author SHA1 Message Date
siddharthd 6db2345c49 Net the trip debt to one figure, and show payer, category and search on Shared
ci / lint-test (push) Successful in 48s
Shared view: the query already returned owner_name and effective_category, the
table just never rendered them. Paid by sits next to Splits because together
they are whose money went out and whose share it was. Search is client-side —
this endpoint returns all 1,267 split rows in one request with no pagination, so
there is nothing for a round-trip to narrow, and the sort was already
client-side. It matches description, merchant, notes, category and payer, but not
participant names: the dropdown does that, and "sonu" matching every row she is
split on would read as broken.

Trip owed collapses to one settle-up figure per person, with the breakdown
beside it so the net is auditable rather than asserted.

I argued against netting a few hours ago and was wrong. The claim was that the
grouped-payment allocation cleared each trip against the one-directional gross,
so netting would redefine that debt after the fact. The rows say otherwise:
Europe's $802.75 is 56 transactions Sonu actually paid across Rome, Venice, the
Dolomites, Bellagio, Lucerne and Paris on which I hold 25%, and paid_by_me is
$0.00 on every row of every trip because nothing has ever been recorded going
from me to her. Her side looked settled only because the allocation derived her
payment split from her gross, so it lands on zero by construction. The
one-directional view was hiding a live obligation, not protecting an allocation.

Nets now: Auckland Sonu +$1,077.25, Europe Sonu -$802.75, Sonu + Sunny -$936.34,
Europe Molina -$816.16.

Also correcting an error in my own reporting: I said Auckland's mirror was
$0.00. It is $428.39 — 17 Auckland rows Sonu paid that I hold a split on. Two
ad-hoc verification queries mis-joined on a nullable scope column and
under-reported the mirror side. The app code was never affected and the owed
column is still byte-identical.

The footnote now states the trap the netting exposes: a debt settled by a payment
left on the household tab still reads as outstanding on the trip. Payment 5
(Molina to Sonu, $1,605.49) is exactly that case and is left alone as a data
decision.

277 passing, build clean.
2026-08-02 21:19:40 +10:00
siddharthd cb7665ded1 Let everyone on a trip see it, and give payments their scope back
ci / lint-test (push) Successful in 52s
Trips were scoped to trips.owner_id, so Sonu saw no trips at all — despite
having paid for 104 of the tagged rows herself. Her own spending was invisible
on the only page organised around it.

A participant is now anyone with a split on, who paid for, or whose payment is
scoped to, a transaction tagged to the trip. Derived, not stored. A
trip_participants table was designed and rejected: the expenses already carry
the fact, and two records of one fact drift apart. Deriving it also excludes
Singapore + Bangkok 2026 from Sonu for free, which a table would have to be kept
in sync to do. Siddharth 4 trips, Sonu 3, Molina 1.

Everything about a trip is shared except delete. Both trip foreign keys are
ON DELETE SET NULL, so deleting Europe 2026 untags 210 transactions and NULLs
the trip scope on 6 payments — where the hand-derived Europe-first allocation
lives, which nothing recomputes. That stays with the owner.

Trip owed now returns both directions and nets neither. An obligation lives on a
row someone else paid for, so a viewer-as-payer figure can never hold it, and
Sonu's Europe read "you are owed $2,408.24" while omitting the $8,004.04 she
owed. Collapsing the two into a signed net is the tempting next step and would
have corrupted the scope allocation: the grouped-payment allocation cleared each
trip against the one-directional gross, so redefining the debt afterwards turns
$8,004.04 already allocated into an $802.75 over-allocation with household
understated by the same amount. Verified byte-identical — Auckland $1,505.64,
Europe Molina -$816.16, Europe Sonu $0.00, Sonu + Sunny $0.00.

getTransactions gained trip_all_rows so a participant sees the whole trip. It is
opt-in and not implied by trip_id, because the same endpoint backs the main
transactions list and its trip filter must keep owner scoping. Participation is
re-checked in SQL, so passing the flag for someone else's trip returns nothing.

Payments can finally say what they settle. trip_id has existed since migration
0022 but POST never read it and GET never returned it, so every payment made in
the app landed on household and the 9 trip-scoped rows were hand-written SQL.
"Both" needs no new shape — one row per scope sharing a linked_transaction_id.

Three write paths had no authorisation at all and were reachable by any
participant: assignTransactionsToTrip checked nothing, DELETE on a payment
deleted by bare id, and POST accepted any from/to pair. All three now check.

Also fixes the test suite, which was pointing at postgres-pantry: container IPs
move on recreation and 172.22.0.47 stopped being postgres-personal. It only
failed safe because the credentials did not match — resetDB now refuses to
truncate anything not named personal_test.

22 new tests, 276 passing, build clean.
2026-08-02 19:11:19 +10:00
siddharthd 6e179d3a0a docs: put the UI review's priorities 1-4 on the board, and correct two claims
ci / lint-test (push) Successful in 53s
The review was written 2026-07-26 and last touched 2026-07-29, one day before
the board replaced ACTIONS.md, so it was never migrated. Priorities 1-4 existed
only in this file. They are now epic #154 with #155-#159 under it.

Two claims in the doc were wrong. It said Priority 0 was guarded by
analytics-sql.test.ts; that file covers the three SQL fragments and nothing
else, so items 2, 3 and 6 -- pace/headline parity, the fees date range, and
partial-month comparison -- have no test at all. Those are the three where a
regression is silent, which is why the gap is worth a ticket rather than a note.

And it predated two changes in the same metric-integrity family: the signed
investments line (c70d2b1) and transfers hidden by default (f6c500b). The first
matters to the doc directly -- the cashflow strip it describes now carries a
signed invested figure, so a negative month is data rather than a bug.
2026-08-02 16:44:04 +10:00
siddharthd dbc6fd1352 Add source identity to transactions so a feed can be re-imported safely
ci / lint-test (push) Successful in 51s
The CSV importer has no idempotency and structurally cannot have any:
batchInsertCSVTransactions assigns row_index = MAX(row_index) + 1, and
row_index is the fifth column of uq_transaction_identity, so the constraint
is guaranteed a fresh value on every run. The same file imported twice
produces two full sets of rows and nothing objects.

Tolerable for the hand-driven bank CSV this was built for. Not tolerable for
any recurring feed, whose windows overlap by design — and not cleanable
afterwards, since every child of transactions is ON DELETE CASCADE, so a
duplicate must be superseded rather than deleted (0023). ~$42k of re-imported
statement rows already show how that goes.

source_ref carries the provider's own key verbatim, enforced by a partial
unique index rather than an application-side ON CONFLICT that a refactor can
quietly drop.

Found while evaluating Frollo (DECISIONS.md ING-11 in the smarthome repo).
That feed is not being ingested — 88% of it duplicates existing statement
coverage — but this gap is real independently of it.
2026-08-01 22:13:07 +10:00
siddharthd c70d2b1fac Sign the investments line so withdrawals net against contributions
ci / lint-test (push) Successful in 50s
A withdrawal from a fund is a disinvestment, not income: units convert
back to cash and net worth is unchanged. Summed unsigned it read as more
money invested. March 2026 showed $38,615.34 of investing in a month that
was net -$11,384.66, because a $25,000 Raiz withdrawal was added to an
$8,563.80 IBKR deposit instead of cancelling it.

Each credit costs twice — once for being added, once for not being
subtracted — so the error is double the credit: $50,000 in March, $3,000
in May 2025, $53,000 across the window. Since net = income - spent -
investments, March's net of -$55,918.73 should read -$5,918.73.

Filing withdrawals as income is the other tempting answer and is worse:
it books an asset disposal as earnings and feeds the same figure into net
with a flattering sign. Same reason the Up item sales in Known Gaps do
not belong on the income line.

What this cannot resolve: part of a withdrawal genuinely is income — the
capital gain. The bank descriptor is one gross figure with no cost base,
so it cannot be decomposed from statement data. Netting tracks cash
committed against cash returned and leaves the gain for holdings data to
surface; it does not assert the gain is zero.

The budget page gates the Invested card on `!== 0` rather than `> 0` — a
net-disinvesting month is real data, not an empty one — and renders
negative months in amber so the sign is not hidden by matching digits.
2026-07-31 00:05:05 +10:00
siddharthd f6c500b27a Hide transfers in the transactions view by default
ci / lint-test (push) Successful in 51s
Transfers move money between your own accounts; at 433 of 3,996 rows
(~11%) they crowd out the rows that represent actual spending.

getTransactions gains `exclude_categories`, opt-in per caller and
deliberately not defaulted in queries.ts: the rules preview and the bulk
rule-apply path both read candidate rows through getTransactions, and a
default exclusion there would silently shrink what a rule can see and
reach — invisibly, since a rule that matches nothing looks the same as a
rule with nothing to do.

Two behaviours the filter needs, both tested:

- An explicit category pick beats the exclusion. Selecting "Transfers"
  while the default is on subtracts it from the hidden list instead of
  returning zero rows and reading as "you have no transfers".
- COALESCE the effective category to '' before `<> ALL`. NULL <> ALL(...)
  is NULL, not true, so an uncategorised row would disappear from a
  filter that never named its category — the trap EXCLUDE_NON_SPEND
  already documents.

The default is off when the view is scoped to a statement: that is a
reconciliation view, the row count has to match the statement, and a
credit-card payment is the row you went there to check.
2026-07-30 23:34:31 +10:00
siddharthd 549abd8cca docs: point work tracking at the board, not ACTIONS.md
ci / lint-test (push) Successful in 41s
Status for this app now lives on the Vikunja board (saved filter
finance-app), which replaced the smarthome repo's ACTIONS.md on 2026-07-30.

Notes the thing a single-label filter hides: a ticket can carry several
system labels — the receipt→pantry work is finance-app, pantry-app and
email-ingestion at once — so the finance filter is a view, not the boundary
of what will touch this codebase.

Also records the one dated item here: postgres-personal runs PostgreSQL 14,
EOL 2026-11-12, and it holds statements, transactions, orders and
expense_metadata.
2026-07-30 23:12:50 +10:00
siddharthd a465b147a3 Read receipt lines under the keys the receipt panel renders
ci / lint-test (push) Successful in 48s
The order-details panel reads qty/description/amount from
expense_metadata.line_items. A grocery shop is a receipt like any other, so it
stores the same keys rather than name/quantity/line_total — otherwise the rows
arrive complete and display blank, which is exactly the failure ING-8 names.
unit and category are the two fields a grocery line has and a delivery line does
not; nothing renders them yet and the category composition will.
2026-07-30 13:51:33 +10:00
siddharthd 17028c79ff Accept grocery receipts scanned in pantry as candidate spend
ci / lint-test (push) Successful in 48s
Adds /api/receipts/ingest as a sibling to the order lane, sharing its shape but
making one decision differently: nothing is parked. An order can wait for its
statement because it is already visible as an email; a gift-card grocery shop is
visible nowhere at all, so a scan that produces no transaction produces nothing
anyone can see. Every payment becomes a manual row immediately and the existing
pending-reconciliation queue resolves the ones with a card leg coming.

One transaction per tender leg. A $114.57 shop settled $40.75 gift card +
$73.82 Mastercard has a statement line for $73.82 only. A single row marked
credits is excluded from the queue while that line double-counts; marked card it
is searched for at 1% of $114.57 and never matches. Either way the shop books
$188.39. Per-leg rows make each amount the settled amount, so the matcher works
untouched.

Reconciliation now carries expense_metadata across. It already moved overrides,
tags and splits from the manual row to the statement row and left metadata
behind, which did not matter while metadata only came from an email that made
its own transaction. It matters now that it carries a shop's line items:
unmoved, the contents vanish at exactly the moment the statement line appears,
and COLES 0556 MANOR LAKES stays as unreadable as before anything was scanned.
transaction_id is UNIQUE, so a statement row that already has metadata keeps it
and the pantry row is flagged rather than raising a constraint violation.

Also regenerates the Prisma model. card_last4, currency, flags, reconciled_at,
matched_transaction_id, platform and route have been in the database since
migrations 0019/0020 and were absent from schema.prisma — regenerating the
client from it would have dropped columns the order lane writes on every ingest.

23 integration tests against the real schema, built from the three receipts that
drove the design. Existing suites unchanged: 104 unit, 144 integration.
2026-07-30 13:40:32 +10:00
siddharthd 69b3ed8ea9 docs: catch CLAUDE.md and the UI review up with the shared-expenses rebuild
ci / lint-test (push) Successful in 45s
The redesign is live (2026-07-28); only the loan model remains a proposal.
The UI review's 'settled is dead data' guidance described the pre-rebuild
state and is superseded — settled now gates ACTIVE_OBLIGATION.
2026-07-29 22:22:42 +10:00
siddharthd a56e5e2de5 docs: splits total 100%, and why the remainder is the owner's
ci / lint-test (push) Successful in 45s
2026-07-29 10:26:44 +10:00
siddharthd 22e4a1ead0 fix(splits): make every split account for 100%
ci / lint-test (push) Successful in 1m39s
A 50/50 arrangement was stored as a single row saying "Sonu 50%". The
arithmetic was never wrong — `myShare` resolves the payer's share as
`100 - SUM(everyone else)`, so balances and per-user spend were correct
throughout. It was still a bug, because a ledger is read as well as
computed: on screen that row is a 50% share against a blank, which looks
like half the money is unallocated and is indistinguishable from a split
somebody abandoned half-finished.

It also leaked. `getSharedTransactions` filters by participant with an
EXISTS on an explicit split row, so filtering the Shared view by the
payer silently dropped every transaction where their share was only ever
implied.

Four write paths could produce it, three of them unguarded:

  - the Slack nudge's share button, which inserted one row
  - `POST /api/transactions`, where the add form shows an amber total
    under 100 but saves anyway — this is how Lawn Mowing and Hedge
    Pruning were stored
  - `applyRuleActions`, where ten of the fourteen live split rules name
    only the other person

`completeSplit` is now the single place that writes the remainder, and
every one of those paths ends in it. The remainder goes to the
transaction's *owner*, never to "me": the owner's row on their own
transaction is excluded from both halves of the balance query, so it
cannot create, enlarge or discharge a debt, whereas a row for me on
someone else's transaction is a real obligation. That distinction is
what makes this safe to apply to existing data.

Also fixes the order panel's "Shared 50/50" toggle, which was inert in
both directions: it posted a lone 50% row to share (rejected — must
total 100%) and an empty array to un-share (rejected — array required),
because no way to clear a split existed. DELETE on the splits route is
that way.

Backfill: 7 rows, verified against a row-level dump diff — 2549 -> 2556
rows, none removed, none modified — and participant balances byte
identical before and after (Molina 19556.07, Sonu 20913.35). Every split
in the database now totals 100%.

Not done: a database-level constraint. Enforcing the sum needs a
deferred constraint trigger, and the rule path commits its DELETE and
INSERT as separate statements, so the trigger would reject the
intermediate state. Making it work means wrapping every write path in a
transaction, which is a larger change than the defect warrants.
2026-07-29 10:23:06 +10:00
siddharthd dd0462a5f9 fix(orders): decode &bull; so item options separate again
ci / lint-test (push) Successful in 53s
`&bull;` was missing from the entity table, and that was not cosmetic.
DoorDash separates an item's name from its options with a bullet and
parseDoorDashLineItems splits on the literal "•" — so left encoded, the
split never happened and the line collapsed into the description:
"Bucket and Side Pack (Meal Deals) &bull; Hot Bucket &bull; Chips" with
options []. The entity showed on screen and the structure behind it was
gone.

Numeric entities are now decoded generically rather than one at a time,
which is how &#36; came to be listed individually while its neighbours
were not, and &amp; resolves last so a literal "&amp;bull;" stays as
written instead of turning into a bullet.

Repaired the 47 stored rows by re-parsing the captured email behind each
one rather than string-replacing the entity, since a replacement would
have fixed the display and left options [] underneath. Rehearsed first:
all 47 re-parsed, all 47 gained options, 0 line items lost, 0 amounts
changed. Old values kept in dump/rollback-line-items-20260728-223737.json.

Pre-existing — 22 rows predate today — but the pre-cutover backfill more
than doubled the affected rows, which is what surfaced it. Verified in
the Order details panel, not in SQL.
2026-07-28 22:38:38 +10:00
siddharthd 3144cf3176 feat(orders): record credits orders from before the cutover
ci / lint-test (push) Successful in 58s
I1 refused any credits-funded order dated before 2026-01-09, storing
nothing at all — no transaction and no metadata, so the receipt was
discarded rather than kept as history.

Its reason was splits, not spend: before the cutover shared expenses
lived in SplitMyExpenses, and re-importing them would double-charge
against carryover transaction 2348. That reason expired with 788219b,
where ACTIVE_OBLIGATION became `settled = false AND transaction_date >=
'2026-01-09'`. A pre-cutover split can no longer assert a debt, so a
pre-cutover order cannot move a balance however it is recorded — and
ingestion writes no splits at any date, which now has a test of its own.

What the guard was still doing was hiding ordinary history: 275 orders,
$9,799.96 of meals and rides across 2020-2025, invisible only because the
money came from a gift-card balance instead of a card.

The funding side stays as it is, deliberately. Some of those orders were
paid from ShopBack gift cards that are themselves booked as expenses, so
that portion is counted twice. The exposure is bounded at $3,411.16 over
14 loads and is probably smaller: the descriptors name no brand — "ShopBack
Gift Cards SQ" is a batch code, and the card could be Amazon, Airbnb or
Shell as easily as DoorDash — and six are categorised `gifts`, which may
be real presents rather than self-funding. Reclassifying them on a guess
would corrupt correct data to fix a double-count that cannot be shown.
Only the ShopBack purchase emails can settle it, joined on total paid.
2026-07-28 22:20:03 +10:00
siddharthd fe104a9618 fix(orders): read both legs of an Uber payment
ci / lint-test (push) Successful in 53s
Uber Cash IS credits, and the payment line had become unreadable in the
newer layout: "Payments Uber Cash 10/17/25 8:50 PM A$54.87" carries a
timestamp between the label and the amount, and writes the currency as a
prefix. Both defeated the pattern, so credits_amount stayed null and the
order was filed as card-settled — sent looking for a card leg that does
not exist, found nothing, and left as an orphan with no transaction and
no card tail to match on. 118 captured messages sit in that state, and
every one of them is pre-cutover, so reading them correctly means I1
skips them rather than storing enrichment that points at nothing.

The card leg had the same blind spot, hidden behind the first: the gap
between the mask and the amount was [^\d]{0,40}, which a timestamp
breaks. While BOTH legs were unreadable a mixed payment still looked
consistent — the order read as card-settled for the full total. Fixing
only credits reads half an order, which validateOrderTotals correctly
refuses. Found exactly that way: four messages that validated before
began failing "payments sum to 34.92 but receipt states 43.60".

Legs are summed rather than taken first, because one order can be charged
in instalments and an instrument can carry no mask at all (PayPal). But a
leg that already equals the stated total IS the payment, not an
instalment: a Dubai trip prints an AED 17.67 authorisation and then the
AED 577.83 settled charge, and adding the hold overstates the trip. A
mixed credits+card order is unaffected — neither leg equals the total
there, which is why it needs summing.

A/B over all 776 captures: 468 parsed by both, zero change to any amount,
currency or existing card tail, 0 lost, 118 credits figures newly read.
Mutation-tested: dropping the exact-leg rule fails 2 tests, dropping the
Uber Cash read fails 7.
2026-07-28 21:59:35 +10:00
siddharthd 63aaf1eb21 fix(orders): read A$ totals and credits-funded receipts
ci / lint-test (push) Successful in 51s
Two parse bugs that between them made 218 of 776 captured messages
unreadable. Neither was the "old template" they were filed as.

Uber writes the currency three ways and only two were handled. "Total
A$54.87" is what it sends for ordinary Australian orders — A$ misses
[A-Z]{3} by one character — so 98 of 275 Uber Eats mails and 78 of 275
trip mails failed with "no Total found" while the amount sat in plain
sight. Most were 2024-2025, i.e. current mail. NZ$, US$, S$, HK$, C$ and
a bare rupee/euro/pound symbol are handled the same way. A bare "$" is
still left unresolved on purpose: a dozen currencies use it, and the
body-wide scan that reads the receipt's own stated code should win.

A DoorDash order paid from credits states "Total Charged $0.00"
truthfully, above a real subtotal. Read literally that is a $0 order, and
validateOrderTotals rejected 88 of them as non-positive — discarding
exactly the credit-funded spend this pipeline exists to surface. The
order's value is its subtotal; recording zero would show the order and
hide what it cost. Guarded on the receipt actually saying credits, so an
empty mail still fails rather than inheriting a stray subtotal, and the
header cross-check stands down for these or it rejects the figure the
parser deliberately overrode.

Measured A/B over all 776 captures: 254 parsed by both parsers with zero
change to any amount or currency, 0 lost, 214 newly readable. Both fixes
mutation-tested — reverting the regex fails 4 tests, removing the credits
branch fails 3.

Fixtures are real captured receipts, per the 2026-07-26 rewrite: the
earlier synthetic suite passed while the parser could not read a real
email.

No history replay — I7 idempotency refuses re-reads and that needs an
explicit update mode. This fixes ingest from here on.
2026-07-28 20:32:02 +10:00
siddharthd 7cf247951a docs: the verdict scale, and the two Slack rules that cost real data
ci / lint-test (push) Successful in 43s
2026-07-28 19:17:25 +10:00
siddharthd d458228625 feat(orders): a fifth verdict, 'bad', between ok and never again
ci / lint-test (push) Successful in 47s
The jump from "OK" to "Never again" is too big and most
disappointments live in the gap (user, 2026-07-28) — so a merely poor
meal either flattered itself as OK or got blacklisted.

Only 'never' raises the warning on a future order. A blacklist that fires
for every mediocre delivery is one nobody reads, so 'bad' records the
disappointment without triggering the alarm. Both set order_again = false
— you would not choose either again — and that split between "would I
order it" and "warn me about it" is the point of the extra level.

Migration widens the CHECK; nothing is removed, so no existing row needs
mapping.
2026-07-28 19:12:26 +10:00
siddharthd 82f751cbf4 fix(slack): update the card via response_url, not the HTTP response
ci / lint-test (push) Successful in 43s
Every press wrote correctly and then left the card showing stale state,
so a working button looked dead — and a button that looks dead gets
pressed again, which toggled the split back. That is how two orders got
unshared while looking like nothing had happened.

The cause was a wrong assumption in the original design: Block Kit
interactivity ignores the HTTP response body. Replacing a message from
the response is legacy attachment-style behaviour. The update has to go
to payload.response_url, which needs no bot token — so it stays in the
app rather than becoming another n8n node.

Two things that were also invisible now speak up. The share guard used to
return silently when it refused a three-way or uneven split, which is
indistinguishable from a broken button; it now says which it was, as an
ephemeral only the presser sees. Same for an unmapped Slack user.

The response still echoes the rendered blocks. Slack ignores them, but it
lets a card be rendered server-side without pressing anything — which is
what stops the replay tooling from hand-writing a card with a guessed
share state, the mistake that cost a real split earlier today.
2026-07-28 18:54:53 +10:00
siddharthd 9f0f38449b feat(slack): ask the other person too, and put rating before sharing
ci / lint-test (push) Successful in 43s
Two halves of the same requirement, one of which was quietly missing.

Sharing split the money but never reached her: she is not in #smarthome,
so the card whose caption said "both verdicts welcome" was one she could
not see. Now a share DMs her a card of her own. A DM rather than adding
her to the channel, so her surface stays "orders that concern me"
instead of the whole house's ops feed. She was already in SLACK_USER_MAP,
so her press files under participant 4.

Only on the press that turns sharing ON, and only when someone else did
the sharing. Re-notifying on every later rating press would turn one
shared meal into a stream of DMs, which is how a nudge gets muted.

Her card carries no share button: she is being told it was shared, not
asked to decide, and two people toggling one split from separate copies
of a card is a race with no upside.

The app still holds no Slack bot token — it returns a notify instruction
and n8n sends it, the same shape as the modal open. If SLACK_USER_MAP has
no id for her the DM is skipped silently: the split is correct and
complete either way, and failing the press over an unaddressable nudge
would be the worse trade.

Card reordered to rate -> details -> share. You judge the food, then
decide who pays for it; asking "was this shared?" first inverts the
order a person thinks in. The status caption moved under the share button
it describes rather than sitting orphaned mid-card.
2026-07-28 18:33:25 +10:00
siddharthd 0595d49d5c fix(orders): the restaurant is the merchant, not the courier
ci / lint-test (push) Successful in 43s
Reverses a change made on request. The platform in the headline
fragmented the merchant: the same restaurant read as two, depending on
who carried the bag, and that is not a distinction anyone rating the food
cares about. It also already has a home — the expandable Order details
panel renders expense_metadata.platform next to its heading, which is
where the user asked for it.

The fragmentation was worse than cosmetic. merchantVerdict joined on an
exact merchant_normalized, and the platforms capitalise differently
("TEG Kebabs & Biryani" on Uber Eats, "TEG KEBABS & BIRYANI" on
DoorDash), so one restaurant kept two separate histories and a "never
again" recorded through one app never warned in the other — silently
defeating the point of the memory. Now case-folded; verified on real
data, where the DoorDash order sees 1 prior verdict against 0 before.

81 existing descriptions backfilled in one transaction, dry-run first and
dumped beforehand. The regex is anchored to the end so suburb parens
survive: "Order - Coles (Wyndham Vale) (Uber Eats)" becomes
"Order - Coles (Wyndham Vale)", not "Order - Coles".
2026-07-28 17:51:36 +10:00
siddharthd 50c5b7c430 fix(slack): don't let one tap flatten a hand-made split
ci / lint-test (push) Successful in 43s
Splits on orders are made by hand, so a third participant or an uneven
share is a deliberate decision — and "Make it just me" deleted every
split row regardless. A one-tap button silently destroying an
arrangement made with more care than the tap that undid it is the same
failure shape as the rewrite that dropped `settled`.

Now it refuses when a participant other than the two consumers is
present, or when the share is not 50. Verified against the running stack:
a three-way split and a 70/30 both survive a press; a plain 50/50 still
toggles off and back on.

Also: the nudge reads share state instead of assuming false. Today a
freshly ingested order has no splits — the 140 that do were split by hand
after the backfill, not by a rule — but the label drives a destructive
button, so a wrong assumption there costs data rather than a cosmetic
error. One query is cheaper.
2026-07-28 16:40:53 +10:00
siddharthd 14d6b40578 feat(slack): per-item verdicts and a note, in a Slack modal
ci / lint-test (push) Successful in 42s
The card can rate an order but cannot ask which dish or why: a message
cannot collect free text, and an actions block caps at 25 elements while
item counts vary per receipt. A modal is the only Slack-native answer,
and it stays inside Slack — no browser, no app, which is the whole reason
it exists rather than a link.

The overall rating deliberately stays on the card. That is the thing done
every time and it should cost one tap; this is for when something was
notably good or bad.

finance-app holds no Slack bot token by design, so it returns the view
and n8n — which already has the credential — calls views.open. One copy
of the token, no new secret, no compose change.

Item text travels in private_metadata because a submission returns block
ids and values, never labels, so there is otherwise no way back to which
dish a radio button referred to. Capped at 20 rows: a grocery order runs
long and nobody scrolls a modal to rate a tin of tomatoes.

The modal does NOT write the rating. A form that silently reset a
decision the user did not revisit is the same class of bug as the split
rewrite that dropped `settled`.
2026-07-28 16:33:10 +10:00
siddharthd 8fbcbc5f83 fix(slack): one select instead of four rating buttons
ci / lint-test (push) Successful in 47s
Slack's mobile client gives every button in an actions block its own
full-width row, so the four ratings rendered as four stacked bars and the
nudge filled the screen (user, with a screenshot). A select is one row
and still one decision.

The interactive route now resolves both shapes — a button carries
`value`, a select carries it on `selected_option`. Reading only the
former would have left sharing working while rating silently did
nothing.
2026-07-28 16:22:49 +10:00
siddharthd aaa36dd75e feat(slack): answer the order nudge in Slack, without opening the app
ci / lint-test (push) Successful in 40s
Being sent to a web app to answer "was this shared?" is enough friction
that the question stops getting answered — which is the exact failure the
nudge exists to prevent. So the buttons now act in place: pressing
"Shared 50/50" writes the transaction_splits row and edits the message,
and the app is never opened.

Slack does NOT reach this route directly. It posts to an n8n webhook that
forwards the raw body and signature headers here (user's suggestion).
That is the better shape: n8n already terminates public webhooks, so the
app keeps its blanket OAuth chain and gains no internet-facing
unauthenticated route, and no Traefik change is needed. n8n cannot verify
the signature itself — its Code sandbox has no `require`, so no `crypto`.

Two independent gates, both failing closed: the shared x-ingest-token
(came from n8n) and Slack's v0 signature over the raw body (came from
Slack, not replayed within 5 minutes). An unset signing secret rejects
everything rather than waving it through, because the alternative turns a
misconfigured deploy into an open write endpoint.

An unmapped Slack user is refused rather than defaulted to the owner. In
a two-person household a wrong attribution is not a rounding error, it is
the other person's opinion recorded under your name.

Block Kit is built in the app (ingest returns slack_blocks) rather than
in n8n expressions: a template string is untestable, and this shape has
to stay in step with what the interactive endpoint re-renders after each
press. Null when there is no transaction yet — a card-settled order is
parked until its statement arrives, so there is nothing to split or rate.

Also: /transactions now honours ?q=, so the link lands on the row instead
of the top of an unfiltered ledger.
2026-07-28 15:50:06 +10:00
siddharthd 66a6a51fb8 docs: order verdicts are built; record the four load-bearing shape decisions
ci / lint-test (push) Successful in 41s
2026-07-28 15:43:20 +10:00
siddharthd b8919a4775 feat(transactions): honour ?q= so a link can land on one row
ci / lint-test (push) Successful in 42s
The Slack order nudge links here. Without it the link drops you at the
top of an unfiltered ledger and the merchant has to be found by hand,
which is how a nudge stops being opened.
2026-07-28 15:34:24 +10:00
siddharthd 0cb46a087b feat(orders): record what we thought of an order, per person
The ledger already knew we had ordered from a place; it did not know the
food was bad. Orders got repeated from places we disliked because nobody
remembered by the time the next one went in. That is what the receipt
ingestion was for (ING-9) and the last piece was missing: order_reviews
existed as a table with no API, no UI and no writes.

Four levels, not three. "Loved" and "liked" are both "would order again"
but only one is worth a detour, and "ok" is not a recommendation.

A verdict belongs to a person, not to an order. A shared meal produces
two opinions and they routinely disagree — that disagreement is the
useful part, and the old UNIQUE on transaction_id alone could not hold
it. Now UNIQUE (transaction_id, participant_id), and the default is the
signed-in user rather than the owner: Sonu authenticates through the same
Traefik OAuth as participant 4, so an owner default would have filed her
verdict under his name.

Per-item opinions key on the item DESCRIPTION, not its index. An index is
meaningless across orders; "the Pad Thai here is good" is the signal that
has to survive into the next order from the same merchant. Only the two
poles are offered — a per-item "ok" answers neither of the questions you
ask at order time.

Sharing is recorded as a real 50/50 split, not a decorative flag. The
split already IS the record that an order was shared, and two records of
one fact drift apart.

An ABSENT item_verdicts means "leave them alone"; an empty array clears
them. Without that distinction a note-only save silently wipes every
per-item opinion — the same shape as the bug that reset `settled` on
split rewrites, and just as invisible on screen. Mutation-tested: making
keepItems a no-op fails exactly one test.

mockDbWithPool gained queryRow. Omitting an export from the mock makes it
undefined at the call site, which fails as "not a function" and reads
like a code bug rather than a test-harness gap.
2026-07-28 15:31:22 +10:00
siddharthd d081d80a3f docs: rewrite the shared-expenses section, which had gone false
ci / lint-test (push) Successful in 39s
Every warning in it was inverted by this week's work:

  - "settled is dead data, false on every row" -- there are now 1,266 settled
    splits across 657 pre-2026 transactions.
  - "do not fix getParticipantBalances to exclude settled splits" -- it now
    excludes them, via ACTIVE_OBLIGATION, and must.
  - "settlement cannot be attributed per trip" -- migration 0022 added
    split_payments.trip_id and it is attributed.
  - "splits exist from 2026-01-09 only" -- pre-2026 transactions are now split
    deliberately, to stop them inflating spend.

Replaced with what is actually true, including the rule that matters most: the
cutover DATE is the primary balance gate and the settled flag only refines it,
so pre-2026 expenses can be split freely.
2026-07-28 14:41:05 +10:00
siddharthd db6b7f8375 docs(shared): the cutover date is the gate, the flag refines it
ci / lint-test (push) Successful in 44s
2026-07-28 13:54:46 +10:00
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 6add958132 fix(splits): editing a split must not resurrect a settled debt
ci / lint-test (push) Successful in 47s
The route replaces every split for a transaction rather than editing in place,
so the recreated rows took the column default settled=false. Opening the split
modal on a historical transaction and saving it therefore converted a
discharged obligation into a live one, with nothing on screen saying so.

That is not theoretical. 657 pre-2026 transactions now carry settled splits
imported from SplitMyExpenses -- $37,233.28 of balance that the carryover
(transaction 2348) already accounts for. Editing one would double-count its
share against a debt that was paid years ago.

Now carries settled and settled_at across the rewrite, per participant, the
same way the rules revert route already does. Changing someone's percentage
does not re-open the obligation: it was settled outside this app and stays
settled. A participant who was not on the transaction before is a genuinely new
obligation and correctly starts unsettled.

rule-actions.ts was already safe here -- it upserts ON CONFLICT DO UPDATE SET
share_percent, so it never touches the flag.
2026-07-28 13:40:48 +10:00
siddharthd 3339a0b9b7 chore: ignore the scripts venv
ci / lint-test (push) Successful in 45s
scripts/split_csv_match.py needs psycopg2, so scripts/ now has a venv beside it.
This repo deploys from its working tree, so an untracked .venv would be swept
into the Docker build context.
2026-07-28 13:32:31 +10:00
siddharthd 3b9d302ce2 docs(shared): record the grouped-payment allocation
ci / lint-test (push) Successful in 49s
Sonu's two "transfer" payments are split by scope, Europe first and the
remainder to household, chronologically so each settles what was outstanding
when it was made. Both Europe tabs now read $0.00.

No schema change was needed and that is the point worth writing down:
split_payments has no unique constraint on linked_transaction_id, so one bank
transfer carries one row per scope and the rows re-add to the transfer --
verified, 4111 sums to $3,779.33 and 4121 to $4,794.06.

Her overall balance is unchanged at $5,428.08. Allocation moves money between
tabs, never between people; that invariance is the check to repeat on any
future re-allocation.
2026-07-28 13:02:56 +10:00
siddharthd 4fc8eeac95 docs(shared): record the Sonu+Sunny leg as its own trip
ci / lint-test (push) Successful in 51s
Trip 3, 2026-04-12 to 2026-04-28, 124 rows, $9,914.24. It was marked only by
tag 5 and so was invisible to every trip figure.

It reads at first like a sharing scope overlapping the group trip, because the
tag's earliest row is 17 March. It is not: that is a single advance booking
(Ticketmaster Nanterre), 8 more rows fall on the 12 April handover day and were
already held out of Europe 2026, and the remaining 115 run 13-28 April. A clean
sequential leg.

Also records that grouped payments need no schema change -- split_payments has
no unique constraint on linked_transaction_id, so one transfer can carry a row
per scope -- and that the 23 Apr Qantas booking is the flight to Bangkok
starting a solo leg, which is why it stays out of this trip.
2026-07-28 12:59:53 +10:00
siddharthd 89300450a7 docs(shared): describe what was built, not what was proposed
ci / lint-test (push) Successful in 50s
The 2026-07-26 document was a proposal marked "nothing built". Everything it
described as broken is now fixed, and the fix is not the one it proposed, so
leaving it in place would misdescribe the system to whoever reads it next.

Records what the code now does: settled as the single balance gate, settled and
trip_id as orthogonal axes, settling up by recording a payment rather than
flipping a flag, and the reasons duplicates are superseded rather than deleted.

Keeps the loan design intact and clearly marked as still a proposal -- it was
never built and nothing in this work touched it.

Also records that the proposal's own recommendation not to restate history from
the CSVs was overturned, and why it was wrong: it measured the value in
balances, where it is nil, and missed it in spend, where it is $35,259.
2026-07-28 12:06:56 +10:00
siddharthd b4a116c134 feat(scripts): import the matched split history as settled
ci / lint-test (push) Successful in 46s
Adds --write to the matcher. Wrote 1,242 split rows across 657 transactions.

Imported settled, and that is the whole design. These obligations were
discharged years ago on a platform we no longer run, and their residual is
already carried by transaction 2348. Writing them unsettled would re-open
roughly $40k of debts that were paid. ACTIVE_OBLIGATION keeps settled splits
out of every owed figure while myShare/mySplitOf still count them, which is
exactly the asymmetry this needs: the import exists to correct historical
SPEND, not to move a balance.

Effect: $35,259 leaves my historical spend -- $13,088 in 2024, $22,117 in
2025 -- because a $200 grocery shop that was always half hers no longer reads
as $200 of mine. Balances are byte-identical before and after (Molina
-1226.72/145, Sonu 5428.08/419), which is the assertion that matters.

Shares are written as the CSV computed them, so a 50/50 row can land as
50.01/49.99. That is faithful rather than tidy; no transaction exceeds 100%.

Rehearsed on the 37-row Rome file first (24 rows) and verified before the full
run -- both the balances and one split read back through the API.
2026-07-28 12:02:57 +10:00
siddharthd c9b000a428 feat(scripts): dry-run matcher for the SplitMyExpenses history
ci / lint-test (push) Successful in 48s
Matches the five CSV exports against transactions already in the ledger and
reports what it would do. Writes nothing -- importing is a separate step, and
rehearsing it first is what catches the defects that tests do not.

What it found, and why the number is what it is: 676 of 1,536 shareable rows
match (44%). The ceiling is ledger coverage, not matcher quality. The CSVs
describe 678 shared expenses in 2024 alone; the ledger holds 591 rows for the
whole of that year, 3 to 72 a month, which is far less than a household
actually spends. Most 2024 CSV rows have no transaction to attach a split to
and never will. South Korea April 2024 matches 4 of 158 for that reason.

Three decisions are encoded deliberately:

  - Date format is decided per FILE, not per row. The household export writes
    D/M/YYYY and the four trip exports write ISO, and 474 rows parse validly
    under both readings -- per-row guessing silently swaps January and February
    for some rows and not others.
  - A person's column is their net balance impact, not their share. The payer
    is whoever is positive; the other's share is |their negative| / cost. So a
    +cost/-cost row means the other party owes 100%, not that the expense was
    unshared -- the reading that would fake an arrangement change.
  - Matching is one-to-one, best pair first. The NZ trip has two identical
    $10.16 Uber rows against three ledger rows and four PayMyPark rows in the
    same shape; without this a ledger row is claimed repeatedly and the second
    CSV row looks matched while being unrepresented.
2026-07-28 12:00:18 +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 7a1acc32a9 feat(trips): say which direction a trip balance points
ci / lint-test (push) Successful in 45s
A participant who has overpaid a trip showed as "$-816.16" under a column
headed "Outstanding on this trip". A negative outstanding reads as a bug
rather than as "they are ahead", so the sign is now spelled out: magnitude
plus one of all square / owes you / ahead — you owe them, coloured the same
way Shared colours the same three states.

Also corrects the footer, which had gone stale and was now simply false. It
said settlement could not be computed per trip because payments carried no
trip attribution. Migration 0022 added split_payments.trip_id and the figures
above it have been net of trip-scoped payments since. What a reader needs to
know is the opposite of what it said: household-tab payments are the ones NOT
counted here.
2026-07-28 11:39:11 +10:00
siddharthd e92fcb709f docs(trips): say whose money the trip total counts
ci / lint-test (push) Successful in 48s
Total Spend is every payer's trip-tagged spending; the split figures directly
below it are scoped to the owner. Two lenses on one screen read as one unless
the card says which it is. The number is unchanged and deliberate -- a trip
cost what the group put into it -- so this is a label, not a fix.
2026-07-28 11:30:37 +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 ff0629462c fix(ci): generate the Prisma client before running tests
ci / lint-test (push) Successful in 53s
The pipeline has been red on every run since at least ae0c34f. npm ci
installs dependencies but the Prisma client is generated into
src/generated/prisma, which is gitignored — so a fresh CI checkout has no
client and anything importing src/lib/db.ts fails with 'Cannot find package
@/generated/prisma/client' before a single assertion runs.

prisma generate reads only the schema, so it needs no database and no
secrets.

Worth noting why this went unnoticed for a dozen commits: a pipeline that
is always red carries no signal, so it stopped being read.
2026-07-27 23:23:32 +10:00
siddharthd ae23b03d5d fix(trips): carry the currency and reconcile rules into the trip figure
ci / lint-test (pull_request) Failing after 43s
ci / lint-test (push) Failing after 43s
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.
2026-07-27 23:15:28 +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 a4ab543a6c fix(analytics): make the displayed numbers mean what they say
ci / lint-test (push) Failing after 44s
Six metric-integrity defects from the UI/IA review, plus two found while
verifying the review's own claims against the code.

The reconciled-row exclusion existed only in queries.ts. Every analytics
route counted the superseded manual rows as spend — 48 rows, $4,474.79 of
double count, invisible precisely because the transaction list looked
right. It is now one fragment both sides import.

The spend-pace chart computed its own totals in the browser: gross
amounts, debits only, no personal share, no refunds, fees, interest or
itemised loan repayments. On live data it ended July at $4,747.31 under a
headline reading $3,597.10 — and its own baseline line was drawn from the
split-adjusted monthly totals, so the two series in one chart disagreed
with each other. Both now come from /api/analytics/daily, built from the
same fragments as the headline.

Fees aggregated every statement ever imported with no date filter, under
a heading with no period, so a lifetime figure read as a current one and
grew forever. Now bounded, labelled, and selectable.

Comparisons no longer measure a month in progress against complete ones:
the in-progress month is out of every baseline, and a selected current
month is compared through the same day.

Two the review did not catch:

- Every analytics window was a day early. toISOString() on a
  local-midnight Date converts backwards through UTC. Surfaced only once
  fees started reporting the range it had used.
- /monthly rounded per category, /daily per category-day, so the pace
  chart ended a few cents off the headline above it.

Shared currency needed amending rather than applying. Reading s.currency
would have labelled every order row AUD, since an order receipt has no
statement and carries its own currency — the opposite convention from a
foreign charge on an AUD statement, where amount IS AUD. NATIVE_CURRENCY's
COALESCE order keeps the two apart. Balances also now count rows whose AUD
value is genuinely unknown instead of netting a foreign figure against AUD
ones. Latent today: no foreign transaction is currently split.

Tag-filtered balance cards no longer claim "owes you". With a filter on,
payments are deliberately not subtracted, so the figure is a split total
and settling against it would record a payment for a debt that never was.

Split-coverage warnings deliberately omitted (user decision).
2026-07-27 17:10:27 +10:00
siddharthd 5ee5ee24cf feat(orders): expand a row to see the receipt it came from
ci / lint-test (push) Failing after 44s
Enrichment is the point of the ingestion pipeline (DECISIONS ING-8) — a bank
statement gives a date, an amount and a mangled descriptor, and everything that
makes a transaction understandable arrives by email. It was all reachable only
by opening the edit modal, which is a strange place to look for "what was in
this order".

Rows with a receipt behind them get a disclosure arrow in the description cell;
clicking expands an inline panel with the line items, the pick-up and drop-off
stops, the card tail and the provider's reference. Several rows can be open at
once — the point is comparing orders without losing your place.

The arrow appears only where `order_platform` is set. Putting one on every
transaction would promise detail that mostly does not exist.

OrderDetails moves out of edit-transaction-modal.tsx into its own component so
both surfaces render the same thing; `bare` drops the modal's top border when
it sits in a table row.
2026-07-27 11:52:33 +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