import { readFileSync } from "node:fs"; import path from "node:path"; import { deflateSync } from "node:zlib"; import { computeTotals } from "../../lib/invoice"; import { amountInWords } from "../../lib/numberToWords"; import type { BankAccount, Invoice, InvoiceItem, Settings, TaxType } from "../../lib/types"; import { buildRenderModel, type LogoInput, type RenderModel } from "../model/build"; import { DEFAULT_PREFS, type RenderPrefsV1 } from "../model/prefs"; import { renderInvoicePdf, type RenderInvoiceResult } from "../render/invoice"; import { parsePdfText, type RenderedPdf } from "../testing/pdfText"; import { setupPdfTest } from "../testing/setup"; /** Fixtures and helpers shared by the template harness and the sample renderer. */ export type LogoKind = "none" | "real" | "wide" | "square"; export interface FixtureSpec { name: string; vendor?: Partial; invoice?: Partial; /** An item list, or a count of generated one-line items. */ items?: InvoiceItem[] | number; logo?: LogoKind; signature?: boolean; /** Tax invoice with a registered supplier (default true; false gives the plain invoice). */ registered?: boolean; } export const FIXTURE_GSTIN = "29AAAAA0000A1Z5"; const FIXTURE_CLIENT_GSTIN = "29ABCDE1234F1Z5"; const baseVendor: Settings = { vendorName: "Test Vendor", vendorAddress: "1 Test Road, Bangalore - 560001", vendorEmail: "vendor@example.com", vendorPhone: "9000000000", vendorPan: "ABCDE1234F", 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: "", renderPrefs: "", }; export const FIXTURE_BANK: BankAccount = { id: 1, label: "Primary", bankName: "State Bank of India", accountName: "Test Vendor", accountNo: "000000000000", branch: "000001", ifsc: "TEST0000001", isDefault: true, }; export const makeItems = (n: number, hsn: boolean): 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, ...(hsn ? { hsnSac: "998397" } : {}), })); // --- tiny PNG writer: logos for the fixtures without a native image library or a binary fixture ---- const crcTable = Array.from({ length: 256 }, (_, n) => { let c = n; for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; return c >>> 0; }); const crc32 = (buf: Buffer) => { let c = 0xffffffff; for (const b of buf) c = crcTable[(c ^ b) & 0xff] ^ (c >>> 8); return (c ^ 0xffffffff) >>> 0; }; const chunk = (type: string, data: Buffer) => { const len = Buffer.alloc(4); len.writeUInt32BE(data.length); const body = Buffer.concat([Buffer.from(type, "latin1"), data]); const crc = Buffer.alloc(4); crc.writeUInt32BE(crc32(body)); return Buffer.concat([len, body, crc]); }; /** A w x h RGBA PNG: a navy block with a light stripe, so a wrongly stretched logo is visible. */ export function solidPng(w: number, h: number): Buffer { const row = Buffer.alloc(1 + w * 4); const raw = Buffer.alloc(row.length * h); for (let y = 0; y < h; y++) { raw[y * row.length] = 0; for (let x = 0; x < w; x++) { const stripe = Math.abs(x - y * (w / h)) < h * 0.12; const o = y * row.length + 1 + x * 4; raw[o] = stripe ? 120 : 24; raw[o + 1] = stripe ? 160 : 52; raw[o + 2] = stripe ? 240 : 120; raw[o + 3] = 255; } } const ihdr = Buffer.alloc(13); ihdr.writeUInt32BE(w, 0); ihdr.writeUInt32BE(h, 4); ihdr[8] = 8; ihdr[9] = 6; return Buffer.concat([ Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), chunk("IHDR", ihdr), chunk("IDAT", deflateSync(raw)), chunk("IEND", Buffer.alloc(0)), ]); } const dataUri = (png: Buffer) => `data:image/png;base64,${png.toString("base64")}`; const cache = new Map(); export function fixtureLogo(kind: LogoKind): LogoInput | null { if (kind === "none") return null; let hit = cache.get(kind); if (!hit) { if (kind === "real") { // 2172 x 724: the untrimmed Test Vendor logo, a 3:1 aspect. const png = readFileSync(path.resolve(process.cwd(), "public/brand/sample-wordmark-logo.png")); hit = { dataUri: dataUri(png), width: png.readUInt32BE(16), height: png.readUInt32BE(20) }; } else if (kind === "wide") { // The trimmed Test Vendor ink box is 4.6:1. hit = { dataUri: dataUri(solidPng(460, 100)), width: 460, height: 100 }; } else { hit = { dataUri: dataUri(solidPng(120, 120)), width: 120, height: 120 }; } cache.set(kind, hit); } return hit; } let signature: string | null = null; const signatureUri = () => (signature ??= dataUri(solidPng(320, 90))); export interface BuiltFixture { spec: FixtureSpec; model: RenderModel; invoice: Invoice; } /** A full `Invoice` read model (totals derived the way Rust does) pushed through buildRenderModel. */ export function buildFixture(spec: FixtureSpec): BuiltFixture { const registered = spec.registered !== false; const taxInvoice = registered; const items = Array.isArray(spec.items) ? spec.items : makeItems(spec.items ?? 3, taxInvoice); const taxType: TaxType = spec.invoice?.taxType ?? (taxInvoice ? "cgst_sgst" : "none"); const taxRate = spec.invoice?.taxRate ?? (taxInvoice ? 18 : 0); const discount = spec.invoice?.discount ?? 0; const totals = computeTotals(items, discount, taxType, taxRate); const vendor: Settings = { ...baseVendor, ...(registered ? { vendorGstin: FIXTURE_GSTIN, gstRegistration: "regular" as const, signatoryName: "Test Vendor", signatoryDesignation: "Proprietor" } : // A stale GSTIN left in the settings of an unregistered supplier must never print. { vendorGstin: "27ZZZZZ9999Z1Z9", gstRegistration: "unregistered" as const }), ...spec.vendor, }; const pos = vendor.vendorStateCode; const invoice: Invoice = { id: 1, number: "INV/2026-001", seriesId: 1, invoiceDate: "2026-09-15", dueDate: "2026-10-15", clientId: null, clientName: "Acme Learning Pvt Ltd", clientAddress: "42 MG Road\nBangalore 560001", clientGstin: FIXTURE_CLIENT_GSTIN, poNumber: "", placeOfSupplyStateCode: taxInvoice ? pos : "", subtotal: totals.subtotal, discount: totals.discount, taxType, taxRate, cgstAmount: totals.cgst, sgstAmount: totals.sgst, igstAmount: totals.igst, total: totals.total, amountInWords: amountInWords(totals.total), bankAccountId: 1, bankSnapshot: "", signaturePath: null, notes: "", status: "issued", createdAt: "2026-09-15T00:00:00Z", updatedAt: "2026-09-15T00:00:00Z", docType: taxInvoice ? "tax_invoice" : "invoice", reverseCharge: false, vendorSnapshot: "", snapshotOrigin: "", cancelledAt: null, cancelReason: "", archivedPdfSha256: null, renderPrefs: "", items, ...spec.invoice, }; const model = buildRenderModel({ invoice, vendor, bank: FIXTURE_BANK, logo: fixtureLogo(spec.logo ?? "real"), signatureDataUri: spec.signature ? signatureUri() : null, appVersion: "0.1.0", }); return { spec, model, invoice }; } const LONG_ADDRESS = "Unit 4, Third Floor, The Grand Heritage Business Centre, Plot 1187-B, Old Airport Road Extension, Opposite the Municipal Corporation Ward Office, Domlur Second Stage, Bengaluru Urban District, Karnataka State, India - 560071; Registered office: Level 12, Tower C, Global Technology Park, Outer Ring Road, Marathahalli, Bengaluru - 560103"; /** The fixture matrix of the template harness. Every name is unique; the file name of a sample uses it. */ export const FIXTURES: FixtureSpec[] = [ { name: "minimal", items: 1, logo: "none" }, { name: "long-address", vendor: { vendorAddress: LONG_ADDRESS }, items: 4 }, { name: "id-tokens", vendor: { vendorEmail: "Sample.longsurname.voiceover.studio.bangalore@examplestudios-india.co.in", vendorPhone: "+91 97382 84459 / +91 80 4123 9988 (studio)", vendorName: "Venkatachalam Longsurname Ramasubramanian Voice Studios Pvt", }, invoice: { clientName: "https://www.extremely-long-client-website-address.example.com/projects/2026/voice", clientGstin: "29ABCDE1234F1Z5", poNumber: "PO/2026/BANGALORE/0042/REV-3/LONG-REFERENCE" }, items: 3, }, { name: "rows-60", items: 60, invoice: { notes: "Payment due within 30 days.\nLate payments attract 2% monthly interest." }, signature: true }, { name: "multiline", items: [ { description: "Voice-over for the onboarding course\nModule 1 - introduction\nModule 2 - safety basics\nIncludes two rounds of revisions", mode: "rate", rate: 1500, unit: "hour", quantity: 3, amount: 0, sortOrder: 0, hsnSac: "998397" }, { description: "Studio hire", mode: "fixed", rate: 0, unit: "unit", quantity: 1, amount: 8000, sortOrder: 1, hsnSac: "997212" }, { description: "Audio editing and mastering of all deliverables, including noise reduction, levelling, and export to the three formats requested by the client in the brief of August", mode: "rate", rate: 400, unit: "minute", quantity: 25, amount: 0, sortOrder: 2, hsnSac: "998397" }, ], }, { name: "long-notes", items: 30, invoice: { notes: Array.from({ length: 18 }, (_, i) => `Note ${i + 1}: payment is due within thirty days of the invoice date; late payments attract interest at two percent per month.`).join("\n") }, }, { name: "igst", invoice: { taxType: "igst", placeOfSupplyStateCode: "27", discount: 500 }, items: 5 }, { name: "cgst-sgst", items: 5, invoice: { poNumber: "PO-2026-0042", notes: "Thank you for your business." } }, { name: "utgst", vendor: { vendorStateCode: "04", vendorGstin: "04AAAAA0000A1Z5" }, invoice: { placeOfSupplyStateCode: "04" }, items: 4 }, { name: "unregistered", registered: false, items: 3 }, { name: "cancelled", invoice: { status: "cancelled" }, items: 4, signature: true }, { name: "logo-none", logo: "none", items: 3 }, { name: "logo-wide", logo: "wide", items: 3 }, { name: "logo-square", logo: "square", items: 3 }, { name: "long-client", invoice: { clientName: "The Board of Trustees of the Mahatma Gandhi Memorial Institute of Education, Research and Rural Development (Registered Society)", clientAddress: "Administrative Block, Level 3, Institutional Area, Sector 62\nNoida, Gautam Buddha Nagar, Uttar Pradesh - 201309", }, items: 3, }, ]; export const PAGE_SIZE_IDS = ["A4", "LETTER"] as const; export const prefsFor = (pageSize: RenderPrefsV1["pageSize"], over: Partial = {}): RenderPrefsV1 => ({ ...DEFAULT_PREFS, pageSize, ...over }); export interface Rendered { fixture: BuiltFixture; prefs: RenderPrefsV1; result: RenderInvoiceResult; pdf: RenderedPdf; } let ready = false; /** Registers fonts and installs the measurer once per process. */ export function ensureSetup(): void { if (!ready) { setupPdfTest(); ready = true; } } export async function renderFixture(spec: FixtureSpec, prefs: RenderPrefsV1): Promise { ensureSetup(); const fixture = buildFixture(spec); const result = await renderInvoicePdf({ model: fixture.model, prefs }); const pdf = await parsePdfText(result.bytes); return { fixture, prefs, result, pdf }; }