feat(orders): show the receipt in the transaction detail panel
ci / lint-test (push) Failing after 47s
ci / lint-test (push) Failing after 47s
`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). Adds GET /api/transactions/[id]/order and an "Order details" section in the edit modal: line items with their options, pick-up/delivery stops with times and addresses, the card tail when one was involved, and the provider's own order reference. Two details that matter: - The lookup resolves from **both** sides — `transaction_id` OR `matched_transaction_id`. A card-settled order creates no transaction of its own (I5); the receipt points at the statement line instead. Matching only on transaction_id would have left the panel blank on exactly the card-paid orders, which are the ones whose detail is hardest to find elsewhere. - An empty item list says so in words rather than rendering nothing. Uber itemises groceries but not restaurant orders, and orders ingested before the Uber item parser existed have none either — a blank section reads as a bug when it is usually the receipt. Read-only. This is what a provider sent; editing it would make provenance mean nothing.
This commit is contained in:
@@ -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<string, string> = {
|
||||
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 (
|
||||
<div className="border-t border-zinc-800 pt-4">
|
||||
<div className="flex items-baseline justify-between mb-2">
|
||||
<p className="text-xs text-zinc-500">
|
||||
Order details
|
||||
{receipt.platform && (
|
||||
<span className="ml-1.5 text-zinc-400">{PLATFORM_LABEL[receipt.platform] ?? receipt.platform}</span>
|
||||
)}
|
||||
</p>
|
||||
{receipt.card_last4 && (
|
||||
<span className="text-xs text-zinc-600">card ••••{receipt.card_last4}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{items.length > 0 ? (
|
||||
<ul className="space-y-1.5 mb-3">
|
||||
{items.map((it, i) => (
|
||||
<li key={i} className="flex gap-2 text-xs">
|
||||
<span className="text-zinc-600 tabular-nums shrink-0">{it.qty}×</span>
|
||||
<span className="text-zinc-300 flex-1 min-w-0">
|
||||
{it.description}
|
||||
{it.options && it.options.length > 0 && (
|
||||
<span className="block text-zinc-600">{it.options.join(" · ")}</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="text-zinc-400 tabular-nums shrink-0">{fmt(Number(it.amount))}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
// 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.
|
||||
<p className="text-xs text-zinc-600 italic mb-3">
|
||||
No itemised list on this receipt
|
||||
</p>
|
||||
)}
|
||||
|
||||
{route.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
{route.map((pt, i) => (
|
||||
<div key={i} className="flex gap-2 text-xs">
|
||||
<span className="text-zinc-600 shrink-0 w-24">
|
||||
{pt.label}
|
||||
{pt.time && <span className="block text-zinc-700">{pt.time}</span>}
|
||||
</span>
|
||||
<span className="text-zinc-400 flex-1">{pt.address}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{receipt.order_reference && !receipt.order_reference.startsWith("msg:") && (
|
||||
<p className="mt-3 text-[11px] text-zinc-700 font-mono break-all">
|
||||
{receipt.order_reference}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function EditTransactionModal({
|
||||
transaction,
|
||||
onClose,
|
||||
@@ -314,6 +399,8 @@ export function EditTransactionModal({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<OrderDetails transactionId={transaction.id} currency={transaction.currency ?? null} />
|
||||
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
|
||||
Reference in New Issue
Block a user