diff --git a/package-lock.json b/package-lock.json index 4e184c8..dd3c114 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "app-ts", - "version": "2.4.35", + "version": "2.4.36", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "app-ts", - "version": "2.4.35", + "version": "2.4.36", "license": "ISC", "dependencies": { "@anthropic-ai/sdk": "^0.102.0", diff --git a/package.json b/package.json index 11f2213..2ae12b4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "app-ts", - "version": "2.4.35", + "version": "2.4.36", "description": "", "main": "dist/server.js", "scripts": { diff --git a/prisma/migrations/20260613195833_add_item_discount/migration.sql b/prisma/migrations/20260613195833_add_item_discount/migration.sql new file mode 100644 index 0000000..9a7c012 --- /dev/null +++ b/prisma/migrations/20260613195833_add_item_discount/migration.sql @@ -0,0 +1,6 @@ +-- AlterTable +ALTER TABLE `invoice_items` ADD COLUMN `discount` DECIMAL(5, 2) NULL DEFAULT 0.00; + +-- AlterTable +ALTER TABLE `quotation_items` ADD COLUMN `discount` DECIMAL(5, 2) NULL DEFAULT 0.00; + diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e12e01b..c7c1d5c 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -208,6 +208,7 @@ model invoice_items { quantity Decimal? @default(1.000) @db.Decimal(12, 3) unit String? @db.VarChar(20) unit_price Decimal? @default(0.00) @db.Decimal(12, 2) + discount Decimal? @default(0.00) @db.Decimal(5, 2) vat_rate Decimal? @default(21.00) @db.Decimal(5, 2) position Int? @default(0) invoices invoices @relation(fields: [invoice_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "invoice_items_ibfk_1") @@ -505,6 +506,7 @@ model quotation_items { quantity Decimal? @default(1.000) @db.Decimal(12, 3) unit String? @db.VarChar(20) unit_price Decimal? @default(0.00) @db.Decimal(12, 2) + discount Decimal? @default(0.00) @db.Decimal(5, 2) is_included_in_total Boolean? @default(true) modified_at DateTime? @db.DateTime(0) quotations quotations @relation(fields: [quotation_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "quotation_items_ibfk_1") diff --git a/src/__tests__/item-discount-pdf.test.ts b/src/__tests__/item-discount-pdf.test.ts new file mode 100644 index 0000000..a37ba1d --- /dev/null +++ b/src/__tests__/item-discount-pdf.test.ts @@ -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 | 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 => { + 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 => { + 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"); + }); +}); diff --git a/src/__tests__/item-discount-schema.test.ts b/src/__tests__/item-discount-schema.test.ts new file mode 100644 index 0000000..4d84edb --- /dev/null +++ b/src/__tests__/item-discount-schema.test.ts @@ -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 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); + }); +}); diff --git a/src/__tests__/item-discount-totals.test.ts b/src/__tests__/item-discount-totals.test.ts new file mode 100644 index 0000000..5191fa3 --- /dev/null +++ b/src/__tests__/item-discount-totals.test.ts @@ -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 + }); +}); diff --git a/src/__tests__/money.test.ts b/src/__tests__/money.test.ts new file mode 100644 index 0000000..68fb595 --- /dev/null +++ b/src/__tests__/money.test.ts @@ -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); + }); +}); diff --git a/src/admin/components/document/DocumentItemsEditor.tsx b/src/admin/components/document/DocumentItemsEditor.tsx index 40225d9..df1700c 100644 --- a/src/admin/components/document/DocumentItemsEditor.tsx +++ b/src/admin/components/document/DocumentItemsEditor.tsx @@ -49,10 +49,31 @@ export interface DocumentItem { quantity: string | number; unit: string; unit_price: string | number; + /** Per-item discount as a percentage (0–100). Offers only; 0 elsewhere. */ + discount: string | number; /** Offers only — undefined (issued orders) counts as included. */ is_included_in_total?: boolean; } +/** + * Discount input styling: centered, and with the native number spinners hidden + * so a 3-digit value ("100") isn't clipped under the up/down arrows. + */ +const discountInputSx = { + "& input": { textAlign: "center" }, + "& input[type=number]": { MozAppearance: "textfield" as const }, + "& input::-webkit-outer-spin-button, & input::-webkit-inner-spin-button": { + WebkitAppearance: "none", + margin: 0, + }, +}; + +/** NET of one line after its per-item percentage discount. */ +const discountedLineNet = (item: DocumentItem): number => + (Number(item.quantity) || 0) * + (Number(item.unit_price) || 0) * + (1 - (Number(item.discount) || 0) / 100); + let documentItemKeyCounter = 0; /** Unique client-side key for a (new or hydrated) document item row. */ @@ -66,6 +87,7 @@ export const emptyDocumentItem = (): DocumentItem => ({ quantity: 1, unit: "ks", unit_price: 0, + discount: 0, is_included_in_total: true, }); @@ -73,9 +95,7 @@ export const emptyDocumentItem = (): DocumentItem => ({ export const documentItemsTotal = (items: DocumentItem[]): number => items.reduce( (sum, it) => - it.is_included_in_total === false - ? sum - : sum + (Number(it.quantity) || 0) * (Number(it.unit_price) || 0), + it.is_included_in_total === false ? sum : sum + discountedLineNet(it), 0, ); @@ -111,6 +131,7 @@ function SortableItemRow({ readOnly, canDelete, showIncludedInTotal, + showDiscount, itemDescriptionMaxLength, onUpdate, onRemove, @@ -121,6 +142,7 @@ function SortableItemRow({ readOnly: boolean; canDelete: boolean; showIncludedInTotal: boolean; + showDiscount: boolean; itemDescriptionMaxLength: number; onUpdate: (field: keyof DocumentItem, value: string | boolean) => void; onRemove: () => void; @@ -143,8 +165,7 @@ function SortableItemRow({ position: "relative" as const, zIndex: isDragging ? 10 : undefined, }; - const lineTotal = - (Number(item.quantity) || 0) * (Number(item.unit_price) || 0); + const lineTotal = discountedLineNet(item); if (isMobile) { return ( @@ -253,6 +274,17 @@ function SortableItemRow({ InputProps={{ readOnly }} sx={{ "& input": { textAlign: "right" } }} /> + {showDiscount && ( + onUpdate("discount", e.target.value)} + slotProps={{ htmlInput: { min: "0", max: "100", step: "any" } }} + InputProps={{ readOnly }} + sx={discountInputSx} + /> + )} + {showDiscount && ( + + onUpdate("discount", e.target.value)} + slotProps={{ htmlInput: { min: "0", max: "100", step: "any" } }} + InputProps={{ readOnly }} + sx={discountInputSx} + /> + + )} {showIncludedInTotal && ( 1} showIncludedInTotal={showIncludedInTotal} + showDiscount={showDiscount} itemDescriptionMaxLength={itemDescriptionMaxLength} onUpdate={(field, value) => updateItem(index, field, value)} onRemove={() => removeItem(index)} @@ -594,6 +642,11 @@ export default function DocumentItemsEditor({ Jedn. cena + {showDiscount && ( + + Sleva % + + )} {showIncludedInTotal && ( V ceně @@ -615,6 +668,7 @@ export default function DocumentItemsEditor({ readOnly={readOnly} canDelete={items.length > 1} showIncludedInTotal={showIncludedInTotal} + showDiscount={showDiscount} itemDescriptionMaxLength={itemDescriptionMaxLength} onUpdate={(field, value) => updateItem(index, field, value) diff --git a/src/admin/lib/queries/invoices.ts b/src/admin/lib/queries/invoices.ts index 00fafc1..1591b7d 100644 --- a/src/admin/lib/queries/invoices.ts +++ b/src/admin/lib/queries/invoices.ts @@ -38,6 +38,7 @@ export interface InvoiceItem { quantity: number; unit: string; unit_price: number; + discount?: number; vat_rate?: number; is_included_in_total: boolean; position?: number; diff --git a/src/admin/lib/queries/offers.ts b/src/admin/lib/queries/offers.ts index 7d73292..2934602 100644 --- a/src/admin/lib/queries/offers.ts +++ b/src/admin/lib/queries/offers.ts @@ -143,6 +143,7 @@ export interface OfferItemData { quantity: number; unit: string; unit_price: number; + discount?: number; is_included_in_total: boolean; position?: number; } diff --git a/src/admin/pages/InvoiceDetail.tsx b/src/admin/pages/InvoiceDetail.tsx index a689d2d..ce020d9 100644 --- a/src/admin/pages/InvoiceDetail.tsx +++ b/src/admin/pages/InvoiceDetail.tsx @@ -185,9 +185,34 @@ interface InvoiceItem { quantity: string | number; unit: string; unit_price: string | number; + /** Per-item discount as a percentage (0–100). */ + discount: string | number; vat_rate: number; } +/** + * Discount input styling: centered, native number spinners hidden so a 3-digit + * value ("100") isn't clipped under the up/down arrows. + */ +const discountInputSx = { + "& input": { textAlign: "center" }, + "& input[type=number]": { MozAppearance: "textfield" as const }, + "& input::-webkit-outer-spin-button, & input::-webkit-inner-spin-button": { + WebkitAppearance: "none", + margin: 0, + }, +}; + +/** NET of one invoice line after its per-item percentage discount. */ +const invoiceLineNet = (item: { + quantity: string | number; + unit_price: string | number; + discount?: string | number | null; +}): number => + (Number(item.quantity) || 0) * + (Number(item.unit_price) || 0) * + (1 - (Number(item.discount) || 0) / 100); + interface InvoiceForm { customer_id: number | null; customer_name: string; @@ -253,8 +278,7 @@ function SortableInvoiceRow({ position: "relative" as const, zIndex: isDragging ? 10 : undefined, }; - const lineTotal = - (Number(item.quantity) || 0) * (Number(item.unit_price) || 0); + const lineTotal = invoiceLineNet(item); if (isMobile) { return ( @@ -349,6 +373,14 @@ function SortableInvoiceRow({ onChange={(e) => onUpdate(index, "unit_price", e.target.value)} slotProps={{ htmlInput: { step: "any" } }} /> + onUpdate(index, "discount", e.target.value)} + slotProps={{ htmlInput: { min: "0", max: "100", step: "any" } }} + sx={discountInputSx} + /> {apply_vat && ( Number(it.discount) > 0, + ); const nextNumberQuery = useQuery({ queryKey: ["invoices", "next-number"], @@ -751,6 +798,7 @@ export default function InvoiceDetail() { quantity: Number(item.quantity) || 1, unit: item.unit || "", unit_price: Number(item.unit_price) || 0, + discount: Number(item.discount) || 0, // Per-line VAT: hydrate from the ITEM's own stored rate (a real DB // column returned by the detail endpoint) — falling back to the // invoice-level rate only when the line carries none. Hydrating @@ -848,6 +896,7 @@ export default function InvoiceDetail() { quantity: Number(item.quantity) || 1, unit: (item.unit as string) || "", unit_price: Number(item.unit_price) || 0, + discount: Number(item.discount) || 0, vat_rate: vatRate, })), ); @@ -967,8 +1016,7 @@ export default function InvoiceDetail() { const vatByRate: Record = {}; items.forEach((item) => { - const lineTotal = - (Number(item.quantity) || 0) * (Number(item.unit_price) || 0); + const lineTotal = invoiceLineNet(item); subtotal += lineTotal; if (form.apply_vat) { @@ -1038,6 +1086,7 @@ export default function InvoiceDetail() { // field never reaches the server as NaN (mirrors createTotals). quantity: Number(item.quantity) || 0, unit_price: Number(item.unit_price) || 0, + discount: Number(item.discount) || 0, position: i, })), sections: sections.map((s, i) => ({ @@ -1361,9 +1410,7 @@ export default function InvoiceDetail() { isMobile ? ( {invoice.items.map((item, index) => { - const lineSubtotal = - (Number(item.quantity) || 0) * - (Number(item.unit_price) || 0); + const lineSubtotal = invoiceLineNet(item); const lineVat = Number(invoice.apply_vat) ? (lineSubtotal * (Number(item.vat_rate) || 0)) / 100 : 0; @@ -1494,6 +1541,11 @@ export default function InvoiceDetail() { Jedn. cena + {hasItemDiscount && ( + + Sleva % + + )} DPH @@ -1504,9 +1556,7 @@ export default function InvoiceDetail() { {invoice.items.map((item, index) => { - const lineSubtotal = - (Number(item.quantity) || 0) * - (Number(item.unit_price) || 0); + const lineSubtotal = invoiceLineNet(item); const lineVat = Number(invoice.apply_vat) ? (lineSubtotal * (Number(item.vat_rate) || 0)) / 100 : 0; @@ -1555,6 +1605,13 @@ export default function InvoiceDetail() { > {formatCurrency(item.unit_price, invoice.currency)} + {hasItemDiscount && ( + + {Number(item.discount) > 0 + ? `${Number(item.discount)} %` + : "—"} + + )} {Number(invoice.apply_vat) ? Number(item.vat_rate) @@ -2238,6 +2295,9 @@ export default function InvoiceDetail() { Jedn. cena + + Sleva % + {form.apply_vat ? ( DPH diff --git a/src/admin/pages/IssuedOrderDetail.tsx b/src/admin/pages/IssuedOrderDetail.tsx index 7d64d25..870d017 100644 --- a/src/admin/pages/IssuedOrderDetail.tsx +++ b/src/admin/pages/IssuedOrderDetail.tsx @@ -293,6 +293,8 @@ export default function IssuedOrderDetail() { quantity: numberOr(it.quantity, 1), unit: it.unit || "", unit_price: Number(it.unit_price) || 0, + // Issued orders have no Sleva column; keep the shared item shape happy. + discount: 0, })) : [emptyDocumentItem()]; setItems(mappedItems); diff --git a/src/admin/pages/OfferDetail.tsx b/src/admin/pages/OfferDetail.tsx index c8fcd1f..86286c5 100644 --- a/src/admin/pages/OfferDetail.tsx +++ b/src/admin/pages/OfferDetail.tsx @@ -93,6 +93,7 @@ interface OfferItemPayload { quantity: number; unit: string; unit_price: number; + discount: number; is_included_in_total: boolean; position: number; } @@ -303,6 +304,7 @@ export default function OfferDetail() { quantity: numberOr(it.quantity, 1), unit: it.unit || "", unit_price: Number(it.unit_price) || 0, + discount: Number(it.discount) || 0, is_included_in_total: it.is_included_in_total !== false, })) : [emptyDocumentItem()]; @@ -433,6 +435,7 @@ export default function OfferDetail() { quantity: Number(item.quantity) || 0, unit: item.unit, unit_price: Number(item.unit_price) || 0, + discount: Number(item.discount) || 0, is_included_in_total: item.is_included_in_total !== false, position: i, })), @@ -936,6 +939,7 @@ export default function OfferDetail() { readOnly={readOnly} error={errors.items} showIncludedInTotal + showDiscount itemDescriptionMaxLength={8000} templatesSlot={ itemTemplates && itemTemplates.length > 0 ? ( @@ -957,6 +961,7 @@ export default function OfferDetail() { quantity: 1, unit: "ks", unit_price: template.default_price || 0, + discount: 0, is_included_in_total: true, }, ]); diff --git a/src/routes/admin/invoices-pdf.ts b/src/routes/admin/invoices-pdf.ts index 104db4a..59ee18a 100644 --- a/src/routes/admin/invoices-pdf.ts +++ b/src/routes/admin/invoices-pdf.ts @@ -7,6 +7,7 @@ import { htmlToPdf } from "../../utils/html-to-pdf"; import { getRate } from "../../services/exchange-rates"; import { localDateStr } from "../../utils/date"; import { parseId, success } from "../../utils/response"; +import { lineNet } from "../../utils/money"; import { formatDate, formatNum, @@ -161,6 +162,7 @@ const translations: Record> = { col_desc: "Popis", col_qty: "Množství", col_unit_price: "Jedn. cena", + col_discount: "Sleva", col_price: "Cena", col_vat_pct: "%DPH", col_vat: "DPH", @@ -208,6 +210,7 @@ const translations: Record> = { col_desc: "Description", col_qty: "Quantity", col_unit_price: "Unit price", + col_discount: "Discount", col_price: "Price", col_vat_pct: "VAT%", col_vat: "VAT", @@ -312,6 +315,8 @@ export default async function invoicesPdfRoutes( where: { invoice_id: id }, orderBy: [{ position: "asc" }, { id: "asc" }], }); + // Sleva column is rendered only when at least one item has a discount. + const hasDiscount = items.some((it) => Number(it.discount) > 0); let customer: Record | null = null; if (invoice.customer_id) { @@ -362,7 +367,11 @@ export default async function invoicesPdfRoutes( let subtotal = 0; for (const item of items) { - const lineSubtotal = Number(item.quantity) * Number(item.unit_price); + const lineSubtotal = lineNet( + Number(item.quantity), + Number(item.unit_price), + Number(item.discount), + ); subtotal += lineSubtotal; const rate = Number(item.vat_rate); const key = String(rate); @@ -489,11 +498,16 @@ export default async function invoicesPdfRoutes( .map((item, i) => { const qty = Number(item.quantity); const unitPrice = Number(item.unit_price); - const lineSubtotal = qty * unitPrice; + const disc = Number(item.discount) || 0; + const lineSubtotal = lineNet(qty, unitPrice, disc); const vatRate = Number(item.vat_rate); const lineVat = applyVat ? (lineSubtotal * vatRate) / 100 : 0; const lineTotal = lineSubtotal + lineVat; const qtyDecimals = Math.floor(qty) === qty ? 0 : 2; + const discDecimals = Math.floor(disc) === disc ? 0 : 2; + const discCell = hasDiscount + ? `${disc > 0 ? `${formatNum(disc, discDecimals)} %` : "—"}` + : ""; const subDesc = item.item_description || ""; return ` @@ -501,6 +515,7 @@ export default async function invoicesPdfRoutes( ${escapeHtml(item.description)}${subDesc ? `
${escapeHtml(subDesc)}
` : ""} ${formatNum(qty, qtyDecimals)}${item.unit ? ` / ${escapeHtml(item.unit)}` : ""} ${formatNum(unitPrice)} + ${discCell} ${formatNum(lineSubtotal)} ${applyVat ? Math.floor(vatRate) : 0}% ${formatNum(lineVat)} @@ -1052,9 +1067,10 @@ ${indentCSS} ${escapeHtml(t.col_no)} - ${escapeHtml(t.col_desc)} + ${escapeHtml(t.col_desc)} ${escapeHtml(t.col_qty)} ${escapeHtml(t.col_unit_price)} + ${hasDiscount ? `${escapeHtml(t.col_discount)}` : ""} ${escapeHtml(t.col_price)} ${escapeHtml(t.col_vat_pct)} ${escapeHtml(t.col_vat)} diff --git a/src/routes/admin/offers-pdf.ts b/src/routes/admin/offers-pdf.ts index 73a0b51..7010def 100644 --- a/src/routes/admin/offers-pdf.ts +++ b/src/routes/admin/offers-pdf.ts @@ -5,6 +5,7 @@ import { requirePermission } from "../../middleware/auth"; import { nasOffersManager } from "../../services/nas-offers-manager"; import { htmlToPdf } from "../../utils/html-to-pdf"; import { parseId, success } from "../../utils/response"; +import { lineNet } from "../../utils/money"; import { formatDate, formatNum, @@ -123,6 +124,7 @@ const TRANSLATIONS: Record> = { description: { EN: "Description", CZ: "Popis" }, qty: { EN: "Qty", CZ: "Mn." }, unit_price: { EN: "Unit Price", CZ: "Jedn. cena" }, + discount: { EN: "Discount", CZ: "Sleva" }, included: { EN: "Included", CZ: "Zahrnuto" }, total: { EN: "Total", CZ: "Celkem" }, total_no_vat: { EN: "Total excl. VAT", CZ: "Celkem bez DPH" }, @@ -170,9 +172,15 @@ export function renderOfferHtml( let total = 0; for (const item of items) { if (item.is_included_in_total !== false) { - total += (Number(item.quantity) || 0) * (Number(item.unit_price) || 0); + total += lineNet( + Number(item.quantity) || 0, + Number(item.unit_price) || 0, + Number(item.discount), + ); } } + // Sleva column is rendered only when at least one item carries a discount. + const hasDiscount = items.some((it) => Number(it.discount) > 0); // Visibility uses the SAME per-language title rule as the render loop plus // a tag-stripped content check (an empty-Quill "


" doesn't count) // — otherwise a section with only the other language's title would count as @@ -217,10 +225,15 @@ export function renderOfferHtml( let itemsHtml = ""; items.forEach((item, i) => { const qty = Number(item.quantity) || 0; - const lineTotal = qty * (Number(item.unit_price) || 0); + const disc = Number(item.discount) || 0; + const lineTotal = lineNet(qty, Number(item.unit_price) || 0, disc); // Integers render without decimals; fractional quantities keep 2 decimals // (the old toFixed(0) ROUNDED 1.5 → 2 on the printed document). const qtyDecimals = Math.floor(qty) === qty ? 0 : 2; + const discDecimals = Math.floor(disc) === disc ? 0 : 2; + const discCell = hasDiscount + ? `${disc > 0 ? `${formatNum(disc, discDecimals)} %` : "—"}` + : ""; const subDesc = item.item_description || ""; const evenClass = i % 2 === 1 ? ' class="even"' : ""; itemsHtml += ` @@ -228,6 +241,7 @@ export function renderOfferHtml( ${escapeHtml(item.description)}${subDesc ? `
${escapeHtml(subDesc)}
` : ""} ${formatNum(qty, qtyDecimals)}${(item.unit || "").trim() ? ` / ${escapeHtml((item.unit || "").trim())}` : ""} ${formatCurrency(Number(item.unit_price) || 0, currency)} + ${discCell} ${formatCurrency(lineTotal, currency)} `; }); @@ -621,10 +635,11 @@ ${indentCSS} ${escapeHtml(t("no"))} - ${escapeHtml(t("description"))} - ${escapeHtml(t("qty"))} - ${escapeHtml(t("unit_price"))} - ${escapeHtml(t("total"))} + ${escapeHtml(t("description"))} + ${escapeHtml(t("qty"))} + ${escapeHtml(t("unit_price"))} + ${hasDiscount ? `${escapeHtml(t("discount"))}` : ""} + ${escapeHtml(t("total"))} diff --git a/src/schemas/invoices.schema.ts b/src/schemas/invoices.schema.ts index 5f02dad..69c01b3 100644 --- a/src/schemas/invoices.schema.ts +++ b/src/schemas/invoices.schema.ts @@ -16,6 +16,8 @@ const InvoiceItemSchema = z.object({ quantity: positiveNumberFromForm.default(1), unit: z.string().max(20).nullish(), unit_price: numberFromForm.default(0), + // Per-item discount as a percentage (0–100); column is Decimal(5,2). + discount: numberInRange(0, 100).optional().default(0), vat_rate: numberInRange(0, 100).optional().default(21.0), position: nonNegativeNumberFromForm.optional(), }); diff --git a/src/schemas/offers.schema.ts b/src/schemas/offers.schema.ts index 96d3aa6..4fec4d9 100644 --- a/src/schemas/offers.schema.ts +++ b/src/schemas/offers.schema.ts @@ -3,6 +3,7 @@ import { nonNegativeIntFromForm, nonNegativeNumberFromForm, positiveNumberFromForm, + numberInRange, nullableIntIdFromForm, booleanFromForm, isoDateString, @@ -17,6 +18,8 @@ const QuotationItemSchema = z.object({ quantity: positiveNumberFromForm.default(1), unit: z.string().max(20).nullish(), unit_price: nonNegativeNumberFromForm.default(0), + // Per-item discount as a percentage (0–100); column is Decimal(5,2). + discount: numberInRange(0, 100).optional().default(0), is_included_in_total: booleanFromForm.optional().default(true), // Int column — a float position would 500 at Prisma. position: nonNegativeIntFromForm.optional(), diff --git a/src/services/invoices.service.ts b/src/services/invoices.service.ts index 97fbd05..175f9b5 100644 --- a/src/services/invoices.service.ts +++ b/src/services/invoices.service.ts @@ -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, })), diff --git a/src/services/offers.service.ts b/src/services/offers.service.ts index b256b4f..da66514 100644 --- a/src/services/offers.service.ts +++ b/src/services/offers.service.ts @@ -1,5 +1,6 @@ import { Prisma } from "@prisma/client"; import prisma from "../config/database"; +import { lineNet } from "../utils/money"; import { generateOfferNumber, releaseOfferNumber, @@ -14,6 +15,8 @@ interface QuotationItemInput { quantity?: number; unit?: string; unit_price?: number; + /** Per-item discount as a percentage (0–100). */ + discount?: number; is_included_in_total?: boolean; position?: number; } @@ -122,6 +125,7 @@ type EnrichableLineItem = { is_included_in_total?: unknown; quantity?: unknown; unit_price?: unknown; + discount?: unknown; }; type EnrichableQuotation = { quotation_items: EnrichableLineItem[]; @@ -138,7 +142,12 @@ function enrichQuotation(q: T) { .filter((i) => i.is_included_in_total !== false) .reduce( (s: number, i) => - s + (Number(i.quantity) || 0) * (Number(i.unit_price) || 0), + s + + lineNet( + Number(i.quantity) || 0, + Number(i.unit_price) || 0, + Number(i.discount), + ), 0, ); const { quotation_items, scope_sections, ...rest } = q; @@ -345,6 +354,7 @@ export async function createOffer(body: Record) { quantity: item.quantity ?? 1, unit: item.unit ?? null, unit_price: item.unit_price ?? 0, + discount: item.discount ?? 0, is_included_in_total: item.is_included_in_total !== false, position: item.position ?? i, })), @@ -480,6 +490,7 @@ export async function updateOffer(id: number, body: Record) { quantity: item.quantity ?? 1, unit: item.unit ?? null, unit_price: item.unit_price ?? 0, + discount: item.discount ?? 0, is_included_in_total: item.is_included_in_total !== false, position: item.position ?? i, })), @@ -602,6 +613,7 @@ export async function duplicateOffer(id: number) { quantity: item.quantity, unit: item.unit, unit_price: item.unit_price, + discount: item.discount, is_included_in_total: item.is_included_in_total, position: item.position, })), diff --git a/src/utils/money.ts b/src/utils/money.ts new file mode 100644 index 0000000..3fad87d --- /dev/null +++ b/src/utils/money.ts @@ -0,0 +1,17 @@ +/** + * Net amount of a single document line after its per-item percentage discount + * ("Sleva"). `net = qty × unit_price × (1 − discount/100)`. + * + * The discount is tolerant of null/undefined/NaN (a missing discount = none) + * and clamped to 0–100 so a stray value can never make the net negative. + * Single source of truth for offers + invoices (totals, VAT base, PDFs). + */ +export function lineNet( + qty: number, + unitPrice: number, + discountPct: number | null | undefined, +): number { + const pct = Number(discountPct); + const safePct = Number.isFinite(pct) ? Math.min(100, Math.max(0, pct)) : 0; + return qty * unitPrice * (1 - safePct / 100); +}