Skip to content
Open
Show file tree
Hide file tree
Changes from 17 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,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,

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\ —\ Security}$

The French user role grants RIMD directly on "FR E-Invoice Message", even though the table is consumed through a read-only page and message-management codeunits. That lets assigned users bypass the lifecycle workflow and alter or delete refusal reasons, received message metadata, and sent-status records directly through any client surface that honors table permissions. This table should be readable directly at most, with writes reserved for code-mediated paths.

Knowledge:

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

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;
}
Original file line number Diff line number Diff line change
@@ -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)
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;
}
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.")
{
}
}
}
Original file line number Diff line number Diff line change
@@ -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;

/// <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.");
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);

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}$

ValidateLifecycleTransition filters FR E-Invoice Message by E-Document Entry No. and Type, then calls FindLast(), but the new table has no key whose leading fields match that lookup. Once collected/payment messages accumulate for a document, this transition check has to read through unrelated message rows to find the last lifecycle status; add a key for the lifecycle lookup (for example starting with E-Document Entry No. and Type, plus the field used to identify the latest row) and use it here.

Knowledge:

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

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

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

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\ —\ Style}$

NoPreviousStatusTok is natural-language fallback text that is inserted into InvalidLifecycleTransitionErr, so it behaves like translatable UI text rather than a wire-level token. Rename it to a UI-text suffix such as NoPreviousStatusLbl to match its usage and avoid the misleading Tok naming convention.

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

RejectedReasonCodeErr: Label 'A technical rejection reason code is required.';
RejectedReasonDescriptionErr: Label 'A technical rejection reason description is required.';
}
Loading
Loading