Skip to content

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

Open
djukicmilica wants to merge 9 commits into
mainfrom
bugs/master_442216_Payments
Open

[FR E-Reporting] Implement invoice payment lifecycle tracking (#442216)#9659
djukicmilica wants to merge 9 commits into
mainfrom
bugs/master_442216_Payments

Conversation

@djukicmilica

@djukicmilica 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

@djukicmilica
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
@github-actions

Copy link
Copy Markdown
Contributor

Could not find a linked ADO work item. Please link one by using the pattern 'AB#' followed by the relevant work item number. You may use the 'Fixes' keyword to automatically resolve the work item when the pull request is merged. E.g. 'Fixes AB#1234'

FREInvoiceLifecycle."Detailed Ledger Entry No." := DetailedLedgerEntryNo;
FREInvoiceLifecycle."Processing Status" := FREInvoiceLifecycle."Processing Status"::Captured;
FREInvoiceLifecycle."Created At" := CurrentDateTime();
if InvoiceCustLedgerEntryNo <> 0 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\ —\ Agent}$

FR E-Invoice Lifecycle Mgt.CapturePaymentOccurrence only calls PopulatePPFContext (which TestFields EDocumentService."FR Sender Platform ID/Scheme/Name" and Company Information "Registration No."/Name) when InvoiceCustLedgerEntryNo <> 0. Both real production entry points — OnAfterInsertDtldCustLedgEntry via ProcessDetailedLedgerApplication, and the unapplication/reversal path via ProcessDetailedLedgerUnapplication — always resolve and pass a non-zero invoice customer ledger entry number, so PopulatePPFContext always runs and the FR Sender Platform fields become mandatory for every occurrence captured through the actual posting flow. Consequently the CDV/general profile branch that FR E-Invoice Lifecycle Msg. explicitly implements (IsPPFMessage = false, urn.cpro.gouv.fr:1p0:CDV:invoice) and that the test suite exercises only via a direct API call with InvoiceCustLedgerEntryNo = 0, can never be produced by the shipped event-driven flow — the dual-profile design is effectively dead in production. Recommend gating PopulatePPFContext (and thus the PPF/CDV branch) on an explicit signal of platform routing, such as EDocumentService."FR Sender Platform ID" being configured for the service, rather than on whether an invoice ledger entry happens to be linked.

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


asserterror FREInvoiceLifecycleMgt.ProcessDetailedLedgerApplication(DetailedCustLedgEntry);

Assert.ExpectedError('FR Sender Platform ID must have a value');

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

In DetailedApplicationRejectsMissingSenderPlatformSetup, asserterror is paired with Assert.ExpectedError('FR Sender Platform ID must have a value') — a hardcoded copy of the platform's auto-generated TestField message. The referenced guidance recommends Assert.ExpectedTestFieldError(FieldCaption, ExpectedValue) for mandatory-field checks instead of a literal string, so the test does not silently pass for the wrong reason if the TestField message format or field caption changes, and does not repeat platform message knowledge in the test.

Knowledge:

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

@djukicmilica 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
InherentPermissions = X;
PageType = List;
SourceTable = "FR E-Invoice Lifecycle";
UsageCategory = History;

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

This new history list page does not set a descending default view, so it will open oldest-first. Historical pages should default to newest-first unless there is a specific requirement to start with the oldest entries.

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

    UsageCategory = History;
    SourceTableView = order(descending);

Knowledge:

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

@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

Comment on lines +194 to +197
trigger OnDelete()
begin
Error(ImmutableOccurrenceErr);
end;

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 "FR E-Invoice Lifecycle" table is intended to be immutable, but it blocks Delete and value-changing Modify operations without blocking Rename. A Rename call can still change the primary key "Entry No." on an existing occurrence, which breaks the record's identity and can leave related "FR E-Invoice Lifecycle VAT" rows pointing to the old entry number. Add an OnRename trigger that raises the same immutable-record error.

Suggested change
trigger OnDelete()
begin
Error(ImmutableOccurrenceErr);
end;
trigger OnDelete()
begin
Error(ImmutableOccurrenceErr);
end;
trigger OnRename()
begin
Error(ImmutableOccurrenceErr);
end;

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

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

