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", () => {