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