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

4.0 KiB
Raw Blame History

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:

// 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.
  • Invoicessrc/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.