From bae4f9e7953a7e0fd71063dcad25e316d79293ac Mon Sep 17 00:00:00 2001
From: Dave
Date: Sat, 31 Oct 2026 10:20:23 +0200
Subject: [PATCH] Lock quote letterheads to customer companies
Add a per-company quote letterhead lock backed by `companies.confirmed_letterhead`. The quote process flow prompts for a default when unset, permits only the confirmed letterhead with a triple-click per-quote override, and enforces the rule during PDF generation and email sending. Also add company settings controls and API, email-ingest defaults, migration and backfill tooling, and unit coverage.
---
AGENTS.md | 39 +++--
package.json | 1 +
...2026-10-31-backfill-company-letterheads.ts | 116 ++++++++++++++
scripts/2026-10-31-clean-deliveries-board.ts | 137 ++++++++++++++++
.../2026-10-31-add-company-letterhead.ts | 60 +++++++
src/app/(app)/companies/[id]/page.tsx | 67 ++++++++
src/app/(app)/quotes/[id]/process/page.tsx | 87 ++++++----
.../api/v1/companies/[id]/letterhead/route.ts | 38 +++++
.../api/v1/quotes/[id]/pdf/base64/route.ts | 18 ++-
src/app/api/v1/quotes/[id]/pdf/route.ts | 23 ++-
.../process-letterhead-buttons.test.tsx | 53 ++++++
.../quotes/process-letterhead-buttons.tsx | 151 ++++++++++++++++++
src/lib/company-letterhead.test.ts | 48 ++++++
src/lib/company-letterhead.ts | 82 ++++++++++
src/lib/delivery-pool.ts | 2 +-
src/lib/server/company-letterhead-service.ts | 46 ++++++
src/lib/server/quote-pdf-service.test.ts | 146 +++++++++++++++++
src/lib/server/quote-pdf-service.ts | 134 +++++++++++++---
src/lib/server/quotes-from-email-service.ts | 27 ++++
src/lib/server/quotes-service.ts | 9 ++
src/lib/types.ts | 6 +
21 files changed, 1216 insertions(+), 74 deletions(-)
create mode 100644 scripts/2026-10-31-backfill-company-letterheads.ts
create mode 100644 scripts/2026-10-31-clean-deliveries-board.ts
create mode 100644 scripts/migrations/2026-10-31-add-company-letterhead.ts
create mode 100644 src/app/api/v1/companies/[id]/letterhead/route.ts
create mode 100644 src/components/quotes/process-letterhead-buttons.test.tsx
create mode 100644 src/components/quotes/process-letterhead-buttons.tsx
create mode 100644 src/lib/company-letterhead.test.ts
create mode 100644 src/lib/company-letterhead.ts
create mode 100644 src/lib/server/company-letterhead-service.ts
diff --git a/AGENTS.md b/AGENTS.md
index 6fc8310..e2009b9 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -331,6 +331,17 @@ The preview popup is REUSED, not rebuilt: the service emits
`SearchPreviewDialog` (`src/app/(app)/search/search-preview.tsx`), so
the manager sees the same quick-overview the desk sees on /search.
+# The POS counter never sells at R0
+
+The POS (`/api/v1/pos/catalog`) lists `stock_items` matching
+`POS_CATALOG_FILTER` in `pos-service.ts`: active **and** `selling_price > 0`.
+`checkoutSale` refuses any line with `unit_price <= 0`. Owner's rule
+(2026-10-30): only stock (and over-collected surplus) goes on the counter.
+Customer-order lines go to the `price_list` catalogue instead
+(`addRowsToPriceList`), never straight into `stock_items`. Any path that creates
+a stock item with a cost must price it with `applyDefaultMarkupToPatch`
+(80%); without a cost it stays off the till until someone prices it.
+
# Delivery Pool (`/delivery-pool`) — invoiced lines waiting to go out
Design: `docs/superpowers/specs/2026-10-30-delivery-pool-design.md`. Files:
@@ -350,13 +361,23 @@ routes under `/api/v1/delivery-pool` (browser session), page `src/app/(app)/deli
- Manual live step: `npm run pb:migrate:delivery-pool` before deploying (the service 503s
until the fields exist). The old `/deliveries` page stays until the office switches over.
-# The POS counter never sells at R0
+# Company letterhead lock — one quote letterhead per company
-The POS (`/api/v1/pos/catalog`) lists `stock_items` matching
-`POS_CATALOG_FILTER` in `pos-service.ts`: active **and** `selling_price > 0`.
-`checkoutSale` refuses any line with `unit_price <= 0`. Owner's rule
-(2026-10-30): only stock (and over-collected surplus) goes on the counter.
-Customer-order lines go to the `price_list` catalogue instead
-(`addRowsToPriceList`), never straight into `stock_items`. Any path that creates
-a stock item with a cost must price it with `applyDefaultMarkupToPatch`
-(80%); without a cost it stays off the till until someone prices it.
+Each company is quoted on one letterhead, stored on `companies.confirmed_letterhead`
+(CNL / ISHERWOOD, blank until confirmed). Rules: `src/lib/company-letterhead.ts`.
+The company is always the **customer's** company (`customer.company`), never
+`quotes.company` (that copy goes stale).
+
+- The process page (`process-letterhead-buttons.tsx`): blank → either button opens
+ "Set the default letterhead for {company}"; set → that button works, the other is
+ locked and only a triple-click renders on it (override for that quote only).
+- Enforced in `generateQuotePdf`: another letterhead without `override` returns
+ `letterhead_locked` (409 from `POST /api/v1/quotes/{id}/pdf`). So MCP / Giellie /
+ n8n can't render the wrong one either. An override sticks once rendered (a quote
+ already rendered on a letterhead may re-render on it).
+- With no letterhead passed, an unrendered quote takes the company's.
+- Changed on the company page via `PUT /api/v1/companies/{id}/letterhead`.
+- Manual live steps: `npm run pb:migrate:company-letterhead`, then dry-run and
+ `--apply` `scripts/2026-10-31-backfill-company-letterheads.ts` (fills companies
+ whose rendered quotes all used one letterhead). Until the migration runs the
+ lock is off and everything behaves as before.
diff --git a/package.json b/package.json
index e91bf46..aaa6a19 100644
--- a/package.json
+++ b/package.json
@@ -25,6 +25,7 @@
"pb:migrate:collection-ai": "npx tsx scripts/migrations/2026-10-17-add-collection-ai.ts",
"pb:migrate:pool-pending": "npx tsx scripts/migrations/2026-10-30-add-stock-pool-pending.ts",
"pb:migrate:quote-email-to": "npx tsx scripts/migrations/2026-10-30-add-quote-email-to.ts",
+ "pb:migrate:company-letterhead": "npx tsx scripts/migrations/2026-10-31-add-company-letterhead.ts",
"pb:migrate:collection-auto-push": "npx tsx scripts/migrations/2026-10-28-add-collection-auto-push.ts",
"pb:migrate:order-assistant": "npx tsx scripts/migrations/2026-10-24-add-order-assistant-fields.ts",
"pb:migrate:po-number-unique": "npx tsx scripts/migrations/2026-10-19-po-number-unique-index.ts",
diff --git a/scripts/2026-10-31-backfill-company-letterheads.ts b/scripts/2026-10-31-backfill-company-letterheads.ts
new file mode 100644
index 0000000..233b6bd
--- /dev/null
+++ b/scripts/2026-10-31-backfill-company-letterheads.ts
@@ -0,0 +1,116 @@
+/**
+ * Pre-fill `companies.confirmed_letterhead` from quote history, so the
+ * office doesn't get the "set the default letterhead" popup for every
+ * company in the first week.
+ *
+ * For every company, counts the letterheads of its customers' quotes
+ * that were rendered (`pdf_generated_at` set). A company is filled only
+ * when ALL of its rendered quotes used one letterhead and there are at
+ * least MIN_QUOTES of them. Mixed companies are listed and left blank —
+ * the office confirms those on the next quote. Companies that already
+ * have a confirmed letterhead are never touched.
+ *
+ * Dry run by default (prints the plan). Run after
+ * `npm run pb:migrate:company-letterhead`:
+ *
+ * npx tsx scripts/2026-10-31-backfill-company-letterheads.ts
+ * npx tsx scripts/2026-10-31-backfill-company-letterheads.ts --apply
+ */
+import "./migrations/_env-loader";
+import PocketBase from "pocketbase";
+
+const PB_URL = process.env.NEXT_PUBLIC_POCKETBASE_URL || process.env.PB_URL || "http://127.0.0.1:8090";
+const ADMIN_EMAIL = process.env.PB_ADMIN_EMAIL;
+const ADMIN_PASSWORD = process.env.PB_ADMIN_PASSWORD;
+const APPLY = process.argv.includes("--apply");
+const MIN_QUOTES = 2;
+const CODES = ["CNL", "ISHERWOOD"] as const;
+
+type Row = {
+ company_letterhead?: string;
+ expand?: { customer?: { company?: string } };
+};
+
+async function main() {
+ if (!ADMIN_EMAIL || !ADMIN_PASSWORD) {
+ throw new Error("Set PB_ADMIN_EMAIL and PB_ADMIN_PASSWORD env vars.");
+ }
+ const pb = new PocketBase(PB_URL);
+ pb.autoCancellation(false);
+ await pb.collection("_superusers").authWithPassword(ADMIN_EMAIL, ADMIN_PASSWORD);
+ console.log(`[letterhead-backfill] ${APPLY ? "APPLY" : "DRY RUN"} against ${PB_URL}`);
+
+ const companies = await pb
+ .collection("companies")
+ .getFullList<{ id: string; company: string; confirmed_letterhead?: string }>({
+ fields: "id,company,confirmed_letterhead",
+ });
+ if (companies.length > 0 && !("confirmed_letterhead" in companies[0])) {
+ throw new Error("companies.confirmed_letterhead is missing: run npm run pb:migrate:company-letterhead first.");
+ }
+ const byId = new Map(companies.map((c) => [c.id, c]));
+
+ const quotes = await pb.collection("quotes").getFullList({
+ filter: 'pdf_generated_at != ""',
+ expand: "customer",
+ fields: "company_letterhead,expand.customer.company",
+ });
+
+ const counts = new Map>();
+ for (const q of quotes) {
+ const companyId = q.expand?.customer?.company;
+ const code = q.company_letterhead;
+ if (!companyId || !code || !(CODES as readonly string[]).includes(code)) continue;
+ const c = counts.get(companyId) ?? {};
+ c[code] = (c[code] ?? 0) + 1;
+ counts.set(companyId, c);
+ }
+
+ const toSet: { id: string; name: string; code: string; n: number }[] = [];
+ const mixed: { name: string; c: Record }[] = [];
+ let tooFew = 0;
+ let alreadySet = 0;
+ for (const [companyId, c] of counts) {
+ const company = byId.get(companyId);
+ if (!company) continue;
+ if (company.confirmed_letterhead) {
+ alreadySet++;
+ continue;
+ }
+ const used = Object.keys(c);
+ if (used.length > 1) {
+ mixed.push({ name: company.company, c });
+ continue;
+ }
+ const n = c[used[0]];
+ if (n < MIN_QUOTES) {
+ tooFew++;
+ continue;
+ }
+ toSet.push({ id: companyId, name: company.company, code: used[0], n });
+ }
+
+ console.log(`\nRendered quotes read: ${quotes.length}; companies with history: ${counts.size}`);
+ console.log(`Will set: ${toSet.length} · mixed (left blank): ${mixed.length} · fewer than ${MIN_QUOTES} quotes: ${tooFew} · already set: ${alreadySet}`);
+ for (const code of CODES) {
+ console.log(` ${code}: ${toSet.filter((t) => t.code === code).length}`);
+ }
+ console.log("\nMixed companies (the office confirms these on the next quote):");
+ for (const m of mixed.sort((a, b) => a.name.localeCompare(b.name))) {
+ console.log(` ${m.name} — ${CODES.map((k) => `${k} ${m.c[k] ?? 0}`).join(", ")}`);
+ }
+
+ if (!APPLY) {
+ console.log("\nDry run: nothing written. Re-run with --apply to set them.");
+ return;
+ }
+ for (const t of toSet) {
+ await pb.collection("companies").update(t.id, { confirmed_letterhead: t.code });
+ }
+ console.log(`\nSet ${toSet.length} companies.`);
+}
+
+main().catch((err) => {
+ console.error(err);
+ process.exit(1);
+});
diff --git a/scripts/2026-10-31-clean-deliveries-board.ts b/scripts/2026-10-31-clean-deliveries-board.ts
new file mode 100644
index 0000000..ae0734e
--- /dev/null
+++ b/scripts/2026-10-31-clean-deliveries-board.ts
@@ -0,0 +1,137 @@
+/**
+ * Clear the old /deliveries board before the office switches to the
+ * Delivery Pool (owner's call, 2026-10-31: pool starts at 2026-10-28,
+ * everything older counts as delivered).
+ *
+ * Sets `item_status = "completed"` on:
+ *
+ * A. every `checked_in` line (back from the driver; the n8n job that
+ * was meant to complete them never shipped)
+ * B. every `with_driver` line (loaded on a driver in October)
+ * C. every `invoiced` line that is NOT left for the pool:
+ * - its delivery note is already `completed` / `cancelled`
+ * (POS counter sales — the paper is signed), or
+ * - it was invoiced before POOL_START, or has no invoice date.
+ *
+ * Left alone: `invoiced` lines on/after POOL_START with no closed DN (they
+ * show in the Delivery Pool), supplier-side lines (`exclude_from_invoice`),
+ * and delivery notes (open DNs are listed for a person to check).
+ *
+ * `delivered_at` is NOT stamped — the real delivery date is unknown. The
+ * audit hook records the change as "Deliveries board cleanup".
+ *
+ * Run:
+ * npx tsx scripts/2026-10-31-clean-deliveries-board.ts # dry-run
+ * npx tsx scripts/2026-10-31-clean-deliveries-board.ts --apply # write
+ * ... --report=/path/report.csv # CSV of every line
+ *
+ * Requires .env.local with NEXT_PUBLIC_POCKETBASE_URL, PB_ADMIN_EMAIL,
+ * PB_ADMIN_PASSWORD.
+ */
+
+import "./_env"; // side-effect import: loads .env.local before anything else
+
+import { writeFileSync } from "node:fs";
+import { getAdminPocketBase } from "../src/lib/server/pocketbase-admin";
+import { runWithAuditActor } from "../src/lib/server/audit-context";
+import { DELIVERY_POOL_START } from "../src/lib/delivery-pool";
+
+const POOL_START = "2026-10-28";
+const CLOSED_DN = new Set(["completed", "cancelled"]);
+
+type Line = {
+ id: string;
+ item_status: string;
+ invoiced_date: string;
+ customer_invoice_number: string;
+ delivery_note_number: string;
+ exclude_from_invoice: boolean;
+ description: string;
+};
+
+type Reason = "checked_in" | "with_driver" | "dn_closed" | "before_pool" | "no_date";
+
+const apply = process.argv.includes("--apply");
+const reportArg = process.argv.find((a) => a.startsWith("--report="));
+const reportPath = reportArg ? reportArg.slice("--report=".length) : null;
+
+function reasonFor(line: Line, dnStatus: Map): Reason | null {
+ if (line.exclude_from_invoice) return null;
+ if (line.item_status === "checked_in") return "checked_in";
+ if (line.item_status === "with_driver") return "with_driver";
+ if (line.item_status !== "invoiced") return null;
+ const dn = line.delivery_note_number ? dnStatus.get(line.delivery_note_number) : undefined;
+ if (dn && CLOSED_DN.has(dn)) return "dn_closed";
+ const day = (line.invoiced_date || "").slice(0, 10);
+ if (!day) return "no_date";
+ return day < POOL_START ? "before_pool" : null;
+}
+
+async function main() {
+ if (DELIVERY_POOL_START !== POOL_START) {
+ throw new Error(
+ `DELIVERY_POOL_START is ${DELIVERY_POOL_START}; set it to ${POOL_START} first or lines between the two dates fall through the gap.`,
+ );
+ }
+ const pb = await getAdminPocketBase();
+ const lines = await pb.collection("order_items").getFullList({
+ filter: `item_status = "checked_in" || item_status = "with_driver" || item_status = "invoiced"`,
+ fields: "id,item_status,invoiced_date,customer_invoice_number,delivery_note_number,exclude_from_invoice,description",
+ });
+ const dns = await pb.collection("delivery_notes").getFullList<{ delivery_note_number: string; status: string; created: string }>({
+ fields: "delivery_note_number,status,created",
+ });
+ const dnStatus = new Map(dns.map((d) => [d.delivery_note_number, d.status]));
+
+ const plan = lines.map((line) => ({ line, reason: reasonFor(line, dnStatus) }));
+ const todo = plan.filter((p) => p.reason !== null);
+ const counts = new Map }>();
+ for (const p of plan) {
+ const key = p.reason ?? (p.line.exclude_from_invoice ? "keep:supplier_side" : "keep:pool");
+ const c = counts.get(key) ?? { lines: 0, invoices: new Set() };
+ c.lines += 1;
+ c.invoices.add(p.line.customer_invoice_number || `(none:${p.line.id})`);
+ counts.set(key, c);
+ }
+ console.log(`[deliveries-cleanup] ${apply ? "APPLY" : "DRY-RUN"} — pool starts ${POOL_START}`);
+ for (const [k, c] of [...counts].sort()) {
+ console.log(` ${k.padEnd(20)} lines ${String(c.lines).padStart(5)} invoices ${c.invoices.size}`);
+ }
+ console.log(` → ${todo.length} lines to mark completed`);
+
+ const openDns = dns
+ .filter((d) => d.status === "draft" || d.status === "sent" || d.status === "loaded")
+ .sort((a, b) => a.created.localeCompare(b.created));
+ console.log(` Open delivery notes (not touched): ${openDns.map((d) => `${d.delivery_note_number} ${d.status}`).join(", ")}`);
+
+ if (reportPath) {
+ const rows = plan.map((p) =>
+ [p.line.id, p.line.item_status, p.reason ?? "keep", p.line.customer_invoice_number, p.line.delivery_note_number,
+ (p.line.invoiced_date || "").slice(0, 10), JSON.stringify(p.line.description ?? "")].join(","),
+ );
+ writeFileSync(reportPath, ["id,status,action,invoice,dn,invoiced_date,description", ...rows].join("\n"));
+ console.log(` Report: ${reportPath}`);
+ }
+
+ if (!apply) return;
+ let done = 0;
+ let failed = 0;
+ await runWithAuditActor({ name: "Deliveries board cleanup" }, async () => {
+ for (const p of todo) {
+ try {
+ await pb.collection("order_items").update(p.line.id, { item_status: "completed" });
+ done += 1;
+ } catch (err) {
+ failed += 1;
+ console.error(` ✗ ${p.line.id}: ${err instanceof Error ? err.message : String(err)}`);
+ }
+ if (done % 200 === 0 && done > 0) console.log(` … ${done}/${todo.length}`);
+ }
+ });
+ console.log(`[deliveries-cleanup] completed ${done}, failed ${failed}`);
+}
+
+main().catch((err) => {
+ console.error("[deliveries-cleanup] failed:", err);
+ process.exit(1);
+});
diff --git a/scripts/migrations/2026-10-31-add-company-letterhead.ts b/scripts/migrations/2026-10-31-add-company-letterhead.ts
new file mode 100644
index 0000000..3ab8c25
--- /dev/null
+++ b/scripts/migrations/2026-10-31-add-company-letterhead.ts
@@ -0,0 +1,60 @@
+/**
+ * One-shot migration: add `confirmed_letterhead` to the live
+ * `companies` collection (idempotent).
+ *
+ * The company's quote letterhead (CNL or ISHERWOOD, blank until
+ * confirmed). Once set, the quote process page only lets the matching
+ * letterhead button through without an override, and the quote PDF
+ * service refuses the other one. See `src/lib/company-letterhead.ts`.
+ *
+ * Until this has run, the lock is off: the app treats a company record
+ * without the field as "no lock" and behaves as before.
+ *
+ * Run with:
+ *
+ * npm run pb:migrate:company-letterhead
+ *
+ * Safe to run multiple times.
+ */
+import "./_env-loader";
+import PocketBase from "pocketbase";
+
+const PB_URL = process.env.NEXT_PUBLIC_POCKETBASE_URL || process.env.PB_URL || "http://127.0.0.1:8090";
+const ADMIN_EMAIL = process.env.PB_ADMIN_EMAIL;
+const ADMIN_PASSWORD = process.env.PB_ADMIN_PASSWORD;
+
+async function main() {
+ if (!ADMIN_EMAIL || !ADMIN_PASSWORD) {
+ throw new Error("Set PB_ADMIN_EMAIL and PB_ADMIN_PASSWORD env vars.");
+ }
+
+ const pb = new PocketBase(PB_URL);
+ await pb.collection("_superusers").authWithPassword(ADMIN_EMAIL, ADMIN_PASSWORD);
+ console.log(`[confirmed_letterhead] Authenticated against ${PB_URL}`);
+
+ const collection = await pb.collections.getOne("companies");
+ if (collection.fields.some((f: { name: string }) => f.name === "confirmed_letterhead")) {
+ console.log("[confirmed_letterhead] Field already exists, nothing to do.");
+ return;
+ }
+
+ await pb.collections.update(collection.id, {
+ fields: [
+ ...collection.fields,
+ {
+ name: "confirmed_letterhead",
+ type: "select",
+ required: false,
+ presentable: false,
+ maxSelect: 1,
+ values: ["CNL", "ISHERWOOD"],
+ },
+ ],
+ });
+ console.log("[confirmed_letterhead] Field added to companies.");
+}
+
+main().catch((err) => {
+ console.error(err);
+ process.exit(1);
+});
diff --git a/src/app/(app)/companies/[id]/page.tsx b/src/app/(app)/companies/[id]/page.tsx
index f073be2..4390466 100644
--- a/src/app/(app)/companies/[id]/page.tsx
+++ b/src/app/(app)/companies/[id]/page.tsx
@@ -11,6 +11,12 @@ import { toast } from "sonner";
import getPocketBase from "@/lib/pocketbase";
import { getCompany, formatDate } from "@/lib/pb-helpers";
import type { Company } from "@/lib/types";
+import {
+ confirmedLetterheadOf,
+ QUOTE_LETTERHEAD_CODES,
+ QUOTE_LETTERHEAD_LABELS,
+ type QuoteLetterheadCode,
+} from "@/lib/company-letterhead";
/**
* Treat PB's `{ lon: 0, lat: 0 }` (Null Island) as "no coordinates".
@@ -64,6 +70,33 @@ export default function CompanyDetailPage({ params }: { params: Promise<{ id: st
fetchCompany();
}, [fetchCompany]);
+ const [savingLetterhead, setSavingLetterhead] = useState(false);
+ const letterhead = confirmedLetterheadOf(company);
+
+ // The company's quote letterhead (src/lib/company-letterhead.ts):
+ // the only button that works on its quotes' process page.
+ const saveLetterhead = async (code: QuoteLetterheadCode | null) => {
+ setSavingLetterhead(true);
+ try {
+ const res = await fetch(`/api/v1/companies/${id}/letterhead`, {
+ method: "PUT",
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: getPocketBase().authStore.token,
+ },
+ body: JSON.stringify({ letterhead: code }),
+ });
+ const data = await res.json().catch(() => ({}));
+ if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
+ setCompany((prev) => (prev ? { ...prev, confirmed_letterhead: code ?? "" } : prev));
+ toast.success(code ? `Quotes for this company use ${QUOTE_LETTERHEAD_LABELS[code]}` : "Letterhead cleared — the next quote will ask");
+ } catch (err: unknown) {
+ toast.error(err instanceof Error ? err.message : "Failed to save the letterhead");
+ } finally {
+ setSavingLetterhead(false);
+ }
+ };
+
const handleDelete = async () => {
try {
const pb = getPocketBase();
@@ -207,6 +240,40 @@ export default function CompanyDetailPage({ params }: { params: Promise<{ id: st
+ {letterhead !== undefined && (
+
+
+ Quote letterhead
+
+
+
+ {QUOTE_LETTERHEAD_CODES.map((code) => (
+ saveLetterhead(code)}
+ aria-pressed={letterhead === code}
+ >
+ {QUOTE_LETTERHEAD_LABELS[code]}
+
+ ))}
+ {letterhead && (
+ saveLetterhead(null)}>
+ Clear
+
+ )}
+
+
+ {letterhead
+ ? `Quotes for this company are locked to ${QUOTE_LETTERHEAD_LABELS[letterhead]}.`
+ : "Not confirmed yet — the next quote's PDF button will ask."}
+
+
+
+ )}
+
Created
diff --git a/src/app/(app)/quotes/[id]/process/page.tsx b/src/app/(app)/quotes/[id]/process/page.tsx
index ff0dcc8..797f08d 100644
--- a/src/app/(app)/quotes/[id]/process/page.tsx
+++ b/src/app/(app)/quotes/[id]/process/page.tsx
@@ -22,7 +22,6 @@ import { SupplierSearch } from "@/components/ui/supplier-search";
import {
ArrowLeft,
FileText,
- FileDown,
Save,
Trash2,
RefreshCw,
@@ -62,6 +61,11 @@ import { QuotedBeforePanel } from "@/components/quoted-before-panel";
import type { Quote, QuoteItem, QuoteAttachment, Supplier, Customer } from "@/lib/types";
import { cn } from "@/lib/utils";
import type { LetterheadCode } from "@/lib/letterhead";
+import { confirmedLetterheadOf, type QuoteLetterheadCode } from "@/lib/company-letterhead";
+import {
+ ProcessLetterheadButtons,
+ type GenerateLetterheadOptions,
+} from "@/components/quotes/process-letterhead-buttons";
export default function QuoteProcessPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = use(params);
@@ -435,6 +439,37 @@ export default function QuoteProcessPage({ params }: { params: Promise<{ id: str
}
};
+ // The customer's company and its confirmed letterhead (the lock —
+ // see src/lib/company-letterhead.ts). Updated locally after the
+ // popup saves a default so the buttons lock without a refetch.
+ const letterheadCompany = quote?.expand?.customer?.expand?.company as
+ | { id: string; company?: string; confirmed_letterhead?: unknown }
+ | undefined;
+ // A placeholder customer gets no lock and no popup: the PDF route
+ // refuses it until the real customer is picked anyway.
+ const companyLetterhead =
+ quote?.expand?.customer && isPlaceholderCustomer(quote.expand.customer)
+ ? undefined
+ : confirmedLetterheadOf(letterheadCompany);
+ const setCompanyLetterhead = (code: QuoteLetterheadCode | null) =>
+ setQuote((prev) => {
+ const company = prev?.expand?.customer?.expand?.company;
+ if (!prev || !company) return prev;
+ return {
+ ...prev,
+ expand: {
+ ...prev.expand,
+ customer: {
+ ...prev.expand!.customer!,
+ expand: {
+ ...prev.expand!.customer!.expand,
+ company: { ...company, confirmed_letterhead: code ?? "" },
+ },
+ },
+ },
+ } as Quote;
+ });
+
/**
* POST /api/v1/quotes/[id]/pdf to render + auto-save the PDF, then
* open the in-app viewer modal. Refreshes local state so the status
@@ -444,17 +479,24 @@ export default function QuoteProcessPage({ params }: { params: Promise<{ id: str
* returns a structured 422 with `code` + `details.issues`. We surface
* that as a richer toast so the office knows exactly what to fix.
*/
- const generatePdf = async (letterhead: LetterheadCode) => {
+ const generatePdf = async (letterhead: LetterheadCode, options?: GenerateLetterheadOptions) => {
if (generatingPdf) return;
setGeneratingPdf(letterhead);
try {
const res = await fetch(`/api/v1/quotes/${id}/pdf`, {
method: "POST",
headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ letterhead }),
+ body: JSON.stringify({ letterhead, ...options }),
});
const data = await res.json();
if (!res.ok || !data.ok) {
+ // The company is confirmed on the other letterhead (someone set
+ // it since this page loaded): show it and lock the buttons.
+ if (data.code === "letterhead_locked") {
+ setCompanyLetterhead(data.details?.confirmed ?? null);
+ toast.error(data.error, { duration: 8000 });
+ return;
+ }
// Structured validation error — show the operator what to fix.
if (data.code === "placeholder_customer") {
toast.error(
@@ -485,6 +527,7 @@ export default function QuoteProcessPage({ params }: { params: Promise<{ id: str
attachmentId: data.attachmentId,
filename: data.filename,
});
+ if (options?.setCompanyDefault) setCompanyLetterhead(letterhead as QuoteLetterheadCode);
// Update local status + claimed_by so the badge / "Claimed by"
// chip reflect the new state immediately. Auto-claim on the
// server may have written `claimed_by` when it was empty —
@@ -1810,35 +1853,15 @@ export default function QuoteProcessPage({ params }: { params: Promise<{ id: str
: `Price every item + assign a supplier before generating (${missingPricing} unpriced, ${missingSupplier} missing supplier)`}
-
- generatePdf("CNL")}
- data-testid="generate-pdf-cnl"
- >
-
- {generatingPdf === "CNL" ? "Generating…" : "CNL"}
-
- generatePdf("ISHERWOOD")}
- data-testid="generate-pdf-isherwood"
- >
-
- {generatingPdf === "ISHERWOOD" ? "Generating…" : "ISherwood"}
-
-
+ generatePdf(code, options)}
+ />
diff --git a/src/app/api/v1/companies/[id]/letterhead/route.ts b/src/app/api/v1/companies/[id]/letterhead/route.ts
new file mode 100644
index 0000000..39e95c1
--- /dev/null
+++ b/src/app/api/v1/companies/[id]/letterhead/route.ts
@@ -0,0 +1,38 @@
+import { NextRequest, NextResponse } from "next/server";
+import { getBrowserSessionUser } from "@/lib/server/browser-session";
+import { setCompanyLetterhead } from "@/lib/server/company-letterhead-service";
+
+/**
+ * PUT /api/v1/companies/[id]/letterhead
+ *
+ * Body: `{ letterhead: "CNL" | "ISHERWOOD" | null }` — the company's
+ * confirmed quote letterhead (null clears it, so the next quote asks
+ * again). See `src/lib/company-letterhead.ts`.
+ *
+ * Auth: browser session (PB token forwarded in `Authorization`).
+ *
+ * Response: `{ id, company, confirmed_letterhead }`. 409 while the
+ * field is missing on the live PB (migration not run).
+ */
+export const runtime = "nodejs";
+export const dynamic = "force-dynamic";
+
+export async function PUT(
+ req: NextRequest,
+ { params }: { params: Promise<{ id: string }> },
+) {
+ try {
+ await getBrowserSessionUser(req);
+ const { id } = await params;
+ const body = (await req.json().catch(() => null)) as { letterhead?: unknown } | null;
+ if (!body || !("letterhead" in body)) {
+ return NextResponse.json({ error: "letterhead is required (CNL, ISHERWOOD or null)" }, { status: 422 });
+ }
+ const result = await setCompanyLetterhead(id, body.letterhead as never);
+ return NextResponse.json(result);
+ } catch (err) {
+ const message = err instanceof Error ? err.message : "Failed to set the company letterhead";
+ const status = (err as Error & { status?: number }).status || 500;
+ return NextResponse.json({ error: message }, { status });
+ }
+}
diff --git a/src/app/api/v1/quotes/[id]/pdf/base64/route.ts b/src/app/api/v1/quotes/[id]/pdf/base64/route.ts
index a8f6d27..ce62428 100644
--- a/src/app/api/v1/quotes/[id]/pdf/base64/route.ts
+++ b/src/app/api/v1/quotes/[id]/pdf/base64/route.ts
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { getAdminPocketBase } from "@/lib/server/pocketbase-admin";
import { LETTERHEAD_CODES, type LetterheadCode } from "@/lib/letterhead";
+import { confirmedLetterheadOf, defaultQuoteLetterhead } from "@/lib/company-letterhead";
/**
* GET /api/v1/quotes/[id]/pdf/base64
@@ -82,17 +83,24 @@ export async function GET(
}
try {
- // Load the quote (we need its company_letterhead as default).
+ // Load the quote (we need its company_letterhead as default, and
+ // the customer's company for its confirmed letterhead — an
+ // unrendered quote follows the company, see company-letterhead.ts).
const quote = await adminPb.collection("quotes").getOne(quoteId, {
- fields: "id,company_letterhead,pdf_generated_at",
+ expand: "customer.company",
});
+ const hasPdf = !!(quote.pdf_generated_at || "").trim();
const chosenLetterhead =
- letterheadParam ?? quote.company_letterhead ?? "CNL";
+ letterheadParam ??
+ defaultQuoteLetterhead({
+ stored: quote.company_letterhead,
+ hasPdf,
+ confirmed: confirmedLetterheadOf(quote.expand?.customer?.expand?.company),
+ });
// Render only if needed: no PDF yet, regeneration forced, or
// letterhead differs from the existing one (best-effort: we just
// always render — POST is idempotent and cheap).
- const hasPdf = !!(quote.pdf_generated_at || "").trim();
if (!hasPdf || regenerate) {
// Re-use the same code path the user-facing button does. The POST
// handler in `[id]/pdf/route.ts` accepts a `letterhead` body.
@@ -111,7 +119,7 @@ export async function GET(
const text = await postRes.text();
return NextResponse.json(
{ error: `PDF render failed: ${postRes.status} ${text}` },
- { status: postRes.status === 404 ? 404 : 500 },
+ { status: postRes.status === 404 || postRes.status === 409 ? postRes.status : 500 },
);
}
const renderResult = (await postRes.json()) as PdfRenderResult;
diff --git a/src/app/api/v1/quotes/[id]/pdf/route.ts b/src/app/api/v1/quotes/[id]/pdf/route.ts
index 1f2fa8a..42af9f1 100644
--- a/src/app/api/v1/quotes/[id]/pdf/route.ts
+++ b/src/app/api/v1/quotes/[id]/pdf/route.ts
@@ -73,7 +73,7 @@ export async function POST(
) {
const { id: quoteId } = await ctx.params;
- let body: { letterhead?: string };
+ let body: { letterhead?: string; override?: boolean; setCompanyDefault?: boolean };
try {
body = await req.json();
} catch {
@@ -104,9 +104,15 @@ export async function POST(
// stored letterhead when the caller passes something unknown —
// same lenient behaviour as before, so a stale UI that sends
// `letterhead: "CNL"` for an ISHERWOOD quote doesn't 500.
+ //
+ // `override` / `setCompanyDefault` are the company letterhead lock
+ // (`src/lib/company-letterhead.ts`): the process page's triple-click
+ // and its "set the default letterhead" popup.
const outcome = await generateQuotePdf(quoteId, {
letterhead: body.letterhead,
actorEmail,
+ override: body.override === true,
+ setCompanyDefault: body.setCompanyDefault === true,
});
return renderOutcomeToResponse(outcome);
@@ -155,6 +161,21 @@ function renderOutcomeToResponse(outcome: GenerateQuotePdfOutcome) {
};
return NextResponse.json(body, { status: 422 });
}
+ if (outcome.kind === "letterhead_locked") {
+ return NextResponse.json(
+ {
+ error: outcome.message,
+ code: "letterhead_locked",
+ details: {
+ requested: outcome.requested,
+ confirmed: outcome.confirmed,
+ companyId: outcome.companyId,
+ companyName: outcome.companyName,
+ },
+ },
+ { status: 409 },
+ );
+ }
// render_error
return NextResponse.json(
{
diff --git a/src/components/quotes/process-letterhead-buttons.test.tsx b/src/components/quotes/process-letterhead-buttons.test.tsx
new file mode 100644
index 0000000..b2c122f
--- /dev/null
+++ b/src/components/quotes/process-letterhead-buttons.test.tsx
@@ -0,0 +1,53 @@
+import { describe, it, expect, vi } from "vitest";
+import { render, screen, fireEvent } from "@testing-library/react";
+import { ProcessLetterheadButtons } from "./process-letterhead-buttons";
+
+function setup(confirmed: "CNL" | "ISHERWOOD" | null | undefined) {
+ const onGenerate = vi.fn();
+ render(
+ ,
+ );
+ return onGenerate;
+}
+
+describe("ProcessLetterheadButtons", () => {
+ it("works like before when the company has no lock field", () => {
+ const onGenerate = setup(undefined);
+ fireEvent.click(screen.getByTestId("generate-pdf-isherwood"));
+ expect(onGenerate).toHaveBeenCalledWith("ISHERWOOD");
+ expect(screen.queryByTestId("letterhead-lock-hint")).toBeNull();
+ });
+
+ it("the confirmed letterhead works on one click", () => {
+ const onGenerate = setup("CNL");
+ fireEvent.click(screen.getByTestId("generate-pdf-cnl"));
+ expect(onGenerate).toHaveBeenCalledWith("CNL");
+ });
+
+ it("the other letterhead needs a triple-click, and then overrides", () => {
+ const onGenerate = setup("CNL");
+ const locked = screen.getByTestId("generate-pdf-isherwood");
+ expect(locked.getAttribute("data-locked")).toBe("true");
+ fireEvent.click(locked, { detail: 1 });
+ fireEvent.click(locked, { detail: 2 });
+ expect(onGenerate).not.toHaveBeenCalled();
+ fireEvent.click(locked, { detail: 3 });
+ expect(onGenerate).toHaveBeenCalledWith("ISHERWOOD", { override: true });
+ expect(screen.getByTestId("letterhead-lock-hint").textContent).toContain("Harmony Gold uses CNL");
+ });
+
+ it("asks for the company default when it is blank, and saves the choice", () => {
+ const onGenerate = setup(null);
+ fireEvent.click(screen.getByTestId("generate-pdf-cnl"));
+ expect(onGenerate).not.toHaveBeenCalled();
+ expect(screen.getByText("Set the default letterhead for Harmony Gold")).toBeTruthy();
+ fireEvent.click(screen.getByTestId("letterhead-default-isherwood"));
+ expect(onGenerate).toHaveBeenCalledWith("ISHERWOOD", { setCompanyDefault: true });
+ });
+});
diff --git a/src/components/quotes/process-letterhead-buttons.tsx b/src/components/quotes/process-letterhead-buttons.tsx
new file mode 100644
index 0000000..3848344
--- /dev/null
+++ b/src/components/quotes/process-letterhead-buttons.tsx
@@ -0,0 +1,151 @@
+"use client";
+
+import { useState, type MouseEvent } from "react";
+import { FileDown, Lock } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import {
+ QUOTE_LETTERHEAD_CODES,
+ QUOTE_LETTERHEAD_LABELS,
+ type QuoteLetterheadCode,
+} from "@/lib/company-letterhead";
+import { cn } from "@/lib/utils";
+
+export interface GenerateLetterheadOptions {
+ override?: boolean;
+ setCompanyDefault?: boolean;
+}
+
+/**
+ * The process page's CNL / ISherwood "Generate Quote PDF" buttons with
+ * the company letterhead lock (`src/lib/company-letterhead.ts`):
+ *
+ * - `confirmed` undefined (no company, or the field isn't on the live
+ * PB yet): both buttons work, as before.
+ * - `confirmed` null: either button opens "Set the default letterhead
+ * for {company}"; the choice is saved on the company and rendered.
+ * - `confirmed` set: that button works; the other is locked and only
+ * a triple-click renders on it (an override for this quote only).
+ */
+export function ProcessLetterheadButtons({
+ companyName,
+ confirmed,
+ disabled,
+ generating,
+ onGenerate,
+}: {
+ companyName: string | null;
+ confirmed: QuoteLetterheadCode | null | undefined;
+ disabled: boolean;
+ generating: QuoteLetterheadCode | null;
+ onGenerate: (code: QuoteLetterheadCode, options?: GenerateLetterheadOptions) => void;
+}) {
+ const [askDefault, setAskDefault] = useState(false);
+
+ const handleClick = (code: QuoteLetterheadCode, e: MouseEvent) => {
+ if (confirmed === null) {
+ setAskDefault(true);
+ return;
+ }
+ if (confirmed && code !== confirmed) {
+ // Locked: the first two clicks of a triple-click do nothing.
+ if (e.detail === 3) onGenerate(code, { override: true });
+ return;
+ }
+ onGenerate(code);
+ };
+
+ const chooseDefault = (code: QuoteLetterheadCode) => {
+ setAskDefault(false);
+ onGenerate(code, { setCompanyDefault: true });
+ };
+
+ return (
+
+
+ {QUOTE_LETTERHEAD_CODES.map((code) => {
+ const locked = !!confirmed && code !== confirmed;
+ return (
+ handleClick(code, e)}
+ title={
+ locked
+ ? `${companyName ?? "This company"} is quoted on ${QUOTE_LETTERHEAD_LABELS[confirmed]}. Triple-click to use ${QUOTE_LETTERHEAD_LABELS[code]} for this quote only.`
+ : undefined
+ }
+ aria-label={locked ? `${QUOTE_LETTERHEAD_LABELS[code]} (locked, triple-click to override)` : undefined}
+ data-testid={`generate-pdf-${code.toLowerCase()}`}
+ data-locked={locked ? "true" : undefined}
+ >
+ {locked ? : }
+ {generating === code ? "Generating…" : QUOTE_LETTERHEAD_LABELS[code]}
+
+ );
+ })}
+
+ {confirmed && (
+
+ {companyName ?? "This company"} uses {QUOTE_LETTERHEAD_LABELS[confirmed]} · triple-click the other to override
+
+ )}
+
+
+
+
+ Set the default letterhead for {companyName ?? "this company"}
+
+ Every quote for this company will use it. Make sure it's the right one: after
+ this, only that letterhead's button works for this company. You can change it
+ later on the company page.
+
+
+
+ {QUOTE_LETTERHEAD_CODES.map((code) => (
+ chooseDefault(code)}
+ data-testid={`letterhead-default-${code.toLowerCase()}`}
+ >
+ {QUOTE_LETTERHEAD_LABELS[code]}
+
+ ))}
+
+
+ setAskDefault(false)}>
+ Cancel
+
+
+
+
+
+ );
+}
diff --git a/src/lib/company-letterhead.test.ts b/src/lib/company-letterhead.test.ts
new file mode 100644
index 0000000..e9507f2
--- /dev/null
+++ b/src/lib/company-letterhead.test.ts
@@ -0,0 +1,48 @@
+import { describe, it, expect } from "vitest";
+import {
+ checkLetterheadChoice,
+ confirmedLetterheadOf,
+ defaultQuoteLetterhead,
+} from "./company-letterhead";
+
+describe("confirmedLetterheadOf", () => {
+ it("is undefined when the field is not on the record (migration not run)", () => {
+ expect(confirmedLetterheadOf({})).toBeUndefined();
+ expect(confirmedLetterheadOf(null)).toBeUndefined();
+ });
+ it("is null when blank or unknown, the code when set", () => {
+ expect(confirmedLetterheadOf({ confirmed_letterhead: "" })).toBeNull();
+ expect(confirmedLetterheadOf({ confirmed_letterhead: "WESTSCOTT GROUP OF COMPANIES" })).toBeNull();
+ expect(confirmedLetterheadOf({ confirmed_letterhead: "ISHERWOOD" })).toBe("ISHERWOOD");
+ });
+});
+
+describe("checkLetterheadChoice", () => {
+ it("allows anything when the company has no confirmed letterhead", () => {
+ expect(checkLetterheadChoice({ requested: "ISHERWOOD", confirmed: null })).toEqual({ ok: true });
+ expect(checkLetterheadChoice({ requested: "ISHERWOOD", confirmed: undefined })).toEqual({ ok: true });
+ });
+ it("allows the match, refuses the other, allows it on override", () => {
+ expect(checkLetterheadChoice({ requested: "CNL", confirmed: "CNL" })).toEqual({ ok: true });
+ expect(checkLetterheadChoice({ requested: "ISHERWOOD", confirmed: "CNL" })).toEqual({ ok: false, confirmed: "CNL" });
+ expect(checkLetterheadChoice({ requested: "ISHERWOOD", confirmed: "CNL", override: true })).toEqual({ ok: true });
+ });
+ it("lets a quote already rendered on the other letterhead keep it", () => {
+ expect(
+ checkLetterheadChoice({ requested: "ISHERWOOD", confirmed: "CNL", renderedLetterhead: "ISHERWOOD" }),
+ ).toEqual({ ok: true });
+ });
+});
+
+describe("defaultQuoteLetterhead", () => {
+ it("keeps a rendered quote's own letterhead", () => {
+ expect(defaultQuoteLetterhead({ stored: "ISHERWOOD", hasPdf: true, confirmed: "CNL" })).toBe("ISHERWOOD");
+ });
+ it("takes the company's for an unrendered quote", () => {
+ expect(defaultQuoteLetterhead({ stored: "CNL", hasPdf: false, confirmed: "ISHERWOOD" })).toBe("ISHERWOOD");
+ });
+ it("falls back to the stored value, then CNL", () => {
+ expect(defaultQuoteLetterhead({ stored: "ISHERWOOD", hasPdf: false, confirmed: null })).toBe("ISHERWOOD");
+ expect(defaultQuoteLetterhead({ stored: "", hasPdf: false, confirmed: undefined })).toBe("CNL");
+ });
+});
diff --git a/src/lib/company-letterhead.ts b/src/lib/company-letterhead.ts
new file mode 100644
index 0000000..2af6e12
--- /dev/null
+++ b/src/lib/company-letterhead.ts
@@ -0,0 +1,82 @@
+/**
+ * Company letterhead lock — client-safe rules.
+ *
+ * Every company is quoted on ONE letterhead (CNL or ISherwood). The
+ * company's choice lives on `companies.confirmed_letterhead` (a select,
+ * blank until someone confirms it). Once it is set, a quote for that
+ * company can only be rendered on that letterhead unless the operator
+ * deliberately overrides (the triple-click on the process page).
+ *
+ * The company is always the CUSTOMER's company (`customer.company`),
+ * never `quotes.company` — that copy is not kept up to date when a
+ * quote's customer changes (see QUOTE_LIST_EXPAND in quotes-service).
+ *
+ * Shared by `quote-pdf-service.ts` (the server check every render goes
+ * through) and the process page (which buttons are live).
+ */
+
+export type QuoteLetterheadCode = "CNL" | "ISHERWOOD";
+
+export const QUOTE_LETTERHEAD_CODES: readonly QuoteLetterheadCode[] = ["CNL", "ISHERWOOD"] as const;
+
+export const QUOTE_LETTERHEAD_LABELS: Record = {
+ CNL: "CNL",
+ ISHERWOOD: "ISherwood",
+};
+
+export function isQuoteLetterheadCode(value: unknown): value is QuoteLetterheadCode {
+ return typeof value === "string" && (QUOTE_LETTERHEAD_CODES as readonly string[]).includes(value);
+}
+
+/**
+ * The company's confirmed letterhead, or null when it is blank.
+ * `undefined` when the record has no such field at all — the live PB
+ * has not had `npm run pb:migrate:company-letterhead` yet, so the lock
+ * is off and everything behaves as it did before.
+ */
+export function confirmedLetterheadOf(
+ company: { confirmed_letterhead?: unknown } | null | undefined,
+): QuoteLetterheadCode | null | undefined {
+ if (!company || !("confirmed_letterhead" in company)) return undefined;
+ return isQuoteLetterheadCode(company.confirmed_letterhead) ? company.confirmed_letterhead : null;
+}
+
+export type LetterheadCheck =
+ | { ok: true }
+ | { ok: false; confirmed: QuoteLetterheadCode };
+
+/**
+ * May this quote be rendered on `requested`?
+ *
+ * Allowed when the company has no confirmed letterhead, when it matches,
+ * when the operator overrides, or when the quote already has a PDF on
+ * `requested` (an earlier override on this quote stays put, so a later
+ * re-render — e.g. the send's staleness regen — doesn't need it again).
+ */
+export function checkLetterheadChoice(args: {
+ requested: string;
+ confirmed: QuoteLetterheadCode | null | undefined;
+ /** The quote's stored letterhead, only when the quote already has a PDF. */
+ renderedLetterhead?: string | null;
+ override?: boolean;
+}): LetterheadCheck {
+ const { requested, confirmed, renderedLetterhead, override } = args;
+ if (!confirmed || requested === confirmed || override) return { ok: true };
+ if (renderedLetterhead && renderedLetterhead === requested) return { ok: true };
+ return { ok: false, confirmed };
+}
+
+/**
+ * The letterhead to use when the caller didn't pick one: the quote's
+ * own once it has a PDF (that is what was sent / approved), otherwise
+ * the company's confirmed one, otherwise the quote's stored default.
+ */
+export function defaultQuoteLetterhead(args: {
+ stored: string | null | undefined;
+ hasPdf: boolean;
+ confirmed: QuoteLetterheadCode | null | undefined;
+}): QuoteLetterheadCode {
+ const stored = isQuoteLetterheadCode(args.stored) ? args.stored : null;
+ if (args.hasPdf && stored) return stored;
+ return args.confirmed || stored || "CNL";
+}
diff --git a/src/lib/delivery-pool.ts b/src/lib/delivery-pool.ts
index 332eca7..5ef43b9 100644
--- a/src/lib/delivery-pool.ts
+++ b/src/lib/delivery-pool.ts
@@ -10,7 +10,7 @@
*/
/** Invoices dated before this never enter the pool (they went out on the old flow). */
-export const DELIVERY_POOL_START = "2026-10-30";
+export const DELIVERY_POOL_START = "2026-10-28";
export type DeliveryPoolLine = {
id: string;
diff --git a/src/lib/server/company-letterhead-service.ts b/src/lib/server/company-letterhead-service.ts
new file mode 100644
index 0000000..3bb684a
--- /dev/null
+++ b/src/lib/server/company-letterhead-service.ts
@@ -0,0 +1,46 @@
+import { getAdminPocketBase } from "./pocketbase-admin";
+import {
+ confirmedLetterheadOf,
+ isQuoteLetterheadCode,
+ type QuoteLetterheadCode,
+} from "@/lib/company-letterhead";
+
+/**
+ * Company letterhead lock — the write side (the company page's
+ * "Quote letterhead" card). The rules live in
+ * `src/lib/company-letterhead.ts`; the quote PDF service enforces them.
+ */
+
+export const COMPANY_LETTERHEAD_NOT_SET_UP =
+ "Company letterheads are not set up yet: run `npm run pb:migrate:company-letterhead` against the live PocketBase.";
+
+/**
+ * Set (or clear, with null) a company's confirmed quote letterhead.
+ * Refuses while the field is missing on the live PB, because PB drops
+ * unknown fields silently and the save would look like it worked.
+ */
+export async function setCompanyLetterhead(
+ companyId: string,
+ letterhead: QuoteLetterheadCode | null,
+): Promise<{ id: string; company: string; confirmed_letterhead: QuoteLetterheadCode | null }> {
+ if (letterhead !== null && !isQuoteLetterheadCode(letterhead)) {
+ throw Object.assign(new Error("letterhead must be CNL, ISHERWOOD or null"), { status: 422 });
+ }
+ const pb = await getAdminPocketBase();
+ const current = await pb
+ .collection("companies")
+ .getOne<{ id: string; company: string; confirmed_letterhead?: unknown }>(companyId);
+ if (confirmedLetterheadOf(current) === undefined) {
+ throw Object.assign(new Error(COMPANY_LETTERHEAD_NOT_SET_UP), { status: 409 });
+ }
+ const updated = await pb
+ .collection("companies")
+ .update<{ id: string; company: string; confirmed_letterhead?: unknown }>(companyId, {
+ confirmed_letterhead: letterhead ?? "",
+ });
+ return {
+ id: updated.id,
+ company: updated.company,
+ confirmed_letterhead: confirmedLetterheadOf(updated) ?? null,
+ };
+}
diff --git a/src/lib/server/quote-pdf-service.test.ts b/src/lib/server/quote-pdf-service.test.ts
index 781e9cf..064dfc7 100644
--- a/src/lib/server/quote-pdf-service.test.ts
+++ b/src/lib/server/quote-pdf-service.test.ts
@@ -174,6 +174,14 @@ const { pbMock, clearStore } = vi.hoisted(() => {
...hit,
expand: { [nested]: nestedHit ? { ...nestedHit, id: nestedId } : null },
};
+ // Real PB nests `a.b` under `expand.a.expand.b`.
+ const parent = expand[target] as Record | null | undefined;
+ if (parent && nestedHit) {
+ parent.expand = {
+ ...((parent.expand as Record) ?? {}),
+ [nested]: { ...nestedHit, id: nestedId },
+ };
+ }
} else {
expand[rel] = { ...hit, expand: { [nested]: null } };
}
@@ -1240,3 +1248,141 @@ describe("generateQuotePdf auto-claim", () => {
expect(after.claimed_by).toBeFalsy();
});
});
+
+// ─────────────────────────────────────────────────────────────────────────────
+// generateQuotePdf — company letterhead lock (src/lib/company-letterhead.ts)
+// ─────────────────────────────────────────────────────────────────────────────
+
+describe("generateQuotePdf — company letterhead lock", () => {
+ async function seedLocked(opts: {
+ confirmed?: string;
+ stored?: string;
+ rendered?: boolean;
+ customerName?: string;
+ customerEmail?: string;
+ }) {
+ const company = await pbMock.collection("companies").create({
+ company: "Harmony Gold",
+ ...(opts.confirmed !== undefined ? { confirmed_letterhead: opts.confirmed } : {}),
+ });
+ const customer = await pbMock.collection("customers").create({
+ customer: opts.customerName ?? "Jan Buyer",
+ email: opts.customerEmail ?? "jan@harmony.co.za",
+ company: company.id,
+ });
+ const quote = await pbMock.collection("quotes").create({
+ quote_number: "Q-2026-10-777",
+ customer: customer.id,
+ status: "processed",
+ company_letterhead: opts.stored ?? "CNL",
+ pdf_generated_at: opts.rendered ? "2026-10-30T10:00:00Z" : "",
+ });
+ await pbMock.collection("quote_items").create({
+ quote: quote.id,
+ description: "M16 hex bolt",
+ quantity: 1,
+ cost_price: 2.39,
+ selling_price: 3.11,
+ markup: 30,
+ supplier: "sup-acme",
+ is_processed: true,
+ moved_to_quote: null,
+ });
+ return { company, customer, quote };
+ }
+
+ const pdfCount = async (quoteId: string) =>
+ (
+ await pbMock
+ .collection("quote_attachments")
+ .getFullList({ filter: `quote = "${quoteId}" && kind = "pdf"` })
+ ).length;
+
+ it("refuses the other letterhead without an override and writes nothing", async () => {
+ const { quote, company } = await seedLocked({ confirmed: "CNL" });
+ const outcome = await generateQuotePdf(quote.id, { letterhead: "ISHERWOOD" });
+ expect(outcome.kind).toBe("letterhead_locked");
+ if (outcome.kind !== "letterhead_locked") throw new Error("expected locked");
+ expect(outcome.confirmed).toBe("CNL");
+ expect(outcome.companyId).toBe(company.id);
+ expect(outcome.message).toContain("Harmony Gold");
+ expect(await pdfCount(quote.id)).toBe(0);
+ const after = await pbMock.collection("quotes").getOne<{ company_letterhead: string }>(quote.id);
+ expect(after.company_letterhead).toBe("CNL");
+ });
+
+ it("renders the company's letterhead normally", async () => {
+ const { quote } = await seedLocked({ confirmed: "ISHERWOOD", stored: "CNL" });
+ const outcome = await generateQuotePdf(quote.id, { letterhead: "ISHERWOOD" });
+ expect(outcome.kind).toBe("ok");
+ });
+
+ it("renders the other letterhead on override, and the override sticks for a later regen", async () => {
+ const { quote } = await seedLocked({ confirmed: "CNL" });
+ const first = await generateQuotePdf(quote.id, { letterhead: "ISHERWOOD", override: true });
+ expect(first.kind).toBe("ok");
+ if (first.kind !== "ok") throw new Error("expected ok");
+ expect(first.companyLetterhead).toBe("ISHERWOOD");
+
+ // The send's staleness regen passes no letterhead.
+ const regen = await generateQuotePdf(quote.id);
+ expect(regen.kind).toBe("ok");
+ if (regen.kind !== "ok") throw new Error("expected ok");
+ expect(regen.companyLetterhead).toBe("ISHERWOOD");
+ });
+
+ it("an unrendered quote takes the company's letterhead when none is passed, and stores it", async () => {
+ const { quote } = await seedLocked({ confirmed: "ISHERWOOD", stored: "CNL" });
+ const outcome = await generateQuotePdf(quote.id);
+ expect(outcome.kind).toBe("ok");
+ if (outcome.kind !== "ok") throw new Error("expected ok");
+ expect(outcome.companyLetterhead).toBe("ISHERWOOD");
+ const after = await pbMock.collection("quotes").getOne<{ company_letterhead: string }>(quote.id);
+ expect(after.company_letterhead).toBe("ISHERWOOD");
+ });
+
+ it("saves the company default from the popup, then renders on it", async () => {
+ const { quote, company } = await seedLocked({ confirmed: "" });
+ const outcome = await generateQuotePdf(quote.id, {
+ letterhead: "ISHERWOOD",
+ setCompanyDefault: true,
+ });
+ expect(outcome.kind).toBe("ok");
+ const after = await pbMock
+ .collection("companies")
+ .getOne<{ confirmed_letterhead: string }>(company.id);
+ expect(after.confirmed_letterhead).toBe("ISHERWOOD");
+ });
+
+ it("never overwrites an already-confirmed company via setCompanyDefault", async () => {
+ const { quote, company } = await seedLocked({ confirmed: "CNL" });
+ const outcome = await generateQuotePdf(quote.id, {
+ letterhead: "ISHERWOOD",
+ setCompanyDefault: true,
+ });
+ expect(outcome.kind).toBe("letterhead_locked");
+ const after = await pbMock
+ .collection("companies")
+ .getOne<{ confirmed_letterhead: string }>(company.id);
+ expect(after.confirmed_letterhead).toBe("CNL");
+ });
+
+ it("does not confirm a placeholder customer's company", async () => {
+ const { quote, company } = await seedLocked({ confirmed: "", customerEmail: "" });
+ const outcome = await generateQuotePdf(quote.id, {
+ letterhead: "CNL",
+ setCompanyDefault: true,
+ });
+ expect(outcome.kind).toBe("readiness_failure");
+ const after = await pbMock
+ .collection("companies")
+ .getOne<{ confirmed_letterhead: string }>(company.id);
+ expect(after.confirmed_letterhead).toBe("");
+ });
+
+ it("has no lock while the field is missing on the live PB", async () => {
+ const { quote } = await seedLocked({});
+ const outcome = await generateQuotePdf(quote.id, { letterhead: "ISHERWOOD" });
+ expect(outcome.kind).toBe("ok");
+ });
+});
diff --git a/src/lib/server/quote-pdf-service.ts b/src/lib/server/quote-pdf-service.ts
index bd97626..0d1e053 100644
--- a/src/lib/server/quote-pdf-service.ts
+++ b/src/lib/server/quote-pdf-service.ts
@@ -5,6 +5,14 @@ import {
loadLetterheadLogoDataUri,
type LetterheadCode,
} from "@/lib/letterhead";
+import {
+ checkLetterheadChoice,
+ confirmedLetterheadOf,
+ defaultQuoteLetterhead,
+ isQuoteLetterheadCode,
+ QUOTE_LETTERHEAD_LABELS,
+ type QuoteLetterheadCode,
+} from "@/lib/company-letterhead";
import { renderQuotePdf } from "@/lib/pdf/render";
import {
PLACEHOLDER_CUSTOMER_IDS,
@@ -171,6 +179,19 @@ export type GenerateQuotePdfOutcome =
issues?: QuotePdfReadinessIssue[];
};
}
+ | {
+ /**
+ * The customer's company is confirmed on another letterhead and
+ * the caller did not override (see `company-letterhead.ts`).
+ * Nothing was rendered or written.
+ */
+ kind: "letterhead_locked";
+ message: string;
+ requested: string;
+ confirmed: QuoteLetterheadCode;
+ companyId: string;
+ companyName: string;
+ }
| {
kind: "render_error";
message: string;
@@ -206,7 +227,17 @@ export type GenerateQuotePdfOutcome =
*/
export async function generateQuotePdf(
quoteId: string,
- options?: { letterhead?: string; actorEmail?: string },
+ options?: {
+ letterhead?: string;
+ actorEmail?: string;
+ /** Render on a letterhead other than the company's confirmed one (the triple-click). */
+ override?: boolean;
+ /**
+ * The company has no confirmed letterhead yet: confirm `letterhead`
+ * as its default before rendering (the process page's popup).
+ */
+ setCompanyDefault?: boolean;
+ },
): Promise {
const pb = await getAdminPocketBase();
@@ -240,6 +271,31 @@ export async function generateQuotePdf(
};
}
+ // ── 1b. Customer readiness gate ──────────────────────────
+ // (Runs before the letterhead lock so a placeholder customer
+ // never gets its company confirmed on a letterhead.)
+ // Block on placeholder customer. The office has to pick a
+ // real one on the process page before they can generate a
+ // PDF — sending a quote addressed to "Unknown Sender" is a
+ // data leak waiting to happen.
+ if (
+ PLACEHOLDER_CUSTOMER_IDS.has(customer.id) ||
+ PLACEHOLDER_CUSTOMER_NAMES.has((customer.customer || "").trim()) ||
+ !customer.email
+ ) {
+ return {
+ kind: "readiness_failure",
+ code: "placeholder_customer",
+ message:
+ "This quote is still pointed at a placeholder / unnamed customer. " +
+ "Pick the real customer on the process page before generating a PDF.",
+ details: {
+ customerId: customer.id,
+ customerName: customer.customer || "(blank)",
+ },
+ };
+ }
+
// ── 2. Letterhead resolution ─────────────────────────────
// Default to the quote's stored letterhead; let an explicit
// caller override win (the route does this from the request
@@ -249,12 +305,55 @@ export async function generateQuotePdf(
(LETTERHEAD_CODES as readonly string[]).includes(options.letterhead)
? (options.letterhead as LetterheadCode)
: null;
- const requestedCode = operatorLetterheadOverride ?? quote.company_letterhead;
+ // ── 2a. Company letterhead lock ──────────────────────────
+ // The customer's company may be confirmed on one letterhead
+ // (`companies.confirmed_letterhead`). An explicit choice that
+ // differs is refused unless the operator overrode; with no
+ // explicit choice, an unrendered quote takes the company's.
+ const company = customer.expand?.company as
+ | { id: string; company?: string; confirmed_letterhead?: unknown }
+ | undefined;
+ let confirmed = confirmedLetterheadOf(company);
+ const hasPdf = !!(quote.pdf_generated_at || "").trim();
+ if (company && confirmed === null && options?.setCompanyDefault && operatorLetterheadOverride) {
+ if (!isQuoteLetterheadCode(operatorLetterheadOverride)) {
+ return {
+ kind: "render_error",
+ message: `${operatorLetterheadOverride} cannot be a company's quote letterhead`,
+ };
+ }
+ await pb.collection("companies").update(company.id, {
+ confirmed_letterhead: operatorLetterheadOverride,
+ });
+ confirmed = operatorLetterheadOverride;
+ }
+ const requestedCode =
+ operatorLetterheadOverride ??
+ defaultQuoteLetterhead({ stored: quote.company_letterhead, hasPdf, confirmed });
const letterheadCode = (LETTERHEAD_CODES as readonly string[]).includes(
requestedCode || "",
)
? (requestedCode as LetterheadCode)
: ("CNL" as LetterheadCode);
+ const lock = checkLetterheadChoice({
+ requested: letterheadCode,
+ confirmed,
+ renderedLetterhead: hasPdf ? quote.company_letterhead : null,
+ override: options?.override === true,
+ });
+ if (!lock.ok && company) {
+ const companyName = company.company || "This company";
+ return {
+ kind: "letterhead_locked",
+ message:
+ `${companyName} is quoted on the ${QUOTE_LETTERHEAD_LABELS[lock.confirmed]} letterhead. ` +
+ `Use ${QUOTE_LETTERHEAD_LABELS[lock.confirmed]}, or override it on the quote's process page.`,
+ requested: letterheadCode,
+ confirmed: lock.confirmed,
+ companyId: company.id,
+ companyName,
+ };
+ }
// ── 2b. Auto-claim resolution ────────────────────────────
// Resolve `actorEmail` (from the route's `getServerIdentity`) to
@@ -294,29 +393,6 @@ export async function generateQuotePdf(
}
}
- // ── 3. Customer readiness gate ───────────────────────────
- // Block on placeholder customer. The office has to pick a
- // real one on the process page before they can generate a
- // PDF — sending a quote addressed to "Unknown Sender" is a
- // data leak waiting to happen.
- if (
- PLACEHOLDER_CUSTOMER_IDS.has(customer.id) ||
- PLACEHOLDER_CUSTOMER_NAMES.has((customer.customer || "").trim()) ||
- !customer.email
- ) {
- return {
- kind: "readiness_failure",
- code: "placeholder_customer",
- message:
- "This quote is still pointed at a placeholder / unnamed customer. " +
- "Pick the real customer on the process page before generating a PDF.",
- details: {
- customerId: customer.id,
- customerName: customer.customer || "(blank)",
- },
- };
- }
-
// ── 4. Item readiness gate ───────────────────────────────
// Active items = priced items that haven't been moved to a
// continuation quote. Items with `moved_to_quote != null`
@@ -475,9 +551,15 @@ export async function generateQuotePdf(
// when the caller passed a *known* letterhead code (not a
// typo'd unknown code) so a silent fallback to CNL doesn't
// silently rewrite the stored value either.
+ // An unrendered quote that took its company's confirmed letterhead
+ // is persisted too, so the quote page shows what was rendered.
+ const tookCompanyLetterhead =
+ !operatorLetterheadOverride && !!confirmed && letterheadCode === confirmed;
const letterheadPersistence = operatorLetterheadOverride
? { company_letterhead: operatorLetterheadOverride }
- : {};
+ : tookCompanyLetterhead && quote.company_letterhead !== letterheadCode
+ ? { company_letterhead: letterheadCode }
+ : {};
let newStatus = quote.status;
let pdfGeneratedAt: string | null = quote.pdf_generated_at || null;
/**
diff --git a/src/lib/server/quotes-from-email-service.ts b/src/lib/server/quotes-from-email-service.ts
index f8465a9..3dda9fd 100644
--- a/src/lib/server/quotes-from-email-service.ts
+++ b/src/lib/server/quotes-from-email-service.ts
@@ -38,6 +38,7 @@
*/
import { getAdminPocketBase } from "./pocketbase-admin";
import type { Quote, Customer, Company } from "@/lib/types";
+import { confirmedLetterheadOf, type QuoteLetterheadCode } from "@/lib/company-letterhead";
import {
findOrCreateCustomer,
findOrCreateCompany,
@@ -221,6 +222,10 @@ export async function ingestQuoteFromEmail(
// the upload step is allowed to fail (per file) without rolling
// back the quote, so this is the operator's "expect files"
// hint rather than the source of truth. ─────────────────────
+ // The customer's company may be confirmed on a letterhead
+ // (company-letterhead.ts); start the quote on it instead of CNL.
+ const letterhead = await customerCompanyLetterhead(pb, customer);
+
const quote = await pb.collection(QUOTES_COLLECTION).create({
quote_number: quoteNumber,
customer: customer.id,
@@ -232,6 +237,7 @@ export async function ingestQuoteFromEmail(
has_attachments: (payload.attachments?.length ?? 0) > 0,
email_message_id: effectiveMessageId,
...INTAKE_FIELDS,
+ ...(letterhead ? { company_letterhead: letterhead } : {}),
});
return {
@@ -243,3 +249,24 @@ export async function ingestQuoteFromEmail(
quote_number: quoteNumber,
};
}
+
+/**
+ * The confirmed letterhead of the customer's company, or null (no
+ * company, not confirmed, or the lookup failed — the intake default
+ * stands and the PDF service still applies the lock at render time).
+ */
+async function customerCompanyLetterhead(
+ pb: Awaited>,
+ customer: Customer,
+): Promise {
+ const companyId = typeof customer.company === "string" ? customer.company : "";
+ if (!companyId) return null;
+ try {
+ const company = await pb
+ .collection(COMPANIES_COLLECTION)
+ .getOne<{ confirmed_letterhead?: unknown }>(companyId);
+ return confirmedLetterheadOf(company) ?? null;
+ } catch {
+ return null;
+ }
+}
diff --git a/src/lib/server/quotes-service.ts b/src/lib/server/quotes-service.ts
index 3e7f43f..b454f9a 100644
--- a/src/lib/server/quotes-service.ts
+++ b/src/lib/server/quotes-service.ts
@@ -2240,6 +2240,15 @@ export async function sendQuoteEmail(
details: regen.details,
});
}
+ if (regen.kind === "letterhead_locked") {
+ // The stored letterhead isn't the company's confirmed one and
+ // was never rendered (an override sticks once rendered), so
+ // don't ship the stale PDF on the wrong letterhead.
+ return markPreSendFailure(pb, quote, {
+ code: "items_not_ready",
+ message: "Could not regenerate the PDF — the email was not sent. " + regen.message,
+ });
+ }
if (regen.kind === "render_error") {
// The render itself crashed (PB outage, render crash).
// Same shape — refuse to send, don't ship the stale PDF.
diff --git a/src/lib/types.ts b/src/lib/types.ts
index 59e0485..8a7ec98 100644
--- a/src/lib/types.ts
+++ b/src/lib/types.ts
@@ -122,6 +122,12 @@ export interface Company extends BaseRecord {
* radius filter without re-parsing.
*/
coordinates?: { lon: number; lat: number };
+ /**
+ * The company's quote letterhead, "" until confirmed. Absent on the
+ * record until `npm run pb:migrate:company-letterhead` has run. See
+ * `src/lib/company-letterhead.ts`.
+ */
+ confirmed_letterhead?: "" | "CNL" | "ISHERWOOD";
}
export interface CompanyDetails extends BaseRecord {