Skip to content

[E-Documents Core] [Peppol] - Enabling EDI capabilities with E-Documents. PEPPOL Order Response Message Handling#8698

Open
jzaksauskas wants to merge 2 commits into
microsoft:mainfrom
jzaksauskas:dev/aan/peppol-messages-responses
Open

[E-Documents Core] [Peppol] - Enabling EDI capabilities with E-Documents. PEPPOL Order Response Message Handling#8698
jzaksauskas wants to merge 2 commits into
microsoft:mainfrom
jzaksauskas:dev/aan/peppol-messages-responses

Conversation

@jzaksauskas

@jzaksauskas jzaksauskas commented Jun 19, 2026

Copy link
Copy Markdown
Contributor

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:

  • 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.

Linked work

Fixes #

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.
@jzaksauskas jzaksauskas requested review from a team June 19, 2026 15:31
@jzaksauskas jzaksauskas requested review from a team as code owners June 19, 2026 15:31
@github-actions github-actions Bot added AL: Apps (W1) Add-on apps for W1 From Fork Pull request is coming from a fork labels Jun 19, 2026
@github-actions

Copy link
Copy Markdown
Contributor

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.

@jzaksauskas jzaksauskas changed the title [E-Documents Core] [Peppol] - Enabling EDI capabilities with E-Documents. PEPPOL order response message handling [E-Documents Core] [Peppol] - Enabling EDI capabilities with E-Documents. PEPPOL Order Response Message Handling Jun 19, 2026
@github-actions github-actions Bot added the needs-approval Workflow runs require maintainer approval to start label Jun 19, 2026
Caption = 'Sales Order';
Implementation = IProcessStructuredData = "Prepare Sales E-Doc. Draft";
}
value(4; "E-Document Message")

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.

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

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.

This seems quite useless?

begin
end;

[EventSubscriber(ObjectType::Codeunit, Codeunit::"Release Sales Document", 'OnAfterReleaseSalesDoc', '', false, false)]

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\ —\ Performance} \quad \color{gray}{\texttt{\small Iteration\ 1}}$

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.
Suggested change
[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";

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\ —\ Performance} \quad \color{gray}{\texttt{\small Iteration\ 1}}$

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 implementing IProcessStructuredData, or correct the implementation reference to an existing codeunit.
Suggested change
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:

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\ —\ Performance} \quad \color{gray}{\texttt{\small Iteration\ 1}}$

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 else branch that either raises an error or maps to a sensible default code to prevent silent production of malformed XML.
Suggested change
"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

@github-actions

Copy link
Copy Markdown
Contributor

$\textbf{🟡\ Medium\ Severity\ —\ Performance} \quad \color{gray}{\texttt{\small Iteration\ 1}}$

RejectOrder action missing ApplicationArea

The newly added RejectOrder action does not specify an ApplicationArea property. In Business Central, omitting ApplicationArea on an action means it will either be invisible or will generate a compiler warning depending on the app's AppSourceCop configuration.

Recommendation:

  • Add ApplicationArea = Basic, Suite; to the RejectOrder action, consistent with the surrounding actions on the same page.
action(RejectOrder)
{
    Caption = 'Reject Order';
    ToolTip = 'Sends a rejection response to the sender of this inbound order.';
    ApplicationArea = Basic, Suite;
    Image = Reject;
    Visible = IsIncomingDoc;

Line mapping was unavailable, so this was posted as an issue comment.

👍 useful · ❤️ especially valuable · 👎 wrong - reply with why

@github-actions

Copy link
Copy Markdown
Contributor

$\textbf{🟡\ Medium\ Severity\ —\ Performance} \quad \color{gray}{\texttt{\small Iteration\ 1}}$

SendOrderRejection missing state validation

SendOrderRejection only validates Direction = Incoming before building and persisting a rejection message. It does not check whether a rejection has already been sent (i.e., whether an outgoing Rejected message already exists), so the same document can accumulate duplicate rejection messages with no guard.

Recommendation:

  • Before creating the rejection message, check whether an outgoing rejection message already exists for this E-Document and exit early (or raise an informational error) if one is found.
procedure SendOrderRejection(EDocument: Record "E-Document")
var
    EDocMessage: Record "E-Document Message";
    // ...
begin
    EDocument.TestField(Direction, EDocument.Direction::Incoming);
    // Guard against duplicate rejections
    EDocMessage.SetRange("E-Document Entry No.", EDocument."Entry No");
    EDocMessage.SetRange("Response Type", "E-Doc. Response Type"::Rejected);
    EDocMessage.SetRange(Direction, "E-Document Direction"::Outgoing);
    if not EDocMessage.IsEmpty() then
        Error('A rejection has already been sent for this document.');
    // ... rest of method

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

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} \quad \color{gray}{\texttt{\small Iteration\ 1}}$

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…until loop to ensure every linked EDocument receives the acceptance response.
Suggested change
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

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} \quad \color{gray}{\texttt{\small Iteration\ 1}}$

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 Service or a unique identifier embedded in the response, and raise an error or log a warning when multiple matches are found.
Suggested change
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();

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} \quad \color{gray}{\texttt{\small Iteration\ 1}}$

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 TableRelation from the field definition if empty-blob messages are a legitimate use-case and add a separate HasData() helper to distinguish them.
Suggested change
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.")));

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} \quad \color{gray}{\texttt{\small Iteration\ 1}}$

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 response ID element) before accepting the association. Consider also verifying that the response comes through the expected service connector.
Suggested change
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

@JesperSchulz JesperSchulz added the Integration GitHub request for Integration area label Jun 22, 2026
@github-actions github-actions Bot removed the needs-approval Workflow runs require maintainer approval to start label Jun 22, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

AL: Apps (W1) Add-on apps for W1 From Fork Pull request is coming from a fork Integration GitHub request for Integration area

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants