From 91d8363380f165233b255c993e5218cfa638ebf5 Mon Sep 17 00:00:00 2001 From: SJCoetz Date: Mon, 3 Aug 2026 16:51:07 +0200 Subject: [PATCH] fix(stock-purchase-send): render auto-PDF in-process instead of HTTP self-call The send-email route's auto-PDF block did an internal \`fetch(/api/v1/stock-purchases/[id]/pdf)\` to render a PDF when the operator hit Send on a stock PO without one. Cloudflare's loopback filter rejects that self-call with a network-level error before Next.js sees it, surfacing as 'fetch failed' in the modal (status rolled to draft, route returned 500 with code 'pdf_failed'). Extract the PDF generation into \`generateStockPurchasePdf\` on the service layer so the send-email route can render the PDF in-process. Same pattern as the customer-PO flow's \`generatePurchaseOrderPdf\`. The route stays a thin shim that maps \`StockPurchasePdfValidationError\` to 422. Co-Authored-By: Claude Co-authored-by: Orca --- .../api/v1/stock-purchases/[id]/pdf/route.ts | 200 +++------------ .../stock-purchases/[id]/send-email/route.ts | 42 ++-- .../server/stock-purchases-service.test.ts | 229 +++++++++++++++++- src/lib/server/stock-purchases-service.ts | 223 +++++++++++++++++ 4 files changed, 490 insertions(+), 204 deletions(-) diff --git a/src/app/api/v1/stock-purchases/[id]/pdf/route.ts b/src/app/api/v1/stock-purchases/[id]/pdf/route.ts index a4dc4ab..d56bbce 100644 --- a/src/app/api/v1/stock-purchases/[id]/pdf/route.ts +++ b/src/app/api/v1/stock-purchases/[id]/pdf/route.ts @@ -1,16 +1,10 @@ import { NextRequest, NextResponse } from "next/server"; import { getAdminPocketBase } from "@/lib/server/pocketbase-admin"; import { - fetchLetterhead, - loadLetterheadLogoDataUri, -} from "@/lib/letterhead"; -import { renderStockPurchasePdf } from "@/lib/pdf/render"; -import type { - StockPurchase, - StockPurchaseItem, - Supplier, - PurchaseOrderDocumentEntry, -} from "@/lib/types"; + generateStockPurchasePdf, + StockPurchasePdfValidationError, +} from "@/lib/server/stock-purchases-service"; +import type { StockPurchase, PurchaseOrderDocumentEntry } from "@/lib/types"; /** * Stock Purchase Order PDF API — `POST /api/v1/stock-purchases/[id]/pdf` @@ -18,26 +12,12 @@ import type { * Body: `{ notes?: string }` — optional free-text reason, required on * regenerate (v≥2) so the audit trail explains why the PDF changed. * - * Mirrors the customer-PO PDF route - * (`src/app/api/v1/purchase-orders/[id]/pdf/route.ts`) line-for-line - * except: - * - reads from `stock_purchases` + `stock_purchase_items` - * - renders `StockPurchaseDocument` (no customer/order context) - * - drops the resulting file into `stock_purchases.documents` - * - * Flow: - * 1. Validate PO exists + has supplier (422 with code) - * 2. Load lines linked to this PO (`stock_purchase_items.purchase = id`) - * 3. Fetch the hard-coded CNL letterhead (stock POs are always - * issued by CNL — same as customer POs) - * 4. Inline CNL logo as base64 SVG data URI (same @react-pdf workaround) - * 5. Compute subtotal (locked snapshot for storage) - * 6. Compute next version = max(documents_history.version) + 1 - * 7. Render PDF in-process via @react-pdf (Node runtime) - * 8. Append the new PDF to `stock_purchases.documents` (file, multi) - * AND append a metadata entry to `stock_purchases.documents_history` - * in a single PB update. - * 9. Return { pdfId, version, filename, downloadUrl, total } + * Thin shim — the actual PDF rendering lives in + * `generateStockPurchasePdf` (`src/lib/server/stock-purchases-service.ts`). + * That same function is called from the send-email route's auto-PDF + * block so we don't HTTP self-call (Cloudflare rejects the loopback + * with a network-level error, surfacing as "fetch failed" to the + * operator). * * `GET /api/v1/stock-purchases/[id]/pdf?version=N` * @@ -54,7 +34,11 @@ export const dynamic = "force-dynamic"; type ValidationErrorBody = { error: string; - code: "missing_relations" | "no_items" | "no_letterhead"; + code: + | "missing_relations" + | "no_items" + | "no_letterhead" + | "purchase_not_found"; details?: { purchaseId?: string; supplierId?: string; @@ -80,153 +64,23 @@ export async function POST( } try { - const pb = await getAdminPocketBase(); - - // ─── 1. PO + supplier (all expanded server-side) ─── - const purchase = await pb - .collection("stock_purchases") - .getOne(purchaseId, { expand: "supplier" }); - - const supplier = purchase.expand?.supplier as Supplier | undefined; - if (!supplier) { - const body: ValidationErrorBody = { - error: - "Stock purchase is missing the supplier relation. Cannot render PDF.", - code: "missing_relations", - details: { - purchaseId: purchase.id, - supplierId: purchase.supplier || undefined, - }, - }; - return NextResponse.json(body, { status: 422 }); - } - - // ─── 2. Lines linked to this PO ─── - const items = await pb - .collection("stock_purchase_items") - .getFullList({ - filter: `purchase = "${purchaseId}"`, - sort: "created", - expand: "stock_item", - }); - - if (items.length === 0) { - const body: ValidationErrorBody = { - error: - "This stock PO has no line items attached. Add lines on the " + - "stock-order detail page before generating a PDF.", - code: "no_items", - details: { purchaseId: purchase.id }, - }; - return NextResponse.json(body, { status: 422 }); - } - - // ─── 3. Letterhead — always CNL ─── - let letterhead; - try { - letterhead = await fetchLetterhead("CNL"); - } catch (err) { - const body: ValidationErrorBody = { - error: - err instanceof Error - ? err.message - : "CNL letterhead not found in letterhead_settings.", - code: "no_letterhead", - details: { purchaseId: purchase.id }, - }; - return NextResponse.json(body, { status: 422 }); - } - - // ─── 4. Logo ─── - const logoDataUri = await loadLetterheadLogoDataUri("CNL"); - - // ─── 5. Compute totals (locked snapshot for storage) ─── - const subtotal = Math.round( - items.reduce( - (s, it) => - s + (Number(it.quantity) || 0) * (Number(it.cost_price) || 0), - 0 - ) * 100 - ) / 100; - - // ─── 6. Next version number ─── - const history: PurchaseOrderDocumentEntry[] = purchase.documents_history ?? []; - const nextVersion = - history.length > 0 - ? Math.max(...history.map((h) => h.version)) + 1 - : 1; - - // Mark every previously-"current" history entry as "superseded". - const newHistoryBase: PurchaseOrderDocumentEntry[] = history.map((h) => - h.status === "current" ? { ...h, status: "superseded" } : h - ); - - // ─── 7. Render PDF ─── - const generatedAtIso = new Date().toISOString(); - const pdfBuffer = await renderStockPurchasePdf({ - purchase, - items, - supplier, - letterhead, - logoDataUri, - generatedAtIso, - totalAmount: subtotal, + const result = await generateStockPurchasePdf(purchaseId, { + notes: body.notes, + generatedBy: body.generatedBy, }); - - // ─── 8. Append PDF + history entry to stock_purchases.documents ─── - const safePo = String(purchase.purchase_number || purchaseId).replace( - /[^\w.-]/g, - "_" - ); - const filename = `STK-PO-CNL-${safePo}-v${nextVersion}.pdf`; - - // PB file fields accept an array of File/Blob. Append to the - // existing array so every regen is preserved. - const formData = new FormData(); - formData.append( - "documents", - new Blob([new Uint8Array(pdfBuffer)], { type: "application/pdf" }), - filename - ); - const updated = await pb - .collection("stock_purchases") - .update<{ documents: string[] | string }>(purchaseId, formData); - const docsRaw = updated?.documents ?? []; - const docs = Array.isArray(docsRaw) ? docsRaw : [docsRaw]; - const newToken = docs[docs.length - 1] ?? ""; - - const newHistoryEntry: PurchaseOrderDocumentEntry = { - version: nextVersion, - file: filename, - token: newToken, - generatedAt: generatedAtIso, - generatedBy: body.generatedBy || "", - reason: (body.notes || "").slice(0, 500), - status: "current", - }; - - await pb.collection("stock_purchases").update(purchaseId, { - documents_history: [...newHistoryBase, newHistoryEntry], - }); - - // ─── 9. Persist total_amount (locked snapshot). Status is NOT - // touched here — the send flow owns the draft → sending → - // sent state machine. Stock PO receive is also separate. - await pb.collection("stock_purchases").update(purchaseId, { - total_amount: subtotal, - }); - - // ─── 10. Response ─── return NextResponse.json({ ok: true, - pdfId: purchaseId, - version: nextVersion, - filename, - downloadUrl: `/api/v1/files/stock_purchases/${purchaseId}/${filename}`, - total: subtotal, - generatedAt: generatedAtIso, + ...result, }); } catch (err) { + if (err instanceof StockPurchasePdfValidationError) { + const responseBody: ValidationErrorBody = { + error: err.message, + code: err.code, + details: err.details, + }; + return NextResponse.json(responseBody, { status: 422 }); + } console.error("[stock-po-pdf] render failed:", err); return NextResponse.json( { diff --git a/src/app/api/v1/stock-purchases/[id]/send-email/route.ts b/src/app/api/v1/stock-purchases/[id]/send-email/route.ts index 7f6b0cb..f143063 100644 --- a/src/app/api/v1/stock-purchases/[id]/send-email/route.ts +++ b/src/app/api/v1/stock-purchases/[id]/send-email/route.ts @@ -1,6 +1,8 @@ import { NextRequest, NextResponse } from "next/server"; import { sendStockPurchaseEmail, + generateStockPurchasePdf, + StockPurchasePdfValidationError, type SendStockPurchaseEmailPayload, } from "@/lib/server/stock-purchases-service"; import { getAdminPocketBase } from "@/lib/server/pocketbase-admin"; @@ -105,38 +107,18 @@ export async function POST( // version already exists, we use the latest. If the operator // edits the costs and re-sends, the next "Send" creates a new // version on top of the old one. + // + // Calls the rendering logic in-process via + // `generateStockPurchasePdf` rather than HTTP self-calling + // `/api/v1/stock-purchases/[id]/pdf`. The self-call was rejected + // by Cloudflare's loopback filter as a network-level error + // (surfaced to the operator as "fetch failed" in the modal). if ( !result.purchase.documents || (Array.isArray(result.purchase.documents) && result.purchase.documents.length === 0) ) { try { - const pdfRes = await fetch( - new URL(`/api/v1/stock-purchases/${purchaseId}/pdf`, req.nextUrl.origin).toString(), - { - method: "POST", - headers: { cookie: req.headers.get("cookie") ?? "" }, - // Body is optional; the route accepts an empty object. - } - ); - if (!pdfRes.ok) { - // PDF generation failed — roll the status back to draft so - // the operator can retry. The email wasn't sent yet. - const reason = `PDF generation failed: ${pdfRes.status}`; - const rolled = (await adminPb - .collection("stock_purchases") - .update(purchaseId, { - status: "draft", - email_error: `[pdf] ${reason}`, - }) - .catch(() => result.purchase)) as typeof result.purchase; - return NextResponse.json( - { - purchase: rolled, - failure: { code: "pdf_failed", message: reason }, - }, - { status: 500 } - ); - } + await generateStockPurchasePdf(purchaseId, {}); // Re-read the purchase to pick up the freshly-stored // documents + documents_history. const fresh = (await adminPb @@ -145,7 +127,11 @@ export async function POST( result.purchase = fresh; } catch (pdfErr) { const reason = - pdfErr instanceof Error ? pdfErr.message : "PDF generation threw"; + pdfErr instanceof StockPurchasePdfValidationError + ? pdfErr.message + : pdfErr instanceof Error + ? pdfErr.message + : "PDF generation threw"; const rolled = (await adminPb .collection("stock_purchases") .update(purchaseId, { diff --git a/src/lib/server/stock-purchases-service.test.ts b/src/lib/server/stock-purchases-service.test.ts index 0aa4a26..69681ce 100644 --- a/src/lib/server/stock-purchases-service.test.ts +++ b/src/lib/server/stock-purchases-service.test.ts @@ -102,7 +102,7 @@ const { store, pbMock, clearStore, setNow, now } = vi.hoisted(() => { } return items[0]; }, - async getOne(id: string) { + async getOne(id: string, _opts?: { expand?: string }) { const item = getOrCreateMap(name).get(id); if (!item) { const err = new Error("not found") as Error & { status: number }; @@ -126,7 +126,7 @@ const { store, pbMock, clearStore, setNow, now } = vi.hoisted(() => { getOrCreateMap(name).set(id, item); return item; }, - async update(id: string, data: Record) { + async update(id: string, data: Record | FormData) { const map = getOrCreateMap(name); const existing = map.get(id); if (!existing) { @@ -134,7 +134,34 @@ const { store, pbMock, clearStore, setNow, now } = vi.hoisted(() => { err.status = 404; throw err; } - for (const [key, value] of Object.entries(data)) { + // Handle FormData uploads (PB file fields). PB stores the + // generated filename token; for the test we approximate by + // appending the original filename to `documents` so the + // post-update `getOne` sees what the production code would. + if ( + typeof FormData !== "undefined" && + data instanceof FormData + ) { + for (const [key, value] of data.entries()) { + if (typeof Blob !== "undefined" && value instanceof Blob) { + // The PDF service uses `new Blob([...], { type: ... })` + // with a filename in the FormData append slot. The + // blob itself doesn't carry the filename, so we read + // it off the FormData entry metadata if available. + const filename = + (value as Blob & { name?: string }).name || "upload.pdf"; + const arr = Array.isArray(existing[key]) + ? (existing[key] as string[]) + : existing[key] + ? [existing[key] as string] + : []; + existing[key] = [...arr, filename]; + } + } + existing.updated = nowDate.toISOString(); + return { ...existing }; + } + for (const [key, value] of Object.entries(data as Record)) { if (key.endsWith("+")) { const real = key.slice(0, -1); existing[real] = Number(existing[real] ?? 0) + Number(value); @@ -171,9 +198,31 @@ vi.mock("./pocketbase-admin", () => ({ getAdminPocketBase: vi.fn().mockResolvedValue(pbMock), })); +// Mock the PDF rendering + letterhead so `generateStockPurchasePdf` +// can run in unit tests without spinning up the full @react-pdf +// pipeline. The render mock returns a fake Buffer; the letterhead +// mock returns a minimal shape the code reads. +vi.mock("@/lib/pdf/render", () => ({ + renderStockPurchasePdf: vi.fn().mockResolvedValue(Buffer.from("FAKE-PDF")), +})); +vi.mock("@/lib/letterhead", () => ({ + fetchLetterhead: vi.fn().mockResolvedValue({ + companyName: "CNL Mining Supplies", + address: "1 Test Lane", + phone: "", + email: "", + vatNumber: "", + registrationNumber: "", + bankDetails: "", + }), + loadLetterheadLogoDataUri: vi.fn().mockResolvedValue("data:image/svg+xml;base64,FAKE"), +})); + import { createStockPurchase, generateStockPurchaseNumber, + generateStockPurchasePdf, + StockPurchasePdfValidationError, listStockPurchases, getStockPurchase, getStockPurchaseItems, @@ -902,3 +951,177 @@ describe("recordStockPurchaseSendOutcome", () => { expect(itemsAfter.find((i) => i.id === it1.id)?.status).toBe("collection_pool"); }); }); + +describe("generateStockPurchasePdf", () => { + /** + * Seed a stock purchase with a supplier + at least one linked + * line item. Returns the purchase row. + */ + async function seedStockPurchaseWithLines(opts: { + documents?: string[]; + documentsHistory?: Array>; + existingDocuments?: string[]; + } = {}) { + const sup = await pbMock.collection("suppliers").create({ + suppliername: "Acme Mining", + email: "sales@acme.test", + }); + const p = await pbMock.collection("stock_purchases").create({ + purchase_number: "PO26071501STK", + status: "draft", + supplier: sup.id, + documents: opts.existingDocuments ?? [], + documents_history: opts.documentsHistory ?? [], + }); + // Stitch `expand.supplier` onto the purchase so the mock's + // expand option isn't needed (the collection map keys are + // plural — "suppliers" — but the service reads `expand.supplier`, + // so it's easier to set expand directly here). + (p as Record).expand = { supplier: sup }; + await pbMock.collection("stock_purchase_items").create({ + purchase: p.id, + stock_item: "stk-1", + description: "Hose 1m", + quantity: 4, + cost_price: 250, + status: "collection_pool", + }); + await pbMock.collection("stock_purchase_items").create({ + purchase: p.id, + stock_item: "stk-2", + description: "Fitting", + quantity: 2, + cost_price: 100, + status: "collection_pool", + }); + return p; + } + + it("renders a PDF, appends it to documents, and writes a history entry", async () => { + const p = await seedStockPurchaseWithLines(); + + const result = await generateStockPurchasePdf(p.id, {}); + + expect(result.pdfId).toBe(p.id); + expect(result.version).toBe(1); + expect(result.filename).toMatch(/^STK-PO-CNL-PO26071501STK-v1\.pdf$/); + expect(result.total).toBe(1200); // 4*250 + 2*100 = 1200 + expect(result.downloadUrl).toBe(`/api/v1/files/stock_purchases/${p.id}/${result.filename}`); + + const after = await pbMock.collection("stock_purchases").getOne(p.id); + const docsAfter = Array.isArray(after.documents) + ? after.documents + : after.documents + ? [after.documents] + : []; + expect(docsAfter).toContain(result.filename); + + const history = (after.documents_history ?? []) as Array>; + expect(history).toHaveLength(1); + expect(history[0]).toMatchObject({ + version: 1, + file: result.filename, + status: "current", + }); + expect(after.total_amount).toBe(1200); + }); + + it("bumps version + marks previous 'current' as 'superseded' on regen", async () => { + const p = await seedStockPurchaseWithLines({ + documents: ["STK-PO-CNL-PO26071501STK-v1.pdf"], + documentsHistory: [ + { + version: 1, + file: "STK-PO-CNL-PO26071501STK-v1.pdf", + token: "tok-v1", + generatedAt: "2026-07-14T12:00:00.000Z", + generatedBy: "", + reason: "", + status: "current", + }, + ], + }); + + const result = await generateStockPurchasePdf(p.id, { + notes: "Edited cost_price on line 2", + }); + + expect(result.version).toBe(2); + + const after = await pbMock.collection("stock_purchases").getOne(p.id); + const history = after.documents_history as Array>; + expect(history).toHaveLength(2); + expect(history[0]?.status).toBe("superseded"); + expect(history[1]).toMatchObject({ + version: 2, + file: result.filename, + status: "current", + reason: "Edited cost_price on line 2", + }); + }); + + it("does NOT touch the purchase status (PDF lifecycle ≠ email lifecycle)", async () => { + const p = await seedStockPurchaseWithLines(); + // Status starts as "draft"; PDF generation must leave it alone. + const result = await generateStockPurchasePdf(p.id, {}); + expect(result).toBeDefined(); + const after = await pbMock.collection("stock_purchases").getOne(p.id); + expect(after.status).toBe("draft"); + }); + + it("throws StockPurchasePdfValidationError(purchase_not_found) for a missing id", async () => { + await expect(generateStockPurchasePdf("does-not-exist", {})).rejects.toBeInstanceOf( + StockPurchasePdfValidationError, + ); + await expect(generateStockPurchasePdf("does-not-exist", {})).rejects.toMatchObject({ + code: "purchase_not_found", + }); + }); + + it("throws StockPurchasePdfValidationError(missing_relations) when the PO has no supplier", async () => { + const p = await pbMock.collection("stock_purchases").create({ + purchase_number: "PO26071501STK", + status: "draft", + supplier: "", + documents: [], + documents_history: [], + }); + await pbMock.collection("stock_purchase_items").create({ + purchase: p.id, + description: "x", + quantity: 1, + cost_price: 1, + }); + + await expect(generateStockPurchasePdf(p.id, {})).rejects.toBeInstanceOf( + StockPurchasePdfValidationError, + ); + await expect(generateStockPurchasePdf(p.id, {})).rejects.toMatchObject({ + code: "missing_relations", + }); + }); + + it("throws StockPurchasePdfValidationError(no_items) when there are no line items", async () => { + const sup = await pbMock.collection("suppliers").create({ + suppliername: "Acme", + email: "a@b.test", + }); + const p = await pbMock.collection("stock_purchases").create({ + purchase_number: "PO26071501STK", + status: "draft", + supplier: sup.id, + documents: [], + documents_history: [], + }); + // Stitch expand so the supplier check passes and the no_items + // branch is reached. + (p as Record).expand = { supplier: sup }; + + await expect(generateStockPurchasePdf(p.id, {})).rejects.toBeInstanceOf( + StockPurchasePdfValidationError, + ); + await expect(generateStockPurchasePdf(p.id, {})).rejects.toMatchObject({ + code: "no_items", + }); + }); +}); diff --git a/src/lib/server/stock-purchases-service.ts b/src/lib/server/stock-purchases-service.ts index 01f8c9f..f80ffff 100644 --- a/src/lib/server/stock-purchases-service.ts +++ b/src/lib/server/stock-purchases-service.ts @@ -47,9 +47,15 @@ import type { StockPurchaseItem, StockPurchaseStatus, Supplier, + PurchaseOrderDocumentEntry, } from "@/lib/types"; import { buildDefaultPoEmailBody } from "./po-email-template"; import { getAdminPocketBase } from "./pocketbase-admin"; +import { + fetchLetterhead, + loadLetterheadLogoDataUri, +} from "@/lib/letterhead"; +import { renderStockPurchasePdf } from "@/lib/pdf/render"; // Re-export the types from `@/lib/types` so the route handlers // don't need a second import. The types are the source of truth @@ -1110,3 +1116,220 @@ export async function recordStockPurchaseSendOutcome( .collection(STOCK_PURCHASES_COLL) .update(input.purchaseId, patch)) as StockPurchase; } + +/* ============================================================================ + * PDF generation (extracted from the /pdf route so the send-email + * route can render server-side without HTTP self-calling — see + * src/app/api/v1/stock-purchases/[id]/send-email/route.ts for context) + * ========================================================================== */ + +/** + * Validation errors thrown by `generateStockPurchasePdf`. Mirrors + * the customer-PO `PurchaseOrderPdfValidationError` shape so the + * route handlers can map these to HTTP 422 with a consistent body. + */ +export class StockPurchasePdfValidationError extends Error { + constructor( + message: string, + public readonly code: + | "missing_relations" + | "no_items" + | "no_letterhead" + | "purchase_not_found", + public readonly details?: { + purchaseId?: string; + supplierId?: string; + }, + ) { + super(message); + this.name = "StockPurchasePdfValidationError"; + } +} + +export interface GenerateStockPurchasePdfInput { + /** Free-text reason written to `documents_history.reason`. */ + notes?: string; + /** Operator id/name for the audit trail. Optional. */ + generatedBy?: string; +} + +export interface GenerateStockPurchasePdfResult { + pdfId: string; + version: number; + filename: string; + downloadUrl: string; + total: number; + generatedAt: string; +} + +/** + * Server-side render of a Stock Purchase Order PDF and append it to + * `stock_purchases.documents` (file) + `documents_history` (json). + * + * Shared by the explicit `/api/v1/stock-purchases/[id]/pdf` route + * (operator clicks "Regenerate PDF") and the send-email route's + * auto-PDF block (when the operator clicks Send on a PO that has + * no document yet). + * + * The previous send-email flow tried to invoke this via + * `fetch(\`/api/v1/stock-purchases/.../pdf\`)` — a self-call over + * HTTPS. Cloudflare (or any reverse proxy that filters loopback + * traffic) rejects that with a network-level error before the + * request reaches Next.js, surfacing as "fetch failed" to the + * operator. Calling the rendering logic in-process bypasses the + * network round-trip entirely. + * + * What it does NOT do: flip the PO to `sent`. The PDF lifecycle is + * decoupled from the email lifecycle — this matches the customer-PO + * behaviour adopted in the generatePurchaseOrderPdf refactor. + * + * Throws `StockPurchasePdfValidationError` for user-actionable + * preconditions (missing relations, no items, missing letterhead) + * so the caller can map them to HTTP 422. Other errors propagate. + */ +export async function generateStockPurchasePdf( + purchaseId: string, + input: GenerateStockPurchasePdfInput = {}, +): Promise { + const pb = await getAdminPocketBase(); + + // 1. PO + supplier (expanded server-side) + let purchase: StockPurchase; + try { + purchase = await pb + .collection(STOCK_PURCHASES_COLL) + .getOne(purchaseId, { expand: "supplier" }); + } catch { + throw new StockPurchasePdfValidationError( + "Stock purchase does not exist.", + "purchase_not_found", + { purchaseId }, + ); + } + + const supplier = purchase.expand?.supplier as Supplier | undefined; + if (!supplier) { + throw new StockPurchasePdfValidationError( + "Stock purchase is missing the supplier relation. Cannot render PDF.", + "missing_relations", + { + purchaseId: purchase.id, + supplierId: purchase.supplier || undefined, + }, + ); + } + + // 2. Items linked to this PO + const items = await pb + .collection(STOCK_PURCHASE_ITEMS_COLL) + .getFullList({ + filter: `purchase = "${purchaseId}"`, + sort: "created", + expand: "stock_item", + }); + + if (items.length === 0) { + throw new StockPurchasePdfValidationError( + "This stock PO has no line items attached. Add lines on the " + + "stock-order detail page before generating a PDF.", + "no_items", + { purchaseId: purchase.id }, + ); + } + + // 3. Letterhead — always CNL + let letterhead; + try { + letterhead = await fetchLetterhead("CNL"); + } catch (err) { + throw new StockPurchasePdfValidationError( + err instanceof Error + ? err.message + : "CNL letterhead not found in letterhead_settings.", + "no_letterhead", + { purchaseId: purchase.id }, + ); + } + + // 4. Logo + const logoDataUri = await loadLetterheadLogoDataUri("CNL"); + + // 5. Compute totals (locked snapshot for storage) + const subtotal = + Math.round( + items.reduce( + (s, it) => + s + (Number(it.quantity) || 0) * (Number(it.cost_price) || 0), + 0, + ) * 100, + ) / 100; + + // 6. Next version number + const history: PurchaseOrderDocumentEntry[] = purchase.documents_history ?? []; + const nextVersion = + history.length > 0 + ? Math.max(...history.map((h) => h.version)) + 1 + : 1; + + // 7. Render PDF + const generatedAtIso = new Date().toISOString(); + const pdfBuffer = await renderStockPurchasePdf({ + purchase, + items, + supplier, + letterhead, + logoDataUri, + generatedAtIso, + totalAmount: subtotal, + }); + + // 8. Append PDF to stock_purchases.documents (multipart) + const safePo = String(purchase.purchase_number || purchaseId).replace( + /[^\w.-]/g, + "_", + ); + const filename = `STK-PO-CNL-${safePo}-v${nextVersion}.pdf`; + const formData = new FormData(); + formData.append( + "documents", + new Blob([new Uint8Array(pdfBuffer)], { type: "application/pdf" }), + filename, + ); + const updated = await pb + .collection(STOCK_PURCHASES_COLL) + .update<{ documents: string[] | string }>(purchaseId, formData); + const docsRaw = updated?.documents ?? []; + const docs = Array.isArray(docsRaw) ? docsRaw : [docsRaw]; + const newToken = docs[docs.length - 1] ?? ""; + + // 9. Append history entry — mark previous "current" as "superseded" + const newHistoryBase: PurchaseOrderDocumentEntry[] = history.map((h) => + h.status === "current" ? { ...h, status: "superseded" } : h, + ); + const newHistoryEntry: PurchaseOrderDocumentEntry = { + version: nextVersion, + file: filename, + token: newToken, + generatedAt: generatedAtIso, + generatedBy: input.generatedBy || "", + reason: (input.notes || "").slice(0, 500), + status: "current", + }; + await pb.collection(STOCK_PURCHASES_COLL).update(purchaseId, { + documents_history: [...newHistoryBase, newHistoryEntry], + }); + + // 10. Persist total_amount (status stays as-is — see function JSDoc) + await pb.collection(STOCK_PURCHASES_COLL).update(purchaseId, { + total_amount: subtotal, + }); + + return { + pdfId: purchaseId, + version: nextVersion, + filename, + downloadUrl: `/api/v1/files/stock_purchases/${purchaseId}/${filename}`, + total: subtotal, + generatedAt: generatedAtIso, + }; +}