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.
This commit is contained in:
Dave 2026-10-31 10:20:23 +02:00
parent afdfbce71d
commit bae4f9e795
21 changed files with 1216 additions and 74 deletions

View file

@ -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.

View file

@ -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",

View file

@ -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<Row>({
filter: 'pdf_generated_at != ""',
expand: "customer",
fields: "company_letterhead,expand.customer.company",
});
const counts = new Map<string, Record<string, number>>();
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<string, number> }[] = [];
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);
});

View file

@ -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<string, string>): 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<Line>({
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<string, { lines: number; invoices: Set<string> }>();
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<string>() };
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);
});

View file

@ -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);
});

View file

@ -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
</CardContent>
</Card>
{letterhead !== undefined && (
<Card data-testid="company-letterhead-card">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Quote letterhead</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
<div className="flex flex-wrap gap-2">
{QUOTE_LETTERHEAD_CODES.map((code) => (
<Button
key={code}
size="sm"
variant={letterhead === code ? "default" : "outline"}
disabled={savingLetterhead || letterhead === code}
onClick={() => saveLetterhead(code)}
aria-pressed={letterhead === code}
>
{QUOTE_LETTERHEAD_LABELS[code]}
</Button>
))}
{letterhead && (
<Button size="sm" variant="ghost" disabled={savingLetterhead} onClick={() => saveLetterhead(null)}>
Clear
</Button>
)}
</div>
<p className="text-xs text-muted-foreground">
{letterhead
? `Quotes for this company are locked to ${QUOTE_LETTERHEAD_LABELS[letterhead]}.`
: "Not confirmed yet — the next quote's PDF button will ask."}
</p>
</CardContent>
</Card>
)}
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Created</CardTitle>

View file

@ -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)`}
</p>
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
// Green (CNL) + blue (ISherwood) letterhead chips.
// Light-mode palettes; dark-mode flips to a deeper
// tint + lighter foreground so the chip still reads
// against the dark card. Buttons stay disabled while
// any item is unpriced or supplier-less.
className="bg-success/15 border-success/40 text-success hover:bg-success/25 dark:bg-success/15 dark:border-success/60 dark:text-success dark:hover:bg-success/25 disabled:opacity-40 disabled:bg-success/10"
disabled={!itemsReady || generatingPdf !== null}
onClick={() => generatePdf("CNL")}
data-testid="generate-pdf-cnl"
>
<FileDown className="mr-1 h-4 w-4" />
{generatingPdf === "CNL" ? "Generating…" : "CNL"}
</Button>
<Button
variant="outline"
size="sm"
className="bg-info/15 border-info/40 text-info hover:bg-info/25 dark:bg-info/15 dark:border-info/60 dark:text-info dark:hover:bg-info/25 disabled:opacity-40 disabled:bg-info/10"
disabled={!itemsReady || generatingPdf !== null}
onClick={() => generatePdf("ISHERWOOD")}
data-testid="generate-pdf-isherwood"
>
<FileDown className="mr-1 h-4 w-4" />
{generatingPdf === "ISHERWOOD" ? "Generating…" : "ISherwood"}
</Button>
</div>
<ProcessLetterheadButtons
companyName={letterheadCompany?.company || null}
confirmed={companyLetterhead}
disabled={!itemsReady}
generating={
generatingPdf === "CNL" || generatingPdf === "ISHERWOOD" ? generatingPdf : null
}
onGenerate={(code, options) => generatePdf(code, options)}
/>
</div>
</CardContent>
</Card>

View file

@ -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 });
}
}

View file

@ -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;

View file

@ -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(
{

View file

@ -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(
<ProcessLetterheadButtons
companyName="Harmony Gold"
confirmed={confirmed}
disabled={false}
generating={null}
onGenerate={onGenerate}
/>,
);
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 });
});
});

View file

@ -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<HTMLButtonElement>) => {
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 (
<div className="flex flex-col items-end gap-1">
<div className="flex gap-2">
{QUOTE_LETTERHEAD_CODES.map((code) => {
const locked = !!confirmed && code !== confirmed;
return (
<Button
key={code}
variant="outline"
size="sm"
// Green (CNL) + blue (ISherwood) letterhead chips.
// Light-mode palettes; dark-mode flips to a deeper
// tint + lighter foreground so the chip still reads
// against the dark card. Buttons stay disabled while
// any item is unpriced or supplier-less.
className={cn(
code === "CNL"
? "bg-success/15 border-success/40 text-success hover:bg-success/25 dark:bg-success/15 dark:border-success/60 dark:text-success dark:hover:bg-success/25 disabled:opacity-40 disabled:bg-success/10"
: "bg-info/15 border-info/40 text-info hover:bg-info/25 dark:bg-info/15 dark:border-info/60 dark:text-info dark:hover:bg-info/25 disabled:opacity-40 disabled:bg-info/10",
locked && "opacity-40 select-none",
)}
disabled={disabled || generating !== null}
onClick={(e) => 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 ? <Lock className="mr-1 h-4 w-4" /> : <FileDown className="mr-1 h-4 w-4" />}
{generating === code ? "Generating…" : QUOTE_LETTERHEAD_LABELS[code]}
</Button>
);
})}
</div>
{confirmed && (
<p className="text-[11px] text-muted-foreground" data-testid="letterhead-lock-hint">
{companyName ?? "This company"} uses {QUOTE_LETTERHEAD_LABELS[confirmed]} · triple-click the other to override
</p>
)}
<Dialog open={askDefault} onOpenChange={setAskDefault}>
<DialogContent className="sm:max-w-md" data-testid="letterhead-default-dialog">
<DialogHeader>
<DialogTitle>Set the default letterhead for {companyName ?? "this company"}</DialogTitle>
<DialogDescription>
Every quote for this company will use it. Make sure it&apos;s the right one: after
this, only that letterhead&apos;s button works for this company. You can change it
later on the company page.
</DialogDescription>
</DialogHeader>
<div className="grid grid-cols-2 gap-3 py-2">
{QUOTE_LETTERHEAD_CODES.map((code) => (
<Button
key={code}
variant="outline"
className={cn(
"h-14 text-base font-semibold",
code === "CNL"
? "bg-success/15 border-success/40 text-success hover:bg-success/25"
: "bg-info/15 border-info/40 text-info hover:bg-info/25",
)}
onClick={() => chooseDefault(code)}
data-testid={`letterhead-default-${code.toLowerCase()}`}
>
{QUOTE_LETTERHEAD_LABELS[code]}
</Button>
))}
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => setAskDefault(false)}>
Cancel
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View file

@ -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");
});
});

View file

@ -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<QuoteLetterheadCode, string> = {
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";
}

View file

@ -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;

View file

@ -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,
};
}

View file

@ -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<string, unknown> | null | undefined;
if (parent && nestedHit) {
parent.expand = {
...((parent.expand as Record<string, unknown>) ?? {}),
[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");
});
});

View file

@ -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<GenerateQuotePdfOutcome> {
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;
/**

View file

@ -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<ReturnType<typeof getAdminPocketBase>>,
customer: Customer,
): Promise<QuoteLetterheadCode | null> {
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;
}
}

View file

@ -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.

View file

@ -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 {