Rechnungen: Rabatt je Position

This commit is contained in:
Kundencenter 2026-09-28 11:01:19 +02:00
parent e7e5f31ebe
commit 96df0c334a
5 changed files with 51 additions and 22 deletions

View file

@ -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<CompanySettings> {
}
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));

View file

@ -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;

View file

@ -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 });
});
});

View file

@ -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<HTMLFormElement>) {
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 && <Field id="fromContract" label="Position aus laufendem Vertrag übernehmen" hint="Übernimmt den wiederkehrenden Preis der aktuellen Periode."><select id="fromContract" value="" onChange={(e) => void takeFrom('contract', e.target.value)}><option value="">Vertrag wählen …</option>{contracts.map((c) => <option key={c.id} value={c.id}>{c.number} · {c.productName ?? '–'}</option>)}</select></Field>}
{domains.length > 0 && <Field id="fromDomain" label="Position aus Domain übernehmen" hint="Verkaufspreis aus der Domain-Aufstellung, nie der Einkauf."><select id="fromDomain" value="" onChange={(e) => void takeFrom('domain', e.target.value)}><option value="">Domain wählen …</option>{domains.map((d) => <option key={d.id} value={d.id} disabled={d.sellNetCents === null}>{d.domain}{d.sellNetCents === null ? ' (kein Verkaufspreis)' : ''}</option>)}</select></Field>}
</div>}
{rows.map((r, i) => (<div className="cols" key={i} style={{ gridTemplateColumns: '3fr 1fr 1fr 1fr auto', alignItems: 'end' }}>
{rows.map((r, i) => (<div className="cols" key={i} style={{ gridTemplateColumns: '3fr 1fr 1fr 1fr 1fr auto', alignItems: 'end' }}>
<Field id={`d${i}`} label="Beschreibung"><input id={`d${i}`} value={r.description} onChange={(e) => setRow(i, 'description', e.target.value)} /></Field>
<Field id={`q${i}`} label="Menge"><input id={`q${i}`} value={r.quantity} onChange={(e) => setRow(i, 'quantity', e.target.value)} /></Field>
<Field id={`u${i}`} label="Einzelpreis netto (€)"><input id={`u${i}`} value={r.unitPrice} onChange={(e) => setRow(i, 'unitPrice', e.target.value)} placeholder="0,00" /></Field>
<Field id={`t${i}`} label="USt. %"><input id={`t${i}`} value={r.taxBp} onChange={(e) => setRow(i, 'taxBp', e.target.value)} /></Field>
<Field id={`r${i}`} label="Rabatt %"><input id={`r${i}`} value={r.discount} onChange={(e) => setRow(i, 'discount', e.target.value)} placeholder="0" /></Field>
<button className="btn small" type="button" onClick={() => setRows((rs) => rs.filter((_, idx) => idx !== i))} disabled={rows.length === 1}>Entfernen</button>
</div>))}
<div className="row" style={{ margin: '8px 0 16px' }}><button className="btn small" type="button" onClick={() => setRows((rs) => [...rs, emptyRow()])}>+ Position</button></div>
@ -80,8 +81,8 @@ export default function RechnungDetail() {
</div>
<p className="muted small">Solange die Rechnung ein Entwurf ist, lassen sich Positionen beliebig ändern. Nach dem Ausstellen ist sie unveränderlich und nur noch per Storno korrigierbar.</p>
</form>) : (
<div className="card"><table style={{ width: '100%' }}><thead><tr><th style={{ textAlign: 'left' }}>Beschreibung</th><th>Menge</th><th>Einzelpreis</th><th>USt.</th><th style={{ textAlign: 'right' }}>Netto</th></tr></thead><tbody>
{inv.items.map((it) => (<tr key={it.id}><td>{it.description}</td><td style={{ textAlign: 'center' }}>{it.quantity}</td><td style={{ textAlign: 'center' }}>{eur(it.unitPriceNetCents)}</td><td style={{ textAlign: 'center' }}>{it.taxBp / 100} %</td><td style={{ textAlign: 'right' }}>{eur(it.netCents)}</td></tr>))}
<div className="card"><table style={{ width: '100%' }}><thead><tr><th style={{ textAlign: 'left' }}>Beschreibung</th><th>Menge</th><th>Einzelpreis</th><th>USt.</th><th>Rabatt</th><th style={{ textAlign: 'right' }}>Netto</th></tr></thead><tbody>
{inv.items.map((it) => (<tr key={it.id}><td>{it.description}</td><td style={{ textAlign: 'center' }}>{it.quantity}</td><td style={{ textAlign: 'center' }}>{eur(it.unitPriceNetCents)}</td><td style={{ textAlign: 'center' }}>{it.taxBp / 100} %</td><td style={{ textAlign: 'center' }}>{it.discountBp > 0 ? `${it.discountBp / 100} %` : '–'}</td><td style={{ textAlign: 'right' }}>{eur(it.netCents)}</td></tr>))}
</tbody></table></div>
)}