Skip to content

[E-Documents Core] [Peppol] - Enabling EDI capabilities with E-Documents. Send path (Purchase Order → E Document).#7796

Open
GMatuleviciute wants to merge 16 commits into
microsoft:mainfrom
GMatuleviciute:dev/aan/purchorder-outbound-edocument
Open

[E-Documents Core] [Peppol] - Enabling EDI capabilities with E-Documents. Send path (Purchase Order → E Document).#7796
GMatuleviciute wants to merge 16 commits into
microsoft:mainfrom
GMatuleviciute:dev/aan/purchorder-outbound-edocument

Conversation

@GMatuleviciute
Copy link
Copy Markdown
Contributor

This pull request does not have a related issue as it's part of the delivery for development agreed directly with @altotovi @Groenbech96

Implementation

Enables electronic document export for purchase orders in PEPPOL BIS 3.0 format, allowing buyers to send structured order documents to vendors.

Key additions:

  • Creates an E-Document when a purchase order is released.
  • Generates a UBL-compliant XML file for purchase orders, including buyer/seller party info, delivery address, payment terms, monetary totals, and order lines.
  • Extends PEPPOL 3.0 infrastructure with purchase-header-aware overloads for all relevant data retrieval methods.
  • Adds a "Canceled" status to E-Document Status enum to support order lifecycle management.
  • Prevents deletion of purchase orders linked to active (non-canceled) E-Documents to protect data integrity.
  • Supports re-export of the same purchase order when triggered again, updating the service status accordingly.
  • Adds test coverage for E-Document creation on release, deletion guard, and successful deletion after cancellation.

Fixes #

AndriusAndrulevicius and others added 6 commits April 15, 2026 11:44
Enables electronic document export for purchase orders in PEPPOL BIS 3.0 format, allowing buyers to send structured order documents to vendors.

Key additions:
- Creates an E-Document when a purchase order is released, mirroring the existing behavior for sales documents
- Generates a UBL-compliant XML file for purchase orders, including buyer/seller party info, delivery address, payment terms, monetary totals, and order lines
- Extends PEPPOL 3.0 infrastructure with purchase-header-aware overloads for all relevant data retrieval methods
- Adds a "Canceled" status to E-Document Status enum to support order lifecycle management
- Prevents deletion of purchase orders linked to active (non-canceled) E-Documents to protect data integrity
- Supports re-export of the same purchase order when triggered again, updating the service status accordingly
- Adds test coverage for E-Document creation on release, deletion guard, and successful deletion after cancellation
Improves readability by adding a blank line between two procedure definitions, aligning with standard formatting conventions.
…bleExt.al

Co-authored-by: Grasiele Matuleviciute <131970463+GMatuleviciute@users.noreply.github.com>
Co-authored-by: Grasiele Matuleviciute <131970463+GMatuleviciute@users.noreply.github.com>
Renames namespace label constants to use the `Tok` suffix per naming conventions.

Ensures e-document status is updated correctly when a service is created for a supported document type.

Fixes a redundant variable assignment in the purchase order processing and removes an incorrectly duplicated currency ID assignment.

Extracts a dedicated procedure for creating e-documents from unposted purchase orders to clarify intent and improve code readability.

Co-authored-by: Copilot <copilot@github.com>
@GMatuleviciute GMatuleviciute requested review from a team as code owners April 22, 2026 09:48
@github-actions github-actions Bot added AL: Apps (W1) Add-on apps for W1 From Fork Pull request is coming from a fork labels Apr 22, 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.

Copy link
Copy Markdown
Contributor

@Groenbech96 Groenbech96 left a comment

Choose a reason for hiding this comment

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

Thanks for the work here. I'd like to request we restructure the purchase path to preserve the existing PEPPOL extensibility contract before this lands.

The issue

PEPPOL30 implements 8 interfaces exposed through the "PEPPOL 3.0 Format" enum, and that's the documented extensibility surface — partners customize PEPPOL output by extending
the enum and plugging in their own implementations (see extensibility_examples.md). Sales and service consistently go through the interface, e.g. PEPPOL30Common.GetTotals does
PEPPOLTaxInfoProvider := PEPPOLFormat; PEPPOLTaxInfoProvider.GetTaxTotals(...).

