Merge upstream/main; adopt articles[] eval override, add sample-link READ convention

- Resolve conflicts in al-data-modeling-review.md by keeping both sides'
  additions (folder-path support, InitRecord/Round cues from upstream;
  the 9 document-distribution/pricing/barcode cues from this branch).
- Switch the data-modeling evaluation override from an ad-hoc
  additionalArticles field to upstream's now-established articles[]
  convention (used elsewhere for finance/scm/query/reporting/style),
  removing the redundant parallel code path from Test-ReviewFixtures.ps1.
- Fix all 9 new articles' sample references to the markdown-link READ
  convention required by Knowledge-Retrieval.ps1's Assert-SampleLink
  (plain backticks satisfy validate_frontmatter.py's regex alone but not
  this stricter check - both validators must pass).

Validators: frontmatter 0/0, review-fixtures 126 cases/20 domains PASSED,
knowledge-index 342 articles/575 samples PASSED, skill-index 19 review
leaves PASSED.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Michael Dieringer 2026-09-24 06:41:24 +02:00
commit e22352b248
504 changed files with 9501 additions and 1969 deletions

View file

@ -1,43 +0,0 @@
// Anti-pattern: an own object with no affix. Another app that also defines a
// "Loyalty Tier" table cannot be installed alongside this one.
table 50379 "Loyalty Tier"
{
Caption = 'Loyalty Tier';
DataClassification = CustomerContent;
fields
{
field(1; "Code"; Code[20])
{
Caption = 'Code';
}
field(10; Description; Text[100])
{
Caption = 'Description';
}
}
keys
{
key(PK; "Code")
{
Clustered = true;
}
}
}
// Anti-pattern (the common half-measure): the extension object carries the
// affix, but the field it adds to the standard Customer table does not. That
// unaffixed field still collides with any other app that adds "Loyalty Points"
// to Customer, and AS0011 flags it.
tableextension 50378 "ABC Customer Ext" extends Customer
{
fields
{
field(50378; "Loyalty Points"; Integer)
{
Caption = 'Loyalty Points';
DataClassification = CustomerContent;
}
}
}

View file

@ -1,40 +0,0 @@
// Own object: the affix "ABC" is carried at object-name level.
table 50377 "ABC Loyalty Tier"
{
Caption = 'Loyalty Tier';
DataClassification = CustomerContent;
fields
{
field(1; "Code"; Code[20])
{
Caption = 'Code';
}
field(10; Description; Text[100])
{
Caption = 'Description';
}
}
keys
{
key(PK; "Code")
{
Clustered = true;
}
}
}
// Extension of a standard object: the added field is individually affixed,
// because the object name (Customer) belongs to the base application.
tableextension 50376 "ABC Customer Ext" extends Customer
{
fields
{
field(50376; "Loyalty Points ABC"; Integer)
{
Caption = 'Loyalty Points';
DataClassification = CustomerContent;
}
}
}

View file

@ -1,30 +0,0 @@
---
bc-version: [all]
domain: appsource
keywords: [object-affix, prefix, suffix, as0011, appsourcecop, collision, tableextension, first-party, isv]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Apply a reserved affix to objects and to members added to base objects
## Description
An AppSource extension must prevent name collisions through its registered affix or, on BC23 and later for objects it owns, a namespace with at least two levels. The affix still applies to every field, key, control, or action added to a base-application object; see `two-level-namespace-replaces-object-affix-not-extension-member-affix.md`. Without either mechanism, two apps that both define a `Loyalty Tier` table cannot coexist, and two apps that add an unaffixed `Loyalty Points` field to `Customer` still collide regardless of their namespaces.
AppSourceCop enforces this. The primary rule is AS0011 ("An affix is required"); the affixes are configured through `mandatoryAffixes` (and `mandatoryPrefix`) in `AppSourceCop.json`. Two placements matter and are easy to get half-right: an object you define carries the affix at **object-name** level, while a member you add to a **standard** object carries the affix on that **member's** name. Adding an affixed object is not enough — an unaffixed field bolted onto `Customer` still collides and still fails validation.
This rule scopes to Marketplace ISV extensions, which is what AppSourceCop validates. A first-party Microsoft in-box module (publisher `Microsoft`, an object range reserved for first-party use, and no `AppSourceCop.json`/`mandatoryAffixes` in the app) is not built or shipped as an Marketplace extension and is not subject to AS0011, so an unaffixed action or field it adds to a base-application page is not a collision risk to flag. Renaming an existing shipped first-party member to add an affix is itself a breaking change to that module's own history and is not required by this rule.
## Best Practice
Own objects use the registered affix (for example `ABC Loyalty Tier`) or, when targeting BC23 or later, a qualifying namespace. Every field or action added to a standard object remains individually affixed (for example `Loyalty Points ABC` on a `Customer` tableextension).
See sample: `object-affixes-prevent-collisions.good.al`.
## Anti Pattern
An owned object with neither a qualifying namespace nor an affix, an unaffixed extension member, or the common half-measure where the extension object carries the affix but a field it adds to a standard table does not. AS0011 flags the missing collision protection and the field can still collide with another app.
See sample: `object-affixes-prevent-collisions.bad.al`.

View file

@ -17,10 +17,10 @@ An AppSource app must provide permission sets that let assigned users complete t
Trace every setup page, normal page, report, codeunit, and tabledata operation exposed by the app and cover it through assignable role permission sets composed from focused non-assignable sets. Validate setup and representative workflows as a user assigned only those app roles. Grant the minimum required operations; completeness is not a reason to use wildcards.
See sample: `permission-sets-cover-setup-and-usage-without-super.good.al`.
See sample: [`permission-sets-cover-setup-and-usage-without-super.good.al`](permission-sets-cover-setup-and-usage-without-super.good.al).
## Anti Pattern
Shipping no permission set, omitting a tabledata or execute grant used by the app's own UI, or instructing users and validators to assign `SUPER` when setup fails. Do not flag a permission-set name that differs from the app name; no such naming requirement exists.
See sample: `permission-sets-cover-setup-and-usage-without-super.bad.al`.
See sample: [`permission-sets-cover-setup-and-usage-without-super.bad.al`](permission-sets-cover-setup-and-usage-without-super.bad.al).

View file

@ -1,22 +0,0 @@
namespace Contoso;
table 50462 "Rental Agreement"
{
DataClassification = CustomerContent;
fields
{
field(1; "No."; Code[20]) { }
}
}
tableextension 50463 "Rental Customer Ext" extends Customer
{
fields
{
field(50463; "Loyalty Points"; Integer)
{
DataClassification = CustomerContent;
}
}
}

View file

@ -1,22 +0,0 @@
namespace Contoso.Rentals;
table 50460 "Rental Agreement"
{
DataClassification = CustomerContent;
fields
{
field(1; "No."; Code[20]) { }
}
}
tableextension 50461 "Rental Customer Ext" extends Customer
{
fields
{
field(50461; "Loyalty Points RNT"; Integer)
{
DataClassification = CustomerContent;
}
}
}

View file

@ -1,28 +0,0 @@
---
bc-version: [23..]
domain: appsource
keywords: [namespace, two-level, affix, prefix, suffix, as0011, tableextension, pageextension, false-positive]
technologies: [al]
countries: [w1]
application-area: [all]
---
# A two-level namespace replaces an object affix, not an extension-member affix
## Description
Current AppSource naming guidance accepts a namespace with at least two levels, such as `Contoso.Rentals`, instead of a registered prefix or suffix on the names of objects the app owns. The namespace does not qualify members added to another publisher's object: fields, keys, controls, and actions introduced through table or page extensions still share the target object's flat member namespace and still need the registered affix.
The requirement comes from AppSourceCop rule AS0011, which only runs when the app enables AppSourceCop and configures a mandatory affix — normally an `AppSourceCop.json` next to the app manifest. An app that ships no such configuration is not subject to AS0011, and its extension members are not a compliance gap. This is the usual situation for first-party, in-box apps that ship as part of the product rather than through AppSource: their uniqueness comes from allocated object ID ranges and a controlled source tree, not from a registered affix. Confirm the extending app actually configures a mandatory affix before reporting an unaffixed extension member.
## Best Practice
Choose one collision strategy for owned objects: a registered affix or a globally meaningful namespace with at least two levels. Regardless of that choice, apply the registered affix to every member added to a base or third-party object. Keep the affix configured for AppSourceCop so member validation remains deterministic. Do not raise a missing member affix against an app that does not enable AppSourceCop with a mandatory affix; there AS0011 never fires, and the app's namespace is not the reason — the absent configuration is.
See sample: `two-level-namespace-replaces-object-affix-not-extension-member-affix.good.al`.
## Anti Pattern
Using `namespace Contoso;` as though one level satisfied the AppSource alternative, or declaring `namespace Contoso.Rentals;` and then adding an unaffixed `Loyalty Points` field to `Customer` in an app that does configure a mandatory affix. The namespace distinguishes the extension's own objects; it cannot disambiguate members on Customer. The mirror-image mistake is reporting an unaffixed extension member in an app that enables no mandatory affix at all — AS0011 does not apply there, and the finding is a false positive.
See sample: `two-level-namespace-replaces-object-affix-not-extension-member-affix.bad.al`.

View file

@ -17,10 +17,10 @@ Access is a decision about what you are willing to support forever. The moment a
Start everything `local` or `internal` and promote a member to `public` only when you have decided to support it as a stable contract. Expose a small, intentional surface — the supported entry point — and keep validation, posting, and helper routines `internal` for in-app reuse or `local` when single-object. Do not drop `[Scope('OnPrem')]` without intent, since that too widens the contract. Every public member is a maintenance commitment; spend them deliberately.
See sample: `choose-access-modifiers-deliberately.good.al`.
See sample: [`choose-access-modifiers-deliberately.good.al`](choose-access-modifiers-deliberately.good.al).
## Anti Pattern
Declaring every procedure `public` by default, so internal helpers like `ValidateOrder` and `PostOrder` become a de-facto API that consumers bind to and that can no longer be changed freely. Detection: an object where implementation-detail procedures carry no access modifier or are `public` without a reason to support them externally. Default them to `internal`/`local` and make only the intended entry point public.
See sample: `choose-access-modifiers-deliberately.bad.al`.
See sample: [`choose-access-modifiers-deliberately.bad.al`](choose-access-modifiers-deliberately.bad.al).

View file

@ -17,10 +17,10 @@ Deleting or renaming a published procedure (or object) in a single release is a
When a published procedure is superseded, keep it in place and mark it `[Obsolete('Use CalculateNetAmount instead.', '25.0')]`, where the message names the replacement and the tag records when the method became obsolete. Have the obsolete member forward to the new one so behavior is preserved during the window. Only after the deprecation window has elapsed should a later release delete the method. For an object or field, use `Pending` during the warning window and `Removed` afterward.
See sample: `deprecate-public-members-with-the-obsolete-lifecycle.good.al`.
See sample: [`deprecate-public-members-with-the-obsolete-lifecycle.good.al`](deprecate-public-members-with-the-obsolete-lifecycle.good.al).
## Anti Pattern
Renaming or deleting the published `CalcNet` procedure in place — replacing it with `CalculateNetAmount` and nothing else — so consumers calling `CalcNet` break immediately with no deprecation notice. Detection: a previously shipped non-`local` procedure that vanished or was renamed between versions with no `[Obsolete]` marker left behind during a prior warning window. Do not suggest `ObsoleteState = Removed` for a method; that property belongs to supported object and element types.
See sample: `deprecate-public-members-with-the-obsolete-lifecycle.bad.al`.
See sample: [`deprecate-public-members-with-the-obsolete-lifecycle.bad.al`](deprecate-public-members-with-the-obsolete-lifecycle.bad.al).

View file

@ -19,10 +19,10 @@ This rule governs procedures that dependents *call*. An event publisher — a pr
Treat a published signature as frozen. When new behavior needs more inputs, add a new procedure or overload alongside the original — for example a `CalculateDiscountWithRate(Amount; Rate)` next to the unchanged `CalculateDiscount(Amount)` — and let the old one delegate to the new one. Existing callers keep compiling; new callers opt into the richer entry point. Naming an unnamed return value is the one in-place change that is always safe.
See sample: `do-not-change-published-procedure-signatures.good.al`.
See sample: [`do-not-change-published-procedure-signatures.good.al`](do-not-change-published-procedure-signatures.good.al).
## Anti Pattern
Editing the existing public procedure's parameter list — here, adding a `Rate` parameter to `CalculateDiscount` — so every dependent extension that called the old form fails to compile. Detection: a parameter added, removed, reordered, retyped, or flipped to/from `var`, or a changed return type, on any non-`local` procedure that already shipped. Add a new overload instead. Exclude event publishers whose only change is an added parameter: subscribers bind by parameter name, not position, so that edit is additive and reporting it here is a false positive.
See sample: `do-not-change-published-procedure-signatures.bad.al`.
See sample: [`do-not-change-published-procedure-signatures.bad.al`](do-not-change-published-procedure-signatures.bad.al).

View file

@ -17,10 +17,10 @@ Every member you make publicly reachable becomes a contract you must keep — an
Keep secrets in `internal` or `local` members, and prefer the `SecretText` type so the value cannot be read back or logged. Where callers genuinely need a credential, pass it inward (a setter) rather than handing it outward (a getter). Public API should return only non-sensitive data — a masked reference, a status, a business identifier — never the raw secret. Treat each public member as a lasting commitment and keep the security-sensitive surface as small as possible.
See sample: `do-not-expose-sensitive-data-through-public-api.good.al`.
See sample: [`do-not-expose-sensitive-data-through-public-api.good.al`](do-not-expose-sensitive-data-through-public-api.good.al).
## Anti Pattern
A public `GetAccessToken()` that returns the raw token (or an event parameter carrying a credential to all subscribers), turning a secret into a de-facto public API any dependent can consume. Detection: a non-`local` procedure, event parameter, or global variable that surfaces a token, password, key, or other credential. Keep the secret internal and expose only non-sensitive data.
See sample: `do-not-expose-sensitive-data-through-public-api.bad.al`.
See sample: [`do-not-expose-sensitive-data-through-public-api.bad.al`](do-not-expose-sensitive-data-through-public-api.bad.al).

View file

@ -17,10 +17,10 @@ A member carrying `[Obsolete]`, or wrapped in a `#if not CLEANxx` conditional-co
Leave obsolete members exactly as they are and implement against the current, supported replacement. New logic — a surcharge calculation, an event publisher, a hook — belongs on the live API (`GetUnitPrice`), never inside the deprecated `GetPrice` or behind a `#if not CLEAN25` guard. If the replacement does not yet exist, create it as a first-class member and build there. The obsolete code should only shrink over time, not accrete new behavior.
See sample: `do-not-modify-code-already-marked-obsolete.good.al`.
See sample: [`do-not-modify-code-already-marked-obsolete.good.al`](do-not-modify-code-already-marked-obsolete.good.al).
## Anti Pattern
Adding a surcharge calculation inside the `[Obsolete]` `GetPrice` procedure, or behind a `#if not CLEAN25` block, so the new behavior is wired to code that will be removed when `CLEAN25` is enabled. Detection: new statements, event declarations, or dependencies introduced inside an `[Obsolete]`-marked member or a `#if not CLEANxx` region. Move the logic onto the supported replacement instead.
See sample: `do-not-modify-code-already-marked-obsolete.bad.al`.
See sample: [`do-not-modify-code-already-marked-obsolete.bad.al`](do-not-modify-code-already-marked-obsolete.bad.al).

View file

@ -1,9 +0,0 @@
// This published object previously used namespace Contoso.Rentals.
namespace Contoso.RentalManagement;
codeunit 50467 "Rental Agreement Mgt."
{
procedure CreateAgreement()
begin
end;
}

View file

@ -1,8 +0,0 @@
namespace Contoso.Rentals;
codeunit 50466 "Rental Agreement Mgt."
{
procedure CreateAgreement()
begin
end;
}

View file

@ -1,26 +0,0 @@
---
bc-version: [23..]
domain: breaking-changes
keywords: [namespace, published-object, dependency, breaking-change, as0007, compile-time-identity]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Treat a published namespace as part of object identity
## Description
AL resolves an object by namespace and name. Once an app ships and dependent extensions compile against that identity, changing the namespace breaks their references even when the object name and ID stay unchanged. AppSourceCop AS0007 rejects changing the namespace of published objects; namespaces are therefore not a cosmetic folder-like label that can be reorganized after release.
## Best Practice
Choose a globally meaningful namespace before first publication and keep it stable. Add new functional areas beneath that structure without moving existing published objects. If an identity must move, use the platform's supported move/obsoletion lifecycle rather than a source-only namespace rename.
See sample: `namespace-is-part-of-published-object-identity.good.al`.
## Anti Pattern
Changing `namespace Contoso.Rentals;` to `namespace Contoso.RentalManagement;` as a cleanup while leaving the object name and ID untouched. Every dependent `using` directive and qualified reference targets the old identity and stops compiling.
See sample: `namespace-is-part-of-published-object-identity.bad.al`.

View file

