diff --git a/DocumentOperator.API/Controllers/ZugferdController.cs b/DocumentOperator.API/Controllers/ZugferdController.cs
new file mode 100644
index 0000000..a5761a3
--- /dev/null
+++ b/DocumentOperator.API/Controllers/ZugferdController.cs
@@ -0,0 +1,182 @@
+using DocumentOperator.Application.Common.DTOs;
+using DocumentOperator.Application.ExtractZugferd;
+using DocumentOperator.Application.HasZugferd.Queries;
+using DocumentOperator.Domain.Common.Exceptions;
+using MediatR;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+
+namespace DocumentOperator.API.Controllers;
+
+///
+/// Controller for ZUGFeRD operations (detection, extraction)
+///
+[ApiController]
+[Route("api/pdf/zugferd")]
+[Produces("application/json")]
+public class ZugferdController(IMediator mediator) : ControllerBase
+{
+ ///
+ /// Checks if a PDF contains ZUGFeRD XML attachment.
+ /// Supports multipart/form-data file upload.
+ ///
+ /// The PDF file to check for ZUGFeRD
+ /// Cancellation token
+ /// ZUGFeRD check result with metadata
+ /// PDF successfully checked - returns ZUGFeRD status
+ /// Invalid input (file missing, not a PDF, or corrupted)
+ /// Internal server error during PDF processing
+ [HttpPost("has-zugferd")]
+ [Consumes("multipart/form-data")]
+ [ProducesResponseType(typeof(ZugferdCheckResult), StatusCodes.Status200OK)]
+ [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)]
+ public async Task HasZugferdFromFile(
+ IFormFile file,
+ CancellationToken cancellationToken)
+ {
+ // Use IFormFile stream directly (no intermediate byte[] conversion)
+ using var pdfStream = file.OpenReadStream();
+
+ // Send query to MediatR (ValidationBehavior runs automatically)
+ var query = new HasZugferdQuery { PdfStream = pdfStream };
+ var result = await mediator.Send(query, cancellationToken);
+
+ return Ok(result);
+ }
+
+ ///
+ /// Checks if a PDF contains ZUGFeRD XML attachment.
+ /// Supports Base64-encoded PDF via JSON payload.
+ ///
+ /// Request containing Base64-encoded PDF
+ /// Cancellation token
+ /// ZUGFeRD check result with metadata
+ /// PDF successfully checked - returns ZUGFeRD status
+ /// Invalid input (Base64 format error, not a PDF, or corrupted)
+ /// Internal server error during PDF processing
+ [HttpPost("has-zugferd")]
+ [Consumes("application/json")]
+ [ProducesResponseType(typeof(ZugferdCheckResult), StatusCodes.Status200OK)]
+ [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)]
+ public async Task HasZugferdFromBase64(
+ [FromBody] HasZugferdRequest request,
+ CancellationToken cancellationToken)
+ {
+ // Convert Base64 to stream (wrap in try-catch to throw BadRequestException)
+ byte[] pdfBytes;
+ try
+ {
+ pdfBytes = Convert.FromBase64String(request.Base64Pdf);
+ }
+ catch (FormatException ex)
+ {
+ throw new BadRequestException("Invalid Base64 format: " + ex.Message);
+ }
+
+ using var pdfStream = new MemoryStream(pdfBytes);
+
+ // Send query to MediatR (ValidationBehavior runs automatically)
+ var query = new HasZugferdQuery { PdfStream = pdfStream };
+ var result = await mediator.Send(query, cancellationToken);
+
+ return Ok(result);
+ }
+
+ ///
+ /// Extracts ZUGFeRD XML from a PDF document.
+ /// Supports multipart/form-data file upload.
+ ///
+ /// The PDF file to extract ZUGFeRD from
+ /// Cancellation token
+ /// ZUGFeRD XML content and metadata
+ /// ZUGFeRD XML extracted successfully
+ /// Invalid input (file missing, not a PDF, or corrupted)
+ /// PDF contains no ZUGFeRD XML
+ /// Internal server error during PDF processing
+ [HttpPost("extract")]
+ [Consumes("multipart/form-data")]
+ [ProducesResponseType(typeof(ZugferdExtractionResult), StatusCodes.Status200OK)]
+ [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
+ [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)]
+ public async Task ExtractZugferdFromFile(
+ IFormFile file,
+ CancellationToken cancellationToken)
+ {
+ // Use IFormFile stream directly (no intermediate byte[] conversion)
+ using var pdfStream = file.OpenReadStream();
+
+ // Send command to MediatR
+ var command = new ExtractZugferdCommand { PdfStream = pdfStream };
+ var result = await mediator.Send(command, cancellationToken);
+
+ return Ok(result);
+ }
+
+ ///
+ /// Extracts ZUGFeRD XML from a PDF document.
+ /// Supports Base64-encoded PDF via JSON payload.
+ ///
+ /// Request containing Base64-encoded PDF
+ /// Cancellation token
+ /// ZUGFeRD XML content and metadata
+ /// ZUGFeRD XML extracted successfully
+ /// Invalid input (Base64 format error, not a PDF, or corrupted)
+ /// PDF contains no ZUGFeRD XML
+ /// Internal server error during PDF processing
+ [HttpPost("extract")]
+ [Consumes("application/json")]
+ [ProducesResponseType(typeof(ZugferdExtractionResult), StatusCodes.Status200OK)]
+ [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
+ [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)]
+ public async Task ExtractZugferdFromBase64(
+ [FromBody] ExtractZugferdRequest request,
+ CancellationToken cancellationToken)
+ {
+ // Convert Base64 to stream (wrap in try-catch to throw BadRequestException)
+ byte[] pdfBytes;
+ try
+ {
+ pdfBytes = Convert.FromBase64String(request.Base64Pdf);
+ }
+ catch (FormatException ex)
+ {
+ throw new BadRequestException("Invalid Base64 format: " + ex.Message);
+ }
+
+ using var pdfStream = new MemoryStream(pdfBytes);
+
+ // Send command to MediatR
+ var command = new ExtractZugferdCommand { PdfStream = pdfStream };
+ var result = await mediator.Send(command, cancellationToken);
+
+ return Ok(result);
+ }
+}
+
+///
+/// Request DTO for Base64-encoded PDF ZUGFeRD check
+///
+public record HasZugferdRequest
+{
+ ///
+ /// PDF document encoded as Base64 string
+ ///
+ /// JVBERi0xLjQKJeLjz9MKMSAwIG9iago8PC9UeXBlL0NhdGFsb2c...
+ public string Base64Pdf { get; init; } = string.Empty;
+}
+
+///
+/// Request DTO for Base64-encoded PDF ZUGFeRD extraction
+///
+public record ExtractZugferdRequest
+{
+ ///
+ /// PDF document encoded as Base64 string
+ ///
+ /// JVBERi0xLjQKJeLjz9MKMSAwIG9iago8PC9UeXBlL0NhdGFsb2c...
+ public required string Base64Pdf { get; init; }
+}
diff --git a/DocumentOperator.API/Program.cs b/DocumentOperator.API/Program.cs
index 28de095..204d052 100644
--- a/DocumentOperator.API/Program.cs
+++ b/DocumentOperator.API/Program.cs
@@ -5,6 +5,7 @@ using Serilog.Ui.Web.Extensions;
using Scalar.AspNetCore;
using DocumentOperator.Infrastructure.Configuration;
using DocumentOperator.Application;
+using DocumentOperator.Application.Common.Configuration;
using DocumentOperator.Infrastructure;
using DocumentOperator.API.Middleware;
using DocumentOperator.API.Configuration;
@@ -32,6 +33,9 @@ try
builder.Services.Configure(
builder.Configuration.GetSection(DocumentOperatorSettings.SectionName));
+ builder.Services.Configure(
+ builder.Configuration.GetSection("ZugferdSettings"));
+
builder.Services.Configure(
builder.Configuration.GetSection(RedisSettings.SectionName));
diff --git a/DocumentOperator.API/appsettings.json b/DocumentOperator.API/appsettings.json
index 76798eb..8d6ba3d 100644
--- a/DocumentOperator.API/appsettings.json
+++ b/DocumentOperator.API/appsettings.json
@@ -61,6 +61,22 @@
"EnableDetailedLogging": true
},
+ "ZugferdSettings": {
+ "ZugferdFileNames": [
+ "factur-x.xml",
+ "zugferd-invoice.xml",
+ "ZUGFeRD-invoice.xml",
+ "xrechnung.xml",
+ "XRechnung.xml"
+ ],
+ "ZugferdFileNamePatterns": [
+ "factur",
+ "zugferd",
+ "xrechnung",
+ "peppol"
+ ]
+ },
+
"RedisSettings": {
"ConnectionString": "localhost:6379",
"InstanceName": "DocumentOperator:",
diff --git a/DocumentOperator.Application/Common/Configuration/ZugferdSettings.cs b/DocumentOperator.Application/Common/Configuration/ZugferdSettings.cs
new file mode 100644
index 0000000..1207eaf
--- /dev/null
+++ b/DocumentOperator.Application/Common/Configuration/ZugferdSettings.cs
@@ -0,0 +1,29 @@
+namespace DocumentOperator.Application.Common.Configuration;
+
+///
+/// Configuration settings for ZUGFeRD/Factur-X/XRechnung detection
+///
+public class ZugferdSettings
+{
+ ///
+ /// Exact ZUGFeRD/Factur-X/XRechnung file names to check (case-insensitive)
+ ///
+ public List ZugferdFileNames { get; set; } = new()
+ {
+ "factur-x.xml",
+ "zugferd-invoice.xml",
+ "ZUGFeRD-invoice.xml",
+ "xrechnung.xml"
+ };
+
+ ///
+ /// Partial filename patterns for ZUGFeRD detection (case-insensitive)
+ ///
+ public List ZugferdFileNamePatterns { get; set; } = new()
+ {
+ "factur",
+ "zugferd",
+ "xrechnung",
+ "peppol"
+ };
+}
diff --git a/DocumentOperator.Application/Common/DTOs/ZugferdCheckResult.cs b/DocumentOperator.Application/Common/DTOs/ZugferdCheckResult.cs
new file mode 100644
index 0000000..b5d3a98
--- /dev/null
+++ b/DocumentOperator.Application/Common/DTOs/ZugferdCheckResult.cs
@@ -0,0 +1,27 @@
+namespace DocumentOperator.Application.Common.DTOs;
+
+///
+/// DTO for ZUGFeRD check result
+///
+public record ZugferdCheckResult
+{
+ ///
+ /// Indicates whether the PDF contains ZUGFeRD XML attachment
+ ///
+ public bool HasZugferd { get; init; }
+
+ ///
+ /// ZUGFeRD XML file name (e.g., "factur-x.xml")
+ ///
+ public string? ZugferdFileName { get; init; }
+
+ ///
+ /// ZUGFeRD XML file size in bytes
+ ///
+ public long? ZugferdFileSize { get; init; }
+
+ ///
+ /// MIME type of the ZUGFeRD XML file
+ ///
+ public string? ZugferdMimeType { get; init; }
+}
diff --git a/DocumentOperator.Application/ExtractZugferd/ExtractZugferdCommand.cs b/DocumentOperator.Application/ExtractZugferd/ExtractZugferdCommand.cs
new file mode 100644
index 0000000..dc85f0a
--- /dev/null
+++ b/DocumentOperator.Application/ExtractZugferd/ExtractZugferdCommand.cs
@@ -0,0 +1,113 @@
+using DocumentOperator.Application.Common.Configuration;
+using DocumentOperator.Application.Common.Interfaces;
+using DocumentOperator.Domain.Common.Exceptions;
+using FluentValidation;
+using MediatR;
+using Microsoft.Extensions.Options;
+
+namespace DocumentOperator.Application.ExtractZugferd;
+
+///
+/// Command to extract ZUGFeRD XML from a PDF document
+///
+public record ExtractZugferdCommand : IRequest
+{
+ ///
+ /// PDF document stream. Must be positioned at the beginning (Position = 0).
+ ///
+ public required Stream PdfStream { get; init; }
+}
+
+///
+/// Handler for ExtractZugferdCommand.
+/// Extracts ZUGFeRD XML from PDF and returns XML content
+///
+public class ExtractZugferdCommandHandler(
+ IPdfProcessor pdfProcessor,
+ IOptions settings)
+ : IRequestHandler
+{
+ private readonly ZugferdSettings _settings = settings.Value;
+
+ public async Task Handle(ExtractZugferdCommand request, CancellationToken cancellationToken)
+ {
+ // Get all attachments
+ var attachmentInfo = await pdfProcessor.CheckAttachmentsAsync(request.PdfStream);
+
+ // Find ZUGFeRD XML file using configured names and patterns
+ var zugferdAttachment = attachmentInfo.Attachments.FirstOrDefault(a =>
+ _settings.ZugferdFileNames.Any(name =>
+ a.FileName.Equals(name, StringComparison.OrdinalIgnoreCase)) ||
+ _settings.ZugferdFileNamePatterns.Any(pattern =>
+ a.FileName.Contains(pattern, StringComparison.OrdinalIgnoreCase)));
+
+ if (zugferdAttachment == null)
+ {
+ throw new NotFoundException("ZUGFeRD XML not found in PDF attachments");
+ }
+
+ // Reset stream position for extraction
+ request.PdfStream.Position = 0;
+
+ // Extract all attachments as ZIP
+ byte[] zipBytes = await pdfProcessor.ExtractAttachmentsAsync(request.PdfStream);
+
+ // Find ZUGFeRD XML in ZIP
+ using var zipStream = new MemoryStream(zipBytes);
+ using var zipArchive = new System.IO.Compression.ZipArchive(zipStream, System.IO.Compression.ZipArchiveMode.Read);
+
+ var zugferdEntry = zipArchive.Entries.FirstOrDefault(e =>
+ e.Name.Equals(zugferdAttachment.FileName, StringComparison.OrdinalIgnoreCase));
+
+ if (zugferdEntry == null)
+ {
+ throw new NotFoundException($"ZUGFeRD XML '{zugferdAttachment.FileName}' not found in extracted attachments");
+ }
+
+ // Read XML content
+ using var entryStream = zugferdEntry.Open();
+ using var reader = new StreamReader(entryStream);
+ string xmlContent = await reader.ReadToEndAsync(cancellationToken);
+
+ return new ZugferdExtractionResult
+ {
+ FileName = zugferdAttachment.FileName,
+ XmlContent = xmlContent,
+ FileSize = zugferdAttachment.SizeBytes
+ };
+ }
+}
+
+///
+/// Validator for ExtractZugferdCommand.
+///
+public class ExtractZugferdCommandValidator : AbstractValidator
+{
+ public ExtractZugferdCommandValidator()
+ {
+ RuleFor(x => x.PdfStream)
+ .NotNull()
+ .WithMessage("PDF stream is required");
+ }
+}
+
+///
+/// Result DTO for ZUGFeRD extraction
+///
+public record ZugferdExtractionResult
+{
+ ///
+ /// ZUGFeRD XML file name
+ ///
+ public required string FileName { get; init; }
+
+ ///
+ /// ZUGFeRD XML content as string
+ ///
+ public required string XmlContent { get; init; }
+
+ ///
+ /// File size in bytes
+ ///
+ public long FileSize { get; init; }
+}
diff --git a/DocumentOperator.Application/HasZugferd/Queries/HasZugferdQuery.cs b/DocumentOperator.Application/HasZugferd/Queries/HasZugferdQuery.cs
new file mode 100644
index 0000000..c7c1c31
--- /dev/null
+++ b/DocumentOperator.Application/HasZugferd/Queries/HasZugferdQuery.cs
@@ -0,0 +1,62 @@
+using DocumentOperator.Application.Common.Configuration;
+using DocumentOperator.Application.Common.DTOs;
+using DocumentOperator.Application.Common.Interfaces;
+using MediatR;
+using Microsoft.Extensions.Options;
+
+namespace DocumentOperator.Application.HasZugferd.Queries;
+
+///
+/// Query for checking if PDF contains ZUGFeRD XML attachment
+///
+public record HasZugferdQuery : IRequest
+{
+ ///
+ /// PDF as stream (caller is responsible for disposal)
+ ///
+ public required Stream PdfStream { get; init; }
+}
+
+///
+/// Handler for HasZugferdQuery
+/// Checks if PDF contains ZUGFeRD/Factur-X XML attachment
+///
+public class HasZugferdQueryHandler(
+ IPdfProcessor pdfProcessor,
+ IOptions settings)
+ : IRequestHandler
+{
+ private readonly ZugferdSettings _settings = settings.Value;
+
+ ///
+ /// Checks if PDF contains ZUGFeRD XML and returns metadata
+ ///
+ public async Task Handle(HasZugferdQuery request, CancellationToken cancellationToken)
+ {
+ // Get all attachments
+ var attachmentInfo = await pdfProcessor.CheckAttachmentsAsync(request.PdfStream);
+
+ // Check for ZUGFeRD/Factur-X XML files using configured names and patterns
+ var zugferdAttachment = attachmentInfo.Attachments.FirstOrDefault(a =>
+ _settings.ZugferdFileNames.Any(name =>
+ a.FileName.Equals(name, StringComparison.OrdinalIgnoreCase)) ||
+ _settings.ZugferdFileNamePatterns.Any(pattern =>
+ a.FileName.Contains(pattern, StringComparison.OrdinalIgnoreCase)));
+
+ if (zugferdAttachment != null)
+ {
+ return new ZugferdCheckResult
+ {
+ HasZugferd = true,
+ ZugferdFileName = zugferdAttachment.FileName,
+ ZugferdFileSize = zugferdAttachment.SizeBytes,
+ ZugferdMimeType = zugferdAttachment.MimeType
+ };
+ }
+
+ return new ZugferdCheckResult
+ {
+ HasZugferd = false
+ };
+ }
+}
diff --git a/DocumentOperator.Application/HasZugferd/Queries/HasZugferdQueryValidator.cs b/DocumentOperator.Application/HasZugferd/Queries/HasZugferdQueryValidator.cs
new file mode 100644
index 0000000..04ee98b
--- /dev/null
+++ b/DocumentOperator.Application/HasZugferd/Queries/HasZugferdQueryValidator.cs
@@ -0,0 +1,16 @@
+using FluentValidation;
+
+namespace DocumentOperator.Application.HasZugferd.Queries;
+
+///
+/// Validator for HasZugferdQuery
+///
+public class HasZugferdQueryValidator : AbstractValidator
+{
+ public HasZugferdQueryValidator()
+ {
+ RuleFor(x => x.PdfStream)
+ .NotNull()
+ .WithMessage("PDF stream is required");
+ }
+}
diff --git a/DocumentOperator.Tests/TestData/Pdfs/ZUGFeRD-Example.pdf b/DocumentOperator.Tests/TestData/Pdfs/ZUGFeRD-Example.pdf
new file mode 100644
index 0000000..09546b8
Binary files /dev/null and b/DocumentOperator.Tests/TestData/Pdfs/ZUGFeRD-Example.pdf differ