feat(invoice): Rechnungen als E-Rechnung (ZUGFeRD/Factur-X EN 16931)

Jedes Rechnungs-PDF ist jetzt eine hybride E-Rechnung: PDF/A-3b mit
eingebettetem factur-x.xml (CII D16B, Profil EN 16931). Netto und Steuer je
Satz kommen unveraendert aus der Aufstellung des PDFs, Versand und Rabatt
stehen als Zu- und Abschlag je Satz; XML und PDF tragen dieselben Summen.

- lib/einvoice/cii.ts: XML-Erzeugung (Rechnung 380, Stornorechnung 381 mit
  Bezug), ohne USt-IdNr. oder bei Positionen ohne Satz nur PDF mit Warnung
- lib/einvoice/facturx-pdf.ts: PDF/A-3b, Factur-X-XMP mit Erweiterungsschema,
  eingebettete Liberation-Schriften (gleiche Laufweiten wie Helvetica/Times)
- invoice-pdf: Darstellung als Stornorechnung (negative Betraege, Bezug)
- Geprueft mit XSD, CEN-Schematron EN 16931 1.3.16 und Mustang 2.26
  (veraPDF: PDF/A-3b konform, XML valid)

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-12 05:32:44 +02:00
1 parent f28e5e9d84
commit 508ff24aa7
16 files changed
+941 -45

No files matched your search

