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:
94
src/__tests__/item-discount-totals.test.ts
Normal file
94
src/__tests__/item-discount-totals.test.ts
Normal 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
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user