ci / lint-test (push) Successful in 1m39s
A 50/50 arrangement was stored as a single row saying "Sonu 50%". The
arithmetic was never wrong — `myShare` resolves the payer's share as
`100 - SUM(everyone else)`, so balances and per-user spend were correct
throughout. It was still a bug, because a ledger is read as well as
computed: on screen that row is a 50% share against a blank, which looks
like half the money is unallocated and is indistinguishable from a split
somebody abandoned half-finished.
It also leaked. `getSharedTransactions` filters by participant with an
EXISTS on an explicit split row, so filtering the Shared view by the
payer silently dropped every transaction where their share was only ever
implied.
Four write paths could produce it, three of them unguarded:
- the Slack nudge's share button, which inserted one row
- `POST /api/transactions`, where the add form shows an amber total
under 100 but saves anyway — this is how Lawn Mowing and Hedge
Pruning were stored
- `applyRuleActions`, where ten of the fourteen live split rules name
only the other person
`completeSplit` is now the single place that writes the remainder, and
every one of those paths ends in it. The remainder goes to the
transaction's *owner*, never to "me": the owner's row on their own
transaction is excluded from both halves of the balance query, so it
cannot create, enlarge or discharge a debt, whereas a row for me on
someone else's transaction is a real obligation. That distinction is
what makes this safe to apply to existing data.
Also fixes the order panel's "Shared 50/50" toggle, which was inert in
both directions: it posted a lone 50% row to share (rejected — must
total 100%) and an empty array to un-share (rejected — array required),
because no way to clear a split existed. DELETE on the splits route is
that way.
Backfill: 7 rows, verified against a row-level dump diff — 2549 -> 2556
rows, none removed, none modified — and participant balances byte
identical before and after (Molina 19556.07, Sonu 20913.35). Every split
in the database now totals 100%.
Not done: a database-level constraint. Enforcing the sum needs a
deferred constraint trigger, and the rule path commits its DELETE and
INSERT as separate statements, so the trigger would reject the
intermediate state. Making it work means wrapping every write path in a
transaction, which is a larger change than the defect warrants.
460 lines
15 KiB
TypeScript
460 lines
15 KiB
TypeScript
"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";
|
||
|
||
const PLATFORM_LABEL: Record<string, string> = {
|
||
doordash: "DoorDash",
|
||
ubereats: "Uber Eats",
|
||
uber: "Uber",
|
||
};
|
||
|
||
/**
|
||
* 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);
|
||
|
||
if (isLoading || !receipt) return null;
|
||
|
||
const cur = receipt.currency ?? currency ?? "AUD";
|
||
const fmt = (n: number) => (cur === "AUD" ? `$${n.toFixed(2)}` : `${cur} ${n.toFixed(2)}`);
|
||
const items: OrderReceipt["line_items"] = receipt.line_items ?? [];
|
||
const route: OrderReceipt["route"] = receipt.route ?? [];
|
||
|
||
const mine = review?.reviews.find((r) => r.participant_id === reviewer);
|
||
|
||
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">{PLATFORM_LABEL[receipt.platform] ?? receipt.platform}</span>
|
||
)}
|
||
</p>
|
||
{receipt.card_last4 && (
|
||
<span className="text-xs text-zinc-600">card ••••{receipt.card_last4}</span>
|
||
)}
|
||
</div>
|
||
|
||
{items.length > 0 ? (
|
||
<ul className="space-y-1.5 mb-3">
|
||
{items.map((it, i) => (
|
||
<li key={i} className="flex gap-2 text-xs items-start">
|
||
<span className="text-zinc-600 tabular-nums shrink-0">{it.qty}×</span>
|
||
<span className="text-zinc-300 flex-1 min-w-0">
|
||
{it.description}
|
||
{it.options && it.options.length > 0 && (
|
||
<span className="block text-zinc-600">{it.options.join(" · ")}</span>
|
||
)}
|
||
</span>
|
||
<ItemVerdictToggle
|
||
transactionId={transactionId}
|
||
reviewer={reviewer}
|
||
item={it.description}
|
||
current={mine?.item_verdicts ?? []}
|
||
rating={mine?.rating ?? null}
|
||
note={mine?.note ?? 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>
|
||
)}
|
||
|
||
<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 “never again” 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>
|
||
);
|
||
}
|