Files
Voiced/src/pdf/model/build.test.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

296 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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();
});
});