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

@@ -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") {