feat(orders): show the receipt in the transaction detail panel
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:
2026-07-27 10:55:57 +10:00
parent df4b875b82
commit b6cd62f7b5
4 changed files with 220 additions and 0 deletions
@@ -437,3 +437,65 @@ describe("how an ingested order presents in the app", () => {
expect(row!.route.map((r) => r.label)).toEqual(["Pick-up", "Delivery"]); 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();
});
});
@@ -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);
}
+87
View File
@@ -8,6 +8,8 @@ import {
useRemoveTransactionTag, useRemoveTransactionTag,
useTransactionSplits, useTransactionSplits,
useTrips, useTrips,
useOrderReceipt,
type OrderReceipt,
} from "@/lib/hooks"; } from "@/lib/hooks";
import { SplitModal } from "./split-modal"; import { SplitModal } from "./split-modal";
import { CATEGORIES, formatCategory } from "@/lib/categories"; 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({ export function EditTransactionModal({
transaction, transaction,
onClose, onClose,
@@ -314,6 +399,8 @@ export function EditTransactionModal({
)} )}
</div> </div>
<OrderDetails transactionId={transaction.id} currency={transaction.currency ?? null} />
</div> </div>
{/* Footer */} {/* Footer */}
+27
View File
@@ -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<OrderReceipt | null>({
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() { export function useSetSplits() {
const qc = useQueryClient(); const qc = useQueryClient();
return useMutation({ return useMutation({