Quantities can now be time, count, distance, area, weight or volume, or a custom unit, instead of only
second/minute/hour/session/unit.
- Registry in src/lib/units.ts (mirrored in src-tauri/src/units.rs): grouped built-in units plus a custom unit of 1-12
characters. A rate line's unit is validated when an invoice or a preset is saved; migration M12 drops the old unit
allow-list from item_presets and keeps every row.
- Time is typed as h:mm: "4:30" hours counts as 4.5, "4:20" as 4.333... (kept exact, so rate x quantity rounds once, the
same in TypeScript and Rust). Other units take plain decimals up to 3 places. A bad quantity blocks issuing.
- The PDF quantity column shows the unit ("4.5 hr", "12.75 km"); the rate keeps "per hour" / "/hr". RenderItem carries
priceText, perText and rateShort, so no template parses the rate text with a regex over five hard-coded units.
- Quantity columns are sized from the widest word once a quantity with its unit is wider than 72 pt, so one long
custom unit cannot squeeze the description (the serenity and citrus-split tables overflowed in the new fixture).
Classic's quantity column is now content-sized.
- ERPNext: the default UOM map comes from the registry, with fractional-capable UOMs for time, distance, area, weight
and volume. A custom unit is sent as Nos. A quantity that is not exact to 3 decimals (4:20 hours) is still refused for
push, as before.
- Goldens and template thumbnails regenerated; the template harness has a "units" fixture with the longest texts.
Claude-Session: https://claude.ai/code/session_01PZypiWDfMkDTeEPeXjRhW5
363 lines
14 KiB
TypeScript
363 lines
14 KiB
TypeScript
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" | "knockout";
|
|
|
|
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: "27 Example Apartments, Sample Layout, Test Nagar, Bangalore - 560001",
|
|
vendorEmail: "vendor.test@example.in",
|
|
vendorPhone: "9000000000",
|
|
vendorPan: "ABCDE1234F",
|
|
vendorGstin: "",
|
|
vendorStateCode: "29",
|
|
logoPath: null,
|
|
logoOriginalPath: null,
|
|
logoKnockoutPath: null,
|
|
logoMeta: "",
|
|
logoIncludesName: "auto",
|
|
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)),
|
|
]);
|
|
}
|
|
|
|
/** A transparent-margin w x h PNG in one colour: the shape of a logo that has alpha, so it can have a white knockout. */
|
|
function inkPng(w: number, h: number, rgb: [number, number, number]): Buffer {
|
|
const row = Buffer.alloc(1 + w * 4);
|
|
const raw = Buffer.alloc(row.length * h);
|
|
for (let y = 0; y < h; y++) {
|
|
for (let x = 0; x < w; x++) {
|
|
const inside = x >= w * 0.08 && x < w * 0.92 && y >= h * 0.12 && y < h * 0.88;
|
|
const o = y * row.length + 1 + x * 4;
|
|
raw[o] = rgb[0];
|
|
raw[o + 1] = rgb[1];
|
|
raw[o + 2] = rgb[2];
|
|
raw[o + 3] = inside ? 255 : 0;
|
|
}
|
|
}
|
|
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 sample logo, a 3:1 aspect.
|
|
const png = readFileSync(path.resolve(process.cwd(), "src/pdf/testing/fixtures/sample-wordmark-logo.png"));
|
|
const [width, height] = [png.readUInt32BE(16), png.readUInt32BE(20)];
|
|
hit = { dataUri: dataUri(png), width, height, aspect: width / height, density: 0.21, kind: "wordmark", includesName: true };
|
|
} else if (kind === "knockout") {
|
|
// A wordmark with alpha: navy print image plus its white knockout, as the logo pipeline writes them.
|
|
hit = {
|
|
dataUri: dataUri(inkPng(460, 100, [24, 52, 120])),
|
|
knockoutDataUri: dataUri(inkPng(460, 100, [255, 255, 255])),
|
|
hasKnockout: true,
|
|
width: 460,
|
|
height: 100,
|
|
aspect: 4.6,
|
|
density: 0.6,
|
|
kind: "wordmark",
|
|
includesName: true,
|
|
};
|
|
} else if (kind === "wide") {
|
|
// The trimmed sample ink box is 4.6:1.
|
|
hit = { dataUri: dataUri(solidPng(460, 100)), width: 460, height: 100, aspect: 4.6, density: 1, kind: "wordmark", includesName: true };
|
|
} else {
|
|
hit = { dataUri: dataUri(solidPng(120, 120)), width: 120, height: 120, aspect: 1, density: 1, kind: "mark", includesName: false };
|
|
}
|
|
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: "anon.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" },
|
|
],
|
|
},
|
|
{
|
|
// Units of measure: a decimal distance, an area, a time that is not a whole number of hundredths (4 h 20 min),
|
|
// a 12-character custom unit and a month, so the quantity and rate columns see the longest texts they can.
|
|
name: "units",
|
|
items: [
|
|
{ description: "Travel to the client's studio", mode: "rate", rate: 18.5, unit: "km", quantity: 12.75, amount: 0, sortOrder: 0, hsnSac: "996601" },
|
|
{ description: "Acoustic treatment, floor area", mode: "rate", rate: 85, unit: "sqft", quantity: 1250.5, amount: 0, sortOrder: 1, hsnSac: "995411" },
|
|
{ description: "Evening studio session", mode: "rate", rate: 1500, unit: "hour", quantity: 4 + 1 / 3, amount: 0, sortOrder: 2, hsnSac: "998397" },
|
|
{ description: "Packed equipment cases", mode: "rate", rate: 99.5, unit: "wooden crate", quantity: 120, amount: 0, sortOrder: 3, hsnSac: "998397" },
|
|
{ description: "Retainer", mode: "rate", rate: 25000, unit: "month", quantity: 1, amount: 0, sortOrder: 4, hsnSac: "998397" },
|
|
{ description: "Setup fee", mode: "fixed", rate: 0, unit: "unit", quantity: 1, amount: 8000, sortOrder: 5, hsnSac: "997212" },
|
|
],
|
|
},
|
|
{
|
|
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 };
|
|
}
|