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

@@ -77,28 +77,22 @@ function buildOfferWhere(params: OfferFilterParams): Record<string, unknown> {
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<string, any>) {
: 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<string, any>) {
: 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,