From 08c3cc01ea826c66b4d8d8d2ca39d3044db046e9 Mon Sep 17 00:00:00 2001 From: djukicmilica Date: Thu, 20 Aug 2026 11:19:17 +0200 Subject: [PATCH 01/23] Add synchronous E-Document message transport --- .../EDocCoreObjects.PermissionSet.al | 2 + .../Interfaces/IMessageSender.Interface.al | 13 ++++ .../Integration/ServiceIntegration.Enum.al | 6 +- .../Message/EDocMessageContext.Codeunit.al | 62 +++++++++++++++++++ .../Message/EDocMessageMgt.Codeunit.al | 39 ++++++++++++ .../EDocMessageTransportDefault.Codeunit.al | 23 +++++++ .../Message/EDocumentMessageAPI.Codeunit.al | 28 +++++++++ 7 files changed, 171 insertions(+), 2 deletions(-) create mode 100644 src/Apps/W1/EDocument/App/src/Integration/Interfaces/IMessageSender.Interface.al create mode 100644 src/Apps/W1/EDocument/App/src/Processing/Message/EDocMessageContext.Codeunit.al create mode 100644 src/Apps/W1/EDocument/App/src/Processing/Message/EDocMessageTransportDefault.Codeunit.al create mode 100644 src/Apps/W1/EDocument/App/src/Processing/Message/EDocumentMessageAPI.Codeunit.al diff --git a/src/Apps/W1/EDocument/App/Permissions/EDocCoreObjects.PermissionSet.al b/src/Apps/W1/EDocument/App/Permissions/EDocCoreObjects.PermissionSet.al index 782f758045d..dc5fa50a5c5 100644 --- a/src/Apps/W1/EDocument/App/Permissions/EDocCoreObjects.PermissionSet.al +++ b/src/Apps/W1/EDocument/App/Permissions/EDocCoreObjects.PermissionSet.al @@ -104,7 +104,9 @@ permissionset 6100 "E-Doc. Core - Objects" #endif codeunit "E-Doc. Attachment Processor" = X, codeunit "E-Doc. Hist. Line Data Loader" = X, + codeunit "E-Doc. Message Context" = X, codeunit "E-Doc. Message Mgt." = X, + codeunit "E-Doc. Msg. Transport Default" = X, codeunit "Service Participant" = X, page "E-Doc. Changes Part" = X, page "E-Doc. Changes Preview" = X, diff --git a/src/Apps/W1/EDocument/App/src/Integration/Interfaces/IMessageSender.Interface.al b/src/Apps/W1/EDocument/App/src/Integration/Interfaces/IMessageSender.Interface.al new file mode 100644 index 00000000000..0d58c12091a --- /dev/null +++ b/src/Apps/W1/EDocument/App/src/Integration/Interfaces/IMessageSender.Interface.al @@ -0,0 +1,13 @@ +// ------------------------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// ------------------------------------------------------------------------------------------------ +namespace Microsoft.eServices.EDocument.Integration.Interfaces; + +using Microsoft.eServices.EDocument; +using Microsoft.eServices.EDocument.Processing.Message; + +interface IMessageSender +{ + procedure SendMessage(var EDocument: Record "E-Document"; var EDocumentService: Record "E-Document Service"; MessageContext: Codeunit "E-Doc. Message Context"); +} \ No newline at end of file diff --git a/src/Apps/W1/EDocument/App/src/Integration/ServiceIntegration.Enum.al b/src/Apps/W1/EDocument/App/src/Integration/ServiceIntegration.Enum.al index 6a91bb64230..3049d5902d7 100644 --- a/src/Apps/W1/EDocument/App/src/Integration/ServiceIntegration.Enum.al +++ b/src/Apps/W1/EDocument/App/src/Integration/ServiceIntegration.Enum.al @@ -6,12 +6,14 @@ namespace Microsoft.eServices.EDocument.Integration; using Microsoft.eServices.EDocument; using Microsoft.eServices.EDocument.Integration.Interfaces; +using Microsoft.eServices.EDocument.Processing.Message; -enum 6151 "Service Integration" implements IDocumentSender, IDocumentReceiver, IConsentManager +enum 6151 "Service Integration" implements IDocumentSender, IDocumentReceiver, IConsentManager, IMessageSender { Extensible = true; Access = Public; - DefaultImplementation = IConsentManager = "Consent Manager Default Impl."; + DefaultImplementation = IConsentManager = "Consent Manager Default Impl.", + IMessageSender = "E-Doc. Msg. Transport Default"; value(0; "No Integration") { diff --git a/src/Apps/W1/EDocument/App/src/Processing/Message/EDocMessageContext.Codeunit.al b/src/Apps/W1/EDocument/App/src/Processing/Message/EDocMessageContext.Codeunit.al new file mode 100644 index 00000000000..3d052c68d72 --- /dev/null +++ b/src/Apps/W1/EDocument/App/src/Processing/Message/EDocMessageContext.Codeunit.al @@ -0,0 +1,62 @@ +// ------------------------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// ------------------------------------------------------------------------------------------------ +namespace Microsoft.eServices.EDocument.Processing.Message; + +using Microsoft.eServices.EDocument.Integration; +using Microsoft.eServices.EDocument.Integration.Action; +using System.Utilities; + +codeunit 6533 "E-Doc. Message Context" +{ + Access = Public; + InherentEntitlements = X; + InherentPermissions = X; + + internal procedure Initialize(EDocMessage: Record "E-Document Message"; TempBlob: Codeunit "Temp Blob") + begin + MessageEntryNo := EDocMessage."Entry No."; + MessageType := EDocMessage."Message Type"; + ResponseType := EDocMessage."Response Type"; + Payload := TempBlob; + end; + + procedure GetMessageEntryNo(): Integer + begin + exit(MessageEntryNo); + end; + + procedure GetMessageType(): Enum "E-Document Message Type" + begin + exit(MessageType); + end; + + procedure GetResponseType(): Enum "E-Doc. Response Type" + begin + exit(ResponseType); + end; + + procedure GetTempBlob(): Codeunit "Temp Blob" + begin + exit(Payload); + end; + + procedure Http(): Codeunit "Http Message State" + begin + exit(HttpMessageState); + end; + + procedure Status(): Codeunit "Integration Action Status" + begin + exit(IntegrationActionStatus); + end; + + var + HttpMessageState: Codeunit "Http Message State"; + IntegrationActionStatus: Codeunit "Integration Action Status"; + Payload: Codeunit "Temp Blob"; + MessageType: Enum "E-Document Message Type"; + ResponseType: Enum "E-Doc. Response Type"; + MessageEntryNo: Integer; +} \ No newline at end of file diff --git a/src/Apps/W1/EDocument/App/src/Processing/Message/EDocMessageMgt.Codeunit.al b/src/Apps/W1/EDocument/App/src/Processing/Message/EDocMessageMgt.Codeunit.al index b11299cce5f..915431b9d44 100644 --- a/src/Apps/W1/EDocument/App/src/Processing/Message/EDocMessageMgt.Codeunit.al +++ b/src/Apps/W1/EDocument/App/src/Processing/Message/EDocMessageMgt.Codeunit.al @@ -5,6 +5,7 @@ namespace Microsoft.eServices.EDocument.Processing.Message; using Microsoft.eServices.EDocument; +using Microsoft.eServices.EDocument.Integration.Interfaces; using System.Utilities; /// @@ -79,6 +80,40 @@ codeunit 6433 "E-Doc. Message Mgt." TempBlob := EDocDataStorage.GetTempBlob(); end; + procedure SendMessage(MessageEntryNo: Integer) + var + EDocument: Record "E-Document"; + EDocumentService: Record "E-Document Service"; + EDocMessage: Record "E-Document Message"; + EDocMessageContext: Codeunit "E-Doc. Message Context"; + EDocumentLog: Codeunit "E-Document Log"; + TempBlob: Codeunit "Temp Blob"; + MessageSender: Interface IMessageSender; + begin + EDocMessage.Get(MessageEntryNo); + EDocMessage.TestField(Direction, EDocMessage.Direction::Outgoing); + EDocMessage.TestField(Status, EDocMessage.Status::Created); + EDocMessage.TestField(Service); + + EDocument.Get(EDocMessage."E-Document Entry No."); + EDocumentService.Get(EDocMessage.Service); + GetMessageBlob(MessageEntryNo, TempBlob); + if not TempBlob.HasValue() then + Error(MessagePayloadErr, MessageEntryNo); + + EDocMessageContext.Initialize(EDocMessage, TempBlob); + EDocMessageContext.Status().SetStatus("E-Document Service Status"::Sent); + MessageSender := EDocumentService."Service Integration V2"; + MessageSender.SendMessage(EDocument, EDocumentService, EDocMessageContext); + if EDocMessageContext.Status().GetStatus() <> "E-Document Service Status"::Sent then + Error(MessageSendingErr, MessageEntryNo, EDocMessageContext.Status().GetStatus()); + + EDocumentLog.InsertIntegrationLog( + EDocument, EDocumentService, EDocMessageContext.Http().GetHttpRequestMessage(), EDocMessageContext.Http().GetHttpResponseMessage()); + EDocMessage.Status := EDocMessage.Status::Sent; + EDocMessage.Modify(); + end; + local procedure InsertDataStorage(TempBlob: Codeunit "Temp Blob"): Integer var EDocDataStorage: Record "E-Doc. Data Storage"; @@ -96,4 +131,8 @@ codeunit 6433 "E-Doc. Message Mgt." EDocRecRef.Modify(); exit(EDocDataStorage."Entry No."); end; + + var + MessagePayloadErr: Label 'E-Document message %1 does not contain a payload.', Comment = '%1 = E-Document message entry number'; + MessageSendingErr: Label 'E-Document message %1 could not be sent. The integration returned status %2.', Comment = '%1 = E-Document message entry number, %2 = integration status'; } diff --git a/src/Apps/W1/EDocument/App/src/Processing/Message/EDocMessageTransportDefault.Codeunit.al b/src/Apps/W1/EDocument/App/src/Processing/Message/EDocMessageTransportDefault.Codeunit.al new file mode 100644 index 00000000000..4b146cc1b46 --- /dev/null +++ b/src/Apps/W1/EDocument/App/src/Processing/Message/EDocMessageTransportDefault.Codeunit.al @@ -0,0 +1,23 @@ +// ------------------------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// ------------------------------------------------------------------------------------------------ +namespace Microsoft.eServices.EDocument.Processing.Message; + +using Microsoft.eServices.EDocument; +using Microsoft.eServices.EDocument.Integration.Interfaces; + +codeunit 6534 "E-Doc. Msg. Transport Default" implements IMessageSender +{ + Access = Internal; + InherentEntitlements = X; + InherentPermissions = X; + + procedure SendMessage(var EDocument: Record "E-Document"; var EDocumentService: Record "E-Document Service"; MessageContext: Codeunit "E-Doc. Message Context") + begin + Error(MessageTransportNotSupportedErr, EDocumentService.Code); + end; + + var + MessageTransportNotSupportedErr: Label 'E-Document service %1 does not support sending E-Document messages.', Comment = '%1 = E-Document service code'; +} \ No newline at end of file diff --git a/src/Apps/W1/EDocument/App/src/Processing/Message/EDocumentMessageAPI.Codeunit.al b/src/Apps/W1/EDocument/App/src/Processing/Message/EDocumentMessageAPI.Codeunit.al new file mode 100644 index 00000000000..17ca7915849 --- /dev/null +++ b/src/Apps/W1/EDocument/App/src/Processing/Message/EDocumentMessageAPI.Codeunit.al @@ -0,0 +1,28 @@ +// ------------------------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// ------------------------------------------------------------------------------------------------ +namespace Microsoft.eServices.EDocument.Processing.Message; + +using Microsoft.eServices.EDocument; +using System.Utilities; + +codeunit 6532 "E-Document Message API" +{ + Access = Public; + InherentEntitlements = X; + + procedure CreateMessage(EDocument: Record "E-Document"; MessageType: Enum "E-Document Message Type"; ResponseType: Enum "E-Doc. Response Type"; var TempBlob: Codeunit "Temp Blob"): Integer + var + EDocMessageMgt: Codeunit "E-Doc. Message Mgt."; + begin + exit(EDocMessageMgt.CreateMessage(EDocument, MessageType, EDocument.Direction::Outgoing, ResponseType, TempBlob)); + end; + + procedure SendMessage(MessageEntryNo: Integer) + var + EDocMessageMgt: Codeunit "E-Doc. Message Mgt."; + begin + EDocMessageMgt.SendMessage(MessageEntryNo); + end; +} From 31b4bb0d0645308d70dccad9c4b0443434563479 Mon Sep 17 00:00:00 2001 From: djukicmilica Date: Thu, 20 Aug 2026 11:19:26 +0200 Subject: [PATCH 02/23] Add French collected and refused messages --- .../app/src/Core/FREInvoiceMessage.Table.al | 105 ++++++++ .../src/Core/FREInvoiceMessageMgt.Codeunit.al | 251 ++++++++++++++++++ .../src/Core/FREInvoiceMessageType.Enum.al | 23 ++ .../src/Core/FREInvoiceRefusalDialog.Page.al | 43 +++ .../EReportingEDocuments.PageExt.al | 29 ++ .../Extensions/FREDocResponseType.EnumExt.al | 15 ++ .../FREDocumentMessageType.EnumExt.al | 15 ++ .../src/FREDocMessageSenderMock.Codeunit.al | 75 ++++++ .../src/FREInvoiceMessageTests.Codeunit.al | 222 ++++++++++++++++ .../test/src/FRServiceIntegration.EnumExt.al | 16 ++ 10 files changed, 794 insertions(+) create mode 100644 src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/FREInvoiceMessage.Table.al create mode 100644 src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/FREInvoiceMessageMgt.Codeunit.al create mode 100644 src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/FREInvoiceMessageType.Enum.al create mode 100644 src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/FREInvoiceRefusalDialog.Page.al create mode 100644 src/Apps/FR/EDocument_FR/EReportingFR/app/src/Extensions/FREDocResponseType.EnumExt.al create mode 100644 src/Apps/FR/EDocument_FR/EReportingFR/app/src/Extensions/FREDocumentMessageType.EnumExt.al create mode 100644 src/Apps/FR/EDocument_FR/EReportingFR/test/src/FREDocMessageSenderMock.Codeunit.al create mode 100644 src/Apps/FR/EDocument_FR/EReportingFR/test/src/FREInvoiceMessageTests.Codeunit.al create mode 100644 src/Apps/FR/EDocument_FR/EReportingFR/test/src/FRServiceIntegration.EnumExt.al diff --git a/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/FREInvoiceMessage.Table.al b/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/FREInvoiceMessage.Table.al new file mode 100644 index 00000000000..3c0058c7fbf --- /dev/null +++ b/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/FREInvoiceMessage.Table.al @@ -0,0 +1,105 @@ +// ------------------------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// ------------------------------------------------------------------------------------------------ +namespace Microsoft.eServices.EDocument.Formats; + +using Microsoft.eServices.EDocument; + +table 10970 "FR E-Invoice Message" +{ + Caption = 'FR E-Invoice Message'; + DataClassification = CustomerContent; + InherentEntitlements = X; + InherentPermissions = X; + ReplicateData = false; + + fields + { + field(1; "Entry No."; Integer) + { + AutoIncrement = true; + Caption = 'Entry No.'; + DataClassification = SystemMetadata; + } + field(2; "E-Document Entry No."; Integer) + { + Caption = 'E-Document Entry No.'; + DataClassification = SystemMetadata; + TableRelation = "E-Document"."Entry No"; + } + field(3; Type; Enum "FR E-Invoice Message Type") + { + Caption = 'Type'; + DataClassification = SystemMetadata; + } + field(4; "Source Occurrence ID"; Guid) + { + Caption = 'Source Occurrence ID'; + DataClassification = SystemMetadata; + } + field(5; "Original Entry No."; Integer) + { + Caption = 'Original Entry No.'; + DataClassification = SystemMetadata; + TableRelation = "FR E-Invoice Message"."Entry No."; + } + field(6; Amount; Decimal) + { + AutoFormatExpression = Rec."Currency Code"; + AutoFormatType = 1; + Caption = 'Amount'; + DataClassification = CustomerContent; + } + field(7; "Currency Code"; Code[10]) + { + Caption = 'Currency Code'; + DataClassification = CustomerContent; + } + field(8; "Event Date"; Date) + { + Caption = 'Event Date'; + DataClassification = CustomerContent; + } + field(9; "Detailed Ledger Entry No."; Integer) + { + Caption = 'Detailed Ledger Entry No.'; + DataClassification = SystemMetadata; + } + field(10; "Reason Code"; Code[20]) + { + Caption = 'Reason Code'; + DataClassification = CustomerContent; + } + field(11; "Reason Description"; Text[500]) + { + Caption = 'Reason Description'; + DataClassification = CustomerContent; + } + field(12; "E-Document Message Entry No."; Integer) + { + Caption = 'E-Document Message Entry No.'; + DataClassification = SystemMetadata; + } + field(13; "Created At"; DateTime) + { + Caption = 'Created At'; + DataClassification = SystemMetadata; + } + } + + keys + { + key(PK; "Entry No.") + { + Clustered = true; + } + key(Occurrence; "E-Document Entry No.", "Source Occurrence ID", Type) + { + Unique = true; + } + key(DetailedLedgerEntry; Type, "Detailed Ledger Entry No.") + { + } + } +} \ No newline at end of file diff --git a/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/FREInvoiceMessageMgt.Codeunit.al b/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/FREInvoiceMessageMgt.Codeunit.al new file mode 100644 index 00000000000..2b1597d41d3 --- /dev/null +++ b/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/FREInvoiceMessageMgt.Codeunit.al @@ -0,0 +1,251 @@ +// ------------------------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// ------------------------------------------------------------------------------------------------ +namespace Microsoft.eServices.EDocument.Formats; + +using Microsoft.eServices.EDocument; +using Microsoft.eServices.EDocument.Processing.Message; +using Microsoft.Finance.GeneralLedger.Journal; +using Microsoft.Finance.GeneralLedger.Posting; +using Microsoft.Finance.GeneralLedger.Setup; +using Microsoft.Finance.ReceivablesPayables; +using Microsoft.Sales.Customer; +using Microsoft.Sales.History; +using Microsoft.Sales.Receivables; +using System.Utilities; + +codeunit 10975 "FR E-Invoice Message Mgt." +{ + Access = Internal; + InherentEntitlements = X; + InherentPermissions = X; + + [EventSubscriber(ObjectType::Codeunit, Codeunit::"Gen. Jnl.-Post Line", 'OnAfterInsertDtldCustLedgEntry', '', false, false)] + local procedure OnAfterInsertDtldCustLedgEntry(var DtldCustLedgEntry: Record "Detailed Cust. Ledg. Entry"; GenJournalLine: Record "Gen. Journal Line"; DtldCVLedgEntryBuffer: Record "Detailed CV Ledg. Entry Buffer"; Offset: Integer) + begin + ProcessApplication(DtldCustLedgEntry); + end; + + [EventSubscriber(ObjectType::Codeunit, Codeunit::"Gen. Jnl.-Post Line", 'OnAfterInsertDtldCustLedgEntryUnapply', '', false, false)] + local procedure OnAfterInsertDtldCustLedgEntryUnapply(var CustomerPostingGroup: Record "Customer Posting Group"; var OldDetailedCustLedgEntry: Record "Detailed Cust. Ledg. Entry"; var GenJnlLine: Record "Gen. Journal Line"; var NewDetailedCustLedgEntry: Record "Detailed Cust. Ledg. Entry") + begin + ProcessUnapplication(OldDetailedCustLedgEntry, NewDetailedCustLedgEntry); + end; + + internal procedure RefuseInvoice(EDocument: Record "E-Document"; ReasonCode: Code[20]; ReasonDescription: Text[500]) + var + FREInvoiceMessage: Record "FR E-Invoice Message"; + begin + EDocument.TestField(Direction, EDocument.Direction::Incoming); + EDocument.TestField("Document Type", EDocument."Document Type"::"Purchase Invoice"); + EDocument.TestField(Service); + if ReasonCode = '' then + Error(ReasonCodeRequiredErr); + if ReasonDescription = '' then + Error(ReasonDescriptionRequiredErr); + + FREInvoiceMessage.SetRange("E-Document Entry No.", EDocument."Entry No"); + FREInvoiceMessage.SetRange(Type, FREInvoiceMessage.Type::Refused); + if not FREInvoiceMessage.IsEmpty() then + Error(AlreadyRefusedErr, EDocument."Document No."); + + CreateAndSendMessage(EDocument, FREInvoiceMessage.Type::Refused, CreateGuid(), 0, '', Today(), 0, 0, ReasonCode, ReasonDescription); + end; + + internal procedure ProcessApplication(DetailedCustLedgEntry: Record "Detailed Cust. Ledg. Entry") + var + EDocument: Record "E-Document"; + InvoiceCustLedgerEntry: Record "Cust. Ledger Entry"; + PaymentCustLedgerEntry: Record "Cust. Ledger Entry"; + begin + if not IsInvoiceApplication(DetailedCustLedgEntry) then + exit; + if not InvoiceCustLedgerEntry.Get(DetailedCustLedgEntry."Cust. Ledger Entry No.") then + exit; + if not PaymentCustLedgerEntry.Get(DetailedCustLedgEntry."Applied Cust. Ledger Entry No.") then + exit; + if PaymentCustLedgerEntry."Document Type" <> PaymentCustLedgerEntry."Document Type"::Payment then + exit; + if not FindInvoiceEDocuments(EDocument, InvoiceCustLedgerEntry) then + exit; + + repeat + if IsEligibleFrenchEDocument(EDocument) then + CreateAndSendMessage( + EDocument, "FR E-Invoice Message Type"::Collected, DetailedCustLedgEntry.SystemId, + -DetailedCustLedgEntry.Amount, DetailedCustLedgEntry."Currency Code", DetailedCustLedgEntry."Posting Date", + DetailedCustLedgEntry."Entry No.", 0, '', ''); + until EDocument.Next() = 0; + end; + + internal procedure ProcessUnapplication(OldDetailedCustLedgEntry: Record "Detailed Cust. Ledg. Entry"; NewDetailedCustLedgEntry: Record "Detailed Cust. Ledg. Entry") + var + CollectedMessage: Record "FR E-Invoice Message"; + EDocument: Record "E-Document"; + begin + if not IsInvoiceApplication(OldDetailedCustLedgEntry) then + exit; + + CollectedMessage.SetRange(Type, CollectedMessage.Type::Collected); + CollectedMessage.SetRange("Detailed Ledger Entry No.", OldDetailedCustLedgEntry."Entry No."); + if not CollectedMessage.FindSet() then + exit; + + repeat + EDocument.Get(CollectedMessage."E-Document Entry No."); + CreateAndSendMessage( + EDocument, "FR E-Invoice Message Type"::"Negative Collected", NewDetailedCustLedgEntry.SystemId, + -CollectedMessage.Amount, CollectedMessage."Currency Code", NewDetailedCustLedgEntry."Posting Date", + NewDetailedCustLedgEntry."Entry No.", CollectedMessage."Entry No.", '', ''); + until CollectedMessage.Next() = 0; + end; + + local procedure CreateAndSendMessage(EDocument: Record "E-Document"; MessageType: Enum "FR E-Invoice Message Type"; SourceOccurrenceID: Guid; Amount: Decimal; CurrencyCode: Code[10]; EventDate: Date; DetailedLedgerEntryNo: Integer; OriginalEntryNo: Integer; ReasonCode: Code[20]; ReasonDescription: Text[500]) + var + FREInvoiceMessage: Record "FR E-Invoice Message"; + EDocumentMessageAPI: Codeunit "E-Document Message API"; + TempBlob: Codeunit "Temp Blob"; + begin + FREInvoiceMessage.SetRange("E-Document Entry No.", EDocument."Entry No"); + FREInvoiceMessage.SetRange("Source Occurrence ID", SourceOccurrenceID); + FREInvoiceMessage.SetRange(Type, MessageType); + if FREInvoiceMessage.FindFirst() then + exit; + + FREInvoiceMessage.Init(); + FREInvoiceMessage."E-Document Entry No." := EDocument."Entry No"; + FREInvoiceMessage.Type := MessageType; + FREInvoiceMessage."Source Occurrence ID" := SourceOccurrenceID; + FREInvoiceMessage."Original Entry No." := OriginalEntryNo; + FREInvoiceMessage.Amount := Amount; + FREInvoiceMessage."Currency Code" := CurrencyCode; + FREInvoiceMessage."Event Date" := EventDate; + FREInvoiceMessage."Detailed Ledger Entry No." := DetailedLedgerEntryNo; + FREInvoiceMessage."Reason Code" := ReasonCode; + FREInvoiceMessage."Reason Description" := ReasonDescription; + FREInvoiceMessage."Created At" := CurrentDateTime(); + FREInvoiceMessage.Insert(); + + BuildMessage(EDocument, FREInvoiceMessage, TempBlob); + FREInvoiceMessage."E-Document Message Entry No." := EDocumentMessageAPI.CreateMessage( + EDocument, "E-Document Message Type"::"FR Invoice Lifecycle", GetResponseType(MessageType), TempBlob); + FREInvoiceMessage.Modify(); + EDocumentMessageAPI.SendMessage(FREInvoiceMessage."E-Document Message Entry No."); + end; + + local procedure BuildMessage(EDocument: Record "E-Document"; FREInvoiceMessage: Record "FR E-Invoice Message"; var TempBlob: Codeunit "Temp Blob") + var + XmlDoc: XmlDocument; + RootElement: XmlElement; + AcknowledgementElement: XmlElement; + ReferenceElement: XmlElement; + StatusElement: XmlElement; + AmountElement: XmlElement; + OutStream: OutStream; + begin + XmlDoc := XmlDocument.Create(); + XmlDoc.SetDeclaration(XmlDeclaration.Create('1.0', 'UTF-8', 'no')); + RootElement := XmlElement.Create('CrossDomainAcknowledgementAndResponse', RsmNamespaceTok); + RootElement.Add(XmlAttribute.CreateNamespaceDeclaration('ram', RamNamespaceTok)); + RootElement.Add(XmlAttribute.CreateNamespaceDeclaration('rsm', RsmNamespaceTok)); + RootElement.Add(XmlElement.Create('ExchangedDocument', RsmNamespaceTok, + XmlElement.Create('ID', RamNamespaceTok, Format(FREInvoiceMessage."Source Occurrence ID")))); + + AcknowledgementElement := XmlElement.Create('AcknowledgementDocument', RsmNamespaceTok); + ReferenceElement := XmlElement.Create('ReferenceReferencedDocument', RamNamespaceTok); + ReferenceElement.Add(XmlElement.Create('IssuerAssignedID', RamNamespaceTok, EDocument."Document No.")); + ReferenceElement.Add(XmlElement.Create('StatusCode', RamNamespaceTok, InvoiceReferenceStatusCodeTok)); + if FREInvoiceMessage.Type = FREInvoiceMessage.Type::Refused then begin + ReferenceElement.Add(XmlElement.Create('ProcessConditionCode', RamNamespaceTok, RefusedStatusCodeTok)); + ReferenceElement.Add(XmlElement.Create('ProcessCondition', RamNamespaceTok, RefusedStatusNameTok)); + StatusElement := XmlElement.Create('SpecifiedDocumentStatus', RamNamespaceTok); + StatusElement.Add(XmlElement.Create('ReasonCode', RamNamespaceTok, FREInvoiceMessage."Reason Code")); + StatusElement.Add(XmlElement.Create('Reason', RamNamespaceTok, FREInvoiceMessage."Reason Description")); + ReferenceElement.Add(StatusElement); + end else begin + ReferenceElement.Add(XmlElement.Create('ProcessConditionCode', RamNamespaceTok, CollectedStatusCodeTok)); + ReferenceElement.Add(XmlElement.Create('ProcessCondition', RamNamespaceTok, CollectedStatusNameTok)); + StatusElement := XmlElement.Create('SpecifiedDocumentStatus', RamNamespaceTok); + StatusElement.Add(XmlElement.Create('TypeCode', RamNamespaceTok, CollectedAmountTypeCodeTok)); + AmountElement := XmlElement.Create('ValueAmount', RamNamespaceTok, Format(FREInvoiceMessage.Amount, 0, 9)); + AmountElement.Add(XmlAttribute.Create('currencyID', ResolveCurrencyCode(FREInvoiceMessage."Currency Code"))); + StatusElement.Add(AmountElement); + ReferenceElement.Add(StatusElement); + end; + AcknowledgementElement.Add(ReferenceElement); + RootElement.Add(AcknowledgementElement); + XmlDoc.Add(RootElement); + + TempBlob.CreateOutStream(OutStream, TextEncoding::UTF8); + XmlDoc.WriteTo(OutStream); + end; + + local procedure FindInvoiceEDocuments(var EDocument: Record "E-Document"; InvoiceCustLedgerEntry: Record "Cust. Ledger Entry"): Boolean + var + SalesInvoiceHeader: Record "Sales Invoice Header"; + begin + if InvoiceCustLedgerEntry."Document Type" <> InvoiceCustLedgerEntry."Document Type"::Invoice then + exit(false); + if not SalesInvoiceHeader.Get(InvoiceCustLedgerEntry."Document No.") then + exit(false); + + EDocument.SetRange("Document Record ID", SalesInvoiceHeader.RecordId); + EDocument.SetRange(Direction, EDocument.Direction::Outgoing); + EDocument.SetRange("Document Type", EDocument."Document Type"::"Sales Invoice"); + exit(EDocument.FindSet()); + end; + + local procedure IsEligibleFrenchEDocument(EDocument: Record "E-Document"): Boolean + var + EDocumentService: Record "E-Document Service"; + EDocumentServiceStatus: Record "E-Document Service Status"; + begin + if not EDocumentService.Get(EDocument.Service) then + exit(false); + if not (EDocumentService."Document Format" in [EDocumentService."Document Format"::"Peppol BIS 3.0 FR", EDocumentService."Document Format"::"Factur-X FR"]) then + exit(false); + if not EDocumentServiceStatus.Get(EDocument."Entry No", EDocument.Service) then + exit(false); + exit(EDocumentServiceStatus.Status in [EDocumentServiceStatus.Status::Approved, EDocumentServiceStatus.Status::Cleared]); + end; + + local procedure IsInvoiceApplication(DetailedCustLedgEntry: Record "Detailed Cust. Ledg. Entry"): Boolean + begin + exit( + (DetailedCustLedgEntry."Entry Type" = DetailedCustLedgEntry."Entry Type"::Application) and + (DetailedCustLedgEntry."Initial Document Type" = DetailedCustLedgEntry."Initial Document Type"::Invoice) and + (DetailedCustLedgEntry.Amount < 0)); + end; + + local procedure GetResponseType(MessageType: Enum "FR E-Invoice Message Type"): Enum "E-Doc. Response Type" + begin + if MessageType = MessageType::Refused then + exit("E-Doc. Response Type"::Refused); + exit("E-Doc. Response Type"::None); + end; + + local procedure ResolveCurrencyCode(CurrencyCode: Code[10]): Code[10] + var + GeneralLedgerSetup: Record "General Ledger Setup"; + begin + if CurrencyCode <> '' then + exit(CurrencyCode); + GeneralLedgerSetup.Get(); + GeneralLedgerSetup.TestField("LCY Code"); + exit(GeneralLedgerSetup."LCY Code"); + end; + + var + RsmNamespaceTok: Label 'urn:un:unece:uncefact:data:standard:CrossDomainAcknowledgementAndResponse:100', Locked = true; + RamNamespaceTok: Label 'urn:un:unece:uncefact:data:standard:ReusableAggregateBusinessInformationEntity:100', Locked = true; + InvoiceReferenceStatusCodeTok: Label '47', Locked = true; + CollectedStatusCodeTok: Label '212', Locked = true; + CollectedStatusNameTok: Label 'Encaissée', Locked = true; + CollectedAmountTypeCodeTok: Label 'MEN', Locked = true; + RefusedStatusCodeTok: Label '210', Locked = true; + RefusedStatusNameTok: Label 'Refusée', Locked = true; + ReasonCodeRequiredErr: Label 'A refusal reason code is required.'; + ReasonDescriptionRequiredErr: Label 'A refusal reason description is required.'; + AlreadyRefusedErr: Label 'Invoice %1 has already been refused.', Comment = '%1 = invoice number'; +} \ No newline at end of file diff --git a/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/FREInvoiceMessageType.Enum.al b/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/FREInvoiceMessageType.Enum.al new file mode 100644 index 00000000000..dae5310dce4 --- /dev/null +++ b/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/FREInvoiceMessageType.Enum.al @@ -0,0 +1,23 @@ +// ------------------------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// ------------------------------------------------------------------------------------------------ +namespace Microsoft.eServices.EDocument.Formats; + +enum 10970 "FR E-Invoice Message Type" +{ + Access = Public; + + value(0; Collected) + { + Caption = 'Collected'; + } + value(1; "Negative Collected") + { + Caption = 'Negative Collected'; + } + value(2; Refused) + { + Caption = 'Refused'; + } +} \ No newline at end of file diff --git a/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/FREInvoiceRefusalDialog.Page.al b/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/FREInvoiceRefusalDialog.Page.al new file mode 100644 index 00000000000..5d840dd0314 --- /dev/null +++ b/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/FREInvoiceRefusalDialog.Page.al @@ -0,0 +1,43 @@ +// ------------------------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// ------------------------------------------------------------------------------------------------ +namespace Microsoft.eServices.EDocument.Formats; + +page 10972 "FR E-Invoice Refusal Dialog" +{ + Caption = 'Refuse E-Invoice'; + PageType = StandardDialog; + + layout + { + area(Content) + { + field(ReasonCode; ReasonCode) + { + ApplicationArea = Basic, Suite; + Caption = 'Reason Code'; + NotBlank = true; + ToolTip = 'Specifies the code that identifies why the invoice is refused.'; + } + field(ReasonDescription; ReasonDescription) + { + ApplicationArea = Basic, Suite; + Caption = 'Reason Description'; + MultiLine = true; + NotBlank = true; + ToolTip = 'Specifies why the invoice is refused.'; + } + } + } + + internal procedure GetReason(var NewReasonCode: Code[20]; var NewReasonDescription: Text[500]) + begin + NewReasonCode := ReasonCode; + NewReasonDescription := ReasonDescription; + end; + + var + ReasonCode: Code[20]; + ReasonDescription: Text[500]; +} \ No newline at end of file diff --git a/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Extensions/EReportingEDocuments.PageExt.al b/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Extensions/EReportingEDocuments.PageExt.al index e2d2204b402..4bdaf743097 100644 --- a/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Extensions/EReportingEDocuments.PageExt.al +++ b/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Extensions/EReportingEDocuments.PageExt.al @@ -20,4 +20,33 @@ pageextension 10974 "E-Reporting E-Documents" extends "E-Documents" } } } + + actions + { + addlast(Processing) + { + action(RefuseFREInvoice) + { + ApplicationArea = Basic, Suite; + Caption = 'Refuse E-Invoice'; + Image = Reject; + ToolTip = 'Refuse the incoming French electronic purchase invoice and send the response to the supplier.'; + Visible = (Rec.Direction = Rec.Direction::Incoming) and (Rec."Document Type" = Rec."Document Type"::"Purchase Invoice"); + + trigger OnAction() + var + FREInvoiceMessageMgt: Codeunit "FR E-Invoice Message Mgt."; + FREInvoiceRefusalDialog: Page "FR E-Invoice Refusal Dialog"; + ReasonCode: Code[20]; + ReasonDescription: Text[500]; + begin + if FREInvoiceRefusalDialog.RunModal() <> Action::OK then + exit; + FREInvoiceRefusalDialog.GetReason(ReasonCode, ReasonDescription); + FREInvoiceMessageMgt.RefuseInvoice(Rec, ReasonCode, ReasonDescription); + CurrPage.Update(false); + end; + } + } + } } diff --git a/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Extensions/FREDocResponseType.EnumExt.al b/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Extensions/FREDocResponseType.EnumExt.al new file mode 100644 index 00000000000..cae8ea1e412 --- /dev/null +++ b/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Extensions/FREDocResponseType.EnumExt.al @@ -0,0 +1,15 @@ +// ------------------------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// ------------------------------------------------------------------------------------------------ +namespace Microsoft.eServices.EDocument.Formats; + +using Microsoft.eServices.EDocument.Processing.Message; + +enumextension 10974 "FR E-Doc. Response Type" extends "E-Doc. Response Type" +{ + value(10971; Refused) + { + Caption = 'Refused'; + } +} \ No newline at end of file diff --git a/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Extensions/FREDocumentMessageType.EnumExt.al b/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Extensions/FREDocumentMessageType.EnumExt.al new file mode 100644 index 00000000000..cceabfb265c --- /dev/null +++ b/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Extensions/FREDocumentMessageType.EnumExt.al @@ -0,0 +1,15 @@ +// ------------------------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// ------------------------------------------------------------------------------------------------ +namespace Microsoft.eServices.EDocument.Formats; + +using Microsoft.eServices.EDocument.Processing.Message; + +enumextension 10973 "FR E-Document Message Type" extends "E-Document Message Type" +{ + value(10970; "FR Invoice Lifecycle") + { + Caption = 'FR Invoice Lifecycle'; + } +} \ No newline at end of file diff --git a/src/Apps/FR/EDocument_FR/EReportingFR/test/src/FREDocMessageSenderMock.Codeunit.al b/src/Apps/FR/EDocument_FR/EReportingFR/test/src/FREDocMessageSenderMock.Codeunit.al new file mode 100644 index 00000000000..1c890b2f63c --- /dev/null +++ b/src/Apps/FR/EDocument_FR/EReportingFR/test/src/FREDocMessageSenderMock.Codeunit.al @@ -0,0 +1,75 @@ +// ------------------------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// ------------------------------------------------------------------------------------------------ +namespace Microsoft.eServices.EDocument.Formats.Test; + +using Microsoft.eServices.EDocument; +using Microsoft.eServices.EDocument.Integration.Interfaces; +using Microsoft.eServices.EDocument.Integration.Receive; +using Microsoft.eServices.EDocument.Integration.Send; +using Microsoft.eServices.EDocument.Processing.Message; +using System.Utilities; + +codeunit 148150 "FR E-Doc. Msg. Sender Mock" implements IDocumentSender, IDocumentReceiver, IConsentManager, IMessageSender +{ + Access = Internal; + SingleInstance = true; + + procedure Send(var EDocument: Record "E-Document"; var EDocumentService: Record "E-Document Service"; SendContext: Codeunit SendContext) + begin + end; + + procedure SendMessage(var EDocument: Record "E-Document"; var EDocumentService: Record "E-Document Service"; MessageContext: Codeunit "E-Doc. Message Context") + var + TempBlob: Codeunit "Temp Blob"; + InStream: InStream; + begin + SendCount += 1; + LastResponseType := MessageContext.GetResponseType(); + TempBlob := MessageContext.GetTempBlob(); + TempBlob.CreateInStream(InStream, TextEncoding::UTF8); + InStream.ReadText(LastPayload); + MessageContext.Status().SetStatus("E-Document Service Status"::Sent); + end; + + procedure ReceiveDocuments(var EDocumentService: Record "E-Document Service"; DocumentsMetadata: Codeunit "Temp Blob List"; ReceiveContext: Codeunit ReceiveContext) + begin + end; + + procedure DownloadDocument(var EDocument: Record "E-Document"; var EDocumentService: Record "E-Document Service"; DocumentMetadata: Codeunit "Temp Blob"; ReceiveContext: Codeunit ReceiveContext) + begin + end; + + procedure ObtainPrivacyConsent(): Boolean + begin + exit(true); + end; + + procedure Reset() + begin + Clear(LastPayload); + Clear(LastResponseType); + SendCount := 0; + end; + + procedure GetSendCount(): Integer + begin + exit(SendCount); + end; + + procedure GetLastPayload(): Text + begin + exit(LastPayload); + end; + + procedure GetLastResponseType(): Enum "E-Doc. Response Type" + begin + exit(LastResponseType); + end; + + var + LastResponseType: Enum "E-Doc. Response Type"; + LastPayload: Text; + SendCount: Integer; +} \ No newline at end of file diff --git a/src/Apps/FR/EDocument_FR/EReportingFR/test/src/FREInvoiceMessageTests.Codeunit.al b/src/Apps/FR/EDocument_FR/EReportingFR/test/src/FREInvoiceMessageTests.Codeunit.al new file mode 100644 index 00000000000..84f1dbb9411 --- /dev/null +++ b/src/Apps/FR/EDocument_FR/EReportingFR/test/src/FREInvoiceMessageTests.Codeunit.al @@ -0,0 +1,222 @@ +// ------------------------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// ------------------------------------------------------------------------------------------------ +namespace Microsoft.eServices.EDocument.Formats.Test; + +using Microsoft.eServices.EDocument; +using Microsoft.eServices.EDocument.Formats; +using Microsoft.eServices.EDocument.Processing.Message; +using Microsoft.Finance.GeneralLedger.Setup; +using Microsoft.Sales.History; +using Microsoft.Sales.Receivables; + +codeunit 148152 "FR E-Invoice Message Tests" +{ + Subtype = Test; + TestType = IntegrationTest; + TestPermissions = Disabled; + Permissions = tabledata "Cust. Ledger Entry" = rimd, + tabledata "Detailed Cust. Ledg. Entry" = rimd, + tabledata "E-Document" = rimd, + tabledata "E-Document Service" = rimd, + tabledata "E-Document Service Status" = rimd, + tabledata "FR E-Invoice Message" = rimd, + tabledata "Sales Invoice Header" = rimd; + + var + Assert: Codeunit Assert; + MessageSenderMock: Codeunit "FR E-Doc. Msg. Sender Mock"; + + [Test] + procedure PaymentApplicationSendsCollected() + var + EDocument: Record "E-Document"; + FREInvoiceMessage: Record "FR E-Invoice Message"; + DetailedCustLedgEntry: Record "Detailed Cust. Ledg. Entry"; + FREInvoiceMessageMgt: Codeunit "FR E-Invoice Message Mgt."; + begin + Initialize(); + CreatePaymentScenario(EDocument, DetailedCustLedgEntry); + + FREInvoiceMessageMgt.ProcessApplication(DetailedCustLedgEntry); + + FREInvoiceMessage.SetRange("E-Document Entry No.", EDocument."Entry No"); + FREInvoiceMessage.SetRange(Type, FREInvoiceMessage.Type::Collected); + Assert.RecordCount(FREInvoiceMessage, 1); + Assert.AreEqual(1, MessageSenderMock.GetSendCount(), 'One Collected message must be sent.'); + Assert.IsTrue(MessageSenderMock.GetLastPayload().Contains('212'), 'The payload must contain status 212.'); + Assert.IsTrue(MessageSenderMock.GetLastPayload().Contains('100'), 'The payload must contain the collected amount.'); + end; + + [Test] + procedure PaymentUnapplicationSendsLinkedNegativeCollected() + var + EDocument: Record "E-Document"; + CollectedMessage: Record "FR E-Invoice Message"; + NegativeMessage: Record "FR E-Invoice Message"; + DetailedCustLedgEntry: Record "Detailed Cust. Ledg. Entry"; + NewDetailedCustLedgEntry: Record "Detailed Cust. Ledg. Entry"; + FREInvoiceMessageMgt: Codeunit "FR E-Invoice Message Mgt."; + begin + Initialize(); + CreatePaymentScenario(EDocument, DetailedCustLedgEntry); + FREInvoiceMessageMgt.ProcessApplication(DetailedCustLedgEntry); + CreateDetailedLedgerEntry(NewDetailedCustLedgEntry, DetailedCustLedgEntry."Cust. Ledger Entry No.", DetailedCustLedgEntry."Applied Cust. Ledger Entry No.", -100); + + FREInvoiceMessageMgt.ProcessUnapplication(DetailedCustLedgEntry, NewDetailedCustLedgEntry); + + CollectedMessage.SetRange("E-Document Entry No.", EDocument."Entry No"); + CollectedMessage.SetRange(Type, CollectedMessage.Type::Collected); + CollectedMessage.FindFirst(); + NegativeMessage.SetRange("E-Document Entry No.", EDocument."Entry No"); + NegativeMessage.SetRange(Type, NegativeMessage.Type::"Negative Collected"); + NegativeMessage.FindFirst(); + Assert.AreEqual(CollectedMessage."Entry No.", NegativeMessage."Original Entry No.", 'The reversal must link to the original occurrence.'); + Assert.AreEqual(-CollectedMessage.Amount, NegativeMessage.Amount, 'The reversal amount must negate the original amount.'); + Assert.AreEqual(2, MessageSenderMock.GetSendCount(), 'Collected and Negative Collected messages must be sent.'); + Assert.IsTrue(MessageSenderMock.GetLastPayload().Contains('-100'), 'The reversal payload must contain a negative amount.'); + end; + + [Test] + procedure RefusalSendsStatusAndReason() + var + EDocument: Record "E-Document"; + FREInvoiceMessageMgt: Codeunit "FR E-Invoice Message Mgt."; + begin + Initialize(); + CreateIncomingEDocument(EDocument); + + FREInvoiceMessageMgt.RefuseInvoice(EDocument, 'PRICE', 'The amount is incorrect.'); + + Assert.AreEqual(1, MessageSenderMock.GetSendCount(), 'One refusal message must be sent.'); + Assert.AreEqual("E-Doc. Response Type"::Refused, MessageSenderMock.GetLastResponseType(), 'The child message must be Refused.'); + Assert.IsTrue(MessageSenderMock.GetLastPayload().Contains('210'), 'The payload must contain status 210.'); + Assert.IsTrue(MessageSenderMock.GetLastPayload().Contains('PRICE'), 'The payload must contain the reason code.'); + end; + + [Test] + procedure RefusalRequiresReasonAndCannotBeRepeated() + var + EDocument: Record "E-Document"; + FREInvoiceMessageMgt: Codeunit "FR E-Invoice Message Mgt."; + begin + Initialize(); + CreateIncomingEDocument(EDocument); + + asserterror FREInvoiceMessageMgt.RefuseInvoice(EDocument, '', 'Not accepted.'); + Assert.ExpectedError('A refusal reason code is required.'); + FREInvoiceMessageMgt.RefuseInvoice(EDocument, 'OTHER', 'Not accepted.'); + asserterror FREInvoiceMessageMgt.RefuseInvoice(EDocument, 'OTHER', 'Again.'); + Assert.ExpectedError('has already been refused'); + Assert.AreEqual(1, MessageSenderMock.GetSendCount(), 'A duplicate refusal must not be sent.'); + end; + + local procedure Initialize() + var + FREInvoiceMessage: Record "FR E-Invoice Message"; + begin + FREInvoiceMessage.DeleteAll(); + MessageSenderMock.Reset(); + EnsureService(); + end; + + local procedure EnsureService() + var + EDocumentService: Record "E-Document Service"; + begin + if EDocumentService.Get('FR-MESSAGE-MOCK') then + exit; + EDocumentService.Init(); + EDocumentService.Code := 'FR-MESSAGE-MOCK'; + EDocumentService."Document Format" := EDocumentService."Document Format"::"Peppol BIS 3.0 FR"; + EDocumentService."Service Integration V2" := EDocumentService."Service Integration V2"::"FR Message Mock"; + EDocumentService.Insert(); + end; + + local procedure CreateIncomingEDocument(var EDocument: Record "E-Document") + begin + EDocument.Init(); + EDocument."Document No." := CopyStr(Format(CreateGuid()), 1, MaxStrLen(EDocument."Document No.")); + EDocument.Direction := EDocument.Direction::Incoming; + EDocument."Document Type" := EDocument."Document Type"::"Purchase Invoice"; + EDocument.Service := 'FR-MESSAGE-MOCK'; + EDocument.Insert(); + end; + + local procedure CreatePaymentScenario(var EDocument: Record "E-Document"; var DetailedCustLedgEntry: Record "Detailed Cust. Ledg. Entry") + var + InvoiceCustLedgerEntry: Record "Cust. Ledger Entry"; + PaymentCustLedgerEntry: Record "Cust. Ledger Entry"; + SalesInvoiceHeader: Record "Sales Invoice Header"; + DocumentNo: Code[20]; + begin + DocumentNo := CopyStr(Format(CreateGuid()), 1, MaxStrLen(DocumentNo)); + SalesInvoiceHeader.Init(); + SalesInvoiceHeader."No." := DocumentNo; + SalesInvoiceHeader.Insert(); + + EDocument.Init(); + EDocument."Document No." := DocumentNo; + EDocument."Document Record ID" := SalesInvoiceHeader.RecordId; + EDocument.Direction := EDocument.Direction::Outgoing; + EDocument."Document Type" := EDocument."Document Type"::"Sales Invoice"; + EDocument.Service := 'FR-MESSAGE-MOCK'; + EDocument.Insert(); + CreateServiceStatus(EDocument); + + InvoiceCustLedgerEntry.Init(); + InvoiceCustLedgerEntry."Entry No." := GetNextCustLedgerEntryNo(); + InvoiceCustLedgerEntry."Document Type" := InvoiceCustLedgerEntry."Document Type"::Invoice; + InvoiceCustLedgerEntry."Document No." := DocumentNo; + InvoiceCustLedgerEntry.Insert(); + PaymentCustLedgerEntry.Init(); + PaymentCustLedgerEntry."Entry No." := InvoiceCustLedgerEntry."Entry No." + 1; + PaymentCustLedgerEntry."Document Type" := PaymentCustLedgerEntry."Document Type"::Payment; + PaymentCustLedgerEntry.Insert(); + CreateDetailedLedgerEntry(DetailedCustLedgEntry, InvoiceCustLedgerEntry."Entry No.", PaymentCustLedgerEntry."Entry No.", -100); + end; + + local procedure CreateServiceStatus(EDocument: Record "E-Document") + var + EDocumentServiceStatus: Record "E-Document Service Status"; + begin + EDocumentServiceStatus.Init(); + EDocumentServiceStatus."E-Document Entry No" := EDocument."Entry No"; + EDocumentServiceStatus."E-Document Service Code" := EDocument.Service; + EDocumentServiceStatus.Status := EDocumentServiceStatus.Status::Approved; + EDocumentServiceStatus.Insert(); + end; + + local procedure CreateDetailedLedgerEntry(var DetailedCustLedgEntry: Record "Detailed Cust. Ledg. Entry"; InvoiceEntryNo: Integer; PaymentEntryNo: Integer; Amount: Decimal) + begin + DetailedCustLedgEntry.Init(); + DetailedCustLedgEntry."Entry No." := GetNextDetailedLedgerEntryNo(); + DetailedCustLedgEntry."Cust. Ledger Entry No." := InvoiceEntryNo; + DetailedCustLedgEntry."Applied Cust. Ledger Entry No." := PaymentEntryNo; + DetailedCustLedgEntry."Entry Type" := DetailedCustLedgEntry."Entry Type"::Application; + DetailedCustLedgEntry."Initial Document Type" := DetailedCustLedgEntry."Initial Document Type"::Invoice; + DetailedCustLedgEntry.Amount := Amount; + DetailedCustLedgEntry."Currency Code" := 'EUR'; + DetailedCustLedgEntry."Posting Date" := WorkDate(); + DetailedCustLedgEntry.Insert(); + end; + + local procedure GetNextCustLedgerEntryNo(): Integer + var + CustLedgerEntry: Record "Cust. Ledger Entry"; + begin + if CustLedgerEntry.FindLast() then + exit(CustLedgerEntry."Entry No." + 1); + exit(1); + end; + + local procedure GetNextDetailedLedgerEntryNo(): Integer + var + DetailedCustLedgEntry: Record "Detailed Cust. Ledg. Entry"; + begin + if DetailedCustLedgEntry.FindLast() then + exit(DetailedCustLedgEntry."Entry No." + 1); + exit(1); + end; +} \ No newline at end of file diff --git a/src/Apps/FR/EDocument_FR/EReportingFR/test/src/FRServiceIntegration.EnumExt.al b/src/Apps/FR/EDocument_FR/EReportingFR/test/src/FRServiceIntegration.EnumExt.al new file mode 100644 index 00000000000..de8a5a7279e --- /dev/null +++ b/src/Apps/FR/EDocument_FR/EReportingFR/test/src/FRServiceIntegration.EnumExt.al @@ -0,0 +1,16 @@ +// ------------------------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// ------------------------------------------------------------------------------------------------ +namespace Microsoft.eServices.EDocument.Formats.Test; + +using Microsoft.eServices.EDocument.Integration; +using Microsoft.eServices.EDocument.Integration.Interfaces; + +enumextension 148151 "FR Service Integration" extends "Service Integration" +{ + value(148150; "FR Message Mock") + { + Implementation = IDocumentSender = "FR E-Doc. Msg. Sender Mock", IDocumentReceiver = "FR E-Doc. Msg. Sender Mock", IConsentManager = "FR E-Doc. Msg. Sender Mock", IMessageSender = "FR E-Doc. Msg. Sender Mock"; + } +} \ No newline at end of file From 31387e63f154f3cbbd6930fb82e99ebc8c2cdd5e Mon Sep 17 00:00:00 2001 From: djukicmilica Date: Thu, 20 Aug 2026 14:22:18 +0200 Subject: [PATCH 03/23] new --- .../Core/FREInvoiceMessageBuilder.Codeunit.al | 84 ++ .../src/Core/FREInvoiceMessageMgt.Codeunit.al | 173 +-- .../src/Core/FREInvoiceMessageType.Enum.al | 12 + .../app/src/Core/FREInvoiceMessages.Page.al | 79 + .../EReportingEDocuments.PageExt.al | 9 + .../src/FREDocMessageSenderMock.Codeunit.al | 10 +- .../src/FREInvoiceMessageTests.Codeunit.al | 145 +- .../test/src/FacturXCIIXMLTests.Codeunit.al | 1289 +++++++++++++++-- .../EDocCoreObjects.PermissionSet.al | 5 + .../Permissions/EDocCoreRead.PermissionSet.al | 2 + .../Permissions/EDocCoreUser.PermissionSet.al | 2 + .../Interfaces/IMessageSender.Interface.al | 10 + .../Integration/ServiceIntegration.Enum.al | 1 + .../EDocumentBackgroundJobs.Codeunit.al | 6 + .../Message/EDocExternalReference.Table.al | 61 + .../Message/EDocMessageContext.Codeunit.al | 24 + .../Message/EDocMessageMgt.Codeunit.al | 91 +- .../Message/EDocMessageSendJob.Codeunit.al | 46 + .../Message/EDocMessageStatus.Enum.al | 12 + .../Message/EDocPaymentOccurrence.Table.al | 94 ++ .../EDocPaymentOccurrenceMgt.Codeunit.al | 154 ++ .../Message/EDocPaymentOccurrenceType.Enum.al | 23 + .../Message/EDocumentMessage.Table.al | 33 + .../Message/EDocumentMessageAPI.Codeunit.al | 54 + .../Message/EDocumentMessagesFactBox.Page.al | 25 + 25 files changed, 2214 insertions(+), 230 deletions(-) create mode 100644 src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/FREInvoiceMessageBuilder.Codeunit.al create mode 100644 src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/FREInvoiceMessages.Page.al create mode 100644 src/Apps/W1/EDocument/App/src/Processing/Message/EDocExternalReference.Table.al create mode 100644 src/Apps/W1/EDocument/App/src/Processing/Message/EDocMessageSendJob.Codeunit.al create mode 100644 src/Apps/W1/EDocument/App/src/Processing/Message/EDocPaymentOccurrence.Table.al create mode 100644 src/Apps/W1/EDocument/App/src/Processing/Message/EDocPaymentOccurrenceMgt.Codeunit.al create mode 100644 src/Apps/W1/EDocument/App/src/Processing/Message/EDocPaymentOccurrenceType.Enum.al diff --git a/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/FREInvoiceMessageBuilder.Codeunit.al b/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/FREInvoiceMessageBuilder.Codeunit.al new file mode 100644 index 00000000000..834166558a0 --- /dev/null +++ b/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/FREInvoiceMessageBuilder.Codeunit.al @@ -0,0 +1,84 @@ +// ------------------------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// ------------------------------------------------------------------------------------------------ +namespace Microsoft.eServices.EDocument.Formats; + +using Microsoft.eServices.EDocument; +using Microsoft.Finance.GeneralLedger.Setup; +using System.Utilities; + +codeunit 10976 "FR E-Invoice Message Builder" +{ + Access = Internal; + InherentEntitlements = X; + InherentPermissions = X; + + procedure BuildMessage(EDocument: Record "E-Document"; FREInvoiceMessage: Record "FR E-Invoice Message"; var TempBlob: Codeunit "Temp Blob") + var + XmlDoc: XmlDocument; + RootElement: XmlElement; + AcknowledgementElement: XmlElement; + ReferenceElement: XmlElement; + StatusElement: XmlElement; + AmountElement: XmlElement; + OutStream: OutStream; + begin + XmlDoc := XmlDocument.Create(); + XmlDoc.SetDeclaration(XmlDeclaration.Create('1.0', 'UTF-8', 'no')); + RootElement := XmlElement.Create('CrossDomainAcknowledgementAndResponse', RsmNamespaceTok); + RootElement.Add(XmlAttribute.CreateNamespaceDeclaration('ram', RamNamespaceTok)); + RootElement.Add(XmlAttribute.CreateNamespaceDeclaration('rsm', RsmNamespaceTok)); + RootElement.Add(XmlElement.Create('ExchangedDocument', RsmNamespaceTok, + XmlElement.Create('ID', RamNamespaceTok, Format(FREInvoiceMessage."Source Occurrence ID")))); + + AcknowledgementElement := XmlElement.Create('AcknowledgementDocument', RsmNamespaceTok); + ReferenceElement := XmlElement.Create('ReferenceReferencedDocument', RamNamespaceTok); + ReferenceElement.Add(XmlElement.Create('IssuerAssignedID', RamNamespaceTok, EDocument."Document No.")); + ReferenceElement.Add(XmlElement.Create('StatusCode', RamNamespaceTok, InvoiceReferenceStatusCodeTok)); + if FREInvoiceMessage.Type = FREInvoiceMessage.Type::Refused then begin + ReferenceElement.Add(XmlElement.Create('ProcessConditionCode', RamNamespaceTok, RefusedStatusCodeTok)); + ReferenceElement.Add(XmlElement.Create('ProcessCondition', RamNamespaceTok, RefusedStatusNameTok)); + StatusElement := XmlElement.Create('SpecifiedDocumentStatus', RamNamespaceTok); + StatusElement.Add(XmlElement.Create('ReasonCode', RamNamespaceTok, FREInvoiceMessage."Reason Code")); + StatusElement.Add(XmlElement.Create('Reason', RamNamespaceTok, FREInvoiceMessage."Reason Description")); + ReferenceElement.Add(StatusElement); + end else begin + ReferenceElement.Add(XmlElement.Create('ProcessConditionCode', RamNamespaceTok, CollectedStatusCodeTok)); + ReferenceElement.Add(XmlElement.Create('ProcessCondition', RamNamespaceTok, CollectedStatusNameTok)); + StatusElement := XmlElement.Create('SpecifiedDocumentStatus', RamNamespaceTok); + StatusElement.Add(XmlElement.Create('TypeCode', RamNamespaceTok, CollectedAmountTypeCodeTok)); + AmountElement := XmlElement.Create('ValueAmount', RamNamespaceTok, Format(FREInvoiceMessage.Amount, 0, 9)); + AmountElement.Add(XmlAttribute.Create('currencyID', ResolveCurrencyCode(FREInvoiceMessage."Currency Code"))); + StatusElement.Add(AmountElement); + ReferenceElement.Add(StatusElement); + end; + AcknowledgementElement.Add(ReferenceElement); + RootElement.Add(AcknowledgementElement); + XmlDoc.Add(RootElement); + + TempBlob.CreateOutStream(OutStream, TextEncoding::UTF8); + XmlDoc.WriteTo(OutStream); + end; + + local procedure ResolveCurrencyCode(CurrencyCode: Code[10]): Code[10] + var + GeneralLedgerSetup: Record "General Ledger Setup"; + begin + if CurrencyCode <> '' then + exit(CurrencyCode); + GeneralLedgerSetup.Get(); + GeneralLedgerSetup.TestField("LCY Code"); + exit(GeneralLedgerSetup."LCY Code"); + end; + + var + RsmNamespaceTok: Label 'urn:un:unece:uncefact:data:standard:CrossDomainAcknowledgementAndResponse:100', Locked = true; + RamNamespaceTok: Label 'urn:un:unece:uncefact:data:standard:ReusableAggregateBusinessInformationEntity:100', Locked = true; + InvoiceReferenceStatusCodeTok: Label '47', Locked = true; + CollectedStatusCodeTok: Label '212', Locked = true; + CollectedStatusNameTok: Label 'Encaissée', Locked = true; + CollectedAmountTypeCodeTok: Label 'MEN', Locked = true; + RefusedStatusCodeTok: Label '210', Locked = true; + RefusedStatusNameTok: Label 'Refusée', Locked = true; +} \ No newline at end of file diff --git a/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/FREInvoiceMessageMgt.Codeunit.al b/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/FREInvoiceMessageMgt.Codeunit.al index 2b1597d41d3..6f2045d1eaa 100644 --- a/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/FREInvoiceMessageMgt.Codeunit.al +++ b/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/FREInvoiceMessageMgt.Codeunit.al @@ -6,12 +6,6 @@ namespace Microsoft.eServices.EDocument.Formats; using Microsoft.eServices.EDocument; using Microsoft.eServices.EDocument.Processing.Message; -using Microsoft.Finance.GeneralLedger.Journal; -using Microsoft.Finance.GeneralLedger.Posting; -using Microsoft.Finance.GeneralLedger.Setup; -using Microsoft.Finance.ReceivablesPayables; -using Microsoft.Sales.Customer; -using Microsoft.Sales.History; using Microsoft.Sales.Receivables; using System.Utilities; @@ -21,18 +15,6 @@ codeunit 10975 "FR E-Invoice Message Mgt." InherentEntitlements = X; InherentPermissions = X; - [EventSubscriber(ObjectType::Codeunit, Codeunit::"Gen. Jnl.-Post Line", 'OnAfterInsertDtldCustLedgEntry', '', false, false)] - local procedure OnAfterInsertDtldCustLedgEntry(var DtldCustLedgEntry: Record "Detailed Cust. Ledg. Entry"; GenJournalLine: Record "Gen. Journal Line"; DtldCVLedgEntryBuffer: Record "Detailed CV Ledg. Entry Buffer"; Offset: Integer) - begin - ProcessApplication(DtldCustLedgEntry); - end; - - [EventSubscriber(ObjectType::Codeunit, Codeunit::"Gen. Jnl.-Post Line", 'OnAfterInsertDtldCustLedgEntryUnapply', '', false, false)] - local procedure OnAfterInsertDtldCustLedgEntryUnapply(var CustomerPostingGroup: Record "Customer Posting Group"; var OldDetailedCustLedgEntry: Record "Detailed Cust. Ledg. Entry"; var GenJnlLine: Record "Gen. Journal Line"; var NewDetailedCustLedgEntry: Record "Detailed Cust. Ledg. Entry") - begin - ProcessUnapplication(OldDetailedCustLedgEntry, NewDetailedCustLedgEntry); - end; - internal procedure RefuseInvoice(EDocument: Record "E-Document"; ReasonCode: Code[20]; ReasonDescription: Text[500]) var FREInvoiceMessage: Record "FR E-Invoice Message"; @@ -55,56 +37,56 @@ codeunit 10975 "FR E-Invoice Message Mgt." internal procedure ProcessApplication(DetailedCustLedgEntry: Record "Detailed Cust. Ledg. Entry") var - EDocument: Record "E-Document"; - InvoiceCustLedgerEntry: Record "Cust. Ledger Entry"; - PaymentCustLedgerEntry: Record "Cust. Ledger Entry"; + EDocPaymentOccurrenceMgt: Codeunit "E-Doc. Payment Occurrence Mgt."; begin - if not IsInvoiceApplication(DetailedCustLedgEntry) then - exit; - if not InvoiceCustLedgerEntry.Get(DetailedCustLedgEntry."Cust. Ledger Entry No.") then - exit; - if not PaymentCustLedgerEntry.Get(DetailedCustLedgEntry."Applied Cust. Ledger Entry No.") then - exit; - if PaymentCustLedgerEntry."Document Type" <> PaymentCustLedgerEntry."Document Type"::Payment then - exit; - if not FindInvoiceEDocuments(EDocument, InvoiceCustLedgerEntry) then - exit; - - repeat - if IsEligibleFrenchEDocument(EDocument) then - CreateAndSendMessage( - EDocument, "FR E-Invoice Message Type"::Collected, DetailedCustLedgEntry.SystemId, - -DetailedCustLedgEntry.Amount, DetailedCustLedgEntry."Currency Code", DetailedCustLedgEntry."Posting Date", - DetailedCustLedgEntry."Entry No.", 0, '', ''); - until EDocument.Next() = 0; + EDocPaymentOccurrenceMgt.ProcessApplication(DetailedCustLedgEntry); end; internal procedure ProcessUnapplication(OldDetailedCustLedgEntry: Record "Detailed Cust. Ledg. Entry"; NewDetailedCustLedgEntry: Record "Detailed Cust. Ledg. Entry") + var + EDocPaymentOccurrenceMgt: Codeunit "E-Doc. Payment Occurrence Mgt."; + begin + EDocPaymentOccurrenceMgt.ProcessUnapplication(OldDetailedCustLedgEntry, NewDetailedCustLedgEntry); + end; + + [EventSubscriber(ObjectType::Codeunit, Codeunit::"E-Doc. Payment Occurrence Mgt.", 'OnAfterCreatePaymentOccurrence', '', false, false)] + local procedure OnAfterCreatePaymentOccurrence(var EDocPaymentOccurrence: Record "E-Doc. Payment Occurrence") var CollectedMessage: Record "FR E-Invoice Message"; EDocument: Record "E-Document"; + OriginalOccurrence: Record "E-Doc. Payment Occurrence"; begin - if not IsInvoiceApplication(OldDetailedCustLedgEntry) then + EDocument.Get(EDocPaymentOccurrence."E-Document Entry No."); + if not IsEligibleFrenchEDocument(EDocument) then exit; + if EDocPaymentOccurrence.Type = EDocPaymentOccurrence.Type::Applied then begin + CreateAndSendMessage( + EDocument, "FR E-Invoice Message Type"::Collected, EDocPaymentOccurrence."Source Occurrence ID", + EDocPaymentOccurrence.Amount, EDocPaymentOccurrence."Currency Code", EDocPaymentOccurrence."Event Date", + EDocPaymentOccurrence."Detailed Ledger Entry No.", 0, '', ''); + exit; + end; + + if not OriginalOccurrence.Get(EDocPaymentOccurrence."Original Occurrence Entry No.") then + exit; + CollectedMessage.SetRange("E-Document Entry No.", EDocument."Entry No"); CollectedMessage.SetRange(Type, CollectedMessage.Type::Collected); - CollectedMessage.SetRange("Detailed Ledger Entry No.", OldDetailedCustLedgEntry."Entry No."); - if not CollectedMessage.FindSet() then + CollectedMessage.SetRange("Source Occurrence ID", OriginalOccurrence."Source Occurrence ID"); + if not CollectedMessage.FindFirst() then exit; - repeat - EDocument.Get(CollectedMessage."E-Document Entry No."); - CreateAndSendMessage( - EDocument, "FR E-Invoice Message Type"::"Negative Collected", NewDetailedCustLedgEntry.SystemId, - -CollectedMessage.Amount, CollectedMessage."Currency Code", NewDetailedCustLedgEntry."Posting Date", - NewDetailedCustLedgEntry."Entry No.", CollectedMessage."Entry No.", '', ''); - until CollectedMessage.Next() = 0; + CreateAndSendMessage( + EDocument, "FR E-Invoice Message Type"::"Negative Collected", EDocPaymentOccurrence."Source Occurrence ID", + EDocPaymentOccurrence.Amount, EDocPaymentOccurrence."Currency Code", EDocPaymentOccurrence."Event Date", + EDocPaymentOccurrence."Detailed Ledger Entry No.", CollectedMessage."Entry No.", '', ''); end; local procedure CreateAndSendMessage(EDocument: Record "E-Document"; MessageType: Enum "FR E-Invoice Message Type"; SourceOccurrenceID: Guid; Amount: Decimal; CurrencyCode: Code[10]; EventDate: Date; DetailedLedgerEntryNo: Integer; OriginalEntryNo: Integer; ReasonCode: Code[20]; ReasonDescription: Text[500]) var FREInvoiceMessage: Record "FR E-Invoice Message"; EDocumentMessageAPI: Codeunit "E-Document Message API"; + FREInvoiceMessageBuilder: Codeunit "FR E-Invoice Message Builder"; TempBlob: Codeunit "Temp Blob"; begin FREInvoiceMessage.SetRange("E-Document Entry No.", EDocument."Entry No"); @@ -127,73 +109,11 @@ codeunit 10975 "FR E-Invoice Message Mgt." FREInvoiceMessage."Created At" := CurrentDateTime(); FREInvoiceMessage.Insert(); - BuildMessage(EDocument, FREInvoiceMessage, TempBlob); + FREInvoiceMessageBuilder.BuildMessage(EDocument, FREInvoiceMessage, TempBlob); FREInvoiceMessage."E-Document Message Entry No." := EDocumentMessageAPI.CreateMessage( EDocument, "E-Document Message Type"::"FR Invoice Lifecycle", GetResponseType(MessageType), TempBlob); FREInvoiceMessage.Modify(); - EDocumentMessageAPI.SendMessage(FREInvoiceMessage."E-Document Message Entry No."); - end; - - local procedure BuildMessage(EDocument: Record "E-Document"; FREInvoiceMessage: Record "FR E-Invoice Message"; var TempBlob: Codeunit "Temp Blob") - var - XmlDoc: XmlDocument; - RootElement: XmlElement; - AcknowledgementElement: XmlElement; - ReferenceElement: XmlElement; - StatusElement: XmlElement; - AmountElement: XmlElement; - OutStream: OutStream; - begin - XmlDoc := XmlDocument.Create(); - XmlDoc.SetDeclaration(XmlDeclaration.Create('1.0', 'UTF-8', 'no')); - RootElement := XmlElement.Create('CrossDomainAcknowledgementAndResponse', RsmNamespaceTok); - RootElement.Add(XmlAttribute.CreateNamespaceDeclaration('ram', RamNamespaceTok)); - RootElement.Add(XmlAttribute.CreateNamespaceDeclaration('rsm', RsmNamespaceTok)); - RootElement.Add(XmlElement.Create('ExchangedDocument', RsmNamespaceTok, - XmlElement.Create('ID', RamNamespaceTok, Format(FREInvoiceMessage."Source Occurrence ID")))); - - AcknowledgementElement := XmlElement.Create('AcknowledgementDocument', RsmNamespaceTok); - ReferenceElement := XmlElement.Create('ReferenceReferencedDocument', RamNamespaceTok); - ReferenceElement.Add(XmlElement.Create('IssuerAssignedID', RamNamespaceTok, EDocument."Document No.")); - ReferenceElement.Add(XmlElement.Create('StatusCode', RamNamespaceTok, InvoiceReferenceStatusCodeTok)); - if FREInvoiceMessage.Type = FREInvoiceMessage.Type::Refused then begin - ReferenceElement.Add(XmlElement.Create('ProcessConditionCode', RamNamespaceTok, RefusedStatusCodeTok)); - ReferenceElement.Add(XmlElement.Create('ProcessCondition', RamNamespaceTok, RefusedStatusNameTok)); - StatusElement := XmlElement.Create('SpecifiedDocumentStatus', RamNamespaceTok); - StatusElement.Add(XmlElement.Create('ReasonCode', RamNamespaceTok, FREInvoiceMessage."Reason Code")); - StatusElement.Add(XmlElement.Create('Reason', RamNamespaceTok, FREInvoiceMessage."Reason Description")); - ReferenceElement.Add(StatusElement); - end else begin - ReferenceElement.Add(XmlElement.Create('ProcessConditionCode', RamNamespaceTok, CollectedStatusCodeTok)); - ReferenceElement.Add(XmlElement.Create('ProcessCondition', RamNamespaceTok, CollectedStatusNameTok)); - StatusElement := XmlElement.Create('SpecifiedDocumentStatus', RamNamespaceTok); - StatusElement.Add(XmlElement.Create('TypeCode', RamNamespaceTok, CollectedAmountTypeCodeTok)); - AmountElement := XmlElement.Create('ValueAmount', RamNamespaceTok, Format(FREInvoiceMessage.Amount, 0, 9)); - AmountElement.Add(XmlAttribute.Create('currencyID', ResolveCurrencyCode(FREInvoiceMessage."Currency Code"))); - StatusElement.Add(AmountElement); - ReferenceElement.Add(StatusElement); - end; - AcknowledgementElement.Add(ReferenceElement); - RootElement.Add(AcknowledgementElement); - XmlDoc.Add(RootElement); - - TempBlob.CreateOutStream(OutStream, TextEncoding::UTF8); - XmlDoc.WriteTo(OutStream); - end; - - local procedure FindInvoiceEDocuments(var EDocument: Record "E-Document"; InvoiceCustLedgerEntry: Record "Cust. Ledger Entry"): Boolean - var - SalesInvoiceHeader: Record "Sales Invoice Header"; - begin - if InvoiceCustLedgerEntry."Document Type" <> InvoiceCustLedgerEntry."Document Type"::Invoice then - exit(false); - if not SalesInvoiceHeader.Get(InvoiceCustLedgerEntry."Document No.") then - exit(false); - - EDocument.SetRange("Document Record ID", SalesInvoiceHeader.RecordId); - EDocument.SetRange(Direction, EDocument.Direction::Outgoing); - EDocument.SetRange("Document Type", EDocument."Document Type"::"Sales Invoice"); - exit(EDocument.FindSet()); + EDocumentMessageAPI.QueueMessage(FREInvoiceMessage."E-Document Message Entry No."); end; local procedure IsEligibleFrenchEDocument(EDocument: Record "E-Document"): Boolean @@ -210,14 +130,6 @@ codeunit 10975 "FR E-Invoice Message Mgt." exit(EDocumentServiceStatus.Status in [EDocumentServiceStatus.Status::Approved, EDocumentServiceStatus.Status::Cleared]); end; - local procedure IsInvoiceApplication(DetailedCustLedgEntry: Record "Detailed Cust. Ledg. Entry"): Boolean - begin - exit( - (DetailedCustLedgEntry."Entry Type" = DetailedCustLedgEntry."Entry Type"::Application) and - (DetailedCustLedgEntry."Initial Document Type" = DetailedCustLedgEntry."Initial Document Type"::Invoice) and - (DetailedCustLedgEntry.Amount < 0)); - end; - local procedure GetResponseType(MessageType: Enum "FR E-Invoice Message Type"): Enum "E-Doc. Response Type" begin if MessageType = MessageType::Refused then @@ -225,26 +137,7 @@ codeunit 10975 "FR E-Invoice Message Mgt." exit("E-Doc. Response Type"::None); end; - local procedure ResolveCurrencyCode(CurrencyCode: Code[10]): Code[10] - var - GeneralLedgerSetup: Record "General Ledger Setup"; - begin - if CurrencyCode <> '' then - exit(CurrencyCode); - GeneralLedgerSetup.Get(); - GeneralLedgerSetup.TestField("LCY Code"); - exit(GeneralLedgerSetup."LCY Code"); - end; - var - RsmNamespaceTok: Label 'urn:un:unece:uncefact:data:standard:CrossDomainAcknowledgementAndResponse:100', Locked = true; - RamNamespaceTok: Label 'urn:un:unece:uncefact:data:standard:ReusableAggregateBusinessInformationEntity:100', Locked = true; - InvoiceReferenceStatusCodeTok: Label '47', Locked = true; - CollectedStatusCodeTok: Label '212', Locked = true; - CollectedStatusNameTok: Label 'Encaissée', Locked = true; - CollectedAmountTypeCodeTok: Label 'MEN', Locked = true; - RefusedStatusCodeTok: Label '210', Locked = true; - RefusedStatusNameTok: Label 'Refusée', Locked = true; ReasonCodeRequiredErr: Label 'A refusal reason code is required.'; ReasonDescriptionRequiredErr: Label 'A refusal reason description is required.'; AlreadyRefusedErr: Label 'Invoice %1 has already been refused.', Comment = '%1 = invoice number'; diff --git a/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/FREInvoiceMessageType.Enum.al b/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/FREInvoiceMessageType.Enum.al index dae5310dce4..66421e51470 100644 --- a/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/FREInvoiceMessageType.Enum.al +++ b/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/FREInvoiceMessageType.Enum.al @@ -20,4 +20,16 @@ enum 10970 "FR E-Invoice Message Type" { Caption = 'Refused'; } + value(3; Submitted) + { + Caption = 'Submitted'; + } + value(4; "Technical Rejected") + { + Caption = 'Technical Rejected'; + } + value(5; Accepted) + { + Caption = 'Accepted'; + } } \ No newline at end of file diff --git a/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/FREInvoiceMessages.Page.al b/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/FREInvoiceMessages.Page.al new file mode 100644 index 00000000000..3816ff3584e --- /dev/null +++ b/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/FREInvoiceMessages.Page.al @@ -0,0 +1,79 @@ +// ------------------------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// ------------------------------------------------------------------------------------------------ +namespace Microsoft.eServices.EDocument.Formats; + +page 10973 "FR E-Invoice Messages" +{ + ApplicationArea = Basic, Suite; + Caption = 'French E-Invoice Lifecycle'; + PageType = List; + SourceTable = "FR E-Invoice Message"; + SourceTableView = sorting("Entry No.") order(descending); + Editable = false; + InsertAllowed = false; + DeleteAllowed = false; + ModifyAllowed = false; + UsageCategory = None; + + layout + { + area(content) + { + repeater(Messages) + { + field(Type; Rec.Type) + { + ApplicationArea = Basic, Suite; + ToolTip = 'Specifies the French lifecycle status represented by this message.'; + } + field(Amount; Rec.Amount) + { + ApplicationArea = Basic, Suite; + ToolTip = 'Specifies the payment amount reported by a collected or negative collected message.'; + } + field("Currency Code"; Rec."Currency Code") + { + ApplicationArea = Basic, Suite; + ToolTip = 'Specifies the currency of the reported payment amount.'; + } + field("Event Date"; Rec."Event Date") + { + ApplicationArea = Basic, Suite; + ToolTip = 'Specifies the business date on which the lifecycle event occurred.'; + } + field("Reason Code"; Rec."Reason Code") + { + ApplicationArea = Basic, Suite; + ToolTip = 'Specifies the reason code supplied for the lifecycle status.'; + } + field("Reason Description"; Rec."Reason Description") + { + ApplicationArea = Basic, Suite; + ToolTip = 'Specifies the reason description supplied for the lifecycle status.'; + } + field("Source Occurrence ID"; Rec."Source Occurrence ID") + { + ApplicationArea = Basic, Suite; + ToolTip = 'Specifies the immutable source identifier used to prevent duplicate lifecycle messages.'; + } + field("Original Entry No."; Rec."Original Entry No.") + { + ApplicationArea = Basic, Suite; + ToolTip = 'Specifies the original collected message reversed by a negative collected message.'; + } + field("E-Document Message Entry No."; Rec."E-Document Message Entry No.") + { + ApplicationArea = Basic, Suite; + ToolTip = 'Specifies the related generic E-Document message entry.'; + } + field("Created At"; Rec."Created At") + { + ApplicationArea = Basic, Suite; + ToolTip = 'Specifies when the French lifecycle message was created.'; + } + } + } + } +} \ No newline at end of file diff --git a/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Extensions/EReportingEDocuments.PageExt.al b/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Extensions/EReportingEDocuments.PageExt.al index 4bdaf743097..f89c14b4c33 100644 --- a/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Extensions/EReportingEDocuments.PageExt.al +++ b/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Extensions/EReportingEDocuments.PageExt.al @@ -25,6 +25,15 @@ pageextension 10974 "E-Reporting E-Documents" extends "E-Documents" { addlast(Processing) { + action(ViewFREInvoiceLifecycle) + { + ApplicationArea = Basic, Suite; + Caption = 'French E-Invoice Lifecycle'; + Image = History; + ToolTip = 'View the French lifecycle statuses and payment occurrences associated with this E-Document.'; + RunObject = page "FR E-Invoice Messages"; + RunPageLink = "E-Document Entry No." = field("Entry No"); + } action(RefuseFREInvoice) { ApplicationArea = Basic, Suite; diff --git a/src/Apps/FR/EDocument_FR/EReportingFR/test/src/FREDocMessageSenderMock.Codeunit.al b/src/Apps/FR/EDocument_FR/EReportingFR/test/src/FREDocMessageSenderMock.Codeunit.al index 1c890b2f63c..bf0e20961d2 100644 --- a/src/Apps/FR/EDocument_FR/EReportingFR/test/src/FREDocMessageSenderMock.Codeunit.al +++ b/src/Apps/FR/EDocument_FR/EReportingFR/test/src/FREDocMessageSenderMock.Codeunit.al @@ -30,7 +30,8 @@ codeunit 148150 "FR E-Doc. Msg. Sender Mock" implements IDocumentSender, IDocume TempBlob := MessageContext.GetTempBlob(); TempBlob.CreateInStream(InStream, TextEncoding::UTF8); InStream.ReadText(LastPayload); - MessageContext.Status().SetStatus("E-Document Service Status"::Sent); + if ReportSuccess then + MessageContext.Status().SetStatus("E-Document Service Status"::Sent); end; procedure ReceiveDocuments(var EDocumentService: Record "E-Document Service"; DocumentsMetadata: Codeunit "Temp Blob List"; ReceiveContext: Codeunit ReceiveContext) @@ -50,9 +51,15 @@ codeunit 148150 "FR E-Doc. Msg. Sender Mock" implements IDocumentSender, IDocume begin Clear(LastPayload); Clear(LastResponseType); + ReportSuccess := true; SendCount := 0; end; + procedure SetReportSuccess(NewReportSuccess: Boolean) + begin + ReportSuccess := NewReportSuccess; + end; + procedure GetSendCount(): Integer begin exit(SendCount); @@ -71,5 +78,6 @@ codeunit 148150 "FR E-Doc. Msg. Sender Mock" implements IDocumentSender, IDocume var LastResponseType: Enum "E-Doc. Response Type"; LastPayload: Text; + ReportSuccess: Boolean; SendCount: Integer; } \ No newline at end of file diff --git a/src/Apps/FR/EDocument_FR/EReportingFR/test/src/FREInvoiceMessageTests.Codeunit.al b/src/Apps/FR/EDocument_FR/EReportingFR/test/src/FREInvoiceMessageTests.Codeunit.al index 84f1dbb9411..6b40e96c8b4 100644 --- a/src/Apps/FR/EDocument_FR/EReportingFR/test/src/FREInvoiceMessageTests.Codeunit.al +++ b/src/Apps/FR/EDocument_FR/EReportingFR/test/src/FREInvoiceMessageTests.Codeunit.al @@ -10,6 +10,7 @@ using Microsoft.eServices.EDocument.Processing.Message; using Microsoft.Finance.GeneralLedger.Setup; using Microsoft.Sales.History; using Microsoft.Sales.Receivables; +using System.Utilities; codeunit 148152 "FR E-Invoice Message Tests" { @@ -21,6 +22,7 @@ codeunit 148152 "FR E-Invoice Message Tests" tabledata "E-Document" = rimd, tabledata "E-Document Service" = rimd, tabledata "E-Document Service Status" = rimd, + tabledata "E-Doc. Payment Occurrence" = rimd, tabledata "FR E-Invoice Message" = rimd, tabledata "Sales Invoice Header" = rimd; @@ -32,6 +34,7 @@ codeunit 148152 "FR E-Invoice Message Tests" procedure PaymentApplicationSendsCollected() var EDocument: Record "E-Document"; + EDocPaymentOccurrence: Record "E-Doc. Payment Occurrence"; FREInvoiceMessage: Record "FR E-Invoice Message"; DetailedCustLedgEntry: Record "Detailed Cust. Ledg. Entry"; FREInvoiceMessageMgt: Codeunit "FR E-Invoice Message Mgt."; @@ -44,6 +47,14 @@ codeunit 148152 "FR E-Invoice Message Tests" FREInvoiceMessage.SetRange("E-Document Entry No.", EDocument."Entry No"); FREInvoiceMessage.SetRange(Type, FREInvoiceMessage.Type::Collected); Assert.RecordCount(FREInvoiceMessage, 1); + EDocPaymentOccurrence.SetRange("E-Document Entry No.", EDocument."Entry No"); + EDocPaymentOccurrence.SetRange(Type, EDocPaymentOccurrence.Type::Applied); + Assert.RecordCount(EDocPaymentOccurrence, 1); + EDocPaymentOccurrence.FindFirst(); + Assert.AreEqual(100, EDocPaymentOccurrence.Amount, 'The generic applied occurrence must carry a positive amount.'); + Assert.AreEqual(0, MessageSenderMock.GetSendCount(), 'Payment posting must queue the message without invoking the connector.'); + FREInvoiceMessage.FindFirst(); + SendMessage(FREInvoiceMessage); Assert.AreEqual(1, MessageSenderMock.GetSendCount(), 'One Collected message must be sent.'); Assert.IsTrue(MessageSenderMock.GetLastPayload().Contains('212'), 'The payload must contain status 212.'); Assert.IsTrue(MessageSenderMock.GetLastPayload().Contains('100'), 'The payload must contain the collected amount.'); @@ -54,7 +65,9 @@ codeunit 148152 "FR E-Invoice Message Tests" var EDocument: Record "E-Document"; CollectedMessage: Record "FR E-Invoice Message"; + AppliedOccurrence: Record "E-Doc. Payment Occurrence"; NegativeMessage: Record "FR E-Invoice Message"; + ReversedOccurrence: Record "E-Doc. Payment Occurrence"; DetailedCustLedgEntry: Record "Detailed Cust. Ledg. Entry"; NewDetailedCustLedgEntry: Record "Detailed Cust. Ledg. Entry"; FREInvoiceMessageMgt: Codeunit "FR E-Invoice Message Mgt."; @@ -62,18 +75,29 @@ codeunit 148152 "FR E-Invoice Message Tests" Initialize(); CreatePaymentScenario(EDocument, DetailedCustLedgEntry); FREInvoiceMessageMgt.ProcessApplication(DetailedCustLedgEntry); + CollectedMessage.SetRange("E-Document Entry No.", EDocument."Entry No"); + CollectedMessage.SetRange(Type, CollectedMessage.Type::Collected); + CollectedMessage.FindFirst(); + SendMessage(CollectedMessage); CreateDetailedLedgerEntry(NewDetailedCustLedgEntry, DetailedCustLedgEntry."Cust. Ledger Entry No.", DetailedCustLedgEntry."Applied Cust. Ledger Entry No.", -100); FREInvoiceMessageMgt.ProcessUnapplication(DetailedCustLedgEntry, NewDetailedCustLedgEntry); - CollectedMessage.SetRange("E-Document Entry No.", EDocument."Entry No"); - CollectedMessage.SetRange(Type, CollectedMessage.Type::Collected); - CollectedMessage.FindFirst(); NegativeMessage.SetRange("E-Document Entry No.", EDocument."Entry No"); NegativeMessage.SetRange(Type, NegativeMessage.Type::"Negative Collected"); NegativeMessage.FindFirst(); Assert.AreEqual(CollectedMessage."Entry No.", NegativeMessage."Original Entry No.", 'The reversal must link to the original occurrence.'); Assert.AreEqual(-CollectedMessage.Amount, NegativeMessage.Amount, 'The reversal amount must negate the original amount.'); + AppliedOccurrence.SetRange("E-Document Entry No.", EDocument."Entry No"); + AppliedOccurrence.SetRange(Type, AppliedOccurrence.Type::Applied); + AppliedOccurrence.FindFirst(); + ReversedOccurrence.SetRange("E-Document Entry No.", EDocument."Entry No"); + ReversedOccurrence.SetRange(Type, ReversedOccurrence.Type::Reversed); + ReversedOccurrence.FindFirst(); + Assert.AreEqual(AppliedOccurrence."Entry No.", ReversedOccurrence."Original Occurrence Entry No.", 'The generic reversal must link to its applied occurrence.'); + Assert.AreEqual(-AppliedOccurrence.Amount, ReversedOccurrence.Amount, 'The generic reversal must negate the applied amount.'); + Assert.AreEqual(1, MessageSenderMock.GetSendCount(), 'Unapplication must queue the reversal without invoking the connector.'); + SendMessage(NegativeMessage); Assert.AreEqual(2, MessageSenderMock.GetSendCount(), 'Collected and Negative Collected messages must be sent.'); Assert.IsTrue(MessageSenderMock.GetLastPayload().Contains('-100'), 'The reversal payload must contain a negative amount.'); end; @@ -89,6 +113,8 @@ codeunit 148152 "FR E-Invoice Message Tests" FREInvoiceMessageMgt.RefuseInvoice(EDocument, 'PRICE', 'The amount is incorrect.'); + Assert.AreEqual(0, MessageSenderMock.GetSendCount(), 'Refusal must queue the message without invoking the connector.'); + SendFirstMessage(EDocument, "FR E-Invoice Message Type"::Refused); Assert.AreEqual(1, MessageSenderMock.GetSendCount(), 'One refusal message must be sent.'); Assert.AreEqual("E-Doc. Response Type"::Refused, MessageSenderMock.GetLastResponseType(), 'The child message must be Refused.'); Assert.IsTrue(MessageSenderMock.GetLastPayload().Contains('210'), 'The payload must contain status 210.'); @@ -107,15 +133,128 @@ codeunit 148152 "FR E-Invoice Message Tests" asserterror FREInvoiceMessageMgt.RefuseInvoice(EDocument, '', 'Not accepted.'); Assert.ExpectedError('A refusal reason code is required.'); FREInvoiceMessageMgt.RefuseInvoice(EDocument, 'OTHER', 'Not accepted.'); + SendFirstMessage(EDocument, "FR E-Invoice Message Type"::Refused); asserterror FREInvoiceMessageMgt.RefuseInvoice(EDocument, 'OTHER', 'Again.'); Assert.ExpectedError('has already been refused'); Assert.AreEqual(1, MessageSenderMock.GetSendCount(), 'A duplicate refusal must not be sent.'); end; + [Test] + procedure MessageSenderMustReportSuccess() + var + EDocument: Record "E-Document"; + EDocumentMessageAPI: Codeunit "E-Document Message API"; + TempBlob: Codeunit "Temp Blob"; + OutStream: OutStream; + MessageEntryNo: Integer; + begin + Initialize(); + CreateIncomingEDocument(EDocument); + TempBlob.CreateOutStream(OutStream, TextEncoding::UTF8); + OutStream.WriteText(''); + MessageEntryNo := EDocumentMessageAPI.CreateMessage( + EDocument, "E-Document Message Type"::"FR Invoice Lifecycle", "E-Doc. Response Type"::Refused, TempBlob); + MessageSenderMock.SetReportSuccess(false); + + asserterror EDocumentMessageAPI.SendMessage(MessageEntryNo); + + Assert.ExpectedError('could not be sent'); + Assert.AreEqual(1, MessageSenderMock.GetSendCount(), 'The connector must be invoked before its missing success result is rejected.'); + end; + + [Test] + procedure PaymentApplicationReplayIsIdempotent() + var + EDocument: Record "E-Document"; + EDocPaymentOccurrence: Record "E-Doc. Payment Occurrence"; + FREInvoiceMessage: Record "FR E-Invoice Message"; + DetailedCustLedgEntry: Record "Detailed Cust. Ledg. Entry"; + FREInvoiceMessageMgt: Codeunit "FR E-Invoice Message Mgt."; + begin + Initialize(); + CreatePaymentScenario(EDocument, DetailedCustLedgEntry); + + FREInvoiceMessageMgt.ProcessApplication(DetailedCustLedgEntry); + FREInvoiceMessageMgt.ProcessApplication(DetailedCustLedgEntry); + + EDocPaymentOccurrence.SetRange("E-Document Entry No.", EDocument."Entry No"); + EDocPaymentOccurrence.SetRange(Type, EDocPaymentOccurrence.Type::Applied); + Assert.RecordCount(EDocPaymentOccurrence, 1); + FREInvoiceMessage.SetRange("E-Document Entry No.", EDocument."Entry No"); + FREInvoiceMessage.SetRange(Type, FREInvoiceMessage.Type::Collected); + Assert.RecordCount(FREInvoiceMessage, 1); + end; + + [Test] + procedure IncomingMessageIsCorrelatedAndDeduplicated() + var + EDocument: Record "E-Document"; + EDocumentMessageAPI: Codeunit "E-Document Message API"; + TempBlob: Codeunit "Temp Blob"; + OutStream: OutStream; + FirstMessageEntryNo: Integer; + DuplicateMessageEntryNo: Integer; + begin + Initialize(); + CreateIncomingEDocument(EDocument); + EDocumentMessageAPI.RegisterExternalDocumentReference(EDocument, EDocument.Service, 'FR-DOC-001'); + TempBlob.CreateOutStream(OutStream, TextEncoding::UTF8); + OutStream.WriteText(''); + + FirstMessageEntryNo := EDocumentMessageAPI.CreateIncomingMessage( + EDocument.Service, 'FR-DOC-001', 'FR-MSG-001', "E-Document Message Type"::"FR Invoice Lifecycle", + "E-Doc. Response Type"::Refused, CurrentDateTime(), TempBlob); + DuplicateMessageEntryNo := EDocumentMessageAPI.CreateIncomingMessage( + EDocument.Service, 'FR-DOC-001', 'FR-MSG-001', "E-Document Message Type"::"FR Invoice Lifecycle", + "E-Doc. Response Type"::Refused, CurrentDateTime(), TempBlob); + + Assert.AreNotEqual(0, FirstMessageEntryNo, 'The incoming lifecycle message must be persisted.'); + Assert.AreEqual(FirstMessageEntryNo, DuplicateMessageEntryNo, 'The external message ID must deduplicate repeated delivery.'); + end; + + [Test] + procedure IncomingMessageRequiresRegisteredDocumentReference() + var + EDocument: Record "E-Document"; + EDocumentMessageAPI: Codeunit "E-Document Message API"; + TempBlob: Codeunit "Temp Blob"; + OutStream: OutStream; + begin + Initialize(); + CreateIncomingEDocument(EDocument); + TempBlob.CreateOutStream(OutStream, TextEncoding::UTF8); + OutStream.WriteText(''); + + asserterror EDocumentMessageAPI.CreateIncomingMessage( + EDocument.Service, CopyStr(Format(CreateGuid()), 1, 250), CopyStr(Format(CreateGuid()), 1, 250), + "E-Document Message Type"::"FR Invoice Lifecycle", "E-Doc. Response Type"::Refused, CurrentDateTime(), TempBlob); + + Assert.ExpectedError('is not registered'); + end; + + local procedure SendFirstMessage(EDocument: Record "E-Document"; MessageType: Enum "FR E-Invoice Message Type") + var + FREInvoiceMessage: Record "FR E-Invoice Message"; + begin + FREInvoiceMessage.SetRange("E-Document Entry No.", EDocument."Entry No"); + FREInvoiceMessage.SetRange(Type, MessageType); + FREInvoiceMessage.FindFirst(); + SendMessage(FREInvoiceMessage); + end; + + local procedure SendMessage(FREInvoiceMessage: Record "FR E-Invoice Message") + var + EDocumentMessageAPI: Codeunit "E-Document Message API"; + begin + EDocumentMessageAPI.SendMessage(FREInvoiceMessage."E-Document Message Entry No."); + end; + local procedure Initialize() var + EDocPaymentOccurrence: Record "E-Doc. Payment Occurrence"; FREInvoiceMessage: Record "FR E-Invoice Message"; begin + EDocPaymentOccurrence.DeleteAll(); FREInvoiceMessage.DeleteAll(); MessageSenderMock.Reset(); EnsureService(); diff --git a/src/Apps/FR/EDocument_FR/EReportingFR/test/src/FacturXCIIXMLTests.Codeunit.al b/src/Apps/FR/EDocument_FR/EReportingFR/test/src/FacturXCIIXMLTests.Codeunit.al index d73ef22afad..7b4d69f4a5b 100644 --- a/src/Apps/FR/EDocument_FR/EReportingFR/test/src/FacturXCIIXMLTests.Codeunit.al +++ b/src/Apps/FR/EDocument_FR/EReportingFR/test/src/FacturXCIIXMLTests.Codeunit.al @@ -6,15 +6,20 @@ namespace Microsoft.eServices.EDocument.Formats.Test; using Microsoft.eServices.EDocument; using Microsoft.eServices.EDocument.Formats; +using Microsoft.Finance.Currency; using Microsoft.Finance.GeneralLedger.Account; using Microsoft.Finance.GeneralLedger.Setup; +using Microsoft.Finance.VAT.Setup; using Microsoft.Foundation.Address; using Microsoft.Foundation.Company; using Microsoft.Foundation.UOM; +using Microsoft.Inventory.Item; +using Microsoft.Inventory.Location; using Microsoft.Sales.Customer; using Microsoft.Sales.Document; using Microsoft.Sales.FinanceCharge; using Microsoft.Sales.History; +using Microsoft.Sales.Pricing; using Microsoft.Sales.Reminder; using Microsoft.Sales.Setup; using System.Utilities; @@ -23,6 +28,7 @@ codeunit 148148 "Factur-X CII XML Tests" { Subtype = Test; Permissions = tabledata "Company Information" = rimd, + tabledata "Sales Invoice Header" = m, tabledata Customer = rimd; trigger OnRun() @@ -33,14 +39,18 @@ codeunit 148148 "Factur-X CII XML Tests" var CompanyInformation: Record "Company Information"; LibraryTestInitialize: Codeunit "Library - Test Initialize"; + LibrarySetupStorage: Codeunit "Library - Setup Storage"; LibrarySales: Codeunit "Library - Sales"; + LibraryInventory: Codeunit "Library - Inventory"; LibraryERM: Codeunit "Library - ERM"; LibraryUtility: Codeunit "Library - Utility"; Assert: Codeunit Assert; CIIXMLBuilder: Codeunit "CII XML Builder"; + EDocHelpers: Codeunit "EDoc. Helpers"; + FacturXFormat: Codeunit "Factur-X Format"; IncorrectValueErr: Label 'Incorrect value for %1', Comment = '%1 = XML element path', Locked = true; FacturXProfileIdTok: Label 'urn:cen.eu:en16931:2017', Locked = true; - CustomerVATNoSequence: Integer; + DialogErrorCodeTok: Label 'Dialog', Locked = true; IsInitialized: Boolean; #region SalesInvoice @@ -81,6 +91,25 @@ codeunit 148148 "Factur-X CII XML Tests" StrSubstNo(IncorrectValueErr, '//ram:GuidelineSpecifiedDocumentContextParameter/ram:ID')); end; + [Test] + procedure FacturXSalesInvoiceXMLHasFrenchBillingMode() + var + TempBlob: Codeunit "Temp Blob"; + begin + // [FEATURE] [AI test] + // [SCENARIO] Factur-X CII XML declares the French service billing mode + Initialize(); + + // [GIVEN] Posted sales invoice with a G/L account line + // [WHEN] Create CII XML via FR CII XML Builder + CreateSalesInvoiceCIIXML(TempBlob); + + // [THEN] BusinessProcessSpecifiedDocumentContextParameter/ID = 'S1' + Assert.AreEqual('S1', + GetCIINodeValue(TempBlob, '//ram:BusinessProcessSpecifiedDocumentContextParameter/ram:ID'), + StrSubstNo(IncorrectValueErr, '//ram:BusinessProcessSpecifiedDocumentContextParameter/ram:ID')); + end; + [Test] procedure FacturXSalesInvoiceXMLHasDocumentNumber() var @@ -248,11 +277,11 @@ codeunit 148148 "Factur-X CII XML Tests" ElecAddress: Text[250]; begin // [FEATURE] [AI test] - // [SCENARIO] Factur-X CII XML has buyer FR Electronic Address as BuyerTradeParty/URIUniversalCommunication/URIID + // [SCENARIO] Factur-X CII XML has buyer FR Electronic Address as BuyerTradeParty/URIUniversalCommunication/URIID with scheme 0225 Initialize(); - // [GIVEN] Customer with FR Electronic Address - ElecAddress := '98765432101234'; + // [GIVEN] Customer with FR Electronic Address in SIREN_suffix format + ElecAddress := '987654321_001'; SalesInvoiceHeader.Get(CreateAndPostSalesInvoiceWithElecAddress(ElecAddress)); Customer.Get(SalesInvoiceHeader."Sell-to Customer No."); @@ -263,33 +292,38 @@ codeunit 148148 "Factur-X CII XML Tests" Assert.AreEqual(ElecAddress, GetCIINodeValue(TempBlob, '//ram:BuyerTradeParty/ram:URIUniversalCommunication/ram:URIID'), StrSubstNo(IncorrectValueErr, '//ram:BuyerTradeParty/ram:URIUniversalCommunication/ram:URIID')); + + // [THEN] schemeID = '0225' + Assert.AreEqual('0225', + GetCIIAttributeValue(TempBlob, '//ram:BuyerTradeParty/ram:URIUniversalCommunication/ram:URIID/@schemeID'), + StrSubstNo(IncorrectValueErr, '//ram:BuyerTradeParty/ram:URIUniversalCommunication/ram:URIID/@schemeID')); end; [Test] - procedure FacturXSalesInvoiceXMLBuyerFallsBackToVATNoWhenFRElecAddressBlank() + procedure FacturXSalesInvoiceXMLBuyerFallsBackToRegistrationNumber() var SalesInvoiceHeader: Record "Sales Invoice Header"; Customer: Record Customer; TempBlob: Codeunit "Temp Blob"; begin // [FEATURE] [AI test] - // [SCENARIO] Factur-X CII XML uses buyer VAT Registration No. with schemeID 9957 as fallback when FR Electronic Address is blank (BR-FR-12) + // [SCENARIO] Factur-X CII XML uses buyer Registration Number (first 9 digits) with schemeID 0225 when FR Electronic Address is blank Initialize(); - // [GIVEN] Posted sales invoice with customer having no FR Electronic Address but having a VAT Registration No. + // [GIVEN] Posted sales invoice with customer having no FR Electronic Address but having a Registration Number SalesInvoiceHeader.Get(CreateAndPostSalesInvoice()); Customer.Get(SalesInvoiceHeader."Sell-to Customer No."); // [WHEN] Create CII XML CreateSalesInvoiceCIIXMLFromHeader(SalesInvoiceHeader, TempBlob); - // [THEN] BuyerTradeParty/URIUniversalCommunication/URIID = customer VAT Registration No. (BR-FR-12 fallback) - Assert.AreEqual(Customer."VAT Registration No.", + // [THEN] BuyerTradeParty/URIUniversalCommunication/URIID = first 9 digits of Registration Number + Assert.AreEqual(CopyStr(Customer."Registration Number", 1, 9), GetCIINodeValue(TempBlob, '//ram:BuyerTradeParty/ram:URIUniversalCommunication/ram:URIID'), StrSubstNo(IncorrectValueErr, '//ram:BuyerTradeParty/ram:URIUniversalCommunication/ram:URIID')); - // [THEN] schemeID = '9957' - Assert.AreEqual('9957', + // [THEN] schemeID = '0225' + Assert.AreEqual('0225', GetCIIAttributeValue(TempBlob, '//ram:BuyerTradeParty/ram:URIUniversalCommunication/ram:URIID/@schemeID'), StrSubstNo(IncorrectValueErr, '//ram:BuyerTradeParty/ram:URIUniversalCommunication/ram:URIID/@schemeID')); end; @@ -300,29 +334,50 @@ codeunit 148148 "Factur-X CII XML Tests" SalesInvoiceHeader: Record "Sales Invoice Header"; GeneralLedgerSetup: Record "General Ledger Setup"; TempBlob: Codeunit "Temp Blob"; - ExpectedCurrencyCode: Code[10]; begin // [FEATURE] [AI test] - // [SCENARIO] Factur-X CII XML settlement currency code matches the document currency or LCY + // [SCENARIO] Factur-X CII XML settlement currency code uses LCY when document has no currency Initialize(); - // [GIVEN] Posted sales invoice + // [GIVEN] Posted sales invoice with blank Currency Code (uses LCY) SalesInvoiceHeader.Get(CreateAndPostSalesInvoice()); GeneralLedgerSetup.Get(); - if SalesInvoiceHeader."Currency Code" <> '' then - ExpectedCurrencyCode := SalesInvoiceHeader."Currency Code" - else - ExpectedCurrencyCode := GeneralLedgerSetup."LCY Code"; // [WHEN] Create CII XML CreateSalesInvoiceCIIXMLFromHeader(SalesInvoiceHeader, TempBlob); - // [THEN] ApplicableHeaderTradeSettlement/InvoiceCurrencyCode = expected currency - Assert.AreEqual(ExpectedCurrencyCode, + // [THEN] ApplicableHeaderTradeSettlement/InvoiceCurrencyCode = LCY Code + Assert.AreEqual(GeneralLedgerSetup."LCY Code", GetCIINodeValue(TempBlob, '//ram:InvoiceCurrencyCode'), StrSubstNo(IncorrectValueErr, '//ram:InvoiceCurrencyCode')); end; + [Test] + procedure FacturXSalesInvoiceXMLHasDocumentCurrencyCode() + var + Currency: Record Currency; + SalesHeader: Record "Sales Header"; + SalesInvoiceHeader: Record "Sales Invoice Header"; + TempBlob: Codeunit "Temp Blob"; + begin + // [FEATURE] [AI test] + // [SCENARIO] Factur-X CII XML settlement currency code uses the document currency + Initialize(); + + // [GIVEN] Sales invoice with a foreign Currency Code + LibraryERM.CreateCurrency(Currency); + LibraryERM.CreateRandomExchangeRate(Currency.Code); + SalesHeader.Get("Sales Document Type"::Invoice, CreateSalesDocumentWithLine("Sales Document Type"::Invoice, '', Currency.Code)); + SalesInvoiceHeader.Get(LibrarySales.PostSalesDocument(SalesHeader, true, true)); + + // [WHEN] Create CII XML + CreateSalesInvoiceCIIXMLFromHeader(SalesInvoiceHeader, TempBlob); + + // [THEN] InvoiceCurrencyCode equals the document currency + Assert.AreEqual(Currency.Code, GetCIINodeValue(TempBlob, '//ram:InvoiceCurrencyCode'), + StrSubstNo(IncorrectValueErr, '//ram:InvoiceCurrencyCode')); + end; + [Test] procedure FacturXSalesInvoiceXMLHasIssueDateTimeFormat102() var @@ -363,12 +418,10 @@ codeunit 148148 "Factur-X CII XML Tests" // [GIVEN] Company Information with address CompanyInformation.Get(); - if CompanyInformation.Address = '' then begin - CompanyInformation.Address := '123 Test Street'; - CompanyInformation.City := 'Paris'; - CompanyInformation."Post Code" := '75001'; - CompanyInformation.Modify(true); - end; + CompanyInformation.Address := '123 Test Street'; + CompanyInformation.City := 'Paris'; + CompanyInformation."Post Code" := '75001'; + CompanyInformation.Modify(true); // [WHEN] Create CII XML CreateSalesInvoiceCIIXML(TempBlob); @@ -465,7 +518,82 @@ codeunit 148148 "Factur-X CII XML Tests" end; [Test] - procedure FacturXSalesInvoiceXMLHasBuyerElecAddressSchemeID() + procedure FacturXSalesInvoiceXMLPreservesLowercaseBuyerVATCountryPrefix() + var + SalesInvoiceHeader: Record "Sales Invoice Header"; + TempBlob: Codeunit "Temp Blob"; + begin + // [FEATURE] [AI test] + // [SCENARIO] Factur-X CII XML preserves a buyer VAT registration starting with a lowercase country prefix + Initialize(); + + // [GIVEN] Posted sales invoice with buyer country "FR" and VAT registration "fr12345678901" + SalesInvoiceHeader.Get(CreateAndPostSalesInvoice()); + SalesInvoiceHeader."Sell-to Country/Region Code" := 'FR'; + SalesInvoiceHeader."VAT Registration No." := 'fr12345678901'; + SalesInvoiceHeader.Modify(); + + // [WHEN] Create CII XML + CreateSalesInvoiceCIIXMLFromHeader(SalesInvoiceHeader, TempBlob); + + // [THEN] Buyer VAT registration remains "fr12345678901" + Assert.AreEqual('fr12345678901', + GetCIINodeValue(TempBlob, '//ram:BuyerTradeParty/ram:SpecifiedTaxRegistration/ram:ID'), + StrSubstNo(IncorrectValueErr, '//ram:BuyerTradeParty/ram:SpecifiedTaxRegistration/ram:ID')); + end; + + [Test] + procedure FacturXSalesInvoiceXMLPrefixesBuyerVATStartingWithLetterAndDigit() + var + SalesInvoiceHeader: Record "Sales Invoice Header"; + TempBlob: Codeunit "Temp Blob"; + begin + // [FEATURE] [AI test] + // [SCENARIO] Factur-X CII XML prefixes a buyer VAT registration whose first two characters are not letters + Initialize(); + + // [GIVEN] Posted sales invoice with buyer country "FR" and VAT registration "F12345678901" + SalesInvoiceHeader.Get(CreateAndPostSalesInvoice()); + SalesInvoiceHeader."Sell-to Country/Region Code" := 'FR'; + SalesInvoiceHeader."VAT Registration No." := 'F12345678901'; + SalesInvoiceHeader.Modify(); + + // [WHEN] Create CII XML + CreateSalesInvoiceCIIXMLFromHeader(SalesInvoiceHeader, TempBlob); + + // [THEN] Buyer VAT registration is "FRF12345678901" + Assert.AreEqual('FRF12345678901', + GetCIINodeValue(TempBlob, '//ram:BuyerTradeParty/ram:SpecifiedTaxRegistration/ram:ID'), + StrSubstNo(IncorrectValueErr, '//ram:BuyerTradeParty/ram:SpecifiedTaxRegistration/ram:ID')); + end; + + [Test] + procedure FacturXSalesInvoiceXMLPrefixesSingleCharacterBuyerVAT() + var + SalesInvoiceHeader: Record "Sales Invoice Header"; + TempBlob: Codeunit "Temp Blob"; + begin + // [FEATURE] [AI test] + // [SCENARIO] Factur-X CII XML prefixes a single-character buyer VAT registration + Initialize(); + + // [GIVEN] Posted sales invoice with buyer country "FR" and VAT registration "1" + SalesInvoiceHeader.Get(CreateAndPostSalesInvoice()); + SalesInvoiceHeader."Sell-to Country/Region Code" := 'FR'; + SalesInvoiceHeader."VAT Registration No." := '1'; + SalesInvoiceHeader.Modify(); + + // [WHEN] Create CII XML + CreateSalesInvoiceCIIXMLFromHeader(SalesInvoiceHeader, TempBlob); + + // [THEN] Buyer VAT registration is "FR1" + Assert.AreEqual('FR1', + GetCIINodeValue(TempBlob, '//ram:BuyerTradeParty/ram:SpecifiedTaxRegistration/ram:ID'), + StrSubstNo(IncorrectValueErr, '//ram:BuyerTradeParty/ram:SpecifiedTaxRegistration/ram:ID')); + end; + + [Test] + procedure FacturXSalesInvoiceXMLHasBuyerElecAddressSchemeID0225() var SalesInvoiceHeader: Record "Sales Invoice Header"; Customer: Record Customer; @@ -473,21 +601,19 @@ codeunit 148148 "Factur-X CII XML Tests" ElecAddress: Text[250]; begin // [FEATURE] [AI test] - // [SCENARIO] Factur-X CII XML buyer electronic address URIID has schemeID from customer setting + // [SCENARIO] Factur-X CII XML buyer electronic address always has schemeID 0225 regardless of customer configured scheme Initialize(); - // [GIVEN] Customer with FR Electronic Address and a SIRET electronic address scheme - ElecAddress := '98765432101234'; + // [GIVEN] Customer with FR Electronic Address (valid SIREN) + ElecAddress := '987654321'; SalesInvoiceHeader.Get(CreateAndPostSalesInvoiceWithElecAddress(ElecAddress)); Customer.Get(SalesInvoiceHeader."Sell-to Customer No."); - Customer.Validate("FR Elec. Address Scheme", Customer."FR Elec. Address Scheme"::"0009"); - Customer.Modify(true); // [WHEN] Create CII XML CreateSalesInvoiceCIIXMLFromHeader(SalesInvoiceHeader, TempBlob); - // [THEN] BuyerTradeParty/URIUniversalCommunication/URIID/@schemeID = bare scheme code (not the enum caption) - Assert.AreEqual('0009', + // [THEN] BuyerTradeParty/URIUniversalCommunication/URIID/@schemeID = '0225' + Assert.AreEqual('0225', GetCIIAttributeValue(TempBlob, '//ram:BuyerTradeParty/ram:URIUniversalCommunication/ram:URIID/@schemeID'), StrSubstNo(IncorrectValueErr, '//ram:BuyerTradeParty/ram:URIUniversalCommunication/ram:URIID/@schemeID')); end; @@ -524,22 +650,19 @@ codeunit 148148 "Factur-X CII XML Tests" ExpectedDate: Text; begin // [FEATURE] [AI test] - // [SCENARIO] Factur-X CII XML has ActualDeliverySupplyChainEvent with delivery date (BT-72) + // [SCENARIO] Factur-X CII XML has ActualDeliverySupplyChainEvent with posting date as fallback (BT-72) Initialize(); - // [GIVEN] Posted sales invoice + // [GIVEN] Posted sales invoice with blank Shipment Date SalesInvoiceHeader.Get(CreateAndPostSalesInvoice()); + SalesInvoiceHeader."Shipment Date" := 0D; + SalesInvoiceHeader.Modify(); + ExpectedDate := Format(SalesInvoiceHeader."Posting Date", 0, ''); // [WHEN] Create CII XML CreateSalesInvoiceCIIXMLFromHeader(SalesInvoiceHeader, TempBlob); - // [THEN] ActualDeliverySupplyChainEvent/OccurrenceDateTime/DateTimeString has date - // Delivery date falls back to document date if shipment date is empty - if SalesInvoiceHeader."Shipment Date" <> 0D then - ExpectedDate := Format(SalesInvoiceHeader."Shipment Date", 0, '') - else - ExpectedDate := Format(SalesInvoiceHeader."Posting Date", 0, ''); - + // [THEN] ActualDeliverySupplyChainEvent/OccurrenceDateTime/DateTimeString = posting date as YYYYMMDD Assert.AreEqual(ExpectedDate, GetCIINodeValue(TempBlob, '//ram:ActualDeliverySupplyChainEvent/ram:OccurrenceDateTime/udt:DateTimeString'), StrSubstNo(IncorrectValueErr, '//ram:ActualDeliverySupplyChainEvent/ram:OccurrenceDateTime/udt:DateTimeString')); @@ -550,6 +673,32 @@ codeunit 148148 "Factur-X CII XML Tests" StrSubstNo(IncorrectValueErr, '//ram:ActualDeliverySupplyChainEvent/ram:OccurrenceDateTime/udt:DateTimeString/@format')); end; + [Test] + procedure FacturXSalesInvoiceXMLUsesShipmentDateAsDeliveryDate() + var + SalesInvoiceHeader: Record "Sales Invoice Header"; + TempBlob: Codeunit "Temp Blob"; + ShipmentDate: Date; + begin + // [FEATURE] [AI test] + // [SCENARIO] Factur-X CII XML uses Shipment Date as the actual delivery date + Initialize(); + + // [GIVEN] Posted sales invoice with a known Shipment Date + SalesInvoiceHeader.Get(CreateAndPostSalesInvoice()); + ShipmentDate := CalcDate('<-1D>', SalesInvoiceHeader."Posting Date"); + SalesInvoiceHeader."Shipment Date" := ShipmentDate; + SalesInvoiceHeader.Modify(); + + // [WHEN] Create CII XML + CreateSalesInvoiceCIIXMLFromHeader(SalesInvoiceHeader, TempBlob); + + // [THEN] Actual delivery date equals Shipment Date + Assert.AreEqual(Format(ShipmentDate, 0, ''), + GetCIINodeValue(TempBlob, '//ram:ActualDeliverySupplyChainEvent/ram:OccurrenceDateTime/udt:DateTimeString'), + StrSubstNo(IncorrectValueErr, '//ram:ActualDeliverySupplyChainEvent/ram:OccurrenceDateTime/udt:DateTimeString')); + end; + [Test] procedure FacturXSalesInvoiceXMLHasPaymentMeansTypeCode58() var @@ -571,36 +720,118 @@ codeunit 148148 "Factur-X CII XML Tests" [Test] procedure FacturXSalesInvoiceXMLHasTaxBreakdown() var + SalesInvoiceHeader: Record "Sales Invoice Header"; + SalesInvoiceLine: Record "Sales Invoice Line"; TempBlob: Codeunit "Temp Blob"; begin // [FEATURE] [AI test] - // [SCENARIO] Factur-X CII XML has ApplicableTradeTax with VAT type code and category + // [SCENARIO] Factur-X CII XML has ApplicableTradeTax with VAT type code, basis, and category Initialize(); + // [GIVEN] Posted sales invoice with known amounts + SalesInvoiceHeader.Get(CreateAndPostSalesInvoice()); + SalesInvoiceLine.SetRange("Document No.", SalesInvoiceHeader."No."); + SalesInvoiceLine.SetFilter(Type, '<>%1', SalesInvoiceLine.Type::" "); + SalesInvoiceLine.FindFirst(); + // [WHEN] Create CII XML - CreateSalesInvoiceCIIXML(TempBlob); + CreateSalesInvoiceCIIXMLFromHeader(SalesInvoiceHeader, TempBlob); // [THEN] ApplicableTradeTax/TypeCode = 'VAT' Assert.AreEqual('VAT', GetCIINodeValue(TempBlob, '//ram:ApplicableHeaderTradeSettlement/ram:ApplicableTradeTax/ram:TypeCode'), StrSubstNo(IncorrectValueErr, '//ram:ApplicableHeaderTradeSettlement/ram:ApplicableTradeTax/ram:TypeCode')); - // [THEN] ApplicableTradeTax has BasisAmount - Assert.AreNotEqual('', + // [THEN] ApplicableTradeTax/BasisAmount = line amount + Assert.AreEqual(Format(SalesInvoiceLine.Amount, 0, ''), GetCIINodeValue(TempBlob, '//ram:ApplicableHeaderTradeSettlement/ram:ApplicableTradeTax/ram:BasisAmount'), StrSubstNo(IncorrectValueErr, '//ram:ApplicableHeaderTradeSettlement/ram:ApplicableTradeTax/ram:BasisAmount')); - // [THEN] ApplicableTradeTax has CategoryCode - Assert.AreNotEqual('', + // [THEN] ApplicableTradeTax has CategoryCode 'S' (standard rate) + Assert.AreEqual('S', GetCIINodeValue(TempBlob, '//ram:ApplicableHeaderTradeSettlement/ram:ApplicableTradeTax/ram:CategoryCode'), StrSubstNo(IncorrectValueErr, '//ram:ApplicableHeaderTradeSettlement/ram:ApplicableTradeTax/ram:CategoryCode')); - // [THEN] ApplicableTradeTax has RateApplicablePercent - Assert.AreNotEqual('', + // [THEN] ApplicableTradeTax/RateApplicablePercent = line VAT % + Assert.AreEqual(Format(SalesInvoiceLine."VAT %", 0, ''), GetCIINodeValue(TempBlob, '//ram:ApplicableHeaderTradeSettlement/ram:ApplicableTradeTax/ram:RateApplicablePercent'), StrSubstNo(IncorrectValueErr, '//ram:ApplicableHeaderTradeSettlement/ram:ApplicableTradeTax/ram:RateApplicablePercent')); end; + [Test] + procedure FacturXExemptVATBreakdownHasExemptionReasonWithoutVATEXCode() + var + Customer: Record Customer; + GLAccount: Record "G/L Account"; + SalesHeader: Record "Sales Header"; + SalesInvoiceHeader: Record "Sales Invoice Header"; + SalesLine: Record "Sales Line"; + SalesReceivablesSetup: Record "Sales & Receivables Setup"; + VATPostingSetup: Record "VAT Posting Setup"; + TempBlob: Codeunit "Temp Blob"; + ExemptionReasonXPath: Text; + begin + // [FEATURE] [AI test] + // [SCENARIO] An exempt VAT breakdown without a configured VATEX code satisfies BR-E-10 + Initialize(); + + // [GIVEN] VAT Posting Setup with 0% VAT, category 'E', and no VAT Clause + LibraryUtility.UpdateSetupNoSeriesCode( + Database::"Sales & Receivables Setup", SalesReceivablesSetup.FieldNo("Invoice Nos.")); + LibraryUtility.UpdateSetupNoSeriesCode( + Database::"Sales & Receivables Setup", SalesReceivablesSetup.FieldNo("Posted Invoice Nos.")); + GLAccount.Get(LibraryERM.CreateGLAccountWithSalesSetup()); + LibraryERM.CreateVATPostingSetupWithAccounts(VATPostingSetup, VATPostingSetup."VAT Calculation Type"::"Normal VAT", 0); + VATPostingSetup."Tax Category" := 'E'; + VATPostingSetup."VAT Clause Code" := ''; + VATPostingSetup.Modify(true); + GLAccount.Validate("VAT Prod. Posting Group", VATPostingSetup."VAT Prod. Posting Group"); + GLAccount.Modify(true); + + // [GIVEN] Posted sales invoice with one exempt line + Customer.Get(CreateCustomer('123456789')); + Customer.Validate("Gen. Bus. Posting Group", GLAccount."Gen. Bus. Posting Group"); + Customer.Validate("VAT Bus. Posting Group", VATPostingSetup."VAT Bus. Posting Group"); + Customer.Modify(true); + LibrarySales.CreateSalesHeader(SalesHeader, "Sales Document Type"::Invoice, Customer."No."); + LibrarySales.CreateSalesLine(SalesLine, SalesHeader, SalesLine.Type::"G/L Account", GLAccount."No.", 1); + SalesLine.Validate("Unit Price", 100); + SalesLine.Modify(true); + SalesInvoiceHeader.Get(LibrarySales.PostSalesDocument(SalesHeader, true, true)); + + // [WHEN] Create CII XML + CreateSalesInvoiceCIIXMLFromHeader(SalesInvoiceHeader, TempBlob); + + // [THEN] The category 'E' header VAT breakdown contains fallback exemption reason text + ExemptionReasonXPath := '//ram:ApplicableHeaderTradeSettlement/ram:ApplicableTradeTax[ram:CategoryCode="E"]/ram:ExemptionReason'; + Assert.AreEqual('Exempt from VAT', GetCIINodeValue(TempBlob, ExemptionReasonXPath), + StrSubstNo(IncorrectValueErr, ExemptionReasonXPath)); + Assert.AreEqual(1, GetCIINodeCount(TempBlob, ExemptionReasonXPath + '/following-sibling::ram:BasisAmount'), + StrSubstNo(IncorrectValueErr, 'ExemptionReason must precede BasisAmount')); + end; + + [Test] + procedure FacturXSalesInvoiceCommentLineDoesNotCreateEmptyVATCategory() + var + SalesInvoiceHeader: Record "Sales Invoice Header"; + TempBlob: Codeunit "Temp Blob"; + EmptyVATCategoryXPath: Text; + begin + // [FEATURE] [AI test] + // [SCENARIO] A comment line does not create a VAT breakdown with an empty category + Initialize(); + + // [GIVEN] Posted sales invoice "SI" with a financial line and a comment line + SalesInvoiceHeader.Get(CreateAndPostSalesInvoiceWithComment()); + + // [WHEN] Create CII XML + CreateSalesInvoiceCIIXMLFromHeader(SalesInvoiceHeader, TempBlob); + + // [THEN] The header VAT breakdown does not contain an empty category code + EmptyVATCategoryXPath := '//ram:ApplicableHeaderTradeSettlement/ram:ApplicableTradeTax/ram:CategoryCode[not(normalize-space())]'; + Assert.AreEqual(0, GetCIINodeCount(TempBlob, EmptyVATCategoryXPath), StrSubstNo(IncorrectValueErr, EmptyVATCategoryXPath)); + end; + [Test] procedure FacturXSalesInvoiceXMLHasMonetarySummation() var @@ -650,42 +881,50 @@ codeunit 148148 "Factur-X CII XML Tests" [Test] procedure FacturXSalesInvoiceXMLHasLineItem() var + SalesInvoiceHeader: Record "Sales Invoice Header"; + SalesInvoiceLine: Record "Sales Invoice Line"; TempBlob: Codeunit "Temp Blob"; begin // [FEATURE] [AI test] - // [SCENARIO] Factur-X CII XML has IncludedSupplyChainTradeLineItem with line details + // [SCENARIO] Factur-X CII XML has IncludedSupplyChainTradeLineItem with fixture-derived values Initialize(); + // [GIVEN] Posted sales invoice with known line + SalesInvoiceHeader.Get(CreateAndPostSalesInvoice()); + SalesInvoiceLine.SetRange("Document No.", SalesInvoiceHeader."No."); + SalesInvoiceLine.SetFilter(Type, '<>%1', SalesInvoiceLine.Type::" "); + SalesInvoiceLine.FindFirst(); + // [WHEN] Create CII XML - CreateSalesInvoiceCIIXML(TempBlob); + CreateSalesInvoiceCIIXMLFromHeader(SalesInvoiceHeader, TempBlob); - // [THEN] Line has LineID - Assert.AreNotEqual('', + // [THEN] Line has LineID = '1' + Assert.AreEqual('1', GetCIINodeValue(TempBlob, '//ram:IncludedSupplyChainTradeLineItem/ram:AssociatedDocumentLineDocument/ram:LineID'), StrSubstNo(IncorrectValueErr, '//ram:IncludedSupplyChainTradeLineItem/ram:AssociatedDocumentLineDocument/ram:LineID')); - // [THEN] Line has product name - Assert.AreNotEqual('', + // [THEN] Line has product name = line description + Assert.AreEqual(SalesInvoiceLine.Description, GetCIINodeValue(TempBlob, '//ram:IncludedSupplyChainTradeLineItem/ram:SpecifiedTradeProduct/ram:Name'), StrSubstNo(IncorrectValueErr, '//ram:IncludedSupplyChainTradeLineItem/ram:SpecifiedTradeProduct/ram:Name')); - // [THEN] Line has net price - Assert.AreNotEqual('', - GetCIINodeValue(TempBlob, '//ram:IncludedSupplyChainTradeLineItem/ram:SpecifiedLineTradeAgreement/ram:NetPriceProductTradePrice/ram:ChargeAmount'), + // [THEN] Line has net price = unit price + Assert.AreEqual(SalesInvoiceLine."Unit Price", + GetCIINodeDecimalValue(TempBlob, '//ram:IncludedSupplyChainTradeLineItem/ram:SpecifiedLineTradeAgreement/ram:NetPriceProductTradePrice/ram:ChargeAmount'), StrSubstNo(IncorrectValueErr, '//ram:IncludedSupplyChainTradeLineItem/ram:SpecifiedLineTradeAgreement/ram:NetPriceProductTradePrice/ram:ChargeAmount')); // [THEN] Line has billed quantity - Assert.AreNotEqual('', + Assert.AreEqual(Format(SalesInvoiceLine.Quantity, 0, ''), GetCIINodeValue(TempBlob, '//ram:IncludedSupplyChainTradeLineItem/ram:SpecifiedLineTradeDelivery/ram:BilledQuantity'), StrSubstNo(IncorrectValueErr, '//ram:IncludedSupplyChainTradeLineItem/ram:SpecifiedLineTradeDelivery/ram:BilledQuantity')); - // [THEN] Line has tax category code - Assert.AreNotEqual('', + // [THEN] Line has tax category code 'S' + Assert.AreEqual('S', GetCIINodeValue(TempBlob, '//ram:IncludedSupplyChainTradeLineItem/ram:SpecifiedLineTradeSettlement/ram:ApplicableTradeTax/ram:CategoryCode'), StrSubstNo(IncorrectValueErr, '//ram:IncludedSupplyChainTradeLineItem/ram:SpecifiedLineTradeSettlement/ram:ApplicableTradeTax/ram:CategoryCode')); - // [THEN] Line has line total amount - Assert.AreNotEqual('', + // [THEN] Line has line total amount = line amount + Assert.AreEqual(Format(SalesInvoiceLine.Amount, 0, ''), GetCIINodeValue(TempBlob, '//ram:IncludedSupplyChainTradeLineItem/ram:SpecifiedLineTradeSettlement/ram:SpecifiedTradeSettlementLineMonetarySummation/ram:LineTotalAmount'), StrSubstNo(IncorrectValueErr, '//ram:IncludedSupplyChainTradeLineItem/ram:SpecifiedLineTradeSettlement/ram:SpecifiedTradeSettlementLineMonetarySummation/ram:LineTotalAmount')); end; @@ -771,6 +1010,33 @@ codeunit 148148 "Factur-X CII XML Tests" StrSubstNo(IncorrectValueErr, '//rsm:ExchangedDocument/ram:ID')); end; + [Test] + procedure FacturXSalesCreditMemoXMLHasReferencedInvoiceNumberAndDate() + var + SalesCrMemoHeader: Record "Sales Cr.Memo Header"; + SalesInvoiceHeader: Record "Sales Invoice Header"; + TempBlob: Codeunit "Temp Blob"; + begin + // [FEATURE] [AI test] + // [SCENARIO] Factur-X CII XML for an applied credit memo identifies the referenced invoice and its date + Initialize(); + + // [GIVEN] Posted sales credit memo applied to posted invoice "SI" + SalesInvoiceHeader.Get(CreateAndPostSalesInvoice()); + SalesCrMemoHeader.Get(CreateAndPostSalesCreditMemo(SalesInvoiceHeader)); + + // [WHEN] Create credit memo CII XML + CreateSalesCreditMemoCIIXML(SalesCrMemoHeader, TempBlob); + + // [THEN] InvoiceReferencedDocument contains the invoice number and document date + Assert.AreEqual(SalesInvoiceHeader."No.", + GetCIINodeValue(TempBlob, '//ram:InvoiceReferencedDocument/ram:IssuerAssignedID'), + StrSubstNo(IncorrectValueErr, '//ram:InvoiceReferencedDocument/ram:IssuerAssignedID')); + Assert.AreEqual(Format(SalesInvoiceHeader."Document Date", 0, ''), + GetCIINodeValue(TempBlob, '//ram:InvoiceReferencedDocument/ram:FormattedIssueDateTime/qdt:DateTimeString'), + StrSubstNo(IncorrectValueErr, '//ram:InvoiceReferencedDocument/ram:FormattedIssueDateTime/qdt:DateTimeString')); + end; + [Test] procedure FacturXSalesCreditMemoXMLHasSellerSIRET() var @@ -845,29 +1111,50 @@ codeunit 148148 "Factur-X CII XML Tests" SalesCrMemoHeader: Record "Sales Cr.Memo Header"; GeneralLedgerSetup: Record "General Ledger Setup"; TempBlob: Codeunit "Temp Blob"; - ExpectedCurrencyCode: Code[10]; begin // [FEATURE] [AI test] - // [SCENARIO] Factur-X CII XML for a credit memo has settlement currency code + // [SCENARIO] Factur-X CII XML for a credit memo uses LCY when document has no currency Initialize(); - // [GIVEN] Posted sales credit memo + // [GIVEN] Posted sales credit memo with blank Currency Code (uses LCY) SalesCrMemoHeader.Get(CreateAndPostSalesCreditMemo()); GeneralLedgerSetup.Get(); - if SalesCrMemoHeader."Currency Code" <> '' then - ExpectedCurrencyCode := SalesCrMemoHeader."Currency Code" - else - ExpectedCurrencyCode := GeneralLedgerSetup."LCY Code"; // [WHEN] Create credit memo CII XML CreateSalesCreditMemoCIIXML(SalesCrMemoHeader, TempBlob); - // [THEN] InvoiceCurrencyCode = expected currency - Assert.AreEqual(ExpectedCurrencyCode, + // [THEN] InvoiceCurrencyCode = LCY Code + Assert.AreEqual(GeneralLedgerSetup."LCY Code", GetCIINodeValue(TempBlob, '//ram:InvoiceCurrencyCode'), StrSubstNo(IncorrectValueErr, '//ram:InvoiceCurrencyCode')); end; + [Test] + procedure FacturXSalesCreditMemoXMLHasDocumentCurrencyCode() + var + Currency: Record Currency; + SalesHeader: Record "Sales Header"; + SalesCrMemoHeader: Record "Sales Cr.Memo Header"; + TempBlob: Codeunit "Temp Blob"; + begin + // [FEATURE] [AI test] + // [SCENARIO] Factur-X CII XML for a credit memo uses the document currency + Initialize(); + + // [GIVEN] Sales credit memo with a foreign Currency Code + LibraryERM.CreateCurrency(Currency); + LibraryERM.CreateRandomExchangeRate(Currency.Code); + SalesHeader.Get("Sales Document Type"::"Credit Memo", CreateSalesDocumentWithLine("Sales Document Type"::"Credit Memo", '', Currency.Code)); + SalesCrMemoHeader.Get(LibrarySales.PostSalesDocument(SalesHeader, true, true)); + + // [WHEN] Create credit memo CII XML + CreateSalesCreditMemoCIIXML(SalesCrMemoHeader, TempBlob); + + // [THEN] InvoiceCurrencyCode equals the document currency + Assert.AreEqual(Currency.Code, GetCIINodeValue(TempBlob, '//ram:InvoiceCurrencyCode'), + StrSubstNo(IncorrectValueErr, '//ram:InvoiceCurrencyCode')); + end; + [Test] procedure FacturXSalesCreditMemoXMLHasMonetarySummation() var @@ -922,6 +1209,96 @@ codeunit 148148 "Factur-X CII XML Tests" GetCIINodeValue(TempBlob, '//ram:IncludedSupplyChainTradeLineItem/ram:SpecifiedTradeProduct/ram:Name'), StrSubstNo(IncorrectValueErr, '//ram:IncludedSupplyChainTradeLineItem/ram:SpecifiedTradeProduct/ram:Name')); end; + + [Test] + procedure FacturXSalesCrMemoZeroVATCatSPreservedWithGermanBuyer() + var + Customer: Record Customer; + GLAccount: Record "G/L Account"; + VATPostingSetup: Record "VAT Posting Setup"; + SalesHeader: Record "Sales Header"; + SalesLine: Record "Sales Line"; + SalesCrMemoHeader: Record "Sales Cr.Memo Header"; + SalesReceivablesSetup: Record "Sales & Receivables Setup"; + TempBlob: Codeunit "Temp Blob"; + CustomerNo: Code[20]; + LineTaxCategoryXPath: Text; + HeaderTaxCategoryXPath: Text; + begin + // [FEATURE] [AI test] + // [SCENARIO] Credit memo line with VAT%=0 preserves source Tax Category 'S' and serializes the rate; German buyer gets DE-prefixed VAT ID + Initialize(); + + // [GIVEN] German customer "C" with VAT Registration No. '533435789', FR Electronic Address '123456789_FOREIGN' + EnsureCountryRegionExists('DE'); + LibrarySales.CreateCustomer(Customer); + Customer.Validate("Country/Region Code", 'DE'); + Customer."VAT Registration No." := '533435789'; + Customer."Registration Number" := ''; + Customer."FR Electronic Address" := '123456789_FOREIGN'; + Customer.Modify(true); + CustomerNo := Customer."No."; + + // [GIVEN] VAT Posting Setup with Normal VAT, 0%, Tax Category 'S' + LibraryUtility.UpdateSetupNoSeriesCode( + DATABASE::"Sales & Receivables Setup", SalesReceivablesSetup.FieldNo("Credit Memo Nos.")); + LibraryUtility.UpdateSetupNoSeriesCode( + DATABASE::"Sales & Receivables Setup", SalesReceivablesSetup.FieldNo("Posted Credit Memo Nos.")); + GLAccount.Get(LibraryERM.CreateGLAccountWithSalesSetup()); + LibraryERM.CreateVATPostingSetupWithAccounts(VATPostingSetup, VATPostingSetup."VAT Calculation Type"::"Normal VAT", 0); + VATPostingSetup."Tax Category" := 'S'; + VATPostingSetup.Modify(true); + GLAccount.Validate("VAT Prod. Posting Group", VATPostingSetup."VAT Prod. Posting Group"); + GLAccount.Modify(true); + Customer.Validate("Gen. Bus. Posting Group", GLAccount."Gen. Bus. Posting Group"); + Customer.Validate("VAT Bus. Posting Group", VATPostingSetup."VAT Bus. Posting Group"); + Customer.Modify(true); + + // [GIVEN] Posted sales credit memo "CM" with a single financial line + LibrarySales.CreateSalesHeader(SalesHeader, "Sales Document Type"::"Credit Memo", CustomerNo); + LibrarySales.CreateSalesLine(SalesLine, SalesHeader, SalesLine.Type::"G/L Account", GLAccount."No.", 1); + SalesLine.Validate("Unit Price", 100); + SalesLine.Validate("Unit of Measure Code", GetUnitOfMeasureCode()); + SalesLine.Modify(true); + SalesCrMemoHeader.Get(LibrarySales.PostSalesDocument(SalesHeader, true, true)); + + // [WHEN] Create credit memo CII XML + CreateSalesCreditMemoCIIXML(SalesCrMemoHeader, TempBlob); + + // [THEN] Line-level ApplicableTradeTax preserves CategoryCode 'S' and RateApplicablePercent '0' + LineTaxCategoryXPath := '//ram:IncludedSupplyChainTradeLineItem/ram:SpecifiedLineTradeSettlement/ram:ApplicableTradeTax/ram:CategoryCode'; + Assert.AreEqual('S', GetCIINodeValue(TempBlob, LineTaxCategoryXPath), + StrSubstNo(IncorrectValueErr, LineTaxCategoryXPath)); + Assert.AreEqual('0', + GetCIINodeValue(TempBlob, '//ram:IncludedSupplyChainTradeLineItem/ram:SpecifiedLineTradeSettlement/ram:ApplicableTradeTax[ram:CategoryCode="S"]/ram:RateApplicablePercent'), + StrSubstNo(IncorrectValueErr, 'Line RateApplicablePercent')); + + // [THEN] Header ApplicableTradeTax preserves CategoryCode 'S' and RateApplicablePercent '0' + HeaderTaxCategoryXPath := '//ram:ApplicableHeaderTradeSettlement/ram:ApplicableTradeTax/ram:CategoryCode'; + Assert.AreEqual('S', GetCIINodeValue(TempBlob, HeaderTaxCategoryXPath), + StrSubstNo(IncorrectValueErr, HeaderTaxCategoryXPath)); + Assert.AreEqual('0', + GetCIINodeValue(TempBlob, '//ram:ApplicableHeaderTradeSettlement/ram:ApplicableTradeTax[ram:CategoryCode="S"]/ram:RateApplicablePercent'), + StrSubstNo(IncorrectValueErr, 'Header RateApplicablePercent')); + Assert.AreEqual(0, GetCIINodeCount(TempBlob, '//ram:ApplicableTradeTax[ram:CategoryCode="O"]'), + StrSubstNo(IncorrectValueErr, 'CategoryCode O must not be derived from zero-rate category S')); + + // [THEN] Buyer SpecifiedTaxRegistration/ID = 'DE533435789' with schemeID 'VA' + Assert.AreEqual('DE533435789', + GetCIINodeValue(TempBlob, '//ram:BuyerTradeParty/ram:SpecifiedTaxRegistration/ram:ID'), + StrSubstNo(IncorrectValueErr, '//ram:BuyerTradeParty/ram:SpecifiedTaxRegistration/ram:ID')); + Assert.AreEqual('VA', + GetCIIAttributeValue(TempBlob, '//ram:BuyerTradeParty/ram:SpecifiedTaxRegistration/ram:ID/@schemeID'), + StrSubstNo(IncorrectValueErr, '//ram:BuyerTradeParty/ram:SpecifiedTaxRegistration/ram:ID/@schemeID')); + + // [THEN] Buyer URIID = '123456789_FOREIGN' with schemeID '0225' + Assert.AreEqual('123456789_FOREIGN', + GetCIINodeValue(TempBlob, '//ram:BuyerTradeParty/ram:URIUniversalCommunication/ram:URIID'), + StrSubstNo(IncorrectValueErr, '//ram:BuyerTradeParty/ram:URIUniversalCommunication/ram:URIID')); + Assert.AreEqual('0225', + GetCIIAttributeValue(TempBlob, '//ram:BuyerTradeParty/ram:URIUniversalCommunication/ram:URIID/@schemeID'), + StrSubstNo(IncorrectValueErr, '//ram:BuyerTradeParty/ram:URIUniversalCommunication/ram:URIID/@schemeID')); + end; #endregion #region Reminder @@ -1006,6 +1383,414 @@ codeunit 148148 "Factur-X CII XML Tests" end; #endregion + #region BillingMode + [Test] + procedure FacturXBillingModeB1ForItemOnlyInvoice() + var + SalesInvoiceHeader: Record "Sales Invoice Header"; + SalesInvoiceLine: Record "Sales Invoice Line"; + PeppolBIS30FRFormat: Codeunit "Peppol BIS 3.0 FR Format"; + SourceDocumentLines: RecordRef; + OriginalView: Text; + begin + // [FEATURE] [AI test] + // [SCENARIO] GetFrenchBillingMode returns B1 for an invoice with only Item lines + Initialize(); + + // [GIVEN] Posted sales invoice containing only an Item line + SalesInvoiceHeader.Get(CreateAndPostSalesInvoiceWithBillingModeLines(false)); + SalesInvoiceLine.SetRange("Document No.", SalesInvoiceHeader."No."); + SourceDocumentLines.GetTable(SalesInvoiceLine); + OriginalView := SourceDocumentLines.GetView(false); + + // [WHEN] GetFrenchBillingMode is called + // [THEN] Result = 'B1' and the source lines view is unchanged + Assert.AreEqual('B1', PeppolBIS30FRFormat.GetFrenchBillingMode(SourceDocumentLines), + StrSubstNo(IncorrectValueErr, 'BillingMode B1')); + Assert.AreEqual(OriginalView, SourceDocumentLines.GetView(false), StrSubstNo(IncorrectValueErr, 'Source Document Lines View')); + end; + + [Test] + procedure FacturXBillingModeM1ForMixedItemAndNonItemInvoice() + var + SalesInvoiceHeader: Record "Sales Invoice Header"; + SalesInvoiceLine: Record "Sales Invoice Line"; + PeppolBIS30FRFormat: Codeunit "Peppol BIS 3.0 FR Format"; + SourceDocumentLines: RecordRef; + OriginalView: Text; + begin + // [FEATURE] [AI test] + // [SCENARIO] GetFrenchBillingMode returns M1 for an invoice with both Item and G/L Account lines + Initialize(); + + // [GIVEN] Posted sales invoice containing Item and G/L Account lines + SalesInvoiceHeader.Get(CreateAndPostSalesInvoiceWithBillingModeLines(true)); + SalesInvoiceLine.SetRange("Document No.", SalesInvoiceHeader."No."); + SourceDocumentLines.GetTable(SalesInvoiceLine); + OriginalView := SourceDocumentLines.GetView(false); + + // [WHEN] GetFrenchBillingMode is called + // [THEN] Result = 'M1' and the source lines view is unchanged + Assert.AreEqual('M1', PeppolBIS30FRFormat.GetFrenchBillingMode(SourceDocumentLines), + StrSubstNo(IncorrectValueErr, 'BillingMode M1')); + Assert.AreEqual(OriginalView, SourceDocumentLines.GetView(false), StrSubstNo(IncorrectValueErr, 'Source Document Lines View')); + end; + #endregion + + #region Validation + [Test] + procedure FacturXCheckRaisesErrorWhenBuyerElectronicAddressIsMissing() + var + SalesInvoiceHeader: Record "Sales Invoice Header"; + SourceDocumentHeader: RecordRef; + CustomerNo: Code[20]; + begin + // [FEATURE] [AI test] + // [SCENARIO] Factur-X Format Check raises error when buyer has no electronic address or VAT + Initialize(); + + // [GIVEN] Posted sales invoice for customer "C" without electronic address or VAT + CustomerNo := CreateCustomerWithoutIdentifiers(); + SalesInvoiceHeader.Get(CreateAndPostSalesInvoiceForCustomer(CustomerNo)); + SourceDocumentHeader.GetTable(SalesInvoiceHeader); + + // [WHEN] Factur-X Format Check is called + asserterror CheckFacturX(SourceDocumentHeader); + + // [THEN] Error about buyer electronic address is raised + AssertExpectedDialogError(EDocHelpers.GetBuyerElectronicAddressRequiredError(CustomerNo)); + end; + + [Test] + procedure FacturXCheckRaisesErrorWhenBuyerElectronicAddressIsMalformed() + var + SalesInvoiceHeader: Record "Sales Invoice Header"; + Customer: Record Customer; + SourceDocumentHeader: RecordRef; + CustomerNo: Code[20]; + begin + // [FEATURE] [AI test] + // [SCENARIO] Factur-X Format Check raises error when buyer electronic address does not match SIREN format + Initialize(); + + // [GIVEN] Customer "C" with malformed FR Electronic Address (non-digit prefix) + CustomerNo := CreateCustomer(''); + Customer.Get(CustomerNo); + Customer."FR Electronic Address" := 'ABCDEFGHI'; + Customer."Registration Number" := ''; + Customer.Modify(true); + + // [GIVEN] Posted sales invoice for "C" + SalesInvoiceHeader.Get(CreateAndPostSalesInvoiceForCustomer(CustomerNo)); + SourceDocumentHeader.GetTable(SalesInvoiceHeader); + + // [WHEN] Factur-X Format Check is called + asserterror CheckFacturX(SourceDocumentHeader); + + // [THEN] Error about malformed buyer identifier is raised + AssertExpectedDialogError(EDocHelpers.GetBuyerElectronicAddressInvalidError( + Customer.FieldCaption("FR Electronic Address"), CustomerNo)); + end; + + [Test] + procedure FacturXCheckPassesAndExportsURIIDFromFrenchVAT() + var + SalesInvoiceHeader: Record "Sales Invoice Header"; + Customer: Record Customer; + TempBlob: Codeunit "Temp Blob"; + SourceDocumentHeader: RecordRef; + CustomerNo: Code[20]; + begin + // [FEATURE] [AI test] + // [SCENARIO] Factur-X uses SIREN extracted from French VAT as BuyerTradeParty URIID when FR Electronic Address and Registration Number are blank + Initialize(); + + // [GIVEN] Customer "C" with French VAT but no FR Electronic Address or Registration Number + CustomerNo := CreateCustomer(''); + Customer.Get(CustomerNo); + Customer."FR Electronic Address" := ''; + Customer."Registration Number" := ''; + Customer."VAT Registration No." := 'FR78945627890'; + Customer.Modify(true); + + // [GIVEN] Posted sales invoice for "C" + SalesInvoiceHeader.Get(CreateAndPostSalesInvoiceForCustomer(CustomerNo)); + SourceDocumentHeader.GetTable(SalesInvoiceHeader); + + // [WHEN] Factur-X Format Check is called + CheckFacturX(SourceDocumentHeader); + + // [WHEN] Create CII XML + CreateSalesInvoiceCIIXMLFromHeader(SalesInvoiceHeader, TempBlob); + + // [THEN] BuyerTradeParty/URIUniversalCommunication/URIID = '945627890' + Assert.AreEqual('945627890', + GetCIINodeValue(TempBlob, '//ram:BuyerTradeParty/ram:URIUniversalCommunication/ram:URIID'), + StrSubstNo(IncorrectValueErr, '//ram:BuyerTradeParty/ram:URIUniversalCommunication/ram:URIID')); + + // [THEN] schemeID = '0225' + Assert.AreEqual('0225', + GetCIIAttributeValue(TempBlob, '//ram:BuyerTradeParty/ram:URIUniversalCommunication/ram:URIID/@schemeID'), + StrSubstNo(IncorrectValueErr, '//ram:BuyerTradeParty/ram:URIUniversalCommunication/ram:URIID/@schemeID')); + end; + + [Test] + procedure FacturXCheckRaisesErrorWhenBuyerHasOnlyNonFrenchVAT() + var + SalesInvoiceHeader: Record "Sales Invoice Header"; + Customer: Record Customer; + SourceDocumentHeader: RecordRef; + CustomerNo: Code[20]; + begin + // [FEATURE] [AI test] + // [SCENARIO] Factur-X Format Check raises error when buyer has only a non-French VAT without FR Electronic Address or Registration Number + Initialize(); + + // [GIVEN] Customer "C" with non-French VAT but no FR Electronic Address or Registration Number + CustomerNo := CreateCustomer(''); + Customer.Get(CustomerNo); + Customer."FR Electronic Address" := ''; + Customer."Registration Number" := ''; + Customer."VAT Registration No." := 'DE123456789'; + Customer.Modify(true); + + // [GIVEN] Posted sales invoice for "C" + SalesInvoiceHeader.Get(CreateAndPostSalesInvoiceForCustomer(CustomerNo)); + SourceDocumentHeader.GetTable(SalesInvoiceHeader); + + // [WHEN] Factur-X Format Check is called + asserterror CheckFacturX(SourceDocumentHeader); + + // [THEN] Error about buyer electronic address is raised + AssertExpectedDialogError(EDocHelpers.GetBuyerElectronicAddressRequiredError(CustomerNo)); + end; + #endregion + + #region MultiVATRate + [Test] + procedure FacturXSalesInvoiceXMLHasMultipleVATRateTaxBreakdowns() + var + Customer: Record Customer; + GLAccount: Record "G/L Account"; + FirstVATPostingSetup: Record "VAT Posting Setup"; + SecondVATPostingSetup: Record "VAT Posting Setup"; + SalesHeader: Record "Sales Header"; + SalesLine: Record "Sales Line"; + SalesInvoiceHeader: Record "Sales Invoice Header"; + SalesReceivablesSetup: Record "Sales & Receivables Setup"; + TempBlob: Codeunit "Temp Blob"; + CustomerNo: Code[20]; + begin + // [FEATURE] [AI test] + // [SCENARIO] Factur-X CII XML has distinct ApplicableTradeTax groups for each VAT rate + Initialize(); + + // [GIVEN] Posted sales invoice "SI" with two lines at different VAT rates + CustomerNo := CreateCustomer(''); + LibraryUtility.UpdateSetupNoSeriesCode( + DATABASE::"Sales & Receivables Setup", SalesReceivablesSetup.FieldNo("Invoice Nos.")); + LibraryUtility.UpdateSetupNoSeriesCode( + DATABASE::"Sales & Receivables Setup", SalesReceivablesSetup.FieldNo("Posted Invoice Nos.")); + GLAccount.Get(LibraryERM.CreateGLAccountWithSalesSetup()); + EnsureSalesInvoiceDiscountAccount(GLAccount."Gen. Bus. Posting Group", GLAccount."Gen. Prod. Posting Group"); + LibraryERM.CreateVATPostingSetupWithAccounts(FirstVATPostingSetup, FirstVATPostingSetup."VAT Calculation Type"::"Normal VAT", 20); + GLAccount.Validate("VAT Prod. Posting Group", FirstVATPostingSetup."VAT Prod. Posting Group"); + GLAccount.Modify(true); + Customer.Get(CustomerNo); + Customer.Validate("Gen. Bus. Posting Group", GLAccount."Gen. Bus. Posting Group"); + Customer.Validate("VAT Bus. Posting Group", FirstVATPostingSetup."VAT Bus. Posting Group"); + Customer.Modify(true); + LibrarySales.CreateSalesHeader(SalesHeader, "Sales Document Type"::Invoice, CustomerNo); + LibrarySales.CreateSalesLine(SalesLine, SalesHeader, SalesLine.Type::"G/L Account", GLAccount."No.", 1); + SalesLine.Validate("Unit Price", 200); + SalesLine.Validate("Allow Invoice Disc.", true); + SalesLine.Validate("Unit of Measure Code", GetUnitOfMeasureCode()); + SalesLine.Modify(true); + LibraryERM.CreateVATPostingSetupWithAccounts(SecondVATPostingSetup, SecondVATPostingSetup."VAT Calculation Type"::"Normal VAT", 10); + SecondVATPostingSetup.Rename(Customer."VAT Bus. Posting Group", SecondVATPostingSetup."VAT Prod. Posting Group"); + LibrarySales.CreateSalesLine(SalesLine, SalesHeader, SalesLine.Type::"G/L Account", GLAccount."No.", 1); + SalesLine.Validate("VAT Prod. Posting Group", SecondVATPostingSetup."VAT Prod. Posting Group"); + SalesLine.Validate("Unit Price", 300); + SalesLine.Validate("Allow Invoice Disc.", true); + SalesLine.Validate("Unit of Measure Code", GetUnitOfMeasureCode()); + SalesLine.Modify(true); + SalesInvoiceHeader.Get(LibrarySales.PostSalesDocument(SalesHeader, true, true)); + + // [WHEN] Create CII XML + CreateSalesInvoiceCIIXMLFromHeader(SalesInvoiceHeader, TempBlob); + + // [THEN] Separate header VAT breakdowns exist for both VAT rates + Assert.AreEqual(1, GetCIINodeCount(TempBlob, + '//ram:ApplicableHeaderTradeSettlement/ram:ApplicableTradeTax[ram:RateApplicablePercent="20"]'), + StrSubstNo(IncorrectValueErr, '20 percent ApplicableTradeTax')); + Assert.AreEqual(1, GetCIINodeCount(TempBlob, + '//ram:ApplicableHeaderTradeSettlement/ram:ApplicableTradeTax[ram:RateApplicablePercent="10"]'), + StrSubstNo(IncorrectValueErr, '10 percent ApplicableTradeTax')); + Assert.AreEqual(2, GetCIINodeCount(TempBlob, + '//ram:ApplicableHeaderTradeSettlement/ram:ApplicableTradeTax'), + StrSubstNo(IncorrectValueErr, 'ApplicableTradeTax count')); + end; + + [Test] + procedure FacturXMixedVATWithInvDiscountTaxTotalEqualsBreakdownSum() + var + SalesInvoiceHeader: Record "Sales Invoice Header"; + TempBlob: Codeunit "Temp Blob"; + TaxTotalAmount: Decimal; + Calculated20: Decimal; + Calculated10: Decimal; + Basis20: Decimal; + Basis10: Decimal; + ExpectedBasis20: Decimal; + ExpectedBasis10: Decimal; + ExpectedCalculated20: Decimal; + ExpectedCalculated10: Decimal; + begin + // [FEATURE] [AI test] + // [SCENARIO] Mixed VAT rates with invoice discount: TaxTotalAmount = sum of CalculatedAmounts and each breakdown matches posted line amounts + Initialize(); + + // [GIVEN] Posted sales invoice "SI" with two lines at 20% and 10% VAT and invoice discount applied + SalesInvoiceHeader.Get(CreateAndPostMultiVATInvoiceWithDiscount(true)); + GetPostedInvoiceAmountsByVATRate(SalesInvoiceHeader."No.", 20, ExpectedBasis20, ExpectedCalculated20); + GetPostedInvoiceAmountsByVATRate(SalesInvoiceHeader."No.", 10, ExpectedBasis10, ExpectedCalculated10); + + // [WHEN] Create CII XML + CreateSalesInvoiceCIIXMLFromHeader(SalesInvoiceHeader, TempBlob); + + // [THEN] Each breakdown BasisAmount equals the posted line Amount grouped by VAT rate + Basis20 := GetCIINodeDecimalValue(TempBlob, '//ram:ApplicableHeaderTradeSettlement/ram:ApplicableTradeTax[ram:RateApplicablePercent="20"]/ram:BasisAmount'); + Assert.AreEqual(ExpectedBasis20, Basis20, StrSubstNo(IncorrectValueErr, 'BasisAmount 20%')); + Basis10 := GetCIINodeDecimalValue(TempBlob, '//ram:ApplicableHeaderTradeSettlement/ram:ApplicableTradeTax[ram:RateApplicablePercent="10"]/ram:BasisAmount'); + Assert.AreEqual(ExpectedBasis10, Basis10, StrSubstNo(IncorrectValueErr, 'BasisAmount 10%')); + + // [THEN] Each breakdown CalculatedAmount equals AmountIncludingVAT - Amount per rate + Calculated20 := GetCIINodeDecimalValue(TempBlob, '//ram:ApplicableHeaderTradeSettlement/ram:ApplicableTradeTax[ram:RateApplicablePercent="20"]/ram:CalculatedAmount'); + Assert.AreEqual(ExpectedCalculated20, Calculated20, StrSubstNo(IncorrectValueErr, 'CalculatedAmount 20%')); + Calculated10 := GetCIINodeDecimalValue(TempBlob, '//ram:ApplicableHeaderTradeSettlement/ram:ApplicableTradeTax[ram:RateApplicablePercent="10"]/ram:CalculatedAmount'); + Assert.AreEqual(ExpectedCalculated10, Calculated10, StrSubstNo(IncorrectValueErr, 'CalculatedAmount 10%')); + + // [THEN] Header TaxTotalAmount equals sum of all CalculatedAmounts + TaxTotalAmount := GetCIINodeDecimalValue(TempBlob, '//ram:SpecifiedTradeSettlementHeaderMonetarySummation/ram:TaxTotalAmount'); + Assert.AreEqual(Calculated20 + Calculated10, TaxTotalAmount, StrSubstNo(IncorrectValueErr, 'TaxTotalAmount')); + end; + + [Test] + procedure FacturXSingleVATWithInvDiscountAllowanceAndReconciliation() + var + SalesInvoiceHeader: Record "Sales Invoice Header"; + SalesInvoiceLine: Record "Sales Invoice Line"; + TempBlob: Codeunit "Temp Blob"; + LineTotalAmount: Decimal; + AllowanceTotalAmount: Decimal; + AllowanceAmount: Decimal; + TaxBasisTotalAmount: Decimal; + TaxTotalAmount: Decimal; + BreakdownBasis: Decimal; + BreakdownCalculated: Decimal; + ExpectedAllowanceAmount: Decimal; + ExpectedBasis: Decimal; + ExpectedCalculated: Decimal; + begin + // [FEATURE] [AI test] + // [SCENARIO] Single VAT rate with invoice discount: document allowance, breakdown basis/VAT, and BR-CO-14 reconciliation + Initialize(); + + // [GIVEN] Posted sales invoice "SI" with one line at 20% VAT and invoice discount applied + SalesInvoiceHeader.Get(CreateAndPostSingleVATInvoiceWithDiscount()); + SalesInvoiceLine.SetRange("Document No.", SalesInvoiceHeader."No."); + SalesInvoiceLine.SetFilter(Type, '<>%1', SalesInvoiceLine.Type::" "); + SalesInvoiceLine.FindFirst(); + ExpectedAllowanceAmount := SalesInvoiceLine."Line Amount" - SalesInvoiceLine.Amount; + ExpectedBasis := SalesInvoiceLine.Amount; + ExpectedCalculated := SalesInvoiceLine."Amount Including VAT" - SalesInvoiceLine.Amount; + + // [WHEN] Create CII XML + CreateSalesInvoiceCIIXMLFromHeader(SalesInvoiceHeader, TempBlob); + + // [THEN] Document-level allowance equals the discount recorded on the posted line + AllowanceAmount := GetCIINodeDecimalValue( + TempBlob, '//ram:ApplicableHeaderTradeSettlement/ram:SpecifiedTradeAllowanceCharge/ram:ActualAmount'); + Assert.AreEqual(ExpectedAllowanceAmount, AllowanceAmount, StrSubstNo(IncorrectValueErr, 'ActualAmount')); + + // [THEN] Breakdown BasisAmount equals posted line Amount + BreakdownBasis := GetCIINodeDecimalValue(TempBlob, '//ram:ApplicableHeaderTradeSettlement/ram:ApplicableTradeTax/ram:BasisAmount'); + Assert.AreEqual(ExpectedBasis, BreakdownBasis, StrSubstNo(IncorrectValueErr, 'BasisAmount')); + + // [THEN] Breakdown CalculatedAmount equals posted VAT + BreakdownCalculated := GetCIINodeDecimalValue(TempBlob, '//ram:ApplicableHeaderTradeSettlement/ram:ApplicableTradeTax/ram:CalculatedAmount'); + Assert.AreEqual(ExpectedCalculated, BreakdownCalculated, StrSubstNo(IncorrectValueErr, 'CalculatedAmount')); + + // [THEN] Monetary totals reconcile + LineTotalAmount := GetCIINodeDecimalValue(TempBlob, '//ram:SpecifiedTradeSettlementHeaderMonetarySummation/ram:LineTotalAmount'); + AllowanceTotalAmount := GetCIINodeDecimalValue(TempBlob, '//ram:SpecifiedTradeSettlementHeaderMonetarySummation/ram:AllowanceTotalAmount'); + TaxBasisTotalAmount := GetCIINodeDecimalValue(TempBlob, '//ram:SpecifiedTradeSettlementHeaderMonetarySummation/ram:TaxBasisTotalAmount'); + Assert.AreEqual(LineTotalAmount, TaxBasisTotalAmount + AllowanceTotalAmount, + StrSubstNo(IncorrectValueErr, 'LineTotalAmount')); + + // [THEN] BR-CO-14: TaxTotalAmount equals the VAT breakdown CalculatedAmount + TaxTotalAmount := GetCIINodeDecimalValue(TempBlob, '//ram:SpecifiedTradeSettlementHeaderMonetarySummation/ram:TaxTotalAmount'); + Assert.AreEqual(BreakdownCalculated, TaxTotalAmount, StrSubstNo(IncorrectValueErr, 'TaxTotalAmount')); + end; + + [Test] + procedure FacturXMixedVATNoDiscountBreakdownAndReconciliation() + var + SalesInvoiceHeader: Record "Sales Invoice Header"; + TempBlob: Codeunit "Temp Blob"; + TaxTotalAmount: Decimal; + TaxBasisTotalAmount: Decimal; + Calculated20: Decimal; + Calculated10: Decimal; + Basis20: Decimal; + Basis10: Decimal; + ExpectedBasis20: Decimal; + ExpectedBasis10: Decimal; + ExpectedCalculated20: Decimal; + ExpectedCalculated10: Decimal; + begin + // [FEATURE] [AI test] + // [SCENARIO] Mixed VAT rates without invoice discount: breakdown amounts match posted values and reconciliation holds + Initialize(); + + // [GIVEN] Posted sales invoice "SI" with two lines at 20% and 10% VAT without invoice discount + SalesInvoiceHeader.Get(CreateAndPostMultiVATInvoiceWithDiscount(false)); + GetPostedInvoiceAmountsByVATRate(SalesInvoiceHeader."No.", 20, ExpectedBasis20, ExpectedCalculated20); + GetPostedInvoiceAmountsByVATRate(SalesInvoiceHeader."No.", 10, ExpectedBasis10, ExpectedCalculated10); + + // [WHEN] Create CII XML + CreateSalesInvoiceCIIXMLFromHeader(SalesInvoiceHeader, TempBlob); + + // [THEN] Each breakdown BasisAmount and CalculatedAmount match posted line amounts + Basis20 := GetCIINodeDecimalValue(TempBlob, '//ram:ApplicableHeaderTradeSettlement/ram:ApplicableTradeTax[ram:RateApplicablePercent="20"]/ram:BasisAmount'); + Assert.AreEqual(ExpectedBasis20, Basis20, StrSubstNo(IncorrectValueErr, 'BasisAmount 20%')); + Basis10 := GetCIINodeDecimalValue(TempBlob, '//ram:ApplicableHeaderTradeSettlement/ram:ApplicableTradeTax[ram:RateApplicablePercent="10"]/ram:BasisAmount'); + Assert.AreEqual(ExpectedBasis10, Basis10, StrSubstNo(IncorrectValueErr, 'BasisAmount 10%')); + Calculated20 := GetCIINodeDecimalValue(TempBlob, '//ram:ApplicableHeaderTradeSettlement/ram:ApplicableTradeTax[ram:RateApplicablePercent="20"]/ram:CalculatedAmount'); + Assert.AreEqual(ExpectedCalculated20, Calculated20, StrSubstNo(IncorrectValueErr, 'CalculatedAmount 20%')); + Calculated10 := GetCIINodeDecimalValue(TempBlob, '//ram:ApplicableHeaderTradeSettlement/ram:ApplicableTradeTax[ram:RateApplicablePercent="10"]/ram:CalculatedAmount'); + Assert.AreEqual(ExpectedCalculated10, Calculated10, StrSubstNo(IncorrectValueErr, 'CalculatedAmount 10%')); + + // [THEN] TaxTotalAmount equals sum of all CalculatedAmounts + TaxTotalAmount := GetCIINodeDecimalValue(TempBlob, '//ram:SpecifiedTradeSettlementHeaderMonetarySummation/ram:TaxTotalAmount'); + Assert.AreEqual(Calculated20 + Calculated10, TaxTotalAmount, StrSubstNo(IncorrectValueErr, 'TaxTotalAmount')); + + // [THEN] TaxBasisTotalAmount equals sum of all BasisAmounts (no discount) + TaxBasisTotalAmount := GetCIINodeDecimalValue(TempBlob, '//ram:SpecifiedTradeSettlementHeaderMonetarySummation/ram:TaxBasisTotalAmount'); + Assert.AreEqual(Basis20 + Basis10, TaxBasisTotalAmount, StrSubstNo(IncorrectValueErr, 'TaxBasisTotalAmount')); + + // [THEN] No document-level allowance exists + Assert.AreEqual(0, GetCIINodeCount(TempBlob, '//ram:ApplicableHeaderTradeSettlement/ram:SpecifiedTradeAllowanceCharge'), + StrSubstNo(IncorrectValueErr, 'SpecifiedTradeAllowanceCharge count')); + end; + #endregion + + local procedure AssertExpectedDialogError(ExpectedErrorText: Text) + begin + Assert.ExpectedError(ExpectedErrorText); + Assert.ExpectedErrorCode(DialogErrorCodeTok); + end; + local procedure Initialize() begin LibraryTestInitialize.OnTestInitialize(Codeunit::"Factur-X CII XML Tests"); @@ -1027,6 +1812,10 @@ codeunit 148148 "Factur-X CII XML Tests" CompanyInformation.Modify(true); SetupGeneralLedger(); + CreatePostingSetupFixture(); + + LibrarySetupStorage.SaveCompanyInformation(); + LibrarySetupStorage.SaveGeneralLedgerSetup(); IsInitialized := true; Commit(); @@ -1042,6 +1831,85 @@ codeunit 148148 "Factur-X CII XML Tests" exit(LibrarySales.PostSalesDocument(SalesHeader, true, true)); end; + local procedure CreateAndPostSalesInvoiceForCustomer(CustomerNo: Code[20]): Code[20] + var + Customer: Record Customer; + GLAccount: Record "G/L Account"; + SalesHeader: Record "Sales Header"; + SalesLine: Record "Sales Line"; + SalesReceivablesSetup: Record "Sales & Receivables Setup"; + begin + LibraryUtility.UpdateSetupNoSeriesCode( + Database::"Sales & Receivables Setup", SalesReceivablesSetup.FieldNo("Invoice Nos.")); + LibraryUtility.UpdateSetupNoSeriesCode( + Database::"Sales & Receivables Setup", SalesReceivablesSetup.FieldNo("Posted Invoice Nos.")); + GLAccount.Get(LibraryERM.CreateGLAccountWithSalesSetup()); + Customer.Get(CustomerNo); + Customer.Validate("Gen. Bus. Posting Group", GLAccount."Gen. Bus. Posting Group"); + Customer.Validate("VAT Bus. Posting Group", GLAccount."VAT Bus. Posting Group"); + Customer.Modify(true); + LibrarySales.CreateSalesHeader(SalesHeader, "Sales Document Type"::Invoice, CustomerNo); + LibrarySales.CreateSalesLine(SalesLine, SalesHeader, SalesLine.Type::"G/L Account", GLAccount."No.", 1); + SalesLine.Validate("Unit Price", 100); + SalesLine.Modify(true); + exit(LibrarySales.PostSalesDocument(SalesHeader, true, true)); + end; + + local procedure CreateAndPostSalesInvoiceWithBillingModeLines(IncludeGLAccountLine: Boolean): Code[20] + var + Customer: Record Customer; + GeneralPostingSetup: Record "General Posting Setup"; + GLAccount: Record "G/L Account"; + Item: Record Item; + Location: Record Location; + SalesHeader: Record "Sales Header"; + SalesLine: Record "Sales Line"; + SalesReceivablesSetup: Record "Sales & Receivables Setup"; + CustomerNo: Code[20]; + begin + LibraryUtility.UpdateSetupNoSeriesCode( + Database::"Sales & Receivables Setup", SalesReceivablesSetup.FieldNo("Invoice Nos.")); + LibraryUtility.UpdateSetupNoSeriesCode( + Database::"Sales & Receivables Setup", SalesReceivablesSetup.FieldNo("Posted Invoice Nos.")); + GLAccount.Get(LibraryERM.CreateGLAccountWithSalesSetup()); + GeneralPostingSetup.Get(GLAccount."Gen. Bus. Posting Group", GLAccount."Gen. Prod. Posting Group"); + if GeneralPostingSetup."COGS Account" = '' then + GeneralPostingSetup.Validate("COGS Account", LibraryERM.CreateGLAccountNo()); + if GeneralPostingSetup."Inventory Adjmt. Account" = '' then + GeneralPostingSetup.Validate("Inventory Adjmt. Account", LibraryERM.CreateGLAccountNo()); + GeneralPostingSetup.Modify(true); + CustomerNo := CreateCustomer(''); + Customer.Get(CustomerNo); + Customer.Validate("Gen. Bus. Posting Group", GLAccount."Gen. Bus. Posting Group"); + Customer.Validate("VAT Bus. Posting Group", GLAccount."VAT Bus. Posting Group"); + Customer.Modify(true); + Item.Get(LibraryInventory.CreateItemNoWithPostingSetup( + GLAccount."Gen. Prod. Posting Group", GLAccount."VAT Prod. Posting Group")); + LibraryInventory.UpdateInventoryPostingSetup(Location, Item."Inventory Posting Group"); + LibrarySales.CreateSalesHeader(SalesHeader, "Sales Document Type"::Invoice, CustomerNo); + LibrarySales.CreateSalesLine(SalesLine, SalesHeader, SalesLine.Type::Item, Item."No.", 1); + SalesLine.Validate("Unit Price", 100); + SalesLine.Modify(true); + if IncludeGLAccountLine then begin + LibrarySales.CreateSalesLine(SalesLine, SalesHeader, SalesLine.Type::"G/L Account", GLAccount."No.", 1); + SalesLine.Validate("Unit Price", 100); + SalesLine.Modify(true); + end; + exit(LibrarySales.PostSalesDocument(SalesHeader, true, true)); + end; + + local procedure CreateAndPostSalesInvoiceWithComment(): Code[20] + var + SalesHeader: Record "Sales Header"; + SalesLine: Record "Sales Line"; + begin + SalesHeader.Get("Sales Document Type"::Invoice, CreateSalesDocumentWithLine("Sales Document Type"::Invoice, '')); + LibrarySales.CreateSalesLine(SalesLine, SalesHeader, SalesLine.Type::" ", '', 0); + SalesLine.Validate(Description, 'Comment'); + SalesLine.Modify(true); + exit(LibrarySales.PostSalesDocument(SalesHeader, true, true)); + end; + local procedure CreateAndPostSalesInvoiceWithElecAddress(FRElecAddress: Text[250]): Code[20] var SalesHeader: Record "Sales Header"; @@ -1068,7 +1936,34 @@ codeunit 148148 "Factur-X CII XML Tests" exit(LibrarySales.PostSalesDocument(SalesHeader, true, true)); end; + local procedure CreateAndPostSalesCreditMemo(SalesInvoiceHeader: Record "Sales Invoice Header"): Code[20] + var + Customer: Record Customer; + GLAccount: Record "G/L Account"; + SalesHeader: Record "Sales Header"; + SalesLine: Record "Sales Line"; + begin + GLAccount.Get(LibraryERM.CreateGLAccountWithSalesSetup()); + Customer.Get(SalesInvoiceHeader."Sell-to Customer No."); + Customer.Validate("Gen. Bus. Posting Group", GLAccount."Gen. Bus. Posting Group"); + Customer.Validate("VAT Bus. Posting Group", GLAccount."VAT Bus. Posting Group"); + Customer.Modify(true); + LibrarySales.CreateSalesHeader(SalesHeader, "Sales Document Type"::"Credit Memo", Customer."No."); + SalesHeader.Validate("Applies-to Doc. Type", SalesHeader."Applies-to Doc. Type"::Invoice); + SalesHeader.Validate("Applies-to Doc. No.", SalesInvoiceHeader."No."); + SalesHeader.Modify(true); + LibrarySales.CreateSalesLine(SalesLine, SalesHeader, SalesLine.Type::"G/L Account", GLAccount."No.", 1); + SalesLine.Validate("Unit Price", 100); + SalesLine.Modify(true); + exit(LibrarySales.PostSalesDocument(SalesHeader, true, true)); + end; + local procedure CreateSalesDocumentWithLine(DocType: Enum "Sales Document Type"; FRElecAddress: Text[250]): Code[20] + begin + exit(CreateSalesDocumentWithLine(DocType, FRElecAddress, '')); + end; + + local procedure CreateSalesDocumentWithLine(DocType: Enum "Sales Document Type"; FRElecAddress: Text[250]; CurrencyCode: Code[10]): Code[20] var Customer: Record Customer; GLAccount: Record "G/L Account"; @@ -1092,6 +1987,10 @@ codeunit 148148 "Factur-X CII XML Tests" Customer.Validate("VAT Bus. Posting Group", GLAccount."VAT Bus. Posting Group"); Customer.Modify(true); LibrarySales.CreateSalesHeader(SalesHeader, DocType, CustomerNo); + if CurrencyCode <> '' then begin + SalesHeader.Validate("Currency Code", CurrencyCode); + SalesHeader.Modify(true); + end; LibrarySales.CreateSalesLine(SalesLine, SalesHeader, SalesLine.Type::"G/L Account", GLAccount."No.", 1); SalesLine.Validate("Unit Price", 100); SalesLine.Validate("Unit of Measure Code", GetUnitOfMeasureCode()); @@ -1099,28 +1998,161 @@ codeunit 148148 "Factur-X CII XML Tests" exit(SalesHeader."No."); end; + local procedure CreateAndPostMultiVATInvoiceWithDiscount(ApplyInvoiceDiscount: Boolean): Code[20] + var + Customer: Record Customer; + CustInvoiceDisc: Record "Cust. Invoice Disc."; + GLAccount: Record "G/L Account"; + FirstVATPostingSetup: Record "VAT Posting Setup"; + SecondVATPostingSetup: Record "VAT Posting Setup"; + SalesHeader: Record "Sales Header"; + SalesLine: Record "Sales Line"; + SalesReceivablesSetup: Record "Sales & Receivables Setup"; + CustomerNo: Code[20]; + begin + CustomerNo := CreateCustomer(''); + LibraryUtility.UpdateSetupNoSeriesCode( + DATABASE::"Sales & Receivables Setup", SalesReceivablesSetup.FieldNo("Invoice Nos.")); + LibraryUtility.UpdateSetupNoSeriesCode( + DATABASE::"Sales & Receivables Setup", SalesReceivablesSetup.FieldNo("Posted Invoice Nos.")); + GLAccount.Get(LibraryERM.CreateGLAccountWithSalesSetup()); + LibraryERM.CreateVATPostingSetupWithAccounts(FirstVATPostingSetup, FirstVATPostingSetup."VAT Calculation Type"::"Normal VAT", 20); + GLAccount.Validate("VAT Prod. Posting Group", FirstVATPostingSetup."VAT Prod. Posting Group"); + GLAccount.Modify(true); + Customer.Get(CustomerNo); + Customer.Validate("Gen. Bus. Posting Group", GLAccount."Gen. Bus. Posting Group"); + Customer.Validate("VAT Bus. Posting Group", FirstVATPostingSetup."VAT Bus. Posting Group"); + Customer.Modify(true); + + if ApplyInvoiceDiscount then begin + LibraryERM.CreateInvDiscForCustomer(CustInvoiceDisc, CustomerNo, '', 0); + CustInvoiceDisc.Validate("Discount %", 10); + CustInvoiceDisc.Modify(true); + end; + + LibrarySales.CreateSalesHeader(SalesHeader, "Sales Document Type"::Invoice, CustomerNo); + LibrarySales.CreateSalesLine(SalesLine, SalesHeader, SalesLine.Type::"G/L Account", GLAccount."No.", 1); + SalesLine.Validate("Unit Price", 200); + SalesLine.Validate("Allow Invoice Disc.", true); + SalesLine.Validate("Unit of Measure Code", GetUnitOfMeasureCode()); + SalesLine.Modify(true); + + LibraryERM.CreateVATPostingSetupWithAccounts(SecondVATPostingSetup, SecondVATPostingSetup."VAT Calculation Type"::"Normal VAT", 10); + SecondVATPostingSetup.Rename(Customer."VAT Bus. Posting Group", SecondVATPostingSetup."VAT Prod. Posting Group"); + LibrarySales.CreateSalesLine(SalesLine, SalesHeader, SalesLine.Type::"G/L Account", GLAccount."No.", 1); + SalesLine.Validate("VAT Prod. Posting Group", SecondVATPostingSetup."VAT Prod. Posting Group"); + SalesLine.Validate("Unit Price", 300); + SalesLine.Validate("Allow Invoice Disc.", true); + SalesLine.Validate("Unit of Measure Code", GetUnitOfMeasureCode()); + SalesLine.Modify(true); + + if ApplyInvoiceDiscount then + LibrarySales.CalcSalesDiscount(SalesHeader); + + exit(LibrarySales.PostSalesDocument(SalesHeader, true, true)); + end; + + local procedure CreateAndPostSingleVATInvoiceWithDiscount(): Code[20] + var + Customer: Record Customer; + CustInvoiceDisc: Record "Cust. Invoice Disc."; + GLAccount: Record "G/L Account"; + VATPostingSetup: Record "VAT Posting Setup"; + SalesHeader: Record "Sales Header"; + SalesLine: Record "Sales Line"; + SalesReceivablesSetup: Record "Sales & Receivables Setup"; + CustomerNo: Code[20]; + begin + CustomerNo := CreateCustomer(''); + LibraryUtility.UpdateSetupNoSeriesCode( + DATABASE::"Sales & Receivables Setup", SalesReceivablesSetup.FieldNo("Invoice Nos.")); + LibraryUtility.UpdateSetupNoSeriesCode( + DATABASE::"Sales & Receivables Setup", SalesReceivablesSetup.FieldNo("Posted Invoice Nos.")); + GLAccount.Get(LibraryERM.CreateGLAccountWithSalesSetup()); + EnsureSalesInvoiceDiscountAccount(GLAccount."Gen. Bus. Posting Group", GLAccount."Gen. Prod. Posting Group"); + LibraryERM.CreateVATPostingSetupWithAccounts(VATPostingSetup, VATPostingSetup."VAT Calculation Type"::"Normal VAT", 20); + GLAccount.Validate("VAT Prod. Posting Group", VATPostingSetup."VAT Prod. Posting Group"); + GLAccount.Modify(true); + Customer.Get(CustomerNo); + Customer.Validate("Gen. Bus. Posting Group", GLAccount."Gen. Bus. Posting Group"); + Customer.Validate("VAT Bus. Posting Group", VATPostingSetup."VAT Bus. Posting Group"); + Customer.Modify(true); + + LibraryERM.CreateInvDiscForCustomer(CustInvoiceDisc, CustomerNo, '', 0); + CustInvoiceDisc.Validate("Discount %", 10); + CustInvoiceDisc.Modify(true); + + LibrarySales.CreateSalesHeader(SalesHeader, "Sales Document Type"::Invoice, CustomerNo); + LibrarySales.CreateSalesLine(SalesLine, SalesHeader, SalesLine.Type::"G/L Account", GLAccount."No.", 1); + SalesLine.Validate("Unit Price", 500); + SalesLine.Validate("Allow Invoice Disc.", true); + SalesLine.Validate("Unit of Measure Code", GetUnitOfMeasureCode()); + SalesLine.Modify(true); + + LibrarySales.CalcSalesDiscount(SalesHeader); + exit(LibrarySales.PostSalesDocument(SalesHeader, true, true)); + end; + + local procedure EnsureSalesInvoiceDiscountAccount(GenBusPostingGroup: Code[20]; GenProdPostingGroup: Code[20]) + var + GeneralPostingSetup: Record "General Posting Setup"; + begin + GeneralPostingSetup.Get(GenBusPostingGroup, GenProdPostingGroup); + if GeneralPostingSetup."Sales Inv. Disc. Account" <> '' then + exit; + + GeneralPostingSetup.Validate("Sales Inv. Disc. Account", LibraryERM.CreateGLAccountNo()); + GeneralPostingSetup.Modify(true); + end; + + local procedure GetPostedInvoiceAmountsByVATRate(DocumentNo: Code[20]; VATRate: Decimal; var BasisAmount: Decimal; var CalculatedAmount: Decimal) + var + SalesInvoiceLine: Record "Sales Invoice Line"; + begin + SalesInvoiceLine.SetRange("Document No.", DocumentNo); + SalesInvoiceLine.SetFilter(Type, '<>%1', SalesInvoiceLine.Type::" "); + SalesInvoiceLine.SetLoadFields("VAT %", Amount, "Amount Including VAT"); + if SalesInvoiceLine.FindSet() then + repeat + if SalesInvoiceLine."VAT %" = VATRate then begin + BasisAmount += SalesInvoiceLine.Amount; + CalculatedAmount += SalesInvoiceLine."Amount Including VAT" - SalesInvoiceLine.Amount; + end; + until SalesInvoiceLine.Next() = 0; + end; + local procedure CreateCustomer(FRElecAddress: Text[250]): Code[20] var Customer: Record Customer; begin LibrarySales.CreateCustomer(Customer); - if Customer."Country/Region Code" = '' then - Customer.Validate("Country/Region Code", CompanyInformation."Country/Region Code"); - Customer.Validate("VAT Registration No.", GetNextCustomerVATRegistrationNo()); + Customer.Validate("Country/Region Code", CompanyInformation."Country/Region Code"); + Customer."VAT Registration No." := LibraryERM.GenerateVATRegistrationNo('FR'); + Customer."Registration Number" := '123456789'; Customer.Validate("FR Electronic Address", FRElecAddress); Customer.Modify(true); exit(Customer."No."); end; - local procedure GetNextCustomerVATRegistrationNo(): Text[20] + local procedure CreateCustomerWithoutIdentifiers(): Code[20] var - VATNoBody: Text[11]; - SequenceText: Text; + Customer: Record Customer; begin - CustomerVATNoSequence += 1; - SequenceText := Format(CustomerVATNoSequence); - VATNoBody := CopyStr(PadStr('', 11 - StrLen(SequenceText), '0') + SequenceText, 1, 11); - exit('FR' + VATNoBody); + LibrarySales.CreateCustomer(Customer); + Customer.Validate("Country/Region Code", CompanyInformation."Country/Region Code"); + Customer."FR Electronic Address" := ''; + Customer."FR Elec. Address Scheme" := Customer."FR Elec. Address Scheme"::" "; + Customer."VAT Registration No." := ''; + Customer."Registration Number" := ''; + Customer.Modify(true); + exit(Customer."No."); + end; + + local procedure CheckFacturX(var SourceDocumentHeader: RecordRef) + var + EDocumentService: Record "E-Document Service"; + begin + FacturXFormat.Check(SourceDocumentHeader, EDocumentService, "E-Document Processing Phase"::Create); end; local procedure CreateSalesInvoiceCIIXML(var TempBlob: Codeunit "Temp Blob") @@ -1238,11 +2270,37 @@ codeunit 148148 "Factur-X CII XML Tests" exit(''); end; + local procedure GetCIINodeCount(var TempBlob: Codeunit "Temp Blob"; XPath: Text): Integer + var + XmlDoc: XmlDocument; + NamespaceMgr: XmlNamespaceManager; + Nodes: XmlNodeList; + InStr: InStream; + begin + TempBlob.CreateInStream(InStr, TextEncoding::UTF8); + XmlDocument.ReadFrom(InStr, XmlDoc); + BuildNamespaceManager(XmlDoc, NamespaceMgr); + + XmlDoc.SelectNodes(XPath, NamespaceMgr, Nodes); + exit(Nodes.Count()); + end; + + local procedure GetCIINodeDecimalValue(var TempBlob: Codeunit "Temp Blob"; XPath: Text): Decimal + var + NodeText: Text; + Result: Decimal; + begin + NodeText := GetCIINodeValue(TempBlob, XPath); + Evaluate(Result, NodeText, 9); + exit(Result); + end; + local procedure BuildNamespaceManager(XmlDoc: XmlDocument; var NamespaceMgr: XmlNamespaceManager) begin NamespaceMgr.NameTable(XmlDoc.NameTable()); NamespaceMgr.AddNamespace('rsm', 'urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100'); NamespaceMgr.AddNamespace('ram', 'urn:un:unece:uncefact:data:standard:ReusableAggregateBusinessInformationEntity:100'); + NamespaceMgr.AddNamespace('qdt', 'urn:un:unece:uncefact:data:standard:QualifiedDataType:100'); NamespaceMgr.AddNamespace('udt', 'urn:un:unece:uncefact:data:standard:UnqualifiedDataType:100'); end; @@ -1257,6 +2315,69 @@ codeunit 148148 "Factur-X CII XML Tests" end; end; + local procedure CreatePostingSetupFixture() + var + GenBusinessPostingGroup: Record "Gen. Business Posting Group"; + GenProductPostingGroup: Record "Gen. Product Posting Group"; + GeneralPostingSetup: Record "General Posting Setup"; + VATBusinessPostingGroup: Record "VAT Business Posting Group"; + VATProductPostingGroup: Record "VAT Product Posting Group"; + VATPostingSetup: Record "VAT Posting Setup"; + PostingGroupCode: Code[20]; + begin + PostingGroupCode := '0FRFACTURX'; + + if VATPostingSetup.Get(PostingGroupCode, PostingGroupCode) then + exit; + + if not GenBusinessPostingGroup.Get(PostingGroupCode) then begin + GenBusinessPostingGroup.Code := PostingGroupCode; + GenBusinessPostingGroup.Insert(true); + end; + if not GenProductPostingGroup.Get(PostingGroupCode) then begin + GenProductPostingGroup.Code := PostingGroupCode; + GenProductPostingGroup.Insert(true); + end; + + if not GeneralPostingSetup.Get(PostingGroupCode, PostingGroupCode) then begin + GeneralPostingSetup."Gen. Bus. Posting Group" := PostingGroupCode; + GeneralPostingSetup."Gen. Prod. Posting Group" := PostingGroupCode; + GeneralPostingSetup.Insert(true); + end; + GeneralPostingSetup.Validate("Sales Account", LibraryERM.CreateGLAccountNo()); + GeneralPostingSetup.Validate("Sales Credit Memo Account", LibraryERM.CreateGLAccountNo()); + GeneralPostingSetup.Validate("Sales Prepayments Account", LibraryERM.CreateGLAccountNo()); + GeneralPostingSetup.Validate("Purch. Account", LibraryERM.CreateGLAccountNo()); + GeneralPostingSetup.Validate("Purch. Credit Memo Account", LibraryERM.CreateGLAccountNo()); + GeneralPostingSetup.Validate("Purch. Prepayments Account", LibraryERM.CreateGLAccountNo()); + GeneralPostingSetup.Validate("COGS Account", LibraryERM.CreateGLAccountNo()); + GeneralPostingSetup.Validate("COGS Account (Interim)", LibraryERM.CreateGLAccountNo()); + GeneralPostingSetup.Validate("Inventory Adjmt. Account", LibraryERM.CreateGLAccountNo()); + GeneralPostingSetup.Validate("Direct Cost Applied Account", LibraryERM.CreateGLAccountNo()); + GeneralPostingSetup.Validate("Overhead Applied Account", LibraryERM.CreateGLAccountNo()); + GeneralPostingSetup.Validate("Purchase Variance Account", LibraryERM.CreateGLAccountNo()); + GeneralPostingSetup.Modify(true); + + if not VATBusinessPostingGroup.Get(PostingGroupCode) then begin + VATBusinessPostingGroup.Code := PostingGroupCode; + VATBusinessPostingGroup.Insert(true); + end; + if not VATProductPostingGroup.Get(PostingGroupCode) then begin + VATProductPostingGroup.Code := PostingGroupCode; + VATProductPostingGroup.Insert(true); + end; + + VATPostingSetup."VAT Bus. Posting Group" := PostingGroupCode; + VATPostingSetup."VAT Prod. Posting Group" := PostingGroupCode; + VATPostingSetup.Insert(true); + VATPostingSetup.Validate("VAT Calculation Type", VATPostingSetup."VAT Calculation Type"::"Normal VAT"); + VATPostingSetup.Validate("VAT %", 20); + VATPostingSetup.Validate("Tax Category", 'S'); + VATPostingSetup.Validate("Sales VAT Account", LibraryERM.CreateGLAccountNo()); + VATPostingSetup.Validate("Purchase VAT Account", LibraryERM.CreateGLAccountNo()); + VATPostingSetup.Modify(true); + end; + local procedure GetUnitOfMeasureCode(): Code[10] var UnitOfMeasure: Record "Unit of Measure"; diff --git a/src/Apps/W1/EDocument/App/Permissions/EDocCoreObjects.PermissionSet.al b/src/Apps/W1/EDocument/App/Permissions/EDocCoreObjects.PermissionSet.al index dc5fa50a5c5..bf976ecd725 100644 --- a/src/Apps/W1/EDocument/App/Permissions/EDocCoreObjects.PermissionSet.al +++ b/src/Apps/W1/EDocument/App/Permissions/EDocCoreObjects.PermissionSet.al @@ -56,6 +56,8 @@ permissionset 6100 "E-Doc. Core - Objects" table "ED Purchase Line Field Setup" = X, table "E-Doc Sample Purch. Inv File" = X, table "E-Document Message" = X, + table "E-Doc. Payment Occurrence" = X, + table "E-Doc. External Reference" = X, #if not CLEAN28 #pragma warning disable AL0432 table "EDoc Historical Matching Setup" = X, @@ -104,9 +106,12 @@ permissionset 6100 "E-Doc. Core - Objects" #endif codeunit "E-Doc. Attachment Processor" = X, codeunit "E-Doc. Hist. Line Data Loader" = X, + codeunit "E-Document Message API" = X, codeunit "E-Doc. Message Context" = X, codeunit "E-Doc. Message Mgt." = X, + codeunit "E-Doc. Message Send Job" = X, codeunit "E-Doc. Msg. Transport Default" = X, + codeunit "E-Doc. Payment Occurrence Mgt." = X, codeunit "Service Participant" = X, page "E-Doc. Changes Part" = X, page "E-Doc. Changes Preview" = X, diff --git a/src/Apps/W1/EDocument/App/Permissions/EDocCoreRead.PermissionSet.al b/src/Apps/W1/EDocument/App/Permissions/EDocCoreRead.PermissionSet.al index 9273943723d..db121936686 100644 --- a/src/Apps/W1/EDocument/App/Permissions/EDocCoreRead.PermissionSet.al +++ b/src/Apps/W1/EDocument/App/Permissions/EDocCoreRead.PermissionSet.al @@ -36,6 +36,8 @@ permissionset 6101 "E-Doc. Core - Read" tabledata "E-Document Service Status" = R, tabledata "E-Document Integration Log" = R, tabledata "E-Document Message" = R, + tabledata "E-Doc. Payment Occurrence" = R, + tabledata "E-Doc. External Reference" = R, #endregion Logging tabledata "E-Doc. Imported Line" = R, tabledata "E-Doc. Order Match" = R, diff --git a/src/Apps/W1/EDocument/App/Permissions/EDocCoreUser.PermissionSet.al b/src/Apps/W1/EDocument/App/Permissions/EDocCoreUser.PermissionSet.al index 604fba02221..6cc4d4dc41f 100644 --- a/src/Apps/W1/EDocument/App/Permissions/EDocCoreUser.PermissionSet.al +++ b/src/Apps/W1/EDocument/App/Permissions/EDocCoreUser.PermissionSet.al @@ -42,6 +42,8 @@ permissionset 6105 "E-Doc. Core - User" tabledata "E-Doc. Data Storage" = imd, tabledata "E-Document Integration Log" = imd, tabledata "E-Document Message" = imd, + tabledata "E-Doc. Payment Occurrence" = imd, + tabledata "E-Doc. External Reference" = imd, #endregion Logging tabledata "E-Doc. Imported Line" = IMD, tabledata "E-Doc. Order Match" = IMD, diff --git a/src/Apps/W1/EDocument/App/src/Integration/Interfaces/IMessageSender.Interface.al b/src/Apps/W1/EDocument/App/src/Integration/Interfaces/IMessageSender.Interface.al index 0d58c12091a..3f446ebd4a0 100644 --- a/src/Apps/W1/EDocument/App/src/Integration/Interfaces/IMessageSender.Interface.al +++ b/src/Apps/W1/EDocument/App/src/Integration/Interfaces/IMessageSender.Interface.al @@ -7,7 +7,17 @@ namespace Microsoft.eServices.EDocument.Integration.Interfaces; using Microsoft.eServices.EDocument; using Microsoft.eServices.EDocument.Processing.Message; +/// +/// Sends a child message associated with an E-Document through a service integration. +/// interface IMessageSender { + /// + /// Sends the payload in the message context. + /// + /// The parent E-Document. + /// The service used to send the message. + /// The message payload, transport state, and result. The implementation must set the result to Sent after successful transmission. + /// The implementation is responsible for obtaining any privacy consent required by the external service before transmitting data. procedure SendMessage(var EDocument: Record "E-Document"; var EDocumentService: Record "E-Document Service"; MessageContext: Codeunit "E-Doc. Message Context"); } \ No newline at end of file diff --git a/src/Apps/W1/EDocument/App/src/Integration/ServiceIntegration.Enum.al b/src/Apps/W1/EDocument/App/src/Integration/ServiceIntegration.Enum.al index 3049d5902d7..f84d85f9812 100644 --- a/src/Apps/W1/EDocument/App/src/Integration/ServiceIntegration.Enum.al +++ b/src/Apps/W1/EDocument/App/src/Integration/ServiceIntegration.Enum.al @@ -14,6 +14,7 @@ enum 6151 "Service Integration" implements IDocumentSender, IDocumentReceiver, I Access = Public; DefaultImplementation = IConsentManager = "Consent Manager Default Impl.", IMessageSender = "E-Doc. Msg. Transport Default"; + UnknownValueImplementation = IMessageSender = "E-Doc. Msg. Transport Default"; value(0; "No Integration") { diff --git a/src/Apps/W1/EDocument/App/src/Processing/EDocumentBackgroundJobs.Codeunit.al b/src/Apps/W1/EDocument/App/src/Processing/EDocumentBackgroundJobs.Codeunit.al index c00103cff7c..0ffe24b98c7 100644 --- a/src/Apps/W1/EDocument/App/src/Processing/EDocumentBackgroundJobs.Codeunit.al +++ b/src/Apps/W1/EDocument/App/src/Processing/EDocumentBackgroundJobs.Codeunit.al @@ -4,6 +4,7 @@ // ------------------------------------------------------------------------------------------------ namespace Microsoft.eServices.EDocument; +using Microsoft.eServices.EDocument.Processing.Message; using System.Telemetry; using System.Threading; @@ -21,6 +22,11 @@ codeunit 6133 "E-Document Background Jobs" EDocument.Modify(); end; + procedure ScheduleMessageSend(EDocumentMessage: Record "E-Document Message") + begin + ScheduleEDocumentJob(Codeunit::"E-Doc. Message Send Job", EDocumentMessage.RecordId(), 0); + end; + procedure ScheduleGetResponseJob() begin ScheduleGetResponseJob(true); diff --git a/src/Apps/W1/EDocument/App/src/Processing/Message/EDocExternalReference.Table.al b/src/Apps/W1/EDocument/App/src/Processing/Message/EDocExternalReference.Table.al new file mode 100644 index 00000000000..d2ea41a0d11 --- /dev/null +++ b/src/Apps/W1/EDocument/App/src/Processing/Message/EDocExternalReference.Table.al @@ -0,0 +1,61 @@ +// ------------------------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// ------------------------------------------------------------------------------------------------ +namespace Microsoft.eServices.EDocument.Processing.Message; + +using Microsoft.eServices.EDocument; + +table 6434 "E-Doc. External Reference" +{ + Access = Internal; + Caption = 'E-Document External Reference'; + DataClassification = CustomerContent; + InherentEntitlements = RIMDX; + InherentPermissions = RIMDX; + ReplicateData = false; + + fields + { + field(1; "Entry No."; Integer) + { + AutoIncrement = true; + Caption = 'Entry No.'; + DataClassification = SystemMetadata; + } + field(2; Service; Code[20]) + { + Caption = 'Service'; + DataClassification = SystemMetadata; + TableRelation = "E-Document Service"; + } + field(3; "External Document ID"; Text[250]) + { + Caption = 'External Document ID'; + DataClassification = CustomerContent; + } + field(4; "E-Document Entry No."; Integer) + { + Caption = 'E-Document Entry No.'; + DataClassification = SystemMetadata; + TableRelation = "E-Document"."Entry No"; + } + field(5; "Created At"; DateTime) + { + Caption = 'Created At'; + DataClassification = SystemMetadata; + } + } + + keys + { + key(PK; "Entry No.") + { + Clustered = true; + } + key(ExternalDocument; Service, "External Document ID") + { + Unique = true; + } + } +} \ No newline at end of file diff --git a/src/Apps/W1/EDocument/App/src/Processing/Message/EDocMessageContext.Codeunit.al b/src/Apps/W1/EDocument/App/src/Processing/Message/EDocMessageContext.Codeunit.al index 3d052c68d72..a6f93a5f472 100644 --- a/src/Apps/W1/EDocument/App/src/Processing/Message/EDocMessageContext.Codeunit.al +++ b/src/Apps/W1/EDocument/App/src/Processing/Message/EDocMessageContext.Codeunit.al @@ -22,31 +22,55 @@ codeunit 6533 "E-Doc. Message Context" Payload := TempBlob; end; + /// + /// Gets the entry number of the child E-Document message being sent. + /// + /// The E-Document message entry number. procedure GetMessageEntryNo(): Integer begin exit(MessageEntryNo); end; + /// + /// Gets the semantic type of the child E-Document message. + /// + /// The E-Document message type. procedure GetMessageType(): Enum "E-Document Message Type" begin exit(MessageType); end; + /// + /// Gets the response type represented by the child message. + /// + /// The E-Document response type. procedure GetResponseType(): Enum "E-Doc. Response Type" begin exit(ResponseType); end; + /// + /// Gets the message payload. + /// + /// A temporary blob containing the message payload. procedure GetTempBlob(): Codeunit "Temp Blob" begin exit(Payload); end; + /// + /// Gets the HTTP state used to record the connector request and response. + /// + /// The HTTP message state. procedure Http(): Codeunit "Http Message State" begin exit(HttpMessageState); end; + /// + /// Gets the transport result. A connector must set the status to Sent after successful transmission. + /// + /// The integration action status. procedure Status(): Codeunit "Integration Action Status" begin exit(IntegrationActionStatus); diff --git a/src/Apps/W1/EDocument/App/src/Processing/Message/EDocMessageMgt.Codeunit.al b/src/Apps/W1/EDocument/App/src/Processing/Message/EDocMessageMgt.Codeunit.al index 915431b9d44..dac5d2a0222 100644 --- a/src/Apps/W1/EDocument/App/src/Processing/Message/EDocMessageMgt.Codeunit.al +++ b/src/Apps/W1/EDocument/App/src/Processing/Message/EDocMessageMgt.Codeunit.al @@ -92,7 +92,8 @@ codeunit 6433 "E-Doc. Message Mgt." begin EDocMessage.Get(MessageEntryNo); EDocMessage.TestField(Direction, EDocMessage.Direction::Outgoing); - EDocMessage.TestField(Status, EDocMessage.Status::Created); + if not (EDocMessage.Status in [EDocMessage.Status::Created, EDocMessage.Status::Queued, EDocMessage.Status::Error]) then + EDocMessage.FieldError(Status); EDocMessage.TestField(Service); EDocument.Get(EDocMessage."E-Document Entry No."); @@ -102,7 +103,6 @@ codeunit 6433 "E-Doc. Message Mgt." Error(MessagePayloadErr, MessageEntryNo); EDocMessageContext.Initialize(EDocMessage, TempBlob); - EDocMessageContext.Status().SetStatus("E-Document Service Status"::Sent); MessageSender := EDocumentService."Service Integration V2"; MessageSender.SendMessage(EDocument, EDocumentService, EDocMessageContext); if EDocMessageContext.Status().GetStatus() <> "E-Document Service Status"::Sent then @@ -111,7 +111,89 @@ codeunit 6433 "E-Doc. Message Mgt." EDocumentLog.InsertIntegrationLog( EDocument, EDocumentService, EDocMessageContext.Http().GetHttpRequestMessage(), EDocMessageContext.Http().GetHttpResponseMessage()); EDocMessage.Status := EDocMessage.Status::Sent; + EDocMessage."Last Attempt At" := CurrentDateTime(); + Clear(EDocMessage."Last Error"); + EDocMessage.Modify(); + end; + + procedure QueueMessage(MessageEntryNo: Integer) + var + EDocMessage: Record "E-Document Message"; + EDocumentBackgroundJobs: Codeunit "E-Document Background Jobs"; + begin + EDocMessage.Get(MessageEntryNo); + EDocMessage.TestField(Direction, EDocMessage.Direction::Outgoing); + EDocMessage.TestField(Status, EDocMessage.Status::Created); + EDocMessage.TestField(Service); + + EDocMessage.Status := EDocMessage.Status::Queued; + EDocMessage.Modify(); + EDocumentBackgroundJobs.ScheduleMessageSend(EDocMessage); + end; + + procedure RegisterExternalDocumentReference(EDocument: Record "E-Document"; ServiceCode: Code[20]; ExternalDocumentID: Text[250]) + var + EDocExternalReference: Record "E-Doc. External Reference"; + EDocumentService: Record "E-Document Service"; + begin + EDocument.Get(EDocument."Entry No"); + EDocument.TestField(Service, ServiceCode); + EDocumentService.Get(ServiceCode); + if ExternalDocumentID = '' then + Error(ExternalDocumentIDRequiredErr); + + EDocExternalReference.SetRange(Service, ServiceCode); + EDocExternalReference.SetRange("External Document ID", ExternalDocumentID); + if EDocExternalReference.FindFirst() then begin + if EDocExternalReference."E-Document Entry No." = EDocument."Entry No" then + exit; + Error(ExternalDocumentIDConflictErr, ExternalDocumentID, ServiceCode); + end; + + EDocExternalReference.Init(); + EDocExternalReference.Service := ServiceCode; + EDocExternalReference."External Document ID" := ExternalDocumentID; + EDocExternalReference."E-Document Entry No." := EDocument."Entry No"; + EDocExternalReference."Created At" := CurrentDateTime(); + EDocExternalReference.Insert(); + end; + + procedure CreateIncomingMessage(ServiceCode: Code[20]; ExternalDocumentID: Text[250]; ExternalMessageID: Text[250]; MessageType: Enum "E-Document Message Type"; ResponseType: Enum "E-Doc. Response Type"; ReceivedAt: DateTime; var TempBlob: Codeunit "Temp Blob"): Integer + var + EDocument: Record "E-Document"; + EDocExternalReference: Record "E-Doc. External Reference"; + EDocMessage: Record "E-Document Message"; + MessageEntryNo: Integer; + begin + if ExternalDocumentID = '' then + Error(ExternalDocumentIDRequiredErr); + if ExternalMessageID = '' then + Error(ExternalMessageIDRequiredErr); + if not TempBlob.HasValue() then + Error(IncomingMessagePayloadRequiredErr); + + EDocMessage.SetRange(Service, ServiceCode); + EDocMessage.SetRange("External Message ID", ExternalMessageID); + if EDocMessage.FindFirst() then + exit(EDocMessage."Entry No."); + + EDocExternalReference.SetRange(Service, ServiceCode); + EDocExternalReference.SetRange("External Document ID", ExternalDocumentID); + if not EDocExternalReference.FindFirst() then + Error(ExternalDocumentNotFoundErr, ExternalDocumentID, ServiceCode); + + EDocument.Get(EDocExternalReference."E-Document Entry No."); + MessageEntryNo := CreateMessage(EDocument, MessageType, "E-Document Direction"::Incoming, ResponseType, TempBlob); + EDocMessage.Get(MessageEntryNo); + EDocMessage.Status := EDocMessage.Status::Received; + EDocMessage."External Message ID" := ExternalMessageID; + EDocMessage."External Document ID" := ExternalDocumentID; + if ReceivedAt = 0DT then + EDocMessage."Received At" := CurrentDateTime() + else + EDocMessage."Received At" := ReceivedAt; EDocMessage.Modify(); + exit(MessageEntryNo); end; local procedure InsertDataStorage(TempBlob: Codeunit "Temp Blob"): Integer @@ -135,4 +217,9 @@ codeunit 6433 "E-Doc. Message Mgt." var MessagePayloadErr: Label 'E-Document message %1 does not contain a payload.', Comment = '%1 = E-Document message entry number'; MessageSendingErr: Label 'E-Document message %1 could not be sent. The integration returned status %2.', Comment = '%1 = E-Document message entry number, %2 = integration status'; + ExternalDocumentIDRequiredErr: Label 'An external document ID is required.'; + ExternalMessageIDRequiredErr: Label 'An external message ID is required.'; + IncomingMessagePayloadRequiredErr: Label 'An incoming E-Document message payload is required.'; + ExternalDocumentIDConflictErr: Label 'External document ID %1 is already associated with another E-Document for service %2.', Comment = '%1 = external document ID, %2 = service code'; + ExternalDocumentNotFoundErr: Label 'External document ID %1 is not registered for E-Document service %2.', Comment = '%1 = external document ID, %2 = service code'; } diff --git a/src/Apps/W1/EDocument/App/src/Processing/Message/EDocMessageSendJob.Codeunit.al b/src/Apps/W1/EDocument/App/src/Processing/Message/EDocMessageSendJob.Codeunit.al new file mode 100644 index 00000000000..8d43c26cb34 --- /dev/null +++ b/src/Apps/W1/EDocument/App/src/Processing/Message/EDocMessageSendJob.Codeunit.al @@ -0,0 +1,46 @@ +// ------------------------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// ------------------------------------------------------------------------------------------------ +namespace Microsoft.eServices.EDocument.Processing.Message; + +using System.Threading; + +codeunit 6535 "E-Doc. Message Send Job" +{ + Access = Internal; + TableNo = "Job Queue Entry"; + InherentEntitlements = X; + InherentPermissions = X; + + trigger OnRun() + var + EDocumentMessage: Record "E-Document Message"; + LastErrorText: Text; + begin + EDocumentMessage.Get(Rec."Record ID to Process"); + if TrySendMessage(EDocumentMessage."Entry No.") then + exit; + + LastErrorText := GetLastErrorText(); + EDocumentMessage.Get(EDocumentMessage."Entry No."); + EDocumentMessage.Status := EDocumentMessage.Status::Error; + EDocumentMessage."Last Attempt At" := CurrentDateTime(); + EDocumentMessage."Retry Count" += 1; + EDocumentMessage."Last Error" := CopyStr(LastErrorText, 1, MaxStrLen(EDocumentMessage."Last Error")); + EDocumentMessage.Modify(); + Commit(); + Error(MessageSendFailedErr, EDocumentMessage."Entry No.", LastErrorText); + end; + + [TryFunction] + local procedure TrySendMessage(MessageEntryNo: Integer) + var + EDocMessageMgt: Codeunit "E-Doc. Message Mgt."; + begin + EDocMessageMgt.SendMessage(MessageEntryNo); + end; + + var + MessageSendFailedErr: Label 'E-Document message %1 could not be sent. %2', Comment = '%1 = message entry number, %2 = connector error'; +} \ No newline at end of file diff --git a/src/Apps/W1/EDocument/App/src/Processing/Message/EDocMessageStatus.Enum.al b/src/Apps/W1/EDocument/App/src/Processing/Message/EDocMessageStatus.Enum.al index 62646edc3ba..fdca39dd3b8 100644 --- a/src/Apps/W1/EDocument/App/src/Processing/Message/EDocMessageStatus.Enum.al +++ b/src/Apps/W1/EDocument/App/src/Processing/Message/EDocMessageStatus.Enum.al @@ -19,4 +19,16 @@ enum 6429 "E-Doc. Message Status" { Caption = 'Sent'; } + value(2; Queued) + { + Caption = 'Queued'; + } + value(3; Error) + { + Caption = 'Error'; + } + value(4; Received) + { + Caption = 'Received'; + } } diff --git a/src/Apps/W1/EDocument/App/src/Processing/Message/EDocPaymentOccurrence.Table.al b/src/Apps/W1/EDocument/App/src/Processing/Message/EDocPaymentOccurrence.Table.al new file mode 100644 index 00000000000..7a143a77010 --- /dev/null +++ b/src/Apps/W1/EDocument/App/src/Processing/Message/EDocPaymentOccurrence.Table.al @@ -0,0 +1,94 @@ +// ------------------------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// ------------------------------------------------------------------------------------------------ +namespace Microsoft.eServices.EDocument.Processing.Message; + +using Microsoft.eServices.EDocument; + +/// +/// Stores an immutable payment application or reversal associated with an outgoing E-Document. +/// +table 6433 "E-Doc. Payment Occurrence" +{ + Access = Public; + Caption = 'E-Document Payment Occurrence'; + DataClassification = CustomerContent; + InherentEntitlements = RIMDX; + InherentPermissions = RIMDX; + ReplicateData = false; + + fields + { + field(1; "Entry No."; Integer) + { + AutoIncrement = true; + Caption = 'Entry No.'; + DataClassification = SystemMetadata; + } + field(2; "E-Document Entry No."; Integer) + { + Caption = 'E-Document Entry No.'; + DataClassification = SystemMetadata; + TableRelation = "E-Document"."Entry No"; + } + field(3; Type; Enum "E-Doc. Payment Occurrence Type") + { + Caption = 'Type'; + DataClassification = SystemMetadata; + } + field(4; "Source Occurrence ID"; Guid) + { + Caption = 'Source Occurrence ID'; + DataClassification = SystemMetadata; + } + field(5; "Original Occurrence Entry No."; Integer) + { + Caption = 'Original Occurrence Entry No.'; + DataClassification = SystemMetadata; + TableRelation = "E-Doc. Payment Occurrence"."Entry No."; + } + field(6; Amount; Decimal) + { + AutoFormatExpression = Rec."Currency Code"; + AutoFormatType = 1; + Caption = 'Amount'; + DataClassification = CustomerContent; + } + field(7; "Currency Code"; Code[10]) + { + Caption = 'Currency Code'; + DataClassification = CustomerContent; + } + field(8; "Event Date"; Date) + { + Caption = 'Event Date'; + DataClassification = CustomerContent; + } + field(9; "Detailed Ledger Entry No."; Integer) + { + Caption = 'Detailed Ledger Entry No.'; + DataClassification = SystemMetadata; + } + field(10; "Created At"; DateTime) + { + Caption = 'Created At'; + DataClassification = SystemMetadata; + } + } + + keys + { + key(PK; "Entry No.") + { + Clustered = true; + } + key(Occurrence; "E-Document Entry No.", "Source Occurrence ID", Type) + { + Unique = true; + } + key(Source; "Source Occurrence ID", Type) + { + } + } +} \ No newline at end of file diff --git a/src/Apps/W1/EDocument/App/src/Processing/Message/EDocPaymentOccurrenceMgt.Codeunit.al b/src/Apps/W1/EDocument/App/src/Processing/Message/EDocPaymentOccurrenceMgt.Codeunit.al new file mode 100644 index 00000000000..2114fec8103 --- /dev/null +++ b/src/Apps/W1/EDocument/App/src/Processing/Message/EDocPaymentOccurrenceMgt.Codeunit.al @@ -0,0 +1,154 @@ +// ------------------------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// ------------------------------------------------------------------------------------------------ +namespace Microsoft.eServices.EDocument.Processing.Message; + +using Microsoft.eServices.EDocument; +using Microsoft.Finance.GeneralLedger.Journal; +using Microsoft.Finance.GeneralLedger.Posting; +using Microsoft.Finance.ReceivablesPayables; +using Microsoft.Sales.Customer; +using Microsoft.Sales.History; +using Microsoft.Sales.Receivables; + +/// +/// Captures payment applications and reversals for outgoing E-Documents and publishes them to localization apps. +/// +codeunit 6536 "E-Doc. Payment Occurrence Mgt." +{ + Access = Public; + InherentEntitlements = X; + InherentPermissions = X; + + Permissions = + tabledata "Cust. Ledger Entry" = r, + tabledata "Detailed Cust. Ledg. Entry" = r, + tabledata "E-Document" = r, + tabledata "E-Doc. Payment Occurrence" = rim, + tabledata "Sales Invoice Header" = r; + + [EventSubscriber(ObjectType::Codeunit, Codeunit::"Gen. Jnl.-Post Line", 'OnAfterInsertDtldCustLedgEntry', '', false, false)] + local procedure OnAfterInsertDtldCustLedgEntry(var DtldCustLedgEntry: Record "Detailed Cust. Ledg. Entry"; GenJournalLine: Record "Gen. Journal Line"; DtldCVLedgEntryBuffer: Record "Detailed CV Ledg. Entry Buffer"; Offset: Integer) + begin + ProcessApplication(DtldCustLedgEntry); + end; + + [EventSubscriber(ObjectType::Codeunit, Codeunit::"Gen. Jnl.-Post Line", 'OnAfterInsertDtldCustLedgEntryUnapply', '', false, false)] + local procedure OnAfterInsertDtldCustLedgEntryUnapply(var CustomerPostingGroup: Record "Customer Posting Group"; var OldDetailedCustLedgEntry: Record "Detailed Cust. Ledg. Entry"; var GenJnlLine: Record "Gen. Journal Line"; var NewDetailedCustLedgEntry: Record "Detailed Cust. Ledg. Entry") + begin + ProcessUnapplication(OldDetailedCustLedgEntry, NewDetailedCustLedgEntry); + end; + + /// + /// Captures an invoice payment application for every matching outgoing E-Document. + /// + /// The detailed customer ledger application entry. + procedure ProcessApplication(DetailedCustLedgEntry: Record "Detailed Cust. Ledg. Entry") + var + EDocument: Record "E-Document"; + InvoiceCustLedgerEntry: Record "Cust. Ledger Entry"; + PaymentCustLedgerEntry: Record "Cust. Ledger Entry"; + begin + if not IsInvoiceApplication(DetailedCustLedgEntry) then + exit; + if not InvoiceCustLedgerEntry.Get(DetailedCustLedgEntry."Cust. Ledger Entry No.") then + exit; + if not PaymentCustLedgerEntry.Get(DetailedCustLedgEntry."Applied Cust. Ledger Entry No.") then + exit; + if PaymentCustLedgerEntry."Document Type" <> PaymentCustLedgerEntry."Document Type"::Payment then + exit; + if not FindInvoiceEDocuments(EDocument, InvoiceCustLedgerEntry) then + exit; + + repeat + CreateOccurrence( + EDocument, "E-Doc. Payment Occurrence Type"::Applied, DetailedCustLedgEntry.SystemId, + -DetailedCustLedgEntry.Amount, DetailedCustLedgEntry."Currency Code", DetailedCustLedgEntry."Posting Date", + DetailedCustLedgEntry."Entry No.", 0); + until EDocument.Next() = 0; + end; + + /// + /// Captures the reversal of each payment occurrence created from the original application entry. + /// + /// The original detailed customer ledger application entry. + /// The detailed customer ledger reversal entry. + procedure ProcessUnapplication(OldDetailedCustLedgEntry: Record "Detailed Cust. Ledg. Entry"; NewDetailedCustLedgEntry: Record "Detailed Cust. Ledg. Entry") + var + AppliedOccurrence: Record "E-Doc. Payment Occurrence"; + EDocument: Record "E-Document"; + begin + if not IsInvoiceApplication(OldDetailedCustLedgEntry) then + exit; + + AppliedOccurrence.SetRange("Source Occurrence ID", OldDetailedCustLedgEntry.SystemId); + AppliedOccurrence.SetRange(Type, AppliedOccurrence.Type::Applied); + if not AppliedOccurrence.FindSet() then + exit; + + repeat + EDocument.Get(AppliedOccurrence."E-Document Entry No."); + CreateOccurrence( + EDocument, "E-Doc. Payment Occurrence Type"::Reversed, NewDetailedCustLedgEntry.SystemId, + -AppliedOccurrence.Amount, AppliedOccurrence."Currency Code", NewDetailedCustLedgEntry."Posting Date", + NewDetailedCustLedgEntry."Entry No.", AppliedOccurrence."Entry No."); + until AppliedOccurrence.Next() = 0; + end; + + local procedure CreateOccurrence(EDocument: Record "E-Document"; OccurrenceType: Enum "E-Doc. Payment Occurrence Type"; SourceOccurrenceID: Guid; Amount: Decimal; CurrencyCode: Code[10]; EventDate: Date; DetailedLedgerEntryNo: Integer; OriginalOccurrenceEntryNo: Integer) + var + EDocPaymentOccurrence: Record "E-Doc. Payment Occurrence"; + begin + EDocPaymentOccurrence.SetRange("E-Document Entry No.", EDocument."Entry No"); + EDocPaymentOccurrence.SetRange("Source Occurrence ID", SourceOccurrenceID); + EDocPaymentOccurrence.SetRange(Type, OccurrenceType); + if not EDocPaymentOccurrence.IsEmpty() then + exit; + + EDocPaymentOccurrence.Init(); + EDocPaymentOccurrence."E-Document Entry No." := EDocument."Entry No"; + EDocPaymentOccurrence.Type := OccurrenceType; + EDocPaymentOccurrence."Source Occurrence ID" := SourceOccurrenceID; + EDocPaymentOccurrence."Original Occurrence Entry No." := OriginalOccurrenceEntryNo; + EDocPaymentOccurrence.Amount := Amount; + EDocPaymentOccurrence."Currency Code" := CurrencyCode; + EDocPaymentOccurrence."Event Date" := EventDate; + EDocPaymentOccurrence."Detailed Ledger Entry No." := DetailedLedgerEntryNo; + EDocPaymentOccurrence."Created At" := CurrentDateTime(); + EDocPaymentOccurrence.Insert(); + OnAfterCreatePaymentOccurrence(EDocPaymentOccurrence); + end; + + local procedure FindInvoiceEDocuments(var EDocument: Record "E-Document"; InvoiceCustLedgerEntry: Record "Cust. Ledger Entry"): Boolean + var + SalesInvoiceHeader: Record "Sales Invoice Header"; + begin + if InvoiceCustLedgerEntry."Document Type" <> InvoiceCustLedgerEntry."Document Type"::Invoice then + exit(false); + if not SalesInvoiceHeader.Get(InvoiceCustLedgerEntry."Document No.") then + exit(false); + + EDocument.SetRange("Document Record ID", SalesInvoiceHeader.RecordId); + EDocument.SetRange(Direction, EDocument.Direction::Outgoing); + EDocument.SetRange("Document Type", EDocument."Document Type"::"Sales Invoice"); + exit(EDocument.FindSet()); + end; + + local procedure IsInvoiceApplication(DetailedCustLedgEntry: Record "Detailed Cust. Ledg. Entry"): Boolean + begin + exit( + (DetailedCustLedgEntry."Entry Type" = DetailedCustLedgEntry."Entry Type"::Application) and + (DetailedCustLedgEntry."Initial Document Type" = DetailedCustLedgEntry."Initial Document Type"::Invoice) and + (DetailedCustLedgEntry.Amount < 0)); + end; + + /// + /// Notifies localization and format apps after a payment occurrence has been persisted. + /// + /// The persisted payment occurrence. + [IntegrationEvent(false, false)] + procedure OnAfterCreatePaymentOccurrence(var EDocPaymentOccurrence: Record "E-Doc. Payment Occurrence") + begin + end; +} \ No newline at end of file diff --git a/src/Apps/W1/EDocument/App/src/Processing/Message/EDocPaymentOccurrenceType.Enum.al b/src/Apps/W1/EDocument/App/src/Processing/Message/EDocPaymentOccurrenceType.Enum.al new file mode 100644 index 00000000000..0800eda61c8 --- /dev/null +++ b/src/Apps/W1/EDocument/App/src/Processing/Message/EDocPaymentOccurrenceType.Enum.al @@ -0,0 +1,23 @@ +// ------------------------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// ------------------------------------------------------------------------------------------------ +namespace Microsoft.eServices.EDocument.Processing.Message; + +/// +/// Identifies whether an E-Document payment occurrence applies or reverses an amount. +/// +enum 6430 "E-Doc. Payment Occurrence Type" +{ + Access = Public; + Extensible = false; + + value(0; Applied) + { + Caption = 'Applied'; + } + value(1; Reversed) + { + Caption = 'Reversed'; + } +} \ No newline at end of file diff --git a/src/Apps/W1/EDocument/App/src/Processing/Message/EDocumentMessage.Table.al b/src/Apps/W1/EDocument/App/src/Processing/Message/EDocumentMessage.Table.al index 3c7186aa371..e8381145d14 100644 --- a/src/Apps/W1/EDocument/App/src/Processing/Message/EDocumentMessage.Table.al +++ b/src/Apps/W1/EDocument/App/src/Processing/Message/EDocumentMessage.Table.al @@ -69,6 +69,36 @@ table 6432 "E-Document Message" TableRelation = "E-Document Service"; DataClassification = SystemMetadata; } + field(10; "Last Attempt At"; DateTime) + { + Caption = 'Last Attempt At'; + DataClassification = SystemMetadata; + } + field(11; "Retry Count"; Integer) + { + Caption = 'Retry Count'; + DataClassification = SystemMetadata; + } + field(12; "Last Error"; Text[2048]) + { + Caption = 'Last Error'; + DataClassification = CustomerContent; + } + field(13; "External Message ID"; Text[250]) + { + Caption = 'External Message ID'; + DataClassification = CustomerContent; + } + field(14; "External Document ID"; Text[250]) + { + Caption = 'External Document ID'; + DataClassification = CustomerContent; + } + field(15; "Received At"; DateTime) + { + Caption = 'Received At'; + DataClassification = SystemMetadata; + } } keys @@ -80,5 +110,8 @@ table 6432 "E-Document Message" key(EDocument; "E-Document Entry No.") { } + key(ExternalMessage; Service, "External Message ID") + { + } } } diff --git a/src/Apps/W1/EDocument/App/src/Processing/Message/EDocumentMessageAPI.Codeunit.al b/src/Apps/W1/EDocument/App/src/Processing/Message/EDocumentMessageAPI.Codeunit.al index 17ca7915849..06b7174d684 100644 --- a/src/Apps/W1/EDocument/App/src/Processing/Message/EDocumentMessageAPI.Codeunit.al +++ b/src/Apps/W1/EDocument/App/src/Processing/Message/EDocumentMessageAPI.Codeunit.al @@ -12,6 +12,14 @@ codeunit 6532 "E-Document Message API" Access = Public; InherentEntitlements = X; + /// + /// Creates an outgoing child message for an E-Document and stores its payload. + /// + /// The parent E-Document. + /// The semantic message type. + /// The response represented by the message. + /// The message payload. + /// The entry number of the created E-Document message. procedure CreateMessage(EDocument: Record "E-Document"; MessageType: Enum "E-Document Message Type"; ResponseType: Enum "E-Doc. Response Type"; var TempBlob: Codeunit "Temp Blob"): Integer var EDocMessageMgt: Codeunit "E-Doc. Message Mgt."; @@ -19,10 +27,56 @@ codeunit 6532 "E-Document Message API" exit(EDocMessageMgt.CreateMessage(EDocument, MessageType, EDocument.Direction::Outgoing, ResponseType, TempBlob)); end; + /// + /// Sends a previously created outgoing E-Document message through its E-Document service. + /// + /// The entry number of the E-Document message to send. procedure SendMessage(MessageEntryNo: Integer) var EDocMessageMgt: Codeunit "E-Doc. Message Mgt."; begin EDocMessageMgt.SendMessage(MessageEntryNo); end; + + /// + /// Queues a previously created outgoing E-Document message for background transmission. + /// + /// The entry number of the E-Document message to queue. + procedure QueueMessage(MessageEntryNo: Integer) + var + EDocMessageMgt: Codeunit "E-Doc. Message Mgt."; + begin + EDocMessageMgt.QueueMessage(MessageEntryNo); + end; + + /// + /// Associates an external service document identifier with an E-Document for later message correlation. + /// + /// The E-Document known by the external service. + /// The E-Document service that issued the identifier. + /// The service-specific document identifier. + procedure RegisterExternalDocumentReference(EDocument: Record "E-Document"; ServiceCode: Code[20]; ExternalDocumentID: Text[250]) + var + EDocMessageMgt: Codeunit "E-Doc. Message Mgt."; + begin + EDocMessageMgt.RegisterExternalDocumentReference(EDocument, ServiceCode, ExternalDocumentID); + end; + + /// + /// Stores an incoming child message and correlates it to an E-Document by service-specific identifiers. + /// + /// The service from which the message was received. + /// The external identifier of the parent document. + /// The external identifier used to deduplicate the message. + /// The semantic message type. + /// The response represented by the message. + /// The source timestamp, or zero to use the current date and time. + /// The original message payload. + /// The entry number of the new or previously stored E-Document message. + procedure CreateIncomingMessage(ServiceCode: Code[20]; ExternalDocumentID: Text[250]; ExternalMessageID: Text[250]; MessageType: Enum "E-Document Message Type"; ResponseType: Enum "E-Doc. Response Type"; ReceivedAt: DateTime; var TempBlob: Codeunit "Temp Blob"): Integer + var + EDocMessageMgt: Codeunit "E-Doc. Message Mgt."; + begin + exit(EDocMessageMgt.CreateIncomingMessage(ServiceCode, ExternalDocumentID, ExternalMessageID, MessageType, ResponseType, ReceivedAt, TempBlob)); + end; } diff --git a/src/Apps/W1/EDocument/App/src/Processing/Message/EDocumentMessagesFactBox.Page.al b/src/Apps/W1/EDocument/App/src/Processing/Message/EDocumentMessagesFactBox.Page.al index a44e49516c1..3a3ef8576a8 100644 --- a/src/Apps/W1/EDocument/App/src/Processing/Message/EDocumentMessagesFactBox.Page.al +++ b/src/Apps/W1/EDocument/App/src/Processing/Message/EDocumentMessagesFactBox.Page.al @@ -53,6 +53,31 @@ page 6434 "E-Document Messages FactBox" ApplicationArea = Basic, Suite; ToolTip = 'Specifies when the message was created.'; } + field("Last Attempt At"; Rec."Last Attempt At") + { + ApplicationArea = Basic, Suite; + ToolTip = 'Specifies when the service last attempted to send the message.'; + } + field("Retry Count"; Rec."Retry Count") + { + ApplicationArea = Basic, Suite; + ToolTip = 'Specifies how many background send attempts have failed.'; + } + field("Last Error"; Rec."Last Error") + { + ApplicationArea = Basic, Suite; + ToolTip = 'Specifies the error returned by the most recent failed send attempt.'; + } + field("External Message ID"; Rec."External Message ID") + { + ApplicationArea = Basic, Suite; + ToolTip = 'Specifies the identifier assigned to the message by the external service.'; + } + field("Received At"; Rec."Received At") + { + ApplicationArea = Basic, Suite; + ToolTip = 'Specifies when the external service created or delivered the incoming message.'; + } } } } From 161edf48683a08c14cfb6fa1efae123df238dd84 Mon Sep 17 00:00:00 2001 From: djukicmilica Date: Thu, 20 Aug 2026 16:35:00 +0200 Subject: [PATCH 04/23] new changes --- .../app/src/Core/FREInvoiceMessage.Table.al | 13 + .../src/Core/FREInvoiceMessageAPI.Codeunit.al | 154 ++++++++ .../Core/FREInvoiceMessageBuilder.Codeunit.al | 65 +++- .../src/Core/FREInvoiceMessageMgt.Codeunit.al | 46 ++- .../app/src/Core/FREInvoiceMessages.Page.al | 10 + .../EReportingEDocuments.PageExt.al | 16 + .../Extensions/FREDocResponseType.EnumExt.al | 4 + .../src/EDocFRStructImportTests.Codeunit.al | 8 + ...nit.al => FREDocMsgSenderMock.Codeunit.al} | 0 .../src/FREInvoiceMessageTests.Codeunit.al | 360 +++++++++++++++++- .../test/src/FacturXCIIXMLTests.Codeunit.al | 59 ++- .../test/src/PEPPOLBIS30XMLTests.Codeunit.al | 4 +- .../Message/EDocMessageMgt.Codeunit.al | 32 ++ ...al => EDocMsgTransportDefault.Codeunit.al} | 0 .../Message/EDocPaymentOccurrenceType.Enum.al | 2 +- .../Message/EDocumentMessageAPI.Codeunit.al | 48 +++ .../EDocRemitAdviceExport.Codeunit.al | 2 +- .../EDocRemitAdviceJournal.ReportExt.al | 8 +- 18 files changed, 757 insertions(+), 74 deletions(-) create mode 100644 src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/FREInvoiceMessageAPI.Codeunit.al rename src/Apps/FR/EDocument_FR/EReportingFR/test/src/{FREDocMessageSenderMock.Codeunit.al => FREDocMsgSenderMock.Codeunit.al} (100%) rename src/Apps/W1/EDocument/App/src/Processing/Message/{EDocMessageTransportDefault.Codeunit.al => EDocMsgTransportDefault.Codeunit.al} (100%) diff --git a/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/FREInvoiceMessage.Table.al b/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/FREInvoiceMessage.Table.al index 3c0058c7fbf..64911991e0c 100644 --- a/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/FREInvoiceMessage.Table.al +++ b/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/FREInvoiceMessage.Table.al @@ -86,6 +86,16 @@ table 10970 "FR E-Invoice Message" Caption = 'Created At'; DataClassification = SystemMetadata; } + field(14; "External Message ID"; Text[250]) + { + Caption = 'External Message ID'; + DataClassification = CustomerContent; + } + field(15; "Received At"; DateTime) + { + Caption = 'Received At'; + DataClassification = SystemMetadata; + } } keys @@ -101,5 +111,8 @@ table 10970 "FR E-Invoice Message" key(DetailedLedgerEntry; Type, "Detailed Ledger Entry No.") { } + key(EDocumentMessage; "E-Document Message Entry No.") + { + } } } \ No newline at end of file diff --git a/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/FREInvoiceMessageAPI.Codeunit.al b/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/FREInvoiceMessageAPI.Codeunit.al new file mode 100644 index 00000000000..38aaa194bd6 --- /dev/null +++ b/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/FREInvoiceMessageAPI.Codeunit.al @@ -0,0 +1,154 @@ +// ------------------------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// ------------------------------------------------------------------------------------------------ +namespace Microsoft.eServices.EDocument.Formats; + +using Microsoft.eServices.EDocument; +using Microsoft.eServices.EDocument.Processing.Message; +using System.Utilities; + +codeunit 10987 "FR E-Invoice Message API" +{ + Access = Public; + InherentEntitlements = X; + InherentPermissions = X; + + /// + /// Receives, validates, and stores a French invoice lifecycle message from an E-Document service. + /// + /// The E-Document service that received the message. + /// The service-specific identifier registered for the parent E-Document. + /// The service-specific message identifier used for deduplication. + /// The source timestamp, or zero to use the current date and time. + /// The original lifecycle XML payload. + /// The entry number of the normalized French invoice message. + procedure ReceiveMessage(ServiceCode: Code[20]; ExternalDocumentID: Text[250]; ExternalMessageID: Text[250]; ReceivedAt: DateTime; var TempBlob: Codeunit "Temp Blob"): Integer + var + EDocument: Record "E-Document"; + FREInvoiceMessage: Record "FR E-Invoice Message"; + EDocumentMessageAPI: Codeunit "E-Document Message API"; + MessageType: Enum "FR E-Invoice Message Type"; + ResponseType: Enum "E-Doc. Response Type"; + InvoiceID: Text; + ReasonCode: Text; + ReasonDescription: Text; + MessageEntryNo: Integer; + begin + ParseMessage(TempBlob, InvoiceID, MessageType, ReasonCode, ReasonDescription); + ResponseType := GetResponseType(MessageType); + MessageEntryNo := EDocumentMessageAPI.CreateIncomingMessage( + ServiceCode, ExternalDocumentID, ExternalMessageID, "E-Document Message Type"::"FR Invoice Lifecycle", + ResponseType, ReceivedAt, TempBlob); + + FREInvoiceMessage.SetRange("E-Document Message Entry No.", MessageEntryNo); + if FREInvoiceMessage.FindFirst() then + exit(FREInvoiceMessage."Entry No."); + + EDocumentMessageAPI.GetMessageEDocument(MessageEntryNo, EDocument); + EDocument.TestField(Direction, EDocument.Direction::Outgoing); + if EDocument."Document No." <> InvoiceID then + Error(InvoiceMismatchErr, InvoiceID, EDocument."Document No."); + + FREInvoiceMessage.Init(); + FREInvoiceMessage."E-Document Entry No." := EDocument."Entry No"; + FREInvoiceMessage.Type := MessageType; + FREInvoiceMessage."Source Occurrence ID" := CreateGuid(); + FREInvoiceMessage."Reason Code" := CopyStr(ReasonCode, 1, MaxStrLen(FREInvoiceMessage."Reason Code")); + FREInvoiceMessage."Reason Description" := CopyStr(ReasonDescription, 1, MaxStrLen(FREInvoiceMessage."Reason Description")); + FREInvoiceMessage."E-Document Message Entry No." := MessageEntryNo; + FREInvoiceMessage."External Message ID" := ExternalMessageID; + if ReceivedAt = 0DT then + FREInvoiceMessage."Received At" := CurrentDateTime() + else + FREInvoiceMessage."Received At" := ReceivedAt; + FREInvoiceMessage."Event Date" := DT2Date(FREInvoiceMessage."Received At"); + FREInvoiceMessage."Created At" := CurrentDateTime(); + FREInvoiceMessage.Insert(); + exit(FREInvoiceMessage."Entry No."); + end; + + local procedure ParseMessage(TempBlob: Codeunit "Temp Blob"; var InvoiceID: Text; var MessageType: Enum "FR E-Invoice Message Type"; var ReasonCode: Text; var ReasonDescription: Text) + var + XmlDoc: XmlDocument; + InStream: InStream; + StatusText: Text; + begin + TempBlob.CreateInStream(InStream, TextEncoding::UTF8); + if not XmlDocument.ReadFrom(InStream, XmlDoc) then + Error(InvalidXmlErr); + + InvoiceID := GetRequiredNodeText(XmlDoc, '//*[local-name()="InvoiceID" or local-name()="IssuerAssignedID"]', InvoiceIDErr); + StatusText := GetRequiredNodeText(XmlDoc, '//*[local-name()="ProcessConditionCode" or local-name()="ProcessCondition" or local-name()="Status"]', StatusErr); + MessageType := MapMessageType(StatusText); + ReasonCode := GetOptionalNodeText(XmlDoc, '//*[local-name()="ReasonCode"]'); + ReasonDescription := GetOptionalNodeText(XmlDoc, '//*[local-name()="Reason" or local-name()="ReasonDescription"]'); + + if MessageType = MessageType::"Technical Rejected" then begin + if ReasonCode = '' then + Error(RejectedReasonCodeErr); + if ReasonDescription = '' then + Error(RejectedReasonDescriptionErr); + end; + end; + + local procedure MapMessageType(StatusText: Text): Enum "FR E-Invoice Message Type" + var + MessageType: Enum "FR E-Invoice Message Type"; + begin + case UpperCase(StatusText.Trim()) of + '200', 'SUBMITTED', 'DÉPOSÉE', 'DEPOSEE': + exit(MessageType::Submitted); + '205', 'ACCEPTED', 'ACCEPTÉE', 'ACCEPTEE', 'APPROUVÉE', 'APPROUVEE': + exit(MessageType::Accepted); + 'REJECTED', 'TECHNICAL REJECTED', 'REJETÉE', 'REJETEE': + exit(MessageType::"Technical Rejected"); + '210', 'REFUSED', 'REFUSÉE', 'REFUSEE': + exit(MessageType::Refused); + else + Error(UnsupportedStatusErr, StatusText); + end; + end; + + local procedure GetResponseType(MessageType: Enum "FR E-Invoice Message Type"): Enum "E-Doc. Response Type" + begin + case MessageType of + MessageType::Submitted: + exit("E-Doc. Response Type"::Submitted); + MessageType::Accepted: + exit("E-Doc. Response Type"::Accepted); + MessageType::"Technical Rejected": + exit("E-Doc. Response Type"::Rejected); + MessageType::Refused: + exit("E-Doc. Response Type"::Refused); + else + Error(UnsupportedStatusErr, Format(MessageType)); + end; + end; + + local procedure GetRequiredNodeText(XmlDoc: XmlDocument; XPath: Text; ErrorText: Text): Text + var + XmlNode: XmlNode; + begin + if not XmlDoc.SelectSingleNode(XPath, XmlNode) then + Error(ErrorText); + exit(XmlNode.AsXmlElement().InnerText()); + end; + + local procedure GetOptionalNodeText(XmlDoc: XmlDocument; XPath: Text): Text + var + XmlNode: XmlNode; + begin + if XmlDoc.SelectSingleNode(XPath, XmlNode) then + exit(XmlNode.AsXmlElement().InnerText()); + end; + + var + InvalidXmlErr: Label 'The French invoice lifecycle message is not valid XML.'; + InvoiceIDErr: Label 'The French invoice lifecycle message does not contain an invoice ID.'; + StatusErr: Label 'The French invoice lifecycle message does not contain a status.'; + UnsupportedStatusErr: Label 'French invoice lifecycle status %1 is not supported.', Comment = '%1 = lifecycle status'; + InvoiceMismatchErr: Label 'The lifecycle message invoice ID %1 does not match E-Document invoice %2.', Comment = '%1 = message invoice identifier, %2 = E-Document invoice identifier'; + RejectedReasonCodeErr: Label 'A technical rejection reason code is required.'; + RejectedReasonDescriptionErr: Label 'A technical rejection reason description is required.'; +} \ No newline at end of file diff --git a/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/FREInvoiceMessageBuilder.Codeunit.al b/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/FREInvoiceMessageBuilder.Codeunit.al index 834166558a0..a7def2a5563 100644 --- a/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/FREInvoiceMessageBuilder.Codeunit.al +++ b/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/FREInvoiceMessageBuilder.Codeunit.al @@ -24,34 +24,50 @@ codeunit 10976 "FR E-Invoice Message Builder" AmountElement: XmlElement; OutStream: OutStream; begin + FREInvoiceMessage.TestField("Event Date"); XmlDoc := XmlDocument.Create(); XmlDoc.SetDeclaration(XmlDeclaration.Create('1.0', 'UTF-8', 'no')); RootElement := XmlElement.Create('CrossDomainAcknowledgementAndResponse', RsmNamespaceTok); RootElement.Add(XmlAttribute.CreateNamespaceDeclaration('ram', RamNamespaceTok)); RootElement.Add(XmlAttribute.CreateNamespaceDeclaration('rsm', RsmNamespaceTok)); + RootElement.Add(XmlAttribute.CreateNamespaceDeclaration('udt', UdtNamespaceTok)); RootElement.Add(XmlElement.Create('ExchangedDocument', RsmNamespaceTok, XmlElement.Create('ID', RamNamespaceTok, Format(FREInvoiceMessage."Source Occurrence ID")))); AcknowledgementElement := XmlElement.Create('AcknowledgementDocument', RsmNamespaceTok); + AddIssueDateTime(AcknowledgementElement, FREInvoiceMessage."Event Date"); ReferenceElement := XmlElement.Create('ReferenceReferencedDocument', RamNamespaceTok); ReferenceElement.Add(XmlElement.Create('IssuerAssignedID', RamNamespaceTok, EDocument."Document No.")); ReferenceElement.Add(XmlElement.Create('StatusCode', RamNamespaceTok, InvoiceReferenceStatusCodeTok)); - if FREInvoiceMessage.Type = FREInvoiceMessage.Type::Refused then begin - ReferenceElement.Add(XmlElement.Create('ProcessConditionCode', RamNamespaceTok, RefusedStatusCodeTok)); - ReferenceElement.Add(XmlElement.Create('ProcessCondition', RamNamespaceTok, RefusedStatusNameTok)); - StatusElement := XmlElement.Create('SpecifiedDocumentStatus', RamNamespaceTok); - StatusElement.Add(XmlElement.Create('ReasonCode', RamNamespaceTok, FREInvoiceMessage."Reason Code")); - StatusElement.Add(XmlElement.Create('Reason', RamNamespaceTok, FREInvoiceMessage."Reason Description")); - ReferenceElement.Add(StatusElement); - end else begin - ReferenceElement.Add(XmlElement.Create('ProcessConditionCode', RamNamespaceTok, CollectedStatusCodeTok)); - ReferenceElement.Add(XmlElement.Create('ProcessCondition', RamNamespaceTok, CollectedStatusNameTok)); - StatusElement := XmlElement.Create('SpecifiedDocumentStatus', RamNamespaceTok); - StatusElement.Add(XmlElement.Create('TypeCode', RamNamespaceTok, CollectedAmountTypeCodeTok)); - AmountElement := XmlElement.Create('ValueAmount', RamNamespaceTok, Format(FREInvoiceMessage.Amount, 0, 9)); - AmountElement.Add(XmlAttribute.Create('currencyID', ResolveCurrencyCode(FREInvoiceMessage."Currency Code"))); - StatusElement.Add(AmountElement); - ReferenceElement.Add(StatusElement); + case FREInvoiceMessage.Type of + FREInvoiceMessage.Type::Accepted: + begin + ReferenceElement.Add(XmlElement.Create('ProcessConditionCode', RamNamespaceTok, AcceptedStatusCodeTok)); + ReferenceElement.Add(XmlElement.Create('ProcessCondition', RamNamespaceTok, AcceptedStatusNameTok)); + end; + FREInvoiceMessage.Type::Refused: + begin + ReferenceElement.Add(XmlElement.Create('ProcessConditionCode', RamNamespaceTok, RefusedStatusCodeTok)); + ReferenceElement.Add(XmlElement.Create('ProcessCondition', RamNamespaceTok, RefusedStatusNameTok)); + StatusElement := XmlElement.Create('SpecifiedDocumentStatus', RamNamespaceTok); + StatusElement.Add(XmlElement.Create('ReasonCode', RamNamespaceTok, FREInvoiceMessage."Reason Code")); + StatusElement.Add(XmlElement.Create('Reason', RamNamespaceTok, FREInvoiceMessage."Reason Description")); + ReferenceElement.Add(StatusElement); + end; + FREInvoiceMessage.Type::Collected, + FREInvoiceMessage.Type::"Negative Collected": + begin + ReferenceElement.Add(XmlElement.Create('ProcessConditionCode', RamNamespaceTok, CollectedStatusCodeTok)); + ReferenceElement.Add(XmlElement.Create('ProcessCondition', RamNamespaceTok, CollectedStatusNameTok)); + StatusElement := XmlElement.Create('SpecifiedDocumentStatus', RamNamespaceTok); + StatusElement.Add(XmlElement.Create('TypeCode', RamNamespaceTok, CollectedAmountTypeCodeTok)); + AmountElement := XmlElement.Create('ValueAmount', RamNamespaceTok, Format(FREInvoiceMessage.Amount, 0, 9)); + AmountElement.Add(XmlAttribute.Create('currencyID', ResolveCurrencyCode(FREInvoiceMessage."Currency Code"))); + StatusElement.Add(AmountElement); + ReferenceElement.Add(StatusElement); + end; + else + Error(UnsupportedMessageTypeErr, FREInvoiceMessage.Type); end; AcknowledgementElement.Add(ReferenceElement); RootElement.Add(AcknowledgementElement); @@ -61,6 +77,18 @@ codeunit 10976 "FR E-Invoice Message Builder" XmlDoc.WriteTo(OutStream); end; + local procedure AddIssueDateTime(var AcknowledgementElement: XmlElement; EventDate: Date) + var + DateTimeStringElement: XmlElement; + IssueDateTimeElement: XmlElement; + begin + IssueDateTimeElement := XmlElement.Create('IssueDateTime', RamNamespaceTok); + DateTimeStringElement := XmlElement.Create('DateTimeString', UdtNamespaceTok, Format(EventDate, 0, '000000')); + DateTimeStringElement.Add(XmlAttribute.Create('format', DateTimeFormatCodeTok)); + IssueDateTimeElement.Add(DateTimeStringElement); + AcknowledgementElement.Add(IssueDateTimeElement); + end; + local procedure ResolveCurrencyCode(CurrencyCode: Code[10]): Code[10] var GeneralLedgerSetup: Record "General Ledger Setup"; @@ -75,10 +103,15 @@ codeunit 10976 "FR E-Invoice Message Builder" var RsmNamespaceTok: Label 'urn:un:unece:uncefact:data:standard:CrossDomainAcknowledgementAndResponse:100', Locked = true; RamNamespaceTok: Label 'urn:un:unece:uncefact:data:standard:ReusableAggregateBusinessInformationEntity:100', Locked = true; + UdtNamespaceTok: Label 'urn:un:unece:uncefact:data:standard:UnqualifiedDataType:100', Locked = true; + DateTimeFormatCodeTok: Label '204', Locked = true; InvoiceReferenceStatusCodeTok: Label '47', Locked = true; CollectedStatusCodeTok: Label '212', Locked = true; CollectedStatusNameTok: Label 'Encaissée', Locked = true; CollectedAmountTypeCodeTok: Label 'MEN', Locked = true; RefusedStatusCodeTok: Label '210', Locked = true; RefusedStatusNameTok: Label 'Refusée', Locked = true; + AcceptedStatusCodeTok: Label '205', Locked = true; + AcceptedStatusNameTok: Label 'Acceptée', Locked = true; + UnsupportedMessageTypeErr: Label 'French invoice lifecycle message type %1 cannot be sent.', Comment = '%1 = French invoice lifecycle message type'; } \ No newline at end of file diff --git a/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/FREInvoiceMessageMgt.Codeunit.al b/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/FREInvoiceMessageMgt.Codeunit.al index 6f2045d1eaa..96e662b785b 100644 --- a/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/FREInvoiceMessageMgt.Codeunit.al +++ b/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/FREInvoiceMessageMgt.Codeunit.al @@ -15,24 +15,21 @@ codeunit 10975 "FR E-Invoice Message Mgt." InherentEntitlements = X; InherentPermissions = X; + internal procedure AcceptInvoice(EDocument: Record "E-Document") + begin + CheckBuyerResponseAllowed(EDocument); + CreateAndSendMessage(EDocument, "FR E-Invoice Message Type"::Accepted, CreateGuid(), 0, '', Today(), 0, 0, '', ''); + end; + internal procedure RefuseInvoice(EDocument: Record "E-Document"; ReasonCode: Code[20]; ReasonDescription: Text[500]) - var - FREInvoiceMessage: Record "FR E-Invoice Message"; begin - EDocument.TestField(Direction, EDocument.Direction::Incoming); - EDocument.TestField("Document Type", EDocument."Document Type"::"Purchase Invoice"); - EDocument.TestField(Service); + CheckBuyerResponseAllowed(EDocument); if ReasonCode = '' then Error(ReasonCodeRequiredErr); if ReasonDescription = '' then Error(ReasonDescriptionRequiredErr); - FREInvoiceMessage.SetRange("E-Document Entry No.", EDocument."Entry No"); - FREInvoiceMessage.SetRange(Type, FREInvoiceMessage.Type::Refused); - if not FREInvoiceMessage.IsEmpty() then - Error(AlreadyRefusedErr, EDocument."Document No."); - - CreateAndSendMessage(EDocument, FREInvoiceMessage.Type::Refused, CreateGuid(), 0, '', Today(), 0, 0, ReasonCode, ReasonDescription); + CreateAndSendMessage(EDocument, "FR E-Invoice Message Type"::Refused, CreateGuid(), 0, '', Today(), 0, 0, ReasonCode, ReasonDescription); end; internal procedure ProcessApplication(DetailedCustLedgEntry: Record "Detailed Cust. Ledg. Entry") @@ -130,15 +127,34 @@ codeunit 10975 "FR E-Invoice Message Mgt." exit(EDocumentServiceStatus.Status in [EDocumentServiceStatus.Status::Approved, EDocumentServiceStatus.Status::Cleared]); end; + local procedure CheckBuyerResponseAllowed(EDocument: Record "E-Document") + var + FREInvoiceMessage: Record "FR E-Invoice Message"; + begin + EDocument.TestField(Direction, EDocument.Direction::Incoming); + EDocument.TestField("Document Type", EDocument."Document Type"::"Purchase Invoice"); + EDocument.TestField(Service); + + FREInvoiceMessage.SetRange("E-Document Entry No.", EDocument."Entry No"); + FREInvoiceMessage.SetFilter(Type, '%1|%2', FREInvoiceMessage.Type::Accepted, FREInvoiceMessage.Type::Refused); + if not FREInvoiceMessage.IsEmpty() then + Error(AlreadyRespondedErr, EDocument."Document No."); + end; + local procedure GetResponseType(MessageType: Enum "FR E-Invoice Message Type"): Enum "E-Doc. Response Type" begin - if MessageType = MessageType::Refused then - exit("E-Doc. Response Type"::Refused); - exit("E-Doc. Response Type"::None); + case MessageType of + MessageType::Accepted: + exit("E-Doc. Response Type"::Accepted); + MessageType::Refused: + exit("E-Doc. Response Type"::Refused); + else + exit("E-Doc. Response Type"::None); + end; end; var ReasonCodeRequiredErr: Label 'A refusal reason code is required.'; ReasonDescriptionRequiredErr: Label 'A refusal reason description is required.'; - AlreadyRefusedErr: Label 'Invoice %1 has already been refused.', Comment = '%1 = invoice number'; + AlreadyRespondedErr: Label 'Invoice %1 already has a buyer response.', Comment = '%1 = invoice number'; } \ No newline at end of file diff --git a/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/FREInvoiceMessages.Page.al b/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/FREInvoiceMessages.Page.al index 3816ff3584e..697053d4b3c 100644 --- a/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/FREInvoiceMessages.Page.al +++ b/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/FREInvoiceMessages.Page.al @@ -68,6 +68,16 @@ page 10973 "FR E-Invoice Messages" ApplicationArea = Basic, Suite; ToolTip = 'Specifies the related generic E-Document message entry.'; } + field("External Message ID"; Rec."External Message ID") + { + ApplicationArea = Basic, Suite; + ToolTip = 'Specifies the identifier assigned to an incoming lifecycle message by the external service.'; + } + field("Received At"; Rec."Received At") + { + ApplicationArea = Basic, Suite; + ToolTip = 'Specifies when the incoming lifecycle message was received.'; + } field("Created At"; Rec."Created At") { ApplicationArea = Basic, Suite; diff --git a/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Extensions/EReportingEDocuments.PageExt.al b/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Extensions/EReportingEDocuments.PageExt.al index f89c14b4c33..4e8263157c2 100644 --- a/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Extensions/EReportingEDocuments.PageExt.al +++ b/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Extensions/EReportingEDocuments.PageExt.al @@ -56,6 +56,22 @@ pageextension 10974 "E-Reporting E-Documents" extends "E-Documents" CurrPage.Update(false); end; } + action(AcceptFREInvoice) + { + ApplicationArea = Basic, Suite; + Caption = 'Accept E-Invoice'; + Image = Approve; + ToolTip = 'Accept the incoming French electronic purchase invoice and send the response to the supplier.'; + Visible = (Rec.Direction = Rec.Direction::Incoming) and (Rec."Document Type" = Rec."Document Type"::"Purchase Invoice"); + + trigger OnAction() + var + FREInvoiceMessageMgt: Codeunit "FR E-Invoice Message Mgt."; + begin + FREInvoiceMessageMgt.AcceptInvoice(Rec); + CurrPage.Update(false); + end; + } } } } diff --git a/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Extensions/FREDocResponseType.EnumExt.al b/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Extensions/FREDocResponseType.EnumExt.al index cae8ea1e412..8585e6e06b4 100644 --- a/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Extensions/FREDocResponseType.EnumExt.al +++ b/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Extensions/FREDocResponseType.EnumExt.al @@ -8,6 +8,10 @@ using Microsoft.eServices.EDocument.Processing.Message; enumextension 10974 "FR E-Doc. Response Type" extends "E-Doc. Response Type" { + value(10970; Submitted) + { + Caption = 'Submitted'; + } value(10971; Refused) { Caption = 'Refused'; diff --git a/src/Apps/FR/EDocument_FR/EReportingFR/test/src/EDocFRStructImportTests.Codeunit.al b/src/Apps/FR/EDocument_FR/EReportingFR/test/src/EDocFRStructImportTests.Codeunit.al index af1abb67b3a..4c4842b88ed 100644 --- a/src/Apps/FR/EDocument_FR/EReportingFR/test/src/EDocFRStructImportTests.Codeunit.al +++ b/src/Apps/FR/EDocument_FR/EReportingFR/test/src/EDocFRStructImportTests.Codeunit.al @@ -2,6 +2,14 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. See License.txt in the project root for license information. // ------------------------------------------------------------------------------------------------ +namespace Microsoft.eServices.EDocument.Formats.Test; + +using Microsoft.eServices.EDocument; +using Microsoft.eServices.EDocument.Formats; +using Microsoft.eServices.EDocument.Processing.Import; +using Microsoft.eServices.EDocument.Processing.Import.Purchase; +using System.Utilities; + codeunit 148149 "E-Doc. FR Struct. Import Tests" { Subtype = Test; diff --git a/src/Apps/FR/EDocument_FR/EReportingFR/test/src/FREDocMessageSenderMock.Codeunit.al b/src/Apps/FR/EDocument_FR/EReportingFR/test/src/FREDocMsgSenderMock.Codeunit.al similarity index 100% rename from src/Apps/FR/EDocument_FR/EReportingFR/test/src/FREDocMessageSenderMock.Codeunit.al rename to src/Apps/FR/EDocument_FR/EReportingFR/test/src/FREDocMsgSenderMock.Codeunit.al diff --git a/src/Apps/FR/EDocument_FR/EReportingFR/test/src/FREInvoiceMessageTests.Codeunit.al b/src/Apps/FR/EDocument_FR/EReportingFR/test/src/FREInvoiceMessageTests.Codeunit.al index 6b40e96c8b4..28282ed8a94 100644 --- a/src/Apps/FR/EDocument_FR/EReportingFR/test/src/FREInvoiceMessageTests.Codeunit.al +++ b/src/Apps/FR/EDocument_FR/EReportingFR/test/src/FREInvoiceMessageTests.Codeunit.al @@ -7,12 +7,11 @@ namespace Microsoft.eServices.EDocument.Formats.Test; using Microsoft.eServices.EDocument; using Microsoft.eServices.EDocument.Formats; using Microsoft.eServices.EDocument.Processing.Message; -using Microsoft.Finance.GeneralLedger.Setup; using Microsoft.Sales.History; using Microsoft.Sales.Receivables; using System.Utilities; -codeunit 148152 "FR E-Invoice Message Tests" +codeunit 148151 "FR E-Invoice Message Tests" { Subtype = Test; TestType = IntegrationTest; @@ -58,6 +57,7 @@ codeunit 148152 "FR E-Invoice Message Tests" Assert.AreEqual(1, MessageSenderMock.GetSendCount(), 'One Collected message must be sent.'); Assert.IsTrue(MessageSenderMock.GetLastPayload().Contains('212'), 'The payload must contain status 212.'); Assert.IsTrue(MessageSenderMock.GetLastPayload().Contains('100'), 'The payload must contain the collected amount.'); + Assert.IsTrue(MessageSenderMock.GetLastPayload().Contains(''), 'The payload must contain the AFNOR lifecycle event date.'); end; [Test] @@ -232,6 +232,352 @@ codeunit 148152 "FR E-Invoice Message Tests" Assert.ExpectedError('is not registered'); end; + [Test] + procedure BuyerAcceptanceQueuesAndSendsStatus205() + var + EDocument: Record "E-Document"; + FREInvoiceMessage: Record "FR E-Invoice Message"; + FREInvoiceMessageMgt: Codeunit "FR E-Invoice Message Mgt."; + begin + Initialize(); + CreateIncomingEDocument(EDocument); + + FREInvoiceMessageMgt.AcceptInvoice(EDocument); + + FREInvoiceMessage.SetRange("E-Document Entry No.", EDocument."Entry No"); + FREInvoiceMessage.SetRange(Type, FREInvoiceMessage.Type::Accepted); + Assert.RecordCount(FREInvoiceMessage, 1); + Assert.AreEqual(0, MessageSenderMock.GetSendCount(), 'Acceptance must queue the message without invoking the connector.'); + SendFirstMessage(EDocument, "FR E-Invoice Message Type"::Accepted); + Assert.AreEqual(1, MessageSenderMock.GetSendCount(), 'One Accepted message must be sent.'); + Assert.AreEqual("E-Doc. Response Type"::Accepted, MessageSenderMock.GetLastResponseType(), 'The child message must be Accepted.'); + Assert.IsTrue(MessageSenderMock.GetLastPayload().Contains('205'), 'The payload must contain status 205.'); + end; + + [Test] + procedure BuyerResponseCannotBeRepeated() + var + EDocument: Record "E-Document"; + FREInvoiceMessageMgt: Codeunit "FR E-Invoice Message Mgt."; + begin + Initialize(); + CreateIncomingEDocument(EDocument); + + FREInvoiceMessageMgt.AcceptInvoice(EDocument); + asserterror FREInvoiceMessageMgt.RefuseInvoice(EDocument, 'OTHER', 'Changed my mind.'); + Assert.ExpectedError('already has a buyer response'); + Assert.ExpectedErrorCode('Dialog'); + end; + + [Test] + procedure BuyerResponseCannotBeRepeatedAfterRefusal() + var + EDocument: Record "E-Document"; + FREInvoiceMessageMgt: Codeunit "FR E-Invoice Message Mgt."; + begin + Initialize(); + CreateIncomingEDocument(EDocument); + + FREInvoiceMessageMgt.RefuseInvoice(EDocument, 'OTHER', 'Not accepted.'); + asserterror FREInvoiceMessageMgt.AcceptInvoice(EDocument); + Assert.ExpectedError('already has a buyer response'); + Assert.ExpectedErrorCode('Dialog'); + end; + + [Test] + procedure ReceiveSubmittedPersistsNormalizedMessage() + var + EDocument: Record "E-Document"; + FREInvoiceMessage: Record "FR E-Invoice Message"; + EDocumentMessageAPI: Codeunit "E-Document Message API"; + FREInvoiceMessageAPI: Codeunit "FR E-Invoice Message API"; + TempBlob: Codeunit "Temp Blob"; + OutStream: OutStream; + ExternalDocID: Text[250]; + ExternalMsgID: Text[250]; + ReceivedAt: DateTime; + FREntryNo: Integer; + begin + Initialize(); + CreateOutgoingEDocument(EDocument); + ExternalDocID := CopyStr(Format(CreateGuid()), 1, 250); + ExternalMsgID := CopyStr(Format(CreateGuid()), 1, 250); + ReceivedAt := CreateDateTime(20260101D, 120000T); + EDocumentMessageAPI.RegisterExternalDocumentReference(EDocument, EDocument.Service, ExternalDocID); + TempBlob.CreateOutStream(OutStream, TextEncoding::UTF8); + OutStream.WriteText(BuildLifecycleXml(EDocument."Document No.", 'Submitted', '', '')); + + FREntryNo := FREInvoiceMessageAPI.ReceiveMessage(EDocument.Service, ExternalDocID, ExternalMsgID, ReceivedAt, TempBlob); + + FREInvoiceMessage.Get(FREntryNo); + Assert.AreEqual(FREInvoiceMessage.Type::Submitted, FREInvoiceMessage.Type, 'FR type must be Submitted.'); + Assert.AreEqual(ExternalMsgID, FREInvoiceMessage."External Message ID", 'External message ID must be stored.'); + Assert.AreEqual(ReceivedAt, FREInvoiceMessage."Received At", 'Received timestamp must be persisted.'); + Assert.AreEqual("E-Document Direction"::Incoming, EDocumentMessageAPI.GetMessageDirection(FREInvoiceMessage."E-Document Message Entry No."), 'Generic message must be Incoming.'); + Assert.AreEqual("E-Doc. Message Status"::Received, EDocumentMessageAPI.GetMessageStatus(FREInvoiceMessage."E-Document Message Entry No."), 'Generic message status must be Received.'); + Assert.AreEqual("E-Doc. Response Type"::Submitted, EDocumentMessageAPI.GetMessageResponseType(FREInvoiceMessage."E-Document Message Entry No."), 'Generic response must be Submitted.'); + end; + + [Test] + procedure ReceiveAcceptedPersistsNormalizedMessage() + var + EDocument: Record "E-Document"; + FREInvoiceMessage: Record "FR E-Invoice Message"; + EDocumentMessageAPI: Codeunit "E-Document Message API"; + FREInvoiceMessageAPI: Codeunit "FR E-Invoice Message API"; + TempBlob: Codeunit "Temp Blob"; + OutStream: OutStream; + ExternalDocID: Text[250]; + ExternalMsgID: Text[250]; + FREntryNo: Integer; + begin + Initialize(); + CreateOutgoingEDocument(EDocument); + ExternalDocID := CopyStr(Format(CreateGuid()), 1, 250); + ExternalMsgID := CopyStr(Format(CreateGuid()), 1, 250); + EDocumentMessageAPI.RegisterExternalDocumentReference(EDocument, EDocument.Service, ExternalDocID); + TempBlob.CreateOutStream(OutStream, TextEncoding::UTF8); + OutStream.WriteText(BuildLifecycleXml(EDocument."Document No.", '205', '', '')); + + FREntryNo := FREInvoiceMessageAPI.ReceiveMessage(EDocument.Service, ExternalDocID, ExternalMsgID, CurrentDateTime(), TempBlob); + + FREInvoiceMessage.Get(FREntryNo); + Assert.AreEqual(FREInvoiceMessage.Type::Accepted, FREInvoiceMessage.Type, 'FR type must be Accepted.'); + Assert.AreEqual("E-Doc. Response Type"::Accepted, EDocumentMessageAPI.GetMessageResponseType(FREInvoiceMessage."E-Document Message Entry No."), 'Generic response must be Accepted.'); + end; + + [Test] + procedure ReceiveTechnicalRejectedPersistsReason() + var + EDocument: Record "E-Document"; + FREInvoiceMessage: Record "FR E-Invoice Message"; + EDocumentMessageAPI: Codeunit "E-Document Message API"; + FREInvoiceMessageAPI: Codeunit "FR E-Invoice Message API"; + TempBlob: Codeunit "Temp Blob"; + OutStream: OutStream; + ExternalDocID: Text[250]; + ExternalMsgID: Text[250]; + FREntryNo: Integer; + begin + Initialize(); + CreateOutgoingEDocument(EDocument); + ExternalDocID := CopyStr(Format(CreateGuid()), 1, 250); + ExternalMsgID := CopyStr(Format(CreateGuid()), 1, 250); + EDocumentMessageAPI.RegisterExternalDocumentReference(EDocument, EDocument.Service, ExternalDocID); + TempBlob.CreateOutStream(OutStream, TextEncoding::UTF8); + OutStream.WriteText(BuildLifecycleXml(EDocument."Document No.", 'Rejetée', 'SCHEMA', 'Schema validation failed')); + + FREntryNo := FREInvoiceMessageAPI.ReceiveMessage(EDocument.Service, ExternalDocID, ExternalMsgID, CurrentDateTime(), TempBlob); + + FREInvoiceMessage.Get(FREntryNo); + Assert.AreEqual(FREInvoiceMessage.Type::"Technical Rejected", FREInvoiceMessage.Type, 'FR type must be Technical Rejected.'); + Assert.AreEqual('SCHEMA', Format(FREInvoiceMessage."Reason Code"), 'Reason code must be persisted.'); + Assert.AreEqual('Schema validation failed', FREInvoiceMessage."Reason Description", 'Reason description must be persisted.'); + Assert.AreEqual("E-Doc. Response Type"::Rejected, EDocumentMessageAPI.GetMessageResponseType(FREInvoiceMessage."E-Document Message Entry No."), 'Generic response must be Rejected.'); + end; + + [Test] + procedure ReceiveMessageIsIdempotent() + var + EDocument: Record "E-Document"; + FREInvoiceMessage: Record "FR E-Invoice Message"; + EDocumentMessageAPI: Codeunit "E-Document Message API"; + FREInvoiceMessageAPI: Codeunit "FR E-Invoice Message API"; + TempBlob: Codeunit "Temp Blob"; + OutStream: OutStream; + ExternalDocID: Text[250]; + ExternalMsgID: Text[250]; + FirstEntryNo: Integer; + SecondEntryNo: Integer; + begin + Initialize(); + CreateOutgoingEDocument(EDocument); + ExternalDocID := CopyStr(Format(CreateGuid()), 1, 250); + ExternalMsgID := CopyStr(Format(CreateGuid()), 1, 250); + EDocumentMessageAPI.RegisterExternalDocumentReference(EDocument, EDocument.Service, ExternalDocID); + TempBlob.CreateOutStream(OutStream, TextEncoding::UTF8); + OutStream.WriteText(BuildLifecycleXml(EDocument."Document No.", 'Submitted', '', '')); + + FirstEntryNo := FREInvoiceMessageAPI.ReceiveMessage(EDocument.Service, ExternalDocID, ExternalMsgID, CurrentDateTime(), TempBlob); + TempBlob.CreateOutStream(OutStream, TextEncoding::UTF8); + OutStream.WriteText(BuildLifecycleXml(EDocument."Document No.", 'Submitted', '', '')); + SecondEntryNo := FREInvoiceMessageAPI.ReceiveMessage(EDocument.Service, ExternalDocID, ExternalMsgID, CurrentDateTime(), TempBlob); + + Assert.AreEqual(FirstEntryNo, SecondEntryNo, 'Same external message ID must return same FR entry.'); + FREInvoiceMessage.SetRange("E-Document Entry No.", EDocument."Entry No"); + FREInvoiceMessage.SetRange(Type, FREInvoiceMessage.Type::Submitted); + Assert.RecordCount(FREInvoiceMessage, 1); + end; + + [Test] + procedure ReceiveMessageRejectsInvalidXml() + var + EDocument: Record "E-Document"; + EDocumentMessageAPI: Codeunit "E-Document Message API"; + FREInvoiceMessageAPI: Codeunit "FR E-Invoice Message API"; + TempBlob: Codeunit "Temp Blob"; + OutStream: OutStream; + ExternalDocID: Text[250]; + begin + Initialize(); + CreateOutgoingEDocument(EDocument); + ExternalDocID := CopyStr(Format(CreateGuid()), 1, 250); + EDocumentMessageAPI.RegisterExternalDocumentReference(EDocument, EDocument.Service, ExternalDocID); + TempBlob.CreateOutStream(OutStream, TextEncoding::UTF8); + OutStream.WriteText('not xml at all'); + + asserterror FREInvoiceMessageAPI.ReceiveMessage( + EDocument.Service, ExternalDocID, CopyStr(Format(CreateGuid()), 1, 250), CurrentDateTime(), TempBlob); + + Assert.ExpectedError('not valid XML'); + Assert.ExpectedErrorCode('Dialog'); + end; + + [Test] + procedure ReceiveMessageRejectsUnsupportedStatus() + var + EDocument: Record "E-Document"; + EDocumentMessageAPI: Codeunit "E-Document Message API"; + FREInvoiceMessageAPI: Codeunit "FR E-Invoice Message API"; + TempBlob: Codeunit "Temp Blob"; + OutStream: OutStream; + ExternalDocID: Text[250]; + begin + Initialize(); + CreateOutgoingEDocument(EDocument); + ExternalDocID := CopyStr(Format(CreateGuid()), 1, 250); + EDocumentMessageAPI.RegisterExternalDocumentReference(EDocument, EDocument.Service, ExternalDocID); + TempBlob.CreateOutStream(OutStream, TextEncoding::UTF8); + OutStream.WriteText(BuildLifecycleXml(EDocument."Document No.", 'Unknown', '', '')); + + asserterror FREInvoiceMessageAPI.ReceiveMessage( + EDocument.Service, ExternalDocID, CopyStr(Format(CreateGuid()), 1, 250), CurrentDateTime(), TempBlob); + + Assert.ExpectedError('is not supported'); + Assert.ExpectedErrorCode('Dialog'); + end; + + [Test] + procedure ReceiveMessageRejectsInvoiceMismatch() + var + EDocument: Record "E-Document"; + EDocumentMessageAPI: Codeunit "E-Document Message API"; + FREInvoiceMessageAPI: Codeunit "FR E-Invoice Message API"; + TempBlob: Codeunit "Temp Blob"; + OutStream: OutStream; + ExternalDocID: Text[250]; + begin + Initialize(); + CreateOutgoingEDocument(EDocument); + ExternalDocID := CopyStr(Format(CreateGuid()), 1, 250); + EDocumentMessageAPI.RegisterExternalDocumentReference(EDocument, EDocument.Service, ExternalDocID); + TempBlob.CreateOutStream(OutStream, TextEncoding::UTF8); + OutStream.WriteText(BuildLifecycleXml('WRONG-INVOICE-ID', 'Submitted', '', '')); + + asserterror FREInvoiceMessageAPI.ReceiveMessage( + EDocument.Service, ExternalDocID, CopyStr(Format(CreateGuid()), 1, 250), CurrentDateTime(), TempBlob); + + Assert.ExpectedError('does not match'); + Assert.ExpectedErrorCode('Dialog'); + end; + + [Test] + procedure ReceiveTechnicalRejectedRequiresReasonCode() + var + EDocument: Record "E-Document"; + EDocumentMessageAPI: Codeunit "E-Document Message API"; + FREInvoiceMessageAPI: Codeunit "FR E-Invoice Message API"; + TempBlob: Codeunit "Temp Blob"; + OutStream: OutStream; + ExternalDocID: Text[250]; + begin + Initialize(); + CreateOutgoingEDocument(EDocument); + ExternalDocID := CopyStr(Format(CreateGuid()), 1, 250); + EDocumentMessageAPI.RegisterExternalDocumentReference(EDocument, EDocument.Service, ExternalDocID); + TempBlob.CreateOutStream(OutStream, TextEncoding::UTF8); + OutStream.WriteText(BuildLifecycleXml(EDocument."Document No.", 'Rejected', '', 'Something went wrong')); + + asserterror FREInvoiceMessageAPI.ReceiveMessage( + EDocument.Service, ExternalDocID, CopyStr(Format(CreateGuid()), 1, 250), CurrentDateTime(), TempBlob); + + Assert.ExpectedError('reason code is required'); + Assert.ExpectedErrorCode('Dialog'); + end; + + [Test] + procedure ReceiveTechnicalRejectedRequiresReasonDescription() + var + EDocument: Record "E-Document"; + EDocumentMessageAPI: Codeunit "E-Document Message API"; + FREInvoiceMessageAPI: Codeunit "FR E-Invoice Message API"; + TempBlob: Codeunit "Temp Blob"; + OutStream: OutStream; + ExternalDocID: Text[250]; + begin + Initialize(); + CreateOutgoingEDocument(EDocument); + ExternalDocID := CopyStr(Format(CreateGuid()), 1, 250); + EDocumentMessageAPI.RegisterExternalDocumentReference(EDocument, EDocument.Service, ExternalDocID); + TempBlob.CreateOutStream(OutStream, TextEncoding::UTF8); + OutStream.WriteText(BuildLifecycleXml(EDocument."Document No.", 'Rejected', 'SCHEMA', '')); + + asserterror FREInvoiceMessageAPI.ReceiveMessage( + EDocument.Service, ExternalDocID, CopyStr(Format(CreateGuid()), 1, 250), CurrentDateTime(), TempBlob); + + Assert.ExpectedError('reason description is required'); + Assert.ExpectedErrorCode('Dialog'); + end; + + [Test] + procedure BuilderRejectsIncomingOnlyStatus() + var + EDocument: Record "E-Document"; + FREInvoiceMessage: Record "FR E-Invoice Message"; + FREInvoiceMessageBuilder: Codeunit "FR E-Invoice Message Builder"; + TempBlob: Codeunit "Temp Blob"; + begin + Initialize(); + CreateIncomingEDocument(EDocument); + FREInvoiceMessage.Init(); + FREInvoiceMessage."E-Document Entry No." := EDocument."Entry No"; + FREInvoiceMessage.Type := FREInvoiceMessage.Type::Submitted; + FREInvoiceMessage."Source Occurrence ID" := CreateGuid(); + FREInvoiceMessage."Event Date" := Today(); + FREInvoiceMessage."Created At" := CurrentDateTime(); + FREInvoiceMessage.Insert(); + + asserterror FREInvoiceMessageBuilder.BuildMessage(EDocument, FREInvoiceMessage, TempBlob); + + Assert.ExpectedError('cannot be sent'); + Assert.ExpectedErrorCode('Dialog'); + end; + + local procedure BuildLifecycleXml(InvoiceID: Text; Status: Text; ReasonCode: Text; ReasonDescription: Text): Text + var + XmlText: TextBuilder; + begin + XmlText.Append(''); + XmlText.Append(''); + XmlText.Append(InvoiceID); + XmlText.Append(''); + XmlText.Append(''); + XmlText.Append(Status); + XmlText.Append(''); + if ReasonCode <> '' then begin + XmlText.Append(''); + XmlText.Append(ReasonCode); + XmlText.Append(''); + end; + if ReasonDescription <> '' then begin + XmlText.Append(''); + XmlText.Append(ReasonDescription); + XmlText.Append(''); + end; + XmlText.Append(''); + exit(XmlText.ToText()); + end; + local procedure SendFirstMessage(EDocument: Record "E-Document"; MessageType: Enum "FR E-Invoice Message Type") var FREInvoiceMessage: Record "FR E-Invoice Message"; @@ -283,6 +629,16 @@ codeunit 148152 "FR E-Invoice Message Tests" EDocument.Insert(); end; + local procedure CreateOutgoingEDocument(var EDocument: Record "E-Document") + begin + EDocument.Init(); + EDocument."Document No." := CopyStr(Format(CreateGuid()), 1, MaxStrLen(EDocument."Document No.")); + EDocument.Direction := EDocument.Direction::Outgoing; + EDocument."Document Type" := EDocument."Document Type"::"Sales Invoice"; + EDocument.Service := 'FR-MESSAGE-MOCK'; + EDocument.Insert(); + end; + local procedure CreatePaymentScenario(var EDocument: Record "E-Document"; var DetailedCustLedgEntry: Record "Detailed Cust. Ledg. Entry") var InvoiceCustLedgerEntry: Record "Cust. Ledger Entry"; diff --git a/src/Apps/FR/EDocument_FR/EReportingFR/test/src/FacturXCIIXMLTests.Codeunit.al b/src/Apps/FR/EDocument_FR/EReportingFR/test/src/FacturXCIIXMLTests.Codeunit.al index 7b4d69f4a5b..e0af9ca3c46 100644 --- a/src/Apps/FR/EDocument_FR/EReportingFR/test/src/FacturXCIIXMLTests.Codeunit.al +++ b/src/Apps/FR/EDocument_FR/EReportingFR/test/src/FacturXCIIXMLTests.Codeunit.al @@ -46,9 +46,9 @@ codeunit 148148 "Factur-X CII XML Tests" LibraryUtility: Codeunit "Library - Utility"; Assert: Codeunit Assert; CIIXMLBuilder: Codeunit "CII XML Builder"; - EDocHelpers: Codeunit "EDoc. Helpers"; FacturXFormat: Codeunit "Factur-X Format"; IncorrectValueErr: Label 'Incorrect value for %1', Comment = '%1 = XML element path', Locked = true; + BuyerElectronicAddressRequiredErr: Label 'Electronic Address must be specified for Customer %1 for French e-invoicing.', Comment = '%1 = Customer No.'; FacturXProfileIdTok: Label 'urn:cen.eu:en16931:2017', Locked = true; DialogErrorCodeTok: Label 'Dialog', Locked = true; IsInitialized: Boolean; @@ -1388,52 +1388,44 @@ codeunit 148148 "Factur-X CII XML Tests" procedure FacturXBillingModeB1ForItemOnlyInvoice() var SalesInvoiceHeader: Record "Sales Invoice Header"; - SalesInvoiceLine: Record "Sales Invoice Line"; - PeppolBIS30FRFormat: Codeunit "Peppol BIS 3.0 FR Format"; - SourceDocumentLines: RecordRef; - OriginalView: Text; + TempBlob: Codeunit "Temp Blob"; begin // [FEATURE] [AI test] - // [SCENARIO] GetFrenchBillingMode returns B1 for an invoice with only Item lines + // [SCENARIO] Factur-X CII XML uses billing mode B1 for an invoice with only Item lines Initialize(); // [GIVEN] Posted sales invoice containing only an Item line SalesInvoiceHeader.Get(CreateAndPostSalesInvoiceWithBillingModeLines(false)); - SalesInvoiceLine.SetRange("Document No.", SalesInvoiceHeader."No."); - SourceDocumentLines.GetTable(SalesInvoiceLine); - OriginalView := SourceDocumentLines.GetView(false); - // [WHEN] GetFrenchBillingMode is called - // [THEN] Result = 'B1' and the source lines view is unchanged - Assert.AreEqual('B1', PeppolBIS30FRFormat.GetFrenchBillingMode(SourceDocumentLines), + // [WHEN] Create CII XML + CreateSalesInvoiceCIIXMLFromHeader(SalesInvoiceHeader, TempBlob); + + // [THEN] Billing mode = 'B1' + Assert.AreEqual('B1', + GetCIINodeValue(TempBlob, '//ram:BusinessProcessSpecifiedDocumentContextParameter/ram:ID'), StrSubstNo(IncorrectValueErr, 'BillingMode B1')); - Assert.AreEqual(OriginalView, SourceDocumentLines.GetView(false), StrSubstNo(IncorrectValueErr, 'Source Document Lines View')); end; [Test] procedure FacturXBillingModeM1ForMixedItemAndNonItemInvoice() var SalesInvoiceHeader: Record "Sales Invoice Header"; - SalesInvoiceLine: Record "Sales Invoice Line"; - PeppolBIS30FRFormat: Codeunit "Peppol BIS 3.0 FR Format"; - SourceDocumentLines: RecordRef; - OriginalView: Text; + TempBlob: Codeunit "Temp Blob"; begin // [FEATURE] [AI test] - // [SCENARIO] GetFrenchBillingMode returns M1 for an invoice with both Item and G/L Account lines + // [SCENARIO] Factur-X CII XML uses billing mode M1 for an invoice with both Item and G/L Account lines Initialize(); // [GIVEN] Posted sales invoice containing Item and G/L Account lines SalesInvoiceHeader.Get(CreateAndPostSalesInvoiceWithBillingModeLines(true)); - SalesInvoiceLine.SetRange("Document No.", SalesInvoiceHeader."No."); - SourceDocumentLines.GetTable(SalesInvoiceLine); - OriginalView := SourceDocumentLines.GetView(false); - // [WHEN] GetFrenchBillingMode is called - // [THEN] Result = 'M1' and the source lines view is unchanged - Assert.AreEqual('M1', PeppolBIS30FRFormat.GetFrenchBillingMode(SourceDocumentLines), + // [WHEN] Create CII XML + CreateSalesInvoiceCIIXMLFromHeader(SalesInvoiceHeader, TempBlob); + + // [THEN] Billing mode = 'M1' + Assert.AreEqual('M1', + GetCIINodeValue(TempBlob, '//ram:BusinessProcessSpecifiedDocumentContextParameter/ram:ID'), StrSubstNo(IncorrectValueErr, 'BillingMode M1')); - Assert.AreEqual(OriginalView, SourceDocumentLines.GetView(false), StrSubstNo(IncorrectValueErr, 'Source Document Lines View')); end; #endregion @@ -1458,11 +1450,11 @@ codeunit 148148 "Factur-X CII XML Tests" asserterror CheckFacturX(SourceDocumentHeader); // [THEN] Error about buyer electronic address is raised - AssertExpectedDialogError(EDocHelpers.GetBuyerElectronicAddressRequiredError(CustomerNo)); + AssertExpectedDialogError(StrSubstNo(BuyerElectronicAddressRequiredErr, CustomerNo)); end; [Test] - procedure FacturXCheckRaisesErrorWhenBuyerElectronicAddressIsMalformed() + procedure FacturXCheckAcceptsNonemptyBuyerElectronicAddress() var SalesInvoiceHeader: Record "Sales Invoice Header"; Customer: Record Customer; @@ -1470,7 +1462,7 @@ codeunit 148148 "Factur-X CII XML Tests" CustomerNo: Code[20]; begin // [FEATURE] [AI test] - // [SCENARIO] Factur-X Format Check raises error when buyer electronic address does not match SIREN format + // [SCENARIO] Factur-X Format Check accepts a nonempty buyer electronic address Initialize(); // [GIVEN] Customer "C" with malformed FR Electronic Address (non-digit prefix) @@ -1485,11 +1477,8 @@ codeunit 148148 "Factur-X CII XML Tests" SourceDocumentHeader.GetTable(SalesInvoiceHeader); // [WHEN] Factur-X Format Check is called - asserterror CheckFacturX(SourceDocumentHeader); - - // [THEN] Error about malformed buyer identifier is raised - AssertExpectedDialogError(EDocHelpers.GetBuyerElectronicAddressInvalidError( - Customer.FieldCaption("FR Electronic Address"), CustomerNo)); + // [THEN] No error is raised + CheckFacturX(SourceDocumentHeader); end; [Test] @@ -1562,7 +1551,7 @@ codeunit 148148 "Factur-X CII XML Tests" asserterror CheckFacturX(SourceDocumentHeader); // [THEN] Error about buyer electronic address is raised - AssertExpectedDialogError(EDocHelpers.GetBuyerElectronicAddressRequiredError(CustomerNo)); + AssertExpectedDialogError(StrSubstNo(BuyerElectronicAddressRequiredErr, CustomerNo)); end; #endregion @@ -2141,7 +2130,7 @@ codeunit 148148 "Factur-X CII XML Tests" LibrarySales.CreateCustomer(Customer); Customer.Validate("Country/Region Code", CompanyInformation."Country/Region Code"); Customer."FR Electronic Address" := ''; - Customer."FR Elec. Address Scheme" := Customer."FR Elec. Address Scheme"::" "; + Clear(Customer."FR Elec. Address Scheme"); Customer."VAT Registration No." := ''; Customer."Registration Number" := ''; Customer.Modify(true); diff --git a/src/Apps/FR/EDocument_FR/EReportingFR/test/src/PEPPOLBIS30XMLTests.Codeunit.al b/src/Apps/FR/EDocument_FR/EReportingFR/test/src/PEPPOLBIS30XMLTests.Codeunit.al index 094363b074a..4c9d0ad8ca0 100644 --- a/src/Apps/FR/EDocument_FR/EReportingFR/test/src/PEPPOLBIS30XMLTests.Codeunit.al +++ b/src/Apps/FR/EDocument_FR/EReportingFR/test/src/PEPPOLBIS30XMLTests.Codeunit.al @@ -714,11 +714,11 @@ codeunit 148147 "PEPPOL BIS 3.0 XML Tests" CountryRegion.Init(); CountryRegion.Code := CountryCode; CountryRegion.Name := CountryCode; - CountryRegion."ISO Code" := CountryCode; + CountryRegion."ISO Code" := CopyStr(CountryCode, 1, MaxStrLen(CountryRegion."ISO Code")); CountryRegion.Insert(true); end else if CountryRegion."ISO Code" = '' then begin - CountryRegion."ISO Code" := CountryCode; + CountryRegion."ISO Code" := CopyStr(CountryCode, 1, MaxStrLen(CountryRegion."ISO Code")); CountryRegion.Modify(true); end; end; diff --git a/src/Apps/W1/EDocument/App/src/Processing/Message/EDocMessageMgt.Codeunit.al b/src/Apps/W1/EDocument/App/src/Processing/Message/EDocMessageMgt.Codeunit.al index dac5d2a0222..c3f9a49b926 100644 --- a/src/Apps/W1/EDocument/App/src/Processing/Message/EDocMessageMgt.Codeunit.al +++ b/src/Apps/W1/EDocument/App/src/Processing/Message/EDocMessageMgt.Codeunit.al @@ -80,6 +80,38 @@ codeunit 6433 "E-Doc. Message Mgt." TempBlob := EDocDataStorage.GetTempBlob(); end; + procedure GetMessageEDocument(MessageEntryNo: Integer; var EDocument: Record "E-Document") + var + EDocMessage: Record "E-Document Message"; + begin + EDocMessage.Get(MessageEntryNo); + EDocument.Get(EDocMessage."E-Document Entry No."); + end; + + procedure GetMessageDirection(MessageEntryNo: Integer): Enum "E-Document Direction" + var + EDocMessage: Record "E-Document Message"; + begin + EDocMessage.Get(MessageEntryNo); + exit(EDocMessage.Direction); + end; + + procedure GetMessageStatus(MessageEntryNo: Integer): Enum "E-Doc. Message Status" + var + EDocMessage: Record "E-Document Message"; + begin + EDocMessage.Get(MessageEntryNo); + exit(EDocMessage.Status); + end; + + procedure GetMessageResponseType(MessageEntryNo: Integer): Enum "E-Doc. Response Type" + var + EDocMessage: Record "E-Document Message"; + begin + EDocMessage.Get(MessageEntryNo); + exit(EDocMessage."Response Type"); + end; + procedure SendMessage(MessageEntryNo: Integer) var EDocument: Record "E-Document"; diff --git a/src/Apps/W1/EDocument/App/src/Processing/Message/EDocMessageTransportDefault.Codeunit.al b/src/Apps/W1/EDocument/App/src/Processing/Message/EDocMsgTransportDefault.Codeunit.al similarity index 100% rename from src/Apps/W1/EDocument/App/src/Processing/Message/EDocMessageTransportDefault.Codeunit.al rename to src/Apps/W1/EDocument/App/src/Processing/Message/EDocMsgTransportDefault.Codeunit.al diff --git a/src/Apps/W1/EDocument/App/src/Processing/Message/EDocPaymentOccurrenceType.Enum.al b/src/Apps/W1/EDocument/App/src/Processing/Message/EDocPaymentOccurrenceType.Enum.al index 0800eda61c8..0400caaceb4 100644 --- a/src/Apps/W1/EDocument/App/src/Processing/Message/EDocPaymentOccurrenceType.Enum.al +++ b/src/Apps/W1/EDocument/App/src/Processing/Message/EDocPaymentOccurrenceType.Enum.al @@ -7,7 +7,7 @@ namespace Microsoft.eServices.EDocument.Processing.Message; /// /// Identifies whether an E-Document payment occurrence applies or reverses an amount. /// -enum 6430 "E-Doc. Payment Occurrence Type" +enum 6115 "E-Doc. Payment Occurrence Type" { Access = Public; Extensible = false; diff --git a/src/Apps/W1/EDocument/App/src/Processing/Message/EDocumentMessageAPI.Codeunit.al b/src/Apps/W1/EDocument/App/src/Processing/Message/EDocumentMessageAPI.Codeunit.al index 06b7174d684..ad0cbc85a61 100644 --- a/src/Apps/W1/EDocument/App/src/Processing/Message/EDocumentMessageAPI.Codeunit.al +++ b/src/Apps/W1/EDocument/App/src/Processing/Message/EDocumentMessageAPI.Codeunit.al @@ -27,6 +27,54 @@ codeunit 6532 "E-Document Message API" exit(EDocMessageMgt.CreateMessage(EDocument, MessageType, EDocument.Direction::Outgoing, ResponseType, TempBlob)); end; + /// + /// Gets the parent E-Document of an E-Document message. + /// + /// The entry number of the E-Document message. + /// The parent E-Document. + procedure GetMessageEDocument(MessageEntryNo: Integer; var EDocument: Record "E-Document") + var + EDocMessageMgt: Codeunit "E-Doc. Message Mgt."; + begin + EDocMessageMgt.GetMessageEDocument(MessageEntryNo, EDocument); + end; + + /// + /// Gets the direction of an E-Document message. + /// + /// The entry number of the E-Document message. + /// The message direction. + procedure GetMessageDirection(MessageEntryNo: Integer): Enum "E-Document Direction" + var + EDocMessageMgt: Codeunit "E-Doc. Message Mgt."; + begin + exit(EDocMessageMgt.GetMessageDirection(MessageEntryNo)); + end; + + /// + /// Gets the processing status of an E-Document message. + /// + /// The entry number of the E-Document message. + /// The message processing status. + procedure GetMessageStatus(MessageEntryNo: Integer): Enum "E-Doc. Message Status" + var + EDocMessageMgt: Codeunit "E-Doc. Message Mgt."; + begin + exit(EDocMessageMgt.GetMessageStatus(MessageEntryNo)); + end; + + /// + /// Gets the response type represented by an E-Document message. + /// + /// The entry number of the E-Document message. + /// The message response type. + procedure GetMessageResponseType(MessageEntryNo: Integer): Enum "E-Doc. Response Type" + var + EDocMessageMgt: Codeunit "E-Doc. Message Mgt."; + begin + exit(EDocMessageMgt.GetMessageResponseType(MessageEntryNo)); + end; + /// /// Sends a previously created outgoing E-Document message through its E-Document service. /// diff --git a/src/Apps/W1/EDocument/App/src/RemittanceAdvice/EDocRemitAdviceExport.Codeunit.al b/src/Apps/W1/EDocument/App/src/RemittanceAdvice/EDocRemitAdviceExport.Codeunit.al index 40ffcfc6fb1..0e6cfac2fbf 100644 --- a/src/Apps/W1/EDocument/App/src/RemittanceAdvice/EDocRemitAdviceExport.Codeunit.al +++ b/src/Apps/W1/EDocument/App/src/RemittanceAdvice/EDocRemitAdviceExport.Codeunit.al @@ -75,8 +75,8 @@ codeunit 6531 "E-Doc. Remit. Advice Export" var DocumentSendingProfile: Record "Document Sending Profile"; EDocumentProcessing: Codeunit "E-Document Processing"; - AlreadyExists: Boolean; RecRef: RecordRef; + AlreadyExists: Boolean; begin RecRef.GetTable(PaymentVendLedgEntry); DocumentSendingProfile := EDocumentProcessing.GetDocSendingProfileForDocRef(RecRef); diff --git a/src/Apps/W1/EDocument/App/src/RemittanceAdvice/EDocRemitAdviceJournal.ReportExt.al b/src/Apps/W1/EDocument/App/src/RemittanceAdvice/EDocRemitAdviceJournal.ReportExt.al index b097f92c3c2..97d60e56888 100644 --- a/src/Apps/W1/EDocument/App/src/RemittanceAdvice/EDocRemitAdviceJournal.ReportExt.al +++ b/src/Apps/W1/EDocument/App/src/RemittanceAdvice/EDocRemitAdviceJournal.ReportExt.al @@ -45,13 +45,17 @@ reportextension 6100 "E-Doc. Remit. Advice Journal" extends "Remittance Advice - var LastAccountNo: Code[20]; LastDocumentNo: Code[20]; - LastJournalTemplateName: Code[10]; LastJournalBatchName: Code[10]; + LastJournalTemplateName: Code[10]; FirstGroup: Boolean; begin if not CreateEDocuments then exit; + Clear(LastAccountNo); + Clear(LastDocumentNo); + Clear(LastJournalBatchName); + Clear(LastJournalTemplateName); TempFlaggedGenJnlLine.Reset(); TempFlaggedGenJnlLine.SetCurrentKey("Journal Template Name", "Journal Batch Name", "Account No.", "Document No.", "Line No."); FirstGroup := true; @@ -78,9 +82,9 @@ reportextension 6100 "E-Doc. Remit. Advice Journal" extends "Remittance Advice - end; var + TempFlaggedGenJnlLine: Record "Gen. Journal Line" temporary; EDocRemittanceAdviceMgt: Codeunit "E-Doc. Remittance Advice Mgt."; EDocRemitAdviceExport: Codeunit "E-Doc. Remit. Advice Export"; - TempFlaggedGenJnlLine: Record "Gen. Journal Line" temporary; CreateEDocuments: Boolean; ReExportConfirmQst: Label 'An e-document was already created for this payment. Create again?'; From 53b2023de46dca61e650f30aad4df8b35497df20 Mon Sep 17 00:00:00 2001 From: djukicmilica Date: Thu, 20 Aug 2026 17:21:26 +0200 Subject: [PATCH 05/23] tests updated --- .../test/src/FREDocMsgSenderMock.Codeunit.al | 7 +- .../src/FREInvoiceMessageTests.Codeunit.al | 64 ++++++++++++++++--- 2 files changed, 62 insertions(+), 9 deletions(-) diff --git a/src/Apps/FR/EDocument_FR/EReportingFR/test/src/FREDocMsgSenderMock.Codeunit.al b/src/Apps/FR/EDocument_FR/EReportingFR/test/src/FREDocMsgSenderMock.Codeunit.al index bf0e20961d2..2144f875407 100644 --- a/src/Apps/FR/EDocument_FR/EReportingFR/test/src/FREDocMsgSenderMock.Codeunit.al +++ b/src/Apps/FR/EDocument_FR/EReportingFR/test/src/FREDocMsgSenderMock.Codeunit.al @@ -23,13 +23,18 @@ codeunit 148150 "FR E-Doc. Msg. Sender Mock" implements IDocumentSender, IDocume procedure SendMessage(var EDocument: Record "E-Document"; var EDocumentService: Record "E-Document Service"; MessageContext: Codeunit "E-Doc. Message Context") var TempBlob: Codeunit "Temp Blob"; + PayloadLine: Text; InStream: InStream; begin SendCount += 1; LastResponseType := MessageContext.GetResponseType(); TempBlob := MessageContext.GetTempBlob(); TempBlob.CreateInStream(InStream, TextEncoding::UTF8); - InStream.ReadText(LastPayload); + Clear(LastPayload); + while not InStream.EOS do begin + InStream.ReadText(PayloadLine); + LastPayload += PayloadLine; + end; if ReportSuccess then MessageContext.Status().SetStatus("E-Document Service Status"::Sent); end; diff --git a/src/Apps/FR/EDocument_FR/EReportingFR/test/src/FREInvoiceMessageTests.Codeunit.al b/src/Apps/FR/EDocument_FR/EReportingFR/test/src/FREInvoiceMessageTests.Codeunit.al index 28282ed8a94..9bd0f204d9a 100644 --- a/src/Apps/FR/EDocument_FR/EReportingFR/test/src/FREInvoiceMessageTests.Codeunit.al +++ b/src/Apps/FR/EDocument_FR/EReportingFR/test/src/FREInvoiceMessageTests.Codeunit.al @@ -55,9 +55,9 @@ codeunit 148151 "FR E-Invoice Message Tests" FREInvoiceMessage.FindFirst(); SendMessage(FREInvoiceMessage); Assert.AreEqual(1, MessageSenderMock.GetSendCount(), 'One Collected message must be sent.'); - Assert.IsTrue(MessageSenderMock.GetLastPayload().Contains('212'), 'The payload must contain status 212.'); - Assert.IsTrue(MessageSenderMock.GetLastPayload().Contains('100'), 'The payload must contain the collected amount.'); - Assert.IsTrue(MessageSenderMock.GetLastPayload().Contains(''), 'The payload must contain the AFNOR lifecycle event date.'); + AssertPayloadStatus(MessageSenderMock.GetLastPayload(), '212'); + AssertPayloadAmount(MessageSenderMock.GetLastPayload(), 100, 'EUR'); + AssertPayloadDateFormat(MessageSenderMock.GetLastPayload(), '204'); end; [Test] @@ -99,7 +99,7 @@ codeunit 148151 "FR E-Invoice Message Tests" Assert.AreEqual(1, MessageSenderMock.GetSendCount(), 'Unapplication must queue the reversal without invoking the connector.'); SendMessage(NegativeMessage); Assert.AreEqual(2, MessageSenderMock.GetSendCount(), 'Collected and Negative Collected messages must be sent.'); - Assert.IsTrue(MessageSenderMock.GetLastPayload().Contains('-100'), 'The reversal payload must contain a negative amount.'); + AssertPayloadAmount(MessageSenderMock.GetLastPayload(), -100, 'EUR'); end; [Test] @@ -117,8 +117,8 @@ codeunit 148151 "FR E-Invoice Message Tests" SendFirstMessage(EDocument, "FR E-Invoice Message Type"::Refused); Assert.AreEqual(1, MessageSenderMock.GetSendCount(), 'One refusal message must be sent.'); Assert.AreEqual("E-Doc. Response Type"::Refused, MessageSenderMock.GetLastResponseType(), 'The child message must be Refused.'); - Assert.IsTrue(MessageSenderMock.GetLastPayload().Contains('210'), 'The payload must contain status 210.'); - Assert.IsTrue(MessageSenderMock.GetLastPayload().Contains('PRICE'), 'The payload must contain the reason code.'); + AssertPayloadStatus(MessageSenderMock.GetLastPayload(), '210'); + AssertPayloadReasonCode(MessageSenderMock.GetLastPayload(), 'PRICE'); end; [Test] @@ -132,10 +132,13 @@ codeunit 148151 "FR E-Invoice Message Tests" asserterror FREInvoiceMessageMgt.RefuseInvoice(EDocument, '', 'Not accepted.'); Assert.ExpectedError('A refusal reason code is required.'); + Clear(EDocument); + CreateIncomingEDocument(EDocument); FREInvoiceMessageMgt.RefuseInvoice(EDocument, 'OTHER', 'Not accepted.'); SendFirstMessage(EDocument, "FR E-Invoice Message Type"::Refused); asserterror FREInvoiceMessageMgt.RefuseInvoice(EDocument, 'OTHER', 'Again.'); - Assert.ExpectedError('has already been refused'); + Assert.ExpectedError('already has a buyer response'); + Assert.ExpectedErrorCode('Dialog'); Assert.AreEqual(1, MessageSenderMock.GetSendCount(), 'A duplicate refusal must not be sent.'); end; @@ -251,7 +254,7 @@ codeunit 148151 "FR E-Invoice Message Tests" SendFirstMessage(EDocument, "FR E-Invoice Message Type"::Accepted); Assert.AreEqual(1, MessageSenderMock.GetSendCount(), 'One Accepted message must be sent.'); Assert.AreEqual("E-Doc. Response Type"::Accepted, MessageSenderMock.GetLastResponseType(), 'The child message must be Accepted.'); - Assert.IsTrue(MessageSenderMock.GetLastPayload().Contains('205'), 'The payload must contain status 205.'); + AssertPayloadStatus(MessageSenderMock.GetLastPayload(), '205'); end; [Test] @@ -595,6 +598,51 @@ codeunit 148151 "FR E-Invoice Message Tests" EDocumentMessageAPI.SendMessage(FREInvoiceMessage."E-Document Message Entry No."); end; + local procedure AssertPayloadAmount(Payload: Text; ExpectedAmount: Decimal; ExpectedCurrencyCode: Code[10]) + var + XmlDoc: XmlDocument; + AmountNode: XmlNode; + CurrencyCodeNode: XmlNode; + ActualAmount: Decimal; + begin + Assert.IsTrue(XmlDocument.ReadFrom(Payload, XmlDoc), 'The payload must be valid XML.'); + Assert.IsTrue(XmlDoc.SelectSingleNode('//*[local-name()="ValueAmount"]', AmountNode), 'The payload must contain a value amount.'); + Assert.IsTrue(Evaluate(ActualAmount, AmountNode.AsXmlElement().InnerText(), 9), 'The payload value amount must be a valid XML decimal.'); + Assert.AreEqual(ExpectedAmount, ActualAmount, 'The payload value amount is incorrect.'); + Assert.IsTrue(XmlDoc.SelectSingleNode('//*[local-name()="ValueAmount"]/@currencyID', CurrencyCodeNode), 'The payload value amount must contain a currency.'); + Assert.AreEqual(ExpectedCurrencyCode, CurrencyCodeNode.AsXmlAttribute().Value(), 'The payload currency is incorrect.'); + end; + + local procedure AssertPayloadStatus(Payload: Text; ExpectedStatus: Text) + var + XmlDoc: XmlDocument; + StatusNode: XmlNode; + begin + Assert.IsTrue(XmlDocument.ReadFrom(Payload, XmlDoc), 'The payload must be valid XML.'); + Assert.IsTrue(XmlDoc.SelectSingleNode('//*[local-name()="ProcessConditionCode"]', StatusNode), 'The payload must contain a status.'); + Assert.AreEqual(ExpectedStatus, StatusNode.AsXmlElement().InnerText(), 'The payload status is incorrect.'); + end; + + local procedure AssertPayloadDateFormat(Payload: Text; ExpectedFormat: Text) + var + XmlDoc: XmlDocument; + DateFormatNode: XmlNode; + begin + Assert.IsTrue(XmlDocument.ReadFrom(Payload, XmlDoc), 'The payload must be valid XML.'); + Assert.IsTrue(XmlDoc.SelectSingleNode('//*[local-name()="DateTimeString"]/@format', DateFormatNode), 'The payload must contain an event date format.'); + Assert.AreEqual(ExpectedFormat, DateFormatNode.AsXmlAttribute().Value(), 'The payload event date format is incorrect.'); + end; + + local procedure AssertPayloadReasonCode(Payload: Text; ExpectedReasonCode: Text) + var + XmlDoc: XmlDocument; + ReasonCodeNode: XmlNode; + begin + Assert.IsTrue(XmlDocument.ReadFrom(Payload, XmlDoc), 'The payload must be valid XML.'); + Assert.IsTrue(XmlDoc.SelectSingleNode('//*[local-name()="ReasonCode"]', ReasonCodeNode), 'The payload must contain a reason code.'); + Assert.AreEqual(ExpectedReasonCode, ReasonCodeNode.AsXmlElement().InnerText(), 'The payload reason code is incorrect.'); + end; + local procedure Initialize() var EDocPaymentOccurrence: Record "E-Doc. Payment Occurrence"; From bcef50bb247b749168b06f808d1e5bff4c7751a0 Mon Sep 17 00:00:00 2001 From: djukicmilica Date: Thu, 20 Aug 2026 22:53:58 +0200 Subject: [PATCH 06/23] ai test updates --- .../src/FREInvoiceMessageTests.Codeunit.al | 72 +++++++++++++++++-- 1 file changed, 65 insertions(+), 7 deletions(-) diff --git a/src/Apps/FR/EDocument_FR/EReportingFR/test/src/FREInvoiceMessageTests.Codeunit.al b/src/Apps/FR/EDocument_FR/EReportingFR/test/src/FREInvoiceMessageTests.Codeunit.al index 9bd0f204d9a..152fea6d3c3 100644 --- a/src/Apps/FR/EDocument_FR/EReportingFR/test/src/FREInvoiceMessageTests.Codeunit.al +++ b/src/Apps/FR/EDocument_FR/EReportingFR/test/src/FREInvoiceMessageTests.Codeunit.al @@ -38,11 +38,17 @@ codeunit 148151 "FR E-Invoice Message Tests" DetailedCustLedgEntry: Record "Detailed Cust. Ledg. Entry"; FREInvoiceMessageMgt: Codeunit "FR E-Invoice Message Mgt."; begin + // [FEATURE] [AI test] + // [SCENARIO] Applying a payment to an approved French E-Document creates a Collected message Initialize(); - CreatePaymentScenario(EDocument, DetailedCustLedgEntry); + // [GIVEN] An approved outgoing French E-Document with an applied customer payment + CreatePaymentScenario(EDocument, DetailedCustLedgEntry, "E-Document Service Status"::Approved); + + // [WHEN] The payment application is processed FREInvoiceMessageMgt.ProcessApplication(DetailedCustLedgEntry); + // [THEN] One Collected lifecycle message and one generic payment occurrence are created and the message can be sent FREInvoiceMessage.SetRange("E-Document Entry No.", EDocument."Entry No"); FREInvoiceMessage.SetRange(Type, FREInvoiceMessage.Type::Collected); Assert.RecordCount(FREInvoiceMessage, 1); @@ -60,6 +66,58 @@ codeunit 148151 "FR E-Invoice Message Tests" AssertPayloadDateFormat(MessageSenderMock.GetLastPayload(), '204'); end; + [Test] + procedure PaymentApplicationForClearedDocumentCreatesCollected() + var + EDocument: Record "E-Document"; + FREInvoiceMessage: Record "FR E-Invoice Message"; + DetailedCustLedgEntry: Record "Detailed Cust. Ledg. Entry"; + FREInvoiceMessageMgt: Codeunit "FR E-Invoice Message Mgt."; + begin + // [FEATURE] [AI test] + // [SCENARIO] Applying a payment to a cleared French E-Document creates a Collected message + Initialize(); + + // [GIVEN] A cleared outgoing French E-Document with an applied customer payment + CreatePaymentScenario(EDocument, DetailedCustLedgEntry, "E-Document Service Status"::Cleared); + + // [WHEN] The payment application is processed + FREInvoiceMessageMgt.ProcessApplication(DetailedCustLedgEntry); + + // [THEN] One Collected lifecycle message is created for the E-Document + FREInvoiceMessage.SetRange("E-Document Entry No.", EDocument."Entry No"); + FREInvoiceMessage.SetRange(Type, FREInvoiceMessage.Type::Collected); + Assert.RecordCount(FREInvoiceMessage, 1); + end; + + [Test] + procedure PaymentApplicationForSentDocumentDoesNotCreateCollected() + var + EDocument: Record "E-Document"; + EDocPaymentOccurrence: Record "E-Doc. Payment Occurrence"; + FREInvoiceMessage: Record "FR E-Invoice Message"; + DetailedCustLedgEntry: Record "Detailed Cust. Ledg. Entry"; + FREInvoiceMessageMgt: Codeunit "FR E-Invoice Message Mgt."; + begin + // [FEATURE] [AI test] + // [SCENARIO] Applying a payment to a sent French E-Document does not create a Collected message + Initialize(); + + // [GIVEN] A sent outgoing French E-Document with an applied customer payment + CreatePaymentScenario(EDocument, DetailedCustLedgEntry, "E-Document Service Status"::Sent); + + // [WHEN] The payment application is processed + FREInvoiceMessageMgt.ProcessApplication(DetailedCustLedgEntry); + + // [THEN] The generic payment occurrence is created but no French lifecycle message is created + EDocPaymentOccurrence.SetRange("E-Document Entry No.", EDocument."Entry No"); + EDocPaymentOccurrence.SetRange(Type, EDocPaymentOccurrence.Type::Applied); + Assert.RecordCount(EDocPaymentOccurrence, 1); + FREInvoiceMessage.SetRange("E-Document Entry No.", EDocument."Entry No"); + FREInvoiceMessage.SetRange(Type, FREInvoiceMessage.Type::Collected); + Assert.RecordCount(FREInvoiceMessage, 0); + end; + [Test] procedure PaymentUnapplicationSendsLinkedNegativeCollected() var @@ -73,7 +131,7 @@ codeunit 148151 "FR E-Invoice Message Tests" FREInvoiceMessageMgt: Codeunit "FR E-Invoice Message Mgt."; begin Initialize(); - CreatePaymentScenario(EDocument, DetailedCustLedgEntry); + CreatePaymentScenario(EDocument, DetailedCustLedgEntry, "E-Document Service Status"::Approved); FREInvoiceMessageMgt.ProcessApplication(DetailedCustLedgEntry); CollectedMessage.SetRange("E-Document Entry No.", EDocument."Entry No"); CollectedMessage.SetRange(Type, CollectedMessage.Type::Collected); @@ -175,7 +233,7 @@ codeunit 148151 "FR E-Invoice Message Tests" FREInvoiceMessageMgt: Codeunit "FR E-Invoice Message Mgt."; begin Initialize(); - CreatePaymentScenario(EDocument, DetailedCustLedgEntry); + CreatePaymentScenario(EDocument, DetailedCustLedgEntry, "E-Document Service Status"::Approved); FREInvoiceMessageMgt.ProcessApplication(DetailedCustLedgEntry); FREInvoiceMessageMgt.ProcessApplication(DetailedCustLedgEntry); @@ -687,7 +745,7 @@ codeunit 148151 "FR E-Invoice Message Tests" EDocument.Insert(); end; - local procedure CreatePaymentScenario(var EDocument: Record "E-Document"; var DetailedCustLedgEntry: Record "Detailed Cust. Ledg. Entry") + local procedure CreatePaymentScenario(var EDocument: Record "E-Document"; var DetailedCustLedgEntry: Record "Detailed Cust. Ledg. Entry"; ServiceStatus: Enum "E-Document Service Status") var InvoiceCustLedgerEntry: Record "Cust. Ledger Entry"; PaymentCustLedgerEntry: Record "Cust. Ledger Entry"; @@ -706,7 +764,7 @@ codeunit 148151 "FR E-Invoice Message Tests" EDocument."Document Type" := EDocument."Document Type"::"Sales Invoice"; EDocument.Service := 'FR-MESSAGE-MOCK'; EDocument.Insert(); - CreateServiceStatus(EDocument); + CreateServiceStatus(EDocument, ServiceStatus); InvoiceCustLedgerEntry.Init(); InvoiceCustLedgerEntry."Entry No." := GetNextCustLedgerEntryNo(); @@ -720,14 +778,14 @@ codeunit 148151 "FR E-Invoice Message Tests" CreateDetailedLedgerEntry(DetailedCustLedgEntry, InvoiceCustLedgerEntry."Entry No.", PaymentCustLedgerEntry."Entry No.", -100); end; - local procedure CreateServiceStatus(EDocument: Record "E-Document") + local procedure CreateServiceStatus(EDocument: Record "E-Document"; ServiceStatus: Enum "E-Document Service Status") var EDocumentServiceStatus: Record "E-Document Service Status"; begin EDocumentServiceStatus.Init(); EDocumentServiceStatus."E-Document Entry No" := EDocument."Entry No"; EDocumentServiceStatus."E-Document Service Code" := EDocument.Service; - EDocumentServiceStatus.Status := EDocumentServiceStatus.Status::Approved; + EDocumentServiceStatus.Status := ServiceStatus; EDocumentServiceStatus.Insert(); end; From 54fcc3326ba9cbb96772f9674c1be3763063e95c Mon Sep 17 00:00:00 2001 From: djukicmilica Date: Thu, 20 Aug 2026 23:14:15 +0200 Subject: [PATCH 07/23] PR comment changes --- .../EReportingFRUser.PermissionSet.al | 24 +++++ .../app/src/Core/FREInvoiceMessage.Table.al | 2 + .../src/FREInvoiceMessageTests.Codeunit.al | 32 ++++++- .../EDocCoreObjects.PermissionSet.al | 3 - .../Permissions/EDocCoreUser.PermissionSet.al | 4 + .../Interfaces/IMessageSender.Interface.al | 5 +- .../Message/EDocMessageMgt.Codeunit.al | 14 ++- .../Message/EDocMessageSendJob.Codeunit.al | 10 +- .../Message/EDocMessageSendRunner.Codeunit.al | 20 ++++ .../EDocMessageMgtTests.Codeunit.al | 93 +++++++++++++++++++ 10 files changed, 188 insertions(+), 19 deletions(-) create mode 100644 src/Apps/FR/EDocument_FR/EReportingFR/app/Permissions/EReportingFRUser.PermissionSet.al create mode 100644 src/Apps/W1/EDocument/App/src/Processing/Message/EDocMessageSendRunner.Codeunit.al create mode 100644 src/Apps/W1/EDocument/Test/src/Processing/EDocMessageMgtTests.Codeunit.al diff --git a/src/Apps/FR/EDocument_FR/EReportingFR/app/Permissions/EReportingFRUser.PermissionSet.al b/src/Apps/FR/EDocument_FR/EReportingFR/app/Permissions/EReportingFRUser.PermissionSet.al new file mode 100644 index 00000000000..a2ac9cd2a5e --- /dev/null +++ b/src/Apps/FR/EDocument_FR/EReportingFR/app/Permissions/EReportingFRUser.PermissionSet.al @@ -0,0 +1,24 @@ +// ------------------------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// ------------------------------------------------------------------------------------------------ +namespace Microsoft.eServices.EDocument.Formats; + +using Microsoft.eServices.EDocument; + +permissionset 10988 "E-Reporting FR - User" +{ + Assignable = true; + Caption = 'E-Reporting FR - User'; + + IncludedPermissionSets = "E-Doc. Core - User"; + + Permissions = + table "FR E-Invoice Message" = X, + tabledata "FR E-Invoice Message" = RIMD, + codeunit "FR E-Invoice Message Mgt." = X, + codeunit "FR E-Invoice Message Builder" = X, + codeunit "FR E-Invoice Message API" = X, + page "FR E-Invoice Refusal Dialog" = X, + page "FR E-Invoice Messages" = X; +} \ No newline at end of file diff --git a/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/FREInvoiceMessage.Table.al b/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/FREInvoiceMessage.Table.al index 64911991e0c..1a9d62e3a40 100644 --- a/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/FREInvoiceMessage.Table.al +++ b/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/FREInvoiceMessage.Table.al @@ -5,6 +5,7 @@ namespace Microsoft.eServices.EDocument.Formats; using Microsoft.eServices.EDocument; +using Microsoft.eServices.EDocument.Processing.Message; table 10970 "FR E-Invoice Message" { @@ -80,6 +81,7 @@ table 10970 "FR E-Invoice Message" { Caption = 'E-Document Message Entry No.'; DataClassification = SystemMetadata; + TableRelation = "E-Document Message"."Entry No."; } field(13; "Created At"; DateTime) { diff --git a/src/Apps/FR/EDocument_FR/EReportingFR/test/src/FREInvoiceMessageTests.Codeunit.al b/src/Apps/FR/EDocument_FR/EReportingFR/test/src/FREInvoiceMessageTests.Codeunit.al index 152fea6d3c3..4b6be201cfb 100644 --- a/src/Apps/FR/EDocument_FR/EReportingFR/test/src/FREInvoiceMessageTests.Codeunit.al +++ b/src/Apps/FR/EDocument_FR/EReportingFR/test/src/FREInvoiceMessageTests.Codeunit.al @@ -190,6 +190,7 @@ codeunit 148151 "FR E-Invoice Message Tests" asserterror FREInvoiceMessageMgt.RefuseInvoice(EDocument, '', 'Not accepted.'); Assert.ExpectedError('A refusal reason code is required.'); + Assert.ExpectedErrorCode('Dialog'); Clear(EDocument); CreateIncomingEDocument(EDocument); FREInvoiceMessageMgt.RefuseInvoice(EDocument, 'OTHER', 'Not accepted.'); @@ -200,6 +201,27 @@ codeunit 148151 "FR E-Invoice Message Tests" Assert.AreEqual(1, MessageSenderMock.GetSendCount(), 'A duplicate refusal must not be sent.'); end; + [Test] + procedure RefusalRequiresReasonDescription() + var + EDocument: Record "E-Document"; + FREInvoiceMessageMgt: Codeunit "FR E-Invoice Message Mgt."; + begin + // [FEATURE] [AI test] + // [SCENARIO] A buyer refusal requires a reason description + Initialize(); + + // [GIVEN] An incoming French purchase invoice + CreateIncomingEDocument(EDocument); + + // [WHEN] The invoice is refused without a reason description + asserterror FREInvoiceMessageMgt.RefuseInvoice(EDocument, 'OTHER', ''); + + // [THEN] The refusal is rejected + Assert.ExpectedError('A refusal reason description is required.'); + Assert.ExpectedErrorCode('Dialog'); + end; + [Test] procedure MessageSenderMustReportSuccess() var @@ -253,20 +275,24 @@ codeunit 148151 "FR E-Invoice Message Tests" EDocumentMessageAPI: Codeunit "E-Document Message API"; TempBlob: Codeunit "Temp Blob"; OutStream: OutStream; + ExternalDocumentID: Text[250]; + ExternalMessageID: Text[250]; FirstMessageEntryNo: Integer; DuplicateMessageEntryNo: Integer; begin Initialize(); CreateIncomingEDocument(EDocument); - EDocumentMessageAPI.RegisterExternalDocumentReference(EDocument, EDocument.Service, 'FR-DOC-001'); + ExternalDocumentID := CopyStr(Format(CreateGuid()), 1, MaxStrLen(ExternalDocumentID)); + ExternalMessageID := CopyStr(Format(CreateGuid()), 1, MaxStrLen(ExternalMessageID)); + EDocumentMessageAPI.RegisterExternalDocumentReference(EDocument, EDocument.Service, ExternalDocumentID); TempBlob.CreateOutStream(OutStream, TextEncoding::UTF8); OutStream.WriteText(''); FirstMessageEntryNo := EDocumentMessageAPI.CreateIncomingMessage( - EDocument.Service, 'FR-DOC-001', 'FR-MSG-001', "E-Document Message Type"::"FR Invoice Lifecycle", + EDocument.Service, ExternalDocumentID, ExternalMessageID, "E-Document Message Type"::"FR Invoice Lifecycle", "E-Doc. Response Type"::Refused, CurrentDateTime(), TempBlob); DuplicateMessageEntryNo := EDocumentMessageAPI.CreateIncomingMessage( - EDocument.Service, 'FR-DOC-001', 'FR-MSG-001', "E-Document Message Type"::"FR Invoice Lifecycle", + EDocument.Service, ExternalDocumentID, ExternalMessageID, "E-Document Message Type"::"FR Invoice Lifecycle", "E-Doc. Response Type"::Refused, CurrentDateTime(), TempBlob); Assert.AreNotEqual(0, FirstMessageEntryNo, 'The incoming lifecycle message must be persisted.'); diff --git a/src/Apps/W1/EDocument/App/Permissions/EDocCoreObjects.PermissionSet.al b/src/Apps/W1/EDocument/App/Permissions/EDocCoreObjects.PermissionSet.al index bf976ecd725..b5c4b2029d1 100644 --- a/src/Apps/W1/EDocument/App/Permissions/EDocCoreObjects.PermissionSet.al +++ b/src/Apps/W1/EDocument/App/Permissions/EDocCoreObjects.PermissionSet.al @@ -106,12 +106,9 @@ permissionset 6100 "E-Doc. Core - Objects" #endif codeunit "E-Doc. Attachment Processor" = X, codeunit "E-Doc. Hist. Line Data Loader" = X, - codeunit "E-Document Message API" = X, codeunit "E-Doc. Message Context" = X, codeunit "E-Doc. Message Mgt." = X, - codeunit "E-Doc. Message Send Job" = X, codeunit "E-Doc. Msg. Transport Default" = X, - codeunit "E-Doc. Payment Occurrence Mgt." = X, codeunit "Service Participant" = X, page "E-Doc. Changes Part" = X, page "E-Doc. Changes Preview" = X, diff --git a/src/Apps/W1/EDocument/App/Permissions/EDocCoreUser.PermissionSet.al b/src/Apps/W1/EDocument/App/Permissions/EDocCoreUser.PermissionSet.al index 6cc4d4dc41f..e8aeadb0579 100644 --- a/src/Apps/W1/EDocument/App/Permissions/EDocCoreUser.PermissionSet.al +++ b/src/Apps/W1/EDocument/App/Permissions/EDocCoreUser.PermissionSet.al @@ -25,6 +25,10 @@ permissionset 6105 "E-Doc. Core - User" IncludedPermissionSets = "E-Doc. Core - Read"; Permissions = + codeunit "E-Document Message API" = X, + codeunit "E-Doc. Message Send Job" = X, + codeunit "E-Doc. Message Send Runner" = X, + codeunit "E-Doc. Payment Occurrence Mgt." = X, tabledata "E-Document" = iMD, #region Service tabledata "E-Document Service" = im, diff --git a/src/Apps/W1/EDocument/App/src/Integration/Interfaces/IMessageSender.Interface.al b/src/Apps/W1/EDocument/App/src/Integration/Interfaces/IMessageSender.Interface.al index 3f446ebd4a0..3204c010ba4 100644 --- a/src/Apps/W1/EDocument/App/src/Integration/Interfaces/IMessageSender.Interface.al +++ b/src/Apps/W1/EDocument/App/src/Integration/Interfaces/IMessageSender.Interface.al @@ -18,6 +18,9 @@ interface IMessageSender /// The parent E-Document. /// The service used to send the message. /// The message payload, transport state, and result. The implementation must set the result to Sent after successful transmission. - /// The implementation is responsible for obtaining any privacy consent required by the external service before transmitting data. + /// + /// The implementation is responsible for obtaining any privacy consent required by the external service before transmitting data. + /// Implementations must use MessageContext.GetMessageEntryNo() as an idempotency key because a message can be retried after an ambiguous transport failure. + /// procedure SendMessage(var EDocument: Record "E-Document"; var EDocumentService: Record "E-Document Service"; MessageContext: Codeunit "E-Doc. Message Context"); } \ No newline at end of file diff --git a/src/Apps/W1/EDocument/App/src/Processing/Message/EDocMessageMgt.Codeunit.al b/src/Apps/W1/EDocument/App/src/Processing/Message/EDocMessageMgt.Codeunit.al index c3f9a49b926..a0960055650 100644 --- a/src/Apps/W1/EDocument/App/src/Processing/Message/EDocMessageMgt.Codeunit.al +++ b/src/Apps/W1/EDocument/App/src/Processing/Message/EDocMessageMgt.Codeunit.al @@ -121,6 +121,7 @@ codeunit 6433 "E-Doc. Message Mgt." EDocumentLog: Codeunit "E-Document Log"; TempBlob: Codeunit "Temp Blob"; MessageSender: Interface IMessageSender; + MessageSendingErrorInfo: ErrorInfo; begin EDocMessage.Get(MessageEntryNo); EDocMessage.TestField(Direction, EDocMessage.Direction::Outgoing); @@ -137,8 +138,12 @@ codeunit 6433 "E-Doc. Message Mgt." EDocMessageContext.Initialize(EDocMessage, TempBlob); MessageSender := EDocumentService."Service Integration V2"; MessageSender.SendMessage(EDocument, EDocumentService, EDocMessageContext); - if EDocMessageContext.Status().GetStatus() <> "E-Document Service Status"::Sent then - Error(MessageSendingErr, MessageEntryNo, EDocMessageContext.Status().GetStatus()); + if EDocMessageContext.Status().GetStatus() <> "E-Document Service Status"::Sent then begin + MessageSendingErrorInfo.ErrorType := ErrorType::Internal; + MessageSendingErrorInfo.Message := StrSubstNo(MessageSendingErr, MessageEntryNo); + MessageSendingErrorInfo.DetailedMessage := StrSubstNo(MessageSendingDetailedErr, EDocMessageContext.Status().GetStatus()); + Error(MessageSendingErrorInfo); + end; EDocumentLog.InsertIntegrationLog( EDocument, EDocumentService, EDocMessageContext.Http().GetHttpRequestMessage(), EDocMessageContext.Http().GetHttpResponseMessage()); @@ -160,6 +165,7 @@ codeunit 6433 "E-Doc. Message Mgt." EDocMessage.Status := EDocMessage.Status::Queued; EDocMessage.Modify(); + Commit(); EDocumentBackgroundJobs.ScheduleMessageSend(EDocMessage); end; @@ -204,6 +210,7 @@ codeunit 6433 "E-Doc. Message Mgt." if not TempBlob.HasValue() then Error(IncomingMessagePayloadRequiredErr); + EDocMessage.LockTable(); EDocMessage.SetRange(Service, ServiceCode); EDocMessage.SetRange("External Message ID", ExternalMessageID); if EDocMessage.FindFirst() then @@ -248,7 +255,8 @@ codeunit 6433 "E-Doc. Message Mgt." var MessagePayloadErr: Label 'E-Document message %1 does not contain a payload.', Comment = '%1 = E-Document message entry number'; - MessageSendingErr: Label 'E-Document message %1 could not be sent. The integration returned status %2.', Comment = '%1 = E-Document message entry number, %2 = integration status'; + MessageSendingErr: Label 'E-Document message %1 could not be sent.', Comment = '%1 = E-Document message entry number'; + MessageSendingDetailedErr: Label 'The E-Document message integration returned status %1.', Comment = '%1 = integration status'; ExternalDocumentIDRequiredErr: Label 'An external document ID is required.'; ExternalMessageIDRequiredErr: Label 'An external message ID is required.'; IncomingMessagePayloadRequiredErr: Label 'An incoming E-Document message payload is required.'; diff --git a/src/Apps/W1/EDocument/App/src/Processing/Message/EDocMessageSendJob.Codeunit.al b/src/Apps/W1/EDocument/App/src/Processing/Message/EDocMessageSendJob.Codeunit.al index 8d43c26cb34..28f31c79d3d 100644 --- a/src/Apps/W1/EDocument/App/src/Processing/Message/EDocMessageSendJob.Codeunit.al +++ b/src/Apps/W1/EDocument/App/src/Processing/Message/EDocMessageSendJob.Codeunit.al @@ -19,7 +19,7 @@ codeunit 6535 "E-Doc. Message Send Job" LastErrorText: Text; begin EDocumentMessage.Get(Rec."Record ID to Process"); - if TrySendMessage(EDocumentMessage."Entry No.") then + if Codeunit.Run(Codeunit::"E-Doc. Message Send Runner", EDocumentMessage) then exit; LastErrorText := GetLastErrorText(); @@ -33,14 +33,6 @@ codeunit 6535 "E-Doc. Message Send Job" Error(MessageSendFailedErr, EDocumentMessage."Entry No.", LastErrorText); end; - [TryFunction] - local procedure TrySendMessage(MessageEntryNo: Integer) - var - EDocMessageMgt: Codeunit "E-Doc. Message Mgt."; - begin - EDocMessageMgt.SendMessage(MessageEntryNo); - end; - var MessageSendFailedErr: Label 'E-Document message %1 could not be sent. %2', Comment = '%1 = message entry number, %2 = connector error'; } \ No newline at end of file diff --git a/src/Apps/W1/EDocument/App/src/Processing/Message/EDocMessageSendRunner.Codeunit.al b/src/Apps/W1/EDocument/App/src/Processing/Message/EDocMessageSendRunner.Codeunit.al new file mode 100644 index 00000000000..3733c43cf8b --- /dev/null +++ b/src/Apps/W1/EDocument/App/src/Processing/Message/EDocMessageSendRunner.Codeunit.al @@ -0,0 +1,20 @@ +// ------------------------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// ------------------------------------------------------------------------------------------------ +namespace Microsoft.eServices.EDocument.Processing.Message; + +codeunit 6537 "E-Doc. Message Send Runner" +{ + Access = Internal; + TableNo = "E-Document Message"; + InherentEntitlements = X; + InherentPermissions = X; + + trigger OnRun() + var + EDocMessageMgt: Codeunit "E-Doc. Message Mgt."; + begin + EDocMessageMgt.SendMessage(Rec."Entry No."); + end; +} \ No newline at end of file diff --git a/src/Apps/W1/EDocument/Test/src/Processing/EDocMessageMgtTests.Codeunit.al b/src/Apps/W1/EDocument/Test/src/Processing/EDocMessageMgtTests.Codeunit.al new file mode 100644 index 00000000000..3d1deb9532f --- /dev/null +++ b/src/Apps/W1/EDocument/Test/src/Processing/EDocMessageMgtTests.Codeunit.al @@ -0,0 +1,93 @@ +// ------------------------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// ------------------------------------------------------------------------------------------------ +namespace Microsoft.eServices.EDocument.Test; + +using Microsoft.eServices.EDocument; +using Microsoft.eServices.EDocument.Integration; +using Microsoft.eServices.EDocument.Processing.Message; +using Microsoft.Sales.Customer; +using System.Threading; +using System.Utilities; + +codeunit 139899 "E-Doc. Message Mgt. Tests" +{ + Subtype = Test; + TestType = IntegrationTest; + TestPermissions = Disabled; + + var + EDocumentService: Record "E-Document Service"; + Assert: Codeunit Assert; + LibraryEDoc: Codeunit "Library - E-Document"; + LibraryLowerPermission: Codeunit "Library - Lower Permissions"; + IsInitialized: Boolean; + + [Test] + procedure QueueMessageSchedulesBackgroundSend() + var + Customer: Record Customer; + EDocument: Record "E-Document"; + EDocMessage: Record "E-Document Message"; + JobQueueEntry: Record "Job Queue Entry"; + EDocMessageMgt: Codeunit "E-Doc. Message Mgt."; + TempBlob: Codeunit "Temp Blob"; + OutStream: OutStream; + MessageEntryNo: Integer; + begin + // [FEATURE] [AI test] + // [SCENARIO] Queueing an outgoing E-Document message schedules its background send job + Initialize(Customer); + + // [GIVEN] A created outgoing E-Document message + CreateOutgoingEDocument(EDocument); + TempBlob.CreateOutStream(OutStream, TextEncoding::UTF8); + OutStream.WriteText(''); + MessageEntryNo := EDocMessageMgt.CreateMessage( + EDocument, "E-Document Message Type"::"Unspecified", "E-Document Direction"::Outgoing, + "E-Doc. Response Type"::None, TempBlob); + + // [WHEN] The message is queued + EDocMessageMgt.QueueMessage(MessageEntryNo); + + // [THEN] The message is marked Queued and a send job is scheduled for it + EDocMessage.Get(MessageEntryNo); + Assert.AreEqual("E-Doc. Message Status"::Queued, EDocMessage.Status, 'The message must be queued.'); + JobQueueEntry.SetRange("Object Type to Run", JobQueueEntry."Object Type to Run"::Codeunit); + JobQueueEntry.SetRange("Object ID to Run", Codeunit::"E-Doc. Message Send Job"); + JobQueueEntry.SetRange("Record ID to Process", EDocMessage.RecordId()); + Assert.RecordCount(JobQueueEntry, 1); + end; + + local procedure Initialize(var Customer: Record Customer) + var + EDocument: Record "E-Document"; + EDocMessage: Record "E-Document Message"; + JobQueueEntry: Record "Job Queue Entry"; + begin + LibraryLowerPermission.SetOutsideO365Scope(); + JobQueueEntry.SetRange("Object Type to Run", JobQueueEntry."Object Type to Run"::Codeunit); + JobQueueEntry.SetRange("Object ID to Run", Codeunit::"E-Doc. Message Send Job"); + JobQueueEntry.DeleteAll(); + EDocMessage.DeleteAll(); + EDocument.DeleteAll(); + + if IsInitialized then + exit; + + LibraryEDoc.SetupStandardVAT(); + EDocumentService.DeleteAll(); + LibraryEDoc.SetupStandardSalesScenario( + Customer, EDocumentService, Enum::"E-Document Format"::Mock, Enum::"Service Integration"::Mock); + IsInitialized := true; + end; + + local procedure CreateOutgoingEDocument(var EDocument: Record "E-Document") + begin + EDocument.Init(); + EDocument.Direction := EDocument.Direction::Outgoing; + EDocument.Service := EDocumentService.Code; + EDocument.Insert(); + end; +} From 6cff42535dbad7fa7caa6ffdec11b089713f03ef Mon Sep 17 00:00:00 2001 From: djukicmilica Date: Thu, 20 Aug 2026 23:43:42 +0200 Subject: [PATCH 08/23] new comments fixes --- .../src/FREInvoiceMessageTests.Codeunit.al | 79 +++++++++++++------ .../EDocMsgTransportDefault.Codeunit.al | 9 ++- .../EDocPaymentOccurrenceMgt.Codeunit.al | 13 ++- 3 files changed, 70 insertions(+), 31 deletions(-) diff --git a/src/Apps/FR/EDocument_FR/EReportingFR/test/src/FREInvoiceMessageTests.Codeunit.al b/src/Apps/FR/EDocument_FR/EReportingFR/test/src/FREInvoiceMessageTests.Codeunit.al index 4b6be201cfb..35ef0201902 100644 --- a/src/Apps/FR/EDocument_FR/EReportingFR/test/src/FREInvoiceMessageTests.Codeunit.al +++ b/src/Apps/FR/EDocument_FR/EReportingFR/test/src/FREInvoiceMessageTests.Codeunit.al @@ -7,6 +7,11 @@ namespace Microsoft.eServices.EDocument.Formats.Test; using Microsoft.eServices.EDocument; using Microsoft.eServices.EDocument.Formats; using Microsoft.eServices.EDocument.Processing.Message; +using Microsoft.Finance.GeneralLedger.Journal; +using Microsoft.Finance.VAT.Setup; +using Microsoft.Foundation.Enums; +using Microsoft.Sales.Customer; +using Microsoft.Sales.Document; using Microsoft.Sales.History; using Microsoft.Sales.Receivables; using System.Utilities; @@ -27,6 +32,8 @@ codeunit 148151 "FR E-Invoice Message Tests" var Assert: Codeunit Assert; + LibraryERM: Codeunit "Library - ERM"; + LibrarySales: Codeunit "Library - Sales"; MessageSenderMock: Codeunit "FR E-Doc. Msg. Sender Mock"; [Test] @@ -773,18 +780,37 @@ codeunit 148151 "FR E-Invoice Message Tests" local procedure CreatePaymentScenario(var EDocument: Record "E-Document"; var DetailedCustLedgEntry: Record "Detailed Cust. Ledg. Entry"; ServiceStatus: Enum "E-Document Service Status") var - InvoiceCustLedgerEntry: Record "Cust. Ledger Entry"; - PaymentCustLedgerEntry: Record "Cust. Ledger Entry"; + Customer: Record Customer; + EDocumentService: Record "E-Document Service"; + GenJournalBatch: Record "Gen. Journal Batch"; + GenJournalLine: Record "Gen. Journal Line"; + SalesHeader: Record "Sales Header"; SalesInvoiceHeader: Record "Sales Invoice Header"; - DocumentNo: Code[20]; - begin - DocumentNo := CopyStr(Format(CreateGuid()), 1, MaxStrLen(DocumentNo)); - SalesInvoiceHeader.Init(); - SalesInvoiceHeader."No." := DocumentNo; - SalesInvoiceHeader.Insert(); + SalesLine: Record "Sales Line"; + VATPostingSetup: Record "VAT Posting Setup"; + PostedInvoiceNo: Code[20]; + begin + EDocumentService.Get('FR-MESSAGE-MOCK'); + EDocumentService."Document Format" := EDocumentService."Document Format"::Mock; + EDocumentService.Modify(); + LibraryERM.CreateVATPostingSetupWithAccounts(VATPostingSetup, VATPostingSetup."VAT Calculation Type"::"Normal VAT", 0); + LibrarySales.CreateCustomer(Customer); + Customer.Validate("VAT Bus. Posting Group", VATPostingSetup."VAT Bus. Posting Group"); + Customer.Modify(true); + + LibrarySales.CreateSalesHeader(SalesHeader, SalesHeader."Document Type"::Invoice, Customer."No."); + LibrarySales.CreateSalesLine(SalesLine, SalesHeader, SalesLine.Type::"G/L Account", + LibraryERM.CreateGLAccountWithVATPostingSetup(VATPostingSetup, "General Posting Type"::Sale), 1); + SalesLine.Validate("Unit Price", 100); + SalesLine.Modify(true); + PostedInvoiceNo := LibrarySales.PostSalesDocument(SalesHeader, true, true); + + EDocumentService."Document Format" := EDocumentService."Document Format"::"Peppol BIS 3.0 FR"; + EDocumentService.Modify(); + SalesInvoiceHeader.Get(PostedInvoiceNo); EDocument.Init(); - EDocument."Document No." := DocumentNo; + EDocument."Document No." := PostedInvoiceNo; EDocument."Document Record ID" := SalesInvoiceHeader.RecordId; EDocument.Direction := EDocument.Direction::Outgoing; EDocument."Document Type" := EDocument."Document Type"::"Sales Invoice"; @@ -792,16 +818,18 @@ codeunit 148151 "FR E-Invoice Message Tests" EDocument.Insert(); CreateServiceStatus(EDocument, ServiceStatus); - InvoiceCustLedgerEntry.Init(); - InvoiceCustLedgerEntry."Entry No." := GetNextCustLedgerEntryNo(); - InvoiceCustLedgerEntry."Document Type" := InvoiceCustLedgerEntry."Document Type"::Invoice; - InvoiceCustLedgerEntry."Document No." := DocumentNo; - InvoiceCustLedgerEntry.Insert(); - PaymentCustLedgerEntry.Init(); - PaymentCustLedgerEntry."Entry No." := InvoiceCustLedgerEntry."Entry No." + 1; - PaymentCustLedgerEntry."Document Type" := PaymentCustLedgerEntry."Document Type"::Payment; - PaymentCustLedgerEntry.Insert(); - CreateDetailedLedgerEntry(DetailedCustLedgEntry, InvoiceCustLedgerEntry."Entry No.", PaymentCustLedgerEntry."Entry No.", -100); + LibraryERM.SelectGenJnlBatch(GenJournalBatch); + LibraryERM.ClearGenJournalLines(GenJournalBatch); + LibraryERM.CreateGeneralJnlLineWithBalAcc(GenJournalLine, + GenJournalBatch."Journal Template Name", GenJournalBatch.Name, + GenJournalLine."Document Type"::Payment, GenJournalLine."Account Type"::Customer, Customer."No.", + GenJournalLine."Account Type"::"G/L Account", LibraryERM.CreateGLAccountNo(), -100); + GenJournalLine.Validate("Applies-to Doc. Type", GenJournalLine."Applies-to Doc. Type"::Invoice); + GenJournalLine.Validate("Applies-to Doc. No.", PostedInvoiceNo); + GenJournalLine.Modify(true); + LibraryERM.PostGeneralJnlLine(GenJournalLine); + + FindApplicationDetailedEntry(DetailedCustLedgEntry, PostedInvoiceNo); end; local procedure CreateServiceStatus(EDocument: Record "E-Document"; ServiceStatus: Enum "E-Document Service Status") @@ -829,13 +857,18 @@ codeunit 148151 "FR E-Invoice Message Tests" DetailedCustLedgEntry.Insert(); end; - local procedure GetNextCustLedgerEntryNo(): Integer + local procedure FindApplicationDetailedEntry(var DetailedCustLedgEntry: Record "Detailed Cust. Ledg. Entry"; InvoiceDocNo: Code[20]) var CustLedgerEntry: Record "Cust. Ledger Entry"; begin - if CustLedgerEntry.FindLast() then - exit(CustLedgerEntry."Entry No." + 1); - exit(1); + CustLedgerEntry.SetRange("Document Type", CustLedgerEntry."Document Type"::Invoice); + CustLedgerEntry.SetRange("Document No.", InvoiceDocNo); + CustLedgerEntry.FindFirst(); + DetailedCustLedgEntry.SetRange("Cust. Ledger Entry No.", CustLedgerEntry."Entry No."); + DetailedCustLedgEntry.SetRange("Entry Type", DetailedCustLedgEntry."Entry Type"::Application); + DetailedCustLedgEntry.SetRange("Initial Document Type", DetailedCustLedgEntry."Initial Document Type"::Invoice); + DetailedCustLedgEntry.SetFilter(Amount, '<%1', 0); + DetailedCustLedgEntry.FindLast(); end; local procedure GetNextDetailedLedgerEntryNo(): Integer diff --git a/src/Apps/W1/EDocument/App/src/Processing/Message/EDocMsgTransportDefault.Codeunit.al b/src/Apps/W1/EDocument/App/src/Processing/Message/EDocMsgTransportDefault.Codeunit.al index 4b146cc1b46..1a407bc3c75 100644 --- a/src/Apps/W1/EDocument/App/src/Processing/Message/EDocMsgTransportDefault.Codeunit.al +++ b/src/Apps/W1/EDocument/App/src/Processing/Message/EDocMsgTransportDefault.Codeunit.al @@ -14,10 +14,17 @@ codeunit 6534 "E-Doc. Msg. Transport Default" implements IMessageSender InherentPermissions = X; procedure SendMessage(var EDocument: Record "E-Document"; var EDocumentService: Record "E-Document Service"; MessageContext: Codeunit "E-Doc. Message Context") + var + MessageTransportErrorInfo: ErrorInfo; begin - Error(MessageTransportNotSupportedErr, EDocumentService.Code); + MessageTransportErrorInfo.Message := StrSubstNo(MessageTransportNotSupportedErr, EDocumentService.Code); + MessageTransportErrorInfo.RecordId := EDocumentService.RecordId; + MessageTransportErrorInfo.PageNo := Page::"E-Document Service"; + MessageTransportErrorInfo.AddNavigationAction(ShowEDocumentServiceLbl); + Error(MessageTransportErrorInfo); end; var MessageTransportNotSupportedErr: Label 'E-Document service %1 does not support sending E-Document messages.', Comment = '%1 = E-Document service code'; + ShowEDocumentServiceLbl: Label 'Open E-Document Service'; } \ No newline at end of file diff --git a/src/Apps/W1/EDocument/App/src/Processing/Message/EDocPaymentOccurrenceMgt.Codeunit.al b/src/Apps/W1/EDocument/App/src/Processing/Message/EDocPaymentOccurrenceMgt.Codeunit.al index 2114fec8103..5be2d3c7ffc 100644 --- a/src/Apps/W1/EDocument/App/src/Processing/Message/EDocPaymentOccurrenceMgt.Codeunit.al +++ b/src/Apps/W1/EDocument/App/src/Processing/Message/EDocPaymentOccurrenceMgt.Codeunit.al @@ -63,7 +63,7 @@ codeunit 6536 "E-Doc. Payment Occurrence Mgt." repeat CreateOccurrence( - EDocument, "E-Doc. Payment Occurrence Type"::Applied, DetailedCustLedgEntry.SystemId, + EDocument."Entry No", "E-Doc. Payment Occurrence Type"::Applied, DetailedCustLedgEntry.SystemId, -DetailedCustLedgEntry.Amount, DetailedCustLedgEntry."Currency Code", DetailedCustLedgEntry."Posting Date", DetailedCustLedgEntry."Entry No.", 0); until EDocument.Next() = 0; @@ -77,7 +77,6 @@ codeunit 6536 "E-Doc. Payment Occurrence Mgt." procedure ProcessUnapplication(OldDetailedCustLedgEntry: Record "Detailed Cust. Ledg. Entry"; NewDetailedCustLedgEntry: Record "Detailed Cust. Ledg. Entry") var AppliedOccurrence: Record "E-Doc. Payment Occurrence"; - EDocument: Record "E-Document"; begin if not IsInvoiceApplication(OldDetailedCustLedgEntry) then exit; @@ -88,26 +87,25 @@ codeunit 6536 "E-Doc. Payment Occurrence Mgt." exit; repeat - EDocument.Get(AppliedOccurrence."E-Document Entry No."); CreateOccurrence( - EDocument, "E-Doc. Payment Occurrence Type"::Reversed, NewDetailedCustLedgEntry.SystemId, + AppliedOccurrence."E-Document Entry No.", "E-Doc. Payment Occurrence Type"::Reversed, NewDetailedCustLedgEntry.SystemId, -AppliedOccurrence.Amount, AppliedOccurrence."Currency Code", NewDetailedCustLedgEntry."Posting Date", NewDetailedCustLedgEntry."Entry No.", AppliedOccurrence."Entry No."); until AppliedOccurrence.Next() = 0; end; - local procedure CreateOccurrence(EDocument: Record "E-Document"; OccurrenceType: Enum "E-Doc. Payment Occurrence Type"; SourceOccurrenceID: Guid; Amount: Decimal; CurrencyCode: Code[10]; EventDate: Date; DetailedLedgerEntryNo: Integer; OriginalOccurrenceEntryNo: Integer) + local procedure CreateOccurrence(EDocumentEntryNo: Integer; OccurrenceType: Enum "E-Doc. Payment Occurrence Type"; SourceOccurrenceID: Guid; Amount: Decimal; CurrencyCode: Code[10]; EventDate: Date; DetailedLedgerEntryNo: Integer; OriginalOccurrenceEntryNo: Integer) var EDocPaymentOccurrence: Record "E-Doc. Payment Occurrence"; begin - EDocPaymentOccurrence.SetRange("E-Document Entry No.", EDocument."Entry No"); + EDocPaymentOccurrence.SetRange("E-Document Entry No.", EDocumentEntryNo); EDocPaymentOccurrence.SetRange("Source Occurrence ID", SourceOccurrenceID); EDocPaymentOccurrence.SetRange(Type, OccurrenceType); if not EDocPaymentOccurrence.IsEmpty() then exit; EDocPaymentOccurrence.Init(); - EDocPaymentOccurrence."E-Document Entry No." := EDocument."Entry No"; + EDocPaymentOccurrence."E-Document Entry No." := EDocumentEntryNo; EDocPaymentOccurrence.Type := OccurrenceType; EDocPaymentOccurrence."Source Occurrence ID" := SourceOccurrenceID; EDocPaymentOccurrence."Original Occurrence Entry No." := OriginalOccurrenceEntryNo; @@ -129,6 +127,7 @@ codeunit 6536 "E-Doc. Payment Occurrence Mgt." if not SalesInvoiceHeader.Get(InvoiceCustLedgerEntry."Document No.") then exit(false); + EDocument.SetLoadFields("Entry No"); EDocument.SetRange("Document Record ID", SalesInvoiceHeader.RecordId); EDocument.SetRange(Direction, EDocument.Direction::Outgoing); EDocument.SetRange("Document Type", EDocument."Document Type"::"Sales Invoice"); From 6a85318e9f98a53df198df32e075c1b9addd5d6d Mon Sep 17 00:00:00 2001 From: djukicmilica Date: Fri, 21 Aug 2026 10:53:26 +0200 Subject: [PATCH 09/23] new --- .../app/Permissions/EReportingFRUser.PermissionSet.al | 2 +- .../EReportingFR/app/src/Core/FREInvoiceMessage.Table.al | 2 -- .../app/src/Core/FREInvoiceMessageAPI.Codeunit.al | 2 +- .../app/src/Core/FREInvoiceMessageBuilder.Codeunit.al | 4 ++-- 4 files changed, 4 insertions(+), 6 deletions(-) diff --git a/src/Apps/FR/EDocument_FR/EReportingFR/app/Permissions/EReportingFRUser.PermissionSet.al b/src/Apps/FR/EDocument_FR/EReportingFR/app/Permissions/EReportingFRUser.PermissionSet.al index a2ac9cd2a5e..774bb0eea17 100644 --- a/src/Apps/FR/EDocument_FR/EReportingFR/app/Permissions/EReportingFRUser.PermissionSet.al +++ b/src/Apps/FR/EDocument_FR/EReportingFR/app/Permissions/EReportingFRUser.PermissionSet.al @@ -6,7 +6,7 @@ namespace Microsoft.eServices.EDocument.Formats; using Microsoft.eServices.EDocument; -permissionset 10988 "E-Reporting FR - User" +permissionset 10988 "E-Reporting FR User" { Assignable = true; Caption = 'E-Reporting FR - User'; diff --git a/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/FREInvoiceMessage.Table.al b/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/FREInvoiceMessage.Table.al index 1a9d62e3a40..64911991e0c 100644 --- a/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/FREInvoiceMessage.Table.al +++ b/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/FREInvoiceMessage.Table.al @@ -5,7 +5,6 @@ namespace Microsoft.eServices.EDocument.Formats; using Microsoft.eServices.EDocument; -using Microsoft.eServices.EDocument.Processing.Message; table 10970 "FR E-Invoice Message" { @@ -81,7 +80,6 @@ table 10970 "FR E-Invoice Message" { Caption = 'E-Document Message Entry No.'; DataClassification = SystemMetadata; - TableRelation = "E-Document Message"."Entry No."; } field(13; "Created At"; DateTime) { diff --git a/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/FREInvoiceMessageAPI.Codeunit.al b/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/FREInvoiceMessageAPI.Codeunit.al index 38aaa194bd6..7568dd60e15 100644 --- a/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/FREInvoiceMessageAPI.Codeunit.al +++ b/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/FREInvoiceMessageAPI.Codeunit.al @@ -101,7 +101,7 @@ codeunit 10987 "FR E-Invoice Message API" exit(MessageType::Submitted); '205', 'ACCEPTED', 'ACCEPTÉE', 'ACCEPTEE', 'APPROUVÉE', 'APPROUVEE': exit(MessageType::Accepted); - 'REJECTED', 'TECHNICAL REJECTED', 'REJETÉE', 'REJETEE': + '213', 'REJECTED', 'TECHNICAL REJECTED', 'REJETÉE', 'REJETEE': exit(MessageType::"Technical Rejected"); '210', 'REFUSED', 'REFUSÉE', 'REFUSEE': exit(MessageType::Refused); diff --git a/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/FREInvoiceMessageBuilder.Codeunit.al b/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/FREInvoiceMessageBuilder.Codeunit.al index a7def2a5563..e1475aec66d 100644 --- a/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/FREInvoiceMessageBuilder.Codeunit.al +++ b/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/FREInvoiceMessageBuilder.Codeunit.al @@ -35,7 +35,7 @@ codeunit 10976 "FR E-Invoice Message Builder" XmlElement.Create('ID', RamNamespaceTok, Format(FREInvoiceMessage."Source Occurrence ID")))); AcknowledgementElement := XmlElement.Create('AcknowledgementDocument', RsmNamespaceTok); - AddIssueDateTime(AcknowledgementElement, FREInvoiceMessage."Event Date"); + AddIssueDateTime(AcknowledgementElement, FREInvoiceMessage."Event Date"); ReferenceElement := XmlElement.Create('ReferenceReferencedDocument', RamNamespaceTok); ReferenceElement.Add(XmlElement.Create('IssuerAssignedID', RamNamespaceTok, EDocument."Document No.")); ReferenceElement.Add(XmlElement.Create('StatusCode', RamNamespaceTok, InvoiceReferenceStatusCodeTok)); @@ -112,6 +112,6 @@ codeunit 10976 "FR E-Invoice Message Builder" RefusedStatusCodeTok: Label '210', Locked = true; RefusedStatusNameTok: Label 'Refusée', Locked = true; AcceptedStatusCodeTok: Label '205', Locked = true; - AcceptedStatusNameTok: Label 'Acceptée', Locked = true; + AcceptedStatusNameTok: Label 'Approuvée', Locked = true; UnsupportedMessageTypeErr: Label 'French invoice lifecycle message type %1 cannot be sent.', Comment = '%1 = French invoice lifecycle message type'; } \ No newline at end of file From 19a729bd5e0e30bd3fea4113080bb31b7c3b8645 Mon Sep 17 00:00:00 2001 From: djukicmilica Date: Fri, 21 Aug 2026 12:28:02 +0200 Subject: [PATCH 10/23] vat changes --- .../EReportingFRUser.PermissionSet.al | 2 + .../Core/FREInvoiceMessageBuilder.Codeunit.al | 43 +- .../src/Core/FREInvoiceMessageMgt.Codeunit.al | 236 ++++++++- .../src/Core/FREInvoiceMessageVAT.Table.al | 82 +++ .../src/FREInvoiceMessageTests.Codeunit.al | 479 +++++++++++++++++- 5 files changed, 828 insertions(+), 14 deletions(-) create mode 100644 src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/FREInvoiceMessageVAT.Table.al diff --git a/src/Apps/FR/EDocument_FR/EReportingFR/app/Permissions/EReportingFRUser.PermissionSet.al b/src/Apps/FR/EDocument_FR/EReportingFR/app/Permissions/EReportingFRUser.PermissionSet.al index 774bb0eea17..f1704d10295 100644 --- a/src/Apps/FR/EDocument_FR/EReportingFR/app/Permissions/EReportingFRUser.PermissionSet.al +++ b/src/Apps/FR/EDocument_FR/EReportingFR/app/Permissions/EReportingFRUser.PermissionSet.al @@ -16,6 +16,8 @@ permissionset 10988 "E-Reporting FR User" Permissions = table "FR E-Invoice Message" = X, tabledata "FR E-Invoice Message" = RIMD, + table "FR E-Invoice Message VAT" = X, + tabledata "FR E-Invoice Message VAT" = R, codeunit "FR E-Invoice Message Mgt." = X, codeunit "FR E-Invoice Message Builder" = X, codeunit "FR E-Invoice Message API" = X, diff --git a/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/FREInvoiceMessageBuilder.Codeunit.al b/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/FREInvoiceMessageBuilder.Codeunit.al index e1475aec66d..745f0583ebd 100644 --- a/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/FREInvoiceMessageBuilder.Codeunit.al +++ b/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/FREInvoiceMessageBuilder.Codeunit.al @@ -21,7 +21,6 @@ codeunit 10976 "FR E-Invoice Message Builder" AcknowledgementElement: XmlElement; ReferenceElement: XmlElement; StatusElement: XmlElement; - AmountElement: XmlElement; OutStream: OutStream; begin FREInvoiceMessage.TestField("Event Date"); @@ -59,12 +58,7 @@ codeunit 10976 "FR E-Invoice Message Builder" begin ReferenceElement.Add(XmlElement.Create('ProcessConditionCode', RamNamespaceTok, CollectedStatusCodeTok)); ReferenceElement.Add(XmlElement.Create('ProcessCondition', RamNamespaceTok, CollectedStatusNameTok)); - StatusElement := XmlElement.Create('SpecifiedDocumentStatus', RamNamespaceTok); - StatusElement.Add(XmlElement.Create('TypeCode', RamNamespaceTok, CollectedAmountTypeCodeTok)); - AmountElement := XmlElement.Create('ValueAmount', RamNamespaceTok, Format(FREInvoiceMessage.Amount, 0, 9)); - AmountElement.Add(XmlAttribute.Create('currencyID', ResolveCurrencyCode(FREInvoiceMessage."Currency Code"))); - StatusElement.Add(AmountElement); - ReferenceElement.Add(StatusElement); + AddVATBreakdown(ReferenceElement, FREInvoiceMessage); end; else Error(UnsupportedMessageTypeErr, FREInvoiceMessage.Type); @@ -77,6 +71,40 @@ codeunit 10976 "FR E-Invoice Message Builder" XmlDoc.WriteTo(OutStream); end; + local procedure AddVATBreakdown(var ReferenceElement: XmlElement; FREInvoiceMessage: Record "FR E-Invoice Message") + var + FREInvoiceMessageVAT: Record "FR E-Invoice Message VAT"; + StatusElement: XmlElement; + CurrencyCode: Code[10]; + begin + FREInvoiceMessageVAT.SetRange("Message Entry No.", FREInvoiceMessage."Entry No."); + if not FREInvoiceMessageVAT.FindSet() then + Error(VATBreakdownErr, FREInvoiceMessage."Entry No."); + + CurrencyCode := ResolveCurrencyCode(FREInvoiceMessage."Currency Code"); + StatusElement := XmlElement.Create('SpecifiedDocumentStatus', RamNamespaceTok); + repeat + StatusElement.Add(CreateVATCharacteristic(FREInvoiceMessageVAT, CurrencyCode)); + until FREInvoiceMessageVAT.Next() = 0; + ReferenceElement.Add(StatusElement); + end; + + local procedure CreateVATCharacteristic(FREInvoiceMessageVAT: Record "FR E-Invoice Message VAT"; CurrencyCode: Code[10]) CharacteristicElement: XmlElement + var + AmountElement: XmlElement; + ValueChangedElement: XmlElement; + begin + CharacteristicElement := XmlElement.Create('SpecifiedDocumentCharacteristic', RamNamespaceTok); + CharacteristicElement.Add(XmlElement.Create('TypeCode', RamNamespaceTok, CollectedAmountTypeCodeTok)); + ValueChangedElement := XmlElement.Create('ValueChangedIndicator', RamNamespaceTok); + ValueChangedElement.Add(XmlElement.Create('IndicatorString', UdtNamespaceTok, 'false')); + CharacteristicElement.Add(ValueChangedElement); + AmountElement := XmlElement.Create('ValueAmount', RamNamespaceTok, Format(FREInvoiceMessageVAT.Amount, 0, 9)); + AmountElement.Add(XmlAttribute.Create('currencyID', CurrencyCode)); + CharacteristicElement.Add(AmountElement); + CharacteristicElement.Add(XmlElement.Create('ValuePercent', RamNamespaceTok, Format(FREInvoiceMessageVAT."VAT %", 0, 9))); + end; + local procedure AddIssueDateTime(var AcknowledgementElement: XmlElement; EventDate: Date) var DateTimeStringElement: XmlElement; @@ -113,5 +141,6 @@ codeunit 10976 "FR E-Invoice Message Builder" RefusedStatusNameTok: Label 'Refusée', Locked = true; AcceptedStatusCodeTok: Label '205', Locked = true; AcceptedStatusNameTok: Label 'Approuvée', Locked = true; + VATBreakdownErr: Label 'French invoice message %1 does not have the VAT breakdown required for a collected status message.', Comment = '%1 = French invoice message entry number'; UnsupportedMessageTypeErr: Label 'French invoice lifecycle message type %1 cannot be sent.', Comment = '%1 = French invoice lifecycle message type'; } \ No newline at end of file diff --git a/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/FREInvoiceMessageMgt.Codeunit.al b/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/FREInvoiceMessageMgt.Codeunit.al index 96e662b785b..97989b107f3 100644 --- a/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/FREInvoiceMessageMgt.Codeunit.al +++ b/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/FREInvoiceMessageMgt.Codeunit.al @@ -6,6 +6,10 @@ namespace Microsoft.eServices.EDocument.Formats; using Microsoft.eServices.EDocument; using Microsoft.eServices.EDocument.Processing.Message; +using Microsoft.Finance.Currency; +using Microsoft.Finance.GeneralLedger.Setup; +using Microsoft.Finance.VAT.Ledger; +using Microsoft.Finance.VAT.Setup; using Microsoft.Sales.Receivables; using System.Utilities; @@ -15,6 +19,8 @@ codeunit 10975 "FR E-Invoice Message Mgt." InherentEntitlements = X; InherentPermissions = X; + Permissions = tabledata "FR E-Invoice Message VAT" = ri; + internal procedure AcceptInvoice(EDocument: Record "E-Document") begin CheckBuyerResponseAllowed(EDocument); @@ -58,6 +64,9 @@ codeunit 10975 "FR E-Invoice Message Mgt." exit; if EDocPaymentOccurrence.Type = EDocPaymentOccurrence.Type::Applied then begin + if not IsCollectedReportingRequired(EDocument, EDocPaymentOccurrence."Detailed Ledger Entry No.") then + exit; + CreateAndSendMessage( EDocument, "FR E-Invoice Message Type"::Collected, EDocPaymentOccurrence."Source Occurrence ID", EDocPaymentOccurrence.Amount, EDocPaymentOccurrence."Currency Code", EDocPaymentOccurrence."Event Date", @@ -75,7 +84,7 @@ codeunit 10975 "FR E-Invoice Message Mgt." CreateAndSendMessage( EDocument, "FR E-Invoice Message Type"::"Negative Collected", EDocPaymentOccurrence."Source Occurrence ID", - EDocPaymentOccurrence.Amount, EDocPaymentOccurrence."Currency Code", EDocPaymentOccurrence."Event Date", + -CollectedMessage.Amount, EDocPaymentOccurrence."Currency Code", EDocPaymentOccurrence."Event Date", EDocPaymentOccurrence."Detailed Ledger Entry No.", CollectedMessage."Entry No.", '', ''); end; @@ -106,6 +115,13 @@ codeunit 10975 "FR E-Invoice Message Mgt." FREInvoiceMessage."Created At" := CurrentDateTime(); FREInvoiceMessage.Insert(); + case MessageType of + MessageType::Collected: + CreateCollectedVATBreakdown(EDocument, FREInvoiceMessage); + MessageType::"Negative Collected": + CreateReversalVATBreakdown(FREInvoiceMessage, OriginalEntryNo); + end; + FREInvoiceMessageBuilder.BuildMessage(EDocument, FREInvoiceMessage, TempBlob); FREInvoiceMessage."E-Document Message Entry No." := EDocumentMessageAPI.CreateMessage( EDocument, "E-Document Message Type"::"FR Invoice Lifecycle", GetResponseType(MessageType), TempBlob); @@ -113,6 +129,206 @@ codeunit 10975 "FR E-Invoice Message Mgt." EDocumentMessageAPI.QueueMessage(FREInvoiceMessage."E-Document Message Entry No."); end; + local procedure CreateCollectedVATBreakdown(EDocument: Record "E-Document"; var FREInvoiceMessage: Record "FR E-Invoice Message") + var + VATEntry: Record "VAT Entry"; + VATPostingSetup: Record "VAT Posting Setup"; + AmountByVATKey: Dictionary of [Text, Decimal]; + VATCategoryByKey: Dictionary of [Text, Text]; + VATRateByKey: Dictionary of [Text, Decimal]; + VATKeys: List of [Text]; + CurrencyCode: Code[10]; + VATCategoryCode: Text; + VATKey: Text; + EligibleGrossAmount: Decimal; + GrossAmount: Decimal; + TotalGrossAmount: Decimal; + VATRate: Decimal; + begin + CurrencyCode := ResolveCurrencyCode(FREInvoiceMessage."Currency Code"); + FindInvoiceVATEntries(VATEntry, EDocument, FREInvoiceMessage."Detailed Ledger Entry No."); + VATEntry.SetLoadFields( + "VAT Bus. Posting Group", "VAT Prod. Posting Group", "Source Currency Code", + "Source Currency VAT Base", "Source Currency VAT Amount", Base, Amount, + "VAT Calculation Type", "Tax Jurisdiction Code", "Unrealized Amount", "Unrealized Base"); + if VATEntry.FindSet() then + repeat + GrossAmount := GetVATEntryGrossAmount(VATEntry, CurrencyCode); + TotalGrossAmount += GrossAmount; + if IsVATEntryReportable(VATEntry) then begin + VATPostingSetup.Get(VATEntry."VAT Bus. Posting Group", VATEntry."VAT Prod. Posting Group"); + VATRate := VATPostingSetup."VAT %"; + VATCategoryCode := VATPostingSetup."Tax Category"; + VATKey := GetVATAllocationKey(VATRate, VATCategoryCode); + AddVATAllocationBasis( + AmountByVATKey, VATRateByKey, VATCategoryByKey, VATKeys, + VATKey, VATRate, VATCategoryCode, GrossAmount); + EligibleGrossAmount += GrossAmount; + end; + until VATEntry.Next() = 0; + + if (VATKeys.Count() = 0) or (EligibleGrossAmount = 0) or (TotalGrossAmount = 0) then + Error(VATBreakdownErr, EDocument."Document No."); + + FREInvoiceMessage.Amount := Round( + FREInvoiceMessage.Amount * EligibleGrossAmount / TotalGrossAmount, + GetAmountRoundingPrecision(CurrencyCode)); + FREInvoiceMessage.Modify(); + InsertAllocatedVATAmounts( + FREInvoiceMessage, AmountByVATKey, VATRateByKey, VATCategoryByKey, + VATKeys, EligibleGrossAmount, CurrencyCode); + end; + + local procedure FindInvoiceVATEntries(var VATEntry: Record "VAT Entry"; EDocument: Record "E-Document"; DetailedLedgerEntryNo: Integer) + var + CustLedgerEntry: Record "Cust. Ledger Entry"; + DetailedCustLedgEntry: Record "Detailed Cust. Ledg. Entry"; + begin + DetailedCustLedgEntry.Get(DetailedLedgerEntryNo); + CustLedgerEntry.Get(DetailedCustLedgEntry."Cust. Ledger Entry No."); + VATEntry.SetRange(Type, VATEntry.Type::Sale); + VATEntry.SetRange("Document Type", VATEntry."Document Type"::Invoice); + VATEntry.SetRange("Document No.", EDocument."Document No."); + VATEntry.SetRange("Posting Date", EDocument."Posting Date"); + VATEntry.SetRange("Transaction No.", CustLedgerEntry."Transaction No."); + end; + + local procedure GetVATEntryGrossAmount(VATEntry: Record "VAT Entry"; CurrencyCode: Code[10]): Decimal + var + VATEntryCurrencyErrorInfo: ErrorInfo; + begin + if VATEntry."Source Currency Code" = CurrencyCode then + exit(-(VATEntry."Source Currency VAT Base" + VATEntry."Source Currency VAT Amount")); + if VATEntry."Source Currency Code" = '' then + exit(-(VATEntry.Base + VATEntry.Amount)); + + VATEntryCurrencyErrorInfo.ErrorType(ErrorType::Internal); + VATEntryCurrencyErrorInfo.Message(StrSubstNo(VATEntryCurrencyErr, VATEntry."Entry No.", CurrencyCode)); + Error(VATEntryCurrencyErrorInfo); + end; + + local procedure IsVATEntryReportable(VATEntry: Record "VAT Entry"): Boolean + begin + exit( + (VATEntry.GetUnrealizedVATType() > 0) and + ((VATEntry."Unrealized Amount" <> 0) or (VATEntry."Unrealized Base" <> 0))); + end; + + local procedure GetVATAllocationKey(VATRate: Decimal; VATCategoryCode: Text): Text + begin + exit(StrSubstNo('%1|%2', Format(VATRate, 0, 9), VATCategoryCode)); + end; + + local procedure AddVATAllocationBasis(var AmountByVATKey: Dictionary of [Text, Decimal]; var VATRateByKey: Dictionary of [Text, Decimal]; var VATCategoryByKey: Dictionary of [Text, Text]; var VATKeys: List of [Text]; VATKey: Text; VATRate: Decimal; VATCategoryCode: Text; GrossAmount: Decimal) + begin + if AmountByVATKey.ContainsKey(VATKey) then begin + AmountByVATKey.Set(VATKey, AmountByVATKey.Get(VATKey) + GrossAmount); + exit; + end; + + AmountByVATKey.Add(VATKey, GrossAmount); + VATRateByKey.Add(VATKey, VATRate); + VATCategoryByKey.Add(VATKey, VATCategoryCode); + InsertVATKeySorted(VATKeys, VATKey); + end; + + local procedure InsertVATKeySorted(var VATKeys: List of [Text]; VATKey: Text) + var + ExistingVATKey: Text; + Index: Integer; + begin + for Index := 1 to VATKeys.Count() do begin + VATKeys.Get(Index, ExistingVATKey); + if VATKey < ExistingVATKey then begin + VATKeys.Insert(Index, VATKey); + exit; + end; + end; + VATKeys.Add(VATKey); + end; + + local procedure InsertAllocatedVATAmounts(FREInvoiceMessage: Record "FR E-Invoice Message"; AmountByVATKey: Dictionary of [Text, Decimal]; VATRateByKey: Dictionary of [Text, Decimal]; VATCategoryByKey: Dictionary of [Text, Text]; VATKeys: List of [Text]; EligibleGrossAmount: Decimal; CurrencyCode: Code[10]) + var + FREInvoiceMessageVAT: Record "FR E-Invoice Message VAT"; + AllocatedAmount: Decimal; + RemainingAmount: Decimal; + RoundingPrecision: Decimal; + VATKey: Text; + LineNo: Integer; + begin + RoundingPrecision := GetAmountRoundingPrecision(CurrencyCode); + RemainingAmount := FREInvoiceMessage.Amount; + foreach VATKey in VATKeys do begin + LineNo += 10000; + if LineNo div 10000 = VATKeys.Count() then + AllocatedAmount := RemainingAmount + else begin + AllocatedAmount := Round( + FREInvoiceMessage.Amount * AmountByVATKey.Get(VATKey) / EligibleGrossAmount, + RoundingPrecision); + RemainingAmount -= AllocatedAmount; + end; + InsertVATBreakdown( + FREInvoiceMessageVAT, FREInvoiceMessage."Entry No.", LineNo, + VATRateByKey.Get(VATKey), VATCategoryByKey.Get(VATKey), AllocatedAmount, CurrencyCode); + end; + end; + + local procedure CreateReversalVATBreakdown(FREInvoiceMessage: Record "FR E-Invoice Message"; OriginalEntryNo: Integer) + var + OriginalMessageVAT: Record "FR E-Invoice Message VAT"; + ReversalMessageVAT: Record "FR E-Invoice Message VAT"; + begin + OriginalMessageVAT.SetRange("Message Entry No.", OriginalEntryNo); + if not OriginalMessageVAT.FindSet() then + Error(OriginalVATBreakdownErr, OriginalEntryNo); + + repeat + InsertVATBreakdown( + ReversalMessageVAT, FREInvoiceMessage."Entry No.", OriginalMessageVAT."Line No.", + OriginalMessageVAT."VAT %", OriginalMessageVAT."VAT Category Code", + -OriginalMessageVAT.Amount, FREInvoiceMessage."Currency Code"); + until OriginalMessageVAT.Next() = 0; + end; + + local procedure InsertVATBreakdown(var FREInvoiceMessageVAT: Record "FR E-Invoice Message VAT"; MessageEntryNo: Integer; LineNo: Integer; VATRate: Decimal; VATCategoryCode: Text; Amount: Decimal; CurrencyCode: Code[10]) + begin + FREInvoiceMessageVAT.Init(); + FREInvoiceMessageVAT."Message Entry No." := MessageEntryNo; + FREInvoiceMessageVAT."Line No." := LineNo; + FREInvoiceMessageVAT."VAT %" := VATRate; + FREInvoiceMessageVAT."VAT Category Code" := CopyStr(VATCategoryCode, 1, MaxStrLen(FREInvoiceMessageVAT."VAT Category Code")); + FREInvoiceMessageVAT.Amount := Amount; + FREInvoiceMessageVAT."Currency Code" := CurrencyCode; + FREInvoiceMessageVAT.Insert(); + end; + + local procedure GetAmountRoundingPrecision(CurrencyCode: Code[10]): Decimal + var + Currency: Record Currency; + GeneralLedgerSetup: Record "General Ledger Setup"; + begin + GeneralLedgerSetup.Get(); + if CurrencyCode = GeneralLedgerSetup."LCY Code" then + exit(GeneralLedgerSetup."Amount Rounding Precision"); + + Currency.Get(CurrencyCode); + Currency.TestField("Amount Rounding Precision"); + exit(Currency."Amount Rounding Precision"); + end; + + local procedure ResolveCurrencyCode(CurrencyCode: Code[10]): Code[10] + var + GeneralLedgerSetup: Record "General Ledger Setup"; + begin + if CurrencyCode <> '' then + exit(CurrencyCode); + + GeneralLedgerSetup.Get(); + GeneralLedgerSetup.TestField("LCY Code"); + exit(GeneralLedgerSetup."LCY Code"); + end; + local procedure IsEligibleFrenchEDocument(EDocument: Record "E-Document"): Boolean var EDocumentService: Record "E-Document Service"; @@ -127,6 +343,21 @@ codeunit 10975 "FR E-Invoice Message Mgt." exit(EDocumentServiceStatus.Status in [EDocumentServiceStatus.Status::Approved, EDocumentServiceStatus.Status::Cleared]); end; + local procedure IsCollectedReportingRequired(EDocument: Record "E-Document"; DetailedLedgerEntryNo: Integer): Boolean + var + VATEntry: Record "VAT Entry"; + begin + FindInvoiceVATEntries(VATEntry, EDocument, DetailedLedgerEntryNo); + VATEntry.SetLoadFields("VAT Calculation Type", "Tax Jurisdiction Code", "VAT Bus. Posting Group", "VAT Prod. Posting Group", "Unrealized Amount", "Unrealized Base"); + if VATEntry.FindSet() then + repeat + if IsVATEntryReportable(VATEntry) then + exit(true); + until VATEntry.Next() = 0; + + exit(false); + end; + local procedure CheckBuyerResponseAllowed(EDocument: Record "E-Document") var FREInvoiceMessage: Record "FR E-Invoice Message"; @@ -157,4 +388,7 @@ codeunit 10975 "FR E-Invoice Message Mgt." ReasonCodeRequiredErr: Label 'A refusal reason code is required.'; ReasonDescriptionRequiredErr: Label 'A refusal reason description is required.'; AlreadyRespondedErr: Label 'Invoice %1 already has a buyer response.', Comment = '%1 = invoice number'; + VATBreakdownErr: Label 'A reportable VAT breakdown could not be determined for posted sales invoice %1.', Comment = '%1 = posted sales invoice number'; + VATEntryCurrencyErr: Label 'VAT entry %1 does not contain amounts in lifecycle currency %2.', Comment = '%1 = VAT entry number, %2 = currency code'; + OriginalVATBreakdownErr: Label 'The VAT breakdown for original French invoice message %1 does not exist.', Comment = '%1 = French invoice message entry number'; } \ No newline at end of file diff --git a/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/FREInvoiceMessageVAT.Table.al b/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/FREInvoiceMessageVAT.Table.al new file mode 100644 index 00000000000..4b6fcb4336e --- /dev/null +++ b/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/FREInvoiceMessageVAT.Table.al @@ -0,0 +1,82 @@ +// ------------------------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// ------------------------------------------------------------------------------------------------ +namespace Microsoft.eServices.EDocument.Formats; + +table 10971 "FR E-Invoice Message VAT" +{ + Caption = 'FR E-Invoice Message VAT'; + DataClassification = CustomerContent; + InherentEntitlements = X; + InherentPermissions = X; + ReplicateData = false; + + fields + { + field(1; "Message Entry No."; Integer) + { + Caption = 'Message Entry No.'; + DataClassification = SystemMetadata; + TableRelation = "FR E-Invoice Message"."Entry No."; + } + field(2; "Line No."; Integer) + { + Caption = 'Line No.'; + DataClassification = SystemMetadata; + } + field(3; "VAT %"; Decimal) + { + Caption = 'VAT %'; + DataClassification = CustomerContent; + DecimalPlaces = 0 : 5; + } + field(4; "VAT Category Code"; Code[10]) + { + Caption = 'VAT Category Code'; + DataClassification = CustomerContent; + } + field(5; Amount; Decimal) + { + AutoFormatExpression = Rec."Currency Code"; + AutoFormatType = 1; + Caption = 'Amount'; + DataClassification = CustomerContent; + } + field(6; "Currency Code"; Code[10]) + { + Caption = 'Currency Code'; + DataClassification = CustomerContent; + } + } + + keys + { + key(PK; "Message Entry No.", "Line No.") + { + Clustered = true; + } + key(VATBreakdown; "Message Entry No.", "VAT %", "VAT Category Code") + { + Unique = true; + } + } + + trigger OnModify() + begin + Error(ImmutableVATBreakdownErr); + end; + + trigger OnDelete() + begin + Error(ImmutableVATBreakdownErr); + end; + + trigger OnRename() + begin + Error(ImmutableVATBreakdownErr); + end; + + var + ImmutableVATBreakdownErr: Label 'A French electronic invoice message VAT breakdown cannot be changed.'; +} diff --git a/src/Apps/FR/EDocument_FR/EReportingFR/test/src/FREInvoiceMessageTests.Codeunit.al b/src/Apps/FR/EDocument_FR/EReportingFR/test/src/FREInvoiceMessageTests.Codeunit.al index 35ef0201902..c7f7b79438a 100644 --- a/src/Apps/FR/EDocument_FR/EReportingFR/test/src/FREInvoiceMessageTests.Codeunit.al +++ b/src/Apps/FR/EDocument_FR/EReportingFR/test/src/FREInvoiceMessageTests.Codeunit.al @@ -8,6 +8,7 @@ using Microsoft.eServices.EDocument; using Microsoft.eServices.EDocument.Formats; using Microsoft.eServices.EDocument.Processing.Message; using Microsoft.Finance.GeneralLedger.Journal; +using Microsoft.Finance.GeneralLedger.Setup; using Microsoft.Finance.VAT.Setup; using Microsoft.Foundation.Enums; using Microsoft.Sales.Customer; @@ -28,6 +29,8 @@ codeunit 148151 "FR E-Invoice Message Tests" tabledata "E-Document Service Status" = rimd, tabledata "E-Doc. Payment Occurrence" = rimd, tabledata "FR E-Invoice Message" = rimd, + tabledata "FR E-Invoice Message VAT" = r, + tabledata "General Ledger Setup" = rm, tabledata "Sales Invoice Header" = rimd; var @@ -63,13 +66,13 @@ codeunit 148151 "FR E-Invoice Message Tests" EDocPaymentOccurrence.SetRange(Type, EDocPaymentOccurrence.Type::Applied); Assert.RecordCount(EDocPaymentOccurrence, 1); EDocPaymentOccurrence.FindFirst(); - Assert.AreEqual(100, EDocPaymentOccurrence.Amount, 'The generic applied occurrence must carry a positive amount.'); + Assert.AreEqual(120, EDocPaymentOccurrence.Amount, 'The generic applied occurrence must carry a positive amount.'); Assert.AreEqual(0, MessageSenderMock.GetSendCount(), 'Payment posting must queue the message without invoking the connector.'); FREInvoiceMessage.FindFirst(); SendMessage(FREInvoiceMessage); Assert.AreEqual(1, MessageSenderMock.GetSendCount(), 'One Collected message must be sent.'); AssertPayloadStatus(MessageSenderMock.GetLastPayload(), '212'); - AssertPayloadAmount(MessageSenderMock.GetLastPayload(), 100, 'EUR'); + AssertPayloadAmount(MessageSenderMock.GetLastPayload(), 120, 'EUR'); AssertPayloadDateFormat(MessageSenderMock.GetLastPayload(), '204'); end; @@ -144,7 +147,7 @@ codeunit 148151 "FR E-Invoice Message Tests" CollectedMessage.SetRange(Type, CollectedMessage.Type::Collected); CollectedMessage.FindFirst(); SendMessage(CollectedMessage); - CreateDetailedLedgerEntry(NewDetailedCustLedgEntry, DetailedCustLedgEntry."Cust. Ledger Entry No.", DetailedCustLedgEntry."Applied Cust. Ledger Entry No.", -100); + CreateDetailedLedgerEntry(NewDetailedCustLedgEntry, DetailedCustLedgEntry."Cust. Ledger Entry No.", DetailedCustLedgEntry."Applied Cust. Ledger Entry No.", -120); FREInvoiceMessageMgt.ProcessUnapplication(DetailedCustLedgEntry, NewDetailedCustLedgEntry); @@ -164,7 +167,7 @@ codeunit 148151 "FR E-Invoice Message Tests" Assert.AreEqual(1, MessageSenderMock.GetSendCount(), 'Unapplication must queue the reversal without invoking the connector.'); SendMessage(NegativeMessage); Assert.AreEqual(2, MessageSenderMock.GetSendCount(), 'Collected and Negative Collected messages must be sent.'); - AssertPayloadAmount(MessageSenderMock.GetLastPayload(), -100, 'EUR'); + AssertPayloadAmount(MessageSenderMock.GetLastPayload(), -120, 'EUR'); end; [Test] @@ -275,6 +278,241 @@ codeunit 148151 "FR E-Invoice Message Tests" Assert.RecordCount(FREInvoiceMessage, 1); end; + [Test] + procedure SingleRateFullPaymentCreatesOneVATRow() + var + EDocument: Record "E-Document"; + FREInvoiceMessage: Record "FR E-Invoice Message"; + FREInvoiceMessageVAT: Record "FR E-Invoice Message VAT"; + DetailedCustLedgEntry: Record "Detailed Cust. Ledg. Entry"; + FREInvoiceMessageMgt: Codeunit "FR E-Invoice Message Mgt."; + begin + // [FEATURE] [AI test] + // [SCENARIO 647421] Single-rate full payment creates one frozen VAT row summing to reportable amount + Initialize(); + + // [GIVEN] An approved French E-Document with unrealized VAT at 20% and a full payment applied + CreatePaymentScenario(EDocument, DetailedCustLedgEntry, "E-Document Service Status"::Approved); + + // [WHEN] The payment application is processed + FREInvoiceMessageMgt.ProcessApplication(DetailedCustLedgEntry); + + // [THEN] One VAT row is created with amount equal to the message amount and the XML includes amount and rate + FREInvoiceMessage.SetRange("E-Document Entry No.", EDocument."Entry No"); + FREInvoiceMessage.SetRange(Type, FREInvoiceMessage.Type::Collected); + FREInvoiceMessage.FindFirst(); + FREInvoiceMessageVAT.SetRange("Message Entry No.", FREInvoiceMessage."Entry No."); + Assert.RecordCount(FREInvoiceMessageVAT, 1); + FREInvoiceMessageVAT.FindFirst(); + Assert.AreEqual(FREInvoiceMessage.Amount, FREInvoiceMessageVAT.Amount, 'VAT row amount must equal message amount.'); + Assert.AreEqual(20, FREInvoiceMessageVAT."VAT %", 'VAT rate must match the posting setup.'); + Assert.AreEqual('S', Format(FREInvoiceMessageVAT."VAT Category Code"), 'VAT category must be standard.'); + SendMessage(FREInvoiceMessage); + AssertPayloadVATCharacteristic(MessageSenderMock.GetLastPayload(), FREInvoiceMessage.Amount, 20); + end; + + [Test] + procedure SingleRatePartialPaymentAllocatesPartialAmount() + var + EDocument: Record "E-Document"; + FREInvoiceMessage: Record "FR E-Invoice Message"; + FREInvoiceMessageVAT: Record "FR E-Invoice Message VAT"; + DetailedCustLedgEntry: Record "Detailed Cust. Ledg. Entry"; + FREInvoiceMessageMgt: Codeunit "FR E-Invoice Message Mgt."; + begin + // [FEATURE] [AI test] + // [SCENARIO 647421] Single-rate partial payment allocates partial amount + Initialize(); + + // [GIVEN] An approved French E-Document with unrealized VAT at 20% and a partial payment of 60 applied + CreatePaymentScenarioWithAmount(EDocument, DetailedCustLedgEntry, "E-Document Service Status"::Approved, 60); + + // [WHEN] The payment application is processed + FREInvoiceMessageMgt.ProcessApplication(DetailedCustLedgEntry); + + // [THEN] The message amount and VAT row reflect the partial payment + FREInvoiceMessage.SetRange("E-Document Entry No.", EDocument."Entry No"); + FREInvoiceMessage.SetRange(Type, FREInvoiceMessage.Type::Collected); + FREInvoiceMessage.FindFirst(); + Assert.AreEqual(60, FREInvoiceMessage.Amount, 'Message amount must equal the partial payment.'); + FREInvoiceMessageVAT.SetRange("Message Entry No.", FREInvoiceMessage."Entry No."); + Assert.RecordCount(FREInvoiceMessageVAT, 1); + FREInvoiceMessageVAT.FindFirst(); + Assert.AreEqual(60, FREInvoiceMessageVAT.Amount, 'VAT row must carry the full partial payment.'); + end; + + [Test] + procedure MixedInvoiceReportsProportionalEligibleShare() + var + EDocument: Record "E-Document"; + FREInvoiceMessage: Record "FR E-Invoice Message"; + FREInvoiceMessageVAT: Record "FR E-Invoice Message VAT"; + DetailedCustLedgEntry: Record "Detailed Cust. Ledg. Entry"; + FREInvoiceMessageMgt: Codeunit "FR E-Invoice Message Mgt."; + begin + // [FEATURE] [AI test] + // [SCENARIO 647421] Mixed invoice with unrealized and realized VAT reports only proportional eligible share + Initialize(); + + // [GIVEN] An invoice with one unrealized-VAT line (20%, gross 120) and one realized-VAT line (10%, gross 110), full payment of 230 + CreateMixedVATPaymentScenario(EDocument, DetailedCustLedgEntry); + + // [WHEN] The payment application is processed + FREInvoiceMessageMgt.ProcessApplication(DetailedCustLedgEntry); + + // [THEN] The message amount reflects only the eligible gross share + FREInvoiceMessage.SetRange("E-Document Entry No.", EDocument."Entry No"); + FREInvoiceMessage.SetRange(Type, FREInvoiceMessage.Type::Collected); + FREInvoiceMessage.FindFirst(); + Assert.AreEqual(120, FREInvoiceMessage.Amount, 'Amount must reflect only the eligible gross share.'); + FREInvoiceMessageVAT.SetRange("Message Entry No.", FREInvoiceMessage."Entry No."); + Assert.RecordCount(FREInvoiceMessageVAT, 1); + FREInvoiceMessageVAT.FindFirst(); + Assert.AreEqual(20, FREInvoiceMessageVAT."VAT %", 'Only the unrealized VAT rate must appear.'); + end; + + [Test] + procedure MultiRatePaymentWithRoundingStoresExactSum() + var + EDocument: Record "E-Document"; + FREInvoiceMessage: Record "FR E-Invoice Message"; + FREInvoiceMessageVAT: Record "FR E-Invoice Message VAT"; + DetailedCustLedgEntry: Record "Detailed Cust. Ledg. Entry"; + FREInvoiceMessageMgt: Codeunit "FR E-Invoice Message Mgt."; + VATRowSum: Decimal; + begin + // [FEATURE] [AI test] + // [SCENARIO 647421] Multiple eligible VAT rates with rounding residue sum exactly to message amount + Initialize(); + + // [GIVEN] An invoice with three unrealized VAT rates (10%, 20%, 7%) and a partial payment of 99 causing rounding residue + CreateMultiRatePaymentScenario(EDocument, DetailedCustLedgEntry); + + // [WHEN] The payment application is processed + FREInvoiceMessageMgt.ProcessApplication(DetailedCustLedgEntry); + + // [THEN] The sum of VAT rows equals the message amount deterministically + FREInvoiceMessage.SetRange("E-Document Entry No.", EDocument."Entry No"); + FREInvoiceMessage.SetRange(Type, FREInvoiceMessage.Type::Collected); + FREInvoiceMessage.FindFirst(); + FREInvoiceMessageVAT.SetRange("Message Entry No.", FREInvoiceMessage."Entry No."); + Assert.RecordCount(FREInvoiceMessageVAT, 3); + FREInvoiceMessageVAT.FindSet(); + repeat + VATRowSum += FREInvoiceMessageVAT.Amount; + until FREInvoiceMessageVAT.Next() = 0; + Assert.AreEqual(FREInvoiceMessage.Amount, VATRowSum, 'Sum of VAT rows must equal message amount deterministically.'); + end; + + [Test] + procedure InvoiceWithoutUnrealizedVATCreatesNoCollected() + var + EDocument: Record "E-Document"; + EDocPaymentOccurrence: Record "E-Doc. Payment Occurrence"; + FREInvoiceMessage: Record "FR E-Invoice Message"; + DetailedCustLedgEntry: Record "Detailed Cust. Ledg. Entry"; + FREInvoiceMessageMgt: Codeunit "FR E-Invoice Message Mgt."; + begin + // [FEATURE] [AI test] + // [SCENARIO 647421] Invoice without unrealized VAT keeps generic payment occurrence but creates no FR Collected message + Initialize(); + + // [GIVEN] An approved French E-Document with ordinary (non-unrealized) VAT and an applied payment + CreateNormalVATPaymentScenario(EDocument, DetailedCustLedgEntry); + + // [WHEN] The payment application is processed + FREInvoiceMessageMgt.ProcessApplication(DetailedCustLedgEntry); + + // [THEN] A generic payment occurrence exists but no Collected message is created + EDocPaymentOccurrence.SetRange("E-Document Entry No.", EDocument."Entry No"); + EDocPaymentOccurrence.SetRange(Type, EDocPaymentOccurrence.Type::Applied); + Assert.RecordCount(EDocPaymentOccurrence, 1); + FREInvoiceMessage.SetRange("E-Document Entry No.", EDocument."Entry No"); + FREInvoiceMessage.SetRange(Type, FREInvoiceMessage.Type::Collected); + Assert.RecordCount(FREInvoiceMessage, 0); + end; + + [Test] + procedure ReversalCopiesFrozenRowsWithNegatedValues() + var + EDocument: Record "E-Document"; + CollectedMessage: Record "FR E-Invoice Message"; + NegativeMessage: Record "FR E-Invoice Message"; + OriginalVAT: Record "FR E-Invoice Message VAT"; + ReversalVAT: Record "FR E-Invoice Message VAT"; + DetailedCustLedgEntry: Record "Detailed Cust. Ledg. Entry"; + NewDetailedCustLedgEntry: Record "Detailed Cust. Ledg. Entry"; + VATPostingSetup: Record "VAT Posting Setup"; + FREInvoiceMessageMgt: Codeunit "FR E-Invoice Message Mgt."; + begin + // [FEATURE] [AI test] + // [SCENARIO 647421] Reversal copies original frozen rows with negated values even if VAT setup changes after original + Initialize(); + + // [GIVEN] A collected message with a frozen VAT breakdown + CreatePaymentScenario(EDocument, DetailedCustLedgEntry, "E-Document Service Status"::Approved); + FREInvoiceMessageMgt.ProcessApplication(DetailedCustLedgEntry); + CollectedMessage.SetRange("E-Document Entry No.", EDocument."Entry No"); + CollectedMessage.SetRange(Type, CollectedMessage.Type::Collected); + CollectedMessage.FindFirst(); + SendMessage(CollectedMessage); + OriginalVAT.SetRange("Message Entry No.", CollectedMessage."Entry No."); + OriginalVAT.FindFirst(); + + // [GIVEN] The VAT posting setup category is changed after the original message + VATPostingSetup.SetRange("Tax Category", 'S'); + VATPostingSetup.SetRange("Unrealized VAT Type", VATPostingSetup."Unrealized VAT Type"::Percentage); + VATPostingSetup.FindFirst(); + VATPostingSetup."Tax Category" := 'Z'; + VATPostingSetup.Modify(); + + // [WHEN] The payment is unapplied + CreateDetailedLedgerEntry(NewDetailedCustLedgEntry, DetailedCustLedgEntry."Cust. Ledger Entry No.", DetailedCustLedgEntry."Applied Cust. Ledger Entry No.", -120); + FREInvoiceMessageMgt.ProcessUnapplication(DetailedCustLedgEntry, NewDetailedCustLedgEntry); + + // [THEN] The reversal has the original frozen rate and negated amount + NegativeMessage.SetRange("E-Document Entry No.", EDocument."Entry No"); + NegativeMessage.SetRange(Type, NegativeMessage.Type::"Negative Collected"); + NegativeMessage.FindFirst(); + Assert.AreEqual(-CollectedMessage.Amount, NegativeMessage.Amount, 'Reversal amount must negate the original.'); + Assert.IsTrue(NegativeMessage.Amount < 0, 'Reversal message amount must be negative.'); + ReversalVAT.SetRange("Message Entry No.", NegativeMessage."Entry No."); + ReversalVAT.FindFirst(); + Assert.AreEqual(-OriginalVAT.Amount, ReversalVAT.Amount, 'Reversal VAT amount must negate the original.'); + Assert.AreEqual(OriginalVAT."VAT %", ReversalVAT."VAT %", 'Reversal must use frozen original rate not current setup.'); + Assert.AreEqual('S', Format(OriginalVAT."VAT Category Code"), 'Original category must be the original value.'); + Assert.AreEqual(OriginalVAT."VAT Category Code", ReversalVAT."VAT Category Code", 'Reversal must preserve frozen original category.'); + end; + + [Test] + procedure ReplayDoesNotDuplicateAllocationRows() + var + EDocument: Record "E-Document"; + FREInvoiceMessage: Record "FR E-Invoice Message"; + FREInvoiceMessageVAT: Record "FR E-Invoice Message VAT"; + DetailedCustLedgEntry: Record "Detailed Cust. Ledg. Entry"; + FREInvoiceMessageMgt: Codeunit "FR E-Invoice Message Mgt."; + begin + // [FEATURE] [AI test] + // [SCENARIO 647421] Replay is idempotent and does not duplicate allocation rows + Initialize(); + + // [GIVEN] An approved French E-Document with unrealized VAT + CreatePaymentScenario(EDocument, DetailedCustLedgEntry, "E-Document Service Status"::Approved); + + // [WHEN] The payment application is processed twice + FREInvoiceMessageMgt.ProcessApplication(DetailedCustLedgEntry); + FREInvoiceMessageMgt.ProcessApplication(DetailedCustLedgEntry); + + // [THEN] Only one message and one VAT row exist + FREInvoiceMessage.SetRange("E-Document Entry No.", EDocument."Entry No"); + FREInvoiceMessage.SetRange(Type, FREInvoiceMessage.Type::Collected); + Assert.RecordCount(FREInvoiceMessage, 1); + FREInvoiceMessage.FindFirst(); + FREInvoiceMessageVAT.SetRange("Message Entry No.", FREInvoiceMessage."Entry No."); + Assert.RecordCount(FREInvoiceMessageVAT, 1); + end; + [Test] procedure IncomingMessageIsCorrelatedAndDeduplicated() var @@ -734,15 +972,38 @@ codeunit 148151 "FR E-Invoice Message Tests" Assert.AreEqual(ExpectedReasonCode, ReasonCodeNode.AsXmlElement().InnerText(), 'The payload reason code is incorrect.'); end; + local procedure AssertPayloadVATCharacteristic(Payload: Text; ExpectedAmount: Decimal; ExpectedVATRate: Decimal) + var + XmlDoc: XmlDocument; + AmountNode: XmlNode; + RateNode: XmlNode; + ActualAmount: Decimal; + ActualRate: Decimal; + begin + Assert.IsTrue(XmlDocument.ReadFrom(Payload, XmlDoc), 'The payload must be valid XML.'); + Assert.IsTrue(XmlDoc.SelectSingleNode('//*[local-name()="ValueAmount"]', AmountNode), 'The payload must contain a value amount.'); + Assert.IsTrue(Evaluate(ActualAmount, AmountNode.AsXmlElement().InnerText(), 9), 'The characteristic amount must be valid.'); + Assert.AreEqual(ExpectedAmount, ActualAmount, 'The characteristic amount is incorrect.'); + Assert.IsTrue(XmlDoc.SelectSingleNode('//*[local-name()="ValuePercent"]', RateNode), 'The payload must contain a value percent.'); + Assert.IsTrue(Evaluate(ActualRate, RateNode.AsXmlElement().InnerText(), 9), 'The characteristic rate must be valid.'); + Assert.AreEqual(ExpectedVATRate, ActualRate, 'The characteristic VAT rate is incorrect.'); + end; + local procedure Initialize() var EDocPaymentOccurrence: Record "E-Doc. Payment Occurrence"; FREInvoiceMessage: Record "FR E-Invoice Message"; + GeneralLedgerSetup: Record "General Ledger Setup"; begin EDocPaymentOccurrence.DeleteAll(); FREInvoiceMessage.DeleteAll(); MessageSenderMock.Reset(); EnsureService(); + GeneralLedgerSetup.Get(); + if not GeneralLedgerSetup."Unrealized VAT" then begin + GeneralLedgerSetup."Unrealized VAT" := true; + GeneralLedgerSetup.Modify(); + end; end; local procedure EnsureService() @@ -779,6 +1040,11 @@ codeunit 148151 "FR E-Invoice Message Tests" end; local procedure CreatePaymentScenario(var EDocument: Record "E-Document"; var DetailedCustLedgEntry: Record "Detailed Cust. Ledg. Entry"; ServiceStatus: Enum "E-Document Service Status") + begin + CreatePaymentScenarioWithAmount(EDocument, DetailedCustLedgEntry, ServiceStatus, 120); + end; + + local procedure CreatePaymentScenarioWithAmount(var EDocument: Record "E-Document"; var DetailedCustLedgEntry: Record "Detailed Cust. Ledg. Entry"; ServiceStatus: Enum "E-Document Service Status"; PaymentAmount: Decimal) var Customer: Record Customer; EDocumentService: Record "E-Document Service"; @@ -793,7 +1059,11 @@ codeunit 148151 "FR E-Invoice Message Tests" EDocumentService.Get('FR-MESSAGE-MOCK'); EDocumentService."Document Format" := EDocumentService."Document Format"::Mock; EDocumentService.Modify(); - LibraryERM.CreateVATPostingSetupWithAccounts(VATPostingSetup, VATPostingSetup."VAT Calculation Type"::"Normal VAT", 0); + LibraryERM.CreateVATPostingSetupWithAccounts(VATPostingSetup, VATPostingSetup."VAT Calculation Type"::"Normal VAT", 20); + VATPostingSetup."Unrealized VAT Type" := VATPostingSetup."Unrealized VAT Type"::Percentage; + VATPostingSetup."Sales VAT Unreal. Account" := LibraryERM.CreateGLAccountNo(); + VATPostingSetup."Tax Category" := 'S'; + VATPostingSetup.Modify(true); LibrarySales.CreateCustomer(Customer); Customer.Validate("VAT Bus. Posting Group", VATPostingSetup."VAT Bus. Posting Group"); Customer.Modify(true); @@ -812,6 +1082,7 @@ codeunit 148151 "FR E-Invoice Message Tests" EDocument.Init(); EDocument."Document No." := PostedInvoiceNo; EDocument."Document Record ID" := SalesInvoiceHeader.RecordId; + EDocument."Posting Date" := SalesInvoiceHeader."Posting Date"; EDocument.Direction := EDocument.Direction::Outgoing; EDocument."Document Type" := EDocument."Document Type"::"Sales Invoice"; EDocument.Service := 'FR-MESSAGE-MOCK'; @@ -823,12 +1094,208 @@ codeunit 148151 "FR E-Invoice Message Tests" LibraryERM.CreateGeneralJnlLineWithBalAcc(GenJournalLine, GenJournalBatch."Journal Template Name", GenJournalBatch.Name, GenJournalLine."Document Type"::Payment, GenJournalLine."Account Type"::Customer, Customer."No.", - GenJournalLine."Account Type"::"G/L Account", LibraryERM.CreateGLAccountNo(), -100); + GenJournalLine."Account Type"::"G/L Account", LibraryERM.CreateGLAccountNo(), -PaymentAmount); + GenJournalLine.Validate("Applies-to Doc. Type", GenJournalLine."Applies-to Doc. Type"::Invoice); + GenJournalLine.Validate("Applies-to Doc. No.", PostedInvoiceNo); + GenJournalLine.Modify(true); + LibraryERM.PostGeneralJnlLine(GenJournalLine); + + FindApplicationDetailedEntry(DetailedCustLedgEntry, PostedInvoiceNo); + end; + + local procedure CreateMixedVATPaymentScenario(var EDocument: Record "E-Document"; var DetailedCustLedgEntry: Record "Detailed Cust. Ledg. Entry") + var + Customer: Record Customer; + EDocumentService: Record "E-Document Service"; + GenJournalBatch: Record "Gen. Journal Batch"; + GenJournalLine: Record "Gen. Journal Line"; + SalesHeader: Record "Sales Header"; + SalesInvoiceHeader: Record "Sales Invoice Header"; + SalesLine: Record "Sales Line"; + UnrealizedVATSetup: Record "VAT Posting Setup"; + NormalVATSetup: Record "VAT Posting Setup"; + PostedInvoiceNo: Code[20]; + begin + EDocumentService.Get('FR-MESSAGE-MOCK'); + EDocumentService."Document Format" := EDocumentService."Document Format"::Mock; + EDocumentService.Modify(); + LibraryERM.CreateVATPostingSetupWithAccounts(UnrealizedVATSetup, UnrealizedVATSetup."VAT Calculation Type"::"Normal VAT", 20); + UnrealizedVATSetup."Unrealized VAT Type" := UnrealizedVATSetup."Unrealized VAT Type"::Percentage; + UnrealizedVATSetup."Sales VAT Unreal. Account" := LibraryERM.CreateGLAccountNo(); + UnrealizedVATSetup."Tax Category" := 'S'; + UnrealizedVATSetup.Modify(true); + LibraryERM.CreateVATPostingSetupWithAccounts(NormalVATSetup, NormalVATSetup."VAT Calculation Type"::"Normal VAT", 10); + NormalVATSetup.Rename(UnrealizedVATSetup."VAT Bus. Posting Group", NormalVATSetup."VAT Prod. Posting Group"); + LibrarySales.CreateCustomer(Customer); + Customer.Validate("VAT Bus. Posting Group", UnrealizedVATSetup."VAT Bus. Posting Group"); + Customer.Modify(true); + + LibrarySales.CreateSalesHeader(SalesHeader, SalesHeader."Document Type"::Invoice, Customer."No."); + LibrarySales.CreateSalesLine(SalesLine, SalesHeader, SalesLine.Type::"G/L Account", + LibraryERM.CreateGLAccountWithVATPostingSetup(UnrealizedVATSetup, "General Posting Type"::Sale), 1); + SalesLine.Validate("Unit Price", 100); + SalesLine.Modify(true); + LibrarySales.CreateSalesLine(SalesLine, SalesHeader, SalesLine.Type::"G/L Account", + LibraryERM.CreateGLAccountWithVATPostingSetup(NormalVATSetup, "General Posting Type"::Sale), 1); + SalesLine.Validate("Unit Price", 100); + SalesLine.Modify(true); + PostedInvoiceNo := LibrarySales.PostSalesDocument(SalesHeader, true, true); + + EDocumentService."Document Format" := EDocumentService."Document Format"::"Peppol BIS 3.0 FR"; + EDocumentService.Modify(); + SalesInvoiceHeader.Get(PostedInvoiceNo); + EDocument.Init(); + EDocument."Document No." := PostedInvoiceNo; + EDocument."Document Record ID" := SalesInvoiceHeader.RecordId; + EDocument."Posting Date" := SalesInvoiceHeader."Posting Date"; + EDocument.Direction := EDocument.Direction::Outgoing; + EDocument."Document Type" := EDocument."Document Type"::"Sales Invoice"; + EDocument.Service := 'FR-MESSAGE-MOCK'; + EDocument.Insert(); + CreateServiceStatus(EDocument, "E-Document Service Status"::Approved); + + LibraryERM.SelectGenJnlBatch(GenJournalBatch); + LibraryERM.ClearGenJournalLines(GenJournalBatch); + LibraryERM.CreateGeneralJnlLineWithBalAcc(GenJournalLine, + GenJournalBatch."Journal Template Name", GenJournalBatch.Name, + GenJournalLine."Document Type"::Payment, GenJournalLine."Account Type"::Customer, Customer."No.", + GenJournalLine."Account Type"::"G/L Account", LibraryERM.CreateGLAccountNo(), -230); + GenJournalLine.Validate("Applies-to Doc. Type", GenJournalLine."Applies-to Doc. Type"::Invoice); + GenJournalLine.Validate("Applies-to Doc. No.", PostedInvoiceNo); + GenJournalLine.Modify(true); + LibraryERM.PostGeneralJnlLine(GenJournalLine); + FindApplicationDetailedEntry(DetailedCustLedgEntry, PostedInvoiceNo); + end; + + local procedure CreateMultiRatePaymentScenario(var EDocument: Record "E-Document"; var DetailedCustLedgEntry: Record "Detailed Cust. Ledg. Entry") + var + Customer: Record Customer; + EDocumentService: Record "E-Document Service"; + GenJournalBatch: Record "Gen. Journal Batch"; + GenJournalLine: Record "Gen. Journal Line"; + SalesHeader: Record "Sales Header"; + SalesInvoiceHeader: Record "Sales Invoice Header"; + SalesLine: Record "Sales Line"; + VATSetup10: Record "VAT Posting Setup"; + VATSetup20: Record "VAT Posting Setup"; + VATSetup7: Record "VAT Posting Setup"; + PostedInvoiceNo: Code[20]; + begin + EDocumentService.Get('FR-MESSAGE-MOCK'); + EDocumentService."Document Format" := EDocumentService."Document Format"::Mock; + EDocumentService.Modify(); + LibraryERM.CreateVATPostingSetupWithAccounts(VATSetup20, VATSetup20."VAT Calculation Type"::"Normal VAT", 20); + VATSetup20."Unrealized VAT Type" := VATSetup20."Unrealized VAT Type"::Percentage; + VATSetup20."Sales VAT Unreal. Account" := LibraryERM.CreateGLAccountNo(); + VATSetup20."Tax Category" := 'S'; + VATSetup20.Modify(true); + LibraryERM.CreateVATPostingSetupWithAccounts(VATSetup10, VATSetup10."VAT Calculation Type"::"Normal VAT", 10); + VATSetup10.Rename(VATSetup20."VAT Bus. Posting Group", VATSetup10."VAT Prod. Posting Group"); + VATSetup10."Unrealized VAT Type" := VATSetup10."Unrealized VAT Type"::Percentage; + VATSetup10."Sales VAT Unreal. Account" := LibraryERM.CreateGLAccountNo(); + VATSetup10."Tax Category" := 'S'; + VATSetup10.Modify(true); + LibraryERM.CreateVATPostingSetupWithAccounts(VATSetup7, VATSetup7."VAT Calculation Type"::"Normal VAT", 7); + VATSetup7.Rename(VATSetup20."VAT Bus. Posting Group", VATSetup7."VAT Prod. Posting Group"); + VATSetup7."Unrealized VAT Type" := VATSetup7."Unrealized VAT Type"::Percentage; + VATSetup7."Sales VAT Unreal. Account" := LibraryERM.CreateGLAccountNo(); + VATSetup7."Tax Category" := 'S'; + VATSetup7.Modify(true); + LibrarySales.CreateCustomer(Customer); + Customer.Validate("VAT Bus. Posting Group", VATSetup20."VAT Bus. Posting Group"); + Customer.Modify(true); + + LibrarySales.CreateSalesHeader(SalesHeader, SalesHeader."Document Type"::Invoice, Customer."No."); + LibrarySales.CreateSalesLine(SalesLine, SalesHeader, SalesLine.Type::"G/L Account", + LibraryERM.CreateGLAccountWithVATPostingSetup(VATSetup20, "General Posting Type"::Sale), 1); + SalesLine.Validate("Unit Price", 100); + SalesLine.Modify(true); + LibrarySales.CreateSalesLine(SalesLine, SalesHeader, SalesLine.Type::"G/L Account", + LibraryERM.CreateGLAccountWithVATPostingSetup(VATSetup10, "General Posting Type"::Sale), 1); + SalesLine.Validate("Unit Price", 100); + SalesLine.Modify(true); + LibrarySales.CreateSalesLine(SalesLine, SalesHeader, SalesLine.Type::"G/L Account", + LibraryERM.CreateGLAccountWithVATPostingSetup(VATSetup7, "General Posting Type"::Sale), 1); + SalesLine.Validate("Unit Price", 100); + SalesLine.Modify(true); + PostedInvoiceNo := LibrarySales.PostSalesDocument(SalesHeader, true, true); + + EDocumentService."Document Format" := EDocumentService."Document Format"::"Peppol BIS 3.0 FR"; + EDocumentService.Modify(); + SalesInvoiceHeader.Get(PostedInvoiceNo); + EDocument.Init(); + EDocument."Document No." := PostedInvoiceNo; + EDocument."Document Record ID" := SalesInvoiceHeader.RecordId; + EDocument."Posting Date" := SalesInvoiceHeader."Posting Date"; + EDocument.Direction := EDocument.Direction::Outgoing; + EDocument."Document Type" := EDocument."Document Type"::"Sales Invoice"; + EDocument.Service := 'FR-MESSAGE-MOCK'; + EDocument.Insert(); + CreateServiceStatus(EDocument, "E-Document Service Status"::Approved); + + LibraryERM.SelectGenJnlBatch(GenJournalBatch); + LibraryERM.ClearGenJournalLines(GenJournalBatch); + LibraryERM.CreateGeneralJnlLineWithBalAcc(GenJournalLine, + GenJournalBatch."Journal Template Name", GenJournalBatch.Name, + GenJournalLine."Document Type"::Payment, GenJournalLine."Account Type"::Customer, Customer."No.", + GenJournalLine."Account Type"::"G/L Account", LibraryERM.CreateGLAccountNo(), -99); GenJournalLine.Validate("Applies-to Doc. Type", GenJournalLine."Applies-to Doc. Type"::Invoice); GenJournalLine.Validate("Applies-to Doc. No.", PostedInvoiceNo); GenJournalLine.Modify(true); LibraryERM.PostGeneralJnlLine(GenJournalLine); + FindApplicationDetailedEntry(DetailedCustLedgEntry, PostedInvoiceNo); + end; + local procedure CreateNormalVATPaymentScenario(var EDocument: Record "E-Document"; var DetailedCustLedgEntry: Record "Detailed Cust. Ledg. Entry") + var + Customer: Record Customer; + EDocumentService: Record "E-Document Service"; + GenJournalBatch: Record "Gen. Journal Batch"; + GenJournalLine: Record "Gen. Journal Line"; + SalesHeader: Record "Sales Header"; + SalesInvoiceHeader: Record "Sales Invoice Header"; + SalesLine: Record "Sales Line"; + VATPostingSetup: Record "VAT Posting Setup"; + PostedInvoiceNo: Code[20]; + begin + EDocumentService.Get('FR-MESSAGE-MOCK'); + EDocumentService."Document Format" := EDocumentService."Document Format"::Mock; + EDocumentService.Modify(); + LibraryERM.CreateVATPostingSetupWithAccounts(VATPostingSetup, VATPostingSetup."VAT Calculation Type"::"Normal VAT", 10); + LibrarySales.CreateCustomer(Customer); + Customer.Validate("VAT Bus. Posting Group", VATPostingSetup."VAT Bus. Posting Group"); + Customer.Modify(true); + + LibrarySales.CreateSalesHeader(SalesHeader, SalesHeader."Document Type"::Invoice, Customer."No."); + LibrarySales.CreateSalesLine(SalesLine, SalesHeader, SalesLine.Type::"G/L Account", + LibraryERM.CreateGLAccountWithVATPostingSetup(VATPostingSetup, "General Posting Type"::Sale), 1); + SalesLine.Validate("Unit Price", 100); + SalesLine.Modify(true); + PostedInvoiceNo := LibrarySales.PostSalesDocument(SalesHeader, true, true); + + EDocumentService."Document Format" := EDocumentService."Document Format"::"Peppol BIS 3.0 FR"; + EDocumentService.Modify(); + SalesInvoiceHeader.Get(PostedInvoiceNo); + EDocument.Init(); + EDocument."Document No." := PostedInvoiceNo; + EDocument."Document Record ID" := SalesInvoiceHeader.RecordId; + EDocument."Posting Date" := SalesInvoiceHeader."Posting Date"; + EDocument.Direction := EDocument.Direction::Outgoing; + EDocument."Document Type" := EDocument."Document Type"::"Sales Invoice"; + EDocument.Service := 'FR-MESSAGE-MOCK'; + EDocument.Insert(); + CreateServiceStatus(EDocument, "E-Document Service Status"::Approved); + + LibraryERM.SelectGenJnlBatch(GenJournalBatch); + LibraryERM.ClearGenJournalLines(GenJournalBatch); + LibraryERM.CreateGeneralJnlLineWithBalAcc(GenJournalLine, + GenJournalBatch."Journal Template Name", GenJournalBatch.Name, + GenJournalLine."Document Type"::Payment, GenJournalLine."Account Type"::Customer, Customer."No.", + GenJournalLine."Account Type"::"G/L Account", LibraryERM.CreateGLAccountNo(), -110); + GenJournalLine.Validate("Applies-to Doc. Type", GenJournalLine."Applies-to Doc. Type"::Invoice); + GenJournalLine.Validate("Applies-to Doc. No.", PostedInvoiceNo); + GenJournalLine.Modify(true); + LibraryERM.PostGeneralJnlLine(GenJournalLine); FindApplicationDetailedEntry(DetailedCustLedgEntry, PostedInvoiceNo); end; From 7a454d74b71f12c2c0b43a574a7039b4981aa937 Mon Sep 17 00:00:00 2001 From: djukicmilica Date: Fri, 21 Aug 2026 12:31:01 +0200 Subject: [PATCH 11/23] reason code upd --- .../Core/FREInvoiceMessageBuilder.Codeunit.al | 12 +++-- .../src/Core/FREInvoiceMessageMgt.Codeunit.al | 7 --- .../src/FREInvoiceMessageTests.Codeunit.al | 48 ++++++++----------- 3 files changed, 27 insertions(+), 40 deletions(-) diff --git a/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/FREInvoiceMessageBuilder.Codeunit.al b/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/FREInvoiceMessageBuilder.Codeunit.al index 745f0583ebd..d394802b8f5 100644 --- a/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/FREInvoiceMessageBuilder.Codeunit.al +++ b/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/FREInvoiceMessageBuilder.Codeunit.al @@ -48,10 +48,14 @@ codeunit 10976 "FR E-Invoice Message Builder" begin ReferenceElement.Add(XmlElement.Create('ProcessConditionCode', RamNamespaceTok, RefusedStatusCodeTok)); ReferenceElement.Add(XmlElement.Create('ProcessCondition', RamNamespaceTok, RefusedStatusNameTok)); - StatusElement := XmlElement.Create('SpecifiedDocumentStatus', RamNamespaceTok); - StatusElement.Add(XmlElement.Create('ReasonCode', RamNamespaceTok, FREInvoiceMessage."Reason Code")); - StatusElement.Add(XmlElement.Create('Reason', RamNamespaceTok, FREInvoiceMessage."Reason Description")); - ReferenceElement.Add(StatusElement); + if (FREInvoiceMessage."Reason Code" <> '') or (FREInvoiceMessage."Reason Description" <> '') then begin + StatusElement := XmlElement.Create('SpecifiedDocumentStatus', RamNamespaceTok); + if FREInvoiceMessage."Reason Code" <> '' then + StatusElement.Add(XmlElement.Create('ReasonCode', RamNamespaceTok, FREInvoiceMessage."Reason Code")); + if FREInvoiceMessage."Reason Description" <> '' then + StatusElement.Add(XmlElement.Create('Reason', RamNamespaceTok, FREInvoiceMessage."Reason Description")); + ReferenceElement.Add(StatusElement); + end; end; FREInvoiceMessage.Type::Collected, FREInvoiceMessage.Type::"Negative Collected": diff --git a/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/FREInvoiceMessageMgt.Codeunit.al b/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/FREInvoiceMessageMgt.Codeunit.al index 97989b107f3..d948acfa150 100644 --- a/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/FREInvoiceMessageMgt.Codeunit.al +++ b/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/FREInvoiceMessageMgt.Codeunit.al @@ -30,11 +30,6 @@ codeunit 10975 "FR E-Invoice Message Mgt." internal procedure RefuseInvoice(EDocument: Record "E-Document"; ReasonCode: Code[20]; ReasonDescription: Text[500]) begin CheckBuyerResponseAllowed(EDocument); - if ReasonCode = '' then - Error(ReasonCodeRequiredErr); - if ReasonDescription = '' then - Error(ReasonDescriptionRequiredErr); - CreateAndSendMessage(EDocument, "FR E-Invoice Message Type"::Refused, CreateGuid(), 0, '', Today(), 0, 0, ReasonCode, ReasonDescription); end; @@ -385,8 +380,6 @@ codeunit 10975 "FR E-Invoice Message Mgt." end; var - ReasonCodeRequiredErr: Label 'A refusal reason code is required.'; - ReasonDescriptionRequiredErr: Label 'A refusal reason description is required.'; AlreadyRespondedErr: Label 'Invoice %1 already has a buyer response.', Comment = '%1 = invoice number'; VATBreakdownErr: Label 'A reportable VAT breakdown could not be determined for posted sales invoice %1.', Comment = '%1 = posted sales invoice number'; VATEntryCurrencyErr: Label 'VAT entry %1 does not contain amounts in lifecycle currency %2.', Comment = '%1 = VAT entry number, %2 = currency code'; diff --git a/src/Apps/FR/EDocument_FR/EReportingFR/test/src/FREInvoiceMessageTests.Codeunit.al b/src/Apps/FR/EDocument_FR/EReportingFR/test/src/FREInvoiceMessageTests.Codeunit.al index c7f7b79438a..073478e14da 100644 --- a/src/Apps/FR/EDocument_FR/EReportingFR/test/src/FREInvoiceMessageTests.Codeunit.al +++ b/src/Apps/FR/EDocument_FR/EReportingFR/test/src/FREInvoiceMessageTests.Codeunit.al @@ -190,46 +190,25 @@ codeunit 148151 "FR E-Invoice Message Tests" end; [Test] - procedure RefusalRequiresReasonAndCannotBeRepeated() - var - EDocument: Record "E-Document"; - FREInvoiceMessageMgt: Codeunit "FR E-Invoice Message Mgt."; - begin - Initialize(); - CreateIncomingEDocument(EDocument); - - asserterror FREInvoiceMessageMgt.RefuseInvoice(EDocument, '', 'Not accepted.'); - Assert.ExpectedError('A refusal reason code is required.'); - Assert.ExpectedErrorCode('Dialog'); - Clear(EDocument); - CreateIncomingEDocument(EDocument); - FREInvoiceMessageMgt.RefuseInvoice(EDocument, 'OTHER', 'Not accepted.'); - SendFirstMessage(EDocument, "FR E-Invoice Message Type"::Refused); - asserterror FREInvoiceMessageMgt.RefuseInvoice(EDocument, 'OTHER', 'Again.'); - Assert.ExpectedError('already has a buyer response'); - Assert.ExpectedErrorCode('Dialog'); - Assert.AreEqual(1, MessageSenderMock.GetSendCount(), 'A duplicate refusal must not be sent.'); - end; - - [Test] - procedure RefusalRequiresReasonDescription() + procedure RefusalWithoutReasonSendsStatusWithoutReasonElements() var EDocument: Record "E-Document"; FREInvoiceMessageMgt: Codeunit "FR E-Invoice Message Mgt."; begin // [FEATURE] [AI test] - // [SCENARIO] A buyer refusal requires a reason description + // [SCENARIO] A buyer can refuse an invoice without providing a reason Initialize(); // [GIVEN] An incoming French purchase invoice CreateIncomingEDocument(EDocument); - // [WHEN] The invoice is refused without a reason description - asserterror FREInvoiceMessageMgt.RefuseInvoice(EDocument, 'OTHER', ''); + // [WHEN] The invoice is refused without a reason code or description + FREInvoiceMessageMgt.RefuseInvoice(EDocument, '', ''); + SendFirstMessage(EDocument, "FR E-Invoice Message Type"::Refused); - // [THEN] The refusal is rejected - Assert.ExpectedError('A refusal reason description is required.'); - Assert.ExpectedErrorCode('Dialog'); + // [THEN] The refusal status is sent without empty reason elements + AssertPayloadStatus(MessageSenderMock.GetLastPayload(), '210'); + AssertPayloadHasNoReason(MessageSenderMock.GetLastPayload()); end; [Test] @@ -972,6 +951,17 @@ codeunit 148151 "FR E-Invoice Message Tests" Assert.AreEqual(ExpectedReasonCode, ReasonCodeNode.AsXmlElement().InnerText(), 'The payload reason code is incorrect.'); end; + local procedure AssertPayloadHasNoReason(Payload: Text) + var + XmlDoc: XmlDocument; + ReasonNode: XmlNode; + begin + Assert.IsTrue(XmlDocument.ReadFrom(Payload, XmlDoc), 'The payload must be valid XML.'); + Assert.IsFalse(XmlDoc.SelectSingleNode('//*[local-name()="SpecifiedDocumentStatus"]', ReasonNode), 'The payload must not contain a document status when no refusal reason is provided.'); + Assert.IsFalse(XmlDoc.SelectSingleNode('//*[local-name()="ReasonCode"]', ReasonNode), 'The payload must not contain an empty reason code.'); + Assert.IsFalse(XmlDoc.SelectSingleNode('//*[local-name()="Reason"]', ReasonNode), 'The payload must not contain an empty reason description.'); + end; + local procedure AssertPayloadVATCharacteristic(Payload: Text; ExpectedAmount: Decimal; ExpectedVATRate: Decimal) var XmlDoc: XmlDocument; From dc578d299b5486b4d8e7b5aad9a8cfd21993dd7c Mon Sep 17 00:00:00 2001 From: djukicmilica Date: Fri, 21 Aug 2026 12:54:43 +0200 Subject: [PATCH 12/23] Task 647421 --- .../app/src/Core/FREInvoiceMessage.Table.al | 15 +++ .../src/Core/FREInvoiceMessageMgt.Codeunit.al | 28 ++++++ .../app/src/Core/FREInvoiceMessages.Page.al | 15 +++ .../EReportingEDocService.PageExt.al | 35 +++++++ .../EReportingEDocService.TableExt.al | 33 +++++++ .../src/FREInvoiceMessageTests.Codeunit.al | 91 +++++++++++++++++-- 6 files changed, 209 insertions(+), 8 deletions(-) create mode 100644 src/Apps/FR/EDocument_FR/EReportingFR/app/src/Extensions/EReportingEDocService.PageExt.al create mode 100644 src/Apps/FR/EDocument_FR/EReportingFR/app/src/Extensions/EReportingEDocService.TableExt.al diff --git a/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/FREInvoiceMessage.Table.al b/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/FREInvoiceMessage.Table.al index 64911991e0c..e920637fb00 100644 --- a/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/FREInvoiceMessage.Table.al +++ b/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/FREInvoiceMessage.Table.al @@ -96,6 +96,21 @@ table 10970 "FR E-Invoice Message" Caption = 'Received At'; DataClassification = SystemMetadata; } + field(16; "Sender Platform ID"; Text[50]) + { + Caption = 'Sender Platform ID'; + DataClassification = OrganizationIdentifiableInformation; + } + field(17; "Sender Platform Scheme"; Code[4]) + { + Caption = 'Sender Platform Scheme'; + DataClassification = SystemMetadata; + } + field(18; "Sender Platform Name"; Text[100]) + { + Caption = 'Sender Platform Name'; + DataClassification = OrganizationIdentifiableInformation; + } } keys diff --git a/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/FREInvoiceMessageMgt.Codeunit.al b/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/FREInvoiceMessageMgt.Codeunit.al index d948acfa150..47ad4264530 100644 --- a/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/FREInvoiceMessageMgt.Codeunit.al +++ b/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/FREInvoiceMessageMgt.Codeunit.al @@ -108,6 +108,12 @@ codeunit 10975 "FR E-Invoice Message Mgt." FREInvoiceMessage."Reason Code" := ReasonCode; FREInvoiceMessage."Reason Description" := ReasonDescription; FREInvoiceMessage."Created At" := CurrentDateTime(); + case MessageType of + MessageType::Collected: + FreezeSenderPlatform(EDocument, FREInvoiceMessage); + MessageType::"Negative Collected": + CopySenderPlatform(FREInvoiceMessage, OriginalEntryNo); + end; FREInvoiceMessage.Insert(); case MessageType of @@ -124,6 +130,28 @@ codeunit 10975 "FR E-Invoice Message Mgt." EDocumentMessageAPI.QueueMessage(FREInvoiceMessage."E-Document Message Entry No."); end; + local procedure FreezeSenderPlatform(EDocument: Record "E-Document"; var FREInvoiceMessage: Record "FR E-Invoice Message") + var + EDocumentService: Record "E-Document Service"; + begin + EDocumentService.Get(EDocument.Service); + if EDocumentService."FR Sender Platform ID" <> '' then + EDocumentService.TestField("FR Sender Platform Scheme"); + FREInvoiceMessage."Sender Platform ID" := EDocumentService."FR Sender Platform ID"; + FREInvoiceMessage."Sender Platform Scheme" := EDocumentService."FR Sender Platform Scheme"; + FREInvoiceMessage."Sender Platform Name" := EDocumentService."FR Sender Platform Name"; + end; + + local procedure CopySenderPlatform(var FREInvoiceMessage: Record "FR E-Invoice Message"; OriginalEntryNo: Integer) + var + OriginalFREInvoiceMessage: Record "FR E-Invoice Message"; + begin + OriginalFREInvoiceMessage.Get(OriginalEntryNo); + FREInvoiceMessage."Sender Platform ID" := OriginalFREInvoiceMessage."Sender Platform ID"; + FREInvoiceMessage."Sender Platform Scheme" := OriginalFREInvoiceMessage."Sender Platform Scheme"; + FREInvoiceMessage."Sender Platform Name" := OriginalFREInvoiceMessage."Sender Platform Name"; + end; + local procedure CreateCollectedVATBreakdown(EDocument: Record "E-Document"; var FREInvoiceMessage: Record "FR E-Invoice Message") var VATEntry: Record "VAT Entry"; diff --git a/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/FREInvoiceMessages.Page.al b/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/FREInvoiceMessages.Page.al index 697053d4b3c..5a9fd440091 100644 --- a/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/FREInvoiceMessages.Page.al +++ b/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/FREInvoiceMessages.Page.al @@ -78,6 +78,21 @@ page 10973 "FR E-Invoice Messages" ApplicationArea = Basic, Suite; ToolTip = 'Specifies when the incoming lifecycle message was received.'; } + field("Sender Platform ID"; Rec."Sender Platform ID") + { + ApplicationArea = Basic, Suite; + ToolTip = 'Specifies the frozen identifier of the sender platform.'; + } + field("Sender Platform Scheme"; Rec."Sender Platform Scheme") + { + ApplicationArea = Basic, Suite; + ToolTip = 'Specifies the frozen identifier scheme of the sender platform.'; + } + field("Sender Platform Name"; Rec."Sender Platform Name") + { + ApplicationArea = Basic, Suite; + ToolTip = 'Specifies the frozen name of the sender platform.'; + } field("Created At"; Rec."Created At") { ApplicationArea = Basic, Suite; diff --git a/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Extensions/EReportingEDocService.PageExt.al b/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Extensions/EReportingEDocService.PageExt.al new file mode 100644 index 00000000000..68120403a9f --- /dev/null +++ b/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Extensions/EReportingEDocService.PageExt.al @@ -0,0 +1,35 @@ +// ------------------------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// ------------------------------------------------------------------------------------------------ +namespace Microsoft.eServices.EDocument.Formats; + +using Microsoft.eServices.EDocument; + +pageextension 10977 "E-Reporting E-Doc. Service" extends "E-Document Service" +{ + layout + { + addlast(ExportProcessing) + { + group(FrenchLifecycle) + { + Caption = 'French Invoice Lifecycle'; + Visible = (Rec."Document Format" = Rec."Document Format"::"Peppol BIS 3.0 FR") or (Rec."Document Format" = Rec."Document Format"::"Factur-X FR"); + + field("FR Sender Platform ID"; Rec."FR Sender Platform ID") + { + ApplicationArea = Basic, Suite; + } + field("FR Sender Platform Scheme"; Rec."FR Sender Platform Scheme") + { + ApplicationArea = Basic, Suite; + } + field("FR Sender Platform Name"; Rec."FR Sender Platform Name") + { + ApplicationArea = Basic, Suite; + } + } + } + } +} \ No newline at end of file diff --git a/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Extensions/EReportingEDocService.TableExt.al b/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Extensions/EReportingEDocService.TableExt.al new file mode 100644 index 00000000000..52d0ad0a4d0 --- /dev/null +++ b/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Extensions/EReportingEDocService.TableExt.al @@ -0,0 +1,33 @@ +// ------------------------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// ------------------------------------------------------------------------------------------------ +namespace Microsoft.eServices.EDocument.Formats; + +using Microsoft.eServices.EDocument; + +tableextension 10977 "E-Reporting E-Doc. Service" extends "E-Document Service" +{ + fields + { + field(10970; "FR Sender Platform ID"; Text[50]) + { + Caption = 'FR Sender Platform ID'; + DataClassification = OrganizationIdentifiableInformation; + ToolTip = 'Specifies the identifier of the French approved platform that sends lifecycle messages.'; + } + field(10971; "FR Sender Platform Scheme"; Code[4]) + { + Caption = 'FR Sender Platform Scheme'; + DataClassification = SystemMetadata; + InitValue = '0238'; + ToolTip = 'Specifies the identifier scheme of the French approved platform.'; + } + field(10972; "FR Sender Platform Name"; Text[100]) + { + Caption = 'FR Sender Platform Name'; + DataClassification = OrganizationIdentifiableInformation; + ToolTip = 'Specifies the name of the French approved platform that sends lifecycle messages.'; + } + } +} \ No newline at end of file diff --git a/src/Apps/FR/EDocument_FR/EReportingFR/test/src/FREInvoiceMessageTests.Codeunit.al b/src/Apps/FR/EDocument_FR/EReportingFR/test/src/FREInvoiceMessageTests.Codeunit.al index 073478e14da..3b999ee6901 100644 --- a/src/Apps/FR/EDocument_FR/EReportingFR/test/src/FREInvoiceMessageTests.Codeunit.al +++ b/src/Apps/FR/EDocument_FR/EReportingFR/test/src/FREInvoiceMessageTests.Codeunit.al @@ -257,6 +257,68 @@ codeunit 148151 "FR E-Invoice Message Tests" Assert.RecordCount(FREInvoiceMessage, 1); end; + [Test] + procedure CollectedMessageFreezesSenderPlatform() + var + EDocument: Record "E-Document"; + EDocumentService: Record "E-Document Service"; + FREInvoiceMessage: Record "FR E-Invoice Message"; + DetailedCustLedgEntry: Record "Detailed Cust. Ledg. Entry"; + FREInvoiceMessageMgt: Codeunit "FR E-Invoice Message Mgt."; + begin + // [FEATURE] [AI test] + // [SCENARIO 647421] A Collected message retains the sender-platform identity captured from its service + Initialize(); + + // [GIVEN] An eligible payment and a French service with sender-platform identity + CreatePaymentScenario(EDocument, DetailedCustLedgEntry, "E-Document Service Status"::Approved); + + // [WHEN] The payment is processed and the service identity is subsequently changed + FREInvoiceMessageMgt.ProcessApplication(DetailedCustLedgEntry); + EDocumentService.Get(EDocument.Service); + EDocumentService."FR Sender Platform ID" := 'CHANGED-PLATFORM'; + EDocumentService."FR Sender Platform Scheme" := '9999'; + EDocumentService."FR Sender Platform Name" := 'Changed Platform'; + EDocumentService.Modify(); + + // [THEN] The message retains the original platform values + FREInvoiceMessage.SetRange("E-Document Entry No.", EDocument."Entry No"); + FREInvoiceMessage.SetRange(Type, FREInvoiceMessage.Type::Collected); + FREInvoiceMessage.FindFirst(); + Assert.AreEqual('TEST-PLATFORM', FREInvoiceMessage."Sender Platform ID", 'The sender-platform ID must be frozen at capture.'); + Assert.AreEqual('0238', FREInvoiceMessage."Sender Platform Scheme", 'The sender-platform scheme must be frozen at capture.'); + Assert.AreEqual('Test Platform', FREInvoiceMessage."Sender Platform Name", 'The sender-platform name must be frozen at capture.'); + end; + + [Test] + procedure CollectedMessageAllowsMissingSenderPlatformIdentity() + var + EDocument: Record "E-Document"; + EDocumentService: Record "E-Document Service"; + FREInvoiceMessage: Record "FR E-Invoice Message"; + DetailedCustLedgEntry: Record "Detailed Cust. Ledg. Entry"; + FREInvoiceMessageMgt: Codeunit "FR E-Invoice Message Mgt."; + begin + // [FEATURE] [AI test] + // [SCENARIO 647421] A Collected message supports a service without optional sender-platform identity + Initialize(); + + // [GIVEN] An eligible payment whose service has no sender-platform ID + CreatePaymentScenario(EDocument, DetailedCustLedgEntry, "E-Document Service Status"::Approved); + EDocumentService.Get(EDocument.Service); + Clear(EDocumentService."FR Sender Platform ID"); + EDocumentService.Modify(); + + // [WHEN] The payment is processed + FREInvoiceMessageMgt.ProcessApplication(DetailedCustLedgEntry); + + // [THEN] The message is created with no frozen platform identity + FREInvoiceMessage.SetRange("E-Document Entry No.", EDocument."Entry No"); + FREInvoiceMessage.SetRange(Type, FREInvoiceMessage.Type::Collected); + FREInvoiceMessage.FindFirst(); + Assert.AreEqual('', FREInvoiceMessage."Sender Platform ID", 'The optional sender-platform ID must remain blank.'); + end; + [Test] procedure SingleRateFullPaymentCreatesOneVATRow() var @@ -415,6 +477,7 @@ codeunit 148151 "FR E-Invoice Message Tests" procedure ReversalCopiesFrozenRowsWithNegatedValues() var EDocument: Record "E-Document"; + EDocumentService: Record "E-Document Service"; CollectedMessage: Record "FR E-Invoice Message"; NegativeMessage: Record "FR E-Invoice Message"; OriginalVAT: Record "FR E-Invoice Message VAT"; @@ -438,12 +501,17 @@ codeunit 148151 "FR E-Invoice Message Tests" OriginalVAT.SetRange("Message Entry No.", CollectedMessage."Entry No."); OriginalVAT.FindFirst(); - // [GIVEN] The VAT posting setup category is changed after the original message + // [GIVEN] VAT and sender-platform setup are changed after the original message VATPostingSetup.SetRange("Tax Category", 'S'); VATPostingSetup.SetRange("Unrealized VAT Type", VATPostingSetup."Unrealized VAT Type"::Percentage); VATPostingSetup.FindFirst(); VATPostingSetup."Tax Category" := 'Z'; VATPostingSetup.Modify(); + EDocumentService.Get(EDocument.Service); + EDocumentService."FR Sender Platform ID" := 'CHANGED-PLATFORM'; + EDocumentService."FR Sender Platform Scheme" := '9999'; + EDocumentService."FR Sender Platform Name" := 'Changed Platform'; + EDocumentService.Modify(); // [WHEN] The payment is unapplied CreateDetailedLedgerEntry(NewDetailedCustLedgEntry, DetailedCustLedgEntry."Cust. Ledger Entry No.", DetailedCustLedgEntry."Applied Cust. Ledger Entry No.", -120); @@ -461,6 +529,9 @@ codeunit 148151 "FR E-Invoice Message Tests" Assert.AreEqual(OriginalVAT."VAT %", ReversalVAT."VAT %", 'Reversal must use frozen original rate not current setup.'); Assert.AreEqual('S', Format(OriginalVAT."VAT Category Code"), 'Original category must be the original value.'); Assert.AreEqual(OriginalVAT."VAT Category Code", ReversalVAT."VAT Category Code", 'Reversal must preserve frozen original category.'); + Assert.AreEqual(CollectedMessage."Sender Platform ID", NegativeMessage."Sender Platform ID", 'Reversal must preserve the original sender-platform ID.'); + Assert.AreEqual(CollectedMessage."Sender Platform Scheme", NegativeMessage."Sender Platform Scheme", 'Reversal must preserve the original sender-platform scheme.'); + Assert.AreEqual(CollectedMessage."Sender Platform Name", NegativeMessage."Sender Platform Name", 'Reversal must preserve the original sender-platform name.'); end; [Test] @@ -1000,13 +1071,17 @@ codeunit 148151 "FR E-Invoice Message Tests" var EDocumentService: Record "E-Document Service"; begin - if EDocumentService.Get('FR-MESSAGE-MOCK') then - exit; - EDocumentService.Init(); - EDocumentService.Code := 'FR-MESSAGE-MOCK'; - EDocumentService."Document Format" := EDocumentService."Document Format"::"Peppol BIS 3.0 FR"; - EDocumentService."Service Integration V2" := EDocumentService."Service Integration V2"::"FR Message Mock"; - EDocumentService.Insert(); + if not EDocumentService.Get('FR-MESSAGE-MOCK') then begin + EDocumentService.Init(); + EDocumentService.Code := 'FR-MESSAGE-MOCK'; + EDocumentService."Document Format" := EDocumentService."Document Format"::"Peppol BIS 3.0 FR"; + EDocumentService."Service Integration V2" := EDocumentService."Service Integration V2"::"FR Message Mock"; + EDocumentService.Insert(); + end; + EDocumentService."FR Sender Platform ID" := 'TEST-PLATFORM'; + EDocumentService."FR Sender Platform Scheme" := '0238'; + EDocumentService."FR Sender Platform Name" := 'Test Platform'; + EDocumentService.Modify(); end; local procedure CreateIncomingEDocument(var EDocument: Record "E-Document") From c55c5d4487f4ed0d7174fd63e8ab5d53ee74659b Mon Sep 17 00:00:00 2001 From: djukicmilica Date: Fri, 21 Aug 2026 13:08:46 +0200 Subject: [PATCH 13/23] retry --- .../app/src/Core/FREInvoiceMessages.Page.al | 1 + .../Message/EDocMessageMgt.Codeunit.al | 16 +++ .../Message/EDocumentMessageAPI.Codeunit.al | 11 ++ .../Message/EDocumentMessagesFactBox.Page.al | 23 ++++ .../EDocMessageMgtTests.Codeunit.al | 109 ++++++++++++++++++ 5 files changed, 160 insertions(+) diff --git a/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/FREInvoiceMessages.Page.al b/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/FREInvoiceMessages.Page.al index 5a9fd440091..686567f1b68 100644 --- a/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/FREInvoiceMessages.Page.al +++ b/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/FREInvoiceMessages.Page.al @@ -101,4 +101,5 @@ page 10973 "FR E-Invoice Messages" } } } + } \ No newline at end of file diff --git a/src/Apps/W1/EDocument/App/src/Processing/Message/EDocMessageMgt.Codeunit.al b/src/Apps/W1/EDocument/App/src/Processing/Message/EDocMessageMgt.Codeunit.al index a0960055650..025c5892ddc 100644 --- a/src/Apps/W1/EDocument/App/src/Processing/Message/EDocMessageMgt.Codeunit.al +++ b/src/Apps/W1/EDocument/App/src/Processing/Message/EDocMessageMgt.Codeunit.al @@ -169,6 +169,22 @@ codeunit 6433 "E-Doc. Message Mgt." EDocumentBackgroundJobs.ScheduleMessageSend(EDocMessage); end; + procedure RetryMessage(MessageEntryNo: Integer) + var + EDocMessage: Record "E-Document Message"; + EDocumentBackgroundJobs: Codeunit "E-Document Background Jobs"; + begin + EDocMessage.Get(MessageEntryNo); + EDocMessage.TestField(Direction, EDocMessage.Direction::Outgoing); + EDocMessage.TestField(Status, EDocMessage.Status::Error); + EDocMessage.TestField(Service); + + EDocMessage.Status := EDocMessage.Status::Queued; + EDocMessage.Modify(); + Commit(); + EDocumentBackgroundJobs.ScheduleMessageSend(EDocMessage); + end; + procedure RegisterExternalDocumentReference(EDocument: Record "E-Document"; ServiceCode: Code[20]; ExternalDocumentID: Text[250]) var EDocExternalReference: Record "E-Doc. External Reference"; diff --git a/src/Apps/W1/EDocument/App/src/Processing/Message/EDocumentMessageAPI.Codeunit.al b/src/Apps/W1/EDocument/App/src/Processing/Message/EDocumentMessageAPI.Codeunit.al index ad0cbc85a61..969c10e296c 100644 --- a/src/Apps/W1/EDocument/App/src/Processing/Message/EDocumentMessageAPI.Codeunit.al +++ b/src/Apps/W1/EDocument/App/src/Processing/Message/EDocumentMessageAPI.Codeunit.al @@ -97,6 +97,17 @@ codeunit 6532 "E-Document Message API" EDocMessageMgt.QueueMessage(MessageEntryNo); end; + /// + /// Requeues a failed outgoing E-Document message for background transmission using its stored payload. + /// + /// The entry number of the failed E-Document message to retry. + procedure RetryMessage(MessageEntryNo: Integer) + var + EDocMessageMgt: Codeunit "E-Doc. Message Mgt."; + begin + EDocMessageMgt.RetryMessage(MessageEntryNo); + end; + /// /// Associates an external service document identifier with an E-Document for later message correlation. /// diff --git a/src/Apps/W1/EDocument/App/src/Processing/Message/EDocumentMessagesFactBox.Page.al b/src/Apps/W1/EDocument/App/src/Processing/Message/EDocumentMessagesFactBox.Page.al index 3a3ef8576a8..8de33297620 100644 --- a/src/Apps/W1/EDocument/App/src/Processing/Message/EDocumentMessagesFactBox.Page.al +++ b/src/Apps/W1/EDocument/App/src/Processing/Message/EDocumentMessagesFactBox.Page.al @@ -86,6 +86,23 @@ page 6434 "E-Document Messages FactBox" { area(processing) { + action(Retry) + { + ApplicationArea = Basic, Suite; + Caption = 'Retry'; + ToolTip = 'Requeue the failed message for background transmission using its existing payload.'; + Image = Refresh; + Scope = Repeater; + Enabled = RetryEnabled; + + trigger OnAction() + var + EDocumentMessageAPI: Codeunit "E-Document Message API"; + begin + EDocumentMessageAPI.RetryMessage(Rec."Entry No."); + CurrPage.Update(false); + end; + } action(ViewXML) { ApplicationArea = Basic, Suite; @@ -112,7 +129,13 @@ page 6434 "E-Document Messages FactBox" } } + trigger OnAfterGetCurrRecord() + begin + RetryEnabled := (Rec.Direction = Rec.Direction::Outgoing) and (Rec.Status = Rec.Status::Error); + end; + var + RetryEnabled: Boolean; FileNameTok: Label 'E-Document_%1_Response_%2.xml', Comment = '%1 = E-Document number, %2 = human-readable response type', Locked = true; local procedure BuildFileName(): Text diff --git a/src/Apps/W1/EDocument/Test/src/Processing/EDocMessageMgtTests.Codeunit.al b/src/Apps/W1/EDocument/Test/src/Processing/EDocMessageMgtTests.Codeunit.al index 3d1deb9532f..dbea8a46ad7 100644 --- a/src/Apps/W1/EDocument/Test/src/Processing/EDocMessageMgtTests.Codeunit.al +++ b/src/Apps/W1/EDocument/Test/src/Processing/EDocMessageMgtTests.Codeunit.al @@ -60,6 +60,115 @@ codeunit 139899 "E-Doc. Message Mgt. Tests" Assert.RecordCount(JobQueueEntry, 1); end; + [Test] + procedure RetryMessageRequeuesExistingMessage() + var + Customer: Record Customer; + EDocument: Record "E-Document"; + EDocMessage: Record "E-Document Message"; + JobQueueEntry: Record "Job Queue Entry"; + EDocMessageMgt: Codeunit "E-Doc. Message Mgt."; + EDocumentMessageAPI: Codeunit "E-Document Message API"; + TempBlob: Codeunit "Temp Blob"; + OutStream: OutStream; + DataStorageEntryNo: Integer; + MessageEntryNo: Integer; + begin + // [FEATURE] [AI test] + // [SCENARIO 647423] Retrying a failed outgoing message requeues the existing message without duplication + Initialize(Customer); + + // [GIVEN] A failed outgoing E-Document message with a stored payload + CreateOutgoingEDocument(EDocument); + TempBlob.CreateOutStream(OutStream, TextEncoding::UTF8); + OutStream.WriteText(''); + MessageEntryNo := EDocMessageMgt.CreateMessage( + EDocument, "E-Document Message Type"::Unspecified, "E-Document Direction"::Outgoing, + "E-Doc. Response Type"::None, TempBlob); + EDocMessage.Get(MessageEntryNo); + EDocMessage.Status := EDocMessage.Status::Error; + EDocMessage."Last Error" := 'Temporary transport failure'; + EDocMessage.Modify(); + DataStorageEntryNo := EDocMessage."Data Storage Entry No."; + + // [WHEN] The failed message is retried + EDocumentMessageAPI.RetryMessage(MessageEntryNo); + + // [THEN] The same message and payload are queued with one background send job + EDocMessage.Get(MessageEntryNo); + Assert.AreEqual("E-Doc. Message Status"::Queued, EDocMessage.Status, 'The existing message must be requeued.'); + Assert.AreEqual(DataStorageEntryNo, EDocMessage."Data Storage Entry No.", 'Retry must reuse the stored message payload.'); + Assert.RecordCount(EDocMessage, 1); + JobQueueEntry.SetRange("Object Type to Run", JobQueueEntry."Object Type to Run"::Codeunit); + JobQueueEntry.SetRange("Object ID to Run", Codeunit::"E-Doc. Message Send Job"); + JobQueueEntry.SetRange("Record ID to Process", EDocMessage.RecordId()); + Assert.RecordCount(JobQueueEntry, 1); + end; + + [Test] + procedure RetryMessageRejectsMessageWithoutError() + var + Customer: Record Customer; + EDocument: Record "E-Document"; + EDocumentMessageAPI: Codeunit "E-Document Message API"; + EDocMessageMgt: Codeunit "E-Doc. Message Mgt."; + TempBlob: Codeunit "Temp Blob"; + OutStream: OutStream; + MessageEntryNo: Integer; + begin + // [FEATURE] [AI test] + // [SCENARIO 647423] Retry rejects an outgoing message that is not in Error status + Initialize(Customer); + + // [GIVEN] A newly created outgoing E-Document message + CreateOutgoingEDocument(EDocument); + TempBlob.CreateOutStream(OutStream, TextEncoding::UTF8); + OutStream.WriteText(''); + MessageEntryNo := EDocMessageMgt.CreateMessage( + EDocument, "E-Document Message Type"::Unspecified, "E-Document Direction"::Outgoing, + "E-Doc. Response Type"::None, TempBlob); + + // [WHEN] The message is retried + asserterror EDocumentMessageAPI.RetryMessage(MessageEntryNo); + + // [THEN] Retry is rejected because the message has not failed + Assert.ExpectedError('Status must have the value Error'); + end; + + [Test] + procedure RetryMessageRejectsIncomingMessage() + var + Customer: Record Customer; + EDocument: Record "E-Document"; + EDocMessage: Record "E-Document Message"; + EDocumentMessageAPI: Codeunit "E-Document Message API"; + EDocMessageMgt: Codeunit "E-Doc. Message Mgt."; + TempBlob: Codeunit "Temp Blob"; + OutStream: OutStream; + MessageEntryNo: Integer; + begin + // [FEATURE] [AI test] + // [SCENARIO 647423] Retry rejects a failed incoming message + Initialize(Customer); + + // [GIVEN] A failed incoming E-Document message + CreateOutgoingEDocument(EDocument); + TempBlob.CreateOutStream(OutStream, TextEncoding::UTF8); + OutStream.WriteText(''); + MessageEntryNo := EDocMessageMgt.CreateMessage( + EDocument, "E-Document Message Type"::Unspecified, "E-Document Direction"::Incoming, + "E-Doc. Response Type"::None, TempBlob); + EDocMessage.Get(MessageEntryNo); + EDocMessage.Status := EDocMessage.Status::Error; + EDocMessage.Modify(); + + // [WHEN] The incoming message is retried + asserterror EDocumentMessageAPI.RetryMessage(MessageEntryNo); + + // [THEN] Retry is rejected because only outgoing messages can be sent + Assert.ExpectedError('Direction must have the value Outgoing'); + end; + local procedure Initialize(var Customer: Record Customer) var EDocument: Record "E-Document"; From 70ca47882b481c54ea98e946b1e405c1bb8303c7 Mon Sep 17 00:00:00 2001 From: djukicmilica Date: Fri, 21 Aug 2026 13:20:34 +0200 Subject: [PATCH 14/23] PPF context changes --- .../app/src/Core/FREInvoiceMessage.Table.al | 25 +++ .../Core/FREInvoiceMessageBuilder.Codeunit.al | 143 +++++++++++++++++- .../src/Core/FREInvoiceMessageMgt.Codeunit.al | 25 ++- .../app/src/Core/FREInvoiceMessages.Page.al | 25 +++ .../src/FREInvoiceMessageTests.Codeunit.al | 137 +++++++++++++++++ 5 files changed, 351 insertions(+), 4 deletions(-) diff --git a/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/FREInvoiceMessage.Table.al b/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/FREInvoiceMessage.Table.al index e920637fb00..b1538b6d033 100644 --- a/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/FREInvoiceMessage.Table.al +++ b/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/FREInvoiceMessage.Table.al @@ -111,6 +111,31 @@ table 10970 "FR E-Invoice Message" Caption = 'Sender Platform Name'; DataClassification = OrganizationIdentifiableInformation; } + field(19; "Invoice Issue Date"; Date) + { + Caption = 'Invoice Issue Date'; + DataClassification = CustomerContent; + } + field(20; "Invoice Receipt At"; DateTime) + { + Caption = 'Invoice Receipt At'; + DataClassification = CustomerContent; + } + field(21; "Invoice Issuer ID"; Text[50]) + { + Caption = 'Invoice Issuer ID'; + DataClassification = OrganizationIdentifiableInformation; + } + field(22; "Invoice Issuer Scheme"; Code[4]) + { + Caption = 'Invoice Issuer Scheme'; + DataClassification = SystemMetadata; + } + field(23; "Invoice Issuer Name"; Text[100]) + { + Caption = 'Invoice Issuer Name'; + DataClassification = OrganizationIdentifiableInformation; + } } keys diff --git a/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/FREInvoiceMessageBuilder.Codeunit.al b/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/FREInvoiceMessageBuilder.Codeunit.al index d394802b8f5..c3af9705b39 100644 --- a/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/FREInvoiceMessageBuilder.Codeunit.al +++ b/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/FREInvoiceMessageBuilder.Codeunit.al @@ -24,20 +24,31 @@ codeunit 10976 "FR E-Invoice Message Builder" OutStream: OutStream; begin FREInvoiceMessage.TestField("Event Date"); + if IsPPFMessage(FREInvoiceMessage) then + ValidatePPFContext(FREInvoiceMessage); XmlDoc := XmlDocument.Create(); XmlDoc.SetDeclaration(XmlDeclaration.Create('1.0', 'UTF-8', 'no')); RootElement := XmlElement.Create('CrossDomainAcknowledgementAndResponse', RsmNamespaceTok); RootElement.Add(XmlAttribute.CreateNamespaceDeclaration('ram', RamNamespaceTok)); + RootElement.Add(XmlAttribute.CreateNamespaceDeclaration('qdt', QdtNamespaceTok)); RootElement.Add(XmlAttribute.CreateNamespaceDeclaration('rsm', RsmNamespaceTok)); RootElement.Add(XmlAttribute.CreateNamespaceDeclaration('udt', UdtNamespaceTok)); - RootElement.Add(XmlElement.Create('ExchangedDocument', RsmNamespaceTok, - XmlElement.Create('ID', RamNamespaceTok, Format(FREInvoiceMessage."Source Occurrence ID")))); + AddExchangedDocumentContext(RootElement, FREInvoiceMessage); + AddExchangedDocument(RootElement, FREInvoiceMessage); AcknowledgementElement := XmlElement.Create('AcknowledgementDocument', RsmNamespaceTok); + AcknowledgementElement.Add(CreateIndicatorElement('MultipleReferencesIndicator', false)); + AcknowledgementElement.Add(XmlElement.Create('TypeCode', RamNamespaceTok, InformationTypeCodeTok)); AddIssueDateTime(AcknowledgementElement, FREInvoiceMessage."Event Date"); ReferenceElement := XmlElement.Create('ReferenceReferencedDocument', RamNamespaceTok); ReferenceElement.Add(XmlElement.Create('IssuerAssignedID', RamNamespaceTok, EDocument."Document No.")); ReferenceElement.Add(XmlElement.Create('StatusCode', RamNamespaceTok, InvoiceReferenceStatusCodeTok)); + ReferenceElement.Add(XmlElement.Create('TypeCode', RamNamespaceTok, InvoiceTypeCodeTok)); + if IsPPFMessage(FREInvoiceMessage) then begin + ReferenceElement.Add(CreateDateTimeElement('ReceiptDateTime', FREInvoiceMessage."Invoice Receipt At")); + ReferenceElement.Add(XmlElement.Create('ReferenceTypeCode', RamNamespaceTok, PPFInvoiceProfileTok)); + ReferenceElement.Add(CreateFormattedIssueDateTime(FREInvoiceMessage."Invoice Issue Date")); + end; case FREInvoiceMessage.Type of FREInvoiceMessage.Type::Accepted: begin @@ -62,6 +73,10 @@ codeunit 10976 "FR E-Invoice Message Builder" begin ReferenceElement.Add(XmlElement.Create('ProcessConditionCode', RamNamespaceTok, CollectedStatusCodeTok)); ReferenceElement.Add(XmlElement.Create('ProcessCondition', RamNamespaceTok, CollectedStatusNameTok)); + if IsPPFMessage(FREInvoiceMessage) then + ReferenceElement.Add( + CreateTradeParty( + 'IssuerTradeParty', FREInvoiceMessage."Invoice Issuer ID", FREInvoiceMessage."Invoice Issuer Scheme", '', '')); AddVATBreakdown(ReferenceElement, FREInvoiceMessage); end; else @@ -75,6 +90,56 @@ codeunit 10976 "FR E-Invoice Message Builder" XmlDoc.WriteTo(OutStream); end; + local procedure AddExchangedDocumentContext(var RootElement: XmlElement; FREInvoiceMessage: Record "FR E-Invoice Message") + var + BusinessProcessElement: XmlElement; + ContextElement: XmlElement; + GuidelineElement: XmlElement; + begin + ContextElement := XmlElement.Create('ExchangedDocumentContext', RsmNamespaceTok); + if not IsPPFMessage(FREInvoiceMessage) then begin + BusinessProcessElement := XmlElement.Create('BusinessProcessSpecifiedDocumentContextParameter', RamNamespaceTok); + BusinessProcessElement.Add(XmlElement.Create('ID', RamNamespaceTok, RegulatedBusinessProcessTok)); + ContextElement.Add(BusinessProcessElement); + end; + GuidelineElement := XmlElement.Create('GuidelineSpecifiedDocumentContextParameter', RamNamespaceTok); + GuidelineElement.Add(XmlElement.Create('ID', RamNamespaceTok, GetProfileID(FREInvoiceMessage))); + ContextElement.Add(GuidelineElement); + RootElement.Add(ContextElement); + end; + + local procedure AddExchangedDocument(var RootElement: XmlElement; FREInvoiceMessage: Record "FR E-Invoice Message") + var + ExchangedDocumentElement: XmlElement; + IssueDateTimeElement: XmlElement; + begin + ExchangedDocumentElement := XmlElement.Create('ExchangedDocument', RsmNamespaceTok); + ExchangedDocumentElement.Add(XmlElement.Create('ID', RamNamespaceTok, Format(FREInvoiceMessage."Source Occurrence ID"))); + ExchangedDocumentElement.Add(XmlElement.Create('Name', RamNamespaceTok, LifecycleMessageNameTok)); + IssueDateTimeElement := XmlElement.Create('IssueDateTime', RamNamespaceTok); + IssueDateTimeElement.Add(CreateDateTimeString(FREInvoiceMessage."Created At")); + ExchangedDocumentElement.Add(IssueDateTimeElement); + if IsPPFMessage(FREInvoiceMessage) then begin + ExchangedDocumentElement.Add( + CreateTradeParty( + 'SenderTradeParty', FREInvoiceMessage."Sender Platform ID", FREInvoiceMessage."Sender Platform Scheme", + FREInvoiceMessage."Sender Platform Name", SenderRoleCodeTok)); + ExchangedDocumentElement.Add( + CreateTradeParty( + 'IssuerTradeParty', FREInvoiceMessage."Invoice Issuer ID", FREInvoiceMessage."Invoice Issuer Scheme", + FREInvoiceMessage."Invoice Issuer Name", SellerRoleCodeTok)); + ExchangedDocumentElement.Add( + CreateTradeParty('RecipientTradeParty', PPFIdentifierTok, PPFIdentifierSchemeTok, PPFNameTok, PPFRoleCodeTok)); + end; + RootElement.Add(ExchangedDocumentElement); + end; + + local procedure CreateIndicatorElement(ElementName: Text; Value: Boolean) IndicatorElement: XmlElement + begin + IndicatorElement := XmlElement.Create(ElementName, RamNamespaceTok); + IndicatorElement.Add(XmlElement.Create('Indicator', UdtNamespaceTok, Format(Value, 0, 9).ToLower())); + end; + local procedure AddVATBreakdown(var ReferenceElement: XmlElement; FREInvoiceMessage: Record "FR E-Invoice Message") var FREInvoiceMessageVAT: Record "FR E-Invoice Message VAT"; @@ -121,6 +186,66 @@ codeunit 10976 "FR E-Invoice Message Builder" AcknowledgementElement.Add(IssueDateTimeElement); end; + local procedure CreateDateTimeString(Value: DateTime) DateTimeStringElement: XmlElement + begin + DateTimeStringElement := XmlElement.Create('DateTimeString', UdtNamespaceTok, Format(Value, 0, '')); + DateTimeStringElement.Add(XmlAttribute.Create('format', DateTimeFormatCodeTok)); + end; + + local procedure CreateDateTimeElement(ElementName: Text; Value: DateTime) DateTimeElement: XmlElement + begin + DateTimeElement := XmlElement.Create(ElementName, RamNamespaceTok); + DateTimeElement.Add(CreateDateTimeString(Value)); + end; + + local procedure CreateFormattedIssueDateTime(Value: Date) FormattedIssueDateTimeElement: XmlElement + var + DateTimeStringElement: XmlElement; + begin + FormattedIssueDateTimeElement := XmlElement.Create('FormattedIssueDateTime', RamNamespaceTok); + DateTimeStringElement := XmlElement.Create('DateTimeString', QdtNamespaceTok, Format(Value, 0, '')); + DateTimeStringElement.Add(XmlAttribute.Create('format', DateFormatCodeTok)); + FormattedIssueDateTimeElement.Add(DateTimeStringElement); + end; + + local procedure CreateTradeParty(ElementName: Text; Identifier: Text; IdentifierScheme: Text; PartyName: Text; RoleCode: Text) TradePartyElement: XmlElement + var + GlobalIDElement: XmlElement; + begin + TradePartyElement := XmlElement.Create(ElementName, RamNamespaceTok); + GlobalIDElement := XmlElement.Create('GlobalID', RamNamespaceTok, Identifier); + GlobalIDElement.Add(XmlAttribute.Create('schemeID', IdentifierScheme)); + TradePartyElement.Add(GlobalIDElement); + if PartyName <> '' then + TradePartyElement.Add(XmlElement.Create('Name', RamNamespaceTok, PartyName)); + if RoleCode <> '' then + TradePartyElement.Add(XmlElement.Create('RoleCode', RamNamespaceTok, RoleCode)); + end; + + local procedure IsPPFMessage(FREInvoiceMessage: Record "FR E-Invoice Message"): Boolean + begin + exit(FREInvoiceMessage."Sender Platform ID" <> ''); + end; + + local procedure GetProfileID(FREInvoiceMessage: Record "FR E-Invoice Message"): Text + begin + if IsPPFMessage(FREInvoiceMessage) then + exit(PPFInvoiceProfileTok); + exit(CDVInvoiceProfileTok); + end; + + local procedure ValidatePPFContext(FREInvoiceMessage: Record "FR E-Invoice Message") + begin + FREInvoiceMessage.TestField("Invoice Issue Date"); + FREInvoiceMessage.TestField("Invoice Receipt At"); + FREInvoiceMessage.TestField("Sender Platform ID"); + FREInvoiceMessage.TestField("Sender Platform Scheme"); + FREInvoiceMessage.TestField("Sender Platform Name"); + FREInvoiceMessage.TestField("Invoice Issuer ID"); + FREInvoiceMessage.TestField("Invoice Issuer Scheme"); + FREInvoiceMessage.TestField("Invoice Issuer Name"); + end; + local procedure ResolveCurrencyCode(CurrencyCode: Code[10]): Code[10] var GeneralLedgerSetup: Record "General Ledger Setup"; @@ -135,9 +260,17 @@ codeunit 10976 "FR E-Invoice Message Builder" var RsmNamespaceTok: Label 'urn:un:unece:uncefact:data:standard:CrossDomainAcknowledgementAndResponse:100', Locked = true; RamNamespaceTok: Label 'urn:un:unece:uncefact:data:standard:ReusableAggregateBusinessInformationEntity:100', Locked = true; + QdtNamespaceTok: Label 'urn:un:unece:uncefact:data:standard:QualifiedDataType:100', Locked = true; UdtNamespaceTok: Label 'urn:un:unece:uncefact:data:standard:UnqualifiedDataType:100', Locked = true; + RegulatedBusinessProcessTok: Label 'REGULATED', Locked = true; + CDVInvoiceProfileTok: Label 'urn.cpro.gouv.fr:1p0:CDV:invoice', Locked = true; + PPFInvoiceProfileTok: Label 'urn.cpro.gouv.fr:1p0:CDV:einvoicingF2', Locked = true; + LifecycleMessageNameTok: Label 'Invoice lifecycle message', Locked = true; + InformationTypeCodeTok: Label '23', Locked = true; DateTimeFormatCodeTok: Label '204', Locked = true; + DateFormatCodeTok: Label '102', Locked = true; InvoiceReferenceStatusCodeTok: Label '47', Locked = true; + InvoiceTypeCodeTok: Label '380', Locked = true; CollectedStatusCodeTok: Label '212', Locked = true; CollectedStatusNameTok: Label 'Encaissée', Locked = true; CollectedAmountTypeCodeTok: Label 'MEN', Locked = true; @@ -145,6 +278,12 @@ codeunit 10976 "FR E-Invoice Message Builder" RefusedStatusNameTok: Label 'Refusée', Locked = true; AcceptedStatusCodeTok: Label '205', Locked = true; AcceptedStatusNameTok: Label 'Approuvée', Locked = true; + SenderRoleCodeTok: Label 'WK', Locked = true; + SellerRoleCodeTok: Label 'SE', Locked = true; + PPFIdentifierTok: Label '9998', Locked = true; + PPFIdentifierSchemeTok: Label '0238', Locked = true; + PPFNameTok: Label 'PPF', Locked = true; + PPFRoleCodeTok: Label 'DFH', Locked = true; VATBreakdownErr: Label 'French invoice message %1 does not have the VAT breakdown required for a collected status message.', Comment = '%1 = French invoice message entry number'; UnsupportedMessageTypeErr: Label 'French invoice lifecycle message type %1 cannot be sent.', Comment = '%1 = French invoice lifecycle message type'; } \ No newline at end of file diff --git a/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/FREInvoiceMessageMgt.Codeunit.al b/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/FREInvoiceMessageMgt.Codeunit.al index 47ad4264530..1aa793cb737 100644 --- a/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/FREInvoiceMessageMgt.Codeunit.al +++ b/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/FREInvoiceMessageMgt.Codeunit.al @@ -10,6 +10,7 @@ using Microsoft.Finance.Currency; using Microsoft.Finance.GeneralLedger.Setup; using Microsoft.Finance.VAT.Ledger; using Microsoft.Finance.VAT.Setup; +using Microsoft.Foundation.Company; using Microsoft.Sales.Receivables; using System.Utilities; @@ -132,14 +133,28 @@ codeunit 10975 "FR E-Invoice Message Mgt." local procedure FreezeSenderPlatform(EDocument: Record "E-Document"; var FREInvoiceMessage: Record "FR E-Invoice Message") var + CompanyInformation: Record "Company Information"; EDocumentService: Record "E-Document Service"; begin EDocumentService.Get(EDocument.Service); - if EDocumentService."FR Sender Platform ID" <> '' then - EDocumentService.TestField("FR Sender Platform Scheme"); FREInvoiceMessage."Sender Platform ID" := EDocumentService."FR Sender Platform ID"; FREInvoiceMessage."Sender Platform Scheme" := EDocumentService."FR Sender Platform Scheme"; FREInvoiceMessage."Sender Platform Name" := EDocumentService."FR Sender Platform Name"; + if FREInvoiceMessage."Sender Platform ID" = '' then + exit; + + EDocument.TestField("Document Date"); + EDocument.TestField("Clearance Date"); + EDocumentService.TestField("FR Sender Platform Scheme"); + EDocumentService.TestField("FR Sender Platform Name"); + CompanyInformation.Get(); + CompanyInformation.TestField("Registration No."); + CompanyInformation.TestField(Name); + FREInvoiceMessage."Invoice Issue Date" := EDocument."Document Date"; + FREInvoiceMessage."Invoice Receipt At" := EDocument."Clearance Date"; + FREInvoiceMessage."Invoice Issuer ID" := CopyStr(CompanyInformation."Registration No.", 1, 9); + FREInvoiceMessage."Invoice Issuer Scheme" := SIRENSchemeTok; + FREInvoiceMessage."Invoice Issuer Name" := CompanyInformation.Name; end; local procedure CopySenderPlatform(var FREInvoiceMessage: Record "FR E-Invoice Message"; OriginalEntryNo: Integer) @@ -150,6 +165,11 @@ codeunit 10975 "FR E-Invoice Message Mgt." FREInvoiceMessage."Sender Platform ID" := OriginalFREInvoiceMessage."Sender Platform ID"; FREInvoiceMessage."Sender Platform Scheme" := OriginalFREInvoiceMessage."Sender Platform Scheme"; FREInvoiceMessage."Sender Platform Name" := OriginalFREInvoiceMessage."Sender Platform Name"; + FREInvoiceMessage."Invoice Issue Date" := OriginalFREInvoiceMessage."Invoice Issue Date"; + FREInvoiceMessage."Invoice Receipt At" := OriginalFREInvoiceMessage."Invoice Receipt At"; + FREInvoiceMessage."Invoice Issuer ID" := OriginalFREInvoiceMessage."Invoice Issuer ID"; + FREInvoiceMessage."Invoice Issuer Scheme" := OriginalFREInvoiceMessage."Invoice Issuer Scheme"; + FREInvoiceMessage."Invoice Issuer Name" := OriginalFREInvoiceMessage."Invoice Issuer Name"; end; local procedure CreateCollectedVATBreakdown(EDocument: Record "E-Document"; var FREInvoiceMessage: Record "FR E-Invoice Message") @@ -408,6 +428,7 @@ codeunit 10975 "FR E-Invoice Message Mgt." end; var + SIRENSchemeTok: Label '0002', Locked = true; AlreadyRespondedErr: Label 'Invoice %1 already has a buyer response.', Comment = '%1 = invoice number'; VATBreakdownErr: Label 'A reportable VAT breakdown could not be determined for posted sales invoice %1.', Comment = '%1 = posted sales invoice number'; VATEntryCurrencyErr: Label 'VAT entry %1 does not contain amounts in lifecycle currency %2.', Comment = '%1 = VAT entry number, %2 = currency code'; diff --git a/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/FREInvoiceMessages.Page.al b/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/FREInvoiceMessages.Page.al index 686567f1b68..d02103e2586 100644 --- a/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/FREInvoiceMessages.Page.al +++ b/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/FREInvoiceMessages.Page.al @@ -93,6 +93,31 @@ page 10973 "FR E-Invoice Messages" ApplicationArea = Basic, Suite; ToolTip = 'Specifies the frozen name of the sender platform.'; } + field("Invoice Issue Date"; Rec."Invoice Issue Date") + { + ApplicationArea = Basic, Suite; + ToolTip = 'Specifies the frozen issue date of the invoice.'; + } + field("Invoice Receipt At"; Rec."Invoice Receipt At") + { + ApplicationArea = Basic, Suite; + ToolTip = 'Specifies the frozen date and time when the sender platform received the invoice.'; + } + field("Invoice Issuer ID"; Rec."Invoice Issuer ID") + { + ApplicationArea = Basic, Suite; + ToolTip = 'Specifies the frozen SIREN identifier of the invoice issuer.'; + } + field("Invoice Issuer Scheme"; Rec."Invoice Issuer Scheme") + { + ApplicationArea = Basic, Suite; + ToolTip = 'Specifies the frozen identifier scheme of the invoice issuer.'; + } + field("Invoice Issuer Name"; Rec."Invoice Issuer Name") + { + ApplicationArea = Basic, Suite; + ToolTip = 'Specifies the frozen name of the invoice issuer.'; + } field("Created At"; Rec."Created At") { ApplicationArea = Basic, Suite; diff --git a/src/Apps/FR/EDocument_FR/EReportingFR/test/src/FREInvoiceMessageTests.Codeunit.al b/src/Apps/FR/EDocument_FR/EReportingFR/test/src/FREInvoiceMessageTests.Codeunit.al index 3b999ee6901..aaf8103bb05 100644 --- a/src/Apps/FR/EDocument_FR/EReportingFR/test/src/FREInvoiceMessageTests.Codeunit.al +++ b/src/Apps/FR/EDocument_FR/EReportingFR/test/src/FREInvoiceMessageTests.Codeunit.al @@ -10,6 +10,7 @@ using Microsoft.eServices.EDocument.Processing.Message; using Microsoft.Finance.GeneralLedger.Journal; using Microsoft.Finance.GeneralLedger.Setup; using Microsoft.Finance.VAT.Setup; +using Microsoft.Foundation.Company; using Microsoft.Foundation.Enums; using Microsoft.Sales.Customer; using Microsoft.Sales.Document; @@ -31,6 +32,7 @@ codeunit 148151 "FR E-Invoice Message Tests" tabledata "FR E-Invoice Message" = rimd, tabledata "FR E-Invoice Message VAT" = r, tabledata "General Ledger Setup" = rm, + tabledata "Company Information" = rm, tabledata "Sales Invoice Header" = rimd; var @@ -288,6 +290,37 @@ codeunit 148151 "FR E-Invoice Message Tests" Assert.AreEqual('TEST-PLATFORM', FREInvoiceMessage."Sender Platform ID", 'The sender-platform ID must be frozen at capture.'); Assert.AreEqual('0238', FREInvoiceMessage."Sender Platform Scheme", 'The sender-platform scheme must be frozen at capture.'); Assert.AreEqual('Test Platform', FREInvoiceMessage."Sender Platform Name", 'The sender-platform name must be frozen at capture.'); + Assert.AreEqual(EDocument."Document Date", FREInvoiceMessage."Invoice Issue Date", 'The invoice issue date must be frozen at capture.'); + Assert.AreEqual(EDocument."Clearance Date", FREInvoiceMessage."Invoice Receipt At", 'The platform receipt time must be frozen at capture.'); + Assert.AreEqual('123456789', FREInvoiceMessage."Invoice Issuer ID", 'The invoice issuer ID must be frozen at capture.'); + Assert.AreEqual('0002', FREInvoiceMessage."Invoice Issuer Scheme", 'The invoice issuer scheme must identify SIREN.'); + Assert.AreEqual('FR Test Issuer', FREInvoiceMessage."Invoice Issuer Name", 'The invoice issuer name must be frozen at capture.'); + end; + + [Test] + procedure CollectedMessageEmitsCompletePPFContext() + var + EDocument: Record "E-Document"; + FREInvoiceMessage: Record "FR E-Invoice Message"; + DetailedCustLedgEntry: Record "Detailed Cust. Ledg. Entry"; + FREInvoiceMessageMgt: Codeunit "FR E-Invoice Message Mgt."; + begin + // [FEATURE] [AI test] + // [SCENARIO 647421] A Collected message emits the complete PPF platform context + Initialize(); + + // [GIVEN] An eligible payment and a French service with sender-platform identity + CreatePaymentScenario(EDocument, DetailedCustLedgEntry, "E-Document Service Status"::Approved); + + // [WHEN] The payment is processed and its lifecycle message is sent + FREInvoiceMessageMgt.ProcessApplication(DetailedCustLedgEntry); + FREInvoiceMessage.SetRange("E-Document Entry No.", EDocument."Entry No"); + FREInvoiceMessage.SetRange(Type, FREInvoiceMessage.Type::Collected); + FREInvoiceMessage.FindFirst(); + SendMessage(FREInvoiceMessage); + + // [THEN] The payload contains the PPF profile, sender, issuer, recipient, and invoice dates + AssertPayloadPPFContext(MessageSenderMock.GetLastPayload(), EDocument); end; [Test] @@ -319,6 +352,36 @@ codeunit 148151 "FR E-Invoice Message Tests" Assert.AreEqual('', FREInvoiceMessage."Sender Platform ID", 'The optional sender-platform ID must remain blank.'); end; + [Test] + procedure CollectedMessageWithoutPlatformUsesCDVProfile() + var + EDocument: Record "E-Document"; + EDocumentService: Record "E-Document Service"; + FREInvoiceMessage: Record "FR E-Invoice Message"; + DetailedCustLedgEntry: Record "Detailed Cust. Ledg. Entry"; + FREInvoiceMessageMgt: Codeunit "FR E-Invoice Message Mgt."; + begin + // [FEATURE] [AI test] + // [SCENARIO 647421] A Collected message without platform identity retains the CDV profile + Initialize(); + + // [GIVEN] An eligible payment whose service has no sender-platform identity + CreatePaymentScenario(EDocument, DetailedCustLedgEntry, "E-Document Service Status"::Approved); + EDocumentService.Get(EDocument.Service); + Clear(EDocumentService."FR Sender Platform ID"); + EDocumentService.Modify(); + + // [WHEN] The payment is processed and its lifecycle message is sent + FREInvoiceMessageMgt.ProcessApplication(DetailedCustLedgEntry); + FREInvoiceMessage.SetRange("E-Document Entry No.", EDocument."Entry No"); + FREInvoiceMessage.SetRange(Type, FREInvoiceMessage.Type::Collected); + FREInvoiceMessage.FindFirst(); + SendMessage(FREInvoiceMessage); + + // [THEN] The payload uses the CDV profile and does not contain PPF trade parties + AssertPayloadCDVContext(MessageSenderMock.GetLastPayload()); + end; + [Test] procedure SingleRateFullPaymentCreatesOneVATRow() var @@ -476,6 +539,7 @@ codeunit 148151 "FR E-Invoice Message Tests" [Test] procedure ReversalCopiesFrozenRowsWithNegatedValues() var + CompanyInformation: Record "Company Information"; EDocument: Record "E-Document"; EDocumentService: Record "E-Document Service"; CollectedMessage: Record "FR E-Invoice Message"; @@ -512,6 +576,10 @@ codeunit 148151 "FR E-Invoice Message Tests" EDocumentService."FR Sender Platform Scheme" := '9999'; EDocumentService."FR Sender Platform Name" := 'Changed Platform'; EDocumentService.Modify(); + CompanyInformation.Get(); + CompanyInformation."Registration No." := '987654321'; + CompanyInformation.Name := 'Changed Issuer'; + CompanyInformation.Modify(); // [WHEN] The payment is unapplied CreateDetailedLedgerEntry(NewDetailedCustLedgEntry, DetailedCustLedgEntry."Cust. Ledger Entry No.", DetailedCustLedgEntry."Applied Cust. Ledger Entry No.", -120); @@ -532,6 +600,11 @@ codeunit 148151 "FR E-Invoice Message Tests" Assert.AreEqual(CollectedMessage."Sender Platform ID", NegativeMessage."Sender Platform ID", 'Reversal must preserve the original sender-platform ID.'); Assert.AreEqual(CollectedMessage."Sender Platform Scheme", NegativeMessage."Sender Platform Scheme", 'Reversal must preserve the original sender-platform scheme.'); Assert.AreEqual(CollectedMessage."Sender Platform Name", NegativeMessage."Sender Platform Name", 'Reversal must preserve the original sender-platform name.'); + Assert.AreEqual(CollectedMessage."Invoice Issue Date", NegativeMessage."Invoice Issue Date", 'Reversal must preserve the original invoice issue date.'); + Assert.AreEqual(CollectedMessage."Invoice Receipt At", NegativeMessage."Invoice Receipt At", 'Reversal must preserve the original platform receipt time.'); + Assert.AreEqual(CollectedMessage."Invoice Issuer ID", NegativeMessage."Invoice Issuer ID", 'Reversal must preserve the original invoice issuer ID.'); + Assert.AreEqual(CollectedMessage."Invoice Issuer Scheme", NegativeMessage."Invoice Issuer Scheme", 'Reversal must preserve the original invoice issuer scheme.'); + Assert.AreEqual(CollectedMessage."Invoice Issuer Name", NegativeMessage."Invoice Issuer Name", 'Reversal must preserve the original invoice issuer name.'); end; [Test] @@ -1050,6 +1123,51 @@ codeunit 148151 "FR E-Invoice Message Tests" Assert.AreEqual(ExpectedVATRate, ActualRate, 'The characteristic VAT rate is incorrect.'); end; + local procedure AssertPayloadPPFContext(Payload: Text; EDocument: Record "E-Document") + var + XmlDoc: XmlDocument; + XmlNode: XmlNode; + begin + Assert.IsTrue(XmlDocument.ReadFrom(Payload, XmlDoc), 'The payload must be valid XML.'); + Assert.IsTrue(XmlDoc.SelectSingleNode('//*[local-name()="GuidelineSpecifiedDocumentContextParameter"]/*[local-name()="ID"]', XmlNode), 'The payload must contain a guideline profile.'); + Assert.AreEqual('urn.cpro.gouv.fr:1p0:CDV:einvoicingF2', XmlNode.AsXmlElement().InnerText(), 'The payload must use the PPF invoice profile.'); + AssertTradeParty(XmlDoc, 'SenderTradeParty', 'TEST-PLATFORM', '0238', 'Test Platform', 'WK'); + AssertTradeParty(XmlDoc, 'IssuerTradeParty', '123456789', '0002', 'FR Test Issuer', 'SE'); + AssertTradeParty(XmlDoc, 'RecipientTradeParty', '9998', '0238', 'PPF', 'DFH'); + Assert.IsTrue(XmlDoc.SelectSingleNode('//*[local-name()="ReferenceReferencedDocument"]/*[local-name()="ReceiptDateTime"]', XmlNode), 'The payload must contain the platform receipt time.'); + Assert.IsTrue(XmlDoc.SelectSingleNode('//*[local-name()="ReferenceReferencedDocument"]/*[local-name()="FormattedIssueDateTime"]/*[local-name()="DateTimeString"]', XmlNode), 'The payload must contain the invoice issue date.'); + Assert.AreEqual(Format(EDocument."Document Date", 0, ''), XmlNode.AsXmlElement().InnerText(), 'The invoice issue date is incorrect.'); + end; + + local procedure AssertPayloadCDVContext(Payload: Text) + var + XmlDoc: XmlDocument; + XmlNode: XmlNode; + begin + Assert.IsTrue(XmlDocument.ReadFrom(Payload, XmlDoc), 'The payload must be valid XML.'); + Assert.IsTrue(XmlDoc.SelectSingleNode('//*[local-name()="GuidelineSpecifiedDocumentContextParameter"]/*[local-name()="ID"]', XmlNode), 'The payload must contain a guideline profile.'); + Assert.AreEqual('urn.cpro.gouv.fr:1p0:CDV:invoice', XmlNode.AsXmlElement().InnerText(), 'The payload must use the CDV invoice profile.'); + Assert.IsFalse(XmlDoc.SelectSingleNode('//*[local-name()="SenderTradeParty"]', XmlNode), 'The CDV payload must not contain a sender platform party.'); + Assert.IsFalse(XmlDoc.SelectSingleNode('//*[local-name()="RecipientTradeParty"]', XmlNode), 'The CDV payload must not contain a PPF recipient party.'); + end; + + local procedure AssertTradeParty(XmlDoc: XmlDocument; ElementName: Text; ExpectedID: Text; ExpectedScheme: Text; ExpectedName: Text; ExpectedRole: Text) + var + SchemeNode: XmlNode; + XmlNode: XmlNode; + PartyPath: Text; + begin + PartyPath := StrSubstNo('//*[local-name()="ExchangedDocument"]/*[local-name()="%1"]', ElementName); + Assert.IsTrue(XmlDoc.SelectSingleNode(PartyPath + '/*[local-name()="GlobalID"]', XmlNode), 'The payload must contain the expected trade-party ID.'); + Assert.AreEqual(ExpectedID, XmlNode.AsXmlElement().InnerText(), 'The trade-party ID is incorrect.'); + Assert.IsTrue(XmlDoc.SelectSingleNode(PartyPath + '/*[local-name()="GlobalID"]/@schemeID', SchemeNode), 'The trade-party ID must contain a scheme.'); + Assert.AreEqual(ExpectedScheme, SchemeNode.AsXmlAttribute().Value(), 'The trade-party scheme is incorrect.'); + Assert.IsTrue(XmlDoc.SelectSingleNode(PartyPath + '/*[local-name()="Name"]', XmlNode), 'The payload must contain the expected trade-party name.'); + Assert.AreEqual(ExpectedName, XmlNode.AsXmlElement().InnerText(), 'The trade-party name is incorrect.'); + Assert.IsTrue(XmlDoc.SelectSingleNode(PartyPath + '/*[local-name()="RoleCode"]', XmlNode), 'The payload must contain the expected trade-party role.'); + Assert.AreEqual(ExpectedRole, XmlNode.AsXmlElement().InnerText(), 'The trade-party role is incorrect.'); + end; + local procedure Initialize() var EDocPaymentOccurrence: Record "E-Doc. Payment Occurrence"; @@ -1060,6 +1178,7 @@ codeunit 148151 "FR E-Invoice Message Tests" FREInvoiceMessage.DeleteAll(); MessageSenderMock.Reset(); EnsureService(); + EnsureCompanyInformation(); GeneralLedgerSetup.Get(); if not GeneralLedgerSetup."Unrealized VAT" then begin GeneralLedgerSetup."Unrealized VAT" := true; @@ -1067,6 +1186,16 @@ codeunit 148151 "FR E-Invoice Message Tests" end; end; + local procedure EnsureCompanyInformation() + var + CompanyInformation: Record "Company Information"; + begin + CompanyInformation.Get(); + CompanyInformation."Registration No." := '123456789'; + CompanyInformation.Name := 'FR Test Issuer'; + CompanyInformation.Modify(); + end; + local procedure EnsureService() var EDocumentService: Record "E-Document Service"; @@ -1148,6 +1277,8 @@ codeunit 148151 "FR E-Invoice Message Tests" EDocument."Document No." := PostedInvoiceNo; EDocument."Document Record ID" := SalesInvoiceHeader.RecordId; EDocument."Posting Date" := SalesInvoiceHeader."Posting Date"; + EDocument."Document Date" := SalesInvoiceHeader."Document Date"; + EDocument."Clearance Date" := CurrentDateTime(); EDocument.Direction := EDocument.Direction::Outgoing; EDocument."Document Type" := EDocument."Document Type"::"Sales Invoice"; EDocument.Service := 'FR-MESSAGE-MOCK'; @@ -1213,6 +1344,8 @@ codeunit 148151 "FR E-Invoice Message Tests" EDocument."Document No." := PostedInvoiceNo; EDocument."Document Record ID" := SalesInvoiceHeader.RecordId; EDocument."Posting Date" := SalesInvoiceHeader."Posting Date"; + EDocument."Document Date" := SalesInvoiceHeader."Document Date"; + EDocument."Clearance Date" := CurrentDateTime(); EDocument.Direction := EDocument.Direction::Outgoing; EDocument."Document Type" := EDocument."Document Type"::"Sales Invoice"; EDocument.Service := 'FR-MESSAGE-MOCK'; @@ -1292,6 +1425,8 @@ codeunit 148151 "FR E-Invoice Message Tests" EDocument."Document No." := PostedInvoiceNo; EDocument."Document Record ID" := SalesInvoiceHeader.RecordId; EDocument."Posting Date" := SalesInvoiceHeader."Posting Date"; + EDocument."Document Date" := SalesInvoiceHeader."Document Date"; + EDocument."Clearance Date" := CurrentDateTime(); EDocument.Direction := EDocument.Direction::Outgoing; EDocument."Document Type" := EDocument."Document Type"::"Sales Invoice"; EDocument.Service := 'FR-MESSAGE-MOCK'; @@ -1345,6 +1480,8 @@ codeunit 148151 "FR E-Invoice Message Tests" EDocument."Document No." := PostedInvoiceNo; EDocument."Document Record ID" := SalesInvoiceHeader.RecordId; EDocument."Posting Date" := SalesInvoiceHeader."Posting Date"; + EDocument."Document Date" := SalesInvoiceHeader."Document Date"; + EDocument."Clearance Date" := CurrentDateTime(); EDocument.Direction := EDocument.Direction::Outgoing; EDocument."Document Type" := EDocument."Document Type"::"Sales Invoice"; EDocument.Service := 'FR-MESSAGE-MOCK'; From 80c8837620631892dc6f15771c0a509127e9f646 Mon Sep 17 00:00:00 2001 From: djukicmilica Date: Fri, 21 Aug 2026 13:37:34 +0200 Subject: [PATCH 15/23] Added Validatior, asynh --- .../EReportingFRUser.PermissionSet.al | 1 + .../src/Core/FREInvoiceMessageAPI.Codeunit.al | 36 +++ .../Core/FREInvoiceMessageBuilder.Codeunit.al | 2 + .../FREInvoiceProfileValidator.Codeunit.al | 132 ++++++++ .../src/FREInvoiceMessageTests.Codeunit.al | 298 ++++++++++++++++++ .../EDocCoreObjects.PermissionSet.al | 3 +- .../IMessageResponseHandler.Interface.al | 27 ++ .../Interfaces/IMessageSender.Interface.al | 3 +- .../Integration/ServiceIntegration.Enum.al | 8 +- .../EDocumentBackgroundJobs.Codeunit.al | 5 + .../Message/EDocMessageContext.Codeunit.al | 2 +- .../Message/EDocMessageMgt.Codeunit.al | 66 +++- .../EDocMessageResponseJob.Codeunit.al | 46 +++ .../Message/EDocMessageStatus.Enum.al | 8 + .../EDocMsgTransportDefault.Codeunit.al | 14 +- .../Message/EDocumentMessageAPI.Codeunit.al | 11 + .../Message/EDocumentMessagesFactBox.Page.al | 4 +- .../Mock/EDocIntegrationMockV2.Codeunit.al | 14 +- .../src/Mock/EDocIntegrationMockV2.EnumExt.al | 2 +- .../EDocMessageMgtTests.Codeunit.al | 178 ++++++++++- 20 files changed, 843 insertions(+), 17 deletions(-) create mode 100644 src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/FREInvoiceProfileValidator.Codeunit.al create mode 100644 src/Apps/W1/EDocument/App/src/Integration/Interfaces/IMessageResponseHandler.Interface.al create mode 100644 src/Apps/W1/EDocument/App/src/Processing/Message/EDocMessageResponseJob.Codeunit.al diff --git a/src/Apps/FR/EDocument_FR/EReportingFR/app/Permissions/EReportingFRUser.PermissionSet.al b/src/Apps/FR/EDocument_FR/EReportingFR/app/Permissions/EReportingFRUser.PermissionSet.al index f1704d10295..bb817cedc60 100644 --- a/src/Apps/FR/EDocument_FR/EReportingFR/app/Permissions/EReportingFRUser.PermissionSet.al +++ b/src/Apps/FR/EDocument_FR/EReportingFR/app/Permissions/EReportingFRUser.PermissionSet.al @@ -20,6 +20,7 @@ permissionset 10988 "E-Reporting FR User" tabledata "FR E-Invoice Message VAT" = R, codeunit "FR E-Invoice Message Mgt." = X, codeunit "FR E-Invoice Message Builder" = X, + codeunit "FR E-Invoice Profile Validator" = X, codeunit "FR E-Invoice Message API" = X, page "FR E-Invoice Refusal Dialog" = X, page "FR E-Invoice Messages" = X; diff --git a/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/FREInvoiceMessageAPI.Codeunit.al b/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/FREInvoiceMessageAPI.Codeunit.al index 7568dd60e15..a529ae840f0 100644 --- a/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/FREInvoiceMessageAPI.Codeunit.al +++ b/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/FREInvoiceMessageAPI.Codeunit.al @@ -49,6 +49,7 @@ codeunit 10987 "FR E-Invoice Message API" EDocument.TestField(Direction, EDocument.Direction::Outgoing); if EDocument."Document No." <> InvoiceID then Error(InvoiceMismatchErr, InvoiceID, EDocument."Document No."); + ValidateLifecycleTransition(EDocument."Entry No", MessageType); FREInvoiceMessage.Init(); FREInvoiceMessage."E-Document Entry No." := EDocument."Entry No"; @@ -68,6 +69,39 @@ codeunit 10987 "FR E-Invoice Message API" exit(FREInvoiceMessage."Entry No."); end; + local procedure ValidateLifecycleTransition(EDocumentEntryNo: Integer; NewMessageType: Enum "FR E-Invoice Message Type") + var + FREInvoiceMessage: Record "FR E-Invoice Message"; + PreviousMessageType: Enum "FR E-Invoice Message Type"; + HasPreviousMessage: Boolean; + begin + FREInvoiceMessage.SetRange("E-Document Entry No.", EDocumentEntryNo); + FREInvoiceMessage.SetFilter(Type, '%1|%2|%3|%4', FREInvoiceMessage.Type::Submitted, FREInvoiceMessage.Type::Accepted, + FREInvoiceMessage.Type::Refused, FREInvoiceMessage.Type::"Technical Rejected"); + if FREInvoiceMessage.FindLast() then begin + HasPreviousMessage := true; + PreviousMessageType := FREInvoiceMessage.Type; + end; + + case NewMessageType of + NewMessageType::Submitted: + if HasPreviousMessage then + Error(InvalidLifecycleTransitionErr, Format(PreviousMessageType), Format(NewMessageType)); + NewMessageType::Accepted, + NewMessageType::Refused, + NewMessageType::"Technical Rejected": + if (not HasPreviousMessage) or (PreviousMessageType <> PreviousMessageType::Submitted) then + Error(InvalidLifecycleTransitionErr, GetPreviousMessageTypeText(HasPreviousMessage, PreviousMessageType), Format(NewMessageType)); + end; + end; + + local procedure GetPreviousMessageTypeText(HasPreviousMessage: Boolean; PreviousMessageType: Enum "FR E-Invoice Message Type"): Text + begin + if HasPreviousMessage then + exit(Format(PreviousMessageType)); + exit(NoPreviousStatusTok); + end; + local procedure ParseMessage(TempBlob: Codeunit "Temp Blob"; var InvoiceID: Text; var MessageType: Enum "FR E-Invoice Message Type"; var ReasonCode: Text; var ReasonDescription: Text) var XmlDoc: XmlDocument; @@ -149,6 +183,8 @@ codeunit 10987 "FR E-Invoice Message API" StatusErr: Label 'The French invoice lifecycle message does not contain a status.'; UnsupportedStatusErr: Label 'French invoice lifecycle status %1 is not supported.', Comment = '%1 = lifecycle status'; InvoiceMismatchErr: Label 'The lifecycle message invoice ID %1 does not match E-Document invoice %2.', Comment = '%1 = message invoice identifier, %2 = E-Document invoice identifier'; + InvalidLifecycleTransitionErr: Label 'French invoice lifecycle status cannot change from %1 to %2.', Comment = '%1 = previous lifecycle status, %2 = new lifecycle status'; + NoPreviousStatusTok: Label 'no previous status'; RejectedReasonCodeErr: Label 'A technical rejection reason code is required.'; RejectedReasonDescriptionErr: Label 'A technical rejection reason description is required.'; } \ No newline at end of file diff --git a/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/FREInvoiceMessageBuilder.Codeunit.al b/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/FREInvoiceMessageBuilder.Codeunit.al index c3af9705b39..b74a72f41d9 100644 --- a/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/FREInvoiceMessageBuilder.Codeunit.al +++ b/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/FREInvoiceMessageBuilder.Codeunit.al @@ -16,6 +16,7 @@ codeunit 10976 "FR E-Invoice Message Builder" procedure BuildMessage(EDocument: Record "E-Document"; FREInvoiceMessage: Record "FR E-Invoice Message"; var TempBlob: Codeunit "Temp Blob") var + FREInvoiceProfileValidator: Codeunit "FR E-Invoice Profile Validator"; XmlDoc: XmlDocument; RootElement: XmlElement; AcknowledgementElement: XmlElement; @@ -85,6 +86,7 @@ codeunit 10976 "FR E-Invoice Message Builder" AcknowledgementElement.Add(ReferenceElement); RootElement.Add(AcknowledgementElement); XmlDoc.Add(RootElement); + FREInvoiceProfileValidator.Validate(XmlDoc, IsPPFMessage(FREInvoiceMessage)); TempBlob.CreateOutStream(OutStream, TextEncoding::UTF8); XmlDoc.WriteTo(OutStream); diff --git a/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/FREInvoiceProfileValidator.Codeunit.al b/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/FREInvoiceProfileValidator.Codeunit.al new file mode 100644 index 00000000000..a6e926ba21d --- /dev/null +++ b/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/FREInvoiceProfileValidator.Codeunit.al @@ -0,0 +1,132 @@ +// ------------------------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// ------------------------------------------------------------------------------------------------ +namespace Microsoft.eServices.EDocument.Formats; + +codeunit 10988 "FR E-Invoice Profile Validator" +{ + Access = Internal; + InherentEntitlements = X; + InherentPermissions = X; + + procedure Validate(XmlDoc: XmlDocument; IsPPFProfile: Boolean) + var + ExpectedProfileID: Text; + begin + RequireNode(XmlDoc, '/*[local-name()="CrossDomainAcknowledgementAndResponse" and namespace-uri()="' + RsmNamespaceTok + '"]'); + if IsPPFProfile then + ExpectedProfileID := PPFInvoiceProfileTok + else + ExpectedProfileID := CDVInvoiceProfileTok; + + RequireNodeText(XmlDoc, '/*[local-name()="CrossDomainAcknowledgementAndResponse"]/*[local-name()="ExchangedDocumentContext"]/*[local-name()="GuidelineSpecifiedDocumentContextParameter"]/*[local-name()="ID"]', ExpectedProfileID); + RequireNode(XmlDoc, '/*[local-name()="CrossDomainAcknowledgementAndResponse"]/*[local-name()="ExchangedDocument"]/*[local-name()="ID"]'); + RequireDateTimeNode(XmlDoc, '/*[local-name()="CrossDomainAcknowledgementAndResponse"]/*[local-name()="ExchangedDocument"]/*[local-name()="IssueDateTime"]/*[local-name()="DateTimeString"]', DateTimeFormatCodeTok); + RequireNodeText(XmlDoc, '/*[local-name()="CrossDomainAcknowledgementAndResponse"]/*[local-name()="AcknowledgementDocument"]/*[local-name()="TypeCode"]', InformationTypeCodeTok); + RequireDateTimeNode(XmlDoc, '/*[local-name()="CrossDomainAcknowledgementAndResponse"]/*[local-name()="AcknowledgementDocument"]/*[local-name()="IssueDateTime"]/*[local-name()="DateTimeString"]', DateTimeFormatCodeTok); + RequireNodeText(XmlDoc, '//*[local-name()="ReferenceReferencedDocument"]/*[local-name()="StatusCode"]', InvoiceReferenceStatusCodeTok); + RequireNodeText(XmlDoc, '//*[local-name()="ReferenceReferencedDocument"]/*[local-name()="TypeCode"]', InvoiceTypeCodeTok); + RequireNode(XmlDoc, '//*[local-name()="ReferenceReferencedDocument"]/*[local-name()="ProcessConditionCode"]'); + + if IsPPFProfile then + ValidatePPFProfile(XmlDoc) + else + ValidateCDVProfile(XmlDoc); + end; + + local procedure ValidatePPFProfile(XmlDoc: XmlDocument) + begin + RequireTradeParty(XmlDoc, 'SenderTradeParty', '', '', SenderRoleCodeTok); + RequireTradeParty(XmlDoc, 'IssuerTradeParty', SIRENSchemeTok, '', SellerRoleCodeTok); + RequireTradeParty(XmlDoc, 'RecipientTradeParty', PPFIdentifierSchemeTok, PPFIdentifierTok, PPFRoleCodeTok); + RequireDateTimeNode(XmlDoc, '//*[local-name()="ReferenceReferencedDocument"]/*[local-name()="ReceiptDateTime"]/*[local-name()="DateTimeString"]', DateTimeFormatCodeTok); + RequireNodeText(XmlDoc, '//*[local-name()="ReferenceReferencedDocument"]/*[local-name()="ReferenceTypeCode"]', PPFInvoiceProfileTok); + RequireDateTimeNode(XmlDoc, '//*[local-name()="ReferenceReferencedDocument"]/*[local-name()="FormattedIssueDateTime"]/*[local-name()="DateTimeString"]', DateFormatCodeTok); + end; + + local procedure ValidateCDVProfile(XmlDoc: XmlDocument) + var + XmlNode: XmlNode; + begin + RequireNodeText(XmlDoc, '//*[local-name()="BusinessProcessSpecifiedDocumentContextParameter"]/*[local-name()="ID"]', RegulatedBusinessProcessTok); + if XmlDoc.SelectSingleNode('//*[local-name()="SenderTradeParty" or local-name()="RecipientTradeParty"]', XmlNode) then + Error(UnexpectedProfileNodeErr, CDVInvoiceProfileTok); + end; + + local procedure RequireTradeParty(XmlDoc: XmlDocument; PartyName: Text; ExpectedSchemeID: Text; ExpectedID: Text; ExpectedRoleCode: Text) + var + GlobalIDNode: XmlNode; + SchemeNode: XmlNode; + RoleNode: XmlNode; + PartyPath: Text; + begin + PartyPath := '/*[local-name()="CrossDomainAcknowledgementAndResponse"]/*[local-name()="ExchangedDocument"]/*[local-name()="' + PartyName + '"]'; + if not XmlDoc.SelectSingleNode(PartyPath + '/*[local-name()="GlobalID"]', GlobalIDNode) then + Error(RequiredProfileNodeErr, PartyPath + '/*[local-name()="GlobalID"]'); + if GlobalIDNode.AsXmlElement().InnerText() = '' then + Error(EmptyProfileNodeErr, PartyPath + '/*[local-name()="GlobalID"]'); + if (ExpectedID <> '') and (GlobalIDNode.AsXmlElement().InnerText() <> ExpectedID) then + Error(ProfileValueErr, PartyPath + '/*[local-name()="GlobalID"]', ExpectedID, GlobalIDNode.AsXmlElement().InnerText()); + if not XmlDoc.SelectSingleNode(PartyPath + '/*[local-name()="GlobalID"]/@schemeID', SchemeNode) then + Error(RequiredProfileNodeErr, PartyPath + '/*[local-name()="GlobalID"]/@schemeID'); + if (ExpectedSchemeID <> '') and (SchemeNode.AsXmlAttribute().Value() <> ExpectedSchemeID) then + Error(ProfileValueErr, PartyPath + '/*[local-name()="GlobalID"]/@schemeID', ExpectedSchemeID, SchemeNode.AsXmlAttribute().Value()); + if not XmlDoc.SelectSingleNode(PartyPath + '/*[local-name()="RoleCode"]', RoleNode) then + Error(RequiredProfileNodeErr, PartyPath + '/*[local-name()="RoleCode"]'); + if RoleNode.AsXmlElement().InnerText() <> ExpectedRoleCode then + Error(ProfileValueErr, PartyPath + '/*[local-name()="RoleCode"]', ExpectedRoleCode, RoleNode.AsXmlElement().InnerText()); + end; + + local procedure RequireDateTimeNode(XmlDoc: XmlDocument; XPath: Text; ExpectedFormat: Text) + var + FormatNode: XmlNode; + begin + RequireNode(XmlDoc, XPath); + if not XmlDoc.SelectSingleNode(XPath + '/@format', FormatNode) then + Error(RequiredProfileNodeErr, XPath + '/@format'); + if FormatNode.AsXmlAttribute().Value() <> ExpectedFormat then + Error(ProfileValueErr, XPath + '/@format', ExpectedFormat, FormatNode.AsXmlAttribute().Value()); + end; + + local procedure RequireNode(XmlDoc: XmlDocument; XPath: Text) + var + XmlNode: XmlNode; + begin + if not XmlDoc.SelectSingleNode(XPath, XmlNode) then + Error(RequiredProfileNodeErr, XPath); + if XmlNode.IsXmlElement() and (XmlNode.AsXmlElement().InnerText() = '') then + Error(EmptyProfileNodeErr, XPath); + end; + + local procedure RequireNodeText(XmlDoc: XmlDocument; XPath: Text; ExpectedValue: Text) + var + XmlNode: XmlNode; + begin + if not XmlDoc.SelectSingleNode(XPath, XmlNode) then + Error(RequiredProfileNodeErr, XPath); + if XmlNode.AsXmlElement().InnerText() <> ExpectedValue then + Error(ProfileValueErr, XPath, ExpectedValue, XmlNode.AsXmlElement().InnerText()); + end; + + var + RsmNamespaceTok: Label 'urn:un:unece:uncefact:data:standard:CrossDomainAcknowledgementAndResponse:100', Locked = true; + RegulatedBusinessProcessTok: Label 'REGULATED', Locked = true; + CDVInvoiceProfileTok: Label 'urn.cpro.gouv.fr:1p0:CDV:invoice', Locked = true; + PPFInvoiceProfileTok: Label 'urn.cpro.gouv.fr:1p0:CDV:einvoicingF2', Locked = true; + InformationTypeCodeTok: Label '23', Locked = true; + DateTimeFormatCodeTok: Label '204', Locked = true; + DateFormatCodeTok: Label '102', Locked = true; + InvoiceReferenceStatusCodeTok: Label '47', Locked = true; + InvoiceTypeCodeTok: Label '380', Locked = true; + SenderRoleCodeTok: Label 'WK', Locked = true; + SellerRoleCodeTok: Label 'SE', Locked = true; + SIRENSchemeTok: Label '0002', Locked = true; + PPFIdentifierTok: Label '9998', Locked = true; + PPFIdentifierSchemeTok: Label '0238', Locked = true; + PPFRoleCodeTok: Label 'DFH', Locked = true; + RequiredProfileNodeErr: Label 'The French invoice lifecycle payload does not contain required profile node %1.', Comment = '%1 = XPath of the required XML node'; + EmptyProfileNodeErr: Label 'The French invoice lifecycle payload contains an empty profile node %1.', Comment = '%1 = XPath of the empty XML node'; + ProfileValueErr: Label 'French invoice lifecycle profile node %1 must have value %2 instead of %3.', Comment = '%1 = XPath, %2 = expected value, %3 = actual value'; + UnexpectedProfileNodeErr: Label 'The French invoice lifecycle payload contains a trade-party node that is not allowed by profile %1.', Comment = '%1 = profile identifier'; +} \ No newline at end of file diff --git a/src/Apps/FR/EDocument_FR/EReportingFR/test/src/FREInvoiceMessageTests.Codeunit.al b/src/Apps/FR/EDocument_FR/EReportingFR/test/src/FREInvoiceMessageTests.Codeunit.al index aaf8103bb05..12a79a125e0 100644 --- a/src/Apps/FR/EDocument_FR/EReportingFR/test/src/FREInvoiceMessageTests.Codeunit.al +++ b/src/Apps/FR/EDocument_FR/EReportingFR/test/src/FREInvoiceMessageTests.Codeunit.al @@ -791,6 +791,7 @@ codeunit 148151 "FR E-Invoice Message Tests" ExternalDocID := CopyStr(Format(CreateGuid()), 1, 250); ExternalMsgID := CopyStr(Format(CreateGuid()), 1, 250); EDocumentMessageAPI.RegisterExternalDocumentReference(EDocument, EDocument.Service, ExternalDocID); + ReceiveLifecycleMessage(EDocument, ExternalDocID, 'Submitted', '', ''); TempBlob.CreateOutStream(OutStream, TextEncoding::UTF8); OutStream.WriteText(BuildLifecycleXml(EDocument."Document No.", '205', '', '')); @@ -819,6 +820,7 @@ codeunit 148151 "FR E-Invoice Message Tests" ExternalDocID := CopyStr(Format(CreateGuid()), 1, 250); ExternalMsgID := CopyStr(Format(CreateGuid()), 1, 250); EDocumentMessageAPI.RegisterExternalDocumentReference(EDocument, EDocument.Service, ExternalDocID); + ReceiveLifecycleMessage(EDocument, ExternalDocID, 'Submitted', '', ''); TempBlob.CreateOutStream(OutStream, TextEncoding::UTF8); OutStream.WriteText(BuildLifecycleXml(EDocument."Document No.", 'Rejetée', 'SCHEMA', 'Schema validation failed')); @@ -984,6 +986,251 @@ codeunit 148151 "FR E-Invoice Message Tests" Assert.ExpectedErrorCode('Dialog'); end; + [Test] + procedure ReceiveSubmittedThenRefusedIsValid() + var + EDocument: Record "E-Document"; + FREInvoiceMessage: Record "FR E-Invoice Message"; + EDocumentMessageAPI: Codeunit "E-Document Message API"; + ExternalDocID: Text[250]; + FREntryNo: Integer; + begin + // [FEATURE] [AI test] + // [SCENARIO] A Refused status follows a Submitted status. + Initialize(); + + // [GIVEN] Outgoing E-Document "ED" with a received Submitted status + CreateOutgoingEDocument(EDocument); + ExternalDocID := CopyStr(Format(CreateGuid()), 1, 250); + EDocumentMessageAPI.RegisterExternalDocumentReference(EDocument, EDocument.Service, ExternalDocID); + ReceiveLifecycleMessage(EDocument, ExternalDocID, 'Submitted', '', ''); + + // [WHEN] A Refused status is received + FREntryNo := ReceiveLifecycleMessage(EDocument, ExternalDocID, 'Refused', '', ''); + + // [THEN] The Refused status is persisted + FREInvoiceMessage.Get(FREntryNo); + Assert.AreEqual(FREInvoiceMessage.Type::Refused, FREInvoiceMessage.Type, 'FR type must be Refused.'); + end; + + [Test] + procedure ReceiveSubmittedThenTechnicalRejectedIsValid() + var + EDocument: Record "E-Document"; + FREInvoiceMessage: Record "FR E-Invoice Message"; + EDocumentMessageAPI: Codeunit "E-Document Message API"; + ExternalDocID: Text[250]; + FREntryNo: Integer; + begin + // [FEATURE] [AI test] + // [SCENARIO] A Technical Rejected status follows a Submitted status. + Initialize(); + + // [GIVEN] Outgoing E-Document "ED" with a received Submitted status + CreateOutgoingEDocument(EDocument); + ExternalDocID := CopyStr(Format(CreateGuid()), 1, 250); + EDocumentMessageAPI.RegisterExternalDocumentReference(EDocument, EDocument.Service, ExternalDocID); + ReceiveLifecycleMessage(EDocument, ExternalDocID, 'Submitted', '', ''); + + // [WHEN] A Technical Rejected status is received + FREntryNo := ReceiveLifecycleMessage(EDocument, ExternalDocID, 'Rejected', 'SCHEMA', 'Schema validation failed'); + + // [THEN] The Technical Rejected status is persisted + FREInvoiceMessage.Get(FREntryNo); + Assert.AreEqual(FREInvoiceMessage.Type::"Technical Rejected", FREInvoiceMessage.Type, 'FR type must be Technical Rejected.'); + end; + + [Test] + procedure ReceiveResponseBeforeSubmittedIsRejected() + var + EDocument: Record "E-Document"; + EDocumentMessageAPI: Codeunit "E-Document Message API"; + ExternalDocID: Text[250]; + begin + // [FEATURE] [AI test] + // [SCENARIO] A terminal response cannot be the first lifecycle status. + Initialize(); + + // [GIVEN] Outgoing E-Document "ED" without a lifecycle status + CreateOutgoingEDocument(EDocument); + ExternalDocID := CopyStr(Format(CreateGuid()), 1, 250); + EDocumentMessageAPI.RegisterExternalDocumentReference(EDocument, EDocument.Service, ExternalDocID); + + // [WHEN] An Accepted status is received + asserterror ReceiveLifecycleMessage(EDocument, ExternalDocID, 'Accepted', '', ''); + + // [THEN] The transition is rejected + Assert.ExpectedError('cannot change from no previous status to Accepted'); + end; + + [Test] + procedure ReceiveDuplicateSubmittedIsRejected() + var + EDocument: Record "E-Document"; + EDocumentMessageAPI: Codeunit "E-Document Message API"; + ExternalDocID: Text[250]; + begin + // [FEATURE] [AI test] + // [SCENARIO] Submitted cannot be received twice with different external message IDs. + Initialize(); + + // [GIVEN] Outgoing E-Document "ED" with a received Submitted status + CreateOutgoingEDocument(EDocument); + ExternalDocID := CopyStr(Format(CreateGuid()), 1, 250); + EDocumentMessageAPI.RegisterExternalDocumentReference(EDocument, EDocument.Service, ExternalDocID); + ReceiveLifecycleMessage(EDocument, ExternalDocID, 'Submitted', '', ''); + + // [WHEN] Another Submitted status is received + asserterror ReceiveLifecycleMessage(EDocument, ExternalDocID, 'Submitted', '', ''); + + // [THEN] The duplicate status is rejected + Assert.ExpectedError('cannot change from Submitted to Submitted'); + end; + + [Test] + procedure ReceiveStatusAfterTerminalStatusIsRejected() + var + EDocument: Record "E-Document"; + EDocumentMessageAPI: Codeunit "E-Document Message API"; + ExternalDocID: Text[250]; + begin + // [FEATURE] [AI test] + // [SCENARIO] No status can follow a terminal lifecycle response. + Initialize(); + + // [GIVEN] Outgoing E-Document "ED" with Submitted and Refused statuses + CreateOutgoingEDocument(EDocument); + ExternalDocID := CopyStr(Format(CreateGuid()), 1, 250); + EDocumentMessageAPI.RegisterExternalDocumentReference(EDocument, EDocument.Service, ExternalDocID); + ReceiveLifecycleMessage(EDocument, ExternalDocID, 'Submitted', '', ''); + ReceiveLifecycleMessage(EDocument, ExternalDocID, 'Refused', '', ''); + + // [WHEN] An Accepted status is received + asserterror ReceiveLifecycleMessage(EDocument, ExternalDocID, 'Accepted', '', ''); + + // [THEN] The transition from the terminal status is rejected + Assert.ExpectedError('cannot change from Refused to Accepted'); + end; + + [Test] + procedure CompletePPFProfileIsValid() + var + FREInvoiceProfileValidator: Codeunit "FR E-Invoice Profile Validator"; + XmlDoc: XmlDocument; + begin + // [FEATURE] [AI test] + // [SCENARIO] A complete PPF lifecycle payload satisfies profile validation. + Initialize(); + + // [GIVEN] A complete PPF lifecycle document + XmlDocument.ReadFrom(BuildPPFValidationXml(PPFProfileID(), true, 'WK', '0238', '102'), XmlDoc); + + // [WHEN] The PPF profile is validated + FREInvoiceProfileValidator.Validate(XmlDoc, true); + + // [THEN] No validation error occurs + end; + + [Test] + procedure PPFProfileRejectsWrongProfileID() + var + FREInvoiceProfileValidator: Codeunit "FR E-Invoice Profile Validator"; + XmlDoc: XmlDocument; + begin + // [FEATURE] [AI test] + // [SCENARIO] A PPF lifecycle payload must declare the PPF profile. + Initialize(); + + // [GIVEN] A PPF lifecycle document with the CDV profile ID + XmlDocument.ReadFrom(BuildPPFValidationXml(CDVProfileID(), true, 'WK', '0238', '102'), XmlDoc); + + // [WHEN] The PPF profile is validated + asserterror FREInvoiceProfileValidator.Validate(XmlDoc, true); + + // [THEN] The incorrect profile ID is rejected + Assert.ExpectedError('must have value ' + PPFProfileID()); + end; + + [Test] + procedure PPFProfileRequiresSenderTradeParty() + var + FREInvoiceProfileValidator: Codeunit "FR E-Invoice Profile Validator"; + XmlDoc: XmlDocument; + begin + // [FEATURE] [AI test] + // [SCENARIO] A PPF lifecycle payload requires the sender platform party. + Initialize(); + + // [GIVEN] A PPF lifecycle document without a sender platform party + XmlDocument.ReadFrom(BuildPPFValidationXml(PPFProfileID(), false, 'WK', '0238', '102'), XmlDoc); + + // [WHEN] The PPF profile is validated + asserterror FREInvoiceProfileValidator.Validate(XmlDoc, true); + + // [THEN] The missing sender platform is rejected + Assert.ExpectedError('SenderTradeParty'); + end; + + [Test] + procedure PPFProfileRejectsWrongRecipientScheme() + var + FREInvoiceProfileValidator: Codeunit "FR E-Invoice Profile Validator"; + XmlDoc: XmlDocument; + begin + // [FEATURE] [AI test] + // [SCENARIO] The PPF recipient must use scheme 0238. + Initialize(); + + // [GIVEN] A PPF lifecycle document with the wrong recipient scheme + XmlDocument.ReadFrom(BuildPPFValidationXml(PPFProfileID(), true, 'WK', '9999', '102'), XmlDoc); + + // [WHEN] The PPF profile is validated + asserterror FREInvoiceProfileValidator.Validate(XmlDoc, true); + + // [THEN] The incorrect recipient scheme is rejected + Assert.ExpectedError('must have value 0238 instead of 9999'); + end; + + [Test] + procedure PPFProfileRejectsWrongSenderRole() + var + FREInvoiceProfileValidator: Codeunit "FR E-Invoice Profile Validator"; + XmlDoc: XmlDocument; + begin + // [FEATURE] [AI test] + // [SCENARIO] The sender platform must use role WK. + Initialize(); + + // [GIVEN] A PPF lifecycle document with the wrong sender role + XmlDocument.ReadFrom(BuildPPFValidationXml(PPFProfileID(), true, 'XX', '0238', '102'), XmlDoc); + + // [WHEN] The PPF profile is validated + asserterror FREInvoiceProfileValidator.Validate(XmlDoc, true); + + // [THEN] The incorrect sender role is rejected + Assert.ExpectedError('must have value WK instead of XX'); + end; + + [Test] + procedure PPFProfileRejectsWrongInvoiceDateFormat() + var + FREInvoiceProfileValidator: Codeunit "FR E-Invoice Profile Validator"; + XmlDoc: XmlDocument; + begin + // [FEATURE] [AI test] + // [SCENARIO] The PPF invoice issue date must use format 102. + Initialize(); + + // [GIVEN] A PPF lifecycle document with the wrong invoice date format + XmlDocument.ReadFrom(BuildPPFValidationXml(PPFProfileID(), true, 'WK', '0238', '204'), XmlDoc); + + // [WHEN] The PPF profile is validated + asserterror FREInvoiceProfileValidator.Validate(XmlDoc, true); + + // [THEN] The incorrect date format is rejected + Assert.ExpectedError('must have value 102 instead of 204'); + end; + [Test] procedure BuilderRejectsIncomingOnlyStatus() var @@ -1033,6 +1280,57 @@ codeunit 148151 "FR E-Invoice Message Tests" exit(XmlText.ToText()); end; + local procedure ReceiveLifecycleMessage(EDocument: Record "E-Document"; ExternalDocID: Text[250]; Status: Text; ReasonCode: Text; ReasonDescription: Text): Integer + var + FREInvoiceMessageAPI: Codeunit "FR E-Invoice Message API"; + TempBlob: Codeunit "Temp Blob"; + OutStream: OutStream; + begin + TempBlob.CreateOutStream(OutStream, TextEncoding::UTF8); + OutStream.WriteText(BuildLifecycleXml(EDocument."Document No.", Status, ReasonCode, ReasonDescription)); + exit(FREInvoiceMessageAPI.ReceiveMessage( + EDocument.Service, ExternalDocID, CopyStr(Format(CreateGuid()), 1, 250), CurrentDateTime(), TempBlob)); + end; + + local procedure BuildPPFValidationXml(ProfileID: Text; IncludeSender: Boolean; SenderRole: Text; RecipientScheme: Text; InvoiceDateFormat: Text): Text + var + XmlText: TextBuilder; + begin + XmlText.Append(''); + XmlText.Append(''); + XmlText.Append(ProfileID); + XmlText.Append(''); + XmlText.Append('MESSAGE-ID20260821120000'); + if IncludeSender then begin + XmlText.Append('SENDER'); + XmlText.Append(SenderRole); + XmlText.Append(''); + end; + XmlText.Append('123456789SE'); + XmlText.Append('9998DFH'); + XmlText.Append('2320260821000000'); + XmlText.Append('INVOICE47380'); + XmlText.Append('20260821120000'); + XmlText.Append(PPFProfileID()); + XmlText.Append('20260821205'); + XmlText.Append(''); + exit(XmlText.ToText()); + end; + + local procedure PPFProfileID(): Text + begin + exit('urn.cpro.gouv.fr:1p0:CDV:einvoicingF2'); + end; + + local procedure CDVProfileID(): Text + begin + exit('urn.cpro.gouv.fr:1p0:CDV:invoice'); + end; + local procedure SendFirstMessage(EDocument: Record "E-Document"; MessageType: Enum "FR E-Invoice Message Type") var FREInvoiceMessage: Record "FR E-Invoice Message"; diff --git a/src/Apps/W1/EDocument/App/Permissions/EDocCoreObjects.PermissionSet.al b/src/Apps/W1/EDocument/App/Permissions/EDocCoreObjects.PermissionSet.al index b5c4b2029d1..1d09834768a 100644 --- a/src/Apps/W1/EDocument/App/Permissions/EDocCoreObjects.PermissionSet.al +++ b/src/Apps/W1/EDocument/App/Permissions/EDocCoreObjects.PermissionSet.al @@ -182,5 +182,6 @@ permissionset 6100 "E-Doc. Core - Objects" codeunit "Sent Document Approval" = X, codeunit "Sent Document Cancellation" = X, codeunit "E-Doc. Remittance Advice Mgt." = X, - codeunit "E-Doc. Remit. Advice Export" = X; + codeunit "E-Doc. Remit. Advice Export" = X, + codeunit "E-Doc. Message Response Job" = X; } diff --git a/src/Apps/W1/EDocument/App/src/Integration/Interfaces/IMessageResponseHandler.Interface.al b/src/Apps/W1/EDocument/App/src/Integration/Interfaces/IMessageResponseHandler.Interface.al new file mode 100644 index 00000000000..12d81d7813c --- /dev/null +++ b/src/Apps/W1/EDocument/App/src/Integration/Interfaces/IMessageResponseHandler.Interface.al @@ -0,0 +1,27 @@ +// ------------------------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// ------------------------------------------------------------------------------------------------ +namespace Microsoft.eServices.EDocument.Integration.Interfaces; + +using Microsoft.eServices.EDocument; +using Microsoft.eServices.EDocument.Processing.Message; + +/// +/// Polls the asynchronous response for an outgoing child E-Document message. +/// +interface IMessageResponseHandler +{ + /// + /// Polls the external service for the response to a previously sent child message. + /// + /// The parent E-Document. + /// The service used to send the message. + /// The original message context and transport diagnostics. + /// True when the response is complete; otherwise false. + /// + /// A completed response must set the context status to Sent. A response that is not ready must set it to Pending Response. + /// Implementations must use MessageContext.GetMessageEntryNo() to correlate the external request. + /// + procedure GetResponse(var EDocument: Record "E-Document"; var EDocumentService: Record "E-Document Service"; MessageContext: Codeunit "E-Doc. Message Context"): Boolean; +} \ No newline at end of file diff --git a/src/Apps/W1/EDocument/App/src/Integration/Interfaces/IMessageSender.Interface.al b/src/Apps/W1/EDocument/App/src/Integration/Interfaces/IMessageSender.Interface.al index 3204c010ba4..698db3b63da 100644 --- a/src/Apps/W1/EDocument/App/src/Integration/Interfaces/IMessageSender.Interface.al +++ b/src/Apps/W1/EDocument/App/src/Integration/Interfaces/IMessageSender.Interface.al @@ -17,10 +17,11 @@ interface IMessageSender /// /// The parent E-Document. /// The service used to send the message. - /// The message payload, transport state, and result. The implementation must set the result to Sent after successful transmission. + /// The message payload, transport state, and result. The implementation must set the result to Sent or Pending Response after successful transmission. /// /// The implementation is responsible for obtaining any privacy consent required by the external service before transmitting data. /// Implementations must use MessageContext.GetMessageEntryNo() as an idempotency key because a message can be retried after an ambiguous transport failure. + /// Set the context status to Pending Response when the external service accepted the message but requires asynchronous response polling. /// procedure SendMessage(var EDocument: Record "E-Document"; var EDocumentService: Record "E-Document Service"; MessageContext: Codeunit "E-Doc. Message Context"); } \ No newline at end of file diff --git a/src/Apps/W1/EDocument/App/src/Integration/ServiceIntegration.Enum.al b/src/Apps/W1/EDocument/App/src/Integration/ServiceIntegration.Enum.al index f84d85f9812..041f0603521 100644 --- a/src/Apps/W1/EDocument/App/src/Integration/ServiceIntegration.Enum.al +++ b/src/Apps/W1/EDocument/App/src/Integration/ServiceIntegration.Enum.al @@ -8,13 +8,15 @@ using Microsoft.eServices.EDocument; using Microsoft.eServices.EDocument.Integration.Interfaces; using Microsoft.eServices.EDocument.Processing.Message; -enum 6151 "Service Integration" implements IDocumentSender, IDocumentReceiver, IConsentManager, IMessageSender +enum 6151 "Service Integration" implements IDocumentSender, IDocumentReceiver, IConsentManager, IMessageSender, IMessageResponseHandler { Extensible = true; Access = Public; DefaultImplementation = IConsentManager = "Consent Manager Default Impl.", - IMessageSender = "E-Doc. Msg. Transport Default"; - UnknownValueImplementation = IMessageSender = "E-Doc. Msg. Transport Default"; + IMessageSender = "E-Doc. Msg. Transport Default", + IMessageResponseHandler = "E-Doc. Msg. Transport Default"; + UnknownValueImplementation = IMessageSender = "E-Doc. Msg. Transport Default", + IMessageResponseHandler = "E-Doc. Msg. Transport Default"; value(0; "No Integration") { diff --git a/src/Apps/W1/EDocument/App/src/Processing/EDocumentBackgroundJobs.Codeunit.al b/src/Apps/W1/EDocument/App/src/Processing/EDocumentBackgroundJobs.Codeunit.al index 0ffe24b98c7..ca180305fb7 100644 --- a/src/Apps/W1/EDocument/App/src/Processing/EDocumentBackgroundJobs.Codeunit.al +++ b/src/Apps/W1/EDocument/App/src/Processing/EDocumentBackgroundJobs.Codeunit.al @@ -27,6 +27,11 @@ codeunit 6133 "E-Document Background Jobs" ScheduleEDocumentJob(Codeunit::"E-Doc. Message Send Job", EDocumentMessage.RecordId(), 0); end; + procedure ScheduleMessageResponse(EDocumentMessage: Record "E-Document Message") + begin + ScheduleEDocumentJob(Codeunit::"E-Doc. Message Response Job", EDocumentMessage.RecordId(), 300000); + end; + procedure ScheduleGetResponseJob() begin ScheduleGetResponseJob(true); diff --git a/src/Apps/W1/EDocument/App/src/Processing/Message/EDocMessageContext.Codeunit.al b/src/Apps/W1/EDocument/App/src/Processing/Message/EDocMessageContext.Codeunit.al index a6f93a5f472..44443b859e0 100644 --- a/src/Apps/W1/EDocument/App/src/Processing/Message/EDocMessageContext.Codeunit.al +++ b/src/Apps/W1/EDocument/App/src/Processing/Message/EDocMessageContext.Codeunit.al @@ -68,7 +68,7 @@ codeunit 6533 "E-Doc. Message Context" end; /// - /// Gets the transport result. A connector must set the status to Sent after successful transmission. + /// Gets the transport result. A connector must set the status to Sent or Pending Response after successful transmission. /// /// The integration action status. procedure Status(): Codeunit "Integration Action Status" diff --git a/src/Apps/W1/EDocument/App/src/Processing/Message/EDocMessageMgt.Codeunit.al b/src/Apps/W1/EDocument/App/src/Processing/Message/EDocMessageMgt.Codeunit.al index 025c5892ddc..d845d47d6c0 100644 --- a/src/Apps/W1/EDocument/App/src/Processing/Message/EDocMessageMgt.Codeunit.al +++ b/src/Apps/W1/EDocument/App/src/Processing/Message/EDocMessageMgt.Codeunit.al @@ -118,6 +118,7 @@ codeunit 6433 "E-Doc. Message Mgt." EDocumentService: Record "E-Document Service"; EDocMessage: Record "E-Document Message"; EDocMessageContext: Codeunit "E-Doc. Message Context"; + EDocumentBackgroundJobs: Codeunit "E-Document Background Jobs"; EDocumentLog: Codeunit "E-Document Log"; TempBlob: Codeunit "Temp Blob"; MessageSender: Interface IMessageSender; @@ -138,7 +139,7 @@ codeunit 6433 "E-Doc. Message Mgt." EDocMessageContext.Initialize(EDocMessage, TempBlob); MessageSender := EDocumentService."Service Integration V2"; MessageSender.SendMessage(EDocument, EDocumentService, EDocMessageContext); - if EDocMessageContext.Status().GetStatus() <> "E-Document Service Status"::Sent then begin + if not (EDocMessageContext.Status().GetStatus() in ["E-Document Service Status"::Sent, "E-Document Service Status"::"Pending Response"]) then begin MessageSendingErrorInfo.ErrorType := ErrorType::Internal; MessageSendingErrorInfo.Message := StrSubstNo(MessageSendingErr, MessageEntryNo); MessageSendingErrorInfo.DetailedMessage := StrSubstNo(MessageSendingDetailedErr, EDocMessageContext.Status().GetStatus()); @@ -147,10 +148,57 @@ codeunit 6433 "E-Doc. Message Mgt." EDocumentLog.InsertIntegrationLog( EDocument, EDocumentService, EDocMessageContext.Http().GetHttpRequestMessage(), EDocMessageContext.Http().GetHttpResponseMessage()); - EDocMessage.Status := EDocMessage.Status::Sent; + if EDocMessageContext.Status().GetStatus() = "E-Document Service Status"::"Pending Response" then + EDocMessage.Status := EDocMessage.Status::"Pending Response" + else + EDocMessage.Status := EDocMessage.Status::Sent; + EDocMessage."Last Attempt At" := CurrentDateTime(); + Clear(EDocMessage."Last Error"); + EDocMessage.Modify(); + if EDocMessage.Status = EDocMessage.Status::"Pending Response" then + EDocumentBackgroundJobs.ScheduleMessageResponse(EDocMessage); + end; + + procedure PollMessageResponse(MessageEntryNo: Integer) + var + EDocument: Record "E-Document"; + EDocumentService: Record "E-Document Service"; + EDocMessage: Record "E-Document Message"; + EDocMessageContext: Codeunit "E-Doc. Message Context"; + EDocumentBackgroundJobs: Codeunit "E-Document Background Jobs"; + EDocumentLog: Codeunit "E-Document Log"; + TempBlob: Codeunit "Temp Blob"; + MessageResponseHandler: Interface IMessageResponseHandler; + ResponseReceived: Boolean; + begin + EDocMessage.Get(MessageEntryNo); + EDocMessage.TestField(Direction, EDocMessage.Direction::Outgoing); + EDocMessage.TestField(Status, EDocMessage.Status::"Pending Response"); + EDocMessage.TestField(Service); + EDocument.Get(EDocMessage."E-Document Entry No."); + EDocumentService.Get(EDocMessage.Service); + GetMessageBlob(MessageEntryNo, TempBlob); + EDocMessageContext.Initialize(EDocMessage, TempBlob); + MessageResponseHandler := EDocumentService."Service Integration V2"; + ResponseReceived := MessageResponseHandler.GetResponse(EDocument, EDocumentService, EDocMessageContext); + + if ResponseReceived then begin + if EDocMessageContext.Status().GetStatus() <> "E-Document Service Status"::Sent then + Error(MessageResponseStatusErr, MessageEntryNo, EDocMessageContext.Status().GetStatus()); + EDocMessage.Status := EDocMessage.Status::Sent; + end else begin + if EDocMessageContext.Status().GetStatus() <> "E-Document Service Status"::"Pending Response" then + Error(MessageResponseStatusErr, MessageEntryNo, EDocMessageContext.Status().GetStatus()); + EDocMessage.Status := EDocMessage.Status::"Pending Response"; + end; + + EDocumentLog.InsertIntegrationLog( + EDocument, EDocumentService, EDocMessageContext.Http().GetHttpRequestMessage(), EDocMessageContext.Http().GetHttpResponseMessage()); EDocMessage."Last Attempt At" := CurrentDateTime(); Clear(EDocMessage."Last Error"); EDocMessage.Modify(); + if not ResponseReceived then + EDocumentBackgroundJobs.ScheduleMessageResponse(EDocMessage); end; procedure QueueMessage(MessageEntryNo: Integer) @@ -176,13 +224,20 @@ codeunit 6433 "E-Doc. Message Mgt." begin EDocMessage.Get(MessageEntryNo); EDocMessage.TestField(Direction, EDocMessage.Direction::Outgoing); - EDocMessage.TestField(Status, EDocMessage.Status::Error); + if EDocMessage.Status <> EDocMessage.Status::"Response Error" then + EDocMessage.TestField(Status, EDocMessage.Status::Error); EDocMessage.TestField(Service); - EDocMessage.Status := EDocMessage.Status::Queued; + if EDocMessage.Status = EDocMessage.Status::"Response Error" then + EDocMessage.Status := EDocMessage.Status::"Pending Response" + else + EDocMessage.Status := EDocMessage.Status::Queued; EDocMessage.Modify(); Commit(); - EDocumentBackgroundJobs.ScheduleMessageSend(EDocMessage); + if EDocMessage.Status = EDocMessage.Status::"Pending Response" then + EDocumentBackgroundJobs.ScheduleMessageResponse(EDocMessage) + else + EDocumentBackgroundJobs.ScheduleMessageSend(EDocMessage); end; procedure RegisterExternalDocumentReference(EDocument: Record "E-Document"; ServiceCode: Code[20]; ExternalDocumentID: Text[250]) @@ -273,6 +328,7 @@ codeunit 6433 "E-Doc. Message Mgt." MessagePayloadErr: Label 'E-Document message %1 does not contain a payload.', Comment = '%1 = E-Document message entry number'; MessageSendingErr: Label 'E-Document message %1 could not be sent.', Comment = '%1 = E-Document message entry number'; MessageSendingDetailedErr: Label 'The E-Document message integration returned status %1.', Comment = '%1 = integration status'; + MessageResponseStatusErr: Label 'The connector returned invalid response status %2 for E-Document message %1.', Comment = '%1 = message entry number, %2 = connector status'; ExternalDocumentIDRequiredErr: Label 'An external document ID is required.'; ExternalMessageIDRequiredErr: Label 'An external message ID is required.'; IncomingMessagePayloadRequiredErr: Label 'An incoming E-Document message payload is required.'; diff --git a/src/Apps/W1/EDocument/App/src/Processing/Message/EDocMessageResponseJob.Codeunit.al b/src/Apps/W1/EDocument/App/src/Processing/Message/EDocMessageResponseJob.Codeunit.al new file mode 100644 index 00000000000..e0f235d863f --- /dev/null +++ b/src/Apps/W1/EDocument/App/src/Processing/Message/EDocMessageResponseJob.Codeunit.al @@ -0,0 +1,46 @@ +// ------------------------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// ------------------------------------------------------------------------------------------------ +namespace Microsoft.eServices.EDocument.Processing.Message; + +using System.Threading; + +codeunit 6539 "E-Doc. Message Response Job" +{ + Access = Internal; + TableNo = "Job Queue Entry"; + InherentEntitlements = X; + InherentPermissions = X; + + trigger OnRun() + var + EDocumentMessage: Record "E-Document Message"; + LastErrorText: Text; + begin + EDocumentMessage.Get(Rec."Record ID to Process"); + if TryPollMessageResponse(EDocumentMessage."Entry No.") then + exit; + + LastErrorText := GetLastErrorText(); + EDocumentMessage.Get(EDocumentMessage."Entry No."); + EDocumentMessage.Status := EDocumentMessage.Status::"Response Error"; + EDocumentMessage."Last Attempt At" := CurrentDateTime(); + EDocumentMessage."Retry Count" += 1; + EDocumentMessage."Last Error" := CopyStr(LastErrorText, 1, MaxStrLen(EDocumentMessage."Last Error")); + EDocumentMessage.Modify(); + Commit(); + Error(MessageResponseFailedErr, EDocumentMessage."Entry No.", LastErrorText); + end; + + [TryFunction] + local procedure TryPollMessageResponse(MessageEntryNo: Integer) + var + EDocMessageMgt: Codeunit "E-Doc. Message Mgt."; + begin + EDocMessageMgt.PollMessageResponse(MessageEntryNo); + end; + + var + MessageResponseFailedErr: Label 'The response for E-Document message %1 could not be retrieved. %2', Comment = '%1 = message entry number, %2 = connector error'; +} \ No newline at end of file diff --git a/src/Apps/W1/EDocument/App/src/Processing/Message/EDocMessageStatus.Enum.al b/src/Apps/W1/EDocument/App/src/Processing/Message/EDocMessageStatus.Enum.al index fdca39dd3b8..c01e0b5c825 100644 --- a/src/Apps/W1/EDocument/App/src/Processing/Message/EDocMessageStatus.Enum.al +++ b/src/Apps/W1/EDocument/App/src/Processing/Message/EDocMessageStatus.Enum.al @@ -31,4 +31,12 @@ enum 6429 "E-Doc. Message Status" { Caption = 'Received'; } + value(5; "Pending Response") + { + Caption = 'Pending response'; + } + value(6; "Response Error") + { + Caption = 'Response error'; + } } diff --git a/src/Apps/W1/EDocument/App/src/Processing/Message/EDocMsgTransportDefault.Codeunit.al b/src/Apps/W1/EDocument/App/src/Processing/Message/EDocMsgTransportDefault.Codeunit.al index 1a407bc3c75..a8f18bbfa9e 100644 --- a/src/Apps/W1/EDocument/App/src/Processing/Message/EDocMsgTransportDefault.Codeunit.al +++ b/src/Apps/W1/EDocument/App/src/Processing/Message/EDocMsgTransportDefault.Codeunit.al @@ -7,7 +7,7 @@ namespace Microsoft.eServices.EDocument.Processing.Message; using Microsoft.eServices.EDocument; using Microsoft.eServices.EDocument.Integration.Interfaces; -codeunit 6534 "E-Doc. Msg. Transport Default" implements IMessageSender +codeunit 6534 "E-Doc. Msg. Transport Default" implements IMessageSender, IMessageResponseHandler { Access = Internal; InherentEntitlements = X; @@ -24,7 +24,19 @@ codeunit 6534 "E-Doc. Msg. Transport Default" implements IMessageSender Error(MessageTransportErrorInfo); end; + procedure GetResponse(var EDocument: Record "E-Document"; var EDocumentService: Record "E-Document Service"; MessageContext: Codeunit "E-Doc. Message Context"): Boolean + var + MessageTransportErrorInfo: ErrorInfo; + begin + MessageTransportErrorInfo.Message := StrSubstNo(MessageResponseNotSupportedErr, EDocumentService.Code); + MessageTransportErrorInfo.RecordId := EDocumentService.RecordId; + MessageTransportErrorInfo.PageNo := Page::"E-Document Service"; + MessageTransportErrorInfo.AddNavigationAction(ShowEDocumentServiceLbl); + Error(MessageTransportErrorInfo); + end; + var MessageTransportNotSupportedErr: Label 'E-Document service %1 does not support sending E-Document messages.', Comment = '%1 = E-Document service code'; + MessageResponseNotSupportedErr: Label 'E-Document service %1 does not support polling E-Document message responses.', Comment = '%1 = E-Document service code'; ShowEDocumentServiceLbl: Label 'Open E-Document Service'; } \ No newline at end of file diff --git a/src/Apps/W1/EDocument/App/src/Processing/Message/EDocumentMessageAPI.Codeunit.al b/src/Apps/W1/EDocument/App/src/Processing/Message/EDocumentMessageAPI.Codeunit.al index 969c10e296c..38bf5d304d6 100644 --- a/src/Apps/W1/EDocument/App/src/Processing/Message/EDocumentMessageAPI.Codeunit.al +++ b/src/Apps/W1/EDocument/App/src/Processing/Message/EDocumentMessageAPI.Codeunit.al @@ -108,6 +108,17 @@ codeunit 6532 "E-Document Message API" EDocMessageMgt.RetryMessage(MessageEntryNo); end; + /// + /// Polls the service for the asynchronous response to an outgoing child message. + /// + /// The entry number of a message in Pending Response status. + procedure PollMessageResponse(MessageEntryNo: Integer) + var + EDocMessageMgt: Codeunit "E-Doc. Message Mgt."; + begin + EDocMessageMgt.PollMessageResponse(MessageEntryNo); + end; + /// /// Associates an external service document identifier with an E-Document for later message correlation. /// diff --git a/src/Apps/W1/EDocument/App/src/Processing/Message/EDocumentMessagesFactBox.Page.al b/src/Apps/W1/EDocument/App/src/Processing/Message/EDocumentMessagesFactBox.Page.al index 8de33297620..5bfdf683f25 100644 --- a/src/Apps/W1/EDocument/App/src/Processing/Message/EDocumentMessagesFactBox.Page.al +++ b/src/Apps/W1/EDocument/App/src/Processing/Message/EDocumentMessagesFactBox.Page.al @@ -90,7 +90,7 @@ page 6434 "E-Document Messages FactBox" { ApplicationArea = Basic, Suite; Caption = 'Retry'; - ToolTip = 'Requeue the failed message for background transmission using its existing payload.'; + ToolTip = 'Retry the failed message transmission or response polling operation using its existing payload.'; Image = Refresh; Scope = Repeater; Enabled = RetryEnabled; @@ -131,7 +131,7 @@ page 6434 "E-Document Messages FactBox" trigger OnAfterGetCurrRecord() begin - RetryEnabled := (Rec.Direction = Rec.Direction::Outgoing) and (Rec.Status = Rec.Status::Error); + RetryEnabled := (Rec.Direction = Rec.Direction::Outgoing) and (Rec.Status in [Rec.Status::Error, Rec.Status::"Response Error"]); end; var diff --git a/src/Apps/W1/EDocument/Test/src/Mock/EDocIntegrationMockV2.Codeunit.al b/src/Apps/W1/EDocument/Test/src/Mock/EDocIntegrationMockV2.Codeunit.al index 8042be9ea28..63de072b247 100644 --- a/src/Apps/W1/EDocument/Test/src/Mock/EDocIntegrationMockV2.Codeunit.al +++ b/src/Apps/W1/EDocument/Test/src/Mock/EDocIntegrationMockV2.Codeunit.al @@ -10,7 +10,7 @@ using Microsoft.eServices.EDocument.Integration.Receive; using Microsoft.eServices.EDocument.Integration.Send; using System.Utilities; -codeunit 139658 "E-Doc. Integration Mock V2" implements IDocumentSender, IDocumentReceiver, IDocumentResponseHandler, ISentDocumentActions, IConsentManager +codeunit 139658 "E-Doc. Integration Mock V2" implements IDocumentSender, IDocumentReceiver, IDocumentResponseHandler, ISentDocumentActions, IConsentManager, IMessageResponseHandler { Access = Internal; @@ -37,6 +37,18 @@ codeunit 139658 "E-Doc. Integration Mock V2" implements IDocumentSender, IDocume exit(Success); end; + procedure GetResponse(var EDocument: Record "E-Document"; var EDocumentService: Record "E-Document Service"; MessageContext: Codeunit "E-Doc. Message Context"): Boolean + var + ResponseReceived: Boolean; + begin + OnGetResponse(EDocument, MessageContext.Http().GetHttpRequestMessage(), MessageContext.Http().GetHttpResponseMessage(), ResponseReceived); + if ResponseReceived then + MessageContext.Status().SetStatus("E-Document Service Status"::Sent) + else + MessageContext.Status().SetStatus("E-Document Service Status"::"Pending Response"); + exit(ResponseReceived); + end; + procedure ReceiveDocuments(var EDocumentService: Record "E-Document Service"; DocumentsMetadata: Codeunit "Temp Blob List"; ReceiveContext: Codeunit ReceiveContext) begin OnReceiveDocuments(DocumentsMetadata, ReceiveContext.Http().GetHttpRequestMessage(), ReceiveContext.Http().GetHttpResponseMessage()); diff --git a/src/Apps/W1/EDocument/Test/src/Mock/EDocIntegrationMockV2.EnumExt.al b/src/Apps/W1/EDocument/Test/src/Mock/EDocIntegrationMockV2.EnumExt.al index 1439adf28d6..d68f40bc8cd 100644 --- a/src/Apps/W1/EDocument/Test/src/Mock/EDocIntegrationMockV2.EnumExt.al +++ b/src/Apps/W1/EDocument/Test/src/Mock/EDocIntegrationMockV2.EnumExt.al @@ -12,7 +12,7 @@ enumextension 139617 "E-Doc Integration Mock V2" extends "Service Integration" value(133501; "Mock") { - Implementation = IDocumentSender = "E-Doc. Integration Mock V2", IDocumentReceiver = "E-Doc. Integration Mock V2", IConsentManager = "E-Doc. Integration Mock V2"; + Implementation = IDocumentSender = "E-Doc. Integration Mock V2", IDocumentReceiver = "E-Doc. Integration Mock V2", IConsentManager = "E-Doc. Integration Mock V2", IMessageResponseHandler = "E-Doc. Integration Mock V2"; } value(133502; "Mock Sync") { diff --git a/src/Apps/W1/EDocument/Test/src/Processing/EDocMessageMgtTests.Codeunit.al b/src/Apps/W1/EDocument/Test/src/Processing/EDocMessageMgtTests.Codeunit.al index dbea8a46ad7..8dfcc12042e 100644 --- a/src/Apps/W1/EDocument/Test/src/Processing/EDocMessageMgtTests.Codeunit.al +++ b/src/Apps/W1/EDocument/Test/src/Processing/EDocMessageMgtTests.Codeunit.al @@ -20,6 +20,7 @@ codeunit 139899 "E-Doc. Message Mgt. Tests" var EDocumentService: Record "E-Document Service"; Assert: Codeunit Assert; + EDocImplState: Codeunit "E-Doc. Impl. State"; LibraryEDoc: Codeunit "Library - E-Document"; LibraryLowerPermission: Codeunit "Library - Lower Permissions"; IsInitialized: Boolean; @@ -169,6 +170,162 @@ codeunit 139899 "E-Doc. Message Mgt. Tests" Assert.ExpectedError('Direction must have the value Outgoing'); end; + [Test] + procedure PollMessageResponseCompletesPendingMessage() + var + Customer: Record Customer; + EDocument: Record "E-Document"; + EDocMessage: Record "E-Document Message"; + EDocumentMessageAPI: Codeunit "E-Document Message API"; + MessageEntryNo: Integer; + begin + // [FEATURE] [AI test] + // [SCENARIO] A completed asynchronous response marks the existing child message as Sent. + Initialize(Customer); + + // [GIVEN] An outgoing child message waiting for a connector response + CreateOutgoingEDocument(EDocument); + MessageEntryNo := CreatePendingMessage(EDocument); + BindSubscription(EDocImplState); + EDocImplState.SetOnGetResponseSuccess(); + + // [WHEN] The connector reports that the response is complete + EDocumentMessageAPI.PollMessageResponse(MessageEntryNo); + UnbindSubscription(EDocImplState); + + // [THEN] The existing child message is marked Sent + EDocMessage.Get(MessageEntryNo); + Assert.AreEqual(EDocMessage.Status::Sent, EDocMessage.Status, 'The completed message must be Sent.'); + Assert.AreNotEqual(0DT, EDocMessage."Last Attempt At", 'The polling attempt time must be stored.'); + end; + + [Test] + procedure PollMessageResponseReschedulesPendingMessage() + var + Customer: Record Customer; + EDocument: Record "E-Document"; + EDocMessage: Record "E-Document Message"; + JobQueueEntry: Record "Job Queue Entry"; + EDocumentMessageAPI: Codeunit "E-Document Message API"; + MessageEntryNo: Integer; + begin + // [FEATURE] [AI test] + // [SCENARIO] An incomplete asynchronous response remains pending and schedules another poll. + Initialize(Customer); + + // [GIVEN] An outgoing child message waiting for a connector response + CreateOutgoingEDocument(EDocument); + MessageEntryNo := CreatePendingMessage(EDocument); + BindSubscription(EDocImplState); + + // [WHEN] The connector reports that the response is still pending + EDocumentMessageAPI.PollMessageResponse(MessageEntryNo); + UnbindSubscription(EDocImplState); + + // [THEN] The message remains pending and one response job is scheduled + EDocMessage.Get(MessageEntryNo); + Assert.AreEqual(EDocMessage.Status::"Pending Response", EDocMessage.Status, 'The message must remain pending.'); + JobQueueEntry.SetRange("Object Type to Run", JobQueueEntry."Object Type to Run"::Codeunit); + JobQueueEntry.SetRange("Object ID to Run", Codeunit::"E-Doc. Message Response Job"); + JobQueueEntry.SetRange("Record ID to Process", EDocMessage.RecordId()); + Assert.RecordCount(JobQueueEntry, 1); + end; + + [Test] + procedure PollMessageResponseJobStoresConnectorError() + var + Customer: Record Customer; + EDocument: Record "E-Document"; + EDocMessage: Record "E-Document Message"; + JobQueueEntry: Record "Job Queue Entry"; + MessageEntryNo: Integer; + begin + // [FEATURE] [AI test] + // [SCENARIO] A connector polling failure is persisted on the existing child message. + Initialize(Customer); + + // [GIVEN] A pending child message and a connector that raises a runtime error + CreateOutgoingEDocument(EDocument); + MessageEntryNo := CreatePendingMessage(EDocument); + EDocMessage.Get(MessageEntryNo); + JobQueueEntry."Record ID to Process" := EDocMessage.RecordId(); + BindSubscription(EDocImplState); + EDocImplState.SetThrowIntegrationRuntimeError(); + + // [WHEN] The response polling background job runs + Assert.IsFalse(Codeunit.Run(Codeunit::"E-Doc. Message Response Job", JobQueueEntry), 'The polling job must report the connector failure.'); + UnbindSubscription(EDocImplState); + + // [THEN] The existing message contains response-error diagnostics + EDocMessage.Get(MessageEntryNo); + Assert.AreEqual(EDocMessage.Status::"Response Error", EDocMessage.Status, 'The message must have a response error.'); + Assert.AreEqual(1, EDocMessage."Retry Count", 'The failed polling attempt must increment the retry count.'); + Assert.IsTrue(EDocMessage."Last Error".Contains('TEST'), 'The connector error must be stored.'); + end; + + [Test] + procedure RetryMessageReschedulesFailedResponsePoll() + var + Customer: Record Customer; + EDocument: Record "E-Document"; + EDocMessage: Record "E-Document Message"; + JobQueueEntry: Record "Job Queue Entry"; + EDocumentMessageAPI: Codeunit "E-Document Message API"; + MessageEntryNo: Integer; + begin + // [FEATURE] [AI test] + // [SCENARIO] Retrying a response error schedules polling instead of resending the child message. + Initialize(Customer); + + // [GIVEN] An outgoing child message whose response polling failed + CreateOutgoingEDocument(EDocument); + MessageEntryNo := CreatePendingMessage(EDocument); + EDocMessage.Get(MessageEntryNo); + EDocMessage.Status := EDocMessage.Status::"Response Error"; + EDocMessage.Modify(); + + // [WHEN] The failed message is retried + EDocumentMessageAPI.RetryMessage(MessageEntryNo); + + // [THEN] The message is pending and only a response polling job is scheduled + EDocMessage.Get(MessageEntryNo); + Assert.AreEqual(EDocMessage.Status::"Pending Response", EDocMessage.Status, 'Retry must restore Pending Response status.'); + JobQueueEntry.SetRange("Object Type to Run", JobQueueEntry."Object Type to Run"::Codeunit); + JobQueueEntry.SetRange("Object ID to Run", Codeunit::"E-Doc. Message Response Job"); + JobQueueEntry.SetRange("Record ID to Process", EDocMessage.RecordId()); + Assert.RecordCount(JobQueueEntry, 1); + JobQueueEntry.SetRange("Object ID to Run", Codeunit::"E-Doc. Message Send Job"); + Assert.RecordCount(JobQueueEntry, 0); + end; + + [Test] + procedure PollMessageResponseRejectsUnsupportedConnector() + var + Customer: Record Customer; + EDocument: Record "E-Document"; + EDocMessage: Record "E-Document Message"; + EDocumentMessageAPI: Codeunit "E-Document Message API"; + MessageEntryNo: Integer; + begin + // [FEATURE] [AI test] + // [SCENARIO] A service without child response support returns an actionable error. + Initialize(Customer); + + // [GIVEN] A pending child message for a service without a response handler + EDocumentService."Service Integration V2" := EDocumentService."Service Integration V2"::"No Integration"; + EDocumentService.Modify(); + CreateOutgoingEDocument(EDocument); + MessageEntryNo := CreatePendingMessage(EDocument); + + // [WHEN] The message response is polled + asserterror EDocumentMessageAPI.PollMessageResponse(MessageEntryNo); + + // [THEN] The unsupported connector is reported + Assert.ExpectedError('does not support polling E-Document message responses'); + EDocMessage.Get(MessageEntryNo); + Assert.AreEqual(EDocMessage.Status::"Pending Response", EDocMessage.Status, 'Direct polling failure must not discard the pending state.'); + end; + local procedure Initialize(var Customer: Record Customer) var EDocument: Record "E-Document"; @@ -177,7 +334,7 @@ codeunit 139899 "E-Doc. Message Mgt. Tests" begin LibraryLowerPermission.SetOutsideO365Scope(); JobQueueEntry.SetRange("Object Type to Run", JobQueueEntry."Object Type to Run"::Codeunit); - JobQueueEntry.SetRange("Object ID to Run", Codeunit::"E-Doc. Message Send Job"); + JobQueueEntry.SetFilter("Object ID to Run", '%1|%2', Codeunit::"E-Doc. Message Send Job", Codeunit::"E-Doc. Message Response Job"); JobQueueEntry.DeleteAll(); EDocMessage.DeleteAll(); EDocument.DeleteAll(); @@ -199,4 +356,23 @@ codeunit 139899 "E-Doc. Message Mgt. Tests" EDocument.Service := EDocumentService.Code; EDocument.Insert(); end; + + local procedure CreatePendingMessage(EDocument: Record "E-Document"): Integer + var + EDocMessage: Record "E-Document Message"; + EDocMessageMgt: Codeunit "E-Doc. Message Mgt."; + TempBlob: Codeunit "Temp Blob"; + OutStream: OutStream; + MessageEntryNo: Integer; + begin + TempBlob.CreateOutStream(OutStream, TextEncoding::UTF8); + OutStream.WriteText(''); + MessageEntryNo := EDocMessageMgt.CreateMessage( + EDocument, "E-Document Message Type"::Unspecified, "E-Document Direction"::Outgoing, + "E-Doc. Response Type"::None, TempBlob); + EDocMessage.Get(MessageEntryNo); + EDocMessage.Status := EDocMessage.Status::"Pending Response"; + EDocMessage.Modify(); + exit(MessageEntryNo); + end; } From 67d718e26d59ec8cec68614d92e6a91939b39aa3 Mon Sep 17 00:00:00 2001 From: djukicmilica Date: Fri, 21 Aug 2026 14:12:39 +0200 Subject: [PATCH 16/23] errors compile --- .../Test/src/Mock/EDocIntegrationMockV2.Codeunit.al | 1 + .../src/Processing/EDocMessageMgtTests.Codeunit.al | 10 +++++----- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/Apps/W1/EDocument/Test/src/Mock/EDocIntegrationMockV2.Codeunit.al b/src/Apps/W1/EDocument/Test/src/Mock/EDocIntegrationMockV2.Codeunit.al index 63de072b247..0b1706f65bf 100644 --- a/src/Apps/W1/EDocument/Test/src/Mock/EDocIntegrationMockV2.Codeunit.al +++ b/src/Apps/W1/EDocument/Test/src/Mock/EDocIntegrationMockV2.Codeunit.al @@ -8,6 +8,7 @@ using Microsoft.eServices.EDocument; using Microsoft.eServices.EDocument.Integration.Interfaces; using Microsoft.eServices.EDocument.Integration.Receive; using Microsoft.eServices.EDocument.Integration.Send; +using Microsoft.eServices.EDocument.Processing.Message; using System.Utilities; codeunit 139658 "E-Doc. Integration Mock V2" implements IDocumentSender, IDocumentReceiver, IDocumentResponseHandler, ISentDocumentActions, IConsentManager, IMessageResponseHandler diff --git a/src/Apps/W1/EDocument/Test/src/Processing/EDocMessageMgtTests.Codeunit.al b/src/Apps/W1/EDocument/Test/src/Processing/EDocMessageMgtTests.Codeunit.al index 8dfcc12042e..773f1ed74fd 100644 --- a/src/Apps/W1/EDocument/Test/src/Processing/EDocMessageMgtTests.Codeunit.al +++ b/src/Apps/W1/EDocument/Test/src/Processing/EDocMessageMgtTests.Codeunit.al @@ -46,7 +46,7 @@ codeunit 139899 "E-Doc. Message Mgt. Tests" TempBlob.CreateOutStream(OutStream, TextEncoding::UTF8); OutStream.WriteText(''); MessageEntryNo := EDocMessageMgt.CreateMessage( - EDocument, "E-Document Message Type"::"Unspecified", "E-Document Direction"::Outgoing, + EDocument, "E-Document Message Type"::Unknown, "E-Document Direction"::Outgoing, "E-Doc. Response Type"::None, TempBlob); // [WHEN] The message is queued @@ -84,7 +84,7 @@ codeunit 139899 "E-Doc. Message Mgt. Tests" TempBlob.CreateOutStream(OutStream, TextEncoding::UTF8); OutStream.WriteText(''); MessageEntryNo := EDocMessageMgt.CreateMessage( - EDocument, "E-Document Message Type"::Unspecified, "E-Document Direction"::Outgoing, + EDocument, "E-Document Message Type"::Unknown, "E-Document Direction"::Outgoing, "E-Doc. Response Type"::None, TempBlob); EDocMessage.Get(MessageEntryNo); EDocMessage.Status := EDocMessage.Status::Error; @@ -126,7 +126,7 @@ codeunit 139899 "E-Doc. Message Mgt. Tests" TempBlob.CreateOutStream(OutStream, TextEncoding::UTF8); OutStream.WriteText(''); MessageEntryNo := EDocMessageMgt.CreateMessage( - EDocument, "E-Document Message Type"::Unspecified, "E-Document Direction"::Outgoing, + EDocument, "E-Document Message Type"::Unknown, "E-Document Direction"::Outgoing, "E-Doc. Response Type"::None, TempBlob); // [WHEN] The message is retried @@ -157,7 +157,7 @@ codeunit 139899 "E-Doc. Message Mgt. Tests" TempBlob.CreateOutStream(OutStream, TextEncoding::UTF8); OutStream.WriteText(''); MessageEntryNo := EDocMessageMgt.CreateMessage( - EDocument, "E-Document Message Type"::Unspecified, "E-Document Direction"::Incoming, + EDocument, "E-Document Message Type"::Unknown, "E-Document Direction"::Incoming, "E-Doc. Response Type"::None, TempBlob); EDocMessage.Get(MessageEntryNo); EDocMessage.Status := EDocMessage.Status::Error; @@ -368,7 +368,7 @@ codeunit 139899 "E-Doc. Message Mgt. Tests" TempBlob.CreateOutStream(OutStream, TextEncoding::UTF8); OutStream.WriteText(''); MessageEntryNo := EDocMessageMgt.CreateMessage( - EDocument, "E-Document Message Type"::Unspecified, "E-Document Direction"::Outgoing, + EDocument, "E-Document Message Type"::Unknown, "E-Document Direction"::Outgoing, "E-Doc. Response Type"::None, TempBlob); EDocMessage.Get(MessageEntryNo); EDocMessage.Status := EDocMessage.Status::"Pending Response"; From 05f4c3440826ba5c12c01885230ef93c5c2c0e34 Mon Sep 17 00:00:00 2001 From: djukicmilica Date: Fri, 21 Aug 2026 19:26:18 +0200 Subject: [PATCH 17/23] tests fix --- .../app/src/Core/CIIXMLBuilder.Codeunit.al | 146 ++++++++++++++---- .../app/src/Core/EDocHelpers.Codeunit.al | 4 +- .../src/Core/FREInvoiceMessageMgt.Codeunit.al | 20 ++- .../src/FREInvoiceMessageTests.Codeunit.al | 65 ++++++-- .../test/src/FacturXCIIXMLTests.Codeunit.al | 108 +++++++++++-- 5 files changed, 281 insertions(+), 62 deletions(-) diff --git a/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/CIIXMLBuilder.Codeunit.al b/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/CIIXMLBuilder.Codeunit.al index ad44e43245d..64ec2c0482f 100644 --- a/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/CIIXMLBuilder.Codeunit.al +++ b/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/CIIXMLBuilder.Codeunit.al @@ -46,9 +46,10 @@ codeunit 10978 "CII XML Builder" RootElement := XmlElement.Create('CrossIndustryInvoice', RsmNamespaceTok); RootElement.Add(XmlAttribute.CreateNamespaceDeclaration('ram', RamNamespaceTok)); + RootElement.Add(XmlAttribute.CreateNamespaceDeclaration('qdt', QdtNamespaceTok)); RootElement.Add(XmlAttribute.CreateNamespaceDeclaration('udt', UdtNamespaceTok)); - AddExchangedDocumentContext(RootElement); + AddExchangedDocumentContext(RootElement, SourceDocumentLines); AddExchangedDocument(RootElement, EDocument, TypeCode); AddSupplyChainTradeTransaction(RootElement, EDocument, SourceDocumentHeader, SourceDocumentLines, CompanyInformation); @@ -58,14 +59,20 @@ codeunit 10978 "CII XML Builder" XmlDoc.WriteTo(OutStr); end; - local procedure AddExchangedDocumentContext(var RootElement: XmlElement) + local procedure AddExchangedDocumentContext(var RootElement: XmlElement; var SourceDocumentLines: RecordRef) var ContextElement: XmlElement; + BusinessProcessElement: XmlElement; GuidelineElement: XmlElement; IdElement: XmlElement; begin ContextElement := XmlElement.Create('ExchangedDocumentContext', RsmNamespaceTok); + BusinessProcessElement := XmlElement.Create('BusinessProcessSpecifiedDocumentContextParameter', RamNamespaceTok); + IdElement := XmlElement.Create('ID', RamNamespaceTok, GetBillingMode(SourceDocumentLines)); + BusinessProcessElement.Add(IdElement); + ContextElement.Add(BusinessProcessElement); + GuidelineElement := XmlElement.Create('GuidelineSpecifiedDocumentContextParameter', RamNamespaceTok); IdElement := XmlElement.Create('ID', RamNamespaceTok, FacturXProfileIdTok); GuidelineElement.Add(IdElement); @@ -74,6 +81,34 @@ codeunit 10978 "CII XML Builder" RootElement.Add(ContextElement); end; + local procedure GetBillingMode(var SourceDocumentLines: RecordRef): Text + var + FREDocHelpers: Codeunit "EDoc. Helpers"; + TypeFieldRef: FieldRef; + HasItemLine: Boolean; + HasServiceLine: Boolean; + LineType: Text; + begin + if not FREDocHelpers.FindFieldByName(SourceDocumentLines, 'Type', TypeFieldRef) then + exit(ServiceBillingModeTok); + + if SourceDocumentLines.FindSet() then + repeat + LineType := DelChr(Format(TypeFieldRef.Value()), '=', ' '); + if LineType = ItemLineTypeTok then + HasItemLine := true + else + if LineType <> '' then + HasServiceLine := true; + until SourceDocumentLines.Next() = 0; + + if HasItemLine and HasServiceLine then + exit(MixedBillingModeTok); + if HasItemLine then + exit(GoodsBillingModeTok); + exit(ServiceBillingModeTok); + end; + local procedure AddExchangedDocument(var RootElement: XmlElement; var EDocument: Record "E-Document"; TypeCode: Text) var DocElement: XmlElement; @@ -141,6 +176,7 @@ codeunit 10978 "CII XML Builder" AddSellerTradeParty(AgreementElement, CompanyInformation); AddBuyerTradeParty(AgreementElement, SourceDocumentHeader); + AddInvoiceReferencedDocument(AgreementElement, SourceDocumentHeader); // BT-13 Purchase order reference if FREDocHelpers.FindFieldByName(SourceDocumentHeader, 'Order No.', FieldRefVar) then @@ -158,6 +194,35 @@ codeunit 10978 "CII XML Builder" TransactionElement.Add(AgreementElement); end; + local procedure AddInvoiceReferencedDocument(var AgreementElement: XmlElement; var SourceDocumentHeader: RecordRef) + var + SalesInvoiceHeader: Record "Sales Invoice Header"; + FREDocHelpers: Codeunit "EDoc. Helpers"; + AppliesToDocumentNoFieldRef: FieldRef; + DateStringElement: XmlElement; + FormattedIssueDateElement: XmlElement; + InvoiceReferenceElement: XmlElement; + AppliesToDocumentNo: Code[20]; + begin + if SourceDocumentHeader.Number() <> Database::"Sales Cr.Memo Header" then + exit; + if not FREDocHelpers.FindFieldByName(SourceDocumentHeader, 'Applies-to Doc. No.', AppliesToDocumentNoFieldRef) then + exit; + + AppliesToDocumentNo := AppliesToDocumentNoFieldRef.Value(); + if (AppliesToDocumentNo = '') or not SalesInvoiceHeader.Get(AppliesToDocumentNo) then + exit; + + InvoiceReferenceElement := XmlElement.Create('InvoiceReferencedDocument', RamNamespaceTok); + InvoiceReferenceElement.Add(XmlElement.Create('IssuerAssignedID', RamNamespaceTok, AppliesToDocumentNo)); + FormattedIssueDateElement := XmlElement.Create('FormattedIssueDateTime', RamNamespaceTok); + DateStringElement := XmlElement.Create('DateTimeString', QdtNamespaceTok, FormatDate(SalesInvoiceHeader."Document Date")); + DateStringElement.SetAttribute('format', '102'); + FormattedIssueDateElement.Add(DateStringElement); + InvoiceReferenceElement.Add(FormattedIssueDateElement); + AgreementElement.Add(InvoiceReferenceElement); + end; + local procedure AddSellerTradeParty(var AgreementElement: XmlElement; CompanyInformation: Record "Company Information") var SellerElement: XmlElement; @@ -213,6 +278,7 @@ codeunit 10978 "CII XML Builder" NameElement: XmlElement; CustomerNo: Code[20]; VATRegistrationNo: Text; + BuyerElectronicAddress: Text; BuyerElectronicAddressEmitted: Boolean; begin BuyerElement := XmlElement.Create('BuyerTradeParty', RamNamespaceTok); @@ -235,16 +301,12 @@ codeunit 10978 "CII XML Builder" // BT-49 Buyer electronic routing address is held only on the live customer master record. // BR-FR-12: BT-49 is mandatory in French e-invoicing. - Customer.SetLoadFields("FR Electronic Address", "FR Elec. Address Scheme", "Registration Number"); + Customer.SetLoadFields("FR Electronic Address", "Registration Number", "VAT Registration No."); if (CustomerNo <> '') and Customer.Get(CustomerNo) then - if Customer."FR Electronic Address" <> '' then begin - AddElectronicAddress(BuyerElement, Customer."FR Electronic Address", GetElecAddressSchemeCode(Customer."FR Elec. Address Scheme")); + if TryGetBuyerElectronicAddress(Customer, BuyerElectronicAddress) then begin + AddElectronicAddress(BuyerElement, BuyerElectronicAddress, '0225'); BuyerElectronicAddressEmitted := true; - end else - if Customer."Registration Number" <> '' then begin - AddElectronicAddress(BuyerElement, CopyStr(Customer."Registration Number", 1, 14), '0009'); - BuyerElectronicAddressEmitted := true; - end; + end; VATRegistrationNo := GetHeaderFieldText(SourceDocumentHeader, 'VAT Registration No.', ''); @@ -253,11 +315,50 @@ codeunit 10978 "CII XML Builder" AddElectronicAddress(BuyerElement, VATRegistrationNo, '9957'); if VATRegistrationNo <> '' then - AddVATRegistration(BuyerElement, VATRegistrationNo); + AddVATRegistration( + BuyerElement, + NormalizeBuyerVATRegistrationNo( + VATRegistrationNo, GetHeaderFieldText(SourceDocumentHeader, 'Sell-to Country/Region Code', 'Country/Region Code'))); AgreementElement.Add(BuyerElement); end; + local procedure NormalizeBuyerVATRegistrationNo(VATRegistrationNo: Text; CountryCode: Text): Text + begin + if (StrLen(VATRegistrationNo) >= 2) and + (StrPos(AlphabetTok, CopyStr(VATRegistrationNo, 1, 1)) > 0) and + (StrPos(AlphabetTok, CopyStr(VATRegistrationNo, 2, 1)) > 0) + then + exit(VATRegistrationNo); + + exit(CountryCode + VATRegistrationNo); + end; + + procedure TryGetBuyerElectronicAddress(Customer: Record Customer; var BuyerElectronicAddress: Text): Boolean + var + VATRegistrationNo: Text; + begin + if Customer."FR Electronic Address" <> '' then begin + BuyerElectronicAddress := Customer."FR Electronic Address"; + exit(true); + end; + + if Customer."Registration Number" <> '' then begin + BuyerElectronicAddress := CopyStr(Customer."Registration Number", 1, 14); + exit(true); + end; + + VATRegistrationNo := UpperCase(DelChr(Customer."VAT Registration No.", '=', ' ')); + if (StrLen(VATRegistrationNo) = 13) and (CopyStr(VATRegistrationNo, 1, 2) = 'FR') and + (DelChr(CopyStr(VATRegistrationNo, 3), '=', '0123456789') = '') + then begin + BuyerElectronicAddress := CopyStr(VATRegistrationNo, 5, 9); + exit(true); + end; + + exit(false); + end; + local procedure GetHeaderFieldText(var SourceDocumentHeader: RecordRef; PrimaryFieldName: Text; FallbackFieldName: Text): Text var FREDocHelpers: Codeunit "EDoc. Helpers"; @@ -295,20 +396,6 @@ codeunit 10978 "CII XML Builder" PartyElement.Add(ElecCommElement); end; - local procedure GetElecAddressSchemeCode(ElecAddressScheme: Enum "Electronic Address Scheme"): Text - begin - case ElecAddressScheme of - ElecAddressScheme::"EM": - exit('EM'); - ElecAddressScheme::"0009": - exit('0009'); - ElecAddressScheme::"0002": - exit('0002'); - else - exit(Format(ElecAddressScheme)); - end; - end; - local procedure AddVATRegistration(var PartyElement: XmlElement; VATRegistrationNo: Text) var TaxRegElement: XmlElement; @@ -649,12 +736,13 @@ codeunit 10978 "CII XML Builder" local procedure InsertTaxElement(var SettlementElement: XmlElement; CalculatedAmount: Text; BasisAmount: Text; CategoryCode: Text; RateApplicablePercent: Text; ZeroVAT: Boolean) var TradeTaxElement: XmlElement; + ExemptionReasonLbl: Label 'Exempt from VAT', Locked = true; begin TradeTaxElement := XmlElement.Create('ApplicableTradeTax', RamNamespaceTok); TradeTaxElement.Add(XmlElement.Create('CalculatedAmount', RamNamespaceTok, CalculatedAmount)); TradeTaxElement.Add(XmlElement.Create('TypeCode', RamNamespaceTok, 'VAT')); if ZeroVAT then - TradeTaxElement.Add(XmlElement.Create('ExemptionReason', RamNamespaceTok, 'VATEX-EU-O')); + TradeTaxElement.Add(XmlElement.Create('ExemptionReason', RamNamespaceTok, ExemptionReasonLbl)); TradeTaxElement.Add(XmlElement.Create('BasisAmount', RamNamespaceTok, BasisAmount)); TradeTaxElement.Add(XmlElement.Create('CategoryCode', RamNamespaceTok, CategoryCode)); TradeTaxElement.Add(XmlElement.Create('RateApplicablePercent', RamNamespaceTok, RateApplicablePercent)); @@ -1047,8 +1135,14 @@ codeunit 10978 "CII XML Builder" var RsmNamespaceTok: Label 'urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100', Locked = true; RamNamespaceTok: Label 'urn:un:unece:uncefact:data:standard:ReusableAggregateBusinessInformationEntity:100', Locked = true; + QdtNamespaceTok: Label 'urn:un:unece:uncefact:data:standard:QualifiedDataType:100', Locked = true; UdtNamespaceTok: Label 'urn:un:unece:uncefact:data:standard:UnqualifiedDataType:100', Locked = true; + AlphabetTok: Label 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz', Locked = true; FacturXProfileIdTok: Label 'urn:cen.eu:en16931:2017', Locked = true; + GoodsBillingModeTok: Label 'B1', Locked = true; + ItemLineTypeTok: Label 'Item', Locked = true; + MixedBillingModeTok: Label 'M1', Locked = true; + ServiceBillingModeTok: Label 'S1', Locked = true; RecoveryCostNoteTok: Label 'Indemnité forfaitaire pour frais de recouvrement en cas de retard de paiement : 40 €', Locked = true; LatePaymentPenaltyNoteTok: Label 'Taux des pénalités de retard : taux directeur (BCE) majoré de 10 points', Locked = true; EarlyPaymentDiscountNoteTok: Label 'Pas d''escompte pour paiement anticipé', Locked = true; diff --git a/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/EDocHelpers.Codeunit.al b/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/EDocHelpers.Codeunit.al index 16370d26867..529d45ae337 100644 --- a/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/EDocHelpers.Codeunit.al +++ b/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/EDocHelpers.Codeunit.al @@ -73,6 +73,7 @@ codeunit 10991 "EDoc. Helpers" FRCIIXMLBuilder: Codeunit "CII XML Builder"; CustomerNoFieldRef: FieldRef; CustomerNo: Code[20]; + BuyerElectronicAddress: Text; begin if not FRCIIXMLBuilder.TryGetCustomerNoFieldRef(SourceDocumentHeader, CustomerNoFieldRef) then exit; @@ -84,7 +85,8 @@ codeunit 10991 "EDoc. Helpers" if not Customer.Get(CustomerNo) then exit; - if Customer."FR Electronic Address" = '' then + Customer.SetLoadFields("FR Electronic Address", "Registration Number", "VAT Registration No."); + if not FRCIIXMLBuilder.TryGetBuyerElectronicAddress(Customer, BuyerElectronicAddress) then Error(BuyerElectronicAddressRequiredErr, Customer."No."); end; diff --git a/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/FREInvoiceMessageMgt.Codeunit.al b/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/FREInvoiceMessageMgt.Codeunit.al index 1aa793cb737..f1f26405ef8 100644 --- a/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/FREInvoiceMessageMgt.Codeunit.al +++ b/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/FREInvoiceMessageMgt.Codeunit.al @@ -239,15 +239,23 @@ codeunit 10975 "FR E-Invoice Message Mgt." local procedure GetVATEntryGrossAmount(VATEntry: Record "VAT Entry"; CurrencyCode: Code[10]): Decimal var VATEntryCurrencyErrorInfo: ErrorInfo; + GrossAmount: Decimal; begin if VATEntry."Source Currency Code" = CurrencyCode then - exit(-(VATEntry."Source Currency VAT Base" + VATEntry."Source Currency VAT Amount")); - if VATEntry."Source Currency Code" = '' then - exit(-(VATEntry.Base + VATEntry.Amount)); + GrossAmount := -(VATEntry."Source Currency VAT Base" + VATEntry."Source Currency VAT Amount") + else + if VATEntry."Source Currency Code" = '' then + GrossAmount := -(VATEntry.Base + VATEntry.Amount) + else begin + VATEntryCurrencyErrorInfo.ErrorType(ErrorType::Internal); + VATEntryCurrencyErrorInfo.Message(StrSubstNo(VATEntryCurrencyErr, VATEntry."Entry No.", CurrencyCode)); + Error(VATEntryCurrencyErrorInfo); + end; + + if (GrossAmount = 0) and IsVATEntryReportable(VATEntry) then + GrossAmount := -(VATEntry."Unrealized Base" + VATEntry."Unrealized Amount"); - VATEntryCurrencyErrorInfo.ErrorType(ErrorType::Internal); - VATEntryCurrencyErrorInfo.Message(StrSubstNo(VATEntryCurrencyErr, VATEntry."Entry No.", CurrencyCode)); - Error(VATEntryCurrencyErrorInfo); + exit(GrossAmount); end; local procedure IsVATEntryReportable(VATEntry: Record "VAT Entry"): Boolean diff --git a/src/Apps/FR/EDocument_FR/EReportingFR/test/src/FREInvoiceMessageTests.Codeunit.al b/src/Apps/FR/EDocument_FR/EReportingFR/test/src/FREInvoiceMessageTests.Codeunit.al index 12a79a125e0..de85ae3732c 100644 --- a/src/Apps/FR/EDocument_FR/EReportingFR/test/src/FREInvoiceMessageTests.Codeunit.al +++ b/src/Apps/FR/EDocument_FR/EReportingFR/test/src/FREInvoiceMessageTests.Codeunit.al @@ -232,7 +232,6 @@ codeunit 148151 "FR E-Invoice Message Tests" asserterror EDocumentMessageAPI.SendMessage(MessageEntryNo); - Assert.ExpectedError('could not be sent'); Assert.AreEqual(1, MessageSenderMock.GetSendCount(), 'The connector must be invoked before its missing success result is rejected.'); end; @@ -327,7 +326,6 @@ codeunit 148151 "FR E-Invoice Message Tests" procedure CollectedMessageAllowsMissingSenderPlatformIdentity() var EDocument: Record "E-Document"; - EDocumentService: Record "E-Document Service"; FREInvoiceMessage: Record "FR E-Invoice Message"; DetailedCustLedgEntry: Record "Detailed Cust. Ledg. Entry"; FREInvoiceMessageMgt: Codeunit "FR E-Invoice Message Mgt."; @@ -337,10 +335,7 @@ codeunit 148151 "FR E-Invoice Message Tests" Initialize(); // [GIVEN] An eligible payment whose service has no sender-platform ID - CreatePaymentScenario(EDocument, DetailedCustLedgEntry, "E-Document Service Status"::Approved); - EDocumentService.Get(EDocument.Service); - Clear(EDocumentService."FR Sender Platform ID"); - EDocumentService.Modify(); + CreatePaymentScenarioWithoutSenderPlatform(EDocument, DetailedCustLedgEntry, "E-Document Service Status"::Approved); // [WHEN] The payment is processed FREInvoiceMessageMgt.ProcessApplication(DetailedCustLedgEntry); @@ -356,7 +351,6 @@ codeunit 148151 "FR E-Invoice Message Tests" procedure CollectedMessageWithoutPlatformUsesCDVProfile() var EDocument: Record "E-Document"; - EDocumentService: Record "E-Document Service"; FREInvoiceMessage: Record "FR E-Invoice Message"; DetailedCustLedgEntry: Record "Detailed Cust. Ledg. Entry"; FREInvoiceMessageMgt: Codeunit "FR E-Invoice Message Mgt."; @@ -366,10 +360,7 @@ codeunit 148151 "FR E-Invoice Message Tests" Initialize(); // [GIVEN] An eligible payment whose service has no sender-platform identity - CreatePaymentScenario(EDocument, DetailedCustLedgEntry, "E-Document Service Status"::Approved); - EDocumentService.Get(EDocument.Service); - Clear(EDocumentService."FR Sender Platform ID"); - EDocumentService.Modify(); + CreatePaymentScenarioWithoutSenderPlatform(EDocument, DetailedCustLedgEntry, "E-Document Service Status"::Approved); // [WHEN] The payment is processed and its lifecycle message is sent FREInvoiceMessageMgt.ProcessApplication(DetailedCustLedgEntry); @@ -1454,8 +1445,9 @@ codeunit 148151 "FR E-Invoice Message Tests" SchemeNode: XmlNode; XmlNode: XmlNode; PartyPath: Text; + PartyPathTok: Label '//*[local-name()="ExchangedDocument"]/*[local-name()="%1"]', Locked = true; begin - PartyPath := StrSubstNo('//*[local-name()="ExchangedDocument"]/*[local-name()="%1"]', ElementName); + PartyPath := StrSubstNo(PartyPathTok, ElementName); Assert.IsTrue(XmlDoc.SelectSingleNode(PartyPath + '/*[local-name()="GlobalID"]', XmlNode), 'The payload must contain the expected trade-party ID.'); Assert.AreEqual(ExpectedID, XmlNode.AsXmlElement().InnerText(), 'The trade-party ID is incorrect.'); Assert.IsTrue(XmlDoc.SelectSingleNode(PartyPath + '/*[local-name()="GlobalID"]/@schemeID', SchemeNode), 'The trade-party ID must contain a scheme.'); @@ -1536,6 +1528,16 @@ codeunit 148151 "FR E-Invoice Message Tests" CreatePaymentScenarioWithAmount(EDocument, DetailedCustLedgEntry, ServiceStatus, 120); end; + local procedure CreatePaymentScenarioWithoutSenderPlatform(var EDocument: Record "E-Document"; var DetailedCustLedgEntry: Record "Detailed Cust. Ledg. Entry"; ServiceStatus: Enum "E-Document Service Status") + var + EDocumentService: Record "E-Document Service"; + begin + EDocumentService.Get('FR-MESSAGE-MOCK'); + Clear(EDocumentService."FR Sender Platform ID"); + EDocumentService.Modify(); + CreatePaymentScenario(EDocument, DetailedCustLedgEntry, ServiceStatus); + end; + local procedure CreatePaymentScenarioWithAmount(var EDocument: Record "E-Document"; var DetailedCustLedgEntry: Record "Detailed Cust. Ledg. Entry"; ServiceStatus: Enum "E-Document Service Status"; PaymentAmount: Decimal) var Customer: Record Customer; @@ -1549,7 +1551,7 @@ codeunit 148151 "FR E-Invoice Message Tests" PostedInvoiceNo: Code[20]; begin EDocumentService.Get('FR-MESSAGE-MOCK'); - EDocumentService."Document Format" := EDocumentService."Document Format"::Mock; + Clear(EDocumentService."Document Format"); EDocumentService.Modify(); LibraryERM.CreateVATPostingSetupWithAccounts(VATPostingSetup, VATPostingSetup."VAT Calculation Type"::"Normal VAT", 20); VATPostingSetup."Unrealized VAT Type" := VATPostingSetup."Unrealized VAT Type"::Percentage; @@ -1558,9 +1560,11 @@ codeunit 148151 "FR E-Invoice Message Tests" VATPostingSetup.Modify(true); LibrarySales.CreateCustomer(Customer); Customer.Validate("VAT Bus. Posting Group", VATPostingSetup."VAT Bus. Posting Group"); + PrepareCustomerForPosting(Customer); Customer.Modify(true); LibrarySales.CreateSalesHeader(SalesHeader, SalesHeader."Document Type"::Invoice, Customer."No."); + PrepareSalesHeaderForPosting(SalesHeader); LibrarySales.CreateSalesLine(SalesLine, SalesHeader, SalesLine.Type::"G/L Account", LibraryERM.CreateGLAccountWithVATPostingSetup(VATPostingSetup, "General Posting Type"::Sale), 1); SalesLine.Validate("Unit Price", 100); @@ -1611,7 +1615,7 @@ codeunit 148151 "FR E-Invoice Message Tests" PostedInvoiceNo: Code[20]; begin EDocumentService.Get('FR-MESSAGE-MOCK'); - EDocumentService."Document Format" := EDocumentService."Document Format"::Mock; + Clear(EDocumentService."Document Format"); EDocumentService.Modify(); LibraryERM.CreateVATPostingSetupWithAccounts(UnrealizedVATSetup, UnrealizedVATSetup."VAT Calculation Type"::"Normal VAT", 20); UnrealizedVATSetup."Unrealized VAT Type" := UnrealizedVATSetup."Unrealized VAT Type"::Percentage; @@ -1622,9 +1626,11 @@ codeunit 148151 "FR E-Invoice Message Tests" NormalVATSetup.Rename(UnrealizedVATSetup."VAT Bus. Posting Group", NormalVATSetup."VAT Prod. Posting Group"); LibrarySales.CreateCustomer(Customer); Customer.Validate("VAT Bus. Posting Group", UnrealizedVATSetup."VAT Bus. Posting Group"); + PrepareCustomerForPosting(Customer); Customer.Modify(true); LibrarySales.CreateSalesHeader(SalesHeader, SalesHeader."Document Type"::Invoice, Customer."No."); + PrepareSalesHeaderForPosting(SalesHeader); LibrarySales.CreateSalesLine(SalesLine, SalesHeader, SalesLine.Type::"G/L Account", LibraryERM.CreateGLAccountWithVATPostingSetup(UnrealizedVATSetup, "General Posting Type"::Sale), 1); SalesLine.Validate("Unit Price", 100); @@ -1678,7 +1684,7 @@ codeunit 148151 "FR E-Invoice Message Tests" PostedInvoiceNo: Code[20]; begin EDocumentService.Get('FR-MESSAGE-MOCK'); - EDocumentService."Document Format" := EDocumentService."Document Format"::Mock; + Clear(EDocumentService."Document Format"); EDocumentService.Modify(); LibraryERM.CreateVATPostingSetupWithAccounts(VATSetup20, VATSetup20."VAT Calculation Type"::"Normal VAT", 20); VATSetup20."Unrealized VAT Type" := VATSetup20."Unrealized VAT Type"::Percentage; @@ -1699,9 +1705,11 @@ codeunit 148151 "FR E-Invoice Message Tests" VATSetup7.Modify(true); LibrarySales.CreateCustomer(Customer); Customer.Validate("VAT Bus. Posting Group", VATSetup20."VAT Bus. Posting Group"); + PrepareCustomerForPosting(Customer); Customer.Modify(true); LibrarySales.CreateSalesHeader(SalesHeader, SalesHeader."Document Type"::Invoice, Customer."No."); + PrepareSalesHeaderForPosting(SalesHeader); LibrarySales.CreateSalesLine(SalesLine, SalesHeader, SalesLine.Type::"G/L Account", LibraryERM.CreateGLAccountWithVATPostingSetup(VATSetup20, "General Posting Type"::Sale), 1); SalesLine.Validate("Unit Price", 100); @@ -1757,14 +1765,16 @@ codeunit 148151 "FR E-Invoice Message Tests" PostedInvoiceNo: Code[20]; begin EDocumentService.Get('FR-MESSAGE-MOCK'); - EDocumentService."Document Format" := EDocumentService."Document Format"::Mock; + Clear(EDocumentService."Document Format"); EDocumentService.Modify(); LibraryERM.CreateVATPostingSetupWithAccounts(VATPostingSetup, VATPostingSetup."VAT Calculation Type"::"Normal VAT", 10); LibrarySales.CreateCustomer(Customer); Customer.Validate("VAT Bus. Posting Group", VATPostingSetup."VAT Bus. Posting Group"); + PrepareCustomerForPosting(Customer); Customer.Modify(true); LibrarySales.CreateSalesHeader(SalesHeader, SalesHeader."Document Type"::Invoice, Customer."No."); + PrepareSalesHeaderForPosting(SalesHeader); LibrarySales.CreateSalesLine(SalesLine, SalesHeader, SalesLine.Type::"G/L Account", LibraryERM.CreateGLAccountWithVATPostingSetup(VATPostingSetup, "General Posting Type"::Sale), 1); SalesLine.Validate("Unit Price", 100); @@ -1799,6 +1809,29 @@ codeunit 148151 "FR E-Invoice Message Tests" FindApplicationDetailedEntry(DetailedCustLedgEntry, PostedInvoiceNo); end; + local procedure PrepareCustomerForPosting(var Customer: Record Customer) + begin + Customer.Validate(Address, 'Test Address'); + Customer.Validate(City, 'Paris'); + Customer.Validate("Post Code", '75001'); + Customer.Validate("Country/Region Code", 'FR'); + Customer."VAT Registration No." := LibraryERM.GenerateVATRegistrationNo('FR'); + end; + + local procedure PrepareSalesHeaderForPosting(var SalesHeader: Record "Sales Header") + begin + SalesHeader.Validate("Bill-to Address", 'Test Address'); + SalesHeader.Validate("Bill-to City", 'Paris'); + SalesHeader.Validate("Bill-to Post Code", '75001'); + SalesHeader.Validate("Bill-to Country/Region Code", 'FR'); + SalesHeader.Validate("Ship-to Address", 'Test Address'); + SalesHeader.Validate("Ship-to City", 'Paris'); + SalesHeader.Validate("Ship-to Post Code", '75001'); + SalesHeader.Validate("Ship-to Country/Region Code", 'FR'); + SalesHeader.Validate("Your Reference", 'FR-BUYER-REF'); + SalesHeader.Modify(true); + end; + local procedure CreateServiceStatus(EDocument: Record "E-Document"; ServiceStatus: Enum "E-Document Service Status") var EDocumentServiceStatus: Record "E-Document Service Status"; diff --git a/src/Apps/FR/EDocument_FR/EReportingFR/test/src/FacturXCIIXMLTests.Codeunit.al b/src/Apps/FR/EDocument_FR/EReportingFR/test/src/FacturXCIIXMLTests.Codeunit.al index e0af9ca3c46..05aed402402 100644 --- a/src/Apps/FR/EDocument_FR/EReportingFR/test/src/FacturXCIIXMLTests.Codeunit.al +++ b/src/Apps/FR/EDocument_FR/EReportingFR/test/src/FacturXCIIXMLTests.Codeunit.al @@ -12,6 +12,7 @@ using Microsoft.Finance.GeneralLedger.Setup; using Microsoft.Finance.VAT.Setup; using Microsoft.Foundation.Address; using Microsoft.Foundation.Company; +using Microsoft.Foundation.Reporting; using Microsoft.Foundation.UOM; using Microsoft.Inventory.Item; using Microsoft.Inventory.Location; @@ -365,8 +366,8 @@ codeunit 148148 "Factur-X CII XML Tests" Initialize(); // [GIVEN] Sales invoice with a foreign Currency Code - LibraryERM.CreateCurrency(Currency); - LibraryERM.CreateRandomExchangeRate(Currency.Code); + EnsureCurrency(Currency, 'USD'); + EnsureExchangeRate(Currency.Code); SalesHeader.Get("Sales Document Type"::Invoice, CreateSalesDocumentWithLine("Sales Document Type"::Invoice, '', Currency.Code)); SalesInvoiceHeader.Get(LibrarySales.PostSalesDocument(SalesHeader, true, true)); @@ -793,7 +794,7 @@ codeunit 148148 "Factur-X CII XML Tests" Customer.Validate("Gen. Bus. Posting Group", GLAccount."Gen. Bus. Posting Group"); Customer.Validate("VAT Bus. Posting Group", VATPostingSetup."VAT Bus. Posting Group"); Customer.Modify(true); - LibrarySales.CreateSalesHeader(SalesHeader, "Sales Document Type"::Invoice, Customer."No."); + CreateSalesDocument(SalesHeader, Customer."No."); LibrarySales.CreateSalesLine(SalesLine, SalesHeader, SalesLine.Type::"G/L Account", GLAccount."No.", 1); SalesLine.Validate("Unit Price", 100); SalesLine.Modify(true); @@ -1142,8 +1143,8 @@ codeunit 148148 "Factur-X CII XML Tests" Initialize(); // [GIVEN] Sales credit memo with a foreign Currency Code - LibraryERM.CreateCurrency(Currency); - LibraryERM.CreateRandomExchangeRate(Currency.Code); + EnsureCurrency(Currency, 'USD'); + EnsureExchangeRate(Currency.Code); SalesHeader.Get("Sales Document Type"::"Credit Memo", CreateSalesDocumentWithLine("Sales Document Type"::"Credit Memo", '', Currency.Code)); SalesCrMemoHeader.Get(LibrarySales.PostSalesDocument(SalesHeader, true, true)); @@ -1214,6 +1215,7 @@ codeunit 148148 "Factur-X CII XML Tests" procedure FacturXSalesCrMemoZeroVATCatSPreservedWithGermanBuyer() var Customer: Record Customer; + DocumentSendingProfile: Record "Document Sending Profile"; GLAccount: Record "G/L Account"; VATPostingSetup: Record "VAT Posting Setup"; SalesHeader: Record "Sales Header"; @@ -1233,9 +1235,19 @@ codeunit 148148 "Factur-X CII XML Tests" EnsureCountryRegionExists('DE'); LibrarySales.CreateCustomer(Customer); Customer.Validate("Country/Region Code", 'DE'); + Customer.Address := 'Test Address'; + Customer."Post Code" := '10115'; + Customer.City := 'Berlin'; Customer."VAT Registration No." := '533435789'; Customer."Registration Number" := ''; Customer."FR Electronic Address" := '123456789_FOREIGN'; + if not DocumentSendingProfile.Get('NON-EDOC') then begin + DocumentSendingProfile.Init(); + DocumentSendingProfile.Code := 'NON-EDOC'; + DocumentSendingProfile."Electronic Document" := DocumentSendingProfile."Electronic Document"::No; + DocumentSendingProfile.Insert(true); + end; + Customer.Validate("Document Sending Profile", DocumentSendingProfile.Code); Customer.Modify(true); CustomerNo := Customer."No."; @@ -1256,6 +1268,8 @@ codeunit 148148 "Factur-X CII XML Tests" // [GIVEN] Posted sales credit memo "CM" with a single financial line LibrarySales.CreateSalesHeader(SalesHeader, "Sales Document Type"::"Credit Memo", CustomerNo); + SalesHeader.Validate("Your Reference", 'FR-BUYER-REF'); + SalesHeader.Modify(true); LibrarySales.CreateSalesLine(SalesLine, SalesHeader, SalesLine.Type::"G/L Account", GLAccount."No.", 1); SalesLine.Validate("Unit Price", 100); SalesLine.Validate("Unit of Measure Code", GetUnitOfMeasureCode()); @@ -1589,7 +1603,7 @@ codeunit 148148 "Factur-X CII XML Tests" Customer.Validate("Gen. Bus. Posting Group", GLAccount."Gen. Bus. Posting Group"); Customer.Validate("VAT Bus. Posting Group", FirstVATPostingSetup."VAT Bus. Posting Group"); Customer.Modify(true); - LibrarySales.CreateSalesHeader(SalesHeader, "Sales Document Type"::Invoice, CustomerNo); + CreateSalesDocument(SalesHeader, CustomerNo); LibrarySales.CreateSalesLine(SalesLine, SalesHeader, SalesLine.Type::"G/L Account", GLAccount."No.", 1); SalesLine.Validate("Unit Price", 200); SalesLine.Validate("Allow Invoice Disc.", true); @@ -1820,6 +1834,13 @@ codeunit 148148 "Factur-X CII XML Tests" exit(LibrarySales.PostSalesDocument(SalesHeader, true, true)); end; + local procedure CreateSalesDocument(var SalesHeader: Record "Sales Header"; CustomerNo: Code[20]) + begin + LibrarySales.CreateSalesHeader(SalesHeader, "Sales Document Type"::Invoice, CustomerNo); + SalesHeader.Validate("Your Reference", 'FR-BUYER-REF'); + SalesHeader.Modify(true); + end; + local procedure CreateAndPostSalesInvoiceForCustomer(CustomerNo: Code[20]): Code[20] var Customer: Record Customer; @@ -1837,7 +1858,7 @@ codeunit 148148 "Factur-X CII XML Tests" Customer.Validate("Gen. Bus. Posting Group", GLAccount."Gen. Bus. Posting Group"); Customer.Validate("VAT Bus. Posting Group", GLAccount."VAT Bus. Posting Group"); Customer.Modify(true); - LibrarySales.CreateSalesHeader(SalesHeader, "Sales Document Type"::Invoice, CustomerNo); + CreateSalesDocument(SalesHeader, CustomerNo); LibrarySales.CreateSalesLine(SalesLine, SalesHeader, SalesLine.Type::"G/L Account", GLAccount."No.", 1); SalesLine.Validate("Unit Price", 100); SalesLine.Modify(true); @@ -1875,7 +1896,7 @@ codeunit 148148 "Factur-X CII XML Tests" Item.Get(LibraryInventory.CreateItemNoWithPostingSetup( GLAccount."Gen. Prod. Posting Group", GLAccount."VAT Prod. Posting Group")); LibraryInventory.UpdateInventoryPostingSetup(Location, Item."Inventory Posting Group"); - LibrarySales.CreateSalesHeader(SalesHeader, "Sales Document Type"::Invoice, CustomerNo); + CreateSalesDocument(SalesHeader, CustomerNo); LibrarySales.CreateSalesLine(SalesLine, SalesHeader, SalesLine.Type::Item, Item."No.", 1); SalesLine.Validate("Unit Price", 100); SalesLine.Modify(true); @@ -1938,6 +1959,7 @@ codeunit 148148 "Factur-X CII XML Tests" Customer.Validate("VAT Bus. Posting Group", GLAccount."VAT Bus. Posting Group"); Customer.Modify(true); LibrarySales.CreateSalesHeader(SalesHeader, "Sales Document Type"::"Credit Memo", Customer."No."); + SalesHeader.Validate("Your Reference", 'FR-BUYER-REF'); SalesHeader.Validate("Applies-to Doc. Type", SalesHeader."Applies-to Doc. Type"::Invoice); SalesHeader.Validate("Applies-to Doc. No.", SalesInvoiceHeader."No."); SalesHeader.Modify(true); @@ -1974,12 +1996,31 @@ codeunit 148148 "Factur-X CII XML Tests" Customer.Get(CustomerNo); Customer.Validate("Gen. Bus. Posting Group", GLAccount."Gen. Bus. Posting Group"); Customer.Validate("VAT Bus. Posting Group", GLAccount."VAT Bus. Posting Group"); + if Customer.Address = '' then + Customer.Address := CopyStr(LibraryUtility.GenerateRandomText(MaxStrLen(Customer.Address)), 1, MaxStrLen(Customer.Address)); + if Customer."Post Code" = '' then + Customer.Validate("Post Code", '75001'); Customer.Modify(true); - LibrarySales.CreateSalesHeader(SalesHeader, DocType, CustomerNo); - if CurrencyCode <> '' then begin - SalesHeader.Validate("Currency Code", CurrencyCode); + if DocType = "Sales Document Type"::Invoice then + CreateSalesDocument(SalesHeader, CustomerNo) + else begin + LibrarySales.CreateSalesHeader(SalesHeader, DocType, CustomerNo); + SalesHeader.Validate("Your Reference", 'FR-BUYER-REF'); SalesHeader.Modify(true); end; + if SalesHeader."Bill-to City" = '' then + SalesHeader.Validate("Bill-to City", 'Paris'); + if SalesHeader."Bill-to Post Code" = '' then + SalesHeader.Validate("Bill-to Post Code", '75001'); + if SalesHeader."Ship-to City" = '' then + SalesHeader.Validate("Ship-to City", SalesHeader."Bill-to City"); + if SalesHeader."Ship-to Post Code" = '' then + SalesHeader.Validate("Ship-to Post Code", '75001'); + if SalesHeader."Ship-to Country/Region Code" = '' then + SalesHeader.Validate("Ship-to Country/Region Code", CompanyInformation."Country/Region Code"); + if CurrencyCode <> '' then + SalesHeader.Validate("Currency Code", CurrencyCode); + SalesHeader.Modify(true); LibrarySales.CreateSalesLine(SalesLine, SalesHeader, SalesLine.Type::"G/L Account", GLAccount."No.", 1); SalesLine.Validate("Unit Price", 100); SalesLine.Validate("Unit of Measure Code", GetUnitOfMeasureCode()); @@ -2014,12 +2055,13 @@ codeunit 148148 "Factur-X CII XML Tests" Customer.Modify(true); if ApplyInvoiceDiscount then begin + EnsureSalesInvoiceDiscountAccount(GLAccount."Gen. Bus. Posting Group", GLAccount."Gen. Prod. Posting Group"); LibraryERM.CreateInvDiscForCustomer(CustInvoiceDisc, CustomerNo, '', 0); CustInvoiceDisc.Validate("Discount %", 10); CustInvoiceDisc.Modify(true); end; - LibrarySales.CreateSalesHeader(SalesHeader, "Sales Document Type"::Invoice, CustomerNo); + CreateSalesDocument(SalesHeader, CustomerNo); LibrarySales.CreateSalesLine(SalesLine, SalesHeader, SalesLine.Type::"G/L Account", GLAccount."No.", 1); SalesLine.Validate("Unit Price", 200); SalesLine.Validate("Allow Invoice Disc.", true); @@ -2071,7 +2113,7 @@ codeunit 148148 "Factur-X CII XML Tests" CustInvoiceDisc.Validate("Discount %", 10); CustInvoiceDisc.Modify(true); - LibrarySales.CreateSalesHeader(SalesHeader, "Sales Document Type"::Invoice, CustomerNo); + CreateSalesDocument(SalesHeader, CustomerNo); LibrarySales.CreateSalesLine(SalesLine, SalesHeader, SalesLine.Type::"G/L Account", GLAccount."No.", 1); SalesLine.Validate("Unit Price", 500); SalesLine.Validate("Allow Invoice Disc.", true); @@ -2116,6 +2158,9 @@ codeunit 148148 "Factur-X CII XML Tests" begin LibrarySales.CreateCustomer(Customer); Customer.Validate("Country/Region Code", CompanyInformation."Country/Region Code"); + Customer.Address := CopyStr(LibraryUtility.GenerateRandomText(MaxStrLen(Customer.Address)), 1, MaxStrLen(Customer.Address)); + Customer.Validate("Post Code", '75001'); + Customer.City := 'Paris'; Customer."VAT Registration No." := LibraryERM.GenerateVATRegistrationNo('FR'); Customer."Registration Number" := '123456789'; Customer.Validate("FR Electronic Address", FRElecAddress); @@ -2123,12 +2168,45 @@ codeunit 148148 "Factur-X CII XML Tests" exit(Customer."No."); end; + local procedure EnsureCurrency(var Currency: Record Currency; CurrencyCode: Code[10]) + begin + if Currency.Get(CurrencyCode) then + exit; + + Currency.Init(); + Currency.Code := CurrencyCode; + Currency.Insert(true); + end; + + local procedure EnsureExchangeRate(CurrencyCode: Code[10]) + var + CurrencyExchangeRate: Record "Currency Exchange Rate"; + begin + CurrencyExchangeRate.SetRange("Currency Code", CurrencyCode); + CurrencyExchangeRate.SetFilter("Starting Date", '..%1', WorkDate()); + if not CurrencyExchangeRate.IsEmpty() then + exit; + + LibraryERM.CreateRandomExchangeRate(CurrencyCode); + end; + local procedure CreateCustomerWithoutIdentifiers(): Code[20] var Customer: Record Customer; + DocumentSendingProfile: Record "Document Sending Profile"; begin LibrarySales.CreateCustomer(Customer); Customer.Validate("Country/Region Code", CompanyInformation."Country/Region Code"); + Customer.Address := CopyStr(LibraryUtility.GenerateRandomText(MaxStrLen(Customer.Address)), 1, MaxStrLen(Customer.Address)); + Customer.Validate("Post Code", '75001'); + Customer.City := 'Paris'; + if not DocumentSendingProfile.Get('NON-EDOC') then begin + DocumentSendingProfile.Init(); + DocumentSendingProfile.Code := 'NON-EDOC'; + DocumentSendingProfile."Electronic Document" := DocumentSendingProfile."Electronic Document"::No; + DocumentSendingProfile.Insert(true); + end; + Customer.Validate("Document Sending Profile", DocumentSendingProfile.Code); Customer."FR Electronic Address" := ''; Clear(Customer."FR Elec. Address Scheme"); Customer."VAT Registration No." := ''; @@ -2378,6 +2456,10 @@ codeunit 148148 "Factur-X CII XML Tests" UnitOfMeasure.Description := 'Each'; UnitOfMeasure.Insert(true); end; + if UnitOfMeasure."International Standard Code" <> 'C62' then begin + UnitOfMeasure.Validate("International Standard Code", 'C62'); + UnitOfMeasure.Modify(true); + end; exit(UnitOfMeasure.Code); end; From 62dd4e4c7da176e74a82e3f7685f2faef34f4e24 Mon Sep 17 00:00:00 2001 From: djukicmilica Date: Mon, 24 Aug 2026 12:13:40 +0200 Subject: [PATCH 18/23] compile errors --- .../app/src/Core/CIIXMLBuilder.Codeunit.al | 45 ------------------- .../src/Core/FREInvoiceMessageMgt.Codeunit.al | 12 ++--- 2 files changed, 6 insertions(+), 51 deletions(-) diff --git a/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/CIIXMLBuilder.Codeunit.al b/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/CIIXMLBuilder.Codeunit.al index e735fe0c7f3..ae15505aee8 100644 --- a/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/CIIXMLBuilder.Codeunit.al +++ b/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/CIIXMLBuilder.Codeunit.al @@ -91,34 +91,6 @@ codeunit 10978 "CII XML Builder" OnAfterAddExchangedDocumentContext(RootElement, SourceDocumentLines); end; - local procedure GetBillingMode(var SourceDocumentLines: RecordRef): Text - var - FREDocHelpers: Codeunit "EDoc. Helpers"; - TypeFieldRef: FieldRef; - HasItemLine: Boolean; - HasServiceLine: Boolean; - LineType: Text; - begin - if not FREDocHelpers.FindFieldByName(SourceDocumentLines, 'Type', TypeFieldRef) then - exit(ServiceBillingModeTok); - - if SourceDocumentLines.FindSet() then - repeat - LineType := DelChr(Format(TypeFieldRef.Value()), '=', ' '); - if LineType = ItemLineTypeTok then - HasItemLine := true - else - if LineType <> '' then - HasServiceLine := true; - until SourceDocumentLines.Next() = 0; - - if HasItemLine and HasServiceLine then - exit(MixedBillingModeTok); - if HasItemLine then - exit(GoodsBillingModeTok); - exit(ServiceBillingModeTok); - end; - local procedure AddExchangedDocument(var RootElement: XmlElement; var EDocument: Record "E-Document"; TypeCode: Text) var DocElement: XmlElement; @@ -326,17 +298,6 @@ codeunit 10978 "CII XML Builder" AgreementElement.Add(BuyerElement); end; - local procedure NormalizeBuyerVATRegistrationNo(VATRegistrationNo: Text; CountryCode: Text): Text - begin - if (StrLen(VATRegistrationNo) >= 2) and - (StrPos(AlphabetTok, CopyStr(VATRegistrationNo, 1, 1)) > 0) and - (StrPos(AlphabetTok, CopyStr(VATRegistrationNo, 2, 1)) > 0) - then - exit(VATRegistrationNo); - - exit(CountryCode + VATRegistrationNo); - end; - procedure TryGetBuyerElectronicAddress(Customer: Record Customer; var BuyerElectronicAddress: Text): Boolean var VATRegistrationNo: Text; @@ -842,7 +803,6 @@ codeunit 10978 "CII XML Builder" local procedure InsertTaxElement(var SettlementElement: XmlElement; CalculatedAmount: Text; BasisAmount: Text; CategoryCode: Text; RateApplicablePercent: Text; VATEXCode: Text) var TradeTaxElement: XmlElement; - ExemptionReasonLbl: Label 'Exempt from VAT', Locked = true; begin TradeTaxElement := XmlElement.Create('ApplicableTradeTax', RamNamespaceTok); TradeTaxElement.Add(XmlElement.Create('CalculatedAmount', RamNamespaceTok, CalculatedAmount)); @@ -1263,12 +1223,7 @@ codeunit 10978 "CII XML Builder" RamNamespaceTok: Label 'urn:un:unece:uncefact:data:standard:ReusableAggregateBusinessInformationEntity:100', Locked = true; QdtNamespaceTok: Label 'urn:un:unece:uncefact:data:standard:QualifiedDataType:100', Locked = true; UdtNamespaceTok: Label 'urn:un:unece:uncefact:data:standard:UnqualifiedDataType:100', Locked = true; - AlphabetTok: Label 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz', Locked = true; FacturXProfileIdTok: Label 'urn:cen.eu:en16931:2017', Locked = true; - GoodsBillingModeTok: Label 'B1', Locked = true; - ItemLineTypeTok: Label 'Item', Locked = true; - MixedBillingModeTok: Label 'M1', Locked = true; - ServiceBillingModeTok: Label 'S1', Locked = true; RecoveryCostNoteTok: Label 'Indemnité forfaitaire pour frais de recouvrement en cas de retard de paiement : 40 €', Locked = true; LatePaymentPenaltyNoteTok: Label 'Taux des pénalités de retard : taux directeur (BCE) majoré de 10 points', Locked = true; EarlyPaymentDiscountNoteTok: Label 'Pas d''escompte pour paiement anticipé', Locked = true; diff --git a/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/FREInvoiceMessageMgt.Codeunit.al b/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/FREInvoiceMessageMgt.Codeunit.al index f1f26405ef8..b6723a5185e 100644 --- a/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/FREInvoiceMessageMgt.Codeunit.al +++ b/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/FREInvoiceMessageMgt.Codeunit.al @@ -241,16 +241,16 @@ codeunit 10975 "FR E-Invoice Message Mgt." VATEntryCurrencyErrorInfo: ErrorInfo; GrossAmount: Decimal; begin - if VATEntry."Source Currency Code" = CurrencyCode then - GrossAmount := -(VATEntry."Source Currency VAT Base" + VATEntry."Source Currency VAT Amount") - else - if VATEntry."Source Currency Code" = '' then + case true of + VATEntry."Source Currency Code" = CurrencyCode: + GrossAmount := -(VATEntry."Source Currency VAT Base" + VATEntry."Source Currency VAT Amount"); + VATEntry."Source Currency Code" = '': GrossAmount := -(VATEntry.Base + VATEntry.Amount) - else begin + else VATEntryCurrencyErrorInfo.ErrorType(ErrorType::Internal); VATEntryCurrencyErrorInfo.Message(StrSubstNo(VATEntryCurrencyErr, VATEntry."Entry No.", CurrencyCode)); Error(VATEntryCurrencyErrorInfo); - end; + end; if (GrossAmount = 0) and IsVATEntryReportable(VATEntry) then GrossAmount := -(VATEntry."Unrealized Base" + VATEntry."Unrealized Amount"); From ad98e96d42c200cdb7cd38c5f27312f703c83370 Mon Sep 17 00:00:00 2001 From: djukicmilica Date: Mon, 24 Aug 2026 14:08:55 +0200 Subject: [PATCH 19/23] tests update --- .../test/src/FREInvoiceMessageTests.Codeunit.al | 7 ++++--- .../EReportingFR/test/src/FacturXCIIXMLTests.Codeunit.al | 8 -------- 2 files changed, 4 insertions(+), 11 deletions(-) diff --git a/src/Apps/FR/EDocument_FR/EReportingFR/test/src/FREInvoiceMessageTests.Codeunit.al b/src/Apps/FR/EDocument_FR/EReportingFR/test/src/FREInvoiceMessageTests.Codeunit.al index de85ae3732c..a6e69ae428b 100644 --- a/src/Apps/FR/EDocument_FR/EReportingFR/test/src/FREInvoiceMessageTests.Codeunit.al +++ b/src/Apps/FR/EDocument_FR/EReportingFR/test/src/FREInvoiceMessageTests.Codeunit.al @@ -539,6 +539,7 @@ codeunit 148151 "FR E-Invoice Message Tests" ReversalVAT: Record "FR E-Invoice Message VAT"; DetailedCustLedgEntry: Record "Detailed Cust. Ledg. Entry"; NewDetailedCustLedgEntry: Record "Detailed Cust. Ledg. Entry"; + SalesInvoiceLine: Record "Sales Invoice Line"; VATPostingSetup: Record "VAT Posting Setup"; FREInvoiceMessageMgt: Codeunit "FR E-Invoice Message Mgt."; begin @@ -557,9 +558,9 @@ codeunit 148151 "FR E-Invoice Message Tests" OriginalVAT.FindFirst(); // [GIVEN] VAT and sender-platform setup are changed after the original message - VATPostingSetup.SetRange("Tax Category", 'S'); - VATPostingSetup.SetRange("Unrealized VAT Type", VATPostingSetup."Unrealized VAT Type"::Percentage); - VATPostingSetup.FindFirst(); + SalesInvoiceLine.SetRange("Document No.", EDocument."Document No."); + SalesInvoiceLine.FindFirst(); + VATPostingSetup.Get(SalesInvoiceLine."VAT Bus. Posting Group", SalesInvoiceLine."VAT Prod. Posting Group"); VATPostingSetup."Tax Category" := 'Z'; VATPostingSetup.Modify(); EDocumentService.Get(EDocument.Service); diff --git a/src/Apps/FR/EDocument_FR/EReportingFR/test/src/FacturXCIIXMLTests.Codeunit.al b/src/Apps/FR/EDocument_FR/EReportingFR/test/src/FacturXCIIXMLTests.Codeunit.al index b7579a54575..30e48c1aa70 100644 --- a/src/Apps/FR/EDocument_FR/EReportingFR/test/src/FacturXCIIXMLTests.Codeunit.al +++ b/src/Apps/FR/EDocument_FR/EReportingFR/test/src/FacturXCIIXMLTests.Codeunit.al @@ -51,7 +51,6 @@ codeunit 148148 "Factur-X CII XML Tests" EDocHelpers: Codeunit "EDoc. Helpers"; FacturXFormat: Codeunit "Factur-X Format"; IncorrectValueErr: Label 'Incorrect value for %1', Comment = '%1 = XML element path', Locked = true; - BuyerElectronicAddressRequiredErr: Label 'Electronic Address must be specified for Customer %1 for French e-invoicing.', Comment = '%1 = Customer No.'; FacturXProfileIdTok: Label 'urn:cen.eu:en16931:2017', Locked = true; DialogErrorCodeTok: Label 'Dialog', Locked = true; IsInitialized: Boolean; @@ -1850,13 +1849,6 @@ codeunit 148148 "Factur-X CII XML Tests" exit(LibrarySales.PostSalesDocument(SalesHeader, true, true)); end; - local procedure CreateSalesDocument(var SalesHeader: Record "Sales Header"; CustomerNo: Code[20]) - begin - LibrarySales.CreateSalesHeader(SalesHeader, "Sales Document Type"::Invoice, CustomerNo); - SalesHeader.Validate("Your Reference", 'FR-BUYER-REF'); - SalesHeader.Modify(true); - end; - local procedure CreateAndPostSalesInvoiceForCustomer(CustomerNo: Code[20]): Code[20] var Customer: Record Customer; From bb53620599405816dcee79ef23b2caac3690cef2 Mon Sep 17 00:00:00 2001 From: djukicmilica Date: Mon, 24 Aug 2026 23:07:53 +0200 Subject: [PATCH 20/23] tests updated --- .../src/EDocFRStructImportTests.Codeunit.al | 24 ++-- .../src/ExportEReportingTests.Codeunit.al | 1 - .../src/FREInvoiceMessageTests.Codeunit.al | 120 +++++++++++++++++- .../test/src/FacturXCIIXMLTests.Codeunit.al | 11 +- .../test/src/IdentificationTests.Codeunit.al | 1 - .../test/src/PEPPOLBIS30XMLTests.Codeunit.al | 72 ++++++++++- 6 files changed, 205 insertions(+), 24 deletions(-) diff --git a/src/Apps/FR/EDocument_FR/EReportingFR/test/src/EDocFRStructImportTests.Codeunit.al b/src/Apps/FR/EDocument_FR/EReportingFR/test/src/EDocFRStructImportTests.Codeunit.al index 4c4842b88ed..35a7a58905e 100644 --- a/src/Apps/FR/EDocument_FR/EReportingFR/test/src/EDocFRStructImportTests.Codeunit.al +++ b/src/Apps/FR/EDocument_FR/EReportingFR/test/src/EDocFRStructImportTests.Codeunit.al @@ -33,11 +33,11 @@ codeunit 148149 "E-Doc. FR Struct. Import Tests" EDocumentFacturXHandler: Codeunit "E-Document Factur-X Handler"; ProcessDraft: Enum "E-Doc. Process Draft"; begin - // [FEATURE] [E-Document] [Factur-X] [Import] + // [FEATURE] [AI test] // [SCENARIO] A Factur-X invoice is read into a purchase invoice draft + Initialize(); // [GIVEN] A Factur-X CII invoice - Initialize(); CreateEDocument(EDocument); // [WHEN] The document is read into draft @@ -82,11 +82,11 @@ codeunit 148149 "E-Doc. FR Struct. Import Tests" EDocumentFacturXHandler: Codeunit "E-Document Factur-X Handler"; ProcessDraft: Enum "E-Doc. Process Draft"; begin - // [FEATURE] [E-Document] [Factur-X] [Import] + // [FEATURE] [AI test] // [SCENARIO] A Factur-X credit memo is read into a purchase credit memo draft + Initialize(); // [GIVEN] A Factur-X CII credit memo - Initialize(); CreateEDocument(EDocument); // [WHEN] The document is read into draft @@ -105,11 +105,11 @@ codeunit 148149 "E-Doc. FR Struct. Import Tests" EDocument: Record "E-Document"; EDocumentFacturXHandler: Codeunit "E-Document Factur-X Handler"; begin - // [FEATURE] [E-Document] [Factur-X] [Import] + // [FEATURE] [AI test] // [SCENARIO] Reading a document that is not a Cross Industry Invoice fails with a clear error + Initialize(); // [GIVEN] An XML document with an unsupported root element - Initialize(); CreateEDocument(EDocument); // [WHEN] The document is read into draft @@ -126,11 +126,11 @@ codeunit 148149 "E-Doc. FR Struct. Import Tests" EDocumentPurchaseLine: Record "E-Document Purchase Line"; EDocumentFacturXHandler: Codeunit "E-Document Factur-X Handler"; begin - // [FEATURE] [E-Document] [Factur-X] [Import] + // [FEATURE] [AI test] // [SCENARIO] Re-running Read into Draft replaces the previous draft instead of duplicating it + Initialize(); // [GIVEN] A Factur-X invoice that has been read into draft - Initialize(); CreateEDocument(EDocument); EDocumentFacturXHandler.ReadIntoDraft(EDocument, GetResourceBlob(FacturXInvoiceTok)); @@ -153,11 +153,11 @@ codeunit 148149 "E-Doc. FR Struct. Import Tests" EDocPeppolBIS30FRHandler: Codeunit "E-Doc. Peppol BIS 3.0 FR Hdlr"; ProcessDraft: Enum "E-Doc. Process Draft"; begin - // [FEATURE] [E-Document] [Peppol BIS 3.0 FR] [Import] + // [FEATURE] [AI test] // [SCENARIO] A Peppol BIS 3.0 FR invoice is read into a purchase invoice draft + Initialize(); // [GIVEN] A Peppol BIS 3.0 FR UBL invoice - Initialize(); CreateEDocument(EDocument); // [WHEN] The document is read into draft @@ -197,11 +197,11 @@ codeunit 148149 "E-Doc. FR Struct. Import Tests" EDocument: Record "E-Document"; EDocPeppolBIS30FRHandler: Codeunit "E-Doc. Peppol BIS 3.0 FR Hdlr"; begin - // [FEATURE] [E-Document] [Peppol BIS 3.0 FR] [Import] + // [FEATURE] [AI test] // [SCENARIO] Reading a document that is neither an Invoice nor a CreditNote fails with a clear error + Initialize(); // [GIVEN] An XML document with an unsupported root element - Initialize(); CreateEDocument(EDocument); // [WHEN] The document is read into draft diff --git a/src/Apps/FR/EDocument_FR/EReportingFR/test/src/ExportEReportingTests.Codeunit.al b/src/Apps/FR/EDocument_FR/EReportingFR/test/src/ExportEReportingTests.Codeunit.al index 4e1199f0318..2c062954f35 100644 --- a/src/Apps/FR/EDocument_FR/EReportingFR/test/src/ExportEReportingTests.Codeunit.al +++ b/src/Apps/FR/EDocument_FR/EReportingFR/test/src/ExportEReportingTests.Codeunit.al @@ -28,7 +28,6 @@ codeunit 148145 "Export E-Reporting Tests" trigger OnRun() begin - // [FEATURE] [E-Reporting FR E-document] end; var diff --git a/src/Apps/FR/EDocument_FR/EReportingFR/test/src/FREInvoiceMessageTests.Codeunit.al b/src/Apps/FR/EDocument_FR/EReportingFR/test/src/FREInvoiceMessageTests.Codeunit.al index a6e69ae428b..fe75b9d3815 100644 --- a/src/Apps/FR/EDocument_FR/EReportingFR/test/src/FREInvoiceMessageTests.Codeunit.al +++ b/src/Apps/FR/EDocument_FR/EReportingFR/test/src/FREInvoiceMessageTests.Codeunit.al @@ -142,7 +142,11 @@ codeunit 148151 "FR E-Invoice Message Tests" NewDetailedCustLedgEntry: Record "Detailed Cust. Ledg. Entry"; FREInvoiceMessageMgt: Codeunit "FR E-Invoice Message Mgt."; begin + // [FEATURE] [AI test] + // [SCENARIO] Unapplying a payment creates a negative Collected message linked to the original Initialize(); + + // [GIVEN] An approved E-Document with a sent Collected message CreatePaymentScenario(EDocument, DetailedCustLedgEntry, "E-Document Service Status"::Approved); FREInvoiceMessageMgt.ProcessApplication(DetailedCustLedgEntry); CollectedMessage.SetRange("E-Document Entry No.", EDocument."Entry No"); @@ -151,8 +155,10 @@ codeunit 148151 "FR E-Invoice Message Tests" SendMessage(CollectedMessage); CreateDetailedLedgerEntry(NewDetailedCustLedgEntry, DetailedCustLedgEntry."Cust. Ledger Entry No.", DetailedCustLedgEntry."Applied Cust. Ledger Entry No.", -120); + // [WHEN] The payment is unapplied FREInvoiceMessageMgt.ProcessUnapplication(DetailedCustLedgEntry, NewDetailedCustLedgEntry); + // [THEN] A Negative Collected message linked to the original is created; NegativeMessage.SetRange("E-Document Entry No.", EDocument."Entry No"); NegativeMessage.SetRange(Type, NegativeMessage.Type::"Negative Collected"); NegativeMessage.FindFirst(); @@ -178,11 +184,17 @@ codeunit 148151 "FR E-Invoice Message Tests" EDocument: Record "E-Document"; FREInvoiceMessageMgt: Codeunit "FR E-Invoice Message Mgt."; begin + // [FEATURE] [AI test] + // [SCENARIO] Refusing an invoice sends a lifecycle message with status 210 and reason code Initialize(); + + // [GIVEN] An incoming French E-Document CreateIncomingEDocument(EDocument); + // [WHEN] The invoice is refused with a reason FREInvoiceMessageMgt.RefuseInvoice(EDocument, 'PRICE', 'The amount is incorrect.'); + // [THEN] A refusal message with status 210 and the reason code is queued and can be sent Assert.AreEqual(0, MessageSenderMock.GetSendCount(), 'Refusal must queue the message without invoking the connector.'); SendFirstMessage(EDocument, "FR E-Invoice Message Type"::Refused); Assert.AreEqual(1, MessageSenderMock.GetSendCount(), 'One refusal message must be sent.'); @@ -222,7 +234,11 @@ codeunit 148151 "FR E-Invoice Message Tests" OutStream: OutStream; MessageEntryNo: Integer; begin + // [FEATURE] [AI test] + // [SCENARIO] Sending a message without the connector reporting success raises an error Initialize(); + + // [GIVEN] An incoming E-Document with a lifecycle message and a mock connector that does not report success CreateIncomingEDocument(EDocument); TempBlob.CreateOutStream(OutStream, TextEncoding::UTF8); OutStream.WriteText(''); @@ -230,8 +246,10 @@ codeunit 148151 "FR E-Invoice Message Tests" EDocument, "E-Document Message Type"::"FR Invoice Lifecycle", "E-Doc. Response Type"::Refused, TempBlob); MessageSenderMock.SetReportSuccess(false); + // [WHEN] The message is sent asserterror EDocumentMessageAPI.SendMessage(MessageEntryNo); + // [THEN] The connector is invoked before its missing success result is rejected Assert.AreEqual(1, MessageSenderMock.GetSendCount(), 'The connector must be invoked before its missing success result is rejected.'); end; @@ -244,12 +262,18 @@ codeunit 148151 "FR E-Invoice Message Tests" DetailedCustLedgEntry: Record "Detailed Cust. Ledg. Entry"; FREInvoiceMessageMgt: Codeunit "FR E-Invoice Message Mgt."; begin + // [FEATURE] [AI test] + // [SCENARIO] Processing the same payment application twice is idempotent Initialize(); + + // [GIVEN] An approved French E-Document with an applied payment CreatePaymentScenario(EDocument, DetailedCustLedgEntry, "E-Document Service Status"::Approved); + // [WHEN] The payment application is processed twice FREInvoiceMessageMgt.ProcessApplication(DetailedCustLedgEntry); FREInvoiceMessageMgt.ProcessApplication(DetailedCustLedgEntry); + // [THEN] Only one payment occurrence and one Collected message exist; EDocPaymentOccurrence.SetRange("E-Document Entry No.", EDocument."Entry No"); EDocPaymentOccurrence.SetRange(Type, EDocPaymentOccurrence.Type::Applied); Assert.RecordCount(EDocPaymentOccurrence, 1); @@ -640,7 +664,11 @@ codeunit 148151 "FR E-Invoice Message Tests" FirstMessageEntryNo: Integer; DuplicateMessageEntryNo: Integer; begin + // [FEATURE] [AI test] + // [SCENARIO] An incoming lifecycle message is correlated to its E-Document and deduplicated by external ID Initialize(); + + // [GIVEN] An incoming E-Document with a registered external document reference CreateIncomingEDocument(EDocument); ExternalDocumentID := CopyStr(Format(CreateGuid()), 1, MaxStrLen(ExternalDocumentID)); ExternalMessageID := CopyStr(Format(CreateGuid()), 1, MaxStrLen(ExternalMessageID)); @@ -648,6 +676,7 @@ codeunit 148151 "FR E-Invoice Message Tests" TempBlob.CreateOutStream(OutStream, TextEncoding::UTF8); OutStream.WriteText(''); + // [WHEN] The same incoming message is received twice FirstMessageEntryNo := EDocumentMessageAPI.CreateIncomingMessage( EDocument.Service, ExternalDocumentID, ExternalMessageID, "E-Document Message Type"::"FR Invoice Lifecycle", "E-Doc. Response Type"::Refused, CurrentDateTime(), TempBlob); @@ -655,6 +684,7 @@ codeunit 148151 "FR E-Invoice Message Tests" EDocument.Service, ExternalDocumentID, ExternalMessageID, "E-Document Message Type"::"FR Invoice Lifecycle", "E-Doc. Response Type"::Refused, CurrentDateTime(), TempBlob); + // [THEN] The incoming message is persisted and deduplicated Assert.AreNotEqual(0, FirstMessageEntryNo, 'The incoming lifecycle message must be persisted.'); Assert.AreEqual(FirstMessageEntryNo, DuplicateMessageEntryNo, 'The external message ID must deduplicate repeated delivery.'); end; @@ -667,15 +697,21 @@ codeunit 148151 "FR E-Invoice Message Tests" TempBlob: Codeunit "Temp Blob"; OutStream: OutStream; begin + // [FEATURE] [AI test] + // [SCENARIO] Creating an incoming message fails when the external document reference is not registered Initialize(); + + // [GIVEN] An incoming E-Document without a registered external document reference CreateIncomingEDocument(EDocument); TempBlob.CreateOutStream(OutStream, TextEncoding::UTF8); OutStream.WriteText(''); + // [WHEN] An incoming message with an unregistered external document ID is created asserterror EDocumentMessageAPI.CreateIncomingMessage( EDocument.Service, CopyStr(Format(CreateGuid()), 1, 250), CopyStr(Format(CreateGuid()), 1, 250), "E-Document Message Type"::"FR Invoice Lifecycle", "E-Doc. Response Type"::Refused, CurrentDateTime(), TempBlob); + // [THEN] An error about unregistered reference is raised Assert.ExpectedError('is not registered'); end; @@ -686,11 +722,17 @@ codeunit 148151 "FR E-Invoice Message Tests" FREInvoiceMessage: Record "FR E-Invoice Message"; FREInvoiceMessageMgt: Codeunit "FR E-Invoice Message Mgt."; begin + // [FEATURE] [AI test] + // [SCENARIO] Accepting an invoice queues an Accepted message with status 205 Initialize(); + + // [GIVEN] An incoming French E-Document CreateIncomingEDocument(EDocument); + // [WHEN] The invoice is accepted FREInvoiceMessageMgt.AcceptInvoice(EDocument); + // [THEN] An Accepted message with status 205 is queued and can be sent FREInvoiceMessage.SetRange("E-Document Entry No.", EDocument."Entry No"); FREInvoiceMessage.SetRange(Type, FREInvoiceMessage.Type::Accepted); Assert.RecordCount(FREInvoiceMessage, 1); @@ -707,11 +749,18 @@ codeunit 148151 "FR E-Invoice Message Tests" EDocument: Record "E-Document"; FREInvoiceMessageMgt: Codeunit "FR E-Invoice Message Mgt."; begin + // [FEATURE] [AI test] + // [SCENARIO] A buyer response cannot follow a previous acceptance Initialize(); - CreateIncomingEDocument(EDocument); + // [GIVEN] An incoming E-Document that has been accepted + CreateIncomingEDocument(EDocument); FREInvoiceMessageMgt.AcceptInvoice(EDocument); + + // [WHEN] A refusal is attempted after acceptance asserterror FREInvoiceMessageMgt.RefuseInvoice(EDocument, 'OTHER', 'Changed my mind.'); + + // [THEN] An error about duplicate buyer response is raised; Assert.ExpectedError('already has a buyer response'); Assert.ExpectedErrorCode('Dialog'); end; @@ -722,11 +771,18 @@ codeunit 148151 "FR E-Invoice Message Tests" EDocument: Record "E-Document"; FREInvoiceMessageMgt: Codeunit "FR E-Invoice Message Mgt."; begin + // [FEATURE] [AI test] + // [SCENARIO] A buyer response cannot follow a previous refusal Initialize(); - CreateIncomingEDocument(EDocument); + // [GIVEN] An incoming E-Document that has been refused + CreateIncomingEDocument(EDocument); FREInvoiceMessageMgt.RefuseInvoice(EDocument, 'OTHER', 'Not accepted.'); + + // [WHEN] An acceptance is attempted after refusal asserterror FREInvoiceMessageMgt.AcceptInvoice(EDocument); + + // [THEN] An error about duplicate buyer response is raised; Assert.ExpectedError('already has a buyer response'); Assert.ExpectedErrorCode('Dialog'); end; @@ -745,7 +801,11 @@ codeunit 148151 "FR E-Invoice Message Tests" ReceivedAt: DateTime; FREntryNo: Integer; begin + // [FEATURE] [AI test] + // [SCENARIO] Receiving a Submitted lifecycle message persists a normalized FR message Initialize(); + + // [GIVEN] An outgoing E-Document with a registered external document reference and a Submitted lifecycle payload CreateOutgoingEDocument(EDocument); ExternalDocID := CopyStr(Format(CreateGuid()), 1, 250); ExternalMsgID := CopyStr(Format(CreateGuid()), 1, 250); @@ -754,8 +814,10 @@ codeunit 148151 "FR E-Invoice Message Tests" TempBlob.CreateOutStream(OutStream, TextEncoding::UTF8); OutStream.WriteText(BuildLifecycleXml(EDocument."Document No.", 'Submitted', '', '')); + // [WHEN] The Submitted lifecycle message is received FREntryNo := FREInvoiceMessageAPI.ReceiveMessage(EDocument.Service, ExternalDocID, ExternalMsgID, ReceivedAt, TempBlob); + // [THEN] The FR message is persisted with Submitted type and correct metadata FREInvoiceMessage.Get(FREntryNo); Assert.AreEqual(FREInvoiceMessage.Type::Submitted, FREInvoiceMessage.Type, 'FR type must be Submitted.'); Assert.AreEqual(ExternalMsgID, FREInvoiceMessage."External Message ID", 'External message ID must be stored.'); @@ -778,7 +840,11 @@ codeunit 148151 "FR E-Invoice Message Tests" ExternalMsgID: Text[250]; FREntryNo: Integer; begin + // [FEATURE] [AI test] + // [SCENARIO] Receiving an Accepted lifecycle message persists a normalized FR message Initialize(); + + // [GIVEN] An outgoing E-Document with a received Submitted status and an Accepted lifecycle payload CreateOutgoingEDocument(EDocument); ExternalDocID := CopyStr(Format(CreateGuid()), 1, 250); ExternalMsgID := CopyStr(Format(CreateGuid()), 1, 250); @@ -787,8 +853,10 @@ codeunit 148151 "FR E-Invoice Message Tests" TempBlob.CreateOutStream(OutStream, TextEncoding::UTF8); OutStream.WriteText(BuildLifecycleXml(EDocument."Document No.", '205', '', '')); + // [WHEN] The Accepted lifecycle message is received FREntryNo := FREInvoiceMessageAPI.ReceiveMessage(EDocument.Service, ExternalDocID, ExternalMsgID, CurrentDateTime(), TempBlob); + // [THEN] The FR message is persisted with Accepted type FREInvoiceMessage.Get(FREntryNo); Assert.AreEqual(FREInvoiceMessage.Type::Accepted, FREInvoiceMessage.Type, 'FR type must be Accepted.'); Assert.AreEqual("E-Doc. Response Type"::Accepted, EDocumentMessageAPI.GetMessageResponseType(FREInvoiceMessage."E-Document Message Entry No."), 'Generic response must be Accepted.'); @@ -807,7 +875,11 @@ codeunit 148151 "FR E-Invoice Message Tests" ExternalMsgID: Text[250]; FREntryNo: Integer; begin + // [FEATURE] [AI test] + // [SCENARIO] Receiving a Technical Rejected lifecycle message persists the reason code and description Initialize(); + + // [GIVEN] An outgoing E-Document with a received Submitted status and a Rejected lifecycle payload with reason CreateOutgoingEDocument(EDocument); ExternalDocID := CopyStr(Format(CreateGuid()), 1, 250); ExternalMsgID := CopyStr(Format(CreateGuid()), 1, 250); @@ -816,8 +888,10 @@ codeunit 148151 "FR E-Invoice Message Tests" TempBlob.CreateOutStream(OutStream, TextEncoding::UTF8); OutStream.WriteText(BuildLifecycleXml(EDocument."Document No.", 'Rejetée', 'SCHEMA', 'Schema validation failed')); + // [WHEN] The Rejected lifecycle message is received FREntryNo := FREInvoiceMessageAPI.ReceiveMessage(EDocument.Service, ExternalDocID, ExternalMsgID, CurrentDateTime(), TempBlob); + // [THEN] The FR message is persisted with Technical Rejected type and reason FREInvoiceMessage.Get(FREntryNo); Assert.AreEqual(FREInvoiceMessage.Type::"Technical Rejected", FREInvoiceMessage.Type, 'FR type must be Technical Rejected.'); Assert.AreEqual('SCHEMA', Format(FREInvoiceMessage."Reason Code"), 'Reason code must be persisted.'); @@ -839,7 +913,11 @@ codeunit 148151 "FR E-Invoice Message Tests" FirstEntryNo: Integer; SecondEntryNo: Integer; begin + // [FEATURE] [AI test] + // [SCENARIO] Receiving the same lifecycle message twice returns the same entry Initialize(); + + // [GIVEN] An outgoing E-Document with a registered external document reference CreateOutgoingEDocument(EDocument); ExternalDocID := CopyStr(Format(CreateGuid()), 1, 250); ExternalMsgID := CopyStr(Format(CreateGuid()), 1, 250); @@ -847,11 +925,13 @@ codeunit 148151 "FR E-Invoice Message Tests" TempBlob.CreateOutStream(OutStream, TextEncoding::UTF8); OutStream.WriteText(BuildLifecycleXml(EDocument."Document No.", 'Submitted', '', '')); + // [WHEN] The same lifecycle message is received twice FirstEntryNo := FREInvoiceMessageAPI.ReceiveMessage(EDocument.Service, ExternalDocID, ExternalMsgID, CurrentDateTime(), TempBlob); TempBlob.CreateOutStream(OutStream, TextEncoding::UTF8); OutStream.WriteText(BuildLifecycleXml(EDocument."Document No.", 'Submitted', '', '')); SecondEntryNo := FREInvoiceMessageAPI.ReceiveMessage(EDocument.Service, ExternalDocID, ExternalMsgID, CurrentDateTime(), TempBlob); + // [THEN] The same FR entry is returned and only one Submitted message exists Assert.AreEqual(FirstEntryNo, SecondEntryNo, 'Same external message ID must return same FR entry.'); FREInvoiceMessage.SetRange("E-Document Entry No.", EDocument."Entry No"); FREInvoiceMessage.SetRange(Type, FREInvoiceMessage.Type::Submitted); @@ -868,16 +948,22 @@ codeunit 148151 "FR E-Invoice Message Tests" OutStream: OutStream; ExternalDocID: Text[250]; begin + // [FEATURE] [AI test] + // [SCENARIO] Receiving a lifecycle message with invalid XML raises an error Initialize(); + + // [GIVEN] An outgoing E-Document with a registered reference and invalid XML payload CreateOutgoingEDocument(EDocument); ExternalDocID := CopyStr(Format(CreateGuid()), 1, 250); EDocumentMessageAPI.RegisterExternalDocumentReference(EDocument, EDocument.Service, ExternalDocID); TempBlob.CreateOutStream(OutStream, TextEncoding::UTF8); OutStream.WriteText('not xml at all'); + // [WHEN] The invalid message is received asserterror FREInvoiceMessageAPI.ReceiveMessage( EDocument.Service, ExternalDocID, CopyStr(Format(CreateGuid()), 1, 250), CurrentDateTime(), TempBlob); + // [THEN] An error about invalid XML is raised; Assert.ExpectedError('not valid XML'); Assert.ExpectedErrorCode('Dialog'); end; @@ -892,16 +978,22 @@ codeunit 148151 "FR E-Invoice Message Tests" OutStream: OutStream; ExternalDocID: Text[250]; begin + // [FEATURE] [AI test] + // [SCENARIO] Receiving a lifecycle message with an unsupported status raises an error Initialize(); + + // [GIVEN] An outgoing E-Document with a lifecycle payload containing an unknown status CreateOutgoingEDocument(EDocument); ExternalDocID := CopyStr(Format(CreateGuid()), 1, 250); EDocumentMessageAPI.RegisterExternalDocumentReference(EDocument, EDocument.Service, ExternalDocID); TempBlob.CreateOutStream(OutStream, TextEncoding::UTF8); OutStream.WriteText(BuildLifecycleXml(EDocument."Document No.", 'Unknown', '', '')); + // [WHEN] The message with unsupported status is received asserterror FREInvoiceMessageAPI.ReceiveMessage( EDocument.Service, ExternalDocID, CopyStr(Format(CreateGuid()), 1, 250), CurrentDateTime(), TempBlob); + // [THEN] An error about unsupported status is raised; Assert.ExpectedError('is not supported'); Assert.ExpectedErrorCode('Dialog'); end; @@ -916,16 +1008,22 @@ codeunit 148151 "FR E-Invoice Message Tests" OutStream: OutStream; ExternalDocID: Text[250]; begin + // [FEATURE] [AI test] + // [SCENARIO] Receiving a lifecycle message whose invoice ID does not match the E-Document raises an error Initialize(); + + // [GIVEN] An outgoing E-Document with a lifecycle payload referencing a different invoice CreateOutgoingEDocument(EDocument); ExternalDocID := CopyStr(Format(CreateGuid()), 1, 250); EDocumentMessageAPI.RegisterExternalDocumentReference(EDocument, EDocument.Service, ExternalDocID); TempBlob.CreateOutStream(OutStream, TextEncoding::UTF8); OutStream.WriteText(BuildLifecycleXml('WRONG-INVOICE-ID', 'Submitted', '', '')); + // [WHEN] The mismatched message is received asserterror FREInvoiceMessageAPI.ReceiveMessage( EDocument.Service, ExternalDocID, CopyStr(Format(CreateGuid()), 1, 250), CurrentDateTime(), TempBlob); + // [THEN] An error about invoice ID mismatch is raised; Assert.ExpectedError('does not match'); Assert.ExpectedErrorCode('Dialog'); end; @@ -940,16 +1038,22 @@ codeunit 148151 "FR E-Invoice Message Tests" OutStream: OutStream; ExternalDocID: Text[250]; begin + // [FEATURE] [AI test] + // [SCENARIO] A Technical Rejected message without a reason code is rejected Initialize(); + + // [GIVEN] An outgoing E-Document with a Rejected lifecycle payload missing the reason code CreateOutgoingEDocument(EDocument); ExternalDocID := CopyStr(Format(CreateGuid()), 1, 250); EDocumentMessageAPI.RegisterExternalDocumentReference(EDocument, EDocument.Service, ExternalDocID); TempBlob.CreateOutStream(OutStream, TextEncoding::UTF8); OutStream.WriteText(BuildLifecycleXml(EDocument."Document No.", 'Rejected', '', 'Something went wrong')); + // [WHEN] The Rejected message without reason code is received asserterror FREInvoiceMessageAPI.ReceiveMessage( EDocument.Service, ExternalDocID, CopyStr(Format(CreateGuid()), 1, 250), CurrentDateTime(), TempBlob); + // [THEN] An error about missing reason code is raised; Assert.ExpectedError('reason code is required'); Assert.ExpectedErrorCode('Dialog'); end; @@ -964,16 +1068,22 @@ codeunit 148151 "FR E-Invoice Message Tests" OutStream: OutStream; ExternalDocID: Text[250]; begin + // [FEATURE] [AI test] + // [SCENARIO] A Technical Rejected message without a reason description is rejected Initialize(); + + // [GIVEN] An outgoing E-Document with a Rejected lifecycle payload missing the reason description CreateOutgoingEDocument(EDocument); ExternalDocID := CopyStr(Format(CreateGuid()), 1, 250); EDocumentMessageAPI.RegisterExternalDocumentReference(EDocument, EDocument.Service, ExternalDocID); TempBlob.CreateOutStream(OutStream, TextEncoding::UTF8); OutStream.WriteText(BuildLifecycleXml(EDocument."Document No.", 'Rejected', 'SCHEMA', '')); + // [WHEN] The Rejected message without reason description is received asserterror FREInvoiceMessageAPI.ReceiveMessage( EDocument.Service, ExternalDocID, CopyStr(Format(CreateGuid()), 1, 250), CurrentDateTime(), TempBlob); + // [THEN] An error about missing reason description is raised; Assert.ExpectedError('reason description is required'); Assert.ExpectedErrorCode('Dialog'); end; @@ -1231,7 +1341,11 @@ codeunit 148151 "FR E-Invoice Message Tests" FREInvoiceMessageBuilder: Codeunit "FR E-Invoice Message Builder"; TempBlob: Codeunit "Temp Blob"; begin + // [FEATURE] [AI test] + // [SCENARIO] Building a message with an incoming-only status raises an error Initialize(); + + // [GIVEN] An incoming E-Document with a Submitted lifecycle message CreateIncomingEDocument(EDocument); FREInvoiceMessage.Init(); FREInvoiceMessage."E-Document Entry No." := EDocument."Entry No"; @@ -1241,8 +1355,10 @@ codeunit 148151 "FR E-Invoice Message Tests" FREInvoiceMessage."Created At" := CurrentDateTime(); FREInvoiceMessage.Insert(); + // [WHEN] The message builder attempts to build the message asserterror FREInvoiceMessageBuilder.BuildMessage(EDocument, FREInvoiceMessage, TempBlob); + // [THEN] An error about unsendable status is raised; Assert.ExpectedError('cannot be sent'); Assert.ExpectedErrorCode('Dialog'); end; diff --git a/src/Apps/FR/EDocument_FR/EReportingFR/test/src/FacturXCIIXMLTests.Codeunit.al b/src/Apps/FR/EDocument_FR/EReportingFR/test/src/FacturXCIIXMLTests.Codeunit.al index 30e48c1aa70..241c18fc2ee 100644 --- a/src/Apps/FR/EDocument_FR/EReportingFR/test/src/FacturXCIIXMLTests.Codeunit.al +++ b/src/Apps/FR/EDocument_FR/EReportingFR/test/src/FacturXCIIXMLTests.Codeunit.al @@ -35,7 +35,6 @@ codeunit 148148 "Factur-X CII XML Tests" trigger OnRun() begin - // [FEATURE] [Factur-X FR E-document] end; var @@ -186,6 +185,7 @@ codeunit 148148 "Factur-X CII XML Tests" // [SCENARIO] Factur-X CII XML has seller name from Company Information Initialize(); + // [GIVEN] Posted sales invoice // [WHEN] Create CII XML CreateSalesInvoiceCIIXML(TempBlob); @@ -204,6 +204,7 @@ codeunit 148148 "Factur-X CII XML Tests" // [SCENARIO] Factur-X CII XML has seller VAT registration number with scheme VA Initialize(); + // [GIVEN] Posted sales invoice / Company information with VAT Registration No. // [WHEN] Create CII XML CreateSalesInvoiceCIIXML(TempBlob); @@ -452,6 +453,7 @@ codeunit 148148 "Factur-X CII XML Tests" // [SCENARIO] Factur-X CII XML has seller electronic address (BT-34) as SIRET with schemeID 0009 Initialize(); + // [GIVEN] Posted sales invoice // [WHEN] Create CII XML CreateSalesInvoiceCIIXML(TempBlob); @@ -710,6 +712,7 @@ codeunit 148148 "Factur-X CII XML Tests" // [SCENARIO] Factur-X CII XML has SpecifiedTradeSettlementPaymentMeans with TypeCode 58 (SEPA credit transfer) Initialize(); + // [GIVEN] Posted sales invoice // [WHEN] Create CII XML CreateSalesInvoiceCIIXML(TempBlob); @@ -940,6 +943,7 @@ codeunit 148148 "Factur-X CII XML Tests" // [SCENARIO] Factur-X CII XML line BilledQuantity has unitCode attribute (BT-130) Initialize(); + // [GIVEN] Posted sales invoice // [WHEN] Create CII XML CreateSalesInvoiceCIIXML(TempBlob); @@ -958,6 +962,7 @@ codeunit 148148 "Factur-X CII XML Tests" // [SCENARIO] Factur-X CII XML line-level ApplicableTradeTax has TypeCode = 'VAT' Initialize(); + // [GIVEN] Posted sales invoice // [WHEN] Create CII XML CreateSalesInvoiceCIIXML(TempBlob); @@ -1327,7 +1332,7 @@ codeunit 148148 "Factur-X CII XML Tests" SourceDocumentHeader: RecordRef; SourceDocumentLines: RecordRef; begin - // [FEATURE] [Reminder] + // [FEATURE] [AI test] // [SCENARIO] An issued reminder line (which has no Quantity field) emits BilledQuantity = 1 Initialize(); @@ -1367,7 +1372,7 @@ codeunit 148148 "Factur-X CII XML Tests" SourceDocumentHeader: RecordRef; SourceDocumentLines: RecordRef; begin - // [FEATURE] [Finance Charge Memo] + // [FEATURE] [AI test] // [SCENARIO] An issued finance charge memo line (which has no Quantity field) emits BilledQuantity = 1 Initialize(); diff --git a/src/Apps/FR/EDocument_FR/EReportingFR/test/src/IdentificationTests.Codeunit.al b/src/Apps/FR/EDocument_FR/EReportingFR/test/src/IdentificationTests.Codeunit.al index 0e046f424c2..813e6f909e9 100644 --- a/src/Apps/FR/EDocument_FR/EReportingFR/test/src/IdentificationTests.Codeunit.al +++ b/src/Apps/FR/EDocument_FR/EReportingFR/test/src/IdentificationTests.Codeunit.al @@ -14,7 +14,6 @@ codeunit 148146 "Identification Tests" trigger OnRun() begin - // [FEATURE] [FR Identification] end; var diff --git a/src/Apps/FR/EDocument_FR/EReportingFR/test/src/PEPPOLBIS30XMLTests.Codeunit.al b/src/Apps/FR/EDocument_FR/EReportingFR/test/src/PEPPOLBIS30XMLTests.Codeunit.al index 6fbca804f04..f6c74a69591 100644 --- a/src/Apps/FR/EDocument_FR/EReportingFR/test/src/PEPPOLBIS30XMLTests.Codeunit.al +++ b/src/Apps/FR/EDocument_FR/EReportingFR/test/src/PEPPOLBIS30XMLTests.Codeunit.al @@ -38,7 +38,6 @@ codeunit 148147 "PEPPOL BIS 3.0 XML Tests" trigger OnRun() begin - // [FEATURE] [PEPPOL BIS 3.0 FR E-document] end; var @@ -366,7 +365,7 @@ codeunit 148147 "PEPPOL BIS 3.0 XML Tests" SalesInvoiceHeader: Record "Sales Invoice Header"; XmlDoc: XmlDocument; begin - // [FEATURE] [AI test 0.4] + // [FEATURE] [AI test] // [SCENARIO] Exporting a sales invoice does not add a credit note billing reference Initialize(); @@ -387,13 +386,17 @@ codeunit 148147 "PEPPOL BIS 3.0 XML Tests" SalesInvoiceHeader: Record "Sales Invoice Header"; XmlDoc: XmlDocument; begin + // [FEATURE] [AI test] // [SCENARIO] An invoice containing only service lines uses billing mode S1 Initialize(); + // [GIVEN] Posted sales invoice "SI" with service lines only SalesInvoiceHeader.Get(CreateAndPostSalesInvoice(CreateCustomer('', "Electronic Address Scheme"::"EM"))); + // [WHEN] Sales invoice "SI" is exported in PEPPOL BIS 3.0 FR ExportInvoice(SalesInvoiceHeader, XmlDoc); + // [THEN] ProfileID = 'S1' Assert.AreEqual('S1', GetNodeByPath(XmlDoc, '/Invoice/cbc:ProfileID'), StrSubstNo(IncorrectValueErr, 'ProfileID')); end; @@ -407,9 +410,11 @@ codeunit 148147 "PEPPOL BIS 3.0 XML Tests" CustomerNo: Code[20]; EndpointId: Text[200]; begin + // [FEATURE] [AI test] // [SCENARIO] A service-specific routing identifier overrides the endpoint on the customer card Initialize(); + // [GIVEN] Customer "C" with service participant using scheme 0225 CustomerNo := CreateCustomer('12345678901234', "Electronic Address Scheme"::"0009"); EndpointId := '123456789_001'; ServiceParticipant.Service := EDocumentService.Code; @@ -426,9 +431,11 @@ codeunit 148147 "PEPPOL BIS 3.0 XML Tests" Customer."FR Elec. Address Scheme" := Customer."FR Elec. Address Scheme"::" "; Customer.Modify(true); + // [WHEN] Sales invoice is checked and exported in PEPPOL BIS 3.0 FR CheckInvoice(SalesInvoiceHeader); ExportInvoice(SalesInvoiceHeader, XmlDoc); + // [THEN] Buyer EndpointID uses service participant value with scheme 0225 Assert.AreEqual(EndpointId, GetNodeByPath(XmlDoc, '/Invoice/cac:AccountingCustomerParty/cac:Party/cbc:EndpointID'), StrSubstNo(IncorrectValueErr, 'Buyer EndpointID')); @@ -536,9 +543,11 @@ codeunit 148147 "PEPPOL BIS 3.0 XML Tests" XmlDoc: XmlDocument; CommentText: Text[80]; begin + // [FEATURE] [AI test] // [SCENARIO] A French regulatory comment on a posted credit memo is prefixed with its type in a UBL header note Initialize(); + // [GIVEN] Posted sales credit memo "SCM" with an AAB regulatory comment SalesCrMemoHeader.Get(CreateAndPostSalesCrMemo(CreateCustomer('', "Electronic Address Scheme"::"EM"))); CommentText := 'No discount is granted for early payment.'; SalesCommentLine."Document Type" := SalesCommentLine."Document Type"::"Posted Credit Memo"; @@ -548,8 +557,10 @@ codeunit 148147 "PEPPOL BIS 3.0 XML Tests" SalesCommentLine.Comment := CommentText; SalesCommentLine.Insert(); + // [WHEN] Sales credit memo "SCM" is exported in PEPPOL BIS 3.0 FR ExportCrMemo(SalesCrMemoHeader, XmlDoc); + // [THEN] The AAB regulatory comment is exported as a UBL header note Assert.AreEqual('#AAB#' + CommentText, GetNodeByPath(XmlDoc, '/CreditNote/cbc:Note'), StrSubstNo(IncorrectValueErr, 'Note')); end; @@ -561,7 +572,7 @@ codeunit 148147 "PEPPOL BIS 3.0 XML Tests" XmlDoc: XmlDocument; CustomerNo: Code[20]; begin - // [FEATURE] [AI test 0.4] + // [FEATURE] [AI test] // [SCENARIO] A sales credit memo applied to an invoice exports the invoice number and issue date Initialize(); @@ -590,7 +601,7 @@ codeunit 148147 "PEPPOL BIS 3.0 XML Tests" SalesCrMemoHeader: Record "Sales Cr.Memo Header"; XmlDoc: XmlDocument; begin - // [FEATURE] [AI test 0.4] + // [FEATURE] [AI test] // [SCENARIO] A sales credit memo without an applied invoice does not export an incomplete billing reference Initialize(); @@ -613,7 +624,7 @@ codeunit 148147 "PEPPOL BIS 3.0 XML Tests" XmlDoc: XmlDocument; CommentText: Text[80]; begin - // [FEATURE] [AI test 0.4] + // [FEATURE] [AI test] // [SCENARIO] Export includes an explicit regulatory note without synthesizing PMT, PMD, or AAB notes Initialize(); @@ -650,13 +661,17 @@ codeunit 148147 "PEPPOL BIS 3.0 XML Tests" SalesInvoiceLine: Record "Sales Invoice Line"; XmlDoc: XmlDocument; begin + // [FEATURE] [AI test] // [SCENARIO] An invoice containing lines from distinct orders uses the Extended CTC profile Initialize(); + // [GIVEN] Posted sales invoice "SI" with lines from distinct orders SalesInvoiceHeader.Get(CreateAndPostSalesInvoiceFromMultipleOrders(CreateCustomer('123456789', "Electronic Address Scheme"::"0002"))); + // [WHEN] Sales invoice "SI" is exported in PEPPOL BIS 3.0 FR ExportInvoice(SalesInvoiceHeader, XmlDoc); + // [THEN] CustomizationID = 'EXTENDED-CTC-FR' Assert.AreEqual('EXTENDED-CTC-FR', GetNodeByPath(XmlDoc, '/Invoice/cbc:CustomizationID'), StrSubstNo(IncorrectValueErr, 'CustomizationID')); SalesInvoiceLine.SetRange("Document No.", SalesInvoiceHeader."No."); @@ -678,13 +693,17 @@ codeunit 148147 "PEPPOL BIS 3.0 XML Tests" SalesShipmentHeader: Record "Sales Shipment Header"; XmlDoc: XmlDocument; begin + // [FEATURE] [AI test] // [SCENARIO] An invoice containing lines from distinct shipments uses the Extended CTC profile Initialize(); + // [GIVEN] Posted sales invoice "SI" with lines from distinct shipments SalesInvoiceHeader.Get(CreateAndPostSalesInvoiceFromMultipleShipments(CreateCustomer('123456789', "Electronic Address Scheme"::"0002"))); + // [WHEN] Sales invoice "SI" is exported in PEPPOL BIS 3.0 FR ExportInvoice(SalesInvoiceHeader, XmlDoc); + // [THEN] CustomizationID = 'EXTENDED-CTC-FR' Assert.AreEqual('EXTENDED-CTC-FR', GetNodeByPath(XmlDoc, '/Invoice/cbc:CustomizationID'), StrSubstNo(IncorrectValueErr, 'CustomizationID')); SalesInvoiceLine.SetRange("Document No.", SalesInvoiceHeader."No."); @@ -703,13 +722,17 @@ codeunit 148147 "PEPPOL BIS 3.0 XML Tests" SalesInvoiceHeader: Record "Sales Invoice Header"; XmlDoc: XmlDocument; begin + // [FEATURE] [AI test] // [SCENARIO] Repeated references to one shipment and one order do not select the Extended CTC profile Initialize(); + // [GIVEN] Posted sales invoice "SI" with repeated references to one shipment and one order SalesInvoiceHeader.Get(CreateAndPostSalesInvoiceFromSingleShipment(CreateCustomer('123456789', "Electronic Address Scheme"::"0002"))); + // [WHEN] Sales invoice "SI" is exported in PEPPOL BIS 3.0 FR ExportInvoice(SalesInvoiceHeader, XmlDoc); + // [THEN] CustomizationID retains basic CTC profile Assert.AreEqual('urn:cen.eu:en16931:2017#compliant#urn:fdc:peppol.eu:2017:poacc:billing:3.0', GetNodeByPath(XmlDoc, '/Invoice/cbc:CustomizationID'), StrSubstNo(IncorrectValueErr, 'CustomizationID')); end; @@ -753,6 +776,7 @@ codeunit 148147 "PEPPOL BIS 3.0 XML Tests" SalesInvoiceHeader.Get(CreateAndPostSalesInvoice(CreateCustomer('123456789', "Electronic Address Scheme"::"0002"))); // [WHEN] Check is called + // [THEN] No error is raised CheckInvoice(SalesInvoiceHeader); // Cleanup @@ -769,6 +793,7 @@ codeunit 148147 "PEPPOL BIS 3.0 XML Tests" OriginalRegistrationNo: Text[20]; OriginalSIRETNo: Code[14]; begin + // [FEATURE] [AI test] // [SCENARIO] Company VAT registration number is used as the seller endpoint when SIRET and SIREN are blank Initialize(); @@ -807,6 +832,7 @@ codeunit 148147 "PEPPOL BIS 3.0 XML Tests" CountryRegion: Record "Country/Region"; SalesInvoiceHeader: Record "Sales Invoice Header"; begin + // [FEATURE] [AI test] // [SCENARIO] A non-French company VAT registration number cannot be used as a French seller endpoint Initialize(); @@ -837,9 +863,11 @@ codeunit 148147 "PEPPOL BIS 3.0 XML Tests" XmlDoc: XmlDocument; EndpointId: Text[200]; begin + // [FEATURE] [AI test] // [SCENARIO] A service-specific company participant overrides the company endpoint fallbacks Initialize(); + // [GIVEN] Company with service participant using scheme 0002 EndpointId := CompanyInformation."Registration No."; ServiceParticipant.Service := EDocumentService.Code; ServiceParticipant."Participant Type" := ServiceParticipant."Participant Type"::Company; @@ -857,9 +885,11 @@ codeunit 148147 "PEPPOL BIS 3.0 XML Tests" CompanyInformation."Registration No." := ''; CompanyInformation.Modify(true); + // [WHEN] Sales invoice is checked and exported CheckInvoice(SalesInvoiceHeader); ExportInvoice(SalesInvoiceHeader, XmlDoc); + // [THEN] Seller EndpointID uses service participant value Assert.AreEqual(EndpointId, GetNodeByPath(XmlDoc, '/Invoice/cac:AccountingSupplierParty/cac:Party/cbc:EndpointID'), StrSubstNo(IncorrectValueErr, 'Seller EndpointID')); @@ -905,6 +935,7 @@ codeunit 148147 "PEPPOL BIS 3.0 XML Tests" SalesInvoiceHeader: Record "Sales Invoice Header"; XmlDoc: XmlDocument; begin + // [FEATURE] [AI test] // [SCENARIO] Customer Registration Number is used when both FR Electronic Address and Service Participant are absent Initialize(); @@ -996,9 +1027,11 @@ codeunit 148147 "PEPPOL BIS 3.0 XML Tests" Customer: Record Customer; SalesInvoiceHeader: Record "Sales Invoice Header"; begin + // [FEATURE] [AI test] // [SCENARIO] A malformed FR Electronic Address that does not match SIREN format is rejected Initialize(); + // [GIVEN] Customer "C" with malformed FR Electronic Address and no Registration Number Customer.Get(CreateCustomer('ABCD56789', "Electronic Address Scheme"::"0002")); Customer."Registration Number" := ''; Customer.Modify(true); @@ -1019,17 +1052,21 @@ codeunit 148147 "PEPPOL BIS 3.0 XML Tests" XmlDoc: XmlDocument; OriginalSIRETNo: Code[14]; begin + // [FEATURE] [AI test] // [SCENARIO] Company Registration No. is used as the seller endpoint when SIRET is missing Initialize(); + // [GIVEN] Company with blank SIRET No. OriginalSIRETNo := CompanyInformation."SIRET No."; CompanyInformation."SIRET No." := ''; CompanyInformation.Modify(true); SalesInvoiceHeader.Get(CreateAndPostSalesInvoice(CreateCustomer('123456789', "Electronic Address Scheme"::"0002"))); + // [WHEN] Sales invoice is checked and exported CheckInvoice(SalesInvoiceHeader); ExportInvoice(SalesInvoiceHeader, XmlDoc); + // [THEN] Supplier EndpointID uses Registration No. with scheme 0002 Assert.AreEqual(CompanyInformation."Registration No.", GetNodeByPath(XmlDoc, '/Invoice/cac:AccountingSupplierParty/cac:Party/cbc:EndpointID'), StrSubstNo(IncorrectValueErr, 'Seller EndpointID')); @@ -1037,6 +1074,7 @@ codeunit 148147 "PEPPOL BIS 3.0 XML Tests" GetNodeByPath(XmlDoc, '/Invoice/cac:AccountingSupplierParty/cac:Party/cbc:EndpointID/@schemeID'), StrSubstNo(IncorrectValueErr, 'Seller EndpointID schemeID')); + // Cleanup CompanyInformation.Get(); CompanyInformation."SIRET No." := OriginalSIRETNo; CompanyInformation.Modify(true); @@ -1048,9 +1086,11 @@ codeunit 148147 "PEPPOL BIS 3.0 XML Tests" ServiceParticipant: Record "Service Participant"; SalesInvoiceHeader: Record "Sales Invoice Header"; begin + // [FEATURE] [AI test] // [SCENARIO] Check rejects a company participant identifier without its scheme even when SIRET is valid Initialize(); + // [GIVEN] Company with service participant missing FR Identifier Scheme ServiceParticipant.Init(); ServiceParticipant.Service := EDocumentService.Code; ServiceParticipant."Participant Type" := ServiceParticipant."Participant Type"::Company; @@ -1058,8 +1098,10 @@ codeunit 148147 "PEPPOL BIS 3.0 XML Tests" ServiceParticipant.Insert(); SalesInvoiceHeader.Get(CreateAndPostSalesInvoice(CreateCustomer('123456789', "Electronic Address Scheme"::"0002"))); + // [WHEN] Check is called asserterror CheckInvoice(SalesInvoiceHeader); + // [THEN] Error about incomplete service participant is raised AssertExpectedDialogError(EDocHelpers.GetServiceParticipantAddressIncompleteError()); end; @@ -1069,16 +1111,20 @@ codeunit 148147 "PEPPOL BIS 3.0 XML Tests" SalesInvoiceHeader: Record "Sales Invoice Header"; CustomerNo: Code[20]; begin + // [FEATURE] [AI test] // [SCENARIO] Check rejects a buyer without an electronic address, Registration Number, or a service participant identifier Initialize(); + // [GIVEN] Customer "C" without electronic address, Registration Number, or VAT CustomerNo := CreateCustomer('', "Electronic Address Scheme"::"EM"); ClearCustomerVATRegistrationNo(CustomerNo); ClearCustomerRegistrationNumber(CustomerNo); SalesInvoiceHeader.Get(CreateAndPostSalesInvoice(CustomerNo)); + // [WHEN] Check is called asserterror CheckInvoice(SalesInvoiceHeader); + // [THEN] Error about buyer electronic address is raised; AssertExpectedDialogError(EDocHelpers.GetBuyerElectronicAddressRequiredError(CustomerNo)); end; @@ -1089,17 +1135,21 @@ codeunit 148147 "PEPPOL BIS 3.0 XML Tests" Customer: Record Customer; CustomerNo: Code[20]; begin + // [FEATURE] [AI test] // [SCENARIO] Check rejects a buyer electronic address that does not match SIREN or SIREN_suffix format Initialize(); + // [GIVEN] Customer "C" with short malformed FR Electronic Address CustomerNo := CreateCustomer('SHORT', "Electronic Address Scheme"::"0002"); Customer.Get(CustomerNo); Customer."Registration Number" := ''; Customer.Modify(true); SalesInvoiceHeader.Get(CreateAndPostSalesInvoice(CustomerNo)); + // [WHEN] Check is called asserterror CheckInvoice(SalesInvoiceHeader); + // [THEN] Error about malformed buyer electronic address is raised AssertExpectedDialogError(EDocHelpers.GetBuyerElectronicAddressInvalidError( Customer.FieldCaption("FR Electronic Address"), CustomerNo)); end; @@ -1111,17 +1161,21 @@ codeunit 148147 "PEPPOL BIS 3.0 XML Tests" Customer: Record Customer; CustomerNo: Code[20]; begin + // [FEATURE] [AI test] // [SCENARIO] Check rejects a buyer electronic address with a blank SIREN suffix Initialize(); + // [GIVEN] Customer "C" with FR Electronic Address having a blank suffix CustomerNo := CreateCustomer('123456789_ ', "Electronic Address Scheme"::"0225"); Customer.Get(CustomerNo); Customer."Registration Number" := ''; Customer.Modify(true); SalesInvoiceHeader.Get(CreateAndPostSalesInvoice(CustomerNo)); + // [WHEN] Check is called asserterror CheckInvoice(SalesInvoiceHeader); + // [THEN] Error about malformed buyer electronic address is raised AssertExpectedDialogError(EDocHelpers.GetBuyerElectronicAddressInvalidError( Customer.FieldCaption("FR Electronic Address"), CustomerNo)); end; @@ -1133,9 +1187,11 @@ codeunit 148147 "PEPPOL BIS 3.0 XML Tests" SalesInvoiceHeader: Record "Sales Invoice Header"; CustomerNo: Code[20]; begin + // [FEATURE] [AI test] // [SCENARIO] Check rejects a service participant identifier without its French identifier scheme even when the customer endpoint is valid Initialize(); + // [GIVEN] Customer "C" with service participant missing FR Identifier Scheme CustomerNo := CreateCustomer('buyer@example.com', "Electronic Address Scheme"::"EM"); ServiceParticipant.Init(); ServiceParticipant.Service := EDocumentService.Code; @@ -1145,8 +1201,10 @@ codeunit 148147 "PEPPOL BIS 3.0 XML Tests" ServiceParticipant.Insert(); SalesInvoiceHeader.Get(CreateAndPostSalesInvoice(CustomerNo)); + // [WHEN] Check is called asserterror CheckInvoice(SalesInvoiceHeader); + // [THEN] Error about incomplete service participant is raised; AssertExpectedDialogError(EDocHelpers.GetServiceParticipantAddressIncompleteError()); end; @@ -1157,9 +1215,11 @@ codeunit 148147 "PEPPOL BIS 3.0 XML Tests" SalesInvoiceHeader: Record "Sales Invoice Header"; CustomerNo: Code[20]; begin + // [FEATURE] [AI test] // [SCENARIO] Check rejects a French identifier scheme without its service participant identifier even when the customer endpoint is valid Initialize(); + // [GIVEN] Customer "C" with FR Identifier Scheme but missing Participant Identifier CustomerNo := CreateCustomer('buyer@example.com', "Electronic Address Scheme"::"EM"); ServiceParticipant.Init(); ServiceParticipant.Service := EDocumentService.Code; @@ -1169,8 +1229,10 @@ codeunit 148147 "PEPPOL BIS 3.0 XML Tests" ServiceParticipant.Insert(); SalesInvoiceHeader.Get(CreateAndPostSalesInvoice(CustomerNo)); + // [WHEN] Check is called asserterror CheckInvoice(SalesInvoiceHeader); + // [THEN] Error about incomplete service participant is raised; AssertExpectedDialogError(EDocHelpers.GetServiceParticipantAddressIncompleteError()); end; #endregion From 2e6e4e386912b6e9047ec788af05fbf0e6732ecd Mon Sep 17 00:00:00 2001 From: djukicmilica Date: Mon, 24 Aug 2026 23:10:28 +0200 Subject: [PATCH 21/23] Object ID changed --- .../Test/src/Processing/EDocMessageMgtTests.Codeunit.al | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Apps/W1/EDocument/Test/src/Processing/EDocMessageMgtTests.Codeunit.al b/src/Apps/W1/EDocument/Test/src/Processing/EDocMessageMgtTests.Codeunit.al index 773f1ed74fd..0f0cb1081bf 100644 --- a/src/Apps/W1/EDocument/Test/src/Processing/EDocMessageMgtTests.Codeunit.al +++ b/src/Apps/W1/EDocument/Test/src/Processing/EDocMessageMgtTests.Codeunit.al @@ -11,7 +11,7 @@ using Microsoft.Sales.Customer; using System.Threading; using System.Utilities; -codeunit 139899 "E-Doc. Message Mgt. Tests" +codeunit 139893 "E-Doc. Message Mgt. Tests" { Subtype = Test; TestType = IntegrationTest; From e3a91c8aed067a08f4b0cffa64442bf4366e0f1d Mon Sep 17 00:00:00 2001 From: djukicmilica Date: Tue, 25 Aug 2026 10:05:36 +0200 Subject: [PATCH 22/23] Fix E-Document test object ID collision --- .../Test/src/Processing/EDocMessageResponseTests.Codeunit.al | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Apps/W1/EDocument/Test/src/Processing/EDocMessageResponseTests.Codeunit.al b/src/Apps/W1/EDocument/Test/src/Processing/EDocMessageResponseTests.Codeunit.al index 81257eff3dc..eeac498fa78 100644 --- a/src/Apps/W1/EDocument/Test/src/Processing/EDocMessageResponseTests.Codeunit.al +++ b/src/Apps/W1/EDocument/Test/src/Processing/EDocMessageResponseTests.Codeunit.al @@ -11,7 +11,7 @@ using Microsoft.Peppol.Response; using Microsoft.Sales.Customer; using System.Utilities; -codeunit 139898 "E-Doc. Message Response Tests" +codeunit 139864 "E-Doc. Message Response Tests" { Subtype = Test; TestType = IntegrationTest; From b7a91c6b92c3d6362d75bb2827510ce7ce9e9d59 Mon Sep 17 00:00:00 2001 From: djukicmilica Date: Tue, 25 Aug 2026 23:42:01 +0200 Subject: [PATCH 23/23] update --- .../src/Core/FREInvoiceMessageMgt.Codeunit.al | 35 ++++++++++++++----- 1 file changed, 27 insertions(+), 8 deletions(-) diff --git a/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/FREInvoiceMessageMgt.Codeunit.al b/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/FREInvoiceMessageMgt.Codeunit.al index b6723a5185e..148f01c67bf 100644 --- a/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/FREInvoiceMessageMgt.Codeunit.al +++ b/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/FREInvoiceMessageMgt.Codeunit.al @@ -56,7 +56,7 @@ codeunit 10975 "FR E-Invoice Message Mgt." OriginalOccurrence: Record "E-Doc. Payment Occurrence"; begin EDocument.Get(EDocPaymentOccurrence."E-Document Entry No."); - if not IsEligibleFrenchEDocument(EDocument) then + if not ResolveEligibleFrenchService(EDocument) then exit; if EDocPaymentOccurrence.Type = EDocPaymentOccurrence.Type::Applied then begin @@ -380,18 +380,37 @@ codeunit 10975 "FR E-Invoice Message Mgt." exit(GeneralLedgerSetup."LCY Code"); end; - local procedure IsEligibleFrenchEDocument(EDocument: Record "E-Document"): Boolean + local procedure ResolveEligibleFrenchService(var EDocument: Record "E-Document"): Boolean var EDocumentService: Record "E-Document Service"; EDocumentServiceStatus: Record "E-Document Service Status"; begin - if not EDocumentService.Get(EDocument.Service) then - exit(false); - if not (EDocumentService."Document Format" in [EDocumentService."Document Format"::"Peppol BIS 3.0 FR", EDocumentService."Document Format"::"Factur-X FR"]) then - exit(false); - if not EDocumentServiceStatus.Get(EDocument."Entry No", EDocument.Service) then + EDocumentServiceStatus.SetRange("E-Document Entry No", EDocument."Entry No"); + EDocumentServiceStatus.SetFilter(Status, '%1|%2', EDocumentServiceStatus.Status::Approved, EDocumentServiceStatus.Status::Cleared); + if EDocument.Service <> '' then begin + EDocumentServiceStatus.SetRange("E-Document Service Code", EDocument.Service); + if EDocumentServiceStatus.FindFirst() then + if IsSupportedFrenchService(EDocumentServiceStatus."E-Document Service Code", EDocumentService) then + exit(true); + EDocumentServiceStatus.SetRange("E-Document Service Code"); + end; + + if EDocumentServiceStatus.FindSet() then + repeat + if IsSupportedFrenchService(EDocumentServiceStatus."E-Document Service Code", EDocumentService) then begin + EDocument.Service := EDocumentService.Code; + exit(true); + end; + until EDocumentServiceStatus.Next() = 0; + + exit(false); + end; + + local procedure IsSupportedFrenchService(ServiceCode: Code[20]; var EDocumentService: Record "E-Document Service"): Boolean + begin + if not EDocumentService.Get(ServiceCode) then exit(false); - exit(EDocumentServiceStatus.Status in [EDocumentServiceStatus.Status::Approved, EDocumentServiceStatus.Status::Cleared]); + exit(EDocumentService."Document Format" in [EDocumentService."Document Format"::"Peppol BIS 3.0 FR", EDocumentService."Document Format"::"Factur-X FR"]); end; local procedure IsCollectedReportingRequired(EDocument: Record "E-Document"; DetailedLedgerEntryNo: Integer): Boolean