From 7398cad4660710019659a730054929ef630457e2 Mon Sep 17 00:00:00 2001 From: BOHA Date: Wed, 10 Jun 2026 13:13:16 +0200 Subject: [PATCH] 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 --- .../migration.sql | 15 + prisma/schema.prisma | 7 - src/__tests__/issued-orders.test.ts | 101 ++-- src/__tests__/manual-create.test.ts | 4 - src/__tests__/offer-invoice-totals.test.ts | 32 +- src/__tests__/order-totals.test.ts | 92 ++-- src/__tests__/orders-list.test.ts | 2 - src/__tests__/pdf-vat-note.test.ts | 147 ++++++ src/__tests__/schema-nan.test.ts | 34 +- .../components/OrderConfirmationModal.tsx | 74 +-- src/admin/lib/queries/issued-orders.ts | 6 +- src/admin/lib/queries/offers.ts | 2 - src/admin/lib/queries/orders.ts | 2 - src/admin/pages/InvoiceDetail.tsx | 11 +- src/admin/pages/IssuedOrderDetail.tsx | 138 +----- src/admin/pages/IssuedOrders.tsx | 2 +- src/admin/pages/OfferDetail.tsx | 81 +--- src/admin/pages/Offers.tsx | 2 +- src/admin/pages/OrderDetail.tsx | 45 +- src/admin/pages/ReceivedOrders.tsx | 29 +- src/routes/admin/issued-orders-pdf.ts | 108 +---- src/routes/admin/offers-pdf.ts | 46 +- src/routes/admin/orders-pdf.ts | 457 ++++++++---------- src/schemas/issued-orders.schema.ts | 5 - src/schemas/offers.schema.ts | 5 - src/schemas/orders.schema.ts | 5 - src/services/issued-orders.service.ts | 57 +-- src/services/offers.service.ts | 25 +- src/services/orders.service.ts | 37 +- 29 files changed, 558 insertions(+), 1013 deletions(-) create mode 100644 prisma/migrations/20260610122819_drop_vat_from_offers_and_orders/migration.sql create mode 100644 src/__tests__/pdf-vat-note.test.ts diff --git a/prisma/migrations/20260610122819_drop_vat_from_offers_and_orders/migration.sql b/prisma/migrations/20260610122819_drop_vat_from_offers_and_orders/migration.sql new file mode 100644 index 0000000..a219a9e --- /dev/null +++ b/prisma/migrations/20260610122819_drop_vat_from_offers_and_orders/migration.sql @@ -0,0 +1,15 @@ +-- AlterTable +ALTER TABLE `issued_order_items` DROP COLUMN `vat_rate`; + +-- AlterTable +ALTER TABLE `issued_orders` DROP COLUMN `apply_vat`, + DROP COLUMN `vat_rate`; + +-- AlterTable +ALTER TABLE `orders` DROP COLUMN `apply_vat`, + DROP COLUMN `vat_rate`; + +-- AlterTable +ALTER TABLE `quotations` DROP COLUMN `apply_vat`, + DROP COLUMN `vat_rate`; + diff --git a/prisma/schema.prisma b/prisma/schema.prisma index b220032..d782991 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -347,8 +347,6 @@ model orders { status String? @default("prijata") @db.VarChar(30) currency String? @default("CZK") @db.VarChar(10) language String? @default("cs") @db.VarChar(5) - vat_rate Decimal? @default(21.00) @db.Decimal(5, 2) - apply_vat Boolean? @default(true) exchange_rate Decimal? @default(1.0000) @db.Decimal(10, 4) scope_title String? @db.VarChar(500) scope_description String? @db.Text @@ -372,8 +370,6 @@ model issued_orders { supplier_id Int? status issued_orders_status @default(draft) currency String? @default("CZK") @db.VarChar(10) - vat_rate Decimal? @default(21.00) @db.Decimal(5, 2) - apply_vat Boolean? @default(true) exchange_rate Decimal? @default(1.0000) @db.Decimal(10, 4) order_date DateTime? @db.Date delivery_date DateTime? @db.Date @@ -401,7 +397,6 @@ model issued_order_items { quantity Decimal? @default(1.000) @db.Decimal(12, 3) unit String? @db.VarChar(20) unit_price Decimal? @default(0.00) @db.Decimal(12, 2) - vat_rate Decimal? @default(21.00) @db.Decimal(5, 2) position Int? @default(0) issued_orders issued_orders @relation(fields: [issued_order_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "issued_order_items_ibfk_1") @@ -494,8 +489,6 @@ model quotations { valid_until DateTime? @db.Date currency String? @default("CZK") @db.VarChar(10) language String? @default("cs") @db.VarChar(5) - vat_rate Decimal? @default(21.00) @db.Decimal(5, 2) - apply_vat Boolean? @default(true) order_id Int? status String @default("active") @db.VarChar(20) scope_title String? @db.VarChar(500) diff --git a/src/__tests__/issued-orders.test.ts b/src/__tests__/issued-orders.test.ts index 718a598..2a5e4c3 100644 --- a/src/__tests__/issued-orders.test.ts +++ b/src/__tests__/issued-orders.test.ts @@ -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: "

Pozn

", 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('42,00'); - expect(html).toContain('242,00'); - 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('42,00'); + // Line total = netto (2 × 100). expect(html).toContain('200,00'); 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", () => { diff --git a/src/__tests__/manual-create.test.ts b/src/__tests__/manual-create.test.ts index a71bb17..b3f8edc 100644 --- a/src/__tests__/manual-create.test.ts +++ b/src/__tests__/manual-create.test.ts @@ -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); diff --git a/src/__tests__/offer-invoice-totals.test.ts b/src/__tests__/offer-invoice-totals.test.ts index 83e03d9..a682017 100644 --- a/src/__tests__/offer-invoice-totals.test.ts +++ b/src/__tests__/offer-invoice-totals.test.ts @@ -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); }); }); diff --git a/src/__tests__/order-totals.test.ts b/src/__tests__/order-totals.test.ts index 6687aed..e48118c 100644 --- a/src/__tests__/order-totals.test.ts +++ b/src/__tests__/order-totals.test.ts @@ -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); }); }); diff --git a/src/__tests__/orders-list.test.ts b/src/__tests__/orders-list.test.ts index add344e..a92a391 100644 --- a/src/__tests__/orders-list.test.ts +++ b/src/__tests__/orders-list.test.ts @@ -30,8 +30,6 @@ const baseOrder = { status: "prijata" as const, currency: "CZK", language: "cs", - vat_rate: 21, - apply_vat: true, exchange_rate: 1, }; diff --git a/src/__tests__/pdf-vat-note.test.ts b/src/__tests__/pdf-vat-note.test.ts new file mode 100644 index 0000000..74707a8 --- /dev/null +++ b/src/__tests__/pdf-vat-note.test.ts @@ -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('200,00'); + 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 | 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ě"); + }); +}); diff --git a/src/__tests__/schema-nan.test.ts b/src/__tests__/schema-nan.test.ts index abf47f9..c505e42 100644 --- a/src/__tests__/schema-nan.test.ts +++ b/src/__tests__/schema-nan.test.ts @@ -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); }); }); diff --git a/src/admin/components/OrderConfirmationModal.tsx b/src/admin/components/OrderConfirmationModal.tsx index 1a13eac..57ef5f3 100644 --- a/src/admin/components/OrderConfirmationModal.tsx +++ b/src/admin/components/OrderConfirmationModal.tsx @@ -7,8 +7,8 @@ import { useTheme } from "@mui/material/styles"; import { Modal, Button, TextField, Field } from "../ui"; import { useAlert } from "../context/AlertContext"; -// Editable line-item. quantity/unit_price/vat_rate are held as the raw typed -// string while editing (so a field can be cleared — empty renders fine in a +// Editable line-item. quantity/unit_price are held as the raw typed string +// while editing (so a field can be cleared — empty renders fine in a // type="number" input) and are coerced to numbers in handleEditGenerate before // being handed to onGenerate (the parent posts them verbatim). interface ConfirmationItem { @@ -17,7 +17,6 @@ interface ConfirmationItem { unit: string; unit_price: string | number; is_included_in_total: boolean; - vat_rate: string | number; } // Numeric shape handed to the parent (the raw editing strings are coerced in @@ -28,21 +27,14 @@ interface GeneratedItem { unit: string; unit_price: number; is_included_in_total: boolean; - vat_rate: number; } interface OrderConfirmationModalProps { isOpen: boolean; onClose: () => void; - onGenerate: ( - lang: string, - applyVat: boolean, - items?: GeneratedItem[], - ) => Promise; + onGenerate: (lang: string, items?: GeneratedItem[]) => Promise; initialItems: ConfirmationItem[]; orderNumber: string; - defaultVatRate: number; - applyVat: boolean; } export default function OrderConfirmationModal({ @@ -51,15 +43,12 @@ export default function OrderConfirmationModal({ onGenerate, initialItems, orderNumber, - defaultVatRate, - applyVat, }: OrderConfirmationModalProps) { const alert = useAlert(); const theme = useTheme(); const isMobile = useMediaQuery(theme.breakpoints.down("sm")); const [step, setStep] = useState<"choose" | "edit">("choose"); const [lang, setLang] = useState("cs"); - const [applyVatState, setApplyVatState] = useState(applyVat); const [items, setItems] = useState(initialItems); const [loading, setLoading] = useState(false); @@ -72,17 +61,16 @@ export default function OrderConfirmationModal({ if (!isOpen) return; setStep("choose"); setLang("cs"); - setApplyVatState(applyVat); setItems(initialItems); - // initialItems/applyVat are captured at open time; intentionally not in the - // dep array so an unrelated parent re-render doesn't clobber the user's edits. + // initialItems are captured at open time; intentionally not in the dep + // array so an unrelated parent re-render doesn't clobber the user's edits. // eslint-disable-next-line react-hooks/exhaustive-deps }, [isOpen]); const handleUseExisting = async () => { setLoading(true); try { - await onGenerate(lang, applyVatState, undefined); + await onGenerate(lang, undefined); // Only close on success — a generation error must keep the modal open. onClose(); } catch (err) { @@ -104,9 +92,8 @@ export default function OrderConfirmationModal({ unit: it.unit, unit_price: Number(it.unit_price) || 0, is_included_in_total: it.is_included_in_total, - vat_rate: Number(it.vat_rate) || 0, })); - await onGenerate(lang, applyVatState, coercedItems); + await onGenerate(lang, coercedItems); // Only close on success — on error keep the user's edited items intact. onClose(); } catch (err) { @@ -145,10 +132,9 @@ export default function OrderConfirmationModal({ unit: "ks", unit_price: 0, is_included_in_total: true, - vat_rate: defaultVatRate, }, ]); - }, [defaultVatRate]); + }, []); return ( - - - - - - - Jak chcete připravit potvrzení objednávky? @@ -291,7 +256,7 @@ export default function OrderConfirmationModal({ @@ -318,15 +283,6 @@ export default function OrderConfirmationModal({ } slotProps={{ htmlInput: { step: "0.01" } }} /> - - updateItem(i, "vat_rate", e.target.value) - } - slotProps={{ htmlInput: { step: "1" } }} - /> ))} @@ -356,7 +312,6 @@ export default function OrderConfirmationModal({ Mn. Jedn. Cena - %DPH @@ -403,17 +358,6 @@ export default function OrderConfirmationModal({ slotProps={{ htmlInput: { step: "0.01" } }} /> - - - updateItem(i, "vat_rate", e.target.value) - } - sx={{ width: 80 }} - slotProps={{ htmlInput: { step: "1" } }} - /> - ({ ...prev, customer_id: order.customer_id as number, @@ -803,7 +802,7 @@ export default function InvoiceDetail() { (order.currency as string) || companySettings?.default_currency || "CZK", - apply_vat: Number(order.apply_vat) || 0, + apply_vat: 1, vat_rate: vatRate, })); const orderItems = order.items as Record[] | undefined; diff --git a/src/admin/pages/IssuedOrderDetail.tsx b/src/admin/pages/IssuedOrderDetail.tsx index dc9df6b..b520e37 100644 --- a/src/admin/pages/IssuedOrderDetail.tsx +++ b/src/admin/pages/IssuedOrderDetail.tsx @@ -57,7 +57,6 @@ import { DateField, Field, StatusChip, - CheckboxField, ConfirmDialog, LoadingState, PageEnter, @@ -85,11 +84,6 @@ const TRANSITION_LABELS: Record = { cancelled: "Stornovat", }; -const VAT_OPTIONS = [0, 10, 12, 15, 21].map((v) => ({ - value: String(v), - label: `${v}%`, -})); - const CURRENCY_FALLBACK = ["CZK", "EUR", "USD", "GBP"]; const BackIcon = ( @@ -151,20 +145,16 @@ interface OrderItem { item_description: string; // Held as the raw typed string while editing so the field can be cleared // (empty renders fine in a type="number" input). Coerced via Number(x) || 0 - // only where used (live totals + save payload). vat_rate stays numeric: it - // is edited via a Select, never a free-text number input. + // only where used (live totals + save payload). quantity: string | number; unit: string; unit_price: string | number; - vat_rate: number; } interface OrderForm { supplier_id: number | null; supplier_name: string; currency: string; - apply_vat: boolean; - vat_rate: number; order_date: string; delivery_date: string; language: string; @@ -182,7 +172,6 @@ function SortableOrderRow({ item, index, currency, - apply_vat, readOnly, onUpdate, onRemove, @@ -191,7 +180,6 @@ function SortableOrderRow({ item: OrderItem; index: number; currency: string; - apply_vat: boolean; readOnly: boolean; onUpdate: ( index: number, @@ -290,7 +278,7 @@ function SortableOrderRow({ @@ -317,21 +305,6 @@ function SortableOrderRow({ slotProps={{ htmlInput: { step: "any" } }} InputProps={{ readOnly }} /> - {apply_vat && - (readOnly ? ( - - ) : ( - onUpdate(index, "vat_rate", Number(val))} - sx={{ minWidth: "4.5rem" }} - options={VAT_OPTIONS} - /> - )} - - ) : null} >({}); @@ -615,8 +570,6 @@ export default function IssuedOrderDetail() { supplier_id: d.supplier_id ?? null, supplier_name: d.supplier_name ?? "", currency: d.currency || "CZK", - apply_vat: d.apply_vat !== false, - vat_rate: numberOr(d.vat_rate, 21), order_date: normalizeDateStr(d.order_date), delivery_date: normalizeDateStr(d.delivery_date), language: d.language || "cs", @@ -640,7 +593,6 @@ export default function IssuedOrderDetail() { quantity: numberOr(it.quantity, 1), unit: it.unit || "", unit_price: Number(it.unit_price) || 0, - vat_rate: numberOr(it.vat_rate, numberOr(d.vat_rate, 21)), })) : []; if (mapped.length > 0) setItems(mapped); @@ -683,21 +635,14 @@ export default function IssuedOrderDetail() { const editable = !isEdit || form.status === "draft" || form.status === "sent"; const canExport = hasPermission("orders.view"); - // ─── Totals (live) ─── + // ─── Totals (live, NET only — issued orders carry no VAT) ─── const totals = useMemo(() => { - let subtotal = 0; - const vatByRate: Record = {}; + let total = 0; items.forEach((it) => { - const line = (Number(it.quantity) || 0) * (Number(it.unit_price) || 0); - subtotal += line; - if (form.apply_vat) { - const rate = Number(it.vat_rate) || 0; - vatByRate[rate] = (vatByRate[rate] || 0) + (line * rate) / 100; - } + total += (Number(it.quantity) || 0) * (Number(it.unit_price) || 0); }); - const totalVat = Object.values(vatByRate).reduce((s, v) => s + v, 0); - return { subtotal, vatByRate, totalVat, total: subtotal + totalVat }; - }, [items, form.apply_vat]); + return { total }; + }, [items]); // ─── Mutations ─── const saveMutation = useApiMutation, { id: number }>({ @@ -774,8 +719,6 @@ export default function IssuedOrderDetail() { const payload: Record = { supplier_id: form.supplier_id, currency: form.currency, - vat_rate: form.vat_rate, - apply_vat: form.apply_vat, order_date: form.order_date, delivery_date: form.delivery_date || null, language: form.language, @@ -795,7 +738,6 @@ export default function IssuedOrderDetail() { quantity: Number(it.quantity) || 0, unit: it.unit, unit_price: Number(it.unit_price) || 0, - vat_rate: it.vat_rate, position: i, })), }; @@ -1175,7 +1117,7 @@ export default function IssuedOrderDetail() { @@ -1202,31 +1144,6 @@ export default function IssuedOrderDetail() { ]} /> - - updateForm("vat_rate", parseFloat(val) || 0)} - disabled={readOnly} - sx={{ flex: 1 }} - options={( - companySettings?.available_vat_rates || [0, 10, 12, 15, 21] - ).map((r) => ({ value: String(r), label: `${r}%` }))} - /> - - updateForm("apply_vat", e.target.checked)} - disabled={readOnly} - /> - Uplatnit DPH - - - - {/* Items Section with drag-and-drop */} @@ -1596,7 +1547,7 @@ export default function OfferDetail() { - {/* Totals */} + {/* Totals (NET only — offers are not tax documents) */} - - - Mezisoučet: - - - {formatCurrency(subtotal, form.currency)} - - - {form.apply_vat && ( - - - DPH ({form.vat_rate}%): - - - {formatCurrency(vatAmount, form.currency)} - - - )} - Celkem: + Celkem bez DPH: - Celkem: + Celkem bez DPH: window.removeEventListener("beforeunload", handler); }, [isDirty]); + // NET only — received orders are not tax documents (no VAT on them). const totals = useMemo(() => { - if (!order?.items) return { subtotal: 0, vatAmount: 0, total: 0 }; - const subtotal = order.items.reduce((sum, item) => { + if (!order?.items) return { total: 0 }; + const total = order.items.reduce((sum, item) => { if (Number(item.is_included_in_total)) { return ( sum + (Number(item.quantity) || 0) * (Number(item.unit_price) || 0) @@ -154,10 +155,7 @@ export default function OrderDetail() { } return sum; }, 0); - const vatAmount = Number(order.apply_vat) - ? subtotal * ((Number(order.vat_rate) || 0) / 100) - : 0; - return { subtotal, vatAmount, total: subtotal + vatAmount }; + return { total }; }, [order]); const statusMutation = useApiMutation<{ status: string }, unknown>({ @@ -236,14 +234,12 @@ export default function OrderDetail() { const handleGenerateConfirmation = async ( lang: string, - applyVat: boolean, customItems?: Array<{ description: string; quantity: number; unit: string; unit_price: number; is_included_in_total: boolean; - vat_rate: number; }>, ) => { setConfirmationLoading(true); @@ -253,7 +249,7 @@ export default function OrderDetail() { { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ lang, applyVat, items: customItems }), + body: JSON.stringify({ lang, items: customItems }), }, ); if (!response.ok) { @@ -625,7 +621,7 @@ export default function OrderDetail() { empty={} /> - {/* Totals */} + {/* Totals (NET only — orders are not tax documents) */} - - - Mezisoučet: - - - {formatCurrency(totals.subtotal, order.currency)} - - - {Number(order.apply_vat) > 0 && ( - - - DPH ({order.vat_rate}%): - - - {formatCurrency(totals.vatAmount, order.currency)} - - - )} - Celkem k úhradě: + Celkem bez DPH: )} diff --git a/src/admin/pages/ReceivedOrders.tsx b/src/admin/pages/ReceivedOrders.tsx index 3cc338b..e644856 100644 --- a/src/admin/pages/ReceivedOrders.tsx +++ b/src/admin/pages/ReceivedOrders.tsx @@ -184,8 +184,6 @@ export default function OrdersReceived({ customer_id: "", customer_order_number: "", currency: "CZK", - vat_rate: "21", - apply_vat: true, scope_title: "", scope_description: "", notes: "", @@ -219,8 +217,6 @@ export default function OrdersReceived({ customer_id: "", customer_order_number: "", currency: "CZK", - vat_rate: "21", - apply_vat: true, scope_title: "", scope_description: "", notes: "", @@ -255,8 +251,6 @@ export default function OrdersReceived({ fd.append("customer_id", createForm.customer_id); fd.append("customer_order_number", createForm.customer_order_number); fd.append("currency", createForm.currency); - fd.append("vat_rate", createForm.vat_rate); - fd.append("apply_vat", createForm.apply_vat ? "1" : "0"); fd.append("scope_title", createForm.scope_title); fd.append("scope_description", createForm.scope_description); fd.append("notes", createForm.notes); @@ -274,8 +268,6 @@ export default function OrdersReceived({ : null, customer_order_number: createForm.customer_order_number, currency: createForm.currency, - vat_rate: createForm.vat_rate, - apply_vat: createForm.apply_vat, scope_title: createForm.scope_title, scope_description: createForm.scope_description, notes: createForm.notes, @@ -571,7 +563,7 @@ export default function OrdersReceived({ fontSize: "0.9rem", }} > - Celkem: + Celkem bez DPH: - - - - setCreateForm({ ...createForm, vat_rate: e.target.value }) - } - slotProps={{ htmlInput: { min: 0 } }} - /> - - @@ -757,12 +736,6 @@ export default function OrdersReceived({ /> - setCreateForm({ ...createForm, apply_vat: v })} - /> - > = { col_desc: "Popis", col_qty: "Množství", col_unit_price: "Jedn. cena", - col_price: "Cena", - col_vat_pct: "%DPH", - col_vat: "DPH", col_total: "Celkem", - subtotal: "Mezisoučet:", - vat_label: "DPH", - total: "Celkem", total_no_vat: "Celkem bez DPH", amounts_in: "Částky jsou uvedeny v", + vat_notice: + "Ceny jsou uvedeny bez DPH. DPH bude účtováno dle platných předpisů.", notes: "Poznámky", delivery_terms: "Dodací podmínky:", payment_terms: "Platební podmínky:", @@ -228,15 +224,11 @@ const translations: Record> = { col_desc: "Description", col_qty: "Quantity", col_unit_price: "Unit price", - col_price: "Price", - col_vat_pct: "VAT%", - col_vat: "VAT", col_total: "Total", - subtotal: "Subtotal:", - vat_label: "VAT", - total: "Total", total_no_vat: "Total excl. VAT", amounts_in: "Amounts are in", + vat_notice: + "Prices are exclusive of VAT. VAT will be charged according to applicable regulations.", notes: "Notes", delivery_terms: "Delivery terms:", payment_terms: "Payment terms:", @@ -251,8 +243,6 @@ interface IssuedOrderPdfData { order_date: Date | null; delivery_date: Date | null; currency: string | null; - apply_vat: boolean | null; - vat_rate: unknown; notes: string | null; delivery_terms: string | null; payment_terms: string | null; @@ -267,7 +257,6 @@ interface IssuedOrderPdfItem { quantity: unknown; unit: string | null; unit_price: unknown; - vat_rate: unknown; } export function renderIssuedOrderHtml( @@ -279,9 +268,7 @@ export function renderIssuedOrderHtml( issuer: { name: string }, ): string { const t = translations[lang]; - const applyVat = order.apply_vat !== false; const currency = order.currency || "CZK"; - const docRate = order.vat_rate != null ? Number(order.vat_rate) : 21; const poNumber = escapeHtml(order.po_number || ""); // Logo embedding (same logic as the confirmation template). @@ -308,31 +295,15 @@ export function renderIssuedOrderHtml( .map((l) => `
${escapeHtml(l)}
`) .join(""); - // Items — NET + per-line VAT-on-top, rounded per line (same math the - // service computeIssuedOrderTotals uses; mirrors the confirmation loop). - let subtotal = 0; - let totalVat = 0; - const vatSummary: Record = {}; + // Items — NET only. A purchase order is not a tax document: no VAT columns, + // no VAT math (same as the service computeIssuedOrderTotals). + let total = 0; const itemsHtml = items .map((it, i) => { const qty = Number(it.quantity) || 0; const unitPrice = Number(it.unit_price) || 0; - const rate = - it.vat_rate != null && it.vat_rate !== "" - ? Number(it.vat_rate) - : docRate; - const lineSubtotal = qty * unitPrice; - const lineVat = applyVat - ? Math.round(lineSubtotal * (rate / 100) * 100) / 100 - : 0; - const lineTotal = lineSubtotal + lineVat; - - subtotal += lineSubtotal; - totalVat += lineVat; - const key = String(rate); - if (!vatSummary[key]) vatSummary[key] = { base: 0, vat: 0 }; - vatSummary[key].base += lineSubtotal; - vatSummary[key].vat += lineVat; + const lineTotal = qty * unitPrice; + total += lineTotal; const qtyDecimals = Math.floor(qty) === qty ? 0 : 2; const descHtml = `${escapeHtml(it.description)}${ @@ -340,40 +311,17 @@ export function renderIssuedOrderHtml( ? `
${escapeHtml(it.item_description)}
` : "" }`; - // Without "Uplatnit DPH" the VAT columns are dropped entirely (the - // header does the same) instead of printing meaningless 0% / 0.00. - const vatCells = applyVat - ? ` - ${Math.floor(rate)}% - ${formatNum(lineVat)}` - : ""; return ` ${i + 1} ${descHtml} ${formatNum(qty, qtyDecimals)}${it.unit ? ` / ${escapeHtml(it.unit)}` : ""} ${formatNum(unitPrice)} - ${formatNum(lineSubtotal)}${vatCells} ${formatNum(lineTotal)} `; }) .join(""); - subtotal = Math.round(subtotal * 100) / 100; - totalVat = Math.round(totalVat * 100) / 100; - const totalToPay = Math.round((subtotal + totalVat) * 100) / 100; - - let vatDetailHtml = ""; - if (applyVat) { - for (const [rate, data] of Object.entries(vatSummary)) { - if (data.vat > 0) { - vatDetailHtml += ` -
- ${escapeHtml(t.vat_label)} ${Math.floor(Number(rate))}%: - ${formatNum(data.vat)} ${escapeHtml(currency)} -
`; - } - } - } + total = Math.round(total * 100) / 100; const notesRaw = order.notes ?? ""; const notesStripped = notesRaw.replace(/<[^>]*>/g, "").trim(); @@ -645,6 +593,12 @@ export function renderIssuedOrderHtml( color: #1a1a1a; margin-top: 2mm; } + .totals .vat-note { + text-align: right; + font-size: 8pt; + color: #646464; + margin-top: 1mm; + } /* Dodaci / platebni podminky (PO-specificke) */ .terms { @@ -778,17 +732,10 @@ ${indentCSS} ${escapeHtml(t.col_no)} - ${escapeHtml(t.col_desc)} + ${escapeHtml(t.col_desc)} ${escapeHtml(t.col_qty)} ${escapeHtml(t.col_unit_price)} - ${escapeHtml(t.col_price)}${ - applyVat - ? ` - ${escapeHtml(t.col_vat_pct)} - ${escapeHtml(t.col_vat)}` - : "" - } - ${escapeHtml(t.col_total)} + ${escapeHtml(t.col_total)} @@ -796,24 +743,15 @@ ${indentCSS} - +
-
${ - applyVat - ? ` -
-
- ${escapeHtml(t.subtotal)} - ${formatNum(subtotal)} ${escapeHtml(currency)} -
${vatDetailHtml} -
` - : "" - } +
- ${escapeHtml(applyVat ? t.total : t.total_no_vat)} - ${formatNum(totalToPay)} ${escapeHtml(currency)} + ${escapeHtml(t.total_no_vat)} + ${formatNum(total)} ${escapeHtml(currency)}
${escapeHtml(t.amounts_in)} ${escapeHtml(currency)}
+
${escapeHtml(t.vat_notice)}
diff --git a/src/routes/admin/offers-pdf.ts b/src/routes/admin/offers-pdf.ts index 7224232..0dd9009 100644 --- a/src/routes/admin/offers-pdf.ts +++ b/src/routes/admin/offers-pdf.ts @@ -203,9 +203,11 @@ const TRANSLATIONS: Record> = { unit_price: { EN: "Unit Price", CZ: "Jedn. cena" }, included: { EN: "Included", CZ: "Zahrnuto" }, total: { EN: "Total", CZ: "Celkem" }, - subtotal: { EN: "Subtotal", CZ: "Mezisou\u010Det" }, - vat: { EN: "VAT", CZ: "DPH" }, - total_to_pay: { EN: "Total to pay", CZ: "Celkem k \u00FAhrad\u011B" }, + total_no_vat: { EN: "Total excl. VAT", CZ: "Celkem bez DPH" }, + vat_notice: { + EN: "Prices are exclusive of VAT. VAT will be charged according to applicable regulations.", + CZ: "Ceny jsou uvedeny bez DPH. DPH bude \u00FA\u010Dtov\u00E1no dle platn\u00FDch p\u0159edpis\u016F.", + }, ico: { EN: "ID", CZ: "I\u010CO" }, dic: { EN: "VAT ID", CZ: "DI\u010C" }, page: { EN: "Page", CZ: "Strana" }, @@ -256,18 +258,15 @@ export default async function offersPdfRoutes( logoImg = ``; } + // Offers are NOT tax documents — the total is NET only (no VAT math). const items = quotation.quotation_items; - let subtotal = 0; + let total = 0; for (const item of items) { if (item.is_included_in_total !== false) { - subtotal += + total += (Number(item.quantity) || 0) * (Number(item.unit_price) || 0); } } - const applyVat = !!quotation.apply_vat; - const vatRate = Number(quotation.vat_rate) || 21; - const vatAmount = applyVat ? subtotal * (vatRate / 100) : 0; - const totalToPay = subtotal + vatAmount; let hasScopeContent = false; for (const s of quotation.scope_sections) { if ((s.content || "").trim() || (s.title || "").trim()) { @@ -318,23 +317,12 @@ export default async function offersPdfRoutes( `; }); - let totalsHtml = ""; - if (applyVat) { - totalsHtml += `
-
- ${escapeHtml(t("subtotal"))}: - ${formatCurrency(subtotal, currency)} -
-
- ${escapeHtml(t("vat"))} (${Math.round(vatRate)}%): - ${formatCurrency(vatAmount, currency)} -
-
`; - } - totalsHtml += `
- ${escapeHtml(t("total_to_pay"))} - ${formatCurrency(totalToPay, currency)} -
`; + // No Mezisoučet/VAT rows — they would only duplicate the net total. + const totalsHtml = `
+ ${escapeHtml(t("total_no_vat"))} + ${formatCurrency(total, currency)} +
+
${escapeHtml(t("vat_notice"))}
`; const quotationNumber = escapeHtml(quotation.quotation_number); let scopeHtml = ""; @@ -578,6 +566,12 @@ ${indentCSS} border-bottom: 2.5pt solid #de3a3a; padding-bottom: 1mm; } + .totals .vat-note { + text-align: right; + font-size: 8pt; + color: #646464; + margin-top: 2mm; + } /* ---- Scope sections ---- */ .scope-page { diff --git a/src/routes/admin/orders-pdf.ts b/src/routes/admin/orders-pdf.ts index ebbac4c..52c6f7f 100644 --- a/src/routes/admin/orders-pdf.ts +++ b/src/routes/admin/orders-pdf.ts @@ -20,16 +20,14 @@ const OrderPdfItemSchema = z.object({ unit: z.string().max(255), unit_price: z.number().min(0).finite(), is_included_in_total: z.boolean().optional(), - vat_rate: z.number().min(0).max(100).finite(), }); // `z.looseObject` is the Zod 4 replacement for the deprecated `.passthrough()`. -// `items` is strictly validated; `lang`/`applyVat` are typed explicitly so the -// handler no longer reads them as untyped passthrough keys. +// `items` is strictly validated; `lang` is typed explicitly so the handler no +// longer reads it as an untyped passthrough key. const OrderPdfBodySchema = z.looseObject({ items: z.array(OrderPdfItemSchema).optional(), lang: z.string().max(10).optional(), - applyVat: z.boolean().optional(), }); /* ── Helpers ─────────────────────────────────────────────────────── */ @@ -193,15 +191,11 @@ const translations: Record> = { col_desc: "Popis", col_qty: "Množství", col_unit_price: "Jedn. cena", - col_price: "Cena", - col_vat_pct: "%DPH", - col_vat: "DPH", col_total: "Celkem", - subtotal: "Mezisoučet:", - vat_label: "DPH", - total: "Celkem", total_no_vat: "Celkem bez DPH", amounts_in: "Částky jsou uvedeny v", + vat_notice: + "Ceny jsou uvedeny bez DPH. DPH bude účtováno dle platných předpisů.", notes: "Poznámky", issued_by: "Vystavil:", received_by: "Převzal:", @@ -222,15 +216,11 @@ const translations: Record> = { col_desc: "Description", col_qty: "Quantity", col_unit_price: "Unit price", - col_price: "Price", - col_vat_pct: "VAT%", - col_vat: "VAT", col_total: "Total", - subtotal: "Subtotal:", - vat_label: "VAT", - total: "Total", total_no_vat: "Total excl. VAT", amounts_in: "Amounts are in", + vat_notice: + "Prices are exclusive of VAT. VAT will be charged according to applicable regulations.", notes: "Notes", issued_by: "Issued by:", received_by: "Received by:", @@ -240,212 +230,119 @@ const translations: Record> = { }, }; -/* ── Route ───────────────────────────────────────────────────────── */ +/* ── Template ────────────────────────────────────────────────────── */ -export default async function ordersPdfRoutes( - fastify: FastifyInstance, -): Promise { - fastify.post<{ Params: { id: string }; Body: Record }>( - "/:id/confirmation", - { preHandler: requirePermission("orders.view") }, - async (request, reply) => { - const id = parseId(request.params.id, reply); - if (id === null) return; - const parsed = parseBody(OrderPdfBodySchema, request.body || {}); - if ("error" in parsed) return error(reply, parsed.error, 400); - const body = parsed.data; +export interface OrderConfirmationPdfItem { + description: string; + quantity: number; + unit: string; + unit_price: number; + is_included_in_total: boolean; +} - try { - const lang = body.lang === "en" ? "en" : "cs"; - const t = translations[lang]; +interface OrderConfirmationPdfData { + order_number: string | null; + customer_order_number: string | null; + created_at: Date | string | null; + currency: string | null; + notes: string | null; + // Not a real orders column today — read defensively (PHP-era data carried it). + payment_method?: string | null; + customers?: Record | null; +} - const order = await prisma.orders.findUnique({ - where: { id }, - // The confirmation PDF never renders the PO attachment — don't pull - // the blob just to read the order header/items. - omit: { attachment_data: true }, - include: { - customers: true, - order_items: { orderBy: { position: "asc" } }, - }, - }); +/** + * Order-confirmation HTML. The confirmation is NOT a tax document — prices + * are NET only: no VAT columns or VAT summary, the grand total is labeled + * "Celkem bez DPH" and the totals box carries the fixed prices-excl.-VAT + * notice. Exported for tests. + */ +export function renderOrderConfirmationHtml( + order: OrderConfirmationPdfData, + items: OrderConfirmationPdfItem[], + settings: Record | null, + lang: "cs" | "en", + userName: string, +): string { + const t = translations[lang]; - if (!order) { - return reply - .status(404) - .type("text/html") - .send("

Objednávka nenalezena

"); - } + let logoImg = ""; + if (settings?.logo_data) { + const buf = Buffer.from(settings.logo_data as Buffer); + let mime = "image/png"; + if (buf[0] === 0xff && buf[1] === 0xd8) mime = "image/jpeg"; + else if (buf[0] === 0x47 && buf[1] === 0x49) mime = "image/gif"; + else if (buf[0] === 0x52 && buf[1] === 0x49) mime = "image/webp"; + const b64 = buf.toString("base64"); + logoImg = ``; + } - const settings = (await prisma.company_settings.findFirst()) as Record< - string, - unknown - > | null; + const currency = order.currency || "CZK"; - let logoImg = ""; - if (settings?.logo_data) { - const buf = Buffer.from(settings.logo_data as Buffer); - let mime = "image/png"; - if (buf[0] === 0xff && buf[1] === 0xd8) mime = "image/jpeg"; - else if (buf[0] === 0x47 && buf[1] === 0x49) mime = "image/gif"; - else if (buf[0] === 0x52 && buf[1] === 0x49) mime = "image/webp"; - const b64 = buf.toString("base64"); - logoImg = ``; - } + // NET-only total over the included lines — no VAT math anywhere. + let total = 0; + for (const item of items) { + if (item.is_included_in_total) total += item.quantity * item.unit_price; + } + total = Math.round(total * 100) / 100; - const currency = order.currency || "CZK"; - const applyVat = - body.applyVat !== undefined ? !!body.applyVat : !!order.apply_vat; - const orderVatRate = Number(order.vat_rate) || 21; + const supp = buildAddressLines(settings, true, t); + const cust = buildAddressLines( + (order.customers as Record) || null, + false, + t, + ); - // The confirmation PDF can be rendered from client-supplied items (e.g. - // a live preview of unsaved edits on the detail page) OR from the - // stored order. Fabricating descriptions/prices that don't reflect the - // stored order is an editing action, so the custom-items path requires - // `orders.edit` (admins bypass). A view-only caller is silently served - // the STORED order items instead — preventing a `orders.view` holder - // from producing an "official" confirmation with invented figures. - const authData = request.authData; - const canUseCustomItems = - authData?.roleName === "admin" || - !!authData?.permissions.includes("orders.edit"); - const customItemsRaw = - canUseCustomItems && Array.isArray(body.items) ? body.items : null; + const suppLinesHtml = supp.lines + .map((l) => `
${escapeHtml(l)}
`) + .join(""); + const custLinesHtml = cust.lines + .map((l) => `
${escapeHtml(l)}
`) + .join(""); - let items: Array<{ - description: string; - quantity: number; - unit: string; - unit_price: number; - is_included_in_total: boolean; - vat_rate: number; - }> = []; + const orderNumber = escapeHtml(order.order_number || ""); + const poNumber = escapeHtml(order.customer_order_number || ""); + const orderDateStr = formatDate(order.created_at); - if (customItemsRaw && customItemsRaw.length > 0) { - items = customItemsRaw.map((it) => ({ - description: it.description, - quantity: it.quantity, - unit: it.unit, - unit_price: it.unit_price, - is_included_in_total: it.is_included_in_total !== false, - vat_rate: it.vat_rate, - })); - } else { - items = order.order_items.map((it) => ({ - description: it.description || "", - quantity: Number(it.quantity) || 0, - unit: it.unit || "", - unit_price: Number(it.unit_price) || 0, - is_included_in_total: !!it.is_included_in_total, - vat_rate: orderVatRate, - })); - } - - let subtotal = 0; - let totalVat = 0; - const vatSummary: Record = {}; - for (const item of items) { - if (item.is_included_in_total) { - const lineTotal = item.quantity * item.unit_price; - subtotal += lineTotal; - const rate = item.vat_rate; - const key = String(rate); - if (!vatSummary[key]) vatSummary[key] = { base: 0, vat: 0 }; - vatSummary[key].base += lineTotal; - if (applyVat) { - const lineVat = (lineTotal * rate) / 100; - vatSummary[key].vat += lineVat; - totalVat += lineVat; - } - } - } - const totalToPay = subtotal + totalVat; - - const userName = request.authData - ? `${request.authData.firstName || ""} ${request.authData.lastName || ""}`.trim() - : ""; - - const supp = buildAddressLines(settings, true, t); - const cust = buildAddressLines( - (order.customers as Record) || null, - false, - t, - ); - - const suppLinesHtml = supp.lines - .map((l) => `
${escapeHtml(l)}
`) - .join(""); - const custLinesHtml = cust.lines - .map((l) => `
${escapeHtml(l)}
`) - .join(""); - - const orderNumber = escapeHtml(order.order_number || ""); - const poNumber = escapeHtml(order.customer_order_number || ""); - const orderDateStr = formatDate(order.created_at); - - const itemsHtml = items - .map((item, i) => { - const lineSubtotal = item.quantity * item.unit_price; - const lineVat = applyVat ? (lineSubtotal * item.vat_rate) / 100 : 0; - const lineTotal = lineSubtotal + lineVat; - const qtyDecimals = - Math.floor(item.quantity) === item.quantity ? 0 : 2; - // Without "Uplatnit DPH" the VAT columns are dropped entirely - // (the header does the same) instead of printing 0% / 0.00. - const vatCells = applyVat - ? ` - ${Math.floor(item.vat_rate)}% - ${formatNum(lineVat)}` - : ""; - return ` + const itemsHtml = items + .map((item, i) => { + const lineTotal = item.quantity * item.unit_price; + const qtyDecimals = Math.floor(item.quantity) === item.quantity ? 0 : 2; + return ` ${i + 1} ${escapeHtml(item.description)} ${formatNum(item.quantity, qtyDecimals)}${item.unit ? ` / ${escapeHtml(item.unit)}` : ""} ${formatNum(item.unit_price)} - ${formatNum(lineSubtotal)}${vatCells} ${formatNum(lineTotal)} `; - }) - .join(""); + }) + .join(""); - const paymentMethod = - String((order as Record).payment_method || "") || - (lang === "cs" ? "převodem" : "Bank transfer"); + const paymentMethod = + String(order.payment_method || "") || + (lang === "cs" ? "převodem" : "Bank transfer"); - let vatDetailHtml = ""; - if (applyVat) { - for (const [rate, data] of Object.entries(vatSummary)) { - if (data.vat > 0) { - vatDetailHtml += ` -
- ${escapeHtml(t.vat_label)} ${Math.floor(Number(rate))}%: - ${formatNum(data.vat)} ${escapeHtml(currency)} -
`; - } - } - } - - const notesRaw = order.notes ?? ""; - const notesStripped = notesRaw.replace(/<[^>]*>/g, "").trim(); - const notesHtml = notesStripped - ? ` + const notesRaw = order.notes ?? ""; + const notesStripped = notesRaw.replace(/<[^>]*>/g, "").trim(); + const notesHtml = notesStripped + ? `
${escapeHtml(t.notes)}
${cleanQuillHtml(DOMPurify.sanitize(notesRaw))}
` - : ""; + : ""; - // Quill indent CSS - let indentCSS = ""; - for (let n = 1; n <= 9; n++) { - const pad = n * 3; - const liPad = n * 3 + 1.5; - indentCSS += ` .ql-indent-${n} { padding-left: ${pad}em; }\n`; - indentCSS += ` li.ql-indent-${n} { padding-left: ${liPad}em; }\n`; - } + // Quill indent CSS + let indentCSS = ""; + for (let n = 1; n <= 9; n++) { + const pad = n * 3; + const liPad = n * 3 + 1.5; + indentCSS += ` .ql-indent-${n} { padding-left: ${pad}em; }\n`; + indentCSS += ` li.ql-indent-${n} { padding-left: ${liPad}em; }\n`; + } - const html = ` + return ` @@ -676,6 +573,12 @@ export default async function ordersPdfRoutes( color: #1a1a1a; margin-top: 2mm; } + .totals .vat-note { + text-align: right; + font-size: 8pt; + color: #646464; + margin-top: 1mm; + } /* Vystavil */ .issued-by { @@ -693,47 +596,6 @@ export default async function ordersPdfRoutes( line-height: 1.3; } - /* DPH rekapitulace + QR */ - .recap-section { - display: flex; - gap: 5mm; - align-items: flex-start; - margin-top: 1mm; - } - .recap-section .qr { - flex-shrink: 0; - width: 28mm; - } - .recap-section .qr img, - .recap-section .qr svg { width: 28mm; height: 28mm; } - - .recap-section table { - border-collapse: collapse; - font-size: 9pt; - flex: 1; - } - .recap-section table th { - font-size: 8pt; - font-weight: 600; - color: #555; - padding: 3px 6px; - text-align: right; - border-bottom: 0.5pt solid #ccc; - } - .recap-section table td { - padding: 3px 6px; - text-align: right; - border-bottom: 0.5pt solid #eee; - } - .recap-section table td.center { text-align: center; } - .recap-section table td.cnb-rate { - font-size: 8pt; - color: #888; - text-align: right; - border-bottom: none; - padding-top: 4px; - } - /* Prevzal / razitko */ .footer-row { display: flex; @@ -855,17 +717,10 @@ ${indentCSS} ${escapeHtml(t.col_no)} - ${escapeHtml(t.col_desc)} + ${escapeHtml(t.col_desc)} ${escapeHtml(t.col_qty)} ${escapeHtml(t.col_unit_price)} - ${escapeHtml(t.col_price)}${ - applyVat - ? ` - ${escapeHtml(t.col_vat_pct)} - ${escapeHtml(t.col_vat)}` - : "" - } - ${escapeHtml(t.col_total)} + ${escapeHtml(t.col_total)} @@ -873,24 +728,15 @@ ${indentCSS} - +
-
${ - applyVat - ? ` -
-
- ${escapeHtml(t.subtotal)} - ${formatNum(subtotal)} ${escapeHtml(currency)} -
${vatDetailHtml} -
` - : "" - } +
- ${escapeHtml(applyVat ? t.total : t.total_no_vat)} - ${formatNum(totalToPay)} ${escapeHtml(currency)} + ${escapeHtml(t.total_no_vat)} + ${formatNum(total)} ${escapeHtml(currency)}
${escapeHtml(t.amounts_in)} ${escapeHtml(currency)}
+
${escapeHtml(t.vat_notice)}
@@ -915,9 +761,96 @@ ${indentCSS} `; +} + +/* ── Route ───────────────────────────────────────────────────────── */ + +export default async function ordersPdfRoutes( + fastify: FastifyInstance, +): Promise { + fastify.post<{ Params: { id: string }; Body: Record }>( + "/:id/confirmation", + { preHandler: requirePermission("orders.view") }, + async (request, reply) => { + const id = parseId(request.params.id, reply); + if (id === null) return; + const parsed = parseBody(OrderPdfBodySchema, request.body || {}); + if ("error" in parsed) return error(reply, parsed.error, 400); + const body = parsed.data; + + try { + const lang = body.lang === "en" ? "en" : "cs"; + + const order = await prisma.orders.findUnique({ + where: { id }, + // The confirmation PDF never renders the PO attachment — don't pull + // the blob just to read the order header/items. + omit: { attachment_data: true }, + include: { + customers: true, + order_items: { orderBy: { position: "asc" } }, + }, + }); + + if (!order) { + return reply + .status(404) + .type("text/html") + .send("

Objednávka nenalezena

"); + } + + const settings = (await prisma.company_settings.findFirst()) as Record< + string, + unknown + > | null; + + // The confirmation PDF can be rendered from client-supplied items (e.g. + // a live preview of unsaved edits on the detail page) OR from the + // stored order. Fabricating descriptions/prices that don't reflect the + // stored order is an editing action, so the custom-items path requires + // `orders.edit` (admins bypass). A view-only caller is silently served + // the STORED order items instead — preventing a `orders.view` holder + // from producing an "official" confirmation with invented figures. + const authData = request.authData; + const canUseCustomItems = + authData?.roleName === "admin" || + !!authData?.permissions.includes("orders.edit"); + const customItemsRaw = + canUseCustomItems && Array.isArray(body.items) ? body.items : null; + + let items: OrderConfirmationPdfItem[]; + if (customItemsRaw && customItemsRaw.length > 0) { + items = customItemsRaw.map((it) => ({ + description: it.description, + quantity: it.quantity, + unit: it.unit, + unit_price: it.unit_price, + is_included_in_total: it.is_included_in_total !== false, + })); + } else { + items = order.order_items.map((it) => ({ + description: it.description || "", + quantity: Number(it.quantity) || 0, + unit: it.unit || "", + unit_price: Number(it.unit_price) || 0, + is_included_in_total: !!it.is_included_in_total, + })); + } + + const userName = request.authData + ? `${request.authData.firstName || ""} ${request.authData.lastName || ""}`.trim() + : ""; + + const html = renderOrderConfirmationHtml( + order, + items, + settings, + lang, + userName, + ); const pdfBuffer = await htmlToPdf(html); - const filename = `Potvrzeni-${orderNumber || String(id)}.pdf`; + const filename = `Potvrzeni-${order.order_number || String(id)}.pdf`; return reply .type("application/pdf") diff --git a/src/schemas/issued-orders.schema.ts b/src/schemas/issued-orders.schema.ts index 959c5fe..aad1ed7 100644 --- a/src/schemas/issued-orders.schema.ts +++ b/src/schemas/issued-orders.schema.ts @@ -1,10 +1,8 @@ import { z } from "zod"; import { - numberInRange, nonNegativeNumberFromForm, positiveNumberFromForm, nullableIntIdFromForm, - booleanFromForm, isoDateString, } from "./common"; @@ -14,7 +12,6 @@ export const IssuedOrderItemSchema = z.object({ quantity: positiveNumberFromForm.optional(), unit: z.string().max(20).nullish(), unit_price: nonNegativeNumberFromForm.optional(), - vat_rate: numberInRange(0, 100).optional(), position: z.number().int().nonnegative().optional(), }); @@ -31,8 +28,6 @@ export const CreateIssuedOrderSchema = z.object({ supplier_id: nullableIntIdFromForm.nullish(), status: z.enum(ISSUED_ORDER_STATUSES).optional(), currency: z.string().max(10).optional(), - vat_rate: numberInRange(0, 100).optional(), - apply_vat: booleanFromForm.optional(), exchange_rate: nonNegativeNumberFromForm.optional(), order_date: isoDateString.nullish(), delivery_date: isoDateString.nullish(), diff --git a/src/schemas/offers.schema.ts b/src/schemas/offers.schema.ts index 0bde5cc..5f04ea1 100644 --- a/src/schemas/offers.schema.ts +++ b/src/schemas/offers.schema.ts @@ -1,7 +1,6 @@ import { z } from "zod"; import { numberFromForm, - numberInRange, nonNegativeNumberFromForm, positiveNumberFromForm, nullableIntIdFromForm, @@ -33,8 +32,6 @@ export const CreateQuotationSchema = z.object({ valid_until: z.string().max(255).nullish(), currency: z.string().max(20).optional().default("CZK"), language: z.string().max(20).optional().default("cs"), - vat_rate: numberInRange(0, 100).optional().default(21.0), - apply_vat: booleanFromForm.optional().default(true), status: z .enum(["draft", "active", "ordered", "invalidated"]) .optional() @@ -52,8 +49,6 @@ export const UpdateQuotationSchema = z.object({ valid_until: z.union([z.string().max(255), z.null()]).optional(), currency: z.string().max(20).optional(), language: z.string().max(20).optional(), - vat_rate: numberInRange(0, 100).optional(), - apply_vat: booleanFromForm.optional(), status: z.enum(["draft", "active", "ordered", "invalidated"]).optional(), scope_title: z.string().max(255).nullish(), scope_description: z.string().max(8000).nullish(), diff --git a/src/schemas/orders.schema.ts b/src/schemas/orders.schema.ts index 8cb0541..73695f9 100644 --- a/src/schemas/orders.schema.ts +++ b/src/schemas/orders.schema.ts @@ -1,7 +1,6 @@ import { z } from "zod"; import { numberFromForm, - numberInRange, nonNegativeNumberFromForm, positiveNumberFromForm, intIdFromForm, @@ -42,8 +41,6 @@ export const CreateOrderSchema = z.object({ .default("prijata"), currency: z.string().max(20).optional().default("CZK"), language: z.string().max(20).optional().default("cs"), - vat_rate: numberInRange(0, 100).optional().default(21.0), - apply_vat: booleanFromForm.optional().default(true), exchange_rate: positiveNumberFromForm.optional().default(1.0), scope_title: z.string().max(255).nullish(), scope_description: z.string().max(8000).nullish(), @@ -62,8 +59,6 @@ export const UpdateOrderSchema = z.object({ scope_description: z.string().max(8000).nullish(), notes: z.string().max(8000).nullish(), customer_id: nullableIntIdFromForm.optional(), - vat_rate: numberInRange(0, 100).optional(), - apply_vat: booleanFromForm.optional(), items: z.array(OrderItemSchema).optional(), sections: z.array(OrderSectionSchema).optional(), }); diff --git a/src/services/issued-orders.service.ts b/src/services/issued-orders.service.ts index 18a65f0..2047d66 100644 --- a/src/services/issued-orders.service.ts +++ b/src/services/issued-orders.service.ts @@ -14,7 +14,6 @@ export interface IssuedOrderItemInput { quantity?: number | string | null; unit?: string | null; unit_price?: number | string | null; - vat_rate?: number | string | null; position?: number | null; } @@ -23,8 +22,6 @@ export interface IssuedOrderInput { supplier_id?: number | string | null; status?: string; currency?: string; - vat_rate?: number | string | null; - apply_vat?: boolean | number | string; exchange_rate?: number | string | null; order_date?: string | null; delivery_date?: string | null; @@ -106,32 +103,18 @@ const ALLOWED_SORT_FIELDS = [ "currency", ]; -/** NET base + VAT-on-top, rounded per line before accumulation (mirrors invoices). */ +/** + * NET-only total: issued orders (PO) are not tax documents — VAT never appears + * on them. The total is the plain sum of qty × unit_price, rounded to 2dp. + */ export function computeIssuedOrderTotals( - items: Array<{ quantity: unknown; unit_price: unknown; vat_rate: unknown }>, - applyVat: boolean | null, - defaultVatRate: unknown, + items: Array<{ quantity: unknown; unit_price: unknown }>, ) { - let subtotal = 0; - let vat = 0; + let total = 0; for (const it of items) { - const base = (Number(it.quantity) || 0) * (Number(it.unit_price) || 0); - subtotal += base; - if (applyVat) { - const rate = - it.vat_rate != null && it.vat_rate !== "" - ? Number(it.vat_rate) - : defaultVatRate != null - ? Number(defaultVatRate) - : 21; - vat += Math.round(base * (rate / 100) * 100) / 100; - } + total += (Number(it.quantity) || 0) * (Number(it.unit_price) || 0); } - return { - subtotal: Math.round(subtotal * 100) / 100, - vat_amount: Math.round(vat * 100) / 100, - total: Math.round((subtotal + vat) * 100) / 100, - }; + return { total: Math.round(total * 100) / 100 }; } export async function listIssuedOrders(params: ListIssuedOrdersParams) { @@ -163,11 +146,7 @@ export async function listIssuedOrders(params: ListIssuedOrdersParams) { ]); const enriched = rows.map((o) => { - const totals = computeIssuedOrderTotals( - o.issued_order_items, - o.apply_vat, - o.vat_rate, - ); + const totals = computeIssuedOrderTotals(o.issued_order_items); const { issued_order_items, ...rest } = o; return { ...rest, @@ -181,7 +160,7 @@ export async function listIssuedOrders(params: ListIssuedOrdersParams) { } /** - * Sum issued-order TOTAL (incl. VAT) per currency across the WHOLE filtered set + * Sum issued-order NET total per currency across the WHOLE filtered set * (not a single page). Reuses `buildIssuedOrderWhere` so filters track the list, * and `computeIssuedOrderTotals` so per-order math matches the list/detail. * Currency defaults to "CZK" when the column is null. Returns one entry per @@ -199,11 +178,7 @@ export async function getIssuedOrderTotals( const byCurrency: Record = {}; for (const o of rows) { - const { total } = computeIssuedOrderTotals( - o.issued_order_items, - o.apply_vat, - o.vat_rate, - ); + const { total } = computeIssuedOrderTotals(o.issued_order_items); const cur = o.currency || "CZK"; byCurrency[cur] = (byCurrency[cur] || 0) + (Number(total) || 0); } @@ -286,8 +261,6 @@ export async function createIssuedOrder(body: IssuedOrderInput) { supplier_id: supplierId, status: status as $Enums.issued_orders_status, currency: body.currency ? String(body.currency) : "CZK", - vat_rate: body.vat_rate != null ? Number(body.vat_rate) : 21.0, - apply_vat: body.apply_vat !== false, exchange_rate: body.exchange_rate != null ? Number(body.exchange_rate) : 1.0, // order_date is @db.Date (truncated to the UTC date part) — the @@ -322,7 +295,6 @@ export async function createIssuedOrder(body: IssuedOrderInput) { quantity: item.quantity ?? 1, unit: item.unit ?? null, unit_price: item.unit_price ?? 0, - vat_rate: item.vat_rate ?? 21.0, position: item.position ?? i, })), }); @@ -373,12 +345,6 @@ export async function updateIssuedOrder(id: number, body: IssuedOrderInput) { } data.supplier_id = supplierId; } - if (body.vat_rate !== undefined) data.vat_rate = Number(body.vat_rate); - if (body.apply_vat !== undefined) - data.apply_vat = - body.apply_vat === true || - body.apply_vat === 1 || - body.apply_vat === "1"; if (body.exchange_rate !== undefined) data.exchange_rate = body.exchange_rate != null ? Number(body.exchange_rate) : null; @@ -431,7 +397,6 @@ export async function updateIssuedOrder(id: number, body: IssuedOrderInput) { quantity: item.quantity ?? 1, unit: item.unit ?? null, unit_price: item.unit_price ?? 0, - vat_rate: item.vat_rate ?? 21.0, position: item.position ?? i, })), }); diff --git a/src/services/offers.service.ts b/src/services/offers.service.ts index 3f43142..b6e65c4 100644 --- a/src/services/offers.service.ts +++ b/src/services/offers.service.ts @@ -77,28 +77,22 @@ function buildOfferWhere(params: OfferFilterParams): Record { return where; } +// Offers are NOT tax documents — totals are NET only (no VAT anywhere). function enrichQuotation(q: any) { - const subtotal = q.quotation_items + const total = q.quotation_items .filter((i: any) => i.is_included_in_total !== false) .reduce( (s: number, i: any) => s + (Number(i.quantity) || 0) * (Number(i.unit_price) || 0), 0, ); - const vatAmount = q.apply_vat - ? subtotal * - ((q.vat_rate != null && q.vat_rate !== "" ? Number(q.vat_rate) : 21) / - 100) - : 0; const { quotation_items, scope_sections, ...rest } = q; return { ...rest, items: quotation_items, sections: scope_sections, customer_name: q.customers?.name || null, - subtotal: Math.round(subtotal * 100) / 100, - vat_amount: Math.round(vatAmount * 100) / 100, - total: Math.round((subtotal + vatAmount) * 100) / 100, + total: Math.round(total * 100) / 100, }; } @@ -148,7 +142,7 @@ export async function listOffers(params: ListOffersParams) { } /** - * Sum offer TOTAL (incl. VAT) per currency across the WHOLE filtered set (not a + * Sum offer NET total per currency across the WHOLE filtered set (not a * single page). Reuses `buildOfferWhere` so the filters track the list, and * `enrichQuotation` so the per-offer math matches the list/detail exactly. * Returns one entry per currency, rounded to 2dp, zero/empty totals dropped. @@ -259,8 +253,6 @@ export async function createOffer(body: Record) { : null, currency: body.currency ? String(body.currency) : "CZK", language: body.language ? String(body.language) : "cs", - vat_rate: body.vat_rate != null ? Number(body.vat_rate) : 21.0, - apply_vat: body.apply_vat !== false, status, scope_title: body.scope_title ? String(body.scope_title) : null, scope_description: body.scope_description @@ -351,13 +343,6 @@ export async function updateOffer(id: number, body: Record) { : undefined, currency: body.currency !== undefined ? String(body.currency) : undefined, language: body.language !== undefined ? String(body.language) : undefined, - vat_rate: body.vat_rate !== undefined ? Number(body.vat_rate) : undefined, - apply_vat: - body.apply_vat !== undefined - ? body.apply_vat === true || - body.apply_vat === 1 || - body.apply_vat === "1" - : undefined, status: body.status !== undefined ? String(body.status) : undefined, project_code: body.project_code !== undefined @@ -479,8 +464,6 @@ export async function duplicateOffer(id: number) { valid_until: null, currency: original.currency, language: original.language, - vat_rate: original.vat_rate, - apply_vat: original.apply_vat, status: "active", scope_title: original.scope_title, scope_description: original.scope_description, diff --git a/src/services/orders.service.ts b/src/services/orders.service.ts index 1a42225..2be0fd4 100644 --- a/src/services/orders.service.ts +++ b/src/services/orders.service.ts @@ -72,23 +72,20 @@ async function syncProjectStatus( }); } -// ⚠ Also called by getOrderTotals with a MINIMAL select (currency, apply_vat, -// vat_rate, item quantity/unit_price/is_included_in_total). If you read a NEW -// order/item field here, add it to that select — a field missing from the -// select is silently 0 in the per-currency totals. +// ⚠ Also called by getOrderTotals with a MINIMAL select (currency, item +// quantity/unit_price/is_included_in_total). If you read a NEW order/item +// field here, add it to that select — a field missing from the select is +// silently 0 in the per-currency totals. +// +// Orders are NOT tax documents — totals are NET only (no VAT anywhere). function enrichOrder(o: any) { - const subtotal = o.order_items + const total = o.order_items .filter((i: any) => i.is_included_in_total !== false) .reduce( (s: number, i: any) => s + (Number(i.quantity) || 0) * (Number(i.unit_price) || 0), 0, ); - const vatAmount = o.apply_vat - ? subtotal * - ((o.vat_rate != null && o.vat_rate !== "" ? Number(o.vat_rate) : 21) / - 100) - : 0; const { order_items, order_sections, ...rest } = o; const invoice = o.invoices?.[0] || null; return { @@ -100,9 +97,7 @@ function enrichOrder(o: any) { project_code: o.quotations?.project_code || null, invoice_id: invoice?.id || null, invoice_number: invoice?.invoice_number || null, - subtotal: Math.round(subtotal * 100) / 100, - vat_amount: Math.round(vatAmount * 100) / 100, - total: Math.round((subtotal + vatAmount) * 100) / 100, + total: Math.round(total * 100) / 100, }; } @@ -192,7 +187,7 @@ export interface CurrencyAmount { } /** - * Sum order TOTAL (incl. VAT) per currency across the WHOLE filtered set (not a + * Sum order NET total per currency across the WHOLE filtered set (not a * single page). Reuses `buildOrderWhere` so the filters track the list, and * `enrichOrder` so the per-order math matches the list/detail exactly. Returns * one entry per currency, rounded to 2dp, zero/empty totals dropped. @@ -202,15 +197,13 @@ export async function getOrderTotals( ): Promise<{ totals: CurrencyAmount[] }> { const where = buildOrderWhere(params); - // Select ONLY what the math needs (currency + VAT flags + item numbers). + // Select ONLY what the math needs (currency + item numbers). // This runs over the WHOLE filtered set, so pulling attachment blobs, // sections HTML or relations here would multiply the query size for nothing. const orders = await prisma.orders.findMany({ where, select: { currency: true, - apply_vat: true, - vat_rate: true, order_items: { select: { quantity: true, @@ -335,8 +328,6 @@ export async function createOrderFromQuotation( status: "prijata", currency: quotation.currency || "CZK", language: quotation.language || "cs", - vat_rate: quotation.vat_rate ?? 21.0, - apply_vat: quotation.apply_vat ?? true, scope_title: quotation.scope_title, scope_description: quotation.scope_description, attachment_data: attachmentBuffer @@ -428,8 +419,6 @@ interface CreateOrderData { status: string; currency: string; language: string; - vat_rate: number; - apply_vat?: boolean; exchange_rate?: number; scope_title?: string | null; scope_description?: string | null; @@ -469,8 +458,6 @@ export async function createOrder( status: body.status, currency: body.currency, language: body.language, - vat_rate: body.vat_rate, - apply_vat: body.apply_vat !== false, exchange_rate: body.exchange_rate, scope_title: body.scope_title ?? null, scope_description: body.scope_description ?? null, @@ -629,10 +616,6 @@ export async function updateOrder(id: number, body: UpdateOrderData) { } if (body.customer_id !== undefined) data.customer_id = body.customer_id ? Number(body.customer_id) : null; - if (body.vat_rate !== undefined) data.vat_rate = Number(body.vat_rate); - if (body.apply_vat !== undefined) - data.apply_vat = - body.apply_vat === true || body.apply_vat === 1 || body.apply_vat === "1"; if (Array.isArray(body.items) || Array.isArray(body.sections)) { if (currentStatus !== "prijata" && currentStatus !== "v_realizaci") {