Compare commits

..
4 Commits
Author SHA1 Message Date
SvenandClaude Fable 5 cc129b9c23 Build- und Tooling-Dateien aktualisiert (Vaadin-generierte Dateien, VSCode-Einstellungen)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 17:06:42 +02:00
SvenandClaude Fable 5 dcf68fede6 PDF-Service-Aufrufe auf versionierte API umgestellt (/api/v1)
Die API-Version steckt in der Basis-URL (PDF_SERVICE_URL, Standard
http://localhost:8084/api/v1); der Client ruft relativ dazu /invoices/sign auf.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 11:55:25 +02:00
SvenandClaude Fable 5 60aba03633 ZUGFeRD-Umwandlung an das PDF Service delegiert, lokale ZUGFeRD-Klassen entfernt
- 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 <noreply@anthropic.com>
2026-07-17 11:31:10 +02:00
SvenandClaude Fable 5 c95cd99be7 REST-API in das eigenständige Projekt "PDF Service" ausgelagert
InvoiceSigningController entfernt; die ZUGFeRD-Services bleiben für die
Weboberfläche erhalten. Multipart-Konfiguration entfällt (kein Upload mehr).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 11:22:40 +02:00
18 changed files with 376 additions and 860 deletions
+4
View File
@@ -6,3 +6,7 @@ MONGODB_URI=mongodb://BENUTZER:PASSWORT@localhost:27017/pdf-tool?authSource=admi
# HTTP-Port der Anwendung (Standard: 8083) # HTTP-Port der Anwendung (Standard: 8083)
SERVER_PORT=8083 SERVER_PORT=8083
# Basis-URL der PDF-Service-API (ZUGFeRD-Umwandlung); die Endpoints
# /invoices/sign und /invoices/validate liegen unterhalb dieser URL.
PDF_SERVICE_URL=http://localhost:8084/api/v1
+3
View File
@@ -0,0 +1,3 @@
{
"java.compile.nullAnalysis.mode": "disabled"
}
+18 -90
View File
@@ -1,7 +1,7 @@
# PDF Tool — ZUGFeRD E-Rechnungs-Konverter # PDF Tool — ZUGFeRD E-Rechnungs-Konverter
Spring Boot + Vaadin Flow Anwendung, die ein herkömmliches B2B-Rechnungs-PDF in eine Spring Boot + Vaadin Flow Anwendung, die aus gespeicherten Rechnungstemplates
**rechtskonforme ZUGFeRD-E-Rechnung** umwandelt (PDF/A-3 mit eingebettetem EN16931-XML, **rechtskonforme ZUGFeRD-E-Rechnungen** erzeugt (PDF/A-3 mit eingebettetem EN16931-XML,
Profil EN 16931 / Factur-X), das Ergebnis mit dem **Mustang-Validator** prüft und als Profil EN 16931 / Factur-X), das Ergebnis mit dem **Mustang-Validator** prüft und als
**ZIP-Datei** bereitstellt. **ZIP-Datei** bereitstellt.
@@ -26,7 +26,8 @@ mvn spring-boot:run
``` ```
Die Weboberfläche ist dann unter <http://localhost:8083> erreichbar: Die Weboberfläche ist dann unter <http://localhost:8083> erreichbar:
PDF hochladen, Rechnungsdaten ausfüllen, "E-Rechnung erzeugen" klicken, ZIP herunterladen. Rechnungstemplate auswählen, Rechnungsdaten ausfüllen, "E-Rechnung erzeugen" klicken
das ZIP wird automatisch heruntergeladen.
Produktions-Build: Produktions-Build:
@@ -35,94 +36,21 @@ mvn clean package -Pproduction
java -jar target/pdf-tool-1.0.0-SNAPSHOT.jar java -jar target/pdf-tool-1.0.0-SNAPSHOT.jar
``` ```
## REST-API ## PDF Service
### E-Rechnung erzeugen Die ZUGFeRD-Umwandlung (PDF/A-3 + EN16931-XML, Mustangproject) übernimmt das
eigenständige Projekt **PDF Service** (`../PDF Service`, Port 8084). Dieses Tool
`POST /api/invoices/sign` (multipart/form-data) mit zwei Parts: rendert die Rechnung aus dem Template und ruft dann dessen REST-API
(`POST /api/v1/invoices/sign`) auf — der Service muss dafür laufen. Die Basis-URL
| Part | Inhalt | der API ist über `PDF_SERVICE_URL` konfigurierbar (Standard
|------------|-------------------------------------------| `http://localhost:8084/api/v1`).
| `file` | das Rechnungs-PDF |
| `metadata` | Rechnungsdaten als JSON (siehe unten) |
Antwort: `application/zip` mit `<Rechnungsnummer>-zugferd.pdf`, `factur-x.xml` und
`validation-report.xml` (Mustang-Prüfbericht). Der Header `X-Zugferd-Valid: true|false`
meldet das Validierungsergebnis. Bei Fehlern: HTTP 400 mit `{"error": "..."}`.
> Häufigste Ursache für `X-Zugferd-Valid: false`: Das Quell-PDF bettet seine
> Schriften nicht ein. Das kann die Konvertierung nicht reparieren — das PDF muss
> dann mit eingebetteten Schriften neu erzeugt werden (Standard bei den meisten
> ERP-/Reporting-Systemen konfigurierbar).
### Bestehende E-Rechnung prüfen
`POST /api/invoices/validate` (multipart/form-data, Part `file` = ZUGFeRD-PDF oder
Factur-X-XML). Antwort: der Mustang-Prüfbericht als XML — HTTP 200 bei gültiger,
HTTP 422 bei ungültiger Rechnung, ebenfalls mit `X-Zugferd-Valid`-Header.
```bash
curl -X POST http://localhost:8083/api/invoices/validate \
-F "file=@rechnung-zugferd.pdf"
```
### Beispiel
```bash
curl -X POST http://localhost:8083/api/invoices/sign \
-F "file=@rechnung.pdf" \
-F "metadata=@metadata.json;type=application/json" \
-o rechnung-zugferd.zip
```
`metadata.json`:
```json
{
"invoiceNumber": "RE-2026-0815",
"issueDate": "2026-07-08",
"deliveryDate": "2026-07-01",
"dueDate": "2026-07-22",
"currency": "EUR",
"paymentTerms": "Zahlbar innerhalb von 14 Tagen ohne Abzug.",
"sender": {
"name": "Assecutor GmbH",
"street": "Musterstrasse 1",
"zip": "20095",
"city": "Hamburg",
"countryCode": "DE",
"vatId": "DE123456789"
},
"recipient": {
"name": "Kunde AG",
"street": "Beispielweg 2",
"zip": "10115",
"city": "Berlin",
"countryCode": "DE"
},
"items": [
{
"description": "Beratungsleistung",
"quantity": 1,
"unitPriceNet": 1500.00,
"vatPercent": 19
}
]
}
```
Pflichtfelder: `invoiceNumber`, `issueDate`, `sender` (inkl. Adresse), `recipient`,
mindestens ein Eintrag in `items`. `deliveryDate` fällt auf das Rechnungsdatum zurück,
`dueDate` auf Rechnungsdatum + 14 Tage, `currency` auf `EUR`.
## Architektur ## Architektur
- [ZugferdService](src/main/java/de/assecutor/pdftool/zugferd/ZugferdService.java) — Kernlogik: - [TemplatePdfService](src/main/java/de/assecutor/pdftool/template/TemplatePdfService.java) — rendert
PDF → PDF/A-3 + EN16931-XML (Mustangproject), Validierung, Verpackung als ZIP. die Canvas-Templates (JSON aus MongoDB) über iText html2pdf zu einem PDF
Nicht-PDF/A-Eingaben (der Normalfall) werden tolerant konvertiert. - [PdfServiceClient](src/main/java/de/assecutor/pdftool/pdfservice/PdfServiceClient.java) — ruft den
- [ZugferdValidationService](src/main/java/de/assecutor/pdftool/zugferd/ZugferdValidationService.java) — Mustang-Validator PDF Service auf, der daraus die ZUGFeRD-E-Rechnung als ZIP erzeugt
(XSD + Schematron + veraPDF), liefert Status und XML-Prüfbericht
- [InvoiceSigningController](src/main/java/de/assecutor/pdftool/api/InvoiceSigningController.java) — REST-Endpunkte `/sign` und `/validate`
- [MainView](src/main/java/de/assecutor/pdftool/ui/MainView.java) — Vaadin-Weboberfläche - [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 - [InvoiceMetadata](src/main/java/de/assecutor/pdftool/invoice/InvoiceMetadata.java) — DTO für die Rechnungsdaten
@@ -132,6 +60,6 @@ mindestens ein Eintrag in `items`. `deliveryDate` fällt auf das Rechnungsdatum
mvn test mvn test
``` ```
[ZugferdServiceTest](src/test/java/de/assecutor/pdftool/zugferd/ZugferdServiceTest.java) erzeugt ein Die Tests decken das Template-Rendering ([TemplatePdfServiceTest](src/test/java/de/assecutor/pdftool/template/TemplatePdfServiceTest.java),
Test-PDF, konvertiert es und prüft, dass das ZIP ein PDF/A-3 mit eingebettetem [TemplateVariablesTest](src/test/java/de/assecutor/pdftool/template/TemplateVariablesTest.java)) und die
`factur-x.xml` (EN16931-Profil) enthält. USt-IdNr.-Validierung ab; die ZUGFeRD-Umwandlung wird im Projekt PDF Service getestet.
Vendored
+189 -189
View File
@@ -1,189 +1,189 @@
<# : batch portion <# : batch portion
@REM ---------------------------------------------------------------------------- @REM ----------------------------------------------------------------------------
@REM Licensed to the Apache Software Foundation (ASF) under one @REM Licensed to the Apache Software Foundation (ASF) under one
@REM or more contributor license agreements. See the NOTICE file @REM or more contributor license agreements. See the NOTICE file
@REM distributed with this work for additional information @REM distributed with this work for additional information
@REM regarding copyright ownership. The ASF licenses this file @REM regarding copyright ownership. The ASF licenses this file
@REM to you under the Apache License, Version 2.0 (the @REM to you under the Apache License, Version 2.0 (the
@REM "License"); you may not use this file except in compliance @REM "License"); you may not use this file except in compliance
@REM with the License. You may obtain a copy of the License at @REM with the License. You may obtain a copy of the License at
@REM @REM
@REM http://www.apache.org/licenses/LICENSE-2.0 @REM http://www.apache.org/licenses/LICENSE-2.0
@REM @REM
@REM Unless required by applicable law or agreed to in writing, @REM Unless required by applicable law or agreed to in writing,
@REM software distributed under the License is distributed on an @REM software distributed under the License is distributed on an
@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY @REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
@REM KIND, either express or implied. See the License for the @REM KIND, either express or implied. See the License for the
@REM specific language governing permissions and limitations @REM specific language governing permissions and limitations
@REM under the License. @REM under the License.
@REM ---------------------------------------------------------------------------- @REM ----------------------------------------------------------------------------
@REM ---------------------------------------------------------------------------- @REM ----------------------------------------------------------------------------
@REM Apache Maven Wrapper startup batch script, version 3.3.4 @REM Apache Maven Wrapper startup batch script, version 3.3.4
@REM @REM
@REM Optional ENV vars @REM Optional ENV vars
@REM MVNW_REPOURL - repo url base for downloading maven distribution @REM MVNW_REPOURL - repo url base for downloading maven distribution
@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven @REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output @REM MVNW_VERBOSE - true: enable verbose log; others: silence the output
@REM ---------------------------------------------------------------------------- @REM ----------------------------------------------------------------------------
@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0) @IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0)
@SET __MVNW_CMD__= @SET __MVNW_CMD__=
@SET __MVNW_ERROR__= @SET __MVNW_ERROR__=
@SET __MVNW_PSMODULEP_SAVE=%PSModulePath% @SET __MVNW_PSMODULEP_SAVE=%PSModulePath%
@SET PSModulePath= @SET PSModulePath=
@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @( @FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @(
IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B) IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B)
) )
@SET PSModulePath=%__MVNW_PSMODULEP_SAVE% @SET PSModulePath=%__MVNW_PSMODULEP_SAVE%
@SET __MVNW_PSMODULEP_SAVE= @SET __MVNW_PSMODULEP_SAVE=
@SET __MVNW_ARG0_NAME__= @SET __MVNW_ARG0_NAME__=
@SET MVNW_USERNAME= @SET MVNW_USERNAME=
@SET MVNW_PASSWORD= @SET MVNW_PASSWORD=
@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*) @IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*)
@echo Cannot start maven from wrapper >&2 && exit /b 1 @echo Cannot start maven from wrapper >&2 && exit /b 1
@GOTO :EOF @GOTO :EOF
: end batch / begin powershell #> : end batch / begin powershell #>
$ErrorActionPreference = "Stop" $ErrorActionPreference = "Stop"
if ($env:MVNW_VERBOSE -eq "true") { if ($env:MVNW_VERBOSE -eq "true") {
$VerbosePreference = "Continue" $VerbosePreference = "Continue"
} }
# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties # calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties
$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl $distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl
if (!$distributionUrl) { if (!$distributionUrl) {
Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
} }
switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) { switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) {
"maven-mvnd-*" { "maven-mvnd-*" {
$USE_MVND = $true $USE_MVND = $true
$distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip" $distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip"
$MVN_CMD = "mvnd.cmd" $MVN_CMD = "mvnd.cmd"
break break
} }
default { default {
$USE_MVND = $false $USE_MVND = $false
$MVN_CMD = $script -replace '^mvnw','mvn' $MVN_CMD = $script -replace '^mvnw','mvn'
break break
} }
} }
# apply MVNW_REPOURL and calculate MAVEN_HOME # apply MVNW_REPOURL and calculate MAVEN_HOME
# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-<version>,maven-mvnd-<version>-<platform>}/<hash> # maven home pattern: ~/.m2/wrapper/dists/{apache-maven-<version>,maven-mvnd-<version>-<platform>}/<hash>
if ($env:MVNW_REPOURL) { if ($env:MVNW_REPOURL) {
$MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" } $MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" }
$distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')" $distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')"
} }
$distributionUrlName = $distributionUrl -replace '^.*/','' $distributionUrlName = $distributionUrl -replace '^.*/',''
$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$','' $distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$',''
$MAVEN_M2_PATH = "$HOME/.m2" $MAVEN_M2_PATH = "$HOME/.m2"
if ($env:MAVEN_USER_HOME) { if ($env:MAVEN_USER_HOME) {
$MAVEN_M2_PATH = "$env:MAVEN_USER_HOME" $MAVEN_M2_PATH = "$env:MAVEN_USER_HOME"
} }
if (-not (Test-Path -Path $MAVEN_M2_PATH)) { if (-not (Test-Path -Path $MAVEN_M2_PATH)) {
New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null
} }
$MAVEN_WRAPPER_DISTS = $null $MAVEN_WRAPPER_DISTS = $null
if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) { if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) {
$MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists" $MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists"
} else { } else {
$MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists" $MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists"
} }
$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain" $MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain"
$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join '' $MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join ''
$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME" $MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME"
if (Test-Path -Path "$MAVEN_HOME" -PathType Container) { if (Test-Path -Path "$MAVEN_HOME" -PathType Container) {
Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME" Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME"
Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
exit $? exit $?
} }
if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) { if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) {
Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl" Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl"
} }
# prepare tmp dir # prepare tmp dir
$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile $TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile
$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir" $TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir"
$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null $TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null
trap { trap {
if ($TMP_DOWNLOAD_DIR.Exists) { if ($TMP_DOWNLOAD_DIR.Exists) {
try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
} }
} }
New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null
# Download and Install Apache Maven # Download and Install Apache Maven
Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
Write-Verbose "Downloading from: $distributionUrl" Write-Verbose "Downloading from: $distributionUrl"
Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
$webclient = New-Object System.Net.WebClient $webclient = New-Object System.Net.WebClient
if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) { if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) {
$webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD) $webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD)
} }
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null $webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null
# If specified, validate the SHA-256 sum of the Maven distribution zip file # If specified, validate the SHA-256 sum of the Maven distribution zip file
$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum $distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum
if ($distributionSha256Sum) { if ($distributionSha256Sum) {
if ($USE_MVND) { if ($USE_MVND) {
Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties."
} }
Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash
if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) { if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) {
Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property." Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property."
} }
} }
# unzip and move # unzip and move
Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null
# Find the actual extracted directory name (handles snapshots where filename != directory name) # Find the actual extracted directory name (handles snapshots where filename != directory name)
$actualDistributionDir = "" $actualDistributionDir = ""
# First try the expected directory name (for regular distributions) # First try the expected directory name (for regular distributions)
$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain" $expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain"
$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD" $expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD"
if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) { if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) {
$actualDistributionDir = $distributionUrlNameMain $actualDistributionDir = $distributionUrlNameMain
} }
# If not found, search for any directory with the Maven executable (for snapshots) # If not found, search for any directory with the Maven executable (for snapshots)
if (!$actualDistributionDir) { if (!$actualDistributionDir) {
Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object { Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object {
$testPath = Join-Path $_.FullName "bin/$MVN_CMD" $testPath = Join-Path $_.FullName "bin/$MVN_CMD"
if (Test-Path -Path $testPath -PathType Leaf) { if (Test-Path -Path $testPath -PathType Leaf) {
$actualDistributionDir = $_.Name $actualDistributionDir = $_.Name
} }
} }
} }
if (!$actualDistributionDir) { if (!$actualDistributionDir) {
Write-Error "Could not find Maven distribution directory in extracted archive" Write-Error "Could not find Maven distribution directory in extracted archive"
} }
Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir" Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir"
Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null
try { try {
Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null
} catch { } catch {
if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) { if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) {
Write-Error "fail to move MAVEN_HOME" Write-Error "fail to move MAVEN_HOME"
} }
} finally { } finally {
try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
} }
Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
-13
View File
@@ -20,7 +20,6 @@
<properties> <properties>
<java.version>21</java.version> <java.version>21</java.version>
<vaadin.version>24.7.4</vaadin.version> <vaadin.version>24.7.4</vaadin.version>
<mustang.version>2.24.0</mustang.version>
</properties> </properties>
<dependencyManagement> <dependencyManagement>
@@ -54,13 +53,6 @@
<artifactId>spring-boot-starter-data-mongodb</artifactId> <artifactId>spring-boot-starter-data-mongodb</artifactId>
</dependency> </dependency>
<!-- Mustangproject: Open-Source-Referenzimplementierung von ZUGFeRD / Factur-X -->
<dependency>
<groupId>org.mustangproject</groupId>
<artifactId>library</artifactId>
<version>${mustang.version}</version>
</dependency>
<!-- iText html2pdf: rendert die Canvas-Templates des Rechnungsgenerators <!-- iText html2pdf: rendert die Canvas-Templates des Rechnungsgenerators
(HTML mit absoluter mm-Positionierung) nach PDF. Achtung: AGPL-Lizenz. --> (HTML mit absoluter mm-Positionierung) nach PDF. Achtung: AGPL-Lizenz. -->
<dependency> <dependency>
@@ -68,11 +60,6 @@
<artifactId>html2pdf</artifactId> <artifactId>html2pdf</artifactId>
<version>5.0.5</version> <version>5.0.5</version>
</dependency> </dependency>
<dependency>
<groupId>org.mustangproject</groupId>
<artifactId>validator</artifactId>
<version>${mustang.version}</version>
</dependency>
<dependency> <dependency>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
Binary file not shown.
@@ -1,122 +0,0 @@
package de.assecutor.pdftool.api;
import com.fasterxml.jackson.databind.ObjectMapper;
import de.assecutor.pdftool.invoice.InvoiceMetadata;
import de.assecutor.pdftool.zugferd.ZugferdConversionException;
import de.assecutor.pdftool.zugferd.ZugferdResult;
import de.assecutor.pdftool.zugferd.ZugferdService;
import de.assecutor.pdftool.zugferd.ZugferdValidationService;
import jakarta.validation.ConstraintViolation;
import jakarta.validation.Validator;
import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
/**
* REST-API zur Erzeugung und Prüfung rechtskonformer ZUGFeRD-E-Rechnungen.
*
* <pre>
* curl -X POST http://localhost:8083/api/invoices/sign \
* -F "file=@rechnung.pdf" \
* -F "metadata=@metadata.json;type=application/json" \
* -o rechnung-zugferd.zip
*
* curl -X POST http://localhost:8083/api/invoices/validate \
* -F "file=@rechnung-zugferd.pdf"
* </pre>
*/
@RestController
@RequestMapping("/api/invoices")
public class InvoiceSigningController {
/** Response-Header mit dem Ergebnis der Mustang-Validierung (true/false). */
public static final String VALIDATION_HEADER = "X-Zugferd-Valid";
private final ZugferdService zugferdService;
private final ZugferdValidationService validationService;
private final ObjectMapper objectMapper;
private final Validator validator;
public InvoiceSigningController(ZugferdService zugferdService,
ZugferdValidationService validationService,
ObjectMapper objectMapper,
Validator validator) {
this.zugferdService = zugferdService;
this.validationService = validationService;
this.objectMapper = objectMapper;
this.validator = validator;
}
/**
* Erzeugt aus PDF und Metadaten eine ZUGFeRD-Rechnung als ZIP (PDF + Prüfbericht).
* HTTP 200 bei gültiger, HTTP 422 bei ungültiger Rechnung; das ZIP wird in
* beiden Fällen geliefert.
*/
@PostMapping(value = "/sign", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity<byte[]> sign(@RequestPart("file") MultipartFile file,
@RequestPart("metadata") String metadataJson) throws IOException {
InvoiceMetadata metadata = parseAndValidate(metadataJson);
ZugferdResult result = zugferdService.createZugferdZip(file.getBytes(), metadata);
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.parseMediaType("application/zip"));
headers.setContentDisposition(ContentDisposition.attachment().filename(result.zipFileName()).build());
headers.set(VALIDATION_HEADER, String.valueOf(result.valid()));
return new ResponseEntity<>(result.zip(), headers,
result.valid() ? HttpStatus.OK : HttpStatus.UNPROCESSABLE_ENTITY);
}
/**
* Validiert eine bestehende ZUGFeRD-Rechnung (PDF oder Factur-X-XML).
* Antwort ist der Mustang-Prüfbericht als XML; HTTP 200 bei gültiger,
* HTTP 422 bei ungültiger Rechnung.
*/
@PostMapping(value = "/validate", consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
produces = MediaType.APPLICATION_XML_VALUE)
public ResponseEntity<String> validate(@RequestPart("file") MultipartFile file) throws IOException {
ZugferdValidationService.ValidationResult result =
validationService.validate(file.getBytes(), file.getOriginalFilename());
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_XML);
headers.set(VALIDATION_HEADER, String.valueOf(result.valid()));
return new ResponseEntity<>(result.reportXml(), headers,
result.valid() ? HttpStatus.OK : HttpStatus.UNPROCESSABLE_ENTITY);
}
private InvoiceMetadata parseAndValidate(String metadataJson) {
InvoiceMetadata metadata;
try {
metadata = objectMapper.readValue(metadataJson, InvoiceMetadata.class);
} catch (IOException e) {
throw new ZugferdConversionException("Metadaten sind kein gültiges JSON: " + e.getMessage(), e);
}
Set<ConstraintViolation<InvoiceMetadata>> violations = validator.validate(metadata);
if (!violations.isEmpty()) {
String details = violations.stream()
.map(v -> v.getPropertyPath() + ": " + v.getMessage())
.sorted()
.collect(Collectors.joining("; "));
throw new ZugferdConversionException("Metadaten unvollständig: " + details);
}
return metadata;
}
@ExceptionHandler(ZugferdConversionException.class)
public ResponseEntity<Map<String, String>> handleConversionError(ZugferdConversionException e) {
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
}
}
@@ -0,0 +1,136 @@
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 {pdfservice.url}/invoices/sign}, Standard-Basis
* {@code http://localhost:8084/api/v1}).
*/
@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/api/v1}") 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<String, Object> 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<byte[]> response;
try {
response = restClient.post()
.uri("/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;
}
}
@@ -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);
}
}
@@ -40,9 +40,8 @@ import de.assecutor.pdftool.template.InvoiceTemplateService;
import de.assecutor.pdftool.template.TemplatePdfService; import de.assecutor.pdftool.template.TemplatePdfService;
import de.assecutor.pdftool.template.TemplateRenderException; import de.assecutor.pdftool.template.TemplateRenderException;
import de.assecutor.pdftool.template.TemplateVariables; import de.assecutor.pdftool.template.TemplateVariables;
import de.assecutor.pdftool.zugferd.ZugferdConversionException; import de.assecutor.pdftool.pdfservice.PdfServiceClient;
import de.assecutor.pdftool.zugferd.ZugferdResult; import de.assecutor.pdftool.pdfservice.PdfServiceException;
import de.assecutor.pdftool.zugferd.ZugferdService;
import java.io.ByteArrayInputStream; import java.io.ByteArrayInputStream;
import java.math.BigDecimal; import java.math.BigDecimal;
@@ -63,7 +62,7 @@ public class MainView extends VerticalLayout {
private static final BigDecimal MAX_VAT_PERCENT = new BigDecimal("100"); private static final BigDecimal MAX_VAT_PERCENT = new BigDecimal("100");
private final ZugferdService zugferdService; private final PdfServiceClient pdfServiceClient;
private final CapturedInvoiceData capturedInvoiceData; private final CapturedInvoiceData capturedInvoiceData;
private final InvoiceTemplateService invoiceTemplateService; private final InvoiceTemplateService invoiceTemplateService;
private final TemplatePdfService templatePdfService; private final TemplatePdfService templatePdfService;
@@ -117,10 +116,10 @@ public class MainView extends VerticalLayout {
*/ */
private final List<com.vaadin.flow.component.HasEnabled> sourceDependentControls = new ArrayList<>(); private final List<com.vaadin.flow.component.HasEnabled> sourceDependentControls = new ArrayList<>();
public MainView(ZugferdService zugferdService, CapturedInvoiceData capturedInvoiceData, public MainView(PdfServiceClient pdfServiceClient, CapturedInvoiceData capturedInvoiceData,
InvoiceTemplateService invoiceTemplateService, TemplatePdfService templatePdfService, InvoiceTemplateService invoiceTemplateService, TemplatePdfService templatePdfService,
AddressRepository addressRepository, InvoiceDraftService invoiceDraftService) { AddressRepository addressRepository, InvoiceDraftService invoiceDraftService) {
this.zugferdService = zugferdService; this.pdfServiceClient = pdfServiceClient;
this.capturedInvoiceData = capturedInvoiceData; this.capturedInvoiceData = capturedInvoiceData;
this.invoiceTemplateService = invoiceTemplateService; this.invoiceTemplateService = invoiceTemplateService;
this.templatePdfService = templatePdfService; this.templatePdfService = templatePdfService;
@@ -686,12 +685,12 @@ public class MainView extends VerticalLayout {
.findFirst(); .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) { private void convertAndOfferDownload(byte[] sourcePdf) {
try { try {
InvoiceMetadata metadata = collectMetadata(); InvoiceMetadata metadata = collectMetadata();
capturedInvoiceData.setItems(metadata.items()); capturedInvoiceData.setItems(metadata.items());
ZugferdResult result = zugferdService.createZugferdZip(sourcePdf, metadata); PdfServiceClient.SignResult result = pdfServiceClient.sign(sourcePdf, metadata);
StreamResource resource = new StreamResource(result.zipFileName(), StreamResource resource = new StreamResource(result.zipFileName(),
() -> new ByteArrayInputStream(result.zip())); () -> new ByteArrayInputStream(result.zip()));
@@ -709,7 +708,7 @@ public class MainView extends VerticalLayout {
Notification.Position.MIDDLE) Notification.Position.MIDDLE)
.addThemeVariants(NotificationVariant.LUMO_WARNING); .addThemeVariants(NotificationVariant.LUMO_WARNING);
} }
} catch (ZugferdConversionException e) { } catch (PdfServiceException e) {
showErrors(List.of(e.getMessage())); showErrors(List.of(e.getMessage()));
} }
} }
@@ -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);
}
}
@@ -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) {
}
@@ -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());
}
}
@@ -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,
"<validation><error>Validierung nicht durchführbar: " + e.getMessage() + "</error></validation>");
}
}
public record ValidationResult(boolean valid, String reportXml) {
}
}
+3 -2
View File
@@ -15,8 +15,9 @@ server.servlet.session.cookie.name=PDFTOOL_SESSION
# Wird beim Build durch die Version aus der pom.xml ersetzt (Resource-Filtering) # Wird beim Build durch die Version aus der pom.xml ersetzt (Resource-Filtering)
pdftool.version=@project.version@ pdftool.version=@project.version@
spring.servlet.multipart.max-file-size=25MB # PDF Service: erzeugt aus dem gerenderten Rechnungs-PDF die ZUGFeRD-E-Rechnung
spring.servlet.multipart.max-request-size=30MB # (eigenständiges Projekt "PDF Service", POST {pdfservice.url}/invoices/sign)
pdfservice.url=${PDF_SERVICE_URL:http://localhost:8084/api/v1}
# Auch normale (Nicht-Multipart-)Requests bis 30 MB zulassen: Canvas-Daten mit # Auch normale (Nicht-Multipart-)Requests bis 30 MB zulassen: Canvas-Daten mit
# eingebetteten Bild-Elementen können das Tomcat-Standardlimit von 2 MB # eingebetteten Bild-Elementen können das Tomcat-Standardlimit von 2 MB
@@ -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<String, byte[]> 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<String, byte[]> readZip(byte[] zip) throws Exception {
Map<String, byte[]> 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;
}
}
+1 -1
View File
@@ -22,7 +22,7 @@
"noUnusedParameters": false, "noUnusedParameters": false,
"experimentalDecorators": true, "experimentalDecorators": true,
"useDefineForClassFields": false, "useDefineForClassFields": false,
"ignoreDeprecations": "6.0", "ignoreDeprecations": "5.0",
"baseUrl": "src/main/frontend", "baseUrl": "src/main/frontend",
"paths": { "paths": {
"@vaadin/flow-frontend": ["generated/jar-resources"], "@vaadin/flow-frontend": ["generated/jar-resources"],
+1 -1
View File
@@ -274,7 +274,7 @@ function statsExtracterPlugin(): PluginOption {
const frontendFiles: Record<string, string> = {}; const frontendFiles: Record<string, string> = {};
frontendFiles['index.html'] = createHash('sha256').update(customIndexData.replace(/\r\n/g, '\n'), 'utf8').digest('hex'); frontendFiles['index.html'] = createHash('sha256').update(customIndexData.replace(/\r\n/g, '\n'), 'utf8').digest('hex');
const projectFileExtensions = ['.js', '.js.map', '.ts', '.ts.map', '.tsx', '.tsx.map', '.css', '.css.map', '.']; const projectFileExtensions = ['.js', '.js.map', '.ts', '.ts.map', '.tsx', '.tsx.map', '.css', '.css.map'];
const isThemeComponentsResource = (id: string) => const isThemeComponentsResource = (id: string) =>
id.startsWith(themeOptions.frontendGeneratedFolder.replace(/\\/g, '/')) id.startsWith(themeOptions.frontendGeneratedFolder.replace(/\\/g, '/'))