From 4f0f0dd285534af826e4089d2dfd8685edb2c629 Mon Sep 17 00:00:00 2001 From: siddharthd Date: Fri, 21 Aug 2026 18:23:51 +1000 Subject: [PATCH] intake: escape the currency and the button label too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit money() interpolated the extracted currency raw, and parseExtraction accepts any string for it — so "<@U123>" would have been a live mention on every card the batch produced. And plain_text disables *formatting*, not entity decoding: &, < and > are control characters everywhere in a Slack payload, so the Create button's label is escaped like any other value we did not write. My earlier note claiming the opposite was wrong. Candidate rows also carry their transaction's currency (NATIVE_CURRENCY) rather than always printing "$" under a header reading "USD 22.40". --- src/lib/intake-blocks.ts | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/lib/intake-blocks.ts b/src/lib/intake-blocks.ts index 4b542e4..e591477 100644 --- a/src/lib/intake-blocks.ts +++ b/src/lib/intake-blocks.ts @@ -12,7 +12,7 @@ import { platformLabel } from "@/lib/intake-rules"; export type Block = Record; -export type Candidate = { transaction_id: number; description: string | null; transaction_date: string; amount: string | number }; +export type Candidate = { transaction_id: number; description: string | null; transaction_date: string; amount: string | number; currency?: string | null }; export function financePublicUrl(): string { return (process.env.FINANCE_PUBLIC_URL || "https://finance.bosecamp.com").replace(/\/$/, ""); @@ -42,7 +42,9 @@ export function esc(value: string | null | undefined): string { */ const money = (n: number | string, currency?: string | null) => { const amount = Number(n).toFixed(2); - return !currency || currency === "AUD" ? `$${amount}` : `${currency} ${amount}`; + // The currency comes off the model (`parseExtraction` accepts any string), so + // an unescaped `<@U123>` here would be a mention on every card. + return !currency || currency === "AUD" ? `$${amount}` : `${esc(currency)} ${amount}`; }; const section = (text: string): Block => ({ type: "section", text: { type: "mrkdwn", text } }); const button = (action_id: string, text: string, value: unknown, style?: "primary" | "danger"): Block => ({ @@ -103,15 +105,17 @@ export function candidatesCard(c: CardInput, candidates: Candidate[]): { blocks: for (const cand of candidates) { blocks.push({ type: "section", - text: { type: "mrkdwn", text: `${esc(cand.transaction_date)} · ${esc(cand.description) || "—"} · ${money(cand.amount)}` }, + text: { type: "mrkdwn", text: `${esc(cand.transaction_date)} · ${esc(cand.description) || "—"} · ${money(cand.amount, cand.currency)}` }, accessory: button("intake_use_candidate", "Use this", { batch_id: c.batchId, transaction_id: cand.transaction_id }), }); } blocks.push({ type: "actions", block_id: "intake_create", - // A button's `text` is plain_text, never mrkdwn — the raw merchant belongs here. - elements: [button("intake_create", `Create new ${c.total !== null ? money(c.total, c.currency) + " " : ""}${c.merchant ?? ""} transaction`.replace(/\s+/g, " ").trim(), { batch_id: c.batchId }, "primary")], + // plain_text disables *formatting*, not entity decoding: `&`, `<` and `>` + // are control characters everywhere in a Slack payload, so the label is + // escaped like any other value we did not write. + elements: [button("intake_create", `Create new ${c.total !== null ? money(c.total, c.currency) + " " : ""}${esc(c.merchant)} transaction`.replace(/\s+/g, " ").trim(), { batch_id: c.batchId }, "primary")], }); return { blocks, text }; }