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:
@@ -97,7 +97,7 @@ describe("unregistered invoice", () => {
|
||||
expect(m.docTitle).toBe("Invoice");
|
||||
expect(m.docType).toBe("invoice");
|
||||
expect(m.pdfTitle).toBe("Invoice INV-0001");
|
||||
expect(m.slots).toEqual({ gstin: false, clientGstin: false, pos: false, hsn: true, taxRows: false });
|
||||
expect(m.slots).toEqual({ gstin: false, clientGstin: false, pos: false, hsn: true, taxRows: false, reverseCharge: false });
|
||||
expect(m.totals).toMatchObject({
|
||||
subtotalText: "₹5,500.50",
|
||||
discountText: null,
|
||||
@@ -119,7 +119,7 @@ describe("unregistered invoice", () => {
|
||||
expect(m.client.addressLines).toEqual(["1 Main Road", "Mumbai"]);
|
||||
expect(m.client.poNumber).toBeNull();
|
||||
expect(m.notesLines).toEqual(["Thanks", "Pay in 30 days"]);
|
||||
expect(m.meta).toEqual({ numberLabel: "Invoice No.", number: "INV-0001", date: "15 Sept 2026", dueDate: "05 Oct 2026" });
|
||||
expect(m.meta).toEqual({ numberLabel: "Invoice No.", number: "INV-0001", date: "15 Sept 2026", isoDate: "2026-09-15", dueDate: "05 Oct 2026" });
|
||||
});
|
||||
it("builds items with rate, quantity and em-dash fallback", () => {
|
||||
expect(m.items[0]).toEqual({
|
||||
@@ -179,16 +179,24 @@ describe("CGST + SGST tax invoice", () => {
|
||||
expect(m.totals.discountText).toBe("₹500.50");
|
||||
expect(m.totals.taxableText).toBe("₹5,000.00");
|
||||
expect(m.totals.totalText).toBe("₹5,250.00");
|
||||
expect(m.slots).toEqual({ gstin: true, clientGstin: true, pos: true, hsn: true, taxRows: true });
|
||||
expect(m.slots).toEqual({ gstin: true, clientGstin: true, pos: true, hsn: true, taxRows: true, reverseCharge: true });
|
||||
expect(m.client.posLabel).toBe("29 – Karnataka");
|
||||
expect(m.client.gstin).toBe("29ABCDE1234F1Z5");
|
||||
expect(m.client.poNumber).toBe("PO-77");
|
||||
expect(m.supplier.showGstin).toBe(true);
|
||||
expect(m.reverseCharge).toMatch(/reverse charge/i);
|
||||
expect(m.reverseCharge).toBe("Tax is payable on reverse charge: Yes");
|
||||
expect(m.signature).toMatchObject({ name: "A. Person", designation: "Proprietor" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("reverse charge line", () => {
|
||||
it("is always present on a tax invoice (Yes or No) and absent on a plain invoice", () => {
|
||||
expect(make({ docType: "tax_invoice", reverseCharge: false }).reverseCharge).toBe("Tax is payable on reverse charge: No");
|
||||
expect(make({ docType: "tax_invoice", reverseCharge: true }).reverseCharge).toBe("Tax is payable on reverse charge: Yes");
|
||||
expect(make({ docType: "invoice", reverseCharge: true }).reverseCharge).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("IGST", () => {
|
||||
const m = make({ docType: "tax_invoice", taxType: "igst", taxRate: 18, igstAmount: 990.09, total: 6490.59, placeOfSupplyStateCode: "27" });
|
||||
it("has one IGST row at the full rate", () => {
|
||||
|
||||
@@ -58,7 +58,7 @@ export interface RenderModel {
|
||||
stateCode: string;
|
||||
showGstin: boolean;
|
||||
};
|
||||
meta: { numberLabel: string; number: string; date: string; dueDate: string | null };
|
||||
meta: { numberLabel: string; number: string; date: string; /** yyyy-mm-dd, for the PDF metadata dates. */ isoDate: string; dueDate: string | null };
|
||||
client: {
|
||||
name: string;
|
||||
addressLines: string[];
|
||||
@@ -83,6 +83,7 @@ export interface RenderModel {
|
||||
logo: LogoInput | null;
|
||||
footerText: string;
|
||||
appVersion: string;
|
||||
/** "Tax is payable on reverse charge: Yes/No" on a tax invoice; null on a plain invoice. */
|
||||
reverseCharge: string | null;
|
||||
cancelled: boolean;
|
||||
/** Which regulated slots have content, for the template contract tests. */
|
||||
@@ -92,6 +93,7 @@ export interface RenderModel {
|
||||
pos: boolean;
|
||||
hsn: boolean;
|
||||
taxRows: boolean;
|
||||
reverseCharge: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -195,6 +197,7 @@ export function buildRenderModel(input: BuildInput): RenderModel {
|
||||
numberLabel: "Invoice No.",
|
||||
number: sanitize(invoice.number),
|
||||
date: formatDate(invoice.invoiceDate),
|
||||
isoDate: invoice.invoiceDate,
|
||||
dueDate: invoice.dueDate ? formatDate(invoice.dueDate) : null,
|
||||
},
|
||||
client: {
|
||||
@@ -223,7 +226,7 @@ export function buildRenderModel(input: BuildInput): RenderModel {
|
||||
logo,
|
||||
footerText: "Generated with Voiced — a Bongbetic product",
|
||||
appVersion,
|
||||
reverseCharge: invoice.reverseCharge ? "Tax is payable on reverse charge: Yes" : null,
|
||||
reverseCharge: docType === "tax_invoice" ? `Tax is payable on reverse charge: ${invoice.reverseCharge ? "Yes" : "No"}` : null,
|
||||
cancelled: invoice.status === "cancelled",
|
||||
slots: {
|
||||
gstin: showGstin,
|
||||
@@ -231,6 +234,7 @@ export function buildRenderModel(input: BuildInput): RenderModel {
|
||||
pos: posLabel !== null,
|
||||
hsn,
|
||||
taxRows: taxRows.length > 0,
|
||||
reverseCharge: docType === "tax_invoice",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { DEFAULT_PREFS, parsePrefs, prefsToJson } from "./prefs";
|
||||
|
||||
describe("render prefs", () => {
|
||||
it("defaults to Classic on A4 with the template margins", () => {
|
||||
expect(DEFAULT_PREFS).toEqual({ version: 1, templateId: "classic", pageSize: "A4", margins: "template" });
|
||||
});
|
||||
|
||||
it("round-trips through JSON", () => {
|
||||
const p = { version: 1 as const, templateId: "classic", pageSize: "LETTER" as const, margins: "narrow" as const, logo: { useWordmark: true } };
|
||||
expect(parsePrefs(prefsToJson(p))).toEqual(p);
|
||||
});
|
||||
|
||||
it("is tolerant: empty, malformed and out-of-range input fall back field by field", () => {
|
||||
expect(parsePrefs(undefined)).toEqual(DEFAULT_PREFS);
|
||||
expect(parsePrefs("")).toEqual(DEFAULT_PREFS);
|
||||
expect(parsePrefs("{not json")).toEqual(DEFAULT_PREFS);
|
||||
expect(parsePrefs("42")).toEqual(DEFAULT_PREFS);
|
||||
expect(parsePrefs("null")).toEqual(DEFAULT_PREFS);
|
||||
expect(parsePrefs(JSON.stringify({ pageSize: "LETTER", margins: "wide", templateId: 7, logo: { useWordmark: "yes" } }))).toEqual({
|
||||
...DEFAULT_PREFS,
|
||||
pageSize: "LETTER",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not hand out the shared default object", () => {
|
||||
expect(parsePrefs(null)).not.toBe(DEFAULT_PREFS);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { MarginPreset, PageId } from "../engine/geometry";
|
||||
|
||||
/** Page and template choices frozen with an invoice at issue (stored as JSON). */
|
||||
export interface RenderPrefsV1 {
|
||||
version: 1;
|
||||
templateId: string;
|
||||
pageSize: PageId;
|
||||
margins: MarginPreset;
|
||||
logo?: {
|
||||
/** The logo already contains the business name, so a template drops its own name lockup. */
|
||||
useWordmark?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export const DEFAULT_PREFS: RenderPrefsV1 = {
|
||||
version: 1,
|
||||
templateId: "classic",
|
||||
pageSize: "A4",
|
||||
margins: "template",
|
||||
};
|
||||
|
||||
const PAGE_SIZE_IDS: readonly PageId[] = ["A4", "LETTER"];
|
||||
const MARGIN_IDS: readonly MarginPreset[] = ["template", "narrow", "normal"];
|
||||
|
||||
/** Tolerant: anything unreadable or out of range falls back to the default for that field. */
|
||||
export function parsePrefs(json: string | null | undefined): RenderPrefsV1 {
|
||||
if (!json) return { ...DEFAULT_PREFS };
|
||||
let raw: unknown;
|
||||
try {
|
||||
raw = JSON.parse(json);
|
||||
} catch {
|
||||
return { ...DEFAULT_PREFS };
|
||||
}
|
||||
if (typeof raw !== "object" || raw === null) return { ...DEFAULT_PREFS };
|
||||
const o = raw as Record<string, unknown>;
|
||||
const prefs: RenderPrefsV1 = {
|
||||
version: 1,
|
||||
templateId: typeof o.templateId === "string" && o.templateId ? o.templateId : DEFAULT_PREFS.templateId,
|
||||
pageSize: PAGE_SIZE_IDS.includes(o.pageSize as PageId) ? (o.pageSize as PageId) : DEFAULT_PREFS.pageSize,
|
||||
margins: MARGIN_IDS.includes(o.margins as MarginPreset) ? (o.margins as MarginPreset) : DEFAULT_PREFS.margins,
|
||||
};
|
||||
const logo = o.logo;
|
||||
if (typeof logo === "object" && logo !== null && typeof (logo as { useWordmark?: unknown }).useWordmark === "boolean") {
|
||||
prefs.logo = { useWordmark: (logo as { useWordmark: boolean }).useWordmark };
|
||||
}
|
||||
return prefs;
|
||||
}
|
||||
|
||||
export const prefsToJson = (prefs: RenderPrefsV1): string => JSON.stringify(prefs);
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { RenderModel } from "./build";
|
||||
|
||||
/** Regulated data that templates may print only through `Slot.*` (blocks/slots.tsx). */
|
||||
export type SlotName = "gstin" | "pos" | "hsn" | "taxRows" | "amountInWords" | "signatory" | "reverseCharge";
|
||||
|
||||
export interface SlotRule {
|
||||
/** Must appear in the rendered text whenever the model carries its content. */
|
||||
required: SlotName[];
|
||||
/** Must not appear at all. */
|
||||
forbidden: SlotName[];
|
||||
}
|
||||
|
||||
/**
|
||||
* What each document type must and must not print. A tax invoice carries the full GST set; a plain
|
||||
* invoice from an unregistered supplier has no GSTIN row, no tax rows and no reverse-charge line.
|
||||
*/
|
||||
export const SLOT_RULES: Record<RenderModel["docType"], SlotRule> = {
|
||||
tax_invoice: {
|
||||
required: ["gstin", "pos", "hsn", "taxRows", "amountInWords", "signatory", "reverseCharge"],
|
||||
forbidden: [],
|
||||
},
|
||||
invoice: {
|
||||
required: ["amountInWords", "signatory"],
|
||||
forbidden: ["gstin", "taxRows", "reverseCharge"],
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* The strings a slot is expected to put on the page, from the model. Empty when the slot has no
|
||||
* content in this model. Used by the contract tests, so keep it in step with blocks/slots.tsx.
|
||||
*/
|
||||
export function slotContent(model: RenderModel, slot: SlotName): string[] {
|
||||
switch (slot) {
|
||||
case "gstin":
|
||||
return model.slots.gstin && model.supplier.gstin ? [`GSTIN: ${model.supplier.gstin}`] : [];
|
||||
case "pos":
|
||||
return model.client.posLabel ? [`Place of Supply: ${model.client.posLabel}`] : [];
|
||||
case "hsn":
|
||||
return model.slots.hsn ? ["HSN/SAC", ...model.items.flatMap((i) => (i.hsnSac ? [i.hsnSac] : []))] : [];
|
||||
case "taxRows":
|
||||
return model.totals.taxRows.flatMap((r) => [r.label, ...(r.rateText ? [r.rateText] : []), r.amountText]);
|
||||
case "amountInWords":
|
||||
return model.amountInWords ? [`Amount in words: ${model.amountInWords}`] : [];
|
||||
case "signatory":
|
||||
return [model.signature.name, ...(model.signature.designation ? [model.signature.designation] : [])];
|
||||
case "reverseCharge":
|
||||
return model.reverseCharge ? [model.reverseCharge] : [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Text that proves a forbidden slot leaked, independent of the model (the model may hold stale data). */
|
||||
export function forbiddenProbe(model: RenderModel, slot: SlotName, rawSupplierGstin?: string): string[] {
|
||||
switch (slot) {
|
||||
case "gstin":
|
||||
return ["GSTIN: " + (rawSupplierGstin ?? model.supplier.gstin ?? "")].filter((s) => s !== "GSTIN: ");
|
||||
case "taxRows":
|
||||
return ["CGST", "SGST", "UTGST", "IGST"];
|
||||
case "reverseCharge":
|
||||
return ["reverse charge"];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user