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

@@ -57,7 +57,6 @@ import {
DateField,
Field,
StatusChip,
CheckboxField,
ConfirmDialog,
LoadingState,
PageEnter,
@@ -85,11 +84,6 @@ const TRANSITION_LABELS: Record<string, string> = {
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({
<Box
sx={{
display: "grid",
gridTemplateColumns: "repeat(2, minmax(0, 1fr))",
gridTemplateColumns: "repeat(3, minmax(0, 1fr))",
gap: 1,
}}
>
@@ -317,21 +305,6 @@ function SortableOrderRow({
slotProps={{ htmlInput: { step: "any" } }}
InputProps={{ readOnly }}
/>
{apply_vat &&
(readOnly ? (
<TextField
label="DPH"
value={`${Number(item.vat_rate)}%`}
InputProps={{ readOnly: true }}
/>
) : (
<Select
label="DPH"
value={String(item.vat_rate)}
onChange={(val) => onUpdate(index, "vat_rate", Number(val))}
options={VAT_OPTIONS}
/>
))}
</Box>
<Box
sx={{
@@ -436,20 +409,6 @@ function SortableOrderRow({
sx={{ "& input": { textAlign: "right" } }}
/>
</TableCell>
{apply_vat ? (
<TableCell>
{readOnly ? (
<Box sx={{ textAlign: "center" }}>{Number(item.vat_rate)}%</Box>
) : (
<Select
value={String(item.vat_rate)}
onChange={(val) => onUpdate(index, "vat_rate", Number(val))}
sx={{ minWidth: "4.5rem" }}
options={VAT_OPTIONS}
/>
)}
</TableCell>
) : null}
<TableCell
align="right"
sx={{
@@ -496,7 +455,6 @@ export default function IssuedOrderDetail() {
quantity: 1,
unit: "ks",
unit_price: 0,
vat_rate: 21,
}),
[],
);
@@ -513,8 +471,6 @@ export default function IssuedOrderDetail() {
supplier_id: null,
supplier_name: "",
currency: "CZK",
apply_vat: true,
vat_rate: 21,
order_date: todayLocalStr(),
delivery_date: "",
language: "cs",
@@ -534,7 +490,6 @@ export default function IssuedOrderDetail() {
quantity: 1,
unit: "ks",
unit_price: 0,
vat_rate: 21,
},
]);
const [errors, setErrors] = useState<Record<string, string>>({});
@@ -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<number, number> = {};
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<Record<string, unknown>, { id: number }>({
@@ -774,8 +719,6 @@ export default function IssuedOrderDetail() {
const payload: Record<string, unknown> = {
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() {
<Box
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", md: "1fr 1fr 1fr 1fr" },
gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" },
gap: 2,
}}
>
@@ -1202,31 +1144,6 @@ export default function IssuedOrderDetail() {
]}
/>
</Field>
<Field label="Sazba DPH">
<Select
value={String(form.vat_rate)}
disabled={!editable || !form.apply_vat}
onChange={(val) =>
setForm((prev) => ({ ...prev, vat_rate: Number(val) }))
}
options={VAT_OPTIONS}
/>
</Field>
<Field label="DPH">
<Box sx={{ display: "flex", alignItems: "center", height: 40 }}>
<CheckboxField
label="Uplatnit DPH"
checked={form.apply_vat}
disabled={!editable}
onChange={(v) =>
setForm((prev) => ({
...prev,
apply_vat: v,
}))
}
/>
</Box>
</Field>
</Box>
<Field label="Vystavil">
@@ -1291,7 +1208,6 @@ export default function IssuedOrderDetail() {
item={item}
index={index}
currency={form.currency}
apply_vat={form.apply_vat}
readOnly={!editable}
onUpdate={updateItem}
onRemove={removeItem}
@@ -1324,11 +1240,6 @@ export default function IssuedOrderDetail() {
<TableCell sx={{ width: "8rem" }} align="center">
Jedn. cena
</TableCell>
{form.apply_vat ? (
<TableCell sx={{ width: "5rem" }} align="center">
DPH
</TableCell>
) : null}
<TableCell sx={{ width: "8rem" }} align="right">
Celkem
</TableCell>
@@ -1342,7 +1253,6 @@ export default function IssuedOrderDetail() {
item={item}
index={index}
currency={form.currency}
apply_vat={form.apply_vat}
readOnly={!editable}
onUpdate={updateItem}
onRemove={removeItem}
@@ -1356,7 +1266,7 @@ export default function IssuedOrderDetail() {
</SortableContext>
</DndContext>
{/* Totals */}
{/* Totals (NET only — issued orders are not tax documents) */}
<Box
sx={{
mt: 2,
@@ -1367,34 +1277,6 @@ export default function IssuedOrderDetail() {
gap: 0.5,
}}
>
<Box sx={{ display: "flex", justifyContent: "space-between" }}>
<Typography variant="body2" color="text.secondary">
Mezisoučet:
</Typography>
<Typography
variant="body2"
sx={{ fontFamily: "'DM Mono', Menlo, monospace" }}
>
{formatCurrency(totals.subtotal, form.currency)}
</Typography>
</Box>
{form.apply_vat &&
Object.entries(totals.vatByRate).map(([rate, amount]) => (
<Box
key={rate}
sx={{ display: "flex", justifyContent: "space-between" }}
>
<Typography variant="body2" color="text.secondary">
DPH {rate}%:
</Typography>
<Typography
variant="body2"
sx={{ fontFamily: "'DM Mono', Menlo, monospace" }}
>
{formatCurrency(amount, form.currency)}
</Typography>
</Box>
))}
<Box
sx={{
display: "flex",
@@ -1406,7 +1288,7 @@ export default function IssuedOrderDetail() {
}}
>
<Typography variant="body2" sx={{ fontWeight: 700 }}>
Celkem:
Celkem bez DPH:
</Typography>
<Typography
variant="body2"