Files
Voiced/src/pdf/templates/harness.ts
T
xavierk 3aa0d33a01 Add units of measure and fractional quantities to rate lines
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
2026-10-06 09:35:20 +05:30

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 };
}