feat(orders): make an ingested order legible in the transactions view
ci / lint-test (push) Failing after 43s

Four things the view could not tell you, all from reading the rows (user,
2026-07-27).

**Which platform.** The parser has always known — it has to, to read the
template — and then discarded it. "Order - Burger Corner" gives no way to know
whether to open DoorDash or Uber Eats for the detail, and restaurants exist on
both. Now stored on expense_metadata and named in the description:
"Order - Burger Corner (Uber Eats)". Migration 0021 recovers it for the 101
backfilled rows from the order_reference shape — DoorDash receipts carry no id
of their own so ingestion synthesises `msg:<message-id>`, Uber carries a real
trip UUID, which makes the discriminator exact.

**Bank said "Manual".** That label is derived, not stored, and "Manual" reads
as "hand-entered, still awaiting a card line to match". A gift-card order has
no card line coming, ever. It now reads "Gift Card", and — the part that
actually mattered — credits joins cash in needsCardMatch(), so these stop
sitting in the pending-reconciliation queue. All 81 were queued against a match
that could not exist.

**Uber line items were never parsed.** 67 of 101 orders had none. Uber itemises
groceries but not restaurant orders, so some of that is genuine; the rest was
simply unread. Its markup is better than DoorDash's — every cell carries a
data-testid with the item's uuid, so qty/title/amount bind by id rather than by
column position. Sold-out items (0.00) are kept: they are why a total is lower
than what was ordered.

**Uber prints pick-up and delivery addresses on every receipt** and they were
thrown away. Captured as `route` [{label, time, address}], de-duplicated
because the template renders the whole block twice for narrow screens. Wording
is kept as printed ("Pick-up" on some receipts, "Pickup" on others) rather than
normalised, so a template change stays visible. This is the same block a *trip*
receipt uses for start and destination — rides are not ingested today, but the
reader will not need changing when they are.

Also stores source_email_subject/from, which order ingestion had left null on
columns that already existed.

Verified against the captured corpus: route on all 6 Uber fixtures, 5/5 items
on the GLOMARK grocery receipt including the sold-out one. Production data
updated by smarthome:docker/scripts/order-presentation-2026-07-27.sql
(81 descriptions, `backfill` tag, re-run clean). `route` and Uber line items
are parsed from here on only — recovering them for already-ingested orders
means re-reading the mail, which I7 idempotency refuses by design.
This commit is contained in:
2026-07-27 10:51:30 +10:00
parent ae0c34fce7
commit df4b875b82
7 changed files with 354 additions and 29 deletions
@@ -70,6 +70,7 @@ describe("validateOrderTotals", () => {
service_fee: null, tip: null, discounts: null, total_charged: 10,
},
line_items: [],
route: [],
is_family: false,
flags: [],
...over,
@@ -180,3 +181,69 @@ describe("mixed Uber payment (issuer-named card leg)", () => {
expect(validateOrderTotals(p).ok).toBe(true);
});
});
describe("Uber route (pick-up / delivery)", () => {
const uber = (f: string, subject = "Your Wednesday order with Uber Eats") =>
parseOrderHTML(html(f), meta({ subject, sender: "uber.com" }));
it("reads both stops with their times, as printed", () => {
const p = uber("ue-00");
expect(p.route).toEqual([
{ label: "Pick-up", time: "1:20 pm", address: "197 Watton St, Werribee VIC 3030, Australia" },
{ label: "Delivery", time: "1:40 pm", address: "19 Lady Penrhyn Dr, Wyndham Vale VIC 3024, Australia" },
]);
});
it("de-duplicates the block Uber renders twice", () => {
// The receipt emits the whole address section a second time for narrow
// screens. Without de-duplication every trip has four stops.
expect(uber("ue-00").route).toHaveLength(2);
expect(uber("ue-26").route).toHaveLength(2);
});
it("keeps the receipt's own wording rather than normalising it", () => {
// Uber is not internally consistent: "Pick-up" on some receipts,
// "Pickup" on others. Inventing a canonical spelling would hide that a
// template changed.
expect(uber("ue-mixed", "Your Friday morning order with Uber Eats").route[0].label).toBe("Pickup");
});
it("works on an international receipt", () => {
const p = uber("ue-26");
expect(p.route[1].address).toContain("Luzern, Switzerland");
});
it("DoorDash has no route — its receipts carry no addresses", () => {
expect(parseOrderHTML(html("dd-01"), meta()).route).toEqual([]);
});
});
describe("Uber line items", () => {
it("itemises a grocery order, binding qty/title/amount by item id", () => {
const p = parseOrderHTML(
html("ue-09"),
meta({ subject: "Your Sunday evening order with Uber Eats", sender: "uber.com" })
);
expect(p.line_items).toHaveLength(5);
expect(p.line_items[0]).toMatchObject({
qty: 1,
description: "Highland Brewing MILK FULL CREAM U H T 900ML",
amount: 440,
});
// A sold-out item prints 0.00 and is kept: it is why the total is lower
// than what was ordered, and dropping it makes the receipt unexplainable.
expect(p.line_items.map((i) => i.amount)).toContain(0);
});
it("a restaurant order legitimately has none", () => {
// Uber itemises groceries only; a restaurant receipt states a total and
// nothing else. Empty here is the receipt, not a parse failure — so it
// must not raise no_line_items_parsed either.
const p = parseOrderHTML(
html("ue-00"),
meta({ subject: "Your Wednesday order with Uber Eats", sender: "uber.com" })
);
expect(p.line_items).toEqual([]);
expect(p.flags).not.toContain("no_line_items_parsed");
});
});