orders: a browse surface for the purchase history the ledger cannot show
ci / lint-test (push) Successful in 59s
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:
@@ -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();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user