diff --git a/backend/Dockerfile b/backend/Dockerfile index eb41a2d3..cab83cf3 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -53,6 +53,10 @@ COPY backend/prisma ./prisma COPY --from=backend-builder /build/backend/src ./src 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) COPY --from=frontend-builder /build/frontend/dist ./public diff --git a/backend/assets/fonts/DejaVuSans-Bold.ttf b/backend/assets/fonts/DejaVuSans-Bold.ttf new file mode 100644 index 00000000..6d65fa7d Binary files /dev/null and b/backend/assets/fonts/DejaVuSans-Bold.ttf differ diff --git a/backend/assets/fonts/DejaVuSans.ttf b/backend/assets/fonts/DejaVuSans.ttf new file mode 100644 index 00000000..e5f7eecc Binary files /dev/null and b/backend/assets/fonts/DejaVuSans.ttf differ diff --git a/backend/assets/icc/sRGB_IEC61966_2_1.icc b/backend/assets/icc/sRGB_IEC61966_2_1.icc new file mode 100644 index 00000000..49afbfef Binary files /dev/null and b/backend/assets/icc/sRGB_IEC61966_2_1.icc differ diff --git a/backend/src/services/creditNotePdf.service.ts b/backend/src/services/creditNotePdf.service.ts index a975da20..8d143d95 100644 --- a/backend/src/services/creditNotePdf.service.ts +++ b/backend/src/services/creditNotePdf.service.ts @@ -8,6 +8,10 @@ import PDFDocument from 'pdfkit'; import prisma from '../lib/prisma.js'; import { ApiError } from '../utils/apiError.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 { return new Intl.NumberFormat('de-DE', { style: 'currency', currency }).format(n || 0); @@ -41,7 +45,7 @@ async function loadData(creditNoteId: number) { 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 customer = cn.contract.customer; 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(); 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[] = []; doc.on('data', (c: Buffer) => chunks.push(c)); const done = new Promise((resolve) => doc.on('end', () => resolve(Buffer.concat(chunks)))); @@ -97,11 +106,11 @@ export async function generateCreditNotePdf(creditNoteId: number): Promise<{ buf const rightX = 545; const labelW = 300; 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; doc.text(label, startX, y, { width: labelW }); doc.text(value, startX, y, { width: rightX - startX, align: 'right' }); - doc.font('Helvetica'); + doc.font('Body'); }; if (cn.vatRelevant) { @@ -175,7 +184,46 @@ export async function generateCreditNotePdf(creditNoteId: number): Promise<{ buf doc.fillColor('#000'); 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 ---- 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 } }); - return { buffer, pdfPath }; + return { buffer, pdfPath, xml }; } diff --git a/backend/src/services/zugferd.service.ts b/backend/src/services/zugferd.service.ts new file mode 100644 index 00000000..f501dc7a --- /dev/null +++ b/backend/src/services/zugferd.service.ts @@ -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 & { 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, '"'); +} + +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 ` + ${esc(p.postalCode)} + ${esc(lineOne)} + ${esc(p.city)} + ${esc(p.country || 'DE')} + `; +} + +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(` + ${esc(d.seller.vatId)} + `); + } + if (d.seller.taxNumber) { + sellerTax.push(` + ${esc(d.seller.taxNumber)} + `); + } + + const buyerTax = d.buyer.vatId + ? ` + ${esc(d.buyer.vatId)} + ` + : ''; + + const lineTaxCategory = ` + VAT + ${categoryCode} + ${n2(ratePct)} + `; + + const headerTax = ` + ${n2(d.amountVat)} + VAT${exemptionReason ? `\n ${esc(exemptionReason)}` : ''} + ${n2(d.amountNet)} + ${categoryCode} + ${n2(ratePct)} + `; + + return ` + + + + urn:cen.eu:en16931:2017 + + + + ${esc(d.number)} + 381 + + ${date102(d.issueDate)} + + + + + + 1 + + + ${esc(d.lineName)} + + + + ${n2(d.amountNet)} + + + + 1 + + +${lineTaxCategory} + + ${n2(d.amountNet)} + + + + + + ${esc(d.seller.name)} +${addressBlock(d.seller)} +${sellerTax.join('\n')} + + + ${esc(d.buyer.name)} +${addressBlock(d.buyer)}${buyerTax ? `\n${buyerTax}` : ''} + + + + + ${esc(d.currency)} +${headerTax} + + ${n2(d.amountNet)} + ${n2(d.amountNet)} + ${n2(d.amountVat)} + ${n2(d.amountGross)} + ${n2(d.amountGross)} + + + +`; +} diff --git a/backend/src/services/zugferdPdf.service.ts b/backend/src/services/zugferdPdf.service.ts new file mode 100644 index 00000000..4571d8d2 --- /dev/null +++ b/backend/src/services/zugferdPdf.service.ts @@ -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 ` + + + + 3 + B + + + ${title.replace(/[<>&]/g, '')} + + + OpenCRM + ${d} + ${d} + + + + + + Factur-X PDFA Extension Schema + urn:factur-x:pdfa:CrossIndustryDocument:invoice:1p0# + fx + + + DocumentFileNameTextexternalName des eingebetteten XML + DocumentTypeTextexternalINVOICE + VersionTextexternalVersion des Factur-X-Profils + ConformanceLevelTextexternalKonformitaetslevel + + + + + + + + INVOICE + factur-x.xml + 1.0 + EN 16931 + + + +`; +} + +export async function embedZugferd( + basePdf: Buffer, + xml: string, + meta: { title: string; date: Date }, +): Promise { + 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); +} diff --git a/docs/todo.md b/docs/todo.md index f9ff99ee..6233ae38 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -144,10 +144,23 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung Kunden des Vertrags gehört. PDF zeigt bei Überweisung „Unsere Bankverbindung" + „an Bankkonto: ". Section-Zeile zeigt das Auszahlungskonto. - - **Offen:** Phase 3b Teil 2 (ZUGFeRD-XML EN 16931, Typ 381, in PDF/A-3 - einbetten – **muss gegen ZUGFeRD-Validator** geprüft werden). USt- - Einordnung (Vermittlung vs. Abschlussbonus) mit Steuerberater klären – - Modell deckt beide über `vatRelevant` ab. + - **Phase 3b Teil 2 (erledigt):** ZUGFeRD/Factur-X. `zugferd.service.ts` + erzeugt CII-XML (EN 16931 `urn:cen.eu:en16931:2017`, Typ **381**; + Kategorie S bei USt, sonst E + Befreiungsgrund). `zugferdPdf.service.ts` + 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) - „Kopieren"-Button in der Vertragsansicht (`contracts:create`) → öffnet das diff --git a/frontend/src/components/contracts/CreditNotesSection.tsx b/frontend/src/components/contracts/CreditNotesSection.tsx index e18f4c67..86c3cfde 100644 --- a/frontend/src/components/contracts/CreditNotesSection.tsx +++ b/frontend/src/components/contracts/CreditNotesSection.tsx @@ -478,11 +478,11 @@ export default function CreditNotesSection({ contractId, canEdit }: { contractId {canEdit && (
{cn.pdfPath ? ( - + ) : ( - )}