feat(documents): per-item Sleva (% discount) on offers + invoices

Adds an optional per-line-item percentage discount ("Sleva") to offers and
issued invoices, on both the detail-page editor and the PDF.

- DB: discount Decimal(5,2) DEFAULT 0 on quotation_items + invoice_items
  (migration 20260613195833_add_item_discount).
- Totals/VAT: new shared lineNet() (qty × price × (1 − discount/100)); offers'
  NET total and invoices' subtotal AND VAT now compute on the discounted net
  (every getBase/enrichQuotation/stats path). Backward compatible: discount 0
  is identical to before.
- Schemas: discount = numberInRange(0,100), default 0; persisted on
  create/update (+ offer duplicate).
- Detail editors: editable "Sleva %" column — offers via DocumentItemsEditor's
  new opt-in showDiscount prop (issued orders unaffected); invoices in their own
  editor. Spinners hidden + 7rem column so "100" is fully visible. Read-only
  invoice view shows the column when any line has a discount.
- PDFs (offers + invoices): conditional "Sleva"/"Discount" column rendered only
  when an item has a discount; line/total/VAT always use the discounted net.

Tests: +16 (money, totals incl. VAT-on-discounted-net, schema range, PDF
column present/absent). Full suite 665 pass, tsc -b clean, lint 0. Editor
verified live via Playwright. Design spec in docs/superpowers/specs/.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-06-13 20:46:44 +02:00
parent 3787cc4dd0
commit 719d1f2659
21 changed files with 554 additions and 33 deletions

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "app-ts",
"version": "2.4.35",
"version": "2.4.36",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "app-ts",
"version": "2.4.35",
"version": "2.4.36",
"license": "ISC",
"dependencies": {
"@anthropic-ai/sdk": "^0.102.0",

View File

@@ -1,6 +1,6 @@
{
"name": "app-ts",
"version": "2.4.35",
"version": "2.4.36",
"description": "",
"main": "dist/server.js",
"scripts": {

View File

@@ -0,0 +1,6 @@
-- AlterTable
ALTER TABLE `invoice_items` ADD COLUMN `discount` DECIMAL(5, 2) NULL DEFAULT 0.00;
-- AlterTable
ALTER TABLE `quotation_items` ADD COLUMN `discount` DECIMAL(5, 2) NULL DEFAULT 0.00;

View File

@@ -208,6 +208,7 @@ model invoice_items {
quantity Decimal? @default(1.000) @db.Decimal(12, 3)
unit String? @db.VarChar(20)
unit_price Decimal? @default(0.00) @db.Decimal(12, 2)
discount Decimal? @default(0.00) @db.Decimal(5, 2)
vat_rate Decimal? @default(21.00) @db.Decimal(5, 2)
position Int? @default(0)
invoices invoices @relation(fields: [invoice_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "invoice_items_ibfk_1")
@@ -505,6 +506,7 @@ model quotation_items {
quantity Decimal? @default(1.000) @db.Decimal(12, 3)
unit String? @db.VarChar(20)
unit_price Decimal? @default(0.00) @db.Decimal(12, 2)
discount Decimal? @default(0.00) @db.Decimal(5, 2)
is_included_in_total Boolean? @default(true)
modified_at DateTime? @db.DateTime(0)
quotations quotations @relation(fields: [quotation_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "quotation_items_ibfk_1")

View File

@@ -0,0 +1,130 @@
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import Fastify from "fastify";
import jwt from "jsonwebtoken";
import prisma from "../config/database";
import { config } from "../config/env";
import offersPdfRoutes from "../routes/admin/offers-pdf";
import invoicesPdfRoutes from "../routes/admin/invoices-pdf";
import { createInvoice } from "../services/invoices.service";
/**
* The per-item Sleva column is CONDITIONAL on the PDF: render it only when at
* least one item carries a discount > 0; otherwise the table looks exactly as
* before. The discounted net always flows into the line/grand totals.
*/
let app: ReturnType<typeof Fastify> | null = null;
let adminToken = "";
const quotationIds: number[] = [];
const invoiceIds: number[] = [];
beforeAll(async () => {
app = Fastify({ logger: false });
await app.register(offersPdfRoutes, { prefix: "/api/admin/offers-pdf" });
await app.register(invoicesPdfRoutes, { prefix: "/api/admin/invoices-pdf" });
const admin = await prisma.users.findFirst({
where: { roles: { name: "admin" } },
});
if (!admin) throw new Error("Test setup: admin user not found");
adminToken = jwt.sign(
{ sub: admin.id, username: admin.username, role: "admin" },
config.jwt.secret,
{ expiresIn: "15m" },
);
});
afterAll(async () => {
for (const id of quotationIds)
await prisma.quotations.deleteMany({ where: { id } });
for (const id of invoiceIds)
await prisma.invoices.deleteMany({ where: { id } });
if (app) await app.close();
});
const offerHtml = async (discount: number): Promise<string> => {
const q = await prisma.quotations.create({
data: {
quotation_number: `Q-SLEVA-${discount}-${Date.now()}`,
status: "active",
currency: "CZK",
language: "cs",
quotation_items: {
create: [
{
description: "Položka",
quantity: 2,
unit: "ks",
unit_price: 100,
discount,
is_included_in_total: true,
position: 0,
},
],
},
},
});
quotationIds.push(q.id);
const res = await app!.inject({
method: "GET",
url: `/api/admin/offers-pdf/${q.id}`,
headers: { Authorization: `Bearer ${adminToken}` },
});
expect(res.statusCode).toBe(200);
return res.body;
};
describe("offer PDF Sleva column", () => {
it("renders a Sleva column + discounted total when an item has a discount", async () => {
const html = await offerHtml(10);
expect(html).toContain("Sleva");
// 2 × 100 with 10% off => 180,00 (no thousands separator at this size).
expect(html).toContain("180,00");
expect(html).not.toContain("200,00");
});
it("omits the Sleva column when no item has a discount", async () => {
const html = await offerHtml(0);
expect(html).not.toContain("Sleva");
expect(html).toContain("200,00");
});
});
const invoiceHtml = async (discount: number): Promise<string> => {
const inv = await createInvoice({
status: "draft",
currency: "CZK",
apply_vat: true,
vat_rate: 21,
language: "cs",
items: [
{
description: "Položka",
quantity: 2,
unit_price: 1000,
discount,
vat_rate: 21,
},
],
});
invoiceIds.push(inv.id);
const res = await app!.inject({
method: "GET",
url: `/api/admin/invoices-pdf/${inv.id}`,
headers: { Authorization: `Bearer ${adminToken}` },
});
expect(res.statusCode).toBe(200);
return res.body;
};
describe("invoice PDF Sleva column", () => {
it("renders a Sleva column when an item has a discount", async () => {
const html = await invoiceHtml(10);
expect(html).toContain("Sleva");
expect(html).toContain("10");
});
it("omits the Sleva column when no item has a discount", async () => {
const html = await invoiceHtml(0);
expect(html).not.toContain("Sleva");
});
});

View File

@@ -0,0 +1,55 @@
import { describe, it, expect } from "vitest";
import { CreateQuotationSchema } from "../schemas/offers.schema";
import { CreateInvoiceSchema } from "../schemas/invoices.schema";
/**
* The per-item Sleva is a percentage: the offer- and invoice-item schemas must
* accept 0100, default to 0 when absent, and reject out-of-range values.
*/
describe("offer item discount schema", () => {
it("accepts a 0100 discount", () => {
const r = CreateQuotationSchema.safeParse({
items: [{ description: "X", quantity: 1, unit_price: 100, discount: 15 }],
});
expect(r.success).toBe(true);
expect(r.success && r.data.items?.[0].discount).toBe(15);
});
it("defaults discount to 0 when absent", () => {
const r = CreateQuotationSchema.safeParse({
items: [{ description: "X", quantity: 1, unit_price: 100 }],
});
expect(r.success && r.data.items?.[0].discount).toBe(0);
});
it("rejects a discount above 100", () => {
const r = CreateQuotationSchema.safeParse({
items: [
{ description: "X", quantity: 1, unit_price: 100, discount: 150 },
],
});
expect(r.success).toBe(false);
});
});
describe("invoice item discount schema", () => {
it("accepts a 0100 discount and defaults to 0", () => {
const ok = CreateInvoiceSchema.safeParse({
items: [{ description: "X", quantity: 1, unit_price: 100, discount: 10 }],
});
expect(ok.success && ok.data.items?.[0].discount).toBe(10);
const def = CreateInvoiceSchema.safeParse({
items: [{ description: "X", quantity: 1, unit_price: 100 }],
});
expect(def.success && def.data.items?.[0].discount).toBe(0);
});
it("rejects a negative discount", () => {
const r = CreateInvoiceSchema.safeParse({
items: [{ description: "X", quantity: 1, unit_price: 100, discount: -5 }],
});
expect(r.success).toBe(false);
});
});

View File

@@ -0,0 +1,94 @@
import { describe, it, expect, afterEach } from "vitest";
import prisma from "../config/database";
import { createOffer, getOfferTotals } from "../services/offers.service";
import {
createInvoice,
getInvoiceListTotals,
} from "../services/invoices.service";
/**
* Per-item Sleva (% discount) must reduce the line NET — and on invoices the
* VAT is computed on the discounted net. Hits the real `app_test` DB through the
* service layer (suite convention), mirroring offer-invoice-totals.test.ts.
*/
const MARKER = "SLEVA-TOTALS-2096";
const YEAR = 2096;
const MONTH = 8; // invoice issue_date window [2096-08-01, 2096-09-01)
const dateStr = `${YEAR}-0${MONTH}-15`;
const offerIds: number[] = [];
const invoiceIds: number[] = [];
afterEach(async () => {
for (const id of offerIds)
await prisma.quotations.deleteMany({ where: { id } });
for (const id of invoiceIds)
await prisma.invoices.deleteMany({ where: { id } });
offerIds.length = 0;
invoiceIds.length = 0;
await prisma.number_sequences.deleteMany({
where: { type: { in: ["offer", "invoice"] } },
});
});
const amountFor = (
totals: { currency: string; amount: number }[],
currency: string,
): number | undefined => totals.find((t) => t.currency === currency)?.amount;
describe("offer per-item discount", () => {
it("reduces the offer NET total by the per-item percentage", async () => {
// 2 × 1000 with 10% off => 1800 net.
const res = await createOffer({
status: "draft",
currency: "CZK",
project_code: MARKER,
items: [
{ description: "X", quantity: 2, unit_price: 1000, discount: 10 },
],
});
if ("error" in res) throw new Error(JSON.stringify(res));
offerIds.push(res.id);
const { totals } = await getOfferTotals({ search: MARKER });
expect(amountFor(totals, "CZK")).toBe(1800);
});
});
describe("invoice per-item discount", () => {
it("computes VAT on the discounted net (gross = discounted net + VAT)", async () => {
// 2 × 1000 with 10% off => net 1800, VAT@21% = 378, gross 2178.
const inv = await createInvoice({
status: "draft",
currency: "CZK",
vat_rate: 21,
apply_vat: true,
issue_date: dateStr,
items: [
{ description: "X", quantity: 2, unit_price: 1000, discount: 10 },
],
});
invoiceIds.push(inv.id);
const { totals } = await getInvoiceListTotals({ month: MONTH, year: YEAR });
expect(amountFor(totals, "CZK")).toBe(2178);
});
it("treats a 100% discount line as zero", async () => {
const inv = await createInvoice({
status: "draft",
currency: "CZK",
vat_rate: 21,
apply_vat: true,
issue_date: dateStr,
items: [
{ description: "Free", quantity: 5, unit_price: 200, discount: 100 },
],
});
invoiceIds.push(inv.id);
const { totals } = await getInvoiceListTotals({ month: MONTH, year: YEAR });
expect(amountFor(totals, "CZK")).toBeUndefined(); // zero totals are dropped
});
});

View File

@@ -0,0 +1,22 @@
import { describe, it, expect } from "vitest";
import { lineNet } from "../utils/money";
describe("lineNet (per-item discounted net)", () => {
it("returns qty × unit_price when there is no discount", () => {
expect(lineNet(2, 1000, 0)).toBe(2000);
});
it("applies a percentage discount to the line net", () => {
expect(lineNet(2, 1000, 10)).toBe(1800);
});
it("treats a 100% discount as zero", () => {
expect(lineNet(3, 500, 100)).toBe(0);
});
it("treats null/undefined/NaN discount as no discount", () => {
expect(lineNet(2, 1000, null)).toBe(2000);
expect(lineNet(2, 1000, undefined)).toBe(2000);
expect(lineNet(2, 1000, NaN)).toBe(2000);
});
});

View File

@@ -49,10 +49,31 @@ export interface DocumentItem {
quantity: string | number;
unit: string;
unit_price: string | number;
/** Per-item discount as a percentage (0100). Offers only; 0 elsewhere. */
discount: string | number;
/** Offers only — undefined (issued orders) counts as included. */
is_included_in_total?: boolean;
}
/**
* Discount input styling: centered, and with the native number spinners hidden
* so a 3-digit value ("100") isn't clipped under the up/down arrows.
*/
const discountInputSx = {
"& input": { textAlign: "center" },
"& input[type=number]": { MozAppearance: "textfield" as const },
"& input::-webkit-outer-spin-button, & input::-webkit-inner-spin-button": {
WebkitAppearance: "none",
margin: 0,
},
};
/** NET of one line after its per-item percentage discount. */
const discountedLineNet = (item: DocumentItem): number =>
(Number(item.quantity) || 0) *
(Number(item.unit_price) || 0) *
(1 - (Number(item.discount) || 0) / 100);
let documentItemKeyCounter = 0;
/** Unique client-side key for a (new or hydrated) document item row. */
@@ -66,6 +87,7 @@ export const emptyDocumentItem = (): DocumentItem => ({
quantity: 1,
unit: "ks",
unit_price: 0,
discount: 0,
is_included_in_total: true,
});
@@ -73,9 +95,7 @@ export const emptyDocumentItem = (): DocumentItem => ({
export const documentItemsTotal = (items: DocumentItem[]): number =>
items.reduce(
(sum, it) =>
it.is_included_in_total === false
? sum
: sum + (Number(it.quantity) || 0) * (Number(it.unit_price) || 0),
it.is_included_in_total === false ? sum : sum + discountedLineNet(it),
0,
);
@@ -111,6 +131,7 @@ function SortableItemRow({
readOnly,
canDelete,
showIncludedInTotal,
showDiscount,
itemDescriptionMaxLength,
onUpdate,
onRemove,
@@ -121,6 +142,7 @@ function SortableItemRow({
readOnly: boolean;
canDelete: boolean;
showIncludedInTotal: boolean;
showDiscount: boolean;
itemDescriptionMaxLength: number;
onUpdate: (field: keyof DocumentItem, value: string | boolean) => void;
onRemove: () => void;
@@ -143,8 +165,7 @@ function SortableItemRow({
position: "relative" as const,
zIndex: isDragging ? 10 : undefined,
};
const lineTotal =
(Number(item.quantity) || 0) * (Number(item.unit_price) || 0);
const lineTotal = discountedLineNet(item);
if (isMobile) {
return (
@@ -253,6 +274,17 @@ function SortableItemRow({
InputProps={{ readOnly }}
sx={{ "& input": { textAlign: "right" } }}
/>
{showDiscount && (
<TextField
label="Sleva %"
type="number"
value={item.discount ?? ""}
onChange={(e) => onUpdate("discount", e.target.value)}
slotProps={{ htmlInput: { min: "0", max: "100", step: "any" } }}
InputProps={{ readOnly }}
sx={discountInputSx}
/>
)}
</Box>
<Box
sx={{
@@ -396,6 +428,18 @@ function SortableItemRow({
sx={{ "& input": { textAlign: "right" } }}
/>
</TableCell>
{showDiscount && (
<TableCell>
<TextField
type="number"
value={item.discount ?? ""}
onChange={(e) => onUpdate("discount", e.target.value)}
slotProps={{ htmlInput: { min: "0", max: "100", step: "any" } }}
InputProps={{ readOnly }}
sx={discountInputSx}
/>
</TableCell>
)}
{showIncludedInTotal && (
<TableCell align="center">
<Checkbox
@@ -442,6 +486,8 @@ interface DocumentItemsEditorProps {
error?: string;
/** Render the offers-only "V ceně" (is_included_in_total) column. */
showIncludedInTotal?: boolean;
/** Render the per-item "Sleva %" discount column (offers only). */
showDiscount?: boolean;
/** Schema cap for the detailed description (offers 8000, issued orders 5000). */
itemDescriptionMaxLength?: number;
/** Optional page-specific control (e.g. item-template Select) next to the add button. */
@@ -460,6 +506,7 @@ export default function DocumentItemsEditor({
readOnly,
error,
showIncludedInTotal = false,
showDiscount = false,
itemDescriptionMaxLength = 5000,
templatesSlot,
}: DocumentItemsEditorProps) {
@@ -563,6 +610,7 @@ export default function DocumentItemsEditor({
readOnly={readOnly}
canDelete={items.length > 1}
showIncludedInTotal={showIncludedInTotal}
showDiscount={showDiscount}
itemDescriptionMaxLength={itemDescriptionMaxLength}
onUpdate={(field, value) => updateItem(index, field, value)}
onRemove={() => removeItem(index)}
@@ -594,6 +642,11 @@ export default function DocumentItemsEditor({
<TableCell sx={{ width: "8rem" }} align="center">
Jedn. cena
</TableCell>
{showDiscount && (
<TableCell sx={{ width: "7rem" }} align="center">
Sleva %
</TableCell>
)}
{showIncludedInTotal && (
<TableCell sx={{ width: "4rem" }} align="center">
V ceně
@@ -615,6 +668,7 @@ export default function DocumentItemsEditor({
readOnly={readOnly}
canDelete={items.length > 1}
showIncludedInTotal={showIncludedInTotal}
showDiscount={showDiscount}
itemDescriptionMaxLength={itemDescriptionMaxLength}
onUpdate={(field, value) =>
updateItem(index, field, value)

View File

@@ -38,6 +38,7 @@ export interface InvoiceItem {
quantity: number;
unit: string;
unit_price: number;
discount?: number;
vat_rate?: number;
is_included_in_total: boolean;
position?: number;

View File

@@ -143,6 +143,7 @@ export interface OfferItemData {
quantity: number;
unit: string;
unit_price: number;
discount?: number;
is_included_in_total: boolean;
position?: number;
}

View File

@@ -185,9 +185,34 @@ interface InvoiceItem {
quantity: string | number;
unit: string;
unit_price: string | number;
/** Per-item discount as a percentage (0100). */
discount: string | number;
vat_rate: number;
}
/**
* Discount input styling: centered, native number spinners hidden so a 3-digit
* value ("100") isn't clipped under the up/down arrows.
*/
const discountInputSx = {
"& input": { textAlign: "center" },
"& input[type=number]": { MozAppearance: "textfield" as const },
"& input::-webkit-outer-spin-button, & input::-webkit-inner-spin-button": {
WebkitAppearance: "none",
margin: 0,
},
};
/** NET of one invoice line after its per-item percentage discount. */
const invoiceLineNet = (item: {
quantity: string | number;
unit_price: string | number;
discount?: string | number | null;
}): number =>
(Number(item.quantity) || 0) *
(Number(item.unit_price) || 0) *
(1 - (Number(item.discount) || 0) / 100);
interface InvoiceForm {
customer_id: number | null;
customer_name: string;
@@ -253,8 +278,7 @@ function SortableInvoiceRow({
position: "relative" as const,
zIndex: isDragging ? 10 : undefined,
};
const lineTotal =
(Number(item.quantity) || 0) * (Number(item.unit_price) || 0);
const lineTotal = invoiceLineNet(item);
if (isMobile) {
return (
@@ -349,6 +373,14 @@ function SortableInvoiceRow({
onChange={(e) => onUpdate(index, "unit_price", e.target.value)}
slotProps={{ htmlInput: { step: "any" } }}
/>
<TextField
label="Sleva %"
type="number"
value={item.discount ?? ""}
onChange={(e) => onUpdate(index, "discount", e.target.value)}
slotProps={{ htmlInput: { min: "0", max: "100", step: "any" } }}
sx={discountInputSx}
/>
{apply_vat && (
<Select
label="DPH"
@@ -458,6 +490,15 @@ function SortableInvoiceRow({
sx={{ "& input": { textAlign: "right" } }}
/>
</TableCell>
<TableCell>
<TextField
type="number"
value={item.discount ?? ""}
onChange={(e) => onUpdate(index, "discount", e.target.value)}
slotProps={{ htmlInput: { min: "0", max: "100", step: "any" } }}
sx={discountInputSx}
/>
</TableCell>
{apply_vat ? (
<TableCell>
<Select
@@ -513,6 +554,7 @@ export default function InvoiceDetail() {
quantity: 1,
unit: "ks",
unit_price: 0,
discount: 0,
vat_rate: 21,
}),
[],
@@ -577,6 +619,7 @@ export default function InvoiceDetail() {
quantity: 1,
unit: "ks",
unit_price: 0,
discount: 0,
vat_rate: 21,
},
]);
@@ -628,6 +671,10 @@ export default function InvoiceDetail() {
const invoiceQuery = useQuery(invoiceDetailOptions(id));
const invoice = invoiceQuery.data ?? null;
// Read-only view: show the Sleva column only when some line carries a discount.
const hasItemDiscount = (invoice?.items ?? []).some(
(it) => Number(it.discount) > 0,
);
const nextNumberQuery = useQuery({
queryKey: ["invoices", "next-number"],
@@ -751,6 +798,7 @@ export default function InvoiceDetail() {
quantity: Number(item.quantity) || 1,
unit: item.unit || "",
unit_price: Number(item.unit_price) || 0,
discount: Number(item.discount) || 0,
// Per-line VAT: hydrate from the ITEM's own stored rate (a real DB
// column returned by the detail endpoint) — falling back to the
// invoice-level rate only when the line carries none. Hydrating
@@ -848,6 +896,7 @@ export default function InvoiceDetail() {
quantity: Number(item.quantity) || 1,
unit: (item.unit as string) || "",
unit_price: Number(item.unit_price) || 0,
discount: Number(item.discount) || 0,
vat_rate: vatRate,
})),
);
@@ -967,8 +1016,7 @@ export default function InvoiceDetail() {
const vatByRate: Record<number, number> = {};
items.forEach((item) => {
const lineTotal =
(Number(item.quantity) || 0) * (Number(item.unit_price) || 0);
const lineTotal = invoiceLineNet(item);
subtotal += lineTotal;
if (form.apply_vat) {
@@ -1038,6 +1086,7 @@ export default function InvoiceDetail() {
// field never reaches the server as NaN (mirrors createTotals).
quantity: Number(item.quantity) || 0,
unit_price: Number(item.unit_price) || 0,
discount: Number(item.discount) || 0,
position: i,
})),
sections: sections.map((s, i) => ({
@@ -1361,9 +1410,7 @@ export default function InvoiceDetail() {
isMobile ? (
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}>
{invoice.items.map((item, index) => {
const lineSubtotal =
(Number(item.quantity) || 0) *
(Number(item.unit_price) || 0);
const lineSubtotal = invoiceLineNet(item);
const lineVat = Number(invoice.apply_vat)
? (lineSubtotal * (Number(item.vat_rate) || 0)) / 100
: 0;
@@ -1494,6 +1541,11 @@ export default function InvoiceDetail() {
<TableCell sx={{ width: "8rem" }} align="center">
Jedn. cena
</TableCell>
{hasItemDiscount && (
<TableCell sx={{ width: "4rem" }} align="center">
Sleva %
</TableCell>
)}
<TableCell sx={{ width: "4rem" }} align="center">
DPH
</TableCell>
@@ -1504,9 +1556,7 @@ export default function InvoiceDetail() {
</TableHead>
<TableBody>
{invoice.items.map((item, index) => {
const lineSubtotal =
(Number(item.quantity) || 0) *
(Number(item.unit_price) || 0);
const lineSubtotal = invoiceLineNet(item);
const lineVat = Number(invoice.apply_vat)
? (lineSubtotal * (Number(item.vat_rate) || 0)) / 100
: 0;
@@ -1555,6 +1605,13 @@ export default function InvoiceDetail() {
>
{formatCurrency(item.unit_price, invoice.currency)}
</TableCell>
{hasItemDiscount && (
<TableCell align="center">
{Number(item.discount) > 0
? `${Number(item.discount)} %`
: "—"}
</TableCell>
)}
<TableCell align="center">
{Number(invoice.apply_vat)
? Number(item.vat_rate)
@@ -2238,6 +2295,9 @@ export default function InvoiceDetail() {
<TableCell sx={{ width: "8rem" }} align="center">
Jedn. cena
</TableCell>
<TableCell sx={{ width: "7rem" }} align="center">
Sleva %
</TableCell>
{form.apply_vat ? (
<TableCell sx={{ width: "5rem" }} align="center">
DPH

View File

@@ -293,6 +293,8 @@ export default function IssuedOrderDetail() {
quantity: numberOr(it.quantity, 1),
unit: it.unit || "",
unit_price: Number(it.unit_price) || 0,
// Issued orders have no Sleva column; keep the shared item shape happy.
discount: 0,
}))
: [emptyDocumentItem()];
setItems(mappedItems);

View File

@@ -93,6 +93,7 @@ interface OfferItemPayload {
quantity: number;
unit: string;
unit_price: number;
discount: number;
is_included_in_total: boolean;
position: number;
}
@@ -303,6 +304,7 @@ export default function OfferDetail() {
quantity: numberOr(it.quantity, 1),
unit: it.unit || "",
unit_price: Number(it.unit_price) || 0,
discount: Number(it.discount) || 0,
is_included_in_total: it.is_included_in_total !== false,
}))
: [emptyDocumentItem()];
@@ -433,6 +435,7 @@ export default function OfferDetail() {
quantity: Number(item.quantity) || 0,
unit: item.unit,
unit_price: Number(item.unit_price) || 0,
discount: Number(item.discount) || 0,
is_included_in_total: item.is_included_in_total !== false,
position: i,
})),
@@ -936,6 +939,7 @@ export default function OfferDetail() {
readOnly={readOnly}
error={errors.items}
showIncludedInTotal
showDiscount
itemDescriptionMaxLength={8000}
templatesSlot={
itemTemplates && itemTemplates.length > 0 ? (
@@ -957,6 +961,7 @@ export default function OfferDetail() {
quantity: 1,
unit: "ks",
unit_price: template.default_price || 0,
discount: 0,
is_included_in_total: true,
},
]);

View File

@@ -7,6 +7,7 @@ import { htmlToPdf } from "../../utils/html-to-pdf";
import { getRate } from "../../services/exchange-rates";
import { localDateStr } from "../../utils/date";
import { parseId, success } from "../../utils/response";
import { lineNet } from "../../utils/money";
import {
formatDate,
formatNum,
@@ -161,6 +162,7 @@ const translations: Record<string, Record<string, string>> = {
col_desc: "Popis",
col_qty: "Množství",
col_unit_price: "Jedn. cena",
col_discount: "Sleva",
col_price: "Cena",
col_vat_pct: "%DPH",
col_vat: "DPH",
@@ -208,6 +210,7 @@ const translations: Record<string, Record<string, string>> = {
col_desc: "Description",
col_qty: "Quantity",
col_unit_price: "Unit price",
col_discount: "Discount",
col_price: "Price",
col_vat_pct: "VAT%",
col_vat: "VAT",
@@ -312,6 +315,8 @@ export default async function invoicesPdfRoutes(
where: { invoice_id: id },
orderBy: [{ position: "asc" }, { id: "asc" }],
});
// Sleva column is rendered only when at least one item has a discount.
const hasDiscount = items.some((it) => Number(it.discount) > 0);
let customer: Record<string, unknown> | null = null;
if (invoice.customer_id) {
@@ -362,7 +367,11 @@ export default async function invoicesPdfRoutes(
let subtotal = 0;
for (const item of items) {
const lineSubtotal = Number(item.quantity) * Number(item.unit_price);
const lineSubtotal = lineNet(
Number(item.quantity),
Number(item.unit_price),
Number(item.discount),
);
subtotal += lineSubtotal;
const rate = Number(item.vat_rate);
const key = String(rate);
@@ -489,11 +498,16 @@ export default async function invoicesPdfRoutes(
.map((item, i) => {
const qty = Number(item.quantity);
const unitPrice = Number(item.unit_price);
const lineSubtotal = qty * unitPrice;
const disc = Number(item.discount) || 0;
const lineSubtotal = lineNet(qty, unitPrice, disc);
const vatRate = Number(item.vat_rate);
const lineVat = applyVat ? (lineSubtotal * vatRate) / 100 : 0;
const lineTotal = lineSubtotal + lineVat;
const qtyDecimals = Math.floor(qty) === qty ? 0 : 2;
const discDecimals = Math.floor(disc) === disc ? 0 : 2;
const discCell = hasDiscount
? `<td class="center">${disc > 0 ? `${formatNum(disc, discDecimals)}&nbsp;%` : "—"}</td>`
: "";
const subDesc = item.item_description || "";
return `<tr>
@@ -501,6 +515,7 @@ export default async function invoicesPdfRoutes(
<td class="desc">${escapeHtml(item.description)}${subDesc ? `<div class="item-subdesc">${escapeHtml(subDesc)}</div>` : ""}</td>
<td class="center">${formatNum(qty, qtyDecimals)}${item.unit ? ` / ${escapeHtml(item.unit)}` : ""}</td>
<td class="right">${formatNum(unitPrice)}</td>
${discCell}
<td class="right">${formatNum(lineSubtotal)}</td>
<td class="center">${applyVat ? Math.floor(vatRate) : 0}%</td>
<td class="right">${formatNum(lineVat)}</td>
@@ -1052,9 +1067,10 @@ ${indentCSS}
<thead>
<tr>
<th class="center" style="width:3%">${escapeHtml(t.col_no)}</th>
<th style="width:36%">${escapeHtml(t.col_desc)}</th>
<th style="width:${hasDiscount ? 29 : 36}%">${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>
${hasDiscount ? `<th class="center" style="width:7%">${escapeHtml(t.col_discount)}</th>` : ""}
<th class="right" style="width:10%">${escapeHtml(t.col_price)}</th>
<th class="center" style="width:5%">${escapeHtml(t.col_vat_pct)}</th>
<th class="right" style="width:10%">${escapeHtml(t.col_vat)}</th>

View File

@@ -5,6 +5,7 @@ import { requirePermission } from "../../middleware/auth";
import { nasOffersManager } from "../../services/nas-offers-manager";
import { htmlToPdf } from "../../utils/html-to-pdf";
import { parseId, success } from "../../utils/response";
import { lineNet } from "../../utils/money";
import {
formatDate,
formatNum,
@@ -123,6 +124,7 @@ const TRANSLATIONS: Record<string, Record<string, string>> = {
description: { EN: "Description", CZ: "Popis" },
qty: { EN: "Qty", CZ: "Mn." },
unit_price: { EN: "Unit Price", CZ: "Jedn. cena" },
discount: { EN: "Discount", CZ: "Sleva" },
included: { EN: "Included", CZ: "Zahrnuto" },
total: { EN: "Total", CZ: "Celkem" },
total_no_vat: { EN: "Total excl. VAT", CZ: "Celkem bez DPH" },
@@ -170,9 +172,15 @@ export function renderOfferHtml(
let total = 0;
for (const item of items) {
if (item.is_included_in_total !== false) {
total += (Number(item.quantity) || 0) * (Number(item.unit_price) || 0);
total += lineNet(
Number(item.quantity) || 0,
Number(item.unit_price) || 0,
Number(item.discount),
);
}
}
// Sleva column is rendered only when at least one item carries a discount.
const hasDiscount = items.some((it) => Number(it.discount) > 0);
// Visibility uses the SAME per-language title rule as the render loop plus
// a tag-stripped content check (an empty-Quill "<p><br></p>" doesn't count)
// — otherwise a section with only the other language's title would count as
@@ -217,10 +225,15 @@ export function renderOfferHtml(
let itemsHtml = "";
items.forEach((item, i) => {
const qty = Number(item.quantity) || 0;
const lineTotal = qty * (Number(item.unit_price) || 0);
const disc = Number(item.discount) || 0;
const lineTotal = lineNet(qty, Number(item.unit_price) || 0, disc);
// Integers render without decimals; fractional quantities keep 2 decimals
// (the old toFixed(0) ROUNDED 1.5 → 2 on the printed document).
const qtyDecimals = Math.floor(qty) === qty ? 0 : 2;
const discDecimals = Math.floor(disc) === disc ? 0 : 2;
const discCell = hasDiscount
? `<td class="center">${disc > 0 ? `${formatNum(disc, discDecimals)}&nbsp;%` : "—"}</td>`
: "";
const subDesc = item.item_description || "";
const evenClass = i % 2 === 1 ? ' class="even"' : "";
itemsHtml += `<tr${evenClass}>
@@ -228,6 +241,7 @@ export function renderOfferHtml(
<td class="desc">${escapeHtml(item.description)}${subDesc ? `<div class="item-subdesc">${escapeHtml(subDesc)}</div>` : ""}</td>
<td class="center">${formatNum(qty, qtyDecimals)}${(item.unit || "").trim() ? ` / ${escapeHtml((item.unit || "").trim())}` : ""}</td>
<td class="right">${formatCurrency(Number(item.unit_price) || 0, currency)}</td>
${discCell}
<td class="right total-cell">${formatCurrency(lineTotal, currency)}</td>
</tr>`;
});
@@ -621,10 +635,11 @@ ${indentCSS}
<thead>
<tr>
<th class="center" style="width:5%">${escapeHtml(t("no"))}</th>
<th style="width:44%">${escapeHtml(t("description"))}</th>
<th class="center" style="width:13%">${escapeHtml(t("qty"))}</th>
<th class="right" style="width:18%">${escapeHtml(t("unit_price"))}</th>
<th class="right" style="width:20%">${escapeHtml(t("total"))}</th>
<th style="width:${hasDiscount ? 36 : 44}%">${escapeHtml(t("description"))}</th>
<th class="center" style="width:12%">${escapeHtml(t("qty"))}</th>
<th class="right" style="width:16%">${escapeHtml(t("unit_price"))}</th>
${hasDiscount ? `<th class="center" style="width:9%">${escapeHtml(t("discount"))}</th>` : ""}
<th class="right" style="width:18%">${escapeHtml(t("total"))}</th>
</tr>
</thead>
<tbody>

View File

@@ -16,6 +16,8 @@ const InvoiceItemSchema = z.object({
quantity: positiveNumberFromForm.default(1),
unit: z.string().max(20).nullish(),
unit_price: numberFromForm.default(0),
// Per-item discount as a percentage (0100); column is Decimal(5,2).
discount: numberInRange(0, 100).optional().default(0),
vat_rate: numberInRange(0, 100).optional().default(21.0),
position: nonNegativeNumberFromForm.optional(),
});

View File

@@ -3,6 +3,7 @@ import {
nonNegativeIntFromForm,
nonNegativeNumberFromForm,
positiveNumberFromForm,
numberInRange,
nullableIntIdFromForm,
booleanFromForm,
isoDateString,
@@ -17,6 +18,8 @@ const QuotationItemSchema = z.object({
quantity: positiveNumberFromForm.default(1),
unit: z.string().max(20).nullish(),
unit_price: nonNegativeNumberFromForm.default(0),
// Per-item discount as a percentage (0100); column is Decimal(5,2).
discount: numberInRange(0, 100).optional().default(0),
is_included_in_total: booleanFromForm.optional().default(true),
// Int column — a float position would 500 at Prisma.
position: nonNegativeIntFromForm.optional(),

View File

@@ -1,5 +1,6 @@
import prisma from "../config/database";
import { utcMidnightOfLocalDay } from "../utils/date";
import { lineNet } from "../utils/money";
import { toCzk } from "./exchange-rates";
import {
generateInvoiceNumber,
@@ -31,6 +32,8 @@ interface InvoiceItemInput {
quantity?: number;
unit?: string | null;
unit_price?: number;
/** Per-item discount as a percentage (0100). */
discount?: number;
vat_rate?: number;
position?: number;
}
@@ -165,14 +168,24 @@ function buildInvoiceWhere(
}
function computeInvoiceTotals(
items: Array<{ quantity: unknown; unit_price: unknown; vat_rate: unknown }>,
items: Array<{
quantity: unknown;
unit_price: unknown;
discount?: unknown;
vat_rate: unknown;
}>,
applyVat: boolean | null,
defaultVatRate: unknown,
) {
const { subtotal, vat: vatAmount } = computeMoney(
items,
applyVat,
(i) => (Number(i.quantity) || 0) * (Number(i.unit_price) || 0),
(i) =>
lineNet(
Number(i.quantity) || 0,
Number(i.unit_price) || 0,
Number(i.discount),
),
// Preserve original semantics: a line vat_rate of 0 (non-null, non-"") IS used.
(i) =>
i.vat_rate != null && i.vat_rate !== ""
@@ -305,6 +318,7 @@ export function invoiceTotalWithVat(inv: {
invoice_items: Array<{
quantity: { toNumber(): number } | null;
unit_price: { toNumber(): number } | null;
discount?: { toNumber(): number } | null;
vat_rate: { toNumber(): number } | null;
}>;
}) {
@@ -312,8 +326,11 @@ export function invoiceTotalWithVat(inv: {
inv.invoice_items,
inv.apply_vat,
(i) =>
(Number(i.quantity?.toNumber()) || 0) *
(Number(i.unit_price?.toNumber()) || 0),
lineNet(
Number(i.quantity?.toNumber()) || 0,
Number(i.unit_price?.toNumber()) || 0,
i.discount?.toNumber(),
),
// Preserve original `||` semantics: a line rate of 0/NaN falls through to
// the invoice default, then 21.
(i) =>
@@ -401,7 +418,12 @@ export async function getInvoiceStats(queryMonth?: number, queryYear?: number) {
const { vat } = computeMoney(
inv.invoice_items,
inv.apply_vat,
(item) => (Number(item.quantity) || 0) * (Number(item.unit_price) || 0),
(item) =>
lineNet(
Number(item.quantity) || 0,
Number(item.unit_price) || 0,
Number(item.discount),
),
(item) => Number(item.vat_rate) || Number(inv.vat_rate) || 21,
false, // accumulate raw per-line VAT; round only the per-currency sum
);
@@ -551,6 +573,7 @@ export async function createInvoice(body: InvoiceInput) {
quantity: item.quantity ?? 1,
unit: item.unit ?? null,
unit_price: item.unit_price ?? 0,
discount: item.discount ?? 0,
vat_rate: item.vat_rate ?? 21.0,
position: item.position ?? i,
})),
@@ -688,6 +711,7 @@ export async function updateInvoice(id: number, body: InvoiceInput) {
quantity: item.quantity ?? 1,
unit: item.unit ?? null,
unit_price: item.unit_price ?? 0,
discount: item.discount ?? 0,
vat_rate: item.vat_rate ?? 21.0,
position: item.position ?? i,
})),

View File

@@ -1,5 +1,6 @@
import { Prisma } from "@prisma/client";
import prisma from "../config/database";
import { lineNet } from "../utils/money";
import {
generateOfferNumber,
releaseOfferNumber,
@@ -14,6 +15,8 @@ interface QuotationItemInput {
quantity?: number;
unit?: string;
unit_price?: number;
/** Per-item discount as a percentage (0100). */
discount?: number;
is_included_in_total?: boolean;
position?: number;
}
@@ -122,6 +125,7 @@ type EnrichableLineItem = {
is_included_in_total?: unknown;
quantity?: unknown;
unit_price?: unknown;
discount?: unknown;
};
type EnrichableQuotation = {
quotation_items: EnrichableLineItem[];
@@ -138,7 +142,12 @@ function enrichQuotation<T extends EnrichableQuotation>(q: T) {
.filter((i) => i.is_included_in_total !== false)
.reduce(
(s: number, i) =>
s + (Number(i.quantity) || 0) * (Number(i.unit_price) || 0),
s +
lineNet(
Number(i.quantity) || 0,
Number(i.unit_price) || 0,
Number(i.discount),
),
0,
);
const { quotation_items, scope_sections, ...rest } = q;
@@ -345,6 +354,7 @@ export async function createOffer(body: Record<string, unknown>) {
quantity: item.quantity ?? 1,
unit: item.unit ?? null,
unit_price: item.unit_price ?? 0,
discount: item.discount ?? 0,
is_included_in_total: item.is_included_in_total !== false,
position: item.position ?? i,
})),
@@ -480,6 +490,7 @@ export async function updateOffer(id: number, body: Record<string, unknown>) {
quantity: item.quantity ?? 1,
unit: item.unit ?? null,
unit_price: item.unit_price ?? 0,
discount: item.discount ?? 0,
is_included_in_total: item.is_included_in_total !== false,
position: item.position ?? i,
})),
@@ -602,6 +613,7 @@ export async function duplicateOffer(id: number) {
quantity: item.quantity,
unit: item.unit,
unit_price: item.unit_price,
discount: item.discount,
is_included_in_total: item.is_included_in_total,
position: item.position,
})),

17
src/utils/money.ts Normal file
View File

@@ -0,0 +1,17 @@
/**
* Net amount of a single document line after its per-item percentage discount
* ("Sleva"). `net = qty × unit_price × (1 discount/100)`.
*
* The discount is tolerant of null/undefined/NaN (a missing discount = none)
* and clamped to 0100 so a stray value can never make the net negative.
* Single source of truth for offers + invoices (totals, VAT base, PDFs).
*/
export function lineNet(
qty: number,
unitPrice: number,
discountPct: number | null | undefined,
): number {
const pct = Number(discountPct);
const safePct = Number.isFinite(pct) ? Math.min(100, Math.max(0, pct)) : 0;
return qty * unitPrice * (1 - safePct / 100);
}