receipts: adopt the wallet row instead of creating a second one (board #252)
ci / lint-test (push) Successful in 51s
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:
@@ -0,0 +1,153 @@
|
||||
import { describe, it, expect, beforeAll, beforeEach, vi } from "vitest";
|
||||
import { Pool } from "pg";
|
||||
import { createPool, resetDB, seedParticipants } from "./helpers";
|
||||
|
||||
/**
|
||||
* A receipt arriving after the wallet notification must ENRICH that row, not
|
||||
* create a second one.
|
||||
*
|
||||
* The case this is built from is real. Woolworths $103.30 on 2026-08-18: the
|
||||
* wallet event landed at 09:00:14 on the 18th, and nothing created a
|
||||
* transaction until 20:25:06 on the 19th -- 35h25m later -- at which point the
|
||||
* receipt path made its own row from scratch. One shop, two rows, and the
|
||||
* notification's contribution (the instrument, and the suburb) thrown away.
|
||||
*
|
||||
* Sources for one purchase arrive in sequence, not in competition, so the rule
|
||||
* is: the receipt read the register tape and wins on what was bought; anything
|
||||
* only the notification knew survives untouched.
|
||||
*/
|
||||
|
||||
const TOKEN = "test-receipt-token";
|
||||
let pool: Pool;
|
||||
let ownerId: number;
|
||||
let ingest: typeof import("../../app/api/receipts/ingest/route").POST;
|
||||
|
||||
const ingestReq = (body: unknown) =>
|
||||
({ headers: { get: (h: string) => (h === "x-ingest-token" ? TOKEN : null) }, json: async () => body }) as never;
|
||||
|
||||
/** A row shaped the way the wallet lane writes one: statementless, no line
|
||||
* items, carrying the suburb the notification supplied. */
|
||||
async function walletRow(amount: number, date: string, merchant: string, location: string) {
|
||||
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,
|
||||
location, source, source_ref, row_index)
|
||||
VALUES ($1, NULL, $2, $3, $4, $4, 'debit', 'other', 'card', $3, $3, $5, 'wallet', $6, 0)
|
||||
RETURNING id`,
|
||||
[ownerId, date, merchant, amount, location, `evt-${amount}-${date}`]
|
||||
);
|
||||
const id = t.rows[0].id as number;
|
||||
await pool.query(
|
||||
`INSERT INTO expense_metadata
|
||||
(transaction_id, source, merchant_normalized, amount, transaction_date,
|
||||
currency, line_items, payment_method_detail, flags)
|
||||
VALUES ($1, 'wallet', $2, $3, $4, 'AUD', '[]'::jsonb, 'Visa', '[]'::jsonb)`,
|
||||
[id, merchant, amount, date]
|
||||
);
|
||||
return id;
|
||||
}
|
||||
|
||||
const receipt = (amount: number, date: string) => ({
|
||||
receipt_uid: `woolworths:3345:${amount}:2026-08-18`,
|
||||
capture_event_id: 900,
|
||||
merchant_name: "Woolworths",
|
||||
store_detail: "Wyndham Vale",
|
||||
transaction_date: date,
|
||||
total: amount,
|
||||
tender_legs: [
|
||||
{ leg_index: 1, amount, card_last4: "6078", card_product: "VISA PREPAID", class: "gift_card" },
|
||||
],
|
||||
line_items: [
|
||||
{ description: "Milk 2L", qty: 1, unit: "ea", amount: 3.5, category: "dairy" },
|
||||
{ description: "Bread", qty: 1, unit: "ea", amount: 4.0, category: "bakery" },
|
||||
],
|
||||
});
|
||||
|
||||
beforeAll(async () => {
|
||||
process.env.RECEIPT_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/receipts/ingest/route"));
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await resetDB(pool);
|
||||
await pool.query("DELETE FROM expense_metadata");
|
||||
({ ownerId } = await seedParticipants(pool));
|
||||
});
|
||||
|
||||
const txCount = async () =>
|
||||
Number((await pool.query(`SELECT count(*)::int AS n FROM transactions`)).rows[0].n);
|
||||
|
||||
describe("a receipt meeting the wallet row for the same tap", () => {
|
||||
it("adopts it instead of creating a second transaction", async () => {
|
||||
const walletId = await walletRow(103.3, "2026-08-18", "Woolworths", "Wyndham Vale, VIC");
|
||||
expect(await txCount()).toBe(1);
|
||||
|
||||
await ingest(ingestReq(receipt(103.3, "2026-08-18")));
|
||||
|
||||
expect(await txCount()).toBe(1);
|
||||
const meta = await pool.query(
|
||||
`SELECT transaction_id, jsonb_array_length(line_items) AS items, source,
|
||||
payment_method_detail, card_last4
|
||||
FROM expense_metadata`
|
||||
);
|
||||
expect(meta.rowCount).toBe(1);
|
||||
expect(meta.rows[0].transaction_id).toBe(walletId);
|
||||
expect(meta.rows[0].items).toBe(2);
|
||||
expect(meta.rows[0].source).toBe("pantry");
|
||||
// The receipt read the card off the tape; the notification only saw "Visa".
|
||||
expect(meta.rows[0].payment_method_detail).toBe("VISA PREPAID");
|
||||
expect(meta.rows[0].card_last4).toBe("6078");
|
||||
});
|
||||
|
||||
it("keeps the suburb only the notification knew", async () => {
|
||||
await walletRow(103.3, "2026-08-18", "Woolworths", "Wyndham Vale, VIC");
|
||||
await ingest(ingestReq(receipt(103.3, "2026-08-18")));
|
||||
|
||||
const t = await pool.query(`SELECT location, merchant_normalized, category FROM transactions`);
|
||||
// `location` is absent from the adoption UPDATE's column list on purpose:
|
||||
// the receipt has no suburb, so it must not be able to erase one.
|
||||
expect(t.rows[0].location).toBe("Wyndham Vale, VIC");
|
||||
// ...while what the receipt does know wins.
|
||||
expect(t.rows[0].merchant_normalized).toBe("Woolworths");
|
||||
expect(t.rows[0].category).toBe("groceries");
|
||||
});
|
||||
|
||||
it("creates its own row when there is no wallet row to adopt", async () => {
|
||||
await ingest(ingestReq(receipt(58.4, "2026-08-18")));
|
||||
expect(await txCount()).toBe(1);
|
||||
const meta = await pool.query(`SELECT source, jsonb_array_length(line_items) AS items FROM expense_metadata`);
|
||||
expect(meta.rows[0].source).toBe("pantry");
|
||||
expect(meta.rows[0].items).toBe(2);
|
||||
});
|
||||
|
||||
it("refuses to adopt when two candidates share the amount", async () => {
|
||||
// Two taps of the same value in the window: adopting either would move this
|
||||
// shop's line items onto a purchase it did not describe.
|
||||
await walletRow(103.3, "2026-08-17", "Woolworths", "Wyndham Vale, VIC");
|
||||
await walletRow(103.3, "2026-08-18", "Coles", "Manor Lakes, VIC");
|
||||
expect(await txCount()).toBe(2);
|
||||
|
||||
await ingest(ingestReq(receipt(103.3, "2026-08-18")));
|
||||
|
||||
// Falls back to creating its own row rather than guessing.
|
||||
expect(await txCount()).toBe(3);
|
||||
});
|
||||
|
||||
it("refuses to adopt a row that already carries line items", async () => {
|
||||
const other = await walletRow(103.3, "2026-08-18", "Woolworths", "Wyndham Vale, VIC");
|
||||
await pool.query(
|
||||
`UPDATE expense_metadata SET line_items = '[{"description":"Something else","amount":1}]'::jsonb
|
||||
WHERE transaction_id = $1`, [other]
|
||||
);
|
||||
|
||||
await ingest(ingestReq(receipt(103.3, "2026-08-18")));
|
||||
|
||||
// A fully-described row is a different purchase that happens to share an amount.
|
||||
expect(await txCount()).toBe(2);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user