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>
56 lines
1.8 KiB
TypeScript
56 lines
1.8 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
||
import { CreateQuotationSchema } from "../schemas/offers.schema";
|
||
import { CreateInvoiceSchema } from "../schemas/invoices.schema";
|
||
|
||
/**
|
||
* The per-item Sleva is a percentage: the offer- and invoice-item schemas must
|
||
* accept 0–100, default to 0 when absent, and reject out-of-range values.
|
||
*/
|
||
|
||
describe("offer item discount schema", () => {
|
||
it("accepts a 0–100 discount", () => {
|
||
const r = CreateQuotationSchema.safeParse({
|
||
items: [{ description: "X", quantity: 1, unit_price: 100, discount: 15 }],
|
||
});
|
||
expect(r.success).toBe(true);
|
||
expect(r.success && r.data.items?.[0].discount).toBe(15);
|
||
});
|
||
|
||
it("defaults discount to 0 when absent", () => {
|
||
const r = CreateQuotationSchema.safeParse({
|
||
items: [{ description: "X", quantity: 1, unit_price: 100 }],
|
||
});
|
||
expect(r.success && r.data.items?.[0].discount).toBe(0);
|
||
});
|
||
|
||
it("rejects a discount above 100", () => {
|
||
const r = CreateQuotationSchema.safeParse({
|
||
items: [
|
||
{ description: "X", quantity: 1, unit_price: 100, discount: 150 },
|
||
],
|
||
});
|
||
expect(r.success).toBe(false);
|
||
});
|
||
});
|
||
|
||
describe("invoice item discount schema", () => {
|
||
it("accepts a 0–100 discount and defaults to 0", () => {
|
||
const ok = CreateInvoiceSchema.safeParse({
|
||
items: [{ description: "X", quantity: 1, unit_price: 100, discount: 10 }],
|
||
});
|
||
expect(ok.success && ok.data.items?.[0].discount).toBe(10);
|
||
|
||
const def = CreateInvoiceSchema.safeParse({
|
||
items: [{ description: "X", quantity: 1, unit_price: 100 }],
|
||
});
|
||
expect(def.success && def.data.items?.[0].discount).toBe(0);
|
||
});
|
||
|
||
it("rejects a negative discount", () => {
|
||
const r = CreateInvoiceSchema.safeParse({
|
||
items: [{ description: "X", quantity: 1, unit_price: 100, discount: -5 }],
|
||
});
|
||
expect(r.success).toBe(false);
|
||
});
|
||
});
|