Rendering moves into a module Web Worker behind a latest-wins RenderClient with a watchdog and a handshake-based main-thread fallback. The preview shows the real PDF through pdf.js at 2x with zoom, page indicator, audit issues popover and a focus modal. A4/Letter and margin controls are frozen into each invoice at issue and can be saved as the default (migration M3). react-pdf and pdf.js are lazy-loaded; the main chunk shrinks from 1.8 MB to 0.5 MB. The HTML twin and its styles are removed.
267 lines
9.9 KiB
TypeScript
267 lines
9.9 KiB
TypeScript
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: "",
|
||
renderPrefs: "",
|
||
};
|
||
|
||
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,
|
||
renderPrefs: "",
|
||
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, reverseCharge: 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", isoDate: "2026-09-15", 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, 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).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", () => {
|
||
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();
|
||
});
|
||
});
|