ci / lint-test (push) Successful in 39s
Two things a future reader would otherwise have to reverse-engineer. The reconcile queue now excludes account-feed rows for a reason unrelated to payment method: a feed row is the account's own ledger entry, not a receipt awaiting a statement line, because the importer only covers accounts whose statements are deliberately not imported. Measured, the queue went from 8 to 558 without it. And the de-duplication rule needs its history attached, because the obvious version is wrong in a way that passes tests: keeping close-id rows as genuine repeats survived 29 tests and a clean dry run while still doubling three salary payments. In-scope duplicate pairs have id gaps from 58 to 260 million; real repeats sat at 1-4.
873 lines
48 KiB
Markdown
873 lines
48 KiB
Markdown
# CLAUDE.md
|
||
|
||
Guidance for Claude Code when working in this repository.
|
||
|
||
## Project Overview
|
||
|
||
Personal finance tracker. Bank statements are ingested via an N8N workflow (in the smarthome repo at `docker/automation/workflows/cc-statement-processor-paperless.json`) that sends PDFs to Gemini 2.5 Flash for extraction, then inserts into PostgreSQL.
|
||
|
||
- **App**: Next.js 16 App Router, TypeScript, Tailwind CSS
|
||
- **DB**: PostgreSQL container `postgres-personal`, database `personal`, user `personal`
|
||
- **Auth**: `X-Forwarded-User` header (email) set by Traefik → `participants.email`. In dev/fallback: participant id=1 ("Me")
|
||
- **Runs at**: port 3000 inside container, exposed on host port 4100, proxied at `https://finance.bosecamp.com`
|
||
|
||
## Work tracking — the board, not a markdown file
|
||
|
||
Outstanding work for this app lives on the Vikunja board at
|
||
`https://tasks.bosecamp.com` (project **Work**), which replaced the smarthome
|
||
repo's `ACTIONS.md` on 2026-07-30.
|
||
|
||
Find this app's work with the saved filter **finance-app**, or
|
||
`done = false && labels in <finance-app-label-id>`. Note that a ticket can carry
|
||
*several* system labels — the receipt→pantry work is labelled `finance-app`,
|
||
`pantry-app` **and** `email-ingestion` — so don't assume the finance filter shows
|
||
everything that will touch this codebase.
|
||
|
||
Epics that own most finance work: **Order & receipt ingestion (ING-7/8/10)**,
|
||
**Cashback tracking (ING-6)**, **Utility bills slice**, **Lane C — Ingestion
|
||
engine**, **Postgres estate & PG 14 EOL**.
|
||
|
||
**Update the ticket in the same change as the code.** The board is only worth
|
||
having if its status is true, and the previous system drifted precisely because
|
||
status lived somewhere nobody touched while shipping.
|
||
|
||
Token and API conventions: smarthome `CLAUDE.md` → "Work tracking". The token is
|
||
in smarthome `docker/utilities/.env`; this repo does not carry it.
|
||
|
||
**One deadline here is real:** `postgres-personal` runs **PostgreSQL 14, EOL 12
|
||
November 2026** — it holds `statements`, `transactions`, `orders` and
|
||
`expense_metadata`. Tracked in the Postgres epic, not here.
|
||
|
||
## Common Commands
|
||
|
||
**Deployment is push-to-deploy via Komodo** (since 2026-07-19): pushing to `main` on
|
||
Gitea triggers the `deploy-finance` Procedure, which runs DeployStack `--build` on the
|
||
`finance` stack (files_on_host over `docker/finance/` in the smarthome repo). Just
|
||
commit and push — no manual deploy needed.
|
||
|
||
```bash
|
||
# Manual fallback only (from smarthome repo root), e.g. if Komodo is down
|
||
docker compose --env-file docker/common.env --env-file docker/finance/.env \
|
||
-f docker/finance/docker-compose.yml up -d --build
|
||
|
||
# IMPORTANT: docker restart does NOT pick up a new image — push to main (or use the compose command above)
|
||
|
||
# DB access
|
||
docker exec postgres-personal psql -U personal -d personal
|
||
|
||
# View logs
|
||
docker logs finance -f
|
||
```
|
||
|
||
## Architecture
|
||
|
||
### Key Files
|
||
|
||
| File | Purpose |
|
||
|------|---------|
|
||
| `src/lib/db.ts` | `queryRaw<T>()` — the only DB query function; uses `pg` directly |
|
||
| `src/lib/queries.ts` | All SQL query functions (no ORM); import `queryRaw` from `@/lib/db` |
|
||
| `src/lib/hooks.ts` | TanStack Query hooks for all API calls |
|
||
| `src/lib/auth.ts` | `getCurrentUser()` — reads `X-Forwarded-User` header |
|
||
| `src/lib/categories.ts` | Canonical category list (`CATEGORIES` array + `formatCategory()`) |
|
||
| `src/app/api/*/route.ts` | API route handlers |
|
||
| `src/components/` | Shared UI components |
|
||
|
||
### Data Flow
|
||
|
||
- All queries in `src/lib/queries.ts` use raw SQL via `queryRaw` from `src/lib/db.ts`
|
||
- API routes call query functions and return `NextResponse.json()`
|
||
- Frontend uses hooks from `src/lib/hooks.ts` (TanStack Query) — never fetches directly
|
||
- Auth is always checked first in every API route: `const user = await getCurrentUser(req)`
|
||
|
||
### Owner Scoping
|
||
|
||
All data is scoped by `owner_id`. The effective owner of a transaction is:
|
||
```sql
|
||
COALESCE(t.owner_id, s.owner_id)
|
||
```
|
||
- Statement-linked transactions: owner comes from `statements.owner_id`
|
||
- Manual transactions: `statement_id IS NULL`, owner stored directly in `transactions.owner_id`
|
||
|
||
The effective merchant and category always prefer overrides:
|
||
```sql
|
||
COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name) -- merchant
|
||
COALESCE(o.category_override, t.category) -- category
|
||
```
|
||
|
||
### Hiding categories in the transactions view
|
||
|
||
`getTransactions` takes `exclude_categories`. It is **opt-in per caller and
|
||
never defaulted in `queries.ts`** — `GET /api/rules/[id]/matches` and
|
||
`POST /api/rules/apply` both read their candidate rows through `getTransactions`,
|
||
so a default exclusion there would silently shrink what a rule can preview and
|
||
reach. Only the transactions page sets it.
|
||
|
||
The transactions view defaults it to `["transfers"]` (433 of 3,996 rows, ~11%),
|
||
with a visible "Hide transfers" checkbox. Two rules the implementation depends
|
||
on, both tested:
|
||
|
||
- **An explicit category pick beats the exclusion.** Selecting "Transfers" while
|
||
the default is on subtracts it from the hidden list rather than returning zero
|
||
rows — otherwise the view reads "you have no transfers".
|
||
- **`COALESCE(..., '')` before `<> ALL`.** `NULL <> ALL(...)` is NULL, not true,
|
||
so an uncategorised row would vanish from a filter that never named its
|
||
category. Same trap `EXCLUDE_NON_SPEND` documents.
|
||
|
||
It defaults **off** when the view is scoped to a statement (`?statement_id=`).
|
||
That is a reconciliation view — the row count has to match the statement, and a
|
||
credit-card payment is exactly the row you went there to check.
|
||
|
||
## Database
|
||
|
||
```bash
|
||
# Schema inspection
|
||
docker exec postgres-personal psql -U personal -d personal -c "\d transactions"
|
||
|
||
# Apply a migration SQL file
|
||
docker exec postgres-personal psql -U personal -d personal < prisma/migrations/<name>/migration.sql
|
||
```
|
||
|
||
### Key Tables
|
||
|
||
- `statements` — one row per billing period per bank account
|
||
- `transactions` — line items; `statement_id` is nullable (NULL = manual entry); `reconciled_with_id` links a manual tx to its matched statement tx; `payment_method` (migration 0016) is `card | cash | bank_transfer | other`, NULL = unknown; `source` / `source_ref` / `source_account` (migration 0028) identify a row that came from an account feed rather than a statement or a hand entry — `source_ref` is the provider's own id and carries a partial unique index, which is the only thing making a re-import idempotent
|
||
|
||
### Cash and reconciliation
|
||
|
||
`payment_method = 'cash'` excludes a transaction from reconciliation via the
|
||
`notCash()` fragment in `queries.ts`. Cash never appears on a statement, so
|
||
without it a cash entry sits in the pending queue forever being offered matches
|
||
within 3 days and 1% on amount — and accepting one is silently destructive:
|
||
reconciled manual rows are filtered out of every query, so the cash spend
|
||
disappears while the card transaction it matched claims to be that same spend.
|
||
|
||
Only cash is excluded on payment method. Bank transfers *do* appear on a
|
||
statement now that transaction accounts are imported, and NULL means unknown —
|
||
both stay candidates, preserving the behaviour of every pre-existing row.
|
||
|
||
**Account-feed rows are excluded separately, and for a different reason**
|
||
(`awaitsStatementLine()`, added with the Frollo importer). A feed row is the
|
||
account's own ledger entry, not a receipt awaiting a statement line: the importer
|
||
only covers accounts whose statements are deliberately *not* imported. Without
|
||
the exclusion those ~600 rows a year sit in the queue forever and bury the
|
||
receipts that genuinely need a decision — measured, the queue went from 8 to 558.
|
||
It is scoped to a named list (`ACCOUNT_FEED_SOURCES`) rather than to
|
||
`source IS NOT NULL`, so a future source that *does* await a statement is not
|
||
swept up by it.
|
||
|
||
ATM withdrawals stay categorised as spend rather than `transfers`. Treating them
|
||
as transfers only works if every cash purchase is logged; with partial logging
|
||
it silently deletes the unlogged remainder from spend totals.
|
||
- `transaction_overrides` — user corrections to AI-extracted data (category, merchant, notes)
|
||
- `transaction_splits` — shared expense tracking (participant, share_percent, settled)
|
||
- `split_payments` — recorded cash settlements between participants
|
||
- `transaction_tags` — many-to-many join to `tags`
|
||
- `rules` — auto-categorisation rules (JSONB conditions + actions)
|
||
- `rule_apply_runs` — audit log of bulk rule-apply runs with full snapshot for revert
|
||
- `expense_metadata` — enrichment from email receipts; `transaction_id` nullable until reconciled
|
||
- `participants` — people; `id=1` is "Me" (the primary user)
|
||
- `account_owner_mappings` — persists bank+account → owner assignments
|
||
|
||
### Shared expenses and settlement — read before touching
|
||
|
||
Rebuilt 2026-07-28. `docs/shared-expenses-design.md` describes the live model;
|
||
it is no longer a proposal. Everything the old version of this section warned
|
||
about has changed — if you are working from memory of it, re-read.
|
||
|
||
**The cutover date is the primary balance gate, not the `settled` flag.**
|
||
`ACTIVE_OBLIGATION` (`src/lib/analytics-sql.ts`) is
|
||
`ts.settled = false AND t.transaction_date >= '2026-01-09'`. Nothing dated
|
||
before the cutover can ever be owed, because carryover transaction **2348**
|
||
(dated 2026-01-09, $1,093.22) already carries the whole pre-cutover balance as
|
||
one figure. The bound is **inclusive** — 2348 is itself dated 2026-01-09, so an
|
||
exclusive bound would drop the carryover and the entire pre-cutover balance.
|
||
|
||
Consequence: **pre-2026 transactions can be split freely.** A split on a 2024
|
||
grocery shop describes how the expense was shared — which is what stops it
|
||
inflating spend — without asserting a debt. 657 pre-2026 transactions carry
|
||
1,266 such splits, imported from SplitMyExpenses and marked `settled`.
|
||
|
||
**Spend counts settled splits; owed does not.** `myShare`/`mySplitOf` must NOT
|
||
filter on `settled` — half a 2025 grocery shop was your expense whether or not
|
||
the other half was repaid. Filtering it out re-inflates exactly the figures the
|
||
historical import exists to correct.
|
||
|
||
**Every split totals 100%, and the payer's row is written down.** `myShare`
|
||
resolves the payer's share as `100 - SUM(everyone else)`, so a 50/50 stored as
|
||
a lone "Sonu 50%" row still computed correctly — and still read on screen as a
|
||
50% share against a blank. `completeSplit` (`src/lib/splits.ts`) is the single
|
||
place that materialises the remainder, and every write path ends in it:
|
||
`applyRuleActions`, `POST /api/transactions`, the Slack nudge's share button,
|
||
and the rule-revert restore. `POST /api/transactions/[id]/splits` needs no call
|
||
— it already rejects anything not summing to 100.
|
||
|
||
The remainder always goes to the transaction's **owner**, never to "me". The
|
||
owner's row on their own transaction is excluded from both halves of
|
||
`getParticipantBalances` (`ts.participant_id != $1` on transactions I own; the
|
||
converse on ones I do not), so writing it cannot create, enlarge or discharge a
|
||
debt. A row for *me* on someone else's transaction is a real obligation — never
|
||
synthesise one. This is what made the 7-row backfill in `22e4a1e` safe;
|
||
balances were byte-identical across it.
|
||
|
||
There is no database-level constraint on the sum. Enforcing it needs a deferred
|
||
constraint trigger, and the rule path commits its DELETE and INSERT as separate
|
||
autocommitted statements, so the trigger would reject the intermediate state.
|
||
|
||
`share_percent` has a CHECK of `> 0 AND <= 100`, so a 0% row cannot be stored —
|
||
when the others grow to cover the whole amount, the owner's row is deleted
|
||
rather than zeroed.
|
||
|
||
**Un-sharing needs DELETE, not an empty POST.** The splits route rejects an
|
||
empty array ("splits array required"), so `DELETE /api/transactions/[id]/splits`
|
||
is the only way to clear. The order panel's "Shared 50/50" toggle was inert in
|
||
both directions until `22e4a1e` because it posted a lone 50% row to share and
|
||
`[]` to un-share, and the endpoint rejected both.
|
||
|
||
**Any split write path that deletes-and-recreates must carry `settled` across.**
|
||
`POST /api/transactions/[id]/splits` did not, and silently converted discharged
|
||
obligations into live debt — $37,233.28 was exposed. Fixed in `6add958`.
|
||
`rule-actions.ts` is safe only by the shape of its upsert
|
||
(`ON CONFLICT DO UPDATE SET share_percent` never touches the flag).
|
||
The rules-apply revert route restores it explicitly.
|
||
|
||
**Settling up is recording a payment.** There is deliberately no "mark settled"
|
||
action. `settled` marks obligations discharged *outside* this app; doing both
|
||
would subtract the settlement twice.
|
||
|
||
**Payments carry scope, and one transfer can carry several rows.**
|
||
`split_payments.trip_id` (migration 0022) says which tab a payment settles;
|
||
NULL is the ongoing household tab. There is no unique constraint on
|
||
`linked_transaction_id`, so a grouped transfer is recorded as one row per scope
|
||
that re-add to the transfer — that is how Sonu's $3,779.33 and $4,794.06 were
|
||
allocated Europe-first with the remainder to household.
|
||
|
||
**Trip owed must be owner-scoped; trip cost must not be.** The owed query
|
||
applies `OWNER_SCOPE`; without it a debt between the *other two* participants
|
||
reads as owed to you ($1,605.49 on Europe 2026). Trip *cost* deliberately counts
|
||
every payer — a trip cost what the group put into it — which is why the stat card
|
||
says "all payers, net of refunds". Do not "fix" the missing scoping there.
|
||
|
||
**Duplicates are superseded, never deleted.** `transactions.superseded_by_id`
|
||
(migration 0023); 31 rows / $42,040.68 from overlapping ANZ statements 107/142/143.
|
||
Every child of `transactions` is `ON DELETE CASCADE`. The exclusion lives *inside*
|
||
`EXCLUDE_RECONCILED_SOURCE`, so any query applying that fragment gets it free —
|
||
and any query that does not still double-counts.
|
||
|
||
**Refunds:** a *partial* refund is netted in SQL (`NET_SPEND_ROWS`/`SPEND_SIGNED`);
|
||
a *cancelled* booking has both legs untagged from the trip by hand, because a
|
||
trip never incurred a cost it cancelled.
|
||
|
||
**Trips:** Europe 2026 (id 1, 19 Mar–12 Apr), Auckland 2026 (id 2),
|
||
Europe — Sonu + Sunny (id 3, 12–28 Apr, created 2026-07-28 from tag 5),
|
||
Singapore + Bangkok 2026 (id 4).
|
||
|
||
### Why `travel` looked useless on a trip page, and the fix (2026-08-02)
|
||
|
||
`travel` was ~60% of every trip and told you nothing. The tempting fix — a finer
|
||
travel taxonomy (flights / stays / getting around) — needs a hand-maintained
|
||
merchant list, which is the trap ticket #19 already describes. It is also the
|
||
wrong diagnosis.
|
||
|
||
**Measured on Europe 2026, `travel` is the only category that spans both phases
|
||
of a trip. Every other category is 100% on-the-ground — dining, transport,
|
||
entertainment, groceries and shopping are all exactly $0.00 before departure.**
|
||
So the category chart was not a bad chart; it was two different economies stacked
|
||
into one, and travel was the only thing visible in the union.
|
||
|
||
The fix is to split on `trips.start_date` and use the axis that carries
|
||
information in each phase:
|
||
|
||
- **Booked ahead** (before `start_date`, **$21,229.56 / 56%** on Europe, 30
|
||
bookings) — everything is a flight, a stay or a rail ticket, so category is a
|
||
constant and **merchant** is the axis: Agoda $4,491, Air India $3,454, Azwebin
|
||
$2,696, Luxury Escapes $2,463.
|
||
- **On the ground** (on/after `start_date`, **$16,946.77**, 180 charges) — travel
|
||
falls to $8,241 among dining $4,452, transport $2,938, entertainment $698,
|
||
groceries $589, shopping $29. **Category is finally worth charting.**
|
||
|
||
These are the **net** figures the page shows, and they are lower than a raw
|
||
`SUM(amount)` by design: the phase queries apply `NET_SPEND_ROWS` / `SPEND_SIGNED`
|
||
and `EXCLUDE_RECONCILED_SOURCE`, the same fragments as every other analytic. Raw
|
||
sums give $22,050.51 committed and Luxury Escapes at $3,283.80 — the $820.95 gap
|
||
is a partial refund on that booking. Do not "fix" the difference; a partly
|
||
refunded booking must not read at full price.
|
||
|
||
`getTripAnalytics` returns `phases`, `committed_merchants`, `on_ground_categories`
|
||
and `on_ground_daily`. A trip with a NULL `start_date` has no knowable departure,
|
||
so the SQL folds everything into on-ground rather than reporting it as committed.
|
||
|
||
**The daily rate is the only figure comparable between trips**, because totals are
|
||
not — trips differ in length. Europe $677.88/day, Sonu + Sunny $573.57, Singapore
|
||
+ Bangkok $309.76, Auckland $83.39. The page ranks the current trip against the
|
||
others from the already-loaded `useTrips()` list.
|
||
|
||
**A trip with near-zero committed spend is a filing artefact, not a cheap trip.**
|
||
Europe — Sonu + Sunny shows $184.84 committed against Europe 2026's $22,050.51
|
||
because both legs' flights and stays were filed on the first trip. The page says
|
||
so rather than letting the ratio read as missing data.
|
||
|
||
Two chart rules this page now follows, both from the `dataviz` skill and both
|
||
previously broken here:
|
||
|
||
- **One series → one colour.** The category bars use a single copper hue with the
|
||
category as a direct label. `CATEGORY_COLORS` was a per-bar rainbow, which
|
||
double-encodes identity the label already carries — and the trip subset **fails**
|
||
CVD validation on this surface (`other` ↔ `shopping` ΔE 5.0 protan, below the
|
||
floor of 6). Do not reintroduce per-category colour on a labelled bar chart.
|
||
- **No serif and no `tabular-nums` on the hero figure.** Fraunces is for section
|
||
headings; a display face on a large number reads as decoration, and equal-width
|
||
digits make it look loose.
|
||
|
||
The phase split bar is two ordinal steps of one hue (`#7c4820` → `#d28a47`),
|
||
validated with `--ordinal` against the `#171410` card surface, with a 2px gap so
|
||
the boundary is an edge rather than a colour change. Both segments are
|
||
direct-labelled, so it needs no legend.
|
||
|
||
### Trip participation is derived, and a trip is shared
|
||
|
||
Rebuilt 2026-08-02. Trips were scoped to `trips.owner_id`, so Sonu saw **no
|
||
trips at all** despite paying for 104 of the tagged rows herself — her own
|
||
spending was invisible on the only page organised around it.
|
||
|
||
**A participant is anyone with a split on, who paid for, or whose payment is
|
||
scoped to, a transaction tagged to the trip.** Derived (`TRIP_PARTICIPANT` in
|
||
`queries.ts`), never stored. A membership table was designed and rejected: it
|
||
would be a second record of a fact the expenses already carry, and two records
|
||
of one fact drift — the same reason sharing is a real split rather than a flag.
|
||
The derivation also gets the exclusions right for free, which a table has to be
|
||
kept in sync to do: Singapore + Bangkok 2026 has no Sonu split and no Sonu
|
||
payment, so she is not a participant and never sees it. Live result is
|
||
Siddharth 4 trips, Sonu 3, Molina 1.
|
||
|
||
**Everything is shared except delete.** Read, edit and assign are open to any
|
||
participant. `deleteTrip` stays `owner_id`-only because both trip foreign keys
|
||
are `ON DELETE SET NULL`, so deleting Europe 2026 untags 210 transactions *and*
|
||
NULLs the trip scope on 6 payments — which is where the hand-derived
|
||
Europe-first allocation lives, and nothing recomputes it. The route returns 403
|
||
with the reason rather than a 404 that pretends the trip is missing.
|
||
|
||
**`getTransactions` gained `trip_all_rows`, and it is opt-in for a reason.** A
|
||
participant sees every row on a trip, not only their own — the trip total
|
||
already counts every payer. It must NOT be implied by `trip_id` being present:
|
||
`GET /api/transactions` is also the main transactions list, and its trip filter
|
||
has to keep owner scoping or filtering your own ledger by "Europe 2026" would
|
||
quietly fill it with someone else's rows. Participation is re-checked in SQL, so
|
||
passing the flag for a trip you are not on returns nothing rather than
|
||
everything. Only `trips/[id]/page.tsx` sets it.
|
||
|
||
**Trip owed is pairwise and returns BOTH directions, never netted.** `owed` is
|
||
unchanged — their share of rows *the viewer* paid. `i_owe` is the mirror: the
|
||
viewer's share of rows *that participant* paid. Rendering the pair from the
|
||
viewer's side is the whole fix; an obligation lives on a row someone else paid
|
||
for, so a viewer-as-payer figure can never contain it, and Sonu's Europe 2026
|
||
read "you are owed $2,408.24" while omitting the $8,004.04 she owed.
|
||
|
||
**The API returns both halves whole; the trip page nets them for display.** One
|
||
figure per person, with the breakdown beside it, because a net nobody can
|
||
decompose is how a wrong figure survives.
|
||
|
||
**A NEGATIVE trip net is not a bill — this is the trap, and it was got wrong
|
||
twice.** A payment is allocated to a scope as a lump sum, and the grouped-payment
|
||
allocation gave each trip enough to clear the payer's **gross** share. So netting
|
||
the other side off leaves a fully-paid trip negative by exactly what the payment
|
||
over-covered: Europe reads **−$802.75** because Sonu paid $8,004.04 against a net
|
||
share of $7,201.30. That surplus is already carried in the overall balance —
|
||
**she still owes $5,313.38 overall** — so labelling it "you owe them" was flatly
|
||
wrong. Scope nets sum to the overall figure; a negative simply means this scope
|
||
was over-covered and the excess sits in another.
|
||
|
||
The discriminator is `paid_to_me`:
|
||
|
||
- negative **with** a payment into the scope → over-covered, nothing to pay
|
||
- negative **with no** payment → genuinely owed, because the viewer's share of
|
||
the other person's spending exceeds theirs
|
||
|
||
All three of today's negatives are the first kind (Europe Sonu −$802.75,
|
||
Sonu + Sunny −$936.34, Europe Molina −$816.16). Both cases are tested. The trip
|
||
table therefore carries an **Overall balance** column from the unscoped
|
||
`getParticipantBalances` — the trip figure alone cannot tell you whether to pay
|
||
anyone, and **settlement is always against the overall figure, never one trip.**
|
||
|
||
**On whether to net — the reasoning reversed once, and the second answer is the
|
||
right one.** The first objection was that the grouped-payment allocation (memory
|
||
case `allocate_grouped_payments`) cleared each trip against the *one-directional*
|
||
gross, Europe first with the remainder to household, so netting would redefine
|
||
that debt after the fact. Checking the underlying rows overturned it: Europe's
|
||
$802.75 is **56 real transactions Sonu paid** across Rome, Venice, the Dolomites,
|
||
Bellagio, Lucerne and Paris on which Siddharth holds 25% — and `paid_by_me` is
|
||
**$0.00 on every row of every trip**, because nothing has ever been recorded
|
||
going from him to her. The one-directional view was concealing a live obligation,
|
||
not protecting an allocation. Her side was paid in full and looked settled only
|
||
because the allocation derived her payment split *from* her gross, so it lands on
|
||
zero by construction.
|
||
|
||
Current nets: Auckland Sonu **+$1,077.25** (1,505.64 − 428.39), Europe Sonu
|
||
**−$802.75**, Sonu + Sunny **−$936.34**, Europe Molina **−$816.16** — the three
|
||
negatives all being over-coverage, per the rule above. The `owed` column itself
|
||
was verified byte-identical when the mirror was added — $1,505.64, −$816.16,
|
||
$0.00, $0.00.
|
||
|
||
**A payment left on the household tab makes a settled trip debt read as
|
||
outstanding.** Payment 5 (Molina → Sonu, $1,605.49) discharged the Europe debt
|
||
between those two but carries `trip_id IS NULL`, so a trip-scoped net cannot see
|
||
it and Europe still shows it owing. That is the cost of scope being optional, and
|
||
the reason the Record Payment modal now asks. Fixable per row with
|
||
`UPDATE split_payments SET trip_id = 1 WHERE id = 5` — not done, it is a data
|
||
decision.
|
||
|
||
### The Shared view shows payer and category, and is searchable (2026-08-02)
|
||
|
||
`getSharedTransactions` already returned `owner_name` and `effective_category`;
|
||
the table simply never rendered them. **Paid by** sits next to **Splits**
|
||
deliberately — together they are the two halves of the question the page exists
|
||
to answer, whose money went out and whose share it was. It shows the *effective*
|
||
owner (`COALESCE(t.owner_id, s.owner_id)`), which is the account the spend left,
|
||
and the same figure every balance on the page is computed from. Category uses the
|
||
override-first COALESCE, so a correction made anywhere shows here.
|
||
|
||
Search is **client-side**, unlike the transactions page. This endpoint returns
|
||
every split row in one request (1,267 today) with no pagination, so there is
|
||
nothing for a server round-trip to narrow, and the sort was already client-side.
|
||
It matches description, merchant, notes, category and payer — deliberately **not**
|
||
participant names, because the participant dropdown already does that and typing
|
||
"sonu" matching every row she is split on would read as broken.
|
||
|
||
### Payment scope reaches the API (2026-08-02)
|
||
|
||
`split_payments.trip_id` has existed since migration 0022, but `POST
|
||
/api/split-payments` never read it and `GET` never returned it — so **every
|
||
payment recorded through the app landed on the household tab**, and the 9
|
||
trip-scoped rows had to be written by hand in SQL. A $11k Europe settlement was
|
||
silently reducing the ongoing household balance.
|
||
|
||
Both fixed. The modal has a "Settles" selector (Household or a trip) and history
|
||
shows each payment's scope as a chip. **"Both" needs no new shape:** one
|
||
transfer becomes one row per scope sharing a `linked_transaction_id`, which is
|
||
why there is deliberately no unique constraint on it — tx 4121's $4,794.06 sits
|
||
as $1,145.52 against Europe — Sonu + Sunny and $3,648.54 against household, and
|
||
tx 4111's $3,779.33 spans two trips. All six linked transfers reconcile to the
|
||
cent.
|
||
|
||
### Three write paths that had no authorisation
|
||
|
||
All closed 2026-08-02. Each was reachable by any authenticated participant:
|
||
|
||
- **`assignTransactionsToTrip`** took no caller and checked nothing, so
|
||
`PATCH /api/trips/[id]/transactions` and `POST /api/transactions/bulk`
|
||
(`assign_trip`) let anyone move any transaction id into any trip id. Not being
|
||
able to *see* a trip was no obstacle, because the write path never read one.
|
||
Now: only rows the caller can already see move, and a non-null destination must
|
||
be a trip they participate in — enforced in the query, not the route, so
|
||
neither caller can bypass it. Returns the count actually moved.
|
||
- **`DELETE /api/split-payments?id=`** deleted by id with no check at all. Erasing
|
||
a settlement silently resurrects a discharged debt — the same class of damage
|
||
as the split rewrite that reset `settled`. Now limited to the two people the
|
||
payment is between.
|
||
- **`POST /api/split-payments`** accepted any `from`/`to` pair. Now the payment
|
||
must involve the caller, and a trip scope must be a trip they are on.
|
||
|
||
**Partial split coverage inside a category is usually correct, not a gap.** Only
|
||
*shared* items are split. `utilities` sits at 69% yours because Globird, OVO, GWW
|
||
and home telecoms are split while Telstra, Vodafone, Optus and JB Hi-Fi Mobile
|
||
are personal. `subscriptions` is 91% because Uber One, Amazon Prime and OnePass
|
||
are shared while Claude, OpenAI, Anthropic, OpenRouter, You.com, LinkedIn, Xero,
|
||
Billdu, Spotify and Patreon are not. `fees` and `charity` are 100% yours and
|
||
correct. Check the merchants before concluding a rule was never applied — a
|
||
category-level ratio that "looks wrong" usually is not.
|
||
|
||
**Still true, and still a caveat:** Sonu's loan contributions (`…emi` in the
|
||
offset account, 39 rows, $37,980.24) are categorised `transfers`, indistinguishable
|
||
from ordinary internal transfers. The loan model below is unbuilt.
|
||
|
||
### Order verdicts — "never order from here again"
|
||
|
||
Built 2026-07-28 (migrations 0024, 0025). `order_reviews` was previously a table
|
||
wired to nothing; it now backs `GET`/`PUT /api/transactions/[id]/review`, the UI
|
||
in `components/order-details.tsx`, and the Slack card in
|
||
`lib/slack-blocks.ts` + `app/api/slack/interactive/route.ts`. Logic in
|
||
`lib/order-reviews.ts`.
|
||
|
||
Five things about the shape, each load-bearing:
|
||
|
||
- **Per person, not per order.** `UNIQUE (transaction_id, participant_id)`. A
|
||
shared meal produces two opinions that routinely disagree, and the
|
||
disagreement is the useful part. `PUT` defaults to the **signed-in user**,
|
||
not the owner — Sonu authenticates through the same Traefik OAuth as
|
||
participant 4, so an owner default would file her verdict under his name.
|
||
- **Five levels** — `loved`, `liked`, `ok`, `bad`, `never`. Three collapsed the
|
||
distinction that decides a re-order; `bad` was added because the jump from
|
||
`ok` to `never again` is too big and most disappointments live in the gap.
|
||
- **Only `never` sets `warn`.** A blacklist that fires for every mediocre meal
|
||
is one nobody reads. `bad` and `never` both set `order_again = false` — you
|
||
would not choose either — but only `never` raises the alarm on a future
|
||
order. "Would I order it" and "warn me about it" are different questions.
|
||
- **Item verdicts key on the item DESCRIPTION**, not its index — an index is
|
||
meaningless across orders, and "the Pad Thai here is good" has to survive
|
||
into the next order from the same merchant. Pooled case-folded across the
|
||
merchant's orders. Only `loved`/`never`: a per-item "ok" answers neither
|
||
question you ask at order time.
|
||
- **An ABSENT `item_verdicts` means "leave them alone"; `[]` clears them.**
|
||
Without that distinction a note-only save 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.
|
||
|
||
**The merchant is the restaurant, not the courier.** `orderDescription` does not
|
||
append the platform — that was added on request and reversed on 2026-07-28,
|
||
because it fragmented the merchant and the platform already renders in the Order
|
||
details panel. 81 descriptions were backfilled. `merchantVerdict` joins
|
||
**case-folded**: the platforms capitalise differently (`TEG Kebabs & Biryani` vs
|
||
`TEG KEBABS & BIRYANI`) and an exact match kept two separate histories, so a
|
||
"never again" through one app never warned in the other.
|
||
|
||
The merchant signal is *derived* by aggregating on
|
||
`expense_metadata.merchant_normalized` — never `transactions.merchant_name`,
|
||
which is a bank descriptor.
|
||
|
||
**Sharing is a real 50/50 split, not a flag** — the split already IS the record,
|
||
and two records of one fact drift apart. The toggle **refuses** when a
|
||
participant outside {1, 4} is present or the second consumer's share is not 50:
|
||
splits are made by hand here, so a third party or an uneven share is deliberate
|
||
and one tap must not flatten it. It says which case it refused on.
|
||
|
||
`/api/orders/ingest` returns `prior_verdict` (so the nudge can warn inline) and
|
||
`slack_blocks` (so Block Kit stays in tested code rather than n8n expressions).
|
||
|
||
### Slack cards — two rules that cost real data
|
||
|
||
1. **Update via `response_url`, never the HTTP response body.** Block Kit
|
||
interactivity ignores the response body; replacing a message that way is
|
||
legacy attachment-style behaviour. Getting this wrong meant every press wrote
|
||
correctly and left the card stale, so a working button looked dead, got
|
||
pressed again, and toggled itself back — three splits were lost before
|
||
`conversations.history` showing `edited: false` settled it. `response_url`
|
||
needs no bot token, so the app posts it directly.
|
||
2. **Never hand-write a card.** A card built with a guessed `shared: false`
|
||
mislabels an already-shared order and the button then deletes the split.
|
||
Render by calling the interactive endpoint with a no-op verb (`<id>:noop`):
|
||
it writes nothing and returns blocks built from live state.
|
||
|
||
Slack reaches this route through an n8n webhook, not directly — see the
|
||
smarthome repo's CLAUDE.md and the `slack-interactive-via-n8n` memory.
|
||
|
||
### The shared loan
|
||
|
||
The loan is a **separate ledger**, not a shared expense and not a settlement
|
||
context — a contribution must never be able to settle a dinner. Sonu's obligation
|
||
is a fixed 50% of the repayment; actual contributions vary, and the difference is
|
||
a tracked receivable ($4,000.00 over 2025-07 → 2026-06).
|
||
|
||
Do not derive the share from actual payments. During her leave the obligation did
|
||
not change, only the payment did — a percentage-of-actual model would silently
|
||
redefine her share as 30% and make the shortfall vanish.
|
||
|
||
Loan interest reconciles exactly: `repayments − interest − fees = balance
|
||
reduction`. It stays categorised `loan_interest` and counts as spend — over 12
|
||
months $63,500 of cash left and debt fell $44,127.36, and the $16,523.64
|
||
difference bought nothing. Excluding it would leave the balance sheet unable to
|
||
reconcile cash out against equity gained.
|
||
|
||
**The repayment is voluntarily above contracted, and the gap is the largest
|
||
flexible cost in the whole picture.** Contracted is $1,190.54/fortnight
|
||
($2,579.50/mo annualised); the actual direct debit is $2,500.00/fortnight
|
||
($5,416.67/mo). That is $2,837.17/mo of overpayment, and it is *not* sunk — it
|
||
shows up as `statements.redraw_available`, which grew $62,387.17 → $81,017.42
|
||
across the two most recent loan statements. Sonu returned to $1,250/fortnight in
|
||
July 2026 after the reduced $750 period during her leave.
|
||
|
||
Treat the repayment as two figures whenever asking "what does this cost me":
|
||
the contracted floor and the actual. `scheduled_repayment` holds the actual
|
||
($2,500), not the contracted minimum — the contracted figure is not in the DB at
|
||
all. See `docs/expense-baseline.md`.
|
||
|
||
### Import Date (`created_at`)
|
||
|
||
`transactions.created_at` is the import timestamp (DB default `now()`). In the transactions and shared views, the "Imported" column shows:
|
||
- For statement transactions: when the statement was processed by N8N
|
||
- For reconciled transactions: the `created_at` of the original manual/CSV transaction (via `LEFT JOIN transactions src ON src.reconciled_with_id = t.id`) — so the original import date is preserved post-reconciliation
|
||
|
||
Use `created_at` (not `transaction_date`) to answer "what was added since the last settlement?". Sort by `created_at` is supported server-side in `getTransactions` and client-side in the shared view.
|
||
|
||
### Rules System
|
||
|
||
Conditions are AND-evaluated. Fields: `merchant_normalized`, `description`, `category`, `bank_name`, `amount`, `transaction_type`. Operators: `contains`, `equals`, `starts_with`, `gt`, `lt`, `not_equals`. Actions: `set_category`, `set_merchant`, `add_tag_ids`, `apply_split`.
|
||
|
||
`contains` and `equals` operators are case-insensitive (both sides `.toLowerCase()`).
|
||
|
||
**A rule with zero conditions matches every transaction.** Both apply paths use
|
||
`conditions.length === 0 || conditions.every(...)`. Rule 43 "Home 50/50 Sonu" has
|
||
no conditions and a 50/50 split action — applying it blindly would split all
|
||
~3,700 transactions with another participant. That is what `manual_only` is for:
|
||
those rules are excluded from bulk runs and fire from the transactions page
|
||
against a hand-picked selection.
|
||
|
||
### Previewing a rule before applying it
|
||
|
||
`GET /api/rules/[id]/matches` is a dry run — it writes nothing and returns only
|
||
the transactions a rule would actually *change*, with already-correct rows
|
||
summarised as a count. The Preview button on the rules page uses it.
|
||
|
||
Apply then goes through `POST /api/transactions/bulk` with `action: "apply_rule"`
|
||
and **explicit transaction ids**, not the conditions. That is the safety
|
||
property: a rule whose conditions are too broad cannot reach further than what
|
||
the preview showed and the user ticked.
|
||
|
||
Prefer this over auto-applying rules on ingestion. It fails safe, works
|
||
retroactively, and tells you which rules are consistent enough to automate later.
|
||
|
||
### Rule apply history
|
||
|
||
`rule_apply_runs` snapshots the before-state so a run can be reverted, and since
|
||
migration 0017 also records `rule_id`, `rule_name` and `source`
|
||
(`all` | `rule` | `selection`). `rule_name` is denormalised deliberately and
|
||
there is no FK to `rules` — history must stay readable after a rule is renamed or
|
||
deleted, and deleting a rule must not cascade away the audit trail.
|
||
|
||
`GET /api/rules/runs/[id]` diffs that snapshot against current values. Rows
|
||
changed by something else since the run are flagged, because reverting restores
|
||
the pre-run value and discards the later edit.
|
||
|
||
### Frollo account feed (`source = 'frollo'`)
|
||
|
||
Covers the accounts whose statements arrive every 182 to 460 days — AMP including
|
||
the loan, ANZ Access, Wise including the income account, Up, ING, the small
|
||
transaction accounts. **Credit cards are excluded on purpose**: their monthly
|
||
statements already cover them and are authoritative, and a second producer for
|
||
the same rows would only create reconciliation work.
|
||
|
||
- Logic: `src/lib/frollo-csv.ts` (pure, parsing + scoping + de-duplication) and
|
||
`src/lib/frollo-ingest.ts` (report + insert). 42 unit tests.
|
||
- Two callers share that module so they cannot drift: `scripts/import-frollo.mts`
|
||
(dry run by default) and `POST /api/frollo/ingest` (token auth, called daily by
|
||
the n8n `Frollo Import` workflow). The DB is reached through an injected
|
||
executor because one runs under Prisma and the other under a bare `pg` client.
|
||
- **Export with pending EXCLUDED.** A pending row changes both its id *and* its
|
||
description when it settles, so importing one guarantees a duplicate next run.
|
||
|
||
**The one thing to understand before touching this: a CDR re-consent makes Frollo
|
||
re-ingest an account's whole history under fresh transaction ids while the
|
||
originals survive**, and consents expire annually. On one export 385 of 1,989 rows
|
||
were such twins and every salary payment appeared twice — a blind import doubles
|
||
reported income. `dedupe()` collapses each natural-key group to its **lowest** id
|
||
(lowest because old ids survive re-ingestion, which keeps `source_ref` stable and
|
||
makes a re-import insert nothing).
|
||
|
||
An earlier version kept close-id rows on a 10,000 threshold, reasoning that
|
||
genuine same-day repeats have consecutive ids. It passed 29 tests and a clean dry
|
||
run and was still wrong: in-scope duplicate pairs have id gaps from **58 to 260
|
||
million**, while real repeats sat at 1–4. Nothing separates them. It now collapses
|
||
unconditionally and *flags* anything within 10 for review, because the errors are
|
||
asymmetric — keeping a twin doubles income, collapsing a real repeat understates
|
||
by one row.
|
||
|
||
`category` is not imported: Frollo assigns its own and the owner reports they are
|
||
often wrong. Note a trigger rewrites NULL to `'other'` on insert, which is
|
||
equivalent for display and leaves the rules engine (which writes
|
||
`transaction_overrides.category_override`) free to decide.
|
||
|
||
Foreign rows carry only the native figure — there is no AUD equivalent anywhere in
|
||
the export — so they take order-ingestion's shape: `amount` is native,
|
||
`foreign_currency_code` names it, `amount_aud` stays NULL, and
|
||
`AMOUNT_UNCONVERTED` already reports the balance as incomplete.
|
||
|
||
Full history and the reasoning: `DECISIONS.md` ING-11 in the smarthome repo.
|
||
|
||
### Trusting extracted statement data
|
||
|
||
**Balance assertions are the check that works.** `getStatements` computes
|
||
`opening + movement − closing`; the statements page flags any statement that does
|
||
not reconcile. Sign depends on what the balance means — on a credit card or loan
|
||
it is what you owe, so spending increases it; on a transaction or offset account
|
||
it is what you hold. 11 pre-existing statements currently fail, ~$4,177
|
||
unexplained, including two adjacent ANZ statements off by exactly ±$230.38 (a
|
||
transaction filed against the wrong one).
|
||
|
||
**Do not derive `opening_balance` from `closing − movement`.** It is an accounting
|
||
identity, so every statement would reconcile and the check would go permanently
|
||
green. A null that reads "unverified" is worth more than a number that is right
|
||
by construction. For the same reason, do not add a totals assertion comparing
|
||
`total_debits` to the summed rows — those totals are now *computed* from the rows
|
||
(see the N8N `Parse Gemini Result` node), so that check can never fail.
|
||
|
||
**Gemini invents summary fields the statement does not print.** Wise PDFs show
|
||
only a closing balance; asked for an opening balance anyway, the model produced
|
||
11,277.08 against a truth of 0.00, and on another statement read the running
|
||
balance of the oldest row. Every transaction was extracted perfectly in both
|
||
cases — verified row for row against the CSV exports. When a balance assertion
|
||
fails, suspect the summary before the transactions.
|
||
|
||
**Gemini drops rows silently on long tables.** `finishReason` was `STOP`, not
|
||
`MAX_TOKENS`, so raising `maxOutputTokens` does not help. This did *not* actually
|
||
occur on the Wise imports (that was the summary bug above), but it is why an
|
||
empty statement must not throw: a document that errors never gets tagged, so it
|
||
is re-fetched every poll forever and blocks everything behind it in the queue
|
||
(`ordering=-created`, `page_size=1`).
|
||
|
||
**FX is per transaction date**, via Frankfurter (ECB daily, free, no key), with
|
||
weekends resolving to the prior publication. A single spot rate across a 15-month
|
||
statement is wrong by up to 20%. Wise's own rates are more accurate in principle
|
||
but differ by only 0.05% and exist on 44 of 194 rows, so mixing bases is not
|
||
worth it.
|
||
|
||
**When comparing CSV exports to extracted data, order by full timestamp
|
||
including milliseconds.** Two of one statement's rows are 1ms apart; dropping the
|
||
fraction reversed them and produced a bogus opening balance.
|
||
|
||
## Development Patterns
|
||
|
||
### Adding a new API route
|
||
|
||
1. Create `src/app/api/<resource>/route.ts`
|
||
2. Always call `getCurrentUser(req)` first; return 403 if null
|
||
3. Write SQL in `src/lib/queries.ts` using `queryRaw`
|
||
4. Add a TanStack Query hook in `src/lib/hooks.ts`
|
||
|
||
### Adding a new condition field to rules
|
||
|
||
Two files only:
|
||
- `src/app/api/rules/apply/route.ts` — add to `Condition.field` union, `TxFields` interface, and `evaluateCondition()` switch
|
||
- `src/app/rules/page.tsx` — add to `FIELDS` array; add special rendering if needed (e.g. enum dropdown for `transaction_type`)
|
||
|
||
### Modifying queries
|
||
|
||
- All JOINs to `statements` must be `LEFT JOIN` (manual transactions have no statement)
|
||
- Owner filter pattern: `WHERE COALESCE(t.owner_id, s.owner_id) = $1`
|
||
- Bank name pattern: `COALESCE(s.bank_name, 'Manual') as bank_name`
|
||
|
||
Analytics queries must import the fragments from `src/lib/analytics-sql.ts`
|
||
(`STATEMENTS_JOIN`, `OWNER_SCOPE`, `EFFECTIVE_CATEGORY`, `EXCLUDE_NON_SPEND`)
|
||
rather than hand-rolling them. Two failure modes they exist to prevent:
|
||
|
||
- An `INNER JOIN statements` + `WHERE s.owner_id = $1` silently drops every
|
||
manual/CSV transaction (`statement_id IS NULL`).
|
||
- Spend must exclude the `transfers` and `investment` categories. Once bank
|
||
statements are imported alongside card statements, a credit-card payment
|
||
appears twice — as a debit leaving the bank account and as the underlying
|
||
purchases on the card statement. Excluding `transfers` is what nets it out.
|
||
Use the `EXCLUDE_NON_SPEND` fragment: a bare `category NOT IN (...)` evaluates
|
||
to NULL for uncategorised rows and drops them from totals.
|
||
|
||
### Statement types
|
||
|
||
`statements.statement_type` is constrained to `credit_card | transaction |
|
||
savings | loan | offset | investment | other`. Migration 0013 added a
|
||
`normalize_statement_type()` SQL function plus a BEFORE INSERT/UPDATE trigger, so
|
||
the N8N workflow can keep sending raw free text (`'ACCESS ADVANTAGE'`, `'Business
|
||
Card'`) and the DB normalises it on write. The raw extracted value is preserved in
|
||
`account_type`.
|
||
|
||
The TypeScript mirror is `src/lib/statement-types.ts` — keep the list, the SQL
|
||
function, and the CHECK constraint in sync when adding a type.
|
||
|
||
### Loans
|
||
|
||
A loan repayment is **not** an expense. It is part principal (equity, a
|
||
balance-sheet move) and part interest (the only part that is spend). Migration
|
||
0014 adds:
|
||
|
||
- `transactions.principal_amount` / `interest_amount` — populated only when the
|
||
lender itemises the split on the repayment row itself
|
||
- `statements.interest_rate`, `scheduled_repayment`, `repayment_frequency`,
|
||
`redraw_available`, `loan_term_months`
|
||
|
||
Two statement shapes, both handled:
|
||
|
||
1. **Separate rows** (the common Australian case) — the loan statement lists
|
||
repayments and "Interest Charged" separately. `transaction_type` alone is
|
||
enough: `interest` rows count as spend, `payment` rows don't. No split columns
|
||
needed.
|
||
2. **Itemised repayment row** — some lenders print principal and interest on the
|
||
repayment line. That row is typed `payment`, so it would be skipped entirely
|
||
and its interest lost. The `SPEND_ROWS` / `SPEND_BASE` fragments in
|
||
`analytics-sql.ts` handle it: a row with a non-null `interest_amount` counts
|
||
as spend, valued at `interest_amount` rather than `amount`.
|
||
|
||
The N8N `Parse Gemini Result` node only accepts a split when both parts are
|
||
present *and* they sum to the row amount (±2c) — a half-extracted split would
|
||
silently misreport spend, so it is discarded rather than trusted.
|
||
|
||
Loan interest uses the `loan_interest` category; principal repayments use
|
||
`investment` (excluded from spend, surfaced on the investments line in monthly
|
||
analytics).
|
||
|
||
### The investments line is signed
|
||
|
||
A withdrawal from a fund is a **disinvestment**, not income. Units convert back
|
||
to cash; net worth is unchanged. `INVESTMENT_SIGNED` (`analytics-sql.ts`) makes
|
||
credits and refunds negative so they net against contributions, and
|
||
`/api/analytics/monthly` is the only consumer.
|
||
|
||
Summed unsigned, a withdrawal 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 that month.
|
||
|
||
Filing withdrawals as `income` is the other tempting answer and is worse: it
|
||
books an asset disposal as earnings and feeds `net = income − spent −
|
||
investments` 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
|
||
(`TRANSFER FROM RAIZ WITHDRAWAL 7D5262D8A839248A12`), 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.
|
||
|
||
Consequence for the UI: a net-disinvesting month is real data, so the budget page
|
||
gates on `!== 0`, not `> 0`, and renders negatives in amber.
|
||
|
||
### Prisma
|
||
|
||
The schema at `prisma/schema.prisma` covers all tables. The generated client (gitignored) must be regenerated after schema changes:
|
||
```bash
|
||
cd /mnt/m2cache/appdata/finance-app && npx prisma generate
|
||
```
|
||
Docker builds run `npx prisma generate` automatically. Do not commit `src/generated/prisma/` — it is gitignored.
|
||
|
||
## Agent / MCP Access
|
||
|
||
Agents read this DB through the read-only `postgres-personal` MCP server (lives in the
|
||
`personal-agent-gateway` repo, not here): `agent_ro` role, SELECT-only, SQLGlot guardrail,
|
||
100-row cap, every call audited to `mcp_query_log`. See `docs/agent-access.md` for the tool
|
||
list, the five analysis views, and per-client setup (Claude Code, Codex, Hermes).
|
||
|
||
Two things to remember when changing the schema: the agent views are created by
|
||
`smarthome/personal-agent/migrations/006_agent_read_role_views.sql` (not Prisma) and read
|
||
`transactions`/`statements`/`expense_metadata` columns directly — rename a column and they
|
||
break or go stale. And the views are **not** owner-scoped and do **not** merge
|
||
`transaction_overrides`, so agent numbers can differ from the UI.
|
||
|
||
## Known Gaps / TODOs
|
||
|
||
See `README.md` → **Known Gaps / TODOs** for full details.
|
||
|
||
**Payment provider tracking**: `merchant_normalized` currently conflates payment provider (PayPal, Afterpay, Zip) with the actual merchant. Plan: add `payment_provider` column, update Gemini prompt to extract it separately, backfill from `merchant_name` patterns, surface in UI filters.
|
||
|
||
### Open as of 2026-07-29
|
||
|
||
- **Shared expenses: loan section only** — the redesign in
|
||
`docs/shared-expenses-design.md` is built and live as of 2026-07-28; the
|
||
loan model at the end of that doc remains a proposal (Sonu's `…emi`
|
||
contributions still read as ordinary transfers).
|
||
- **Expense baseline / emergency reserve** — `docs/expense-baseline.md`. One-off
|
||
analysis, nothing built. Records four data corrections the raw numbers need
|
||
(misfiled Raiz/super/brokerage debits, `other` credits read as negative spend,
|
||
`government` conflating ATO with rates/rego, `fees` being mostly annual) and
|
||
why only Feb–Jun 2026 is trustworthy for per-person figures.
|
||
- **11 statements fail the balance assertion**, ~$4,177 unexplained. Predates
|
||
this work. One ANZ statement is off by exactly $0.50, traced to a misread digit
|
||
in fee rows ($5.00 vs $5.50).
|
||
- **28 Up Bank debits are categorised `other`** ($3,760.74). Up only categorised
|
||
16 of 88 rows. The `Payee` field is populated throughout, so merchant rules plus
|
||
the rule preview should clear most of it.
|
||
- **Up item sales are categorised `income`** ($4,238.04 across 19 credits — iPad,
|
||
drone, camera). Correct in that they are excluded from spend, but it mixes
|
||
asset disposals into the income line alongside salary.
|
||
- **`payment_method` is not shown in the transactions list** — settable on create
|
||
and edit only. Worth a column or filter if cash becomes routine.
|
||
- **Raw statement exports live in `dump/`**, gitignored since `31a8177`. They were
|
||
committed by accident in `030490e` and remain in that commit's history; the repo
|
||
has no GitHub remote, so exposure is limited to the local Gitea. Purging history
|
||
was offered and not actioned.
|