Files
mylegal-stage-law/src/lib/invoice-pdf.ts
T
2026-04-19 20:30:59 +00:00

609 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
}