# Per-item Sleva (% discount) — Offers + Invoices **Date:** 2026-06-13 **Status:** Approved → implementing **Scope:** Add a per-line-item percentage discount ("Sleva") to **offers** (nabídky) and **issued invoices** (Faktury), on both the detail-page item editor and the PDF. Received invoices, issued orders and orders are NOT touched. ## Decisions (from brainstorming) - **Type:** percentage per item (0–100), not an absolute amount. - **Totals/VAT:** the discount reduces the line **net**; on invoices VAT is computed on the **discounted** net. `net = qty × unit_price × (1 − discount/100)`. - **PDF column is conditional:** render the Sleva column only when at least one (included) item has `discount > 0`; otherwise the table is identical to today. - **Detail-page column is always visible** (it's the input). ## Data model Add to `quotation_items` and `invoice_items`: ``` discount Decimal? @default(0.00) @db.Decimal(5, 2) // percent, 0–100 ``` Nullable with default `0`, matching the existing money columns. "No sleva" = `0` or null. One tracked migration; apply to dev `app` and `app_test`, then `prisma generate`. ## Line math (single rule) `lineNet = qty × unit_price × (1 − clamp(discount,0,100)/100)` Add a shared backend helper so every site is identical: ```ts // src/utils/money.ts (new) export const lineNet = (qty: number, unitPrice: number, discountPct: number) => qty * unitPrice * (1 - (discountPct || 0) / 100); ``` ## Backend - **Schemas (Zod):** add `discount` to the offer-item and invoice-item schemas via `numberInRange(0, 100)` (shared coercer in `common.ts`), optional, default `0`. - **offers.service** (`enrichQuotation`): line total uses `lineNet`; document net ("Celkem bez DPH") sums discounted included lines. Persist `discount` on item create/update. - **invoices.service**: every `computeMoney` `getBase` callback (`computeInvoiceTotals`, monthly-stats `invoiceMonthlyAmount`, per-rate `vatMap`) returns `lineNet(...)` so subtotal **and** VAT use the discounted net. Persist `discount` on item create/update. - **DocumentItemsEditor's `documentItemsTotal`** (frontend) mirrors the same discounted net. ## Frontend (Section 1 — detail-page editor) - **Offers** → shared `src/admin/components/document/DocumentItemsEditor.tsx`: `DocumentItem` gains `discount`; add a **"Sleva %"** numeric column (0–100); per-line total and the footer net total apply it. Mobile card layout gets the field. `emptyDocumentItem` defaults `discount: 0`. Hydration + save payloads carry `discount`. - **Invoices** → `src/admin/pages/InvoiceDetail.tsx` own editor (`SortableInvoiceRow` + create/edit rows): `InvoiceItem` gains `discount`; same column; net/VAT/gross totals apply it. Mobile layout too. ## PDF (Section 2 — conditional column) `src/routes/admin/offers-pdf.ts` and `src/routes/admin/invoices-pdf.ts`: - `const hasDiscount = items.some(i => includedInTotal(i) && Number(i.discount) > 0)`. - When `hasDiscount`: add a column — header **"Sleva"** (cs) / **"Discount"** (en, from the document's `language`), cell renders `" %"` (escaped); adjust colspans of any full-width rows accordingly. - Line net / document total / VAT always use the discounted net regardless of `hasDiscount`. - `escapeHtml` every interpolation (string-built PDF templates). ## Tests - `offer-invoice-totals` / invoice totals: net, VAT and gross with discounts (incl. a 100% line = 0, and VAT-on-discounted-net). - Schema: `discount` rejects <0 and >100, coerces form strings, defaults 0. - PDF: column present when an item has a discount; absent when none; values escaped. ## Non-goals (YAGNI) - No separate "total discount" summary line, no document-level discount. - No changes to received invoices, issued orders, or orders. ## Migration etiquette DB change → user stops the dev server first; create the migration via the non-interactive `prisma migrate diff` recipe, `migrate deploy` + `generate` on dev and `app_test`, commit schema + migration folder together.