feat(documents): per-item Sleva (% discount) on offers + invoices

Adds an optional per-line-item percentage discount ("Sleva") to offers and
issued invoices, on both the detail-page editor and the PDF.

- DB: discount Decimal(5,2) DEFAULT 0 on quotation_items + invoice_items
  (migration 20260613195833_add_item_discount).
- Totals/VAT: new shared lineNet() (qty × price × (1 − discount/100)); offers'
  NET total and invoices' subtotal AND VAT now compute on the discounted net
  (every getBase/enrichQuotation/stats path). Backward compatible: discount 0
  is identical to before.
- Schemas: discount = numberInRange(0,100), default 0; persisted on
  create/update (+ offer duplicate).
- Detail editors: editable "Sleva %" column — offers via DocumentItemsEditor's
  new opt-in showDiscount prop (issued orders unaffected); invoices in their own
  editor. Spinners hidden + 7rem column so "100" is fully visible. Read-only
  invoice view shows the column when any line has a discount.
- PDFs (offers + invoices): conditional "Sleva"/"Discount" column rendered only
  when an item has a discount; line/total/VAT always use the discounted net.

Tests: +16 (money, totals incl. VAT-on-discounted-net, schema range, PDF
column present/absent). Full suite 665 pass, tsc -b clean, lint 0. Editor
verified live via Playwright. Design spec in docs/superpowers/specs/.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-06-13 20:46:44 +02:00
parent 3787cc4dd0
commit 719d1f2659
21 changed files with 554 additions and 33 deletions

View File

@@ -1,5 +1,6 @@
import prisma from "../config/database";
import { utcMidnightOfLocalDay } from "../utils/date";
import { lineNet } from "../utils/money";
import { toCzk } from "./exchange-rates";
import {
generateInvoiceNumber,
@@ -31,6 +32,8 @@ interface InvoiceItemInput {
quantity?: number;
unit?: string | null;
unit_price?: number;
/** Per-item discount as a percentage (0100). */
discount?: number;
vat_rate?: number;
position?: number;
}
@@ -165,14 +168,24 @@ function buildInvoiceWhere(
}
function computeInvoiceTotals(
items: Array<{ quantity: unknown; unit_price: unknown; vat_rate: unknown }>,
items: Array<{
quantity: unknown;
unit_price: unknown;
discount?: unknown;
vat_rate: unknown;
}>,
applyVat: boolean | null,
defaultVatRate: unknown,
) {
const { subtotal, vat: vatAmount } = computeMoney(
items,
applyVat,
(i) => (Number(i.quantity) || 0) * (Number(i.unit_price) || 0),
(i) =>
lineNet(
Number(i.quantity) || 0,
Number(i.unit_price) || 0,
Number(i.discount),
),
// Preserve original semantics: a line vat_rate of 0 (non-null, non-"") IS used.
(i) =>
i.vat_rate != null && i.vat_rate !== ""
@@ -305,6 +318,7 @@ export function invoiceTotalWithVat(inv: {
invoice_items: Array<{
quantity: { toNumber(): number } | null;
unit_price: { toNumber(): number } | null;
discount?: { toNumber(): number } | null;
vat_rate: { toNumber(): number } | null;
}>;
}) {
@@ -312,8 +326,11 @@ export function invoiceTotalWithVat(inv: {
inv.invoice_items,
inv.apply_vat,
(i) =>
(Number(i.quantity?.toNumber()) || 0) *
(Number(i.unit_price?.toNumber()) || 0),
lineNet(
Number(i.quantity?.toNumber()) || 0,
Number(i.unit_price?.toNumber()) || 0,
i.discount?.toNumber(),
),
// Preserve original `||` semantics: a line rate of 0/NaN falls through to
// the invoice default, then 21.
(i) =>
@@ -401,7 +418,12 @@ export async function getInvoiceStats(queryMonth?: number, queryYear?: number) {
const { vat } = computeMoney(
inv.invoice_items,
inv.apply_vat,
(item) => (Number(item.quantity) || 0) * (Number(item.unit_price) || 0),
(item) =>
lineNet(
Number(item.quantity) || 0,
Number(item.unit_price) || 0,
Number(item.discount),
),
(item) => Number(item.vat_rate) || Number(inv.vat_rate) || 21,
false, // accumulate raw per-line VAT; round only the per-currency sum
);
@@ -551,6 +573,7 @@ export async function createInvoice(body: InvoiceInput) {
quantity: item.quantity ?? 1,
unit: item.unit ?? null,
unit_price: item.unit_price ?? 0,
discount: item.discount ?? 0,
vat_rate: item.vat_rate ?? 21.0,
position: item.position ?? i,
})),
@@ -688,6 +711,7 @@ export async function updateInvoice(id: number, body: InvoiceInput) {
quantity: item.quantity ?? 1,
unit: item.unit ?? null,
unit_price: item.unit_price ?? 0,
discount: item.discount ?? 0,
vat_rate: item.vat_rate ?? 21.0,
position: item.position ?? i,
})),