a3e4e1102ac32c95b18e0ec9231b51021b95125a
5
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
a3e4e1102a |
wallet: lock the ingest, pair on time and app, skip notices, ECB fallback
ci / lint-test (push) Successful in 54s
Four defects found reviewing a month of overseas wallet rows: - Race: n8n posts a poll's items concurrently and both halves of a tap usually share a poll, so each ran findPairedRow before the other inserted. 11 taps became 22 rows (A$3,841.40), identical created_at on every pair. One advisory lock now covers re-check -> pair -> adopt -> insert, on the transaction's own connection (a second pooled one per request deadlocks a burst). Mutation-tested: without the lock the concurrent test fails 3/3. - Pair window: amount within +/-1 DAY folded the UNICEF $50 donation and an unrelated $50 Amex charge 17h later into one row and renamed it. A pair is now minutes apart AND from two different apps. Needs when/where a notification arrived: migration 0039 adds expense_metadata.notified_at / notif_app, backfilled from capture.wallet_events (144 rows). A merged half now records wallet_evt:<id>, so a replay cannot merge it twice. - Not purchases: a CommBank overdraft notice became a $21.21 purchase named "To avoid fees and interest," (the Westpac fixed-width parser ran on a CommBank body). Account notices and transit-card balance drawdowns are skipped; the descriptor parser only runs on Westpac. - FX: with no ledger history (Hong Kong) every tap stored A$0.00. The ECB rate via Frankfurter is the fallback, flagged rate_ecb; the ledger median stays first because it carries the card's FX fee. Also: dates in Australia/Melbourne like the statements (UTC slicing filed 28 of 134 rows a day early), and test:setup now actually rebuilds personal_test -- replaying the dump over it never applied a migration. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> |
||
|
|
48c6b2395f |
wallet: never store a native figure as AUD; rate from statement rows only
ci / lint-test (push) Successful in 51s
A CommBank approval prompt for 672006 VND had no VND rate history, and the
no_rate_reference branch left the native figure in transactions.amount -- the
ledger carried A$672,006 for six days (txn 7316, repaired to 35.95).
- no rate history: amount = 0, still flagged aud_provisional/no_rate_reference;
reconcile matches on the native pair, so the statement supplies the AUD
- medianRateToAud reads statement rows only; a wallet row's amount is itself
a guess and a 1:1 row would have poisoned the next estimate
- foreign halves pair on the native pair, since several can share amount 0
- currencyFromAmountText reads ISO codes around the figure ("672006 VND")
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
2c05f10aed |
wallet+intake: adopt the row that is already there, and keep a gift card out of the queue
ci / lint-test (push) Successful in 53s
Three defects found by the 2026-08-18 Woolworths shop reaching the ledger
twice — txn 6858 (pantry receipt, 23 items) and txn 6927 (wallet) were one
$103.30 purchase, and the second parked in the reconcile queue waiting for a
statement line the first had already claimed.
The design says signals for a purchase arrive in sequence — wallet first,
receipt second, statement last — and the receipt lane honours it
(findAdoptableTransaction). Nothing else did.
1. The wallet writer inserted unconditionally. It now adopts a row a receipt
got to first (findWalletAdoptableTransaction). Deliberately NOT the same
predicate as the receipt direction: that one refuses a row carrying line
items, because for a receipt such a row is a different purchase sharing an
amount; here an itemised row IS the receipt being folded into. Losing that
guard means the merchant supplies it, so adoption requires merchant
agreement — a tap that vanishes into an unrelated purchase is worse than a
visible duplicate. Idempotent across a prompt_version replay via a
wallet_evt:<id> metadata flag, since an adopted row keeps no wallet
source_ref of its own.
2. intake_create booked a second row for a tap the wallet lane had already
recorded. matchTransaction cannot see those rows — its key includes the
platform and a notification has none — so an Uber Eats screenshot never
matched the wallet row for the very tap that paid for it. That is the
NORMAL arrival order, not an edge case.
3. A gift-card tap was written payment_method 'card', which means "a statement
line is coming"; the queue and findAutoMatches filter on exactly that, so
it parked forever. The instrument's name survives only in the notification
title ("Visa Gift Card") — the interpreter reduces it to issuer "Visa",
which a plain Visa debit card also produces — so notif_title is now carried
through and a stored-value tap is booked 'credits'. 3 of the first 21 rows.
Also: pantry reports "this receipt is already on my board" in the 200 body and
the forwarder dropped the field, so the one gate that DID fire on the duplicate
was silent (captures 66/69). It now becomes the card. And ambiguity — two
ledger rows for one shop, the exact state behind this bug — no longer reads as
"no match, finance will book it".
|
||
|
|
3d0d75519a |
wallet: the deterministic parse must beat the model's location
ci / lint-test (push) Successful in 46s
Found by reading the backfilled rows rather than by a test. The precedence was subtitle -> MODEL -> descriptor, so the model's answer won wherever it had one, which is always. That defeats the entire point of parsing the descriptor positionally: the ledger got "SAN FRANCISOUS" for the only foreign merchant in the capture window, plus the padded "MELBOURNE AU", and the bare "Sydney"/"Perth" with their country codes dropped. Correct order is subtitle -> deterministic descriptor -> model. After the fix those rows read SAN FRANCISO US, MELBOURNE AU, Sydney AU, Perth AU, and a subtitle-derived place still wins where one exists. The test that should have caught this passed because its fixture omitted `location`, so the fallback chain was never exercised — every real interpretation carries one. Fixture now includes the model's actual mangled answer for that row, so the test fails if the order regresses. Re-ran the backfill on the corrected code: 21 transactions, $6,284.37 unchanged, 13 with a location. The 21 previously-written rows were deleted first rather than superseded, which is normally forbidden on transactions — checked before doing it that they carried zero splits, tags and overrides, being machine-written minutes earlier by this same lane with nothing yet attached. |
||
|
|
8d1875225f |
wallet: a transaction for every tap (board #252, ING-11)
ci / lint-test (push) Successful in 52s
Step 3, and the last app-side piece. POST /api/wallet/ingest takes an
interpreted notification and writes a statementless card transaction plus
one expense_metadata row. Sibling to /api/orders/ingest and
/api/receipts/ingest, same shared-secret shape, its own token so the
wallet credential rotates without touching the order flow.
Nothing here knows about statements, and that is the point: statementless
+ payment_method 'card' is exactly the reconcile queue's predicate, so a
wallet row enters it unaided and the auto-matcher confirms it later.
THE PAIR IS MERGED, NEVER DEDUPED. One tap emits two notifications ~2-8s
apart: the issuer app has the card tail, Apple Wallet has the merchant and
the suburb, and neither alone is complete. Matched on amount and date
because the two share no identifier. My first version COALESCEd every
field, which made the result depend on arrival order — the issuer half
usually lands first, so the truncated all-caps "WYNDHAM VAL AU" beat the
subtitle's "Wyndham Vale, VIC". A subtitle-derived place now overwrites; a
descriptor-derived one only fills a gap. The test that caught this asserts
both arrival orders.
CURRENCY IS NEVER ASSUMED. Only Amex states one ("A$5,103.49"); Westpac,
CommBank and Apple Wallet write a bare "$", which is 14 of 21 captured
rows. Recorded as unknown and flagged, not assumed AUD — harmless at home,
a 23x error on a THB tap abroad. When a currency IS stated and is not AUD,
the NATIVE figure goes to foreign_currency_amount, because the AUD amount
is the bank's own conversion and a notification cannot predict it, while
the native pair is exact on both sides and is what the statement will
match on. transactions.amount is NOT NULL so it takes a provisional
conversion from the ledger-derived median rate, flagged aud_provisional,
overwritten at reconcile. No rate history means no_rate_reference rather
than an invented number.
wallet-descriptor.ts moves the positional parsing out of the model, which
handled ONE fixed-width format three ways on the same day's data: kept the
country code on "WYNDHAM VAL AU", stripped it on "Sydney" and "Perth", and
swallowed "SAN FRANCISOUS" whole — that being "SAN FRANCISO" truncated to
the 12-char city field plus "US", on the only foreign merchant in the
entire capture window. Every unit-test input is a real captured string.
14 unit + 12 integration tests. npm run test:all green (196 + 304), lint
clean.
|