Skip to content

[FR E-Reporting] Implement invoice payment lifecycle tracking - #9659

Open
Milica Đukić (djukicmilica) wants to merge 82 commits into
mainfrom
bugs/master_442216_Payments
Open

[FR E-Reporting] Implement invoice payment lifecycle tracking#9659
Milica Đukić (djukicmilica) wants to merge 82 commits into
mainfrom
bugs/master_442216_Payments

Conversation

@djukicmilica

@djukicmilica Milica Đukić (djukicmilica) commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Why

French e-reporting regulation requires that when a payment is applied to an e-invoice, the Collected lifecycle status must be captured and reported to the Portail Public de Facturation (PPF). Currently, the EReporting FR app has no mechanism to track payment applications against e-invoices or to build the required XML lifecycle messages. This gap blocks compliance with the French e-invoicing mandate.

Summary

  • Added FR E-Invoice Lifecycle table to capture immutable lifecycle occurrences (payment applications, reversals) linked to e-documents and customer ledger entries
  • Added FR E-Invoice Lifecycle VAT table to store per-occurrence VAT breakdown lines with amounts and currency
  • Added FR E-Invoice Lifecycle Mgt. codeunit that subscribes to OnAfterInsertDtldCustLedgEntry and OnAfterInsertDtldCustLedgEntryUnapply to automatically capture payment lifecycle events
  • Added FR E-Invoice Lifecycle Msg. codeunit implementing IEDocMessageBuilder to produce CrossDomainAcknowledgementAndResponse XML messages per the French regulatory profile
  • Added FR E-Invoice Lifecycle Error and FR E-Invoice Lifecycle Worker codeunits for asynchronous processing with error handling
  • Added enums for lifecycle status (Deposited, Collected, PaymentCollected, etc.) and processing status (Queued, Sending, Sent, Failed)
  • Added page extensions on E-Document Service and E-Documents list to surface lifecycle configuration and navigation
  • Added FR E-Invoice Lifecycles list page for viewing captured occurrences
  • Added permission sets (EReportingFRObjects, EReportingFREdit, EReportingFRRead) and D365/Local permission set extensions
  • Added comprehensive tests covering payment application capture, reversal handling, VAT breakdown, and message building

Fixes
AB#637593

@djukicmilica
Milica Đukić (djukicmilica) requested a review from a team July 22, 2026 10:43
@github-actions github-actions Bot added the Integration GitHub request for Integration area label Jul 22, 2026
Comment thread src/Apps/FR/EDocument_FR/EReportingFR/test/src/IdentificationTests.Codeunit.al Outdated
@djukicmilica Milica Đukić (djukicmilica) added Finance GitHub request for Finance area and removed Integration GitHub request for Integration area labels Jul 22, 2026
@github-actions github-actions Bot added Integration GitHub request for Integration area and removed Finance GitHub request for Finance area labels Jul 22, 2026
@github-actions

Copy link
Copy Markdown
Contributor

$\textbf{🟠\ High\ Severity\ —\ AppSource}$

The pageextension declares a namespace but still adds unaffixed members to the base "E-Documents" page: field("Clearance Date"; ...) and action(ViewFREInvoiceLifecycles). A namespace only replaces the owned-object affix; members added to another publisher's page still need the registered app prefix or suffix, otherwise AppSourceCop AS0011 can reject the extension.

Knowledge:

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

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

@github-actions

Copy link
Copy Markdown
Contributor

$\textbf{🟠\ High\ Severity\ —\ Telemetry}$

This Session.LogMessage emits an internal ErrorCallStack diagnostic with TelemetryScope::All, which exposes publisher-only implementation detail to environment telemetry. Per telemetry-scope guidance, this kind of low-level failure diagnostic should stay publisher-only; use ExtensionPublisher here (or split the tenant-facing failure signal from the publisher-only stack trace).

Suggested fix (apply manually — could not be anchored as a one-click suggestion):

        Session.LogMessage(
            '0000TDQ', LifecycleWorkerFailedTelemetryMsg, Verbosity::Error,
            DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher,
            'Category', LifecycleTelemetryCategoryTok, 'ErrorCallStack', GetLastErrorCallStack());

Knowledge:

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

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

@djukicmilica

Copy link
Copy Markdown
Contributor Author

