Step 2 of wallet-first ingestion. Signals for one purchase arrive in
sequence, not in competition: the notification within seconds of the tap,
the receipt hours or days later. Until now the receipt path always
INSERTed its own transaction, so the same shop became two rows.
The case this is built from is measured, not hypothetical. Woolworths
$103.30 on 2026-08-18: the wallet event landed 09:00:14 on the 18th and
nothing created a transaction until 20:25:06 on the 19th -- 35h25m later,
by this path, from scratch -- discarding what only the notification knew
(the instrument, and the suburb).
findAdoptableTransaction() is deliberately as narrow as findAutoMatches():
exact amount, a few days either side, unclaimed, and UNIQUE. Two
candidates means two plausible purchases, and guessing between them moves
one shop's line items onto another shop's row. It also refuses a row that
already carries line items, because a fully-described row is a different
purchase that happens to share an amount. Returning null is always safe --
the caller falls back to creating its own row, which is the old behaviour.
On adoption, both the transaction UPDATE and the expense_metadata UPDATE
have a column list that IS the ownership boundary, following
order_transaction_bridge's UPGRADE pass: the receipt read the register
tape so it wins on what was bought, and `location` is structurally ABSENT
from the update rather than conditionally skipped, so the notification's
suburb cannot be erased by a bug here. expense_metadata.transaction_id is
UNIQUE, so an adopted row that already has metadata is enriched in place
rather than raising.
5 new integration tests including both refusals. Existing receipt tests
unchanged and passing. npm run test:all green.
Prerequisite for the wallet lane writing a transaction per tap. The queue
is worked by hand and already ~177 deep; wallet adds ~63 rows a month,
~767 a year. Filtering those rows back out is explicitly ruled out by the
awaitsStatementLine() removal note: "an imported row that has no statement
belongs in the queue; volume is solved upstream, never downstream by
hiding what is there."
The rule is deliberately stricter than the queue's own candidate ranking
(top 5 within +/-1% and +/-3 days, for a human to choose between). A
machine gets one shot and no judgement, so: exact amount, never a
tolerance band; and unique BOTH ways, so two identical $13.50 bakery taps
in one window are refused rather than paired arbitrarily. Foreign rows
match on the native pair, because `amount` is the bank's own conversion
and a wallet row cannot predict it.
VALIDATED AGAINST REAL DECISIONS, and that overturned my reasoning. I
first required both sides to be non-foreign, arguing a currency mismatch
is ambiguous. Checked against the 48 reconciliations a human has actually
made: that version matched ZERO of them. All 48 are exactly the shape it
refused — a manual row carrying no currency, matched on exact AUD against
a statement row that does carry one (EUR, CHF) — because overseas receipts
and orders record what was charged in AUD, and only the wallet lane
records natively. Reverted; the rule now reproduces all 48 and proposes
nothing they did not. The coincidence it was meant to guard against is
already covered by unique-both-ways.
Extracted the merge into src/lib/reconcile.ts so the manual route and the
automatic pass cannot drift — there must be exactly one implementation of
something that moves curation off a row reconciliation is about to hide.
Fixed a latent bug in that merge while extracting it: splits were
recreated WITHOUT `settled`/`settled_at`, and the column defaults to
false, so reconciling a settled split silently marked a paid debt owed
again. No queue row carries one today, but this path is about to run
unattended, where that surfaces as money rather than as an exception.
PATCH ?dry=1 reports without writing. Each pair is its own transaction, so
one bad pair leaves the rest untouched and in the queue.
11 new integration tests, most of them about refusal. Live dry-run
proposes 0 pairs for either owner, which is correct: nothing in the queue
has an exact statement twin yet. npm run test:all green (182 + 287).