Files
finance-app/src/lib/order-reviews.ts
T
siddharthd d4ff68363a expense_metadata: every reader resolves the row a write lands on
getTransactions' provenance LATERAL, merchantForTransaction and the Slack
details modal all select on transaction_id = $1 OR matched_transaction_id = $1
with a bare LIMIT 1, so they could read a different row than replaceLineItems
writes. Same ORDER BY as the match, the write and the order-detail GET.

Defensive, not a live bug: measured 2026-08-21, expense_metadata holds 667
rows, 147 of them pointing-only, and ZERO transactions resolve to more than one
row by either link. This changes nothing today. It is here so the readers still
agree on the day multiplicity appears, which is the only condition under which
any of them was ever wrong.
2026-08-21 18:23:31 +10:00

215 lines
8.4 KiB
TypeScript

import { queryRaw, queryRow } from "@/lib/db";
/**
* Verdicts on delivery orders — the "don't order from here again" memory.
*
* The problem this exists for is not accounting. Orders were placed twice from
* places we disliked because nobody remembered by the time the next order went
* in (user, 2026-07-28). The ledger already knew we had been there; it just had
* nowhere to record what we thought of it.
*
* **A verdict is recorded per order per person, but read per merchant.**
* `order_reviews` keys on `(transaction_id, participant_id)`, because what you
* are judging is one delivery — this Thai place was bad *that night*, with
* those items — and because a shared meal produces two opinions that routinely
* disagree. That disagreement is the useful part; one row per transaction
* cannot hold it.
*
* The signal you need later is about the merchant, so it is derived by
* aggregating a merchant's orders rather than stored on one. Storing it per
* merchant instead would mean the second verdict silently overwrites the first
* and you lose the fact that it was fine twice and awful once.
*
* The join key is `expense_metadata.merchant_normalized`, not
* `transactions.merchant_name`: the latter is a bank descriptor and reads
* differently for the same restaurant on different nights.
*/
/**
* Five levels. Three collapsed the distinction that decides a re-order —
* "loved" and "liked" are both "would order again" but only one is worth going
* out of your way for, and "ok" is not a recommendation at all. "bad" was added
* because the jump from "ok" to "never again" is too big and most
* disappointments live in the gap (user, 2026-07-28).
*
* Only "never" raises the warning on a future order. A blacklist that fires for
* every mediocre meal stops being read, so "bad" records the disappointment
* without triggering the alarm.
*/
export type Rating = "loved" | "liked" | "ok" | "bad" | "never";
export const RATINGS: Rating[] = ["loved", "liked", "ok", "bad", "never"];
/**
* Per-item opinions, keyed by the line item's description.
*
* Only the poles are offered. A per-item "ok" is noise: the useful question at
* the next order is "what should I get / what should I avoid here", and a
* middling dish answers neither.
*/
export type ItemVerdict = "loved" | "never";
export const ITEM_VERDICTS: ItemVerdict[] = ["loved", "never"];
export interface ItemOpinion {
item: string;
verdict: ItemVerdict;
}
/**
* Whose verdict this is by default, and who the other one is.
*
* A two-person household with one primary user: the owner records almost every
* verdict, and the only other consumer is Sonu (user, 2026-07-28). Named rather
* than inlined so the Slack nudge, the split it creates and the second verdict
* it asks for cannot drift apart.
*/
export const OWNER_PARTICIPANT_ID = 1;
export const SECOND_CONSUMER_ID = 4;
export interface OrderReview {
transaction_id: number;
participant_id: number;
participant_name?: string;
rating: Rating | null;
order_again: boolean | null;
note: string | null;
item_verdicts: ItemOpinion[];
updated_at: string;
}
export interface MerchantVerdict {
merchant: string;
/** Verdicts on OTHER orders from this merchant, newest first. */
history: {
transaction_id: number;
participant_id: number;
participant_name: string;
rating: Rating | null;
note: string | null;
transaction_date: string | null;
}[];
counts: Record<Rating, number>;
/**
* True when this merchant has ever been marked `never` — and ONLY `never`.
* "bad" is deliberately excluded: the warning is a blacklist, and one that
* fires for every mediocre meal is one nobody reads.
*/
warn: boolean;
/**
* What to get and what to avoid here, pooled across every order from this
* merchant. This is the payoff for recording items at all — the order-level
* rating tells you whether to come back, this tells you what to order when
* you do.
*/
items: { item: string; loved: number; never: number }[];
}
/**
* `merchant_normalized` for a transaction, resolving both directions.
*
* A card-settled order has no transaction of its own — the statement line is
* the transaction and the receipt points at it through
* `matched_transaction_id`. Looking only at `transaction_id` misses exactly the
* orders that were paid by card, which is most of them.
*/
export async function merchantForTransaction(
transactionId: number
): Promise<string | null> {
const row = await queryRow<{ merchant_normalized: string | null }>(
`SELECT merchant_normalized FROM expense_metadata
WHERE transaction_id = $1 OR matched_transaction_id = $1
-- Ordered the same way as replaceLineItems (line-items.ts) and
-- matchTransaction (intake-router.ts), so every reader of this row
-- resolves the same one a write lands on. NULLS LAST because DESC is
-- NULLS FIRST and the row that merely POINTS at the transaction has a
-- NULL transaction_id.
-- Measured 2026-08-21: 667 expense_metadata rows, 147 of them
-- pointing-only, and ZERO transactions resolving to more than one row by
-- either link — so this changes nothing today. It is here so the readers
-- still agree on the day multiplicity appears.
ORDER BY (transaction_id = $1) DESC NULLS LAST, id
LIMIT 1`,
[transactionId]
);
return row?.merchant_normalized ?? null;
}
/**
* What we have previously said about a merchant.
*
* `exclude` drops the order being looked at, so the panel shows "what you said
* the other times" rather than echoing the verdict you are currently editing.
* Pass null when there is no current order — the ingest path, where the whole
* point is that nothing has been said about this one yet.
*/
export async function merchantVerdict(
merchant: string | null,
exclude: number | null = null
): Promise<MerchantVerdict | null> {
if (!merchant) return null;
const rows = await queryRaw<{
transaction_id: number;
participant_id: number;
participant_name: string;
rating: Rating | null;
note: string | null;
transaction_date: string | null;
item_verdicts: ItemOpinion[] | null;
}>(
// `rating IS NOT NULL` is deliberately NOT in the WHERE clause: a review
// can carry item verdicts and no overall rating, and dropping those would
// lose exactly the "the noodles here are great" signal this exists for.
`SELECT r.transaction_id, r.participant_id, p.name AS participant_name,
r.rating, r.note, r.item_verdicts,
to_char(t.transaction_date, 'YYYY-MM-DD') AS transaction_date
FROM order_reviews r
JOIN transactions t ON t.id = r.transaction_id
JOIN participants p ON p.id = r.participant_id
JOIN expense_metadata em
ON em.transaction_id = r.transaction_id
OR em.matched_transaction_id = r.transaction_id
-- Case-folded: the platforms capitalise the same restaurant differently
-- ("TEG Kebabs & Biryani" on Uber Eats, "TEG KEBABS & BIRYANI" on
-- DoorDash). An exact match split one restaurant's history in two, so a
-- "never again" recorded through one app never warned you in the other —
-- silently defeating the whole point of the memory.
WHERE lower(em.merchant_normalized) = lower($1)
AND ($2::int IS NULL OR r.transaction_id <> $2)
ORDER BY t.transaction_date DESC, r.participant_id
LIMIT 50`,
[merchant, exclude]
);
const counts: Record<Rating, number> = { loved: 0, liked: 0, ok: 0, bad: 0, never: 0 };
for (const r of rows) if (r.rating) counts[r.rating] += 1;
// Pool item opinions across orders. Case-folded because the same dish comes
// back with inconsistent capitalisation between receipts; the first spelling
// seen is kept for display.
const pool = new Map<string, { item: string; loved: number; never: number }>();
for (const r of rows) {
for (const v of r.item_verdicts ?? []) {
if (!v?.item) continue;
const key = v.item.trim().toLowerCase();
const entry = pool.get(key) ?? { item: v.item.trim(), loved: 0, never: 0 };
if (v.verdict === "loved") entry.loved += 1;
else if (v.verdict === "never") entry.never += 1;
pool.set(key, entry);
}
}
return {
merchant,
history: rows
.filter((r) => r.rating !== null || r.note)
.map(({ item_verdicts: _drop, ...h }) => h),
counts,
warn: counts.never > 0,
items: [...pool.values()].sort(
(a, b) => b.loved + b.never - (a.loved + a.never)
),
};
}