Follow-up on the general review findings:

  • The Clearance Date tooltip is restored, and the new lifecycle action uses the FR-affixed control name. The pre-existing Clearance Date control name is retained to avoid changing an existing control identity.
  • Exact negative-test messages are intentional contract assertions; moving identical strings to labels would not reduce brittleness.
  • A PDF without embedded Factur-X data now raises the dedicated NoEmbeddedInvoiceErr, and malformed French Peppol post-processing raises an internal contextual ErrorInfo.
  • Lifecycle worker call-stack telemetry now uses TelemetryScope::ExtensionPublisher so implementation details remain publisher-only.
  • A real PDF/A-3 happy-path test remains unavailable because the repository contains no approved embedded-invoice PDF fixture or generator; a synthetic non-PDF blob would not exercise the production attachment path.


enum 10971 "FR Regulatory Comment Type"
{
Extensible = true;

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

FR Regulatory Comment Type is modeled as Extensible = true, but the export code serializes the enum member name directly into the regulatory note code. That allows extensions to persist arbitrary values outside the standardized code list and still emit them in outbound XML. Make this enum non-extensible, or add validation that rejects nonstandard extension values before they can be stored/exported.

Agent judgement — not directly backed by a BCQuality knowledge article.

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

begin
OriginalLifecycleVAT.SetRange("Lifecycle Entry No.", OriginalOccurrenceEntryNo);
if not OriginalLifecycleVAT.FindSet() then
Error(OriginalVATBreakdownErr, OriginalOccurrenceEntryNo);

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

This is an internal lifecycle-integrity failure, but it is raised as a plain client-visible error with a raw occurrence entry number. End users cannot correct a missing VAT breakdown on an already-captured lifecycle occurrence, so raise it as an ErrorInfo with ErrorType::Internal instead of exposing the technical detail directly.

Suggested fix (apply manually — could not be anchored as a one-click suggestion):

            RaiseInternalError(StrSubstNo(OriginalVATBreakdownErr, OriginalOccurrenceEntryNo));

Knowledge:

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

exit(TempBlob);

TempBlob.CreateInStream(PdfInStream);
if not TryGetEmbeddedAttachment(PdfInStream) 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\ —\ Error\ Handling}$

ExtractCIIXml() treats every TryGetEmbeddedAttachment() failure as "The PDF file does not contain an embedded Factur-X invoice." Because TryGetEmbeddedAttachment() is a [TryFunction], malformed or unreadable PDFs are also collapsed into that same message, which hides the real parsing failure. Distinguish "no embedded invoice" from unexpected PDF parsing errors so corrupt PDFs are surfaced with a different error path.

Agent judgement — not directly backed by a BCQuality knowledge article.

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

@github-actions

Copy link
Copy Markdown
Contributor

$\textbf{🟡\ Medium\ Severity\ —\ Style}$

InsertAllowanceElement and InsertLineAllowanceElement declare AllowanceReasonLbl and LineDiscountLbl inside procedure-local var blocks. Per the referenced guidance, Labels should live in the codeunit's top-level var block so XLIFF extraction and translation keys remain stable across builds and review tooling.

Knowledge:

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

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

if EventDate = 0D then
RaiseInternalError(EventDateErr);

case LifecycleStatus of

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

The new lifecycle suite never exercises the unsupported-status branch in CapturePaymentOccurrence, so the internal-error contract for a non-payment lifecycle status can regress unnoticed. Add a negative test that calls the new lifecycle management code with a status outside Collected/Negative Collected and asserts the expected internal error.

Agent judgement — not directly backed by a BCQuality knowledge article.

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

Comment on lines +172 to +176
VATEntry.SetLoadFields("VAT Bus. Posting Group", "VAT Prod. Posting Group", "Source Currency Code", "Source Currency VAT Base", "Source Currency VAT Amount", Base, Amount);
if VATEntry.FindSet() then
repeat
VATPostingSetup.Get(VATEntry."VAT Bus. Posting Group", VATEntry."VAT Prod. Posting Group");
VATRate := VATPostingSetup."VAT %";

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

CreateVATBreakdown reads only VAT Posting Setup."VAT %" inside the VAT Entry loop, but VATPostingSetup.Get(...) materializes the full setup row for every VAT entry because no SetLoadFields is applied to that record. Add VATPostingSetup.SetLoadFields("VAT %") before the loop so the hot path transfers only the field it uses.

Suggested change
VATEntry.SetLoadFields("VAT Bus. Posting Group", "VAT Prod. Posting Group", "Source Currency Code", "Source Currency VAT Base", "Source Currency VAT Amount", Base, Amount);
if VATEntry.FindSet() then
repeat
VATPostingSetup.Get(VATEntry."VAT Bus. Posting Group", VATEntry."VAT Prod. Posting Group");
VATRate := VATPostingSetup."VAT %";
VATEntry.SetLoadFields("VAT Bus. Posting Group", "VAT Prod. Posting Group", "Source Currency Code", "Source Currency VAT Base", "Source Currency VAT Amount", Base, Amount);
VATPostingSetup.SetLoadFields("VAT %");
if VATEntry.FindSet() then
repeat
VATPostingSetup.Get(VATEntry."VAT Bus. Posting Group", VATEntry."VAT Prod. Posting Group");
VATRate := VATPostingSetup."VAT %";

