PDF Service: ZUGFeRD-REST-API als eigenständiges Projekt (aus dem PDF Tool herausgelöst)

Endpoints /api/invoices/sign und /api/invoices/validate; Eingabe ist immer ein
fertiges Rechnungs-PDF, Template-Rendering verbleibt im PDF Tool.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-17 11:20:58 +02:00
co-authored by Claude Fable 5
commit cf691649a1
18 changed files with 1424 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
# Build
target/
# IDE / OS
.DS_Store
.vscode/
.idea/
+3
View File
@@ -0,0 +1,3 @@
wrapperVersion=3.3.4
distributionType=only-script
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.11/apache-maven-3.9.11-bin.zip
+41
View File
@@ -0,0 +1,41 @@
# PDF Service
REST-API zur Erzeugung und Prüfung rechtskonformer ZUGFeRD-E-Rechnungen
(PDF/A-3 mit eingebettetem EN16931-XML, [Mustangproject](https://www.mustangproject.org/)).
Aus dem PDF Tool herausgelöst: Das PDF Tool enthält die Weboberfläche
(Rechnungserzeugung aus Templates), dieser Service stellt ausschließlich die
API bereit — Eingabe ist immer ein fertiges Rechnungs-PDF.
## Starten
```bash
./mvnw spring-boot:run
```
Der Service läuft standardmäßig auf Port `8084` (überschreibbar per `SERVER_PORT`).
## Endpoints
### PDF in ZUGFeRD-Rechnung umwandeln
```bash
curl -X POST http://localhost:8084/api/invoices/sign \
-F "file=@rechnung.pdf" \
-F "metadata=@metadata.json;type=application/json" \
-o rechnung-zugferd.zip
```
Antwort: ZIP mit ZUGFeRD-PDF und Mustang-Prüfbericht (`validation-report.xml`).
HTTP 200 bei gültiger, HTTP 422 bei ungültiger Rechnung (ZIP in beiden Fällen);
der Header `X-Zugferd-Valid` enthält das Prüfergebnis.
### ZUGFeRD-Rechnung validieren
```bash
curl -X POST http://localhost:8084/api/invoices/validate \
-F "file=@rechnung-zugferd.pdf"
```
Antwort: Mustang-Prüfbericht als XML; HTTP 200 bei gültiger, HTTP 422 bei
ungültiger Rechnung.
Vendored Executable
+295
View File
@@ -0,0 +1,295 @@
#!/bin/sh
# ----------------------------------------------------------------------------
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
# ----------------------------------------------------------------------------
# ----------------------------------------------------------------------------
# Apache Maven Wrapper startup batch script, version 3.3.4
#
# Optional ENV vars
# -----------------
# JAVA_HOME - location of a JDK home dir, required when download maven via java source
# MVNW_REPOURL - repo url base for downloading maven distribution
# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output
# ----------------------------------------------------------------------------
set -euf
[ "${MVNW_VERBOSE-}" != debug ] || set -x
# OS specific support.
native_path() { printf %s\\n "$1"; }
case "$(uname)" in
CYGWIN* | MINGW*)
[ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")"
native_path() { cygpath --path --windows "$1"; }
;;
esac
# set JAVACMD and JAVACCMD
set_java_home() {
# For Cygwin and MinGW, ensure paths are in Unix format before anything is touched
if [ -n "${JAVA_HOME-}" ]; then
if [ -x "$JAVA_HOME/jre/sh/java" ]; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD="$JAVA_HOME/jre/sh/java"
JAVACCMD="$JAVA_HOME/jre/sh/javac"
else
JAVACMD="$JAVA_HOME/bin/java"
JAVACCMD="$JAVA_HOME/bin/javac"
if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then
echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2
echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2
return 1
fi
fi
else
JAVACMD="$(
'set' +e
'unset' -f command 2>/dev/null
'command' -v java
)" || :
JAVACCMD="$(
'set' +e
'unset' -f command 2>/dev/null
'command' -v javac
)" || :
if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then
echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2
return 1
fi
fi
}
# hash string like Java String::hashCode
hash_string() {
str="${1:-}" h=0
while [ -n "$str" ]; do
char="${str%"${str#?}"}"
h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296))
str="${str#?}"
done
printf %x\\n $h
}
verbose() { :; }
[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; }
die() {
printf %s\\n "$1" >&2
exit 1
}
trim() {
# MWRAPPER-139:
# Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds.
# Needed for removing poorly interpreted newline sequences when running in more
# exotic environments such as mingw bash on Windows.
printf "%s" "${1}" | tr -d '[:space:]'
}
scriptDir="$(dirname "$0")"
scriptName="$(basename "$0")"
# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties
while IFS="=" read -r key value; do
case "${key-}" in
distributionUrl) distributionUrl=$(trim "${value-}") ;;
distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;;
esac
done <"$scriptDir/.mvn/wrapper/maven-wrapper.properties"
[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
case "${distributionUrl##*/}" in
maven-mvnd-*bin.*)
MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/
case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in
*AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;;
:Darwin*x86_64) distributionPlatform=darwin-amd64 ;;
:Darwin*arm64) distributionPlatform=darwin-aarch64 ;;
:Linux*x86_64*) distributionPlatform=linux-amd64 ;;
*)
echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2
distributionPlatform=linux-amd64
;;
esac
distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip"
;;
maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;;
*) MVN_CMD="mvn${scriptName#mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;;
esac
# apply MVNW_REPOURL and calculate MAVEN_HOME
# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-<version>,maven-mvnd-<version>-<platform>}/<hash>
[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}"
distributionUrlName="${distributionUrl##*/}"
distributionUrlNameMain="${distributionUrlName%.*}"
distributionUrlNameMain="${distributionUrlNameMain%-bin}"
MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}"
MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")"
exec_maven() {
unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || :
exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD"
}
if [ -d "$MAVEN_HOME" ]; then
verbose "found existing MAVEN_HOME at $MAVEN_HOME"
exec_maven "$@"
fi
case "${distributionUrl-}" in
*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;;
*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;;
esac
# prepare tmp dir
if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then
clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; }
trap clean HUP INT TERM EXIT
else
die "cannot create temp dir"
fi
mkdir -p -- "${MAVEN_HOME%/*}"
# Download and Install Apache Maven
verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
verbose "Downloading from: $distributionUrl"
verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
# select .zip or .tar.gz
if ! command -v unzip >/dev/null; then
distributionUrl="${distributionUrl%.zip}.tar.gz"
distributionUrlName="${distributionUrl##*/}"
fi
# verbose opt
__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR=''
[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v
# normalize http auth
case "${MVNW_PASSWORD:+has-password}" in
'') MVNW_USERNAME='' MVNW_PASSWORD='' ;;
has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;;
esac
if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then
verbose "Found wget ... using wget"
wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl"
elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then
verbose "Found curl ... using curl"
curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl"
elif set_java_home; then
verbose "Falling back to use Java to download"
javaSource="$TMP_DOWNLOAD_DIR/Downloader.java"
targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName"
cat >"$javaSource" <<-END
public class Downloader extends java.net.Authenticator
{
protected java.net.PasswordAuthentication getPasswordAuthentication()
{
return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() );
}
public static void main( String[] args ) throws Exception
{
setDefault( new Downloader() );
java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() );
}
}
END
# For Cygwin/MinGW, switch paths to Windows format before running javac and java
verbose " - Compiling Downloader.java ..."
"$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java"
verbose " - Running Downloader.java ..."
"$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")"
fi
# If specified, validate the SHA-256 sum of the Maven distribution zip file
if [ -n "${distributionSha256Sum-}" ]; then
distributionSha256Result=false
if [ "$MVN_CMD" = mvnd.sh ]; then
echo "Checksum validation is not supported for maven-mvnd." >&2
echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
exit 1
elif command -v sha256sum >/dev/null; then
if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c - >/dev/null 2>&1; then
distributionSha256Result=true
fi
elif command -v shasum >/dev/null; then
if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then
distributionSha256Result=true
fi
else
echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2
echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
exit 1
fi
if [ $distributionSha256Result = false ]; then
echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2
echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2
exit 1
fi
fi
# unzip and move
if command -v unzip >/dev/null; then
unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip"
else
tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar"
fi
# Find the actual extracted directory name (handles snapshots where filename != directory name)
actualDistributionDir=""
# First try the expected directory name (for regular distributions)
if [ -d "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" ]; then
if [ -f "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/bin/$MVN_CMD" ]; then
actualDistributionDir="$distributionUrlNameMain"
fi
fi
# If not found, search for any directory with the Maven executable (for snapshots)
if [ -z "$actualDistributionDir" ]; then
# enable globbing to iterate over items
set +f
for dir in "$TMP_DOWNLOAD_DIR"/*; do
if [ -d "$dir" ]; then
if [ -f "$dir/bin/$MVN_CMD" ]; then
actualDistributionDir="$(basename "$dir")"
break
fi
fi
done
set -f
fi
if [ -z "$actualDistributionDir" ]; then
verbose "Contents of $TMP_DOWNLOAD_DIR:"
verbose "$(ls -la "$TMP_DOWNLOAD_DIR")"
die "Could not find Maven distribution directory in extracted archive"
fi
verbose "Found extracted Maven distribution directory: $actualDistributionDir"
printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$actualDistributionDir/mvnw.url"
mv -- "$TMP_DOWNLOAD_DIR/$actualDistributionDir" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME"
clean || :
exec_maven "$@"
Vendored
+189
View File
@@ -0,0 +1,189 @@
<# : batch portion
@REM ----------------------------------------------------------------------------
@REM Licensed to the Apache Software Foundation (ASF) under one
@REM or more contributor license agreements. See the NOTICE file
@REM distributed with this work for additional information
@REM regarding copyright ownership. The ASF licenses this file
@REM to you under the Apache License, Version 2.0 (the
@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
@REM http://www.apache.org/licenses/LICENSE-2.0
@REM
@REM Unless required by applicable law or agreed to in writing,
@REM software distributed under the License is distributed on an
@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
@REM KIND, either express or implied. See the License for the
@REM specific language governing permissions and limitations
@REM under the License.
@REM ----------------------------------------------------------------------------
@REM ----------------------------------------------------------------------------
@REM Apache Maven Wrapper startup batch script, version 3.3.4
@REM
@REM Optional ENV vars
@REM MVNW_REPOURL - repo url base for downloading maven distribution
@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output
@REM ----------------------------------------------------------------------------
@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0)
@SET __MVNW_CMD__=
@SET __MVNW_ERROR__=
@SET __MVNW_PSMODULEP_SAVE=%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 @(
IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B)
)
@SET PSModulePath=%__MVNW_PSMODULEP_SAVE%
@SET __MVNW_PSMODULEP_SAVE=
@SET __MVNW_ARG0_NAME__=
@SET MVNW_USERNAME=
@SET MVNW_PASSWORD=
@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*)
@echo Cannot start maven from wrapper >&2 && exit /b 1
@GOTO :EOF
: end batch / begin powershell #>
$ErrorActionPreference = "Stop"
if ($env:MVNW_VERBOSE -eq "true") {
$VerbosePreference = "Continue"
}
# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties
$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl
if (!$distributionUrl) {
Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
}
switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) {
"maven-mvnd-*" {
$USE_MVND = $true
$distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip"
$MVN_CMD = "mvnd.cmd"
break
}
default {
$USE_MVND = $false
$MVN_CMD = $script -replace '^mvnw','mvn'
break
}
}
# apply MVNW_REPOURL and calculate MAVEN_HOME
# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-<version>,maven-mvnd-<version>-<platform>}/<hash>
if ($env:MVNW_REPOURL) {
$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",'')"
}
$distributionUrlName = $distributionUrl -replace '^.*/',''
$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$',''
$MAVEN_M2_PATH = "$HOME/.m2"
if ($env:MAVEN_USER_HOME) {
$MAVEN_M2_PATH = "$env:MAVEN_USER_HOME"
}
if (-not (Test-Path -Path $MAVEN_M2_PATH)) {
New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null
}
$MAVEN_WRAPPER_DISTS = $null
if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) {
$MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists"
} else {
$MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists"
}
$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 = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME"
if (Test-Path -Path "$MAVEN_HOME" -PathType Container) {
Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME"
Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
exit $?
}
if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) {
Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl"
}
# prepare tmp dir
$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile
$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir"
$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null
trap {
if ($TMP_DOWNLOAD_DIR.Exists) {
try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
}
}
New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null
# Download and Install Apache Maven
Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
Write-Verbose "Downloading from: $distributionUrl"
Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
$webclient = New-Object System.Net.WebClient
if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) {
$webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD)
}
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null
# 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
if ($distributionSha256Sum) {
if ($USE_MVND) {
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
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."
}
}
# unzip and move
Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null
# Find the actual extracted directory name (handles snapshots where filename != directory name)
$actualDistributionDir = ""
# First try the expected directory name (for regular distributions)
$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain"
$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD"
if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) {
$actualDistributionDir = $distributionUrlNameMain
}
# If not found, search for any directory with the Maven executable (for snapshots)
if (!$actualDistributionDir) {
Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object {
$testPath = Join-Path $_.FullName "bin/$MVN_CMD"
if (Test-Path -Path $testPath -PathType Leaf) {
$actualDistributionDir = $_.Name
}
}
}
if (!$actualDistributionDir) {
Write-Error "Could not find Maven distribution directory in extracted archive"
}
Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir"
Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null
try {
Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null
} catch {
if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) {
Write-Error "fail to move MAVEN_HOME"
}
} finally {
try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
}
Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
+62
View File
@@ -0,0 +1,62 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.5.3</version>
<relativePath/>
</parent>
<groupId>de.assecutor</groupId>
<artifactId>pdf-service</artifactId>
<version>0.9.0</version>
<name>pdf-service</name>
<description>REST-API zur Erzeugung und Prüfung rechtskonformer ZUGFeRD-E-Rechnungen</description>
<properties>
<java.version>21</java.version>
<mustang.version>2.24.0</mustang.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<!-- Mustangproject: Open-Source-Referenzimplementierung von ZUGFeRD / Factur-X -->
<dependency>
<groupId>org.mustangproject</groupId>
<artifactId>library</artifactId>
<version>${mustang.version}</version>
</dependency>
<dependency>
<groupId>org.mustangproject</groupId>
<artifactId>validator</artifactId>
<version>${mustang.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
@@ -0,0 +1,12 @@
package de.assecutor.pdfservice;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class PdfServiceApplication {
public static void main(String[] args) {
SpringApplication.run(PdfServiceApplication.class, args);
}
}
@@ -0,0 +1,122 @@
package de.assecutor.pdfservice.api;
import com.fasterxml.jackson.databind.ObjectMapper;
import de.assecutor.pdfservice.invoice.InvoiceMetadata;
import de.assecutor.pdfservice.zugferd.ZugferdConversionException;
import de.assecutor.pdfservice.zugferd.ZugferdResult;
import de.assecutor.pdfservice.zugferd.ZugferdService;
import de.assecutor.pdfservice.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,56 @@
package de.assecutor.pdfservice.invoice;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
/**
* Rechnungsdaten, die als EN16931-XML (Profil EN 16931 / "Comfort") in das
* PDF/A-3 eingebettet werden. Ohne diese strukturierten Daten ist eine
* ZUGFeRD-Rechnung nicht rechtskonform.
*/
public record InvoiceMetadata(
@NotBlank String invoiceNumber,
@NotNull LocalDate issueDate,
LocalDate deliveryDate,
LocalDate dueDate,
String currency,
String paymentTerms,
// Käuferreferenz bzw. Leitweg-ID (BT-10) — für Rechnungen an Behörden erforderlich
String buyerReference,
// Zahlungsverbindung des Rechnungsstellers (BG-16, SEPA-Überweisung)
String iban,
String bic,
@NotNull @Valid Party sender,
@NotNull @Valid Party recipient,
@NotEmpty @Valid List<LineItem> items
) {
public record Party(
@NotBlank String name,
@NotBlank String street,
@NotBlank String zip,
@NotBlank String city,
@NotBlank String countryCode,
@ValidVatId String vatId,
// Elektronische Adresse (BT-34/BT-49) — von PEPPOL-EN16931 gefordert
@NotBlank @Email String email,
// Telefon des Verkäufer-Kontakts (BR-DE-6); für den Empfänger optional
String phone
) {
}
public record LineItem(
@NotBlank String description,
@NotNull BigDecimal quantity,
@NotNull BigDecimal unitPriceNet,
@NotNull BigDecimal vatPercent
) {
}
}
@@ -0,0 +1,45 @@
package de.assecutor.pdfservice.invoice;
import jakarta.validation.Constraint;
import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;
import jakarta.validation.Payload;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import java.util.Optional;
/**
* Bean-Validation-Constraint für USt-IdNrn. Leere Werte gelten als gültig —
* ob das Feld Pflicht ist, regeln {@code @NotBlank} bzw. die UI.
*/
@Target({ElementType.FIELD, ElementType.METHOD, ElementType.PARAMETER, ElementType.RECORD_COMPONENT})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = ValidVatId.Validator.class)
public @interface ValidVatId {
String message() default "ungültige USt-IdNr.";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
class Validator implements ConstraintValidator<ValidVatId, String> {
@Override
public boolean isValid(String value, ConstraintValidatorContext context) {
if (value == null || value.isBlank()) {
return true;
}
Optional<String> error = VatIdValidator.validate(value);
if (error.isPresent()) {
context.disableDefaultConstraintViolation();
context.buildConstraintViolationWithTemplate(error.get()).addConstraintViolation();
return false;
}
return true;
}
}
}
@@ -0,0 +1,106 @@
package de.assecutor.pdfservice.invoice;
import java.util.Map;
import java.util.Optional;
import java.util.regex.Pattern;
/**
* Prüft Umsatzsteuer-Identifikationsnummern (USt-IdNr.).
*
* Geprüft werden das länderspezifische Format aller EU-Mitgliedsstaaten
* (plus XI für Nordirland) sowie bei deutschen IdNrn. zusätzlich die
* Prüfziffer nach ISO 7064, MOD 11,10 (§ 27a UStG).
*/
public final class VatIdValidator {
/** Format des nationalen Teils (ohne Länderpräfix) je EU-Land. */
private static final Map<String, Pattern> EU_FORMATS = Map.ofEntries(
Map.entry("AT", Pattern.compile("U\\d{8}")),
Map.entry("BE", Pattern.compile("[01]\\d{9}")),
Map.entry("BG", Pattern.compile("\\d{9,10}")),
Map.entry("CY", Pattern.compile("\\d{8}[A-Z]")),
Map.entry("CZ", Pattern.compile("\\d{8,10}")),
Map.entry("DE", Pattern.compile("[1-9]\\d{8}")),
Map.entry("DK", Pattern.compile("\\d{8}")),
Map.entry("EE", Pattern.compile("\\d{9}")),
Map.entry("EL", Pattern.compile("\\d{9}")),
Map.entry("ES", Pattern.compile("[A-Z0-9]\\d{7}[A-Z0-9]")),
Map.entry("FI", Pattern.compile("\\d{8}")),
Map.entry("FR", Pattern.compile("[A-Z0-9]{2}\\d{9}")),
Map.entry("HR", Pattern.compile("\\d{11}")),
Map.entry("HU", Pattern.compile("\\d{8}")),
Map.entry("IE", Pattern.compile("\\d{7}[A-W][A-I]?|\\d[A-Z+*]\\d{5}[A-W]")),
Map.entry("IT", Pattern.compile("\\d{11}")),
Map.entry("LT", Pattern.compile("\\d{9}|\\d{12}")),
Map.entry("LU", Pattern.compile("\\d{8}")),
Map.entry("LV", Pattern.compile("\\d{11}")),
Map.entry("MT", Pattern.compile("\\d{8}")),
Map.entry("NL", Pattern.compile("\\d{9}B\\d{2}")),
Map.entry("PL", Pattern.compile("\\d{10}")),
Map.entry("PT", Pattern.compile("\\d{9}")),
Map.entry("RO", Pattern.compile("\\d{2,10}")),
Map.entry("SE", Pattern.compile("\\d{10}01")),
Map.entry("SI", Pattern.compile("\\d{8}")),
Map.entry("SK", Pattern.compile("\\d{10}")),
Map.entry("XI", Pattern.compile("\\d{9}(\\d{3})?"))
);
private VatIdValidator() {
}
/**
* @return leeres Optional, wenn die USt-IdNr. gültig ist,
* sonst eine deutschsprachige Fehlermeldung.
*/
public static Optional<String> validate(String vatId) {
if (vatId == null || vatId.isBlank()) {
return Optional.of("USt-IdNr. fehlt.");
}
String normalized = normalize(vatId);
if (normalized.length() < 4
|| !Character.isLetter(normalized.charAt(0))
|| !Character.isLetter(normalized.charAt(1))) {
return Optional.of("USt-IdNr. muss mit einem Länderpräfix beginnen, z. B. DE.");
}
String country = normalized.substring(0, 2);
String body = normalized.substring(2);
Pattern format = EU_FORMATS.get(country);
if (format == null) {
return Optional.of("Unbekanntes Länderpräfix \"" + country + "\".");
}
if (!format.matcher(body).matches()) {
return Optional.of("USt-IdNr. entspricht nicht dem Format für " + country + ".");
}
if ("DE".equals(country) && !checkDigitValidDe(body)) {
return Optional.of("Prüfziffer der deutschen USt-IdNr. ist ungültig.");
}
return Optional.empty();
}
public static boolean isValid(String vatId) {
return validate(vatId).isEmpty();
}
/** Entfernt Leerzeichen, Punkte und Bindestriche und wandelt in Großbuchstaben um. */
public static String normalize(String vatId) {
return vatId.replaceAll("[\\s.\\-]", "").toUpperCase();
}
/** Prüfziffernverfahren ISO 7064, MOD 11,10 für deutsche USt-IdNrn. */
private static boolean checkDigitValidDe(String digits) {
int product = 10;
for (int i = 0; i < 8; i++) {
int sum = (Character.getNumericValue(digits.charAt(i)) + product) % 10;
if (sum == 0) {
sum = 10;
}
product = (2 * sum) % 11;
}
int checkDigit = 11 - product;
if (checkDigit == 10) {
checkDigit = 0;
}
return checkDigit == Character.getNumericValue(digits.charAt(8));
}
}
@@ -0,0 +1,12 @@
package de.assecutor.pdfservice.zugferd;
public class ZugferdConversionException extends RuntimeException {
public ZugferdConversionException(String message) {
super(message);
}
public ZugferdConversionException(String message, Throwable cause) {
super(message, cause);
}
}
@@ -0,0 +1,8 @@
package de.assecutor.pdfservice.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) {
}
@@ -0,0 +1,242 @@
package de.assecutor.pdfservice.zugferd;
import de.assecutor.pdfservice.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());
}
}
@@ -0,0 +1,38 @@
package de.assecutor.pdfservice.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) {
}
}
@@ -0,0 +1,5 @@
spring.application.name=pdf-service
server.port=${SERVER_PORT:8084}
spring.servlet.multipart.max-file-size=25MB
spring.servlet.multipart.max-request-size=30MB
@@ -0,0 +1,48 @@
package de.assecutor.pdfservice.invoice;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import static org.assertj.core.api.Assertions.assertThat;
class VatIdValidatorTest {
@ParameterizedTest
@ValueSource(strings = {
"DE261094748", // Assecutor Data Service GmbH
"DE136695976", // gültige Prüfziffer
"de 261 094 748", // Leerzeichen und Kleinschreibung werden normalisiert
"ATU12345678",
"NL123456789B01",
"FRXX123456789"
})
void accepts_valid_vat_ids(String vatId) {
assertThat(VatIdValidator.validate(vatId)).isEmpty();
}
@ParameterizedTest
@ValueSource(strings = {
"DE261094749", // falsche Prüfziffer
"DE12345678", // zu kurz
"DE1234567890", // zu lang
"XX123456789", // unbekanntes Länderpräfix
"123456789", // Länderpräfix fehlt
"ATU1234567", // falsches Format für AT
"DE" // nur Präfix
})
void rejects_invalid_vat_ids(String vatId) {
assertThat(VatIdValidator.validate(vatId)).isPresent();
}
@Test
void rejects_blank_values() {
assertThat(VatIdValidator.validate(null)).isPresent();
assertThat(VatIdValidator.validate(" ")).isPresent();
}
@Test
void normalizes_separators_and_case() {
assertThat(VatIdValidator.normalize("de 261-094.748")).isEqualTo("DE261094748");
}
}
@@ -0,0 +1,133 @@
package de.assecutor.pdfservice.zugferd;
import de.assecutor.pdfservice.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;
}
}