Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
609 lines
19 KiB
TypeScript
609 lines
19 KiB
TypeScript
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);
|
||
}
|