Knowledge:

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

ShipmentPostingDate: Date;
begin
LineXPath := StrSubstNo(InvoiceLineXPathTok, Format(SalesInvoiceLine."Line No.", 0, 9));
if not XmlDoc.SelectSingleNode(LineXPath, NamespaceMgr, InvoiceLineNode) 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}$

InjectExtendedCTCFranceElements iterates Sales Invoice Line records and, for each row, InjectExtendedLineReferences re-searches the full XML document with XmlDoc.SelectSingleNode(LineXPath, ...). That makes line-reference injection perform one root XPath traversal per invoice line. Select the invoice line nodes once and index them by cbc:ID before the loop, then reuse those nodes when adding order and shipment references.

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

FREInvoiceLifecycle."Processing Status" := FREInvoiceLifecycle."Processing Status"::"Message Created";
Clear(FREInvoiceLifecycle."Last Error");
FREInvoiceLifecycle.Modify();
Session.LogMessage(

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

The new lifecycle status traces in CreateLifecycleMessage and ScheduleMessageCreation log publisher-only background pipeline state to TelemetryScope::All. The created and queued events are internal implementation diagnostics rather than tenant-actionable failures, so emitting them to environment telemetry adds noise; use TelemetryScope::ExtensionPublisher for these status-transition events and keep TelemetryScope::All for actionable failures such as the worker error path.

Knowledge:

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

InherentEntitlements = X;
InherentPermissions = X;

procedure BuildMessage(EDocument: Record "E-Document"; ResponseType: Enum "E-Doc. Response Type"; var TempBlob: Codeunit "Temp Blob")

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

The added lifecycle tests never invoke "FR E-Invoice Lifecycle Msg." through its IEDocMessageBuilder.BuildMessage entrypoint; they all go through CreateLifecycleMessage, so the queued-occurrence lookup and the "No unprocessed French invoice lifecycle occurrence exists..." failure path in the interface method remain untested. Add a direct negative test for BuildMessage with no queued lifecycle record so regressions in the framework entrypoint cannot slip through.

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

@github-actions

Copy link
Copy Markdown
Contributor

$\textbf{🟡\ Medium\ Severity\ —\ Testing}$

The new negative import tests verify asserterror, but they pin Assert.ExpectedError to inline message text (for example at the unsupported-root-element case) instead of a shared label or assert helper. That makes the tests brittle to harmless wording/localization changes rather than to the actual validation behavior.

Knowledge:

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

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

DataClassification = CustomerContent;
ToolTip = 'Specifies the name of the approved platform that sent the invoice.';
}
field(21; "Invoice Issuer ID"; Text[50])

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

The table stores "Invoice Issuer ID" as free-form Text[50] even though the lifecycle code always emits it under scheme 0002 (SIREN). In France, "Registration No." is a Text[20] field, so formatted values such as spaces or punctuation can be frozen into lifecycle rows and later sent as an invalid 0002 identifier. Constrain this field to a normalized SIREN shape (for example Code[9]) and populate it from normalized company data before inserting the lifecycle row.

Agent judgement — not directly backed by a BCQuality knowledge article.

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

begin
Rec.TestField("Processing Status", Rec."Processing Status"::Queued);
Rec.TestField("E-Document Message Entry No.", 0);
Session.LogMessage(

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

The new French invoice lifecycle telemetry ships with placeholder-style Session.LogMessage event IDs (0000TDP, 0000TDQ, 0000TDR, 0000TDS, 0000TDT) across FREInvoiceLifecycleMsg, FREInvoiceLifecycleError, and FREInvoiceLifecycleMgt. Placeholder IDs are not stable catalogue IDs, so these events will be hard to query and can collide with other placeholder-based telemetry. Replace each with a registered, permanent event ID before merge.

Knowledge:

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


// Use SIRET as endpoint with scheme 0009
if CompanyInformation."SIRET No." = '' then
if not GetServiceParticipantAddress(EDocumentServiceCode, Enum::"E-Document Source Type"::Company, '', ElecAddress, ElecAddressScheme) 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\ —\ Testing}$

