From 3787cc4dd0bf958af9a15809d8bfe3dd592baa9a Mon Sep 17 00:00:00 2001 From: BOHA Date: Sat, 13 Jun 2026 19:56:43 +0200 Subject: [PATCH] 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) --- .../2026-06-13-item-sleva-discount-design.md | 98 +++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-13-item-sleva-discount-design.md diff --git a/docs/superpowers/specs/2026-06-13-item-sleva-discount-design.md b/docs/superpowers/specs/2026-06-13-item-sleva-discount-design.md new file mode 100644 index 0000000..555c232 --- /dev/null +++ b/docs/superpowers/specs/2026-06-13-item-sleva-discount-design.md @@ -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 (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.