Files
finance-app/src/components/order-details.tsx
T
siddharthd 22e4a1ead0
ci / lint-test (push) Successful in 1m39s
fix(splits): make every split account for 100%
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.
2026-07-29 10:23:06 +10:00

460 lines
15 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";
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 &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>
);
}