Gutschriften Phase 3b/2: ZUGFeRD/Factur-X (hybrides PDF/A-3)

- zugferd.service.ts: CII-XML (EN 16931, urn:cen.eu:en16931:2017,
  Dokumenttyp 381 Gutschrift). Steuerkategorie S bei USt, sonst E mit
  Befreiungsgrund. Verkaeufer=Firma (CompanyProfile), Kaeufer=Kunde.
- zugferdPdf.service.ts: bettet factur-x.xml als AF /Data ein, setzt
  sRGB-OutputIntent + XMP (PDF/A-3B pdfaid + Factur-X-Extension-Schema)
  via pdf-lib.
- creditNotePdf: eingebettete DejaVuSans-Fonts (Pflicht fuer PDF/A)
  statt Standard-Helvetica; nach PDF-Erzeugung ZUGFeRD-Embedding.
- assets/fonts (DejaVuSans + Bold) + assets/icc (sRGB) ins Repo;
  Dockerfile kopiert backend/assets ins Runtime-Image.

Lokal strukturell verifiziert: 1 Seite, /AF, /Metadata, /OutputIntents,
EmbeddedFiles, Font eingebettet, XML wohlgeformt (xmllint), TypeCode
381, GrandTotal korrekt.

WICHTIG: vor Prod gegen einen ZUGFeRD-/Factur-X-Validator pruefen
(Staging + echte Firmendaten). Feinheiten (Trailer-ID, XMP, XML-MIME)
ggf. nach erstem Validator-Lauf nachziehen.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-07 12:56:12 +02:00
co-authored by Claude Opus 4.8
parent 043bc173b7
commit 5423c83009
9 changed files with 348 additions and 11 deletions
+116
View File
@@ -0,0 +1,116 @@
// ==================== ZUGFeRD PDF/A-3 EMBEDDING ====================
// Nimmt ein bestehendes PDF (pdfkit, mit eingebetteten Fonts) und macht daraus
// ein hybrides ZUGFeRD-PDF: factur-x.xml als AF /Data einbetten, sRGB-
// OutputIntent, XMP-Metadaten (PDF/A-3B + Factur-X-Extension-Schema).
//
// WICHTIG: Vor produktivem Einsatz gegen einen ZUGFeRD-/Factur-X-Validator
// prüfen. Feinheiten der PDF/A-3-Konformität (z.B. Trailer-ID, XMP-Details)
// können nach dem ersten Validator-Lauf noch nachgezogen werden müssen.
import fs from 'fs';
import path from 'path';
import { PDFDocument, AFRelationship, PDFName, PDFString } from 'pdf-lib';
const ICC_PATH = path.join(process.cwd(), 'assets', 'icc', 'sRGB_IEC61966_2_1.icc');
function xmpDate(d: Date): string {
return d.toISOString().replace(/\.\d{3}Z$/, 'Z');
}
function buildXmp(title: string, date: Date): string {
const d = xmpDate(date);
return `<?xpacket begin="" id="W5M0MpCehiHzreSzNTczkc9d"?>
<x:xmpmeta xmlns:x="adobe:ns:meta/">
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description rdf:about="" xmlns:pdfaid="http://www.aiim.org/pdfa/ns/id/">
<pdfaid:part>3</pdfaid:part>
<pdfaid:conformance>B</pdfaid:conformance>
</rdf:Description>
<rdf:Description rdf:about="" xmlns:dc="http://purl.org/dc/elements/1.1/">
<dc:title><rdf:Alt><rdf:li xml:lang="x-default">${title.replace(/[<>&]/g, '')}</rdf:li></rdf:Alt></dc:title>
</rdf:Description>
<rdf:Description rdf:about="" xmlns:xmp="http://ns.adobe.com/xap/1.0/">
<xmp:CreatorTool>OpenCRM</xmp:CreatorTool>
<xmp:CreateDate>${d}</xmp:CreateDate>
<xmp:ModifyDate>${d}</xmp:ModifyDate>
</rdf:Description>
<rdf:Description rdf:about="" xmlns:pdfaExtension="http://www.aiim.org/pdfa/ns/extension/" xmlns:pdfaSchema="http://www.aiim.org/pdfa/ns/schema#" xmlns:pdfaProperty="http://www.aiim.org/pdfa/ns/property#">
<pdfaExtension:schemas>
<rdf:Bag>
<rdf:li rdf:parseType="Resource">
<pdfaSchema:schema>Factur-X PDFA Extension Schema</pdfaSchema:schema>
<pdfaSchema:namespaceURI>urn:factur-x:pdfa:CrossIndustryDocument:invoice:1p0#</pdfaSchema:namespaceURI>
<pdfaSchema:prefix>fx</pdfaSchema:prefix>
<pdfaSchema:property>
<rdf:Seq>
<rdf:li rdf:parseType="Resource"><pdfaProperty:name>DocumentFileName</pdfaProperty:name><pdfaProperty:valueType>Text</pdfaProperty:valueType><pdfaProperty:category>external</pdfaProperty:category><pdfaProperty:description>Name des eingebetteten XML</pdfaProperty:description></rdf:li>
<rdf:li rdf:parseType="Resource"><pdfaProperty:name>DocumentType</pdfaProperty:name><pdfaProperty:valueType>Text</pdfaProperty:valueType><pdfaProperty:category>external</pdfaProperty:category><pdfaProperty:description>INVOICE</pdfaProperty:description></rdf:li>
<rdf:li rdf:parseType="Resource"><pdfaProperty:name>Version</pdfaProperty:name><pdfaProperty:valueType>Text</pdfaProperty:valueType><pdfaProperty:category>external</pdfaProperty:category><pdfaProperty:description>Version des Factur-X-Profils</pdfaProperty:description></rdf:li>
<rdf:li rdf:parseType="Resource"><pdfaProperty:name>ConformanceLevel</pdfaProperty:name><pdfaProperty:valueType>Text</pdfaProperty:valueType><pdfaProperty:category>external</pdfaProperty:category><pdfaProperty:description>Konformitaetslevel</pdfaProperty:description></rdf:li>
</rdf:Seq>
</pdfaSchema:property>
</rdf:li>
</rdf:Bag>
</pdfaExtension:schemas>
</rdf:Description>
<rdf:Description rdf:about="" xmlns:fx="urn:factur-x:pdfa:CrossIndustryDocument:invoice:1p0#">
<fx:DocumentType>INVOICE</fx:DocumentType>
<fx:DocumentFileName>factur-x.xml</fx:DocumentFileName>
<fx:Version>1.0</fx:Version>
<fx:ConformanceLevel>EN 16931</fx:ConformanceLevel>
</rdf:Description>
</rdf:RDF>
</x:xmpmeta>
<?xpacket end="w"?>`;
}
export async function embedZugferd(
basePdf: Buffer,
xml: string,
meta: { title: string; date: Date },
): Promise<Buffer> {
const pdfDoc = await PDFDocument.load(basePdf);
// 1) XML als Associated File (AF /Data) einbetten.
await pdfDoc.attach(Buffer.from(xml, 'utf-8'), 'factur-x.xml', {
mimeType: 'text/xml',
description: 'Factur-X/ZUGFeRD Rechnungsdaten',
afRelationship: AFRelationship.Data,
creationDate: meta.date,
modificationDate: meta.date,
});
// 2) Dokument-Info.
pdfDoc.setTitle(meta.title);
pdfDoc.setProducer('OpenCRM');
pdfDoc.setCreator('OpenCRM');
pdfDoc.setCreationDate(meta.date);
pdfDoc.setModificationDate(meta.date);
// 3) OutputIntent (sRGB) – Pflicht für PDF/A.
const iccBytes = fs.readFileSync(ICC_PATH);
const iccStream = pdfDoc.context.stream(iccBytes, { N: 3 });
const iccRef = pdfDoc.context.register(iccStream);
const outputIntent = pdfDoc.context.obj({
Type: 'OutputIntent',
S: 'GTS_PDFA1',
OutputConditionIdentifier: PDFString.of('sRGB'),
Info: PDFString.of('sRGB IEC61966-2.1'),
DestOutputProfile: iccRef,
});
const oiRef = pdfDoc.context.register(outputIntent);
pdfDoc.catalog.set(PDFName.of('OutputIntents'), pdfDoc.context.obj([oiRef]));
// 4) XMP-Metadaten (unkomprimiert, /Metadata /XML).
const xmp = buildXmp(meta.title, meta.date);
const metadataStream = pdfDoc.context.stream(Buffer.from(xmp, 'utf-8'), {
Type: 'Metadata',
Subtype: 'XML',
});
const metaRef = pdfDoc.context.register(metadataStream);
pdfDoc.catalog.set(PDFName.of('Metadata'), metaRef);
// PDF/A: klassische XRef-Tabelle statt Object-Streams (validator-freundlicher).
const out = await pdfDoc.save({ useObjectStreams: false });
return Buffer.from(out);
}