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

@@ -0,0 +1,130 @@
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import Fastify from "fastify";
import jwt from "jsonwebtoken";
import prisma from "../config/database";
import { config } from "../config/env";
import offersPdfRoutes from "../routes/admin/offers-pdf";
import invoicesPdfRoutes from "../routes/admin/invoices-pdf";
import { createInvoice } from "../services/invoices.service";
/**
* The per-item Sleva column is CONDITIONAL on the PDF: render it only when at
* least one item carries a discount > 0; otherwise the table looks exactly as
* before. The discounted net always flows into the line/grand totals.
*/
let app: ReturnType<typeof Fastify> | null = null;
let adminToken = "";
const quotationIds: number[] = [];
const invoiceIds: number[] = [];
beforeAll(async () => {
app = Fastify({ logger: false });
await app.register(offersPdfRoutes, { prefix: "/api/admin/offers-pdf" });
await app.register(invoicesPdfRoutes, { prefix: "/api/admin/invoices-pdf" });
const admin = await prisma.users.findFirst({
where: { roles: { name: "admin" } },
});
if (!admin) throw new Error("Test setup: admin user not found");
adminToken = jwt.sign(
{ sub: admin.id, username: admin.username, role: "admin" },
config.jwt.secret,
{ expiresIn: "15m" },
);
});
afterAll(async () => {
for (const id of quotationIds)
await prisma.quotations.deleteMany({ where: { id } });
for (const id of invoiceIds)
await prisma.invoices.deleteMany({ where: { id } });
if (app) await app.close();
});
const offerHtml = async (discount: number): Promise<string> => {
const q = await prisma.quotations.create({
data: {
quotation_number: `Q-SLEVA-${discount}-${Date.now()}`,
status: "active",
currency: "CZK",
language: "cs",
quotation_items: {
create: [
{
description: "Položka",
quantity: 2,
unit: "ks",
unit_price: 100,
discount,
is_included_in_total: true,
position: 0,
},
],
},
},
});
quotationIds.push(q.id);
const res = await app!.inject({
method: "GET",
url: `/api/admin/offers-pdf/${q.id}`,
headers: { Authorization: `Bearer ${adminToken}` },
});
expect(res.statusCode).toBe(200);
return res.body;
};
describe("offer PDF Sleva column", () => {
it("renders a Sleva column + discounted total when an item has a discount", async () => {
const html = await offerHtml(10);
expect(html).toContain("Sleva");
// 2 × 100 with 10% off => 180,00 (no thousands separator at this size).
expect(html).toContain("180,00");
expect(html).not.toContain("200,00");
});
it("omits the Sleva column when no item has a discount", async () => {
const html = await offerHtml(0);
expect(html).not.toContain("Sleva");
expect(html).toContain("200,00");
});
});
const invoiceHtml = async (discount: number): Promise<string> => {
const inv = await createInvoice({
status: "draft",
currency: "CZK",
apply_vat: true,
vat_rate: 21,
language: "cs",
items: [
{
description: "Položka",
quantity: 2,
unit_price: 1000,
discount,
vat_rate: 21,
},
],
});
invoiceIds.push(inv.id);
const res = await app!.inject({
method: "GET",
url: `/api/admin/invoices-pdf/${inv.id}`,
headers: { Authorization: `Bearer ${adminToken}` },
});
expect(res.statusCode).toBe(200);
return res.body;
};
describe("invoice PDF Sleva column", () => {
it("renders a Sleva column when an item has a discount", async () => {
const html = await invoiceHtml(10);
expect(html).toContain("Sleva");
expect(html).toContain("10");
});
it("omits the Sleva column when no item has a discount", async () => {
const html = await invoiceHtml(0);
expect(html).not.toContain("Sleva");
});
});

View File

@@ -0,0 +1,55 @@
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 0100, default to 0 when absent, and reject out-of-range values.
*/
describe("offer item discount schema", () => {
it("accepts a 0100 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 0100 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);
});
});