This PR takes a different route for purchase:

  1. The new PurchaseHeader / PurchaseLine overloads are added to the concrete PEPPOL30 codeunit but not to any interface, so a partner's custom "PEPPOL Party Info Provider" can
    never be invoked for the purchase path.
  2. EDocPurchOrderExportToXML declares PEPPOL30: Codeunit PEPPOL30 and calls it directly, bypassing the format enum entirely.

A partner who customizes PEPPOL sales output today will find, when they try to do the same for purchase orders, that the extensibility contract no longer holds. Fixing that
later means either a breaking interface change or yet another parallel API.

What I'm asking for

Please restructure along the existing pattern:

  • Add purchase-specific interfaces — e.g. "PEPPOL Purchase Party Info Provider", "PEPPOL Purchase Line Info Provider", "PEPPOL Purchase Monetary Info Provider", etc. — covering
    the data the purchase-order XML needs.
  • Add a new value to "PEPPOL 3.0 Format" (e.g. "PEPPOL 3.0 - Purchase Order"), with PEPPOL30 as the default implementation for these interfaces.
  • Change EDocPurchOrderExportToXML to resolve the interface from the format enum rather than declaring Codeunit PEPPOL30 directly. This also means to move that to the PEPPOL App.

That keeps the purchase flow on the same extensibility rails as sales/service and lets partners override purchase-order PEPPOL output the same way they override sales today.

Edit:

Separate purchase format enum. Lets use "PEPPOL 3.0 Purchase Format" enum implementing only purchase-specific interfaces ("PEPPOL Purchase Party Info Provider", etc.),

I think its better to have parallel extensibility surfaces — one for outbound sales/service documents, one for outbound
purchase orders.

Another option:
Is to do like Service and map Purchase into a sales buffer, but that might be quite ugly.

Introduces a set of purchase-specific PEPPOL interfaces and a configurable format enum, enabling the purchase order XML export to be extended or overridden without modifying core logic.

Replaces direct codeunit calls with interface-based dispatch so that different implementations can be plugged in via setup, consistent with the existing pattern for sales and service documents.

Adds a "PEPPOL 3.0 Purchase Format" field to the setup table and page so administrators can select the desired implementation.
@AndriusAndrulevicius
Copy link
Copy Markdown
Contributor

Requested changes implemented.
Although I have some doubts regarding procedures added to PEPPOL30Common.Codeunit.al - I was following the same pattern as we have for the sales side, but I'm not sure if we need to use RecordRef for the purchase side. On one hand, this gives more flexibility for the future, on the other - because we're using an enum in the procedure signature, we wouldn't be able to use it for anything else. Maybe I should change the parameters to use the actual table rather than RecordRef? What would be your opinion @Groenbech96

@GMatuleviciute GMatuleviciute requested a review from a team as a code owner May 19, 2026 10:16
Comment thread src/Apps/W1/EDocument/App/src/Processing/EDocExport.Codeunit.al Outdated

EDocument.SetRange("Document Record ID", Rec.RecordId());
if EDocument.FindFirst() then
EDocument.TestField(Status, "E-Document Status"::Canceled);
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\ —\ Security} \quad \color{gray}{\texttt{\small Iteration\ 1}}$

OnDelete blocks PO deletion unless EDoc is Canceled

The new OnDelete trigger calls EDocument.TestField(Status, "E-Document Status"::Canceled) on any Purchase Order linked to an E-Document. If the linked E-Document is not in the Canceled status, the deletion will throw a hard error. Users or processes that delete purchase orders (e.g., via undo or cleanup) without first canceling the related E-Document will be blocked with a potentially confusing error.

Recommendation:

  • Consider showing a user-friendly confirmation or providing an automated cancel step rather than a hard TestField error, or at minimum add a descriptive error message that tells the user how to resolve the issue.