The PEPPOL FR export now falls back to the company VAT registration number with scheme 9957 when both SIRET and Registration No. are blank, but the added tests only cover the SIRET, SIREN, and buyer-VAT fallback paths. Add a seller-path regression test that clears both company identifiers and verifies /Invoice/.../EndpointID uses the VAT number with scheme 9957, otherwise this new branch can regress unnoticed.

Agent judgement — not directly backed by a BCQuality knowledge article.

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

/// Spec reference: https://docs.peppol.eu/poacc/billing/3.0/syntax/ubl-invoice/tree/
/// </summary>
codeunit 10985 "E-Doc. Peppol BIS 3.0 FR Hdlr" implements IStructuredFormatReader
codeunit 10980 "E-Doc. Peppol BIS 3.0 FR Hdlr" implements IStructuredFormatReader

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

Codeunit "E-Doc. Peppol BIS 3.0 FR Hdlr" is renumbered from 10985 to 10980 in this PR (the vacated ID 10985 is immediately reused for the new "FR E-Invoice Lifecycle Error" codeunit) with no obsoletion window. Both codeunits are Access = Internal, so external partner extensions cannot reference either by symbol and cannot fail to compile from this change, which narrows the practical blast radius versus a public-object rename. However, any persisted data, telemetry, or tooling keyed on the raw object ID 10985 for the Peppol handler will now resolve to a different, unrelated object. Prefer picking an unused ID for the new object instead of reusing a just-vacated one, or add an explicit release note calling out the internal ID reuse.

Knowledge:

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

{
fields
{
field(10970; "FR Identifier Scheme"; Enum "Electronic Address Scheme")

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

The new Service Participant field uses enum "Electronic Address Scheme" with InitValue = ' ', but the enum's zero value is EM, not a blank sentinel. On schema sync, existing Service Participant rows will therefore deserialize the new field as EM instead of blank, because InitValue does not migrate stored rows. That can silently mark legacy participants as having an email scheme and trip the French completeness check or export the wrong identifier scheme. Use a true zero-valued blank/unknown member, or add an upgrade migration that normalizes pre-existing records explicitly.

Agent judgement — not directly backed by a BCQuality knowledge article.

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

(FREInvoiceLifecycle."Detailed Ledger Entry No." <> DetailedLedgerEntryNo) or
(FREInvoiceLifecycle."Original Occurrence Entry No." <> OriginalOccurrenceEntryNo)
then
Error(ConflictingReplayErr);

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

VerifyReplay raises a plain client-visible Error when the same source occurrence is replayed with conflicting regulatory values. That is an internal caller-contract violation the end user cannot act on, so it should be raised as an Internal ErrorInfo instead of exposing the raw invariant message.

Suggested fix (apply manually — could not be anchored as a one-click suggestion):

            RaiseInternalError(ConflictingReplayErr);

Knowledge:

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

{
addlast(Processing)
{
action(ViewFREInvoiceLifecycles)

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

The new action(ViewFREInvoiceLifecycles) added to "E-Documents" does not use a clear reserved prefix or suffix; embedding FR inside the identifier is not the same as applying the app's affix to a member added to another app's page. AppSource guidance requires extension actions to carry the registered affix so they cannot collide with actions from other extensions.

Knowledge:

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

@@ -1,3 +1,11 @@
namespace Microsoft.eServices.EDocument.Formats.Test;

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

This existing test-app codeunit now declares namespace Microsoft.eServices.EDocument.Formats.Test, changing the published identity of codeunit 148149 "E-Doc. FR Struct. Import Tests" from an unnamespaced symbol to a namespaced one. Any dependent extension that referenced the old identity will stop compiling; keep the shipped namespace unchanged instead of renaming it in place.

Knowledge:

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

Comment on lines +45 to +50
field(5; "Currency Code"; Code[10])
{
Caption = 'Currency Code';
DataClassification = CustomerContent;
ToolTip = 'Specifies the currency code for the reported amount.';
}

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

Field "Currency Code" stores the currency for the VAT breakdown amount but does not declare a TableRelation to the Currency master, so the table can hold invalid currency codes that do not exist in Business Central. Add TableRelation = Currency to keep the amount's currency reference explicit and valid.

Suggested change
field(5; "Currency Code"; Code[10])
{
Caption = 'Currency Code';
DataClassification = CustomerContent;
ToolTip = 'Specifies the currency code for the reported amount.';
}
field(5; "Currency Code"; Code[10])
{
Caption = 'Currency Code';
DataClassification = CustomerContent;
TableRelation = Currency;
ToolTip = 'Specifies the currency code for the reported amount.';
}

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

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 Integration GitHub request for Integration area

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants