/** * Config-driven invoice PDF renderer used by the desktop and web apps. * * This is distinct from `generateInvoicePDF` in `./pdf` which uses a * template registry (mandala / minimal) and is for the CLI standalone * invoice workflow. The app renderer here takes an `InvoiceTemplateConfig` * (the user-editable config stored in the invoice_templates DB table) * and renders directly. */ import { type Color, type FontInput, measureText, PDF, type PDFImage, type PDFPage, rgb, StandardFonts, } from "@libpdf/core"; import { DEFAULT_TEMPLATES, type InvoiceTemplateConfig } from "../db"; // ── Types ────────────────────────────────────────────────── export interface InvoicePDFLineItem { description: string; quantity: number; rate: number; amount: number; projectName?: string | null; } export interface InvoicePDFInvoiceData { invoiceNumber: string; issueDate: Date; dueDate?: Date | null; periodStart?: Date | null; periodEnd?: Date | null; clientName?: string | null; clientEmail?: string | null; clientAddress?: string | null; subtotal: number; taxRate: number; taxAmount: number; total: number; currency: string; notes?: string | null; paymentNotes?: string | null; lineItems: InvoicePDFLineItem[]; } export interface RenderInvoicePDFProps { invoice: InvoicePDFInvoiceData; template?: InvoiceTemplateConfig; } // ── Helpers ──────────────────────────────────────────────── function formatCurrency(cents: number, currency = "USD"): string { return new Intl.NumberFormat("en-US", { style: "currency", currency, minimumFractionDigits: 2, }).format(cents / 100); } function formatDate(date: Date): string { return new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", year: "numeric", }).format(date); } function hexToRgb(hex: string) { const h = hex.replace("#", ""); const r = Number.parseInt(h.substring(0, 2), 16) / 255; const g = Number.parseInt(h.substring(2, 4), 16) / 255; const b = Number.parseInt(h.substring(4, 6), 16) / 255; return rgb(r, g, b); } // ── Image helpers ───────────────────────────────────────── function dataUriToBytes(dataUri: string): Uint8Array { const base64 = dataUri.split(",")[1]; if (!base64) throw new Error("Invalid data URI: missing base64 payload"); return Uint8Array.from(atob(base64), (c) => c.charCodeAt(0)); } /** * Decode a logo source into raw bytes. Only `data:` URIs are accepted — * fetching arbitrary `http(s)://` URLs from the renderer would be an * SSRF gadget, since this function runs server-side from API routes * that accept user-supplied template configs. If hosted-URL logos are * needed in the future, add a strict allowlist of trusted asset hosts. */ async function loadImageBytes(src: string): Promise { if (!src.startsWith("data:")) { throw new Error( "Invoice logo must be a data URI; remote URL logos are not supported", ); } return dataUriToBytes(src); } // ── Constants ────────────────────────────────────────────── const PAGE_WIDTH = 595.28; const PAGE_HEIGHT = 841.89; const PADDING = 50; const CONTENT_WIDTH = PAGE_WIDTH - PADDING * 2; const TOP_Y = PAGE_HEIGHT - PADDING; const BOTTOM_MARGIN = 50; const RIGHT_EDGE = PAGE_WIDTH - PADDING; const LOGO_MAX_HEIGHT = 60; const LOGO_MAX_WIDTH = 150; // Table column right-edges for alignment const COL_HOURS_RIGHT = 400; const COL_RATE_RIGHT = 470; const COL_AMOUNT_RIGHT = RIGHT_EDGE - 10; // ── Text helpers ─────────────────────────────────────────── const FONT = StandardFonts.Helvetica; const FONT_BOLD = StandardFonts.HelveticaBold; function tw(text: string, font: FontInput, size: number): number { return measureText(text, font, size); } function drawRight( page: PDFPage, text: string, rightX: number, y: number, size: number, font: FontInput, color: Color, ) { const w = tw(text, font, size); page.drawText(text, { x: rightX - w, y, size, font, color }); } // ── PDF Generation ───────────────────────────────────────── /** * Render an invoice PDF using a user-configurable template. * * This function is shared between the desktop and web apps. It takes an * `InvoiceTemplateConfig` from the DB and produces a `Uint8Array` that * can be streamed as a PDF response or attached to an email. */ export async function renderInvoicePDF({ invoice, template = DEFAULT_TEMPLATES.classic, }: RenderInvoicePDFProps): Promise { const pdf = PDF.create(); const primaryColor = hexToRgb(template.primaryColor); const accentColor = hexToRgb(template.accentColor); const textColor = hexToRgb(template.textColor); const lightGray = rgb(0.94, 0.94, 0.94); const medGray = rgb(0.88, 0.88, 0.88); const darkGray = rgb(0.4, 0.4, 0.4); // ── Page management ──────────────────────────────────── let page = pdf.addPage({ size: "a4" }); let y = TOP_Y; /** Add a new page and reset y to the top content area. */ function newPage(): void { page = pdf.addPage({ size: "a4" }); y = TOP_Y; } /** If the remaining space is less than `needed`, start a new page. */ function ensureSpace(needed: number): void { if (y - needed < BOTTOM_MARGIN) newPage(); } // ── Logo embedding ───────────────────────────────────── let logo: PDFImage | null = null; if (template.showLogo && template.logo) { try { const bytes = await loadImageBytes(template.logo); logo = pdf.embedImage(bytes); } catch { // Failed to load logo — fall through to larger company name } } // ── Header ───────────────────────────────────────────── if (template.headerStyle === "modern") { y -= 10; // Logo centered above title if (logo) { const scale = Math.min( LOGO_MAX_WIDTH / logo.width, LOGO_MAX_HEIGHT / logo.height, 1, ); const drawW = logo.width * scale; const drawH = logo.height * scale; page.drawImage(logo, { x: (PAGE_WIDTH - drawW) / 2, y: y - drawH, width: drawW, height: drawH, }); y -= drawH + 10; } const titleW = tw("INVOICE", FONT_BOLD, 32); page.drawText("INVOICE", { x: (PAGE_WIDTH - titleW) / 2, y, size: 32, font: FONT_BOLD, color: primaryColor, }); y -= 20; const numW = tw(invoice.invoiceNumber, FONT, 14); page.drawText(invoice.invoiceNumber, { x: (PAGE_WIDTH - numW) / 2, y, size: 14, font: FONT, color: darkGray, }); y -= 20; if (template.showCompanyDetails) { // If no logo loaded, render company name larger as visual anchor const nameSize = logo ? 14 : 18; const compW = tw(template.companyName, FONT_BOLD, nameSize); page.drawText(template.companyName, { x: (PAGE_WIDTH - compW) / 2, y, size: nameSize, font: FONT_BOLD, color: textColor, }); y -= nameSize; if (template.companyEmail) { const ew = tw(template.companyEmail, FONT, 9); page.drawText(template.companyEmail, { x: (PAGE_WIDTH - ew) / 2, y, size: 9, font: FONT, color: darkGray, }); y -= 12; } if (template.companyPhone) { const pw = tw(template.companyPhone, FONT, 9); page.drawText(template.companyPhone, { x: (PAGE_WIDTH - pw) / 2, y, size: 9, font: FONT, color: darkGray, }); y -= 12; } } y -= 30; } else if (template.headerStyle === "minimal") { // Logo left-aligned before title if (logo) { const scale = Math.min( LOGO_MAX_WIDTH / logo.width, LOGO_MAX_HEIGHT / logo.height, 1, ); const drawW = logo.width * scale; const drawH = logo.height * scale; page.drawImage(logo, { x: PADDING, y: y - drawH, width: drawW, height: drawH, }); y -= drawH + 10; } const titleText = `Invoice ${invoice.invoiceNumber}`; page.drawText(titleText, { x: PADDING, y, size: 16, font: FONT_BOLD, color: primaryColor, }); y -= 15; page.drawLine({ start: { x: PADDING, y }, end: { x: RIGHT_EDGE, y }, thickness: 1, color: medGray, }); y -= 20; } else { // Classic: title left, company right // Logo top-right when company details shown, else top-left if (logo) { const scale = Math.min( LOGO_MAX_WIDTH / logo.width, LOGO_MAX_HEIGHT / logo.height, 1, ); const drawW = logo.width * scale; const drawH = logo.height * scale; if (template.showCompanyDetails) { page.drawImage(logo, { x: RIGHT_EDGE - drawW, y: y - drawH, width: drawW, height: drawH, }); } else { page.drawImage(logo, { x: PADDING, y: y - drawH, width: drawW, height: drawH, }); } } page.drawText("INVOICE", { x: PADDING, y, size: 24, font: FONT_BOLD, color: primaryColor, }); y -= 16; page.drawText(invoice.invoiceNumber, { x: PADDING, y, size: 12, font: FONT, color: darkGray, }); if (template.showCompanyDetails) { // Company details below logo (if logo present) or at top-right let compY = logo ? TOP_Y - Math.min(LOGO_MAX_HEIGHT, logo.height) - 10 : TOP_Y; // If no logo loaded, render company name larger as visual anchor const nameSize = logo ? 14 : 18; drawRight( page, template.companyName, RIGHT_EDGE, compY, nameSize, FONT_BOLD, textColor, ); compY -= nameSize; if (template.companyEmail) { drawRight( page, template.companyEmail, RIGHT_EDGE, compY, 9, FONT, darkGray, ); compY -= 12; } if (template.companyAddress) { drawRight( page, template.companyAddress, RIGHT_EDGE, compY, 9, FONT, darkGray, ); compY -= 12; } if (template.companyPhone) { drawRight( page, template.companyPhone, RIGHT_EDGE, compY, 9, FONT, darkGray, ); } } y -= 30; } // ── Bill To ──────────────────────────────────────────── ensureSpace(60); page.drawText("BILL TO", { x: PADDING, y, size: 8, font: FONT_BOLD, color: darkGray, }); y -= 14; if (invoice.clientName) { page.drawText(invoice.clientName, { x: PADDING, y, size: 12, font: FONT_BOLD, color: textColor, }); y -= 14; if (invoice.clientEmail) { page.drawText(invoice.clientEmail, { x: PADDING, y, size: 10, font: FONT, color: darkGray, }); y -= 12; } if (invoice.clientAddress) { page.drawText(invoice.clientAddress, { x: PADDING, y, size: 10, font: FONT, color: darkGray, }); y -= 12; } } else { page.drawText("No client specified", { x: PADDING, y, size: 10, font: FONT, color: darkGray, }); y -= 12; } y -= 20; // ── Dates ────────────────────────────────────────────── ensureSpace(50); page.drawText("Issue Date", { x: PADDING, y, size: 8, font: FONT, color: darkGray, }); y -= 12; page.drawText(formatDate(new Date(invoice.issueDate)), { x: PADDING, y, size: 10, font: FONT, color: textColor, }); if (invoice.dueDate) { const midX = PAGE_WIDTH / 2; page.drawText("Due Date", { x: midX, y: y + 12, size: 8, font: FONT, color: darkGray, }); page.drawText(formatDate(new Date(invoice.dueDate)), { x: midX, y, size: 10, font: FONT, color: textColor, }); } y -= 20; if (invoice.periodStart && invoice.periodEnd) { page.drawText("Period", { x: PADDING, y, size: 8, font: FONT, color: darkGray, }); y -= 12; page.drawText( `${formatDate(new Date(invoice.periodStart))} - ${formatDate(new Date(invoice.periodEnd))}`, { x: PADDING, y, size: 10, font: FONT, color: textColor }, ); y -= 20; } y -= 10; // ── Line Items Table ─────────────────────────────────── const rowH = 25; /** Draw the table header row on the current page at the current y. */ function drawTableHeader(): void { page.drawRectangle({ x: PADDING, y: y - rowH, width: CONTENT_WIDTH, height: rowH, color: rgb(0.96, 0.96, 0.96), }); page.drawLine({ start: { x: PADDING, y: y - rowH }, end: { x: RIGHT_EDGE, y: y - rowH }, thickness: 1, color: medGray, }); const hY = y - rowH + 8; page.drawText("DESCRIPTION", { x: PADDING + 10, y: hY, size: 8, font: FONT_BOLD, color: darkGray, }); if (template.showHours) drawRight(page, "HOURS", COL_HOURS_RIGHT, hY, 8, FONT_BOLD, darkGray); if (template.showRate) drawRight(page, "RATE", COL_RATE_RIGHT, hY, 8, FONT_BOLD, darkGray); drawRight(page, "AMOUNT", COL_AMOUNT_RIGHT, hY, 8, FONT_BOLD, darkGray); y -= rowH; } // Initial table header ensureSpace(rowH * 2); // header + at least one data row drawTableHeader(); // Data rows for (const item of invoice.lineItems) { // Check if we need a new page before drawing this row if (y - rowH < BOTTOM_MARGIN) { newPage(); drawTableHeader(); } const cellY = y - rowH + 8; page.drawLine({ start: { x: PADDING, y: y - rowH }, end: { x: RIGHT_EDGE, y: y - rowH }, thickness: 0.5, color: lightGray, }); const desc = item.description.replace(/ - [\d.]+ hours$/, ""); page.drawText(desc, { x: PADDING + 10, y: cellY, size: 10, font: FONT, color: textColor, }); if (template.showHours) { drawRight( page, (item.quantity / 60).toFixed(1), COL_HOURS_RIGHT, cellY, 10, FONT, textColor, ); } if (template.showRate) { drawRight( page, formatCurrency(item.rate, invoice.currency), COL_RATE_RIGHT, cellY, 10, FONT, textColor, ); } drawRight( page, formatCurrency(item.amount, invoice.currency), COL_AMOUNT_RIGHT, cellY, 10, FONT, textColor, ); y -= rowH; } y -= 20; // ── Totals ───────────────────────────────────────────── ensureSpace(80); const totalsX = RIGHT_EDGE - 200; page.drawText("Subtotal", { x: totalsX, y, size: 10, font: FONT, color: textColor, }); drawRight( page, formatCurrency(invoice.subtotal, invoice.currency), RIGHT_EDGE, y, 10, FONT, textColor, ); y -= 16; if (invoice.taxRate > 0) { page.drawText(`Tax (${(invoice.taxRate / 100).toFixed(1)}%)`, { x: totalsX, y, size: 10, font: FONT, color: textColor, }); drawRight( page, formatCurrency(invoice.taxAmount, invoice.currency), RIGHT_EDGE, y, 10, FONT, textColor, ); y -= 16; } y -= 4; page.drawLine({ start: { x: totalsX, y }, end: { x: RIGHT_EDGE, y }, thickness: 2, color: primaryColor, }); y -= 14; page.drawText("Total", { x: totalsX, y, size: 12, font: FONT_BOLD, color: textColor, }); drawRight( page, formatCurrency(invoice.total, invoice.currency), RIGHT_EDGE, y, 12, FONT_BOLD, primaryColor, ); y -= 30; // ── Footer ───────────────────────────────────────────── const hasPaymentMethods = !!template.paymentMethods && template.paymentMethods.length > 0; const hasFooter = invoice.notes || invoice.paymentNotes || template.footerText || template.paymentInstructions || hasPaymentMethods; if (hasFooter) { ensureSpace(60); y -= 10; page.drawLine({ start: { x: PADDING, y }, end: { x: RIGHT_EDGE, y }, thickness: 0.5, color: medGray, }); y -= 20; if (invoice.notes) { ensureSpace(40); page.drawText("Notes", { x: PADDING, y, size: 9, font: FONT_BOLD, color: darkGray, }); y -= 12; page.drawText(invoice.notes, { x: PADDING, y, size: 9, font: FONT, color: darkGray, }); y -= 20; } const paymentText = invoice.paymentNotes || template.paymentInstructions; if (paymentText) { ensureSpace(40); page.drawText("Payment Instructions", { x: PADDING, y, size: 9, font: FONT_BOLD, color: darkGray, }); y -= 12; page.drawText(paymentText, { x: PADDING, y, size: 9, font: FONT, color: darkGray, }); y -= 20; } // Structured payment methods (matches the CLI mandala/minimal // templates, which already render these). Free-text paymentNotes / // paymentInstructions above remain for back-compat; both can appear. if (template.paymentMethods) { for (const method of template.paymentMethods) { ensureSpace(24); page.drawText(method.label, { x: PADDING, y, size: 9, font: FONT_BOLD, color: darkGray, }); y -= 12; for (const [key, value] of Object.entries(method.details)) { ensureSpace(12); page.drawText(`${key}: ${value}`, { x: PADDING + 8, y, size: 8, font: FONT, color: darkGray, }); y -= 11; } y -= 8; } } if (template.footerText) { ensureSpace(20); const fw = tw(template.footerText, FONT, 10); page.drawText(template.footerText, { x: (PAGE_WIDTH - fw) / 2, y, size: 10, font: FONT, color: accentColor, }); } } return pdf.save(); }