diff --git a/src/__tests__/integration/order-ingestion.test.ts b/src/__tests__/integration/order-ingestion.test.ts index 6428593..01e3dbb 100644 --- a/src/__tests__/integration/order-ingestion.test.ts +++ b/src/__tests__/integration/order-ingestion.test.ts @@ -437,3 +437,65 @@ describe("how an ingested order presents in the app", () => { expect(row!.route.map((r) => r.label)).toEqual(["Pick-up", "Delivery"]); }); }); + +describe("receipt lookup for the transaction detail panel", () => { + // Mirrors /api/transactions/[id]/order — the panel resolves a receipt from + // either side, and a card-settled order only has the matched_transaction_id + // side, which is exactly where the detail would otherwise go missing. + const receiptFor = (txnId: number) => + queryRow<{ platform: string; route: { label: string }[] }>( + `SELECT platform, route FROM expense_metadata + WHERE transaction_id = $1 OR matched_transaction_id = $1 LIMIT 1`, + [txnId] + ); + + it("finds the receipt for a credits order", async () => { + // ue-00 is Uber Cash — credits, so it creates a transaction. ue-09 names a + // payer with no instrument and correctly parks awaiting a card statement, + // which would leave nothing to look the receipt up by. + const p = parseOrderHTML( + html("ue-00"), + meta({ messageId: `panel-${Date.now()}`, subject: "Your Wednesday order with Uber Eats", sender: "uber.com" }) + ); + const res = await processOrderIngestion(p); + const r = await receiptFor(res.transactionId!); + expect(r!.platform).toBe("ubereats"); + expect(r!.route.map((x) => x.label)).toEqual(["Pick-up", "Delivery"]); + }); + + it("finds it from the statement line for a card-settled order", async () => { + const st = await queryRow<{ id: number }>( + `INSERT INTO statements (bank_name, account_number, filename) + VALUES ('CBA', '5523504401723893', 'panel-cba.pdf') RETURNING id` + ); + const card = await queryRow<{ id: number }>( + `INSERT INTO transactions (statement_id, transaction_date, description, amount, transaction_type) + VALUES ($1, '2026-04-07', 'UBER *EATS ZURICH', 51.23, 'debit') RETURNING id`, + [st!.id] + ); + const m = await queryRow<{ id: number }>( + `INSERT INTO expense_metadata (source, order_reference, platform, route, matched_transaction_id) + VALUES ('email', $1, 'ubereats', '[{"label":"Pick-up","time":null,"address":"Ebikon"}]'::jsonb, $2) + RETURNING id`, + [`panel-card-${Date.now()}`, card!.id] + ); + expect(m).not.toBeNull(); + + const r = await receiptFor(card!.id); + expect(r!.platform).toBe("ubereats"); + expect(r!.route[0].address).toBe("Ebikon"); + }); + + it("returns nothing for an ordinary transaction", async () => { + const st = await queryRow<{ id: number }>( + `INSERT INTO statements (bank_name, account_number, filename) + VALUES ('CBA', '1111', 'panel-plain.pdf') RETURNING id` + ); + const t = await queryRow<{ id: number }>( + `INSERT INTO transactions (statement_id, transaction_date, description, amount, transaction_type) + VALUES ($1, '2026-04-07', 'COLES 1234', 12.00, 'debit') RETURNING id`, + [st!.id] + ); + expect(await receiptFor(t!.id)).toBeNull(); + }); +}); diff --git a/src/app/api/transactions/[id]/order/route.ts b/src/app/api/transactions/[id]/order/route.ts new file mode 100644 index 0000000..ee3fc9f --- /dev/null +++ b/src/app/api/transactions/[id]/order/route.ts @@ -0,0 +1,44 @@ +import { NextRequest, NextResponse } from "next/server"; +import { queryRow } from "@/lib/db"; +import { getCurrentUser } from "@/lib/auth"; +import { canAccessTransactions } from "@/lib/queries"; + +/** + * Order provenance for one transaction. + * + * `expense_metadata` has held the itemised receipt since ingestion started and + * nothing in the UI ever read it — a transaction that came from a DoorDash or + * Uber Eats receipt showed a merchant and an amount, with the item list and the + * delivery addresses sitting unread in the row behind it (user, 2026-07-27). + * + * Read-only. The receipt is a record of what a provider sent; editing it here + * would make provenance mean nothing. + */ +export async function GET( + req: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const user = await getCurrentUser(req); + if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 }); + const { id } = await params; + if (!(await canAccessTransactions(user.id, [Number(id)]))) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + const row = await queryRow( + `SELECT platform, order_reference, line_items, route, subtotal, amount, + currency, card_last4, flags, source_email_subject, transaction_date + FROM expense_metadata + -- A card-settled order creates no transaction of its own (I5): the + -- statement line is the transaction, and the receipt points at it + -- through matched_transaction_id. Both directions have to resolve or the + -- detail is missing on exactly the orders that were paid by card. + WHERE transaction_id = $1 OR matched_transaction_id = $1 + LIMIT 1`, + [Number(id)] + ); + + // Not an order — most transactions aren't. Null, not 404: the caller is + // asking "is there a receipt behind this?", and "no" is a normal answer. + return NextResponse.json(row ?? null); +} diff --git a/src/components/edit-transaction-modal.tsx b/src/components/edit-transaction-modal.tsx index 131d49e..707bd48 100644 --- a/src/components/edit-transaction-modal.tsx +++ b/src/components/edit-transaction-modal.tsx @@ -8,6 +8,8 @@ import { useRemoveTransactionTag, useTransactionSplits, useTrips, + useOrderReceipt, + type OrderReceipt, } from "@/lib/hooks"; import { SplitModal } from "./split-modal"; import { CATEGORIES, formatCategory } from "@/lib/categories"; @@ -85,6 +87,89 @@ function InlineTags({ transactionId, initialTags }: { transactionId: number; ini ); } +const PLATFORM_LABEL: Record = { + doordash: "DoorDash", + ubereats: "Uber Eats", + uber: "Uber", +}; + +/** + * The receipt behind a delivery order: what was actually bought, and where it + * went. All of it was already stored at ingest and none of it was reachable — + * the row showed a merchant and a total and nothing else. + * + * Read-only on purpose. This is what a provider sent, not something to edit. + */ +function OrderDetails({ transactionId, currency }: { transactionId: number; currency: string | null }) { + const { data: receipt, isLoading } = useOrderReceipt(transactionId); + if (isLoading || !receipt) return null; + + const cur = receipt.currency ?? currency ?? "AUD"; + const fmt = (n: number) => (cur === "AUD" ? `$${n.toFixed(2)}` : `${cur} ${n.toFixed(2)}`); + const items: OrderReceipt["line_items"] = receipt.line_items ?? []; + const route: OrderReceipt["route"] = receipt.route ?? []; + + return ( +
+
+

+ Order details + {receipt.platform && ( + {PLATFORM_LABEL[receipt.platform] ?? receipt.platform} + )} +

+ {receipt.card_last4 && ( + card ••••{receipt.card_last4} + )} +
+ + {items.length > 0 ? ( + + ) : ( + // Uber itemises groceries but not restaurant orders, and orders taken + // before this was parsed have none either. Say which, rather than + // showing an empty list that reads like a bug. +

+ No itemised list on this receipt +

+ )} + + {route.length > 0 && ( +
+ {route.map((pt, i) => ( +
+ + {pt.label} + {pt.time && {pt.time}} + + {pt.address} +
+ ))} +
+ )} + + {receipt.order_reference && !receipt.order_reference.startsWith("msg:") && ( +

+ {receipt.order_reference} +

+ )} +
+ ); +} + export function EditTransactionModal({ transaction, onClose, @@ -314,6 +399,8 @@ export function EditTransactionModal({ )} + + {/* Footer */} diff --git a/src/lib/hooks.ts b/src/lib/hooks.ts index 8bd5a50..688014f 100644 --- a/src/lib/hooks.ts +++ b/src/lib/hooks.ts @@ -250,6 +250,33 @@ export function useTransactionSplits(transactionId: number) { }); } +export interface OrderReceipt { + platform: "doordash" | "ubereats" | "uber" | null; + order_reference: string | null; + line_items: { qty: number; description: string; amount: number; options?: string[] }[]; + route: { label: string; time: string | null; address: string }[]; + subtotal: string | null; + amount: string | null; + currency: string | null; + card_last4: string | null; + flags: string[]; + source_email_subject: string | null; + transaction_date: string | null; +} + +/** The receipt behind a transaction, or null when it did not come from one. */ +export function useOrderReceipt(transactionId: number) { + return useQuery({ + queryKey: ["order-receipt", transactionId], + queryFn: async () => { + const res = await fetch(`/api/transactions/${transactionId}/order`); + if (!res.ok) return null; + return res.json(); + }, + staleTime: Infinity, // a receipt never changes + }); +} + export function useSetSplits() { const qc = useQueryClient(); return useMutation({