Skip to content
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
// ------------------------------------------------------------------------------------------------
// 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)
Comment thread
djukicmilica marked this conversation as resolved.
{
Caption = 'E-Document Message Entry No.';
DataClassification = SystemMetadata;
}
Comment thread
djukicmilica marked this conversation as resolved.
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;
}
}

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.")
{
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
// ------------------------------------------------------------------------------------------------
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License. See License.txt in the project root for license information.
// ------------------------------------------------------------------------------------------------
namespace Microsoft.eServices.EDocument.Formats;

using Microsoft.eServices.EDocument;
using Microsoft.eServices.EDocument.Processing.Message;
using System.Utilities;

codeunit 10987 "FR E-Invoice Message API"
{
Access = Public;
InherentEntitlements = X;
InherentPermissions = X;

/// <summary>
/// Receives, validates, and stores a French invoice lifecycle message from an E-Document service.
/// </summary>
/// <param name="ServiceCode">The E-Document service that received the message.</param>
/// <param name="ExternalDocumentID">The service-specific identifier registered for the parent E-Document.</param>
/// <param name="ExternalMessageID">The service-specific message identifier used for deduplication.</param>
/// <param name="ReceivedAt">The source timestamp, or zero to use the current date and time.</param>
/// <param name="TempBlob">The original lifecycle XML payload.</param>
/// <returns>The entry number of the normalized French invoice message.</returns>
procedure ReceiveMessage(ServiceCode: Code[20]; ExternalDocumentID: Text[250]; ExternalMessageID: Text[250]; ReceivedAt: DateTime; var TempBlob: Codeunit "Temp Blob"): Integer

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

$\textbf{🟡\ Medium\ Severity\ —\ Telemetry}$

The new inbound lifecycle-message API can fail on invalid XML, unsupported statuses, missing correlations, or mismatched invoice numbers without emitting any telemetry. Because this public receive path bypasses the existing receive/download telemetry wrappers, production failures become hard to diagnose from tenant telemetry alone. Add explicit receive success/failure telemetry here, and keep the dimensions non-PII (for example service, message type, and outcome) rather than raw payload, invoice identifiers, or rejection text.

👍 useful · ❤️ especially valuable · 👎 wrong - reply with why · AL review agent v1.35.4

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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

$\textbf{🟡\ Medium\ Severity\ —\ Web\ Services}$

FREInvoiceMessageAPI.ReceiveMessage stores and deduplicates the inbound external message before it verifies that the payload's InvoiceID matches the E-Document resolved from ExternalDocumentID. If that validation fails, the message entry is still persisted under the external message ID, so a later redelivery of the same message is treated as a duplicate instead of being reprocessed. Validate the correlated document before calling CreateIncomingMessage, or defer deduplication until normalization succeeds.

Agent judgement — not directly backed by a BCQuality knowledge article.

👍 useful · ❤️ especially valuable · 👎 wrong - reply with why · AL review agent v1.35.4

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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

$\textbf{🟡\ Medium\ Severity\ —\ Performance}$

The duplicate-message fast path loads the full "FR E-Invoice Message" row even though it only uses "Entry No.". Adding SetLoadFields("Entry No.") before FindFirst reduces payload on this wide table for every inbound lifecycle message.

Suggested fix (apply manually — could not be anchored as a one-click suggestion):

        FREInvoiceMessage.SetLoadFields("Entry No.");
        FREInvoiceMessage.SetRange("E-Document Message Entry No.", MessageEntryNo);
        if FREInvoiceMessage.FindFirst() then
            exit(FREInvoiceMessage."Entry No.");

Knowledge:

👍 useful · ❤️ especially valuable · 👎 wrong - reply with why · AL review agent v1.35.4

if FREInvoiceMessage.FindFirst() then
exit(FREInvoiceMessage."Entry No.");

EDocumentMessageAPI.GetMessageEDocument(MessageEntryNo, EDocument);
EDocument.TestField(Direction, EDocument.Direction::Outgoing);
if EDocument."Document No." <> InvoiceID then
Error(InvoiceMismatchErr, InvoiceID, EDocument."Document No.");

FREInvoiceMessage.Init();
FREInvoiceMessage."E-Document Entry No." := EDocument."Entry No";
FREInvoiceMessage.Type := MessageType;
FREInvoiceMessage."Source Occurrence ID" := CreateGuid();
FREInvoiceMessage."Reason Code" := CopyStr(ReasonCode, 1, MaxStrLen(FREInvoiceMessage."Reason Code"));
FREInvoiceMessage."Reason Description" := CopyStr(ReasonDescription, 1, MaxStrLen(FREInvoiceMessage."Reason Description"));
FREInvoiceMessage."E-Document Message Entry No." := MessageEntryNo;
FREInvoiceMessage."External Message ID" := ExternalMessageID;
if ReceivedAt = 0DT then
FREInvoiceMessage."Received At" := CurrentDateTime()
else
FREInvoiceMessage."Received At" := ReceivedAt;
FREInvoiceMessage."Event Date" := DT2Date(FREInvoiceMessage."Received At");
FREInvoiceMessage."Created At" := CurrentDateTime();
FREInvoiceMessage.Insert();
exit(FREInvoiceMessage."Entry No.");
end;

local procedure ParseMessage(TempBlob: Codeunit "Temp Blob"; var InvoiceID: Text; var MessageType: Enum "FR E-Invoice Message Type"; var ReasonCode: Text; var ReasonDescription: Text)
var
XmlDoc: XmlDocument;
InStream: InStream;
StatusText: Text;
begin
TempBlob.CreateInStream(InStream, TextEncoding::UTF8);
if not XmlDocument.ReadFrom(InStream, XmlDoc) then
Error(InvalidXmlErr);

InvoiceID := GetRequiredNodeText(XmlDoc, '//*[local-name()="InvoiceID" or local-name()="IssuerAssignedID"]', InvoiceIDErr);
StatusText := GetRequiredNodeText(XmlDoc, '//*[local-name()="ProcessConditionCode" or local-name()="ProcessCondition" or local-name()="Status"]', StatusErr);
MessageType := MapMessageType(StatusText);
ReasonCode := GetOptionalNodeText(XmlDoc, '//*[local-name()="ReasonCode"]');
ReasonDescription := GetOptionalNodeText(XmlDoc, '//*[local-name()="Reason" or local-name()="ReasonDescription"]');

if MessageType = MessageType::"Technical Rejected" then begin
if ReasonCode = '' then
Error(RejectedReasonCodeErr);
if ReasonDescription = '' then
Error(RejectedReasonDescriptionErr);
end;
end;

local procedure MapMessageType(StatusText: Text): Enum "FR E-Invoice Message Type"
var
MessageType: Enum "FR E-Invoice Message Type";
begin
case UpperCase(StatusText.Trim()) of
'200', 'SUBMITTED', 'DÉPOSÉE', 'DEPOSEE':
exit(MessageType::Submitted);
'205', 'ACCEPTED', 'ACCEPTÉE', 'ACCEPTEE', 'APPROUVÉE', 'APPROUVEE':
exit(MessageType::Accepted);
'REJECTED', 'TECHNICAL REJECTED', 'REJETÉE', 'REJETEE':
exit(MessageType::"Technical Rejected");
'210', 'REFUSED', 'REFUSÉE', 'REFUSEE':
exit(MessageType::Refused);
else
Error(UnsupportedStatusErr, StatusText);
end;
end;

local procedure GetResponseType(MessageType: Enum "FR E-Invoice Message Type"): Enum "E-Doc. Response Type"
begin
case MessageType of
MessageType::Submitted:
exit("E-Doc. Response Type"::Submitted);
MessageType::Accepted:
exit("E-Doc. Response Type"::Accepted);
MessageType::"Technical Rejected":
exit("E-Doc. Response Type"::Rejected);
MessageType::Refused:
exit("E-Doc. Response Type"::Refused);
else
Error(UnsupportedStatusErr, Format(MessageType));
end;
end;

local procedure GetRequiredNodeText(XmlDoc: XmlDocument; XPath: Text; ErrorText: Text): Text
var
XmlNode: XmlNode;
begin
if not XmlDoc.SelectSingleNode(XPath, XmlNode) then
Error(ErrorText);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

$\textbf{🟡\ Medium\ Severity\ —\ Privacy}$

GetRequiredNodeText() raises Error(ErrorText) through a Text variable instead of passing a Label directly, so Error method telemetry loses the stable, classified first argument and falls back to generic guidance.

Knowledge:

👍 useful · ❤️ especially valuable · 👎 wrong - reply with why · AL review agent v1.35.4

exit(XmlNode.AsXmlElement().InnerText());
end;

local procedure GetOptionalNodeText(XmlDoc: XmlDocument; XPath: Text): Text
var
XmlNode: XmlNode;
begin
if XmlDoc.SelectSingleNode(XPath, XmlNode) then
exit(XmlNode.AsXmlElement().InnerText());
end;

var
InvalidXmlErr: Label 'The French invoice lifecycle message is not valid XML.';
InvoiceIDErr: Label 'The French invoice lifecycle message does not contain an invoice ID.';
StatusErr: Label 'The French invoice lifecycle message does not contain a status.';
UnsupportedStatusErr: Label 'French invoice lifecycle status %1 is not supported.', Comment = '%1 = lifecycle status';
InvoiceMismatchErr: Label 'The lifecycle message invoice ID %1 does not match E-Document invoice %2.', Comment = '%1 = message invoice identifier, %2 = E-Document invoice identifier';
RejectedReasonCodeErr: Label 'A technical rejection reason code is required.';
RejectedReasonDescriptionErr: Label 'A technical rejection reason description is required.';
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
// ------------------------------------------------------------------------------------------------
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License. See License.txt in the project root for license information.
// ------------------------------------------------------------------------------------------------
namespace Microsoft.eServices.EDocument.Formats;

using Microsoft.eServices.EDocument;
using Microsoft.Finance.GeneralLedger.Setup;
using System.Utilities;

codeunit 10976 "FR E-Invoice Message Builder"
{
Access = Internal;
InherentEntitlements = X;
InherentPermissions = X;

procedure BuildMessage(EDocument: Record "E-Document"; FREInvoiceMessage: Record "FR E-Invoice Message"; var TempBlob: Codeunit "Temp Blob")
var
XmlDoc: XmlDocument;
RootElement: XmlElement;
AcknowledgementElement: XmlElement;
ReferenceElement: XmlElement;
StatusElement: XmlElement;
AmountElement: XmlElement;
OutStream: OutStream;
begin
FREInvoiceMessage.TestField("Event Date");
XmlDoc := XmlDocument.Create();
XmlDoc.SetDeclaration(XmlDeclaration.Create('1.0', 'UTF-8', 'no'));
RootElement := XmlElement.Create('CrossDomainAcknowledgementAndResponse', RsmNamespaceTok);
RootElement.Add(XmlAttribute.CreateNamespaceDeclaration('ram', RamNamespaceTok));
RootElement.Add(XmlAttribute.CreateNamespaceDeclaration('rsm', RsmNamespaceTok));
RootElement.Add(XmlAttribute.CreateNamespaceDeclaration('udt', UdtNamespaceTok));
RootElement.Add(XmlElement.Create('ExchangedDocument', RsmNamespaceTok,
XmlElement.Create('ID', RamNamespaceTok, Format(FREInvoiceMessage."Source Occurrence ID"))));

AcknowledgementElement := XmlElement.Create('AcknowledgementDocument', RsmNamespaceTok);
AddIssueDateTime(AcknowledgementElement, FREInvoiceMessage."Event Date");
ReferenceElement := XmlElement.Create('ReferenceReferencedDocument', RamNamespaceTok);
ReferenceElement.Add(XmlElement.Create('IssuerAssignedID', RamNamespaceTok, EDocument."Document No."));
ReferenceElement.Add(XmlElement.Create('StatusCode', RamNamespaceTok, InvoiceReferenceStatusCodeTok));
case FREInvoiceMessage.Type of

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

$\textbf{🟠\ High\ Severity\ —\ Interfaces}$

New FR E-Invoice message handling scatters MessageType-dependent behaviour across case branches in FREInvoiceMessageBuilder, FREInvoiceMessageMgt, and FREInvoiceMessageAPI. Each new FR E-Invoice Message Type now requires synchronized edits in multiple call sites. Model the enum as one that implements an interface and dispatch through an interface variable so each message type owns its own payload and response behaviour.

Knowledge:

👍 useful · ❤️ especially valuable · 👎 wrong - reply with why · AL review agent v1.35.4

FREInvoiceMessage.Type::Accepted:
begin
ReferenceElement.Add(XmlElement.Create('ProcessConditionCode', RamNamespaceTok, AcceptedStatusCodeTok));
ReferenceElement.Add(XmlElement.Create('ProcessCondition', RamNamespaceTok, AcceptedStatusNameTok));
end;
FREInvoiceMessage.Type::Refused:
begin
ReferenceElement.Add(XmlElement.Create('ProcessConditionCode', RamNamespaceTok, RefusedStatusCodeTok));
ReferenceElement.Add(XmlElement.Create('ProcessCondition', RamNamespaceTok, RefusedStatusNameTok));
StatusElement := XmlElement.Create('SpecifiedDocumentStatus', RamNamespaceTok);
StatusElement.Add(XmlElement.Create('ReasonCode', RamNamespaceTok, FREInvoiceMessage."Reason Code"));
StatusElement.Add(XmlElement.Create('Reason', RamNamespaceTok, FREInvoiceMessage."Reason Description"));
ReferenceElement.Add(StatusElement);
end;
FREInvoiceMessage.Type::Collected,
FREInvoiceMessage.Type::"Negative Collected":
begin
ReferenceElement.Add(XmlElement.Create('ProcessConditionCode', RamNamespaceTok, CollectedStatusCodeTok));
ReferenceElement.Add(XmlElement.Create('ProcessCondition', RamNamespaceTok, CollectedStatusNameTok));
StatusElement := XmlElement.Create('SpecifiedDocumentStatus', RamNamespaceTok);
StatusElement.Add(XmlElement.Create('TypeCode', RamNamespaceTok, CollectedAmountTypeCodeTok));
AmountElement := XmlElement.Create('ValueAmount', RamNamespaceTok, Format(FREInvoiceMessage.Amount, 0, 9));
AmountElement.Add(XmlAttribute.Create('currencyID', ResolveCurrencyCode(FREInvoiceMessage."Currency Code")));
StatusElement.Add(AmountElement);
ReferenceElement.Add(StatusElement);
end;
else

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

$\textbf{🟠\ High\ Severity\ —\ Error\ Handling}$

This unsupported-message-type branch is a caller-contract/internal-state failure, but it raises a plain Error with technical enum detail. Raise it through ErrorInfo with ErrorType::Internal so only telemetry carries the developer-facing message.

Knowledge:

👍 useful · ❤️ especially valuable · 👎 wrong - reply with why · AL review agent v1.35.4

Error(UnsupportedMessageTypeErr, FREInvoiceMessage.Type);
end;
AcknowledgementElement.Add(ReferenceElement);
RootElement.Add(AcknowledgementElement);
XmlDoc.Add(RootElement);

TempBlob.CreateOutStream(OutStream, TextEncoding::UTF8);
XmlDoc.WriteTo(OutStream);
end;

local procedure AddIssueDateTime(var AcknowledgementElement: XmlElement; EventDate: Date)
var
DateTimeStringElement: XmlElement;
IssueDateTimeElement: XmlElement;
begin
IssueDateTimeElement := XmlElement.Create('IssueDateTime', RamNamespaceTok);
DateTimeStringElement := XmlElement.Create('DateTimeString', UdtNamespaceTok, Format(EventDate, 0, '<Year4><Month,2><Day,2>000000'));
DateTimeStringElement.Add(XmlAttribute.Create('format', DateTimeFormatCodeTok));
IssueDateTimeElement.Add(DateTimeStringElement);
AcknowledgementElement.Add(IssueDateTimeElement);
end;

local procedure ResolveCurrencyCode(CurrencyCode: Code[10]): Code[10]
var
GeneralLedgerSetup: Record "General Ledger Setup";
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;
UdtNamespaceTok: Label 'urn:un:unece:uncefact:data:standard:UnqualifiedDataType:100', Locked = true;
DateTimeFormatCodeTok: Label '204', Locked = true;
InvoiceReferenceStatusCodeTok: Label '47', Locked = true;
CollectedStatusCodeTok: Label '212', Locked = true;
CollectedStatusNameTok: Label 'Encaissée', Locked = true;
CollectedAmountTypeCodeTok: Label 'MEN', Locked = true;
RefusedStatusCodeTok: Label '210', Locked = true;
RefusedStatusNameTok: Label 'Refusée', Locked = true;
AcceptedStatusCodeTok: Label '205', Locked = true;
AcceptedStatusNameTok: Label 'Acceptée', Locked = true;
UnsupportedMessageTypeErr: Label 'French invoice lifecycle message type %1 cannot be sent.', Comment = '%1 = French invoice lifecycle message type';
}
Loading
Loading