Suggested change
EDocument.TestField(Status, "E-Document Status"::Canceled);
if EDocument.Status <> "E-Document Status"::Canceled then
Error(CannotDeletePurchOrderWithActiveEDocErr);

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

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 is a good suggestion.


local procedure CreateEDocumentFromUnpostedPostedDocument(SourceDocumentHeader: Variant; DocumentSendingProfile: Record "Document Sending Profile"; DocumentType: Enum "E-Document Type")
begin
CreateEDocumentFromPostedDocument(SourceDocumentHeader, DocumentSendingProfile, Enum::"E-Document Type"::"Purchase Order");
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} \quad \color{gray}{\texttt{\small Iteration\ 1}}$

DocumentType parameter silently ignored in wrapper method

CreateEDocumentFromUnpostedPostedDocument receives a DocumentType parameter but ignores it, hardcoding Enum::"E-Document Type"::"Purchase Order" instead. This makes the parameter misleading and will cause silent incorrect behavior if the method is ever called with a different document type.

Recommendation:

  • Remove the DocumentType parameter and hardcode the value directly at the call site, or forward the parameter correctly: CreateEDocumentFromPostedDocument(SourceDocumentHeader, DocumentSendingProfile, DocumentType).
Suggested change
CreateEDocumentFromPostedDocument(SourceDocumentHeader, DocumentSendingProfile, Enum::"E-Document Type"::"Purchase Order");
local procedure CreateEDocumentFromUnpostedPostedDocument(SourceDocumentHeader: Variant; DocumentSendingProfile: Record "Document Sending Profile"; DocumentType: Enum "E-Document Type")
begin
CreateEDocumentFromPostedDocument(SourceDocumentHeader, DocumentSendingProfile, DocumentType);
end;

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

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.

We dont need need this function. We can call it directly.

AndriusAndrulevicius and others added 2 commits May 19, 2026 16:54
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Sorts using directives alphabetically, removes unused variables, fixes indentation, corrects a casing typo in a variable reference, and suppresses a false-positive warning on a public procedure signature.
Comment thread src/Apps/W1/EDocument/App/src/Processing/EDocExport.Codeunit.al
@Groenbech96
Copy link
Copy Markdown
Contributor

Groenbech96 commented May 20, 2026

Design feedback: EDocExport re-export path

The PR introduces ReExportEDocumentFromUnpostedDocument to handle re-release of Purchase Orders. The intent is correct — Purchase Orders in EDI are living documents that need to be exportable on every release — but the implementation mixes responsibilities in EDocExport in a way that causes real bugs and will make future EDI document types harder to add.

The problem

CreateEDocument now branches based on whether a record already exists: it either creates a new one, or calls ReExportEDocumentFromUnpostedDocument to re-export. That re-export path:

  • resets service status but never calls ExportEDocument, so the document is never actually sent
  • bypasses OnBeforeCreateEDocument/OnAfterCreateEDocument integration events
  • calls ModifyServiceStatus without checking whether the service status row exists (runtime crash if a service was added after the original E-Document was created)
  • is the only path that handles this case, meaning future EDI document types would each need their own branch here

The root issue is that CreateEDocument has accumulated too many responsibilities. The AllowReExport logic does not belong there.

Suggested design: three building blocks

1. CreateEDocumentRecord (local) — pure record creation, no export

Fires OnBeforeCreateEDocument/OnAfterCreateEDocument, inserts, populates, logs. Nothing else.

2. ExportEDocument — unchanged

Already correct: generates blob, sets service status to Exported/Error. Transparent to whether this is a first or re-export — it just overwrites the blob.

3. CreateAndExportEDocument — the orchestrator

Single public entry point. One job: find-or-create, then export.

internal procedure CreateAndExportEDocument(
    var DocumentHeader: RecordRef;
    var EDocumentService: Record "E-Document Service";
    WorkflowCode: Code[20];
    DocumentSendingProfileCode: Code[20];
    EDocumentType: Enum "E-Document Type";
    AllowReExport: Boolean)
var
    EDocument: Record "E-Document";
