Files
opencrm/backend/src/services/zugferd.service.ts
T
duffyduckandClaude Opus 4.8 5423c83009 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>
2026-08-07 12:56:12 +02:00

157 lines
6.7 KiB
TypeScript

// ==================== 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
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>`;
}