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:
@@ -14,7 +14,6 @@ export interface IssuedOrderItemInput {
|
||||
quantity?: number | string | null;
|
||||
unit?: string | null;
|
||||
unit_price?: number | string | null;
|
||||
vat_rate?: number | string | null;
|
||||
position?: number | null;
|
||||
}
|
||||
|
||||
@@ -23,8 +22,6 @@ export interface IssuedOrderInput {
|
||||
supplier_id?: number | string | null;
|
||||
status?: string;
|
||||
currency?: string;
|
||||
vat_rate?: number | string | null;
|
||||
apply_vat?: boolean | number | string;
|
||||
exchange_rate?: number | string | null;
|
||||
order_date?: string | null;
|
||||
delivery_date?: string | null;
|
||||
@@ -106,32 +103,18 @@ const ALLOWED_SORT_FIELDS = [
|
||||
"currency",
|
||||
];
|
||||
|
||||
/** NET base + VAT-on-top, rounded per line before accumulation (mirrors invoices). */
|
||||
/**
|
||||
* NET-only total: issued orders (PO) are not tax documents — VAT never appears
|
||||
* on them. The total is the plain sum of qty × unit_price, rounded to 2dp.
|
||||
*/
|
||||
export function computeIssuedOrderTotals(
|
||||
items: Array<{ quantity: unknown; unit_price: unknown; vat_rate: unknown }>,
|
||||
applyVat: boolean | null,
|
||||
defaultVatRate: unknown,
|
||||
items: Array<{ quantity: unknown; unit_price: unknown }>,
|
||||
) {
|
||||
let subtotal = 0;
|
||||
let vat = 0;
|
||||
let total = 0;
|
||||
for (const it of items) {
|
||||
const base = (Number(it.quantity) || 0) * (Number(it.unit_price) || 0);
|
||||
subtotal += base;
|
||||
if (applyVat) {
|
||||
const rate =
|
||||
it.vat_rate != null && it.vat_rate !== ""
|
||||
? Number(it.vat_rate)
|
||||
: defaultVatRate != null
|
||||
? Number(defaultVatRate)
|
||||
: 21;
|
||||
vat += Math.round(base * (rate / 100) * 100) / 100;
|
||||
}
|
||||
total += (Number(it.quantity) || 0) * (Number(it.unit_price) || 0);
|
||||
}
|
||||
return {
|
||||
subtotal: Math.round(subtotal * 100) / 100,
|
||||
vat_amount: Math.round(vat * 100) / 100,
|
||||
total: Math.round((subtotal + vat) * 100) / 100,
|
||||
};
|
||||
return { total: Math.round(total * 100) / 100 };
|
||||
}
|
||||
|
||||
export async function listIssuedOrders(params: ListIssuedOrdersParams) {
|
||||
@@ -163,11 +146,7 @@ export async function listIssuedOrders(params: ListIssuedOrdersParams) {
|
||||
]);
|
||||
|
||||
const enriched = rows.map((o) => {
|
||||
const totals = computeIssuedOrderTotals(
|
||||
o.issued_order_items,
|
||||
o.apply_vat,
|
||||
o.vat_rate,
|
||||
);
|
||||
const totals = computeIssuedOrderTotals(o.issued_order_items);
|
||||
const { issued_order_items, ...rest } = o;
|
||||
return {
|
||||
...rest,
|
||||
@@ -181,7 +160,7 @@ export async function listIssuedOrders(params: ListIssuedOrdersParams) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Sum issued-order TOTAL (incl. VAT) per currency across the WHOLE filtered set
|
||||
* Sum issued-order NET total per currency across the WHOLE filtered set
|
||||
* (not a single page). Reuses `buildIssuedOrderWhere` so filters track the list,
|
||||
* and `computeIssuedOrderTotals` so per-order math matches the list/detail.
|
||||
* Currency defaults to "CZK" when the column is null. Returns one entry per
|
||||
@@ -199,11 +178,7 @@ export async function getIssuedOrderTotals(
|
||||
|
||||
const byCurrency: Record<string, number> = {};
|
||||
for (const o of rows) {
|
||||
const { total } = computeIssuedOrderTotals(
|
||||
o.issued_order_items,
|
||||
o.apply_vat,
|
||||
o.vat_rate,
|
||||
);
|
||||
const { total } = computeIssuedOrderTotals(o.issued_order_items);
|
||||
const cur = o.currency || "CZK";
|
||||
byCurrency[cur] = (byCurrency[cur] || 0) + (Number(total) || 0);
|
||||
}
|
||||
@@ -286,8 +261,6 @@ export async function createIssuedOrder(body: IssuedOrderInput) {
|
||||
supplier_id: supplierId,
|
||||
status: status as $Enums.issued_orders_status,
|
||||
currency: body.currency ? String(body.currency) : "CZK",
|
||||
vat_rate: body.vat_rate != null ? Number(body.vat_rate) : 21.0,
|
||||
apply_vat: body.apply_vat !== false,
|
||||
exchange_rate:
|
||||
body.exchange_rate != null ? Number(body.exchange_rate) : 1.0,
|
||||
// order_date is @db.Date (truncated to the UTC date part) — the
|
||||
@@ -322,7 +295,6 @@ export async function createIssuedOrder(body: IssuedOrderInput) {
|
||||
quantity: item.quantity ?? 1,
|
||||
unit: item.unit ?? null,
|
||||
unit_price: item.unit_price ?? 0,
|
||||
vat_rate: item.vat_rate ?? 21.0,
|
||||
position: item.position ?? i,
|
||||
})),
|
||||
});
|
||||
@@ -373,12 +345,6 @@ export async function updateIssuedOrder(id: number, body: IssuedOrderInput) {
|
||||
}
|
||||
data.supplier_id = supplierId;
|
||||
}
|
||||
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 (body.exchange_rate !== undefined)
|
||||
data.exchange_rate =
|
||||
body.exchange_rate != null ? Number(body.exchange_rate) : null;
|
||||
@@ -431,7 +397,6 @@ export async function updateIssuedOrder(id: number, body: IssuedOrderInput) {
|
||||
quantity: item.quantity ?? 1,
|
||||
unit: item.unit ?? null,
|
||||
unit_price: item.unit_price ?? 0,
|
||||
vat_rate: item.vat_rate ?? 21.0,
|
||||
position: item.position ?? i,
|
||||
})),
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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") {
|
||||
|
||||
Reference in New Issue
Block a user