begin
    // filter down to services that support this document type
    ...
    if SupportedServices.Count() = 0 then exit;

    EDocument.SetRange("Document Record ID", DocumentHeader.RecordId);
    if not EDocument.FindFirst() then
        CreateEDocumentRecord(EDocument, DocumentHeader, EDocumentType, WorkflowCode, DocumentSendingProfileCode)
    else begin
        if not AllowReExport then exit;
        PopulateEDocument(EDocument, DocumentHeader);  // refresh header fields from modified PO
        EDocument.Modify();
    end;

    foreach ServiceCode in SupportedServices do begin
        EDocumentService.Get(ServiceCode);
        if EDocumentService."Use Batch Processing" then continue;
        EnsureServiceStatusRow(EDocument, EDocumentService);  // safe insert-or-modify
        ExportEDocument(EDocument, EDocumentService);          // same path every time
    end;

    EDocumentBackgroundJobs.StartEDocumentCreatedFlow(EDocument);
end;

EnsureServiceStatusRow replaces the fragile ModifyServiceStatus-only pattern — it checks whether the row exists before deciding to insert or modify, consistent with how ExportEDocument already handles this.

The subscriber decides AllowReExport — the building block just executes

Workflow and service resolution stays with the caller. The subscriber knows its context:

// Posted documents (sales invoice, credit memo, etc.) — one export only
EDocExport.CreateAndExportEDocument(Header, EDocumentService, WorkFlow.Code, Profile.Code, DocType, false);

// Purchase Order release — re-export on every release
EDocExport.CreateAndExportEDocument(Header, EDocumentService, WorkFlow.Code, Profile.Code, Enum::"E-Document Type"::"Purchase Order", true);

// Future EDI document types (order amendment, despatch advice, etc.)
EDocExport.CreateAndExportEDocument(Header, EDocumentService, WorkFlow.Code, Profile.Code, DocType, true);

CreateEDocument (the existing public overload that receives var EDocumentService) can stay for batch and other callers that already have services resolved.

Flow: first release vs. re-release

First release:
  OnAfterReleasePurchaseDoc → resolve workflow/services → CreateAndExportEDocument(..., true)
    → FindFirst() → not found → CreateEDocumentRecord   [fires integration events]
    → EnsureServiceStatusRow → InsertServiceStatus
    → ExportEDocument → blob written → Exported

Re-release (same PO, possibly modified):
  OnAfterReleasePurchaseDoc → resolve workflow/services → CreateAndExportEDocument(..., true)
    → FindFirst() → found → PopulateEDocument + Modify  [no duplicate, no event re-fire]
    → EnsureServiceStatusRow → ModifyServiceStatus      [safe]
    → ExportEDocument → blob overwritten → Exported

The export path is identical both times. ExportEDocument has no knowledge of first-vs-re-export.

Copy link
Copy Markdown
Contributor

@Groenbech96 Groenbech96 left a comment

Choose a reason for hiding this comment

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

Also left a design comment.

begin
Rec."PEPPOL 3.0 Sales Format" := Rec."PEPPOL 3.0 Sales Format"::"PEPPOL 3.0 - Sales";
Rec."PEPPOL 3.0 Service Format" := Rec."PEPPOL 3.0 Service Format"::"PEPPOL 3.0 - Service";
Rec."PEPPOL 3.0 Purchase Format" := Rec."PEPPOL 3.0 Purchase Format"::"PEPPOL 3.0 - Purchase Order";
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 just call it "Purchase", so it follows the sales and services.


local procedure CreateEDocumentFromUnpostedPostedDocument(SourceDocumentHeader: Variant; DocumentSendingProfile: Record "Document Sending Profile"; DocumentType: Enum "E-Document Type")
begin
CreateEDocumentFromPostedDocument(SourceDocumentHeader, DocumentSendingProfile, Enum::"E-Document Type"::"Purchase Order");
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.

We dont need need this function. We can call it directly.


EDocument.SetRange("Document Record ID", Rec.RecordId());
if EDocument.FindFirst() then
EDocument.TestField(Status, "E-Document Status"::Canceled);
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 is a good suggestion.

using System.Xml;
using Microsoft.Finance.VAT.Setup;

