void: a statementless row for a charge that never posted (migration 0040)
ci / lint-test (push) Successful in 54s
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user