WFDASH-WEBAPP/src/lib/pdf/format.test.ts
Dave 397df8e2b2 fix: correct quote expiry handling for PocketBase's 0 default
PocketBase returns 0 for unset number fields, which broke the original
null-coalescing check in effectiveValidityDays, leading to PDFs showing
"Valid until: today" for all new quotes created via email ingest.

Changes include:
- Update effectiveValidityDays to treat 0, negatives, null/undefined as falling back to 30-day default
- Add test cases for 0 and negative validity_days inputs
- Explicitly set validity_days to QUOTE_VALID_DAYS when creating new quotes
- Add one-time idempotent migration script to backfill legacy invalid validity values
- Refactor item intel popover patch logic to use PocketBase SDK for authenticated requests
- Add n8n workflow for AI-powered item insight matching
2026-08-11 08:32:17 +02:00

165 lines
5.4 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, 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);
});
});