From 60aba03633980871a85a446bdb4a4cd190564c73 Mon Sep 17 00:00:00 2001 From: Sven Carstensen Date: Fri, 17 Jul 2026 11:31:10 +0200 Subject: [PATCH] ZUGFeRD-Umwandlung an das PDF Service delegiert, lokale ZUGFeRD-Klassen entfernt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PdfServiceClient ruft POST /api/invoices/sign auf (Basis-URL über PDF_SERVICE_URL, Standard http://localhost:8084); ZIP, Dateiname und Validierungsergebnis kommen aus der Response - zugferd-Paket samt Test entfernt, Mustang-Abhängigkeiten aus der pom.xml gestrichen - README und .env.example entsprechend angepasst Co-Authored-By: Claude Fable 5 --- .env.example | 3 + README.md | 26 +- pom.xml | 13 - .../pdftool/pdfservice/PdfServiceClient.java | 135 ++++++++++ .../pdfservice/PdfServiceException.java | 13 + .../de/assecutor/pdftool/ui/MainView.java | 17 +- .../zugferd/ZugferdConversionException.java | 12 - .../pdftool/zugferd/ZugferdResult.java | 8 - .../pdftool/zugferd/ZugferdService.java | 242 ------------------ .../zugferd/ZugferdValidationService.java | 38 --- src/main/resources/application.properties | 4 + .../pdftool/zugferd/ZugferdServiceTest.java | 133 ---------- 12 files changed, 176 insertions(+), 468 deletions(-) create mode 100644 src/main/java/de/assecutor/pdftool/pdfservice/PdfServiceClient.java create mode 100644 src/main/java/de/assecutor/pdftool/pdfservice/PdfServiceException.java delete mode 100644 src/main/java/de/assecutor/pdftool/zugferd/ZugferdConversionException.java delete mode 100644 src/main/java/de/assecutor/pdftool/zugferd/ZugferdResult.java delete mode 100644 src/main/java/de/assecutor/pdftool/zugferd/ZugferdService.java delete mode 100644 src/main/java/de/assecutor/pdftool/zugferd/ZugferdValidationService.java delete mode 100644 src/test/java/de/assecutor/pdftool/zugferd/ZugferdServiceTest.java diff --git a/.env.example b/.env.example index 39f4074..29eb0ac 100644 --- a/.env.example +++ b/.env.example @@ -6,3 +6,6 @@ MONGODB_URI=mongodb://BENUTZER:PASSWORT@localhost:27017/pdf-tool?authSource=admi # HTTP-Port der Anwendung (Standard: 8083) SERVER_PORT=8083 + +# Basis-URL des PDF Service (ZUGFeRD-Umwandlung, Standard: http://localhost:8084) +PDF_SERVICE_URL=http://localhost:8084 diff --git a/README.md b/README.md index d6371b1..7f7b0cf 100644 --- a/README.md +++ b/README.md @@ -36,20 +36,20 @@ mvn clean package -Pproduction java -jar target/pdf-tool-1.0.0-SNAPSHOT.jar ``` -## REST-API +## PDF Service -Die REST-API (`/api/invoices/sign` und `/api/invoices/validate`) ist in das -eigenständige Projekt **PDF Service** ausgelagert -(`../PDF Service`, Port 8084). Dieses Tool enthält nur noch die Weboberfläche; -Rechnungen entstehen hier ausschließlich aus gespeicherten Templates. +Die ZUGFeRD-Umwandlung (PDF/A-3 + EN16931-XML, Mustangproject) übernimmt das +eigenständige Projekt **PDF Service** (`../PDF Service`, Port 8084). Dieses Tool +rendert die Rechnung aus dem Template und ruft dann dessen REST-API +(`POST /api/invoices/sign`) auf — der Service muss dafür laufen. Die Basis-URL +ist über `PDF_SERVICE_URL` konfigurierbar (Standard `http://localhost:8084`). ## Architektur -- [ZugferdService](src/main/java/de/assecutor/pdftool/zugferd/ZugferdService.java) — Kernlogik: - PDF → PDF/A-3 + EN16931-XML (Mustangproject), Validierung, Verpackung als ZIP. - Nicht-PDF/A-Eingaben (der Normalfall) werden tolerant konvertiert. -- [ZugferdValidationService](src/main/java/de/assecutor/pdftool/zugferd/ZugferdValidationService.java) — Mustang-Validator - (XSD + Schematron + veraPDF), liefert Status und XML-Prüfbericht +- [TemplatePdfService](src/main/java/de/assecutor/pdftool/template/TemplatePdfService.java) — rendert + die Canvas-Templates (JSON aus MongoDB) über iText html2pdf zu einem PDF +- [PdfServiceClient](src/main/java/de/assecutor/pdftool/pdfservice/PdfServiceClient.java) — ruft den + PDF Service auf, der daraus die ZUGFeRD-E-Rechnung als ZIP erzeugt - [MainView](src/main/java/de/assecutor/pdftool/ui/MainView.java) — Vaadin-Weboberfläche - [InvoiceMetadata](src/main/java/de/assecutor/pdftool/invoice/InvoiceMetadata.java) — DTO für die Rechnungsdaten @@ -59,6 +59,6 @@ Rechnungen entstehen hier ausschließlich aus gespeicherten Templates. mvn test ``` -[ZugferdServiceTest](src/test/java/de/assecutor/pdftool/zugferd/ZugferdServiceTest.java) erzeugt ein -Test-PDF, konvertiert es und prüft, dass das ZIP ein PDF/A-3 mit eingebettetem -`factur-x.xml` (EN16931-Profil) enthält. +Die Tests decken das Template-Rendering ([TemplatePdfServiceTest](src/test/java/de/assecutor/pdftool/template/TemplatePdfServiceTest.java), +[TemplateVariablesTest](src/test/java/de/assecutor/pdftool/template/TemplateVariablesTest.java)) und die +USt-IdNr.-Validierung ab; die ZUGFeRD-Umwandlung wird im Projekt PDF Service getestet. diff --git a/pom.xml b/pom.xml index a80f127..a643683 100644 --- a/pom.xml +++ b/pom.xml @@ -20,7 +20,6 @@ 21 24.7.4 - 2.24.0 @@ -54,13 +53,6 @@ spring-boot-starter-data-mongodb - - - org.mustangproject - library - ${mustang.version} - - @@ -68,11 +60,6 @@ html2pdf 5.0.5 - - org.mustangproject - validator - ${mustang.version} - org.springframework.boot diff --git a/src/main/java/de/assecutor/pdftool/pdfservice/PdfServiceClient.java b/src/main/java/de/assecutor/pdftool/pdfservice/PdfServiceClient.java new file mode 100644 index 0000000..d2a7955 --- /dev/null +++ b/src/main/java/de/assecutor/pdftool/pdfservice/PdfServiceClient.java @@ -0,0 +1,135 @@ +package de.assecutor.pdftool.pdfservice; + +import com.fasterxml.jackson.databind.ObjectMapper; +import de.assecutor.pdftool.invoice.InvoiceMetadata; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.http.ContentDisposition; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.stereotype.Service; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.ResourceAccessException; +import org.springframework.web.client.RestClient; + +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Map; + +/** + * Client für die REST-API des PDF Service (eigenständiges Projekt), der aus + * einem Rechnungs-PDF und den Metadaten die ZUGFeRD-E-Rechnung als ZIP erzeugt + * ({@code POST /api/invoices/sign}). + */ +@Service +public class PdfServiceClient { + + /** Response-Header des PDF Service mit dem Ergebnis der Mustang-Validierung. */ + private static final String VALIDATION_HEADER = "X-Zugferd-Valid"; + + private final RestClient restClient; + private final ObjectMapper objectMapper; + private final String baseUrl; + + /** Ergebnis der Umwandlung: das ZIP, sein Dateiname und das Validierungsergebnis. */ + public record SignResult(byte[] zip, String zipFileName, boolean valid) { + } + + public PdfServiceClient(ObjectMapper objectMapper, + @Value("${pdfservice.url:http://localhost:8084}") String baseUrl) { + this.objectMapper = objectMapper; + this.baseUrl = baseUrl; + SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory(); + requestFactory.setConnectTimeout(Duration.ofSeconds(5)); + // Die Umwandlung (PDF/A-Konvertierung + Validierung) kann einige Sekunden dauern + requestFactory.setReadTimeout(Duration.ofSeconds(120)); + this.restClient = RestClient.builder() + .baseUrl(baseUrl) + .requestFactory(requestFactory) + .build(); + } + + /** + * Wandelt das PDF über den PDF Service in eine ZUGFeRD-E-Rechnung um. Das + * ZIP wird auch bei fehlgeschlagener Validierung (HTTP 422) geliefert; + * {@link SignResult#valid()} meldet das Ergebnis. + * + * @throws PdfServiceException wenn der Service nicht erreichbar ist oder + * die Anfrage ablehnt (z. B. HTTP 400) + */ + public SignResult sign(byte[] pdf, InvoiceMetadata metadata) { + String metadataJson; + try { + metadataJson = objectMapper.writeValueAsString(metadata); + } catch (Exception e) { + throw new PdfServiceException("Die Rechnungsdaten konnten nicht serialisiert werden: " + + e.getMessage(), e); + } + + MultiValueMap parts = new LinkedMultiValueMap<>(); + parts.add("file", new ByteArrayResource(pdf) { + @Override + public String getFilename() { + return "rechnung.pdf"; + } + }); + HttpHeaders metadataHeaders = new HttpHeaders(); + metadataHeaders.setContentType(MediaType.APPLICATION_JSON); + parts.add("metadata", new HttpEntity<>(metadataJson, metadataHeaders)); + + ResponseEntity response; + try { + response = restClient.post() + .uri("/api/invoices/sign") + .contentType(MediaType.MULTIPART_FORM_DATA) + .body(parts) + .retrieve() + // 422 = ungültige Rechnung, das ZIP kommt trotzdem mit + .onStatus(status -> status == HttpStatus.UNPROCESSABLE_ENTITY, (request, res) -> { + }) + .onStatus(org.springframework.http.HttpStatusCode::isError, (request, res) -> { + throw new PdfServiceException(errorMessage(res.getStatusCode().value(), + new String(res.getBody().readAllBytes(), StandardCharsets.UTF_8))); + }) + .toEntity(byte[].class); + } catch (ResourceAccessException e) { + throw new PdfServiceException("Der PDF Service ist nicht erreichbar (" + baseUrl + "): " + + e.getMessage(), e); + } + + byte[] zip = response.getBody(); + if (zip == null || zip.length == 0) { + throw new PdfServiceException("Der PDF Service hat ein leeres ZIP geliefert."); + } + boolean valid = Boolean.parseBoolean(response.getHeaders().getFirst(VALIDATION_HEADER)); + return new SignResult(zip, zipFileName(response.getHeaders(), metadata), valid); + } + + /** Dateiname aus dem Content-Disposition-Header, sonst aus der Rechnungsnummer. */ + private static String zipFileName(HttpHeaders headers, InvoiceMetadata metadata) { + ContentDisposition disposition = headers.getContentDisposition(); + if (disposition.getFilename() != null && !disposition.getFilename().isBlank()) { + return disposition.getFilename(); + } + return metadata.invoiceNumber() + "-zugferd.zip"; + } + + /** Fehlermeldung aus dem JSON-Body ({@code {"error": "..."}}) des PDF Service. */ + private String errorMessage(int status, String body) { + try { + Map json = objectMapper.readValue(body, Map.class); + Object error = json.get("error"); + if (error != null) { + return "Der PDF Service hat die Anfrage abgelehnt: " + error; + } + } catch (Exception ignored) { + // Body war kein JSON — Rohtext verwenden + } + return "Der PDF Service hat die Anfrage abgelehnt (HTTP " + status + "): " + body; + } +} diff --git a/src/main/java/de/assecutor/pdftool/pdfservice/PdfServiceException.java b/src/main/java/de/assecutor/pdftool/pdfservice/PdfServiceException.java new file mode 100644 index 0000000..212eb28 --- /dev/null +++ b/src/main/java/de/assecutor/pdftool/pdfservice/PdfServiceException.java @@ -0,0 +1,13 @@ +package de.assecutor.pdftool.pdfservice; + +/** Fehler beim Aufruf des PDF Service (nicht erreichbar oder Ablehnung der Anfrage). */ +public class PdfServiceException extends RuntimeException { + + public PdfServiceException(String message) { + super(message); + } + + public PdfServiceException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/main/java/de/assecutor/pdftool/ui/MainView.java b/src/main/java/de/assecutor/pdftool/ui/MainView.java index 5934309..1426407 100644 --- a/src/main/java/de/assecutor/pdftool/ui/MainView.java +++ b/src/main/java/de/assecutor/pdftool/ui/MainView.java @@ -40,9 +40,8 @@ import de.assecutor.pdftool.template.InvoiceTemplateService; import de.assecutor.pdftool.template.TemplatePdfService; import de.assecutor.pdftool.template.TemplateRenderException; import de.assecutor.pdftool.template.TemplateVariables; -import de.assecutor.pdftool.zugferd.ZugferdConversionException; -import de.assecutor.pdftool.zugferd.ZugferdResult; -import de.assecutor.pdftool.zugferd.ZugferdService; +import de.assecutor.pdftool.pdfservice.PdfServiceClient; +import de.assecutor.pdftool.pdfservice.PdfServiceException; import java.io.ByteArrayInputStream; import java.math.BigDecimal; @@ -63,7 +62,7 @@ public class MainView extends VerticalLayout { private static final BigDecimal MAX_VAT_PERCENT = new BigDecimal("100"); - private final ZugferdService zugferdService; + private final PdfServiceClient pdfServiceClient; private final CapturedInvoiceData capturedInvoiceData; private final InvoiceTemplateService invoiceTemplateService; private final TemplatePdfService templatePdfService; @@ -117,10 +116,10 @@ public class MainView extends VerticalLayout { */ private final List sourceDependentControls = new ArrayList<>(); - public MainView(ZugferdService zugferdService, CapturedInvoiceData capturedInvoiceData, + public MainView(PdfServiceClient pdfServiceClient, CapturedInvoiceData capturedInvoiceData, InvoiceTemplateService invoiceTemplateService, TemplatePdfService templatePdfService, AddressRepository addressRepository, InvoiceDraftService invoiceDraftService) { - this.zugferdService = zugferdService; + this.pdfServiceClient = pdfServiceClient; this.capturedInvoiceData = capturedInvoiceData; this.invoiceTemplateService = invoiceTemplateService; this.templatePdfService = templatePdfService; @@ -686,12 +685,12 @@ public class MainView extends VerticalLayout { .findFirst(); } - /** Wandelt das Quell-PDF in die ZUGFeRD-E-Rechnung um und bietet das ZIP zum Download an. */ + /** Wandelt das Quell-PDF über den PDF Service in die ZUGFeRD-E-Rechnung um und bietet das ZIP zum Download an. */ private void convertAndOfferDownload(byte[] sourcePdf) { try { InvoiceMetadata metadata = collectMetadata(); capturedInvoiceData.setItems(metadata.items()); - ZugferdResult result = zugferdService.createZugferdZip(sourcePdf, metadata); + PdfServiceClient.SignResult result = pdfServiceClient.sign(sourcePdf, metadata); StreamResource resource = new StreamResource(result.zipFileName(), () -> new ByteArrayInputStream(result.zip())); @@ -709,7 +708,7 @@ public class MainView extends VerticalLayout { Notification.Position.MIDDLE) .addThemeVariants(NotificationVariant.LUMO_WARNING); } - } catch (ZugferdConversionException e) { + } catch (PdfServiceException e) { showErrors(List.of(e.getMessage())); } } diff --git a/src/main/java/de/assecutor/pdftool/zugferd/ZugferdConversionException.java b/src/main/java/de/assecutor/pdftool/zugferd/ZugferdConversionException.java deleted file mode 100644 index 53dd73f..0000000 --- a/src/main/java/de/assecutor/pdftool/zugferd/ZugferdConversionException.java +++ /dev/null @@ -1,12 +0,0 @@ -package de.assecutor.pdftool.zugferd; - -public class ZugferdConversionException extends RuntimeException { - - public ZugferdConversionException(String message) { - super(message); - } - - public ZugferdConversionException(String message, Throwable cause) { - super(message, cause); - } -} diff --git a/src/main/java/de/assecutor/pdftool/zugferd/ZugferdResult.java b/src/main/java/de/assecutor/pdftool/zugferd/ZugferdResult.java deleted file mode 100644 index 0513bfe..0000000 --- a/src/main/java/de/assecutor/pdftool/zugferd/ZugferdResult.java +++ /dev/null @@ -1,8 +0,0 @@ -package de.assecutor.pdftool.zugferd; - -/** - * Ergebnis einer ZUGFeRD-Konvertierung: die ZIP-Datei (ZUGFeRD-PDF, Factur-X-XML, - * Prüfbericht) sowie das Ergebnis der Mustang-Validierung. - */ -public record ZugferdResult(byte[] zip, String zipFileName, boolean valid, String validationReport) { -} diff --git a/src/main/java/de/assecutor/pdftool/zugferd/ZugferdService.java b/src/main/java/de/assecutor/pdftool/zugferd/ZugferdService.java deleted file mode 100644 index 79037d1..0000000 --- a/src/main/java/de/assecutor/pdftool/zugferd/ZugferdService.java +++ /dev/null @@ -1,242 +0,0 @@ -package de.assecutor.pdftool.zugferd; - -import de.assecutor.pdftool.invoice.InvoiceMetadata; -import org.apache.pdfbox.Loader; -import org.apache.pdfbox.pdmodel.PDDocument; -import org.apache.pdfbox.pdmodel.PDPage; -import org.apache.pdfbox.pdmodel.PDResources; -import org.mustangproject.BankDetails; -import org.mustangproject.Contact; -import org.mustangproject.Invoice; -import org.mustangproject.Item; -import org.mustangproject.Product; -import org.mustangproject.SchemedID; -import org.mustangproject.TradeParty; -import org.mustangproject.ZUGFeRD.IZUGFeRDExporter; -import org.mustangproject.ZUGFeRD.Profiles; -import org.mustangproject.ZUGFeRD.ZUGFeRD2PullProvider; -import org.mustangproject.ZUGFeRD.ZUGFeRDExporterFromA1; -import org.mustangproject.ZUGFeRD.ZUGFeRDExporterFromPDFA; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.stereotype.Service; - -import java.io.ByteArrayInputStream; -import java.io.ByteArrayOutputStream; -import java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.time.LocalDate; -import java.time.ZoneId; -import java.util.Date; -import java.util.zip.ZipEntry; -import java.util.zip.ZipOutputStream; - -/** - * Wandelt ein herkömmliches Rechnungs-PDF in eine rechtskonforme ZUGFeRD-Rechnung - * (PDF/A-3 mit eingebettetem EN16931-XML, Profil EN 16931) um, validiert das - * Ergebnis mit dem Mustang-Validator und verpackt PDF, Factur-X-XML und - * Prüfbericht in eine ZIP-Datei. - */ -@Service -public class ZugferdService { - - private static final Logger log = LoggerFactory.getLogger(ZugferdService.class); - - private static final String PROFILE = "EN16931"; - private static final String PRODUCER = "Assecutor Data Service GmbH Invoice Tool"; - - /** Business Process (BT-23), von PEPPOL-EN16931-R001 gefordert. */ - private static final String BUSINESS_PROCESS = "urn:fdc:peppol.eu:2017:poacc:billing:01:1.0"; - - /** EAS-Schema "EM" = E-Mail-Adresse für die elektronische Adresse (BT-34/BT-49). */ - private static final String EAS_EMAIL = "EM"; - - private final ZugferdValidationService validationService; - - public ZugferdService(ZugferdValidationService validationService) { - this.validationService = validationService; - } - - public ZugferdResult createZugferdZip(byte[] sourcePdf, InvoiceMetadata metadata) { - validatePdf(sourcePdf); - sourcePdf = ensurePageResources(sourcePdf); - Invoice invoice = buildInvoice(metadata); - - byte[] zugferdPdf = embedXmlIntoPdf(sourcePdf, invoice); - byte[] facturXml = generateXml(invoice); - - String baseName = sanitizeFileName(metadata.invoiceNumber()); - String pdfName = baseName + "-zugferd.pdf"; - ZugferdValidationService.ValidationResult validation = validationService.validate(zugferdPdf, pdfName); - - byte[] zip = zip( - pdfName, zugferdPdf, - "factur-x.xml", facturXml, - "validation-report.xml", validation.reportXml().getBytes(StandardCharsets.UTF_8)); - return new ZugferdResult(zip, baseName + ".zip", validation.valid(), validation.reportXml()); - } - - private void validatePdf(byte[] pdf) { - if (pdf == null || pdf.length < 5 - || !"%PDF-".equals(new String(pdf, 0, 5, StandardCharsets.US_ASCII))) { - throw new ZugferdConversionException("Die hochgeladene Datei ist kein gültiges PDF."); - } - } - - /** - * Mustang setzt bei jeder Seite ein Resources-Dictionary voraus und stürzt - * sonst mit einer NullPointerException ab. Seiten ohne Resources (z.B. aus - * manchen PDF-Generatoren) erhalten deshalb vorab ein leeres Dictionary. - */ - private byte[] ensurePageResources(byte[] pdf) { - try (PDDocument doc = Loader.loadPDF(pdf)) { - boolean changed = false; - for (PDPage page : doc.getPages()) { - if (page.getResources() == null) { - page.setResources(new PDResources()); - changed = true; - } - } - if (!changed) { - return pdf; - } - try (ByteArrayOutputStream out = new ByteArrayOutputStream()) { - doc.save(out); - return out.toByteArray(); - } - } catch (IOException e) { - throw new ZugferdConversionException("Das PDF konnte nicht gelesen werden: " + e.getMessage(), e); - } - } - - private Invoice buildInvoice(InvoiceMetadata m) { - LocalDate delivery = m.deliveryDate() != null ? m.deliveryDate() : m.issueDate(); - LocalDate due = m.dueDate() != null ? m.dueDate() : m.issueDate().plusDays(14); - String currency = m.currency() != null && !m.currency().isBlank() ? m.currency() : "EUR"; - - Invoice invoice = new Invoice() - .setDocumentName("Rechnung") - .setBusinessProcessId(BUSINESS_PROCESS) - .setNumber(m.invoiceNumber()) - .setIssueDate(toDate(m.issueDate())) - .setDeliveryDate(toDate(delivery)) - .setDueDate(toDate(due)) - .setCurrency(currency) - .setSender(toSender(m)) - .setRecipient(toTradeParty(m.recipient())); - - if (m.paymentTerms() != null && !m.paymentTerms().isBlank()) { - invoice.setPaymentTermDescription(m.paymentTerms()); - } - if (m.buyerReference() != null && !m.buyerReference().isBlank()) { - // Käuferreferenz / Leitweg-ID (BT-10) - invoice.setReferenceNumber(m.buyerReference().trim()); - } - - for (InvoiceMetadata.LineItem li : m.items()) { - // Einheit "C62" = Stück (UN/ECE Recommendation 20) - Product product = new Product(li.description(), "", "C62", li.vatPercent()); - invoice.addItem(new Item(product, li.unitPriceNet(), li.quantity())); - } - return invoice; - } - - /** Rechnungssteller inkl. Verkäufer-Kontakt (BG-6) und Zahlungsverbindung (BG-16). */ - private TradeParty toSender(InvoiceMetadata m) { - InvoiceMetadata.Party p = m.sender(); - TradeParty party = toTradeParty(p); - party.setContact(new Contact(p.name(), p.phone(), p.email())); - if (m.iban() != null && !m.iban().isBlank()) { - String iban = m.iban().replaceAll("\\s", "").toUpperCase(); - party.addBankDetails(m.bic() != null && !m.bic().isBlank() - ? new BankDetails(iban, m.bic().trim().toUpperCase()) - : new BankDetails(iban)); - } - return party; - } - - private TradeParty toTradeParty(InvoiceMetadata.Party p) { - TradeParty party = new TradeParty(p.name(), p.street(), p.zip(), p.city(), p.countryCode()); - if (p.vatId() != null && !p.vatId().isBlank()) { - party.addVATID(p.vatId()); - } - if (p.email() != null && !p.email().isBlank()) { - party.setEmail(p.email()); - party.addUriUniversalCommunicationID(new SchemedID(EAS_EMAIL, p.email())); - } - return party; - } - - private byte[] embedXmlIntoPdf(byte[] sourcePdf, Invoice invoice) { - try (ByteArrayOutputStream out = new ByteArrayOutputStream(); - IZUGFeRDExporter exporter = loadExporter(sourcePdf)) { - exporter.setProducer(PRODUCER) - .setCreator(PRODUCER) - .setProfile(Profiles.getByName(PROFILE)) - .setTransaction(invoice); - exporter.export(out); - return out.toByteArray(); - } catch (IOException | RuntimeException e) { - // Mustang wirft bei problematischen PDFs auch unchecked Exceptions — - // ohne diesen Catch würde daraus ein HTTP 500 statt einer Fehlermeldung. - throw new ZugferdConversionException( - "Das PDF konnte nicht in eine ZUGFeRD-Rechnung umgewandelt werden: " + e.getMessage(), e); - } - } - - /** - * Versucht zuerst die automatische PDF/A-Erkennung; ist das PDF kein PDF/A - * (der Normalfall bei ERP-Ausdrucken), wird es tolerant als PDF/A-1 - * interpretiert und nach PDF/A-3 konvertiert. - */ - private IZUGFeRDExporter loadExporter(byte[] sourcePdf) throws IOException { - ZUGFeRDExporterFromPDFA exporter = new ZUGFeRDExporterFromPDFA(); - try { - exporter.load(new ByteArrayInputStream(sourcePdf)); - return exporter; - } catch (IOException | IllegalArgumentException e) { - // Nicht exporter.close(): vor erfolgreichem load() hält er keine Ressourcen - // und close() würde eine RuntimeException werfen. - log.info("Eingabe ist kein PDF/A, konvertiere tolerant: {}", e.getMessage()); - ZUGFeRDExporterFromA1 fallback = new ZUGFeRDExporterFromA1(); - fallback.ignorePDFAErrors(); - fallback.load(new ByteArrayInputStream(sourcePdf)); - return fallback; - } - } - - private byte[] generateXml(Invoice invoice) { - ZUGFeRD2PullProvider provider = new ZUGFeRD2PullProvider(); - provider.setProfile(Profiles.getByName(PROFILE)); - provider.generateXML(invoice); - return provider.getXML(); - } - - /** - * Packt die übergebenen Einträge (abwechselnd Dateiname als String und Inhalt - * als byte[]) in eine ZIP-Datei. - */ - private byte[] zip(Object... namesAndContents) { - try (ByteArrayOutputStream bos = new ByteArrayOutputStream(); - ZipOutputStream zos = new ZipOutputStream(bos)) { - for (int i = 0; i < namesAndContents.length; i += 2) { - zos.putNextEntry(new ZipEntry((String) namesAndContents[i])); - zos.write((byte[]) namesAndContents[i + 1]); - zos.closeEntry(); - } - zos.finish(); - return bos.toByteArray(); - } catch (IOException e) { - throw new ZugferdConversionException("ZIP-Datei konnte nicht erstellt werden.", e); - } - } - - private static String sanitizeFileName(String name) { - String cleaned = name.replaceAll("[^A-Za-z0-9._-]", "_"); - return cleaned.isBlank() ? "rechnung" : cleaned; - } - - private static Date toDate(LocalDate date) { - return Date.from(date.atStartOfDay(ZoneId.systemDefault()).toInstant()); - } -} diff --git a/src/main/java/de/assecutor/pdftool/zugferd/ZugferdValidationService.java b/src/main/java/de/assecutor/pdftool/zugferd/ZugferdValidationService.java deleted file mode 100644 index 110f287..0000000 --- a/src/main/java/de/assecutor/pdftool/zugferd/ZugferdValidationService.java +++ /dev/null @@ -1,38 +0,0 @@ -package de.assecutor.pdftool.zugferd; - -import org.mustangproject.validator.ZUGFeRDValidator; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.stereotype.Service; - -/** - * Validiert ZUGFeRD-Rechnungen (PDF oder XML) mit dem Mustang-Validator gegen - * XSD-Schema und die EN16931-Schematron-Regeln sowie das PDF gegen PDF/A-3 - * (veraPDF). Liefert das Prüfergebnis samt XML-Prüfbericht. - */ -@Service -public class ZugferdValidationService { - - private static final Logger log = LoggerFactory.getLogger(ZugferdValidationService.class); - - public ValidationResult validate(byte[] content, String fileName) { - try { - // ZUGFeRDValidator hält Zustand pro Prüfung und ist nicht threadsicher, - // daher pro Aufruf eine neue Instanz - ZUGFeRDValidator validator = new ZUGFeRDValidator(); - String report = validator.validate(content, fileName); - boolean valid = validator.wasCompletelyValid(); - if (!valid) { - log.warn("ZUGFeRD-Validierung von {} fehlgeschlagen:\n{}", fileName, report); - } - return new ValidationResult(valid, report); - } catch (RuntimeException e) { - log.error("Validierung von {} nicht durchführbar", fileName, e); - return new ValidationResult(false, - "Validierung nicht durchführbar: " + e.getMessage() + ""); - } - } - - public record ValidationResult(boolean valid, String reportXml) { - } -} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 1986ef1..7742919 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -15,6 +15,10 @@ server.servlet.session.cookie.name=PDFTOOL_SESSION # Wird beim Build durch die Version aus der pom.xml ersetzt (Resource-Filtering) pdftool.version=@project.version@ +# PDF Service: erzeugt aus dem gerenderten Rechnungs-PDF die ZUGFeRD-E-Rechnung +# (eigenständiges Projekt "PDF Service", POST /api/invoices/sign) +pdfservice.url=${PDF_SERVICE_URL:http://localhost:8084} + # Auch normale (Nicht-Multipart-)Requests bis 30 MB zulassen: Canvas-Daten mit # eingebetteten Bild-Elementen können das Tomcat-Standardlimit von 2 MB # überschreiten; ein abgelehnter Request führt im Browser sonst zu einem diff --git a/src/test/java/de/assecutor/pdftool/zugferd/ZugferdServiceTest.java b/src/test/java/de/assecutor/pdftool/zugferd/ZugferdServiceTest.java deleted file mode 100644 index 2742900..0000000 --- a/src/test/java/de/assecutor/pdftool/zugferd/ZugferdServiceTest.java +++ /dev/null @@ -1,133 +0,0 @@ -package de.assecutor.pdftool.zugferd; - -import de.assecutor.pdftool.invoice.InvoiceMetadata; -import org.apache.pdfbox.Loader; -import org.apache.pdfbox.pdmodel.PDDocument; -import org.apache.pdfbox.pdmodel.PDPage; -import org.apache.pdfbox.pdmodel.PDPageContentStream; -import org.apache.pdfbox.pdmodel.font.PDType0Font; -import org.junit.jupiter.api.Test; - -import java.io.ByteArrayInputStream; -import java.io.ByteArrayOutputStream; -import java.math.BigDecimal; -import java.nio.charset.StandardCharsets; -import java.time.LocalDate; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.zip.ZipEntry; -import java.util.zip.ZipInputStream; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -class ZugferdServiceTest { - - private final ZugferdService service = new ZugferdService(new ZugferdValidationService()); - - @Test - void createsZipWithZugferdPdfAndFacturXml() throws Exception { - byte[] sourcePdf = createSamplePdf(); - - ZugferdResult result = service.createZugferdZip(sourcePdf, sampleMetadata()); - - assertEquals("RE-2026-0815.zip", result.zipFileName()); - assertTrue(result.valid(), "Mustang-Validierung fehlgeschlagen:\n" + result.validationReport()); - - Map entries = readZip(result.zip()); - assertEquals(3, entries.size()); - byte[] pdf = entries.get("RE-2026-0815-zugferd.pdf"); - byte[] xml = entries.get("factur-x.xml"); - byte[] report = entries.get("validation-report.xml"); - assertNotNull(pdf, "ZUGFeRD-PDF fehlt im ZIP"); - assertNotNull(xml, "Factur-X-XML fehlt im ZIP"); - assertNotNull(report, "Prüfbericht fehlt im ZIP"); - - String xmlText = new String(xml, StandardCharsets.UTF_8); - assertTrue(xmlText.contains("RE-2026-0815"), "Rechnungsnummer fehlt im XML"); - assertTrue(xmlText.contains("CrossIndustryInvoice"), "Kein CII-XML"); - assertTrue(xmlText.contains("urn:cen.eu:en16931:2017"), "EN16931-Profil fehlt"); - - // Das erzeugte PDF muss das XML als eingebettete Datei enthalten - try (PDDocument doc = Loader.loadPDF(pdf)) { - String names = doc.getDocumentCatalog().getNames().getEmbeddedFiles().getNames().keySet().toString(); - assertTrue(names.contains("factur-x.xml"), "factur-x.xml nicht ins PDF eingebettet: " + names); - } - } - - @Test - void convertsPdfWhosePageHasNoResources() throws Exception { - // Mustang 2.17 stürzt ohne Resources-Dictionary mit einer NPE ab — - // der Service muss solche PDFs vorab reparieren. - byte[] sourcePdf; - try (PDDocument doc = new PDDocument(); ByteArrayOutputStream out = new ByteArrayOutputStream()) { - doc.addPage(new PDPage()); - doc.save(out); - sourcePdf = out.toByteArray(); - } - - ZugferdResult result = service.createZugferdZip(sourcePdf, sampleMetadata()); - - assertTrue(result.valid(), "Mustang-Validierung fehlgeschlagen:\n" + result.validationReport()); - } - - @Test - void rejectsNonPdfInput() { - assertThrows(ZugferdConversionException.class, - () -> service.createZugferdZip("kein pdf".getBytes(StandardCharsets.UTF_8), sampleMetadata())); - } - - private static InvoiceMetadata sampleMetadata() { - return new InvoiceMetadata( - "RE-2026-0815", - LocalDate.of(2026, 7, 8), - LocalDate.of(2026, 7, 1), - LocalDate.of(2026, 7, 22), - "EUR", - "Zahlbar innerhalb von 14 Tagen ohne Abzug.", - "KR-2026-042", - "DE75512108001245126199", - null, - new InvoiceMetadata.Party("Assecutor Data Service GmbH", "Gerhart-Hauptmann-Weg 14", - "21502", "Geesthacht", "DE", "DE261094748", "rechnung@example.com", - "+49 40 18 123 771 0"), - new InvoiceMetadata.Party("Kunde AG", "Beispielweg 2", "10115", "Berlin", "DE", null, - "einkauf@example.com", null), - List.of(new InvoiceMetadata.LineItem("Beratungsleistung", BigDecimal.ONE, - new BigDecimal("1500.00"), new BigDecimal("19"))) - ); - } - - private static byte[] createSamplePdf() throws Exception { - try (PDDocument doc = new PDDocument(); ByteArrayOutputStream out = new ByteArrayOutputStream()) { - PDPage page = new PDPage(); - doc.addPage(page); - try (PDPageContentStream cs = new PDPageContentStream(doc, page)) { - // PDF/A verlangt eingebettete Schriften; LiberationSans liegt pdfbox bei - PDType0Font font = PDType0Font.load(doc, PDDocument.class.getResourceAsStream( - "/org/apache/pdfbox/resources/ttf/LiberationSans-Regular.ttf"), true); - cs.beginText(); - cs.setFont(font, 12); - cs.newLineAtOffset(50, 700); - cs.showText("Rechnung RE-2026-0815"); - cs.endText(); - } - doc.save(out); - return out.toByteArray(); - } - } - - private static Map readZip(byte[] zip) throws Exception { - Map entries = new HashMap<>(); - try (ZipInputStream zis = new ZipInputStream(new ByteArrayInputStream(zip))) { - ZipEntry entry; - while ((entry = zis.getNextEntry()) != null) { - entries.put(entry.getName(), zis.readAllBytes()); - } - } - return entries; - } -}