- 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>
157 lines
6.7 KiB
TypeScript
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, '&')
|
|
.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>`;
|
|
}
|