Files
mylegal-stage-law/src/lib/invoice-pdf.ts
T
2026-04-18 18:10:54 +00:00

541 lines
16 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 InvoiceCaseGroup {
caseNumber: string;
caseTitle: string;
practiceArea?: string | null;
items: InvoiceLineItem[];
subtotal: number;
}
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;
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
const infoRows: Array<[string, string]> = [
["Inv. Number", input.invoice.number],
];
if (input.invoice.matter) infoRows.push(["Matter", input.invoice.matter]);
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
const colDateX = MARGIN + 10;
const colKindX = MARGIN + 100;
const colDescX = MARGIN + 150;
const colRateX = MARGIN + 360;
const colAdjX = MARGIN + 425;
const colTaxX = MARGIN + 470;
const colAmtX = PAGE_W - MARGIN - 10;
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("DESCRIPTION", colDescX, y + 14);
const rW = pdf.getTextWidth("RATE");
pdf.text("RATE", colRateX - rW + 30, y + 14);
const aW = pdf.getTextWidth("ADJMTS");
pdf.text("ADJMTS", colAdjX - aW + 40, y + 14);
const tW = pdf.getTextWidth("TAX");
pdf.text("TAX", colTaxX - tW + 32, y + 14);
const totW = pdf.getTextWidth("TOTAL");
pdf.text("TOTAL", colAmtX - totW, y + 14);
y += 22;
};
drawColHeaders();
// Flatten line items across groups (the reference doesn't separate groups)
const allItems: Array<InvoiceLineItem & { _caseNumber?: string }> = [];
for (const g of input.groups) {
for (const it of g.items) {
allItems.push({ ...it, _caseNumber: g.caseNumber });
}
}
const taxRate = (input.totals.taxRatePct ?? 0) / 100;
for (const item of allItems) {
const isNonBillable = item.billable === false;
const descMaxW = colRateX - colDescX - 10;
const descLines = wrap(pdf, item.description || (item.kind === "expense" ? "Expense" : ""), descMaxW);
const lineHeight = 12;
const blockH = Math.max(lineHeight * descLines.length + 14, 30);
ensure(blockH + 4);
// Date (date + staff name on second line)
setFont(pdf, { size: 9, color: TEXT });
pdf.text(fmtDateShort(item.work_date), colDateX, y + 10);
if (item.user_name) {
setFont(pdf, { size: 8, color: MUTED });
pdf.text(item.user_name, colDateX, y + 22);
}
// Kind label (Fee / Expense)
setFont(pdf, { size: 9, color: TEXT });
const kindLabel =
item.kind === "expense" ? "Expense" : item.kind === "time" ? "Fee" : item.kind === "manual" ? "Fee" : item.kind;
pdf.text(kindLabel, colKindX, y + 10);
// Description
setFont(pdf, { size: 9, color: isNonBillable ? MUTED : TEXT, italic: isNonBillable });
let dy = y + 10;
for (const dl of descLines) {
pdf.text(dl, colDescX, dy);
dy += lineHeight;
}
// Rate (with x quantity below)
setFont(pdf, { size: 9, color: TEXT });
const rateText = fmtCurrency(item.rate);
const rW = pdf.getTextWidth(rateText);
pdf.text(rateText, colRateX - rW + 30, y + 10);
if (item.quantity && item.quantity !== 1) {
setFont(pdf, { size: 8, color: MUTED });
const qText = `x ${Number(item.quantity).toFixed(2)}`;
const qW = pdf.getTextWidth(qText);
pdf.text(qText, colRateX - qW + 30, y + 22);
}
// Adjustments column — placeholder $0.00 unless future per-line adjustments
setFont(pdf, { size: 9, color: TEXT });
const adjText = fmtCurrency(0);
const adjW = pdf.getTextWidth(adjText);
pdf.text(adjText, colAdjX - adjW + 40, y + 10);
// Tax per line
const lineTax = isNonBillable ? 0 : item.amount * taxRate;
const taxText = fmtCurrency(lineTax);
const taxW = pdf.getTextWidth(taxText);
pdf.text(taxText, colTaxX - taxW + 32, y + 10);
// 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 + 10);
y += blockH;
pdf.setDrawColor(...RULE);
pdf.setLineWidth(0.4);
pdf.line(MARGIN, y, PAGE_W - MARGIN, y);
}
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);
}