Files
app/docs/superpowers/specs/2026-06-13-item-sleva-discount-design.md
BOHA 3787cc4dd0 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>
2026-06-13 19:56:43 +02:00

99 lines
4.0 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.