Comment on lines +300 to +329
local procedure ValidatePaymentOccurrence(EDocumentEntryNo: Integer; LifecycleStatus: Enum "FR E-Invoice Lifecycle Status"; SourceOccurrenceID: Guid; ReportedAmount: Decimal; EventDate: Date; OriginalOccurrenceEntryNo: Integer)
var
EDocument: Record "E-Document";
OriginalOccurrence: Record "FR E-Invoice Lifecycle";
begin
EDocument.Get(EDocumentEntryNo);
if IsNullGuid(SourceOccurrenceID) then
Error(SourceOccurrenceIDErr);
if EventDate = 0D then
Error(EventDateErr);

case LifecycleStatus of
LifecycleStatus::Collected:
begin
if ReportedAmount <= 0 then
Error(CollectedAmountErr);
if OriginalOccurrenceEntryNo <> 0 then
Error(CollectedOriginalOccurrenceErr);
end;
LifecycleStatus::"Negative Collected":
begin
if ReportedAmount >= 0 then
Error(NegativeCollectedAmountErr);
if not OriginalOccurrence.Get(OriginalOccurrenceEntryNo) then
Error(OriginalOccurrenceErr);
OriginalOccurrence.TestField("E-Document Entry No.", EDocumentEntryNo);
OriginalOccurrence.TestField("Lifecycle Status", OriginalOccurrence."Lifecycle Status"::Collected);
if ReportedAmount <> -OriginalOccurrence."Reported Amount" then
Error(ReversalAmountErr);
end;

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

ValidatePaymentOccurrence has an unreachable fallback for a closed lifecycle-status enum, so "Lifecycle status %1 is not a payment lifecycle status." is internal contract detail rather than a user-actionable validation. Raise this path with ErrorInfo.ErrorType::Internal so the detail goes to telemetry instead of the client dialog.

Suggested change
local procedure ValidatePaymentOccurrence(EDocumentEntryNo: Integer; LifecycleStatus: Enum "FR E-Invoice Lifecycle Status"; SourceOccurrenceID: Guid; ReportedAmount: Decimal; EventDate: Date; OriginalOccurrenceEntryNo: Integer)
var
EDocument: Record "E-Document";
OriginalOccurrence: Record "FR E-Invoice Lifecycle";
begin
EDocument.Get(EDocumentEntryNo);
if IsNullGuid(SourceOccurrenceID) then
Error(SourceOccurrenceIDErr);
if EventDate = 0D then
Error(EventDateErr);
case LifecycleStatus of
LifecycleStatus::Collected:
begin
if ReportedAmount <= 0 then
Error(CollectedAmountErr);
if OriginalOccurrenceEntryNo <> 0 then
Error(CollectedOriginalOccurrenceErr);
end;
LifecycleStatus::"Negative Collected":
begin
if ReportedAmount >= 0 then
Error(NegativeCollectedAmountErr);
if not OriginalOccurrence.Get(OriginalOccurrenceEntryNo) then
Error(OriginalOccurrenceErr);
OriginalOccurrence.TestField("E-Document Entry No.", EDocumentEntryNo);
OriginalOccurrence.TestField("Lifecycle Status", OriginalOccurrence."Lifecycle Status"::Collected);
if ReportedAmount <> -OriginalOccurrence."Reported Amount" then
Error(ReversalAmountErr);
end;
local procedure ValidatePaymentOccurrence(EDocumentEntryNo: Integer; LifecycleStatus: Enum "FR E-Invoice Lifecycle Status"; SourceOccurrenceID: Guid; ReportedAmount: Decimal; EventDate: Date; OriginalOccurrenceEntryNo: Integer)
var
EDocument: Record "E-Document";
OriginalOccurrence: Record "FR E-Invoice Lifecycle";
InternalErr: ErrorInfo;
begin
EDocument.Get(EDocumentEntryNo);
if IsNullGuid(SourceOccurrenceID) then
Error(SourceOccurrenceIDErr);
if EventDate = 0D then
Error(EventDateErr);
case LifecycleStatus of
LifecycleStatus::Collected:
begin
if ReportedAmount <= 0 then
Error(CollectedAmountErr);
if OriginalOccurrenceEntryNo <> 0 then
Error(CollectedOriginalOccurrenceErr);
end;
LifecycleStatus::"Negative Collected":
begin
if ReportedAmount >= 0 then
Error(NegativeCollectedAmountErr);
if not OriginalOccurrence.Get(OriginalOccurrenceEntryNo) then
Error(OriginalOccurrenceErr);
OriginalOccurrence.TestField("E-Document Entry No.", EDocumentEntryNo);
OriginalOccurrence.TestField("Lifecycle Status", OriginalOccurrence."Lifecycle Status"::Collected);
if ReportedAmount <> -OriginalOccurrence."Reported Amount" then
Error(ReversalAmountErr);
end;
else begin
InternalErr.ErrorType := ErrorType::Internal;
InternalErr.Message := StrSubstNo(PaymentStatusErr, LifecycleStatus);
Error(InternalErr);
end;
end;
end;

