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
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".
This commit is contained in:
+108
-7
@@ -1,5 +1,6 @@
|
||||
import { queryRow, queryRaw } from "@/lib/db";
|
||||
import { DEFAULT_OWNER_ID } from "@/lib/order-ingestion";
|
||||
import { findWalletAdoptableTransaction } from "@/lib/auto-reconcile";
|
||||
import {
|
||||
parseWestpacDescriptor,
|
||||
parseWalletSubtitle,
|
||||
@@ -20,6 +21,12 @@ import {
|
||||
* (receipt-ingestion.ts adopts it), and the statement confirms it via the
|
||||
* reconcile queue, which a wallet row enters unaided: it is statementless with
|
||||
* payment_method 'card'. Nothing here needs to know about statements.
|
||||
*
|
||||
* But that sequence is a TENDENCY, not a guarantee -- these first 21 rows were
|
||||
* themselves backfilled days after the taps -- so this also adopts a row a
|
||||
* receipt got to first (findWalletAdoptableTransaction), and a stored-value
|
||||
* instrument is written as 'credits' so it never enters a queue no statement
|
||||
* line will ever resolve.
|
||||
*/
|
||||
|
||||
export type WalletEvent = {
|
||||
@@ -37,6 +44,8 @@ export type WalletEvent = {
|
||||
issuer?: string | null;
|
||||
location?: string | null;
|
||||
notif_app?: string | null;
|
||||
/** The notification's title line, e.g. "Visa Gift Card" or "Payment". */
|
||||
notif_title?: string | null;
|
||||
notif_body?: string | null;
|
||||
notif_subtitle?: string | null;
|
||||
occurred_at?: string | null;
|
||||
@@ -45,7 +54,7 @@ export type WalletEvent = {
|
||||
export type WalletIngestResult = {
|
||||
transactionId: number | null;
|
||||
metadataId: number | null;
|
||||
skipped?: "not_a_transaction" | "already_ingested" | "no_amount" | "merged_pair";
|
||||
skipped?: "not_a_transaction" | "already_ingested" | "no_amount" | "merged_pair" | "adopted";
|
||||
flags: string[];
|
||||
};
|
||||
|
||||
@@ -103,6 +112,31 @@ async function findPairedRow(amount: number, date: string) {
|
||||
return rows.length === 1 ? rows[0] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A stored-value instrument: a gift card, a prepaid travel card, a store card.
|
||||
*
|
||||
* It matters because payment_method decides queue membership. 'card' means "a
|
||||
* statement line is coming"; findAutoMatches and the reconcile queue both
|
||||
* exclude 'credits' and 'cash' for exactly that reason. A $103.30 Woolworths
|
||||
* tap on a Visa gift card written as 'card' parks in the queue forever, being
|
||||
* offered as an unmatched row against statements that can never contain it --
|
||||
* three of the first 21 wallet rows (events 80, 92, 101) were gift-card taps.
|
||||
*
|
||||
* The evidence is the notification TITLE, which is where Apple Wallet prints
|
||||
* the instrument's own name ("Visa Gift Card"); the interpreter reduces that to
|
||||
* issuer "Visa", which a plain Visa debit card also produces, so the title is
|
||||
* the only place the distinction survives. Read as a prior, not a contract: a
|
||||
* miss leaves today's behaviour, which is the queue.
|
||||
*/
|
||||
const STORED_VALUE = /gift\s*card|prepaid|travel\s*(?:money|card)|store\s*card/i;
|
||||
|
||||
/** How an adopted row records which wallet event folded into it. */
|
||||
const adoptedFlag = (eventId: number) => `wallet_evt:${eventId}`;
|
||||
|
||||
export function isStoredValueInstrument(ev: Pick<WalletEvent, "notif_title" | "issuer" | "notif_app">): boolean {
|
||||
return [ev.notif_title, ev.issuer, ev.notif_app].some((v) => !!v && STORED_VALUE.test(v));
|
||||
}
|
||||
|
||||
export async function processWalletIngestion(ev: WalletEvent): Promise<WalletIngestResult> {
|
||||
const flags: string[] = [];
|
||||
|
||||
@@ -117,9 +151,21 @@ export async function processWalletIngestion(ev: WalletEvent): Promise<WalletIng
|
||||
}
|
||||
|
||||
const sourceRef = `evt:${ev.event_id}`;
|
||||
// Two places this event may already have landed: a row this lane wrote (which
|
||||
// carries the ref natively), or a row it ADOPTED, whose source_ref belongs to
|
||||
// whoever created it. The adopted case is marked in the metadata flags rather
|
||||
// than by overwriting a field another lane owns. Both must be checked here,
|
||||
// because bumping the interpreter's prompt_version deliberately re-interprets
|
||||
// and re-posts every event ever captured -- that is the replay mechanism, and
|
||||
// it must not mint a second row for a purchase that has one.
|
||||
const existing = await queryRow<{ id: number }>(
|
||||
`SELECT id FROM transactions WHERE source = 'wallet' AND source_ref = $1`,
|
||||
[sourceRef]
|
||||
`SELECT id FROM transactions WHERE source = 'wallet' AND source_ref = $1
|
||||
UNION ALL
|
||||
SELECT em.transaction_id AS id FROM expense_metadata em
|
||||
JOIN transactions t ON t.id = em.transaction_id
|
||||
WHERE em.flags @> $2::jsonb AND t.superseded_by_id IS NULL
|
||||
LIMIT 1`,
|
||||
[sourceRef, JSON.stringify([adoptedFlag(ev.event_id)])]
|
||||
);
|
||||
if (existing) {
|
||||
return { transactionId: existing.id, metadataId: null, skipped: "already_ingested", flags };
|
||||
@@ -175,6 +221,12 @@ export async function processWalletIngestion(ev: WalletEvent): Promise<WalletIng
|
||||
}
|
||||
}
|
||||
|
||||
// A gift card has no statement line coming, so it must not enter the
|
||||
// reconcile queue (which filters on exactly this column).
|
||||
const storedValue = isStoredValueInstrument(ev);
|
||||
const paymentMethod = storedValue ? "credits" : "card";
|
||||
if (storedValue) flags.push("stored_value_instrument");
|
||||
|
||||
// Merge the paired notification rather than creating a second row for one
|
||||
// tap. The issuer half brings the card tail; the Wallet half brings the
|
||||
// merchant and suburb. COALESCE so whichever arrives second only ever fills
|
||||
@@ -207,14 +259,63 @@ export async function processWalletIngestion(ev: WalletEvent): Promise<WalletIng
|
||||
return { transactionId: pair.id, metadataId: pair.meta_id, skipped: "merged_pair", flags };
|
||||
}
|
||||
|
||||
// The receipt got here first. Fold into its row instead of writing a second
|
||||
// one for the same purchase (see findWalletAdoptableTransaction). The column
|
||||
// list IS the ownership boundary, the mirror of receipt-ingestion.ts's: the
|
||||
// receipt read the register tape, so merchant, description, category and the
|
||||
// items are its business and are absent here; `location` is wallet's own --
|
||||
// nothing else writes a suburb -- and payment_method is filled only when the
|
||||
// other lane left it unknown.
|
||||
const adopted = await findWalletAdoptableTransaction(DEFAULT_OWNER_ID, amountAud, date, merchant);
|
||||
if (adopted) {
|
||||
await queryRaw(
|
||||
`UPDATE transactions
|
||||
SET location = COALESCE($2, location),
|
||||
payment_method = COALESCE(payment_method, $3)
|
||||
WHERE id = $1`,
|
||||
[adopted.id, location, paymentMethod]
|
||||
);
|
||||
let metadataId = adopted.metadata_id;
|
||||
if (metadataId) {
|
||||
await queryRaw(
|
||||
`UPDATE expense_metadata
|
||||
SET card_last4 = COALESCE(card_last4, $2),
|
||||
payment_method_detail = COALESCE(payment_method_detail, $3),
|
||||
currency = COALESCE(currency, $4),
|
||||
flags = COALESCE(flags, '[]'::jsonb) || $5::jsonb
|
||||
WHERE id = $1`,
|
||||
[metadataId, ev.card_tail ?? null, ev.issuer ?? null, stated, JSON.stringify([...flags, adoptedFlag(ev.event_id)])]
|
||||
);
|
||||
} else {
|
||||
// expense_metadata.transaction_id is UNIQUE, so this branch only runs
|
||||
// when the adopted row genuinely has none -- and it must produce one, or
|
||||
// the card tail and the flag that makes this idempotent have nowhere to
|
||||
// live.
|
||||
const meta = await queryRow<{ id: number }>(
|
||||
`INSERT INTO expense_metadata (
|
||||
transaction_id, source, source_message_id, merchant_normalized, amount,
|
||||
transaction_date, card_last4, currency, payment_method,
|
||||
payment_method_detail, line_items, flags, extraction_model
|
||||
) VALUES ($1,'wallet',$2,$3,$4,$5,$6,$7,$8,$9,'[]'::jsonb,$10::jsonb,'wallet-interpret')
|
||||
RETURNING id`,
|
||||
[
|
||||
adopted.id, sourceRef, merchant, ev.amount, date, ev.card_tail ?? null, stated,
|
||||
paymentMethod, ev.issuer ?? null, JSON.stringify([...flags, adoptedFlag(ev.event_id)]),
|
||||
]
|
||||
);
|
||||
metadataId = meta!.id;
|
||||
}
|
||||
return { transactionId: adopted.id, metadataId, skipped: "adopted", flags };
|
||||
}
|
||||
|
||||
const txn = await queryRow<{ id: number }>(
|
||||
`INSERT INTO transactions (
|
||||
transaction_date, description, amount, amount_aud, category, payment_method,
|
||||
merchant_name, merchant_normalized, transaction_type, owner_id, location,
|
||||
foreign_currency_amount, foreign_currency_code, source, source_ref, row_index
|
||||
) VALUES ($1,$2,$3,$3,'other','card',$4,$4,'debit',$5,$6,$7,$8,'wallet',$9,0)
|
||||
) VALUES ($1,$2,$3,$3,'other',$10,$4,$4,'debit',$5,$6,$7,$8,'wallet',$9,0)
|
||||
RETURNING id`,
|
||||
[date, merchant, amountAud, merchant, DEFAULT_OWNER_ID, location, fxAmount, fxCode, sourceRef]
|
||||
[date, merchant, amountAud, merchant, DEFAULT_OWNER_ID, location, fxAmount, fxCode, sourceRef, paymentMethod]
|
||||
);
|
||||
|
||||
const meta = await queryRow<{ id: number }>(
|
||||
@@ -222,11 +323,11 @@ export async function processWalletIngestion(ev: WalletEvent): Promise<WalletIng
|
||||
transaction_id, source, source_message_id, merchant_normalized, amount,
|
||||
transaction_date, card_last4, currency, payment_method,
|
||||
payment_method_detail, line_items, flags, extraction_model
|
||||
) VALUES ($1,'wallet',$2,$3,$4,$5,$6,$7,'card',$8,'[]'::jsonb,$9::jsonb,'wallet-interpret')
|
||||
) VALUES ($1,'wallet',$2,$3,$4,$5,$6,$7,$8,$9,'[]'::jsonb,$10::jsonb,'wallet-interpret')
|
||||
RETURNING id`,
|
||||
[
|
||||
txn!.id, sourceRef, merchant, ev.amount, date,
|
||||
ev.card_tail ?? null, stated, ev.issuer ?? null, JSON.stringify(flags),
|
||||
ev.card_tail ?? null, stated, paymentMethod, ev.issuer ?? null, JSON.stringify(flags),
|
||||
]
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user