docs(spec): per-item Sleva (% discount) on offers + invoices

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>
This commit is contained in:
BOHA
2026-06-13 19:56:43 +02:00
parent 1ee59b54bc
commit 3787cc4dd0

View File

@@ -0,0 +1,98 @@
# 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 (0100), 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, 0100
```
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 (0100);
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 `"<n> %"` (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.