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..ededb2e5 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) { @@ -200,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}.[/]"); + } } } } 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..d0178696 --- /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. +/// +public enum BusinessRuleExpectedValidationStatus +{ + /// + /// The rule is expected to pass. + /// + Success, + + /// + /// The rule is expected to fail. + /// + Failure +} 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/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/FacturXBusinessRule.cs b/FacturXDotNet/Validation/BusinessRules/BusinessRule.cs similarity index 53% rename from FacturXDotNet/Validation/BusinessRules/FacturXBusinessRule.cs rename to FacturXDotNet/Validation/BusinessRules/BusinessRule.cs index c3bd0987..b3c8d2b3 100644 --- a/FacturXDotNet/Validation/BusinessRules/FacturXBusinessRule.cs +++ b/FacturXDotNet/Validation/BusinessRules/BusinessRule.cs @@ -6,18 +6,11 @@ namespace FacturXDotNet.Validation.BusinessRules; /// Represents any business rule for validating a Factur-X document. /// /// The name of the rule. -/// A description of the rule. /// The profiles in which this rule should be enforced. +/// A description of the rule. /// The severity of the rule. -public abstract record FacturXBusinessRule(string Name, string Description, FacturXProfileFlags Profiles, FacturXBusinessRuleSeverity Severity) +public abstract record BusinessRule(string Name, string Description, FacturXProfileFlags Profiles, FacturXBusinessRuleSeverity Severity) { - /// - /// 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(FacturXDocument invoice); - /// /// 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/CII/CrossIndustryInvoiceBusinessRule.cs b/FacturXDotNet/Validation/BusinessRules/CrossIndustryInvoiceBusinessRule.cs similarity index 57% rename from FacturXDotNet/Validation/BusinessRules/CII/CrossIndustryInvoiceBusinessRule.cs rename to FacturXDotNet/Validation/BusinessRules/CrossIndustryInvoiceBusinessRule.cs index 9e9b5c80..b99f1749 100644 --- a/FacturXDotNet/Validation/BusinessRules/CII/CrossIndustryInvoiceBusinessRule.cs +++ b/FacturXDotNet/Validation/BusinessRules/CrossIndustryInvoiceBusinessRule.cs @@ -1,14 +1,14 @@ using FacturXDotNet.Models; -namespace FacturXDotNet.Validation.BusinessRules.CII; +namespace FacturXDotNet.Validation.BusinessRules; /// -/// Represents a business rule for validating the Cross-Industry Invoice in a Factur-X document. +/// Represents any business rule for validating 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( +public abstract record CrossIndustryInvoiceBusinessRule(string Name, string Description, FacturXProfileFlags Profiles) : BusinessRule( Name, Description, Profiles, @@ -18,15 +18,10 @@ public abstract record CrossIndustryInvoiceBusinessRule(string Name, string Desc /// /// 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(CrossIndustryInvoice invoice); + public abstract bool Check(CrossIndustryInvoice? cii); /// - 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/Hybrid/BrHybrid01.cs b/FacturXDotNet/Validation/BusinessRules/Hybrid/BrHybrid01.cs index 8457672b..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(FacturXDocument invoice) => - // ReSharper disable once ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract - invoice.XmpMetadata != 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 6776acbb..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(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..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(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..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(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..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(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..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(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?.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 1231178e..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(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?.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 5217c849..3dc0c998 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..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(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..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(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..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(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..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(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..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(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..d464601d 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..3257fd7e 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..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(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..97af538a --- /dev/null +++ b/FacturXDotNet/Validation/BusinessRules/HybridBusinessRule.cs @@ -0,0 +1,29 @@ +using FacturXDotNet.Models; + +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, + FacturXProfileFlags.All, + Severity +) +{ + /// + /// 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..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,37 +9,25 @@ 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) { /// - /// Gets the profiles that are valid for the document. + /// The profiles that are valid for the document. /// - public FacturXProfileFlags ValidProfiles { get; } = ComputeActualProfile(Fatal, ExpectedToFail); + public FacturXProfileFlags ValidProfiles { get; } = ComputeActualProfile(Rules); /// - /// 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 => 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 new file mode 100644 index 00000000..71141fb3 --- /dev/null +++ b/FacturXDotNet/Validation/FacturXValidationResultBuilder.cs @@ -0,0 +1,32 @@ +using FacturXDotNet.Models; +using FacturXDotNet.Validation.BusinessRules; + +namespace FacturXDotNet.Validation; + +class FacturXValidationResultBuilder +{ + readonly List _results = []; + FacturXProfile? _expectedProfile; + + public FacturXValidationResultBuilder SetExpectedProfile(FacturXProfile profile) + { + _expectedProfile = profile; + return this; + } + + 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 a2faba55..156283e9 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,96 @@ 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(); + + (XmpMetadata? xmp, CrossIndustryInvoiceAttachment? ciiAttachment) = await ExtractXmpAndCiiAsync(invoice, ciiAttachmentName, password, cancellationToken); + + CrossIndustryInvoice? cii = ciiAttachment == null + ? null + : await ciiAttachment.GetCrossIndustryInvoiceAsync(password, new CrossIndustryInvoiceParserOptions { Logger = options?.Logger }, cancellationToken); + + FacturXProfile expectedProfile = GetExpectedProfile(xmp, cii); + builder.SetExpectedProfile(expectedProfile); + + CheckHybridRules(builder, xmp, cii == null ? null : ciiAttachmentName, cii); + CheckBusinessRules(builder, expectedProfile, cii); + + return builder.Build(); } - (List, List, List) CheckRules(IEnumerable rules, FacturXDocument invoice) + void CheckHybridRules(FacturXValidationResultBuilder builder, XmpMetadata? xmp, string? ciiAttachmentName, CrossIndustryInvoice? cii) { - List passed = []; - List failed = []; - List skipped = []; - - foreach (FacturXBusinessRule rule in rules) + foreach (HybridBusinessRule rule in HybridBusinessRules.Rules) { - if (ShouldSkipRule(rule)) - { - skipped.Add(rule); - continue; - } + // Hybrid rules are always expected to pass + const BusinessRuleExpectedValidationStatus expectation = BusinessRuleExpectedValidationStatus.Success; - if (rule.Check(invoice)) + 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, FacturXProfile expectedProfile, CrossIndustryInvoice? cii) { - FacturXProfile profile = GetExpectedProfile(invoice); - - 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, expectedProfile) ? 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.None; + + 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..2542a762 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,15 +67,13 @@ 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.Rules.Where(r => r.HasFailed).ShouldBeEmpty(); + validationResult.ExpectedProfile.ShouldBe(profile); validationResult.ValidProfiles.ShouldBe(profile.AndLower()); } }