Add bundled TTF fonts, page geometry, column allocator and render model
Static TTF fonts (IBM Plex Sans/Mono, Noto Devanagari/Kannada) with a fontkit-generated manifest, a verifier and OFL licences. Every family registers a normal and italic source so fallback stacks cannot throw. Adds A4/Letter geometry with margin presets, an exact column allocator, deterministic Indian-grouping money and date formatting, text sanitising and a pure buildRenderModel.
This commit is contained in:
@@ -0,0 +1,256 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { BankAccount, Invoice, InvoiceItem, Settings } from "../../lib/types";
|
||||
import { buildRenderModel, type BuildInput } from "./build";
|
||||
import { bankFromSnapshot, vendorFromSnapshot } from "./vendor";
|
||||
|
||||
const vendor: Settings = {
|
||||
vendorName: "Test Vendor",
|
||||
vendorAddress: "13-B Type House,\r\nTest Road, Test Town\nBangalore - 560005",
|
||||
vendorEmail: "Sample@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: "",
|
||||
};
|
||||
|
||||
const bank: BankAccount = {
|
||||
id: 1,
|
||||
label: "Primary",
|
||||
bankName: "State Bank of India",
|
||||
accountName: "Test Vendor",
|
||||
accountNo: "000000000000",
|
||||
branch: "000001",
|
||||
ifsc: "TEST0000001",
|
||||
isDefault: true,
|
||||
};
|
||||
|
||||
const items: InvoiceItem[] = [
|
||||
{ description: "Voice-over\nsession A", mode: "rate", rate: 1200, unit: "hour", quantity: 2.5, amount: 3000, sortOrder: 0, hsnSac: "998397" },
|
||||
{ description: "", mode: "fixed", rate: 0, unit: "unit", quantity: 1, amount: 2500.5, sortOrder: 1 },
|
||||
];
|
||||
|
||||
const base: Invoice = {
|
||||
id: 1,
|
||||
number: "INV-0001",
|
||||
seriesId: 1,
|
||||
invoiceDate: "2026-09-15",
|
||||
dueDate: "2026-10-05",
|
||||
clientId: null,
|
||||
clientName: "Acme Pvt Ltd",
|
||||
clientAddress: "1 Main Road\r\nMumbai",
|
||||
clientGstin: "",
|
||||
poNumber: "",
|
||||
placeOfSupplyStateCode: "",
|
||||
subtotal: 5500.5,
|
||||
discount: 0,
|
||||
taxType: "none",
|
||||
taxRate: 0,
|
||||
cgstAmount: 0,
|
||||
sgstAmount: 0,
|
||||
igstAmount: 0,
|
||||
total: 5500.5,
|
||||
amountInWords: "Five Thousand Five Hundred Rupees and Fifty Paise Only",
|
||||
bankAccountId: 1,
|
||||
bankSnapshot: "",
|
||||
signaturePath: null,
|
||||
notes: "Thanks\r\n\r\nPay in 30 days",
|
||||
status: "issued",
|
||||
createdAt: "2026-09-15T00:00:00Z",
|
||||
updatedAt: "2026-09-15T00:00:00Z",
|
||||
docType: "invoice",
|
||||
reverseCharge: false,
|
||||
vendorSnapshot: "",
|
||||
snapshotOrigin: "",
|
||||
cancelledAt: null,
|
||||
cancelReason: "",
|
||||
archivedPdfSha256: null,
|
||||
items,
|
||||
};
|
||||
|
||||
const make = (over: Partial<Invoice> = {}, v: Partial<Settings> = {}, rest: Partial<BuildInput> = {}) =>
|
||||
buildRenderModel({
|
||||
invoice: { ...base, ...over },
|
||||
vendor: { ...vendor, ...v },
|
||||
bank,
|
||||
logo: null,
|
||||
signatureDataUri: null,
|
||||
appVersion: "0.1.0",
|
||||
...rest,
|
||||
});
|
||||
|
||||
describe("unregistered invoice", () => {
|
||||
const m = make();
|
||||
it("is a plain Invoice without tax slots", () => {
|
||||
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.totals).toMatchObject({
|
||||
subtotalText: "₹5,500.50",
|
||||
discountText: null,
|
||||
taxableText: null,
|
||||
taxRows: [],
|
||||
totalText: "₹5,500.50",
|
||||
});
|
||||
expect(m.supplier.gstin).toBeNull();
|
||||
expect(m.supplier.showGstin).toBe(false);
|
||||
expect(m.client.posLabel).toBeNull();
|
||||
expect(m.cancelled).toBe(false);
|
||||
expect(m.reverseCharge).toBeNull();
|
||||
expect(m.logo).toBeNull();
|
||||
});
|
||||
it("formats and sanitises text", () => {
|
||||
expect(m.supplier.addressLines).toEqual(["13-B Type House,", "Test Road, Test Town", "Bangalore - 560005"]);
|
||||
expect(m.supplier.stateName).toBe("Karnataka");
|
||||
expect(m.client.name).toBe("Acme Pvt Ltd");
|
||||
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" });
|
||||
});
|
||||
it("builds items with rate, quantity and em-dash fallback", () => {
|
||||
expect(m.items[0]).toEqual({
|
||||
sno: 1,
|
||||
descriptionLines: ["Voice-over", "session A"],
|
||||
hsnSac: "998397",
|
||||
rateText: "₹1,200.00 per hour",
|
||||
qtyText: "2.5",
|
||||
amountText: "₹3,000.00",
|
||||
});
|
||||
expect(m.items[1]).toEqual({
|
||||
sno: 2,
|
||||
descriptionLines: ["—"],
|
||||
hsnSac: null,
|
||||
rateText: null,
|
||||
qtyText: "1",
|
||||
amountText: "₹2,500.50",
|
||||
});
|
||||
});
|
||||
it("labels the bank lines and defaults the signature to the vendor name", () => {
|
||||
expect(m.bank).toEqual({
|
||||
accountName: "Test Vendor",
|
||||
lines: [
|
||||
{ label: "Bank", value: "State Bank of India" },
|
||||
{ label: "Account No.", value: "000000000000" },
|
||||
{ label: "Branch", value: "000001" },
|
||||
{ label: "IFSC", value: "TEST0000001" },
|
||||
],
|
||||
});
|
||||
expect(m.signature).toEqual({ dataUri: null, name: "Test Vendor", designation: "" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("CGST + SGST tax invoice", () => {
|
||||
const m = make(
|
||||
{
|
||||
docType: "tax_invoice",
|
||||
taxType: "cgst_sgst",
|
||||
taxRate: 5,
|
||||
discount: 500.5,
|
||||
cgstAmount: 125,
|
||||
sgstAmount: 125,
|
||||
total: 5250,
|
||||
clientGstin: "29ABCDE1234F1Z5",
|
||||
placeOfSupplyStateCode: "29",
|
||||
poNumber: "PO-77",
|
||||
reverseCharge: true,
|
||||
},
|
||||
{ vendorGstin: "29AAAAA0000A1Z5", gstRegistration: "regular", signatoryName: "A. Person", signatoryDesignation: "Proprietor" },
|
||||
);
|
||||
it("derives labels, rates and slots", () => {
|
||||
expect(m.docTitle).toBe("Tax Invoice");
|
||||
expect(m.totals.taxRows).toEqual([
|
||||
{ label: "CGST", rateText: "2.5%", amountText: "₹125.00" },
|
||||
{ label: "SGST", rateText: "2.5%", amountText: "₹125.00" },
|
||||
]);
|
||||
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.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.signature).toMatchObject({ name: "A. Person", designation: "Proprietor" });
|
||||
});
|
||||
});
|
||||
|
||||
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", () => {
|
||||
expect(m.totals.taxRows).toEqual([{ label: "IGST", rateText: "18%", amountText: "₹990.09" }]);
|
||||
expect(m.client.posLabel).toBe("27 – Maharashtra");
|
||||
});
|
||||
});
|
||||
|
||||
describe("UTGST supplier states", () => {
|
||||
it.each(["04", "26", "31", "35", "38"])("uses UTGST for supplier state %s", (code) => {
|
||||
const m = make({ docType: "tax_invoice", taxType: "cgst_sgst", taxRate: 18, cgstAmount: 1, sgstAmount: 1 }, { vendorStateCode: code });
|
||||
expect(m.totals.taxRows.map((r) => r.label)).toEqual(["CGST", "UTGST"]);
|
||||
});
|
||||
it("keeps SGST for ordinary states and Delhi/Puducherry", () => {
|
||||
for (const code of ["29", "07", "34", "01"]) {
|
||||
const m = make({ docType: "tax_invoice", taxType: "cgst_sgst", taxRate: 18 }, { vendorStateCode: code });
|
||||
expect(m.totals.taxRows[1].label).toBe("SGST");
|
||||
}
|
||||
});
|
||||
it("resolves the supplier state name", () => {
|
||||
expect(make({}, { vendorStateCode: "04" }).supplier.stateName).toBe("Chandigarh");
|
||||
});
|
||||
});
|
||||
|
||||
describe("cancelled and logo", () => {
|
||||
it("flags cancelled invoices", () => {
|
||||
expect(make({ status: "cancelled" }).cancelled).toBe(true);
|
||||
expect(make({ status: "draft" }).cancelled).toBe(false);
|
||||
});
|
||||
it("passes the logo and signature through, and keeps null when absent", () => {
|
||||
const logo = { dataUri: "data:image/png;base64,AA==", width: 200, height: 40 };
|
||||
const m = make({}, {}, { logo, signatureDataUri: "data:image/png;base64,BB==" });
|
||||
expect(m.logo).toEqual(logo);
|
||||
expect(m.signature.dataUri).toBe("data:image/png;base64,BB==");
|
||||
expect(make().logo).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("snapshots", () => {
|
||||
it("lets the invoice's vendor snapshot override live settings", () => {
|
||||
const snap = JSON.stringify({ vendorName: "Old Name", vendorStateCode: "04", vendorGstin: "04AAAAA0000A1Z5", gstRegistration: "regular" });
|
||||
const m = make({ vendorSnapshot: snap, docType: "tax_invoice", taxType: "cgst_sgst", taxRate: 12 });
|
||||
expect(m.supplier.name).toBe("Old Name");
|
||||
expect(m.supplier.stateCode).toBe("04");
|
||||
expect(m.totals.taxRows[1].label).toBe("UTGST");
|
||||
expect(m.supplier.showGstin).toBe(true);
|
||||
});
|
||||
it("falls back to live settings for a corrupt snapshot", () => {
|
||||
expect(vendorFromSnapshot("{nope", vendor)).toBe(vendor);
|
||||
expect(vendorFromSnapshot(undefined, vendor)).toBe(vendor);
|
||||
expect(vendorFromSnapshot("", vendor)).toBe(vendor);
|
||||
});
|
||||
it("parses the bank snapshot only when no bank is given", () => {
|
||||
const snap = JSON.stringify({ ...bank, bankName: "HDFC" });
|
||||
expect(bankFromSnapshot(snap, null)?.bankName).toBe("HDFC");
|
||||
expect(bankFromSnapshot(snap, bank)).toBe(bank);
|
||||
expect(bankFromSnapshot("{bad", null)).toBeNull();
|
||||
expect(bankFromSnapshot("", null)).toBeNull();
|
||||
const m = buildRenderModel({ invoice: { ...base, bankSnapshot: snap }, vendor, bank: null, logo: null, signatureDataUri: null, appVersion: "x" });
|
||||
expect(m.bank?.lines[0]).toEqual({ label: "Bank", value: "HDFC" });
|
||||
});
|
||||
it("omits the bank block when there is no bank", () => {
|
||||
expect(make({}, {}, { bank: null }).bank).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user