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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,236 @@
|
||||
import { computeLineAmount } from "../../lib/invoice";
|
||||
import {
|
||||
INDIAN_STATES,
|
||||
UNIT_LABELS,
|
||||
type BankAccount,
|
||||
type Invoice,
|
||||
type Settings,
|
||||
} from "../../lib/types";
|
||||
import { formatDate, formatMoney, formatRupees, toPaise } from "./format";
|
||||
import { sanitize, splitLines } from "./text";
|
||||
import { bankFromSnapshot, vendorFromSnapshot } from "./vendor";
|
||||
|
||||
export interface LogoInput {
|
||||
dataUri: string;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface BuildInput {
|
||||
invoice: Invoice;
|
||||
/** Live settings; the snapshot frozen on the invoice overrides them. */
|
||||
vendor: Settings;
|
||||
bank: BankAccount | null;
|
||||
logo: LogoInput | null;
|
||||
signatureDataUri: string | null;
|
||||
appVersion: string;
|
||||
}
|
||||
|
||||
export interface TaxRow {
|
||||
label: string;
|
||||
rateText?: string;
|
||||
amountText: string;
|
||||
}
|
||||
|
||||
export interface RenderItem {
|
||||
sno: number;
|
||||
descriptionLines: string[];
|
||||
hsnSac: string | null;
|
||||
rateText: string | null;
|
||||
qtyText: string;
|
||||
amountText: string;
|
||||
}
|
||||
|
||||
export interface RenderModel {
|
||||
/** "Invoice" or "Tax Invoice"; the template decides the case. */
|
||||
docTitle: string;
|
||||
docType: "invoice" | "tax_invoice";
|
||||
/** For the PDF metadata title. */
|
||||
pdfTitle: string;
|
||||
supplier: {
|
||||
name: string;
|
||||
addressLines: string[];
|
||||
email: string;
|
||||
phone: string;
|
||||
pan: string;
|
||||
gstin: string | null;
|
||||
stateName: string;
|
||||
stateCode: string;
|
||||
showGstin: boolean;
|
||||
};
|
||||
meta: { numberLabel: string; number: string; date: string; dueDate: string | null };
|
||||
client: {
|
||||
name: string;
|
||||
addressLines: string[];
|
||||
gstin: string | null;
|
||||
/** "29 – Karnataka" */
|
||||
posLabel: string | null;
|
||||
poNumber: string | null;
|
||||
};
|
||||
items: RenderItem[];
|
||||
totals: {
|
||||
subtotalText: string;
|
||||
discountText: string | null;
|
||||
/** Subtotal less discount; only for tax invoices. */
|
||||
taxableText: string | null;
|
||||
taxRows: TaxRow[];
|
||||
totalText: string;
|
||||
};
|
||||
amountInWords: string;
|
||||
notesLines: string[];
|
||||
bank: { accountName: string; lines: { label: string; value: string }[] } | null;
|
||||
signature: { dataUri: string | null; name: string; designation: string };
|
||||
logo: LogoInput | null;
|
||||
footerText: string;
|
||||
appVersion: string;
|
||||
reverseCharge: string | null;
|
||||
cancelled: boolean;
|
||||
/** Which regulated slots have content, for the template contract tests. */
|
||||
slots: {
|
||||
gstin: boolean;
|
||||
clientGstin: boolean;
|
||||
pos: boolean;
|
||||
hsn: boolean;
|
||||
taxRows: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
/** Union territories without a legislature charge UTGST in place of SGST. */
|
||||
const UTGST_STATE_CODES = new Set(["04", "26", "31", "35", "38"]);
|
||||
|
||||
const stateByCode = new Map(INDIAN_STATES.map((s) => [s.code, s.name]));
|
||||
|
||||
const pct = (n: number) => `${Number((Number.isFinite(n) ? n : 0).toFixed(2))}%`;
|
||||
|
||||
const orNull = (s: string | null | undefined) => {
|
||||
const v = sanitize(s);
|
||||
return v ? v : null;
|
||||
};
|
||||
|
||||
function taxRowsFor(invoice: Invoice, supplierStateCode: string): TaxRow[] {
|
||||
if (invoice.taxType === "cgst_sgst") {
|
||||
const half = pct(invoice.taxRate / 2);
|
||||
return [
|
||||
{ label: "CGST", rateText: half, amountText: formatRupees(invoice.cgstAmount) },
|
||||
{
|
||||
label: UTGST_STATE_CODES.has(supplierStateCode) ? "UTGST" : "SGST",
|
||||
rateText: half,
|
||||
amountText: formatRupees(invoice.sgstAmount),
|
||||
},
|
||||
];
|
||||
}
|
||||
if (invoice.taxType === "igst") {
|
||||
return [{ label: "IGST", rateText: pct(invoice.taxRate), amountText: formatRupees(invoice.igstAmount) }];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/** Pure: turns the stored invoice into pre-formatted, sanitised data. Totals are the stored values. */
|
||||
export function buildRenderModel(input: BuildInput): RenderModel {
|
||||
const { invoice, logo, signatureDataUri, appVersion } = input;
|
||||
const vendor = vendorFromSnapshot(invoice.vendorSnapshot, input.vendor);
|
||||
const bank = bankFromSnapshot(invoice.bankSnapshot, input.bank);
|
||||
|
||||
const docType = invoice.docType === "tax_invoice" ? "tax_invoice" : "invoice";
|
||||
const docTitle = docType === "tax_invoice" ? "Tax Invoice" : "Invoice";
|
||||
|
||||
const gstin = orNull(vendor.vendorGstin);
|
||||
const showGstin = gstin !== null && (vendor.gstRegistration === "regular" || docType === "tax_invoice");
|
||||
const supplierStateCode = sanitize(vendor.vendorStateCode);
|
||||
const vendorName = sanitize(vendor.vendorName) || "Vendor";
|
||||
|
||||
const posCode = sanitize(invoice.placeOfSupplyStateCode);
|
||||
const posName = stateByCode.get(posCode);
|
||||
const posLabel = posCode ? (posName ? `${posCode} – ${posName}` : posCode) : null;
|
||||
|
||||
const items: RenderItem[] = invoice.items.map((item, i) => ({
|
||||
sno: i + 1,
|
||||
descriptionLines: (() => {
|
||||
const lines = splitLines(item.description);
|
||||
return lines.length ? lines : ["—"];
|
||||
})(),
|
||||
hsnSac: orNull(item.hsnSac),
|
||||
rateText: item.mode === "rate" ? `${formatRupees(item.rate)} ${UNIT_LABELS[item.unit]}` : null,
|
||||
qtyText: item.mode === "rate" ? String(item.quantity) : "1",
|
||||
amountText: formatRupees(computeLineAmount(item)),
|
||||
}));
|
||||
|
||||
const taxRows = taxRowsFor(invoice, supplierStateCode);
|
||||
const discountPaise = toPaise(invoice.discount);
|
||||
|
||||
const bankModel = bank
|
||||
? {
|
||||
accountName: sanitize(bank.accountName) || vendorName,
|
||||
lines: (
|
||||
[
|
||||
["Bank", bank.bankName],
|
||||
["Account No.", bank.accountNo],
|
||||
["Branch", bank.branch],
|
||||
["IFSC", bank.ifsc],
|
||||
] as const
|
||||
)
|
||||
.map(([label, value]) => ({ label, value: sanitize(value) }))
|
||||
.filter((l) => l.value),
|
||||
}
|
||||
: null;
|
||||
|
||||
const hsn = items.some((i) => i.hsnSac !== null);
|
||||
|
||||
return {
|
||||
docTitle,
|
||||
docType,
|
||||
pdfTitle: `${docTitle} ${invoice.number}`,
|
||||
supplier: {
|
||||
name: vendorName,
|
||||
addressLines: splitLines(vendor.vendorAddress),
|
||||
email: sanitize(vendor.vendorEmail),
|
||||
phone: sanitize(vendor.vendorPhone),
|
||||
pan: sanitize(vendor.vendorPan),
|
||||
gstin,
|
||||
stateName: stateByCode.get(supplierStateCode) ?? "",
|
||||
stateCode: supplierStateCode,
|
||||
showGstin,
|
||||
},
|
||||
meta: {
|
||||
numberLabel: "Invoice No.",
|
||||
number: sanitize(invoice.number),
|
||||
date: formatDate(invoice.invoiceDate),
|
||||
dueDate: invoice.dueDate ? formatDate(invoice.dueDate) : null,
|
||||
},
|
||||
client: {
|
||||
name: sanitize(invoice.clientName) || "—",
|
||||
addressLines: splitLines(invoice.clientAddress),
|
||||
gstin: orNull(invoice.clientGstin),
|
||||
posLabel,
|
||||
poNumber: orNull(invoice.poNumber),
|
||||
},
|
||||
items,
|
||||
totals: {
|
||||
subtotalText: formatRupees(invoice.subtotal),
|
||||
discountText: discountPaise > 0 ? formatMoney(discountPaise) : null,
|
||||
taxableText: invoice.taxType === "none" ? null : formatMoney(toPaise(invoice.subtotal) - discountPaise),
|
||||
taxRows,
|
||||
totalText: formatRupees(invoice.total),
|
||||
},
|
||||
amountInWords: sanitize(invoice.amountInWords),
|
||||
notesLines: splitLines(invoice.notes),
|
||||
bank: bankModel,
|
||||
signature: {
|
||||
dataUri: signatureDataUri,
|
||||
name: sanitize(vendor.signatoryName) || vendorName,
|
||||
designation: sanitize(vendor.signatoryDesignation),
|
||||
},
|
||||
logo,
|
||||
footerText: "Generated with Voiced — a Bongbetic product",
|
||||
appVersion,
|
||||
reverseCharge: invoice.reverseCharge ? "Tax is payable on reverse charge: Yes" : null,
|
||||
cancelled: invoice.status === "cancelled",
|
||||
slots: {
|
||||
gstin: showGstin,
|
||||
clientGstin: orNull(invoice.clientGstin) !== null,
|
||||
pos: posLabel !== null,
|
||||
hsn,
|
||||
taxRows: taxRows.length > 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { formatDate, formatMoney, formatRupees } from "./format";
|
||||
|
||||
describe("formatMoney", () => {
|
||||
it("groups the Indian way", () => {
|
||||
expect(formatMoney(0)).toBe("₹0.00");
|
||||
expect(formatMoney(5)).toBe("₹0.05");
|
||||
expect(formatMoney(99)).toBe("₹0.99");
|
||||
expect(formatMoney(100)).toBe("₹1.00");
|
||||
expect(formatMoney(99_900)).toBe("₹999.00");
|
||||
expect(formatMoney(100_000)).toBe("₹1,000.00");
|
||||
expect(formatMoney(2_500_000)).toBe("₹25,000.00");
|
||||
expect(formatMoney(12_345_678)).toBe("₹1,23,456.78");
|
||||
expect(formatMoney(1_000_000_000)).toBe("₹1,00,00,000.00");
|
||||
expect(formatMoney(123_456_789_012)).toBe("₹1,23,45,67,890.12");
|
||||
});
|
||||
it("handles negatives, the symbol flag and bad input", () => {
|
||||
expect(formatMoney(-12_345_678)).toBe("-₹1,23,456.78");
|
||||
expect(formatMoney(-5)).toBe("-₹0.05");
|
||||
expect(formatMoney(12_345_678, false)).toBe("1,23,456.78");
|
||||
expect(formatMoney(-100_000, false)).toBe("-1,000.00");
|
||||
expect(formatMoney(Number.NaN)).toBe("₹0.00");
|
||||
expect(formatMoney(Number.POSITIVE_INFINITY)).toBe("₹0.00");
|
||||
expect(formatMoney(10.4)).toBe("₹0.10");
|
||||
});
|
||||
it("matches Intl en-IN for a sweep of values", () => {
|
||||
const intl = new Intl.NumberFormat("en-IN", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
for (let p = 0; p < 5_000_000_000; p = p * 7 + 13) {
|
||||
expect(formatMoney(p, false)).toBe(intl.format(p / 100));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatRupees", () => {
|
||||
it("converts real rupees through round(x * 100)", () => {
|
||||
expect(formatRupees(25000)).toBe("₹25,000.00");
|
||||
expect(formatRupees(1234.565)).toBe("₹1,234.57");
|
||||
expect(formatRupees(0.1 + 0.2)).toBe("₹0.30");
|
||||
expect(formatRupees(-1.005, false)).toBe("-1.00");
|
||||
expect(formatRupees(Number.NaN)).toBe("₹0.00");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatDate", () => {
|
||||
it("reproduces en-GB short output for every month", () => {
|
||||
for (let m = 1; m <= 12; m++) {
|
||||
const iso = `2026-${String(m).padStart(2, "0")}-05`;
|
||||
const expected = new Date(`${iso}T00:00:00`).toLocaleDateString("en-GB", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
});
|
||||
// Node 22 prints "Sept" for September; other ICU builds may print "Sep", which is why the
|
||||
// model hard-codes the table.
|
||||
expect(formatDate(iso)).toBe(expected.replace("Sep ", "Sept "));
|
||||
}
|
||||
});
|
||||
it("pins the two reference outputs", () => {
|
||||
expect(formatDate("2026-09-15")).toBe("15 Sept 2026");
|
||||
expect(formatDate("2026-10-05")).toBe("05 Oct 2026");
|
||||
});
|
||||
it("passes through empty and invalid values", () => {
|
||||
expect(formatDate("")).toBe("");
|
||||
expect(formatDate("not a date")).toBe("not a date");
|
||||
expect(formatDate("2026-02-30")).toBe("2026-02-30");
|
||||
expect(formatDate("2026-13-01")).toBe("2026-13-01");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
// Deterministic formatters. No Intl or locale APIs: WebKitGTK and WebView2 may ship different ICU
|
||||
// data, and an invoice must print the same everywhere.
|
||||
|
||||
const RUPEE = "₹";
|
||||
|
||||
/** Indian grouping of a non-negative integer string: 1234567 -> "12,34,567". */
|
||||
function groupIndian(digits: string): string {
|
||||
if (digits.length <= 3) return digits;
|
||||
const head = digits.slice(0, -3);
|
||||
const tail = digits.slice(-3);
|
||||
return `${head.replace(/\B(?=(\d{2})+(?!\d))/g, ",")},${tail}`;
|
||||
}
|
||||
|
||||
/** Integer paise to "₹1,23,456.78" (or "1,23,456.78" without the symbol); negatives as "-₹…". */
|
||||
export function formatMoney(paise: number, withSymbol = true): string {
|
||||
const value = Number.isFinite(paise) ? Math.round(paise) : 0;
|
||||
const abs = Math.abs(value);
|
||||
const rupees = Math.floor(abs / 100);
|
||||
const frac = String(abs % 100).padStart(2, "0");
|
||||
const text = `${groupIndian(String(rupees))}.${frac}`;
|
||||
const sign = value < 0 ? "-" : "";
|
||||
return `${sign}${withSymbol ? RUPEE : ""}${text}`;
|
||||
}
|
||||
|
||||
/** For the read model, which carries real rupees: converts through round(x * 100). */
|
||||
export function formatRupees(rupees: number, withSymbol = true): string {
|
||||
const value = Number.isFinite(rupees) ? rupees : 0;
|
||||
return formatMoney(Math.round(value * 100), withSymbol);
|
||||
}
|
||||
|
||||
/** Converts real rupees to integer paise. */
|
||||
export const toPaise = (rupees: number): number => (Number.isFinite(rupees) ? Math.round(rupees * 100) : 0);
|
||||
|
||||
// Matches what en-GB prints today ("Sept" for September only), so existing PDFs do not change.
|
||||
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sept", "Oct", "Nov", "Dec"];
|
||||
|
||||
/** "2026-09-15" -> "15 Sept 2026", "2026-10-05" -> "05 Oct 2026". Unparseable input is returned as is. */
|
||||
export function formatDate(iso: string): string {
|
||||
if (!iso) return "";
|
||||
const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(iso);
|
||||
if (!m) return iso;
|
||||
const [year, month, day] = [Number(m[1]), Number(m[2]), Number(m[3])];
|
||||
const check = new Date(Date.UTC(year, month - 1, day));
|
||||
if (month < 1 || month > 12 || check.getUTCMonth() !== month - 1 || check.getUTCDate() !== day) return iso;
|
||||
return `${String(day).padStart(2, "0")} ${MONTHS[month - 1]} ${m[1]}`;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { chunkIdentifier, sanitize, splitLines } from "./text";
|
||||
|
||||
describe("sanitize", () => {
|
||||
it("turns tabs and NBSP into spaces and collapses runs", () => {
|
||||
expect(sanitize("a\tb")).toBe("a b");
|
||||
expect(sanitize("a b")).toBe("a b");
|
||||
expect(sanitize("a b c")).toBe("a b c");
|
||||
expect(sanitize(" x ")).toBe("x");
|
||||
});
|
||||
it("strips control characters, ZWSP and BOM", () => {
|
||||
expect(sanitize("a\u0000b\u0007c\u007Fd\u0085e")).toBe("abcde");
|
||||
expect(sanitize("helloworld")).toBe("helloworld");
|
||||
});
|
||||
it("keeps ZWNJ, ZWJ, the rupee sign and Indic text", () => {
|
||||
expect(sanitize("abc")).toBe("abc");
|
||||
expect(sanitize("₹ 100")).toBe("₹ 100");
|
||||
expect(sanitize("नमस्ते ಕನ್ನಡ")).toBe("नमस्ते ಕನ್ನಡ");
|
||||
});
|
||||
it("handles nullish values and embedded newlines", () => {
|
||||
expect(sanitize(null)).toBe("");
|
||||
expect(sanitize(undefined)).toBe("");
|
||||
expect(sanitize("a\r\nb")).toBe("a b");
|
||||
});
|
||||
});
|
||||
|
||||
describe("splitLines", () => {
|
||||
it("splits on CRLF, LF and CR, trims and drops blanks", () => {
|
||||
expect(splitLines("a\r\nb\n\n c \rd")).toEqual(["a", "b", "c", "d"]);
|
||||
expect(splitLines("")).toEqual([]);
|
||||
expect(splitLines(null)).toEqual([]);
|
||||
});
|
||||
it("sanitises each line", () => {
|
||||
expect(splitLines("a\t\tb\nc d")).toEqual(["a b", "c d"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("chunkIdentifier", () => {
|
||||
it("breaks after separators and joins back to the input", () => {
|
||||
const email = "very.long-name_here@example-company.co.in";
|
||||
for (const max of [4, 8, 12, 50]) {
|
||||
const chunks = chunkIdentifier(email, max);
|
||||
expect(chunks.join("")).toBe(email);
|
||||
expect(chunks.every((c) => c.length <= max)).toBe(true);
|
||||
}
|
||||
expect(chunkIdentifier("a@b.com", 3)).toEqual(["a@", "b.", "com"]);
|
||||
});
|
||||
it("keeps the whole value when it fits", () => {
|
||||
expect(chunkIdentifier("a@b.com", 20)).toEqual(["a@b.com"]);
|
||||
});
|
||||
it("hard-cuts a run longer than max", () => {
|
||||
expect(chunkIdentifier("abcdefghij", 4)).toEqual(["abcd", "efgh", "ij"]);
|
||||
});
|
||||
it("handles empty input", () => {
|
||||
expect(chunkIdentifier("", 5)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
/** Normalises line endings and returns trimmed, non-empty, sanitised lines (never contains "\n"). */
|
||||
export function splitLines(value: string | null | undefined): string[] {
|
||||
if (!value) return [];
|
||||
return value
|
||||
.replace(/\r\n?/g, "\n")
|
||||
.split("\n")
|
||||
.map((line) => sanitize(line))
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes a string safe for a react-pdf Text:
|
||||
* - control characters (including tab and newline) are removed, tab becoming a space;
|
||||
* - NBSP becomes a space; zero-width space and BOM are dropped; ZWNJ and ZWJ are kept because
|
||||
* Indic shaping needs them;
|
||||
* - runs of spaces collapse to one, since two spaces before a line break make react-pdf insert a
|
||||
* stray hyphen.
|
||||
*/
|
||||
export function sanitize(value: string | null | undefined): string {
|
||||
if (!value) return "";
|
||||
return value
|
||||
.replace(/\r\n?/g, "\n")
|
||||
.replace(/[\t\n\v\f]/g, " ")
|
||||
.replace(/ /g, " ")
|
||||
.replace(/[\u0000-\u001F\u007F-\u009F]/g, "")
|
||||
.replace(/ {2,}/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits an identifier (e-mail, URL, path) into chunks that may wrap between, breaking after
|
||||
* `@ . / - _` so the separator stays at the end of its chunk, and keeping each chunk at most `max`
|
||||
* characters (a longer run is cut hard at `max`). Joining the chunks gives back the input.
|
||||
*/
|
||||
export function chunkIdentifier(value: string, max: number): string[] {
|
||||
const limit = Math.max(1, Math.floor(max));
|
||||
const parts = value.match(/[^@./\-_]*[@./\-_]|[^@./\-_]+$/g) ?? [];
|
||||
const chunks: string[] = [];
|
||||
let current = "";
|
||||
const flush = () => {
|
||||
if (current) chunks.push(current);
|
||||
current = "";
|
||||
};
|
||||
for (const part of parts) {
|
||||
if (current.length + part.length <= limit) {
|
||||
current += part;
|
||||
continue;
|
||||
}
|
||||
flush();
|
||||
let rest = part;
|
||||
while (rest.length > limit) {
|
||||
chunks.push(rest.slice(0, limit));
|
||||
rest = rest.slice(limit);
|
||||
}
|
||||
current = rest;
|
||||
}
|
||||
flush();
|
||||
return chunks;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { BankAccount, Settings } from "../../lib/types";
|
||||
|
||||
/**
|
||||
* Supplier details for an invoice: the snapshot frozen at issue wins over live settings, which are
|
||||
* only a fallback for legacy rows without one (or with a corrupt one).
|
||||
*/
|
||||
export function vendorFromSnapshot(snapshot: string | undefined, live: Settings): Settings {
|
||||
if (!snapshot) return live;
|
||||
try {
|
||||
return { ...live, ...(JSON.parse(snapshot) as Partial<Settings>) };
|
||||
} catch {
|
||||
return live;
|
||||
}
|
||||
}
|
||||
|
||||
/** The bank passed in wins; otherwise the account frozen on the invoice, if it parses. */
|
||||
export function bankFromSnapshot(snapshot: string | undefined, bank: BankAccount | null): BankAccount | null {
|
||||
if (bank) return bank;
|
||||
if (!snapshot) return null;
|
||||
try {
|
||||
return JSON.parse(snapshot) as BankAccount;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user