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.
This commit is contained in:
@@ -804,6 +804,55 @@ is the drill-down for these totals.
|
||||
pre-2026 SplitMyExpenses splits are all on rows you own, so nothing before the
|
||||
cutover moves.
|
||||
|
||||
### Changing who paid (2026-08-15)
|
||||
|
||||
Owner was **write-once for every ingestion path** until now — a pantry receipt
|
||||
hardcodes `DEFAULT_OWNER_ID` (`receipt-ingestion.ts`), and there was not one
|
||||
`UPDATE ... SET owner_id` in `src/`. A shop the other person paid for was
|
||||
permanently filed as yours.
|
||||
|
||||
Two routes, because the owner lives in two places:
|
||||
|
||||
- **`PATCH /api/transactions/[id]` with `owner_id`** — manual rows only
|
||||
(`statement_id IS NULL`). 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 here would either no-op or
|
||||
detach one row from the account it was extracted from.
|
||||
- **`PATCH /api/statements/[id]` with `owner_id`** — the statements page has had
|
||||
this dropdown since it was built, wired to a route with **no PATCH handler**.
|
||||
Every change 405'd, and `useUpdateStatement` never checked `res.ok`, so it
|
||||
failed silently and the select just snapped back.
|
||||
|
||||
**The statement route 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. It updates rows
|
||||
matching the *old* owner and returns `rows_moved`; all 2,194 agree today, and a
|
||||
row that disagrees was set deliberately and is left alone.
|
||||
|
||||
**Reassignment is a one-way door, and the guard is the point.** Access is
|
||||
`owner OR holds a split` (`canAccessTransactions`), 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. The route returns **409
|
||||
`would_lose_access`** and the modal offers the two real ways forward: add your
|
||||
split first, or "Give it away anyway" (`release: true`). Taking a row *onto*
|
||||
your ledger is never blocked — there is no door to close behind you.
|
||||
|
||||
Reassignment is a correction to a row you already hold, never a way to reach one
|
||||
you do not: `canAccessTransactions` runs first, so claiming a stranger's
|
||||
transaction is a 404 before any owner logic runs.
|
||||
|
||||
**Splits are deliberately not rewritten.** They record shares, not direction —
|
||||
`getParticipantBalances` derives who owes whom from ownership, so a 50/50 flips
|
||||
from "they owe me" to "I owe them" untouched, `settled` included. Tested both
|
||||
directions.
|
||||
|
||||
**Order matters when you do this by hand:** split first, then reassign. The
|
||||
reverse locks you out, which is exactly what the 409 exists to stop.
|
||||
|
||||
`useUpdateTransaction` also gained the missing `res.ok` check. Without it every
|
||||
rejection resolved as success — the modal closed, the list refetched, and the
|
||||
edit silently vanished.
|
||||
|
||||
### Statement types
|
||||
|
||||
`statements.statement_type` is constrained to `credit_card | transaction |
|
||||
@@ -923,6 +972,10 @@ See `README.md` → **Known Gaps / TODOs** for full details.
|
||||
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.
|
||||
- **Pantry receipts still land as yours.** `processReceiptIngestion` hardcodes
|
||||
`DEFAULT_OWNER_ID`; the ingest route takes no owner. Correctable per row now
|
||||
(see "Changing who paid"), but a "Paid by" step at capture time would stop the
|
||||
correction being needed.
|
||||
- **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
|
||||
|
||||
Reference in New Issue
Block a user