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:
1 parent
f28e5e9d84
commit
508ff24aa7
16 files changed
+941
-45
No files matched your search
+113
-11
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user