docs: the wallet lane -- storage convention, pairing, lock, skips, replay
ci / lint-test (push) Successful in 48s

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-10-03 17:30:48 +10:00
co-authored by Claude Opus 5.5
parent ce0bf0a775
commit d75589e128
+36
View File
@@ -325,6 +325,42 @@ paid 2024/2025 as `VICROADS` and 2026 as `VICROADS ONLINE PAYM`, so the series
splits and the 2025 estimate shows as `expected_not_seen`. Not fuzzy-matched on splits and the 2025 estimate shows as `expected_not_seen`. Not fuzzy-matched on
purpose — see the rejected pg_trgm merchant grouping. purpose — see the rejected pg_trgm merchant grouping.
### The wallet lane (`/api/wallet/ingest`, `lib/wallet-ingestion.ts`)
iOS notifications → `capture.wallet_events` → n8n **Wallet Interpret**
(`CAp1s5cuzJk2mDaI`, local gemma, every 15 min) → this route. A row per tap,
statementless, `source='wallet'`, `source_ref='evt:<id>'`; the statement later
confirms it through the reconcile queue. Fixed 2026-10-03 after a month abroad
(ticket #266); the rules below are each the fix for something that broke.
- **Storage convention differs from the order lane.** `amount` = `amount_aud` =
a *provisional AUD* figure; the native figure is `foreign_currency_amount`.
`NATIVE_CURRENCY` reads `amount_aud IS NOT NULL` as "amount is AUD" — without
that an INR 524 tap displayed as "INR 9.42".
- **Provisional rate:** ledger median over *statement* rows (≥5, carries the
card's FX fee) → else ECB via Frankfurter (`rate_ecb`) → else `0` with
`no_rate_reference`. Never the native figure as AUD (672006 VND once became
A$672,006).
- **One tap = two notifications** (issuer app + Wallet, 2–8 s apart), merged.
A pair must be **≤10 minutes apart AND from different apps**
(`expense_metadata.notified_at` / `notif_app`, migration 0039). The old
amount-within-±1-day rule merged the UNICEF $50 into an unrelated $50 charge.
- **The whole check→pair→adopt→insert runs under one advisory lock**
(`hashtextextended('wallet-ingest',0)`), on the transaction's own connection.
n8n posts a poll's items concurrently; without it 11 taps became 22 rows.
Prisma cannot deserialise `void` — the lock SELECT needs `::text`.
- **A merged half records `wallet_evt:<id>` in the survivor's flags**, so a
replay is idempotent. Reconciled wallet rows carry **no** `expense_metadata`
(reconcile moves it to the statement twin) — the flag check follows it there.
- **Skipped, not booked:** account notices (`overdrawn`, `to avoid fees`, …) and
transit-card drawdowns (`Your new/current balance is` — the Octopus *load* is
the spend). The fixed-width descriptor parser runs on Westpac bodies only.
- **Dated in Australia/Melbourne**, like the statements (UTC slicing filed 28 of
134 rows a day early).
- **Replay a few events by relabelling their interpretations**
(`UPDATE capture.wallet_interpretations SET prompt_version='v1-<why>' WHERE …`),
not by bumping `prompt_version`, which re-posts every event ever captured.
### Cash and reconciliation ### Cash and reconciliation
`payment_method` in (`cash`, `credits`) excludes a transaction from `payment_method` in (`cash`, `credits`) excludes a transaction from