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:
@@ -53,6 +53,10 @@ COPY backend/prisma ./prisma
|
|||||||
COPY --from=backend-builder /build/backend/src ./src
|
COPY --from=backend-builder /build/backend/src ./src
|
||||||
COPY backend/tsconfig.json ./tsconfig.json
|
COPY backend/tsconfig.json ./tsconfig.json
|
||||||
|
|
||||||
|
# Statische Assets (eingebettete Fonts für PDF/A + sRGB-ICC-Profil für den
|
||||||
|
# ZUGFeRD-OutputIntent). Werden zur Laufzeit aus process.cwd()/assets gelesen.
|
||||||
|
COPY backend/assets ./assets
|
||||||
|
|
||||||
# Frontend-Build ins public/-Verzeichnis (wird in production-Mode statisch ausgeliefert)
|
# Frontend-Build ins public/-Verzeichnis (wird in production-Mode statisch ausgeliefert)
|
||||||
COPY --from=frontend-builder /build/frontend/dist ./public
|
COPY --from=frontend-builder /build/frontend/dist ./public
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -8,6 +8,10 @@ import PDFDocument from 'pdfkit';
|
|||||||
import prisma from '../lib/prisma.js';
|
import prisma from '../lib/prisma.js';
|
||||||
import { ApiError } from '../utils/apiError.js';
|
import { ApiError } from '../utils/apiError.js';
|
||||||
import { getOrCreateProfile } from './companyProfile.service.js';
|
import { getOrCreateProfile } from './companyProfile.service.js';
|
||||||
|
import { buildZugferdXml } from './zugferd.service.js';
|
||||||
|
import { embedZugferd } from './zugferdPdf.service.js';
|
||||||
|
|
||||||
|
const FONT_DIR = path.join(process.cwd(), 'assets', 'fonts');
|
||||||
|
|
||||||
function euro(n: number, currency = 'EUR'): string {
|
function euro(n: number, currency = 'EUR'): string {
|
||||||
return new Intl.NumberFormat('de-DE', { style: 'currency', currency }).format(n || 0);
|
return new Intl.NumberFormat('de-DE', { style: 'currency', currency }).format(n || 0);
|
||||||
@@ -41,7 +45,7 @@ async function loadData(creditNoteId: number) {
|
|||||||
return { cn, company };
|
return { cn, company };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateCreditNotePdf(creditNoteId: number): Promise<{ buffer: Buffer; pdfPath: string }> {
|
export async function generateCreditNotePdf(creditNoteId: number): Promise<{ buffer: Buffer; pdfPath: string; xml: string }> {
|
||||||
const { cn, company } = await loadData(creditNoteId);
|
const { cn, company } = await loadData(creditNoteId);
|
||||||
const customer = cn.contract.customer;
|
const customer = cn.contract.customer;
|
||||||
const addr = cn.contract.billingAddress || cn.contract.address;
|
const addr = cn.contract.billingAddress || cn.contract.address;
|
||||||
@@ -52,6 +56,11 @@ export async function generateCreditNotePdf(creditNoteId: number): Promise<{ buf
|
|||||||
: `${customer.firstName} ${customer.lastName}`.trim();
|
: `${customer.firstName} ${customer.lastName}`.trim();
|
||||||
|
|
||||||
const doc = new PDFDocument({ size: 'A4', margin: 50 });
|
const doc = new PDFDocument({ size: 'A4', margin: 50 });
|
||||||
|
// PDF/A verlangt eingebettete Fonts – DejaVuSans (im Repo) statt der
|
||||||
|
// nicht-eingebetteten pdfkit-Standard-Fonts (Helvetica).
|
||||||
|
doc.registerFont('Body', path.join(FONT_DIR, 'DejaVuSans.ttf'));
|
||||||
|
doc.registerFont('Body-Bold', path.join(FONT_DIR, 'DejaVuSans-Bold.ttf'));
|
||||||
|
doc.font('Body');
|
||||||
const chunks: Buffer[] = [];
|
const chunks: Buffer[] = [];
|
||||||
doc.on('data', (c: Buffer) => chunks.push(c));
|
doc.on('data', (c: Buffer) => chunks.push(c));
|
||||||
const done = new Promise<Buffer>((resolve) => doc.on('end', () => resolve(Buffer.concat(chunks))));
|
const done = new Promise<Buffer>((resolve) => doc.on('end', () => resolve(Buffer.concat(chunks))));
|
||||||
@@ -97,11 +106,11 @@ export async function generateCreditNotePdf(creditNoteId: number): Promise<{ buf
|
|||||||
const rightX = 545;
|
const rightX = 545;
|
||||||
const labelW = 300;
|
const labelW = 300;
|
||||||
const line = (label: string, value: string, bold = false) => {
|
const line = (label: string, value: string, bold = false) => {
|
||||||
doc.font(bold ? 'Helvetica-Bold' : 'Helvetica').fontSize(11);
|
doc.font(bold ? 'Body-Bold' : 'Body').fontSize(11);
|
||||||
const y = doc.y;
|
const y = doc.y;
|
||||||
doc.text(label, startX, y, { width: labelW });
|
doc.text(label, startX, y, { width: labelW });
|
||||||
doc.text(value, startX, y, { width: rightX - startX, align: 'right' });
|
doc.text(value, startX, y, { width: rightX - startX, align: 'right' });
|
||||||
doc.font('Helvetica');
|
doc.font('Body');
|
||||||
};
|
};
|
||||||
|
|
||||||
if (cn.vatRelevant) {
|
if (cn.vatRelevant) {
|
||||||
@@ -175,7 +184,46 @@ export async function generateCreditNotePdf(creditNoteId: number): Promise<{ buf
|
|||||||
doc.fillColor('#000');
|
doc.fillColor('#000');
|
||||||
|
|
||||||
doc.end();
|
doc.end();
|
||||||
const buffer = await done;
|
const baseBuffer = await done;
|
||||||
|
|
||||||
|
// ---- ZUGFeRD-XML erzeugen + als hybrides PDF/A-3 einbetten ----
|
||||||
|
const xml = buildZugferdXml({
|
||||||
|
number: cn.number,
|
||||||
|
issueDate: new Date(cn.creditDate),
|
||||||
|
currency: cn.currency,
|
||||||
|
seller: {
|
||||||
|
name: company.name,
|
||||||
|
street: company.street,
|
||||||
|
houseNumber: company.houseNumber,
|
||||||
|
postalCode: company.postalCode,
|
||||||
|
city: company.city,
|
||||||
|
country: company.country || 'DE',
|
||||||
|
vatId: company.vatId || undefined,
|
||||||
|
taxNumber: company.taxNumber || undefined,
|
||||||
|
},
|
||||||
|
buyer: {
|
||||||
|
name: recipientName,
|
||||||
|
street: addr?.street || '',
|
||||||
|
houseNumber: addr?.houseNumber || '',
|
||||||
|
postalCode: addr?.postalCode || '',
|
||||||
|
city: addr?.city || '',
|
||||||
|
country: 'DE',
|
||||||
|
},
|
||||||
|
lineName:
|
||||||
|
cn.type === 'SACHWERT'
|
||||||
|
? `Sachwert / Subvention: ${cn.sachwertDescription || ''}`.trim()
|
||||||
|
: 'Subvention (Auszahlung per Überweisung)',
|
||||||
|
vatRelevant: cn.vatRelevant,
|
||||||
|
vatRatePercent: cn.vatRate,
|
||||||
|
amountNet: cn.amountNet,
|
||||||
|
amountVat: cn.amountVat,
|
||||||
|
amountGross: cn.amountGross,
|
||||||
|
});
|
||||||
|
|
||||||
|
const buffer = await embedZugferd(baseBuffer, xml, {
|
||||||
|
title: `Gutschrift ${cn.number}`,
|
||||||
|
date: new Date(cn.creditDate),
|
||||||
|
});
|
||||||
|
|
||||||
// ---- Speichern ----
|
// ---- Speichern ----
|
||||||
const dir = path.join(process.cwd(), 'uploads', 'credit-notes');
|
const dir = path.join(process.cwd(), 'uploads', 'credit-notes');
|
||||||
@@ -187,5 +235,5 @@ export async function generateCreditNotePdf(creditNoteId: number): Promise<{ buf
|
|||||||
|
|
||||||
await prisma.creditNote.update({ where: { id: cn.id }, data: { pdfPath } });
|
await prisma.creditNote.update({ where: { id: cn.id }, data: { pdfPath } });
|
||||||
|
|
||||||
return { buffer, pdfPath };
|
return { buffer, pdfPath, xml };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
// ==================== ZUGFeRD / FACTUR-X XML (CII, EN 16931) ====================
|
||||||
|
// Erzeugt das Cross-Industry-Invoice-XML (UN/CEFACT CII) für eine Gutschrift
|
||||||
|
// (Dokumenttyp 381). Profil: EN 16931 ("urn:cen.eu:en16931:2017").
|
||||||
|
//
|
||||||
|
// WICHTIG: Muss vor produktivem Einsatz gegen einen ZUGFeRD-/Factur-X-
|
||||||
|
// Validator geprüft werden. Verkäufer = ausstellende Firma (CompanyProfile),
|
||||||
|
// Käufer = Kunde. Beträge positiv (der Typcode 381 kennzeichnet die Gutschrift).
|
||||||
|
|
||||||
|
export interface ZugferdParty {
|
||||||
|
name: string;
|
||||||
|
street: string;
|
||||||
|
houseNumber: string;
|
||||||
|
postalCode: string;
|
||||||
|
city: string;
|
||||||
|
country: string; // ISO-2
|
||||||
|
vatId?: string;
|
||||||
|
taxNumber?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ZugferdData {
|
||||||
|
number: string;
|
||||||
|
issueDate: Date;
|
||||||
|
currency: string;
|
||||||
|
seller: ZugferdParty;
|
||||||
|
buyer: Omit<ZugferdParty, 'vatId' | 'taxNumber'> & { vatId?: string };
|
||||||
|
lineName: string;
|
||||||
|
vatRelevant: boolean;
|
||||||
|
vatRatePercent: number;
|
||||||
|
amountNet: number;
|
||||||
|
amountVat: number;
|
||||||
|
amountGross: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function esc(s: string | undefined | null): string {
|
||||||
|
return (s ?? '')
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
const n2 = (n: number) => (Math.round((n + Number.EPSILON) * 100) / 100).toFixed(2);
|
||||||
|
const date102 = (d: Date) =>
|
||||||
|
`${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, '0')}${String(d.getDate()).padStart(2, '0')}`;
|
||||||
|
|
||||||
|
function addressBlock(p: { street: string; houseNumber: string; postalCode: string; city: string; country: string }): string {
|
||||||
|
const lineOne = `${p.street} ${p.houseNumber}`.trim();
|
||||||
|
return ` <ram:PostalTradeAddress>
|
||||||
|
<ram:PostcodeCode>${esc(p.postalCode)}</ram:PostcodeCode>
|
||||||
|
<ram:LineOne>${esc(lineOne)}</ram:LineOne>
|
||||||
|
<ram:CityName>${esc(p.city)}</ram:CityName>
|
||||||
|
<ram:CountryID>${esc(p.country || 'DE')}</ram:CountryID>
|
||||||
|
</ram:PostalTradeAddress>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildZugferdXml(d: ZugferdData): string {
|
||||||
|
// Steuerkategorie: S = Regelsatz, E = steuerbefreit (ohne USt-Ausweis).
|
||||||
|
const categoryCode = d.vatRelevant ? 'S' : 'E';
|
||||||
|
const ratePct = d.vatRelevant ? d.vatRatePercent : 0;
|
||||||
|
const exemptionReason = d.vatRelevant ? '' : 'Kein gesonderter Umsatzsteuerausweis';
|
||||||
|
|
||||||
|
const sellerTax: string[] = [];
|
||||||
|
if (d.seller.vatId) {
|
||||||
|
sellerTax.push(` <ram:SpecifiedTaxRegistration>
|
||||||
|
<ram:ID schemeID="VA">${esc(d.seller.vatId)}</ram:ID>
|
||||||
|
</ram:SpecifiedTaxRegistration>`);
|
||||||
|
}
|
||||||
|
if (d.seller.taxNumber) {
|
||||||
|
sellerTax.push(` <ram:SpecifiedTaxRegistration>
|
||||||
|
<ram:ID schemeID="FC">${esc(d.seller.taxNumber)}</ram:ID>
|
||||||
|
</ram:SpecifiedTaxRegistration>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const buyerTax = d.buyer.vatId
|
||||||
|
? ` <ram:SpecifiedTaxRegistration>
|
||||||
|
<ram:ID schemeID="VA">${esc(d.buyer.vatId)}</ram:ID>
|
||||||
|
</ram:SpecifiedTaxRegistration>`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const lineTaxCategory = ` <ram:ApplicableTradeTax>
|
||||||
|
<ram:TypeCode>VAT</ram:TypeCode>
|
||||||
|
<ram:CategoryCode>${categoryCode}</ram:CategoryCode>
|
||||||
|
<ram:RateApplicablePercent>${n2(ratePct)}</ram:RateApplicablePercent>
|
||||||
|
</ram:ApplicableTradeTax>`;
|
||||||
|
|
||||||
|
const headerTax = ` <ram:ApplicableTradeTax>
|
||||||
|
<ram:CalculatedAmount>${n2(d.amountVat)}</ram:CalculatedAmount>
|
||||||
|
<ram:TypeCode>VAT</ram:TypeCode>${exemptionReason ? `\n <ram:ExemptionReason>${esc(exemptionReason)}</ram:ExemptionReason>` : ''}
|
||||||
|
<ram:BasisAmount>${n2(d.amountNet)}</ram:BasisAmount>
|
||||||
|
<ram:CategoryCode>${categoryCode}</ram:CategoryCode>
|
||||||
|
<ram:RateApplicablePercent>${n2(ratePct)}</ram:RateApplicablePercent>
|
||||||
|
</ram:ApplicableTradeTax>`;
|
||||||
|
|
||||||
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<rsm:CrossIndustryInvoice xmlns:rsm="urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100" xmlns:ram="urn:un:unece:uncefact:data:standard:ReusableAggregateBusinessInformationEntity:100" xmlns:udt="urn:un:unece:uncefact:data:standard:UnqualifiedDataType:100" xmlns:qdt="urn:un:unece:uncefact:data:standard:QualifiedDataType:100">
|
||||||
|
<rsm:ExchangedDocumentContext>
|
||||||
|
<ram:GuidelineSpecifiedDocumentContextParameter>
|
||||||
|
<ram:ID>urn:cen.eu:en16931:2017</ram:ID>
|
||||||
|
</ram:GuidelineSpecifiedDocumentContextParameter>
|
||||||
|
</rsm:ExchangedDocumentContext>
|
||||||
|
<rsm:ExchangedDocument>
|
||||||
|
<ram:ID>${esc(d.number)}</ram:ID>
|
||||||
|
<ram:TypeCode>381</ram:TypeCode>
|
||||||
|
<ram:IssueDateTime>
|
||||||
|
<udt:DateTimeString format="102">${date102(d.issueDate)}</udt:DateTimeString>
|
||||||
|
</ram:IssueDateTime>
|
||||||
|
</rsm:ExchangedDocument>
|
||||||
|
<rsm:SupplyChainTradeTransaction>
|
||||||
|
<ram:IncludedSupplyChainTradeLineItem>
|
||||||
|
<ram:AssociatedDocumentLineDocument>
|
||||||
|
<ram:LineID>1</ram:LineID>
|
||||||
|
</ram:AssociatedDocumentLineDocument>
|
||||||
|
<ram:SpecifiedTradeProduct>
|
||||||
|
<ram:Name>${esc(d.lineName)}</ram:Name>
|
||||||
|
</ram:SpecifiedTradeProduct>
|
||||||
|
<ram:SpecifiedLineTradeAgreement>
|
||||||
|
<ram:NetPriceProductTradePrice>
|
||||||
|
<ram:ChargeAmount>${n2(d.amountNet)}</ram:ChargeAmount>
|
||||||
|
</ram:NetPriceProductTradePrice>
|
||||||
|
</ram:SpecifiedLineTradeAgreement>
|
||||||
|
<ram:SpecifiedLineTradeDelivery>
|
||||||
|
<ram:BilledQuantity unitCode="C62">1</ram:BilledQuantity>
|
||||||
|
</ram:SpecifiedLineTradeDelivery>
|
||||||
|
<ram:SpecifiedLineTradeSettlement>
|
||||||
|
${lineTaxCategory}
|
||||||
|
<ram:SpecifiedTradeSettlementLineMonetarySummation>
|
||||||
|
<ram:LineTotalAmount>${n2(d.amountNet)}</ram:LineTotalAmount>
|
||||||
|
</ram:SpecifiedTradeSettlementLineMonetarySummation>
|
||||||
|
</ram:SpecifiedLineTradeSettlement>
|
||||||
|
</ram:IncludedSupplyChainTradeLineItem>
|
||||||
|
<ram:ApplicableHeaderTradeAgreement>
|
||||||
|
<ram:SellerTradeParty>
|
||||||
|
<ram:Name>${esc(d.seller.name)}</ram:Name>
|
||||||
|
${addressBlock(d.seller)}
|
||||||
|
${sellerTax.join('\n')}
|
||||||
|
</ram:SellerTradeParty>
|
||||||
|
<ram:BuyerTradeParty>
|
||||||
|
<ram:Name>${esc(d.buyer.name)}</ram:Name>
|
||||||
|
${addressBlock(d.buyer)}${buyerTax ? `\n${buyerTax}` : ''}
|
||||||
|
</ram:BuyerTradeParty>
|
||||||
|
</ram:ApplicableHeaderTradeAgreement>
|
||||||
|
<ram:ApplicableHeaderTradeDelivery/>
|
||||||
|
<ram:ApplicableHeaderTradeSettlement>
|
||||||
|
<ram:InvoiceCurrencyCode>${esc(d.currency)}</ram:InvoiceCurrencyCode>
|
||||||
|
${headerTax}
|
||||||
|
<ram:SpecifiedTradeSettlementHeaderMonetarySummation>
|
||||||
|
<ram:LineTotalAmount>${n2(d.amountNet)}</ram:LineTotalAmount>
|
||||||
|
<ram:TaxBasisTotalAmount>${n2(d.amountNet)}</ram:TaxBasisTotalAmount>
|
||||||
|
<ram:TaxTotalAmount currencyID="${esc(d.currency)}">${n2(d.amountVat)}</ram:TaxTotalAmount>
|
||||||
|
<ram:GrandTotalAmount>${n2(d.amountGross)}</ram:GrandTotalAmount>
|
||||||
|
<ram:DuePayableAmount>${n2(d.amountGross)}</ram:DuePayableAmount>
|
||||||
|
</ram:SpecifiedTradeSettlementHeaderMonetarySummation>
|
||||||
|
</ram:ApplicableHeaderTradeSettlement>
|
||||||
|
</rsm:SupplyChainTradeTransaction>
|
||||||
|
</rsm:CrossIndustryInvoice>`;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
+17
-4
@@ -144,10 +144,23 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung
|
|||||||
Kunden des Vertrags gehört. PDF zeigt bei Überweisung „Unsere
|
Kunden des Vertrags gehört. PDF zeigt bei Überweisung „Unsere
|
||||||
Bankverbindung" + „an Bankkonto: <Kunden-IBAN>". Section-Zeile zeigt das
|
Bankverbindung" + „an Bankkonto: <Kunden-IBAN>". Section-Zeile zeigt das
|
||||||
Auszahlungskonto.
|
Auszahlungskonto.
|
||||||
- **Offen:** Phase 3b Teil 2 (ZUGFeRD-XML EN 16931, Typ 381, in PDF/A-3
|
- **Phase 3b Teil 2 (erledigt):** ZUGFeRD/Factur-X. `zugferd.service.ts`
|
||||||
einbetten – **muss gegen ZUGFeRD-Validator** geprüft werden). USt-
|
erzeugt CII-XML (EN 16931 `urn:cen.eu:en16931:2017`, Typ **381**;
|
||||||
Einordnung (Vermittlung vs. Abschlussbonus) mit Steuerberater klären –
|
Kategorie S bei USt, sonst E + Befreiungsgrund). `zugferdPdf.service.ts`
|
||||||
Modell deckt beide über `vatRelevant` ab.
|
bettet als hybrides **PDF/A-3B** ein: `factur-x.xml` (AF /Data), sRGB-
|
||||||
|
OutputIntent (pdfkit-ICC ins Repo kopiert), XMP (pdfaid part=3/conf=B +
|
||||||
|
Factur-X-Extension-Schema). PDF nutzt **eingebettete DejaVuSans-Fonts**
|
||||||
|
(im Repo unter `backend/assets/fonts`, Pflicht für PDF/A). Dockerfile
|
||||||
|
kopiert `backend/assets` ins Runtime-Image. Lokal strukturell verifiziert
|
||||||
|
(1 Seite, /AF, /Metadata, /OutputIntents, EmbeddedFiles, Font eingebettet,
|
||||||
|
XML wohlgeformt, TypeCode 381, GrandTotal korrekt).
|
||||||
|
- **⚠️ VOR PROD:** hybrides PDF gegen einen **ZUGFeRD-/Factur-X-Validator**
|
||||||
|
prüfen (am besten auf Staging mit echten Firmendaten). Feinheiten
|
||||||
|
(Trailer-ID, XMP-Details, MIME `text/xml` vs `application/xml`) ggf.
|
||||||
|
nach dem ersten Validator-Lauf nachziehen.
|
||||||
|
- **Offen (fachlich):** USt-Einordnung (Vermittlung vs. Abschlussbonus) mit
|
||||||
|
Steuerberater klären – Modell/XML decken beide über `vatRelevant` ab.
|
||||||
|
ZUGFeRD-Semantik (Seller=Firma, Buyer=Kunde, Typ 381) ggf. anpassen.
|
||||||
|
|
||||||
- [x] **📄➕ Vertrag kopieren (neuer eigenständiger Vertrag aus Vorlage)** (2026-08-03)
|
- [x] **📄➕ Vertrag kopieren (neuer eigenständiger Vertrag aus Vorlage)** (2026-08-03)
|
||||||
- „Kopieren"-Button in der Vertragsansicht (`contracts:create`) → öffnet das
|
- „Kopieren"-Button in der Vertragsansicht (`contracts:create`) → öffnet das
|
||||||
|
|||||||
@@ -478,11 +478,11 @@ export default function CreditNotesSection({ contractId, canEdit }: { contractId
|
|||||||
{canEdit && (
|
{canEdit && (
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
{cn.pdfPath ? (
|
{cn.pdfPath ? (
|
||||||
<a href={fileUrl(cn.pdfPath, { inline: true })} target="_blank" rel="noopener noreferrer" className="text-gray-400 hover:text-blue-600 p-1" title="PDF ansehen">
|
<a href={fileUrl(cn.pdfPath, { inline: true })} target="_blank" rel="noopener noreferrer" className="text-gray-400 hover:text-blue-600 p-1" title="PDF (ZUGFeRD) ansehen">
|
||||||
<FileText className="w-4 h-4" />
|
<FileText className="w-4 h-4" />
|
||||||
</a>
|
</a>
|
||||||
) : (
|
) : (
|
||||||
<button onClick={() => pdfMutation.mutate(cn.id)} disabled={pdfMutation.isPending} className="text-gray-400 hover:text-blue-600 p-1" title="PDF erzeugen">
|
<button onClick={() => pdfMutation.mutate(cn.id)} disabled={pdfMutation.isPending} className="text-gray-400 hover:text-blue-600 p-1" title="PDF (ZUGFeRD) erzeugen">
|
||||||
<FileText className="w-4 h-4" />
|
<FileText className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user