orders: a browse surface for the purchase history the ledger cannot show
ci / lint-test (push) Successful in 59s

The spine holds ~6,300 purchase orders back to 2006, ~4,600 of them itemised,
and 61 reach a transaction. Everything else has been visible only through SQL.
This adds /orders and /orders/[entityKey] over it.

The point of the page is the manifest. /transactions can only ever say "AMAZON
AU MARKETPLACE SYDNEY"; a row here says what was in the box, which is the one
thing the ledger structurally cannot carry.

Five lanes, because the shapes genuinely differ — retail ends refunded or
returned 14.1% of the time against food's 5.1%, food has no meaningful ETA where
grocery has one on 54.8% of orders, digital never ships at all. The lane comes
from order_lane() in migration 018 rather than a column, because slug 'uber'
carries 394 taxi rides and 485 Eats orders.

Defaults to this year: 449 orders rather than 6,283. Twenty-one years is the
archive, not the working set.

Three things the data forced. Unknown amounts render "not stated", never $0.00,
because 1,648 orders have no amount and a zero would be false. A full reversal
strikes the figure through; a partial refund does not, since striking $191.40
when $13.33 came back is a lie — the charge stays primary and the credit sits
under it with the net. And rows with no amount, no reference and one lifecycle
event are hidden by default, which lifts amount coverage from 74% to 84%; the
toggle says on its face that it is a workaround for board 210 rather than a fix.

Reads are raw SQL in lib/order-feed.ts rather than queries.ts: the spine is
written by the ingestion-engine, is not in prisma/schema.prisma and never will
be, and mixing it into a file where everything is Prisma-modelled would destroy
that invariant.

/orders is gated by an explicit viewer allowlist. Not because the three people
listed need protecting from each other — everything here is on one person's
cards — but because a participant is an accounting entity and any participant
row with an email is a login. Adding someone to split a holiday must not
silently hand them the purchase history.