View File

@@ -0,0 +1,94 @@
import { describe, it, expect, afterEach } from "vitest";
import prisma from "../config/database";
import { createOffer, getOfferTotals } from "../services/offers.service";
import {
createInvoice,
getInvoiceListTotals,
} from "../services/invoices.service";
/**
* Per-item Sleva (% discount) must reduce the line NET — and on invoices the
* VAT is computed on the discounted net. Hits the real `app_test` DB through the
* service layer (suite convention), mirroring offer-invoice-totals.test.ts.
*/
const MARKER = "SLEVA-TOTALS-2096";
const YEAR = 2096;
const MONTH = 8; // invoice issue_date window [2096-08-01, 2096-09-01)
const dateStr = `${YEAR}-0${MONTH}-15`;
const offerIds: number[] = [];
const invoiceIds: number[] = [];
afterEach(async () => {
for (const id of offerIds)
await prisma.quotations.deleteMany({ where: { id } });
for (const id of invoiceIds)
await prisma.invoices.deleteMany({ where: { id } });
offerIds.length = 0;
invoiceIds.length = 0;
await prisma.number_sequences.deleteMany({
where: { type: { in: ["offer", "invoice"] } },
});
});
const amountFor = (
totals: { currency: string; amount: number }[],
currency: string,
): number | undefined => totals.find((t) => t.currency === currency)?.amount;
describe("offer per-item discount", () => {
it("reduces the offer NET total by the per-item percentage", async () => {
// 2 × 1000 with 10% off => 1800 net.
const res = await createOffer({
status: "draft",
currency: "CZK",
project_code: MARKER,
items: [
{ description: "X", quantity: 2, unit_price: 1000, discount: 10 },
],
});
if ("error" in res) throw new Error(JSON.stringify(res));
offerIds.push(res.id);
const { totals } = await getOfferTotals({ search: MARKER });
expect(amountFor(totals, "CZK")).toBe(1800);
});
});
describe("invoice per-item discount", () => {
it("computes VAT on the discounted net (gross = discounted net + VAT)", async () => {
// 2 × 1000 with 10% off => net 1800, VAT@21% = 378, gross 2178.
const inv = await createInvoice({
status: "draft",
currency: "CZK",
vat_rate: 21,
apply_vat: true,
issue_date: dateStr,
items: [
{ description: "X", quantity: 2, unit_price: 1000, discount: 10 },
],
});
invoiceIds.push(inv.id);
const { totals } = await getInvoiceListTotals({ month: MONTH, year: YEAR });
expect(amountFor(totals, "CZK")).toBe(2178);
});
it("treats a 100% discount line as zero", async () => {
const inv = await createInvoice({
status: "draft",
currency: "CZK",
vat_rate: 21,
apply_vat: true,
issue_date: dateStr,
items: [
{ description: "Free", quantity: 5, unit_price: 200, discount: 100 },
],
});
invoiceIds.push(inv.id);
const { totals } = await getInvoiceListTotals({ month: MONTH, year: YEAR });
expect(amountFor(totals, "CZK")).toBeUndefined(); // zero totals are dropped
});
});

View File

@@ -0,0 +1,22 @@
import { describe, it, expect } from "vitest";
import { lineNet } from "../utils/money";
describe("lineNet (per-item discounted net)", () => {
it("returns qty × unit_price when there is no discount", () => {
expect(lineNet(2, 1000, 0)).toBe(2000);
});
it("applies a percentage discount to the line net", () => {
expect(lineNet(2, 1000, 10)).toBe(1800);
});
it("treats a 100% discount as zero", () => {
expect(lineNet(3, 500, 100)).toBe(0);
});
it("treats null/undefined/NaN discount as no discount", () => {
expect(lineNet(2, 1000, null)).toBe(2000);
expect(lineNet(2, 1000, undefined)).toBe(2000);
expect(lineNet(2, 1000, NaN)).toBe(2000);
});
});