Rebuild the Classic template on the engine with A4/Letter and a template harness

Classic now renders through buildRenderModel, computeFrame, the template
and renderCore, with S.No and HSN/SAC columns, GST slots only through
checked Slot components, taxable value, reverse-charge line, cancelled tag,
continuation band and Page n of N. Letter absorbs its height deficit in
elastic gaps. A harness runs 15 fixtures on A4 and Letter plus an item
sweep and asserts zero audit errors, repeated headers, no split blocks,
rupee extraction and deterministic output. The legacy InvoiceDocument is
removed.
This commit is contained in:
2026-10-04 05:31:14 +05:30
parent 8d4f8e666b
commit 3a99b7284b
40 changed files with 2185 additions and 832 deletions
+302
View File
@@ -0,0 +1,302 @@
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<Settings>;
invoice?: Partial<Invoice>;
/** 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: "",
};
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<string, LogoInput>();
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,
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> = {}): 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<Rendered> {
ensureSetup();
const fixture = buildFixture(spec);
const result = await renderInvoicePdf({ model: fixture.model, prefs });
const pdf = await parsePdfText(result.bytes);
return { fixture, prefs, result, pdf };
}