Files
finance-app/src/components/order-details.tsx
T
siddharthd be3d10d4f1 intake: match merchants on whole words, not substrings
merchantKey("Bigals Pizza") contains "iga", so GROCERY_PATTERN filed a pizza
shop as a grocer and merchantMatches matched it against every IGA on the same
date and total. Short names in a substring list collide with ordinary words,
and the collision reads as a correct classification rather than a bug.

GROCERY_PATTERN is now a word-boundary alternation; merchantMatches asks
whether one name's words appear as a contiguous run inside the other's, which
keeps Coles <-> Coles (Wyndham Vale) and Order - Coles (Wyndham Vale).

Also collapses the third PLATFORM_LABEL copy: order-ingestion's was exported
and read nowhere, order-details.tsx had its own, and the intake one — the only
live copy — was missing `uber`. One map, in intake-rules.
2026-08-21 17:52:25 +10:00

478 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useMemo, useState } from "react";
import {
useOrderReceipt,
useOrderReview,
useParticipants,
useSetOrderReview,
useSetSplits,
useClearSplits,
type ItemOpinion,
type ItemVerdict,
type OrderReceipt,
type OrderRating,
} from "@/lib/hooks";
// One platform vocabulary, shared with the intake cards — three copies had
// already drifted apart (the intake one was missing `uber`).
import { platformLabel } from "@/lib/intake-rules";
/**
* Who records verdicts. A two-person household with one primary user: the
* owner records almost everything, and the only other consumer is Sonu (user,
* 2026-07-28). Mirrors OWNER_PARTICIPANT_ID / SECOND_CONSUMER_ID in
* `lib/order-reviews.ts` — duplicated rather than imported because that module
* pulls in the database client and this is a client component.
*/
const OWNER_PARTICIPANT_ID = 1;
const SECOND_CONSUMER_ID = 4;
const RATING_LABEL: Record<OrderRating, string> = {
loved: "Loved it",
liked: "Liked it",
ok: "OK",
bad: "Bad",
never: "Never again",
};
const RATING_STYLE: Record<OrderRating, string> = {
loved: "border-emerald-600 bg-emerald-950 text-emerald-300",
liked: "border-emerald-800 bg-emerald-950/50 text-emerald-400",
ok: "border-zinc-600 bg-zinc-800 text-zinc-300",
bad: "border-amber-800 bg-amber-950 text-amber-300",
never: "border-red-800 bg-red-950 text-red-300",
};
const RATING_ORDER: OrderRating[] = ["loved", "liked", "ok", "bad", "never"];
/**
* The receipt behind a delivery order: what was actually bought, where it went,
* and what we thought of it.
*
* The receipt half is read-only — it is what a provider sent, not something to
* edit. The verdict half is the only part of an order that changes, and it is
* the reason the receipts are ingested at all (ING-9): the ledger already knew
* we had ordered from here, but not that it was bad, so orders got repeated
* from places we disliked because nobody remembered.
*/
export function OrderDetails({
transactionId,
currency,
bare = false,
}: {
transactionId: number;
currency: string | null;
/** Drop the top border and heading spacing when embedded in a table row. */
bare?: boolean;
}) {
const { data: receipt, isLoading } = useOrderReceipt(transactionId);
const { data: review } = useOrderReview(transactionId);
const [reviewer, setReviewer] = useState(OWNER_PARTICIPANT_ID);
// `platform` is the marker of a real receipt. Since phase 2 the endpoint also
// answers with `{ order_link }` alone for a transaction that is merely LINKED
// to an order (a BNPL leg has no receipt of its own), and that object is
// truthy — without this check the panel would render a receipt shell with no
// merchant, no items and no total.
if (isLoading || !receipt || !receipt.platform) return null;
const cur = receipt.currency ?? currency ?? "AUD";
const fmt = (n: number) => (cur === "AUD" ? `$${n.toFixed(2)}` : `${cur} ${n.toFixed(2)}`);
const items: OrderReceipt["line_items"] = receipt.line_items ?? [];
const route: OrderReceipt["route"] = receipt.route ?? [];
const mine = review?.reviews.find((r) => r.participant_id === reviewer);
// Verdicts are the MEAL lane's feedback loop — "loved / never again" exists
// to steer the next restaurant order, and the nudge card warns on re-order.
// Spine-bridged retail (Amazon boxes, eBay, Officeworks) has no such loop:
// rating a roller-shade motor "order again?" is noise, so the whole review
// surface stays hidden for bridge rows.
const rateable = receipt.source !== "order-bridge";
return (
<div className={bare ? "" : "border-t border-zinc-800 pt-4"}>
<div className="flex items-baseline justify-between mb-2">
<p className="text-xs text-zinc-500">
Order details
{receipt.platform && (
<span className="ml-1.5 text-zinc-400">{platformLabel(receipt.platform)}</span>
)}
</p>
{receipt.card_last4 && (
<span className="text-xs text-zinc-600">card ••••{receipt.card_last4}</span>
)}
</div>
{items.length > 0 ? (
<ul className="space-y-1.5 mb-3">
{items.map((it, i) => (
<li key={i} className="flex gap-2 text-xs items-start">
<span className="text-zinc-600 tabular-nums shrink-0">{it.qty ?? 1}×</span>
<span className="text-zinc-300 flex-1 min-w-0">
{it.description}
{it.options && it.options.length > 0 && (
<span className="block text-zinc-600">{it.options.join(" · ")}</span>
)}
</span>
{rateable && (
<ItemVerdictToggle
transactionId={transactionId}
reviewer={reviewer}
item={it.description}
current={mine?.item_verdicts ?? []}
rating={mine?.rating ?? null}
note={mine?.note ?? null}
/>
)}
{/* Spine-bridged receipts (Amazon shipment notices, eBay) often
name the goods without per-item prices — a null here is "the
mail didn't say", and printing $0.00 would assert it did. */}
{it.amount != null && (
<span className="text-zinc-400 tabular-nums shrink-0">{fmt(Number(it.amount))}</span>
)}
</li>
))}
</ul>
) : (
// Uber itemises groceries but not restaurant orders, and orders taken
// before this was parsed have none either. Say which, rather than
// showing an empty list that reads like a bug.
<p className="text-xs text-zinc-600 italic mb-3">
No itemised list on this receipt
</p>
)}
{route.length > 0 && (
<div className="space-y-1">
{route.map((pt, i) => (
<div key={i} className="flex gap-2 text-xs">
<span className="text-zinc-600 shrink-0 w-24">
{pt.label}
{pt.time && <span className="block text-zinc-700">{pt.time}</span>}
</span>
<span className="text-zinc-400 flex-1">{pt.address}</span>
</div>
))}
</div>
)}
{receipt.order_reference && !receipt.order_reference.startsWith("msg:") && (
<p className="mt-3 text-[11px] text-zinc-700 font-mono break-all">
{receipt.order_reference}
</p>
)}
{rateable && (
<OrderVerdict
transactionId={transactionId}
reviewer={reviewer}
onReviewerChange={setReviewer}
/>
)}
</div>
);
}
/**
* Loved / never on a single line item, for the currently selected reviewer.
*
* Only the two poles are offered. A per-item "OK" is noise: the question at the
* next order is "what should I get, what should I avoid", and a middling dish
* answers neither.
*
* Every press sends the whole item array plus the current rating and note,
* because the endpoint upserts a row rather than patching fields — sending a
* partial would blank whatever it omitted.
*/
function ItemVerdictToggle({
transactionId,
reviewer,
item,
current,
rating,
note,
}: {
transactionId: number;
reviewer: number;
item: string;
current: ItemOpinion[];
rating: OrderRating | null;
note: string | null;
}) {
const save = useSetOrderReview();
const existing = current.find(
(v) => v.item.trim().toLowerCase() === item.trim().toLowerCase()
);
const toggle = (verdict: ItemVerdict) => {
const rest = current.filter(
(v) => v.item.trim().toLowerCase() !== item.trim().toLowerCase()
);
// Pressing the active verdict clears it — a mis-tap must be reversible, and
// there is no other route back to "no opinion on this dish".
const next =
existing?.verdict === verdict ? rest : [...rest, { item, verdict }];
save.mutate({
transactionId,
participantId: reviewer,
rating,
note,
itemVerdicts: next,
});
};
return (
<span className="flex gap-0.5 shrink-0">
{(["loved", "never"] as ItemVerdict[]).map((v) => (
<button
key={v}
type="button"
onClick={() => toggle(v)}
disabled={save.isPending}
title={v === "loved" ? "Loved this item" : "Never order this again"}
className={`rounded px-1 leading-none transition-opacity disabled:opacity-40 ${
existing?.verdict === v
? "opacity-100"
: "opacity-25 hover:opacity-60"
}`}
>
{v === "loved" ? "👍" : "👎"}
</button>
))}
</span>
);
}
/**
* Was this order shared? One tap, and the split is the answer.
*
* "Shared" means shared in both senses — we both ate it and we both pay for it
* — so this writes a real 50/50 `transaction_splits` row rather than a
* decorative flag (user, 2026-07-28: the split was part of the original
* requirement). There is no separate "shared" column precisely because the
* split already IS that record, and two records of one fact drift apart.
*
* Unsharing clears the splits. That is safe on an order because an ingested
* order is post-cutover by construction — the DB CHECK forbids credits orders
* before 2026-01-09 — so no settled historical obligation can be sitting on it
* to lose.
*/
function SharedToggle({
transactionId,
splits,
otherName,
}: {
transactionId: number;
splits: { participant_id: number; share_percent: string }[];
otherName: string;
}) {
const setSplits = useSetSplits();
const clearSplits = useClearSplits();
const shared = splits.some((s) => s.participant_id === SECOND_CONSUMER_ID);
return (
<div className="mb-2 flex items-center gap-2">
<button
type="button"
disabled={setSplits.isPending || clearSplits.isPending}
onClick={() =>
// Both halves on the way in, and a real delete on the way out. This
// used to post one 50% row to share and an empty array to un-share,
// and the endpoint rejected both — the toggle did nothing either way.
shared
? clearSplits.mutate(transactionId)
: setSplits.mutate({
transactionId,
splits: [
{ participant_id: OWNER_PARTICIPANT_ID, share_percent: 50 },
{ participant_id: SECOND_CONSUMER_ID, share_percent: 50 },
],
})
}
className={`rounded border px-2 py-1 text-xs transition-colors disabled:opacity-50 ${
shared
? "border-sky-700 bg-sky-950 text-sky-300"
: "border-zinc-700 text-zinc-500 hover:border-zinc-600 hover:text-zinc-300"
}`}
>
{shared ? `Shared 50/50 with ${otherName}` : "Just me"}
</button>
{shared && (
<span className="text-[11px] text-zinc-600">
ask {otherName} for her verdict too
</span>
)}
</div>
);
}
/**
* The overall verdict, whose it is, and this merchant's track record.
*
* The history sits above the buttons deliberately: it is read before the next
* order, not after, and burying it under the form is how you re-order from a
* place you already rejected.
*/
function OrderVerdict({
transactionId,
reviewer,
onReviewerChange,
}: {
transactionId: number;
reviewer: number;
onReviewerChange: (id: number) => void;
}) {
const { data, isLoading } = useOrderReview(transactionId);
const { data: participants } = useParticipants();
const save = useSetOrderReview();
const [noteDraft, setNoteDraft] = useState<string | null>(null);
const reviewers = useMemo(
() =>
[OWNER_PARTICIPANT_ID, SECOND_CONSUMER_ID].map((id) => ({
id,
name:
id === OWNER_PARTICIPANT_ID
? "Me"
: participants?.find((p) => p.id === id)?.name ?? "Them",
})),
[participants]
);
// No merchant means no receipt behind this row — nothing to have a view on.
if (isLoading || !data?.merchant) return null;
const mine = data.reviews.find((r) => r.participant_id === reviewer);
const current = mine?.rating ?? null;
const noteValue = noteDraft ?? mine?.note ?? "";
const { history, warn, items } = data.merchant;
const others = data.reviews.filter((r) => r.participant_id !== reviewer && r.rating);
const set = (rating: OrderRating) =>
save.mutate({
transactionId,
participantId: reviewer,
// Pressing the active rating clears it — otherwise a mis-tap is
// permanent, and there is no other way back to "no opinion".
rating: rating === current ? null : rating,
note: noteValue.trim() || null,
});
return (
<div className="mt-4 border-t border-zinc-800 pt-3">
{warn && (
<p className="mb-2 text-xs text-red-400">
Marked &ldquo;never again&rdquo; here before.
</p>
)}
{items.length > 0 && (
<p className="mb-2 text-[11px] text-zinc-500">
{items
.filter((i) => i.loved > i.never)
.slice(0, 3)
.map((i) => `👍 ${i.item}`)
.concat(
items
.filter((i) => i.never > 0)
.slice(0, 3)
.map((i) => `👎 ${i.item}`)
)
.join(" · ")}
</p>
)}
<SharedToggle
transactionId={transactionId}
splits={data.splits}
otherName={reviewers[1].name}
/>
<div className="flex items-center gap-2 mb-2">
{reviewers.map((r) => (
<button
key={r.id}
type="button"
onClick={() => {
setNoteDraft(null); // the draft belongs to the person who typed it
onReviewerChange(r.id);
}}
className={`text-xs transition-colors ${
reviewer === r.id
? "text-zinc-200 underline underline-offset-4"
: "text-zinc-600 hover:text-zinc-400"
}`}
>
{r.name}
{data.reviews.some((v) => v.participant_id === r.id && v.rating) && " ✓"}
</button>
))}
</div>
<div className="flex flex-wrap gap-1.5">
{RATING_ORDER.map((r) => (
<button
key={r}
type="button"
onClick={() => set(r)}
disabled={save.isPending}
className={`rounded border px-2 py-1 text-xs transition-colors disabled:opacity-50 ${
current === r
? RATING_STYLE[r]
: "border-zinc-700 text-zinc-500 hover:border-zinc-600 hover:text-zinc-300"
}`}
>
{RATING_LABEL[r]}
</button>
))}
</div>
<input
type="text"
value={noteValue}
placeholder="What was wrong (or right)?"
onChange={(e) => setNoteDraft(e.target.value)}
onBlur={() => {
const next = noteValue.trim() || null;
if (next !== (mine?.note ?? null)) {
save.mutate({
transactionId,
participantId: reviewer,
rating: current,
note: next,
});
}
}}
className="mt-2 w-full rounded border border-zinc-800 bg-zinc-900 px-2 py-1 text-xs text-zinc-300 placeholder:text-zinc-700 focus:border-zinc-600 focus:outline-none"
/>
{others.map((o) => (
<p key={o.participant_id} className="mt-1.5 text-[11px] text-zinc-500">
<span className="text-zinc-400">{o.participant_name}:</span>{" "}
{o.rating && RATING_LABEL[o.rating]}
{o.note && <span className="italic"> — {o.note}</span>}
</p>
))}
{history.length > 0 && (
<ul className="mt-2 space-y-1">
{history.map((h) => (
<li
key={`${h.transaction_id}-${h.participant_id}`}
className="text-[11px] text-zinc-600"
>
<span className="tabular-nums">{h.transaction_date}</span>
<span className="ml-1.5 text-zinc-500">
{h.participant_name}
{h.rating ? ` · ${RATING_LABEL[h.rating]}` : ""}
</span>
{h.note && <span className="ml-1.5 italic">{h.note}</span>}
</li>
))}
</ul>
)}
</div>
);
}