Verified live: gate returns 403 for non-participants and for a missing identity
header; search "drone" finds the DJI order through its line items; the detail
page renders its three lifecycle events and its Afterpay settlement sibling; and
a bridged Amazon order shows both split-shipment charges.
This commit is contained in:
2026-08-12 12:15:16 +10:00
parent 872da3b12c
commit b26d526e83
7 changed files with 1142 additions and 0 deletions
+29
View File
@@ -0,0 +1,29 @@
import { NextRequest, NextResponse } from "next/server";
import { getCurrentUser } from "@/lib/auth";
import { getOrderDetail, canViewOrders } from "@/lib/order-feed";
/**
* GET /api/orders/[entityKey] — one order, its lifecycle, settlement siblings
* and any linked ledger transactions.
*
* Keyed on entity_key (text) rather than entity_id: entity_id is BIGINT and
* would break JSON.stringify, and the key is stable, unique and readable.
* All 8,147 order keys are URL-safe today, but nothing enforces that, so the
* client encodes and we decode.
*/
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ entityKey: string }> }
) {
const user = await getCurrentUser(req);
if (!user) return NextResponse.json({ error: "unauthorized" }, { status: 403 });
if (!canViewOrders(user.id)) {
return NextResponse.json({ error: "forbidden" }, { status: 403 });
}
const { entityKey } = await params;
const order = await getOrderDetail(decodeURIComponent(entityKey));
if (!order) return NextResponse.json({ error: "not found" }, { status: 404 });
return NextResponse.json(order);
}
+49
View File
@@ -0,0 +1,49 @@
import { NextRequest, NextResponse } from "next/server";
import { getCurrentUser } from "@/lib/auth";
import { getOrderFeed, getOrderFacets, canViewOrders } from "@/lib/order-feed";
/**
* GET /api/orders — the order browse list.
*
* Note this route is a sibling of /api/orders/ingest (the n8n webhook). Next
* resolves static segments before dynamic ones, so `ingest` is unaffected by
* the [entityKey] route next to it — but it does mean "ingest" is now a
* reserved order key. Every entity key starts "order_", so no real collision.
*/
export async function GET(req: NextRequest) {
const user = await getCurrentUser(req);
if (!user) return NextResponse.json({ error: "unauthorized" }, { status: 403 });
// The spine has no owner column — this is a participant gate, not a row
// filter. See ORDER_VIEWERS in lib/order-feed.ts for why it is an allowlist.
if (!canViewOrders(user.id)) {
return NextResponse.json({ error: "forbidden" }, { status: 403 });
}
const p = req.nextUrl.searchParams;
const list = (k: string) => p.get(k)?.split(",").filter(Boolean);
const filters = {
lane: p.get("lane") ?? undefined,
platforms: list("platforms"),
statuses: list("statuses"),
from: p.get("from") ?? undefined,
to: p.get("to") ?? undefined,
search: p.get("search") ?? undefined,
currency: p.get("currency") ?? undefined,
has_transaction: p.get("has_transaction") ?? undefined,
// buildParams encodes booleans as "1" and omits them when false, so an
// absent param means "on" here — the default hides lifecycle-only rows.
hide_lifecycle_only: p.get("show_lifecycle_only") !== "1",
sort_by: p.get("sort_by") ?? undefined,
sort_dir: p.get("sort_dir") ?? undefined,
limit: p.get("limit") ? Number(p.get("limit")) : undefined,
offset: p.get("offset") ? Number(p.get("offset")) : undefined,
};
const [result, facets] = await Promise.all([
getOrderFeed(filters),
getOrderFacets(filters),
]);
return NextResponse.json({ ...result, facets });
}
+246
View File
@@ -0,0 +1,246 @@
"use client";
import { use } from "react";
import Link from "next/link";
import { useOrderDetail } from "@/lib/hooks";
/**
* One order: what it was, what happened to it, and what paid for it.
*
* The lifecycle timeline IS the tracking UI. tracking_url is NULL on all 7,608
* spine rows and on all 11,092 order_event payloads, because the shared HTML
* renderer strips anchor hrefs before extraction ever sees them (board 208).
* Do not build a tracking widget against a field nothing populates.
*/
const KIND_LABEL: Record<string, string> = {
placed: "Order placed",
shipped: "Dispatched",
out_for_delivery: "Out for delivery",
delivered: "Delivered",
cancelled: "Cancelled",
returned: "Returned",
refunded: "Refunded",
};
const dateFmt = new Intl.DateTimeFormat("en-AU", { day: "numeric", month: "short", year: "numeric" });
function fmtMoney(amount: string | null | undefined, currency: string) {
if (amount === null || amount === undefined) return null;
const n = Number(amount);
if (!Number.isFinite(n)) return null;
try {
return new Intl.NumberFormat("en-AU", { style: "currency", currency, minimumFractionDigits: 2 }).format(n);
} catch {
return `${n.toFixed(2)} ${currency}`;
}
}
function Panel({ title, children }: { title: string; children: React.ReactNode }) {
return (
<section>
<h3 className="font-mono text-[10.5px] uppercase tracking-widest text-zinc-500 font-normal pb-2 mb-3 border-b border-zinc-800">
{title}
</h3>
{children}
</section>
);
}
export default function OrderDetailPage({ params }: { params: Promise<{ entityKey: string }> }) {
const { entityKey } = use(params);
const key = decodeURIComponent(entityKey);
const { data: o, isLoading, error } = useOrderDetail(key);
if (isLoading) return <div className="p-6 text-zinc-500 text-sm">Loading order</div>;
if (error || !o) {
return (
<div className="max-w-[1180px] mx-auto">
<Link href="/orders" className="font-mono text-[11.5px] text-indigo-400"> All orders</Link>
<p className="mt-6 text-zinc-400 text-sm">That order could not be found.</p>
</div>
);
}
const gross = Number(o.gross_total ?? o.order_total ?? NaN);
const refund = Number(o.refunded_amount ?? NaN);
const partial = Number.isFinite(gross) && Number.isFinite(refund) && refund > 0 && refund < gross;
const linkedTotal = o.transactions.reduce((a, t) => a + Math.abs(Number(t.amount) || 0), 0);
return (
<div className="max-w-[1180px] mx-auto">
<Link href="/orders" className="font-mono text-[11.5px] text-indigo-400 hover:text-indigo-300"> All orders</Link>
{/* A settled rail row must never look like an ordinary order — that is
how a human counts the same purchase twice. */}
{o.is_settled_duplicate && (
<div className="mt-4 bg-zinc-900 border-l-2 border-indigo-600 px-4 py-3">
<span className="block font-mono text-[10px] uppercase tracking-widest text-indigo-500 mb-1">
Payment record, not a separate purchase
</span>
<span className="text-[12.5px] text-zinc-300">
This restates another order and is deliberately excluded from spend totals.
</span>
</div>
)}
<header className="border-b border-zinc-800 pb-5 mt-4 mb-6">
<div className="font-mono text-[11.5px] text-zinc-400 tabular-nums">
{o.platform}
{o.order_reference && !o.order_reference.startsWith("msg-") && <> · order {o.order_reference}</>}
{o.ordered_at && <> · {dateFmt.format(new Date(o.ordered_at))}</>}
</div>
<h2 className="font-display text-[27px] leading-tight text-zinc-50 my-3 max-w-[26ch] text-balance">
{o.canonical_name || o.merchant_name || "Order"}
</h2>
<div className="flex gap-6 flex-wrap font-mono text-[11.5px] text-zinc-400 tabular-nums">
<div>
<span className="block text-[10px] uppercase tracking-widest text-zinc-500 mb-0.5">
{partial ? "Charged" : "Total"}
</span>
{fmtMoney(o.gross_total ?? o.order_total, o.currency) ?? <span className="italic text-zinc-500">not stated</span>}
</div>
{partial && (
<div>
<span className="block text-[10px] uppercase tracking-widest text-zinc-500 mb-0.5">Refunded</span>
<span className="text-indigo-400">{fmtMoney(o.refunded_amount, o.currency)}</span>
</div>
)}
<div>
<span className="block text-[10px] uppercase tracking-widest text-zinc-500 mb-0.5">Status</span>
{o.status.replace(/_/g, " ")}
</div>
{o.merchant_name && (
<div>
<span className="block text-[10px] uppercase tracking-widest text-zinc-500 mb-0.5">Merchant</span>
{o.merchant_name}
</div>
)}
</div>
</header>
<div className="grid gap-7 lg:grid-cols-[1.15fr_1fr]">
<div className="flex flex-col gap-7">
<Panel title="Lifecycle">
{/* Ordinal markers are legitimate HERE — the events genuinely are a
sequence and order carries meaning. They are absent from the
list, where they would only decorate. */}
<ol className="list-none m-0 p-0">
{o.events.map((e, i) => (
<li key={e.fact_id} className="grid grid-cols-[26px_92px_1fr_auto] gap-3 items-baseline py-2 border-b border-zinc-800/60">
<span className="font-mono text-[10.5px] text-indigo-600 tabular-nums">{String(i + 1).padStart(2, "0")}</span>
<span className="font-mono text-[11.5px] text-zinc-400 tabular-nums">
{e.effective_at ? dateFmt.format(new Date(e.effective_at)) : "—"}
</span>
<span className="text-[13px] text-zinc-100">
{KIND_LABEL[e.event_kind ?? ""] ?? e.event_kind ?? "Event"}
</span>
<span className="font-mono text-[11.5px] text-zinc-500 tabular-nums">
{fmtMoney(e.amount, e.currency ?? o.currency) ?? "—"}
</span>
</li>
))}
{!o.events.length && <li className="py-2 text-[13px] text-zinc-500">No lifecycle events recorded.</li>}
</ol>
</Panel>
<Panel title={`Contents${o.line_items.length ? `${o.line_items.length}` : ""}`}>
{o.line_items.length ? (
<ul className="list-none m-0 p-0">
{o.line_items.map((li, i) => (
<li key={i} className="flex justify-between gap-4 py-2 border-b border-zinc-800/60 text-[13px]">
<span className="text-zinc-100">
{li.description}
{li.quantity ? <span className="font-mono text-[11px] text-zinc-500 ml-2">×{li.quantity}</span> : null}
</span>
<span className="font-mono text-[12.5px] text-zinc-300 tabular-nums whitespace-nowrap">
{fmtMoney(li.amount != null ? String(li.amount) : null, o.currency) ?? ""}
</span>
</li>
))}
</ul>
) : (
<p className="text-[13px] text-zinc-500 italic m-0">No itemised list on this receipt.</p>
)}
</Panel>
</div>
<div className="flex flex-col gap-7">
<Panel title={o.transactions.length ? `Payments — ${o.transactions.length} charge${o.transactions.length > 1 ? "s" : ""}` : "Payments"}>
{o.transactions.length ? (
<>
<ul className="list-none m-0 p-0">
{o.transactions.map((t) => (
<li key={t.transaction_id} className="grid grid-cols-[1fr_auto] gap-3 items-baseline py-2 border-b border-zinc-800/60">
<span className="font-mono text-[12px] text-zinc-300">
{t.description}
<small className="block text-[10.5px] text-zinc-500">
{dateFmt.format(new Date(t.transaction_date))} · txn {t.transaction_id}
</small>
</span>
<span className="font-mono text-[13px] text-zinc-50 tabular-nums">
{fmtMoney(String(Math.abs(Number(t.amount))), o.currency)}
</span>
</li>
))}
</ul>
<div className="flex justify-between items-baseline pt-3 font-mono text-xs">
<span className="uppercase tracking-widest text-[10px] text-zinc-500">Accounted for</span>
<span className="text-indigo-300 text-sm tabular-nums">
{fmtMoney(String(linkedTotal), o.currency)}
{o.gross_total && <span className="text-zinc-500"> of {fmtMoney(o.gross_total, o.currency)}</span>}
</span>
</div>
</>
) : (
<p className="text-[13px] text-zinc-500 m-0 leading-relaxed">
No transaction linked yet. The ledger is statement-fed, so a recent purchase has
nothing to match against until its card statement is imported often a few weeks.
Charges only; refunds and fees are not linked in this phase.
</p>
)}
</Panel>
{o.siblings.length > 0 && (
<Panel title="Also recorded as">
<ul className="list-none m-0 p-0">
{o.siblings.map((s) => (
<li key={s.entity_key} className="py-2 border-b border-zinc-800/60">
<Link href={`/orders/${encodeURIComponent(s.entity_key)}`}
className="text-[13px] text-zinc-100 hover:text-indigo-300">
{s.canonical_name || s.entity_key}
</Link>
<span className="block font-mono text-[11px] text-zinc-500 tabular-nums">
{s.relation === "settled_by"
? "paid via this record"
: "this record pays for that order"}
{" · "}{s.platform}
{s.order_total && <> · {fmtMoney(s.order_total, s.currency)}</>}
</span>
</li>
))}
</ul>
</Panel>
)}
<Panel title="Provenance">
<dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-2 font-mono text-[11.5px] m-0">
<dt className="text-zinc-500">Key</dt>
<dd className="text-zinc-300 m-0 break-all">{o.entity_key}</dd>
<dt className="text-zinc-500">Reference</dt>
<dd className="text-zinc-300 m-0">
{o.reference_source === "message_id_fallback"
? <span className="text-indigo-500">none printed cannot merge with siblings</span>
: (o.order_reference ?? "—")}
</dd>
<dt className="text-zinc-500">Lane</dt>
<dd className="text-zinc-300 m-0">{o.lane}</dd>
<dt className="text-zinc-500">Trust</dt>
<dd className="text-zinc-300 m-0">{o.source_trust ?? "—"}</dd>
</dl>
</Panel>
</div>
</div>
</div>
);
}
+361
View File
@@ -0,0 +1,361 @@
"use client";
import { Suspense, useMemo, useState } from "react";
import Link from "next/link";
import { useOrders } from "@/lib/hooks";
import type { OrderRow } from "@/lib/order-feed";
/**
* Orders — browse the purchase history the ledger cannot show.
*
* The spine holds ~6,300 purchase orders back to 2006, ~4,600 of them
* itemised, of which a few dozen reach a transaction. Everything else is
* visible only here. The point of the page is the MANIFEST: /transactions can
* only ever say "AMAZON AU SYDNEY"; this says what was in the box.
*/
const LANES = [
{ id: "retail", label: "Retail" },
{ id: "food", label: "Food" },
{ id: "transport", label: "Transport" },
{ id: "digital", label: "Digital" },
{ id: "grocery", label: "Grocery" },
] as const;
// Twenty-one years is the archive, not the working set. Default to this year.
type RangeKey = "m0" | "m1" | "m3" | "y0" | "y1" | "all";
const RANGES: { id: RangeKey; label: string }[] = [
{ id: "m0", label: "This month" },
{ id: "m1", label: "Last month" },
{ id: "m3", label: "Last 3 months" },
{ id: "y0", label: "This year" },
{ id: "y1", label: "Last year" },
{ id: "all", label: "All 21 years" },
];
function iso(d: Date) {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
}
function rangeFor(key: RangeKey): { from?: string; to?: string; label: string } {
const now = new Date();
const y = now.getFullYear();
const m = now.getMonth();
switch (key) {
case "m0": return { from: iso(new Date(y, m, 1)), to: iso(now), label: "this month" };
case "m1": return { from: iso(new Date(y, m - 1, 1)), to: iso(new Date(y, m, 0)), label: "last month" };
case "m3": return { from: iso(new Date(y, m - 2, 1)), to: iso(now), label: "the last 3 months" };
case "y0": return { from: `${y}-01-01`, to: iso(now), label: String(y) };
case "y1": return { from: `${y - 1}-01-01`, to: `${y - 1}-12-31`, label: String(y - 1) };
case "all": return { label: "2006present" };
}
}
const IN_FLIGHT = new Set(["ordered", "shipped", "out_for_delivery"]);
const REVERSED = new Set(["refunded", "returned", "cancelled"]);
const dateFmt = new Intl.DateTimeFormat("en-AU", { day: "numeric", month: "short" });
const yearFmt = new Intl.DateTimeFormat("en-AU", { year: "numeric" });
function fmtMoney(amount: string | null, currency: string) {
if (amount === null || amount === undefined) return null;
const n = Number(amount);
if (!Number.isFinite(n)) return null;
try {
return new Intl.NumberFormat("en-AU", {
style: "currency", currency, minimumFractionDigits: 2,
}).format(n);
} catch {
// 20 currencies live in the spine, including a literal '$', XLM and MANA.
// Intl throws on those; show the number and the raw code rather than
// blowing up the row.
return `${n.toFixed(2)} ${currency}`;
}
}
function StatusPill({ status }: { status: string }) {
const live = IN_FLIGHT.has(status);
const bad = REVERSED.has(status);
const label = status.replace(/_/g, " ");
const cls = live
? "bg-indigo-600 text-zinc-950"
: bad
? "border border-zinc-500 text-zinc-400"
: "border border-zinc-700 text-zinc-400";
return (
<span className={`inline-block px-2 py-0.5 rounded-sm text-[10px] font-mono uppercase tracking-wider whitespace-nowrap ${cls}`}>
{label}
</span>
);
}
function Manifest({ row }: { row: OrderRow }) {
const items = row.item_preview ?? [];
if (!items.length) {
return <span className="text-xs text-zinc-500 italic">No itemised list on this receipt</span>;
}
const extra = row.line_item_count - items.length;
return (
<span className="text-[13px] text-zinc-300 leading-snug">
{items.map((d, i) => (
<span key={i}>
{i > 0 && <span className="text-indigo-800 mx-1.5">·</span>}
{d}
</span>
))}
{extra > 0 && <span className="font-mono text-xs text-indigo-500 ml-1.5">+{extra}</span>}
</span>
);
}
function Row({ row }: { row: OrderRow }) {
const reversed = REVERSED.has(row.status);
const gross = Number(row.order_total ?? NaN);
const refund = Number(row.refunded_amount ?? NaN);
const partial =
Number.isFinite(gross) && Number.isFinite(refund) && refund > 0 && refund < gross;
// A FULL reversal strikes the figure — the money all came back. A PARTIAL
// refund must not: striking $191.40 when $13.33 came back is a lie. The
// charge stays primary (it is what hit the card), the credit sits under it.
const struck = reversed && !partial;
const money = fmtMoney(row.order_total, row.currency);
return (
<tr className={`border-b border-zinc-800/60 hover:bg-zinc-900 ${struck ? "opacity-60" : ""}`}>
<td className="p-3 align-top font-mono text-[11px] text-zinc-400 tabular-nums whitespace-nowrap">
{row.ordered_at ? (
<>
{dateFmt.format(new Date(row.ordered_at))}
<span className="block text-[10px] text-zinc-500">{yearFmt.format(new Date(row.ordered_at))}</span>
</>
) : <span className="text-zinc-600"></span>}
</td>
<td className="p-3 align-top">
<div className="flex items-center gap-2 mb-1">
<Link
href={`/orders/${encodeURIComponent(row.entity_key)}`}
className="text-[13.5px] text-zinc-50 hover:text-indigo-300"
>
{row.merchant_name || row.platform || "Unknown merchant"}
</Link>
{row.reference_source === "message_id_fallback" && (
<span
title="No order reference in this mail — it cannot merge with its lifecycle siblings, so the same purchase may appear twice."
className="font-mono text-[9.5px] uppercase tracking-wide text-indigo-600 border border-indigo-800 rounded-sm px-1.5"
>no ref</span>
)}
{row.source_trust === "untrusted_external" && (
<span title="Content from an unverified sender" className="text-zinc-500 text-xs"></span>
)}
{row.txn_count > 0 && (
<span className="font-mono text-[9.5px] uppercase tracking-wide text-zinc-500 border border-zinc-700 rounded-sm px-1.5">
{row.txn_count === 1 ? "1 charge" : `${row.txn_count} charges`}
</span>
)}
</div>
<Manifest row={row} />
</td>
<td className="p-3 align-top"><StatusPill status={row.status} /></td>
<td className="p-3 align-top font-mono text-[11px] text-zinc-400 tabular-nums whitespace-nowrap">
{row.delivered_at
? dateFmt.format(new Date(row.delivered_at))
: row.eta_date
? <span className="text-zinc-500">ETA {dateFmt.format(new Date(row.eta_date))}</span>
: <span className="text-zinc-600"></span>}
</td>
<td className="p-3 align-top text-right font-mono text-[13.5px] text-zinc-50 tabular-nums whitespace-nowrap">
{money
? <span className={struck ? "line-through decoration-indigo-600" : ""}>{money}</span>
: <span className="text-zinc-500 italic text-xs">not stated</span>}
{partial && (
<>
<span className="block text-[11px] text-indigo-400 mt-0.5">
{fmtMoney(row.refunded_amount, row.currency)} refunded
</span>
<span className="block text-[10.5px] text-zinc-500">
net {fmtMoney(String(gross - refund), row.currency)}
</span>
</>
)}
</td>
</tr>
);
}
function OrdersContent() {
const [lane, setLane] = useState<string>("retail");
const [rangeKey, setRangeKey] = useState<RangeKey>("y0");
const [search, setSearch] = useState("");
const [showLifecycle, setShowLifecycle] = useState(false);
const [platform, setPlatform] = useState<string>("");
const [status, setStatus] = useState<string>("");
const [offset, setOffset] = useState(0);
const range = useMemo(() => rangeFor(rangeKey), [rangeKey]);
const limit = 50;
const filters = useMemo(() => ({
lane,
from: range.from,
to: range.to,
search: search || undefined,
platforms: platform ? [platform] : undefined,
statuses: status ? [status] : undefined,
show_lifecycle_only: showLifecycle,
limit,
offset,
}), [lane, range.from, range.to, search, platform, status, showLifecycle, offset]);
const { data, isLoading, error } = useOrders(filters);
const set = <T,>(fn: (v: T) => void) => (v: T) => { fn(v); setOffset(0); };
const laneCount = (id: string) => data?.facets.lanes.find((l) => l.lane === id)?.n ?? 0;
const page = Math.floor(offset / limit) + 1;
const pages = Math.max(1, Math.ceil((data?.total ?? 0) / limit));
return (
<div className="max-w-[1180px] mx-auto">
<div className="flex items-baseline justify-between gap-4 flex-wrap mb-1">
<h2 className="text-2xl font-display text-zinc-50">Orders</h2>
<div className="font-mono text-[11.5px] text-zinc-400 tabular-nums">
{data ? <><span className="text-zinc-200">{data.total.toLocaleString()}</span> orders in {range.label}</> : "—"}
</div>
</div>
{/* Date range. The archive reaches 2006; nobody browses two decades. */}
<div className="flex gap-1 flex-wrap py-3 border-y border-zinc-800 mb-4">
{RANGES.map((r) => (
<button
key={r.id}
onClick={() => set(setRangeKey)(r.id)}
aria-pressed={rangeKey === r.id}
className={`font-mono text-[10.5px] tracking-wide px-2.5 py-1 rounded-sm border ${
rangeKey === r.id
? "bg-indigo-600 border-indigo-600 text-zinc-950"
: "border-zinc-800 text-zinc-400 hover:border-zinc-600 hover:text-zinc-100"
}`}
>{r.label}</button>
))}
</div>
<div className="flex gap-0.5 flex-wrap mb-3" role="tablist" aria-label="Order lanes">
{LANES.map((l) => (
<button
key={l.id}
role="tab"
aria-selected={lane === l.id}
onClick={() => set(setLane)(l.id)}
className={`flex items-baseline gap-2 px-3 py-1.5 text-[13px] border-b-2 ${
lane === l.id
? "text-zinc-50 border-indigo-500"
: "text-zinc-400 border-transparent hover:text-zinc-100"
}`}
>
{l.label}
<span className={`font-mono text-[11px] tabular-nums ${lane === l.id ? "text-indigo-400" : "text-zinc-500"}`}>
{laneCount(l.id).toLocaleString()}
</span>
</button>
))}
</div>
<div className="flex gap-2.5 items-center flex-wrap py-3 border-b border-zinc-800">
<input
type="search"
value={search}
onChange={(e) => set(setSearch)(e.target.value)}
placeholder="Search items, merchants, order references…"
aria-label="Search orders"
className="flex-1 min-w-[200px] bg-zinc-900 border border-zinc-800 rounded-sm px-3 py-1.5 text-[13px] text-zinc-100 placeholder:text-zinc-500 focus:border-indigo-600 focus:outline-none"
/>
<select
value={platform}
onChange={(e) => set(setPlatform)(e.target.value)}
aria-label="Platform"
className="bg-zinc-900 border border-zinc-800 rounded-sm px-2 py-1.5 text-xs text-zinc-300"
>
<option value="">All platforms</option>
{data?.facets.platforms.map((p) => (
<option key={p.platform} value={p.platform}>{p.platform} ({p.n})</option>
))}
</select>
<select
value={status}
onChange={(e) => set(setStatus)(e.target.value)}
aria-label="Status"
className="bg-zinc-900 border border-zinc-800 rounded-sm px-2 py-1.5 text-xs text-zinc-300"
>
<option value="">Any status</option>
{data?.facets.statuses.map((s) => (
<option key={s.status} value={s.status}>{s.status.replace(/_/g, " ")} ({s.n})</option>
))}
</select>
<label className="flex items-center gap-2 text-xs text-zinc-400 cursor-pointer select-none"
title="Rows with no amount, no order reference and one lifecycle event are not purchases — they are a second entity minted from a mail describing an order that already exists. Hiding them is a workaround for a known ingestion defect, not a fix.">
<input
type="checkbox"
checked={showLifecycle}
onChange={(e) => set(setShowLifecycle)(e.target.checked)}
className="accent-indigo-500 cursor-pointer"
/>
Show lifecycle-only records
</label>
</div>
<div className="overflow-x-auto">
<table className="w-full border-collapse min-w-[720px]">
<thead>
<tr className="border-b border-zinc-800">
{["Ordered", "Merchant and contents", "Status", "Arrived", "Amount"].map((h, i) => (
<th key={h}
className={`p-3 pb-2 font-mono text-[10px] uppercase tracking-widest text-zinc-500 font-normal whitespace-nowrap ${i === 4 ? "text-right" : "text-left"}`}>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{isLoading && (
<tr><td colSpan={5} className="p-10 text-center text-zinc-500 text-[13px]">Loading orders</td></tr>
)}
{error && (
<tr><td colSpan={5} className="p-10 text-center text-zinc-400 text-[13px]">
Could not load orders. The spine views may not be migrated yet apply migration 018.
</td></tr>
)}
{data?.data.length === 0 && (
<tr><td colSpan={5} className="p-10 text-zinc-500 text-[13px]">
No orders in {range.label} match these filters. Widen the date range or clear the search.
</td></tr>
)}
{data?.data.map((r) => <Row key={r.entity_key} row={r} />)}
</tbody>
</table>
</div>
{(data?.total ?? 0) > limit && (
<div className="flex items-center justify-between gap-4 pt-4 font-mono text-[11.5px] text-zinc-400 tabular-nums">
<button
onClick={() => setOffset(Math.max(0, offset - limit))}
disabled={offset === 0}
className="px-3 py-1 border border-zinc-800 rounded-sm disabled:opacity-40 hover:border-zinc-600"
> Previous</button>
<span>Page {page} of {pages}</span>
<button
onClick={() => setOffset(offset + limit)}
disabled={page >= pages}
className="px-3 py-1 border border-zinc-800 rounded-sm disabled:opacity-40 hover:border-zinc-600"
>Next </button>
</div>
)}
</div>
);
}
export default function OrdersPage() {
return (
<Suspense fallback={<div className="p-6 text-zinc-500 text-sm">Loading</div>}>
<OrdersContent />
</Suspense>
);
}
+6
View File
@@ -6,6 +6,7 @@ import { useState, useEffect } from "react";
const NAV_ITEMS = [
{ href: "/transactions", label: "Transactions", icon: "receipt" },
{ href: "/orders", label: "Orders", icon: "package" },
{ href: "/statements", label: "Statements", icon: "file-text" },
{ href: "/trips", label: "Trips", icon: "map-pin" },
{ href: "/shared", label: "Shared", icon: "users" },
@@ -65,6 +66,11 @@ const ICONS: Record<string, React.ReactNode> = {
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 3v12M18 9a3 3 0 100-6 3 3 0 000 6zm0 0v12M6 15a3 3 0 100 6 3 3 0 000-6zm0 0c0-4 3-6 6-6h6" />
</svg>
),
package: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
</svg>
),
store: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 3h2l.4 2M7 13h10l4-8H5.4M7 13L5.4 5M7 13l-2.293 2.293c-.63.63-.184 1.707.707 1.707H17m0 0a2 2 0 100 4 2 2 0 000-4zm-8 2a2 2 0 11-4 0 2 2 0 014 0z" />
+41
View File
@@ -1207,3 +1207,44 @@ export function useAssignTransactionsToTrip() {
},
});
}
// ---------------------------------------------------------------- orders --
// Browse surface over the ingestion spine. See lib/order-feed.ts for why the
// spine is read raw and why /orders is gated by an explicit viewer allowlist.
import type { OrderRow, OrderFacets, OrderDetail, OrderFilters } from "./order-feed";
export type { OrderRow, OrderFacets, OrderDetail, OrderFilters };
interface OrdersResponse {
data: OrderRow[];
total: number;
limit: number;
offset: number;
facets: OrderFacets;
}
export function useOrders(filters: Record<string, unknown>) {
return useQuery<OrdersResponse>({
queryKey: ["orders", filters],
queryFn: async () => {
const res = await fetch(`/api/orders?${buildParams(filters as never)}`);
if (!res.ok) throw new Error("Failed to load orders");
return res.json();
},
});
}
export function useOrderDetail(entityKey: string | null) {
return useQuery<OrderDetail>({
queryKey: ["order", entityKey],
enabled: Boolean(entityKey),
// An order's history does not change while you are looking at it; the
// lifecycle only moves when the ingestion runner ticks.
staleTime: 60_000,
queryFn: async () => {
const res = await fetch(`/api/orders/${encodeURIComponent(entityKey!)}`);
if (!res.ok) throw new Error("Failed to load order");
return res.json();
},
});
}
+410
View File
@@ -0,0 +1,410 @@
/**
* Order spine reads for the /orders surface.
*
* These live here rather than in queries.ts on purpose. queries.ts is ~1,500
* lines of ledger SQL over tables Prisma models; the order spine
* (entity_orders, entities, extracted_facts, order_feed, order_spend) is
* written by the ingestion-engine, is NOT in prisma/schema.prisma, and never
* will be. Mixing it in would destroy that file's "everything here is
* Prisma-modelled" invariant. Same database, same connection, raw SQL.
*
* Two rules inherited from migration 010, worth restating because breaking
* either produces a plausible wrong number rather than an error:
*
* - BROWSE from order_feed. It keeps refunded/cancelled/returned rows, which
* belong on a history page and must never be summed.
* - SUM from order_spend, and only ever within a single currency. The spine
* holds 20 currencies including a literal '$', XLM and MANA.
*/
import { queryRaw, queryRow } from "./db";
/**
* The spine is ONE mailbox (Siddharth's) and carries no owner column, so
* /orders is gated by participant id rather than a row filter.
*
* This is an ALLOWLIST, and not because these three need protecting from each
* other — everything here is on Siddharth's cards (145 of 145 statements) and
* Sonu consumes the goods. It is because a participant is an ACCOUNTING
* entity, and any participant row carrying an email is a login: auth.ts
* matches x-forwarded-user against participants.email. Adding someone to split
* a holiday must not silently hand them the purchase history. These three are
* deliberate; a fourth has to be deliberate too.
*
* See DECISIONS.md ING-13 decision 5.
*/
export const ORDER_VIEWERS = [1, 4, 5]; // Siddharth, Sonu, Molina
export function canViewOrders(userId: number): boolean {
return ORDER_VIEWERS.includes(userId);
}
export type OrderLane = "retail" | "food" | "grocery" | "digital" | "transport";
export interface OrderRow {
entity_id: number;
entity_key: string;
canonical_name: string | null;
merchant_name: string | null;
platform: string | null;
lane: OrderLane;
status: string;
order_reference: string | null;
reference_source: string | null;
source_trust: string | null;
ordered_at: string | null;
eta_date: string | null;
delivered_at: string | null;
currency: string;
order_total: string | null;
refunded_amount: string | null;
line_item_count: number;
item_preview: string[] | null;
txn_count: number;
first_txn_id: number | null;
}
export interface OrderFilters {
lane?: string;
platforms?: string[];
statuses?: string[];
from?: string;
to?: string;
search?: string;
currency?: string;
has_transaction?: string;
hide_lifecycle_only?: boolean;
sort_by?: string;
sort_dir?: string;
limit?: number;
offset?: number;
}
/**
* Rows with no amount, no order reference and a single lifecycle event are not
* purchases — they are a second entity minted from a mail describing an order
* that already exists (Amazon "share your experience", DoorDash no-contact
* delivery details, shipment notices). 755 of them; hiding lifts amount
* coverage from 74% to 84%.
*
* This is a WORKAROUND for board 210, not a fix. The upstream defect is that a
* lifecycle mail printing no order reference cannot join its own order, so it
* becomes an orphan. Do not "fix" it here by deduplicating — 35 of the Amazon
* ones are the only record their purchase happened.
*/
const LIFECYCLE_ONLY = `NOT (
f.order_total IS NULL
AND f.reference_source = 'message_id_fallback'
AND (SELECT count(*) FROM extracted_facts ef
WHERE ef.fact_type = 'order_event'
AND ef.payload->>'_order_entity_key' = f.entity_key) <= 1
)`;
// Never interpolate a sort column from user input.
const SORT_COLUMNS: Record<string, string> = {
date: "f.ordered_at",
amount: "f.order_total",
eta: "f.eta_date",
platform: "f.platform",
items: "f.line_item_count",
};
function buildWhere(filters: OrderFilters) {
const conditions: string[] = [];
const params: unknown[] = [];
let i = 1;
if (filters.lane && filters.lane !== "all") {
conditions.push(`f.lane = $${i++}`);
params.push(filters.lane);
}
if (filters.platforms?.length) {
conditions.push(`f.platform = ANY($${i++}::text[])`);
params.push(filters.platforms);
}
if (filters.statuses?.length) {
conditions.push(`f.status = ANY($${i++}::text[])`);
params.push(filters.statuses);
}
if (filters.from) {
conditions.push(`f.ordered_at >= $${i++}::date`);
params.push(filters.from);
}
if (filters.to) {
// Half-open on the upper bound: ordered_at is a timestamptz, so
// `<= '2026-08-12'` silently drops everything after midnight that day.
conditions.push(`f.ordered_at < ($${i++}::date + 1)`);
params.push(filters.to);
}
if (filters.currency) {
conditions.push(`f.currency = $${i++}`);
params.push(filters.currency);
}
if (filters.search) {
// One bound term, five targets. The jsonb_typeof guard is NOT optional:
// jsonb_array_elements raises on a non-array, and details is a
// last-write-wins merge of extractor output whose shape is not guaranteed.
// Measured at 6,283 rows: 19 ms, sequential scan, no index warranted.
conditions.push(`(
f.canonical_name ILIKE $${i}
OR m.canonical_name ILIKE $${i}
OR f.platform ILIKE $${i}
OR f.order_reference ILIKE $${i}
OR EXISTS (
SELECT 1 FROM jsonb_array_elements(
CASE WHEN jsonb_typeof(f.details->'line_items') = 'array'
THEN f.details->'line_items' ELSE '[]'::jsonb END) li
WHERE li->>'description' ILIKE $${i})
)`);
params.push(`%${filters.search}%`);
i++;
}
if (filters.hide_lifecycle_only !== false) conditions.push(LIFECYCLE_ONLY);
if (filters.has_transaction === "yes") conditions.push(`link.txn_count > 0`);
if (filters.has_transaction === "no") conditions.push(`link.txn_count = 0`);
return { where: conditions.length ? `WHERE ${conditions.join(" AND ")}` : "", params, next: i };
}
/**
* Ledger linkage. Phase 1 reads expense_metadata; phase 2 swaps this lateral
* AND the detail query AND /api/transactions/[id]/order together — moving only
* one of the three leaves the list contradicting the detail page.
*/
const LINK_LATERAL = `
LEFT JOIN LATERAL (
SELECT count(*)::int AS txn_count,
min(em.matched_transaction_id) AS first_txn_id
FROM expense_metadata em
WHERE em.source = 'order-bridge'
AND (em.source_message_id = f.entity_key
OR em.source_message_id LIKE f.entity_key || '#f%')
) link ON true`;
const FROM_CLAUSE = `
FROM order_feed f
LEFT JOIN entities m ON m.id = f.merchant_entity_id
${LINK_LATERAL}`;
export async function getOrderFeed(filters: OrderFilters) {
const { where, params, next } = buildWhere(filters);
const countRows = await queryRaw<{ total: number }>(
`SELECT count(*)::int AS total ${FROM_CLAUSE} ${where}`,
params
);
const total = countRows[0]?.total ?? 0;
const sortCol = SORT_COLUMNS[filters.sort_by ?? "date"] ?? "f.ordered_at";
const sortDir = filters.sort_dir === "asc" ? "ASC" : "DESC";
const limit = Math.min(filters.limit ?? 50, 200);
const offset = filters.offset ?? 0;
const data = await queryRaw<OrderRow>(
`SELECT f.entity_id::int AS entity_id,
f.entity_key, f.canonical_name, f.platform, f.lane, f.status,
f.order_reference, f.reference_source, f.source_trust,
f.ordered_at, f.eta_date, f.delivered_at,
f.currency, f.order_total, f.line_item_count,
m.canonical_name AS merchant_name,
link.txn_count, link.first_txn_id,
(SELECT sp.refunded_amount FROM order_spend sp
WHERE sp.entity_id = f.entity_id) AS refunded_amount,
(SELECT jsonb_agg(t.li->>'description' ORDER BY t.n)
FROM jsonb_array_elements(
CASE WHEN jsonb_typeof(f.details->'line_items') = 'array'
THEN f.details->'line_items' ELSE '[]'::jsonb END)
WITH ORDINALITY t(li, n)
WHERE t.n <= 3) AS item_preview
${FROM_CLAUSE}
${where}
-- NULLS LAST always: eta_date is NULL on ~65% of retail and ~95% of food,
-- and Postgres sorts NULLs FIRST under DESC — an unguarded sort leads with
-- thousands of blanks and reads as an empty page.
ORDER BY ${sortCol} ${sortDir} NULLS LAST, f.entity_id DESC
LIMIT $${next} OFFSET $${next + 1}`,
[...params, limit, offset]
);
return { data, total, limit, offset };
}
export interface OrderFacets {
lanes: { lane: string; n: number }[];
platforms: { platform: string; n: number }[];
statuses: { status: string; n: number }[];
currencies: { currency: string; n: number }[];
}
/**
* Facets are computed under the active date range but NOT under the active
* lane — a lane tab showing "0" is information, a lane tab that vanished is a
* bug report waiting to happen.
*/
export async function getOrderFacets(filters: OrderFilters): Promise<OrderFacets> {
const base: OrderFilters = {
from: filters.from,
to: filters.to,
hide_lifecycle_only: filters.hide_lifecycle_only,
};
const { where, params } = buildWhere(base);
const laneScoped = buildWhere({ ...base, lane: filters.lane });
const [lanes, platforms, statuses, currencies] = await Promise.all([
queryRaw<{ lane: string; n: number }>(
`SELECT f.lane, count(*)::int AS n ${FROM_CLAUSE} ${where} GROUP BY 1 ORDER BY 2 DESC`, params),
queryRaw<{ platform: string; n: number }>(
`SELECT f.platform, count(*)::int AS n ${FROM_CLAUSE} ${laneScoped.where}
GROUP BY 1 ORDER BY 2 DESC LIMIT 40`, laneScoped.params),
queryRaw<{ status: string; n: number }>(
`SELECT f.status, count(*)::int AS n ${FROM_CLAUSE} ${laneScoped.where}
GROUP BY 1 ORDER BY 2 DESC`, laneScoped.params),
queryRaw<{ currency: string; n: number }>(
`SELECT f.currency, count(*)::int AS n ${FROM_CLAUSE} ${laneScoped.where}
GROUP BY 1 ORDER BY 2 DESC`, laneScoped.params),
]);
return { lanes, platforms, statuses, currencies };
}
export interface OrderLifecycleEvent {
fact_id: string;
event_kind: string | null;
effective_at: string | null;
amount: string | null;
currency: string | null;
document_title: string | null;
source_system: string | null;
}
export interface OrderSibling {
entity_key: string;
canonical_name: string | null;
platform: string | null;
order_total: string | null;
currency: string;
ordered_at: string | null;
relation: "settled_by" | "settles";
}
export interface OrderLinkedTxn {
transaction_id: number;
transaction_date: string;
description: string;
amount: string;
source_message_id: string;
}
export interface OrderDetail {
entity_key: string;
canonical_name: string | null;
merchant_name: string | null;
platform: string | null;
lane: OrderLane;
status: string;
direction: string;
order_reference: string | null;
reference_source: string | null;
source_trust: string | null;
ordered_at: string | null;
eta_date: string | null;
delivered_at: string | null;
currency: string;
order_total: string | null;
gross_total: string | null;
refunded_amount: string | null;
tracking_url: string | null;
tracking_carrier: string | null;
line_items: { description?: string; quantity?: number; amount?: number }[];
is_settled_duplicate: boolean;
events: OrderLifecycleEvent[];
siblings: OrderSibling[];
transactions: OrderLinkedTxn[];
}
export async function getOrderDetail(entityKey: string): Promise<OrderDetail | null> {
/**
* Read entity_orders DIRECTLY here — the one sanctioned exception to
* "browse from order_feed". A settled rail order is excluded from the feed
* by design, but must still be openable from the sibling link on the shop
* order it settles. Without this, following that link 404s.
*/
const head = await queryRow<Omit<OrderDetail, "events" | "siblings" | "transactions" | "line_items"> & {
line_items: unknown;
}>(
`SELECT e.entity_key, e.canonical_name, e.source_trust,
o.platform,
order_lane(o.platform, p.category, e.canonical_name) AS lane,
o.status, o.direction, o.order_reference, o.reference_source,
o.ordered_at, o.eta_date, o.delivered_at, o.currency,
o.order_total, o.tracking_url, o.tracking_carrier,
m.canonical_name AS merchant_name,
(o.settles_entity_id IS NOT NULL) AS is_settled_duplicate,
COALESCE(o.details->'line_items', '[]'::jsonb) AS line_items,
sp.order_total AS net_total,
sp.gross_total,
sp.refunded_amount
FROM entities e
JOIN entity_orders o ON o.entity_id = e.id
LEFT JOIN entities m ON m.id = o.merchant_entity_id
LEFT JOIN order_platforms p ON p.slug = o.platform
LEFT JOIN order_spend sp ON sp.entity_id = o.entity_id
WHERE e.entity_key = $1`,
[entityKey]
);
if (!head) return null;
const events = await queryRaw<OrderLifecycleEvent>(
// Uses the expression index on (payload->>'_order_entity_key') added in 016.
`SELECT ef.id::text AS fact_id,
ef.payload->>'event_kind' AS event_kind,
ef.effective_at, ef.amount, ef.currency,
sd.title AS document_title, sd.source_system
FROM extracted_facts ef
LEFT JOIN source_documents sd ON sd.id = ef.source_document_id
WHERE ef.fact_type = 'order_event'
AND ef.status = 'active'
AND ef.payload->>'_order_entity_key' = $1
ORDER BY ef.effective_at NULLS LAST, ef.id`,
[entityKey]
);
const siblings = await queryRaw<OrderSibling>(
`SELECT e2.entity_key, e2.canonical_name, o2.platform, o2.order_total,
o2.currency, o2.ordered_at, 'settled_by'::text AS relation
FROM entity_orders o2
JOIN entities e2 ON e2.id = o2.entity_id
WHERE o2.settles_entity_id = (SELECT id FROM entities WHERE entity_key = $1)
UNION ALL
SELECT e3.entity_key, e3.canonical_name, o3.platform, o3.order_total,
o3.currency, o3.ordered_at, 'settles'::text AS relation
FROM entity_orders o1
JOIN entity_orders o3 ON o3.entity_id = o1.settles_entity_id
JOIN entities e3 ON e3.id = o3.entity_id
WHERE o1.entity_id = (SELECT id FROM entities WHERE entity_key = $1)`,
[entityKey]
);
const transactions = await queryRaw<OrderLinkedTxn>(
`SELECT t.id::int AS transaction_id, t.transaction_date, t.description,
t.amount, em.source_message_id
FROM expense_metadata em
JOIN transactions t
ON t.id = COALESCE(em.matched_transaction_id, em.transaction_id)
WHERE em.source = 'order-bridge'
AND (em.source_message_id = $1 OR em.source_message_id LIKE $1 || '#f%')
ORDER BY t.transaction_date`,
[entityKey]
);
const raw = head as unknown as Record<string, unknown>;
return {
...(head as unknown as OrderDetail),
line_items:
typeof raw.line_items === "string"
? JSON.parse(raw.line_items as string)
: ((raw.line_items as OrderDetail["line_items"]) ?? []),
events,
siblings,
transactions,
};
}