From a465b147a3686e36ead75747ad681b24523568fa Mon Sep 17 00:00:00 2001 From: siddharthd Date: Thu, 30 Jul 2026 13:51:33 +1000 Subject: [PATCH] Read receipt lines under the keys the receipt panel renders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The order-details panel reads qty/description/amount from expense_metadata.line_items. A grocery shop is a receipt like any other, so it stores the same keys rather than name/quantity/line_total — otherwise the rows arrive complete and display blank, which is exactly the failure ING-8 names. unit and category are the two fields a grocery line has and a delivery line does not; nothing renders them yet and the category composition will. --- src/__tests__/integration/receipt-ingest.test.ts | 8 ++++---- .../integration/receipt-reconcile.test.ts | 4 ++-- src/lib/receipt-ingestion.ts | 14 ++++++++++---- 3 files changed, 16 insertions(+), 10 deletions(-) diff --git a/src/__tests__/integration/receipt-ingest.test.ts b/src/__tests__/integration/receipt-ingest.test.ts index f30c757..c696348 100644 --- a/src/__tests__/integration/receipt-ingest.test.ts +++ b/src/__tests__/integration/receipt-ingest.test.ts @@ -37,8 +37,8 @@ const colesSplit = () => ({ { leg_index: 2, amount: 73.82, card_last4: "3302", card_product: "MASTERCARD", class: "card" as const }, ], line_items: [ - { name: "Pork Loin Roast", quantity: 1, unit: "ea", line_total: 15.86, category: "meat" }, - { name: "Tomatoes", quantity: 1, unit: "ea", line_total: 98.71, category: "produce" }, + { description: "Pork Loin Roast", qty: 1, unit: "ea", amount: 15.86, category: "meat" }, + { description: "Tomatoes", qty: 1, unit: "ea", amount: 98.71, category: "produce" }, ], }); @@ -50,7 +50,7 @@ const woolworthsGiftCard = () => ({ transaction_date: "2026-06-29", total: 40.75, tender_legs: [{ leg_index: 1, amount: 40.75, card_last4: "0443", card_product: "STORE CARD", class: "gift_card" as const }], - line_items: [{ name: "Oat Milk", quantity: 2, unit: "ea", line_total: 40.75, category: "dairy" }], + line_items: [{ description: "Oat Milk", qty: 2, unit: "ea", amount: 40.75, category: "dairy" }], }); beforeAll(async () => { @@ -197,7 +197,7 @@ describe("validation — what becomes money", () => { }); it("flags but accepts lines that do not sum, because promo rows are skipped by design", async () => { - const res = await POST(req({ ...colesSplit(), line_items: [{ name: "One line", line_total: 10 }] })); + const res = await POST(req({ ...colesSplit(), line_items: [{ description: "One line", amount: 10 }] })); expect(res.status).toBe(200); expect((await res.json()).flags.some((f: string) => f.startsWith("line_items_sum_"))).toBe(true); }); diff --git a/src/__tests__/integration/receipt-reconcile.test.ts b/src/__tests__/integration/receipt-reconcile.test.ts index 7827b6d..79ec61f 100644 --- a/src/__tests__/integration/receipt-reconcile.test.ts +++ b/src/__tests__/integration/receipt-reconcile.test.ts @@ -38,8 +38,8 @@ const colesSplit = { { leg_index: 2, amount: 73.82, card_last4: "3302", card_product: "MASTERCARD", class: "card" }, ], line_items: [ - { name: "Pork Loin Roast", quantity: 1, unit: "ea", line_total: 15.86, category: "meat" }, - { name: "Jasmine Rice 1kg", quantity: 1, unit: "ea", line_total: 98.71, category: "pantry_dry" }, + { description: "Pork Loin Roast", qty: 1, unit: "ea", amount: 15.86, category: "meat" }, + { description: "Jasmine Rice 1kg", qty: 1, unit: "ea", amount: 98.71, category: "pantry_dry" }, ], }; diff --git a/src/lib/receipt-ingestion.ts b/src/lib/receipt-ingestion.ts index 52e6864..acec5d0 100644 --- a/src/lib/receipt-ingestion.ts +++ b/src/lib/receipt-ingestion.ts @@ -30,11 +30,17 @@ export type TenderLeg = { class?: "card" | "gift_card" | "cash" | null; }; +/** + * Same keys the order lane uses, because the panel that renders an itemised receipt reads + * `qty`/`description`/`amount` and a grocery shop is a receipt like any other. `unit` and + * `category` are the two things a grocery line has and a delivery line does not; nothing + * renders them yet, and the intra-transaction category composition will. + */ export type ReceiptLineItem = { - name: string; - quantity?: number | null; + description: string; + qty?: number | null; + amount?: number | null; unit?: string | null; - line_total?: number | null; category?: string | null; }; @@ -103,7 +109,7 @@ export function validateReceipt(receipt: ParsedReceipt): string[] { // Lines are allowed to disagree: promotional rows are deliberately skipped during // extraction, so this flags rather than rejects. The money is the tender, not the lines. - const lineSum = round2((receipt.line_items ?? []).reduce((total, line) => total + Number(line.line_total || 0), 0)); + const lineSum = round2((receipt.line_items ?? []).reduce((total, line) => total + Number(line.amount || 0), 0)); if (receipt.line_items?.length && Math.abs(lineSum - round2(receipt.total)) > 0.02) flags.push(`line_items_sum_${lineSum.toFixed(2)}`); if (receipt.tender_legs.length > 1) flags.push("split_tender"); if (receipt.tender_legs.some((leg) => !leg.class)) flags.push("unclassified_tender");