diff --git a/DocumentOperator.API/PHASENPLAN.md b/DocumentOperator.API/PHASENPLAN.md index 1ba5754..8212c3d 100644 --- a/DocumentOperator.API/PHASENPLAN.md +++ b/DocumentOperator.API/PHASENPLAN.md @@ -1,6 +1,6 @@ # ?? DocumentOperator - Phasenplan (Feature-Driven Development) -> **Stand:** 17.01.2025 | **Aktuell:** Feature 1 - ValidatePDF ? ABGESCHLOSSEN! | **Projektdauer:** 6 Wochen +> **Stand:** 17.01.2025 | **Aktuell:** Feature 2 - ExtractSwissQrCode ? IN PROGRESS | **Projektdauer:** 6 Wochen --- @@ -9,10 +9,11 @@ | Woche | Features / Concerns | Status | Fortschritt | |-------|---------------------|--------|-------------| | **W1** | Feature 1: ValidatePDF | ? Abgeschlossen | 100% (Foundation + Application + API + Swagger fertig) | -| **W2** | Feature 2: ExtractAttachments | ? Geplant | 0% | -| **W2** | Feature 3: ApplyStamp | ? Geplant | 0% | -| **W3** | Feature 4: EmbedCertificate | ? Geplant | 0% | -| **W3** | Feature 5: ConcatenatePDFs (Async) | ? Geplant | 0% | +| **W1-W2** | Feature 2: ExtractSwissQrCode | ? In Progress | 0% (Gerade gestartet!) | +| **W2** | Feature 3: ExtractAttachments | ? Geplant | 0% | +| **W2** | Feature 4: ApplyStamp | ? Geplant | 0% | +| **W3** | Feature 5: EmbedCertificate | ? Geplant | 0% | +| **W3** | Feature 6: ConcatenatePDFs (Async) | ? Geplant | 0% | | **W4** | Multi-Tenancy (X-API-Key Header) | ? Geplant | 0% | | **W5** | Health Checks + Polly + Logging | ? Geplant | 0% | | **W6** | Production Deployment | ? Geplant | 0% | @@ -169,19 +170,52 @@ --- -### WOCHE 2 - Features 2 & 3 | ? Geplant - 0% +### WOCHE 1-2 - Feature 2: ExtractSwissQrCode | ? In Progress - 0% -#### Feature 2: ExtractAttachments (Synchron) +**Dauer:** ~1-2 Tage +**Status:** ? In Progress (gerade gestartet!) + +**Endpoint:** POST /api/v1/documents/extract-swiss-qr-code + +**Was wird gebaut:** +- Client sendet Referenzen (Array) + PDF (Base64) +- API extrahiert Swiss QR Code von **letzter Seite** +- API gibt Referenzen + alle QR Code Felder zurück (Swiss QR Bill Standard 2.0) + +**Technologie:** +- **DevExpress PDF Document API** (PDF-Zugriff, letzte Seite) +- **Codecrete.SwissQRBill.Generator** (Swiss QR Code Parsing - Standard 2.0) + +**Steps:** +- ?? Step 2.1: Domain Layer (SwissQrCodeData Value Object) +- ?? Step 2.2: Infrastructure Layer (IQrCodeProcessor + DevExpressSwissQrCodeProcessor + Codecrete Integration) +- ?? Step 2.3: Application Layer (ExtractSwissQrCodeQuery + Handler + Validator) +- ?? Step 2.4: API Layer (Endpoint + Integration Tests) +- ?? Step 2.5: Swagger Dokumentation + +**Akzeptanzkriterien:** +- ? QR Code wird von letzter Seite extrahiert +- ? Alle Swiss QR Bill Felder werden geparst (Standard 2.0) +- ? Referenzen werden durchgeschliffen (Echo) +- ? Fehler wenn kein QR Code gefunden +- ? Swagger-testbar +- ? Tests grün + +--- + +### WOCHE 2 - Features 3 & 4 | ? Geplant - 0% + +#### Feature 3: ExtractAttachments (Synchron) **Dauer:** ~1 Tag **Status:** ? Pending **Endpoint:** POST /api/v1/documents/extract-attachments **Steps:** -- ? Step 2.1: Infrastructure Layer (DevExpressPdfProcessor.ExtractAttachmentsAsync) -- ? Step 2.2: Application Layer (ExtractAttachmentsCommand + Handler + Validator) -- ? Step 2.3: API Layer (Endpoint) -- ? Step 2.4: Swagger Dokumentation +- ? Step 3.1: Infrastructure Layer (DevExpressPdfProcessor.ExtractAttachmentsAsync) +- ? Step 3.2: Application Layer (ExtractAttachmentsCommand + Handler + Validator) +- ? Step 3.3: API Layer (Endpoint) +- ? Step 3.4: Swagger Dokumentation **Akzeptanzkriterien:** - ? Endpoint im Swagger testbar @@ -189,17 +223,17 @@ --- -#### Feature 3: ApplyStamp (Synchron) +#### Feature 4: ApplyStamp (Synchron) **Dauer:** ~1 Tag **Status:** ? Pending **Endpoint:** POST /api/v1/documents/apply-stamp **Steps:** -- ? Step 3.1: Infrastructure Layer (DevExpressPdfProcessor.ApplyStampAsync) -- ? Step 3.2: Application Layer (ApplyStampCommand + Handler + Validator) -- ? Step 3.3: API Layer (Endpoint) -- ? Step 3.4: Swagger Dokumentation +- ? Step 4.1: Infrastructure Layer (DevExpressPdfProcessor.ApplyStampAsync) +- ? Step 4.2: Application Layer (ApplyStampCommand + Handler + Validator) +- ? Step 4.3: API Layer (Endpoint) +- ? Step 4.4: Swagger Dokumentation **Akzeptanzkriterien:** - ? Endpoint im Swagger testbar @@ -208,19 +242,19 @@ --- -### WOCHE 3 - Features 4 & 5 | ? Geplant - 0% +### WOCHE 3 - Features 5 & 6 | ? Geplant - 0% -#### Feature 4: EmbedCertificate (Synchron) +#### Feature 5: EmbedCertificate (Synchron) **Dauer:** ~1 Tag **Status:** ? Pending **Endpoint:** POST /api/v1/documents/embed-certificate **Steps:** -- ? Step 4.1: Infrastructure Layer (DevExpressPdfProcessor.EmbedCertificateAsync) -- ? Step 4.2: Application Layer (EmbedCertificateCommand + Handler + Validator) -- ? Step 4.3: API Layer (Endpoint) -- ? Step 4.4: Swagger Dokumentation +- ? Step 5.1: Infrastructure Layer (DevExpressPdfProcessor.EmbedCertificateAsync) +- ? Step 5.2: Application Layer (EmbedCertificateCommand + Handler + Validator) +- ? Step 5.3: API Layer (Endpoint) +- ? Step 5.4: Swagger Dokumentation **Akzeptanzkriterien:** - ? Endpoint im Swagger testbar @@ -229,7 +263,7 @@ --- -#### Feature 5: ConcatenatePDFs (Asynchron) +#### Feature 6: ConcatenatePDFs (Asynchron) **Dauer:** ~2 Tage **Status:** ? Pending @@ -239,10 +273,10 @@ - GET /api/v1/jobs/{jobId}/download (Ergebnis herunterladen) **Steps:** -- ? Step 5.1: Infrastructure Layer (In-Memory Queue + Background Worker) -- ? Step 5.2: Application Layer (SubmitConcatenateJobCommand + GetJobStatusQuery) -- ? Step 5.3: API Layer (Async Endpoints) -- ? Step 5.4: Swagger Dokumentation +- ? Step 6.1: Infrastructure Layer (In-Memory Queue + Background Worker) +- ? Step 6.2: Application Layer (SubmitConcatenateJobCommand + GetJobStatusQuery) +- ? Step 6.3: API Layer (Async Endpoints) +- ? Step 6.4: Swagger Dokumentation **Akzeptanzkriterien:** - ? POST /concatenate gibt JobId zurück @@ -453,16 +487,21 @@ ### Nächstes Feature -**Feature 2: ExtractAttachments** - **NEXT** -1. ? Step 2.1: Infrastructure Layer (DevExpressPdfProcessor.ExtractAttachmentsAsync) -2. ? Step 2.2: Application Layer (ExtractAttachmentsCommand + Handler + Validator + DTOs) -3. ? Step 2.3: API Layer (Endpoint + Integration Tests) -4. ? Step 2.4: Swagger Dokumentation +**Feature 2: ExtractSwissQrCode** - **IN PROGRESS** +1. ?? Step 2.1: Domain Layer (SwissQrCodeData Value Object) +2. ?? Step 2.2: Infrastructure Layer (IQrCodeProcessor + DevExpressSwissQrCodeProcessor + Codecrete Integration) +3. ?? Step 2.3: Application Layer (ExtractSwissQrCodeQuery + Handler + Validator + DTOs) +4. ?? Step 2.4: API Layer (Endpoint + Integration Tests) +5. ?? Step 2.5: Swagger Dokumentation -**Erwarteter Zeitaufwand:** ~1 Tag +**Erwarteter Zeitaufwand:** ~1-2 Tage **Akzeptanzkriterien:** -- ? POST /api/v1/documents/extract-attachments im Swagger testbar +- ? POST /api/v1/documents/extract-swiss-qr-code im Swagger testbar +- ? Swiss QR Code wird von letzter Seite extrahiert +- ? Alle Swiss QR Bill Felder geparst (Standard 2.0) +- ? Referenzen durchgeschliffen +- ? Fehler wenn kein QR Code gefunden - ? Alle Tests grün - ? Clean Architecture eingehalten @@ -483,6 +522,8 @@ | 17.01.2025 | **Feature 1** | ? **KOMPLETT ABGESCHLOSSEN** - ValidatePDF Feature testbar im Swagger UI! | | 17.01.2025 | **Fix: Attachment Detection (Multiple Attachments)** | ? **KORRIGIERT** - ValidatePDF erkennt jetzt auch PDFs mit mehreren Attachments korrekt (globale Suche statt 1000-Zeichen-Limit) - 13/13 Tests grün | | 17.01.2025 | **Fix: Attachment Count (6 Attachments)** | ? **KORRIGIERT** - AttachmentCount wird jetzt korrekt gezählt (objectCount statt objectCount/2). PDFs mit 6 Attachments werden korrekt erkannt - 13/13 Tests grün | +| 17.01.2025 | **PHASENPLAN** | ?? **Feature-Reihenfolge geändert** - Neues Feature 2: ExtractSwissQrCode (Swiss QR Bill Standard 2.0) eingefügt. Alte Features 2-5 werden zu Features 3-6. | +| 17.01.2025 | **Feature 2 - ExtractSwissQrCode** | ?? **GESTARTET** - Swiss QR Code Extraktion von letzter PDF-Seite (DevExpress + Codecrete.SwissQRBill.Generator) | --- diff --git a/DocumentOperator.API/ROADMAP.md b/DocumentOperator.API/ROADMAP.md index a0df384..2cffde6 100644 --- a/DocumentOperator.API/ROADMAP.md +++ b/DocumentOperator.API/ROADMAP.md @@ -45,10 +45,11 @@ | Feature | Type | Status | Swagger Testbar? | |---------|------|--------|------------------| | **1. ValidatePDF** | Synchron | ? Abgeschlossen | ? | -| **2. ExtractAttachments** | Synchron | ? Pending | ? | -| **3. ApplyStamp** | Synchron | ? Pending | ? | -| **4. EmbedCertificate** | Synchron | ? Pending | ? | -| **5. ConcatenatePDFs** | Asynchron | ? Pending | ? | +| **2. ExtractSwissQrCode** | Synchron | ? In Progress | ? | +| **3. ExtractAttachments** | Synchron | ? Pending | ? | +| **4. ApplyStamp** | Synchron | ? Pending | ? | +| **5. EmbedCertificate** | Synchron | ? Pending | ? | +| **6. ConcatenatePDFs** | Asynchron | ? Pending | ? | ### ?? Cross-Cutting Concerns (nach Features 1-4) @@ -278,11 +279,85 @@ Response: { "pageCount": 5, "fileSizeBytes": 1024, "pdfVersion": "1.4", "hasAtta - ? Swagger-Dokumentation vollständig **Nächstes Feature:** -? **Feature 2: ExtractAttachments** +? **Feature 2: ExtractSwissQrCode** --- -## ?? FEATURE 2: ExtractAttachments (Synchron) - **NEXT** +## ?? FEATURE 2: ExtractSwissQrCode (Synchron) - **IN PROGRESS** + +**Was macht dieses Feature?** +- Client sendet PDF als Base64 + Referenzen (Array von Strings) +- API extrahiert Swiss QR Code von der **letzten Seite** des PDFs +- API gibt Referenzen + alle QR Code Felder zurück (Swiss QR Bill Standard 2.0) + +**Endpoint:** +``` +POST /api/v1/documents/extract-swiss-qr-code +Request: +{ + "references": ["REF-001", "REF-002"], + "base64Pdf": "JVBERi0xLjQK..." +} + +Response: +{ + "references": ["REF-001", "REF-002"], + "qrCodeData": { + "qrType": "SPC", + "version": "0200", + "codingType": "1", + "iban": "CH4431999123000889012", + "creditor": { + "name": "Robert Schneider AG", + "addressType": "S", + "street": "Rue du Lac", + "buildingNumber": "1268", + "postalCode": "2501", + "city": "Biel", + "country": "CH" + }, + "ultimateCreditor": null, + "amount": 1949.75, + "currency": "CHF", + "ultimateDebtor": { + "name": "Pia-Maria Rutschmann-Schnyder", + "addressType": "S", + "street": "Grosse Marktgasse", + "buildingNumber": "28", + "postalCode": "9400", + "city": "Rorschach", + "country": "CH" + }, + "referenceType": "QRR", + "reference": "210000000003139471430009017", + "unstructuredMessage": "Rechnung vom 15.01.2025", + "billInformation": "//S1/01/...", + "alternativeProcedureParameters": ["Name AV1: UV;UltraPay005;12345", "Name AV2: XY;XYService;54321"] + } +} +``` + +**Technologie:** +- **DevExpress PDF Document API** (PDF-Zugriff, letzte Seite, QR Code Image) +- **Codecrete.SwissQRBill.Generator** (Swiss QR Code Parsing - Standard 2.0) + +**Steps:** +- ?? Step 2.1: Domain Layer (SwissQrCodeData Value Object) +- ?? Step 2.2: Infrastructure Layer (IQrCodeProcessor + DevExpressSwissQrCodeProcessor) +- ?? Step 2.3: Application Layer (ExtractSwissQrCodeQuery + Handler + Validator) +- ?? Step 2.4: API Layer (Endpoint + Integration Tests) +- ?? Step 2.5: Swagger Dokumentation + +**Akzeptanzkriterien:** +- ? QR Code wird von letzter Seite extrahiert +- ? Alle Swiss QR Bill Felder werden geparst (Standard 2.0) +- ? Referenzen werden durchgeschliffen (Echo) +- ? Fehler wenn kein QR Code gefunden +- ? Swagger-testbar + +--- + +## ?? FEATURE 3: ExtractAttachments (Synchron) - **PENDING** **Was macht dieses Feature?** - Client sendet PDF als Base64 (JSON) @@ -297,14 +372,14 @@ Response: { "attachments": [{ "name": "invoice.xml", "base64Content": "..." }] } ``` **Steps:** -- ?? Step 2.1: Infrastructure Layer (DevExpressPdfProcessor.ExtractAttachmentsAsync) -- ?? Step 2.2: Application Layer (ExtractAttachmentsCommand + Handler + Validator) -- ?? Step 2.3: API Layer (Endpoint) -- ?? Step 2.4: Swagger Dokumentation +- ?? Step 3.1: Infrastructure Layer (DevExpressPdfProcessor.ExtractAttachmentsAsync) +- ?? Step 3.2: Application Layer (ExtractAttachmentsCommand + Handler + Validator) +- ?? Step 3.3: API Layer (Endpoint) +- ?? Step 3.4: Swagger Dokumentation --- -## ?? FEATURE 3: ApplyStamp (Synchron) - **PENDING** +## ?? FEATURE 4: ApplyStamp (Synchron) - **PENDING** **Was macht dieses Feature?** - Client sendet PDF + Stamp-Konfiguration (Text, Position) @@ -319,14 +394,14 @@ Response: { "base64Pdf": "JVBERi0xLjQK..." } ``` **Steps:** -- ?? Step 3.1: Infrastructure Layer (DevExpressPdfProcessor.ApplyStampAsync) -- ?? Step 3.2: Application Layer (ApplyStampCommand + Handler + Validator) -- ?? Step 3.3: API Layer (Endpoint) -- ?? Step 3.4: Swagger Dokumentation +- ?? Step 4.1: Infrastructure Layer (DevExpressPdfProcessor.ApplyStampAsync) +- ?? Step 4.2: Application Layer (ApplyStampCommand + Handler + Validator) +- ?? Step 4.3: API Layer (Endpoint) +- ?? Step 4.4: Swagger Dokumentation --- -## ?? FEATURE 4: EmbedCertificate (Synchron) - **PENDING** +## ?? FEATURE 5: EmbedCertificate (Synchron) - **PENDING** **Was macht dieses Feature?** - Client sendet PDF + Zertifikat (PFX als Base64) @@ -341,14 +416,14 @@ Response: { "base64Pdf": "JVBERi0xLjQK..." } ``` **Steps:** -- ?? Step 4.1: Infrastructure Layer (DevExpressPdfProcessor.EmbedCertificateAsync) -- ?? Step 4.2: Application Layer (EmbedCertificateCommand + Handler + Validator) -- ?? Step 4.3: API Layer (Endpoint) -- ?? Step 4.4: Swagger Dokumentation +- ?? Step 5.1: Infrastructure Layer (DevExpressPdfProcessor.EmbedCertificateAsync) +- ?? Step 5.2: Application Layer (EmbedCertificateCommand + Handler + Validator) +- ?? Step 5.3: API Layer (Endpoint) +- ?? Step 5.4: Swagger Dokumentation --- -## ?? FEATURE 5: ConcatenatePDFs (Asynchron) - **PENDING** +## ?? FEATURE 6: ConcatenatePDFs (Asynchron) - **PENDING** **Was macht dieses Feature?** - Client sendet mehrere PDFs (Array von Base64) @@ -370,10 +445,10 @@ Response: PDF-Datei (Binary) ``` **Steps:** -- ?? Step 5.1: Infrastructure Layer (In-Memory Queue + Background Worker) -- ?? Step 5.2: Application Layer (SubmitConcatenateJobCommand + GetJobStatusQuery) -- ?? Step 5.3: API Layer (Async Endpoints) -- ?? Step 5.4: Swagger Dokumentation +- ?? Step 6.1: Infrastructure Layer (In-Memory Queue + Background Worker) +- ?? Step 6.2: Application Layer (SubmitConcatenateJobCommand + GetJobStatusQuery) +- ?? Step 6.3: API Layer (Async Endpoints) +- ?? Step 6.4: Swagger Dokumentation --- @@ -995,6 +1070,8 @@ DocumentOperator.Tests/ | 17.01.2025 | **Feature 1** | ? **KOMPLETT ABGESCHLOSSEN** - ValidatePDF Feature testbar im Swagger UI! | | 17.01.2025 | **Fix: Attachment Detection (Multiple Attachments)** | ? **KORRIGIERT** - ValidatePDF erkennt jetzt auch PDFs mit mehreren Attachments korrekt (globale Suche statt 1000-Zeichen-Limit) - 13/13 Tests grün | | 17.01.2025 | **Fix: Attachment Count (6 Attachments)** | ? **KORRIGIERT** - AttachmentCount wird jetzt korrekt gezählt (objectCount statt objectCount/2). PDFs mit 6 Attachments werden korrekt erkannt - 13/13 Tests grün | +| 17.01.2025 | **ROADMAP** | ?? **Feature-Reihenfolge geändert** - Neues Feature 2: ExtractSwissQrCode (Swiss QR Bill Standard 2.0) eingefügt. Alte Features 2-5 werden zu Features 3-6. | +| 17.01.2025 | **Feature 2 - ExtractSwissQrCode** | ?? **GESTARTET** - Swiss QR Code Extraktion von letzter PDF-Seite (DevExpress + Codecrete.SwissQRBill.Generator) | --- diff --git a/DocumentOperator.Application/Common/Interfaces/ISwissQrCodeProcessor.cs b/DocumentOperator.Application/Common/Interfaces/ISwissQrCodeProcessor.cs new file mode 100644 index 0000000..c832fe8 --- /dev/null +++ b/DocumentOperator.Application/Common/Interfaces/ISwissQrCodeProcessor.cs @@ -0,0 +1,24 @@ +using DocumentOperator.Domain.ValueObjects; + +namespace DocumentOperator.Application.Common.Interfaces; + +/// +/// Interface for Swiss QR Code processing operations. +/// Extracts and parses Swiss QR Codes from PDF documents. +/// +public interface ISwissQrCodeProcessor +{ + /// + /// Extracts and parses Swiss QR Code from the last page of a PDF document. + /// + /// PDF document as byte array + /// Cancellation token + /// Parsed Swiss QR Code data + /// + /// Thrown when no Swiss QR Code is found on the last page + /// + /// + /// Thrown when PDF processing fails + /// + Task ExtractSwissQrCodeAsync(byte[] pdfBytes, CancellationToken cancellationToken = default); +} diff --git a/DocumentOperator.Domain/Exceptions/SwissQrCodeNotFoundException.cs b/DocumentOperator.Domain/Exceptions/SwissQrCodeNotFoundException.cs new file mode 100644 index 0000000..b06eb8a --- /dev/null +++ b/DocumentOperator.Domain/Exceptions/SwissQrCodeNotFoundException.cs @@ -0,0 +1,22 @@ +namespace DocumentOperator.Domain.Exceptions; + +/// +/// Exception thrown when a Swiss QR Code cannot be found in a PDF document. +/// +public sealed class SwissQrCodeNotFoundException : Exception +{ + public SwissQrCodeNotFoundException() + : base("No Swiss QR Code found on the last page of the PDF document.") + { + } + + public SwissQrCodeNotFoundException(string message) + : base(message) + { + } + + public SwissQrCodeNotFoundException(string message, Exception innerException) + : base(message, innerException) + { + } +} diff --git a/DocumentOperator.Domain/ValueObjects/SwissQrCodeData.cs b/DocumentOperator.Domain/ValueObjects/SwissQrCodeData.cs new file mode 100644 index 0000000..f61348e --- /dev/null +++ b/DocumentOperator.Domain/ValueObjects/SwissQrCodeData.cs @@ -0,0 +1,129 @@ +namespace DocumentOperator.Domain.ValueObjects; + +/// +/// Represents Swiss QR Code data according to Swiss QR Bill Standard 2.0. +/// Immutable value object containing all fields from a Swiss QR payment part. +/// +public sealed record SwissQrCodeData +{ + /// + /// QR type - always "SPC" for Swiss Payment Code + /// + public required string QrType { get; init; } + + /// + /// Version of the Swiss QR Code standard (e.g., "0200" for version 2.0) + /// + public required string Version { get; init; } + + /// + /// Character set code (always "1" for UTF-8) + /// + public required string CodingType { get; init; } + + /// + /// IBAN of the creditor (payee) + /// + public required string Iban { get; init; } + + /// + /// Creditor (payee) information + /// + public required AddressData Creditor { get; init; } + + /// + /// Ultimate creditor information (optional) + /// + public AddressData? UltimateCreditor { get; init; } + + /// + /// Payment amount (null if not specified) + /// + public decimal? Amount { get; init; } + + /// + /// Currency code (CHF or EUR) + /// + public required string Currency { get; init; } + + /// + /// Ultimate debtor (payer) information (optional) + /// + public AddressData? UltimateDebtor { get; init; } + + /// + /// Reference type (QRR, SCOR, or NON) + /// + public required string ReferenceType { get; init; } + + /// + /// Payment reference (format depends on ReferenceType) + /// + public string? Reference { get; init; } + + /// + /// Unstructured message (max 140 characters) + /// + public string? UnstructuredMessage { get; init; } + + /// + /// Bill information (structured data for automated processing) + /// + public string? BillInformation { get; init; } + + /// + /// Alternative procedure parameters (up to 2 entries) + /// + public IReadOnlyList? AlternativeProcedureParameters { get; init; } +} + +/// +/// Represents address data in Swiss QR Code (creditor or debtor) +/// +public sealed record AddressData +{ + /// + /// Address type: "S" for structured, "K" for combined + /// + public required string AddressType { get; init; } + + /// + /// Name of person or company + /// + public required string Name { get; init; } + + /// + /// Street name (structured address only) + /// + public string? Street { get; init; } + + /// + /// Building number (structured address only) + /// + public string? BuildingNumber { get; init; } + + /// + /// Address line 1 (combined address only) + /// + public string? AddressLine1 { get; init; } + + /// + /// Address line 2 (combined address only) + /// + public string? AddressLine2 { get; init; } + + /// + /// Postal code + /// + public required string PostalCode { get; init; } + + /// + /// City/town name + /// + public required string City { get; init; } + + /// + /// Two-letter country code (ISO 3166-1 alpha-2) + /// + public required string Country { get; init; } +} diff --git a/DocumentOperator.Infrastructure/DependencyInjection.cs b/DocumentOperator.Infrastructure/DependencyInjection.cs index 1bab6a8..e48bb13 100644 --- a/DocumentOperator.Infrastructure/DependencyInjection.cs +++ b/DocumentOperator.Infrastructure/DependencyInjection.cs @@ -1,5 +1,6 @@ using DocumentOperator.Application.Common.Interfaces; using DocumentOperator.Infrastructure.Services.PdfProcessing; +using DocumentOperator.Infrastructure.Services.QrCodeProcessing; using Microsoft.Extensions.DependencyInjection; namespace DocumentOperator.Infrastructure; @@ -17,6 +18,9 @@ public static class DependencyInjection // PDF Processing Service (DevExpress) services.AddScoped(); + // Swiss QR Code Processing Service (DevExpress + Codecrete + ZXing) + services.AddScoped(); + return services; } } diff --git a/DocumentOperator.Infrastructure/DocumentOperator.Infrastructure.csproj b/DocumentOperator.Infrastructure/DocumentOperator.Infrastructure.csproj index a6c9ebc..19a62ac 100644 --- a/DocumentOperator.Infrastructure/DocumentOperator.Infrastructure.csproj +++ b/DocumentOperator.Infrastructure/DocumentOperator.Infrastructure.csproj @@ -7,8 +7,11 @@ + + + diff --git a/DocumentOperator.Infrastructure/Services/QrCodeProcessing/DevExpressSwissQrCodeProcessor.cs b/DocumentOperator.Infrastructure/Services/QrCodeProcessing/DevExpressSwissQrCodeProcessor.cs new file mode 100644 index 0000000..e767283 --- /dev/null +++ b/DocumentOperator.Infrastructure/Services/QrCodeProcessing/DevExpressSwissQrCodeProcessor.cs @@ -0,0 +1,175 @@ +using Codecrete.SwissQRBill.Generator; +using DevExpress.Pdf; +using DocumentOperator.Application.Common.Interfaces; +using DocumentOperator.Domain.Exceptions; +using DocumentOperator.Domain.ValueObjects; +using System.Drawing; +using ZXing; + +namespace DocumentOperator.Infrastructure.Services.QrCodeProcessing; + +/// +/// Swiss QR Code processor using DevExpress PDF API for PDF access +/// and Codecrete.SwissQRBill.Generator for QR Code parsing. +/// +public sealed class DevExpressSwissQrCodeProcessor : ISwissQrCodeProcessor +{ + private const int QrCodeSearchDpi = 300; // High DPI for better QR code recognition + + /// + public async Task ExtractSwissQrCodeAsync(byte[] pdfBytes, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(pdfBytes); + + try + { + using var pdfDocument = new PdfDocumentProcessor(); + pdfDocument.LoadDocument(new MemoryStream(pdfBytes)); + + if (pdfDocument.Document.Pages.Count == 0) + { + throw new ArgumentException("PDF document contains no pages.", nameof(pdfBytes)); + } + + // Get last page + int lastPageIndex = pdfDocument.Document.Pages.Count - 1; + + // Convert last page to image for QR code detection + using var pageImage = RenderPageToImage(pdfDocument, lastPageIndex); + + // Detect and decode QR code + string? qrCodeContent = DecodeQrCodeFromImage(pageImage); + + if (string.IsNullOrEmpty(qrCodeContent)) + { + throw new SwissQrCodeNotFoundException( + $"No QR Code found on the last page (page {lastPageIndex + 1}) of the PDF document."); + } + + // Parse Swiss QR Bill content using Codecrete library + SwissQrCodeData qrCodeData = ParseSwissQrBillContent(qrCodeContent); + + return await Task.FromResult(qrCodeData); + } + catch (SwissQrCodeNotFoundException) + { + throw; + } + catch (Exception ex) + { + throw new ArgumentException("Failed to extract Swiss QR Code from PDF.", nameof(pdfBytes), ex); + } + } + + /// + /// Renders a PDF page to a high-resolution bitmap for QR code detection + /// + private static Bitmap RenderPageToImage(PdfDocumentProcessor processor, int pageIndex) + { + // Render page at high DPI for better QR code recognition + var pageImage = processor.CreateBitmap(pageIndex + 1, QrCodeSearchDpi); + return pageImage; + } + + /// + /// Decodes QR code from an image using ZXing library + /// + private static string? DecodeQrCodeFromImage(Bitmap image) + { + var reader = new ZXing.Windows.Compatibility.BarcodeReader + { + AutoRotate = true, + TryInverted = true, + Options = new ZXing.Common.DecodingOptions + { + PossibleFormats = new[] { BarcodeFormat.QR_CODE }, + TryHarder = true + } + }; + + var result = reader.Decode(image); + return result?.Text; + } + + /// + /// Parses Swiss QR Bill content using Codecrete library + /// + private static SwissQrCodeData ParseSwissQrBillContent(string qrCodeText) + { + try + { + // Decode Swiss QR Bill using Codecrete library + var bill = QRBill.DecodeQrCodeText(qrCodeText); + + // Determine reference type based on presence and format of reference + string referenceType = DetermineReferenceType(bill.Reference); + + // Map AlternativeSchemes to string array + var alternativeParams = bill.AlternativeSchemes? + .Select(s => $"{s.Name}: {s.Instruction}") + .ToArray(); + + // Map to our domain value object + return new SwissQrCodeData + { + QrType = "SPC", // Always SPC for Swiss Payment Code + Version = bill.Version.ToString("D4"), // e.g., "0200" for version 2.0 + CodingType = "1", // Always UTF-8 + Iban = bill.Account ?? string.Empty, + Creditor = MapAddress(bill.Creditor), + UltimateCreditor = null, // Not exposed in Codecrete Bill model + Amount = bill.Amount, + Currency = bill.Currency ?? "CHF", + UltimateDebtor = bill.Debtor != null ? MapAddress(bill.Debtor) : null, + ReferenceType = referenceType, + Reference = bill.Reference, + UnstructuredMessage = bill.UnstructuredMessage, + BillInformation = bill.BillInformation, + AlternativeProcedureParameters = alternativeParams + }; + } + catch (Exception ex) + { + throw new ArgumentException( + "Failed to parse Swiss QR Code content. The QR code may not be a valid Swiss QR Bill.", "qrCodeText", ex); + } + } + + /// + /// Determines reference type based on reference string format + /// + private static string DetermineReferenceType(string? reference) + { + if (string.IsNullOrWhiteSpace(reference)) + return "NON"; + + // QRR (QR Reference): 27 digits + if (reference.Length == 27 && reference.All(char.IsDigit)) + return "QRR"; + + // SCOR (Creditor Reference ISO 11649): starts with RF and has check digits + if (reference.StartsWith("RF", StringComparison.OrdinalIgnoreCase) && reference.Length >= 5) + return "SCOR"; + + return "NON"; + } + + /// + /// Maps Codecrete Address to our AddressData value object + /// + private static AddressData MapAddress(Codecrete.SwissQRBill.Generator.Address address) + { + return new AddressData + { + AddressType = address.Type == Codecrete.SwissQRBill.Generator.Address.AddressType.Structured ? "S" : "K", + Name = address.Name ?? string.Empty, + Street = address.Street, + BuildingNumber = address.HouseNo, + AddressLine1 = address.AddressLine1, + AddressLine2 = address.AddressLine2, + PostalCode = address.PostalCode ?? string.Empty, + City = address.Town ?? string.Empty, + Country = address.CountryCode ?? string.Empty + }; + } +}