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:
@@ -12,6 +12,24 @@ export const CUTOVER_DATE = "2026-01-09";
|
||||
*/
|
||||
export const DEFAULT_OWNER_ID = 1;
|
||||
|
||||
/** Human labels for the platform a receipt came from. */
|
||||
export const PLATFORM_LABEL: Record<ParsedOrder["platform"], string> = {
|
||||
doordash: "DoorDash",
|
||||
ubereats: "Uber Eats",
|
||||
uber: "Uber",
|
||||
};
|
||||
|
||||
/**
|
||||
* Transaction description.
|
||||
*
|
||||
* The merchant alone ("Order - Burger Corner") does not say where to go and
|
||||
* look for the detail, and there are restaurants on both platforms. The
|
||||
* platform is the one thing the parser always knows and used to discard.
|
||||
*/
|
||||
export function orderDescription(platform: ParsedOrder["platform"], merchant: string): string {
|
||||
return `Order - ${merchant} (${PLATFORM_LABEL[platform]})`;
|
||||
}
|
||||
|
||||
export interface IngestResult {
|
||||
transactionId: number | null;
|
||||
metadataId: number | null;
|
||||
@@ -93,7 +111,13 @@ async function ensureTag(name: string): Promise<number> {
|
||||
*/
|
||||
export async function processOrderIngestion(
|
||||
order: ParsedOrder,
|
||||
options: { messageId?: string; backfillMode?: boolean; ownerId?: number } = {}
|
||||
options: {
|
||||
messageId?: string;
|
||||
backfillMode?: boolean;
|
||||
ownerId?: number;
|
||||
subject?: string;
|
||||
sender?: string;
|
||||
} = {}
|
||||
): Promise<IngestResult> {
|
||||
const flags = [...order.flags];
|
||||
const day = order.order_datetime.slice(0, 10);
|
||||
@@ -159,7 +183,7 @@ export async function processOrderIngestion(
|
||||
RETURNING id`,
|
||||
[
|
||||
day,
|
||||
`Order - ${order.merchant_name}`,
|
||||
orderDescription(order.platform, order.merchant_name),
|
||||
creditsAmount,
|
||||
// No FX rate is available at ingest, so amount_aud is left NULL for
|
||||
// foreign orders rather than asserting a conversion we cannot make.
|
||||
@@ -194,8 +218,9 @@ export async function processOrderIngestion(
|
||||
`INSERT INTO expense_metadata (
|
||||
transaction_id, source, source_message_id, order_reference, line_items,
|
||||
subtotal, amount, merchant_normalized, transaction_date,
|
||||
card_last4, currency, flags, reconciled_at
|
||||
) VALUES ($1,'email',$2,$3,$4::jsonb,$5,$6,$7,$8,$9,$10,$11::jsonb,$12)
|
||||
card_last4, currency, flags, reconciled_at,
|
||||
platform, source_email_subject, source_email_from, route
|
||||
) VALUES ($1,'email',$2,$3,$4::jsonb,$5,$6,$7,$8,$9,$10,$11::jsonb,$12,$13,$14,$15,$16::jsonb)
|
||||
RETURNING id`,
|
||||
[
|
||||
transactionId,
|
||||
@@ -210,6 +235,10 @@ export async function processOrderIngestion(
|
||||
order.currency,
|
||||
JSON.stringify(flags),
|
||||
pending ? null : new Date().toISOString(),
|
||||
order.platform,
|
||||
options.subject ?? null,
|
||||
options.sender ?? null,
|
||||
JSON.stringify(order.route ?? []),
|
||||
]
|
||||
);
|
||||
|
||||
@@ -239,9 +268,10 @@ export async function reconcilePendingOrders(): Promise<{
|
||||
merchant_normalized: string;
|
||||
card_last4: string | null;
|
||||
currency: string | null;
|
||||
platform: ParsedOrder["platform"] | null;
|
||||
}>(
|
||||
`SELECT id, order_reference, amount::text, transaction_date::text,
|
||||
merchant_normalized, card_last4, currency
|
||||
merchant_normalized, card_last4, currency, platform
|
||||
FROM expense_metadata
|
||||
WHERE transaction_id IS NULL
|
||||
AND reconciled_at IS NULL
|
||||
@@ -255,7 +285,7 @@ export async function reconcilePendingOrders(): Promise<{
|
||||
const total = Number(row.amount);
|
||||
const probe: ParsedOrder = {
|
||||
order_reference: row.order_reference,
|
||||
platform: "doordash",
|
||||
platform: row.platform ?? "doordash",
|
||||
merchant_name: row.merchant_normalized,
|
||||
order_datetime: `${row.transaction_date}T00:00:00Z`,
|
||||
currency: row.currency || "AUD",
|
||||
@@ -265,6 +295,7 @@ export async function reconcilePendingOrders(): Promise<{
|
||||
service_fee: null, tip: null, discounts: null, total_charged: total,
|
||||
},
|
||||
line_items: [],
|
||||
route: [],
|
||||
is_family: false,
|
||||
flags: [],
|
||||
};
|
||||
@@ -291,7 +322,7 @@ export async function reconcilePendingOrders(): Promise<{
|
||||
payment_method, merchant_name, merchant_normalized, transaction_type, owner_id
|
||||
) VALUES ($1,$2,$3,$3,$5,'credits',$4,$4,'debit',$6)
|
||||
RETURNING id`,
|
||||
[row.transaction_date, `Order - ${row.merchant_normalized}`, remainder, row.merchant_normalized, category, DEFAULT_OWNER_ID]
|
||||
[row.transaction_date, orderDescription(row.platform ?? "doordash", row.merchant_normalized), remainder, row.merchant_normalized, category, DEFAULT_OWNER_ID]
|
||||
);
|
||||
txnId = txn!.id;
|
||||
created++;
|
||||
|
||||
Reference in New Issue
Block a user