+113 -11
View File
@@ -5,6 +5,8 @@ import { getDictionary } from "@/i18n/get-dictionary";
import { invoiceVatBreakdown, type VatBreakdownLine } from "@/lib/vat";
import { groupInvoiceItemsByVatRate, type InvoiceItemGroup } from "@/lib/invoice-groups";
import { INVOICE_COLORS as C, loadInvoiceLogo, drawInvoiceLogo, type InvoiceLogo } from "@/lib/invoice-pdf-brand";
import { buildCiiXml, type EInvoiceResult } from "@/lib/einvoice/cii";
import { embedFacturX, pdfaOptions, registerEmbeddedFonts, xmpSafe } from "@/lib/einvoice/facturx-pdf";
import type { Dictionary } from "@/i18n/dictionary-type";
import type { Locale } from "@/i18n/config";
@@ -30,6 +32,8 @@ interface InvoicePdfInput {
street: string;
zip: string;
city: string;
/** ISO-Laendercode der Rechnungsanschrift; fehlt er, gilt Deutschland. */
country?: string;
currency: string;
subtotalCents: number;
shippingCents: number;
@@ -37,6 +41,11 @@ interface InvoicePdfInput {
discountCode?: { code: string } | null;
items: InvoiceItem[];
};
/**
* Gesetzt bei der Stornorechnung: sie hebt die Rechnung invoiceNumber vom
* issuedAt auf und traegt alle Betraege mit umgekehrtem Vorzeichen.
*/
cancellation?: { number: string; issuedAt: Date };
}
type Company = Awaited<ReturnType<typeof getCompanyInfo>>;
@@ -44,9 +53,16 @@ type Company = Awaited<ReturnType<typeof getCompanyInfo>>;
interface Ctx {
doc: PDFKit.PDFDocument;
t: Dictionary["invoicePage"];
/** Betrag mit dem Vorzeichen des Belegs (Stornorechnung negativ). */
money: (cents: number) => string;
/** Betrag ohne Vorzeichenumkehr. */
plain: (cents: number) => string;
isCancellation: boolean;
}
/** Sitz des Shops; die Firmenanschrift in den Einstellungen hat kein Land. */
const SELLER_COUNTRY_CODE = "DE";
// A4 in Punkt. Ab FOOTER_TOP beginnt die Fusszeile; was oberhalb von
// CONTENT_BOTTOM nicht mehr hinpasst, wandert auf die naechste Seite.
const PAGE_WIDTH = 595.28;
@@ -84,12 +100,17 @@ const COL = {
export async function generateInvoicePdf(input: InvoicePdfInput): Promise<Buffer> {
const t = getDictionary(input.locale).invoicePage;
const [company, logo] = await Promise.all([getCompanyInfo(), loadInvoiceLogo()]);
const title = input.cancellation
? `${t.cancellationTitle} ${input.cancellation.number}`
: `${t.documentTitle} ${input.invoiceNumber}`;
const doc = new PDFDocument({
...pdfaOptions(),
margin: MARGIN,
size: "A4",
bufferPages: true,
info: { Title: `${t.documentTitle} ${input.invoiceNumber}`, Author: company.name },
info: { Title: xmpSafe(title), Author: xmpSafe(company.name) },
});
registerEmbeddedFonts(doc);
const chunks: Buffer[] = [];
doc.on("data", (chunk: Buffer) => chunks.push(chunk));
const done = new Promise<Buffer>((resolve, reject) => {
@@ -97,7 +118,9 @@ export async function generateInvoicePdf(input: InvoicePdfInput): Promise<Buffer
doc.on("error", reject);
});
const ctx: Ctx = { doc, t, money: (cents) => formatPrice(cents, input.order.currency) };
const sign = input.cancellation ? -1 : 1;
const plain = (cents: number) => formatPrice(cents, input.order.currency);
const ctx: Ctx = { doc, t, money: (cents) => plain(sign * cents), plain, isCancellation: Boolean(input.cancellation) };
const breakdown = invoiceVatBreakdown(input.order);
drawHeader(ctx, company, logo);
@@ -106,13 +129,65 @@ export async function generateInvoicePdf(input: InvoicePdfInput): Promise<Buffer
let y = drawTableHeader(ctx, Math.max(addressBottom, metaBottom) + 30);
y = drawItems(ctx, groupInvoiceItemsByVatRate(input.order.items), y);
y = drawSummary(ctx, input, breakdown, y + 22);
drawClosing(ctx, breakdown, y + 26);
drawClosing(ctx, input, breakdown, y + 26);
drawFooters(ctx, company);
const einvoice = eInvoiceXml(input, company);
if (einvoice.ok) {
embedFacturX(doc, einvoice.xml, input.cancellation?.issuedAt ?? input.issuedAt);
} else {
console.warn(`[invoice-pdf] ${input.cancellation?.number ?? input.invoiceNumber} ohne E-Rechnung (nur PDF): ${einvoice.reason}`);
}
doc.end();
return done;
}
/**
* Das XML der E-Rechnung (EN 16931) aus denselben Daten wie das PDF. Die
* Stornorechnung ist eine Gutschrift mit Bezug auf die aufgehobene Rechnung.
*/
function eInvoiceXml(input: InvoicePdfInput, company: Company): EInvoiceResult {
const { order, cancellation } = input;
return buildCiiXml({
kind: cancellation ? "creditNote" : "invoice",
number: cancellation?.number ?? input.invoiceNumber,
issuedAt: cancellation?.issuedAt ?? input.issuedAt,
currency: order.currency,
orderNumber: order.orderNumber,
seller: {
name: company.name,
street: company.address.street,
zip: company.address.zip,
city: company.address.city,
countryCode: SELLER_COUNTRY_CODE,
vatId: company.vatId,
email: company.email,
},
buyer: {
name: `${order.firstName} ${order.lastName}`.trim(),
street: order.street,
zip: order.zip,
city: order.city,
countryCode: (order.country || SELLER_COUNTRY_CODE).toUpperCase(),
},
items: order.items.map((item) => ({
name: item.productName,
quantity: item.quantity,
totalCents: item.totalCents,
vatRatePercent: item.vatRatePercent,
})),
shippingCents: order.shippingCents,
discountCents: order.discountCents,
discountCode: order.discountCode?.code ?? null,
totalCents: input.totalCents,
precedingInvoice: cancellation ? { number: input.invoiceNumber, issuedAt: input.issuedAt } : undefined,
paymentNote: cancellation
? "Der Betrag wird über die ursprüngliche Zahlungsart erstattet."
: "Bereits bezahlt (Online-Zahlung über Stripe).",
});
}
function label(ctx: Ctx, text: string, x: number, y: number): void {
ctx.doc
.font("Helvetica-Bold")
@@ -139,11 +214,19 @@ function drawHeader(ctx: Ctx, company: Company, logo: InvoiceLogo | null): void
doc.font("Helvetica").fontSize(8.5).fillColor(C.honeyDark).text(company.claim, textX, 74, { width: 240, lineBreak: false });
}
// "STORNORECHNUNG" ist laenger als "RECHNUNG": die Schrift wird so weit
// verkleinert, dass der Titel in einer Zeile rechts oben bleibt.
const title = (ctx.isCancellation ? t.cancellationTitle : t.documentTitle).toUpperCase();
const titleX = 300;
const titleOptions = { characterSpacing: 2 };
let titleSize = 26;
doc.font("Times-Roman").fontSize(titleSize);
while (titleSize > 14 && doc.widthOfString(title, titleOptions) > RIGHT - titleX) {
doc.fontSize(--titleSize);
}
doc
.font("Times-Roman")
.fontSize(26)
.fillColor(C.forest)
.text(t.documentTitle.toUpperCase(), 360, 48, { width: RIGHT - 360, align: "right", characterSpacing: 2, lineBreak: false });
.text(title, titleX, 48 + (26 - titleSize) / 2, { ...titleOptions, width: RIGHT - titleX, align: "right", lineBreak: false });
hairline(ctx, MARGIN, RIGHT, 112, C.honey);
}
@@ -165,9 +248,18 @@ function drawRecipient(ctx: Ctx, input: InvoicePdfInput, company: Company): numb
/** Rechnungsdaten rechts oben, inklusive USt-IdNr. (§ 14 Abs. 4 Nr. 2 UStG). */
function drawMetaBox(ctx: Ctx, input: InvoicePdfInput, company: Company): number {
const { doc, t } = ctx;
const documentRows: [string, string][] = input.cancellation
? [
[t.cancellationNumberLabel, input.cancellation.number],
[t.cancellationDateLabel, formatDate(input.cancellation.issuedAt)],
[t.cancelsInvoiceLabel, input.invoiceNumber],
]
: [
[t.invoiceNumberLabel, input.invoiceNumber],
[t.invoiceDateLabel, formatDate(input.issuedAt)],
];
const rows: [string, string][] = [
[t.invoiceNumberLabel, input.invoiceNumber],
[t.invoiceDateLabel, formatDate(input.issuedAt)],
...documentRows,
[t.orderNumberLabel, input.order.orderNumber],
...(company.vatId ? ([[t.vatIdLabel, company.vatId]] as [string, string][]) : []),
];
@@ -287,14 +379,16 @@ function drawSummary(ctx: Ctx, input: InvoicePdfInput, breakdown: VatBreakdownLi
}
function drawTotals(ctx: Ctx, input: InvoicePdfInput, top: number): number {
const { doc, t, money } = ctx;
const { doc, t, money, plain } = ctx;
const { order } = input;
const x = 330;
const discountName = order.discountCode ? `${t.discountLabel} (${order.discountCode.code})` : t.discountLabel;
const rows: [string, string][] = [
[t.subtotalLabel, money(order.subtotalCents)],
[t.shippingLabel, order.shippingCents === 0 ? t.freeShipping : money(order.shippingCents)],
...(order.discountCents > 0 ? ([[discountName, `– ${money(order.discountCents)}`]] as [string, string][]) : []),
...(order.discountCents > 0
? ([[discountName, `${ctx.isCancellation ? "+" : "–"} ${plain(order.discountCents)}`]] as [string, string][])
: []),
];
let y = top;
@@ -371,12 +465,20 @@ function drawTaxSummary(ctx: Ctx, breakdown: VatBreakdownLine[], top: number): n
return y;
}
function drawClosing(ctx: Ctx, breakdown: VatBreakdownLine[], startY: number): void {
function drawClosing(ctx: Ctx, input: InvoicePdfInput, breakdown: VatBreakdownLine[], startY: number): void {
const { doc, t } = ctx;
const y = ensureSpace(ctx, startY, 40, false);
if (breakdown.some((line) => line.ratePercent > 0)) {
doc.font("Helvetica").fontSize(8).fillColor(C.sage).text(t.pricesIncludeVat, MARGIN, y, { width: CONTENT_WIDTH });
}
if (input.cancellation) {
doc
.font("Helvetica")
.fontSize(9.5)
.fillColor(C.ink)
.text(t.cancellationNote(input.invoiceNumber, formatDate(input.issuedAt)), MARGIN, y + 16, { width: CONTENT_WIDTH });
return;
}
doc.font("Times-Italic").fontSize(13).fillColor(C.honeyDark).text(t.thankYou, MARGIN, y + 16, { width: CONTENT_WIDTH });
}