codeunit 6405 "E-Doc. Purchase Order To XML"
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 move this codeunit to PEPPOL.
Create a folder called Export, and then put this:

"Purchase Order Export".
Then call it from E-Document core.

Improves the E-Document export flow to support re-exporting existing documents for unposted purchase orders, replacing the fragmented re-export logic with a cleaner orchestration method.

Replaces the generic TestField error when deleting a purchase order linked to an active e-document with a clearer, user-friendly error message.

Moves the purchase order PEPPOL XML export codeunit into the PEPPOL app and renames it for consistency. Renames the purchase format enum to better reflect its broader purpose beyond just purchase orders.
Copy link
Copy Markdown
Contributor

@AndriusAndrulevicius AndriusAndrulevicius left a comment

Choose a reason for hiding this comment

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

The comments have been resolved @Groenbech96

@Groenbech96
Copy link
Copy Markdown
Contributor

@AndriusAndrulevicius thanks for the change.

We are missing a set of tests for the E2E scenario.
Since we adding PO, which can be edited and reexported, we are going to need some tests for that scenario.

Essentially: Create PO -> Verify E-Document (and system state), Edit PO -> Verify E-Document (System State).
Also verify the actual output is changed (Field value change)


// For each service supporting the document type, export it before creating E-Document Created Flow
EDocumentServiceStatus.SetRange("E-Document Entry No", EDocument."Entry No");
EDocumentServiceStatus.SetRange(Status, EDocumentServiceStatus.Status::Created);
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\ —\ Upgrade} \quad \color{gray}{\texttt{\small Iteration\ 1}}$

CreateAndExportEDocument promoted to public

CreateAndExportEDocument was changed from an internal procedure to a public procedure. The signature takes var DocumentHeader: RecordRef and var EDocumentService: Record "E-Document Service" by reference. External callers can now pass arbitrary record references, potentially bypassing service-level validation and creating e-documents for unsupported document types.

Recommendation:

  • Consider keeping the method internal or adding input validation at the start of the method to guard against unsupported document headers and service configurations.
Suggested change
EDocumentServiceStatus.SetRange(Status, EDocumentServiceStatus.Status::Created);
internal procedure CreateAndExportEDocument(var DocumentHeader: RecordRef; var EDocumentService: Record "E-Document Service"; WorkflowCode: Code[20]; DocumentSendingProfileCode: Code[20]; EDocumentType: Enum "E-Document Type"; AllowReExport: Boolean): Boolean

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

@github-actions
Copy link
Copy Markdown
Contributor

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

Release event creates e-doc without validation

A new subscriber to OnAfterReleasePurchaseDoc calls CreateEDocumentFromPostedDocument with AllowReExport: true for purchase orders. If a purchase order is released multiple times (e.g., reopen/re-release cycle), this will attempt to re-export the e-document each time, potentially creating duplicate or overwritten outbound documents.

Recommendation:

  • Guard against re-export by checking whether the document already has a non-cancelled e-document before triggering a new export, or use the existing e-document status to decide.
// Before calling CreateEDocumentFromPostedDocument:
EDocument.SetRange("Document Record ID", SourceDocumentHeader.RecordId());
EDocument.SetFilter(Status, '<>%1', EDocument.Status::Canceled);
if not EDocument.IsEmpty() then
    exit; // Already has an active e-document

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

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

Covers three key scenarios: initial release creates an E-Document, re-releasing updates the existing record rather than creating a new one, and edited order data is reflected in the exported PEPPOL BIS 3.0 XML.

Also renames the purchase order export test codeunit file for consistency.
@AndriusAndrulevicius
Copy link
Copy Markdown
Contributor

@AndriusAndrulevicius thanks for the change.

We are missing a set of tests for the E2E scenario. Since we adding PO, which can be edited and reexported, we are going to need some tests for that scenario.

Essentially: Create PO -> Verify E-Document (and system state), Edit PO -> Verify E-Document (System State). Also verify the actual output is changed (Field value change)

@Groenbech96 missing tests added

Aligns variable declaration order with coding conventions.
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