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:
@@ -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);
|
||||||
|
}
|
||||||
@@ -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 */}
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
Reference in New Issue
Block a user