feat(vat)!: remove VAT entirely from offers, orders and issued orders

Accounting rule: nabidky, objednavky (incl. confirmation PDF) and objednavky
vydane are NOT tax documents - VAT belongs only on invoices. Per user
decision this is a FULL removal including DB columns.

- migration: DROP orders.vat_rate/apply_vat, issued_orders.vat_rate/apply_vat,
  issued_order_items.vat_rate, quotations.vat_rate/apply_vat (applied to
  dev + test DBs)
- services: net-only totals everywhere (computeIssuedOrderTotals(items) ->
  {total}; enrichOrder/enrichQuotation net; per-currency list totals net)
- PDFs (offers, order confirmation, issued order): no VAT columns/summary,
  single 'Celkem bez DPH' / 'Total excl. VAT' total, note under totals:
  'Ceny jsou uvedeny bez DPH. DPH bude uctovano dle platnych predpisu.';
  duplicate Cena/Celkem column merged (desc width 56%); orders-pdf render
  extracted as exported renderOrderConfirmationHtml for testability
- frontend: Uplatnit DPH checkboxes, VAT selects and per-item VAT columns
  removed from OfferDetail/OrderDetail/IssuedOrderDetail/
  OrderConfirmationModal/ReceivedOrders manual-create; list footers read
  'Celkem bez DPH'; invoice-from-order prefill now takes the company default
  VAT (invoice decides its own VAT)
- tests: suites reworked to net math; new pdf-vat-note.test.ts pins the
  exact cs+en note text and VAT-free layout on all three PDFs

Invoices and received invoices keep their VAT handling unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-06-10 13:13:16 +02:00
parent 396a1d37ec
commit 7398cad466
29 changed files with 558 additions and 1013 deletions

View File

