From 165586922283f608823e53bb4a9cdd7cc2ac8c1d Mon Sep 17 00:00:00 2001 From: ismailbennani Date: Sun, 23 Mar 2025 00:22:39 +0100 Subject: [PATCH 1/4] read document data when asked instead of at creation --- Benchmark/Program.cs | 11 +- FacturXDotNet.CLI/Extract/ExtractCommand.cs | 12 +- FacturXDotNet.CLI/Validate/ValidateCommand.cs | 20 +-- FacturXDotNet.sln.DotSettings.user | 1 + .../CrossIndustryInvoiceAttachment.cs | 39 ++++ FacturXDotNet/FacturXDocument.cs | 149 ++++++++++++++- FacturXDotNet/FacturXDocumentAttachment.cs | 79 ++++++++ FacturXDotNet/FacturXDotNet.csproj | 2 + .../Parsing/ExtractAttachmentsFromFacturX.cs | 69 +++++++ .../Parsing/ExtractCiiFromFacturX.cs | 117 ------------ .../FacturXCrossIndustryInvoiceExtractor.cs | 63 ------- FacturXDotNet/Parsing/FacturXParser.cs | 116 ------------ .../BusinessRuleExpectedValidationStatus.cs | 17 ++ .../BusinessRuleValidationStatus.cs | 22 +++ .../Validation/BusinessRules/BusinessRule.cs | 14 ++ .../CII/CrossIndustryInvoiceBusinessRule.cs | 32 ---- ...cs => CrossIndustryInvoiceBusinessRule.cs} | 13 +- .../BusinessRules/Hybrid/BrHybrid01.cs | 4 +- .../BusinessRules/Hybrid/BrHybrid02.cs | 2 +- .../BusinessRules/Hybrid/BrHybrid03.cs | 4 +- .../BusinessRules/Hybrid/BrHybrid04.cs | 4 +- .../BusinessRules/Hybrid/BrHybrid05.cs | 4 +- .../BusinessRules/Hybrid/BrHybrid06.cs | 4 +- .../BusinessRules/Hybrid/BrHybrid07.cs | 4 +- .../BusinessRules/Hybrid/BrHybrid08.cs | 3 +- .../BusinessRules/Hybrid/BrHybrid09.cs | 2 +- .../BusinessRules/Hybrid/BrHybrid10.cs | 2 +- .../BusinessRules/Hybrid/BrHybrid11.cs | 2 +- .../BusinessRules/Hybrid/BrHybrid12.cs | 2 +- .../BusinessRules/Hybrid/BrHybrid13.cs | 2 +- .../BusinessRules/Hybrid/BrHybrid14.cs | 3 +- .../BusinessRules/Hybrid/BrHybrid15.cs | 6 +- .../BusinessRules/Hybrid/BrHybridFr01.cs | 2 +- .../Hybrid/HybridBusinessRule.cs | 23 --- .../BusinessRules/HybridBusinessRule.cs | 25 +++ .../Validation/FacturXValidationOptions.cs | 6 + .../Validation/FacturXValidationResult.cs | 28 +-- .../FacturXValidationResultBuilder.cs | 18 ++ FacturXDotNet/Validation/FacturXValidator.cs | 170 ++++++++++++------ .../ParseAndValidateIntegrationTests.cs | 8 +- 40 files changed, 613 insertions(+), 491 deletions(-) create mode 100644 FacturXDotNet/CrossIndustryInvoiceAttachment.cs create mode 100644 FacturXDotNet/FacturXDocumentAttachment.cs create mode 100644 FacturXDotNet/Parsing/ExtractAttachmentsFromFacturX.cs delete mode 100644 FacturXDotNet/Parsing/ExtractCiiFromFacturX.cs delete mode 100644 FacturXDotNet/Parsing/FacturXCrossIndustryInvoiceExtractor.cs delete mode 100644 FacturXDotNet/Parsing/FacturXParser.cs create mode 100644 FacturXDotNet/Validation/BusinessRuleExpectedValidationStatus.cs create mode 100644 FacturXDotNet/Validation/BusinessRuleValidationStatus.cs create mode 100644 FacturXDotNet/Validation/BusinessRules/BusinessRule.cs delete mode 100644 FacturXDotNet/Validation/BusinessRules/CII/CrossIndustryInvoiceBusinessRule.cs rename FacturXDotNet/Validation/BusinessRules/{FacturXBusinessRule.cs => CrossIndustryInvoiceBusinessRule.cs} (58%) delete mode 100644 FacturXDotNet/Validation/BusinessRules/Hybrid/HybridBusinessRule.cs create mode 100644 FacturXDotNet/Validation/BusinessRules/HybridBusinessRule.cs create mode 100644 FacturXDotNet/Validation/FacturXValidationResultBuilder.cs diff --git a/Benchmark/Program.cs b/Benchmark/Program.cs index caa09f4d..c3b5547b 100644 --- a/Benchmark/Program.cs +++ b/Benchmark/Program.cs @@ -7,7 +7,6 @@ using BenchmarkDotNet.Running; using FacturXDotNet; using FacturXDotNet.Models.CII; -using FacturXDotNet.Parsing; using FacturXDotNet.Validation; BenchmarkRunner.Run(); @@ -44,8 +43,8 @@ public async Task ExtractCiiXml() string sourceFilePath = GetSourceFilePath(); await using FileStream file = File.OpenRead(sourceFilePath); - FacturXParser parser = new(); - _ = await parser.ParseFacturXPdfAsync(file); + FacturXDocument document = await FacturXDocument.FromFileAsync(GetSourceFilePath()); + _ = document.GetCrossIndustryInvoiceAttachmentAsync(); } [Benchmark] @@ -54,11 +53,9 @@ public async Task ValidateFacturX() string sourceFilePath = GetSourceFilePath(); await using FileStream file = File.OpenRead(sourceFilePath); - FacturXParser parser = new(); - FacturXDocument document = await parser.ParseFacturXPdfAsync(file); - + FacturXDocument document = await FacturXDocument.FromFileAsync(GetSourceFilePath()); FacturXValidator validator = new(); - _ = validator.IsValid(document); + _ = await validator.IsValidAsync(document); } string GetSourceFilePath() diff --git a/FacturXDotNet.CLI/Extract/ExtractCommand.cs b/FacturXDotNet.CLI/Extract/ExtractCommand.cs index 91151e6c..a9244968 100644 --- a/FacturXDotNet.CLI/Extract/ExtractCommand.cs +++ b/FacturXDotNet.CLI/Extract/ExtractCommand.cs @@ -115,15 +115,19 @@ await AnsiConsole.Status() return 0; } - static async Task ExtractCii(FileInfo pdfPath, string? ciiAttachment, string outputPath, CancellationToken cancellationToken) + static async Task ExtractCii(FileInfo pdfPath, string? ciiAttachmentName, string outputPath, CancellationToken cancellationToken) { await using FileStream stream = pdfPath.OpenRead(); + FacturXDocument document = await FacturXDocument.FromStream(stream, cancellationToken); - FacturXCrossIndustryInvoiceExtractor extractor = new(new FacturXCrossIndustryInvoiceExtractorOptions { CiiXmlAttachmentName = ciiAttachment }); - await using Stream xmpStream = extractor.ExtractCiiAsync(stream); + CrossIndustryInvoiceAttachment? ciiAttachment = await document.GetCrossIndustryInvoiceAttachmentAsync(ciiAttachmentName, cancellationToken: cancellationToken); + if (ciiAttachment == null) + { + return; + } await using FileStream xmpFile = File.Open(outputPath, FileMode.Create); - await xmpStream.CopyToAsync(xmpFile, cancellationToken); + await ciiAttachment.CopyToAsync(xmpFile, cancellationToken: cancellationToken); } static async Task ExtractXmp(FileInfo pdfPath, string outputPath, CancellationToken cancellationToken) diff --git a/FacturXDotNet.CLI/Validate/ValidateCommand.cs b/FacturXDotNet.CLI/Validate/ValidateCommand.cs index 33d35ea9..87ecbb08 100644 --- a/FacturXDotNet.CLI/Validate/ValidateCommand.cs +++ b/FacturXDotNet.CLI/Validate/ValidateCommand.cs @@ -2,7 +2,6 @@ using System.CommandLine.Parsing; using System.Diagnostics; using FacturXDotNet.Models; -using FacturXDotNet.Parsing; using FacturXDotNet.Validation; using Humanizer; using Spectre.Console; @@ -83,13 +82,6 @@ protected override async Task RunImplAsync(ValidateCommandOptions options, ShowOptions(options); AnsiConsole.WriteLine(); - FacturXParserOptions parserOptions = new() - { - CiiXmlAttachmentName = options.CiiAttachment - }; - - FacturXParser parser = new(parserOptions); - FacturXDocument? facturX = null; await AnsiConsole.Status() .Spinner(Spinner.Known.Default) @@ -101,7 +93,7 @@ await AnsiConsole.Status() sw.Start(); await using FileStream stream = options.Path.OpenRead(); - facturX = await parser.ParseFacturXPdfAsync(stream); + facturX = await FacturXDocument.FromStream(stream, cancellationToken); sw.Stop(); @@ -122,17 +114,17 @@ await AnsiConsole.Status() validationOptions.RulesToSkip.AddRange(options.RulesToSkip); FacturXValidationResult result = default; - AnsiConsole.Status() + await AnsiConsole.Status() .Spinner(Spinner.Known.Default) - .Start( + .StartAsync( "Validating...", - _ => + async _ => { Stopwatch sw = new(); sw.Start(); FacturXValidator validator = new(validationOptions); - result = validator.GetValidationResult(facturX); + result = await validator.GetValidationResultAsync(facturX, options.CiiAttachment, cancellationToken: cancellationToken); sw.Stop(); @@ -184,7 +176,7 @@ static void ShowOptions(ValidateCommandOptions options) static void ShowFinalResult(FacturXDocument document, FacturXValidationResult result) { - FacturXProfile documentProfile = document.CrossIndustryInvoice.ExchangedDocumentContext.GuidelineSpecifiedDocumentContextParameterId.ToFacturXProfile(); + FacturXProfile documentProfile = result.ExpectedProfile; FacturXProfile detectedProfile = result.ValidProfiles.GetMaxProfile(); if (result.Success) { diff --git a/FacturXDotNet.sln.DotSettings.user b/FacturXDotNet.sln.DotSettings.user index c4679444..95638aed 100644 --- a/FacturXDotNet.sln.DotSettings.user +++ b/FacturXDotNet.sln.DotSettings.user @@ -20,6 +20,7 @@ ForceIncluded ForceIncluded ForceIncluded + ForceIncluded ForceIncluded ForceIncluded ForceIncluded diff --git a/FacturXDotNet/CrossIndustryInvoiceAttachment.cs b/FacturXDotNet/CrossIndustryInvoiceAttachment.cs new file mode 100644 index 00000000..4ef31617 --- /dev/null +++ b/FacturXDotNet/CrossIndustryInvoiceAttachment.cs @@ -0,0 +1,39 @@ +using FacturXDotNet.Parsing.CII; + +namespace FacturXDotNet; + +/// +/// The Cross-Industry Invoice attachment. +/// +public class CrossIndustryInvoiceAttachment : FacturXDocumentAttachment +{ + /// + /// The Cross-Industry Invoice attachment. + /// + /// The Factur-X document. + /// The name of the attachment in the Factur-X document. + internal CrossIndustryInvoiceAttachment(FacturXDocument facturX, string name) : base(facturX, name) + { + } + + /// + /// Get the parsed Cross-Industry Invoice. + /// + /// The password to open the PDF document. + /// The options to parse the Cross-Industry Invoice. + /// The cancellation token. + /// The parsed Cross-Industry Invoice. + public async Task GetCrossIndustryInvoiceAsync( + string? password = null, + CrossIndustryInvoiceParserOptions? options = null, + CancellationToken cancellationToken = default + ) + { + options ??= new CrossIndustryInvoiceParserOptions(); + + await using Stream stream = await FindAttachmentStreamAsync(password, cancellationToken); + + CrossIndustryInvoiceParser parser = new(options); + return parser.ParseCiiXml(stream); + } +} diff --git a/FacturXDotNet/FacturXDocument.cs b/FacturXDotNet/FacturXDocument.cs index 87f81199..f27b9a9f 100644 --- a/FacturXDotNet/FacturXDocument.cs +++ b/FacturXDotNet/FacturXDocument.cs @@ -1,22 +1,155 @@ -namespace FacturXDotNet; +using System.Runtime.CompilerServices; +using System.Text.RegularExpressions; +using CommunityToolkit.HighPerformance; +using FacturXDotNet.Parsing; +using FacturXDotNet.Parsing.XMP; +using PdfSharp.Pdf; +using PdfSharp.Pdf.IO; + +namespace FacturXDotNet; /// /// A Factur-X document. /// -public class FacturXDocument +public partial class FacturXDocument { /// - /// The XMP metadata of the Factur-X document. + /// Create a new Factur-X document. + /// + public FacturXDocument(ReadOnlyMemory data) + { + Data = data; + } + + /// + /// The raw document. /// - public required XmpMetadata XmpMetadata { get; set; } + public ReadOnlyMemory Data { get; } /// - /// Information about the Cross-Industry Invoice file that was found in the Factur-X document. + /// Get the XMP metadata of the Factur-X document. /// - public required CrossIndustryInvoiceFileInformation CrossIndustryInvoiceFileInformation { get; set; } + /// The password to open the PDF document. + /// The options to parse the XMP metadata. + /// The cancellation token. + /// The XMP metadata of the Factur-X document. + public async Task GetXmpMetadataAsync(string? password = null, XmpMetadataParserOptions? xmpParserOptions = null, CancellationToken cancellationToken = default) + { + using PdfDocument pdfDocument = await OpenPdfDocumentAsync(password, cancellationToken); + + ExtractXmpFromFacturX extractor = new(); + if (!extractor.TryExtractXmpMetadata(pdfDocument, out Stream? xmpStream)) + { + return null; + } + + await using Stream _ = xmpStream; + + // TODO: avoid these two extra copies, it is only required because TurboXML doesn't support the processing instructions + // an issue has been opened to address this: https://github.com/xoofx/TurboXml/issues/6 + // I need to fix this in the library, but it will take some time + + using StreamReader reader = new(xmpStream); + string content = await reader.ReadToEndAsync(cancellationToken); + string transformedContent = PacketInstructions().Replace(content, string.Empty); + + await using MemoryStream transformedStream = new(transformedContent.Length + 54); + await using StreamWriter writer = new(transformedStream); + await writer.WriteAsync(""); + await writer.WriteAsync(transformedContent); + await writer.FlushAsync(cancellationToken); + transformedStream.Seek(0, SeekOrigin.Begin); + + XmpMetadataParser xmpParser = new(xmpParserOptions); + return xmpParser.ParseXmpMetadata(transformedStream); + } /// - /// The Cross-Industry Invoice of the Factur-X document. + /// Get the Cross-Industry Invoice of the Factur-X document. /// - public required CrossIndustryInvoice CrossIndustryInvoice { get; set; } + /// The name of the attachment containing the Cross-Industry Invoice XML file. If not specified, the default name 'factur-x.xml' will be used. + /// The password to open the PDF document. + /// The cancellation token. + /// The Cross-Industry Invoice of the Factur-X document. + public async Task GetCrossIndustryInvoiceAttachmentAsync( + string? attachmentFileName = null, + string? password = null, + CancellationToken cancellationToken = default + ) + { + attachmentFileName ??= "factur-x.xml"; + using PdfDocument pdfDocument = await OpenPdfDocumentAsync(password, cancellationToken); + + ExtractAttachmentsFromFacturX extractor = new(); + foreach ((string Name, Stream Content) attachment in extractor.ExtractFacturXAttachments(pdfDocument)) + { + if (attachment.Name != attachmentFileName) + { + continue; + } + + return new CrossIndustryInvoiceAttachment(this, attachmentFileName); + } + + return null; + } + + /// + /// Get the attachments of the Factur-X document. + /// + /// The password to open the PDF document. + /// The cancellation token. + /// The attachments of the Factur-X document. + public async IAsyncEnumerable GetAttachmentsAsync(string? password = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + using PdfDocument pdfDocument = await OpenPdfDocumentAsync(password, cancellationToken); + + ExtractAttachmentsFromFacturX extractor = new(); + foreach ((string Name, Stream Content) attachment in extractor.ExtractFacturXAttachments(pdfDocument)) + { + byte[] attachmentBytes = new byte[attachment.Content.Length]; + attachment.Content.ReadExactly(attachmentBytes); + yield return new FacturXDocumentAttachment(this, attachment.Name); + } + } + + internal async Task OpenPdfDocumentAsync(string? password, CancellationToken _ = default) + { + await using Stream stream = Data.AsStream(); + PdfDocument document; + + if (password != null) + { + document = PdfReader.Open(stream, PdfDocumentOpenMode.Import, args => args.Password = password); + } + else + { + document = PdfReader.Open(stream, PdfDocumentOpenMode.Import); + } + + return document; + } + + /// + /// Create a new Factur-X document from a file. + /// + public static async Task FromFileAsync(string filePath, CancellationToken cancellationToken = default) + { + byte[] buffer = await File.ReadAllBytesAsync(filePath, cancellationToken); + return new FacturXDocument(buffer); + } + + /// + /// Create a new Factur-X document from a stream. + /// + /// This method will copy the entire stream. Consider using the constructor if the data is already in memory. + public static async Task FromStream(Stream stream, CancellationToken cancellationToken = default) + { + byte[] buffer = new byte[stream.Length]; + await stream.ReadExactlyAsync(buffer, cancellationToken); + return new FacturXDocument(buffer); + } + + [GeneratedRegex("<\\?xpacket.*?\\?>")] + private static partial Regex PacketInstructions(); } diff --git a/FacturXDotNet/FacturXDocumentAttachment.cs b/FacturXDotNet/FacturXDocumentAttachment.cs new file mode 100644 index 00000000..475ce27a --- /dev/null +++ b/FacturXDotNet/FacturXDocumentAttachment.cs @@ -0,0 +1,79 @@ +using FacturXDotNet.Parsing; +using PdfSharp.Pdf; + +namespace FacturXDotNet; + +/// +/// A file attached to the Factur-X PDF. +/// +public class FacturXDocumentAttachment +{ + readonly FacturXDocument _facturX; + + internal FacturXDocumentAttachment(FacturXDocument facturX, string name) + { + _facturX = facturX; + Name = name; + } + + /// + /// The name of the attachment. + /// + public string Name { get; } + + /// + /// Read the attachment from the Factur-X document to memory. + /// + /// The password to open the PDF document. + /// The cancellation token. + /// The attachment content. + /// The attachment with the specified name could not be found. + public async Task> ReadAsync(string? password = null, CancellationToken cancellationToken = default) + { + using PdfDocument pdfDocument = await _facturX.OpenPdfDocumentAsync(password, cancellationToken); + await using Stream attachmentStream = await FindAttachmentStreamAsync(password, cancellationToken); + + byte[] attachmentBytes = new byte[attachmentStream.Length]; + attachmentStream.ReadExactly(attachmentBytes); + + return attachmentBytes; + } + + /// + /// Write the attachment from the Factur-X document to a stream. + /// + /// The stream to write the attachment to. + /// The password to open the PDF document. + /// The cancellation token. + public async Task CopyToAsync(Stream outputStream, string? password = null, CancellationToken cancellationToken = default) + { + using PdfDocument pdfDocument = await _facturX.OpenPdfDocumentAsync(password, cancellationToken); + await using Stream attachmentStream = await FindAttachmentStreamAsync(password, cancellationToken); + await attachmentStream.CopyToAsync(outputStream, cancellationToken); + } + + /// + /// Get the stream of the attachment from the Factur-X document. + /// + /// The password to open the PDF document. + /// The cancellation token. + /// The attachment stream. + /// The attachment with the specified name could not be found. + protected async Task FindAttachmentStreamAsync(string? password = null, CancellationToken cancellationToken = default) + { + using PdfDocument pdfDocument = await _facturX.OpenPdfDocumentAsync(password, cancellationToken); + + ExtractAttachmentsFromFacturX extractor = new(); + foreach ((string Name, Stream Content) attachment in extractor.ExtractFacturXAttachments(pdfDocument)) + { + if (attachment.Name != Name) + { + continue; + } + + return attachment.Content; + } + + throw new InvalidOperationException("Could not find attachment with name '{Name}'."); + } +} diff --git a/FacturXDotNet/FacturXDotNet.csproj b/FacturXDotNet/FacturXDotNet.csproj index 13d66454..32849e41 100644 --- a/FacturXDotNet/FacturXDotNet.csproj +++ b/FacturXDotNet/FacturXDotNet.csproj @@ -2,6 +2,7 @@ net8.0;net9.0;net10.0 true + true @@ -35,6 +36,7 @@ + diff --git a/FacturXDotNet/Parsing/ExtractAttachmentsFromFacturX.cs b/FacturXDotNet/Parsing/ExtractAttachmentsFromFacturX.cs new file mode 100644 index 00000000..5f33129b --- /dev/null +++ b/FacturXDotNet/Parsing/ExtractAttachmentsFromFacturX.cs @@ -0,0 +1,69 @@ +using PdfSharp.Pdf; +using PdfSharp.Pdf.Advanced; +using PdfSharp.Pdf.Filters; + +namespace FacturXDotNet.Parsing; + +/// +/// Extract attachments from a Factur-X PDF document. +/// +class ExtractAttachmentsFromFacturX +{ + /// + /// Extract attachments from a Factur-X PDF document. + /// + public IEnumerable<(string Name, Stream Content)> ExtractFacturXAttachments(PdfDocument document) + { + PdfCatalog catalog = document.Internals.Catalog; + PdfArray? attachedFiles = catalog.Elements.GetArray("/AF"); + + if (attachedFiles == null) + { + yield break; + } + + foreach (PdfItem? attachedFile in attachedFiles.Elements) + { + if (attachedFile is not PdfReference { Value: PdfDictionary fileSpec }) + { + continue; + } + + string attachmentName = fileSpec.Elements.GetString("/F"); + + if (fileSpec.Elements.GetDictionary("/EF") is not { } embeddedFile) + { + continue; + } + + if (embeddedFile.Elements.GetReference("/F") is not { } pdfStreamReference) + { + continue; + } + + if (pdfStreamReference.Value is not PdfDictionary pdfStreamDictionary) + { + continue; + } + + PdfDictionary.PdfStream pdfStream = pdfStreamDictionary.Stream; + if (pdfStream.Length == 0) + { + continue; + } + + byte[] bytes; + if (pdfStream.TryUnfilter()) + { + bytes = pdfStream.Value; + } + else + { + FlateDecode flate = new(); + bytes = flate.Decode(pdfStream.Value, new PdfDictionary()); + } + + yield return new ValueTuple(attachmentName, new MemoryStream(bytes)); + } + } +} diff --git a/FacturXDotNet/Parsing/ExtractCiiFromFacturX.cs b/FacturXDotNet/Parsing/ExtractCiiFromFacturX.cs deleted file mode 100644 index 720b9731..00000000 --- a/FacturXDotNet/Parsing/ExtractCiiFromFacturX.cs +++ /dev/null @@ -1,117 +0,0 @@ -using System.Diagnostics.CodeAnalysis; -using PdfSharp.Pdf; -using PdfSharp.Pdf.Advanced; -using PdfSharp.Pdf.Filters; - -namespace FacturXDotNet.Parsing; - -/// -/// Extract the Cross-Industry Invoice XML attachment from a Factur-X PDF document. -/// -class ExtractCiiFromFacturX -{ - readonly string? _ciiAttachmentName; - - /// - /// Extract the Cross-Industry Invoice XML attachment from a Factur-X PDF document. - /// - /// The name of the attachment containing the Cross-Industry Invoice XML file. If not specified, the default name 'factur-x.xml' will be used. - public ExtractCiiFromFacturX(string? ciiAttachmentName = null) - { - _ciiAttachmentName = ciiAttachmentName ?? "factur-x.xml"; - } - - /// - /// Extract the Cross-Industry Invoice XML attachment from a Factur-X PDF document. - /// - public Stream ExtractFacturXAttachment(PdfDocument document, out string attachmentFileName) - { - if (TryExtractFacturXAttachment(document, out Stream? result, out string? attachmentFileNameOrNull)) - { - attachmentFileName = attachmentFileNameOrNull; - return result; - } - throw new InvalidOperationException($"The Cross-Industry Invoice XML attachment with name '{_ciiAttachmentName}' could not be found."); - } - - /// - /// Extract the Cross-Industry Invoice XML attachment from a Factur-X PDF document. - /// - /// The Factur-X PDF document to parse. - /// The Cross-Industry Invoice document. - /// The name of the attachment containing the Cross-Industry Invoice XML file. - public bool TryExtractFacturXAttachment(PdfDocument document, [NotNullWhen(true)] out Stream? facturXAttachment, [NotNullWhen(true)] out string? attachmentFileName) - { - PdfCatalog catalog = document.Internals.Catalog; - PdfArray? attachedFiles = catalog.Elements.GetArray("/AF"); - - if (attachedFiles == null) - { - facturXAttachment = null; - attachmentFileName = null; - return false; - } - - foreach (PdfItem? attachedFile in attachedFiles.Elements) - { - if (attachedFile is not PdfReference { Value: PdfDictionary fileSpec }) - { - continue; - } - - string attachedFileName = fileSpec.Elements.GetString("/F"); - if (attachedFileName != _ciiAttachmentName) - { - continue; - } - - if (fileSpec.Elements.GetDictionary("/EF") is not { } embeddedFile) - { - facturXAttachment = null; - attachmentFileName = null; - return false; - } - - if (embeddedFile.Elements.GetReference("/F") is not { } pdfStreamReference) - { - facturXAttachment = null; - attachmentFileName = null; - return false; - } - - if (pdfStreamReference.Value is not PdfDictionary pdfStreamDictionary) - { - facturXAttachment = null; - attachmentFileName = null; - return false; - } - - PdfDictionary.PdfStream pdfStream = pdfStreamDictionary.Stream; - if (pdfStream.Length == 0) - { - facturXAttachment = null; - attachmentFileName = null; - return false; - } - - byte[] bytes; - if (pdfStream.TryUnfilter()) - { - bytes = pdfStream.Value; - } - else - { - FlateDecode flate = new(); - bytes = flate.Decode(pdfStream.Value, new PdfDictionary()); - } - - facturXAttachment = new MemoryStream(bytes); - attachmentFileName = attachedFileName; - return true; - } - - facturXAttachment = null; - attachmentFileName = null; - return false; - } -} diff --git a/FacturXDotNet/Parsing/FacturXCrossIndustryInvoiceExtractor.cs b/FacturXDotNet/Parsing/FacturXCrossIndustryInvoiceExtractor.cs deleted file mode 100644 index 4fbafc31..00000000 --- a/FacturXDotNet/Parsing/FacturXCrossIndustryInvoiceExtractor.cs +++ /dev/null @@ -1,63 +0,0 @@ -using PdfSharp.Pdf; -using PdfSharp.Pdf.IO; - -namespace FacturXDotNet.Parsing; - -/// -/// Extracts XMP metadata from a Factur-X PDF file. -/// -public class FacturXCrossIndustryInvoiceExtractor -{ - readonly FacturXCrossIndustryInvoiceExtractorOptions _options; - readonly ExtractCiiFromFacturX _extractor; - - /// - /// Extracts XMP metadata from a Factur-X PDF file. - /// - public FacturXCrossIndustryInvoiceExtractor(FacturXCrossIndustryInvoiceExtractorOptions? options = null) - { - _options = options ?? new FacturXCrossIndustryInvoiceExtractorOptions(); - _extractor = new ExtractCiiFromFacturX(_options.CiiXmlAttachmentName); - } - - /// - /// Extracts the XMP metadata from a Factur-X PDF file. - /// - public Stream ExtractCiiAsync(Stream facturXStream) - { - using PdfDocument document = OpenPdfDocument(facturXStream); - return _extractor.ExtractFacturXAttachment(document, out _); - } - - PdfDocument OpenPdfDocument(Stream stream) - { - PdfDocument document; - - if (_options.Password != null) - { - document = PdfReader.Open(stream, PdfDocumentOpenMode.Import, args => args.Password = _options.Password); - } - else - { - document = PdfReader.Open(stream, PdfDocumentOpenMode.Import); - } - - return document; - } -} - -/// -/// Options for the . -/// -public class FacturXCrossIndustryInvoiceExtractorOptions -{ - /// - /// The password to open the PDF file. - /// - public string? Password { get; set; } - - /// - /// The name of the attachment containing the Cross-Industry Invoice XML file. - /// - public string? CiiXmlAttachmentName { get; set; } = "factur-x.xml"; -} diff --git a/FacturXDotNet/Parsing/FacturXParser.cs b/FacturXDotNet/Parsing/FacturXParser.cs deleted file mode 100644 index 4b01d1c2..00000000 --- a/FacturXDotNet/Parsing/FacturXParser.cs +++ /dev/null @@ -1,116 +0,0 @@ -using System.Text.RegularExpressions; -using FacturXDotNet.Parsing.CII; -using FacturXDotNet.Parsing.XMP; -using PdfSharp.Pdf; -using PdfSharp.Pdf.IO; - -namespace FacturXDotNet.Parsing; - -/// -/// Parse Factur-X from a PDF stream. -/// -public partial class FacturXParser -{ - readonly FacturXParserOptions _options; - readonly ExtractXmpFromFacturX _xmpExtractor; - readonly ExtractCiiFromFacturX _ciiExtractor; - readonly XmpMetadataParser _xmpParser; - readonly CrossIndustryInvoiceParser _ciiParser; - - /// - /// Initializes a new instance of the class. - /// - public FacturXParser(FacturXParserOptions? options = null) - { - _options = options ?? new FacturXParserOptions(); - _xmpExtractor = new ExtractXmpFromFacturX(); - _ciiExtractor = new ExtractCiiFromFacturX(_options.CiiXmlAttachmentName); - _xmpParser = new XmpMetadataParser(_options.Xmp); - _ciiParser = new CrossIndustryInvoiceParser(_options.Cii); - } - - /// - /// Parse the Factur-X PDF file. - /// - /// - /// - public async Task ParseFacturXPdfAsync(Stream stream) - { - using PdfDocument document = OpenPdfDocument(stream); - XmpMetadata xmpMetadata = await ParseXmpMetadataAsync(document); - await using Stream ciiXmlStream = _ciiExtractor.ExtractFacturXAttachment(document, out string attachmentFileName); - CrossIndustryInvoice ciiXml = _ciiParser.ParseCiiXml(ciiXmlStream); - - return new FacturXDocument - { - XmpMetadata = xmpMetadata, - CrossIndustryInvoiceFileInformation = new CrossIndustryInvoiceFileInformation { Name = attachmentFileName }, - CrossIndustryInvoice = ciiXml - }; - } - - async Task ParseXmpMetadataAsync(PdfDocument document) - { - await using Stream xmpXmlStream = _xmpExtractor.ExtractXmpMetadata(document); - - // TODO: avoid these two extra copies, it is only required because TurboXML doesn't support the processing instructions - using StreamReader reader = new(xmpXmlStream); - string content = await reader.ReadToEndAsync(); - string transformedContent = PacketInstructions().Replace(content, string.Empty); - - await using MemoryStream transformedStream = new(transformedContent.Length + 54); - await using StreamWriter writer = new(transformedStream); - await writer.WriteAsync(""); - await writer.WriteAsync(transformedContent); - await writer.FlushAsync(); - transformedStream.Seek(0, SeekOrigin.Begin); - - return _xmpParser.ParseXmpMetadata(transformedStream); - } - - - PdfDocument OpenPdfDocument(Stream stream) - { - PdfDocument document; - - if (_options.Password != null) - { - document = PdfReader.Open(stream, PdfDocumentOpenMode.Import, args => args.Password = _options.Password); - } - else - { - document = PdfReader.Open(stream, PdfDocumentOpenMode.Import); - } - - return document; - } - - [GeneratedRegex("<\\?xpacket.*?\\?>")] - private static partial Regex PacketInstructions(); -} - -/// -/// The options that can be passed to the . -/// -public class FacturXParserOptions -{ - /// - /// The password to use to open the PDF document if it is encrypted with standard encryption. - /// - public string? Password { get; set; } - - /// - /// The name of the attachment containing the Cross-Industry Invoice XML file. - /// - public string? CiiXmlAttachmentName { get; set; } - - /// - /// The options for parsing the XMP metadata. - /// - public XmpMetadataParserOptions Xmp { get; set; } = new(); - - /// - /// The options for parsing the Cross-Industry Invoice. - /// - public CrossIndustryInvoiceParserOptions Cii { get; } = new(); -} diff --git a/FacturXDotNet/Validation/BusinessRuleExpectedValidationStatus.cs b/FacturXDotNet/Validation/BusinessRuleExpectedValidationStatus.cs new file mode 100644 index 00000000..6ef4ca19 --- /dev/null +++ b/FacturXDotNet/Validation/BusinessRuleExpectedValidationStatus.cs @@ -0,0 +1,17 @@ +namespace FacturXDotNet.Validation; + +/// +/// Represent the expected validation status of a business rule. Some rules are expected to fail because they are associated with a higher profile. +/// +enum BusinessRuleExpectedValidationStatus +{ + /// + /// The rule is expected to pass. + /// + Success, + + /// + /// The rule is expected to fail. + /// + Failure +} diff --git a/FacturXDotNet/Validation/BusinessRuleValidationStatus.cs b/FacturXDotNet/Validation/BusinessRuleValidationStatus.cs new file mode 100644 index 00000000..c5b51e11 --- /dev/null +++ b/FacturXDotNet/Validation/BusinessRuleValidationStatus.cs @@ -0,0 +1,22 @@ +namespace FacturXDotNet.Validation; + +/// +/// Represents the validation status of a business rule. +/// +public enum BusinessRuleValidationStatus +{ + /// + /// The rule passed. + /// + Passed, + + /// + /// The rule failed. + /// + Failed, + + /// + /// The rule was skipped. + /// + Skipped +} diff --git a/FacturXDotNet/Validation/BusinessRules/BusinessRule.cs b/FacturXDotNet/Validation/BusinessRules/BusinessRule.cs new file mode 100644 index 00000000..a6dde8ae --- /dev/null +++ b/FacturXDotNet/Validation/BusinessRules/BusinessRule.cs @@ -0,0 +1,14 @@ +namespace FacturXDotNet.Validation.BusinessRules; + +/// +/// Represents any business rule for validating a Factur-X document. +/// +/// The name of the rule. +/// A description of the rule. +public abstract record BusinessRule(string Name, string Description) +{ + /// + /// Formats the rule as a string. + /// + public abstract string Format(); +} diff --git a/FacturXDotNet/Validation/BusinessRules/CII/CrossIndustryInvoiceBusinessRule.cs b/FacturXDotNet/Validation/BusinessRules/CII/CrossIndustryInvoiceBusinessRule.cs deleted file mode 100644 index 9e9b5c80..00000000 --- a/FacturXDotNet/Validation/BusinessRules/CII/CrossIndustryInvoiceBusinessRule.cs +++ /dev/null @@ -1,32 +0,0 @@ -using FacturXDotNet.Models; - -namespace FacturXDotNet.Validation.BusinessRules.CII; - -/// -/// Represents a business rule for validating the Cross-Industry Invoice in a Factur-X document. -/// -/// The name of the rule. -/// A description of the rule. -/// The profiles in which this rule should be enforced. -public abstract record CrossIndustryInvoiceBusinessRule(string Name, string Description, FacturXProfileFlags Profiles) : FacturXBusinessRule( - Name, - Description, - Profiles, - FacturXBusinessRuleSeverity.Fatal -) -{ - /// - /// Determines whether the invoice satisfies the conditions defined by the rule. - /// - /// The invoice to validate. - /// true if the rule is satisfied by the invoice; otherwise false. - public abstract bool Check(CrossIndustryInvoice invoice); - - /// - public override sealed bool Check(FacturXDocument invoice) => Check(invoice.CrossIndustryInvoice); - - /// - /// Returns a string representation of the business rule. - /// - public override string Format() => $"[{Profiles.GetMinProfile()}] {Name} - {Description}"; -} diff --git a/FacturXDotNet/Validation/BusinessRules/FacturXBusinessRule.cs b/FacturXDotNet/Validation/BusinessRules/CrossIndustryInvoiceBusinessRule.cs similarity index 58% rename from FacturXDotNet/Validation/BusinessRules/FacturXBusinessRule.cs rename to FacturXDotNet/Validation/BusinessRules/CrossIndustryInvoiceBusinessRule.cs index c3bd0987..40cf5a94 100644 --- a/FacturXDotNet/Validation/BusinessRules/FacturXBusinessRule.cs +++ b/FacturXDotNet/Validation/BusinessRules/CrossIndustryInvoiceBusinessRule.cs @@ -8,18 +8,15 @@ namespace FacturXDotNet.Validation.BusinessRules; /// The name of the rule. /// A description of the rule. /// The profiles in which this rule should be enforced. -/// The severity of the rule. -public abstract record FacturXBusinessRule(string Name, string Description, FacturXProfileFlags Profiles, FacturXBusinessRuleSeverity Severity) +public abstract record CrossIndustryInvoiceBusinessRule(string Name, string Description, FacturXProfileFlags Profiles) : BusinessRule(Name, Description) { /// /// Determines whether the invoice satisfies the conditions defined by the rule. /// - /// The invoice to validate. + /// The Cross-Industry Invoice to validate. /// true if the rule is satisfied by the invoice; otherwise false. - public abstract bool Check(FacturXDocument invoice); + public abstract bool Check(CrossIndustryInvoice cii); - /// - /// Formats the rule as a string. - /// - public abstract string Format(); + /// + public override string Format() => $"[{Profiles.GetMinProfile()}] {Name} - {Description}"; } diff --git a/FacturXDotNet/Validation/BusinessRules/Hybrid/BrHybrid01.cs b/FacturXDotNet/Validation/BusinessRules/Hybrid/BrHybrid01.cs index 8457672b..32f57edc 100644 --- a/FacturXDotNet/Validation/BusinessRules/Hybrid/BrHybrid01.cs +++ b/FacturXDotNet/Validation/BusinessRules/Hybrid/BrHybrid01.cs @@ -6,7 +6,7 @@ record BrHybrid01() : HybridBusinessRule( FacturXBusinessRuleSeverity.Information ) { - public override bool Check(FacturXDocument invoice) => + public override bool Check(XmpMetadata xmp, string ciiAttachmentName, CrossIndustryInvoice cii) => // ReSharper disable once ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract - invoice.XmpMetadata != null; + xmp != null; } diff --git a/FacturXDotNet/Validation/BusinessRules/Hybrid/BrHybrid02.cs b/FacturXDotNet/Validation/BusinessRules/Hybrid/BrHybrid02.cs index 6776acbb..14f25517 100644 --- a/FacturXDotNet/Validation/BusinessRules/Hybrid/BrHybrid02.cs +++ b/FacturXDotNet/Validation/BusinessRules/Hybrid/BrHybrid02.cs @@ -5,5 +5,5 @@ record BrHybrid02() : HybridBusinessRule( "The PDF envelope of a hybrid document SHALL use the PDF/A-3 standard.Optionally, a PDF/A-4f file (ISO 19005-4, based on PDF 2.0 ISO 32000-2:2020) is allowed." ) { - public override bool Check(FacturXDocument invoice) => invoice.XmpMetadata.PdfAIdentification is { Part: >= 3 }; + public override bool Check(XmpMetadata xmp, string ciiAttachmentName, CrossIndustryInvoice cii) => xmp.PdfAIdentification is { Part: >= 3 }; } diff --git a/FacturXDotNet/Validation/BusinessRules/Hybrid/BrHybrid03.cs b/FacturXDotNet/Validation/BusinessRules/Hybrid/BrHybrid03.cs index 6c78d5c3..8355b4c7 100644 --- a/FacturXDotNet/Validation/BusinessRules/Hybrid/BrHybrid03.cs +++ b/FacturXDotNet/Validation/BusinessRules/Hybrid/BrHybrid03.cs @@ -4,6 +4,6 @@ namespace FacturXDotNet.Validation.BusinessRules.Hybrid; record BrHybrid03() : HybridBusinessRule("BR-HYBRID-03", "A PDF/A extension schema (XMP) following the structure definition in the corresponding specification SHALL be used.") { - public override bool Check(FacturXDocument invoice) => - invoice.XmpMetadata.PdfAExtensions?.Schemas.SingleOrDefault(s => s.NamespaceUri == XmpFacturXMetadata.NamespaceUri) is not null; + public override bool Check(XmpMetadata xmp, string ciiAttachmentName, CrossIndustryInvoice cii) => + xmp.PdfAExtensions?.Schemas.SingleOrDefault(s => s.NamespaceUri == XmpFacturXMetadata.NamespaceUri) is not null; } diff --git a/FacturXDotNet/Validation/BusinessRules/Hybrid/BrHybrid04.cs b/FacturXDotNet/Validation/BusinessRules/Hybrid/BrHybrid04.cs index c0f5a395..b94b9564 100644 --- a/FacturXDotNet/Validation/BusinessRules/Hybrid/BrHybrid04.cs +++ b/FacturXDotNet/Validation/BusinessRules/Hybrid/BrHybrid04.cs @@ -4,7 +4,7 @@ namespace FacturXDotNet.Validation.BusinessRules.Hybrid; record BrHybrid04() : HybridBusinessRule("BR-HYBRID-04", "The URI in the extension schema SHALL be urn:factur-x:pdfa:CrossIndustryDocument:1p0#.") { - public override bool Check(FacturXDocument invoice) => + public override bool Check(XmpMetadata xmp, string ciiAttachmentName, CrossIndustryInvoice cii) => // a bit of a tautology - invoice.XmpMetadata.PdfAExtensions?.Schemas.SingleOrDefault(s => s.NamespaceUri == XmpFacturXMetadata.NamespaceUri) is { NamespaceUri: XmpFacturXMetadata.NamespaceUri }; + xmp.PdfAExtensions?.Schemas.SingleOrDefault(s => s.NamespaceUri == XmpFacturXMetadata.NamespaceUri) is { NamespaceUri: XmpFacturXMetadata.NamespaceUri }; } diff --git a/FacturXDotNet/Validation/BusinessRules/Hybrid/BrHybrid05.cs b/FacturXDotNet/Validation/BusinessRules/Hybrid/BrHybrid05.cs index 82e19251..3661debe 100644 --- a/FacturXDotNet/Validation/BusinessRules/Hybrid/BrHybrid05.cs +++ b/FacturXDotNet/Validation/BusinessRules/Hybrid/BrHybrid05.cs @@ -4,6 +4,6 @@ namespace FacturXDotNet.Validation.BusinessRules.Hybrid; record BrHybrid05() : HybridBusinessRule("BR-HYBRID-05", "The schema namespace prefix in the XMP extension schema SHALL be fx.") { - public override bool Check(FacturXDocument invoice) => - invoice.XmpMetadata.PdfAExtensions?.Schemas.SingleOrDefault(s => s.NamespaceUri == XmpFacturXMetadata.NamespaceUri) is { Prefix: "fx" }; + public override bool Check(XmpMetadata xmp, string ciiAttachmentName, CrossIndustryInvoice cii) => + xmp.PdfAExtensions?.Schemas.SingleOrDefault(s => s.NamespaceUri == XmpFacturXMetadata.NamespaceUri) is { Prefix: "fx" }; } diff --git a/FacturXDotNet/Validation/BusinessRules/Hybrid/BrHybrid06.cs b/FacturXDotNet/Validation/BusinessRules/Hybrid/BrHybrid06.cs index 83d823bb..2c01bc6e 100644 --- a/FacturXDotNet/Validation/BusinessRules/Hybrid/BrHybrid06.cs +++ b/FacturXDotNet/Validation/BusinessRules/Hybrid/BrHybrid06.cs @@ -2,6 +2,6 @@ record BrHybrid06() : HybridBusinessRule("BR-HYBRID-06", "The fx:DocumentType in the XMP instance SHALL be a value from the HybridDocumentType code list.") { - public override bool Check(FacturXDocument invoice) => - invoice.XmpMetadata.FacturX is { DocumentType: not null } && Enum.IsDefined(invoice.XmpMetadata.FacturX.DocumentType.Value); + public override bool Check(XmpMetadata xmp, string ciiAttachmentName, CrossIndustryInvoice cii) => + xmp.FacturX is { DocumentType: not null } && Enum.IsDefined(xmp.FacturX.DocumentType.Value); } diff --git a/FacturXDotNet/Validation/BusinessRules/Hybrid/BrHybrid07.cs b/FacturXDotNet/Validation/BusinessRules/Hybrid/BrHybrid07.cs index 1231178e..3665efe8 100644 --- a/FacturXDotNet/Validation/BusinessRules/Hybrid/BrHybrid07.cs +++ b/FacturXDotNet/Validation/BusinessRules/Hybrid/BrHybrid07.cs @@ -2,6 +2,6 @@ record BrHybrid07() : HybridBusinessRule("BR-HYBRID-07", "The fx:ConformanceLevel in the XMP instance SHALL be a value from the HybridConformanceType code list.") { - public override bool Check(FacturXDocument invoice) => - invoice.XmpMetadata.FacturX is { ConformanceLevel: not null } && Enum.IsDefined(invoice.XmpMetadata.FacturX.ConformanceLevel.Value); + public override bool Check(XmpMetadata xmp, string ciiAttachmentName, CrossIndustryInvoice cii) => + xmp.FacturX is { ConformanceLevel: not null } && Enum.IsDefined(xmp.FacturX.ConformanceLevel.Value); } diff --git a/FacturXDotNet/Validation/BusinessRules/Hybrid/BrHybrid08.cs b/FacturXDotNet/Validation/BusinessRules/Hybrid/BrHybrid08.cs index 5217c849..4b5d944e 100644 --- a/FacturXDotNet/Validation/BusinessRules/Hybrid/BrHybrid08.cs +++ b/FacturXDotNet/Validation/BusinessRules/Hybrid/BrHybrid08.cs @@ -2,5 +2,6 @@ record BrHybrid08() : HybridBusinessRule("BR-HYBRID-08", "The fx:DocumentFileName in the XMP instance SHALL be a value defined in the HybridDocumentFilename code list.") { - public override bool Check(FacturXDocument invoice) => invoice.XmpMetadata.FacturX is { DocumentFileName: "factur-x.xml" or "xrechnung.xml" or "order-x.xml" }; + public override bool Check(XmpMetadata xmp, string ciiAttachmentName, CrossIndustryInvoice cii) => + xmp.FacturX is { DocumentFileName: "factur-x.xml" or "xrechnung.xml" or "order-x.xml" }; } diff --git a/FacturXDotNet/Validation/BusinessRules/Hybrid/BrHybrid09.cs b/FacturXDotNet/Validation/BusinessRules/Hybrid/BrHybrid09.cs index d6846321..8039a49b 100644 --- a/FacturXDotNet/Validation/BusinessRules/Hybrid/BrHybrid09.cs +++ b/FacturXDotNet/Validation/BusinessRules/Hybrid/BrHybrid09.cs @@ -2,5 +2,5 @@ record BrHybrid09() : HybridBusinessRule("BR-HYBRID-09", "The fx:Version in the XMP instance SHALL be a value defined in the HybridDocumentVersion codelist.") { - public override bool Check(FacturXDocument invoice) => invoice.XmpMetadata.FacturX is { Version: "1.0" or "1p0" or "2p0" or "2p1" or "2p2" }; + public override bool Check(XmpMetadata xmp, string ciiAttachmentName, CrossIndustryInvoice cii) => xmp.FacturX is { Version: "1.0" or "1p0" or "2p0" or "2p1" or "2p2" }; } diff --git a/FacturXDotNet/Validation/BusinessRules/Hybrid/BrHybrid10.cs b/FacturXDotNet/Validation/BusinessRules/Hybrid/BrHybrid10.cs index eaaeaf94..44006fff 100644 --- a/FacturXDotNet/Validation/BusinessRules/Hybrid/BrHybrid10.cs +++ b/FacturXDotNet/Validation/BusinessRules/Hybrid/BrHybrid10.cs @@ -2,5 +2,5 @@ record BrHybrid10() : HybridBusinessRule("BR-HYBRID-10", "The fx:Version SHOULD be 1.0.", FacturXBusinessRuleSeverity.Warning) { - public override bool Check(FacturXDocument invoice) => invoice.XmpMetadata.FacturX is { Version: "1.0" }; + public override bool Check(XmpMetadata xmp, string ciiAttachmentName, CrossIndustryInvoice cii) => xmp.FacturX is { Version: "1.0" }; } diff --git a/FacturXDotNet/Validation/BusinessRules/Hybrid/BrHybrid11.cs b/FacturXDotNet/Validation/BusinessRules/Hybrid/BrHybrid11.cs index 344eb2c6..b3c56e57 100644 --- a/FacturXDotNet/Validation/BusinessRules/Hybrid/BrHybrid11.cs +++ b/FacturXDotNet/Validation/BusinessRules/Hybrid/BrHybrid11.cs @@ -6,7 +6,7 @@ record BrHybrid11() : HybridBusinessRule( FacturXBusinessRuleSeverity.Warning ) { - public override bool Check(FacturXDocument invoice) => + public override bool Check(XmpMetadata xmp, string ciiAttachmentName, CrossIndustryInvoice cii) => // At this point this is necessarily true because we must have found it in order to create the FacturX instance. true; } diff --git a/FacturXDotNet/Validation/BusinessRules/Hybrid/BrHybrid12.cs b/FacturXDotNet/Validation/BusinessRules/Hybrid/BrHybrid12.cs index a8612594..081ff89e 100644 --- a/FacturXDotNet/Validation/BusinessRules/Hybrid/BrHybrid12.cs +++ b/FacturXDotNet/Validation/BusinessRules/Hybrid/BrHybrid12.cs @@ -5,7 +5,7 @@ record BrHybrid12() : HybridBusinessRule( "The method of embedding the XML into the PDF SHALL conform as defined in the current specification in order to assure easy extraction of the machine readable XML file." ) { - public override bool Check(FacturXDocument invoice) => + public override bool Check(XmpMetadata xmp, string ciiAttachmentName, CrossIndustryInvoice cii) => // At this point this is necessarily true because we must have found it in order to create the FacturX instance. true; } diff --git a/FacturXDotNet/Validation/BusinessRules/Hybrid/BrHybrid13.cs b/FacturXDotNet/Validation/BusinessRules/Hybrid/BrHybrid13.cs index afccc047..8bba2df3 100644 --- a/FacturXDotNet/Validation/BusinessRules/Hybrid/BrHybrid13.cs +++ b/FacturXDotNet/Validation/BusinessRules/Hybrid/BrHybrid13.cs @@ -2,7 +2,7 @@ record BrHybrid13() : HybridBusinessRule("BR-HYBRID-13", "The embedded file name SHALL be one of the values defined in the HybridDocumentFilename code list.") { - public override bool Check(FacturXDocument invoice) => + public override bool Check(XmpMetadata xmp, string ciiAttachmentName, CrossIndustryInvoice cii) => // At this point this is necessarily true because we must have found it in order to create the FacturX instance. true; } diff --git a/FacturXDotNet/Validation/BusinessRules/Hybrid/BrHybrid14.cs b/FacturXDotNet/Validation/BusinessRules/Hybrid/BrHybrid14.cs index 12d89864..5f0ea5c3 100644 --- a/FacturXDotNet/Validation/BusinessRules/Hybrid/BrHybrid14.cs +++ b/FacturXDotNet/Validation/BusinessRules/Hybrid/BrHybrid14.cs @@ -2,6 +2,5 @@ record BrHybrid14() : HybridBusinessRule("BR-HYBRID-14", "The embedded file name SHOULD match the fx:DocumentFileName.", FacturXBusinessRuleSeverity.Warning) { - public override bool Check(FacturXDocument invoice) => - invoice.XmpMetadata.FacturX != null && invoice.CrossIndustryInvoiceFileInformation.Name == invoice.XmpMetadata.FacturX.DocumentFileName; + public override bool Check(XmpMetadata xmp, string ciiAttachmentName, CrossIndustryInvoice cii) => xmp.FacturX != null && ciiAttachmentName == xmp.FacturX.DocumentFileName; } diff --git a/FacturXDotNet/Validation/BusinessRules/Hybrid/BrHybrid15.cs b/FacturXDotNet/Validation/BusinessRules/Hybrid/BrHybrid15.cs index acaa8d8c..70b6da27 100644 --- a/FacturXDotNet/Validation/BusinessRules/Hybrid/BrHybrid15.cs +++ b/FacturXDotNet/Validation/BusinessRules/Hybrid/BrHybrid15.cs @@ -4,8 +4,6 @@ namespace FacturXDotNet.Validation.BusinessRules.Hybrid; record BrHybrid15() : HybridBusinessRule("BR-HYBRID-15", "The fx:ConformanceLevel SHOULD match the profile of the embedded XML document.", FacturXBusinessRuleSeverity.Warning) { - public override bool Check(FacturXDocument invoice) => - invoice.XmpMetadata.FacturX != null - && invoice.CrossIndustryInvoice.ExchangedDocumentContext.GuidelineSpecifiedDocumentContextParameterId.ToFacturXProfile() - == invoice.XmpMetadata.FacturX.ConformanceLevel?.ToFacturXProfile(); + public override bool Check(XmpMetadata xmp, string ciiAttachmentName, CrossIndustryInvoice cii) => + xmp.FacturX != null && cii.ExchangedDocumentContext.GuidelineSpecifiedDocumentContextParameterId.ToFacturXProfile() == xmp.FacturX.ConformanceLevel?.ToFacturXProfile(); } diff --git a/FacturXDotNet/Validation/BusinessRules/Hybrid/BrHybridFr01.cs b/FacturXDotNet/Validation/BusinessRules/Hybrid/BrHybridFr01.cs index 9164c908..2f53964c 100644 --- a/FacturXDotNet/Validation/BusinessRules/Hybrid/BrHybridFr01.cs +++ b/FacturXDotNet/Validation/BusinessRules/Hybrid/BrHybridFr01.cs @@ -5,7 +5,7 @@ record BrHybridFr01() : HybridBusinessRule( "If the Buyer Country BT-40 is France and the Seller Country BT-55 is France the XRECHNUNG profile SHALL NOT be used." ) { - public override bool Check(FacturXDocument invoice) => + public override bool Check(XmpMetadata xmp, string ciiAttachmentName, CrossIndustryInvoice cii) => // TODO true; } diff --git a/FacturXDotNet/Validation/BusinessRules/Hybrid/HybridBusinessRule.cs b/FacturXDotNet/Validation/BusinessRules/Hybrid/HybridBusinessRule.cs deleted file mode 100644 index 78b08364..00000000 --- a/FacturXDotNet/Validation/BusinessRules/Hybrid/HybridBusinessRule.cs +++ /dev/null @@ -1,23 +0,0 @@ -using FacturXDotNet.Models; - -namespace FacturXDotNet.Validation.BusinessRules.Hybrid; - -/// -/// Represents a business rule for validating a Factur-X document. -/// Hybrid rules are about the data in the document that is not part of the Cross-Industry Invoice, e.g. XMP metadata. -/// -/// The name of the rule. -/// A description of the rule. -/// The severity of the rule. -public abstract record HybridBusinessRule(string Name, string Description, FacturXBusinessRuleSeverity Severity = FacturXBusinessRuleSeverity.Fatal) : FacturXBusinessRule( - Name, - Description, - FacturXProfileFlags.All, - Severity -) -{ - /// - /// Returns a string representation of the business rule. - /// - public override string Format() => $"[HYBRID] {Name} - {Description}"; -} diff --git a/FacturXDotNet/Validation/BusinessRules/HybridBusinessRule.cs b/FacturXDotNet/Validation/BusinessRules/HybridBusinessRule.cs new file mode 100644 index 00000000..022957b9 --- /dev/null +++ b/FacturXDotNet/Validation/BusinessRules/HybridBusinessRule.cs @@ -0,0 +1,25 @@ +namespace FacturXDotNet.Validation.BusinessRules; + +/// +/// Represents a HYBRID rule for validating a Factur-X document. +/// +/// The name of the rule. +/// A description of the rule. +/// The severity of the rule. +public abstract record HybridBusinessRule(string Name, string Description, FacturXBusinessRuleSeverity Severity = FacturXBusinessRuleSeverity.Fatal) : BusinessRule( + Name, + Description +) +{ + /// + /// Determines whether the invoice satisfies the conditions defined by the rule. + /// + /// The XMP metadata to validate. + /// + /// The Cross-Industry Invoice to validate. + /// true if the rule is satisfied by the invoice; otherwise false. + public abstract bool Check(XmpMetadata xmp, string ciiAttachmentName, CrossIndustryInvoice cii); + + /// + public override string Format() => $"[HYBRID] {Name} - {Description}"; +} diff --git a/FacturXDotNet/Validation/FacturXValidationOptions.cs b/FacturXDotNet/Validation/FacturXValidationOptions.cs index 6e304f47..c89fa091 100644 --- a/FacturXDotNet/Validation/FacturXValidationOptions.cs +++ b/FacturXDotNet/Validation/FacturXValidationOptions.cs @@ -1,5 +1,6 @@ using FacturXDotNet.Models; using FacturXDotNet.Validation.BusinessRules.CII; +using Microsoft.Extensions.Logging; namespace FacturXDotNet.Validation; @@ -28,6 +29,11 @@ public class FacturXValidationOptions /// public List RulesToSkip { get; } = []; + /// + /// The logger that should be used by the validator. + /// + public ILogger? Logger { get; set; } + /// /// Skips all Cross-Industry Invoice business rules during validation. /// diff --git a/FacturXDotNet/Validation/FacturXValidationResult.cs b/FacturXDotNet/Validation/FacturXValidationResult.cs index a60b12a5..04a76594 100644 --- a/FacturXDotNet/Validation/FacturXValidationResult.cs +++ b/FacturXDotNet/Validation/FacturXValidationResult.cs @@ -20,27 +20,35 @@ namespace FacturXDotNet.Validation; /// /// The collection of business rules that were not checked. public readonly record struct FacturXValidationResult( - IReadOnlyCollection Passed, - IReadOnlyCollection Fatal, - IReadOnlyCollection Warning, - IReadOnlyCollection Information, - IReadOnlyCollection ExpectedToFail, - IReadOnlyCollection Skipped + IReadOnlyCollection Passed, + IReadOnlyCollection Fatal, + IReadOnlyCollection Warning, + IReadOnlyCollection Information, + IReadOnlyCollection ExpectedToFail, + IReadOnlyCollection Skipped ) { /// - /// Gets the profiles that are valid for the document. + /// The profile that was expected for the document. This is the profile that is specified in the document. + /// + public FacturXProfile ExpectedProfile { get; } + + /// + /// The profiles that are valid for the document. /// public FacturXProfileFlags ValidProfiles { get; } = ComputeActualProfile(Fatal, ExpectedToFail); /// - /// Gets a value indicating whether the validation was successful. + /// Whether the validation was successful. /// /// /// The validation is considered successful if no business rules have failed, except those that were expected to fail. /// - public bool Success => Fatal.Count == 0; + public bool Success => Fatal == null || Fatal.Count == 0; - static FacturXProfileFlags ComputeActualProfile(IReadOnlyCollection failed, IReadOnlyCollection expectedToFail) => + static FacturXProfileFlags ComputeActualProfile( + IReadOnlyCollection failed, + IReadOnlyCollection expectedToFail + ) => failed.Concat(expectedToFail).Select(r => r.Profiles).Aggregate(FacturXProfileFlags.All, (result, failedProfiles) => result & ~failedProfiles); } diff --git a/FacturXDotNet/Validation/FacturXValidationResultBuilder.cs b/FacturXDotNet/Validation/FacturXValidationResultBuilder.cs new file mode 100644 index 00000000..d38980ff --- /dev/null +++ b/FacturXDotNet/Validation/FacturXValidationResultBuilder.cs @@ -0,0 +1,18 @@ +using FacturXDotNet.Validation.BusinessRules; + +namespace FacturXDotNet.Validation; + +class FacturXValidationResultBuilder +{ + public FacturXValidationResultBuilder AddError(string error) => + // TODO: Implement this method + this; + + public FacturXValidationResultBuilder AddRuleStatus(BusinessRule rule, BusinessRuleExpectedValidationStatus expectedStatus, BusinessRuleValidationStatus status) => + // TODO: Implement this method + this; + + public FacturXValidationResult Build() => + // TODO: Implement this method + new(); +} diff --git a/FacturXDotNet/Validation/FacturXValidator.cs b/FacturXDotNet/Validation/FacturXValidator.cs index a2faba55..e807f2e8 100644 --- a/FacturXDotNet/Validation/FacturXValidator.cs +++ b/FacturXDotNet/Validation/FacturXValidator.cs @@ -1,7 +1,10 @@ using FacturXDotNet.Models; +using FacturXDotNet.Parsing.CII; +using FacturXDotNet.Parsing.XMP; using FacturXDotNet.Validation.BusinessRules; using FacturXDotNet.Validation.BusinessRules.CII; using FacturXDotNet.Validation.BusinessRules.Hybrid; +using CrossIndustryInvoiceBusinessRule = FacturXDotNet.Validation.BusinessRules.CrossIndustryInvoiceBusinessRule; namespace FacturXDotNet.Validation; @@ -22,27 +25,50 @@ public class FacturXValidator(FacturXValidationOptions? options = null) /// This method applies business rules and returns true if all are satisfied; otherwise, false. /// Validation stops at the first failing rule for efficiency. /// - /// Use if you need a detailed report of all rule evaluations. + /// Use if you need a detailed report of all rule evaluations. /// /// /// The invoice to validate. + /// The name of the attachment containing the Cross-Industry Invoice XML file. If not specified, the default name 'factur-x.xml' will be used. + /// The password to open the PDF document. + /// The cancellation token. /// true if the invoice meets all required business rules; otherwise, false. - public bool IsValid(FacturXDocument invoice) + public async Task IsValidAsync(FacturXDocument invoice, string? ciiAttachmentName = null, string? password = null, CancellationToken cancellationToken = default) { - FacturXProfile profile = GetExpectedProfile(invoice); - IEnumerable rules = CrossIndustryInvoiceBusinessRules.Rules.Concat(HybridBusinessRules.Rules) + ciiAttachmentName ??= "factur-x.xml"; + (XmpMetadata? xmp, CrossIndustryInvoiceAttachment? ciiAttachment) = await ExtractXmpAndCiiAsync(invoice, ciiAttachmentName, password, cancellationToken); + if (xmp == null || ciiAttachment == null) + { + return false; + } + + CrossIndustryInvoice cii = await ciiAttachment.GetCrossIndustryInvoiceAsync(password, cancellationToken: cancellationToken); + FacturXProfile profile = GetExpectedProfile(xmp, cii); + + IEnumerable hybridRules = HybridBusinessRules.Rules .Where(rule => rule.Severity is FacturXBusinessRuleSeverity.Fatal || rule.Severity is FacturXBusinessRuleSeverity.Warning && _options.TreatWarningsAsErrors) + .Where(rule => !ShouldSkipRule(rule)); + if (!hybridRules.All(rule => rule.Check(xmp, ciiAttachment.Name, cii))) + { + return false; + } + + IEnumerable businessRules = CrossIndustryInvoiceBusinessRules.Rules .Where(rule => !ShouldSkipRule(rule)) .Where(rule => rule.Profiles.Match(profile)); + if (!businessRules.All(rule => rule.Check(cii))) + { + return false; + } - return rules.All(rule => rule.Check(invoice)); + return true; } /// /// Computes a detailed validation result for the given invoice. /// /// - /// Unlike , this method evaluates all business rules and categorizes them as: + /// Unlike , this method evaluates all business rules and categorizes them as: /// /// /// Passed - Rules that were successfully met. @@ -57,85 +83,115 @@ public bool IsValid(FacturXDocument invoice) /// This method provides full validation at the cost of additional computation time and memory usage. /// /// The invoice to validate. + /// The name of the attachment containing the Cross-Industry Invoice XML file. If not specified, the default name 'factur-x.xml' will be used. + /// The password to open the PDF document. + /// The cancellation token. /// /// A containing details of passed, failed, and skipped business rules. /// - public FacturXValidationResult GetValidationResult(FacturXDocument invoice) + public async Task GetValidationResultAsync( + FacturXDocument invoice, + string? ciiAttachmentName = null, + string? password = null, + CancellationToken cancellationToken = default + ) { - IEnumerable rules = CrossIndustryInvoiceBusinessRules.Rules.Concat(HybridBusinessRules.Rules); - (List passed, List failed, List skipped) = CheckRules(rules, invoice); - return BuildValidationResult(invoice, failed, passed, skipped); - } + ciiAttachmentName ??= "factur-x.xml"; + FacturXValidationResultBuilder builder = new(); - (List, List, List) CheckRules(IEnumerable rules, FacturXDocument invoice) - { - List passed = []; - List failed = []; - List skipped = []; + (XmpMetadata? xmp, CrossIndustryInvoiceAttachment? ciiAttachment) = await ExtractXmpAndCiiAsync(invoice, ciiAttachmentName, password, cancellationToken); + + const string ciiNotFoundErrorMessage = "The Cross-Industry Invoice data could not be extracted."; + const string xmpNotFoundErrorMessage = "The XMP metadata could not be extracted."; - foreach (FacturXBusinessRule rule in rules) + if (xmp == null || ciiAttachment == null) { - if (ShouldSkipRule(rule)) + if (xmp == null) { - skipped.Add(rule); - continue; + builder.AddError(xmpNotFoundErrorMessage); } - if (rule.Check(invoice)) + if (ciiAttachment == null) + { + builder.AddError(ciiNotFoundErrorMessage); + } + + return builder.Build(); + } + + CrossIndustryInvoice cii = await ciiAttachment.GetCrossIndustryInvoiceAsync( + password, + new CrossIndustryInvoiceParserOptions { Logger = options?.Logger }, + cancellationToken + ); + + CheckHybridRules(builder, xmp, ciiAttachmentName, cii); + CheckBusinessRules(builder, xmp, cii); + + return builder.Build(); + } + + void CheckHybridRules(FacturXValidationResultBuilder builder, XmpMetadata xmp, string ciiAttachmentName, CrossIndustryInvoice cii) + { + foreach (HybridBusinessRule rule in HybridBusinessRules.Rules) + { + // Hybrid rules are always expected to pass + BusinessRuleExpectedValidationStatus expectation = BusinessRuleExpectedValidationStatus.Success; + + if (ShouldSkipRule(rule)) { - passed.Add(rule); + builder.AddRuleStatus(rule, expectation, BusinessRuleValidationStatus.Skipped); } else { - failed.Add(rule); + BusinessRuleValidationStatus status = rule.Check(xmp, ciiAttachmentName, cii) ? BusinessRuleValidationStatus.Passed : BusinessRuleValidationStatus.Failed; + builder.AddRuleStatus(rule, expectation, status); } } - - return (passed, failed, skipped); } - FacturXValidationResult BuildValidationResult(FacturXDocument invoice, List failed, List passed, List skipped) + void CheckBusinessRules(FacturXValidationResultBuilder builder, XmpMetadata xmp, CrossIndustryInvoice cii) { - FacturXProfile profile = GetExpectedProfile(invoice); + FacturXProfile profile = GetExpectedProfile(xmp, cii); - List fatal = []; - List warning = []; - List information = []; - List expectedToFail = []; - - foreach (FacturXBusinessRule failedRule in failed) + foreach (CrossIndustryInvoiceBusinessRule rule in CrossIndustryInvoiceBusinessRules.Rules) { - if (!failedRule.Profiles.Match(profile)) + // Hybrid rules are always expected to pass + BusinessRuleExpectedValidationStatus expectation = + IsRuleExpectedToFail(rule, profile) ? BusinessRuleExpectedValidationStatus.Failure : BusinessRuleExpectedValidationStatus.Success; + + if (ShouldSkipRule(rule)) { - expectedToFail.Add(failedRule); - continue; + builder.AddRuleStatus(rule, expectation, BusinessRuleValidationStatus.Skipped); } - - switch (failedRule.Severity) + else { - case FacturXBusinessRuleSeverity.Fatal: - case FacturXBusinessRuleSeverity.Warning when _options.TreatWarningsAsErrors: - fatal.Add(failedRule); - break; - case FacturXBusinessRuleSeverity.Warning: - warning.Add(failedRule); - break; - case FacturXBusinessRuleSeverity.Information: - information.Add(failedRule); - break; - default: - throw new ArgumentOutOfRangeException(nameof(failedRule.Severity), failedRule.Severity, null); + BusinessRuleValidationStatus status = rule.Check(cii) ? BusinessRuleValidationStatus.Passed : BusinessRuleValidationStatus.Failed; + builder.AddRuleStatus(rule, expectation, status); } } - - return new FacturXValidationResult(passed, fatal, warning, information, expectedToFail, skipped); } - bool ShouldSkipRule(FacturXBusinessRule rule) => _options.RulesToSkip.Any(r => string.Equals(rule.Name, r, StringComparison.InvariantCultureIgnoreCase)); - - FacturXProfile GetExpectedProfile(FacturXDocument invoice) => + FacturXProfile GetExpectedProfile(XmpMetadata xmp, CrossIndustryInvoice cii) => _options.ProfileOverride.HasValue && _options.ProfileOverride is not FacturXProfile.None ? _options.ProfileOverride.Value - : invoice.XmpMetadata.FacturX?.ConformanceLevel?.ToFacturXProfile() - ?? invoice.CrossIndustryInvoice.ExchangedDocumentContext.GuidelineSpecifiedDocumentContextParameterId.ToFacturXProfileOrNull() ?? FacturXProfile.Minimum; + : xmp.FacturX?.ConformanceLevel?.ToFacturXProfile() + ?? cii.ExchangedDocumentContext.GuidelineSpecifiedDocumentContextParameterId.ToFacturXProfileOrNull() ?? FacturXProfile.Minimum; + + async Task<(XmpMetadata? Xmp, CrossIndustryInvoiceAttachment? Cii)> ExtractXmpAndCiiAsync( + FacturXDocument invoice, + string ciiAttachmentName, + string? password, + CancellationToken cancellationToken + ) + { + XmpMetadata? xmp = await invoice.GetXmpMetadataAsync(password, new XmpMetadataParserOptions { Logger = options?.Logger }, cancellationToken); + CrossIndustryInvoiceAttachment? cii = await invoice.GetCrossIndustryInvoiceAttachmentAsync(ciiAttachmentName, password, cancellationToken); + + return (xmp, cii); + } + + static bool IsRuleExpectedToFail(CrossIndustryInvoiceBusinessRule rule, FacturXProfile profile) => !rule.Profiles.Match(profile); + bool ShouldSkipRule(CrossIndustryInvoiceBusinessRule rule) => _options.RulesToSkip.Any(r => string.Equals(rule.Name, r, StringComparison.InvariantCultureIgnoreCase)); + bool ShouldSkipRule(HybridBusinessRule rule) => _options.RulesToSkip.Any(r => string.Equals(rule.Name, r, StringComparison.InvariantCultureIgnoreCase)); } diff --git a/Tests.FacturXDotNet/ParseAndValidateIntegrationTests.cs b/Tests.FacturXDotNet/ParseAndValidateIntegrationTests.cs index 1b29a59a..8715c291 100644 --- a/Tests.FacturXDotNet/ParseAndValidateIntegrationTests.cs +++ b/Tests.FacturXDotNet/ParseAndValidateIntegrationTests.cs @@ -1,6 +1,5 @@ using FacturXDotNet; using FacturXDotNet.Models; -using FacturXDotNet.Parsing; using FacturXDotNet.Validation; using Shouldly; @@ -68,13 +67,10 @@ public class ParseAndValidateIntegrationTests [DataRow("TestFiles/FacturX/4.EXTENDED/Facture_UC1_2023025_F-LE_FOURNISSEUR-POUR-L'ACHETEUR_EXTENDED.pdf", FacturXProfile.Extended)] public async Task ParseAndValidateProfile(string filePath, FacturXProfile profile) { - await using FileStream file = File.OpenRead(filePath); - - FacturXParser parser = new(); - FacturXDocument invoice = await parser.ParseFacturXPdfAsync(file); + FacturXDocument invoice = await FacturXDocument.FromFileAsync(filePath); FacturXValidator validator = new(); - FacturXValidationResult validationResult = validator.GetValidationResult(invoice); + FacturXValidationResult validationResult = await validator.GetValidationResultAsync(invoice); validationResult.Fatal.ShouldBeEmpty(); validationResult.ValidProfiles.ShouldBe(profile.AndLower()); From 2124a3071f9c2a605a56bd9dfbfa704027c8cb1a Mon Sep 17 00:00:00 2001 From: ismailbennani Date: Sun, 23 Mar 2025 00:47:29 +0100 Subject: [PATCH 2/4] change validation result to reflect changes in validation --- .../BusinessRuleExpectedValidationStatus.cs | 2 +- .../BusinessRuleValidationResult.cs | 18 +++++++ .../Validation/BusinessRules/BusinessRule.cs | 8 +++- ...InvoiceShallHaveSpecificationIdentifier.cs | 2 +- .../CII/Br02InvoiceShallHaveInvoiceNumber.cs | 2 +- .../CII/Br03InvoiceShallHaveIssueDate.cs | 2 +- .../CII/Br04InvoiceShallHaveTypeCode.cs | 2 +- .../CII/Br05InvoiceShallHaveCurrencyCode.cs | 6 +-- .../CII/Br06InvoiceShallHaveSellerName.cs | 3 +- .../CII/Br07InvoiceShallHaveBuyerName.cs | 2 +- ...Br08InvoiceShallHaveSellerPostalAddress.cs | 4 +- ...lHaveSellerPostalAddressWithCountryCode.cs | 4 +- ...13InvoiceShallHaveTotalAmountWithoutVat.cs | 6 +-- .../Br14InvoiceShallHaveTotalAmountWithVat.cs | 6 +-- ...Br15InvoiceShallHaveAmountDueForPayment.cs | 6 +-- .../Validation/BusinessRules/CII/BrCo09.cs | 6 +-- .../Validation/BusinessRules/CII/BrCo26.cs | 6 +-- ...oiceTotalAmountWithoutVatHasTwoDecimals.cs | 6 +-- ...ec13InvoiceTotalVatAmountHasTwoDecimals.cs | 6 +-- ...BrDec14InvoiceTotalAmountHasTwoDecimals.cs | 6 +-- .../BrDec18InvoiceDueAmountHasTwoDecimals.cs | 6 +-- .../StandardAndReducedRate/BrS01.cs | 2 +- .../CrossIndustryInvoiceBusinessRule.cs | 9 +++- .../BusinessRules/Hybrid/BrHybrid01.cs | 4 +- .../BusinessRules/Hybrid/BrHybrid02.cs | 2 +- .../BusinessRules/Hybrid/BrHybrid03.cs | 4 +- .../BusinessRules/Hybrid/BrHybrid04.cs | 4 +- .../BusinessRules/Hybrid/BrHybrid05.cs | 4 +- .../BusinessRules/Hybrid/BrHybrid06.cs | 4 +- .../BusinessRules/Hybrid/BrHybrid07.cs | 4 +- .../BusinessRules/Hybrid/BrHybrid08.cs | 4 +- .../BusinessRules/Hybrid/BrHybrid09.cs | 2 +- .../BusinessRules/Hybrid/BrHybrid10.cs | 2 +- .../BusinessRules/Hybrid/BrHybrid11.cs | 2 +- .../BusinessRules/Hybrid/BrHybrid12.cs | 2 +- .../BusinessRules/Hybrid/BrHybrid13.cs | 2 +- .../BusinessRules/Hybrid/BrHybrid14.cs | 2 +- .../BusinessRules/Hybrid/BrHybrid15.cs | 4 +- .../BusinessRules/Hybrid/BrHybridFr01.cs | 2 +- .../BusinessRules/HybridBusinessRule.cs | 10 ++-- .../Validation/FacturXValidationResult.cs | 39 ++++----------- .../FacturXValidationResultBuilder.cs | 34 ++++++++++---- FacturXDotNet/Validation/FacturXValidator.cs | 47 ++++++------------- .../ParseAndValidateIntegrationTests.cs | 3 +- 44 files changed, 152 insertions(+), 149 deletions(-) create mode 100644 FacturXDotNet/Validation/BusinessRuleValidationResult.cs diff --git a/FacturXDotNet/Validation/BusinessRuleExpectedValidationStatus.cs b/FacturXDotNet/Validation/BusinessRuleExpectedValidationStatus.cs index 6ef4ca19..d0178696 100644 --- a/FacturXDotNet/Validation/BusinessRuleExpectedValidationStatus.cs +++ b/FacturXDotNet/Validation/BusinessRuleExpectedValidationStatus.cs @@ -3,7 +3,7 @@ /// /// Represent the expected validation status of a business rule. Some rules are expected to fail because they are associated with a higher profile. /// -enum BusinessRuleExpectedValidationStatus +public enum BusinessRuleExpectedValidationStatus { /// /// The rule is expected to pass. diff --git a/FacturXDotNet/Validation/BusinessRuleValidationResult.cs b/FacturXDotNet/Validation/BusinessRuleValidationResult.cs new file mode 100644 index 00000000..632f6ba9 --- /dev/null +++ b/FacturXDotNet/Validation/BusinessRuleValidationResult.cs @@ -0,0 +1,18 @@ +using FacturXDotNet.Validation.BusinessRules; + +namespace FacturXDotNet.Validation; + +/// +/// The result of a business rule validation. +/// +/// The rule that was validated. +/// The expected status of the rule. +/// The actual status of the rule. +public readonly record struct BusinessRuleValidationResult(BusinessRule Rule, BusinessRuleExpectedValidationStatus ExpectedStatus, BusinessRuleValidationStatus Status) +{ + /// + /// Returns true if the validation has failed, i.e., the rule was not expected to fail, and it failed. + /// + public bool HasFailed => + Rule.Severity is FacturXBusinessRuleSeverity.Fatal && ExpectedStatus is not BusinessRuleExpectedValidationStatus.Failure && Status is BusinessRuleValidationStatus.Failed; +} diff --git a/FacturXDotNet/Validation/BusinessRules/BusinessRule.cs b/FacturXDotNet/Validation/BusinessRules/BusinessRule.cs index a6dde8ae..b3c8d2b3 100644 --- a/FacturXDotNet/Validation/BusinessRules/BusinessRule.cs +++ b/FacturXDotNet/Validation/BusinessRules/BusinessRule.cs @@ -1,11 +1,15 @@ -namespace FacturXDotNet.Validation.BusinessRules; +using FacturXDotNet.Models; + +namespace FacturXDotNet.Validation.BusinessRules; /// /// Represents any business rule for validating a Factur-X document. /// /// The name of the rule. +/// The profiles in which this rule should be enforced. /// A description of the rule. -public abstract record BusinessRule(string Name, string Description) +/// The severity of the rule. +public abstract record BusinessRule(string Name, string Description, FacturXProfileFlags Profiles, FacturXBusinessRuleSeverity Severity) { /// /// Formats the rule as a string. diff --git a/FacturXDotNet/Validation/BusinessRules/CII/Br01InvoiceShallHaveSpecificationIdentifier.cs b/FacturXDotNet/Validation/BusinessRules/CII/Br01InvoiceShallHaveSpecificationIdentifier.cs index 8c5e7bba..8d56eea3 100644 --- a/FacturXDotNet/Validation/BusinessRules/CII/Br01InvoiceShallHaveSpecificationIdentifier.cs +++ b/FacturXDotNet/Validation/BusinessRules/CII/Br01InvoiceShallHaveSpecificationIdentifier.cs @@ -8,5 +8,5 @@ record Br01InvoiceShallHaveSpecificationIdentifier() : CrossIndustryInvoiceBusin FacturXProfile.Minimum.AndHigher() ) { - public override bool Check(CrossIndustryInvoice invoice) => Enum.IsDefined(invoice.ExchangedDocumentContext.GuidelineSpecifiedDocumentContextParameterId); + public override bool Check(CrossIndustryInvoice? cii) => cii != null && Enum.IsDefined(cii.ExchangedDocumentContext.GuidelineSpecifiedDocumentContextParameterId); } diff --git a/FacturXDotNet/Validation/BusinessRules/CII/Br02InvoiceShallHaveInvoiceNumber.cs b/FacturXDotNet/Validation/BusinessRules/CII/Br02InvoiceShallHaveInvoiceNumber.cs index 3528ce8b..076dc4f2 100644 --- a/FacturXDotNet/Validation/BusinessRules/CII/Br02InvoiceShallHaveInvoiceNumber.cs +++ b/FacturXDotNet/Validation/BusinessRules/CII/Br02InvoiceShallHaveInvoiceNumber.cs @@ -4,5 +4,5 @@ namespace FacturXDotNet.Validation.BusinessRules.CII; record Br02InvoiceShallHaveInvoiceNumber() : CrossIndustryInvoiceBusinessRule("BR-02", "An Invoice shall have an Invoice number (BT-1).", FacturXProfile.Minimum.AndHigher()) { - public override bool Check(CrossIndustryInvoice invoice) => !string.IsNullOrWhiteSpace(invoice.ExchangedDocument.Id); + public override bool Check(CrossIndustryInvoice? cii) => !string.IsNullOrWhiteSpace(cii?.ExchangedDocument.Id); } diff --git a/FacturXDotNet/Validation/BusinessRules/CII/Br03InvoiceShallHaveIssueDate.cs b/FacturXDotNet/Validation/BusinessRules/CII/Br03InvoiceShallHaveIssueDate.cs index f796e0dc..a0dcd577 100644 --- a/FacturXDotNet/Validation/BusinessRules/CII/Br03InvoiceShallHaveIssueDate.cs +++ b/FacturXDotNet/Validation/BusinessRules/CII/Br03InvoiceShallHaveIssueDate.cs @@ -4,5 +4,5 @@ namespace FacturXDotNet.Validation.BusinessRules.CII; record Br03InvoiceShallHaveIssueDate() : CrossIndustryInvoiceBusinessRule("BR-03", "An Invoice shall have an Invoice issue date (BT-2).", FacturXProfile.Minimum.AndHigher()) { - public override bool Check(CrossIndustryInvoice invoice) => invoice.ExchangedDocument.IssueDateTime != default; + public override bool Check(CrossIndustryInvoice? cii) => cii != null && cii.ExchangedDocument.IssueDateTime != default; } diff --git a/FacturXDotNet/Validation/BusinessRules/CII/Br04InvoiceShallHaveTypeCode.cs b/FacturXDotNet/Validation/BusinessRules/CII/Br04InvoiceShallHaveTypeCode.cs index 623403d4..cffefa89 100644 --- a/FacturXDotNet/Validation/BusinessRules/CII/Br04InvoiceShallHaveTypeCode.cs +++ b/FacturXDotNet/Validation/BusinessRules/CII/Br04InvoiceShallHaveTypeCode.cs @@ -4,5 +4,5 @@ namespace FacturXDotNet.Validation.BusinessRules.CII; record Br04InvoiceShallHaveTypeCode() : CrossIndustryInvoiceBusinessRule("BR-04", "An Invoice shall have an Invoice type code (BT-3).", FacturXProfile.Minimum.AndHigher()) { - public override bool Check(CrossIndustryInvoice invoice) => Enum.IsDefined(invoice.ExchangedDocument.TypeCode); + public override bool Check(CrossIndustryInvoice? cii) => cii != null && Enum.IsDefined(cii.ExchangedDocument.TypeCode); } diff --git a/FacturXDotNet/Validation/BusinessRules/CII/Br05InvoiceShallHaveCurrencyCode.cs b/FacturXDotNet/Validation/BusinessRules/CII/Br05InvoiceShallHaveCurrencyCode.cs index 18be5a58..fc02dccf 100644 --- a/FacturXDotNet/Validation/BusinessRules/CII/Br05InvoiceShallHaveCurrencyCode.cs +++ b/FacturXDotNet/Validation/BusinessRules/CII/Br05InvoiceShallHaveCurrencyCode.cs @@ -4,7 +4,7 @@ namespace FacturXDotNet.Validation.BusinessRules.CII; record Br05InvoiceShallHaveCurrencyCode() : CrossIndustryInvoiceBusinessRule("BR-05", "An Invoice shall have an Invoice currency code (BT-5).", FacturXProfile.Minimum.AndHigher()) { - public override bool Check(CrossIndustryInvoice invoice) => - invoice.SupplyChainTradeTransaction.ApplicableHeaderTradeSettlement != null - && !string.IsNullOrWhiteSpace(invoice.SupplyChainTradeTransaction.ApplicableHeaderTradeSettlement.InvoiceCurrencyCode); + public override bool Check(CrossIndustryInvoice? cii) => + cii?.SupplyChainTradeTransaction.ApplicableHeaderTradeSettlement != null + && !string.IsNullOrWhiteSpace(cii.SupplyChainTradeTransaction.ApplicableHeaderTradeSettlement.InvoiceCurrencyCode); } diff --git a/FacturXDotNet/Validation/BusinessRules/CII/Br06InvoiceShallHaveSellerName.cs b/FacturXDotNet/Validation/BusinessRules/CII/Br06InvoiceShallHaveSellerName.cs index 78eb753f..9ab4b081 100644 --- a/FacturXDotNet/Validation/BusinessRules/CII/Br06InvoiceShallHaveSellerName.cs +++ b/FacturXDotNet/Validation/BusinessRules/CII/Br06InvoiceShallHaveSellerName.cs @@ -4,6 +4,5 @@ namespace FacturXDotNet.Validation.BusinessRules.CII; record Br06InvoiceShallHaveSellerName() : CrossIndustryInvoiceBusinessRule("BR-06", "An Invoice shall contain the Seller name (BT-27).", FacturXProfile.Minimum.AndHigher()) { - public override bool Check(CrossIndustryInvoice invoice) => - !string.IsNullOrWhiteSpace(invoice.SupplyChainTradeTransaction.ApplicableHeaderTradeAgreement.SellerTradeParty.Name); + public override bool Check(CrossIndustryInvoice? cii) => !string.IsNullOrWhiteSpace(cii?.SupplyChainTradeTransaction.ApplicableHeaderTradeAgreement.SellerTradeParty.Name); } diff --git a/FacturXDotNet/Validation/BusinessRules/CII/Br07InvoiceShallHaveBuyerName.cs b/FacturXDotNet/Validation/BusinessRules/CII/Br07InvoiceShallHaveBuyerName.cs index 346bb33f..99e954a0 100644 --- a/FacturXDotNet/Validation/BusinessRules/CII/Br07InvoiceShallHaveBuyerName.cs +++ b/FacturXDotNet/Validation/BusinessRules/CII/Br07InvoiceShallHaveBuyerName.cs @@ -4,5 +4,5 @@ namespace FacturXDotNet.Validation.BusinessRules.CII; record Br07InvoiceShallHaveBuyerName() : CrossIndustryInvoiceBusinessRule("BR-07", "An Invoice shall contain the Buyer name (BT-44).", FacturXProfile.Minimum.AndHigher()) { - public override bool Check(CrossIndustryInvoice invoice) => !string.IsNullOrWhiteSpace(invoice.SupplyChainTradeTransaction.ApplicableHeaderTradeAgreement.BuyerTradeParty.Name); + public override bool Check(CrossIndustryInvoice? cii) => !string.IsNullOrWhiteSpace(cii?.SupplyChainTradeTransaction.ApplicableHeaderTradeAgreement.BuyerTradeParty.Name); } diff --git a/FacturXDotNet/Validation/BusinessRules/CII/Br08InvoiceShallHaveSellerPostalAddress.cs b/FacturXDotNet/Validation/BusinessRules/CII/Br08InvoiceShallHaveSellerPostalAddress.cs index 27da1b19..c64de1fd 100644 --- a/FacturXDotNet/Validation/BusinessRules/CII/Br08InvoiceShallHaveSellerPostalAddress.cs +++ b/FacturXDotNet/Validation/BusinessRules/CII/Br08InvoiceShallHaveSellerPostalAddress.cs @@ -8,8 +8,8 @@ record Br08InvoiceShallHaveSellerPostalAddress() : CrossIndustryInvoiceBusinessR FacturXProfile.Minimum.AndHigher() ) { - public override bool Check(CrossIndustryInvoice invoice) => + public override bool Check(CrossIndustryInvoice? cii) => // Nullability analysis should guarantee that this is always true, however it is still a BT so we check it anyway // ReSharper disable once ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract - invoice.SupplyChainTradeTransaction.ApplicableHeaderTradeAgreement.SellerTradeParty.PostalTradeAddress != null; + cii?.SupplyChainTradeTransaction.ApplicableHeaderTradeAgreement.SellerTradeParty.PostalTradeAddress != null; } diff --git a/FacturXDotNet/Validation/BusinessRules/CII/Br09InvoiceShallHaveSellerPostalAddressWithCountryCode.cs b/FacturXDotNet/Validation/BusinessRules/CII/Br09InvoiceShallHaveSellerPostalAddressWithCountryCode.cs index f789c8e9..a83b26a6 100644 --- a/FacturXDotNet/Validation/BusinessRules/CII/Br09InvoiceShallHaveSellerPostalAddressWithCountryCode.cs +++ b/FacturXDotNet/Validation/BusinessRules/CII/Br09InvoiceShallHaveSellerPostalAddressWithCountryCode.cs @@ -8,6 +8,6 @@ record Br09InvoiceShallHaveSellerPostalAddressWithCountryCode() : CrossIndustryI FacturXProfile.Minimum.AndHigher() ) { - public override bool Check(CrossIndustryInvoice invoice) => - !string.IsNullOrWhiteSpace(invoice.SupplyChainTradeTransaction.ApplicableHeaderTradeAgreement.SellerTradeParty.PostalTradeAddress.CountryId); + public override bool Check(CrossIndustryInvoice? cii) => + !string.IsNullOrWhiteSpace(cii?.SupplyChainTradeTransaction.ApplicableHeaderTradeAgreement.SellerTradeParty.PostalTradeAddress.CountryId); } diff --git a/FacturXDotNet/Validation/BusinessRules/CII/Br13InvoiceShallHaveTotalAmountWithoutVat.cs b/FacturXDotNet/Validation/BusinessRules/CII/Br13InvoiceShallHaveTotalAmountWithoutVat.cs index 5b753b00..972c0b99 100644 --- a/FacturXDotNet/Validation/BusinessRules/CII/Br13InvoiceShallHaveTotalAmountWithoutVat.cs +++ b/FacturXDotNet/Validation/BusinessRules/CII/Br13InvoiceShallHaveTotalAmountWithoutVat.cs @@ -8,7 +8,7 @@ record Br13InvoiceShallHaveTotalAmountWithoutVat() : CrossIndustryInvoiceBusines FacturXProfile.Minimum.AndHigher() ) { - public override bool Check(CrossIndustryInvoice invoice) => - invoice.SupplyChainTradeTransaction.ApplicableHeaderTradeSettlement != null - && invoice.SupplyChainTradeTransaction.ApplicableHeaderTradeSettlement.SpecifiedTradeSettlementHeaderMonetarySummation.TaxBasisTotalAmount != 0; + public override bool Check(CrossIndustryInvoice? cii) => + cii?.SupplyChainTradeTransaction.ApplicableHeaderTradeSettlement != null + && cii.SupplyChainTradeTransaction.ApplicableHeaderTradeSettlement.SpecifiedTradeSettlementHeaderMonetarySummation.TaxBasisTotalAmount != 0; } diff --git a/FacturXDotNet/Validation/BusinessRules/CII/Br14InvoiceShallHaveTotalAmountWithVat.cs b/FacturXDotNet/Validation/BusinessRules/CII/Br14InvoiceShallHaveTotalAmountWithVat.cs index 71f740fc..cdd542ef 100644 --- a/FacturXDotNet/Validation/BusinessRules/CII/Br14InvoiceShallHaveTotalAmountWithVat.cs +++ b/FacturXDotNet/Validation/BusinessRules/CII/Br14InvoiceShallHaveTotalAmountWithVat.cs @@ -8,7 +8,7 @@ record Br14InvoiceShallHaveTotalAmountWithVat() : CrossIndustryInvoiceBusinessRu FacturXProfile.Minimum.AndHigher() ) { - public override bool Check(CrossIndustryInvoice invoice) => - invoice.SupplyChainTradeTransaction.ApplicableHeaderTradeSettlement != null - && invoice.SupplyChainTradeTransaction.ApplicableHeaderTradeSettlement.SpecifiedTradeSettlementHeaderMonetarySummation.GrandTotalAmount != 0; + public override bool Check(CrossIndustryInvoice? cii) => + cii?.SupplyChainTradeTransaction.ApplicableHeaderTradeSettlement != null + && cii.SupplyChainTradeTransaction.ApplicableHeaderTradeSettlement.SpecifiedTradeSettlementHeaderMonetarySummation.GrandTotalAmount != 0; } diff --git a/FacturXDotNet/Validation/BusinessRules/CII/Br15InvoiceShallHaveAmountDueForPayment.cs b/FacturXDotNet/Validation/BusinessRules/CII/Br15InvoiceShallHaveAmountDueForPayment.cs index 9fa2f6c1..a8da1714 100644 --- a/FacturXDotNet/Validation/BusinessRules/CII/Br15InvoiceShallHaveAmountDueForPayment.cs +++ b/FacturXDotNet/Validation/BusinessRules/CII/Br15InvoiceShallHaveAmountDueForPayment.cs @@ -8,7 +8,7 @@ record Br15InvoiceShallHaveAmountDueForPayment() : CrossIndustryInvoiceBusinessR FacturXProfile.Minimum.AndHigher() ) { - public override bool Check(CrossIndustryInvoice invoice) => - invoice.SupplyChainTradeTransaction.ApplicableHeaderTradeSettlement != null - && invoice.SupplyChainTradeTransaction.ApplicableHeaderTradeSettlement.SpecifiedTradeSettlementHeaderMonetarySummation.DuePayableAmount != 0; + public override bool Check(CrossIndustryInvoice? cii) => + cii?.SupplyChainTradeTransaction.ApplicableHeaderTradeSettlement != null + && cii.SupplyChainTradeTransaction.ApplicableHeaderTradeSettlement.SpecifiedTradeSettlementHeaderMonetarySummation.DuePayableAmount != 0; } diff --git a/FacturXDotNet/Validation/BusinessRules/CII/BrCo09.cs b/FacturXDotNet/Validation/BusinessRules/CII/BrCo09.cs index 0974331f..36f96068 100644 --- a/FacturXDotNet/Validation/BusinessRules/CII/BrCo09.cs +++ b/FacturXDotNet/Validation/BusinessRules/CII/BrCo09.cs @@ -12,10 +12,10 @@ The Seller VAT identifier (BT-31), the Seller tax representative VAT identifier FacturXProfile.Minimum.AndHigher() ) { - public override bool Check(CrossIndustryInvoice invoice) => + public override bool Check(CrossIndustryInvoice? cii) => // TODO: also check BT-63 and BT-48 - invoice.SupplyChainTradeTransaction.ApplicableHeaderTradeAgreement.SellerTradeParty.SpecifiedTaxRegistration is { Id: not null } - && CheckPrefix(invoice.SupplyChainTradeTransaction.ApplicableHeaderTradeAgreement.SellerTradeParty.SpecifiedTaxRegistration.Id.AsSpan(0, 2)); + cii?.SupplyChainTradeTransaction.ApplicableHeaderTradeAgreement.SellerTradeParty.SpecifiedTaxRegistration is { Id: not null } + && CheckPrefix(cii.SupplyChainTradeTransaction.ApplicableHeaderTradeAgreement.SellerTradeParty.SpecifiedTaxRegistration.Id.AsSpan(0, 2)); static bool CheckPrefix(ReadOnlySpan prefix) => Iso31661CountryCodesUtils.IsValidCountryCode(prefix) || prefix is "el" || prefix is "El" || prefix is "EL"; } diff --git a/FacturXDotNet/Validation/BusinessRules/CII/BrCo26.cs b/FacturXDotNet/Validation/BusinessRules/CII/BrCo26.cs index 6d5d8896..88818d21 100644 --- a/FacturXDotNet/Validation/BusinessRules/CII/BrCo26.cs +++ b/FacturXDotNet/Validation/BusinessRules/CII/BrCo26.cs @@ -11,8 +11,8 @@ record BrCo26() : CrossIndustryInvoiceBusinessRule( FacturXProfile.Minimum.AndHigher() ) { - public override bool Check(CrossIndustryInvoice invoice) => + public override bool Check(CrossIndustryInvoice? cii) => // TODO: check BT-29 - !string.IsNullOrWhiteSpace(invoice.SupplyChainTradeTransaction.ApplicableHeaderTradeAgreement.SellerTradeParty.SpecifiedLegalOrganization?.Id) - || !string.IsNullOrWhiteSpace(invoice.SupplyChainTradeTransaction.ApplicableHeaderTradeAgreement.SellerTradeParty.SpecifiedTaxRegistration?.Id); + !string.IsNullOrWhiteSpace(cii?.SupplyChainTradeTransaction.ApplicableHeaderTradeAgreement.SellerTradeParty.SpecifiedLegalOrganization?.Id) + || !string.IsNullOrWhiteSpace(cii?.SupplyChainTradeTransaction.ApplicableHeaderTradeAgreement.SellerTradeParty.SpecifiedTaxRegistration?.Id); } diff --git a/FacturXDotNet/Validation/BusinessRules/CII/BrDec12InvoiceTotalAmountWithoutVatHasTwoDecimals.cs b/FacturXDotNet/Validation/BusinessRules/CII/BrDec12InvoiceTotalAmountWithoutVatHasTwoDecimals.cs index 26f9b077..1cf8f238 100644 --- a/FacturXDotNet/Validation/BusinessRules/CII/BrDec12InvoiceTotalAmountWithoutVatHasTwoDecimals.cs +++ b/FacturXDotNet/Validation/BusinessRules/CII/BrDec12InvoiceTotalAmountWithoutVatHasTwoDecimals.cs @@ -9,7 +9,7 @@ record BrDec12InvoiceTotalAmountWithoutVatHasTwoDecimals() : CrossIndustryInvoic FacturXProfile.Minimum.AndHigher() ) { - public override bool Check(CrossIndustryInvoice invoice) => - invoice.SupplyChainTradeTransaction.ApplicableHeaderTradeSettlement?.SpecifiedTradeSettlementHeaderMonetarySummation.TaxBasisTotalAmount == null - || invoice.SupplyChainTradeTransaction.ApplicableHeaderTradeSettlement.SpecifiedTradeSettlementHeaderMonetarySummation.TaxBasisTotalAmount.CountDecimals() <= 2; + public override bool Check(CrossIndustryInvoice? cii) => + cii?.SupplyChainTradeTransaction.ApplicableHeaderTradeSettlement?.SpecifiedTradeSettlementHeaderMonetarySummation.TaxBasisTotalAmount == null + || cii.SupplyChainTradeTransaction.ApplicableHeaderTradeSettlement.SpecifiedTradeSettlementHeaderMonetarySummation.TaxBasisTotalAmount.CountDecimals() <= 2; } diff --git a/FacturXDotNet/Validation/BusinessRules/CII/BrDec13InvoiceTotalVatAmountHasTwoDecimals.cs b/FacturXDotNet/Validation/BusinessRules/CII/BrDec13InvoiceTotalVatAmountHasTwoDecimals.cs index 0576c257..5459a037 100644 --- a/FacturXDotNet/Validation/BusinessRules/CII/BrDec13InvoiceTotalVatAmountHasTwoDecimals.cs +++ b/FacturXDotNet/Validation/BusinessRules/CII/BrDec13InvoiceTotalVatAmountHasTwoDecimals.cs @@ -9,7 +9,7 @@ record BrDec13InvoiceTotalVatAmountHasTwoDecimals() : CrossIndustryInvoiceBusine FacturXProfile.Minimum.AndHigher() ) { - public override bool Check(CrossIndustryInvoice invoice) => - invoice.SupplyChainTradeTransaction.ApplicableHeaderTradeSettlement?.SpecifiedTradeSettlementHeaderMonetarySummation.TaxTotalAmount == null - || invoice.SupplyChainTradeTransaction.ApplicableHeaderTradeSettlement.SpecifiedTradeSettlementHeaderMonetarySummation.TaxTotalAmount.Value.CountDecimals() <= 2; + public override bool Check(CrossIndustryInvoice? cii) => + cii?.SupplyChainTradeTransaction.ApplicableHeaderTradeSettlement?.SpecifiedTradeSettlementHeaderMonetarySummation.TaxTotalAmount == null + || cii.SupplyChainTradeTransaction.ApplicableHeaderTradeSettlement.SpecifiedTradeSettlementHeaderMonetarySummation.TaxTotalAmount.Value.CountDecimals() <= 2; } diff --git a/FacturXDotNet/Validation/BusinessRules/CII/BrDec14InvoiceTotalAmountHasTwoDecimals.cs b/FacturXDotNet/Validation/BusinessRules/CII/BrDec14InvoiceTotalAmountHasTwoDecimals.cs index ec562ed0..5e8ae5de 100644 --- a/FacturXDotNet/Validation/BusinessRules/CII/BrDec14InvoiceTotalAmountHasTwoDecimals.cs +++ b/FacturXDotNet/Validation/BusinessRules/CII/BrDec14InvoiceTotalAmountHasTwoDecimals.cs @@ -9,7 +9,7 @@ record BrDec14InvoiceTotalAmountHasTwoDecimals() : CrossIndustryInvoiceBusinessR FacturXProfile.Minimum.AndHigher() ) { - public override bool Check(CrossIndustryInvoice invoice) => - invoice.SupplyChainTradeTransaction.ApplicableHeaderTradeSettlement?.SpecifiedTradeSettlementHeaderMonetarySummation.GrandTotalAmount == null - || invoice.SupplyChainTradeTransaction.ApplicableHeaderTradeSettlement.SpecifiedTradeSettlementHeaderMonetarySummation.GrandTotalAmount.CountDecimals() <= 2; + public override bool Check(CrossIndustryInvoice? cii) => + cii?.SupplyChainTradeTransaction.ApplicableHeaderTradeSettlement?.SpecifiedTradeSettlementHeaderMonetarySummation.GrandTotalAmount == null + || cii.SupplyChainTradeTransaction.ApplicableHeaderTradeSettlement.SpecifiedTradeSettlementHeaderMonetarySummation.GrandTotalAmount.CountDecimals() <= 2; } diff --git a/FacturXDotNet/Validation/BusinessRules/CII/BrDec18InvoiceDueAmountHasTwoDecimals.cs b/FacturXDotNet/Validation/BusinessRules/CII/BrDec18InvoiceDueAmountHasTwoDecimals.cs index 142ab797..94a0b486 100644 --- a/FacturXDotNet/Validation/BusinessRules/CII/BrDec18InvoiceDueAmountHasTwoDecimals.cs +++ b/FacturXDotNet/Validation/BusinessRules/CII/BrDec18InvoiceDueAmountHasTwoDecimals.cs @@ -9,7 +9,7 @@ record BrDec18InvoiceDueAmountHasTwoDecimals() : CrossIndustryInvoiceBusinessRul FacturXProfile.Minimum.AndHigher() ) { - public override bool Check(CrossIndustryInvoice invoice) => - invoice.SupplyChainTradeTransaction.ApplicableHeaderTradeSettlement?.SpecifiedTradeSettlementHeaderMonetarySummation.DuePayableAmount == null - || invoice.SupplyChainTradeTransaction.ApplicableHeaderTradeSettlement.SpecifiedTradeSettlementHeaderMonetarySummation.DuePayableAmount.CountDecimals() <= 2; + public override bool Check(CrossIndustryInvoice? cii) => + cii?.SupplyChainTradeTransaction.ApplicableHeaderTradeSettlement?.SpecifiedTradeSettlementHeaderMonetarySummation.DuePayableAmount == null + || cii.SupplyChainTradeTransaction.ApplicableHeaderTradeSettlement.SpecifiedTradeSettlementHeaderMonetarySummation.DuePayableAmount.CountDecimals() <= 2; } diff --git a/FacturXDotNet/Validation/BusinessRules/CII/VatRelated/StandardAndReducedRate/BrS01.cs b/FacturXDotNet/Validation/BusinessRules/CII/VatRelated/StandardAndReducedRate/BrS01.cs index 1b36fbee..439ecb61 100644 --- a/FacturXDotNet/Validation/BusinessRules/CII/VatRelated/StandardAndReducedRate/BrS01.cs +++ b/FacturXDotNet/Validation/BusinessRules/CII/VatRelated/StandardAndReducedRate/BrS01.cs @@ -11,7 +11,7 @@ An Invoice that contains an Invoice line (BG-25), a Document level allowance (BG FacturXProfile.BasicWl.AndHigher() ) { - public override bool Check(CrossIndustryInvoice invoice) => + public override bool Check(CrossIndustryInvoice? cii) => // TODO true; } diff --git a/FacturXDotNet/Validation/BusinessRules/CrossIndustryInvoiceBusinessRule.cs b/FacturXDotNet/Validation/BusinessRules/CrossIndustryInvoiceBusinessRule.cs index 40cf5a94..b99f1749 100644 --- a/FacturXDotNet/Validation/BusinessRules/CrossIndustryInvoiceBusinessRule.cs +++ b/FacturXDotNet/Validation/BusinessRules/CrossIndustryInvoiceBusinessRule.cs @@ -8,14 +8,19 @@ namespace FacturXDotNet.Validation.BusinessRules; /// The name of the rule. /// A description of the rule. /// The profiles in which this rule should be enforced. -public abstract record CrossIndustryInvoiceBusinessRule(string Name, string Description, FacturXProfileFlags Profiles) : BusinessRule(Name, Description) +public abstract record CrossIndustryInvoiceBusinessRule(string Name, string Description, FacturXProfileFlags Profiles) : BusinessRule( + Name, + Description, + Profiles, + FacturXBusinessRuleSeverity.Fatal +) { /// /// Determines whether the invoice satisfies the conditions defined by the rule. /// /// The Cross-Industry Invoice to validate. /// true if the rule is satisfied by the invoice; otherwise false. - public abstract bool Check(CrossIndustryInvoice cii); + public abstract bool Check(CrossIndustryInvoice? cii); /// public override string Format() => $"[{Profiles.GetMinProfile()}] {Name} - {Description}"; diff --git a/FacturXDotNet/Validation/BusinessRules/Hybrid/BrHybrid01.cs b/FacturXDotNet/Validation/BusinessRules/Hybrid/BrHybrid01.cs index 32f57edc..10ab6394 100644 --- a/FacturXDotNet/Validation/BusinessRules/Hybrid/BrHybrid01.cs +++ b/FacturXDotNet/Validation/BusinessRules/Hybrid/BrHybrid01.cs @@ -6,7 +6,5 @@ record BrHybrid01() : HybridBusinessRule( FacturXBusinessRuleSeverity.Information ) { - public override bool Check(XmpMetadata xmp, string ciiAttachmentName, CrossIndustryInvoice cii) => - // ReSharper disable once ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract - xmp != null; + public override bool Check(XmpMetadata? xmp, string? ciiAttachmentName, CrossIndustryInvoice? cii) => xmp != null; } diff --git a/FacturXDotNet/Validation/BusinessRules/Hybrid/BrHybrid02.cs b/FacturXDotNet/Validation/BusinessRules/Hybrid/BrHybrid02.cs index 14f25517..5bef35bd 100644 --- a/FacturXDotNet/Validation/BusinessRules/Hybrid/BrHybrid02.cs +++ b/FacturXDotNet/Validation/BusinessRules/Hybrid/BrHybrid02.cs @@ -5,5 +5,5 @@ record BrHybrid02() : HybridBusinessRule( "The PDF envelope of a hybrid document SHALL use the PDF/A-3 standard.Optionally, a PDF/A-4f file (ISO 19005-4, based on PDF 2.0 ISO 32000-2:2020) is allowed." ) { - public override bool Check(XmpMetadata xmp, string ciiAttachmentName, CrossIndustryInvoice cii) => xmp.PdfAIdentification is { Part: >= 3 }; + public override bool Check(XmpMetadata? xmp, string? ciiAttachmentName, CrossIndustryInvoice? cii) => xmp?.PdfAIdentification is { Part: >= 3 }; } diff --git a/FacturXDotNet/Validation/BusinessRules/Hybrid/BrHybrid03.cs b/FacturXDotNet/Validation/BusinessRules/Hybrid/BrHybrid03.cs index 8355b4c7..0042f672 100644 --- a/FacturXDotNet/Validation/BusinessRules/Hybrid/BrHybrid03.cs +++ b/FacturXDotNet/Validation/BusinessRules/Hybrid/BrHybrid03.cs @@ -4,6 +4,6 @@ namespace FacturXDotNet.Validation.BusinessRules.Hybrid; record BrHybrid03() : HybridBusinessRule("BR-HYBRID-03", "A PDF/A extension schema (XMP) following the structure definition in the corresponding specification SHALL be used.") { - public override bool Check(XmpMetadata xmp, string ciiAttachmentName, CrossIndustryInvoice cii) => - xmp.PdfAExtensions?.Schemas.SingleOrDefault(s => s.NamespaceUri == XmpFacturXMetadata.NamespaceUri) is not null; + public override bool Check(XmpMetadata? xmp, string? ciiAttachmentName, CrossIndustryInvoice? cii) => + xmp?.PdfAExtensions?.Schemas.SingleOrDefault(s => s.NamespaceUri == XmpFacturXMetadata.NamespaceUri) is not null; } diff --git a/FacturXDotNet/Validation/BusinessRules/Hybrid/BrHybrid04.cs b/FacturXDotNet/Validation/BusinessRules/Hybrid/BrHybrid04.cs index b94b9564..fc075f20 100644 --- a/FacturXDotNet/Validation/BusinessRules/Hybrid/BrHybrid04.cs +++ b/FacturXDotNet/Validation/BusinessRules/Hybrid/BrHybrid04.cs @@ -4,7 +4,7 @@ namespace FacturXDotNet.Validation.BusinessRules.Hybrid; record BrHybrid04() : HybridBusinessRule("BR-HYBRID-04", "The URI in the extension schema SHALL be urn:factur-x:pdfa:CrossIndustryDocument:1p0#.") { - public override bool Check(XmpMetadata xmp, string ciiAttachmentName, CrossIndustryInvoice cii) => + public override bool Check(XmpMetadata? xmp, string? ciiAttachmentName, CrossIndustryInvoice? cii) => // a bit of a tautology - xmp.PdfAExtensions?.Schemas.SingleOrDefault(s => s.NamespaceUri == XmpFacturXMetadata.NamespaceUri) is { NamespaceUri: XmpFacturXMetadata.NamespaceUri }; + xmp?.PdfAExtensions?.Schemas.SingleOrDefault(s => s.NamespaceUri == XmpFacturXMetadata.NamespaceUri) is { NamespaceUri: XmpFacturXMetadata.NamespaceUri }; } diff --git a/FacturXDotNet/Validation/BusinessRules/Hybrid/BrHybrid05.cs b/FacturXDotNet/Validation/BusinessRules/Hybrid/BrHybrid05.cs index 3661debe..08afd69c 100644 --- a/FacturXDotNet/Validation/BusinessRules/Hybrid/BrHybrid05.cs +++ b/FacturXDotNet/Validation/BusinessRules/Hybrid/BrHybrid05.cs @@ -4,6 +4,6 @@ namespace FacturXDotNet.Validation.BusinessRules.Hybrid; record BrHybrid05() : HybridBusinessRule("BR-HYBRID-05", "The schema namespace prefix in the XMP extension schema SHALL be fx.") { - public override bool Check(XmpMetadata xmp, string ciiAttachmentName, CrossIndustryInvoice cii) => - xmp.PdfAExtensions?.Schemas.SingleOrDefault(s => s.NamespaceUri == XmpFacturXMetadata.NamespaceUri) is { Prefix: "fx" }; + public override bool Check(XmpMetadata? xmp, string? ciiAttachmentName, CrossIndustryInvoice? cii) => + xmp?.PdfAExtensions?.Schemas.SingleOrDefault(s => s.NamespaceUri == XmpFacturXMetadata.NamespaceUri) is { Prefix: "fx" }; } diff --git a/FacturXDotNet/Validation/BusinessRules/Hybrid/BrHybrid06.cs b/FacturXDotNet/Validation/BusinessRules/Hybrid/BrHybrid06.cs index 2c01bc6e..890a079b 100644 --- a/FacturXDotNet/Validation/BusinessRules/Hybrid/BrHybrid06.cs +++ b/FacturXDotNet/Validation/BusinessRules/Hybrid/BrHybrid06.cs @@ -2,6 +2,6 @@ record BrHybrid06() : HybridBusinessRule("BR-HYBRID-06", "The fx:DocumentType in the XMP instance SHALL be a value from the HybridDocumentType code list.") { - public override bool Check(XmpMetadata xmp, string ciiAttachmentName, CrossIndustryInvoice cii) => - xmp.FacturX is { DocumentType: not null } && Enum.IsDefined(xmp.FacturX.DocumentType.Value); + public override bool Check(XmpMetadata? xmp, string? ciiAttachmentName, CrossIndustryInvoice? cii) => + xmp?.FacturX?.DocumentType is not null && Enum.IsDefined(xmp.FacturX.DocumentType.Value); } diff --git a/FacturXDotNet/Validation/BusinessRules/Hybrid/BrHybrid07.cs b/FacturXDotNet/Validation/BusinessRules/Hybrid/BrHybrid07.cs index 3665efe8..94cdbe77 100644 --- a/FacturXDotNet/Validation/BusinessRules/Hybrid/BrHybrid07.cs +++ b/FacturXDotNet/Validation/BusinessRules/Hybrid/BrHybrid07.cs @@ -2,6 +2,6 @@ record BrHybrid07() : HybridBusinessRule("BR-HYBRID-07", "The fx:ConformanceLevel in the XMP instance SHALL be a value from the HybridConformanceType code list.") { - public override bool Check(XmpMetadata xmp, string ciiAttachmentName, CrossIndustryInvoice cii) => - xmp.FacturX is { ConformanceLevel: not null } && Enum.IsDefined(xmp.FacturX.ConformanceLevel.Value); + public override bool Check(XmpMetadata? xmp, string? ciiAttachmentName, CrossIndustryInvoice? cii) => + xmp?.FacturX?.ConformanceLevel is not null && Enum.IsDefined(xmp.FacturX.ConformanceLevel.Value); } diff --git a/FacturXDotNet/Validation/BusinessRules/Hybrid/BrHybrid08.cs b/FacturXDotNet/Validation/BusinessRules/Hybrid/BrHybrid08.cs index 4b5d944e..3dc0c998 100644 --- a/FacturXDotNet/Validation/BusinessRules/Hybrid/BrHybrid08.cs +++ b/FacturXDotNet/Validation/BusinessRules/Hybrid/BrHybrid08.cs @@ -2,6 +2,6 @@ record BrHybrid08() : HybridBusinessRule("BR-HYBRID-08", "The fx:DocumentFileName in the XMP instance SHALL be a value defined in the HybridDocumentFilename code list.") { - public override bool Check(XmpMetadata xmp, string ciiAttachmentName, CrossIndustryInvoice cii) => - xmp.FacturX is { DocumentFileName: "factur-x.xml" or "xrechnung.xml" or "order-x.xml" }; + public override bool Check(XmpMetadata? xmp, string? ciiAttachmentName, CrossIndustryInvoice? cii) => + xmp?.FacturX is { DocumentFileName: "factur-x.xml" or "xrechnung.xml" or "order-x.xml" }; } diff --git a/FacturXDotNet/Validation/BusinessRules/Hybrid/BrHybrid09.cs b/FacturXDotNet/Validation/BusinessRules/Hybrid/BrHybrid09.cs index 8039a49b..091df50f 100644 --- a/FacturXDotNet/Validation/BusinessRules/Hybrid/BrHybrid09.cs +++ b/FacturXDotNet/Validation/BusinessRules/Hybrid/BrHybrid09.cs @@ -2,5 +2,5 @@ record BrHybrid09() : HybridBusinessRule("BR-HYBRID-09", "The fx:Version in the XMP instance SHALL be a value defined in the HybridDocumentVersion codelist.") { - public override bool Check(XmpMetadata xmp, string ciiAttachmentName, CrossIndustryInvoice cii) => xmp.FacturX is { Version: "1.0" or "1p0" or "2p0" or "2p1" or "2p2" }; + public override bool Check(XmpMetadata? xmp, string? ciiAttachmentName, CrossIndustryInvoice? cii) => xmp?.FacturX is { Version: "1.0" or "1p0" or "2p0" or "2p1" or "2p2" }; } diff --git a/FacturXDotNet/Validation/BusinessRules/Hybrid/BrHybrid10.cs b/FacturXDotNet/Validation/BusinessRules/Hybrid/BrHybrid10.cs index 44006fff..b1cde451 100644 --- a/FacturXDotNet/Validation/BusinessRules/Hybrid/BrHybrid10.cs +++ b/FacturXDotNet/Validation/BusinessRules/Hybrid/BrHybrid10.cs @@ -2,5 +2,5 @@ record BrHybrid10() : HybridBusinessRule("BR-HYBRID-10", "The fx:Version SHOULD be 1.0.", FacturXBusinessRuleSeverity.Warning) { - public override bool Check(XmpMetadata xmp, string ciiAttachmentName, CrossIndustryInvoice cii) => xmp.FacturX is { Version: "1.0" }; + public override bool Check(XmpMetadata? xmp, string? ciiAttachmentName, CrossIndustryInvoice? cii) => xmp?.FacturX is { Version: "1.0" }; } diff --git a/FacturXDotNet/Validation/BusinessRules/Hybrid/BrHybrid11.cs b/FacturXDotNet/Validation/BusinessRules/Hybrid/BrHybrid11.cs index b3c56e57..00852135 100644 --- a/FacturXDotNet/Validation/BusinessRules/Hybrid/BrHybrid11.cs +++ b/FacturXDotNet/Validation/BusinessRules/Hybrid/BrHybrid11.cs @@ -6,7 +6,7 @@ record BrHybrid11() : HybridBusinessRule( FacturXBusinessRuleSeverity.Warning ) { - public override bool Check(XmpMetadata xmp, string ciiAttachmentName, CrossIndustryInvoice cii) => + public override bool Check(XmpMetadata? xmp, string? ciiAttachmentName, CrossIndustryInvoice? cii) => // At this point this is necessarily true because we must have found it in order to create the FacturX instance. true; } diff --git a/FacturXDotNet/Validation/BusinessRules/Hybrid/BrHybrid12.cs b/FacturXDotNet/Validation/BusinessRules/Hybrid/BrHybrid12.cs index 081ff89e..27c0aa9a 100644 --- a/FacturXDotNet/Validation/BusinessRules/Hybrid/BrHybrid12.cs +++ b/FacturXDotNet/Validation/BusinessRules/Hybrid/BrHybrid12.cs @@ -5,7 +5,7 @@ record BrHybrid12() : HybridBusinessRule( "The method of embedding the XML into the PDF SHALL conform as defined in the current specification in order to assure easy extraction of the machine readable XML file." ) { - public override bool Check(XmpMetadata xmp, string ciiAttachmentName, CrossIndustryInvoice cii) => + public override bool Check(XmpMetadata? xmp, string? ciiAttachmentName, CrossIndustryInvoice? cii) => // At this point this is necessarily true because we must have found it in order to create the FacturX instance. true; } diff --git a/FacturXDotNet/Validation/BusinessRules/Hybrid/BrHybrid13.cs b/FacturXDotNet/Validation/BusinessRules/Hybrid/BrHybrid13.cs index 8bba2df3..4981b41a 100644 --- a/FacturXDotNet/Validation/BusinessRules/Hybrid/BrHybrid13.cs +++ b/FacturXDotNet/Validation/BusinessRules/Hybrid/BrHybrid13.cs @@ -2,7 +2,7 @@ record BrHybrid13() : HybridBusinessRule("BR-HYBRID-13", "The embedded file name SHALL be one of the values defined in the HybridDocumentFilename code list.") { - public override bool Check(XmpMetadata xmp, string ciiAttachmentName, CrossIndustryInvoice cii) => + public override bool Check(XmpMetadata? xmp, string? ciiAttachmentName, CrossIndustryInvoice? cii) => // At this point this is necessarily true because we must have found it in order to create the FacturX instance. true; } diff --git a/FacturXDotNet/Validation/BusinessRules/Hybrid/BrHybrid14.cs b/FacturXDotNet/Validation/BusinessRules/Hybrid/BrHybrid14.cs index 5f0ea5c3..d464601d 100644 --- a/FacturXDotNet/Validation/BusinessRules/Hybrid/BrHybrid14.cs +++ b/FacturXDotNet/Validation/BusinessRules/Hybrid/BrHybrid14.cs @@ -2,5 +2,5 @@ record BrHybrid14() : HybridBusinessRule("BR-HYBRID-14", "The embedded file name SHOULD match the fx:DocumentFileName.", FacturXBusinessRuleSeverity.Warning) { - public override bool Check(XmpMetadata xmp, string ciiAttachmentName, CrossIndustryInvoice cii) => xmp.FacturX != null && ciiAttachmentName == xmp.FacturX.DocumentFileName; + public override bool Check(XmpMetadata? xmp, string? ciiAttachmentName, CrossIndustryInvoice? cii) => xmp?.FacturX != null && ciiAttachmentName == xmp.FacturX.DocumentFileName; } diff --git a/FacturXDotNet/Validation/BusinessRules/Hybrid/BrHybrid15.cs b/FacturXDotNet/Validation/BusinessRules/Hybrid/BrHybrid15.cs index 70b6da27..3257fd7e 100644 --- a/FacturXDotNet/Validation/BusinessRules/Hybrid/BrHybrid15.cs +++ b/FacturXDotNet/Validation/BusinessRules/Hybrid/BrHybrid15.cs @@ -4,6 +4,6 @@ namespace FacturXDotNet.Validation.BusinessRules.Hybrid; record BrHybrid15() : HybridBusinessRule("BR-HYBRID-15", "The fx:ConformanceLevel SHOULD match the profile of the embedded XML document.", FacturXBusinessRuleSeverity.Warning) { - public override bool Check(XmpMetadata xmp, string ciiAttachmentName, CrossIndustryInvoice cii) => - xmp.FacturX != null && cii.ExchangedDocumentContext.GuidelineSpecifiedDocumentContextParameterId.ToFacturXProfile() == xmp.FacturX.ConformanceLevel?.ToFacturXProfile(); + public override bool Check(XmpMetadata? xmp, string? ciiAttachmentName, CrossIndustryInvoice? cii) => + xmp?.FacturX != null && cii?.ExchangedDocumentContext.GuidelineSpecifiedDocumentContextParameterId.ToFacturXProfile() == xmp.FacturX.ConformanceLevel?.ToFacturXProfile(); } diff --git a/FacturXDotNet/Validation/BusinessRules/Hybrid/BrHybridFr01.cs b/FacturXDotNet/Validation/BusinessRules/Hybrid/BrHybridFr01.cs index 2f53964c..1d73584c 100644 --- a/FacturXDotNet/Validation/BusinessRules/Hybrid/BrHybridFr01.cs +++ b/FacturXDotNet/Validation/BusinessRules/Hybrid/BrHybridFr01.cs @@ -5,7 +5,7 @@ record BrHybridFr01() : HybridBusinessRule( "If the Buyer Country BT-40 is France and the Seller Country BT-55 is France the XRECHNUNG profile SHALL NOT be used." ) { - public override bool Check(XmpMetadata xmp, string ciiAttachmentName, CrossIndustryInvoice cii) => + public override bool Check(XmpMetadata? xmp, string? ciiAttachmentName, CrossIndustryInvoice? cii) => // TODO true; } diff --git a/FacturXDotNet/Validation/BusinessRules/HybridBusinessRule.cs b/FacturXDotNet/Validation/BusinessRules/HybridBusinessRule.cs index 022957b9..97af538a 100644 --- a/FacturXDotNet/Validation/BusinessRules/HybridBusinessRule.cs +++ b/FacturXDotNet/Validation/BusinessRules/HybridBusinessRule.cs @@ -1,4 +1,6 @@ -namespace FacturXDotNet.Validation.BusinessRules; +using FacturXDotNet.Models; + +namespace FacturXDotNet.Validation.BusinessRules; /// /// Represents a HYBRID rule for validating a Factur-X document. @@ -8,7 +10,9 @@ /// The severity of the rule. public abstract record HybridBusinessRule(string Name, string Description, FacturXBusinessRuleSeverity Severity = FacturXBusinessRuleSeverity.Fatal) : BusinessRule( Name, - Description + Description, + FacturXProfileFlags.All, + Severity ) { /// @@ -18,7 +22,7 @@ public abstract record HybridBusinessRule(string Name, string Description, Factu /// /// The Cross-Industry Invoice to validate. /// true if the rule is satisfied by the invoice; otherwise false. - public abstract bool Check(XmpMetadata xmp, string ciiAttachmentName, CrossIndustryInvoice cii); + public abstract bool Check(XmpMetadata? xmp, string? ciiAttachmentName, CrossIndustryInvoice? cii); /// public override string Format() => $"[HYBRID] {Name} - {Description}"; diff --git a/FacturXDotNet/Validation/FacturXValidationResult.cs b/FacturXDotNet/Validation/FacturXValidationResult.cs index 04a76594..050437df 100644 --- a/FacturXDotNet/Validation/FacturXValidationResult.cs +++ b/FacturXDotNet/Validation/FacturXValidationResult.cs @@ -1,5 +1,4 @@ using FacturXDotNet.Models; -using FacturXDotNet.Validation.BusinessRules; namespace FacturXDotNet.Validation; @@ -10,33 +9,14 @@ namespace FacturXDotNet.Validation; /// This record contains the outcome of each business rule applied during the validation process. /// It categorizes the rules into those that have passed, failed, or been skipped based on the validation result. /// -/// The collection of business rules that passed the validation. -/// The collection of business rules that failed the validation. -/// The collection of business rules that failed the validation with a warning. -/// The collection of business rules that failed the validation, but are informational. -/// -/// The collection of business rules that failed the validation, but were expected to fail because they target a profile that is higher than the one specified -/// in the document. -/// -/// The collection of business rules that were not checked. -public readonly record struct FacturXValidationResult( - IReadOnlyCollection Passed, - IReadOnlyCollection Fatal, - IReadOnlyCollection Warning, - IReadOnlyCollection Information, - IReadOnlyCollection ExpectedToFail, - IReadOnlyCollection Skipped -) +/// The profile that was expected for the document. This is the profile that is specified in the document. +/// The validation status of each business rule. +public readonly record struct FacturXValidationResult(FacturXProfile ExpectedProfile, IReadOnlyList Rules) { - /// - /// The profile that was expected for the document. This is the profile that is specified in the document. - /// - public FacturXProfile ExpectedProfile { get; } - /// /// The profiles that are valid for the document. /// - public FacturXProfileFlags ValidProfiles { get; } = ComputeActualProfile(Fatal, ExpectedToFail); + public FacturXProfileFlags ValidProfiles { get; } = ComputeActualProfile(Rules); /// /// Whether the validation was successful. @@ -44,11 +24,10 @@ IReadOnlyCollection Skipped /// /// The validation is considered successful if no business rules have failed, except those that were expected to fail. /// - public bool Success => Fatal == null || Fatal.Count == 0; + public bool Success => Rules.All(r => r.ExpectedStatus is BusinessRuleExpectedValidationStatus.Failure || r.Status is not BusinessRuleValidationStatus.Failed); - static FacturXProfileFlags ComputeActualProfile( - IReadOnlyCollection failed, - IReadOnlyCollection expectedToFail - ) => - failed.Concat(expectedToFail).Select(r => r.Profiles).Aggregate(FacturXProfileFlags.All, (result, failedProfiles) => result & ~failedProfiles); + static FacturXProfileFlags ComputeActualProfile(IReadOnlyList rules) => + rules.Where(r => r.Status is BusinessRuleValidationStatus.Failed) + .Select(r => r.Rule.Profiles) + .Aggregate(FacturXProfileFlags.All, (result, failedProfiles) => result & ~failedProfiles); } diff --git a/FacturXDotNet/Validation/FacturXValidationResultBuilder.cs b/FacturXDotNet/Validation/FacturXValidationResultBuilder.cs index d38980ff..71141fb3 100644 --- a/FacturXDotNet/Validation/FacturXValidationResultBuilder.cs +++ b/FacturXDotNet/Validation/FacturXValidationResultBuilder.cs @@ -1,18 +1,32 @@ -using FacturXDotNet.Validation.BusinessRules; +using FacturXDotNet.Models; +using FacturXDotNet.Validation.BusinessRules; namespace FacturXDotNet.Validation; class FacturXValidationResultBuilder { - public FacturXValidationResultBuilder AddError(string error) => - // TODO: Implement this method - this; + readonly List _results = []; + FacturXProfile? _expectedProfile; - public FacturXValidationResultBuilder AddRuleStatus(BusinessRule rule, BusinessRuleExpectedValidationStatus expectedStatus, BusinessRuleValidationStatus status) => - // TODO: Implement this method - this; + public FacturXValidationResultBuilder SetExpectedProfile(FacturXProfile profile) + { + _expectedProfile = profile; + return this; + } - public FacturXValidationResult Build() => - // TODO: Implement this method - new(); + public FacturXValidationResultBuilder AddRuleStatus(BusinessRule rule, BusinessRuleExpectedValidationStatus expectedStatus, BusinessRuleValidationStatus status) + { + _results.Add(new BusinessRuleValidationResult(rule, expectedStatus, status)); + return this; + } + + public FacturXValidationResult Build() + { + if (!_expectedProfile.HasValue) + { + throw new InvalidOperationException("Expected profile must be set before building the result."); + } + + return new FacturXValidationResult(_expectedProfile.Value, _results); + } } diff --git a/FacturXDotNet/Validation/FacturXValidator.cs b/FacturXDotNet/Validation/FacturXValidator.cs index e807f2e8..2483fedc 100644 --- a/FacturXDotNet/Validation/FacturXValidator.cs +++ b/FacturXDotNet/Validation/FacturXValidator.cs @@ -101,42 +101,25 @@ public async Task GetValidationResultAsync( (XmpMetadata? xmp, CrossIndustryInvoiceAttachment? ciiAttachment) = await ExtractXmpAndCiiAsync(invoice, ciiAttachmentName, password, cancellationToken); - const string ciiNotFoundErrorMessage = "The Cross-Industry Invoice data could not be extracted."; - const string xmpNotFoundErrorMessage = "The XMP metadata could not be extracted."; + CrossIndustryInvoice? cii = ciiAttachment == null + ? null + : await ciiAttachment.GetCrossIndustryInvoiceAsync(password, new CrossIndustryInvoiceParserOptions { Logger = options?.Logger }, cancellationToken); - if (xmp == null || ciiAttachment == null) - { - if (xmp == null) - { - builder.AddError(xmpNotFoundErrorMessage); - } - - if (ciiAttachment == null) - { - builder.AddError(ciiNotFoundErrorMessage); - } - - return builder.Build(); - } + FacturXProfile expectedProfile = GetExpectedProfile(xmp, cii); + builder.SetExpectedProfile(expectedProfile); - CrossIndustryInvoice cii = await ciiAttachment.GetCrossIndustryInvoiceAsync( - password, - new CrossIndustryInvoiceParserOptions { Logger = options?.Logger }, - cancellationToken - ); - - CheckHybridRules(builder, xmp, ciiAttachmentName, cii); - CheckBusinessRules(builder, xmp, cii); + CheckHybridRules(builder, xmp, cii == null ? null : ciiAttachmentName, cii); + CheckBusinessRules(builder, expectedProfile, cii); return builder.Build(); } - void CheckHybridRules(FacturXValidationResultBuilder builder, XmpMetadata xmp, string ciiAttachmentName, CrossIndustryInvoice cii) + void CheckHybridRules(FacturXValidationResultBuilder builder, XmpMetadata? xmp, string? ciiAttachmentName, CrossIndustryInvoice? cii) { foreach (HybridBusinessRule rule in HybridBusinessRules.Rules) { // Hybrid rules are always expected to pass - BusinessRuleExpectedValidationStatus expectation = BusinessRuleExpectedValidationStatus.Success; + const BusinessRuleExpectedValidationStatus expectation = BusinessRuleExpectedValidationStatus.Success; if (ShouldSkipRule(rule)) { @@ -150,15 +133,13 @@ void CheckHybridRules(FacturXValidationResultBuilder builder, XmpMetadata xmp, s } } - void CheckBusinessRules(FacturXValidationResultBuilder builder, XmpMetadata xmp, CrossIndustryInvoice cii) + void CheckBusinessRules(FacturXValidationResultBuilder builder, FacturXProfile expectedProfile, CrossIndustryInvoice? cii) { - FacturXProfile profile = GetExpectedProfile(xmp, cii); - foreach (CrossIndustryInvoiceBusinessRule rule in CrossIndustryInvoiceBusinessRules.Rules) { // Hybrid rules are always expected to pass BusinessRuleExpectedValidationStatus expectation = - IsRuleExpectedToFail(rule, profile) ? BusinessRuleExpectedValidationStatus.Failure : BusinessRuleExpectedValidationStatus.Success; + IsRuleExpectedToFail(rule, expectedProfile) ? BusinessRuleExpectedValidationStatus.Failure : BusinessRuleExpectedValidationStatus.Success; if (ShouldSkipRule(rule)) { @@ -172,11 +153,11 @@ void CheckBusinessRules(FacturXValidationResultBuilder builder, XmpMetadata xmp, } } - FacturXProfile GetExpectedProfile(XmpMetadata xmp, CrossIndustryInvoice cii) => + FacturXProfile GetExpectedProfile(XmpMetadata? xmp, CrossIndustryInvoice? cii) => _options.ProfileOverride.HasValue && _options.ProfileOverride is not FacturXProfile.None ? _options.ProfileOverride.Value - : xmp.FacturX?.ConformanceLevel?.ToFacturXProfile() - ?? cii.ExchangedDocumentContext.GuidelineSpecifiedDocumentContextParameterId.ToFacturXProfileOrNull() ?? FacturXProfile.Minimum; + : xmp?.FacturX?.ConformanceLevel?.ToFacturXProfile() + ?? cii?.ExchangedDocumentContext.GuidelineSpecifiedDocumentContextParameterId.ToFacturXProfileOrNull() ?? FacturXProfile.Minimum; async Task<(XmpMetadata? Xmp, CrossIndustryInvoiceAttachment? Cii)> ExtractXmpAndCiiAsync( FacturXDocument invoice, diff --git a/Tests.FacturXDotNet/ParseAndValidateIntegrationTests.cs b/Tests.FacturXDotNet/ParseAndValidateIntegrationTests.cs index 8715c291..2542a762 100644 --- a/Tests.FacturXDotNet/ParseAndValidateIntegrationTests.cs +++ b/Tests.FacturXDotNet/ParseAndValidateIntegrationTests.cs @@ -72,7 +72,8 @@ public async Task ParseAndValidateProfile(string filePath, FacturXProfile profil FacturXValidator validator = new(); FacturXValidationResult validationResult = await validator.GetValidationResultAsync(invoice); - validationResult.Fatal.ShouldBeEmpty(); + validationResult.Rules.Where(r => r.HasFailed).ShouldBeEmpty(); + validationResult.ExpectedProfile.ShouldBe(profile); validationResult.ValidProfiles.ShouldBe(profile.AndLower()); } } From 2df4631b0e2af2bd0c128772cf3c02b4539ff0da Mon Sep 17 00:00:00 2001 From: ismailbennani Date: Sun, 23 Mar 2025 00:49:17 +0100 Subject: [PATCH 3/4] update default profile fallback to FacturXProfile.None in validation --- FacturXDotNet/Validation/FacturXValidator.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/FacturXDotNet/Validation/FacturXValidator.cs b/FacturXDotNet/Validation/FacturXValidator.cs index 2483fedc..156283e9 100644 --- a/FacturXDotNet/Validation/FacturXValidator.cs +++ b/FacturXDotNet/Validation/FacturXValidator.cs @@ -157,7 +157,7 @@ FacturXProfile GetExpectedProfile(XmpMetadata? xmp, CrossIndustryInvoice? cii) = _options.ProfileOverride.HasValue && _options.ProfileOverride is not FacturXProfile.None ? _options.ProfileOverride.Value : xmp?.FacturX?.ConformanceLevel?.ToFacturXProfile() - ?? cii?.ExchangedDocumentContext.GuidelineSpecifiedDocumentContextParameterId.ToFacturXProfileOrNull() ?? FacturXProfile.Minimum; + ?? cii?.ExchangedDocumentContext.GuidelineSpecifiedDocumentContextParameterId.ToFacturXProfileOrNull() ?? FacturXProfile.None; async Task<(XmpMetadata? Xmp, CrossIndustryInvoiceAttachment? Cii)> ExtractXmpAndCiiAsync( FacturXDocument invoice, From dca66565935b8d1eceb7db1ccdaf7bd49d6c2795 Mon Sep 17 00:00:00 2001 From: ismailbennani Date: Sun, 23 Mar 2025 00:50:08 +0100 Subject: [PATCH 4/4] update validation output to show detected profile only if it differs from the document profile --- FacturXDotNet.CLI/Validate/ValidateCommand.cs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/FacturXDotNet.CLI/Validate/ValidateCommand.cs b/FacturXDotNet.CLI/Validate/ValidateCommand.cs index 87ecbb08..ededb2e5 100644 --- a/FacturXDotNet.CLI/Validate/ValidateCommand.cs +++ b/FacturXDotNet.CLI/Validate/ValidateCommand.cs @@ -192,7 +192,11 @@ static void ShowFinalResult(FacturXDocument document, FacturXValidationResult re { AnsiConsole.MarkupLine("[red]:cross_mark: The Factur-X docuemnt is invalid.[/]"); AnsiConsole.MarkupLine($"[red]:cross_mark: Document profile: {documentProfile}.[/]"); - AnsiConsole.MarkupLine($"[red]:cross_mark: Detected profile: {detectedProfile}.[/]"); + + if (documentProfile != detectedProfile) + { + AnsiConsole.MarkupLine($"[red]:cross_mark: Detected profile: {detectedProfile}.[/]"); + } } } }