[E-Documents Core] [Peppol] - Enabling EDI capabilities with E-Documents. PEPPOL Order Response Message Handling#8698
Conversation
Adds a message model for e-documents so order responses are tracked as lifecycle events instead of creating extra documents. Generates outgoing acknowledgements and accept/reject responses where supported, including a manual reject action for inbound orders. Improves usability and reliability with message visibility in the UI, payload viewing, cleanup of related message data, and new integration tests.
Introduces a dedicated response-building contract so draft-reading integrations remain stable while only eligible formats handle outbound responses. Updates response generation paths to use the new contract for acknowledgment, acceptance, and rejection, which removes redundant no-op implementations and clarifies responsibilities. Aligns message UI and APIs with the refactor, and reassigns related object IDs into reserved ranges to prevent conflicts.
|
Could not find linked issues in the pull request description. Please make sure the pull request description contains a line that contains 'Fixes #' followed by the issue number being fixed. Use that pattern for every issue you want to link. |
| Caption = 'Sales Order'; | ||
| Implementation = IProcessStructuredData = "Prepare Sales E-Doc. Draft"; | ||
| } | ||
| value(4; "E-Document Message") |
There was a problem hiding this comment.
Lets no do messages as drafts.
Drafts are for users to be able to edit before documents are created.
What is the ned for a message to be in a draft?
| /// When ReadIntoDraft returns "E-Document Message", the pipeline calls PrepareDraft which returns None | ||
| /// so that FinishDraft exits early without creating a BC document. | ||
| /// </summary> | ||
| codeunit 6435 "E-Doc. Message Draft Handler" implements IProcessStructuredData |
There was a problem hiding this comment.
This seems quite useless?
| begin | ||
| end; | ||
|
|
||
| [EventSubscriber(ObjectType::Codeunit, Codeunit::"Release Sales Document", 'OnAfterReleaseSalesDoc', '', false, false)] |
There was a problem hiding this comment.
Sales release subscriber queries DB unconditionally
OnAfterReleaseSalesDoc fires on every Sales Document release and immediately executes two SetRange calls plus a FindLast() against the E-Document table, even when the E-Document feature is not in use. This adds measurable overhead to every Sales Order release across all companies.
Recommendation:
- Add an early-exit guard using a feature flag or a cheap, indexed check (e.g., checking whether any E-Documents exist at all) before performing the full lookup.
| [EventSubscriber(ObjectType::Codeunit, Codeunit::"Release Sales Document", 'OnAfterReleaseSalesDoc', '', false, false)] | |
| if PreviewMode then | |
| exit; | |
| // Early exit if EDocument feature is not active | |
| if not EDocumentFeatureMgt.IsEnabled() then | |
| exit; | |
| SalesHeaderRef.GetTable(SalesHeader); | |
| EDocument.SetRange("Document Record ID", SalesHeaderRef.RecordId); |
👍 useful · ❤️ especially valuable · 👎 wrong - reply with why
| value(3; "Sales Order") | ||
| { | ||
| Caption = 'Sales Order'; | ||
| Implementation = IProcessStructuredData = "Prepare Sales E-Doc. Draft"; |
There was a problem hiding this comment.
Missing implementation codeunit breaks compile
The "Sales Order" enum value declares Implementation = IProcessStructuredData = "Prepare Sales E-Doc. Draft", but no codeunit with that name exists anywhere in the repository. This causes a compile error that prevents the EDocument app from building.
Recommendation:
- Add the missing
"Prepare Sales E-Doc. Draft"codeunit implementingIProcessStructuredData, or correct the implementation reference to an existing codeunit.
| Implementation = IProcessStructuredData = "Prepare Sales E-Doc. Draft"; | |
| codeunit XXXXX "Prepare Sales E-Doc. Draft" implements IProcessStructuredData | |
| { | |
| Access = Internal; | |
| InherentEntitlements = X; | |
| InherentPermissions = X; | |
| procedure PrepareDraft(EDocument: Record "E-Document"; EDocImportParameters: Record "E-Doc. Import Parameters"): Enum "E-Document Type" | |
| begin | |
| exit("E-Document Type"::"Sales Order"); | |
| end; | |
| // ... remaining interface methods | |
| } |
👍 useful · ❤️ especially valuable · 👎 wrong - reply with why
| case ResponseType of | ||
| "E-Doc. Response Type"::Acknowledged: | ||
| exit('AB'); | ||
| "E-Doc. Response Type"::Accepted: |
There was a problem hiding this comment.
ResponseTypeToCode returns empty string for None
ResponseTypeToCode has no else branch. When called with "E-Doc. Response Type"::None (the default used by the two-parameter CreateMessage overloads) or any future extension value, the function falls through the case statement and returns an empty Code[10]. This produces <OrderResponseCode></OrderResponseCode> in the PEPPOL XML, which is invalid and will be rejected by receiving parties.
Recommendation:
- Add an
elsebranch that either raises an error or maps to a sensible default code to prevent silent production of malformed XML.
| "E-Doc. Response Type"::Accepted: | |
| local procedure ResponseTypeToCode(ResponseType: Enum "E-Doc. Response Type"): Code[10] | |
| begin | |
| case ResponseType of | |
| "E-Doc. Response Type"::Acknowledged: | |
| exit('AB'); | |
| "E-Doc. Response Type"::Accepted: | |
| exit('AC'); | |
| "E-Doc. Response Type"::Rejected: | |
| exit('RE'); | |
| else | |
| Error('Unsupported response type: %1', ResponseType); | |
| end; | |
| end; |
👍 useful · ❤️ especially valuable · 👎 wrong - reply with why
RejectOrder action missing ApplicationAreaThe newly added Recommendation:
Line mapping was unavailable, so this was posted as an issue comment. 👍 useful · ❤️ especially valuable · 👎 wrong - reply with why |
SendOrderRejection missing state validation
Recommendation:
Line mapping was unavailable, so this was posted as an issue comment. 👍 useful · ❤️ especially valuable · 👎 wrong - reply with why |
| exit; | ||
| SalesHeaderRef.GetTable(SalesHeader); | ||
| EDocument.SetRange("Document Record ID", SalesHeaderRef.RecordId); | ||
| EDocument.SetRange(Direction, EDocument.Direction::Incoming); |
There was a problem hiding this comment.
FindLast() skips all but last linked EDocument
EDocument.FindLast() in OnAfterReleaseSalesDoc silently processes only the most recently created EDocument linked to the Sales Header. If multiple EDocuments are associated with the same Sales Header, all but the last one never receive an acceptance response.
Recommendation:
- Iterate over all matching EDocuments using a
repeat…untilloop to ensure every linked EDocument receives the acceptance response.
| EDocument.SetRange(Direction, EDocument.Direction::Incoming); | |
| if EDocument.FindSet() then | |
| repeat | |
| IResponseBuilder := EDocument."Read into Draft Impl."; | |
| if IResponseBuilder.SupportsOrderResponse(EDocument) then begin | |
| IResponseBuilder.BuildOrderResponse(EDocument, "E-Doc. Response Type"::Accepted, ResponseBlob); | |
| EDocMessageMgt.CreateMessage(EDocument, "E-Document Message Type"::"PEPPOL Order Response", "E-Document Direction"::Outgoing, "E-Doc. Response Type"::Accepted, ResponseBlob); | |
| end; | |
| until EDocument.Next() = 0; |
👍 useful · ❤️ especially valuable · 👎 wrong - reply with why
|
|
||
| OutboundEDocument.SetRange("Document No.", CopyStr(OrderRefId, 1, MaxStrLen(OutboundEDocument."Document No."))); | ||
| OutboundEDocument.SetRange(Direction, OutboundEDocument.Direction::Outgoing); | ||
| if OutboundEDocument.FindLast() then |
There was a problem hiding this comment.
FindLast() links response to arbitrary outbound doc
When locating the outbound E-Document to attach the Order Response to, FindLast() is used. If multiple outbound E-Documents share the same Document No. (e.g. after a re-send), the response is silently attached to the most recently created one, and the association may be wrong.
Recommendation:
- Use a more deterministic lookup, such as also filtering by
Serviceor a unique identifier embedded in the response, and raise an error or log a warning when multiple matches are found.
| if OutboundEDocument.FindLast() then | |
| OutboundEDocument.SetRange("Document No.", CopyStr(OrderRefId, 1, MaxStrLen(OutboundEDocument."Document No."))); | |
| OutboundEDocument.SetRange(Direction, OutboundEDocument.Direction::Outgoing); | |
| OutboundEDocument.SetRange(Service, EDocument.Service); | |
| if OutboundEDocument.FindLast() then |
👍 useful · ❤️ especially valuable · 👎 wrong - reply with why
| exit; | ||
| if not EDocDataStorage.Get(EDocMessage."Data Storage Entry No.") then | ||
| exit; | ||
| TempBlob := EDocDataStorage.GetTempBlob(); |
There was a problem hiding this comment.
DataStorage EntryNo 0 violates table relation
InsertDataStorage returns 0 when TempBlob.HasValue() is false, and the caller stores 0 in EDocMessage."Data Storage Entry No.". That field carries TableRelation = "E-Doc. Data Storage"."Entry No.", so storing 0 (which has no corresponding record) can cause referential-integrity validation errors at runtime.
Recommendation:
- Either refuse to create a message when the blob is empty (raise an error), or remove the
TableRelationfrom the field definition if empty-blob messages are a legitimate use-case and add a separateHasData()helper to distinguish them.
| TempBlob := EDocDataStorage.GetTempBlob(); | |
| local procedure InsertDataStorage(TempBlob: Codeunit "Temp Blob"): Integer | |
| var | |
| EDocDataStorage: Record "E-Doc. Data Storage"; | |
| EDocRecRef: RecordRef; | |
| begin | |
| if not TempBlob.HasValue() then | |
| Error('Cannot create a message with an empty payload blob.'); | |
| // ... rest of method |
👍 useful · ❤️ especially valuable · 👎 wrong - reply with why
| PeppolUtility.TryGetStringValue(PeppolXML, XmlNamespaces, '/resp:OrderResponse/cac:OrderReference/cbc:ID', OrderRefId); | ||
| ResponseType := CodeToResponseType(ResponseCode); | ||
|
|
||
| OutboundEDocument.SetRange("Document No.", CopyStr(OrderRefId, 1, MaxStrLen(OutboundEDocument."Document No."))); |
There was a problem hiding this comment.
Untrusted XML value controls document association
In HandleInboundOrderResponse, OrderRefId is extracted verbatim from the inbound XML payload and used to look up any outbound E-Document by Document No.. An attacker can craft a PEPPOL Order Response with a known Document No. to attach a malicious response message to an arbitrary outbound order.
Recommendation:
- Validate the matched outbound E-Document against additional fields (e.g., sender party ID, service code, or the
Entry No.embedded in the responseIDelement) before accepting the association. Consider also verifying that the response comes through the expected service connector.
| OutboundEDocument.SetRange("Document No.", CopyStr(OrderRefId, 1, MaxStrLen(OutboundEDocument."Document No."))); | |
| OutboundEDocument.SetRange("Document No.", CopyStr(OrderRefId, 1, MaxStrLen(OutboundEDocument."Document No."))); | |
| OutboundEDocument.SetRange(Direction, OutboundEDocument.Direction::Outgoing); | |
| OutboundEDocument.SetRange(Service, EDocument.Service); // constrain to same service | |
| if OutboundEDocument.FindLast() then |
👍 useful · ❤️ especially valuable · 👎 wrong - reply with why
Important: Before completing this PR, related Pull Request has to be completed first - [E-Documents Core] [Peppol] - Enabling EDI capabilities with E-Documents. Receive path (Sales Order ← E‑Document) #8558
Implementation:
Linked work
Fixes #