Knowledge:

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

FREInvoiceLifecycle.TestField("Invoice Issuer Name");
end;

local procedure ValidatePaymentStatus(LifecycleStatus: Enum "FR E-Invoice Lifecycle Status")

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

ValidatePaymentStatus only rejects values that should be impossible for this non-extensible enum, so showing "Lifecycle status %1 is not supported by the French collected status message builder." to the user leaks developer-only state. Raise an ErrorInfo with ErrorType::Internal here instead.

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

    local procedure ValidatePaymentStatus(LifecycleStatus: Enum "FR E-Invoice Lifecycle Status")
    var
        InternalErr: ErrorInfo;
    begin
        if not (LifecycleStatus in [LifecycleStatus::Collected, LifecycleStatus::"Negative Collected"]) then begin
            InternalErr.ErrorType := ErrorType::Internal;
            InternalErr.Message := StrSubstNo(UnsupportedLifecycleStatusErr, LifecycleStatus);
            Error(InternalErr);
        end;
    end;

Knowledge:

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

var
FREInvoiceLifecycle: Record "FR E-Invoice Lifecycle";
begin
FREInvoiceLifecycle.SetCurrentKey("E-Document Entry No.", "Created At");

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

BuildMessage filters "FR E-Invoice Lifecycle" by "E-Document Entry No.", "E-Document Message Entry No.", and "Processing Status", but the new table defines no key whose leading fields cover that queued-row lookup. As lifecycle history grows, finding the next queued message for one document will read more rows than necessary. Add a key for the queued-row predicates (and Created At if oldest-first ordering matters) and select it with SetCurrentKey() before FindFirst().

Knowledge:

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

