From 96df0c334a464fa641b0fd35ff0c70246994bbe0 Mon Sep 17 00:00:00 2001 From: Kundencenter Date: Mon, 28 Sep 2026 11:01:19 +0200 Subject: [PATCH] Rechnungen: Rabatt je Position --- apps/api/src/modules/invoices/index.ts | 20 +++++++-------- apps/api/src/modules/invoices/pdf.ts | 7 +++--- apps/api/test/invoices.test.ts | 25 +++++++++++++++++++ .../src/app/(app)/rechnungen/[id]/page.tsx | 19 +++++++------- migrations/021_invoice_item_discount.sql | 2 ++ 5 files changed, 51 insertions(+), 22 deletions(-) create mode 100644 migrations/021_invoice_item_discount.sql diff --git a/apps/api/src/modules/invoices/index.ts b/apps/api/src/modules/invoices/index.ts index 55d24cc..b6758d6 100644 --- a/apps/api/src/modules/invoices/index.ts +++ b/apps/api/src/modules/invoices/index.ts @@ -15,8 +15,8 @@ import { renderInvoicePdf, type CompanySettings, type InvoiceForPdf } from './pd /** Kaufmännisches Runden (halb auf), wie in @kc/platform/pricing – hier lokal, weil Rechnungspositionen * (Freitext, Dezimalmenge) sich nicht in das Produkt-Preisschema von calculatePrice pressen lassen. */ const divRound = (n: number, d: number): number => Math.floor((n * 2 + d) / (d * 2)); -function lineAmounts(unitNetCents: number, quantity: number, taxBp: number) { - const net = Math.round(unitNetCents * quantity); +function lineAmounts(unitNetCents: number, quantity: number, taxBp: number, discountBp = 0) { + const net = divRound(Math.round(unitNetCents * quantity) * (10000 - discountBp), 10000); const tax = divRound(net * taxBp, 10000); return { net, tax, gross: net + tax }; } @@ -36,7 +36,7 @@ async function settings(): Promise { } const complete = (s: CompanySettings) => !!(s.name && s.street && s.zip && s.city && (s.taxNumber || s.vatId)); -const itemView = (i: any) => ({ id: i.id, contractId: i.contract_id, description: i.description, quantity: Number(i.quantity), unitPriceNetCents: i.unit_price_net_cents, taxBp: i.tax_bp, netCents: i.net_cents, taxCents: i.tax_cents, grossCents: i.gross_cents }); +const itemView = (i: any) => ({ id: i.id, contractId: i.contract_id, description: i.description, quantity: Number(i.quantity), unitPriceNetCents: i.unit_price_net_cents, taxBp: i.tax_bp, discountBp: i.discount_bp, netCents: i.net_cents, taxCents: i.tax_cents, grossCents: i.gross_cents }); const invoiceView = (v: any) => ({ id: v.id, number: v.number, orgId: v.org_id, orgName: v.org_name, customerNumber: v.customer_number, status: v.status, issueDate: v.issue_date, dueDate: v.due_date, overdue: v.status === 'open' && v.due_date && new Date(v.due_date) < new Date(), @@ -137,7 +137,7 @@ export const invoicesModule: KcModule = { app.put('/invoices/:id/items', async (req) => { const a = requirePermission(req, 'invoices.write'); const { id } = z.object({ id: z.string().uuid() }).parse(req.params); const b = z.object({ items: z.array(z.object({ - description: z.string().trim().min(1).max(300), quantity: z.number().positive().max(100000), unitPriceNetCents: z.number().int().min(0).max(100_000_00), taxBp: z.number().int().min(0).max(3000), contractId: z.string().uuid().optional(), + description: z.string().trim().min(1).max(300), quantity: z.number().positive().max(100000), unitPriceNetCents: z.number().int().min(0).max(100_000_00), taxBp: z.number().int().min(0).max(3000), discountBp: z.number().int().min(0).max(10000).default(0), contractId: z.string().uuid().optional(), })).min(1).max(100) }).parse(req.body); const v = await one('SELECT * FROM invoices WHERE id = ?', [id]); if (!v) throw notFound(); if (v.status !== 'draft') throw conflict('Nur Entwürfe können bearbeitet werden. Ausgestellte Rechnungen sind unveränderlich (nur Storno möglich).', 'INVOICE_NOT_DRAFT'); @@ -145,9 +145,9 @@ export const invoicesModule: KcModule = { await tx(async (c) => { await run('DELETE FROM invoice_items WHERE invoice_id = ?', [id], c); for (const [idx, it] of b.items.entries()) { - const a2 = lineAmounts(it.unitPriceNetCents, it.quantity, it.taxBp); net += a2.net; tax += a2.tax; gross += a2.gross; - await run('INSERT INTO invoice_items (id, invoice_id, contract_id, description, quantity, unit_price_net_cents, tax_bp, net_cents, tax_cents, gross_cents, sort_order) VALUES (?,?,?,?,?,?,?,?,?,?,?)', - [randomUUID(), id, it.contractId ?? null, it.description, it.quantity, it.unitPriceNetCents, it.taxBp, a2.net, a2.tax, a2.gross, idx], c); + const a2 = lineAmounts(it.unitPriceNetCents, it.quantity, it.taxBp, it.discountBp); net += a2.net; tax += a2.tax; gross += a2.gross; + await run('INSERT INTO invoice_items (id, invoice_id, contract_id, description, quantity, unit_price_net_cents, tax_bp, discount_bp, net_cents, tax_cents, gross_cents, sort_order) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)', + [randomUUID(), id, it.contractId ?? null, it.description, it.quantity, it.unitPriceNetCents, it.taxBp, it.discountBp, a2.net, a2.tax, a2.gross, idx], c); } await run('UPDATE invoices SET total_net_cents = ?, total_tax_cents = ?, total_gross_cents = ? WHERE id = ?', [net, tax, gross, id], c); }); @@ -252,8 +252,8 @@ export const invoicesModule: KcModule = { const n = await nextInvoiceNumber(c, s.invoicePrefix); await run('INSERT INTO invoices (id, number, org_id, status, issue_date, due_date, total_net_cents, total_tax_cents, total_gross_cents, note, seller_snapshot_json, buyer_snapshot_json, cancels_invoice_id, created_by, issued_at) VALUES (?,?,?,\'open\',CURDATE(),CURDATE(),?,?,?,?,?,?,?,?,UTC_TIMESTAMP(3))', [creditId, n, v.org_id, -v.total_net_cents, -v.total_tax_cents, -v.total_gross_cents, b.reason ? `Storno zu ${v.number}: ${b.reason}` : `Storno zu ${v.number}`, jstr(locked.seller_snapshot_json), jstr(locked.buyer_snapshot_json), id, a.user.id], c); - for (const it of items) await run('INSERT INTO invoice_items (id, invoice_id, contract_id, description, quantity, unit_price_net_cents, tax_bp, net_cents, tax_cents, gross_cents, sort_order) VALUES (?,?,?,?,?,?,?,?,?,?,?)', - [randomUUID(), creditId, it.contract_id, it.description, -Number(it.quantity), it.unit_price_net_cents, it.tax_bp, -it.net_cents, -it.tax_cents, -it.gross_cents, it.sort_order], c); + for (const it of items) await run('INSERT INTO invoice_items (id, invoice_id, contract_id, description, quantity, unit_price_net_cents, tax_bp, discount_bp, net_cents, tax_cents, gross_cents, sort_order) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)', + [randomUUID(), creditId, it.contract_id, it.description, -Number(it.quantity), it.unit_price_net_cents, it.tax_bp, it.discount_bp, -it.net_cents, -it.tax_cents, -it.gross_cents, it.sort_order], c); await run("UPDATE invoices SET status = 'cancelled', cancelled_at = UTC_TIMESTAMP(3), cancelled_by_invoice_id = ? WHERE id = ?", [creditId, id], c); return n; }); @@ -291,7 +291,7 @@ export const invoicesModule: KcModule = { const inv: InvoiceForPdf = { number: res.v.number, issueDate: res.v.issue_date, dueDate: res.v.due_date, status: res.v.status, paymentMethod: res.v.payment_method, note: res.v.note, totalNetCents: res.v.total_net_cents, totalTaxCents: res.v.total_tax_cents, totalGrossCents: res.v.total_gross_cents, - customer, items: res.items.map((i) => ({ description: i.description, quantity: Number(i.quantity), unitPriceNetCents: i.unit_price_net_cents, taxBp: i.tax_bp, netCents: i.net_cents, taxCents: i.tax_cents, grossCents: i.gross_cents })), + customer, items: res.items.map((i) => ({ description: i.description, quantity: Number(i.quantity), unitPriceNetCents: i.unit_price_net_cents, taxBp: i.tax_bp, discountBp: i.discount_bp, netCents: i.net_cents, taxCents: i.tax_cents, grossCents: i.gross_cents })), }; reply.header('content-type', 'application/pdf').header('content-disposition', `inline; filename="${res.v.number}.pdf"`); return reply.send(renderInvoicePdf(inv, seller)); diff --git a/apps/api/src/modules/invoices/pdf.ts b/apps/api/src/modules/invoices/pdf.ts index c76247d..a91a18e 100644 --- a/apps/api/src/modules/invoices/pdf.ts +++ b/apps/api/src/modules/invoices/pdf.ts @@ -5,7 +5,7 @@ export interface CompanySettings { taxNumber: string | null; vatId: string | null; bankName: string | null; iban: string | null; bic: string | null; invoicePrefix: string; defaultDueDays: number; paymentMethods: string[]; footerText: string | null; } -export interface InvoiceItemForPdf { description: string; quantity: number; unitPriceNetCents: number; taxBp: number; netCents: number; taxCents: number; grossCents: number } +export interface InvoiceItemForPdf { description: string; quantity: number; unitPriceNetCents: number; taxBp: number; discountBp: number; netCents: number; taxCents: number; grossCents: number } export interface InvoiceForPdf { number: string | null; issueDate: string | null; dueDate: string | null; status: string; paymentMethod: string | null; note: string | null; totalNetCents: number; totalTaxCents: number; totalGrossCents: number; @@ -42,8 +42,9 @@ export function renderInvoicePdf(inv: InvoiceForPdf, s: CompanySettings): PDFKit doc.moveTo(50, top + 14).lineTo(545, top + 14).strokeColor('#ccc').stroke(); let ry = top + 20; doc.fillColor('#000'); for (const it of inv.items) { - const h = doc.heightOfString(it.description, { width: 240 }); - doc.text(it.description, col.desc, ry, { width: 240 }); doc.text(String(it.quantity), col.qty, ry); + const desc = it.description + (it.discountBp > 0 ? ` (${(it.discountBp / 100).toLocaleString('de-DE')} % Rabatt)` : ''); + const h = doc.heightOfString(desc, { width: 240 }); + doc.text(desc, col.desc, ry, { width: 240 }); doc.text(String(it.quantity), col.qty, ry); doc.text(eur(it.unitPriceNetCents), col.unit, ry); doc.text(`${it.taxBp / 100} %`, col.tax, ry); doc.text(eur(it.netCents), col.sum, ry, { width: 75, align: 'right' }); ry += Math.max(h, 14) + 6; diff --git a/apps/api/test/invoices.test.ts b/apps/api/test/invoices.test.ts index c27db44..9a06b0d 100644 --- a/apps/api/test/invoices.test.ts +++ b/apps/api/test/invoices.test.ts @@ -248,3 +248,28 @@ describe('Rechnungen: eingefrorener Absender-/Empfänger-Stand, atomare Statuswe expect(invAfter.status).toBe('cancelled'); // nicht doppelt storniert, genau ein Storno-Verweis }); }); + +describe('Rechnungspositionen: Rabatt je Position', () => { + it('Rabatt mindert Netto/USt/Brutto korrekt, wirkt auf die Summen, wird im PDF ausgeliefert und bei Storno übernommen', async () => { + const admin = await staff('disc-admin@example.com', 'superadmin'); const acc = await staff('disc-acc@example.com', 'accounting'); + await call(app, admin, 'PUT', '/admin/company-settings', COMPANY); + const A = await customer(admin, 'Kunde Rabatt', 'disc-a@example.com'); + const draft = (await call(app, acc, 'POST', '/invoices', { orgId: A.org })).json(); + await call(app, acc, 'PUT', `/invoices/${draft.id}/items`, { items: [ + { description: 'Mit Rabatt', quantity: 1, unitPriceNetCents: 10000, taxBp: 1900, discountBp: 1000 }, // 10 % + { description: 'Ohne Rabatt', quantity: 1, unitPriceNetCents: 5000, taxBp: 1900 }, + ] }); + const inv = (await call(app, acc, 'GET', `/invoices/${draft.id}`)).json(); + expect(inv.items[0]).toMatchObject({ discountBp: 1000, netCents: 9000, taxCents: 1710, grossCents: 10710 }); + expect(inv.items[1]).toMatchObject({ discountBp: 0, netCents: 5000 }); + expect(inv.totalNetCents).toBe(14000); expect(inv.totalGrossCents).toBe(16660); + + await call(app, acc, 'POST', `/invoices/${draft.id}/issue`); + const pdf = await app.inject({ method: 'GET', url: `/v1/invoices/${draft.id}/pdf`, headers: { cookie: acc.cookie, 'x-csrf-token': acc.csrf } }); + expect(pdf.statusCode).toBe(200); + + const credit = (await call(app, acc, 'POST', `/invoices/${draft.id}/cancel`)).json(); + const creditItems = (await call(app, acc, 'GET', `/invoices/${credit.creditInvoiceId}`)).json().items; + expect(creditItems.find((i: any) => i.description === 'Mit Rabatt')).toMatchObject({ discountBp: 1000, netCents: -9000 }); + }); +}); diff --git a/apps/web/src/app/(app)/rechnungen/[id]/page.tsx b/apps/web/src/app/(app)/rechnungen/[id]/page.tsx index 2a4755c..315ffc3 100644 --- a/apps/web/src/app/(app)/rechnungen/[id]/page.tsx +++ b/apps/web/src/app/(app)/rechnungen/[id]/page.tsx @@ -6,12 +6,12 @@ import { api, errMsg } from '@/lib/api'; import { useSession } from '@/lib/session'; import { Alert, Field, InvoiceStatusBadge, eur, fmt } from '@/components/ui'; -interface Item { id: string; description: string; quantity: number; unitPriceNetCents: number; taxBp: number; netCents: number; taxCents: number; grossCents: number } +interface Item { id: string; description: string; quantity: number; unitPriceNetCents: number; taxBp: number; discountBp: number; netCents: number; taxCents: number; grossCents: number } interface Inv { id: string; number: string | null; orgId: string; orgName: string; status: string; overdue: boolean; issueDate: string | null; dueDate: string | null; paymentMethod: string | null; note: string | null; totalNetCents: number; totalTaxCents: number; totalGrossCents: number; cancelsInvoiceId: string | null; cancelledByInvoiceId: string | null; items: Item[]; canWrite: boolean } -type Row = { description: string; quantity: string; unitPrice: string; taxBp: string }; -const emptyRow = (): Row => ({ description: '', quantity: '1', unitPrice: '', taxBp: '19' }); -interface Suggested { description: string; quantity: number; unitPriceNetCents: number; taxBp: number } -const toRow = (it: Suggested): Row => ({ description: it.description, quantity: String(it.quantity), unitPrice: (it.unitPriceNetCents / 100).toFixed(2).replace('.', ','), taxBp: String(it.taxBp / 100) }); +type Row = { description: string; quantity: string; unitPrice: string; taxBp: string; discount: string }; +const emptyRow = (): Row => ({ description: '', quantity: '1', unitPrice: '', taxBp: '19', discount: '0' }); +interface Suggested { description: string; quantity: number; unitPriceNetCents: number; taxBp: number; discountBp?: number } +const toRow = (it: Suggested): Row => ({ description: it.description, quantity: String(it.quantity), unitPrice: (it.unitPriceNetCents / 100).toFixed(2).replace('.', ','), taxBp: String(it.taxBp / 100), discount: it.discountBp ? String(it.discountBp / 100) : '0' }); interface Prod { id: string; status: string; current: { name: string } | null; termPrices: { termMonths: number }[] } interface Contract { id: string; number: string; productName: string | null } interface Dom { id: string; domain: string; sellNetCents: number | null } @@ -41,7 +41,7 @@ export default function RechnungDetail() { useEffect(() => { if (fromContract && inv?.status === 'draft' && inv.items.length === 0 && !appliedRef.current) { appliedRef.current = true; void takeFrom('contract', fromContract); } }, [fromContract, inv]); // eslint-disable-line react-hooks/exhaustive-deps async function saveItems(e: FormEvent) { e.preventDefault(); - const items = rows.filter((r) => r.description.trim() && r.unitPrice.trim()).map((r) => ({ description: r.description.trim(), quantity: Number(r.quantity.replace(',', '.')), unitPriceNetCents: Math.round(Number(r.unitPrice.replace(',', '.')) * 100), taxBp: Math.round(Number(r.taxBp.replace(',', '.')) * 100) })); + const items = rows.filter((r) => r.description.trim() && r.unitPrice.trim()).map((r) => ({ description: r.description.trim(), quantity: Number(r.quantity.replace(',', '.')), unitPriceNetCents: Math.round(Number(r.unitPrice.replace(',', '.')) * 100), taxBp: Math.round(Number(r.taxBp.replace(',', '.')) * 100), discountBp: Math.round(Number((r.discount || '0').replace(',', '.')) * 100) })); if (items.length === 0) { setErr('Bitte mindestens eine Position angeben.'); return; } await run(() => api('PUT', `/invoices/${id}/items`, { items }), 'Gespeichert.'); } @@ -66,11 +66,12 @@ export default function RechnungDetail() { {contracts.length > 0 && } {domains.length > 0 && } } - {rows.map((r, i) => (
+ {rows.map((r, i) => (
setRow(i, 'description', e.target.value)} /> setRow(i, 'quantity', e.target.value)} /> setRow(i, 'unitPrice', e.target.value)} placeholder="0,00" /> setRow(i, 'taxBp', e.target.value)} /> + setRow(i, 'discount', e.target.value)} placeholder="0" />
))}
@@ -80,8 +81,8 @@ export default function RechnungDetail() {

Solange die Rechnung ein Entwurf ist, lassen sich Positionen beliebig ändern. Nach dem Ausstellen ist sie unveränderlich und nur noch per Storno korrigierbar.

) : ( -
- {inv.items.map((it) => ())} +
BeschreibungMengeEinzelpreisUSt.Netto
{it.description}{it.quantity}{eur(it.unitPriceNetCents)}{it.taxBp / 100} %{eur(it.netCents)}
+ {inv.items.map((it) => ())}
BeschreibungMengeEinzelpreisUSt.RabattNetto
{it.description}{it.quantity}{eur(it.unitPriceNetCents)}{it.taxBp / 100} %{it.discountBp > 0 ? `${it.discountBp / 100} %` : '–'}{eur(it.netCents)}
)} diff --git a/migrations/021_invoice_item_discount.sql b/migrations/021_invoice_item_discount.sql new file mode 100644 index 0000000..26ccf2d --- /dev/null +++ b/migrations/021_invoice_item_discount.sql @@ -0,0 +1,2 @@ +-- Rabatt je Rechnungsposition (Basispunkte, 1000 = 10 %), wie schon bei Bestellungen. +ALTER TABLE invoice_items ADD COLUMN discount_bp INT NOT NULL DEFAULT 0 AFTER tax_bp;