2026-06-23 06:30:38 +00:00
|
|
|
|
import { describe, it, expect } from "vitest";
|
|
|
|
|
|
import {
|
|
|
|
|
|
formatZAR,
|
|
|
|
|
|
formatDateLong,
|
|
|
|
|
|
addDays,
|
|
|
|
|
|
quoteSubtotal,
|
|
|
|
|
|
vatAmount,
|
2026-08-06 09:54:23 +00:00
|
|
|
|
poSubtotal,
|
2026-06-23 06:30:38 +00:00
|
|
|
|
QUOTE_VALID_DAYS,
|
feat: add quote templates, improved email, new statuses
Add complete quote template functionality, enhanced outbound email sending, new quote statuses, and supporting infrastructure:
- Add new quote statuses (template, sending, mail_failed) with tailored UI styling for status pills
- Implement full template workflows: save real quotes as templates, create new quotes from existing templates
- Add support for custom quote dates and extended validity periods on generated PDF documents
- Build out improved email pipeline with persisted compose fields and retry functionality for failed sends
- Add attachment direction tracking to categorize inbound, outbound, and generated quote attachments
- Fix tooltip component to properly handle Radix `asChild` prop to avoid React DOM attribute errors
- Add comprehensive test coverage, schema migrations, and update sample test and production data
2026-06-24 20:57:01 +00:00
|
|
|
|
effectiveQuoteDate,
|
|
|
|
|
|
effectiveValidityDays,
|
2026-06-23 06:30:38 +00:00
|
|
|
|
} 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);
|
|
|
|
|
|
});
|
feat: add quote templates, improved email, new statuses
Add complete quote template functionality, enhanced outbound email sending, new quote statuses, and supporting infrastructure:
- Add new quote statuses (template, sending, mail_failed) with tailored UI styling for status pills
- Implement full template workflows: save real quotes as templates, create new quotes from existing templates
- Add support for custom quote dates and extended validity periods on generated PDF documents
- Build out improved email pipeline with persisted compose fields and retry functionality for failed sends
- Add attachment direction tracking to categorize inbound, outbound, and generated quote attachments
- Fix tooltip component to properly handle Radix `asChild` prop to avoid React DOM attribute errors
- Add comprehensive test coverage, schema migrations, and update sample test and production data
2026-06-24 20:57:01 +00:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
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("");
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-08-06 09:54:23 +00:00
|
|
|
|
|
|
|
|
|
|
|
feat: add quote templates, improved email, new statuses
Add complete quote template functionality, enhanced outbound email sending, new quote statuses, and supporting infrastructure:
- Add new quote statuses (template, sending, mail_failed) with tailored UI styling for status pills
- Implement full template workflows: save real quotes as templates, create new quotes from existing templates
- Add support for custom quote dates and extended validity periods on generated PDF documents
- Build out improved email pipeline with persisted compose fields and retry functionality for failed sends
- Add attachment direction tracking to categorize inbound, outbound, and generated quote attachments
- Fix tooltip component to properly handle Radix `asChild` prop to avoid React DOM attribute errors
- Add comprehensive test coverage, schema migrations, and update sample test and production data
2026-06-24 20:57:01 +00:00
|
|
|
|
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);
|
|
|
|
|
|
});
|
2026-08-06 09:54:23 +00:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|