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) => ( + + ))} + {letterhead && ( + + )} +
+

+ {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(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 ( + + ); + })} +
+ {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) => ( + + ))} +
+ + + +
+
+
+ ); +} 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 {