import { PDF, rgb, StandardFonts } from "@libpdf/core"; import type { InvoicePDFData, InvoiceTemplate } from "../types"; import { fonts, formatCurrency, formatDate, padInvoiceNumber, rightAlignX, } from "./types"; const colors = { black: rgb(0.12, 0.12, 0.12), gray: rgb(0.45, 0.45, 0.45), lightGray: rgb(0.88, 0.88, 0.88), accent: rgb(0.25, 0.35, 0.45), white: rgb(1, 1, 1), }; const PAGE_WIDTH = 612; const PAGE_HEIGHT = 792; const MARGIN = 50; const RIGHT_EDGE = PAGE_WIDTH - MARGIN; const CONTENT_WIDTH = RIGHT_EDGE - MARGIN; /** Minimum Y before we'd clip into the footer area */ const FOOTER_GUARD = 100; function drawSeparator( page: ReturnType["addPage"]>, y: number, x = MARGIN, width = CONTENT_WIDTH, ): void { page.drawLine({ start: { x, y }, end: { x: x + width, y }, color: colors.lightGray, thickness: 0.5, }); } export const minimalTemplate: InvoiceTemplate = { name: "minimal", description: "Clean, professional invoice on white background", async render(data: InvoicePDFData): Promise { const pdf = PDF.create(); const page = pdf.addPage({ width: PAGE_WIDTH, height: PAGE_HEIGHT }); let y = PAGE_HEIGHT - MARGIN; // ── Header: Company name top-left, INVOICE label top-right ── page.drawText(data.from.companyName, { x: MARGIN, y, size: 16, color: colors.black, font: StandardFonts.HelveticaBold, }); const invoiceLabel = "INVOICE"; page.drawText(invoiceLabel, { x: rightAlignX(invoiceLabel, RIGHT_EDGE, 16, fonts.helveticaBold), y, size: 16, color: colors.accent, font: StandardFonts.HelveticaBold, }); // ── From address block (left side) ── y -= 20; const fromStartY = y; if (data.from.address) { for (const line of data.from.address.split("\n")) { const trimmed = line.trim(); if (!trimmed) continue; page.drawText(trimmed, { x: MARGIN, y, size: 9, color: colors.gray, font: StandardFonts.Helvetica, }); y -= 13; } } if (data.from.email) { page.drawText(data.from.email, { x: MARGIN, y, size: 9, color: colors.gray, font: StandardFonts.Helvetica, }); y -= 13; } if (data.from.phone) { page.drawText(data.from.phone, { x: MARGIN, y, size: 9, color: colors.gray, font: StandardFonts.Helvetica, }); y -= 13; } // ── Invoice meta: number, date, due date, period — right-aligned ── let metaY = fromStartY; const metaLabelX = RIGHT_EDGE - 160; const metaFields: [string, string][] = [ ["Invoice #", padInvoiceNumber(data.invoiceNumber)], ["Date", formatDate(data.invoiceDate)], ]; if (data.dueDate) { metaFields.push(["Due Date", formatDate(data.dueDate)]); } if (data.periodStart && data.periodEnd) { metaFields.push([ "Period", `${formatDate(data.periodStart)} - ${formatDate(data.periodEnd)}`, ]); } for (const [label, value] of metaFields) { page.drawText(label, { x: metaLabelX, y: metaY, size: 9, color: colors.gray, font: StandardFonts.Helvetica, }); page.drawText(value, { x: rightAlignX(value, RIGHT_EDGE, 9, fonts.courier), y: metaY, size: 9, color: colors.black, font: StandardFonts.Courier, }); metaY -= 14; } // Use whichever side went lower y = Math.min(y, metaY) - 16; // ── Bill To section ── page.drawText("BILL TO", { x: MARGIN, y, size: 8, color: colors.accent, font: StandardFonts.HelveticaBold, }); y -= 16; page.drawText(data.to.name, { x: MARGIN, y, size: 11, color: colors.black, font: StandardFonts.HelveticaBold, }); y -= 15; if (data.to.email) { page.drawText(data.to.email, { x: MARGIN, y, size: 9, color: colors.gray, font: StandardFonts.Helvetica, }); y -= 13; } if (data.to.address) { for (const line of data.to.address.split("\n")) { const trimmed = line.trim(); if (!trimmed) continue; page.drawText(trimmed, { x: MARGIN, y, size: 9, color: colors.gray, font: StandardFonts.Helvetica, }); y -= 13; } } // ── Separator before line items ── y -= 12; drawSeparator(page, y); y -= 20; // ── Line items table header ── const colQtyX = RIGHT_EDGE - 220; const colRateX = RIGHT_EDGE - 140; const colAmountX = RIGHT_EDGE; page.drawText("Description", { x: MARGIN, y, size: 8, color: colors.accent, font: StandardFonts.HelveticaBold, }); page.drawText("Qty", { x: colQtyX, y, size: 8, color: colors.accent, font: StandardFonts.HelveticaBold, }); page.drawText("Rate", { x: colRateX, y, size: 8, color: colors.accent, font: StandardFonts.HelveticaBold, }); const amountHeader = "Amount"; page.drawText(amountHeader, { x: rightAlignX(amountHeader, colAmountX, 8, fonts.helveticaBold), y, size: 8, color: colors.accent, font: StandardFonts.HelveticaBold, }); y -= 10; drawSeparator(page, y); y -= 16; // ── Line items ── for (const item of data.lineItems) { if (y < FOOTER_GUARD) break; // safety guard // Description (with optional project name) const desc = item.projectName ? `${item.description} (${item.projectName})` : item.description; page.drawText(desc, { x: MARGIN, y, size: 9, color: colors.black, font: StandardFonts.Helvetica, maxWidth: colQtyX - MARGIN - 10, }); const qtyStr = `${item.quantity} ${item.quantityType}`; page.drawText(qtyStr, { x: colQtyX, y, size: 9, color: colors.black, font: StandardFonts.Courier, }); const rateStr = formatCurrency(item.rate, data.currency); page.drawText(rateStr, { x: colRateX, y, size: 9, color: colors.black, font: StandardFonts.Courier, }); const amtStr = formatCurrency(item.amount, data.currency); page.drawText(amtStr, { x: rightAlignX(amtStr, colAmountX, 9, fonts.courier), y, size: 9, color: colors.black, font: StandardFonts.Courier, }); y -= 20; } // ── Credits (negative line items) ── for (const credit of data.credits) { if (y < FOOTER_GUARD) break; page.drawText(credit.description, { x: MARGIN, y, size: 9, color: colors.gray, font: StandardFonts.HelveticaOblique, }); const creditStr = `-${formatCurrency(credit.amount, data.currency)}`; page.drawText(creditStr, { x: rightAlignX(creditStr, colAmountX, 9, fonts.courier), y, size: 9, color: colors.accent, font: StandardFonts.Courier, }); y -= 20; } // ── Totals section ── y -= 6; drawSeparator(page, y, RIGHT_EDGE - 220, 220); y -= 18; // Subtotal const subtotalLabel = "Subtotal"; const subtotalStr = formatCurrency(data.subtotal, data.currency); page.drawText(subtotalLabel, { x: RIGHT_EDGE - 220, y, size: 9, color: colors.gray, font: StandardFonts.Helvetica, }); page.drawText(subtotalStr, { x: rightAlignX(subtotalStr, colAmountX, 9, fonts.courier), y, size: 9, color: colors.black, font: StandardFonts.Courier, }); y -= 16; // Tax (if applicable) if (data.taxRate > 0) { const taxLabel = `Tax (${(data.taxRate / 100).toFixed(1)}%)`; const taxStr = formatCurrency(data.taxAmount, data.currency); page.drawText(taxLabel, { x: RIGHT_EDGE - 220, y, size: 9, color: colors.gray, font: StandardFonts.Helvetica, }); page.drawText(taxStr, { x: rightAlignX(taxStr, colAmountX, 9, fonts.courier), y, size: 9, color: colors.black, font: StandardFonts.Courier, }); y -= 16; } // Total drawSeparator(page, y, RIGHT_EDGE - 220, 220); y -= 18; const totalLabel = `Total ${data.currency}`; const totalStr = formatCurrency(data.total, data.currency); page.drawText(totalLabel, { x: RIGHT_EDGE - 220, y, size: 12, color: colors.black, font: StandardFonts.HelveticaBold, }); page.drawText(totalStr, { x: rightAlignX(totalStr, colAmountX, 12, fonts.courierBold), y, size: 12, color: colors.black, font: StandardFonts.CourierBold, }); // ── Payment methods (bottom section) ── if (data.paymentMethods && data.paymentMethods.length > 0) { // Position payment info well below totals, or at a fixed position if // totals ended high up y = Math.min(y - 40, 200); page.drawText("PAYMENT", { x: MARGIN, y, size: 8, color: colors.accent, font: StandardFonts.HelveticaBold, }); y -= 16; for (const method of data.paymentMethods) { page.drawText(method.label, { x: MARGIN, y, size: 9, color: colors.black, font: StandardFonts.HelveticaBold, }); y -= 13; for (const [key, value] of Object.entries(method.details)) { page.drawText(`${key}: ${value}`, { x: MARGIN, y, size: 8, color: colors.gray, font: StandardFonts.Courier, }); y -= 12; } y -= 6; } } // ── Payment notes ── if (data.paymentNotes) { y = Math.min(y - 10, 80); page.drawText(data.paymentNotes, { x: MARGIN, y, size: 8, color: colors.gray, font: StandardFonts.Helvetica, maxWidth: CONTENT_WIDTH, }); } // ── Notes (bottom of page) ── if (data.notes) { page.drawText(data.notes, { x: MARGIN, y: MARGIN, size: 7, color: colors.gray, font: StandardFonts.HelveticaOblique, maxWidth: CONTENT_WIDTH, }); } return pdf.save(); }, };