@ -17,10 +17,10 @@ A shipped table field carries both a source-level contract and persisted data. R
Keep the old field's ID, name, and type unchanged. Add the replacement as a separate field under an unused ID, then mark the old field `ObsoleteState = Pending` with an `ObsoleteReason` that names the replacement and an `ObsoleteTag` recording the obsoletion version. Keep the old field readable so an upgrade codeunit can copy its data during the deprecation window. Move it to `ObsoleteState = Removed` only in a later release, after the window has passed and data has migrated.
See sample: `obsolete-table-fields-instead-of-deleting-them.good.al`.
See sample: [`obsolete-table-fields-instead-of-deleting-them.good.al`](obsolete-table-fields-instead-of-deleting-them.good.al).
## Anti Pattern
Renaming published `Email` to `Contact Email` with the same ID violates the compatibility contract and AS0005, even though the retained ID does not itself imply a fresh empty column. Deleting `Email` or changing its ID additionally risks losing its stored values. Detection: any previously shipped field whose name changes at the same ID, or whose original ID disappears without the unchanged field being retained as `Pending` and its data migrated to a separate replacement field.
See sample: `obsolete-table-fields-instead-of-deleting-them.bad.al`.
See sample: [`obsolete-table-fields-instead-of-deleting-them.bad.al`](obsolete-table-fields-instead-of-deleting-them.bad.al).

View file

@ -40,7 +40,7 @@ relevant `Method` (e.g. `"Lowest Price"`), `Type` (`Sale`/`Purchase`), and
`Asset Type` — with `Default := true`, since `FindSetup` only considers
rows where `Default` is set when resolving a handler for a line.
See sample: `activate-new-price-calculation-handler-via-onfindsupportedsetup.good.al`.
See sample: [`activate-new-price-calculation-handler-via-onfindsupportedsetup.good.al`](activate-new-price-calculation-handler-via-onfindsupportedsetup.good.al).
## Anti Pattern
@ -51,7 +51,7 @@ selected manually if a user creates their own `Price Calculation Setup`
row through the UI — but ships with no default row, so it's never active
for anyone until someone notices it's missing and configures it by hand.
See sample: `activate-new-price-calculation-handler-via-onfindsupportedsetup.bad.al`.
See sample: [`activate-new-price-calculation-handler-via-onfindsupportedsetup.bad.al`](activate-new-price-calculation-handler-via-onfindsupportedsetup.bad.al).
## Source

View file

@ -19,10 +19,10 @@ Putting the block check inside the master's own `OnInsert`/`OnModify` does nothi
The referencing line validates `Master.TestField(Blocked, false)` in `OnValidate` of the reference field and re-checks before posting. The master table stays logic-free on `Blocked`.
See sample: `check-blocked-in-referencing-code-not-in-master.good.al`.
See sample: [`check-blocked-in-referencing-code-not-in-master.good.al`](check-blocked-in-referencing-code-not-in-master.good.al).
## Anti Pattern
The block check sits in the master's own `OnModify`/`OnInsert` (so referencing and posting proceed unchecked), or there is no check at all on the referencing side.
See sample: `check-blocked-in-referencing-code-not-in-master.bad.al`.
See sample: [`check-blocked-in-referencing-code-not-in-master.bad.al`](check-blocked-in-referencing-code-not-in-master.bad.al).

View file

@ -40,7 +40,7 @@ what validation must pass before sending — is genuinely specific to the
document. Custom logic belongs around the call to `Report Selections`,
not instead of it.
See sample: `custom-document-dispatch-must-not-bypass-report-selections.good.al`.
See sample: [`custom-document-dispatch-must-not-bypass-report-selections.good.al`](custom-document-dispatch-must-not-bypass-report-selections.good.al).
## Anti Pattern
@ -51,7 +51,7 @@ without a code change and a new release, and the document is invisible to
"Document Layouts" — the standard place every other document's
distribution is configured.
See sample: `custom-document-dispatch-must-not-bypass-report-selections.bad.al`.
See sample: [`custom-document-dispatch-must-not-bypass-report-selections.bad.al`](custom-document-dispatch-must-not-bypass-report-selections.bad.al).
## Source

View file

@ -61,7 +61,7 @@ equally correct; neither reads the counterparty's assigned profile.
Reserve a genuine `Get`/`GetDefaultForCustomer`/`GetDefaultForVendor`
lookup and `Send`/`SendVendor` for Post-and-Send.
See sample: `document-print-and-email-actions-call-report-selections-directly.good.al`.
See sample: [`document-print-and-email-actions-call-report-selections-directly.good.al`](document-print-and-email-actions-call-report-selections-directly.good.al).
## Anti Pattern
@ -76,7 +76,7 @@ clicking "Email" does nothing observable. A second version of the same
mistake: an email action on a document that only receives from its
counterparty and was never meant to send anything back.
See sample: `document-print-and-email-actions-call-report-selections-directly.bad.al`.
See sample: [`document-print-and-email-actions-call-report-selections-directly.bad.al`](document-print-and-email-actions-call-report-selections-directly.bad.al).
## Source

View file

@ -59,7 +59,7 @@ custom table that should be searchable by document number:
uncombined keys, with no compound key between them, because `"No."`
alone is already sufficient.
See sample: `extend-find-entries-navigate-for-new-document-types.good.al`.
See sample: [`extend-find-entries-navigate-for-new-document-types.good.al`](extend-find-entries-navigate-for-new-document-types.good.al).
## Anti Pattern
@ -70,7 +70,7 @@ and a correct record count both show up — but leads nowhere when
selected, with no error and no indication to the user that anything is
wrong.
See sample: `extend-find-entries-navigate-for-new-document-types.bad.al`.
See sample: [`extend-find-entries-navigate-for-new-document-types.bad.al`](extend-find-entries-navigate-for-new-document-types.bad.al).
## Source

View file

@ -39,7 +39,7 @@ price list, extend `Price Source Type` and the matching document subset
enum (`Sales Price Source Type`, `Purchase Price Source Type`, `Job Price
Source Type`) together, using the identical numeric ID in both.
See sample: `extend-price-source-type-must-sync-document-subset-enum.good.al`.
See sample: [`extend-price-source-type-must-sync-document-subset-enum.good.al`](extend-price-source-type-must-sync-document-subset-enum.good.al).
## Anti Pattern
@ -50,7 +50,7 @@ absent from the "Applies-to Type" options on an actual sales price list,
with no error anywhere: the base enum extension compiles and installs
cleanly on its own.
See sample: `extend-price-source-type-must-sync-document-subset-enum.bad.al`.
See sample: [`extend-price-source-type-must-sync-document-subset-enum.bad.al`](extend-price-source-type-must-sync-document-subset-enum.bad.al).
## Source

View file

@ -51,7 +51,7 @@ legitimately, since that document posts to both ledgers, not by default.
triad and the value is unreachable in Document Layouts; wire both sides
needlessly and the picker is cluttered with a value that never applies.
See sample: `extend-report-selection-usage-for-new-document-types.good.al`
See sample: [`extend-report-selection-usage-for-new-document-types.good.al`](extend-report-selection-usage-for-new-document-types.good.al)
(customer-only document — only the customer-side enum and triad added).
## Anti Pattern
@ -60,7 +60,7 @@ See sample: `extend-report-selection-usage-for-new-document-types.good.al`
subscribers. Works via the tenant-wide default, so it's invisible in
testing — but Document Layouts shows the value's rows blank, can't offer
it in the Usage dropdown, and "Copy from Report Selection" never lists
it. See sample: `extend-report-selection-usage-for-new-document-types.bad.al`.
it. See sample: [`extend-report-selection-usage-for-new-document-types.bad.al`](extend-report-selection-usage-for-new-document-types.bad.al).
2. Subscribe both counterparties' triads for a one-sided document. This is
the overbroad default Jesper Schulz-Wedde's review caught: it
contradicts how `ReportSelectionHandlerCZZ` actually partitions its

View file

@ -0,0 +1,28 @@
table 50603 "Sample Order Header Bad"
{
fields
{
field(1; "No."; Code[20])
{
DataClassification = CustomerContent;
}
field(2; "Document Date"; Date)
{
DataClassification = CustomerContent;
}
}
trigger OnInsert()
var
SalesSetup: Record "Sales & Receivables Setup";
NoSeries: Codeunit "No. Series";
begin
"Document Date" := WorkDate();
if "No." = '' then begin
SalesSetup.Get();
SalesSetup.TestField("Order Nos.");
"No." := NoSeries.GetNextNo(SalesSetup."Order Nos.");
end;
end;
}

View file

@ -0,0 +1,45 @@
table 50602 "Sample Order Header Good"
{
fields
{
field(1; "No."; Code[20])
{
DataClassification = CustomerContent;
}
field(2; "Document Date"; Date)
{
DataClassification = CustomerContent;
}
}
trigger OnInsert()
var
SalesSetup: Record "Sales & Receivables Setup";
NoSeries: Codeunit "No. Series";
begin
if "No." = '' then begin
SalesSetup.Get();
SalesSetup.TestField("Order Nos.");
"No." := NoSeries.GetNextNo(SalesSetup."Order Nos.");
end;
InitRecord();
end;
procedure InitRecord()
begin
OnBeforeInitRecord(Rec);
"Document Date" := WorkDate();
OnAfterInitRecord(Rec);
end;
[IntegrationEvent(false, false)]
local procedure OnBeforeInitRecord(var SampleOrderHeader: Record "Sample Order Header Good")
begin
end;
[IntegrationEvent(false, false)]
local procedure OnAfterInitRecord(var SampleOrderHeader: Record "Sample Order Header Good")
begin
end;
}

View file

