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
296 lines
11 KiB
TypeScript
296 lines
11 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,
|
||
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: "",
|
||
};
|
||
|
||
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",
|
||
priceText: "₹1,200.00",
|
||
perText: "per hour",
|
||
rateShort: "₹1,200.00/hr",
|
||
qtyText: "2.5 hr",
|
||
amountText: "₹3,000.00",
|
||
});
|
||
expect(m.items[1]).toEqual({
|
||
sno: 2,
|
||
descriptionLines: ["—"],
|
||
hsnSac: null,
|
||
rateText: null,
|
||
priceText: null,
|
||
perText: null,
|
||
rateShort: null,
|
||
qtyText: "1",
|
||
amountText: "₹2,500.50",
|
||
});
|
||
});
|
||
it("words the unit and the quantity for every kind of unit", () => {
|
||
const line = (unit: string, quantity: number, rate = 18.5) =>
|
||
make({ items: [{ ...base.items[0], mode: "rate", unit, quantity, rate }] }).items[0];
|
||
expect(line("km", 12.75)).toMatchObject({ qtyText: "12.75 km", rateText: "₹18.50 per km", rateShort: "₹18.50/km", perText: "per km" });
|
||
expect(line("sqft", 1250.5)).toMatchObject({ qtyText: "1250.5 sq ft", rateShort: "₹18.50/sq ft" });
|
||
expect(line("hour", 4 + 1 / 3)).toMatchObject({ qtyText: "4:20 hr" });
|
||
expect(line("unit", 3)).toMatchObject({ qtyText: "3", rateText: "₹18.50 per unit", rateShort: "₹18.50/unit" });
|
||
expect(line(" bundle ", 2)).toMatchObject({ qtyText: "2 bundle", rateText: "₹18.50 per bundle", rateShort: "₹18.50/bundle" });
|
||
});
|
||
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,
|
||
aspect: 5,
|
||
density: 0.43,
|
||
kind: "wordmark" as const,
|
||
includesName: true,
|
||
hasKnockout: true,
|
||
knockoutDataUri: "data:image/png;base64,CC==",
|
||
};
|
||
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();
|
||
});
|
||
});
|