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:
@@ -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 (0–100). */
|
||||
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,
|
||||
})),
|
||||
|
||||
Reference in New Issue
Block a user