import jsPDF from "jspdf"; import { formatDate } from "./format"; const PAGE_W = 612; const PAGE_H = 792; const MARGIN = 54; const CONTENT_W = PAGE_W - MARGIN * 2; export interface InvoiceLineItem { kind: string; description: string; work_date: string | null; quantity: number; rate: number; amount: number; user_name?: string | null; user_initials?: string | null; billable?: boolean; } export interface InvoiceSubsection { key: string; label: string; billable: boolean; items: InvoiceLineItem[]; subtotal: number; } export interface InvoiceCaseGroup { caseNumber: string; caseTitle: string; practiceArea?: string | null; items: InvoiceLineItem[]; subtotal: number; /** Optional subsections (Billable Fees, Billable Expenses, Non-Billable Fees, Non-Billable Expenses). * When provided, the renderer groups items by subsection with a header and subtotal. */ subsections?: InvoiceSubsection[]; } export interface InvoicePdfInput { firm: { name?: string | null; address1?: string | null; address2?: string | null; city?: string | null; state?: string | null; postal?: string | null; email?: string | null; phone?: string | null; website?: string | null; footerNote?: string | null; logoDataUrl?: string | null; }; client: { name: string; contact?: string | null; address1?: string | null; address2?: string | null; city?: string | null; state?: string | null; postal?: string | null; }; invoice: { number: string; issueDate: string; dueDate?: string | null; status: string; notes?: string | null; matter?: string | null; caseNumber?: string | null; terms?: string | null; }; groups: InvoiceCaseGroup[]; totals: { subtotal: number; tax: number; total: number; paid: number; balance: number; taxRatePct?: number | null; feesAndExpenses?: number | null; alternateFees?: number | null; adjustments?: Array<{ description: string; amount: number }>; }; } const fmtCurrency = (n: number) => new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(n || 0); const fmtDateShort = (s: string | null | undefined): string => { if (!s) return "—"; const d = new Date(s); if (isNaN(d.getTime())) return "—"; const mm = String(d.getMonth() + 1).padStart(2, "0"); const dd = String(d.getDate()).padStart(2, "0"); const yy = String(d.getFullYear()).slice(-2); return `${mm}/${dd}/${yy}`; }; function setFont( pdf: jsPDF, opts: { bold?: boolean; italic?: boolean; size?: number; color?: [number, number, number]; family?: "helvetica" | "times" }, ) { let style: "normal" | "bold" | "italic" | "bolditalic" = "normal"; if (opts.bold && opts.italic) style = "bolditalic"; else if (opts.bold) style = "bold"; else if (opts.italic) style = "italic"; pdf.setFont(opts.family ?? "helvetica", style); pdf.setFontSize(opts.size ?? 10); if (opts.color) pdf.setTextColor(...opts.color); else pdf.setTextColor(20, 24, 32); } function wrap(pdf: jsPDF, text: string, maxWidth: number): string[] { if (!text) return [""]; return pdf.splitTextToSize(text, maxWidth) as string[]; } function detectImageFormat(dataUrl: string): "PNG" | "JPEG" | "WEBP" { const m = /^data:image\/(png|jpe?g|webp)/i.exec(dataUrl); if (!m) return "PNG"; const t = m[1].toLowerCase(); if (t === "jpg" || t === "jpeg") return "JPEG"; if (t === "webp") return "WEBP"; return "PNG"; } async function imageNaturalSize(dataUrl: string): Promise<{ w: number; h: number } | null> { return new Promise((resolve) => { const img = new Image(); img.onload = () => resolve({ w: img.naturalWidth || img.width, h: img.naturalHeight || img.height }); img.onerror = () => resolve(null); img.src = dataUrl; }); } // Palette const TEXT: [number, number, number] = [30, 38, 56]; // dark navy text const MUTED: [number, number, number] = [120, 128, 140]; const RULE: [number, number, number] = [228, 232, 238]; const HEADER_BG: [number, number, number] = [243, 245, 248]; const ACCENT: [number, number, number] = [245, 158, 11]; // warm orange (smile) function drawSmileMark(pdf: jsPDF, cx: number, cy: number, size: number) { // Two checkmark ticks + a curved smile underneath, in accent color. pdf.setDrawColor(...ACCENT); pdf.setLineCap(1); // round pdf.setLineJoin(1); // Two checkmarks pdf.setLineWidth(size * 0.09); // first check pdf.lines( [ [size * 0.22, size * 0.22], [size * 0.42, -size * 0.5], ], cx - size * 0.55, cy - size * 0.05, ); // second check pdf.lines( [ [size * 0.22, size * 0.22], [size * 0.42, -size * 0.5], ], cx - size * 0.2, cy - size * 0.05, ); // Smile curve (open arc) — approximated with bezier pdf.setLineWidth(size * 0.1); // Draw using lines() with bezier control points: [cp1x, cp1y, cp2x, cp2y, ex, ey] pdf.lines( [ [size * 0.2, size * 0.55, size * 0.7, size * 0.55, size * 0.95, 0], ], cx - size * 0.5, cy + size * 0.2, [1, 1], "S", false, ); // Arrow tip on the right end of smile pdf.setLineWidth(size * 0.09); pdf.lines( [ [-size * 0.18, -size * 0.05], [size * 0.05, size * 0.18], ], cx + size * 0.45, cy + size * 0.2, ); } export async function downloadInvoicePdf(input: InvoicePdfInput, filename: string) { const pdf = new jsPDF({ unit: "pt", format: "letter" }); let y = MARGIN; const ensure = (need: number) => { if (y + need > PAGE_H - MARGIN - 40) { drawPageFooter(pdf, input); pdf.addPage(); y = MARGIN; } }; // ===== Top: "Invoice" title (left) + logo or smile mark (right) ===== setFont(pdf, { bold: true, size: 34, color: TEXT, family: "times" }); pdf.text("Invoice", MARGIN, y + 28); // Right side: firm logo if available, else decorative smile mark if (input.firm.logoDataUrl) { const MAX_W = 110; const MAX_H = 60; const size = await imageNaturalSize(input.firm.logoDataUrl); let lw = MAX_W; let lh = MAX_H; if (size && size.w > 0 && size.h > 0) { const scale = Math.min(MAX_W / size.w, MAX_H / size.h, 1); lw = size.w * scale; lh = size.h * scale; } try { pdf.addImage( input.firm.logoDataUrl, detectImageFormat(input.firm.logoDataUrl), PAGE_W - MARGIN - lw, y, lw, lh, undefined, "FAST", ); } catch { drawSmileMark(pdf, PAGE_W - MARGIN - 40, y + 18, 60); } } else { drawSmileMark(pdf, PAGE_W - MARGIN - 40, y + 18, 60); } y += 70; // ===== FROM / TO / Info panel ===== // Three columns: FROM, TO, and a key-value info card on the right const colW = (CONTENT_W - 20) / 3; const fromX = MARGIN; const toX = MARGIN + colW + 10; const infoX = MARGIN + colW * 2 + 20; const infoW = CONTENT_W - (colW * 2 + 20); // FROM setFont(pdf, { bold: true, size: 8, color: MUTED }); pdf.text("FROM:", fromX, y); setFont(pdf, { bold: true, size: 12, color: TEXT }); pdf.text(input.firm.name || "Firm", fromX, y + 14); setFont(pdf, { size: 9, color: TEXT }); let fy = y + 28; const firmLines = [ input.firm.address1, input.firm.address2, [input.firm.city, input.firm.state, input.firm.postal].filter(Boolean).join(", "), input.firm.phone, input.firm.email, input.firm.website, ].filter((l): l is string => !!l && l.trim().length > 0); for (const l of firmLines) { pdf.text(l, fromX, fy); fy += 11; } // TO setFont(pdf, { bold: true, size: 8, color: MUTED }); pdf.text("TO:", toX, y); setFont(pdf, { bold: true, size: 12, color: TEXT }); pdf.text(input.client.name, toX, y + 14); setFont(pdf, { size: 9, color: TEXT }); let ty = y + 28; const clientLines = [ input.client.contact, input.client.address1, input.client.address2, [input.client.city, input.client.state, input.client.postal].filter(Boolean).join(", "), ].filter((l): l is string => !!l && l.trim().length > 0); for (const l of clientLines) { pdf.text(l, toX, ty); ty += 11; } // Info card (right) — key/value rows. // "Case No." is shown directly under the invoice number for quick reference. const infoRows: Array<[string, string]> = [ ["Inv. Number", input.invoice.number], ]; if (input.invoice.caseNumber) infoRows.push(["Case No.", input.invoice.caseNumber]); infoRows.push(["Date of Issue", formatDate(input.invoice.issueDate)]); if (input.invoice.terms) infoRows.push(["Terms", input.invoice.terms]); if (input.invoice.dueDate) infoRows.push(["Date Due", formatDate(input.invoice.dueDate)]); const rowH = 18; const infoTop = y - 4; const infoH = rowH * infoRows.length; // background card pdf.setFillColor(...HEADER_BG); pdf.rect(infoX, infoTop, infoW, infoH, "F"); let iy = infoTop + 12; infoRows.forEach((row, idx) => { setFont(pdf, { size: 9, color: MUTED }); pdf.text(row[0], infoX + 10, iy); setFont(pdf, { size: 9.5, bold: true, color: TEXT }); const w = pdf.getTextWidth(row[1]); pdf.text(row[1], infoX + infoW - 10 - w, iy); if (idx < infoRows.length - 1) { pdf.setDrawColor(...RULE); pdf.setLineWidth(0.5); pdf.line(infoX + 10, iy + 6, infoX + infoW - 10, iy + 6); } iy += rowH; }); y = Math.max(fy, ty, infoTop + infoH) + 24; // ===== FEES & EXPENSES section ===== setFont(pdf, { bold: true, size: 9, color: TEXT }); pdf.text("FEES & EXPENSES", MARGIN, y); y += 10; // Column geometry for fees table. // Layout (left → right): // DATE | TYPE | DESCRIPTION | QTY | RATE | TAX | TOTAL const colDateX = MARGIN + 8; // date left edge const colKindX = MARGIN + 70; // type label left edge const colDescX = MARGIN + 120; // description left edge const colQtyR = MARGIN + 360; // qty right edge const colRateR = MARGIN + 415; // rate right edge const colTaxR = MARGIN + 460; // tax right edge const colAmtX = PAGE_W - MARGIN - 8; // total right edge const drawColHeaders = () => { pdf.setFillColor(...HEADER_BG); pdf.rect(MARGIN, y, CONTENT_W, 22, "F"); setFont(pdf, { bold: true, size: 8, color: TEXT }); pdf.text("DATE", colDateX, y + 14); pdf.text("TYPE", colKindX, y + 14); pdf.text("DESCRIPTION", colDescX, y + 14); const qW = pdf.getTextWidth("QTY"); pdf.text("QTY", colQtyR - qW, y + 14); const rW = pdf.getTextWidth("RATE"); pdf.text("RATE", colRateR - rW, y + 14); const tW = pdf.getTextWidth("TAX"); pdf.text("TAX", colTaxR - tW, y + 14); const totW = pdf.getTextWidth("TOTAL"); pdf.text("TOTAL", colAmtX - totW, y + 14); y += 22; }; const drawCaseHeader = (g: InvoiceCaseGroup) => { ensure(28); pdf.setFillColor(248, 250, 253); pdf.rect(MARGIN, y, CONTENT_W, 20, "F"); setFont(pdf, { bold: true, size: 9.5, color: TEXT }); const titleText = `${g.caseTitle}`; pdf.text(titleText, MARGIN + 8, y + 13); setFont(pdf, { size: 8, color: MUTED }); const meta = [g.caseNumber, g.practiceArea].filter(Boolean).join(" · "); if (meta) { const metaW = pdf.getTextWidth(meta); pdf.text(meta, PAGE_W - MARGIN - 8 - metaW, y + 13); } y += 20; pdf.setDrawColor(...RULE); pdf.setLineWidth(0.4); pdf.line(MARGIN, y, PAGE_W - MARGIN, y); }; drawColHeaders(); const taxRate = (input.totals.taxRatePct ?? 0) / 100; const multipleCases = input.groups.length > 1; const drawSubsectionHeader = (label: string, subtotal: number, billable: boolean) => { ensure(22); pdf.setFillColor(250, 250, 252); pdf.rect(MARGIN, y, CONTENT_W, 16, "F"); setFont(pdf, { bold: true, size: 8, color: MUTED }); pdf.text(label.toUpperCase(), MARGIN + 8, y + 11); if (billable) { const sub = fmtCurrency(subtotal); const sw = pdf.getTextWidth(sub); setFont(pdf, { bold: true, size: 8, color: TEXT }); pdf.text(sub, colAmtX - sw, y + 11); } y += 16; pdf.setDrawColor(...RULE); pdf.setLineWidth(0.4); pdf.line(MARGIN, y, PAGE_W - MARGIN, y); }; const drawItem = (item: InvoiceLineItem) => { const isNonBillable = item.billable === false; // Reserve generous padding before the QTY column so wrapped text never // collides with the right-aligned numeric columns. Measure with the same // font (incl. italic for non-billable rows) that will render the text, // since italic glyphs are slightly wider than upright ones. const descMaxW = colQtyR - colDescX - 24; setFont(pdf, { size: 9, color: isNonBillable ? MUTED : TEXT, italic: isNonBillable }); const descLines = wrap(pdf, item.description || (item.kind === "expense" ? "Expense" : ""), descMaxW); const lineHeight = 12; const blockH = Math.max(lineHeight * descLines.length + 14, item.user_initials ? 32 : 26); ensure(blockH + 4); setFont(pdf, { size: 9, color: TEXT }); pdf.text(fmtDateShort(item.work_date), colDateX, y + 12); if (item.user_initials) { setFont(pdf, { size: 7.5, color: MUTED }); pdf.text(item.user_initials, colDateX, y + 23); } setFont(pdf, { size: 9, color: MUTED }); const kindLabel = item.kind === "expense" ? "Expense" : item.kind === "time" ? "Fee" : item.kind === "manual" ? "Fee" : item.kind; pdf.text(kindLabel, colKindX, y + 12); setFont(pdf, { size: 9, color: isNonBillable ? MUTED : TEXT, italic: isNonBillable }); let dy = y + 12; for (const dl of descLines) { pdf.text(dl, colDescX, dy); dy += lineHeight; } setFont(pdf, { size: 9, color: TEXT }); const qtyText = item.kind === "expense" ? "—" : Number(item.quantity).toFixed(2); const qW = pdf.getTextWidth(qtyText); pdf.text(qtyText, colQtyR - qW, y + 12); const rateText = fmtCurrency(item.rate); const rW = pdf.getTextWidth(rateText); pdf.text(rateText, colRateR - rW, y + 12); const lineTax = isNonBillable ? 0 : item.amount * taxRate; const taxText = fmtCurrency(lineTax); const taxW = pdf.getTextWidth(taxText); pdf.text(taxText, colTaxR - taxW, y + 12); const lineTotal = isNonBillable ? 0 : item.amount + lineTax; const amt = isNonBillable ? "—" : fmtCurrency(lineTotal); setFont(pdf, { size: 9, bold: true, color: TEXT }); const aw = pdf.getTextWidth(amt); pdf.text(amt, colAmtX - aw, y + 12); y += blockH; pdf.setDrawColor(...RULE); pdf.setLineWidth(0.4); pdf.line(MARGIN, y, PAGE_W - MARGIN, y); }; for (const g of input.groups) { drawCaseHeader(g); // Build subsections: use provided list if any, else a single all-items section. const subs: InvoiceSubsection[] = g.subsections && g.subsections.length > 0 ? g.subsections : [{ key: "_all", label: "", billable: true, items: g.items, subtotal: g.subtotal }]; for (const sub of subs) { if (sub.label) drawSubsectionHeader(sub.label, sub.subtotal, sub.billable); for (const item of sub.items) drawItem(item); } // Per-case subtotal when there are multiple cases if (multipleCases) { ensure(22); setFont(pdf, { bold: true, size: 9, color: TEXT }); const label = `Subtotal — ${g.caseNumber}`; pdf.text(label, colDescX, y + 14); const subText = fmtCurrency(g.subtotal); const sw = pdf.getTextWidth(subText); pdf.text(subText, colAmtX - sw, y + 14); y += 20; pdf.setDrawColor(...RULE); pdf.setLineWidth(0.6); pdf.line(MARGIN, y, PAGE_W - MARGIN, y); y += 6; } } y += 20; // ===== ADJUSTMENTS section ===== const adjustments = input.totals.adjustments ?? []; if (input.totals.paid > 0 || adjustments.length > 0) { ensure(80); setFont(pdf, { bold: true, size: 9, color: TEXT }); pdf.text("ADJUSTMENTS", MARGIN, y); y += 10; pdf.setFillColor(...HEADER_BG); pdf.rect(MARGIN, y, CONTENT_W, 22, "F"); setFont(pdf, { bold: true, size: 8, color: TEXT }); pdf.text("DESCRIPTION", colDateX, y + 14); const amtH = pdf.getTextWidth("AMOUNT"); pdf.text("AMOUNT", colAmtX - amtH, y + 14); y += 22; const drawAdj = (desc: string, amount: number) => { ensure(20); setFont(pdf, { size: 9, color: TEXT }); pdf.text(desc, colDateX, y + 12); const txt = amount < 0 ? `−${fmtCurrency(Math.abs(amount))}` : fmtCurrency(amount); const w = pdf.getTextWidth(txt); pdf.text(txt, colAmtX - w, y + 12); y += 20; pdf.setDrawColor(...RULE); pdf.setLineWidth(0.4); pdf.line(MARGIN, y, PAGE_W - MARGIN, y); }; for (const a of adjustments) drawAdj(a.description, a.amount); if (input.totals.paid > 0) drawAdj("Client Funds Applied", -input.totals.paid); } // ===== Totals (right-aligned card, no shading, just rules) ===== ensure(140); y += 18; const totalsX = PAGE_W - MARGIN - 260; const totalsW = 260; const drawTotalRow = ( label: string, value: string, opts?: { bold?: boolean; size?: number; color?: [number, number, number]; emphasize?: boolean }, ) => { setFont(pdf, { size: opts?.size ?? 10, bold: opts?.bold, color: opts?.color ?? TEXT }); pdf.text(label, totalsX + 10, y + 12); const w = pdf.getTextWidth(value); pdf.text(value, totalsX + totalsW - 10 - w, y + 12); y += 22; pdf.setDrawColor(...RULE); pdf.setLineWidth(0.4); pdf.line(totalsX, y, totalsX + totalsW, y); }; // Top rule pdf.setDrawColor(...RULE); pdf.setLineWidth(0.4); pdf.line(totalsX, y, totalsX + totalsW, y); drawTotalRow("Fees & Expenses", fmtCurrency(input.totals.feesAndExpenses ?? input.totals.subtotal)); if (input.totals.alternateFees && input.totals.alternateFees > 0) { drawTotalRow("Alternate Fees", fmtCurrency(input.totals.alternateFees)); } if (input.totals.tax > 0) { const ratePart = input.totals.taxRatePct ? ` (${input.totals.taxRatePct.toFixed(2)}%)` : ""; drawTotalRow(`Tax: State${ratePart}`, fmtCurrency(input.totals.tax)); } if (input.totals.paid > 0) { drawTotalRow("Funds Applied", `−${fmtCurrency(input.totals.paid)}`); } drawTotalRow("Total", fmtCurrency(input.totals.balance > 0 ? input.totals.balance : input.totals.total), { bold: true, size: 12, color: TEXT, }); // ===== Notes ===== if (input.invoice.notes) { ensure(40); y += 18; setFont(pdf, { bold: true, size: 9, color: TEXT }); pdf.text("NOTES", MARGIN, y); y += 12; setFont(pdf, { size: 9, color: TEXT }); const lines = wrap(pdf, input.invoice.notes, CONTENT_W); for (const l of lines) { ensure(12); pdf.text(l, MARGIN, y); y += 12; } } // Footer on every page drawPageFooter(pdf, input); const total = pdf.getNumberOfPages(); for (let i = 1; i <= total; i++) { pdf.setPage(i); setFont(pdf, { size: 8, color: MUTED }); const pageTxt = `Page ${i} of ${total}`; const w = pdf.getTextWidth(pageTxt); pdf.text(pageTxt, PAGE_W - MARGIN - w, PAGE_H - 22); } pdf.save(filename.endsWith(".pdf") ? filename : `${filename}.pdf`); } function drawPageFooter(pdf: jsPDF, input: InvoicePdfInput) { pdf.setDrawColor(...RULE); pdf.setLineWidth(0.5); pdf.line(MARGIN, PAGE_H - 36, PAGE_W - MARGIN, PAGE_H - 36); pdf.setFont("helvetica", "italic"); pdf.setFontSize(8); pdf.setTextColor(...MUTED); const note = input.firm.footerNote || `Thank you for your business. Please remit payment to ${input.firm.name || "us"}.`; pdf.text(note, MARGIN, PAGE_H - 22); }