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:
@@ -200,15 +200,11 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
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<Lang, Record<string, string>> = {
|
||||
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) => `<div class="address-line">${escapeHtml(l)}</div>`)
|
||||
.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<string, { base: number; vat: number }> = {};
|
||||
// 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(
|
||||
? `<div class="item-sub">${escapeHtml(it.item_description)}</div>`
|
||||
: ""
|
||||
}`;
|
||||
// Without "Uplatnit DPH" the VAT columns are dropped entirely (the
|
||||
// header does the same) instead of printing meaningless 0% / 0.00.
|
||||
const vatCells = applyVat
|
||||
? `
|
||||
<td class="center">${Math.floor(rate)}%</td>
|
||||
<td class="right">${formatNum(lineVat)}</td>`
|
||||
: "";
|
||||
return `<tr>
|
||||
<td class="row-num">${i + 1}</td>
|
||||
<td class="desc">${descHtml}</td>
|
||||
<td class="center">${formatNum(qty, qtyDecimals)}${it.unit ? ` / ${escapeHtml(it.unit)}` : ""}</td>
|
||||
<td class="right">${formatNum(unitPrice)}</td>
|
||||
<td class="right">${formatNum(lineSubtotal)}</td>${vatCells}
|
||||
<td class="right total-cell">${formatNum(lineTotal)}</td>
|
||||
</tr>`;
|
||||
})
|
||||
.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 += `
|
||||
<div class="row">
|
||||
<span class="label">${escapeHtml(t.vat_label)} ${Math.floor(Number(rate))}%:</span>
|
||||
<span class="value">${formatNum(data.vat)} ${escapeHtml(currency)}</span>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
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}
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="center" style="width:3%">${escapeHtml(t.col_no)}</th>
|
||||
<th style="width:${applyVat ? 36 : 46}%">${escapeHtml(t.col_desc)}</th>
|
||||
<th style="width:56%">${escapeHtml(t.col_desc)}</th>
|
||||
<th class="center" style="width:10%">${escapeHtml(t.col_qty)}</th>
|
||||
<th class="right" style="width:10%">${escapeHtml(t.col_unit_price)}</th>
|
||||
<th class="right" style="width:10%">${escapeHtml(t.col_price)}</th>${
|
||||
applyVat
|
||||
? `
|
||||
<th class="center" style="width:5%">${escapeHtml(t.col_vat_pct)}</th>
|
||||
<th class="right" style="width:10%">${escapeHtml(t.col_vat)}</th>`
|
||||
: ""
|
||||
}
|
||||
<th class="right" style="width:${applyVat ? 16 : 21}%">${escapeHtml(t.col_total)}</th>
|
||||
<th class="right" style="width:21%">${escapeHtml(t.col_total)}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -796,24 +743,15 @@ ${indentCSS}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Soucty (bez DPH jen souhrnny radek - mezisoucet by ho jen opakoval) -->
|
||||
<!-- Soucty (jen souhrnny radek bez DPH - mezisoucet by ho jen opakoval) -->
|
||||
<div class="totals-wrapper">
|
||||
<div class="totals">${
|
||||
applyVat
|
||||
? `
|
||||
<div class="detail-rows">
|
||||
<div class="row">
|
||||
<span class="label">${escapeHtml(t.subtotal)}</span>
|
||||
<span class="value">${formatNum(subtotal)} ${escapeHtml(currency)}</span>
|
||||
</div>${vatDetailHtml}
|
||||
</div>`
|
||||
: ""
|
||||
}
|
||||
<div class="totals">
|
||||
<div class="grand">
|
||||
<span class="label">${escapeHtml(applyVat ? t.total : t.total_no_vat)}</span>
|
||||
<span class="value">${formatNum(totalToPay)} ${escapeHtml(currency)}</span>
|
||||
<span class="label">${escapeHtml(t.total_no_vat)}</span>
|
||||
<span class="value">${formatNum(total)} ${escapeHtml(currency)}</span>
|
||||
</div>
|
||||
<div class="currency-note">${escapeHtml(t.amounts_in)} ${escapeHtml(currency)}</div>
|
||||
<div class="vat-note">${escapeHtml(t.vat_notice)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user