import { createElement } from "react"; import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; import path from "node:path"; import { beforeAll, describe, expect, it } from "vitest"; import { InvoiceDocument, type InvoicePdfProps } from "./InvoiceDocument"; import { registerPdfFonts } from "./fonts"; import { assertNoOverlap } from "./testing/asserts"; import { describeBox, renderPdfText, type RenderedPdf, type TextBox } from "./testing/pdfText"; import type { BankAccount, InvoiceItem, Settings } from "../lib/types"; registerPdfFonts(path.resolve(process.cwd(), "public/fonts")); /** Set PDF_OUT_DIR to also write the rendered PDFs there for visual review. */ const OUT_DIR = process.env.PDF_OUT_DIR ?? ""; const logoDataUri = `data:image/png;base64,${readFileSync( path.resolve(process.cwd(), "public/brand/arun-p-logo.png"), ).toString("base64")}`; const vendor: Settings = { vendorName: "Arun P", vendorAddress: "13-B type House, Robertson Road, Frazer Town, Bangalore - 560005", vendorEmail: "arunakira113@gmail.com", vendorPhone: "9738284459", vendorPan: "CPOPP7388K", vendorGstin: "", vendorStateCode: "29", logoPath: null, signaturePath: null, defaultBankId: 1, defaultTaxRate: 18, defaultTaxType: "cgst_sgst", paymentTermsDays: 30, currency: "INR", onboarded: true, theme: "g10", gstRegistration: "unregistered", defaultHsnSac: "", signatoryName: "", signatoryDesignation: "", }; const bank: BankAccount = { id: 1, label: "Primary", bankName: "State Bank of India", accountName: "Arun P", accountNo: "54058708786", branch: "017060", ifsc: "SBIN0017060", isDefault: true, }; const makeItems = (n: number): InvoiceItem[] => Array.from({ length: n }, (_, i) => ({ id: i + 1, description: `Voice-over session ${i + 1} - narration for module ${i + 1}`, mode: i % 2 === 0 ? "rate" : "fixed", rate: 1200, unit: "hour", quantity: 1 + (i % 3), amount: 2500 + i * 10, sortOrder: i, })); const round2 = (n: number) => Math.round(n * 100) / 100; const lineAmount = (it: InvoiceItem) => (it.mode === "rate" ? it.rate * it.quantity : it.amount); function build(over: Partial & { items: InvoiceItem[] }): InvoicePdfProps { const subtotal = round2(over.items.reduce((s, it) => s + lineAmount(it), 0)); const discount = over.discount ?? 0; const taxType = over.taxType ?? "cgst_sgst"; const taxRate = over.taxRate ?? 18; const taxable = subtotal - discount; const tax = taxType === "none" ? 0 : round2((taxable * taxRate) / 100); const cgst = taxType === "cgst_sgst" ? round2(tax / 2) : 0; const sgst = taxType === "cgst_sgst" ? round2(tax / 2) : 0; const igst = taxType === "igst" ? tax : 0; return { vendor, bank, logoDataUri, signatureDataUri: null, number: "INV-0001", invoiceDate: "2026-09-15", dueDate: "2026-10-15", clientName: "Acme Learning Pvt Ltd", clientAddress: "42 MG Road\nBangalore 560001", clientGstin: "29ABCDE1234F1Z5", poNumber: "", placeOfSupplyStateCode: "29", subtotal, discount, taxType, taxRate, cgst, sgst, igst, total: round2(taxable + cgst + sgst + igst), amountInWords: "Rupees One Lakh Only", notes: "", ...over, }; } const fixtureA = () => build({ items: makeItems(3), notes: "Payment due within 30 days.\nLate payments attract 2% monthly interest.", poNumber: "PO-2026-0042", }); const fixtureB = () => build({ items: makeItems(60) }); const fixtureD = () => build({ items: makeItems(5), logoDataUri: null, signatureDataUri: null, vendor: { ...vendor, vendorName: "Arunachalam Padmanabhan Ramasubramanian Voice Studios Pvt", vendorEmail: "arun.padmanabhan.voiceover@examplestudios.co.in", vendorAddress: "13-B type House, Robertson Road\nFrazer Town, Cooke Town Extension\nBangalore - 560005", }, clientAddress: "42 MG Road, Ashok Nagar\nBangalore 560001", taxType: "igst", taxRate: 18, discount: 500, }); const render = (p: InvoicePdfProps) => renderPdfText(createElement(InvoiceDocument, p)); const save = (name: string, pdf: RenderedPdf) => { if (!OUT_DIR) return; mkdirSync(OUT_DIR, { recursive: true }); writeFileSync(path.join(OUT_DIR, name), pdf.bytes); }; const MARGIN_L = 39.5; const MARGIN_R = 555.8; const find = (items: TextBox[], str: string) => items.find((t) => t.str.trim() === str); /** Letter-spaced labels come out of pdf.js with spaces between glyph runs ("D E S C R I P TI O N"). */ const findSpaced = (items: TextBox[], str: string) => items.find((t) => t.str.replace(/\s+/g, "").toUpperCase() === str.toUpperCase()); const textOf = (items: TextBox[]) => items.map((t) => t.str).join(" "); /** Assertions that apply to every page of every fixture. */ function checkCommon(pdf: RenderedPdf) { const n = pdf.pages.length; expect(pdf.raw).not.toContain("/BaseFont /Helvetica"); assertNoOverlap(pdf.pages); pdf.pages.forEach((page, idx) => { const k = idx + 1; for (const t of page.items) { const isFooter = t.top > 780; expect(t.x0, `page ${k} ${describeBox(t)} left`).toBeGreaterThanOrEqual(MARGIN_L); expect(t.x1, `page ${k} ${describeBox(t)} right`).toBeLessThanOrEqual(MARGIN_R); if (isFooter) expect(t.bottom, `page ${k} ${describeBox(t)} bottom`).toBeLessThanOrEqual(842); else expect(t.top, `page ${k} ${describeBox(t)} top`).toBeGreaterThanOrEqual(MARGIN_L); } const label = `Page ${k} of ${n}`; const pageLabels = page.items.filter((t) => /^Page \d+ of \d+$/.test(t.str.trim())); expect(pageLabels.map((t) => t.str.trim()), `page ${k} page label`).toEqual([label]); expect( page.items.filter((t) => t.str.includes("Generated with Voiced")).length, `page ${k} footer`, ).toBe(1); const text = textOf(page.items); expect(text).not.toContain("¹"); }); } function checkHeader(pdf: RenderedPdf, hasDue = true) { const { items } = pdf.pages[0]; const metaStrs = ["Invoice No.", "Date", ...(hasDue ? ["Due Date"] : [])]; const metaIdx = items.filter((t) => t.top < 150 && (t.str.trim() === "INVOICE" || /TAX INVOICE/i.test(t.str))); const title = metaIdx[0] ?? find(items, "INVOICE"); expect(title, "title").toBeDefined(); const meta: TextBox[] = [title as TextBox]; for (const s of metaStrs) { const lab = find(items, s); expect(lab, s).toBeDefined(); meta.push(lab as TextBox); // value: item on the same baseline to the right of the label const val = items.find((t) => t !== lab && Math.abs(t.bottom - (lab as TextBox).bottom) < 3 && t.x0 > (lab as TextBox).x1 - 0.5); expect(val, `${s} value`).toBeDefined(); meta.push(val as TextBox); } for (const m of meta) expect(m.x0, describeBox(m)).toBeGreaterThanOrEqual(362.5); const headerBottom = Math.max(...meta.map((m) => m.bottom)); for (const t of items) { if (meta.includes(t) || t.top > headerBottom + 20) continue; if (t.x0 < 339.5) expect(t.x1, `header left ${describeBox(t)}`).toBeLessThanOrEqual(339.5); } } function checkRupee(pdf: RenderedPdf) { const all = pdf.pages.flatMap((p) => p.items); const total = all.filter((t) => t.str.includes("Subtotal") || t.str.trim() === "Total"); expect(total.length).toBeGreaterThan(0); const amounts = all.filter((t) => /\d\.\d\d$/.test(t.str.trim())); expect(amounts.length).toBeGreaterThan(0); for (const a of amounts) expect(a.str, describeBox(a)).toContain("₹"); } /** Table-structure checks for multi-page documents. */ function checkTable(pdf: RenderedPdf) { let totalPage = -1; let subtotalPage = -1; pdf.pages.forEach((page, idx) => { const k = idx + 1; const head = findSpaced(page.items, "DESCRIPTION"); const isTotals = (t: TextBox) => { // amounts at or below the totals block start const sub = find(page.items, "Subtotal"); return !!sub && t.top >= sub.top - 1; }; const rows = page.items.filter( (t) => /^₹[\d,]+\.\d\d$/.test(t.str.trim()) && t.x0 > 440 && !isTotals(t) && (!head || t.top > head.top), ); if (head) { expect(rows.length, `page ${k} has header but no rows`).toBeGreaterThan(0); } if (idx > 0 && rows.length > 0) expect(head, `page ${k} header repeats`).toBeDefined(); if (find(page.items, "Subtotal")) subtotalPage = k; if (find(page.items, "Total")) totalPage = k; }); expect(subtotalPage).toBeGreaterThan(0); expect(totalPage, "Subtotal and Total on same page").toBe(subtotalPage); } describe("InvoiceDocument fixture A (3 items, stacked logo)", () => { let pdf: RenderedPdf; beforeAll(async () => { pdf = await render(fixtureA()); save("new-A.pdf", pdf); }); it("is a single page", () => expect(pdf.pages.length).toBe(1)); it("has no overlaps, margin breaches, stray Helvetica, page labels or footer gaps", () => checkCommon(pdf)); it("keeps header columns apart", () => checkHeader(pdf)); it("prints rupee signs", () => checkRupee(pdf)); it("has table structure", () => checkTable(pdf)); }); describe("InvoiceDocument fixture B (60 items)", () => { let pdf: RenderedPdf; beforeAll(async () => { pdf = await render(fixtureB()); save("new-B-60rows.pdf", pdf); }); it("spans multiple pages", () => expect(pdf.pages.length).toBeGreaterThan(1)); it("common checks", () => checkCommon(pdf)); it("header", () => checkHeader(pdf)); it("rupee", () => checkRupee(pdf)); it("table", () => checkTable(pdf)); }); describe("InvoiceDocument fixture C (1..45 items sweep)", () => { it("never strands a header and always keeps Subtotal/Total together", async () => { for (let n = 1; n <= 45; n++) { const pdf = await render(build({ items: makeItems(n) })); try { checkCommon(pdf); checkHeader(pdf); checkRupee(pdf); checkTable(pdf); } catch (e) { throw new Error(`items=${n} (${pdf.pages.length} pages): ${(e as Error).message}`); } } }); }); describe("InvoiceDocument fixture D (long vendor strings, no logo, IGST, discount)", () => { let pdf: RenderedPdf; beforeAll(async () => { pdf = await render(fixtureD()); save("new-D.pdf", pdf); }); it("common checks", () => checkCommon(pdf)); it("header", () => checkHeader(pdf)); it("rupee", () => checkRupee(pdf)); it("table", () => checkTable(pdf)); it("shows IGST and discount", () => { const text = textOf(pdf.pages.flatMap((p) => p.items)); expect(text).toContain("IGST"); expect(text).toContain("Discount"); }); });