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 <noreply@anthropic.com>

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
SJCoetz 2026-08-03 16:51:07 +02:00
parent 86534f738a
commit 91d8363380
4 changed files with 490 additions and 204 deletions

View file

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

View file

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

View file

@ -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<string, unknown>) {
async update(id: string, data: Record<string, unknown> | 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<string, unknown>)) {
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<Record<string, unknown>>;
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<string, unknown>).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<Record<string, unknown>>;
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<Record<string, unknown>>;
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<string, unknown>).expand = { supplier: sup };
await expect(generateStockPurchasePdf(p.id, {})).rejects.toBeInstanceOf(
StockPurchasePdfValidationError,
);
await expect(generateStockPurchasePdf(p.id, {})).rejects.toMatchObject({
code: "no_items",
});
});
});

View file

@ -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<GenerateStockPurchasePdfResult> {
const pb = await getAdminPocketBase();
// 1. PO + supplier (expanded server-side)
let purchase: StockPurchase;
try {
purchase = await pb
.collection(STOCK_PURCHASES_COLL)
.getOne<StockPurchase>(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<StockPurchaseItem>({
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,
};
}