@@ -56,16 +56,21 @@ describe("issued-order numbering", () => {
});
describe("CreateIssuedOrderSchema", () => {
it("coerces string form numbers and rejects an out-of-range VAT", () => {
it("coerces string form numbers and rejects a NaN quantity", () => {
const ok = CreateIssuedOrderSchema.safeParse({
supplier_id: "5",
vat_rate: "21",
items: [{ description: "X", quantity: "2", unit_price: "100" }],
});
expect(ok.success).toBe(true);
if (ok.success) expect(ok.data.supplier_id).toBe(5);
if (ok.success) {
expect(ok.data.supplier_id).toBe(5);
expect(ok.data.items![0].quantity).toBe(2);
expect(ok.data.items![0].unit_price).toBe(100);
}
const bad = CreateIssuedOrderSchema.safeParse({ vat_rate: "200" });
const bad = CreateIssuedOrderSchema.safeParse({
items: [{ description: "X", quantity: "not-a-number" }],
});
expect(bad.success).toBe(false);
});
});
@@ -105,37 +110,22 @@ async function mkIssued(input: IssuedOrderInput = {}) {
return res;
}
describe("computeIssuedOrderTotals (NET + VAT-on-top)", () => {
it("adds VAT on top of net, rounded per line", () => {
const t = computeIssuedOrderTotals(
[
{ quantity: 2, unit_price: 100, vat_rate: 21 },
{ quantity: 1, unit_price: 50, vat_rate: 12 },
],
true,
21,
);
expect(t.subtotal).toBe(250);
expect(t.vat_amount).toBe(48);
expect(t.total).toBe(298);
describe("computeIssuedOrderTotals (NET only — no VAT on issued orders)", () => {
it("sums qty × unit_price per line, rounded to 2dp", () => {
const t = computeIssuedOrderTotals([
{ quantity: 2, unit_price: 100 },
{ quantity: 1, unit_price: 50 },
]);
expect(t).toEqual({ total: 250 });
});
it("zeroes VAT when apply_vat is false but keeps the net subtotal", () => {
const t = computeIssuedOrderTotals(
[{ quantity: 3, unit_price: 100, vat_rate: 21 }],
false,
21,
);
expect(t).toEqual({ subtotal: 300, vat_amount: 0, total: 300 });
});
it("falls back to the document rate when a line rate is null", () => {
const t = computeIssuedOrderTotals(
[{ quantity: 1, unit_price: 100, vat_rate: null }],
true,
15,
);
expect(t.vat_amount).toBe(15);
it("treats null/garbage numerics as zero", () => {
const t = computeIssuedOrderTotals([
{ quantity: null, unit_price: 100 },
{ quantity: 3, unit_price: "abc" },
{ quantity: 2, unit_price: "10.555" },
]);
expect(t).toEqual({ total: 21.11 });
});
});
@@ -144,9 +134,7 @@ describe("createIssuedOrder", () => {
const s = await makeSupplier();
const order = await mkIssued({
supplier_id: s.id,
items: [
{ description: "Materiál", quantity: 2, unit_price: 100, vat_rate: 21 },
],
items: [{ description: "Materiál", quantity: 2, unit_price: 100 }],
});
// Deferred numbering: a draft carries no number.
expect(order.po_number).toBeNull();
@@ -437,8 +425,6 @@ describe("renderIssuedOrderHtml", () => {
order_date: new Date("2026-06-09T12:00:00"),
delivery_date: null,
currency: "CZK",
apply_vat: true,
vat_rate: 21,
notes: "<p>Pozn</p><script>alert(1)</script>",
delivery_terms: null,
payment_terms: null,
@@ -451,7 +437,6 @@ describe("renderIssuedOrderHtml", () => {
quantity: 2,
unit: "ks",
unit_price: 100,
vat_rate: 21,
},
];
@@ -547,33 +532,29 @@ describe("renderIssuedOrderHtml", () => {
expect(fallback).toContain("Objednáváme si u Vás:");
});
it("with VAT: keeps the VAT columns and the DPH cell holds ONLY the line VAT", () => {
it("never renders VAT columns, totals NET with 'Celkem bez DPH' and the prices notice", () => {
const html = renderIssuedOrderHtml(order, items, null, null, "cs", issuer);
expect(html).toContain("%DPH");
expect(html).toContain(">DPH<");
// 2 × 100 @ 21 %: DPH cell = 42,00 (VAT only), Celkem cell = 242,00.
expect(html).toContain('<td class="right">42,00</td>');
expect(html).toContain('<td class="right total-cell">242,00</td>');
expect(html).not.toContain("Celkem bez DPH");
});
it("without VAT: hides the VAT columns and labels the total 'Celkem bez DPH'", () => {
const html = renderIssuedOrderHtml(
{ ...order, apply_vat: false },
items,
null,
null,
"cs",
issuer,
);
// No VAT anywhere — the PO is not a tax document.
expect(html).not.toContain("%DPH");
expect(html).not.toContain(">DPH<");
// No per-line VAT cell, line total = netto.
expect(html).not.toContain('<td class="right">42,00</td>');
// Line total = netto (2 × 100).
expect(html).toContain('<td class="right total-cell">200,00</td>');
expect(html).toContain("Celkem bez DPH");
// The subtotal detail row is dropped (it would duplicate the grand total).
// No Mezisoučet row (it would duplicate the grand total).
expect(html).not.toContain("Mezisoučet");
// The fixed prices-excl.-VAT notice.
expect(html).toContain(
"Ceny jsou uvedeny bez DPH. DPH bude účtováno dle platných předpisů.",
);
});
it("renders the English notice and total label for lang=en", () => {
const html = renderIssuedOrderHtml(order, items, null, null, "en", issuer);
expect(html).toContain("Total excl. VAT");
expect(html).toContain(
"Prices are exclusive of VAT. VAT will be charged according to applicable regulations.",
);
expect(html).not.toContain("VAT%");
});
it("footer shows the logged-in user's name, no e-mail, no Schválil column", () => {

View File

@@ -47,8 +47,6 @@ const baseOrder = {
status: "prijata" as const,
currency: "CZK",
language: "cs",
vat_rate: 21,
apply_vat: true,
exchange_rate: 1,
};
@@ -117,8 +115,6 @@ describe("createOrderFromQuotation (auto-project gets its OWN number)", () => {
status: "active",
currency: "CZK",
language: "cs",
vat_rate: 21,
apply_vat: true,
},
});
createdQuotationIds.push(quotation.id);

View File

@@ -54,18 +54,16 @@ function amountFor(
}
describe("getOfferTotals per-currency aggregation", () => {
it("sums offer TOTAL incl. VAT per currency over the full filtered set", async () => {
// Two CZK offers + one EUR. 21% VAT applied on the whole subtotal
// (enrichQuotation math).
// CZK #1: 2 x 1000 = 2000 net -> +21% = 2420
// CZK #2: 1 x 500 = 500 net -> +21% = 605 => CZK total 3025
// EUR : 3 x 100 = 300 net -> +21% = 363 => EUR total 363
it("sums offer NET total per currency over the full filtered set", async () => {
// Two CZK offers + one EUR. NET only (enrichQuotation math — offers are
// not tax documents, no VAT anywhere).
// CZK #1: 2 x 1000 = 2000
// CZK #2: 1 x 500 = 500 => CZK total 2500
// EUR : 3 x 100 = 300 => EUR total 300
const mk = async (currency: string, qty: number, price: number) => {
const res = await createOffer({
status: "draft", // draft so no offer number is consumed
currency,
vat_rate: 21,
apply_vat: true,
project_code: OFFER_MARKER,
items: [{ description: "X", quantity: qty, unit_price: price }],
});
@@ -79,8 +77,8 @@ describe("getOfferTotals per-currency aggregation", () => {
await mk("EUR", 3, 100);
const { totals } = await getOfferTotals({ search: OFFER_MARKER });
expect(amountFor(totals, "CZK")).toBe(3025);
expect(amountFor(totals, "EUR")).toBe(363);
expect(amountFor(totals, "CZK")).toBe(2500);
expect(amountFor(totals, "EUR")).toBe(300);
});
it("respects the where: a status filter narrows the result", async () => {
@@ -88,8 +86,6 @@ describe("getOfferTotals per-currency aggregation", () => {
const res = await createOffer({
status,
currency: "CZK",
vat_rate: 21,
apply_vat: true,
project_code: OFFER_MARKER,
items: [{ description: "X", quantity: 1, unit_price: 1000 }],
});
@@ -103,23 +99,23 @@ describe("getOfferTotals per-currency aggregation", () => {
await mk("draft");
await mk("invalidated");
// Marker only: all three count -> 3 x 1210 = 3630.
// Marker only: all three count -> 3 x 1000 = 3000 (net).
const all = await getOfferTotals({ search: OFFER_MARKER });
expect(amountFor(all.totals, "CZK")).toBe(3630);
expect(amountFor(all.totals, "CZK")).toBe(3000);
// Status filter narrows to the two drafts -> 2 x 1210 = 2420.
// Status filter narrows to the two drafts -> 2 x 1000 = 2000.
const onlyDraft = await getOfferTotals({
search: OFFER_MARKER,
status: "draft",
});
expect(amountFor(onlyDraft.totals, "CZK")).toBe(2420);
expect(amountFor(onlyDraft.totals, "CZK")).toBe(2000);
// Invalidated alone -> 1210.
// Invalidated alone -> 1000.
const onlyInvalid = await getOfferTotals({
search: OFFER_MARKER,
status: "invalidated",
});
expect(amountFor(onlyInvalid.totals, "CZK")).toBe(1210);
expect(amountFor(onlyInvalid.totals, "CZK")).toBe(1000);
});
});

View File

@@ -8,8 +8,9 @@ import {
// Per-currency total aggregation for both order lists. These hit the real
// `app_test` DB via the service layer (suite convention) and prove the /stats
// endpoints sum order TOTAL (incl. VAT) per currency across the WHOLE filtered
// set, and that the where (month/year + status) is respected.
// endpoints sum order NET total per currency across the WHOLE filtered set
// (orders are not tax documents — no VAT anywhere), and that the where
// (month/year + status) is respected.
//
// A far-future month/year is used so seeded/other-test rows can't fall in the
// window and skew the deterministic sums (mirrors drafts-aggregation.test.ts).
@@ -46,19 +47,17 @@ function amountFor(
}
describe("getOrderTotals (received orders) per-currency aggregation", () => {
it("sums TOTAL incl. VAT per currency over the full filtered set", async () => {
// Two CZK orders + one EUR, all in the same far-future month. 21% VAT,
// applied on the whole subtotal (enrichOrder math).
// CZK #1: 2 x 1000 = 2000 net -> +21% = 2420
// CZK #2: 1 x 500 = 500 net -> +21% = 605 => CZK total 3025
// EUR : 3 x 100 = 300 net -> +21% = 363 => EUR total 363
it("sums NET total per currency over the full filtered set", async () => {
// Two CZK orders + one EUR, all in the same far-future month. NET only
// (enrichOrder math — orders carry no VAT).
// CZK #1: 2 x 1000 = 2000
// CZK #2: 1 x 500 = 500 => CZK total 2500
// EUR : 3 x 100 = 300 => EUR total 300
const mk = async (currency: string, qty: number, price: number) => {
const res = await createOrder({
status: "prijata",
currency,
language: "cs",
vat_rate: 21,
apply_vat: true,
create_project: false,
items: [{ description: "X", quantity: qty, unit_price: price }],
});
@@ -83,8 +82,8 @@ describe("getOrderTotals (received orders) per-currency aggregation", () => {
month: STATS_MONTH,
year: STATS_YEAR,
});
expect(amountFor(totals, "CZK")).toBe(3025);
expect(amountFor(totals, "EUR")).toBe(363);
expect(amountFor(totals, "CZK")).toBe(2500);
expect(amountFor(totals, "EUR")).toBe(300);
});
it("respects the where: a different month and a status filter change the result", async () => {
@@ -93,8 +92,6 @@ describe("getOrderTotals (received orders) per-currency aggregation", () => {
status,
currency: "CZK",
language: "cs",
vat_rate: 21,
apply_vat: true,
create_project: false,
items: [{ description: "X", quantity: 1, unit_price: 1000 }],
});
@@ -122,48 +119,43 @@ describe("getOrderTotals (received orders) per-currency aggregation", () => {
await mk("stornovana", 0);
await mk("prijata", 1);
// Whole month: both same-month orders count -> 2 x 1210 = 2420.
// Whole month: both same-month orders count -> 2 x 1000 = 2000 (net).
const whole = await getOrderTotals({
month: STATS_MONTH,
year: STATS_YEAR,
});
expect(amountFor(whole.totals, "CZK")).toBe(2420);
expect(amountFor(whole.totals, "CZK")).toBe(2000);
// Status filter narrows to the single 'prijata' in the target month -> 1210.
// Status filter narrows to the single 'prijata' in the target month -> 1000.
const onlyPrijata = await getOrderTotals({
month: STATS_MONTH,
year: STATS_YEAR,
status: "prijata",
});
expect(amountFor(onlyPrijata.totals, "CZK")).toBe(1210);
expect(amountFor(onlyPrijata.totals, "CZK")).toBe(1000);
// Next month sees only the one 'prijata' there -> 1210.
// Next month sees only the one 'prijata' there -> 1000.
const nextMonth = await getOrderTotals({
month: STATS_MONTH + 1,
year: STATS_YEAR,
});
expect(amountFor(nextMonth.totals, "CZK")).toBe(1210);
expect(amountFor(nextMonth.totals, "CZK")).toBe(1000);
});
});
describe("getIssuedOrderTotals (issued orders) per-currency aggregation", () => {
it("sums TOTAL incl. VAT per currency over the full filtered set", async () => {
// order_date is settable at create, so no post-create pin needed. VAT is
// applied per-line (computeIssuedOrderTotals) but with a single line per
// order here the result matches the on-the-whole subtotal math.
// CZK #1: 2 x 1000 @21% = 2420
// CZK #2: 1 x 500 @21% = 605 => CZK total 3025
// EUR : 3 x 100 @21% = 363 => EUR total 363
it("sums NET total per currency over the full filtered set", async () => {
// order_date is settable at create, so no post-create pin needed. NET only
// (computeIssuedOrderTotals — issued orders carry no VAT).
// CZK #1: 2 x 1000 = 2000
// CZK #2: 1 x 500 = 500 => CZK total 2500
// EUR : 3 x 100 = 300 => EUR total 300
const dateStr = `${STATS_YEAR}-0${STATS_MONTH}-15`;
const mk = async (currency: string, qty: number, price: number) => {
const o = await createIssuedOrder({
currency,
vat_rate: 21,
apply_vat: true,
order_date: dateStr,
items: [
{ description: "X", quantity: qty, unit_price: price, vat_rate: 21 },
],
items: [{ description: "X", quantity: qty, unit_price: price }],
});
if ("error" in o) throw new Error(`createIssuedOrder failed: ${o.error}`);
createdIssuedIds.push(o.id);
@@ -178,23 +170,19 @@ describe("getIssuedOrderTotals (issued orders) per-currency aggregation", () =>
month: STATS_MONTH,
year: STATS_YEAR,
});
expect(amountFor(totals, "CZK")).toBe(3025);
expect(amountFor(totals, "EUR")).toBe(363);
expect(amountFor(totals, "CZK")).toBe(2500);
expect(amountFor(totals, "EUR")).toBe(300);
});
it("respects the where: a different month and a status filter change the result", async () => {
const inMonth = `${STATS_YEAR}-0${STATS_MONTH}-15`;
const nextMonth = `${STATS_YEAR}-0${STATS_MONTH + 1}-15`;
// Target month: one draft, one already-sent (both 1210). Next month: one.
// Target month: one draft, one already-sent (both 1000 net). Next month: one.
const draft = await createIssuedOrder({
currency: "CZK",
vat_rate: 21,
apply_vat: true,
order_date: inMonth,
items: [
{ description: "X", quantity: 1, unit_price: 1000, vat_rate: 21 },
],
items: [{ description: "X", quantity: 1, unit_price: 1000 }],
});
if ("error" in draft)
throw new Error(`createIssuedOrder failed: ${draft.error}`);
@@ -203,12 +191,8 @@ describe("getIssuedOrderTotals (issued orders) per-currency aggregation", () =>
const sent = await createIssuedOrder({
status: "sent",
currency: "CZK",
vat_rate: 21,
apply_vat: true,
order_date: inMonth,
items: [
{ description: "X", quantity: 1, unit_price: 1000, vat_rate: 21 },
],
items: [{ description: "X", quantity: 1, unit_price: 1000 }],
});
if ("error" in sent)
throw new Error(`createIssuedOrder failed: ${sent.error}`);
@@ -216,37 +200,33 @@ describe("getIssuedOrderTotals (issued orders) per-currency aggregation", () =>
const next = await createIssuedOrder({
currency: "CZK",
vat_rate: 21,
apply_vat: true,
order_date: nextMonth,
items: [
{ description: "X", quantity: 1, unit_price: 1000, vat_rate: 21 },
],
items: [{ description: "X", quantity: 1, unit_price: 1000 }],
});
if ("error" in next)
throw new Error(`createIssuedOrder failed: ${next.error}`);
createdIssuedIds.push(next.id);
// Whole target month: both count -> 2 x 1210 = 2420.
// Whole target month: both count -> 2 x 1000 = 2000 (net).
const whole = await getIssuedOrderTotals({
month: STATS_MONTH,
year: STATS_YEAR,
});
expect(amountFor(whole.totals, "CZK")).toBe(2420);
expect(amountFor(whole.totals, "CZK")).toBe(2000);
// Status filter narrows to the single 'sent' in the target month -> 1210.
// Status filter narrows to the single 'sent' in the target month -> 1000.
const onlySent = await getIssuedOrderTotals({
month: STATS_MONTH,
year: STATS_YEAR,
status: "sent",
});
expect(amountFor(onlySent.totals, "CZK")).toBe(1210);
expect(amountFor(onlySent.totals, "CZK")).toBe(1000);
// Next month sees only the one order there -> 1210.
// Next month sees only the one order there -> 1000.
const nextStats = await getIssuedOrderTotals({
month: STATS_MONTH + 1,
year: STATS_YEAR,
});
expect(amountFor(nextStats.totals, "CZK")).toBe(1210);
expect(amountFor(nextStats.totals, "CZK")).toBe(1000);
});
});

View File

@@ -30,8 +30,6 @@ const baseOrder = {
status: "prijata" as const,
currency: "CZK",
language: "cs",
vat_rate: 21,
apply_vat: true,
exchange_rate: 1,
};

View File

@@ -0,0 +1,147 @@
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import Fastify from "fastify";
import jwt from "jsonwebtoken";
import prisma from "../config/database";
import { config } from "../config/env";
import { renderOrderConfirmationHtml } from "../routes/admin/orders-pdf";
import offersPdfRoutes from "../routes/admin/offers-pdf";
// Offers, received orders (their confirmation PDF) and issued orders are NOT
// tax documents — VAT must never appear on them. These tests pin that contract
// for the confirmation and offer PDFs: no VAT columns/rows, the grand total is
// "Celkem bez DPH" / "Total excl. VAT", and the fixed prices-excl.-VAT notice
// is printed. (The issued-order PDF is covered in issued-orders.test.ts.)
const CS_NOTE =
"Ceny jsou uvedeny bez DPH. DPH bude účtováno dle platných předpisů.";
const EN_NOTE =
"Prices are exclusive of VAT. VAT will be charged according to applicable regulations.";
describe("renderOrderConfirmationHtml (order confirmation PDF)", () => {
const order = {
order_number: "26730042",
customer_order_number: "PO-XYZ",
created_at: new Date("2026-06-09T12:00:00"),
currency: "CZK",
notes: null,
customers: null,
};
const items = [
{
description: "Materiál",
quantity: 2,
unit: "ks",
unit_price: 100,
is_included_in_total: true,
},
];
it("cs: NET total labeled 'Celkem bez DPH', no VAT columns, notice present", () => {
const html = renderOrderConfirmationHtml(order, items, null, "cs", "Jan");
expect(html).not.toContain("%DPH");
expect(html).not.toContain(">DPH<");
expect(html).not.toContain("Mezisoučet");
// Line total = netto (2 × 100), no VAT-on-top anywhere.
expect(html).toContain('<td class="right total-cell">200,00</td>');
expect(html).toContain("Celkem bez DPH");
expect(html).toContain(CS_NOTE);
});
it("en: 'Total excl. VAT' + the English notice, no VAT columns", () => {
const html = renderOrderConfirmationHtml(order, items, null, "en", "Jan");
expect(html).not.toContain("VAT%");
expect(html).toContain("Total excl. VAT");
expect(html).toContain(EN_NOTE);
});
it("excludes not-included lines from the NET total", () => {
const html = renderOrderConfirmationHtml(
order,
[
...items,
{
description: "Mimo cenu",
quantity: 1,
unit: "ks",
unit_price: 999,
is_included_in_total: false,
},
],
null,
"cs",
"Jan",
);
// Grand total stays 200,00 — the excluded line doesn't count.
expect(html).toContain("200,00 CZK");
expect(html).not.toContain("1 199,00 CZK");
});
});
/* -------------------------------------------------------------------------- */
/* Offer PDF route (returns the HTML for the print view) */
/* -------------------------------------------------------------------------- */
let app: ReturnType<typeof Fastify> | null = null;
let adminToken = "";
const createdQuotationIds: number[] = [];
beforeAll(async () => {
app = Fastify({ logger: false });
await app.register(offersPdfRoutes, { prefix: "/api/admin/offers-pdf" });
const admin = await prisma.users.findFirst({
where: { roles: { name: "admin" } },
});
if (!admin) throw new Error("Test setup: admin user not found");
adminToken = jwt.sign(
{ sub: admin.id, username: admin.username, role: "admin" },
config.jwt.secret,
{ expiresIn: "15m" },
);
});
afterAll(async () => {
for (const id of createdQuotationIds)
await prisma.quotations.deleteMany({ where: { id } });
if (app) await app.close();
});
describe("GET /api/admin/offers-pdf/:id (offer PDF html)", () => {
it("renders NET-only totals with 'Celkem bez DPH' and the cs notice", async () => {
// Direct prisma insert (explicit number → no sequence consumed).
const quotation = await prisma.quotations.create({
data: {
quotation_number: `Q-VATNOTE-${Date.now()}`,
status: "active",
currency: "CZK",
language: "cs",
quotation_items: {
create: [
{
description: "Položka",
quantity: 2,
unit: "ks",
unit_price: 1000,
is_included_in_total: true,
position: 0,
},
],
},
},
});
createdQuotationIds.push(quotation.id);
const res = await app!.inject({
method: "GET",
url: `/api/admin/offers-pdf/${quotation.id}`,
headers: { Authorization: `Bearer ${adminToken}` },
});
expect(res.statusCode).toBe(200);
const html = res.body;
expect(html).toContain("Celkem bez DPH");
expect(html).toContain(CS_NOTE);
expect(html).not.toContain("%DPH");
expect(html).not.toContain("Mezisoučet");
expect(html).not.toContain("Celkem k úhradě");
});
});

View File

@@ -58,34 +58,6 @@ describe("Zod form coercion + NaN rejection", () => {
});
describe("CreateQuotationSchema", () => {
it("rejects NaN string in top-level vat_rate", () => {
const result = CreateQuotationSchema.safeParse({
customer_id: 1,
vat_rate: "bad",
});
expect(result.success).toBe(false);
});
it("accepts valid vat_rate", () => {
const result = CreateQuotationSchema.safeParse({
customer_id: 1,
vat_rate: 21,
});
expect(result.success).toBe(true);
});
it("coerces a valid numeric STRING vat_rate to a number", () => {
const result = CreateQuotationSchema.safeParse({
customer_id: 1,
vat_rate: "21",
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.vat_rate).toBe(21);
expect(typeof result.data.vat_rate).toBe("number");
}
});
it("rejects NaN string in item quantity", () => {
const result = CreateQuotationSchema.safeParse({
customer_id: 1,
@@ -300,8 +272,8 @@ describe("schema hardening — does not reject previously-valid input", () => {
expect(b.success).toBe(true);
if (b.success) expect(b.data.trip_date).toBe("2026-06-09");
// Genuinely malformed dates are still rejected.
expect(CreateTripSchema.safeParse({ ...base, trip_date: "09/06/2026" }).success).toBe(
false,
);
expect(
CreateTripSchema.safeParse({ ...base, trip_date: "09/06/2026" }).success,
).toBe(false);
});
});