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".
This commit is contained in:
2026-08-22 18:10:08 +10:00
parent 9b59edcf48
commit 2c05f10aed
7 changed files with 506 additions and 14 deletions
+69
View File
@@ -105,6 +105,17 @@ async function seedOrder(over: { merchant: string; date: string; amount: number;
}
return id;
}
/** A row shaped the way the wallet lane writes one: statementless, no metadata
* at all, no platform, and named by the notification rather than the receipt. */
async function seedWalletRow(over: { merchant: string; date: string; amount: number; owner?: number }) {
const t = await pool.query(
`INSERT INTO transactions (owner_id, statement_id, transaction_date, description, amount, amount_aud, transaction_type, category, merchant_name, merchant_normalized, payment_method, location, source, source_ref, row_index)
VALUES ($1, NULL, $2, $3, $4, $4, 'debit', 'other', $3, $3, 'card', 'Werribee, VIC', 'wallet', $5, 0) RETURNING id`,
[over.owner ?? ownerId, over.date, over.merchant, over.amount, `evt:${Math.floor(over.amount * 100)}`]
);
return t.rows[0].id as number;
}
/** What `components/order-details.tsx` would actually render for a transaction. */
const orderPanel = async (txnId: number) =>
(await (await orderGET({ headers: { get: () => null } } as unknown as NextRequest, { params: Promise.resolve({ id: String(txnId) }) })).json()) as { line_items?: unknown[] } | null;
@@ -638,6 +649,33 @@ describe("POST /api/intake — grocery → pantry", () => {
expect(pantryCall.intake).toMatchObject({ platform: null, finance_target_transaction_id: null, finance_action: "create" });
});
it("pantry saying the shop is already on its board is SHOWN, not swallowed", async () => {
// Pantry accepts a second capture of one receipt on purpose (the first may
// have been a bad crop) and reports it rather than refusing. Dropping that
// field turned "reported" into silence — and a duplicate nobody sees is a
// duplicate that gets confirmed, which counts the same groceries twice
// because pantry's addItem accumulates. Seen live on captures 66/69.
modelReply = coles;
pantryReply = { status: 200, body: { captureId: 902, items: [1, 2, 3], alreadyCaptured: { captureId: 66, confirmed: true } } };
const body = await (await intakePOST(multipart({ source: "coles" }, [pdf]))).json();
expect(body.status).toBe("forwarded_to_pantry");
expect(body.text).toMatch(/Already on the pantry board/);
expect(body.text).toMatch(/capture 66 \(already confirmed\)/);
expect(body.text).toMatch(/capture 902/);
});
it("two ledger rows for one shop are reported, not treated as no match", async () => {
// Ambiguity is not a miss. This is the exact state that produced the
// 2026-08-18 Woolworths double-book: the shop was in the ledger twice, the
// matcher returned nothing, and the card said finance would book it.
modelReply = coles;
await seedOrder({ merchant: "Coles (Wyndham Vale)", date: "2026-08-18", amount: 85.74, platform: null });
await seedOrder({ merchant: "Coles (Wyndham Vale)", date: "2026-08-18", amount: 85.74, platform: null });
const body = await (await intakePOST(multipart({ source: "coles" }, [pdf]))).json();
expect(body.status).toBe("forwarded_to_pantry");
expect(body.text).toMatch(/found more than one row it could be/);
});
it("pantry 409 → duplicate", async () => {
modelReply = coles;
pantryReply = { status: 409, body: { error: "This receipt has already been added to the pantry.", captureId: 77 } };
@@ -711,6 +749,37 @@ describe("Slack taps (C5)", () => {
expect(lastSlackUpdate().text).toMatch(/^Added 2 items/);
});
it("intake_create adopts the wallet row for the same tap instead of booking it twice", async () => {
// The normal arrival order: the notification lands within seconds of the
// tap, the screenshot is shared later. matchTransaction cannot see that row
// — its key includes the platform and a notification has none — so before
// this, Create booked the purchase a second time.
const wallet = await seedWalletRow({ merchant: "Burgertory", date: "2026-08-19", amount: 22.4 });
const id = uuid();
const body = await (await intakePOST(multipart({ source: "uber_eats", batch_id: id }, [pdf]))).json();
expect(body.status).toBe("candidates");
await slackPOST(slackPress("intake_create", { batch_id: id }));
expect((await pool.query(`SELECT count(*)::int AS n FROM transactions`)).rows[0].n).toBe(1);
expect(await batch(id)).toMatchObject({ status: "enriched", transaction_id: wallet });
expect((await lineItemsOf(wallet)).map((i: { description: string }) => i.description)).toEqual(["Michael", "Joel"]);
// The suburb the notification supplied is the one thing nothing else knows.
expect((await pool.query(`SELECT location FROM transactions WHERE id = $1`, [wallet])).rows[0].location).toBe("Werribee, VIC");
});
it("intake_create still creates when a same-amount row already carries a receipt", async () => {
// A row that another source fully described is a DIFFERENT purchase that
// happens to share an amount — adopting it would move one order's items
// onto another order's row.
const other = await seedOrder({ merchant: "Someone Else", date: "2026-08-19", amount: 22.4, platform: null });
await pool.query(`UPDATE expense_metadata SET line_items = $2::jsonb WHERE transaction_id = $1`, [other, JSON.stringify([{ description: "theirs", qty: 1, amount: 22.4 }])]);
const id = uuid();
await intakePOST(multipart({ source: "uber_eats", batch_id: id }, [pdf]));
await slackPOST(slackPress("intake_create", { batch_id: id }));
expect((await pool.query(`SELECT count(*)::int AS n FROM transactions`)).rows[0].n).toBe(2);
});
it("two pending cards for one order book it ONCE", async () => {
const a = uuid();
const b = uuid();
@@ -0,0 +1,176 @@
import { describe, it, expect, beforeAll, beforeEach, vi } from "vitest";
import { Pool } from "pg";
import { createPool, resetDB, seedParticipants } from "./helpers";
/**
* The mirror of receipt-adopts-wallet-row: a wallet notification arriving AFTER
* something else already booked the purchase must fold into that row.
*
* Built from the failure, not from the design. The 2026-08-18 Woolworths shop
* ($103.30, Visa gift card) reached the ledger twice: txn 6858 from the pantry
* receipt on the 19th, and txn 6927 from the wallet lane on the 22nd, when the
* first 21 wallet events were backfilled days after their taps. The sequencing
* the lane is designed around -- wallet first, receipt second -- is a tendency,
* and every writer has to survive it not holding.
*
* Two rules are load-bearing here and are tested separately below:
* - the merchant must agree, because losing the "no line items" guard that
* protects the other direction means amount+date alone could swallow a tap
* into an unrelated purchase of the same value
* - a gift-card tap is `credits`, because `card` means "a statement line is
* coming" and the reconcile queue filters on exactly that
*/
const TOKEN = "test-wallet-token";
let pool: Pool;
let ownerId: number;
let ingest: typeof import("../../app/api/wallet/ingest/route").POST;
const req = (body: unknown) =>
({ headers: { get: (h: string) => (h === "x-ingest-token" ? TOKEN : null) }, json: async () => body }) as never;
/** A row shaped the way the pantry receipt lane writes one: statementless,
* itemised, no location (nothing but a notification supplies a suburb). */
async function receiptRow(amount: number, date: string, merchant: string, opts: { items?: number; meta?: boolean } = {}) {
const { items = 3, meta = true } = opts;
const t = await pool.query(
`INSERT INTO transactions
(owner_id, statement_id, transaction_date, description, amount, amount_aud,
transaction_type, category, payment_method, merchant_name, merchant_normalized, row_index)
VALUES ($1, NULL, $2, $3, $4, $4, 'debit', 'groceries', 'credits', $3, $3, 0)
RETURNING id`,
[ownerId, date, merchant, amount]
);
const id = t.rows[0].id as number;
if (meta) {
const lineItems = Array.from({ length: items }, (_, i) => ({ description: `item ${i}`, qty: 1, amount: 1 }));
await pool.query(
`INSERT INTO expense_metadata
(transaction_id, source, merchant_normalized, amount, transaction_date, currency, line_items, flags)
VALUES ($1, 'pantry', $2, $3, $4, 'AUD', $5::jsonb, '[]'::jsonb)`,
[id, merchant, amount, date, JSON.stringify(lineItems)]
);
}
return id;
}
/** The real event 80: the Apple Wallet half of a Visa gift-card tap. */
const woolworthsGiftCard = {
event_id: 80, kind: "transaction", direction: "unknown", amount: 103.3,
amount_text: "$103.30", currency: "$?", merchant: "Woolworths", issuer: "Visa",
notif_app: "Wallet", notif_title: "Visa Gift Card",
notif_subtitle: "Woolworths, Wyndham Vale, VIC",
occurred_at: "2026-08-18T09:00:14+10:00",
};
const rows = async () =>
(await pool.query(
`SELECT id, amount, merchant_normalized, location, source, source_ref, payment_method
FROM transactions ORDER BY id`
)).rows;
beforeAll(async () => {
process.env.WALLET_INGEST_TOKEN = TOKEN;
pool = createPool();
vi.doMock("@/lib/auth", () => ({
getCurrentUser: async () => ({ id: ownerId, name: "Alice", email: "alice@example.com" }),
}));
({ POST: ingest } = await import("../../app/api/wallet/ingest/route"));
});
beforeEach(async () => {
await resetDB(pool);
await pool.query("DELETE FROM expense_metadata");
({ ownerId } = await seedParticipants(pool));
});
describe("a wallet notification for a purchase the ledger already holds", () => {
it("adopts the receipt's row instead of creating a second one", async () => {
const existing = await receiptRow(103.3, "2026-08-18", "WOOLWORTHS 3345");
const body = await (await ingest(req(woolworthsGiftCard))).json();
expect(body.skipped).toBe("adopted");
expect(body.transactionId).toBe(existing);
expect(await rows()).toHaveLength(1);
});
it("gives the adopted row the suburb, and leaves what the receipt read alone", async () => {
const existing = await receiptRow(103.3, "2026-08-18", "WOOLWORTHS 3345");
await ingest(req(woolworthsGiftCard));
const [t] = await rows();
// Wallet's own field: nothing else on the estate writes a suburb.
expect(t.location).toBe("Wyndham Vale, VIC");
// The receipt read the register tape. The notification does not get to
// rename the merchant to its own shorter string.
expect(t.merchant_normalized).toBe("WOOLWORTHS 3345");
expect(t.id).toBe(existing);
const [m] = (await pool.query(`SELECT * FROM expense_metadata ORDER BY id`)).rows;
expect(m.source).toBe("pantry");
expect(m.line_items).toHaveLength(3);
expect(m.payment_method_detail).toBe("Visa");
expect(m.flags).toContain("wallet_evt:80");
});
it("is idempotent when the interpreter replays every event", async () => {
// Bumping prompt_version re-interprets and re-posts the whole capture
// history on purpose -- that is the replay mechanism. An adopted row keeps
// no wallet source_ref of its own, so the flag is what makes replay safe.
await ingest(req(woolworthsGiftCard));
const second = await (await ingest(req(woolworthsGiftCard))).json();
expect(second.skipped).toBe("already_ingested");
expect(await rows()).toHaveLength(1);
});
it("writes its own row when the merchant disagrees", async () => {
// Same money, same day, different shop. Folding into it would make the tap
// vanish -- worse than a duplicate, which is at least visible in the queue.
await receiptRow(103.3, "2026-08-18", "Bunnings Werribee");
const body = await (await ingest(req(woolworthsGiftCard))).json();
expect(body.skipped).toBeUndefined();
expect(await rows()).toHaveLength(2);
});
it("writes its own row when two candidates share the amount", async () => {
await receiptRow(103.3, "2026-08-18", "Woolworths Wyndham Vale");
await receiptRow(103.3, "2026-08-18", "Woolworths Point Cook");
const body = await (await ingest(req(woolworthsGiftCard))).json();
expect(body.skipped).toBeUndefined();
expect(await rows()).toHaveLength(3);
});
it("creates the metadata row when the adopted transaction has none", async () => {
const existing = await receiptRow(103.3, "2026-08-18", "WOOLWORTHS 3345", { meta: false });
await ingest(req(woolworthsGiftCard));
const meta = (await pool.query(`SELECT * FROM expense_metadata`)).rows;
expect(meta).toHaveLength(1);
expect(meta[0].transaction_id).toBe(existing);
expect(meta[0].card_last4).toBeNull();
expect(meta[0].flags).toContain("wallet_evt:80");
});
});
describe("a stored-value instrument", () => {
it("is booked as credits, so it never enters the reconcile queue", async () => {
const body = await (await ingest(req(woolworthsGiftCard))).json();
const [t] = await rows();
expect(body.transactionId).toBeTruthy();
// 'card' would park it forever: findAutoMatches and the queue both exclude
// 'credits' and 'cash' precisely because no statement line is coming.
expect(t.payment_method).toBe("credits");
expect(body.flags).toContain("stored_value_instrument");
});
it("leaves an ordinary card tap as card", async () => {
const body = await (await ingest(req({ ...woolworthsGiftCard, notif_title: "Payment", issuer: "Mastercard", notif_app: "Westpac" }))).json();
const [t] = await rows();
expect(t.payment_method).toBe("card");
expect(body.flags).not.toContain("stored_value_instrument");
});
});