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..bb817cedc60 --- /dev/null +++ b/src/Apps/FR/EDocument_FR/EReportingFR/app/Permissions/EReportingFRUser.PermissionSet.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.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, + 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 Profile Validator" = 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/CIIXMLBuilder.Codeunit.al b/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/CIIXMLBuilder.Codeunit.al index fb468e4b730..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 @@ -158,6 +158,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 @@ -175,6 +176,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; @@ -268,6 +298,31 @@ codeunit 10978 "CII XML Builder" AgreementElement.Add(BuyerElement); 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"; 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..b1538b6d033 --- /dev/null +++ b/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/FREInvoiceMessage.Table.al @@ -0,0 +1,158 @@ +// ------------------------------------------------------------------------------------------------ +// 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; + } + field(14; "External Message ID"; Text[250]) + { + Caption = 'External Message ID'; + DataClassification = CustomerContent; + } + field(15; "Received At"; DateTime) + { + 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; + } + 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 + { + 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.") + { + } + 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..a529ae840f0 --- /dev/null +++ b/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/FREInvoiceMessageAPI.Codeunit.al @@ -0,0 +1,190 @@ +// ------------------------------------------------------------------------------------------------ +// 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."); + ValidateLifecycleTransition(EDocument."Entry No", MessageType); + + 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 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; + 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); + '213', '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'; + 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 new file mode 100644 index 00000000000..b74a72f41d9 --- /dev/null +++ b/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/FREInvoiceMessageBuilder.Codeunit.al @@ -0,0 +1,291 @@ +// ------------------------------------------------------------------------------------------------ +// 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 + FREInvoiceProfileValidator: Codeunit "FR E-Invoice Profile Validator"; + XmlDoc: XmlDocument; + RootElement: XmlElement; + AcknowledgementElement: XmlElement; + ReferenceElement: XmlElement; + StatusElement: XmlElement; + 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)); + 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 + 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)); + 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": + 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 + Error(UnsupportedMessageTypeErr, FREInvoiceMessage.Type); + end; + AcknowledgementElement.Add(ReferenceElement); + RootElement.Add(AcknowledgementElement); + XmlDoc.Add(RootElement); + FREInvoiceProfileValidator.Validate(XmlDoc, IsPPFMessage(FREInvoiceMessage)); + + TempBlob.CreateOutStream(OutStream, TextEncoding::UTF8); + 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"; + 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; + 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 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"; + 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; + 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; + RefusedStatusCodeTok: Label '210', Locked = true; + 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 new file mode 100644 index 00000000000..148f01c67bf --- /dev/null +++ b/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/FREInvoiceMessageMgt.Codeunit.al @@ -0,0 +1,463 @@ +// ------------------------------------------------------------------------------------------------ +// 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.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; + +codeunit 10975 "FR E-Invoice Message Mgt." +{ + Access = Internal; + InherentEntitlements = X; + InherentPermissions = X; + + Permissions = tabledata "FR E-Invoice Message VAT" = ri; + + 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]) + begin + CheckBuyerResponseAllowed(EDocument); + 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") + var + EDocPaymentOccurrenceMgt: Codeunit "E-Doc. Payment Occurrence Mgt."; + begin + 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 + EDocument.Get(EDocPaymentOccurrence."E-Document Entry No."); + if not ResolveEligibleFrenchService(EDocument) then + 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", + 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("Source Occurrence ID", OriginalOccurrence."Source Occurrence ID"); + if not CollectedMessage.FindFirst() then + exit; + + CreateAndSendMessage( + EDocument, "FR E-Invoice Message Type"::"Negative Collected", EDocPaymentOccurrence."Source Occurrence ID", + -CollectedMessage.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"); + 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(); + case MessageType of + MessageType::Collected: + FreezeSenderPlatform(EDocument, FREInvoiceMessage); + MessageType::"Negative Collected": + CopySenderPlatform(FREInvoiceMessage, OriginalEntryNo); + end; + 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); + FREInvoiceMessage.Modify(); + EDocumentMessageAPI.QueueMessage(FREInvoiceMessage."E-Document Message Entry No."); + end; + + 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); + 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) + 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"; + 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") + 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; + GrossAmount: Decimal; + begin + 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 + 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"); + + exit(GrossAmount); + 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 ResolveEligibleFrenchService(var EDocument: Record "E-Document"): Boolean + var + EDocumentService: Record "E-Document Service"; + EDocumentServiceStatus: Record "E-Document Service Status"; + begin + 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(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 + 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"; + 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 + 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 + 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'; + 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/FREInvoiceMessageType.Enum.al b/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/FREInvoiceMessageType.Enum.al new file mode 100644 index 00000000000..66421e51470 --- /dev/null +++ b/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/FREInvoiceMessageType.Enum.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; + +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'; + } + 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/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/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..d02103e2586 --- /dev/null +++ b/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Core/FREInvoiceMessages.Page.al @@ -0,0 +1,130 @@ +// ------------------------------------------------------------------------------------------------ +// 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("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("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("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; + 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/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/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/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/app/src/Extensions/EReportingEDocuments.PageExt.al b/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Extensions/EReportingEDocuments.PageExt.al index a6c47dd86a2..902bacb21d4 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 @@ -19,4 +19,58 @@ pageextension 10974 "E-Reporting E-Documents" extends "E-Documents" } } } + + actions + { + 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; + 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; + } + 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 new file mode 100644 index 00000000000..8585e6e06b4 --- /dev/null +++ b/src/Apps/FR/EDocument_FR/EReportingFR/app/src/Extensions/FREDocResponseType.EnumExt.al @@ -0,0 +1,19 @@ +// ------------------------------------------------------------------------------------------------ +// 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(10970; Submitted) + { + Caption = 'Submitted'; + } + 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/EDocFRStructImportTests.Codeunit.al b/src/Apps/FR/EDocument_FR/EReportingFR/test/src/EDocFRStructImportTests.Codeunit.al index af1abb67b3a..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 @@ -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; @@ -25,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 @@ -74,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 @@ -97,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 @@ -118,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)); @@ -145,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 @@ -189,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/FREDocMsgSenderMock.Codeunit.al b/src/Apps/FR/EDocument_FR/EReportingFR/test/src/FREDocMsgSenderMock.Codeunit.al new file mode 100644 index 00000000000..2144f875407 --- /dev/null +++ b/src/Apps/FR/EDocument_FR/EReportingFR/test/src/FREDocMsgSenderMock.Codeunit.al @@ -0,0 +1,88 @@ +// ------------------------------------------------------------------------------------------------ +// 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"; + PayloadLine: Text; + InStream: InStream; + begin + SendCount += 1; + LastResponseType := MessageContext.GetResponseType(); + TempBlob := MessageContext.GetTempBlob(); + TempBlob.CreateInStream(InStream, TextEncoding::UTF8); + 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; + + 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); + ReportSuccess := true; + SendCount := 0; + end; + + procedure SetReportSuccess(NewReportSuccess: Boolean) + begin + ReportSuccess := NewReportSuccess; + 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; + 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 new file mode 100644 index 00000000000..fe75b9d3815 --- /dev/null +++ b/src/Apps/FR/EDocument_FR/EReportingFR/test/src/FREInvoiceMessageTests.Codeunit.al @@ -0,0 +1,1999 @@ +// ------------------------------------------------------------------------------------------------ +// 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.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; +using Microsoft.Sales.History; +using Microsoft.Sales.Receivables; +using System.Utilities; + +codeunit 148151 "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 "E-Doc. Payment Occurrence" = rimd, + 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 + Assert: Codeunit Assert; + LibraryERM: Codeunit "Library - ERM"; + LibrarySales: Codeunit "Library - Sales"; + MessageSenderMock: Codeunit "FR E-Doc. Msg. Sender Mock"; + + [Test] + 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."; + begin + // [FEATURE] [AI test] + // [SCENARIO] Applying a payment to an approved French E-Document creates a Collected message + Initialize(); + + // [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); + EDocPaymentOccurrence.SetRange("E-Document Entry No.", EDocument."Entry No"); + EDocPaymentOccurrence.SetRange(Type, EDocPaymentOccurrence.Type::Applied); + Assert.RecordCount(EDocPaymentOccurrence, 1); + EDocPaymentOccurrence.FindFirst(); + 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(), 120, 'EUR'); + 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 + 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."; + 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"); + CollectedMessage.SetRange(Type, CollectedMessage.Type::Collected); + CollectedMessage.FindFirst(); + 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(); + 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.'); + AssertPayloadAmount(MessageSenderMock.GetLastPayload(), -120, 'EUR'); + end; + + [Test] + procedure RefusalSendsStatusAndReason() + var + 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.'); + Assert.AreEqual("E-Doc. Response Type"::Refused, MessageSenderMock.GetLastResponseType(), 'The child message must be Refused.'); + AssertPayloadStatus(MessageSenderMock.GetLastPayload(), '210'); + AssertPayloadReasonCode(MessageSenderMock.GetLastPayload(), 'PRICE'); + end; + + [Test] + procedure RefusalWithoutReasonSendsStatusWithoutReasonElements() + var + EDocument: Record "E-Document"; + FREInvoiceMessageMgt: Codeunit "FR E-Invoice Message Mgt."; + begin + // [FEATURE] [AI test] + // [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 code or description + FREInvoiceMessageMgt.RefuseInvoice(EDocument, '', ''); + SendFirstMessage(EDocument, "FR E-Invoice Message Type"::Refused); + + // [THEN] The refusal status is sent without empty reason elements + AssertPayloadStatus(MessageSenderMock.GetLastPayload(), '210'); + AssertPayloadHasNoReason(MessageSenderMock.GetLastPayload()); + end; + + [Test] + procedure MessageSenderMustReportSuccess() + var + EDocument: Record "E-Document"; + EDocumentMessageAPI: Codeunit "E-Document Message API"; + TempBlob: Codeunit "Temp Blob"; + 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(''); + MessageEntryNo := EDocumentMessageAPI.CreateMessage( + 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; + + [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 + // [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); + FREInvoiceMessage.SetRange("E-Document Entry No.", EDocument."Entry No"); + FREInvoiceMessage.SetRange(Type, FREInvoiceMessage.Type::Collected); + 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.'); + 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] + procedure CollectedMessageAllowsMissingSenderPlatformIdentity() + 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 supports a service without optional sender-platform identity + Initialize(); + + // [GIVEN] An eligible payment whose service has no sender-platform ID + CreatePaymentScenarioWithoutSenderPlatform(EDocument, DetailedCustLedgEntry, "E-Document Service Status"::Approved); + + // [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 CollectedMessageWithoutPlatformUsesCDVProfile() + 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 without platform identity retains the CDV profile + Initialize(); + + // [GIVEN] An eligible payment whose service has no sender-platform identity + CreatePaymentScenarioWithoutSenderPlatform(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 uses the CDV profile and does not contain PPF trade parties + AssertPayloadCDVContext(MessageSenderMock.GetLastPayload()); + 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 + CompanyInformation: Record "Company Information"; + 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"; + 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 + // [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] VAT and sender-platform setup are changed after the original message + 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); + EDocumentService."FR Sender Platform ID" := 'CHANGED-PLATFORM'; + 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); + 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.'); + 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] + 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 + EDocument: Record "E-Document"; + EDocumentMessageAPI: Codeunit "E-Document Message API"; + TempBlob: Codeunit "Temp Blob"; + OutStream: OutStream; + ExternalDocumentID: Text[250]; + ExternalMessageID: Text[250]; + 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)); + EDocumentMessageAPI.RegisterExternalDocumentReference(EDocument, EDocument.Service, ExternalDocumentID); + 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); + DuplicateMessageEntryNo := EDocumentMessageAPI.CreateIncomingMessage( + 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; + + [Test] + procedure IncomingMessageRequiresRegisteredDocumentReference() + var + EDocument: Record "E-Document"; + EDocumentMessageAPI: Codeunit "E-Document Message API"; + 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; + + [Test] + procedure BuyerAcceptanceQueuesAndSendsStatus205() + var + EDocument: Record "E-Document"; + 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); + 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.'); + AssertPayloadStatus(MessageSenderMock.GetLastPayload(), '205'); + end; + + [Test] + procedure BuyerResponseCannotBeRepeated() + var + 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(); + + // [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; + + [Test] + procedure BuyerResponseCannotBeRepeatedAfterRefusal() + var + 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(); + + // [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; + + [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 + // [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); + ReceivedAt := CreateDateTime(20260101D, 120000T); + EDocumentMessageAPI.RegisterExternalDocumentReference(EDocument, EDocument.Service, ExternalDocID); + 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.'); + 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 + // [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); + EDocumentMessageAPI.RegisterExternalDocumentReference(EDocument, EDocument.Service, ExternalDocID); + ReceiveLifecycleMessage(EDocument, ExternalDocID, 'Submitted', '', ''); + 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.'); + 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 + // [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); + 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')); + + // [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.'); + 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 + // [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); + EDocumentMessageAPI.RegisterExternalDocumentReference(EDocument, EDocument.Service, ExternalDocID); + 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); + 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 + // [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; + + [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 + // [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; + + [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 + // [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; + + [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 + // [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; + + [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 + // [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; + + [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 + EDocument: Record "E-Document"; + FREInvoiceMessage: Record "FR E-Invoice Message"; + 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"; + FREInvoiceMessage.Type := FREInvoiceMessage.Type::Submitted; + FREInvoiceMessage."Source Occurrence ID" := CreateGuid(); + FREInvoiceMessage."Event Date" := Today(); + 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; + + 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 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"; + 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 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 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; + 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 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; + PartyPathTok: Label '//*[local-name()="ExchangedDocument"]/*[local-name()="%1"]', Locked = true; + begin + 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.'); + 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"; + FREInvoiceMessage: Record "FR E-Invoice Message"; + GeneralLedgerSetup: Record "General Ledger Setup"; + begin + EDocPaymentOccurrence.DeleteAll(); + FREInvoiceMessage.DeleteAll(); + MessageSenderMock.Reset(); + EnsureService(); + EnsureCompanyInformation(); + GeneralLedgerSetup.Get(); + if not GeneralLedgerSetup."Unrealized VAT" then begin + GeneralLedgerSetup."Unrealized VAT" := true; + GeneralLedgerSetup.Modify(); + 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"; + begin + 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") + 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 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"; ServiceStatus: Enum "E-Document Service Status") + begin + 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; + 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'); + 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; + 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"); + 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); + 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."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'; + EDocument.Insert(); + CreateServiceStatus(EDocument, ServiceStatus); + + 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(), -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'); + 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; + 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"); + 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); + 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."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'; + 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'); + 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; + 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"); + 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); + 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."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'; + 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'); + 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); + 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."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'; + 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; + + 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"; + begin + EDocumentServiceStatus.Init(); + EDocumentServiceStatus."E-Document Entry No" := EDocument."Entry No"; + EDocumentServiceStatus."E-Document Service Code" := EDocument.Service; + EDocumentServiceStatus.Status := ServiceStatus; + 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 FindApplicationDetailedEntry(var DetailedCustLedgEntry: Record "Detailed Cust. Ledg. Entry"; InvoiceDocNo: Code[20]) + var + CustLedgerEntry: Record "Cust. Ledger Entry"; + begin + 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 + 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 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 e46e3ad628b..c3ef43209b5 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; @@ -34,7 +35,6 @@ codeunit 148148 "Factur-X CII XML Tests" trigger OnRun() begin - // [FEATURE] [Factur-X FR E-document] end; var @@ -185,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); @@ -203,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); @@ -451,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); @@ -709,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); @@ -939,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); @@ -957,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); @@ -1215,6 +1221,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"; @@ -1234,9 +1241,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."; @@ -1257,6 +1274,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()); @@ -1313,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(); @@ -1353,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(); @@ -1953,6 +1972,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); @@ -1989,6 +2009,10 @@ 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 @@ -2029,6 +2053,7 @@ 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); @@ -2131,6 +2156,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); @@ -2153,8 +2181,6 @@ codeunit 148148 "Factur-X CII XML Tests" end; local procedure CheckFacturX(var SourceDocumentHeader: RecordRef) - var - EDocumentService: Record "E-Document Service"; begin FacturXFormat.Check(SourceDocumentHeader, EDocumentService, "E-Document Processing Phase"::Create); end; @@ -2393,6 +2419,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; 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 diff --git a/src/Apps/W1/EDocument/App/Permissions/EDocCoreObjects.PermissionSet.al b/src/Apps/W1/EDocument/App/Permissions/EDocCoreObjects.PermissionSet.al index 782f758045d..1d09834768a 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,7 +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-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, @@ -178,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/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..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, @@ -42,6 +46,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/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 new file mode 100644 index 00000000000..698db3b63da --- /dev/null +++ b/src/Apps/W1/EDocument/App/src/Integration/Interfaces/IMessageSender.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; + +/// +/// 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 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 6a91bb64230..041f0603521 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,17 @@ 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, IMessageResponseHandler { Extensible = true; Access = Public; - DefaultImplementation = IConsentManager = "Consent Manager Default Impl."; + DefaultImplementation = IConsentManager = "Consent Manager Default Impl.", + 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 c00103cff7c..ca180305fb7 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,16 @@ 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 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/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 new file mode 100644 index 00000000000..44443b859e0 --- /dev/null +++ b/src/Apps/W1/EDocument/App/src/Processing/Message/EDocMessageContext.Codeunit.al @@ -0,0 +1,86 @@ +// ------------------------------------------------------------------------------------------------ +// 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; + + /// + /// 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 or Pending Response after successful transmission. + /// + /// The integration action status. + 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..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 @@ -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,232 @@ 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"; + 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; + MessageSendingErrorInfo: ErrorInfo; + begin + EDocMessage.Get(MessageEntryNo); + EDocMessage.TestField(Direction, EDocMessage.Direction::Outgoing); + 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."); + EDocumentService.Get(EDocMessage.Service); + GetMessageBlob(MessageEntryNo, TempBlob); + if not TempBlob.HasValue() then + Error(MessagePayloadErr, MessageEntryNo); + + EDocMessageContext.Initialize(EDocMessage, TempBlob); + MessageSender := EDocumentService."Service Integration V2"; + MessageSender.SendMessage(EDocument, EDocumentService, EDocMessageContext); + 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()); + Error(MessageSendingErrorInfo); + end; + + EDocumentLog.InsertIntegrationLog( + EDocument, EDocumentService, EDocMessageContext.Http().GetHttpRequestMessage(), EDocMessageContext.Http().GetHttpResponseMessage()); + 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) + 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(); + Commit(); + 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); + if EDocMessage.Status <> EDocMessage.Status::"Response Error" then + EDocMessage.TestField(Status, EDocMessage.Status::Error); + EDocMessage.TestField(Service); + + if EDocMessage.Status = EDocMessage.Status::"Response Error" then + EDocMessage.Status := EDocMessage.Status::"Pending Response" + else + EDocMessage.Status := EDocMessage.Status::Queued; + EDocMessage.Modify(); + Commit(); + 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]) + 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.LockTable(); + 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 var EDocDataStorage: Record "E-Doc. Data Storage"; @@ -96,4 +323,15 @@ 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.', 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.'; + 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/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/EDocMessageSendJob.Codeunit.al b/src/Apps/W1/EDocument/App/src/Processing/Message/EDocMessageSendJob.Codeunit.al new file mode 100644 index 00000000000..28f31c79d3d --- /dev/null +++ b/src/Apps/W1/EDocument/App/src/Processing/Message/EDocMessageSendJob.Codeunit.al @@ -0,0 +1,38 @@ +// ------------------------------------------------------------------------------------------------ +// 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 Codeunit.Run(Codeunit::"E-Doc. Message Send Runner", EDocumentMessage) 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; + + 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/App/src/Processing/Message/EDocMessageStatus.Enum.al b/src/Apps/W1/EDocument/App/src/Processing/Message/EDocMessageStatus.Enum.al index 62646edc3ba..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 @@ -19,4 +19,24 @@ enum 6429 "E-Doc. Message Status" { Caption = 'Sent'; } + value(2; Queued) + { + Caption = 'Queued'; + } + value(3; Error) + { + Caption = 'Error'; + } + value(4; Received) + { + 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 new file mode 100644 index 00000000000..a8f18bbfa9e --- /dev/null +++ b/src/Apps/W1/EDocument/App/src/Processing/Message/EDocMsgTransportDefault.Codeunit.al @@ -0,0 +1,42 @@ +// ------------------------------------------------------------------------------------------------ +// 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, IMessageResponseHandler +{ + 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") + var + MessageTransportErrorInfo: ErrorInfo; + begin + MessageTransportErrorInfo.Message := StrSubstNo(MessageTransportNotSupportedErr, EDocumentService.Code); + MessageTransportErrorInfo.RecordId := EDocumentService.RecordId; + MessageTransportErrorInfo.PageNo := Page::"E-Document Service"; + MessageTransportErrorInfo.AddNavigationAction(ShowEDocumentServiceLbl); + 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/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..5be2d3c7ffc --- /dev/null +++ b/src/Apps/W1/EDocument/App/src/Processing/Message/EDocPaymentOccurrenceMgt.Codeunit.al @@ -0,0 +1,153 @@ +// ------------------------------------------------------------------------------------------------ +// 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."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; + 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"; + 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 + CreateOccurrence( + 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(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.", EDocumentEntryNo); + EDocPaymentOccurrence.SetRange("Source Occurrence ID", SourceOccurrenceID); + EDocPaymentOccurrence.SetRange(Type, OccurrenceType); + if not EDocPaymentOccurrence.IsEmpty() then + exit; + + EDocPaymentOccurrence.Init(); + EDocPaymentOccurrence."E-Document Entry No." := EDocumentEntryNo; + 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.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"); + 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..0400caaceb4 --- /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 6115 "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 new file mode 100644 index 00000000000..38bf5d304d6 --- /dev/null +++ b/src/Apps/W1/EDocument/App/src/Processing/Message/EDocumentMessageAPI.Codeunit.al @@ -0,0 +1,152 @@ +// ------------------------------------------------------------------------------------------------ +// 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; + + /// + /// 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."; + begin + 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. + /// + /// 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; + + /// + /// 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; + + /// + /// 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. + /// + /// 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..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 @@ -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.'; + } } } } @@ -61,6 +86,23 @@ page 6434 "E-Document Messages FactBox" { area(processing) { + action(Retry) + { + ApplicationArea = Basic, Suite; + Caption = 'Retry'; + ToolTip = 'Retry the failed message transmission or response polling operation 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; @@ -87,7 +129,13 @@ page 6434 "E-Document Messages FactBox" } } + trigger OnAfterGetCurrRecord() + begin + RetryEnabled := (Rec.Direction = Rec.Direction::Outgoing) and (Rec.Status in [Rec.Status::Error, Rec.Status::"Response 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/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?'; 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..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,9 +8,10 @@ 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 +codeunit 139658 "E-Doc. Integration Mock V2" implements IDocumentSender, IDocumentReceiver, IDocumentResponseHandler, ISentDocumentActions, IConsentManager, IMessageResponseHandler { Access = Internal; @@ -37,6 +38,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 new file mode 100644 index 00000000000..0f0cb1081bf --- /dev/null +++ b/src/Apps/W1/EDocument/Test/src/Processing/EDocMessageMgtTests.Codeunit.al @@ -0,0 +1,378 @@ +// ------------------------------------------------------------------------------------------------ +// 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 139893 "E-Doc. Message Mgt. Tests" +{ + Subtype = Test; + TestType = IntegrationTest; + TestPermissions = Disabled; + + 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; + + [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"::Unknown, "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; + + [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"::Unknown, "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"::Unknown, "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"::Unknown, "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; + + [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"; + 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.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(); + + 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; + + 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"::Unknown, "E-Document Direction"::Outgoing, + "E-Doc. Response Type"::None, TempBlob); + EDocMessage.Get(MessageEntryNo); + EDocMessage.Status := EDocMessage.Status::"Pending Response"; + EDocMessage.Modify(); + exit(MessageEntryNo); + end; +}