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

+4 -34
View File
@@ -3,7 +3,7 @@ import type { Prisma } from "@/generated/prisma/client";
import { prisma } from "@/lib/prisma";
import { generateInvoicePdf } from "@/lib/invoice-pdf";
import { readStoredInvoicePdf } from "@/lib/invoice-storage";
import { vatBreakdown, type VatBreakdownLine } from "@/lib/vat";
import { vatLinesWithShares, type VatLineWithShares } from "@/lib/vat";
import { isLocale, defaultLocale } from "@/i18n/config";
/**
@@ -57,40 +57,10 @@ const invoiceInclude = {
type ErpInvoiceRecord = Prisma.InvoiceGetPayload<{ include: typeof invoiceInclude }>;
export interface ErpVatLine extends VatBreakdownLine {
/** Darin enthaltener Versandanteil (brutto). */
shippingCents: number;
/** Davon abgezogener Rabattanteil (brutto). */
discountCents: number;
}
export type ErpVatLine = VatLineWithShares;
/**
* Umsatzsteuer je Satz wie auf der Rechnung (lib/vat.ts), dazu der Versand-
* und der Rabattanteil je Satz. Beide Anteile ergeben sich aus derselben
* Rechnung ohne den jeweils anderen Betrag, also ohne eigene Verteilungslogik.
* null, wenn eine Position keinen Steuersatz hat (Bestellungen aus der Zeit
* vor der Steuerausweisung).
*/
export function erpVatLines(order: {
items: ReadonlyArray<{ totalCents: number; vatRatePercent: number | null }>;
shippingCents: number;
discountCents: number;
}): ErpVatLine[] | null {
const items: { totalCents: number; vatRatePercent: number }[] = [];
for (const item of order.items) {
if (item.vatRatePercent == null) return null;
items.push({ totalCents: item.totalCents, vatRatePercent: item.vatRatePercent });
}
const full = vatBreakdown({ items, shippingCents: order.shippingCents, discountCents: order.discountCents });
const plain = vatBreakdown({ items, shippingCents: 0, discountCents: 0 });
const withShipping = vatBreakdown({ items, shippingCents: order.shippingCents, discountCents: 0 });
const withDiscount = vatBreakdown({ items, shippingCents: 0, discountCents: order.discountCents });
return full.map((line, index) => ({
...line,
shippingCents: withShipping[index].grossCents - plain[index].grossCents,
discountCents: plain[index].grossCents - withDiscount[index].grossCents,
}));
}
/** Umsatzsteuer je Satz mit Versand- und Rabattanteil, siehe lib/vat.ts. */
export const erpVatLines = vatLinesWithShares;
/** Rechnung fuer ERPNext; bewusst ohne Telefonnummer, Postnummer und Notizen (nicht buchungsrelevant). */
export function toErpInvoice(invoice: ErpInvoiceRecord) {