diff --git a/src/app/api/orders/[entityKey]/route.ts b/src/app/api/orders/[entityKey]/route.ts new file mode 100644 index 0000000..87285a5 --- /dev/null +++ b/src/app/api/orders/[entityKey]/route.ts @@ -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); +} diff --git a/src/app/api/orders/route.ts b/src/app/api/orders/route.ts new file mode 100644 index 0000000..6f78503 --- /dev/null +++ b/src/app/api/orders/route.ts @@ -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 }); +} diff --git a/src/app/orders/[entityKey]/page.tsx b/src/app/orders/[entityKey]/page.tsx new file mode 100644 index 0000000..89a78da --- /dev/null +++ b/src/app/orders/[entityKey]/page.tsx @@ -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 = { + 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 ( +
+

+ {title} +

+ {children} +
+ ); +} + +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
Loading order…
; + if (error || !o) { + return ( +
+ ← All orders +

That order could not be found.

+
+ ); + } + + 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 ( +
+ ← All orders + + {/* 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 && ( +
+ + Payment record, not a separate purchase + + + This restates another order and is deliberately excluded from spend totals. + +
+ )} + +
+
+ {o.platform} + {o.order_reference && !o.order_reference.startsWith("msg-") && <> · order {o.order_reference}} + {o.ordered_at && <> · {dateFmt.format(new Date(o.ordered_at))}} +
+

+ {o.canonical_name || o.merchant_name || "Order"} +

+
+
+ + {partial ? "Charged" : "Total"} + + {fmtMoney(o.gross_total ?? o.order_total, o.currency) ?? not stated} +
+ {partial && ( +
+ Refunded + −{fmtMoney(o.refunded_amount, o.currency)} +
+ )} +
+ Status + {o.status.replace(/_/g, " ")} +
+ {o.merchant_name && ( +
+ Merchant + {o.merchant_name} +
+ )} +
+
+ +
+
+ + {/* 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. */} +
    + {o.events.map((e, i) => ( +
  1. + {String(i + 1).padStart(2, "0")} + + {e.effective_at ? dateFmt.format(new Date(e.effective_at)) : "—"} + + + {KIND_LABEL[e.event_kind ?? ""] ?? e.event_kind ?? "Event"} + + + {fmtMoney(e.amount, e.currency ?? o.currency) ?? "—"} + +
  2. + ))} + {!o.events.length &&
  3. No lifecycle events recorded.
  4. } +
+
+ + + {o.line_items.length ? ( +
    + {o.line_items.map((li, i) => ( +
  • + + {li.description} + {li.quantity ? ×{li.quantity} : null} + + + {fmtMoney(li.amount != null ? String(li.amount) : null, o.currency) ?? ""} + +
  • + ))} +
+ ) : ( +

No itemised list on this receipt.

+ )} +
+
+ +
+ 1 ? "s" : ""}` : "Payments"}> + {o.transactions.length ? ( + <> +
    + {o.transactions.map((t) => ( +
  • + + {t.description} + + {dateFmt.format(new Date(t.transaction_date))} · txn {t.transaction_id} + + + + {fmtMoney(String(Math.abs(Number(t.amount))), o.currency)} + +
  • + ))} +
+
+ Accounted for + + {fmtMoney(String(linkedTotal), o.currency)} + {o.gross_total && of {fmtMoney(o.gross_total, o.currency)}} + +
+ + ) : ( +

+ 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. +

+ )} +
+ + {o.siblings.length > 0 && ( + +
    + {o.siblings.map((s) => ( +
  • + + {s.canonical_name || s.entity_key} + + + {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)}} + +
  • + ))} +
+
+ )} + + +
+
Key
+
{o.entity_key}
+
Reference
+
+ {o.reference_source === "message_id_fallback" + ? none printed — cannot merge with siblings + : (o.order_reference ?? "—")} +
+
Lane
+
{o.lane}
+
Trust
+
{o.source_trust ?? "—"}
+
+
+
+
+
+ ); +} diff --git a/src/app/orders/page.tsx b/src/app/orders/page.tsx new file mode 100644 index 0000000..d74bf0d --- /dev/null +++ b/src/app/orders/page.tsx @@ -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: "2006–present" }; + } +} + +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 ( + + {label} + + ); +} + +function Manifest({ row }: { row: OrderRow }) { + const items = row.item_preview ?? []; + if (!items.length) { + return No itemised list on this receipt; + } + const extra = row.line_item_count - items.length; + return ( + + {items.map((d, i) => ( + + {i > 0 && ·} + {d} + + ))} + {extra > 0 && +{extra}} + + ); +} + +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 ( + + + {row.ordered_at ? ( + <> + {dateFmt.format(new Date(row.ordered_at))} + {yearFmt.format(new Date(row.ordered_at))} + + ) : } + + +
+ + {row.merchant_name || row.platform || "Unknown merchant"} + + {row.reference_source === "message_id_fallback" && ( + no ref + )} + {row.source_trust === "untrusted_external" && ( + + )} + {row.txn_count > 0 && ( + + {row.txn_count === 1 ? "1 charge" : `${row.txn_count} charges`} + + )} +
+ + + + + {row.delivered_at + ? dateFmt.format(new Date(row.delivered_at)) + : row.eta_date + ? ETA {dateFmt.format(new Date(row.eta_date))} + : } + + + {money + ? {money} + : not stated} + {partial && ( + <> + + −{fmtMoney(row.refunded_amount, row.currency)} refunded + + + net {fmtMoney(String(gross - refund), row.currency)} + + + )} + + + ); +} + +function OrdersContent() { + const [lane, setLane] = useState("retail"); + const [rangeKey, setRangeKey] = useState("y0"); + const [search, setSearch] = useState(""); + const [showLifecycle, setShowLifecycle] = useState(false); + const [platform, setPlatform] = useState(""); + const [status, setStatus] = useState(""); + 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 = (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 ( +
+
+

Orders

+
+ {data ? <>{data.total.toLocaleString()} orders in {range.label} : "—"} +
+
+ + {/* Date range. The archive reaches 2006; nobody browses two decades. */} +
+ {RANGES.map((r) => ( + + ))} +
+ +
+ {LANES.map((l) => ( + + ))} +
+ +
+ 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" + /> + + + +
+ +
+ + + + {["Ordered", "Merchant and contents", "Status", "Arrived", "Amount"].map((h, i) => ( + + ))} + + + + {isLoading && ( + + )} + {error && ( + + )} + {data?.data.length === 0 && ( + + )} + {data?.data.map((r) => )} + +
+ {h} +
Loading orders…
+ Could not load orders. The spine views may not be migrated yet — apply migration 018. +
+ No orders in {range.label} match these filters. Widen the date range or clear the search. +
+
+ + {(data?.total ?? 0) > limit && ( +
+ + Page {page} of {pages} + +
+ )} +
+ ); +} + +export default function OrdersPage() { + return ( + Loading…}> + + + ); +} diff --git a/src/components/sidebar.tsx b/src/components/sidebar.tsx index 886e741..11dde80 100644 --- a/src/components/sidebar.tsx +++ b/src/components/sidebar.tsx @@ -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 = { ), + package: ( + + + + ), store: ( diff --git a/src/lib/hooks.ts b/src/lib/hooks.ts index 92c2617..79100a5 100644 --- a/src/lib/hooks.ts +++ b/src/lib/hooks.ts @@ -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) { + return useQuery({ + 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({ + 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(); + }, + }); +} diff --git a/src/lib/order-feed.ts b/src/lib/order-feed.ts new file mode 100644 index 0000000..182c294 --- /dev/null +++ b/src/lib/order-feed.ts @@ -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 = { + 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( + `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 { + 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 { + /** + * 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 & { + 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( + // 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( + `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( + `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; + 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, + }; +}