diff --git a/DocumentOperator.Application/Common/DTOs/ExtractSwissQrCodeRequest.cs b/DocumentOperator.Application/Common/DTOs/ExtractSwissQrCodeRequest.cs new file mode 100644 index 0000000..f84b639 --- /dev/null +++ b/DocumentOperator.Application/Common/DTOs/ExtractSwissQrCodeRequest.cs @@ -0,0 +1,12 @@ +namespace DocumentOperator.Application.Common.DTOs; + +/// +/// Request to extract Swiss QR Code from a PDF document. +/// The QR code must be located on the last page of the document. +/// +/// Array of reference strings to pass through in the response +/// PDF document encoded as Base64 string +public record ExtractSwissQrCodeRequest( + IReadOnlyList References, + string Base64Pdf +); diff --git a/DocumentOperator.Application/Common/DTOs/ExtractSwissQrCodeResponse.cs b/DocumentOperator.Application/Common/DTOs/ExtractSwissQrCodeResponse.cs new file mode 100644 index 0000000..2ea3ad4 --- /dev/null +++ b/DocumentOperator.Application/Common/DTOs/ExtractSwissQrCodeResponse.cs @@ -0,0 +1,46 @@ +namespace DocumentOperator.Application.Common.DTOs; + +/// +/// Response containing extracted Swiss QR Code data and passed-through references. +/// +/// Reference strings passed through from the request +/// Parsed Swiss QR Code data from the last page of the PDF +public record ExtractSwissQrCodeResponse( + IReadOnlyList References, + SwissQrCodeDataDto QrCodeData +); + +/// +/// Swiss QR Code data according to Swiss QR Bill Standard 2.0 +/// +public record SwissQrCodeDataDto( + string QrType, + string Version, + string CodingType, + string Iban, + AddressDataDto Creditor, + AddressDataDto? UltimateCreditor, + decimal? Amount, + string Currency, + AddressDataDto? UltimateDebtor, + string ReferenceType, + string? Reference, + string? UnstructuredMessage, + string? BillInformation, + IReadOnlyList? AlternativeProcedureParameters +); + +/// +/// Address data (creditor or debtor) in Swiss QR Code +/// +public record AddressDataDto( + string AddressType, + string Name, + string? Street, + string? BuildingNumber, + string? AddressLine1, + string? AddressLine2, + string PostalCode, + string City, + string Country +); diff --git a/DocumentOperator.Application/Features/Documents/ExtractSwissQrCode/ExtractSwissQrCodeHandler.cs b/DocumentOperator.Application/Features/Documents/ExtractSwissQrCode/ExtractSwissQrCodeHandler.cs new file mode 100644 index 0000000..54e6d10 --- /dev/null +++ b/DocumentOperator.Application/Features/Documents/ExtractSwissQrCode/ExtractSwissQrCodeHandler.cs @@ -0,0 +1,33 @@ +using DocumentOperator.Application.Common.Interfaces; +using MediatR; + +namespace DocumentOperator.Application.Features.Documents.ExtractSwissQrCode; + +/// +/// Handles extraction of Swiss QR Code from PDF documents. +/// Uses ISwissQrCodeProcessor to extract QR code from the last page and parse it. +/// +public sealed class ExtractSwissQrCodeHandler : IRequestHandler +{ + private readonly ISwissQrCodeProcessor _qrCodeProcessor; + + public ExtractSwissQrCodeHandler(ISwissQrCodeProcessor qrCodeProcessor) + { + _qrCodeProcessor = qrCodeProcessor; + } + + public async Task Handle(ExtractSwissQrCodeQuery request, CancellationToken cancellationToken) + { + // Convert Base64 string to byte array + byte[] pdfBytes = request.PdfContent.ToByteArray(); + + // Extract and parse Swiss QR Code from last page + var qrCodeData = await _qrCodeProcessor.ExtractSwissQrCodeAsync(pdfBytes, cancellationToken); + + // Return references (passed through) + QR code data + return new ExtractSwissQrCodeResult( + References: request.References, + QrCodeData: qrCodeData + ); + } +} diff --git a/DocumentOperator.Application/Features/Documents/ExtractSwissQrCode/ExtractSwissQrCodeQuery.cs b/DocumentOperator.Application/Features/Documents/ExtractSwissQrCode/ExtractSwissQrCodeQuery.cs new file mode 100644 index 0000000..7259588 --- /dev/null +++ b/DocumentOperator.Application/Features/Documents/ExtractSwissQrCode/ExtractSwissQrCodeQuery.cs @@ -0,0 +1,26 @@ +using DocumentOperator.Domain.Models.ValueObjects; +using DocumentOperator.Domain.ValueObjects; +using MediatR; + +namespace DocumentOperator.Application.Features.Documents.ExtractSwissQrCode; + +/// +/// Query to extract Swiss QR Code data from a PDF document. +/// Returns references (passed through) and parsed QR code data. +/// +/// Array of reference strings to pass through in the response +/// PDF document content as Base64 string +public record ExtractSwissQrCodeQuery( + IReadOnlyList References, + Base64String PdfContent +) : IRequest; + +/// +/// Result containing passed-through references and extracted Swiss QR Code data +/// +/// Reference strings passed through from request +/// Parsed Swiss QR Code data from the last page of the PDF +public record ExtractSwissQrCodeResult( + IReadOnlyList References, + SwissQrCodeData QrCodeData +); diff --git a/DocumentOperator.Application/Features/Documents/ExtractSwissQrCode/ExtractSwissQrCodeValidator.cs b/DocumentOperator.Application/Features/Documents/ExtractSwissQrCode/ExtractSwissQrCodeValidator.cs new file mode 100644 index 0000000..d7c48f2 --- /dev/null +++ b/DocumentOperator.Application/Features/Documents/ExtractSwissQrCode/ExtractSwissQrCodeValidator.cs @@ -0,0 +1,21 @@ +using FluentValidation; + +namespace DocumentOperator.Application.Features.Documents.ExtractSwissQrCode; + +/// +/// Validates ExtractSwissQrCodeQuery before handler execution. +/// Ensures references array and PDF content are provided. +/// +public sealed class ExtractSwissQrCodeValidator : AbstractValidator +{ + public ExtractSwissQrCodeValidator() + { + RuleFor(x => x.References) + .NotNull() + .WithMessage("References array is required."); + + RuleFor(x => x.PdfContent) + .NotNull() + .WithMessage("PDF content is required."); + } +} diff --git a/DocumentOperator.Tests/Unit/Application/Features/ExtractSwissQrCode/ExtractSwissQrCodeHandlerTests.cs b/DocumentOperator.Tests/Unit/Application/Features/ExtractSwissQrCode/ExtractSwissQrCodeHandlerTests.cs new file mode 100644 index 0000000..d413167 --- /dev/null +++ b/DocumentOperator.Tests/Unit/Application/Features/ExtractSwissQrCode/ExtractSwissQrCodeHandlerTests.cs @@ -0,0 +1,95 @@ +using DocumentOperator.Application.Common.Interfaces; +using DocumentOperator.Application.Features.Documents.ExtractSwissQrCode; +using DocumentOperator.Domain.Models.ValueObjects; +using DocumentOperator.Domain.ValueObjects; +using FluentAssertions; +using Moq; +using Xunit; + +namespace DocumentOperator.Tests.Unit.Application.Features.ExtractSwissQrCode; + +public sealed class ExtractSwissQrCodeHandlerTests +{ + private readonly Mock _mockQrCodeProcessor; + private readonly ExtractSwissQrCodeHandler _handler; + + public ExtractSwissQrCodeHandlerTests() + { + _mockQrCodeProcessor = new Mock(); + _handler = new ExtractSwissQrCodeHandler(_mockQrCodeProcessor.Object); + } + + [Fact] + public async Task Handle_ValidRequest_ReturnsQrCodeDataAndReferences() + { + // Arrange + var references = new List { "REF-001", "REF-002" }; + var pdfBase64 = "JVBERi0xLjQKJeLjz9MKMSAwIG9iago8PAovVHlwZSAvQ2F0YWxvZwovUGFnZXMgMiAwIFIKPj4KZW5kb2JqCjIgMCBvYmoKPDwKL1R5cGUgL1BhZ2VzCi9LaWRzIFszIDAgUl0KL0NvdW50IDEKPj4KZW5kb2JqCjMgMCBvYmoKPDwKL1R5cGUgL1BhZ2UKL1BhcmVudCAyIDAgUgovTWVkaWFCb3ggWzAgMCA2MTIgNzkyXQovQ29udGVudHMgNCAwIFIKPj4KZW5kb2JqCjQgMCBvYmoKPDwKL0xlbmd0aCAzMgo+PgpzdHJlYW0KQlQKL0YxIDEyIFRmCjEwMCA3MDAgVGQKKEhlbGxvKSBUagpFVAplbmRzdHJlYW0KZW5kb2JqCnhyZWYKMCA1CjAwMDAwMDAwMDAgNjU1MzUgZiAKMDAwMDAwMDAxNSAwMDAwMCBuIAowMDAwMDAwMDY0IDAwMDAwIG4gCjAwMDAwMDAxMjEgMDAwMDAgbiAKMDAwMDAwMDIxMyAwMDAwMCBuIAp0cmFpbGVyCjw8Ci9TaXplIDUKL1Jvb3QgMSAwIFIKPj4Kc3RhcnR4cmVmCjI5NAolJUVPRgo="; + var base64String = Base64String.Create(pdfBase64); + var query = new ExtractSwissQrCodeQuery(references, base64String); + + var expectedQrCodeData = new SwissQrCodeData + { + QrType = "SPC", + Version = "0200", + CodingType = "1", + Iban = "CH4431999123000889012", + Creditor = new AddressData + { + AddressType = "S", + Name = "Robert Schneider AG", + Street = "Rue du Lac", + BuildingNumber = "1268", + PostalCode = "2501", + City = "Biel", + Country = "CH" + }, + UltimateCreditor = null, + Amount = 1949.75m, + Currency = "CHF", + UltimateDebtor = null, + ReferenceType = "QRR", + Reference = "210000000003139471430009017", + UnstructuredMessage = "Order from 15.01.2025", + BillInformation = null, + AlternativeProcedureParameters = null + }; + + _mockQrCodeProcessor + .Setup(x => x.ExtractSwissQrCodeAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(expectedQrCodeData); + + // Act + var result = await _handler.Handle(query, CancellationToken.None); + + // Assert + result.Should().NotBeNull(); + result.References.Should().BeEquivalentTo(references); + result.QrCodeData.Should().BeEquivalentTo(expectedQrCodeData); + + _mockQrCodeProcessor.Verify( + x => x.ExtractSwissQrCodeAsync(It.IsAny(), It.IsAny()), + Times.Once); + } + + [Fact] + public async Task Handle_QrCodeProcessorThrowsException_PropagatesException() + { + // Arrange + var references = new List { "REF-001" }; + var pdfBase64 = "JVBERi0xLjQKJeLjz9MKMSAwIG9iago8PAovVHlwZSAvQ2F0YWxvZwovUGFnZXMgMiAwIFIKPj4KZW5kb2JqCjIgMCBvYmoKPDwKL1R5cGUgL1BhZ2VzCi9LaWRzIFszIDAgUl0KL0NvdW50IDEKPj4KZW5kb2JqCjMgMCBvYmoKPDwKL1R5cGUgL1BhZ2UKL1BhcmVudCAyIDAgUgovTWVkaWFCb3ggWzAgMCA2MTIgNzkyXQovQ29udGVudHMgNCAwIFIKPj4KZW5kb2JqCjQgMCBvYmoKPDwKL0xlbmd0aCAzMgo+PgpzdHJlYW0KQlQKL0YxIDEyIFRmCjEwMCA3MDAgVGQKKEhlbGxvKSBUagpFVAplbmRzdHJlYW0KZW5kb2JqCnhyZWYKMCA1CjAwMDAwMDAwMDAgNjU1MzUgZiAKMDAwMDAwMDAxNSAwMDAwMCBuIAowMDAwMDAwMDY0IDAwMDAwIG4gCjAwMDAwMDAxMjEgMDAwMDAgbiAKMDAwMDAwMDIxMyAwMDAwMCBuIAp0cmFpbGVyCjw8Ci9TaXplIDUKL1Jvb3QgMSAwIFIKPj4Kc3RhcnR4cmVmCjI5NAolJUVPRgo="; + var base64String = Base64String.Create(pdfBase64); + var query = new ExtractSwissQrCodeQuery(references, base64String); + + _mockQrCodeProcessor + .Setup(x => x.ExtractSwissQrCodeAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync(new InvalidOperationException("QR Code processing failed")); + + // Act + Func act = async () => await _handler.Handle(query, CancellationToken.None); + + // Assert + await act.Should().ThrowAsync() + .WithMessage("QR Code processing failed"); + } +}