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;