-
Notifications
You must be signed in to change notification settings - Fork 441
[FR E-Reporting] Add payment and invoice lifecycle messages #10437
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 17 commits
08c3cc0
31b4bb0
dc09a12
31387e6
161edf4
53b2023
bcef50b
54fcc33
6cff425
6a85318
19a729b
7a454d7
dc578d2
c55c5d4
70ca478
80c8837
67d718e
05f4c34
f5b8567
62dd4e4
ad98e96
bb53620
2e6e4e3
e3a91c8
372c19e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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, | ||
| 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) | ||
|
djukicmilica marked this conversation as resolved.
|
||
| { | ||
| Caption = 'E-Document Message Entry No.'; | ||
| DataClassification = SystemMetadata; | ||
| } | ||
|
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 | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 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); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 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); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 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); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 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); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 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'; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
👍 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.'; | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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