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.
This commit is contained in:
@@ -37,8 +37,8 @@ const colesSplit = () => ({
|
|||||||
{ leg_index: 2, amount: 73.82, card_last4: "3302", card_product: "MASTERCARD", class: "card" as const },
|
{ leg_index: 2, amount: 73.82, card_last4: "3302", card_product: "MASTERCARD", class: "card" as const },
|
||||||
],
|
],
|
||||||
line_items: [
|
line_items: [
|
||||||
{ name: "Pork Loin Roast", quantity: 1, unit: "ea", line_total: 15.86, category: "meat" },
|
{ description: "Pork Loin Roast", qty: 1, unit: "ea", amount: 15.86, category: "meat" },
|
||||||
{ name: "Tomatoes", quantity: 1, unit: "ea", line_total: 98.71, category: "produce" },
|
{ description: "Tomatoes", qty: 1, unit: "ea", amount: 98.71, category: "produce" },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -50,7 +50,7 @@ const woolworthsGiftCard = () => ({
|
|||||||
transaction_date: "2026-06-29",
|
transaction_date: "2026-06-29",
|
||||||
total: 40.75,
|
total: 40.75,
|
||||||
tender_legs: [{ leg_index: 1, amount: 40.75, card_last4: "0443", card_product: "STORE CARD", class: "gift_card" as const }],
|
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 () => {
|
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 () => {
|
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(res.status).toBe(200);
|
||||||
expect((await res.json()).flags.some((f: string) => f.startsWith("line_items_sum_"))).toBe(true);
|
expect((await res.json()).flags.some((f: string) => f.startsWith("line_items_sum_"))).toBe(true);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -38,8 +38,8 @@ const colesSplit = {
|
|||||||
{ leg_index: 2, amount: 73.82, card_last4: "3302", card_product: "MASTERCARD", class: "card" },
|
{ leg_index: 2, amount: 73.82, card_last4: "3302", card_product: "MASTERCARD", class: "card" },
|
||||||
],
|
],
|
||||||
line_items: [
|
line_items: [
|
||||||
{ name: "Pork Loin Roast", quantity: 1, unit: "ea", line_total: 15.86, category: "meat" },
|
{ description: "Pork Loin Roast", qty: 1, unit: "ea", amount: 15.86, category: "meat" },
|
||||||
{ name: "Jasmine Rice 1kg", quantity: 1, unit: "ea", line_total: 98.71, category: "pantry_dry" },
|
{ description: "Jasmine Rice 1kg", qty: 1, unit: "ea", amount: 98.71, category: "pantry_dry" },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -30,11 +30,17 @@ export type TenderLeg = {
|
|||||||
class?: "card" | "gift_card" | "cash" | null;
|
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 = {
|
export type ReceiptLineItem = {
|
||||||
name: string;
|
description: string;
|
||||||
quantity?: number | null;
|
qty?: number | null;
|
||||||
|
amount?: number | null;
|
||||||
unit?: string | null;
|
unit?: string | null;
|
||||||
line_total?: number | null;
|
|
||||||
category?: string | 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
|
// 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.
|
// 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.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.length > 1) flags.push("split_tender");
|
||||||
if (receipt.tender_legs.some((leg) => !leg.class)) flags.push("unclassified_tender");
|
if (receipt.tender_legs.some((leg) => !leg.class)) flags.push("unclassified_tender");
|
||||||
|
|||||||
Reference in New Issue
Block a user