import { describe, it, expect } from "vitest"; import { formatZAR, formatDateLong, addDays, quoteSubtotal, vatAmount, poSubtotal, QUOTE_VALID_DAYS, effectiveQuoteDate, effectiveValidityDays, } from "./format"; describe("formatZAR", () => { it("formats whole numbers with 2 decimals", () => { expect(formatZAR(1234)).toBe("R 1 234.00"); }); it("groups thousands with spaces (en-ZA style)", () => { expect(formatZAR(1234567.89)).toBe("R 1 234 567.89"); }); it("handles zero", () => { expect(formatZAR(0)).toBe("R 0.00"); }); it("handles small decimals", () => { expect(formatZAR(99.5)).toBe("R 99.50"); }); it("returns R 0.00 for non-finite numbers", () => { expect(formatZAR(NaN)).toBe("R 0.00"); expect(formatZAR(Infinity)).toBe("R 0.00"); }); it("matches the existing sample PDF format", () => { // From Quote-CNL-Q009638: Subtotal R 737.06, VAT R 110.56, Total R 847.62 expect(formatZAR(737.06)).toBe("R 737.06"); expect(formatZAR(110.56)).toBe("R 110.56"); expect(formatZAR(847.62)).toBe("R 847.62"); }); }); describe("formatDateLong", () => { it("formats ISO date to '22 June 2026' style", () => { expect(formatDateLong("2026-06-22T00:00:00Z")).toBe("22 June 2026"); }); it("returns empty string for empty input", () => { expect(formatDateLong("")).toBe(""); }); it("returns the original string for unparseable input", () => { expect(formatDateLong("not-a-date")).toBe("not-a-date"); }); }); describe("addDays", () => { it("adds exactly N days, preserving the calendar date", () => { expect(addDays("2026-06-22T00:00:00Z", 30)).toBe( "2026-07-22T00:00:00.000Z" ); }); it("handles month rollover", () => { expect(addDays("2026-01-31T00:00:00Z", 1)).toBe( "2026-02-01T00:00:00.000Z" ); }); it("QUOTE_VALID_DAYS is 30 (matches sample PDF)", () => { expect(QUOTE_VALID_DAYS).toBe(30); }); }); describe("quoteSubtotal", () => { it("sums qty * selling_price", () => { expect(quoteSubtotal([{ quantity: 1, selling_price: 648.7 }])).toBeCloseTo(648.7); expect( quoteSubtotal([ { quantity: 1, selling_price: 648.7 }, { quantity: 1, selling_price: 88.36 }, ]) ).toBeCloseTo(737.06); }); it("returns 0 for empty array", () => { expect(quoteSubtotal([])).toBe(0); }); }); describe("vatAmount", () => { it("computes 15% VAT, rounded to 2dp", () => { expect(vatAmount(737.06)).toBe(110.56); }); it("returns 0 for 0 subtotal", () => { expect(vatAmount(0)).toBe(0); }); }); describe("effectiveQuoteDate", () => { it("prefers quote_date when set", () => { expect( effectiveQuoteDate({ quote_date: "2026-06-01T00:00:00.000Z", created: "2026-06-22T13:40:00.000Z", }) ).toBe("2026-06-01T00:00:00.000Z"); }); it("falls back to created when quote_date is empty", () => { expect( effectiveQuoteDate({ quote_date: "", created: "2026-06-22T13:40:00.000Z", }) ).toBe("2026-06-22T13:40:00.000Z"); }); it("returns empty string when both are empty", () => { expect(effectiveQuoteDate({ quote_date: "", created: "" })).toBe(""); }); }); describe("effectiveValidityDays", () => { it("uses quote.validity_days when set to a number", () => { expect(effectiveValidityDays({ validity_days: 60 })).toBe(60); }); it("falls back to QUOTE_VALID_DAYS (30) when null", () => { expect(effectiveValidityDays({ validity_days: null })).toBe(QUOTE_VALID_DAYS); }); it("falls back to QUOTE_VALID_DAYS (30) when undefined", () => { expect(effectiveValidityDays({})).toBe(QUOTE_VALID_DAYS); }); // PB defaults an unset number field to 0 (not null). Pre-fix every // quote shipped with validity_days=0, which the original `??` // passed through and produced a "Valid until: today" PDF. it("falls back to QUOTE_VALID_DAYS (30) when 0 (PocketBase default)", () => { expect(effectiveValidityDays({ validity_days: 0 })).toBe(QUOTE_VALID_DAYS); }); it("falls back to QUOTE_VALID_DAYS (30) when negative", () => { // Defensive — no real path writes this, but a misconfigured // input shouldn't ever produce a pre-date expiry. expect(effectiveValidityDays({ validity_days: -7 })).toBe(QUOTE_VALID_DAYS); }); }); describe("poSubtotal", () => { it("sums order_qty × cost_price when order_qty is set", () => { expect(poSubtotal([{ order_qty: 3, quantity: 1, cost_price: 100 }])).toBe(300); }); it("falls back to quantity when order_qty is missing", () => { expect(poSubtotal([{ quantity: 4, cost_price: 50 }])).toBe(200); }); it("falls back to quantity when order_qty is 0 (blank treated as missing)", () => { expect(poSubtotal([{ order_qty: 0, quantity: 2, cost_price: 75 }])).toBe(150); }); it("sums across multiple items", () => { expect(poSubtotal([ { order_qty: 3, quantity: 1, cost_price: 100 }, { order_qty: 5, quantity: 1, cost_price: 40 }, ])).toBe(500); }); it("rounds to 2 decimal places (avoids floating-point dust)", () => { expect(poSubtotal([ { quantity: 3, cost_price: 33.33 }, { quantity: 3, cost_price: 33.33 }, { quantity: 3, cost_price: 33.34 }, ])).toBe(300); }); it("returns 0 for an empty array", () => { expect(poSubtotal([])).toBe(0); }); it("treats non-numeric cost_price as 0", () => { expect(poSubtotal([{ quantity: 5, cost_price: NaN }])).toBe(0); }); });