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
+3 -1
View File
@@ -190,7 +190,9 @@ model transactions {
owner_id Int?
reconciled_with_id Int?
superseded_by_id Int?
principal_amount Decimal? @db.Decimal(12, 2)
voided_at DateTime? @db.Timestamptz(6) // a charge that never posted (migration 0040)
void_reason String?
principal_amount Decimal? @db.Decimal(12, 2)
interest_amount Decimal? @db.Decimal(12, 2)
source String? // feed this row came from, e.g. "frollo"; NULL = statement/manual (migration 0028)
source_ref String? // the provider's own transaction id — idempotency key for re-imports