@ -0,0 +1,30 @@
---
bc-version: [all]
domain: data-modeling
keywords: [document-header, initrecord, number-series, default-values, oninsert, initialization]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Initialize document defaults in `InitRecord` after assigning the number
## Description
Business Central document headers assign their number series first and then call an `InitRecord` procedure that owns the remaining business defaults, such as posting and document dates. Keeping that sequence and extensibility point makes initialization consistent for every creation path and lets extensions subscribe around one documented operation. Defaults scattered across page triggers or unrelated helpers can differ between UI, API, test, and background creation.
## Best Practice
In the document table's insert path, assign the document number and then call `InitRecord`. Keep the default assignments in that procedure and expose narrow before/after events when other extensions must participate.
See sample: [`initialize-document-defaults-in-initrecord.good.al`](initialize-document-defaults-in-initrecord.good.al).
## Anti Pattern
Assigning document defaults in a page trigger, or scattering them directly through `OnInsert` with no `InitRecord` boundary. Non-page creation paths can then miss the defaults, and extensions have no stable initialization hook.
See sample: [`initialize-document-defaults-in-initrecord.bad.al`](initialize-document-defaults-in-initrecord.bad.al).
## Reference
[Use the InitRecord function](https://learn.microsoft.com/en-us/training/modules/use-document-standards-business-central/3-use-initrecord-function)

View file

@ -19,10 +19,10 @@ This is not an `Integer` `AutoIncrement` key, a GUID, or the `SystemId`. Those a
`No.` `Code[20]` is the sole primary key; a non-editable `No. Series` `Code[20]` field records the source series. `OnInsert` checks `if "No." = ''`, reads the setup table, `TestField`s the configured series, stores it in `No. Series`, and assigns `No.` from the series.
See sample: `master-table-no-from-number-series-in-oninsert.good.al`.
See sample: [`master-table-no-from-number-series-in-oninsert.good.al`](master-table-no-from-number-series-in-oninsert.good.al).
## Anti Pattern
An `Integer` `AutoIncrement` (or GUID / `SystemId`) primary key used as the business key, with no `OnInsert` number assignment. Records get an opaque identifier no user can reference, and the master no longer participates in the standard numbering and manual-entry behavior every other BC master follows.
See sample: `master-table-no-from-number-series-in-oninsert.bad.al`.
See sample: [`master-table-no-from-number-series-in-oninsert.bad.al`](master-table-no-from-number-series-in-oninsert.bad.al).

View file

@ -42,7 +42,7 @@ a trigger on the field itself (its own `OnValidate`, or a matching
`OnAfterValidate` integration event) that calls
`SalesLine.UpdateUnitPriceByField(SalesLine.FieldNo(<TheField>))`.
See sample: `new-price-source-must-add-candidate-and-trigger-recalculation.good.al`.
See sample: [`new-price-source-must-add-candidate-and-trigger-recalculation.good.al`](new-price-source-must-add-candidate-and-trigger-recalculation.good.al).
## Anti Pattern
@ -52,7 +52,7 @@ validation. The field is a genuine, working calculation candidate — new
lines price correctly — but editing the field on an existing line leaves
the unit price stale, with nothing to indicate why.
See sample: `new-price-source-must-add-candidate-and-trigger-recalculation.bad.al`.
See sample: [`new-price-source-must-add-candidate-and-trigger-recalculation.bad.al`](new-price-source-must-add-candidate-and-trigger-recalculation.bad.al).
## Source

View file

@ -25,7 +25,7 @@ See also `validate-table-relation-false-suppresses-rename-propagation.md` for th
The owning table implements `OnDelete` and deletes its dependents there, filtered on the foreign key. Declare `Permissions = tabledata <dependent> = rd` on the owning table — granting delete rights only on the parent is a common miss that makes the trigger fail for a non-`SUPER` user. This mirrors the base application, where every header table deletes its own lines.
See sample: `owning-table-must-delete-dependents-in-ondelete.good.al`.
See sample: [`owning-table-must-delete-dependents-in-ondelete.good.al`](owning-table-must-delete-dependents-in-ondelete.good.al).
## Anti Pattern
@ -33,4 +33,4 @@ A parent table with dependent rows and no `OnDelete` trigger, where the dependen
Detection signal: a table declares `TableRelation` to table X, and table X has no `OnDelete` trigger. Whether a delete path currently exists in the UI is irrelevant to the finding.
See sample: `owning-table-must-delete-dependents-in-ondelete.bad.al`.
See sample: [`owning-table-must-delete-dependents-in-ondelete.bad.al`](owning-table-must-delete-dependents-in-ondelete.bad.al).

View file

@ -57,7 +57,7 @@ font name to specify is literally `IDAutomation2D` (Maxicode itself uses
purchased version name for that specific font (e.g. `IDAutomationHC39M`
for Code 39), never a name containing `Demo`.
See sample: `report-barcodes-must-use-barcode-module-and-production-font-name.good.al`.
See sample: [`report-barcodes-must-use-barcode-module-and-production-font-name.good.al`](report-barcodes-must-use-barcode-module-and-production-font-name.good.al).
## Anti Pattern
@ -75,7 +75,7 @@ in review and testing and fails silently — the first because the encoded
data was never a real barcode, the second because Business Central
online refuses to render it at all.
See sample: `report-barcodes-must-use-barcode-module-and-production-font-name.bad.al`.
See sample: [`report-barcodes-must-use-barcode-module-and-production-font-name.bad.al`](report-barcodes-must-use-barcode-module-and-production-font-name.bad.al).
## Source

View file

@ -0,0 +1,8 @@
codeunit 50601 "Directed Rounding Bad"
{
procedure FloorAmount(Value: Decimal; Precision: Decimal): Decimal
begin
// For negative values, '<' rounds toward zero rather than toward negative infinity.
exit(Round(Value, Precision, '<'));
end;
}

View file

@ -0,0 +1,10 @@
codeunit 50600 "Directed Rounding Good"
{
procedure RoundAmount(Value: Decimal; Precision: Decimal; IncreaseMagnitude: Boolean): Decimal
begin
if IncreaseMagnitude then
exit(Round(Value, Precision, '>'));
exit(Round(Value, Precision, '<'));
end;
}

View file

@ -0,0 +1,30 @@
---
bc-version: [all]
domain: data-modeling
keywords: [round, rounding, direction, precision, negative-decimal, amount]
technologies: [al]
countries: [w1]
application-area: [all]
---
# `Round` direction symbols follow magnitude, not mathematical ordering
## Description
AL's `Round(Number, Precision, Direction)` uses `'>'` to round away from zero and `'<'` to round toward zero. For a negative value this reverses mathematical ordering: `Round(-1234.56789, 0.001, '<')` returns `-1234.567`, while direction `'>'` returns `-1234.568`. Code that treats the symbols as mathematical ceiling and floor produces sign-dependent amount errors, commonly on credit documents and negative adjustments.
## Best Practice
Choose the direction from the business meaning: `'>'` increases absolute magnitude and `'<'` decreases absolute magnitude for both positive and negative values. Include positive and negative cases whenever a directed rounding rule is tested.
See sample: [`round-direction-symbols-use-magnitude.good.al`](round-direction-symbols-use-magnitude.good.al).
## Anti Pattern
Using `'<'` as a mathematical floor or `'>'` as a mathematical ceiling. The result looks correct for positive amounts but moves in the opposite mathematical direction for negative amounts.
See sample: [`round-direction-symbols-use-magnitude.bad.al`](round-direction-symbols-use-magnitude.bad.al).
## Reference
[Use the Round function](https://learn.microsoft.com/en-us/training/modules/use-document-standards-business-central/4a-use-round-function)

View file

@ -19,10 +19,10 @@ The reason is a BC-specific trap: renaming a record changes its primary key and
Both `OnModify` and `OnRename` set `"Last Date Modified" := Today();`, and the field is declared `Editable = false` so only the triggers maintain it.
See sample: `set-last-date-modified-in-onmodify-and-onrename.good.al`.
See sample: [`set-last-date-modified-in-onmodify-and-onrename.good.al`](set-last-date-modified-in-onmodify-and-onrename.good.al).
## Anti Pattern
Only `OnModify` assigns `Last Date Modified`. After a rename the value is stale, and any process that trusts it to detect changes misses the record.
See sample: `set-last-date-modified-in-onmodify-and-onrename.bad.al`.
See sample: [`set-last-date-modified-in-onmodify-and-onrename.bad.al`](set-last-date-modified-in-onmodify-and-onrename.bad.al).

View file

@ -19,10 +19,10 @@ The setup **card** page enforces the singleton: `InsertAllowed = false` and `Del
`Primary Key` `Code[10]` is the sole key; the setup is surfaced through a Card page with `InsertAllowed = false`, `DeleteAllowed = false`, and an open-time guard that inserts the blank row if it is missing.
See sample: `setup-table-is-a-singleton.good.al`.
See sample: [`setup-table-is-a-singleton.good.al`](setup-table-is-a-singleton.good.al).
## Anti Pattern
An `Integer` / `AutoIncrement` key, a page that allows insert or delete, or a List page over the setup table. Any of these lets the table hold zero or many rows, so "the setup" becomes ambiguous and `Get()` may fail or read the wrong record.
See sample: `setup-table-is-a-singleton.bad.al`.
See sample: [`setup-table-is-a-singleton.bad.al`](setup-table-is-a-singleton.bad.al).

View file

@ -17,10 +17,10 @@ application-area: [all]
When sharing media between different tables, iterate the source `MediaSet` and call `Target.MediaSetField.Insert(Source.MediaSetField.Item(Index))`, then modify the target record. Direct field assignment is safe only when source and target are the same record subtype and use the same field ID. This concern is about reference/delete integrity, not the separate performance cost of `ModifyAll` on tables with media fields.
See sample: `share-mediaset-items-with-insert-not-field-assignment.good.al`.
See sample: [`share-mediaset-items-with-insert-not-field-assignment.good.al`](share-mediaset-items-with-insert-not-field-assignment.good.al).
## Anti Pattern
`Target.Picture := Source.Picture;` where the two variables refer to different table types or different media-field IDs. The code copies an opaque ID, but the platform does not know that two independent fields now share the media object.
See sample: `share-mediaset-items-with-insert-not-field-assignment.bad.al`.
See sample: [`share-mediaset-items-with-insert-not-field-assignment.bad.al`](share-mediaset-items-with-insert-not-field-assignment.bad.al).

View file

@ -17,10 +17,10 @@ A `tableextension` can add to an existing `TableRelation`, but the combined rela
When a relation is designed to follow an extensible enum, express the base cases as conditional branches and leave no unconditional catch-all ahead of future extension branches. An enum extension can then append a condition for its new value. When extending a field you do not own, inspect the original `TableRelation`; do not claim that an appended condition overrides an unconditional relation.
See sample: `table-relation-extensions-are-additive-and-top-down.good.al`.
See sample: [`table-relation-extensions-are-additive-and-top-down.good.al`](table-relation-extensions-are-additive-and-top-down.good.al).
## Anti Pattern
A base field has an unconditional `TableRelation = Customer;` and a `tableextension` adds `if (Type = const(Resource)) Resource`. The original unconditional branch always wins, so the new enum value still validates and looks up against Customer. The concern is evaluation order, not `ValidateTableRelation`; free-form input is covered separately by security guidance.
See sample: `table-relation-extensions-are-additive-and-top-down.bad.al`.
See sample: [`table-relation-extensions-are-additive-and-top-down.bad.al`](table-relation-extensions-are-additive-and-top-down.bad.al).

View file

@ -53,7 +53,7 @@ consistency requirement between definitions that are already meant to be
linked, not a mandate to check every field against every table on the
cascade.
See sample: `transferfields-mirrored-fields-must-match-type-and-length.good.al`.
See sample: [`transferfields-mirrored-fields-must-match-type-and-length.good.al`](transferfields-mirrored-fields-must-match-type-and-length.good.al).
## Anti Pattern
@ -64,7 +64,7 @@ incompatible data type — on one side. Both definitions compile without
error; nothing fails until an actual value exceeds the shorter one, which
typical test data never does.
See sample: `transferfields-mirrored-fields-must-match-type-and-length.bad.al`.
See sample: [`transferfields-mirrored-fields-must-match-type-and-length.bad.al`](transferfields-mirrored-fields-must-match-type-and-length.bad.al).
## Source

View file

@ -17,10 +17,10 @@ application-area: [all]
Use `TransferFields(Source)` only when every field the destination requires, including primary key fields, is guaranteed to share a matching field number and type with the source; this form defaults `InitPrimaryKeyFields` to `true`. Fields with no matching field number, and fields whose types differ across extensions, are skipped regardless of `SkipFieldsNotMatchingType` — that parameter only governs same-extension type mismatches. If the destination depends on a field that falls into either case, map and validate it explicitly in code rather than relying on `TransferFields` to catch the gap. Use `SkipFieldsNotMatchingType = true` only when skipping same-extension type mismatches is an intentional, documented part of the transfer contract.
See sample: `transferfields-skip-type-mismatch-can-drop-data.good.al`.
See sample: [`transferfields-skip-type-mismatch-can-drop-data.good.al`](transferfields-skip-type-mismatch-can-drop-data.good.al).
## Anti Pattern
Using `TransferFields(Source, InitPrimaryKeyFields, true)` as a generic way to make two evolving table schemas transfer without errors, when the destination depends on every required source field being copied. A type change on either table can turn a previously transferred field into a silently skipped one without making the transfer itself fail.
See sample: `transferfields-skip-type-mismatch-can-drop-data.bad.al`.
See sample: [`transferfields-skip-type-mismatch-can-drop-data.bad.al`](transferfields-skip-type-mismatch-can-drop-data.bad.al).

View file

@ -19,10 +19,10 @@ LLMs reproduce the legacy `NoSeriesManagement` pattern because it dominates pre-
`OnInsert` assigns the number with `NoSeries.GetNextNo("No. Series")` where `NoSeries` is `Codeunit "No. Series"`. The `No.` field's `OnValidate` guards manual entry by calling `NoSeries.IsManual(...)` (or `TestManual`) before clearing `No. Series`.
See sample: `use-no-series-codeunit-not-noseriesmanagement.good.al`.
See sample: [`use-no-series-codeunit-not-noseriesmanagement.good.al`](use-no-series-codeunit-not-noseriesmanagement.good.al).
## Anti Pattern
`NoSeriesMgt.InitSeries(...)` for assignment and `NoSeriesMgt.TestManual(...)` for the manual check, where `NoSeriesMgt` is `Codeunit NoSeriesManagement`. Both are obsolete-pending and emit compiler warnings.
See sample: `use-no-series-codeunit-not-noseriesmanagement.bad.al`.
See sample: [`use-no-series-codeunit-not-noseriesmanagement.bad.al`](use-no-series-codeunit-not-noseriesmanagement.bad.al).

View file

@ -27,7 +27,7 @@ See also `owning-table-must-delete-dependents-in-ondelete.md` for the delete hal
Leave `ValidateTableRelation` at its default wherever the stored value must stay correct across a rename. When it must be disabled, or when the relationship cannot be expressed as a `TableRelation` at all, the table owning the referenced key carries an explicit `OnRename` that repoints the dependents itself.
See sample: `validate-table-relation-false-suppresses-rename-propagation.good.al`.
See sample: [`validate-table-relation-false-suppresses-rename-propagation.good.al`](validate-table-relation-false-suppresses-rename-propagation.good.al).
## Anti Pattern
@ -35,4 +35,4 @@ See sample: `validate-table-relation-false-suppresses-rename-propagation.good.al
Detection signal: any `ValidateTableRelation = false` on a field that also declares a `TableRelation`. Ask what repoints the value when the target is renamed; if the answer is "the platform", the finding stands.
See sample: `validate-table-relation-false-suppresses-rename-propagation.bad.al`.
See sample: [`validate-table-relation-false-suppresses-rename-propagation.bad.al`](validate-table-relation-false-suppresses-rename-propagation.bad.al).

View file

@ -25,7 +25,7 @@ See also `validate-table-relation-false-suppresses-rename-propagation.md`, which
Use `xRec` for the previous key in `OnRename`, and for the record being removed in `OnDelete`. In `OnModify`, obtain the before-image by re-reading the stored row rather than trusting `xRec`, so the logic behaves identically whether a page, a job queue or an API drove the write.
See sample: `xrec-is-a-before-image-only-in-some-triggers.good.al`.
See sample: [`xrec-is-a-before-image-only-in-some-triggers.good.al`](xrec-is-a-before-image-only-in-some-triggers.good.al).
## Anti Pattern
@ -33,4 +33,4 @@ Comparing `Rec` against `xRec` inside `OnModify` (or `OnInsert`) to detect a cha
Detection signal: any read of `xRec` inside `OnModify` or `OnInsert`. Treat "but it works when I test it on the page" as confirmation of the defect rather than a refutation.
See sample: `xrec-is-a-before-image-only-in-some-triggers.bad.al`.
See sample: [`xrec-is-a-before-image-only-in-some-triggers.bad.al`](xrec-is-a-before-image-only-in-some-triggers.bad.al).

View file

@ -17,10 +17,10 @@ By default a procedure stops on the first `Error`, so a user fixing ten bad rows
Mark the orchestrating procedure `[ErrorBehavior(ErrorBehavior::Collect)]` and run each item's validation so one failure doesn't abandon the rest — typically by calling the per-item routine through `Codeunit.Run`. When the run finishes, inspect `HasCollectedErrors()`, retrieve and clear the list with `GetCollectedErrors(true)`, and fail the operation with the collected messages. The sample intentionally produces a text aggregate and does not claim to retain record/field metadata in the final error. If that metadata is needed, map each `ErrorInfo` to a custom error UI before clearing, following the Microsoft Learn pattern. Do not replace validation failure with `Message`: clearing collected errors suppresses the platform failure, so the custom handler must still block the invalid operation.
See sample: `collect-validation-errors-with-errorbehavior.good.al`.
See sample: [`collect-validation-errors-with-errorbehavior.good.al`](collect-validation-errors-with-errorbehavior.good.al).
## Anti Pattern
Three shapes signal trouble. Hand-rolled accumulation reimplements collection and prevents the handler from receiving individual `ErrorInfo` values. A `Collect` procedure that never handles the collection falls back to the concatenated platform dialog. Finally, code that calls parameterless `GetCollectedErrors()`, assumes it cleared the list, and only shows a `Message` can both leave the errors collected and allow invalid processing to continue.
See sample: `collect-validation-errors-with-errorbehavior.bad.al`.
See sample: [`collect-validation-errors-with-errorbehavior.bad.al`](collect-validation-errors-with-errorbehavior.bad.al).

View file

@ -17,10 +17,10 @@ application-area: [all]
Reserve `ErrorType::Internal` for errors the user cannot act on: corrupted internal state, an unreachable branch, a contract a caller violated. Set a precise, detail-rich `Message` for telemetry, raise it via `Error(ErrorInfo)`, and let the platform show the user a generic dialog. Keep `ErrorType::Client` (or a plain `Error`) for failures the user is expected to read and resolve — validation messages, missing setup, business-rule violations. The test is simple: if the message only makes sense to a developer, mark it `Internal`.
See sample: `errortype-internal-vs-client-for-diagnostics.good.al`.
See sample: [`errortype-internal-vs-client-for-diagnostics.good.al`](errortype-internal-vs-client-for-diagnostics.good.al).
## Anti Pattern
Raising an internal failure with a plain `Error('Unexpected state: ledger bucket %1 not initialized', BucketId)`. The user is shown a technical message they can do nothing about, and the signal is buried in a generic error rather than carried as structured telemetry detail. Detection: an `Error` whose wording targets a developer ("unexpected", "should not happen", raw internal identifiers) raised with default `Client` visibility instead of an `ErrorInfo` marked `ErrorType::Internal`.
See sample: `errortype-internal-vs-client-for-diagnostics.bad.al`.
See sample: [`errortype-internal-vs-client-for-diagnostics.bad.al`](errortype-internal-vs-client-for-diagnostics.bad.al).

View file

@ -14,9 +14,9 @@ application-area: [all]
## Best Practice
For a plain required-field check, prefer `TestField`, which tests the condition and raises the error in one call. When the condition is non-trivial and has already been evaluated, call `FieldError(FieldNo)` with no message to get the localized default (`must have a value`, `is not valid`, etc.), or pass a short lowercase predicate such as `FieldError(FieldNo, 'must be a positive number')`. Start the custom text with a lowercase letter so it reads as one sentence with the auto-inserted caption, and use a field-number reference (or the field token) rather than a hard-coded field name so captions and translations stay correct. Let the framework supply the caption, value, table, and key context for you.
See sample: `fielderror-default-message-logic.good.al`.
See sample: [`fielderror-default-message-logic.good.al`](fielderror-default-message-logic.good.al).
## Anti Pattern
Re-testing a condition you already evaluated, or passing a fully formed sentence like `'The Amount field must be positive.'` to `FieldError`. The result reads as `Amount The Amount field must be positive. in Gen. Journal Line ...` — capital letter mid-sentence, caption and value repeated, and a stray trailing clause. Reviewer signals: a `FieldError` argument that names the field, restates the current value, starts with a capital letter, or ends with a period. Each is a sign the author treated `FieldError` like `Error` instead of as a predicate slotted into framework-generated context.
See sample: `fielderror-default-message-logic.bad.al`.
See sample: [`fielderror-default-message-logic.bad.al`](fielderror-default-message-logic.bad.al).

View file

@ -16,9 +16,9 @@ Use `TestField` when the condition is a simple presence-or-equality check on a s
A page action's `OnAction` trigger is a different case: a page action is only invocable through its own UI control, so when the action's `Enabled` property is already bound to the same condition the trigger would otherwise `TestField`, the control cannot be clicked while the field is blank and the field can never reach the trigger empty. Adding a `TestField` there is redundant defensive code, not a missing check — flag it only when the trigger can run through a path `Enabled` does not cover (a shared procedure, an API, or a condition broader than what gates the action).
See sample: `fielderror-vs-testfield.good.al`.
See sample: [`fielderror-vs-testfield.good.al`](fielderror-vs-testfield.good.al).
## Anti Pattern
Calling `FieldError` to "test" a field — placing it on a path that is reached unconditionally and expecting it to validate — terminates execution every time because `FieldError` never evaluates a condition. The inverse smell is reaching for `TestField` when the rule needs a tailored message, then bolting a vague generic string onto a check that cannot express the real business reason. A reviewer can spot the first by a `FieldError` that is not guarded by a preceding `if`, and the second by a `TestField` whose intent comment describes a condition more complex than presence or equality.
See sample: `fielderror-vs-testfield.bad.al`.
See sample: [`fielderror-vs-testfield.bad.al`](fielderror-vs-testfield.bad.al).

View file

@ -17,13 +17,13 @@ A procedure marked `[TryFunction]` catches errors only when the caller uses its
Consume the result directly: assign it to a Boolean or use the call in an `if` condition. Handle `false` immediately while the last-error state still describes that failure.
See sample: `ignored-tryfunction-return-disables-try-semantics.good.al`.
See sample: [`ignored-tryfunction-return-disables-try-semantics.good.al`](ignored-tryfunction-return-disables-try-semantics.good.al).
## Anti Pattern
Calling a `[TryFunction]` procedure as a standalone statement and assuming the attribute suppresses its errors. The call has ordinary error semantics because its Boolean result is ignored.
See sample: `ignored-tryfunction-return-disables-try-semantics.bad.al`.
See sample: [`ignored-tryfunction-return-disables-try-semantics.bad.al`](ignored-tryfunction-return-disables-try-semantics.bad.al).
## See also

View file

@ -17,10 +17,10 @@ A plain `Error('text')` ends the operation with a dead-end dialog: the user read
Build an `ErrorInfo`, set `Title`, `Message`, and `DetailedMessage`, then attach the action that matches the situation. For a Fix-it, call `AddAction(Caption, Codeunit::Handler, 'MethodName')` where the handler method (which receives the `ErrorInfo`) applies the known-good value; phrase the caption as "Set value to …". For a Show-it, set `PageNo := Page::"…"`, set `RecordId` so navigation opens the right record, and call `AddNavigationAction('Show …')`. Raise it with `Error(ErrorInfo)`. Reserve recommended actions for cases where the solution is genuinely known and the user has permission to apply it.
See sample: `prefer-errorinfo-for-actionable-errors.good.al`.
See sample: [`prefer-errorinfo-for-actionable-errors.good.al`](prefer-errorinfo-for-actionable-errors.good.al).
## Anti Pattern
Surfacing a recoverable validation failure with `Error('You cannot invoice more than %1 units.', MaxQty)` and nothing else. The user is blocked with no offered remedy even though the code knows the maximum and could set it. The detection signal: an `Error` call in a validation or posting path whose message names a specific correct value or a specific related page, with no surrounding `ErrorInfo`, `AddAction`, or `AddNavigationAction`. Replace it with an `ErrorInfo` that carries the corresponding Fix-it or Show-it action.
See sample: `prefer-errorinfo-for-actionable-errors.bad.al`.
See sample: [`prefer-errorinfo-for-actionable-errors.bad.al`](prefer-errorinfo-for-actionable-errors.bad.al).

View file

@ -17,10 +17,10 @@ Event subscribers bind publisher parameters by name and can omit parameters they
Add a parameter directly only when the shipped event publisher is `local` or `internal`. Place it where the signature is clearest; existing subscribers continue binding the parameters they name. For a public event, keep the original publisher unchanged and introduce a new event with the expanded contract.
See sample: `add-new-event-parameters-at-the-end.good.al`.
See sample: [`add-new-event-parameters-at-the-end.good.al`](add-new-event-parameters-at-the-end.good.al).
## Anti Pattern
Appending a parameter to a public event and assuming its position makes the change compatible. Existing external callers still lack the new required argument. Conversely, do not flag a parameter inserted among existing parameters on a `local` or `internal` Business or Integration event merely because it was not appended.
See sample: `add-new-event-parameters-at-the-end.bad.al`.
See sample: [`add-new-event-parameters-at-the-end.bad.al`](add-new-event-parameters-at-the-end.bad.al).

View file

@ -17,10 +17,10 @@ Passing `RecordRef` or `xRec` as event parameters weakens the contract. A `Recor
Give events concrete record types and explicit values, such as `(SalesLine: Record "Sales Line"; PreviousQuantity: Decimal)`, instead of a `RecordRef` or an `xRec` parameter. Subscribers then get type safety, field access, and an unambiguous contract.
See sample: `avoid-loosely-typed-event-parameters.good.al`.
See sample: [`avoid-loosely-typed-event-parameters.good.al`](avoid-loosely-typed-event-parameters.good.al).
## Anti Pattern
Event parameters typed as `RecordRef` (no table type) or an `xRec`-style "previous record" (ambiguous, possibly stale) without strong justification. Detection: an event signature containing a `RecordRef` parameter, or a passed-through `xRec` record, where a concrete typed record and explicit values would serve.
See sample: `avoid-loosely-typed-event-parameters.bad.al`.
See sample: [`avoid-loosely-typed-event-parameters.bad.al`](avoid-loosely-typed-event-parameters.bad.al).

View file

@ -17,10 +17,10 @@ A `TryFunction` catches all errors — including errors thrown by event subscrib
Raise the integration event before entering the TryFunction scope. The event and its subscribers execute outside the error boundary, so subscriber errors propagate normally to the caller. Move only the operation that genuinely needs error isolation (such as an HTTP call or a posting step) inside the TryFunction.
See sample: `avoid-raising-events-inside-try-functions.good.al`.
See sample: [`avoid-raising-events-inside-try-functions.good.al`](avoid-raising-events-inside-try-functions.good.al).
## Anti Pattern
Raising an integration event inside a TryFunction body. Subscriber failures are caught and discarded by the TryFunction. The subscriber contract — that a subscriber can signal failure to the caller — is silently broken.
See sample: `avoid-raising-events-inside-try-functions.bad.al`.
See sample: [`avoid-raising-events-inside-try-functions.bad.al`](avoid-raising-events-inside-try-functions.bad.al).

View file

@ -0,0 +1,91 @@
codeunit 50100 "Transfer Request Bad"
{
// Self-contained demonstration of the anti pattern. Not derived from base-app source.
procedure RequestFromCompany(TargetCompany: Text[30]; ItemNo: Code[20]; Quantity: Decimal)
var
TransferRequest: Record "Transfer Request Bad";
TransferSetup: Record "Transfer Setup Bad";
begin
TransferRequest.ChangeCompany(TargetCompany);
TransferSetup.ChangeCompany(TargetCompany);
TransferSetup.Get();
TransferRequest.Init();
TransferRequest."Entry No." := NextEntryNo(TargetCompany);
TransferRequest."Item No." := ItemNo;
TransferRequest.Quantity := Quantity;
// OnInsert is skipped below, so the default is copied by hand from the target company's setup.
TransferRequest."Location Code" := TransferSetup."Default Location Code";
// The OnAfterInsertEvent subscriber still fires, in the calling company, and grows the caller's counter.
TransferRequest.Insert(false);
TransferSetup."Open Requests" += 1;
TransferSetup.Modify();
end;
local procedure NextEntryNo(TargetCompany: Text[30]): Integer
var
LastRequest: Record "Transfer Request Bad";
begin
LastRequest.ChangeCompany(TargetCompany);
if LastRequest.FindLast() then
exit(LastRequest."Entry No." + 1);
exit(1);
end;
}
table 50100 "Transfer Request Bad"
{
DataClassification = CustomerContent;
fields
{
field(1; "Entry No."; Integer) { }
field(2; "Item No."; Code[20]) { }
field(3; Quantity; Decimal) { }
field(4; "Location Code"; Code[10]) { }
}
keys
{
key(PK; "Entry No.") { Clustered = true; }
}
trigger OnInsert()
var
TransferSetup: Record "Transfer Setup Bad";
begin
TransferSetup.Get();
"Location Code" := TransferSetup."Default Location Code";
end;
}
table 50101 "Transfer Setup Bad"
{
DataClassification = CustomerContent;
fields
{
field(1; "Primary Key"; Code[10]) { }
field(2; "Default Location Code"; Code[10]) { }
field(3; "Open Requests"; Integer) { }
}
keys
{
key(PK; "Primary Key") { Clustered = true; }
}
}
codeunit 50101 "Transfer Request Count Bad"
{
[EventSubscriber(ObjectType::Table, Database::"Transfer Request Bad", OnAfterInsertEvent, '', false, false)]
local procedure CountOpenRequest(var Rec: Record "Transfer Request Bad"; RunTrigger: Boolean)
var
TransferSetup: Record "Transfer Setup Bad";
begin
TransferSetup.Get();
TransferSetup."Open Requests" += 1;
TransferSetup.Modify();
end;
}

View file

@ -0,0 +1,84 @@
codeunit 50100 "Transfer Request Good"
{
// Self-contained demonstration of the best practice. Not derived from base-app source.
procedure RequestFromCompany(TargetCompany: Text[30]; ItemNo: Code[20]; Quantity: Decimal)
var
TransferRequest: Record "Transfer Request Good";
SessionId: Integer;
begin
TransferRequest.Init();
TransferRequest."Item No." := ItemNo;
TransferRequest.Quantity := Quantity;
// The insert runs inside TargetCompany, so OnInsert and the subscriber read that company's setup.
StartSession(SessionId, Codeunit::"Transfer Request Create Good", TargetCompany, TransferRequest);
end;
}
codeunit 50102 "Transfer Request Create Good"
{
TableNo = "Transfer Request Good";
trigger OnRun()
begin
// "Entry No." is AutoIncrement, so concurrent background sessions in TargetCompany never race on the same value.
Rec.Insert(true);
end;
}
table 50100 "Transfer Request Good"
{
DataClassification = CustomerContent;
fields
{
field(1; "Entry No."; Integer) { AutoIncrement = true; }
field(2; "Item No."; Code[20]) { }
field(3; Quantity; Decimal) { }
field(4; "Location Code"; Code[10]) { }
}
keys
{
key(PK; "Entry No.") { Clustered = true; }
}
trigger OnInsert()
var
TransferSetup: Record "Transfer Setup Good";
begin
TransferSetup.Get();
"Location Code" := TransferSetup."Default Location Code";
end;
}
table 50101 "Transfer Setup Good"
{
DataClassification = CustomerContent;
fields
{
field(1; "Primary Key"; Code[10]) { }
field(2; "Default Location Code"; Code[10]) { }
field(3; "Open Requests"; Integer) { }
}
keys
{
key(PK; "Primary Key") { Clustered = true; }
}
}
codeunit 50101 "Transfer Request Count Good"
{
[EventSubscriber(ObjectType::Table, Database::"Transfer Request Good", OnAfterInsertEvent, '', false, false)]
local procedure CountOpenRequest(var Rec: Record "Transfer Request Good"; RunTrigger: Boolean)
var
TransferSetup: Record "Transfer Setup Good";
begin
// Serializes the read-modify-write so concurrent background sessions don't lose an increment.
TransferSetup.LockTable();
TransferSetup.Get();
TransferSetup."Open Requests" += 1;
TransferSetup.Modify();
end;
}

View file

@ -0,0 +1,42 @@
---
bc-version: [all]
domain: events
keywords: [changecompany, cross-company, runtrigger, trigger-event, subscriber, onafterinsertevent, insert, startsession, multi-company]
technologies: [al]
countries: [w1]
application-area: [all]
---
# ChangeCompany leaves triggers and trigger-event subscribers running in the calling company
> Contributions welcome — open a PR to refine or extend this article.
## Description
`ChangeCompany` redirects the data access of one record variable to another company's table. Execution context does not move with it: Microsoft Learn states that triggers still run in the current company, not in the company passed to `ChangeCompany`. Code that knows this usually reaches for `Insert(false)` and copies the trigger's work by hand from the target company's setup. That closes only half of the gap. The runtime raises the database trigger events (`OnBeforeInsertEvent`, `OnAfterInsertEvent`, and their modify, delete, and rename counterparts) on every database operation and only passes the `RunTrigger` flag to the subscriber, so every subscriber that does not exit on `RunTrigger = false` still runs, in the calling company, against the calling company's setup, number series, and companion tables. The row lands in the target company, the side effects land in the caller, and nothing reports an error. The per-row cost of the call is a separate concern, see `changecompany-in-loop-drops-caches`.
## Best Practice
Use `ChangeCompany` to read. Access rights in the target company are still enforced, so reads are safe. When the goal is business data in another company, run the code in that company: `StartSession` takes a company name and runs a codeunit there, so triggers, validation, and subscribers all execute with the target company as their context. `StartSession` is a background session, not a synchronous call: the `Ok` return value reports only whether the session started, not whether the codeunit's work inside it succeeded, the caller's transaction does not extend into it, and an error raised there does not come back to the caller — it has to be logged or telemetered from inside that session. Reach for `StartSession` only for work the caller does not need to confirm before it continues; a write whose success the caller must know synchronously needs a durable status or error channel (a field the caller polls, a job queue with retry) rather than a bare `StartSession` call. Learn notes that a background session costs as much as a user session to start, so batch the work rather than starting one session per row, or let the target company process a hand-off row on its own schedule. A direct cross-company write is acceptable only as such a hand-off into a table the writing extension owns, whose triggers do not read company data and whose trigger-event subscribers exit when `RunTrigger` is false, using `Insert(false)`, `Modify(false)`, or `Delete(false)`, and never `Validate`.
See sample: [`changecompany-runs-triggers-in-the-calling-company.good.al`](changecompany-runs-triggers-in-the-calling-company.good.al).
## Anti Pattern
An `Insert`, `Modify`, `Delete`, or `Validate` on a record variable after `ChangeCompany(<name>)`, on a table whose triggers or trigger-event subscribers read setup, consume a number series, or write companion rows. With `RunTrigger = true` the trigger code fills the row from the caller's setup. With `RunTrigger = false` the trigger code is skipped, but the subscribers still fire in the caller, so a counter, log, or companion row maintained by a subscriber is written in the wrong company, and a caller that also updates the target by hand counts twice.
Detection signal: a record variable that has had `ChangeCompany` called on it with a company name and is later used with `Insert`, `Modify`, `Delete`, or `Validate`, where the table is not owned by the extension, or has triggers that read company data, or has trigger-event subscribers that do not exit on `RunTrigger = false`. Do not flag reads after `ChangeCompany`; writes with `RunTrigger = false` into an owned table whose triggers do not read company data and whose subscribers exit on `RunTrigger = false`; or `ChangeCompany()` without an argument, which points the variable back at the current company.
See sample: [`changecompany-runs-triggers-in-the-calling-company.bad.al`](changecompany-runs-triggers-in-the-calling-company.bad.al).
## See also
- Record.ChangeCompany method, Remarks — https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/methods-auto/record/record-changecompany-method
- Record.Insert(Boolean) method, RunTrigger — https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/methods-auto/record/record-insert-boolean-method
- Record.Delete method, RunTrigger defaults to false — https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/methods-auto/record/record-delete-method
- OnInsert (Table) trigger, Remarks — https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/triggers-auto/table/devenv-oninsert-table-trigger
- Event types, Database trigger events and order of event execution — https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/devenv-event-types
- OnAfterInsertEvent trigger event, RunTrigger parameter — https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/triggers-auto/events/table/devenv-onafterinsertevent-table-trigger
- Session.StartSession method, Company parameter, Remarks (background session, no UI), and Return Value (`Ok` reports whether the session started, not whether the codeunit's work succeeded) — https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/methods-auto/session/session-startsession-integer-integer-string-table-method
- AL error handling, error handling strategies: an error inside a rolled-back transaction is logged from a background session or telemetry, it does not return to the caller — https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/devenv-al-error-handling
- AutoIncrement property, Remarks: "if several transactions are performed at the same time, they will each be assigned a different number" — https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/properties/devenv-autoincrement-property

View file

@ -17,10 +17,10 @@ An `[EventSubscriber]` codeunit is static by default (`EventSubscriberInstance =
Use a static subscriber for behaviour that genuinely applies all the time. For anything scoped, mark the codeunit `EventSubscriberInstance = Manual`, call `BindSubscription(SubscriberInstance)` at the start of the scope and `UnbindSubscription(SubscriberInstance)` at the end. A manual subscriber held only in a local variable unbinds automatically when that variable leaves scope, which suits test setup/teardown; a binding you intend to outlive a single call must be unbound explicitly. Keep subscriber methods `local` per CodeCop AA0207.
See sample: `choose-static-vs-manual-subscribers-deliberately.good.al`.
See sample: [`choose-static-vs-manual-subscribers-deliberately.good.al`](choose-static-vs-manual-subscribers-deliberately.good.al).
## Anti Pattern
Two shapes. First, a static subscriber used for behaviour that should be scoped — an always-on side effect (sending mail, writing extra records) that now fires for every event in every session and test with no way to disable it. Second, a manual subscriber that is bound with `BindSubscription` and never unbound: when the instance is held beyond the intended scope (for example on a `SingleInstance` codeunit), the binding leaks for the whole session and later unrelated operations keep hitting it. Detection: scoped side effects on a static subscriber, or a `BindSubscription` call with no matching `UnbindSubscription` and no scope that releases the instance.
See sample: `choose-static-vs-manual-subscribers-deliberately.bad.al`.
See sample: [`choose-static-vs-manual-subscribers-deliberately.bad.al`](choose-static-vs-manual-subscribers-deliberately.bad.al).

View file

@ -29,7 +29,7 @@ Give an event publisher the narrowest access modifier that still lets the code o
Subscribers are unaffected by any of these choices. A non-public publisher also keeps the freedom to add a parameter later, which a public publisher gives up — see `add-new-event-parameters-at-the-end`.
See sample: `declare-event-publishers-local-or-internal.good.al`.
See sample: [`declare-event-publishers-local-or-internal.good.al`](declare-event-publishers-local-or-internal.good.al).
## Anti Pattern
@ -39,4 +39,4 @@ Detection: an `[IntegrationEvent]` or `[BusinessEvent]` publisher that is public
The mirror-image anti-pattern belongs to the reviewer, human or agent: recommending that a publisher be made public so extensions can subscribe, or reporting a `local`/`internal` publisher as unreachable dead code. Both readings mistake raising for subscribing. Neither should be raised as a finding.
See sample: `declare-event-publishers-local-or-internal.bad.al`.
See sample: [`declare-event-publishers-local-or-internal.bad.al`](declare-event-publishers-local-or-internal.bad.al).

View file

@ -17,10 +17,10 @@ Adding a `var IsHandled: Boolean` parameter to an event that already shipped wit
Keep the existing event as-is and add a separate `OnBeforeX(…; var IsHandled: Boolean)` before the logic you want to make overridable. Two events with distinct, stable contracts are safer than one event whose meaning and signature were changed under its subscribers.
See sample: `do-not-add-ishandled-to-an-existing-event.good.al`.
See sample: [`do-not-add-ishandled-to-an-existing-event.good.al`](do-not-add-ishandled-to-an-existing-event.good.al).
## Anti Pattern
Mutating a shipped event — for example adding `var IsHandled` to `OnAfterCalculateTotal` — to retrofit override behaviour, which overloads the event's meaning and undermines existing subscribers. Detection: an `IsHandled` parameter added to a pre-existing event signature rather than introduced through a new dedicated `OnBefore` publisher.
See sample: `do-not-add-ishandled-to-an-existing-event.bad.al`.
See sample: [`do-not-add-ishandled-to-an-existing-event.bad.al`](do-not-add-ishandled-to-an-existing-event.bad.al).

View file

@ -17,10 +17,10 @@ The IsHandled override pattern lets a subscriber skip the guarded code entirely.
Scope IsHandled to a safe value-calculation block and run the critical operations unconditionally afterwards; or expose a positive `OnAfter…` event for subscribers to adjust results, rather than a bypass around the commit.
See sample: `do-not-bypass-critical-operations-with-ishandled.good.al`.
See sample: [`do-not-bypass-critical-operations-with-ishandled.good.al`](do-not-bypass-critical-operations-with-ishandled.good.al).
## Anti Pattern
An `OnBefore…` IsHandled guard wrapping a posting or ledger routine — `if IsHandled then exit;` around the code that creates ledger entries and updates document status — letting subscribers skip the commit. Detection: an `if IsHandled then exit;` whose skipped body performs posting, ledger writes, number-series consumption, or integrity and permission validation.
See sample: `do-not-bypass-critical-operations-with-ishandled.bad.al`.
See sample: [`do-not-bypass-critical-operations-with-ishandled.bad.al`](do-not-bypass-critical-operations-with-ishandled.bad.al).

View file

@ -17,10 +17,10 @@ application-area: [all]
Keep every available attribute argument exactly as shipped. If new subscribers need different sender/global exposure, publish a new event with the desired flags. Apply the same rule to `Isolated` only on BC20 or later, where that argument exists. Raise both events while the original contract is supported, and choose preferred flags only when designing a new event.
See sample: `do-not-change-shipped-event-attribute-flags.good.al`.
See sample: [`do-not-change-shipped-event-attribute-flags.good.al`](do-not-change-shipped-event-attribute-flags.good.al).
## Anti Pattern
Changing a shipped event's `IncludeSender` or `GlobalVarAccess` to modernize its design, including replacing `IncludeSender` with an explicit parameter. On BC20 or later, adding, removing, or toggling `Isolated` is equally contract-significant. Even a change that leaves old subscribers compiling can alter observable execution or exposure; version the event instead.
See sample: `do-not-change-shipped-event-attribute-flags.bad.al`.
See sample: [`do-not-change-shipped-event-attribute-flags.bad.al`](do-not-change-shipped-event-attribute-flags.bad.al).

View file

@ -17,10 +17,10 @@ Raising an event on every iteration of a loop multiplies the cost of every subsc
Raise `OnBeforeProcessLines` before the loop and `OnAfterProcessLines` after it, outside the `repeat … until`, so each subscriber runs once per batch rather than once per row. Give those events the record or filters they need to operate on the whole set.
See sample: `do-not-publish-events-inside-loops.good.al`.
See sample: [`do-not-publish-events-inside-loops.good.al`](do-not-publish-events-inside-loops.good.al).
## Anti Pattern
An event raised inside the loop body, fired once per iteration, so subscriber cost scales with the row count and large batches slow down or time out. Detection: an `OnBefore…`/`OnAfter…`/`On…` raise located between `repeat` and `until` in a record loop.
See sample: `do-not-publish-events-inside-loops.bad.al`.
See sample: [`do-not-publish-events-inside-loops.bad.al`](do-not-publish-events-inside-loops.bad.al).

View file

@ -25,7 +25,7 @@ Publish the context as a query and let the binding itself be the state. One proc
Bind a fresh instance per run rather than reusing one: the platform refuses to bind the same instance twice but accepts several instances of the same codeunit, so nesting and re-entrancy need no counter. The binding is session-scoped, so work the process starts in another session — a background session, a page background task, a job queue entry — cannot see it; pass the context explicitly there.
See sample: `expose-process-context-via-manually-bound-flag.good.al`.
See sample: [`expose-process-context-via-manually-bound-flag.good.al`](expose-process-context-via-manually-bound-flag.good.al).
## Anti Pattern
@ -37,4 +37,4 @@ Second, the context kept private: the driving app arranges its own marker — ty
The mirror-image anti-pattern belongs to the reviewer: flagging the `BindSubscription` here as a leaked binding because no `UnbindSubscription` follows it. Scope release is the mechanism, not an omission — see `microsoft/knowledge/events/choose-static-vs-manual-subscribers-deliberately.md`, whose leak case is an instance parked on a `SingleInstance` global that never leaves scope.
See sample: `expose-process-context-via-manually-bound-flag.bad.al`.
See sample: [`expose-process-context-via-manually-bound-flag.bad.al`](expose-process-context-via-manually-bound-flag.bad.al).

View file

@ -17,10 +17,10 @@ Event parameter names are part of the public contract a subscriber codes against
Use full, unabbreviated names: `(SalesHeader: Record "Sales Header"; DocumentNo: Code[20]; Amount: Decimal)`. Record parameters mirror the table name without spaces, and value parameters read as whole words so the contract is unambiguous.
See sample: `name-event-parameters-without-abbreviations.good.al`.
See sample: [`name-event-parameters-without-abbreviations.good.al`](name-event-parameters-without-abbreviations.good.al).
## Anti Pattern
Abbreviated parameter names (`SalesHdr`, `DocNo`, `Amt`) that obscure meaning and vary across publishers, so subscribers must guess what each one holds. Detection: event parameters whose names are truncated forms of the table name or contracted words rather than the full term.
See sample: `name-event-parameters-without-abbreviations.bad.al`.
See sample: [`name-event-parameters-without-abbreviations.bad.al`](name-event-parameters-without-abbreviations.bad.al).

View file

@ -17,10 +17,10 @@ An event name should tell a subscriber where in the publisher the event fires. T
Name by position: `OnBeforePostSalesLine` and `OnAfterPostSalesLine` at the routine boundaries, and `OnPostSalesLineOnAfterCalcAmounts` for an event raised partway through `PostSalesLine` after an amount calculation. The name alone then tells a subscriber both the host routine and the exact point it runs.
See sample: `name-events-by-publisher-position.good.al`.
See sample: [`name-events-by-publisher-position.good.al`](name-events-by-publisher-position.good.al).
## Anti Pattern
Ad-hoc event names that omit the host routine or the before/after position (`MyCustomSalesEvent`, `BeforePost`, `SalesLineEvent`), leaving subscribers unable to tell when the event fires relative to the publisher's logic. Detection: publisher names that do not follow the `OnBefore`/`OnAfter<Routine>` or `On<Routine>OnBefore`/`OnAfter<Context>` patterns.
See sample: `name-events-by-publisher-position.bad.al`.
See sample: [`name-events-by-publisher-position.bad.al`](name-events-by-publisher-position.bad.al).

View file

@ -17,10 +17,10 @@ Before adding a publisher, check whether an event already fires at that point in
When the data you need is already exposed at an existing event, subscribe to it. When the event lacks a parameter, extend that event by appending the parameter at the end — one publisher, one raise — rather than adding a second event beside it.
See sample: `prefer-reusing-or-extending-existing-events.good.al`.
See sample: [`prefer-reusing-or-extending-existing-events.good.al`](prefer-reusing-or-extending-existing-events.good.al).
## Anti Pattern
Adding a second event raise immediately after an existing one, or creating `OnBeforeProcessOrderWithCustomer` next to `OnBeforeProcessOrder` just to add a single parameter. Detection: two consecutive `OnBefore…`/`OnAfter…` raises with no logic between them, or near-duplicate event names differing only by a parameter-describing suffix.
See sample: `prefer-reusing-or-extending-existing-events.bad.al`.
See sample: [`prefer-reusing-or-extending-existing-events.bad.al`](prefer-reusing-or-extending-existing-events.bad.al).

View file

@ -17,10 +17,10 @@ When designing a new publisher, setting `IncludeSender` to `true` on `[Integrati
For a new event, declare the publisher `[IntegrationEvent(false, false)]` with an explicit `Sender: Codeunit "…"` parameter and raise it with `this`, for example `OnBeforeProcessOrder(OrderNo, this);`. Subscribers then receive a typed sender they can call directly.
See sample: `prefer-this-over-includesender-in-codeunit-events.good.al`.
See sample: [`prefer-this-over-includesender-in-codeunit-events.good.al`](prefer-this-over-includesender-in-codeunit-events.good.al).
## Anti Pattern
Designing a new codeunit event with `[IntegrationEvent(true, …)]` solely to hand subscribers the publisher instance, where `this` could be passed explicitly as a typed parameter. Do not apply this rule by mutating a shipped event's attribute flags.
See sample: `prefer-this-over-includesender-in-codeunit-events.bad.al`.
See sample: [`prefer-this-over-includesender-in-codeunit-events.bad.al`](prefer-this-over-includesender-in-codeunit-events.bad.al).

View file

@ -17,10 +17,10 @@ When a record passed to an event is a temporary record — an in-memory buffer n
Name temporary record parameters with a `Temp` prefix, for example `var TempSalesLineBuffer: Record "Sales Line" temporary`, so every subscriber sees immediately that the record is an in-memory buffer and treats writes accordingly.
See sample: `prefix-temporary-record-event-parameters-with-temp.good.al`.
See sample: [`prefix-temporary-record-event-parameters-with-temp.good.al`](prefix-temporary-record-event-parameters-with-temp.good.al).
## Anti Pattern
A temporary record parameter named without the `Temp` prefix (`var SalesLineBuffer: Record "Sales Line" temporary`), so subscribers cannot tell the record is non-persistent and may rely on writes that are silently discarded. Detection: an event parameter declared `temporary` whose name does not start with `Temp`.
See sample: `prefix-temporary-record-event-parameters-with-temp.bad.al`.
See sample: [`prefix-temporary-record-event-parameters-with-temp.bad.al`](prefix-temporary-record-event-parameters-with-temp.bad.al).

View file

@ -17,10 +17,10 @@ A routine that exposes both an `OnBefore…` event (with `var IsHandled`) and a
Wrap only the default work in `if not IsHandled then begin … end;` and keep the `OnAfterX(…)` raise after that block, outside the guard, so it always fires regardless of whether a subscriber handled the OnBefore. This keeps the override seam and the after-notification independent, which is what subscribers expect.
See sample: `preserve-onafter-execution-when-ishandled-skips-the-body.good.al`.
See sample: [`preserve-onafter-execution-when-ishandled-skips-the-body.good.al`](preserve-onafter-execution-when-ishandled-skips-the-body.good.al).
## Anti Pattern
Guarding with `if IsHandled then exit;` and placing the `OnAfterX` raise later in the same routine, so handling the OnBefore short-circuits the whole procedure and the OnAfter event is skipped along with the body. Detection: an `if IsHandled then exit;` in a routine that also raises a paired `OnAfter…` event after that point.
See sample: `preserve-onafter-execution-when-ishandled-skips-the-body.bad.al`.
See sample: [`preserve-onafter-execution-when-ishandled-skips-the-body.bad.al`](preserve-onafter-execution-when-ishandled-skips-the-body.bad.al).

View file

@ -17,10 +17,10 @@ A key operation — a posting, release, or validation routine — becomes a hard
Wrap the operation's core with events: raise `OnBeforeX(var Rec, var IsHandled)` before the default work and `OnAfterX(var Rec)` once it succeeds, at the natural boundaries of the routine. Declare each publisher `[IntegrationEvent(false, false)] local procedure` with an empty body and let the calling routine — never the publisher — own the logic. Pass records by `var` so subscribers can read and adjust them, and include the parameters a subscriber would need to act. This gives partners a stable seam without touching base code.
See sample: `publish-thin-onbefore-onafter-integration-events.good.al`.
See sample: [`publish-thin-onbefore-onafter-integration-events.good.al`](publish-thin-onbefore-onafter-integration-events.good.al).
## Anti Pattern
Business logic placed inside an `[IntegrationEvent]` publisher method, so the "event" actually mutates state every time it is raised — defeating the hook and surprising every reader — or a core operation that exposes no extension points at all, forcing partners to overwrite or duplicate it. Detection: an `[IntegrationEvent]`/`[BusinessEvent]` method whose body contains statements rather than being empty, or a posting/validation routine with no surrounding `OnBefore`/`OnAfter` publishers.
See sample: `publish-thin-onbefore-onafter-integration-events.bad.al`.
See sample: [`publish-thin-onbefore-onafter-integration-events.bad.al`](publish-thin-onbefore-onafter-integration-events.bad.al).

View file

@ -17,10 +17,10 @@ A routine that raises an `OnBefore…` integration event with a `var IsHandled:
Reset `IsHandled := false;` before a raise only when the value might otherwise carry over as `true`: the same variable is reused after an earlier raise without a control-flow proof that it is false, a raise is re-entered by a loop, the value comes from an input parameter, field, or global, or earlier code seeds it. Prefer separate fresh locals when independent event seams need independent handled state. A reset on a guaranteed-false fresh local used by one non-looping raise, or before a later raise reached only after a semantically valid `if IsHandled then exit;`, can be retained for readability, but its absence is not a correctness finding.
See sample: `reset-ishandled-only-when-the-value-can-carry-over.good.al`.
See sample: [`reset-ishandled-only-when-the-value-can-carry-over.good.al`](reset-ishandled-only-when-the-value-can-carry-over.good.al).
## Anti Pattern
Raising `OnBeforeX(…, IsHandled)` when the variable can still be `true` from an earlier raise, an earlier loop iteration, or another source, so the publisher call starts with stale state. Do not match a single non-looping raise using a fresh local Boolean, or a later raise reached only after a semantically valid `if IsHandled then exit;` proves the value is false.
See sample: `reset-ishandled-only-when-the-value-can-carry-over.bad.al`.
See sample: [`reset-ishandled-only-when-the-value-can-carry-over.bad.al`](reset-ishandled-only-when-the-value-can-carry-over.bad.al).

View file

@ -17,10 +17,10 @@ The `local` and `internal` access modifiers on Business and Integration event pu
Preserve a shipped Business or Integration event's identity and every existing parameter's name, type/subtype, and passing mode regardless of the procedure access modifier. AS0025 protects names and types, while AS0063 and AS0077 protect removal and addition of `var`. New parameters may be added at any position on a `local` or `internal` event because subscribers can omit them; public event procedures follow the stricter caller contract described by `add-new-event-parameters-at-the-end`.
See sample: `treat-local-and-internal-events-as-subscriber-contracts.good.al`.
See sample: [`treat-local-and-internal-events-as-subscriber-contracts.good.al`](treat-local-and-internal-events-as-subscriber-contracts.good.al).
## Anti Pattern
Renaming or removing an existing parameter, changing its type/subtype, or adding/removing its `var` modifier because the event publisher procedure is `local` or `internal`. AppSourceCop checks these subscriber-breaking changes because dependent event subscribers can still bind to the event. Reordering unchanged parameters, or inserting a new parameter among them, is not this anti-pattern.
See sample: `treat-local-and-internal-events-as-subscriber-contracts.bad.al`.
See sample: [`treat-local-and-internal-events-as-subscriber-contracts.bad.al`](treat-local-and-internal-events-as-subscriber-contracts.bad.al).

View file

@ -17,10 +17,10 @@ AL has no method overriding, so a `procedure` that runs its body unconditionally
Raise `OnBeforeX(…, IsHandled)` as the first step of the routine and guard with `if IsHandled then exit;` before any default logic runs. Declare the publisher `[IntegrationEvent(false, false)] local procedure OnBeforeX(…; var IsHandled: Boolean)` with an empty body, and keep `IsHandled` a `var` parameter so a subscriber can write to it. A subscriber that replaces the behaviour does its work and sets `IsHandled := true`; one that only augments leaves it untouched and guards with `if IsHandled then exit;` itself. Reserve the override hook for cases where a partner genuinely needs to replace logic — when the goal is only to react, a positive `OnAfter` event is the better seam.
See sample: `use-ishandled-to-make-base-behaviour-overridable.good.al`.
See sample: [`use-ishandled-to-make-base-behaviour-overridable.good.al`](use-ishandled-to-make-base-behaviour-overridable.good.al).
## Anti Pattern
Two shapes. First, a routine whose default logic always runs because there is no `OnBefore…`/`IsHandled` hook at all — extensions cannot change it without overwriting base code. Second, a routine that raises `OnBeforeX(IsHandled)` but omits the `if IsHandled then exit;` guard, so the default logic still executes after a subscriber set `IsHandled := true`, duplicating work and side effects. Detection: an `OnBefore` publisher with a `var IsHandled: Boolean` parameter whose caller never tests `IsHandled`, or a public routine doing non-trivial work with no overridable seam.
See sample: `use-ishandled-to-make-base-behaviour-overridable.bad.al`.
See sample: [`use-ishandled-to-make-base-behaviour-overridable.bad.al`](use-ishandled-to-make-base-behaviour-overridable.bad.al).

View file

@ -0,0 +1,33 @@
// Demonstration only; independently authored, not copied from BaseApp.
codeunit 50104 "Customer Settlement Actions"
{
procedure RequestApplication(CustomerEntryNo: Integer)
var
CustomerEntry: Record "Cust. Ledger Entry";
begin
RequireInteractiveSession();
CustomerEntry.Get(CustomerEntryNo);
CustomerEntry.TestField(Open, true);
CustomerEntry.Open := false;
CustomerEntry.Modify(true);
end;
procedure RequestUnapplication(CustomerEntryNo: Integer)
var
DetailedCustomerEntry: Record "Detailed Cust. Ledg. Entry";
begin
RequireInteractiveSession();
DetailedCustomerEntry.SetRange("Cust. Ledger Entry No.", CustomerEntryNo);
DetailedCustomerEntry.SetRange("Entry Type", DetailedCustomerEntry."Entry Type"::Application);
DetailedCustomerEntry.ModifyAll(Unapplied, true);
end;
local procedure RequireInteractiveSession()
begin
if not GuiAllowed() then
Error(InteractiveSessionErr);
end;
var
InteractiveSessionErr: Label 'Request settlement from an interactive session.';
}

View file

@ -0,0 +1,31 @@
// Demonstration only; independently authored, not copied from BaseApp.
codeunit 50104 "Customer Settlement Actions"
{
procedure RequestApplication(CustomerEntryNo: Integer)
var
CustomerEntry: Record "Cust. Ledger Entry";
CustomerApplication: Codeunit "CustEntry-Apply Posted Entries";
begin
RequireInteractiveSession();
CustomerEntry.Get(CustomerEntryNo);
CustomerEntry.TestField(Open, true);
CustomerApplication.ApplyCustEntryFormEntry(CustomerEntry);
end;
procedure RequestUnapplication(CustomerEntryNo: Integer)
var
CustomerApplication: Codeunit "CustEntry-Apply Posted Entries";
begin
RequireInteractiveSession();
CustomerApplication.UnApplyCustLedgEntry(CustomerEntryNo);
end;
local procedure RequireInteractiveSession()
begin
if not GuiAllowed() then
Error(InteractiveSessionErr);
end;
var
InteractiveSessionErr: Label 'Request settlement from an interactive session.';
}

View file

@ -0,0 +1,37 @@
---
bc-version: [all]
domain: finance
keywords: [cust-ledger-entry, vendor-ledger-entry, detailed-ledger-entry, remaining-amount, application, unapplication, open, closed-by-entry-no]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Apply and unapply entries through the application workflow, not status flags
## Description
Customer/vendor settlement is a posting operation involving detailed ledger entries, not just a change to `Open` on the main entry. Remaining amounts are calculated from detailed entries. Unapplication posts correcting entries and handles application-derived effects such as discounts and currency gains/losses; deleting details or changing `Unapplied` cannot reproduce that history.
## Best Practice
Use `"CustEntry-Apply Posted Entries"` / `"VendEntry-Apply Posted Entries"` and the supported application or unapplication workflow. Let it check application dates, entry state, and application ordering. The `ApplyCustEntryFormEntry` / `ApplyVendEntryFormEntry` and `UnApply...LedgEntry` methods are **interactive**: users select and confirm the operation. They are not unattended "mark paid" APIs.
For programmatic posting, use the target version's public `Apply` / `PostUnApply...` APIs with properly prepared selection and `Apply Unapply Parameters`; handle cancellation and the workflow's transaction/commit behavior. `"Applying Entry"`, `"Applies-to ID"`, and `"Amount to Apply"` are legitimate application-preparation fields. Do not report their writes alone, temporary buffers, supported posting/compression internals, or unrelated operational/extension fields.
See sample: [`apply-ledger-entries-through-application-codeunits.good.al`](apply-ledger-entries-through-application-codeunits.good.al).
## Anti Pattern
Implement customer/vendor payment matching, settlement, or reopening by directly persisting `Open`, `"Closed by Entry No."`, closure amounts/dates, or detailed-entry unapplication flags, or by deleting/rewriting detailed application amounts. Require confirmed writes to existing non-temporary customer/vendor or detailed customer/vendor entries and settlement intent. Item/inventory application records belong to SCM, not this rule. Do not suggest assigning a `Remaining Amount` FlowField as a fix.
This article owns fabricated application state. Use the [posted-financial-content rule](do-not-modify-or-delete-posted-ledger-entries.md) for original accounting-value corrections, not a second finding prescribing the same application fix.
See sample: [`apply-ledger-entries-through-application-codeunits.bad.al`](apply-ledger-entries-through-application-codeunits.bad.al).
## References
- [Apply and unapply customer transactions](https://learn.microsoft.com/en-us/dynamics365/business-central/receivables-how-apply-sales-transactions-manually).
- [Apply and unapply vendor transactions](https://learn.microsoft.com/en-us/dynamics365/business-central/payables-how-apply-purchase-transactions-manually).
- [BCApps: customer application workflow](https://github.com/microsoft/BCApps/blob/8f7a04cb0db8aa96cb97e055c45c61aead49e280/src/Layers/W1/BaseApp/Sales/Receivables/CustEntryApplyPostedEntries.Codeunit.al).
- [BCApps: vendor application workflow](https://github.com/microsoft/BCApps/blob/8f7a04cb0db8aa96cb97e055c45c61aead49e280/src/Layers/W1/BaseApp/Purchases/Payables/VendEntryApplyPostedEntries.Codeunit.al).

View file

@ -0,0 +1,21 @@
// Demonstration only; independently authored, not copied from BaseApp.
codeunit 50105 "Update Ledger Due Dates"
{
procedure UpdateCustomerDueDate(EntryNo: Integer; NewDueDate: Date)
var
CustomerEntry: Record "Cust. Ledger Entry";
begin
CustomerEntry.Get(EntryNo);
CustomerEntry.Validate("Due Date", NewDueDate);
CustomerEntry.Modify(true);
end;
procedure UpdateVendorDueDate(EntryNo: Integer; NewDueDate: Date)
var
VendorEntry: Record "Vendor Ledger Entry";
begin
VendorEntry.Get(EntryNo);
VendorEntry.Validate("Due Date", NewDueDate);
VendorEntry.Modify(true);
end;
}

View file

@ -0,0 +1,21 @@
// Demonstration only; independently authored, not copied from BaseApp.
codeunit 50105 "Update Ledger Due Dates"
{
procedure UpdateCustomerDueDate(EntryNo: Integer; NewDueDate: Date)
var
CustomerEntry: Record "Cust. Ledger Entry";
begin
CustomerEntry.Get(EntryNo);
CustomerEntry.Validate("Due Date", NewDueDate);
Codeunit.Run(Codeunit::"Cust. Entry-Edit", CustomerEntry);
end;
procedure UpdateVendorDueDate(EntryNo: Integer; NewDueDate: Date)
var
VendorEntry: Record "Vendor Ledger Entry";
begin
VendorEntry.Get(EntryNo);
VendorEntry.Validate("Due Date", NewDueDate);
Codeunit.Run(Codeunit::"Vend. Entry-Edit", VendorEntry);
end;
}

View file

@ -0,0 +1,35 @@
---
bc-version: [all]
domain: finance
keywords: [due-date, initial-entry-due-date, cust-entry-edit, vend-entry-edit, detailed-ledger-entry, aging]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Change posted customer/vendor due dates through the entry-edit workflow
## Description
A posted customer or vendor due date is supported editable operational data, but it is also represented by `Initial Entry Due Date` on related detailed ledger entries. Validating `Due Date` and calling `Modify(true)` on the main entry does not perform all synchronization done by `"Cust. Entry-Edit"` or `"Vend. Entry-Edit"`. The main entry and due-date-based analysis can otherwise disagree.
## Best Practice
Fetch the existing entry, validate the proposed `Due Date`, and pass the changed record to the corresponding entry-edit codeunit, as the standard ledger pages do. Field validation enforces entry-state rules; the editor persists the supported change and synchronizes related detailed entries. Preserve both steps rather than treating table triggers as equivalent to the edit workflow.
This is a due-date synchronization rule, not a prohibition on all operational edits after posting. Exclude temporary buffers, extension-only fields, supported editor internals, and code that demonstrably performs the equivalent synchronization under the supported workflow. Check the actual table/routine instead of assuming every `*Entry-Edit` accepts the same fields.
See sample: [`change-ledger-due-dates-through-entry-edit.good.al`](change-ledger-due-dates-through-entry-edit.good.al).
## Anti Pattern
Change `Due Date` on an existing non-temporary `Cust. Ledger Entry` or `Vendor Ledger Entry` and persist it with `Modify`, `Modify(true)`, or `ModifyAll` without the edit workflow or equivalent related-entry update. A preceding `Validate("Due Date", ...)` is not sufficient evidence of synchronization.
See sample: [`change-ledger-due-dates-through-entry-edit.bad.al`](change-ledger-due-dates-through-entry-edit.bad.al).
## References
- [Cust. Entry-Edit API](https://learn.microsoft.com/en-us/dynamics365/business-central/application/base-application/codeunit/microsoft.sales.receivables.cust.-entry-edit).
- [Vend. Entry-Edit API](https://learn.microsoft.com/en-us/dynamics365/business-central/application/base-application/codeunit/microsoft.purchases.payables.vend.-entry-edit).
- [BCApps: customer due-date synchronization](https://github.com/microsoft/BCApps/blob/8f7a04cb0db8aa96cb97e055c45c61aead49e280/src/Layers/W1/BaseApp/Sales/Receivables/CustEntryEdit.Codeunit.al).
- [BCApps: vendor due-date synchronization](https://github.com/microsoft/BCApps/blob/8f7a04cb0db8aa96cb97e055c45c61aead49e280/src/Layers/W1/BaseApp/Purchases/Payables/VendEntryEdit.Codeunit.al).

View file

@ -0,0 +1,14 @@
// Demonstration only; independently authored, not copied from BaseApp.
codeunit 50103 "Change Journal Dimension"
{
procedure ChangeExistingDimension(TemplateName: Code[10]; BatchName: Code[10]; LineNo: Integer; DimensionCode: Code[20]; NewValue: Code[20])
var
JournalLine: Record "Gen. Journal Line";
DimensionSetEntry: Record "Dimension Set Entry";
begin
JournalLine.Get(TemplateName, BatchName, LineNo);
DimensionSetEntry.Get(JournalLine."Dimension Set ID", DimensionCode);
DimensionSetEntry.Validate("Dimension Value Code", NewValue);
DimensionSetEntry.Modify();
end;
}

View file

@ -0,0 +1,18 @@
// Demonstration only; independently authored, not copied from BaseApp.
codeunit 50103 "Change Journal Dimension"
{
procedure ChangeExistingDimension(TemplateName: Code[10]; BatchName: Code[10]; LineNo: Integer; DimensionCode: Code[20]; NewValue: Code[20])
var
JournalLine: Record "Gen. Journal Line";
TempDimensionSetEntry: Record "Dimension Set Entry" temporary;
DimensionManagement: Codeunit DimensionManagement;
begin
JournalLine.Get(TemplateName, BatchName, LineNo);
DimensionManagement.GetDimensionSet(TempDimensionSetEntry, JournalLine."Dimension Set ID");
TempDimensionSetEntry.Get(JournalLine."Dimension Set ID", DimensionCode);
TempDimensionSetEntry.Validate("Dimension Value Code", NewValue);
TempDimensionSetEntry.Modify();
JournalLine.Validate("Dimension Set ID", DimensionManagement.GetDimensionSetID(TempDimensionSetEntry));
JournalLine.Modify(true);
end;
}

View file

@ -0,0 +1,35 @@
---
bc-version: [all]
domain: finance
keywords: [dimension-set-entry, dimension-value-id, getdimensionset, getdimensionsetid, posted-dimensions, temporary]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Change a transaction's dimension set reference, not a shared set's membership
## Description
The same `Dimension Set ID` can be referenced by an unposted journal line and by many already-posted entries. Editing or deleting the persisted set's dimension/value rows therefore changes the meaning of unrelated transactions, including posting history. The dimension-set search tree also relies on those combinations remaining stable; a set is not a mutable child collection owned by one journal line.
## Best Practice
To change an unposted transaction's dimensions, load its set into a **temporary** `Dimension Set Entry` buffer with `DimensionManagement.GetDimensionSet`, change the buffer, and obtain a reusable ID with `GetDimensionSetID`. Validate dimension values in the buffer so `Dimension Value ID` matches the chosen value. Store the resulting ID on the transaction and synchronize its projections through that record's supported dimension validation.
For already-posted G/L dimensions, use the supported dimension-correction workflow rather than changing shared rows. Read-only access, temporary buffers, and standard maintenance of projection metadata such as `Global Dimension No.` are not membership changes. This rule protects dimension sets reached from general-journal, financial-document, or Finance-ledger flows. It does not own Item, Value, Capacity, Warehouse, or inventory-application record writes, or prescribe custom-table/default-dimension wiring.
See sample: [`do-not-edit-shared-dimension-sets.good.al`](do-not-edit-shared-dimension-sets.good.al).
## Anti Pattern
Follow a general-journal, financial-document, or Finance-ledger `Dimension Set ID` to a **persistent** `Dimension Set Entry` and modify, rename, or delete its dimension/value membership in order to change that one transaction. Inspect `IsTemporary` guards, aliases, and the fields written before reporting: the same operations on a temporary working copy are expected.
See sample: [`do-not-edit-shared-dimension-sets.bad.al`](do-not-edit-shared-dimension-sets.bad.al).
## References
- [Dimension set entries overview](https://learn.microsoft.com/en-us/dynamics365/business-central/design-details-dimension-set-entries-overview).
- [Supported G/L dimension correction](https://learn.microsoft.com/en-us/dynamics365/business-central/finance-troubleshooting-correcting-dimensions).
- [DimensionManagement API](https://learn.microsoft.com/en-us/dynamics365/business-central/application/base-application/codeunit/microsoft.finance.dimension.dimensionmanagement).
- [BCApps: Dimension Set Entry and its set-ID resolver](https://github.com/microsoft/BCApps/blob/8f7a04cb0db8aa96cb97e055c45c61aead49e280/src/Layers/W1/BaseApp/Finance/Dimension/DimensionSetEntry.Table.al).

View file

@ -0,0 +1,32 @@
// Demonstration only; independently authored, not copied from BaseApp.
codeunit 50101 "Correct Posted Transaction"
{
procedure RequestTransactionReversal(EntryNo: Integer)
var
GLEntry: Record "G/L Entry";
begin
if not GuiAllowed() then
Error(InteractiveSessionErr);
GLEntry.Get(EntryNo);
GLEntry.TestField("Transaction No.");
GLEntry.Amount := -GLEntry.Amount;
GLEntry.Modify(true);
end;
procedure UpdateDescription(EntryNo: Integer; NewDescription: Text[100])
var
GLEntry: Record "G/L Entry";
begin
GLEntry.Get(EntryNo);
GLEntry.Description := NewDescription;
Codeunit.Run(Codeunit::"G/L Entry-Edit", GLEntry);
end;
procedure ClearSimulation(var TempGLEntry: Record "G/L Entry" temporary)
begin
TempGLEntry.DeleteAll();
end;
var
InteractiveSessionErr: Label 'Request the reversal from an interactive session.';
}

View file

@ -0,0 +1,32 @@
// Demonstration only; independently authored, not copied from BaseApp.
codeunit 50101 "Correct Posted Transaction"
{
procedure RequestTransactionReversal(EntryNo: Integer)
var
GLEntry: Record "G/L Entry";
ReversalEntry: Record "Reversal Entry";
begin
if not GuiAllowed() then
Error(InteractiveSessionErr);
GLEntry.Get(EntryNo);
GLEntry.TestField("Transaction No.");
ReversalEntry.ReverseTransaction(GLEntry."Transaction No.");
end;
procedure UpdateDescription(EntryNo: Integer; NewDescription: Text[100])
var
GLEntry: Record "G/L Entry";
begin
GLEntry.Get(EntryNo);
GLEntry.Description := NewDescription;
Codeunit.Run(Codeunit::"G/L Entry-Edit", GLEntry);
end;
procedure ClearSimulation(var TempGLEntry: Record "G/L Entry" temporary)
begin
TempGLEntry.DeleteAll();
end;
var
InteractiveSessionErr: Label 'Request the reversal from an interactive session.';
}

View file

@ -0,0 +1,37 @@
---
bc-version: [all]
domain: finance
keywords: [g-l-entry, ledger-entry, reversal, audit-trail, correction, financial-content, entry-edit]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Correct posted financial content through posting workflows, not row surgery
## Description
Changing a posted entry's original amount, account, posting date, or tax amounts in place does not correct the related ledger, register, or source document. Deleting one erroneous row has the same problem. Business Central provides reversing and correcting posting workflows that retain the relationship between the original transaction and its correction; this is not a blanket prohibition on every write to a posted table.
## Best Practice
Use a supported transaction/register reversal, credit memo, or correcting journal appropriate to the original posting and its current state. Request the standard reversal workflow rather than negating a single row or setting `Reversed` yourself. Let that workflow enforce eligibility; do not change source or application fields to make a rejected reversal pass.
Supported operational edits are deliberate exceptions: for example, `"G/L Entry-Edit"` supports description changes, and `"Cust. Entry-Edit"` / `"Vend. Entry-Edit"` handle their table-specific editable fields. [Due-date synchronization](change-ledger-due-dates-through-entry-edit.md), [application/unapplication](apply-ledger-entries-through-application-codeunits.md), G/L dimension correction, and supported date compression have their own workflows. Do not flag their standard implementations, temporary simulation buffers, or extension-only metadata updates as financial row surgery. A subscriber is not exempt merely because it runs inside a supported workflow: inspect the fields it actually changes.
This rule covers G/L, customer/vendor/detailed, VAT, and financial-posting/register records. Item, Value, Capacity, Warehouse, inventory-application, and other inventory-posting records are SCM concerns. The financial-row leg of one inventory-posting bypass is outside this rule when the same inventory correction resolves it; an independently actionable financial defect remains in scope regardless of the containing module's name.
See sample: [`do-not-modify-or-delete-posted-ledger-entries.good.al`](do-not-modify-or-delete-posted-ledger-entries.good.al).
## Anti Pattern
Persist a change to original financial content, delete posted rows, or fabricate reversal flags/links to repair or undo a transaction outside the supported correction/maintenance workflow. Require an existing, non-temporary Finance-owned record and evidence of the fields or rows affected; a `Modify` token or `*Ledger Entry` name alone is insufficient. Settlement-state writes belong to the application article rather than a duplicate finding here.
See sample: [`do-not-modify-or-delete-posted-ledger-entries.bad.al`](do-not-modify-or-delete-posted-ledger-entries.bad.al).
## References
- [Reverse journal postings](https://learn.microsoft.com/en-us/dynamics365/business-central/finance-how-reverse-journal-posting).
- [Correct G/L dimensions](https://learn.microsoft.com/en-us/dynamics365/business-central/finance-troubleshooting-correcting-dimensions).
- [BCApps: G/L Entry-Edit](https://github.com/microsoft/BCApps/blob/8f7a04cb0db8aa96cb97e055c45c61aead49e280/src/Layers/W1/BaseApp/Finance/GeneralLedger/Ledger/GLEntryEdit.Codeunit.al).
- [BCApps: supported customer-ledger date compression](https://github.com/microsoft/BCApps/blob/8f7a04cb0db8aa96cb97e055c45c61aead49e280/src/Layers/W1/BaseApp/Sales/Receivables/DateCompressCustomerLedger.Report.al).

View file

@ -0,0 +1,54 @@
// Demonstration only; independently authored, not copied from BaseApp.
codeunit 50108 "Import Purchase Journal Total"
{
procedure ImportExampleTotal(TemplateName: Code[10]; BatchName: Code[10]; LineNo: Integer)
var
JournalLine: Record "Gen. Journal Line";
VATPostingSetup: Record "VAT Posting Setup";
GeneralLedgerSetup: Record "General Ledger Setup";
SourceInvoice: JsonObject;
NetToken: JsonToken;
VATToken: JsonToken;
GrossToken: JsonToken;
ImportedNet: Decimal;
ImportedVAT: Decimal;
ImportedGross: Decimal;
begin
SourceInvoice.ReadFrom('{"netAmount":100,"vatAmount":25,"grossAmount":125}');
SourceInvoice.Get('netAmount', NetToken);
SourceInvoice.Get('vatAmount', VATToken);
SourceInvoice.Get('grossAmount', GrossToken);
ImportedNet := NetToken.AsValue().AsDecimal();
ImportedVAT := VATToken.AsValue().AsDecimal();
ImportedGross := GrossToken.AsValue().AsDecimal();
if ImportedGross <> ImportedNet + ImportedVAT then
Error(TotalsErr);
JournalLine.Get(TemplateName, BatchName, LineNo);
JournalLine.TestField("Account Type", JournalLine."Account Type"::"G/L Account");
JournalLine.TestField("Bal. Account Type", JournalLine."Bal. Account Type"::"G/L Account");
JournalLine.TestField("Account No.");
JournalLine.TestField("Bal. Account No.");
JournalLine.TestField("Currency Code", '');
JournalLine.TestField("Gen. Posting Type", JournalLine."Gen. Posting Type"::Purchase);
JournalLine.TestField("VAT Posting", JournalLine."VAT Posting"::"Automatic VAT Entry");
JournalLine.TestField("VAT Calculation Type", JournalLine."VAT Calculation Type"::"Normal VAT");
JournalLine.TestField("VAT %", 25);
JournalLine.TestField("VAT Difference", 0);
JournalLine.TestField("Bal. Gen. Posting Type", JournalLine."Bal. Gen. Posting Type"::" ");
JournalLine.TestField("Bal. VAT %", 0);
VATPostingSetup.Get(JournalLine."VAT Bus. Posting Group", JournalLine."VAT Prod. Posting Group");
VATPostingSetup.TestField("VAT Calculation Type", VATPostingSetup."VAT Calculation Type"::"Normal VAT");
VATPostingSetup.TestField("VAT %", 25);
VATPostingSetup.TestField("Unrealized VAT Type", VATPostingSetup."Unrealized VAT Type"::" ");
GeneralLedgerSetup.Get();
GeneralLedgerSetup.TestField("Additional Reporting Currency", '');
GeneralLedgerSetup.TestField("Amount Rounding Precision", 0.01);
JournalLine.Validate(Amount, ImportedNet);
JournalLine.Modify(true);
end;
var
TotalsErr: Label 'The invoice total must equal its net amount plus VAT.';
}

View file

@ -0,0 +1,54 @@
// Demonstration only; independently authored, not copied from BaseApp.
codeunit 50108 "Import Purchase Journal Total"
{
procedure ImportExampleTotal(TemplateName: Code[10]; BatchName: Code[10]; LineNo: Integer)
var
JournalLine: Record "Gen. Journal Line";
VATPostingSetup: Record "VAT Posting Setup";
GeneralLedgerSetup: Record "General Ledger Setup";
SourceInvoice: JsonObject;
NetToken: JsonToken;
VATToken: JsonToken;
GrossToken: JsonToken;
ImportedNet: Decimal;
ImportedVAT: Decimal;
ImportedGross: Decimal;
begin
SourceInvoice.ReadFrom('{"netAmount":100,"vatAmount":25,"grossAmount":125}');
SourceInvoice.Get('netAmount', NetToken);
SourceInvoice.Get('vatAmount', VATToken);
SourceInvoice.Get('grossAmount', GrossToken);
ImportedNet := NetToken.AsValue().AsDecimal();
ImportedVAT := VATToken.AsValue().AsDecimal();
ImportedGross := GrossToken.AsValue().AsDecimal();
if ImportedGross <> ImportedNet + ImportedVAT then
Error(TotalsErr);
JournalLine.Get(TemplateName, BatchName, LineNo);
JournalLine.TestField("Account Type", JournalLine."Account Type"::"G/L Account");
JournalLine.TestField("Bal. Account Type", JournalLine."Bal. Account Type"::"G/L Account");
JournalLine.TestField("Account No.");
JournalLine.TestField("Bal. Account No.");
JournalLine.TestField("Currency Code", '');
JournalLine.TestField("Gen. Posting Type", JournalLine."Gen. Posting Type"::Purchase);
JournalLine.TestField("VAT Posting", JournalLine."VAT Posting"::"Automatic VAT Entry");
JournalLine.TestField("VAT Calculation Type", JournalLine."VAT Calculation Type"::"Normal VAT");
JournalLine.TestField("VAT %", 25);
JournalLine.TestField("VAT Difference", 0);
JournalLine.TestField("Bal. Gen. Posting Type", JournalLine."Bal. Gen. Posting Type"::" ");
JournalLine.TestField("Bal. VAT %", 0);
VATPostingSetup.Get(JournalLine."VAT Bus. Posting Group", JournalLine."VAT Prod. Posting Group");
VATPostingSetup.TestField("VAT Calculation Type", VATPostingSetup."VAT Calculation Type"::"Normal VAT");
VATPostingSetup.TestField("VAT %", 25);
VATPostingSetup.TestField("Unrealized VAT Type", VATPostingSetup."Unrealized VAT Type"::" ");
GeneralLedgerSetup.Get();
GeneralLedgerSetup.TestField("Additional Reporting Currency", '');
GeneralLedgerSetup.TestField("Amount Rounding Precision", 0.01);
JournalLine.Validate(Amount, ImportedGross);
JournalLine.Modify(true);
end;
var
TotalsErr: Label 'The invoice total must equal its net amount plus VAT.';
}

View file

@ -0,0 +1,37 @@
---
bc-version: [all]
domain: finance
keywords: [normal-vat, automatic-vat-entry, gross-amount, net-amount, vat-posting-setup, gen-journal-line, purchase]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Supply a VAT-inclusive journal Amount for automatic Normal VAT
## Description
For a general-journal line using **Automatic VAT Entry** and **Normal VAT**, `Amount` includes VAT. The posting engine extracts tax from that total; it does not add tax to a VAT-exclusive expense imported into `Amount`. An LCY invoice with net 100 and VAT 25 therefore needs a journal total of 125, not 100.
## Best Practice
Map the source's VAT-inclusive total to journal `Amount` in this posting mode. Establish the intended account and posting-group combination before validating the final amount. Account-derived VAT defaults depend on `Copy VAT Setup to Jnl. Lines`; do not assume account selection always supplies the intended configuration.
Require the actual input contract and calculation mode, not just a variable named `NetAmount`. The examples encode source net, VAT, and gross values plus a 25% Normal-VAT setup check. They target an LCY G/L purchase with 0.01 amount rounding and without balancing-side VAT, additional reporting currency, VAT differences, or unrealized VAT. Other calculation types, Manual VAT Entry, reverse charge, Full VAT, sales/use tax, unrealized tax, and other currency/rounding contexts need their own analysis; this is not a universal gross-up formula or country-specific tax advice.
The concern is the supplied transaction total, not its deductible/non-deductible allocation. Non-deductible VAT features can change the allocation of that total, not turn the source's net amount into its gross amount. Do not infer a particular expense or deductible-VAT split from this rule. The samples therefore do not depend on later-version non-deductible-VAT fields.
See sample: [`normal-vat-journal-amount-includes-vat.good.al`](normal-vat-journal-amount-includes-vat.good.al).
## Anti Pattern
In the demonstrated automatic Normal-VAT configuration, put a provably VAT-exclusive source amount into journal `Amount` while expecting posting to add tax. An amount assignment alone, unknown setup, or a suggestive variable name is insufficient. Do not report the correctly supplied gross amount or automatically rewrite tax calculations outside this scope.
See sample: [`normal-vat-journal-amount-includes-vat.bad.al`](normal-vat-journal-amount-includes-vat.bad.al).
## References
- [VAT posting setup combinations](https://learn.microsoft.com/en-us/dynamics365/business-central/finance-setup-vat#combine-vat-posting-groups-in-vat-posting-setups).
- [BCApps: journal amount validation](https://github.com/microsoft/BCApps/blob/8f7a04cb0db8aa96cb97e055c45c61aead49e280/src/Layers/W1/BaseApp/Finance/GeneralLedger/Journal/GenJournalLine.Table.al).
- [BCApps: Normal VAT extraction during posting](https://github.com/microsoft/BCApps/blob/8f7a04cb0db8aa96cb97e055c45c61aead49e280/src/Layers/W1/BaseApp/Finance/GeneralLedger/Posting/GenJnlPostLine.Codeunit.al).
- [BCApps: journal VAT amount regression cases](https://github.com/microsoft/BCApps/blob/8f7a04cb0db8aa96cb97e055c45c61aead49e280/src/Layers/W1/Tests/VAT/ERMVATOnGenJournalLine.Codeunit.al).

View file

@ -0,0 +1,60 @@
// Demonstration only; independently authored, not copied from BaseApp.
codeunit 50100 "Post Transfer Journal"
{
procedure PostTransferBatch(TemplateName: Code[10]; BatchName: Code[10])
var
JournalLine: Record "Gen. Journal Line";
LastGLEntry: Record "G/L Entry";
NextEntryNo: Integer;
begin
JournalLine.SetRange("Journal Template Name", TemplateName);
JournalLine.SetRange("Journal Batch Name", BatchName);
if JournalLine.Count() <> 1 then
Error(SingleTransferErr);
JournalLine.FindFirst();
JournalLine.TestField("Account Type", JournalLine."Account Type"::"G/L Account");
JournalLine.TestField("Bal. Account Type", JournalLine."Bal. Account Type"::"G/L Account");
JournalLine.TestField("Account No.");
JournalLine.TestField("Bal. Account No.");
JournalLine.TestField("Posting Date");
JournalLine.TestField("Document No.");
JournalLine.TestField(Amount);
JournalLine.TestField("Currency Code", '');
JournalLine.TestField("Gen. Posting Type", JournalLine."Gen. Posting Type"::" ");
JournalLine.TestField("Bal. Gen. Posting Type", JournalLine."Bal. Gen. Posting Type"::" ");
LastGLEntry.LockTable();
if LastGLEntry.FindLast() then
NextEntryNo := LastGLEntry."Entry No." + 1
else
NextEntryNo := 1;
InsertLedgerRow(JournalLine, NextEntryNo, JournalLine."Account No.", JournalLine.Amount);
InsertLedgerRow(JournalLine, NextEntryNo + 1, JournalLine."Bal. Account No.", -JournalLine.Amount);
end;
local procedure InsertLedgerRow(JournalLine: Record "Gen. Journal Line"; EntryNo: Integer; AccountNo: Code[20]; Amount: Decimal)
var
GLEntry: Record "G/L Entry";
begin
GLEntry.Init();
GLEntry."Entry No." := EntryNo;
GLEntry."G/L Account No." := AccountNo;
GLEntry."Posting Date" := JournalLine."Posting Date";
GLEntry."Document Type" := JournalLine."Document Type";
GLEntry."Document No." := JournalLine."Document No.";
GLEntry."Source Code" := JournalLine."Source Code";
GLEntry."Journal Batch Name" := JournalLine."Journal Batch Name";
GLEntry."Dimension Set ID" := JournalLine."Dimension Set ID";
GLEntry."Global Dimension 1 Code" := JournalLine."Shortcut Dimension 1 Code";
GLEntry."Global Dimension 2 Code" := JournalLine."Shortcut Dimension 2 Code";
GLEntry.Amount := Amount;
if Amount > 0 then
GLEntry."Debit Amount" := Amount
else
GLEntry."Credit Amount" := -Amount;
GLEntry.Insert(true);
end;
var
SingleTransferErr: Label 'Use a journal batch containing exactly one self-balancing G/L transfer.';
}

View file

@ -0,0 +1,29 @@
// Demonstration only; independently authored, not copied from BaseApp.
codeunit 50100 "Post Transfer Journal"
{
procedure PostTransferBatch(TemplateName: Code[10]; BatchName: Code[10])
var
JournalLine: Record "Gen. Journal Line";
PostBatch: Codeunit "Gen. Jnl.-Post Batch";
begin
JournalLine.SetRange("Journal Template Name", TemplateName);
JournalLine.SetRange("Journal Batch Name", BatchName);
if JournalLine.Count() <> 1 then
Error(SingleTransferErr);
JournalLine.FindFirst();
JournalLine.TestField("Account Type", JournalLine."Account Type"::"G/L Account");
JournalLine.TestField("Bal. Account Type", JournalLine."Bal. Account Type"::"G/L Account");
JournalLine.TestField("Account No.");
JournalLine.TestField("Bal. Account No.");
JournalLine.TestField("Posting Date");
JournalLine.TestField("Document No.");
JournalLine.TestField(Amount);
JournalLine.TestField("Currency Code", '');
JournalLine.TestField("Gen. Posting Type", JournalLine."Gen. Posting Type"::" ");
JournalLine.TestField("Bal. Gen. Posting Type", JournalLine."Bal. Gen. Posting Type"::" ");
PostBatch.Run(JournalLine);
end;
var
SingleTransferErr: Label 'Use a journal batch containing exactly one self-balancing G/L transfer.';
}

View file

@ -0,0 +1,38 @@
---
bc-version: [all]
domain: finance
keywords: [g-l-entry, ledger-entry, gen-jnl-post-line, gen-jnl-post-batch, journal-line, register, insert]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Create financial ledger entries through the owning posting engine
## Description
Standard financial ledger entries are outputs of posting, not independent rows an extension manufactures. Even two manually inserted G/L rows with balanced amounts bypass posting checks, register bookkeeping, and transaction/source relationships. `G/L Entry.Insert(true)` runs the table trigger; it does not invoke the posting engine.
## Best Practice
Use the owning document or journal posting workflow. For a normal persisted general-journal batch, use `"Gen. Jnl.-Post Batch"`; the example posts an existing batch containing one self-balancing, non-VAT G/L transfer. Let posting allocate entries and maintain the register rather than reconstructing its tables.
`"Gen. Jnl.-Post Line".RunWithCheck` is appropriate for a complete journal line inside a correctly owned posting lifecycle, but it does not invent a balancing account or document number, allocate numbering merely from `Posting No. Series`, or replace [batch document-balancing policy](preserve-journal-batch-document-balance.md). The line codeunit is stateful; its checked wrapper owns its start/continue/finish work. Normal batch posting owns its numbering and commits by default; do not imply these entry points are transaction-neutral.
Exclude temporary buffers and the standard engine's own insertion points. A checked parent may legitimately use `RunWithoutCheck`; do not replace it without inspecting that parent. This rule owns `G/L Entry`, `Cust. Ledger Entry`, `Vendor Ledger Entry`, their detailed customer/vendor entries, `VAT Entry`, and financial-posting/register records, not a custom table merely named `Ledger Entry` or a supported, specifically reviewed migration/repair workflow.
`Item Ledger Entry`, `Value Entry`, Capacity/Warehouse entries, `Item Application Entry`, and other inventory-posting records are SCM concerns, not this rule's financial-ledger scope. That exclusion includes the financial-row leg of a single inventory-posting bypass when restoring the inventory workflow corrects the whole operation. Distinct, independently actionable financial defects remain in scope.
See sample: [`post-ledger-entries-through-posting-codeunits.good.al`](post-ledger-entries-through-posting-codeunits.good.al).
## Anti Pattern
Create posted financial effects by directly inserting the Finance-owned records named above outside their owning posting workflow. Resolve the actual record type, operation, and lifecycle; do not match `*Ledger Entry` as a wildcard. Balanced debit/credit values, copied dimensions, `Insert(true)`, and a lock around entry-number allocation do not turn raw inserts into a complete posting.
See sample: [`post-ledger-entries-through-posting-codeunits.bad.al`](post-ledger-entries-through-posting-codeunits.bad.al).
## References
- [Posting engine structure](https://learn.microsoft.com/en-us/dynamics365/business-central/design-details-posting-engine-structure).
- [Gen. Jnl.-Post Line API](https://learn.microsoft.com/en-us/dynamics365/business-central/application/base-application/codeunit/microsoft.finance.generalledger.posting.gen.-jnl.-post-line).
- [BCApps: posting lifecycle and register maintenance](https://github.com/microsoft/BCApps/blob/8f7a04cb0db8aa96cb97e055c45c61aead49e280/src/Layers/W1/BaseApp/Finance/GeneralLedger/Posting/GenJnlPostLine.Codeunit.al).

View file

@ -0,0 +1,53 @@
// Demonstration only; independently authored, not copied from BaseApp.
codeunit 50107 "Post Journal Allocation"
{
procedure PostAllocation(TemplateName: Code[10]; BatchName: Code[10]; DebitAccount: Code[20]; CreditAccount: Code[20]; PostingDate: Date)
var
JournalTemplate: Record "Gen. Journal Template";
JournalBatch: Record "Gen. Journal Batch";
JournalLine: Record "Gen. Journal Line";
LineToPost: Record "Gen. Journal Line";
PostLine: Codeunit "Gen. Jnl.-Post Line";
begin
JournalTemplate.Get(TemplateName);
JournalTemplate.TestField(Recurring, false);
JournalTemplate.TestField("Force Doc. Balance", true);
JournalTemplate.TestField("Source Code");
JournalBatch.Get(TemplateName, BatchName);
JournalBatch.TestField("No. Series", '');
JournalBatch.TestField("Posting No. Series", '');
JournalLine.SetRange("Journal Template Name", TemplateName);
JournalLine.SetRange("Journal Batch Name", BatchName);
if not JournalLine.IsEmpty() then
Error(EmptyBatchErr);
AddAllocationLine(JournalTemplate, BatchName, 10000, DebitAccount, PostingDate, 'ALLOC-A', 90);
AddAllocationLine(JournalTemplate, BatchName, 20000, CreditAccount, PostingDate, 'ALLOC-B', -90);
JournalLine.FindSet();
repeat
LineToPost := JournalLine;
PostLine.RunWithCheck(LineToPost);
until JournalLine.Next() = 0;
end;
local procedure AddAllocationLine(JournalTemplate: Record "Gen. Journal Template"; BatchName: Code[10]; LineNo: Integer; AccountNo: Code[20]; PostingDate: Date; DocumentNo: Code[20]; LineAmount: Decimal)
var
JournalLine: Record "Gen. Journal Line";
begin
JournalLine.Init();
JournalLine."Journal Template Name" := JournalTemplate.Name;
JournalLine."Journal Batch Name" := BatchName;
JournalLine."Line No." := LineNo;
JournalLine."Source Code" := JournalTemplate."Source Code";
JournalLine.Validate("Posting Date", PostingDate);
JournalLine.Validate("Document No.", DocumentNo);
JournalLine.Validate("Account Type", JournalLine."Account Type"::"G/L Account");
JournalLine.Validate("Account No.", AccountNo);
JournalLine.Validate("Gen. Posting Type", JournalLine."Gen. Posting Type"::" ");
JournalLine.Validate(Amount, LineAmount);
JournalLine.Insert(true);
end;
var
EmptyBatchErr: Label 'Use an empty journal batch for this allocation.';
}

View file

@ -0,0 +1,49 @@
// Demonstration only; independently authored, not copied from BaseApp.
codeunit 50107 "Post Journal Allocation"
{
procedure PostAllocation(TemplateName: Code[10]; BatchName: Code[10]; DebitAccount: Code[20]; CreditAccount: Code[20]; PostingDate: Date)
var
JournalTemplate: Record "Gen. Journal Template";
JournalBatch: Record "Gen. Journal Batch";
JournalLine: Record "Gen. Journal Line";
PostBatch: Codeunit "Gen. Jnl.-Post Batch";
begin
JournalTemplate.Get(TemplateName);
JournalTemplate.TestField(Recurring, false);
JournalTemplate.TestField("Force Doc. Balance", true);
JournalTemplate.TestField("Source Code");
JournalBatch.Get(TemplateName, BatchName);
JournalBatch.TestField("No. Series", '');
JournalBatch.TestField("Posting No. Series", '');
JournalLine.SetRange("Journal Template Name", TemplateName);
JournalLine.SetRange("Journal Batch Name", BatchName);
if not JournalLine.IsEmpty() then
Error(EmptyBatchErr);
AddAllocationLine(JournalTemplate, BatchName, 10000, DebitAccount, PostingDate, 'ALLOC-A', 90);
AddAllocationLine(JournalTemplate, BatchName, 20000, CreditAccount, PostingDate, 'ALLOC-A', -90);
JournalLine.FindFirst();
PostBatch.Run(JournalLine);
end;
local procedure AddAllocationLine(JournalTemplate: Record "Gen. Journal Template"; BatchName: Code[10]; LineNo: Integer; AccountNo: Code[20]; PostingDate: Date; DocumentNo: Code[20]; LineAmount: Decimal)
var
JournalLine: Record "Gen. Journal Line";
begin
JournalLine.Init();
JournalLine."Journal Template Name" := JournalTemplate.Name;
JournalLine."Journal Batch Name" := BatchName;
JournalLine."Line No." := LineNo;
JournalLine."Source Code" := JournalTemplate."Source Code";
JournalLine.Validate("Posting Date", PostingDate);
JournalLine.Validate("Document No.", DocumentNo);
JournalLine.Validate("Account Type", JournalLine."Account Type"::"G/L Account");
JournalLine.Validate("Account No.", AccountNo);
JournalLine.Validate("Gen. Posting Type", JournalLine."Gen. Posting Type"::" ");
JournalLine.Validate(Amount, LineAmount);
JournalLine.Insert(true);
end;
var
EmptyBatchErr: Label 'Use an empty journal batch for this allocation.';
}

View file

@ -0,0 +1,35 @@
---
bc-version: [all]
domain: finance
keywords: [force-doc-balance, gen-journal-template, gen-jnl-post-batch, runwithcheck, document-no, posting-date, balancing]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Preserve the journal batch's document-balancing policy
## Description
A balanced G/L total does not prove that a general-journal batch satisfies its template's document-balancing policy. `"Gen. Jnl.-Post Batch"` checks balances at posting-date boundaries and, when the **journal template's** `Force Doc. Balance` is enabled, document-type/document-number boundaries. A loop over `"Gen. Jnl.-Post Line".RunWithCheck` does not reproduce these batch-level checks.
## Best Practice
Post normal persisted general-journal batches through their owning batch workflow. Keep balancing lines in the appropriate document/date group when the template requires it. The examples use two LCY G/L lines: opposite amounts under different document numbers are not a document-balanced transfer when `Force Doc. Balance` is true; the good example groups them under one document and retains the batch checks.
Do not claim every document must always balance: when that template option is false, the supported workflow can allow document imbalance while still checking the required aggregate balances. Standalone self-balancing line posting and purpose-built posting engines that demonstrably own equivalent aggregate policies are not prohibited. A `RunWithCheck` call or loop alone is not sufficient evidence of a defect.
See sample: [`preserve-journal-batch-document-balance.good.al`](preserve-journal-batch-document-balance.good.al).
## Anti Pattern
Replace a normal persisted journal batch's posting path with per-line posting or only an aggregate-total check, bypassing a demonstrated template/document/date policy. For the document-imbalance finding, require evidence that `Force Doc. Balance` applies and that separate document groups can be unbalanced; do not infer the setting from its name or a comment alone.
See sample: [`preserve-journal-batch-document-balance.bad.al`](preserve-journal-batch-document-balance.bad.al).
## References
- [Work with general journals](https://learn.microsoft.com/en-us/dynamics365/business-central/ui-work-general-journals).
- [Gen. Jnl.-Post Batch API](https://learn.microsoft.com/en-us/dynamics365/business-central/application/base-application/codeunit/microsoft.finance.generalledger.posting.gen.-jnl.-post-batch).
- [BCApps: batch balance checks](https://github.com/microsoft/BCApps/blob/8f7a04cb0db8aa96cb97e055c45c61aead49e280/src/Layers/W1/BaseApp/Finance/GeneralLedger/Posting/GenJnlPostBatch.Codeunit.al).
- [BCApps: document-balance option regression cases](https://github.com/microsoft/BCApps/blob/8f7a04cb0db8aa96cb97e055c45c61aead49e280/src/Layers/W1/Tests/General%20Journal/ERMTestMultipleGenJnlLines.Codeunit.al).

View file

@ -0,0 +1,18 @@
// Demonstration only; independently authored, not copied from BaseApp.
codeunit 50106 "Reverse Selected Posting"
{
procedure RequestReversal(SelectedEntryNo: Integer)
var
GLEntry: Record "G/L Entry";
ReversalEntry: Record "Reversal Entry";
begin
if not GuiAllowed() then
Error(InteractiveSessionErr);
GLEntry.Get(SelectedEntryNo);
GLEntry.TestField("Transaction No.");
ReversalEntry.ReverseTransaction(GLEntry."Entry No.");
end;
var
InteractiveSessionErr: Label 'Request the reversal from an interactive session.';
}

View file

@ -0,0 +1,18 @@
// Demonstration only; independently authored, not copied from BaseApp.
codeunit 50106 "Reverse Selected Posting"
{
procedure RequestReversal(SelectedEntryNo: Integer)
var
GLEntry: Record "G/L Entry";
ReversalEntry: Record "Reversal Entry";
begin
if not GuiAllowed() then
Error(InteractiveSessionErr);
GLEntry.Get(SelectedEntryNo);
GLEntry.TestField("Transaction No.");
ReversalEntry.ReverseTransaction(GLEntry."Transaction No.");
end;
var
InteractiveSessionErr: Label 'Request the reversal from an interactive session.';
}

View file

@ -0,0 +1,34 @@
---
bc-version: [all]
domain: finance
keywords: [reversetransaction, reverseregister, transaction-no, entry-no, reversal-entry, g-l-register]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Pass the transaction number, not a ledger-entry number, to ReverseTransaction
## Description
`Reversal Entry.ReverseTransaction` expects a **transaction number**, while `ReverseRegister` expects a **G/L register number**. Neither parameter means a ledger `Entry No.`. All are integers, so the compiler accepts the wrong identity; a coincidentally matching integer can select another transaction instead of the posting the user intended to reverse.
## Best Practice
When starting from a `G/L Entry`, fetch that entry and pass **its** `Transaction No.` to `Reversal Entry.ReverseTransaction`, as the sample does. The same identity distinction applies to customer/vendor ledger entries when using their supported transaction-reversal path. If the starting point is a G/L register, use `Reversal Entry.ReverseRegister` with that register's number. The interactive workflow collects the participating entries and validates reversal eligibility before the user posts the reversal.
Do not infer eligibility from `Open` alone or bypass a rejection by changing origin, application, or reversal fields. The supported path depends on source and state; some postings require unapplication or a correcting document first. A request to reverse is not a guarantee that reversal will be permitted. This rule concerns the two named `Reversal Entry` APIs, not routines such as `UnApplyCustLedgEntry` that legitimately accept a ledger entry number.
See sample: [`reverse-transactions-by-transaction-number.good.al`](reverse-transactions-by-transaction-number.good.al).
## Anti Pattern
Pass a ledger entry's `Entry No.` or a register number into `ReverseTransaction`, or pass a ledger-entry/transaction number into `ReverseRegister`. Require visible value provenance, not merely a suspicious variable name or an arbitrary integer. A correctly sourced transaction number is valid even when the variable is poorly named.
See sample: [`reverse-transactions-by-transaction-number.bad.al`](reverse-transactions-by-transaction-number.bad.al).
## References
- [Reversal Entry API](https://learn.microsoft.com/en-us/dynamics365/business-central/application/base-application/table/microsoft.finance.generalledger.reversal.reversal-entry).
- [Reverse journal postings](https://learn.microsoft.com/en-us/dynamics365/business-central/finance-how-reverse-journal-posting).
- [BCApps: reversal entry selection](https://github.com/microsoft/BCApps/blob/8f7a04cb0db8aa96cb97e055c45c61aead49e280/src/Layers/W1/BaseApp/Finance/GeneralLedger/Reversal/ReversalEntry.Table.al).

View file

@ -0,0 +1,20 @@
// Demonstration only; independently authored, not copied from BaseApp.
codeunit 50102 "Copy Journal Dimensions"
{
procedure CopyAllLineDimensions(TemplateName: Code[10]; BatchName: Code[10]; SourceLineNo: Integer; TargetLineNo: Integer)
var
SourceLine: Record "Gen. Journal Line";
TargetLine: Record "Gen. Journal Line";
begin
SourceLine.Get(TemplateName, BatchName, SourceLineNo);
TargetLine.Get(TemplateName, BatchName, TargetLineNo);
TargetLine."Shortcut Dimension 1 Code" := SourceLine."Shortcut Dimension 1 Code";
TargetLine."Shortcut Dimension 2 Code" := SourceLine."Shortcut Dimension 2 Code";
TargetLine.Modify(true);
end;
procedure HasShortcutDimension1(JournalLine: Record "Gen. Journal Line"; DimensionValue: Code[20]): Boolean
begin
exit(JournalLine."Shortcut Dimension 1 Code" = DimensionValue);
end;
}

View file

@ -0,0 +1,19 @@
// Demonstration only; independently authored, not copied from BaseApp.
codeunit 50102 "Copy Journal Dimensions"
{
procedure CopyAllLineDimensions(TemplateName: Code[10]; BatchName: Code[10]; SourceLineNo: Integer; TargetLineNo: Integer)
var
SourceLine: Record "Gen. Journal Line";
TargetLine: Record "Gen. Journal Line";
begin
SourceLine.Get(TemplateName, BatchName, SourceLineNo);
TargetLine.Get(TemplateName, BatchName, TargetLineNo);
TargetLine.Validate("Dimension Set ID", SourceLine."Dimension Set ID");
TargetLine.Modify(true);
end;
procedure HasShortcutDimension1(JournalLine: Record "Gen. Journal Line"; DimensionValue: Code[20]): Boolean
begin
exit(JournalLine."Shortcut Dimension 1 Code" = DimensionValue);
end;
}

View file

@ -0,0 +1,34 @@
---
bc-version: [all]
domain: finance
keywords: [dimension-set-id, shortcut-dimension, global-dimension, journal-line, posting, copy-dimensions]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Copy complete posting dimension sets, not only shortcut projections
## Description
When a journal line or posting document inherits dimensions, its `Dimension Set ID` identifies the complete combination of `Dimension Set Entry` rows. Global and shortcut dimensions expose selected dimensions, not the complete set; the eight shortcut dimensions are not a limit on set membership. Copying only these projections can leave the destination's posting dimensions unchanged or silently lose dimensions outside the shortcuts.
## Best Practice
For an intentional **complete** dimension transfer, copy the source set ID and synchronize the destination's projections through its supported validation or dimension-management routine. On `Gen. Journal Line`, `Validate("Dimension Set ID", SourceSetID)` updates the two shortcut fields. Do not assume another table has the same validation trigger.
When line-specific dimensions must survive a header change, use the appropriate set-combination or delta routine instead of blindly replacing the line's entire set. Reading or filtering a known global dimension is legitimate; it is not a claim to enumerate every dimension. This rule owns transfers through general-journal and financial-document posting records, not writes to Item, Value, Capacity, Warehouse, or inventory-application records owned by SCM. Generic custom-table or master `Default Dimension` wiring belongs to data modeling.
See sample: [`write-dimensions-as-dimension-set-entries.good.al`](write-dimensions-as-dimension-set-entries.good.al).
## Anti Pattern
A routine intended to copy **all** posting dimensions copies only global/shortcut codes, or assigns a set ID without synchronizing the destination's stored projections. Require evidence of a complete-transfer intent and inspect surrounding validation; an explicit change to one selected dimension or a read-only filter is not this defect.
See sample: [`write-dimensions-as-dimension-set-entries.bad.al`](write-dimensions-as-dimension-set-entries.bad.al).
## References
- [Dimension set entries overview](https://learn.microsoft.com/en-us/dynamics365/business-central/design-details-dimension-set-entries-overview).
- [DimensionManagement API](https://learn.microsoft.com/en-us/dynamics365/business-central/application/base-application/codeunit/microsoft.finance.dimension.dimensionmanagement).
- [BCApps: Gen. Journal Line dimension validation](https://github.com/microsoft/BCApps/blob/8f7a04cb0db8aa96cb97e055c45c61aead49e280/src/Layers/W1/BaseApp/Finance/GeneralLedger/Journal/GenJournalLine.Table.al).

View file

@ -17,10 +17,10 @@ An interface variable can hold any codeunit that `implements` the interface, ass
Declare the dependency as an `Interface` variable on the consumer and supply the implementation from outside — typically setter injection through a procedure that takes an `Interface` parameter, or a parameter on the entry method. Production passes the real implementation codeunit; a test passes a test-double codeunit that implements the same interface with deterministic behaviour. Because a codeunit assigns to an interface variable directly, no enum or factory is needed for the injectable case. The consumer's logic is then verifiable in isolation.
See sample: `assign-codeunit-to-interface-for-testability.good.al`.
See sample: [`assign-codeunit-to-interface-for-testability.good.al`](assign-codeunit-to-interface-for-testability.good.al).
## Anti Pattern
A consumer that declares its dependency as a concrete `Codeunit "..."` variable and calls it directly. The collaborator cannot be substituted, so a unit test either runs the production side effects or cannot cover the consumer at all. Detection signal: a `var` of type `Codeunit "<concrete impl>"` used for a collaborator that has — or could have — an interface, especially one that performs I/O, posting, or external calls. Extract an interface, depend on the interface variable, and inject the implementation.
See sample: `assign-codeunit-to-interface-for-testability.bad.al`.
See sample: [`assign-codeunit-to-interface-for-testability.bad.al`](assign-codeunit-to-interface-for-testability.bad.al).

View file

@ -17,10 +17,10 @@ Adding a method to a shipped interface changes the contract every implementing c
On BC25 or later, declare a new interface that `extends` the published interface and add the new method there. Existing implementers remain valid for the original contract, while new implementers opt in to the extended contract. For targets BC16 through BC24, where interface inheritance is unavailable, publish a new or versioned sibling interface instead.
See sample: `extend-published-interfaces-dont-edit-them.good.al`.
See sample: [`extend-published-interfaces-dont-edit-them.good.al`](extend-published-interfaces-dont-edit-them.good.al).
## Anti Pattern
Adding a procedure directly to an interface that has already shipped. Every dependent implementation must immediately add that procedure, so an otherwise compatible app update breaks its implementers.
See sample: `extend-published-interfaces-dont-edit-them.bad.al`.
See sample: [`extend-published-interfaces-dont-edit-them.bad.al`](extend-published-interfaces-dont-edit-them.bad.al).

Some files were not shown because too many files have changed in this diff Show more