void: a statementless row for a charge that never posted (migration 0040)
ci / lint-test (push) Successful in 54s

A card notification is sent for an authorisation; a released hold never
reaches a statement. Txn 7951 (a $1,040.41 Airbnb hold, booking cancelled
the next day) sat in the reconcile queue forever and counted as spend.
Neither reconcile (needs a statement twin) nor supersede (needs a survivor)
can express it.

- transactions.voided_at + void_reason; CHECKs: reason mandatory,
  statementless only
- EXCLUDE_RECONCILED_SOURCE excludes voided rows; reconcile queue,
  findAutoMatches, both adoption helpers, annual charges and the manual
  reconcile POST filter it; merge refuses a voided row on either side
- POST/DELETE /api/transactions/<id>/void; refuses reconciled, superseded
  and settled-split rows
- "Never posted" button on /reconcile
- wallet-ingestion deliberately still sees voided rows, so a late twin or a
  prompt_version re-post cannot rebook the hold

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-10-12 18:05:05 +11:00
co-authored by Claude Opus 5.5
parent a1c2eb736f
commit c25c82932d
14 changed files with 459 additions and 5 deletions
+31
View File
@@ -275,6 +275,37 @@ by the `rn` window and ordered by amount closeness, so a looser band changes
live queue at the time of the change: candidate counts went **0 → 0** on all 12
rows, i.e. no new noise. **The auto-matcher stays exact** — see `findAutoMatches()`.
### A charge that never posted (`POST /api/transactions/<id>/void`, migration 0040)
The third way a row stops counting, for a case with **no survivor**: a card
notification is sent for an *authorisation*, and when the merchant releases the
hold no statement line ever arrives. Txn 7951 — a $1,040.41 Airbnb hold on Amex,
booking cancelled the next day — sat in the reconcile queue with nothing to
match and counted as spend everywhere. `reconcile` needs a statement twin,
`supersede` needs a survivor (and `transactions_no_self_supersede` blocks
pointing a row at itself), so it gets its own columns: `voided_at` +
`void_reason` (reason mandatory, by CHECK). Body `{reason}`; `DELETE` undoes it.
UI: **Never posted** on every row of `/reconcile`.
- **In `EXCLUDE_RECONCILED_SOURCE`**, so every "count each purchase once" query
drops it without being edited; the queue, `findAutoMatches`, both adoption
helpers, annual charges and the manual reconcile POST filter it explicitly.
- **Statementless only** — `transactions_void_statementless` enforces it in the
database. A statement line is the bank's record of a posted charge; a wrong
one is a supersede.
- **Refused** on a reconciled row (it posted), a superseded row, and a row with a
*settled* split (someone already paid their share — voiding would quietly turn
that into a credit). `merge` refuses a voided row on either side.
- **The wallet lane deliberately still SEES voided rows.** `alreadyIngested()` and
the issuer/Wallet pairing in `wallet-ingestion.ts` do not filter `voided_at`:
the voided row is the sink that stops a late twin notification, or the next
`prompt_version` re-post, from booking the hold again. Same for the intake
router's idempotency lookup. Do not "fix" those.
- **Outside this repo, nothing filters it**: Hermes' `agent_ro` views
(`cashflow_monthly` etc.) and ingestion-engine's `instalment_matcher` already
ignore `superseded_by_id`/`reconciled_with_id` too, so a voided row is no worse
there than any other hidden row.
### Upcoming annual charges (`GET /api/annual-charges`, migration 0038)
Card annual fees, loan package fees and annual subscriptions, derived from