DataClassification = CustomerContent;
ToolTip = 'Specifies the date and time the invoice was received by the platform, frozen at capture.';
}
field(18; "Sender Platform 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\ —\ Privacy}$

The lifecycle snapshot stores organization identifiers in "Sender Platform ID" and "Invoice Issuer ID" (the latter is explicitly a SIREN), but both fields are classified as CustomerContent. Organization identifiers should use OrganizationIdentifiableInformation.

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

        field(18; "Sender Platform ID"; Text[50])
        {
            Caption = 'Sender Platform ID';
            DataClassification = OrganizationIdentifiableInformation;
            ToolTip = 'Specifies the identifier of the approved platform that sent the invoice.';
        }
        field(19; "Sender Platform Scheme"; Code[4])
        {
            Caption = 'Sender Platform Scheme';
            DataClassification = CustomerContent;
            ToolTip = 'Specifies the identifier scheme of the sender platform.';
        }
        field(20; "Sender Platform Name"; Text[100])
        {
            Caption = 'Sender Platform Name';
            DataClassification = CustomerContent;
            ToolTip = 'Specifies the name of the approved platform that sent the invoice.';
        }
        field(21; "Invoice Issuer ID"; Text[50])
        {
            Caption = 'Invoice Issuer ID';
            DataClassification = OrganizationIdentifiableInformation;
            ToolTip = 'Specifies the identifier (SIREN) of the invoice issuer.';
        }

Knowledge:

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

{
fields
{
field(10970; "FR Sender Platform 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\ —\ Privacy}$

The new "FR Sender Platform ID" field stores an organization identifier, but it is classified as CustomerContent. The privacy guidance calls for OrganizationIdentifiableInformation on fields that identify an organization rather than a person.

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

        field(10970; "FR Sender Platform ID"; Text[50])
        {
            Caption = 'FR Sender Platform ID';
            DataClassification = OrganizationIdentifiableInformation;
            ToolTip = 'Specifies the identifier of the French approved platform that sends lifecycle messages.';
        }

Knowledge:

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

InherentPermissions = X;
TableNo = "FR E-Invoice Lifecycle";

trigger OnRun()

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

"FR E-Invoice Lifecycle Worker" puts privileged message creation behind Access = Internal, but the referenced guidance warns that internal is not a runtime authorization boundary because callers can still reach runnable codeunits through Codeunit.Run. As written, any caller that can supply a lifecycle record can invoke OnRun and create an outbound lifecycle message without an independent guard. Require the worker to validate the queued state before doing the privileged operation.

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

trigger OnRun()
var
    FREInvoiceLifecycleMgt: Codeunit "FR E-Invoice Lifecycle Mgt.";
begin
    Rec.TestField("Processing Status", Rec."Processing Status"::Queued);
    Rec.TestField("E-Document Message Entry No.", 0);
    FREInvoiceLifecycleMgt.CreateLifecycleMessage(Rec);
end

Knowledge:

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


IncludedPermissionSets = "E-Reporting FR - Read";

Permissions = tabledata "FR E-Invoice Lifecycle" = IM,

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

The edit permission set grants direct uppercase write permissions (IM / I) on the lifecycle tables even though writes are supposed to happen through the app's controlled code paths. The referenced guidance requires indirect permissions when table access must stay code-mediated; otherwise assignees can insert rows or change mutable fields such as processing state outside the intended lifecycle flow.

Knowledge:

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

@github-actions

Copy link
Copy Markdown
Contributor

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

This PR deletes the existing ToolTip on the "Clearance Date" field control in pageextension "E-Reporting E-Documents" while adding unrelated new lifecycle actions to the same file. The original file (origin/main) has 'Specifies the date and time when the e-reporting transaction was accepted by the tax authority.'; the PR leaves the field with only ApplicationArea and Caption. This is a regression against AA0218 on an already-shipped field, not merely a missing addition on a new one, and should be restored rather than dropped.

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

            field("Clearance Date"; Rec."Clearance Date")
            {
                ApplicationArea = Basic, Suite;
                Caption = 'E-Reporting Acceptance Date';
                ToolTip = 'Specifies the date and time when the e-reporting transaction was accepted by the tax authority.';
            }

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

LibraryTestInitialize.OnAfterTestSuiteInitialize(Codeunit::"Identification Tests");
end;

local procedure CreateFRFacturXEDocument(var EDocument: Record "E-Document"; SalesInvoiceHeader: Record "Sales Invoice Header")

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

The new fixture helpers hand-roll standard BC records with fabricated keys and direct Insert calls (Sales Invoice Header, E-Document Service, E-Document, Cust. Ledger Entry, VAT Entry, VAT Posting Setup, and Detailed Cust. Ledg. Entry) instead of using the maintained test library codeunits already available in this test app. That bypasses number-series, posting/setup logic, and future mandatory-field validation, so these lifecycle tests can start failing because the synthetic prerequisites drift from real BC data rather than because the feature regressed.

Knowledge:

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

{
Caption = 'FR Sender Platform Scheme';
DataClassification = CustomerContent;
InitValue = '0238';

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{🔴\ Critical\ Severity\ —\ Upgrade}$

The new field "FR Sender Platform Scheme" is added to the existing "E-Document Service" table with InitValue = '0238', but this PR adds no upgrade routine to back-fill existing service rows. On upgrade, pre-existing rows keep the text default '', so PopulatePPFContext() can later fail TestField("FR Sender Platform Scheme") for upgraded tenants until each record is repaired manually. Add upgrade code that initializes existing rows to 0238, preferably guarded by an upgrade tag.

Knowledge:

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

@djukicmilica djukicmilica added Finance GitHub request for Finance area and removed Integration GitHub request for Integration area labels Jul 23, 2026
@github-actions github-actions Bot added Integration GitHub request for Integration area and removed Finance GitHub request for Finance area labels Jul 23, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Integration GitHub request for Integration area

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant