Owner was write-once for every ingestion path — a pantry receipt hardcodes
DEFAULT_OWNER_ID and there was not one `UPDATE ... SET owner_id` in src/ —
so a shop the other person paid for was permanently filed as yours.
PATCH /api/transactions/[id] now takes owner_id, for manual rows only. A
statement row returns 400 statement_owned and points at the statements
page: its effective owner is COALESCE(t.owner_id, s.owner_id), so writing
it there would either no-op or detach one row from the account it came
from.
PATCH /api/statements/[id] is new. The statements page has had an owner
dropdown since it was built, wired to a route with no PATCH handler —
every change 405'd, and because useUpdateStatement never checked res.ok
it failed silently and the select snapped back on refetch. It writes both
tables: 2,194 statement rows carry their own owner_id against 1,803 that
inherit, so updating `statements` alone moves less than half and splits
one account's history between two people.
The guard is the point. Access is "owner OR holds a split", so handing a
row over while holding no split removes it from your list and 404s every
route that could put it back — only the new owner can undo it. That is
409 would_lose_access, and the modal offers both ways forward: add my
split first, or give it away anyway. Taking a row onto your own ledger is
never blocked, and claiming a row you cannot see is a 404 before any
owner logic runs.
Splits are deliberately not rewritten. They record shares, not direction,
so a 50/50 flips from "they owe me" to "I owe them" untouched, settled
included.
Also adds the missing res.ok check to useUpdateTransaction, without which
every rejection resolved as success: the modal closed, the list
refetched, and the edit silently vanished.
14 new integration tests; 203 integration + 130 unit green.
getPendingReconciliations treated every unreconciled manual transaction as
awaiting a matching statement row. Cash never appears on a statement, so a cash
entry sat in the queue indefinitely 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.
Migration 0016 adds transactions.payment_method (card | cash | bank_transfer |
other, NULL = unknown) with a CHECK constraint and a partial index. The notCash()
fragment excludes cash from both halves of the reconciliation query - the pending
list and the candidate match subquery, which aliases the manual row as m.
Only cash is excluded. Bank transfers do appear on a statement now that
transaction accounts are imported, and NULL means unknown, so both stay
candidates and every pre-existing row behaves exactly as before.
ATM withdrawals deliberately stay categorised as spend rather than transfers.
Treating them as transfers is only correct if every cash purchase is logged;
with partial logging it silently deletes the unlogged remainder from spend.
Ten routes accepted requests with no getCurrentUser check (transactions/[id],
bulk, splits, tags-on-tx, splits/settle, statements/[id], tags, tags/[id],
merchants, participants/[id]/balance), and by-id routes did no ownership
check at all — any participant could read or modify another's data.
Adds canAccessTransactions() (owner via statement/direct, or split
participant), applies it to every transaction-scoped route, owner-scopes
statements/[id], and rescopes splits/settle in raw SQL so settlement only
touches splits the caller is party to.
Also: all trip analytics now sum COALESCE(amount_aud, amount) instead of raw
amount, matching every other analytics query — trip totals previously added
foreign-currency amounts to AUD ones unit-less.
And rules apply_split no longer delete+reinserts splits (which reset settled
flags on every run) — it upserts share_percent and removes only participants
no longer in the rule.
Adds trips table usage across API and UI: trip CRUD, per-trip analytics
(category/daily/merchant/tag/participant breakdowns), tag-to-trip
conversion, trip assignment via transaction overrides, and trip filter
in the transactions view. Recovered from working tree after local git
corruption; feature was already live via host-context Docker builds.
- TypeBadge is now clickable — opens inline select to change debit/credit/fee/interest/etc.
- PATCH /api/transactions/[id] now accepts transaction_type, updates transactions table directly
- Analytics monthly query includes fee and interest types as spend (not just debit)
- fee and interest amounts show red in transaction list (same as debit)
- Add fees category to taxonomy