feat(orders): make an ingested order legible in the transactions view
ci / lint-test (push) Failing after 43s
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:
@@ -13,6 +13,7 @@ import {
|
||||
type MessageMeta,
|
||||
} from "../../lib/order-ingestion";
|
||||
import { EXCLUDE_NON_SPEND } from "../../lib/analytics-sql";
|
||||
import { bankLabel, needsCardMatch } from "../../lib/queries";
|
||||
|
||||
/**
|
||||
* These run against REAL captured receipts, not synthetic fixtures. The earlier
|
||||
@@ -163,7 +164,7 @@ describe("Order ingestion — invariants", () => {
|
||||
expect(b.skipped).toBe("already_ingested");
|
||||
expect(b.metadataId).toBe(a.metadataId);
|
||||
const n = await queryRow<{ c: string }>(
|
||||
`SELECT count(*)::text c FROM transactions WHERE description = 'Order - Mad Mex'`
|
||||
`SELECT count(*)::text c FROM transactions WHERE description = 'Order - Mad Mex (DoorDash)'`
|
||||
);
|
||||
expect(Number(n!.c)).toBe(1);
|
||||
});
|
||||
@@ -379,3 +380,60 @@ describe("owner scoping", () => {
|
||||
expect(visible).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("how an ingested order presents in the app", () => {
|
||||
it("names the platform in the description", async () => {
|
||||
// "Order - Burger Corner" gives no way to know where to look for the
|
||||
// detail, and the same restaurant can be on both platforms.
|
||||
const p = parseOrderHTML(html("dd-01"), meta({ messageId: `desc-${Date.now()}` }));
|
||||
const res = await processOrderIngestion(p);
|
||||
const row = await queryRow<{ description: string }>(
|
||||
`SELECT description FROM transactions WHERE id = $1`, [res.transactionId]
|
||||
);
|
||||
expect(row!.description).toMatch(/\(DoorDash\)$/);
|
||||
});
|
||||
|
||||
it("reads as 'Gift Card', not 'Manual', and stays out of the reconcile queue", async () => {
|
||||
// bank_name is derived — no statement means "Manual", which reads as
|
||||
// "hand-entered, awaiting a card line". A credits order has no card line
|
||||
// coming, ever; 81 of them sat in the queue waiting for one.
|
||||
const p = parseOrderHTML(html("dd-01"), meta({ messageId: `bank-${Date.now()}` }));
|
||||
const res = await processOrderIngestion(p);
|
||||
|
||||
const row = await queryRow<{ bank_name: string }>(
|
||||
`SELECT ${bankLabel()} as bank_name
|
||||
FROM transactions t LEFT JOIN statements s ON s.id = t.statement_id
|
||||
WHERE t.id = $1`,
|
||||
[res.transactionId]
|
||||
);
|
||||
expect(row!.bank_name).toBe("Gift Card");
|
||||
|
||||
const queued = await queryRaw(
|
||||
`SELECT t.id FROM transactions t
|
||||
WHERE t.id = $1 AND t.statement_id IS NULL AND ${needsCardMatch("t")}`,
|
||||
[res.transactionId]
|
||||
);
|
||||
expect(queued).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("records the platform and the message it came from", async () => {
|
||||
const p = parseOrderHTML(
|
||||
html("ue-00"),
|
||||
meta({ messageId: `prov-${Date.now()}`, subject: "Your Wednesday order with Uber Eats", sender: "uber.com" })
|
||||
);
|
||||
const res = await processOrderIngestion(p, {
|
||||
messageId: `prov-${Date.now()}`,
|
||||
subject: "Your Wednesday order with Uber Eats",
|
||||
sender: "uber.com",
|
||||
});
|
||||
const row = await queryRow<{
|
||||
platform: string; source_email_from: string; route: { label: string }[];
|
||||
}>(
|
||||
`SELECT platform, source_email_from, route FROM expense_metadata WHERE id = $1`,
|
||||
[res.metadataId]
|
||||
);
|
||||
expect(row!.platform).toBe("ubereats");
|
||||
expect(row!.source_email_from).toBe("uber.com");
|
||||
expect(row!.route.map((r) => r.label)).toEqual(["Pick-up", "Delivery"]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user