receipts: adopt the wallet row instead of creating a second one (board #252)
ci / lint-test (push) Successful in 51s

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.
This commit is contained in:
2026-08-22 16:17:38 +10:00
parent f31f12799a
commit 8e66e527ae
3 changed files with 267 additions and 8 deletions
+46
View File
@@ -115,3 +115,49 @@ export async function findAutoMatches(
[ownerId, windowDays]
);
}
/**
* An existing statementless transaction that a newly-arriving receipt or order
* describes -- i.e. the row the wallet lane already created for this purchase.
*
* WHY: signals for one purchase arrive in sequence, not in competition. The
* wallet notification lands within seconds of the tap; the receipt is shared
* hours or days later. Measured on txn 6858: the wallet event arrived 35h25m
* before anything created a transaction, and pantry then created one from
* scratch -- so the same Woolworths shop would have had two rows. The receipt
* must adopt the row that already exists and enrich it.
*
* Deliberately narrow, for the same reason findAutoMatches() is: exact amount,
* a few days either side, unclaimed, and UNIQUE. Two candidates means two
* plausible purchases, and inventing a link between the wrong pair moves one
* shop's line items onto another shop's row. Returning null is always safe --
* the caller falls back to creating its own row, which is today's behaviour.
*/
export async function findAdoptableTransaction(
ownerId: number,
amount: number,
date: string,
windowDays: number = AUTO_MATCH_WINDOW_DAYS
): Promise<number | null> {
const rows = await queryRaw<{ id: number }>(
`SELECT t.id
FROM transactions t
WHERE t.statement_id IS NULL
AND t.owner_id = $1
AND t.amount = $2::numeric
AND t.transaction_date BETWEEN $3::date - $4::int AND $3::date + $4::int
AND t.reconciled_with_id IS NULL
AND t.superseded_by_id IS NULL
-- Never adopt a row that another source already fully described; that
-- is a different purchase that happens to share an amount.
AND NOT EXISTS (
SELECT 1 FROM expense_metadata em
WHERE (em.transaction_id = t.id OR em.matched_transaction_id = t.id)
AND jsonb_typeof(em.line_items) = 'array'
AND jsonb_array_length(em.line_items) > 0
)
LIMIT 2`,
[ownerId, amount, date, windowDays]
);
return rows.length === 1 ? rows[0].id : null;
}