Files
mylegal-stage-law/src/lib/invoice-pdf.ts
T
2026-04-19 01:14:17 +00:00

588 lines
18 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;
for (const g of input.groups) {
// Always show the case name above its charges (per-case subtotal only when >1 case).
drawCaseHeader(g);
for (const item of g.items) {
const isNonBillable = item.billable === false;
const descMaxW = colQtyR - colDescX - 14;
const descLines = wrap(pdf, item.description || (item.kind === "expense" ? "Expense" : ""), descMaxW);
const lineHeight = 12;
// Reserve enough vertical space: description lines + room for staff name under date.
const blockH = Math.max(lineHeight * descLines.length + 14, item.user_name ? 32 : 26);
ensure(blockH + 4);
// Date + staff name
setFont(pdf, { size: 9, color: TEXT });
pdf.text(fmtDateShort(item.work_date), colDateX, y + 12);
if (item.user_name) {
setFont(pdf, { size: 7.5, color: MUTED });
const nameLines = wrap(pdf, item.user_name, colKindX - colDateX - 4);
pdf.text(nameLines[0], colDateX, y + 23);
}
// Type label (Fee / Expense)
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);
// Description
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;
}
// Qty (right-aligned). For expenses (qty=1, no hours concept) show "—".
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);
// Rate (right-aligned)
const rateText = fmtCurrency(item.rate);
const rW = pdf.getTextWidth(rateText);
pdf.text(rateText, colRateR - rW, y + 12);
// Tax per line
const lineTax = isNonBillable ? 0 : item.amount * taxRate;
const taxText = fmtCurrency(lineTax);
const taxW = pdf.getTextWidth(taxText);
pdf.text(taxText, colTaxR - taxW, y + 12);
// Total (amount + tax)
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);
}
// 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);
}