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:
@@ -0,0 +1,7 @@
|
|||||||
|
# Build
|
||||||
|
target/
|
||||||
|
|
||||||
|
# IDE / OS
|
||||||
|
.DS_Store
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
+3
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -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 "$@"
|
||||||
@@ -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"
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user