[FR E-Reporting] Implement invoice payment lifecycle tracking (#442216)#9659
[FR E-Reporting] Implement invoice payment lifecycle tracking (#442216)#9659djukicmilica wants to merge 9 commits into
Conversation
|
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 |
There was a problem hiding this comment.
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'); |
There was a problem hiding this comment.
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
| InherentPermissions = X; | ||
| PageType = List; | ||
| SourceTable = "FR E-Invoice Lifecycle"; | ||
| UsageCategory = History; |
There was a problem hiding this comment.
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
|
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 |
| trigger OnDelete() | ||
| begin | ||
| Error(ImmutableOccurrenceErr); | ||
| end; |
There was a problem hiding this comment.
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.
| 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
| 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; |
There was a problem hiding this comment.
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.
| 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") |
There was a problem hiding this comment.
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"); |
There was a problem hiding this comment.
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]) |
There was a problem hiding this comment.
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]) |
There was a problem hiding this comment.
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() |
There was a problem hiding this comment.
"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);
endKnowledge:
👍 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, |
There was a problem hiding this comment.
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
|
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") |
There was a problem hiding this comment.
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'; |
There was a problem hiding this comment.
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
Why
French e-reporting regulation requires that when a payment is applied to an e-invoice, the
Collectedlifecycle 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
FR E-Invoice Lifecycletable to capture immutable lifecycle occurrences (payment applications, reversals) linked to e-documents and customer ledger entriesFR E-Invoice Lifecycle VATtable to store per-occurrence VAT breakdown lines with amounts and currencyFR E-Invoice Lifecycle Mgt.codeunit that subscribes toOnAfterInsertDtldCustLedgEntryandOnAfterInsertDtldCustLedgEntryUnapplyto automatically capture payment lifecycle eventsFR E-Invoice Lifecycle Msg.codeunit implementingIEDocMessageBuilderto produce CrossDomainAcknowledgementAndResponse XML messages per the French regulatory profileFR E-Invoice Lifecycle ErrorandFR E-Invoice Lifecycle Workercodeunits for asynchronous processing with error handlingFR E-Invoice Lifecycleslist page for viewing captured occurrences