import assert from "node:assert/strict" import test from "node:test" import { execFileSync } from "node:child_process" import { Readable } from "node:stream" import { PDFDocument } from "pdf-lib" import { initS3, s3 } from "../src/utils/s3" import { createInvoicePDF, InvoicePdfLayout } from "../src/utils/pdf" import { buildOutgoingDocumentPdfData, calculateOutgoingDocumentTotals } from "../src/modules/outgoing-document-pdf.service" import { analyzeOutgoingDocumentLayout } from "../src/mcp/outgoingDocumentLayout" import { renderPdfPagesAsPng } from "../src/utils/pdfPreview" import { asToolResult, withImages } from "../src/mcp/result" import { mcpToolMap } from "../src/mcp/registry" const hasPdftoppm = (() => { try { execFileSync("pdftoppm", ["-v"], { stdio: "ignore" }); return true } catch { return false } })() const document = (rows: any[]) => ({ id: 1, type: "invoices", taxType: "Standard", state: "Entwurf", title: "Rechnung-Nr. XXXX", documentDate: "2026-10-28", deliveryDateType: "Lieferdatum", deliveryDate: "2026-10-27", address: { street: "Weg 1", zip: "12345", city: "Ort" }, startText: "Hallo {{vorname}},", endText: "Zahlbar in {{zahlungsziel_in_tagen}} Tagen.", paymentDays: 14, payment_type: "transfer", usedAdvanceInvoices: [], report: {}, rows, }) const context = { tenant: { name: "Firma", businessInfo: { name: "Firma", street: "Str. 1", zip: "1", city: "Stadt", iban: "DE02120300000000202051" }, extraModules: [] }, customer: { name: "Kunde", customerNumber: "K-1", firstname: "Ada" }, contactPerson: { full_name: "Bea Beispiel", email: "bea@example.invalid" }, units: [{ id: 1, short: "Stk" }], products: [{ id: 3, name: "Kabel" }], services: [], bankAccounts: [], advanceInvoices: [], } const positions = (count: number) => Array.from({ length: count }, (_, index) => ({ id: `r${index}`, pos: String(index + 1), mode: index % 2 ? "normal" : "free", product: 3, text: `Position ${index + 1}`, description: "Zweizeilige\nBeschreibung", quantity: 2, unit: 1, price: 10.5, taxPercent: 19, discountPercent: 0, })) test("pdf data matches the editor: title sums, transfers, optional rows and handlebars", () => { const rows = [ { pos: "1", mode: "title", text: "Bau", titleLevel: 1 }, { pos: "1.1", mode: "free", text: "A", quantity: 2, price: 10, discountPercent: 0, taxPercent: 19 }, { pos: "1.2", mode: "free", text: "B", quantity: 1, price: 5, discountPercent: 0, taxPercent: 19, optional: true }, { mode: "pagebreak" }, { pos: "2", mode: "title", text: "Elektro", titleLevel: 1 }, { pos: "2.1", mode: "normal", product: 3, quantity: 1, price: 100, discountPercent: 10, taxPercent: 7, unit: 1 }, ] const totals = calculateOutgoingDocumentTotals({ type: "invoices", rows }, []) assert.equal(totals.totalNet, 110) assert.deepEqual(totals.titleSums, { "1 - Bau": 20, "2 - Elektro": 90 }) assert.deepEqual(totals.titleSumsTransfer, { "2 - Elektro": 20 }) const data: any = buildOutgoingDocumentPdfData(document(rows), context) assert.equal(data.startText, "Hallo Ada,") assert.equal(data.endText, "Zahlbar in 14 Tagen.") assert.equal(data.rows[5].text, "Kabel") assert.equal(data.rows[5].unit, "Stk") assert.equal(data.total.titleSumsTransfer["2 - Elektro"], "20,00 €") assert.deepEqual(data.totalArray.map((item: any) => item.label), ["Nettobetrag", "zzgl. 19% USt auf 20,00 €", "zzgl. 7% USt auf 90,00 €", "Gesamtbetrag"]) assert.equal(data.info[0].label, "Rechnungsnummer") }) test("layout analysis flags overflowing rows and suggests a page break that keeps titles", () => { const layout: InvoicePdfLayout = { pageHeightMm: 297, pageCount: 1, rows: [ { index: 0, pos: "1", mode: "free", text: "A", page: 1, topMm: 200, bottomMm: 240 }, { index: 1, pos: "2", mode: "title", text: "T", page: 1, topMm: 240, bottomMm: 250 }, { index: 2, pos: "2.1", mode: "free", text: "B", page: 1, topMm: 250, bottomMm: 270 }, ], totals: { page: 1, topMm: 270, bottomMm: 300 } } const { warnings, layout: summary } = analyzeOutgoingDocumentLayout(layout, 262) assert.equal(warnings.length, 1) assert.equal(warnings[0].insertPagebreakBeforeIndex, 1) assert.equal(summary.pages[0].freeMm, -38) }) test("rendered preview records rows per page and rasterizes pages", { skip: !hasPdftoppm && "pdftoppm fehlt" }, async (t) => { const letterhead = await PDFDocument.create() letterhead.addPage([595.28, 841.89]).drawText("Briefpapier", { x: 40, y: 40, size: 8 }) const background = Buffer.from(await letterhead.save()) await initS3() t.mock.method(s3, "send", async () => ({ Body: Readable.from([background]) })) const rows: any[] = positions(30) rows.splice(12, 0, { mode: "pagebreak" }) const data = buildOutgoingDocumentPdfData(document(rows), context) const layout: InvoicePdfLayout = { pageHeightMm: 0, rows: [], pageCount: 0 } const pdf: any = await createInvoicePDF({} as any, "base64", data, "letterhead.pdf", layout) // Rows overflow page 2, so the payment QR code gets an extra third page. assert.equal(layout.pageCount, 3) assert.equal(layout.paymentQr!.page, 3) assert.equal(layout.rows.find((row) => row.index === 13)!.page, 2) assert.ok(layout.endText && layout.totals) const analysis = analyzeOutgoingDocumentLayout(layout) assert.ok(analysis.warnings.some((warning) => warning.type === "overflow" && warning.page === 2), JSON.stringify(analysis.warnings)) const images = await renderPdfPagesAsPng(Buffer.from(pdf.base64, "base64"), [1, 2], 50) assert.deepEqual(images.map((image) => image.page), [1, 2]) assert.ok(images[0].png.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))) const result = asToolResult(withImages({ pageCount: 2 }, images.map((image) => ({ data: image.png.toString("base64"), mimeType: "image/png" })))) assert.deepEqual(result.content.map((item) => item.type), ["text", "image", "image"]) assert.deepEqual(result.structuredContent, { pageCount: 2 }) }) test("preview tool is read-only for outgoing documents", () => { assert.deepEqual(mcpToolMap.get("accounting.outgoing_documents.preview")!.requiredPermissions, ["accounting.outgoing_documents.read"]) }) test("payment QR code moves to its own page instead of covering amounts", { skip: !hasPdftoppm && "pdftoppm fehlt" }, async (t) => { const letterhead = await PDFDocument.create() letterhead.addPage([595.28, 841.89]).drawText("Briefpapier", { x: 40, y: 40, size: 8 }) const background = Buffer.from(await letterhead.save()) await initS3() t.mock.method(s3, "send", async () => ({ Body: Readable.from([background]) })) const render = async (count: number) => { const layout: InvoicePdfLayout = { pageHeightMm: 0, rows: [], pageCount: 0 } await createInvoicePDF({} as any, "base64", buildOutgoingDocumentPdfData(document(positions(count)), context), "letterhead.pdf", layout) return layout } const short = await render(3) assert.equal(short.pageCount, 1) assert.equal(short.paymentQr!.page, 1) assert.ok(short.totals!.bottomMm < short.paymentQr!.topMm) const long = await render(6) assert.equal(long.pageCount, 2) assert.equal(long.paymentQr!.page, 2) assert.ok(long.rows.every((row) => row.page === 1)) const analysis = analyzeOutgoingDocumentLayout(long) const warning = analysis.warnings.find((item) => item.type === "paymentQrOwnPage") assert.ok(warning, JSON.stringify(analysis.warnings)) assert.ok(!analysis.warnings.some((item) => item.type === "emptyPage")) })