Approved design: percentage per line item, reduces net (VAT on discounted net), conditional PDF column, offers + issued invoices only. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
4.0 KiB
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:
// src/utils/money.ts (new)
export const lineNet = (qty: number, unitPrice: number, discountPct: number) =>
qty * unitPrice * (1 - (discountPct || 0) / 100);
Backend
- Schemas (Zod): add
discountto the offer-item and invoice-item schemas vianumberInRange(0, 100)(shared coercer incommon.ts), optional, default0. - offers.service (
enrichQuotation): line total useslineNet; document net ("Celkem bez DPH") sums discounted included lines. Persistdiscounton item create/update. - invoices.service: every
computeMoneygetBasecallback (computeInvoiceTotals, monthly-statsinvoiceMonthlyAmount, per-ratevatMap) returnslineNet(...)so subtotal and VAT use the discounted net. Persistdiscounton 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:DocumentItemgainsdiscount; add a "Sleva %" numeric column (0–100); per-line total and the footer net total apply it. Mobile card layout gets the field.emptyDocumentItemdefaultsdiscount: 0. Hydration + save payloads carrydiscount. - Invoices →
src/admin/pages/InvoiceDetail.tsxown editor (SortableInvoiceRow+ create/edit rows):InvoiceItemgainsdiscount; 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'slanguage), cell renders"<n> %"(escaped); adjust colspans of any full-width rows accordingly. - Line net / document total / VAT always use the discounted net regardless of
hasDiscount. escapeHtmlevery 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:
discountrejects <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.