Merge main into jeremy-retention-policies

This commit is contained in:
Jeremy Vyska 2026-09-30 15:51:24 +02:00
commit 3b52c03121
465 changed files with 16108 additions and 1271 deletions

View file

@ -16,6 +16,8 @@
3. Selection-input integrity — every parsed article row carries the
non-empty `domain` + `keywords` the worklist predicate selects on, and
every article parses (an unparseable article is an invalid file).
4. Bounded retrieval — delegates to tools/Test-KnowledgeRetrieval.ps1 for
lossless paging, exact-body round trips, and explicit failure cases.
Exit code 0 = healthy; non-zero = a problem CI must block on.
#>
@ -90,4 +92,5 @@ if ($problems.Count) {
exit 1
}
Write-Host "Knowledge-index check PASSED: $($rows.Count) articles, deterministic, full coverage, selection inputs intact." -ForegroundColor Green
& (Join-Path $Root 'tools/Test-KnowledgeRetrieval.ps1') -Root $Root
exit 0

318
.github/scripts/Test-SkillIndex.ps1 vendored Normal file
View file

@ -0,0 +1,318 @@
<#
.SYNOPSIS
Validates the BCQuality action-skill index generator and shared schemas.
#>
[CmdletBinding()]
param(
[string] $Root = (Resolve-Path (Join-Path -Path $PSScriptRoot -ChildPath '..' '..'))
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$Root = (Resolve-Path -LiteralPath $Root).Path
function Assert-ThrowsLike {
param(
[scriptblock] $Action,
[string] $Pattern
)
try {
& $Action
}
catch {
if ($_.Exception.Message -like $Pattern) {
return
}
throw "Expected error like '$Pattern', received: $($_.Exception.Message)"
}
throw "Expected error like '$Pattern', but no error was thrown."
}
$generator = Join-Path $Root 'tools/Build-SkillIndex.ps1'
$resolver = Join-Path $Root 'tools/Resolve-SkillWorklist.ps1'
$indexSchema = Join-Path $Root 'schemas/skill-index.schema.json'
$reportSchema = Join-Path $Root 'schemas/findings-report.schema.json'
foreach ($path in $generator, $resolver, $indexSchema, $reportSchema) {
if (-not (Test-Path -LiteralPath $path -PathType Leaf)) {
throw "Required contract file not found: $path"
}
}
$tmp = Join-Path ([IO.Path]::GetTempPath()) ("skillindex_" + [guid]::NewGuid().ToString('N'))
New-Item -ItemType Directory -Path $tmp -Force | Out-Null
try {
$first = Join-Path $tmp 'first.json'
$second = Join-Path $tmp 'second.json'
& $generator -BCQualityRoot $Root -IndexPath $first | Out-Null
& $generator -BCQualityRoot $Root -IndexPath $second | Out-Null
$normalize = {
param([string] $Path)
return ((Get-Content -LiteralPath $Path -Raw) -replace '"generatedAt":"[^"]*"', '"generatedAt":"<timestamp>"')
}
if ((& $normalize $first) -ne (& $normalize $second)) {
throw 'Skill index is not deterministic beyond generatedAt.'
}
$raw = Get-Content -LiteralPath $first -Raw
if (-not ($raw | Test-Json -SchemaFile $indexSchema -ErrorAction Stop)) {
throw 'Generated skill index does not satisfy schemas/skill-index.schema.json.'
}
$index = $raw | ConvertFrom-Json
$skills = @($index.skills)
if ($index.skillCount -ne $skills.Count) {
throw "skillCount is $($index.skillCount), but the index contains $($skills.Count) records."
}
$paths = @($skills.path)
$duplicates = @($paths | Group-Object | Where-Object Count -gt 1)
if ($duplicates.Count) {
throw "Duplicate skill paths: $($duplicates.Name -join ', ')"
}
foreach ($path in $paths) {
if (-not (Test-Path -LiteralPath (Join-Path $Root $path) -PathType Leaf)) {
throw "Indexed skill does not exist: $path"
}
}
$expectedLeaves = @(
'microsoft/skills/review/al-performance-review.md',
'microsoft/skills/review/al-security-review.md',
'microsoft/skills/review/al-privacy-review.md',
'microsoft/skills/review/al-upgrade-review.md',
'microsoft/skills/review/al-style-review.md',
'microsoft/skills/review/al-ui-review.md',
'microsoft/skills/review/al-error-handling-review.md',
'microsoft/skills/review/al-events-review.md',
'microsoft/skills/review/al-interfaces-review.md',
'microsoft/skills/review/al-breaking-changes-review.md',
'microsoft/skills/review/al-web-services-review.md',
'microsoft/skills/review/al-testing-review.md',
'microsoft/skills/review/al-data-modeling-review.md',
'microsoft/skills/review/al-query-review.md',
'microsoft/skills/review/al-reporting-review.md',
'microsoft/skills/review/al-appsource-review.md',
'microsoft/skills/review/al-telemetry-review.md',
'microsoft/skills/review/al-scm-review.md',
'microsoft/skills/review/al-finance-review.md'
)
$review = @($skills | Where-Object id -eq 'al-code-review')
if ($review.Count -ne 1) {
throw "Expected exactly one al-code-review record, found $($review.Count)."
}
if ((@($review[0].subSkills) -join "`n") -cne ($expectedLeaves -join "`n")) {
throw "al-code-review subSkills did not preserve the declared $($expectedLeaves.Count)-leaf order."
}
foreach ($leafPath in $expectedLeaves) {
$leaf = @($skills | Where-Object path -ceq $leafPath)
if ($leaf.Count -ne 1 -or @($leaf[0].subSkills).Count -ne 0) {
throw "Expected '$leafPath' to resolve to exactly one leaf action skill."
}
}
$minimalReport = @{
skill = @{ id = 'al-style-review'; version = 1 }
outcome = 'completed'
summary = @{
counts = @{ blocker = 0; major = 0; minor = 0; info = 0 }
coverage = @{ 'worklist-size' = 0; 'items-evaluated' = 0 }
}
findings = @()
suppressed = @()
} | ConvertTo-Json -Depth 8
if (-not ($minimalReport | Test-Json -SchemaFile $reportSchema -ErrorAction Stop)) {
throw 'Minimal findings report does not satisfy schemas/findings-report.schema.json.'
}
$reviewSkillText = Get-Content -LiteralPath (
Join-Path -Path $Root -ChildPath 'microsoft/skills/review/al-code-review.md'
) -Raw
$reportExamples = [regex]::Matches($reviewSkillText, '(?s)```json\s*(\{.*?\})\s*```')
if ($reportExamples.Count -ne 2) {
throw "Expected two al-code-review JSON examples, found $($reportExamples.Count)."
}
foreach ($example in $reportExamples) {
if (-not ($example.Groups[1].Value | Test-Json -SchemaFile $reportSchema -ErrorAction Stop)) {
throw 'An al-code-review output example does not satisfy schemas/findings-report.schema.json.'
}
}
$fixtureRoot = Join-Path -Path $tmp -ChildPath 'fixture'
$fixtureSkills = Join-Path -Path $fixtureRoot -ChildPath 'microsoft/skills/review'
New-Item -ItemType Directory -Path $fixtureSkills -Force | Out-Null
$leaf = @'
---
kind: action-skill
id: al-leaf-review
version: 1
title: Leaf
description: Test leaf.
inputs: [file-path]
outputs: [findings-report]
---
# Leaf
## Source
Source.
## Relevance
Relevance.
## Worklist
Worklist.
## Action
Action.
## Output
Output.
'@
Set-Content -LiteralPath (Join-Path $fixtureSkills 'al-leaf-review.md') -Value $leaf -Encoding utf8NoBOM
$duplicateSuper = @'
---
kind: action-skill
id: al-code-review
version: 1
title: Review
description: Test super-skill.
inputs: [file-path]
outputs: [findings-report]
sub-skills:
- microsoft/skills/review/al-leaf-review.md
- microsoft/skills/review/al-leaf-review.md
---
# Review
## Source
Source.
## Relevance
Relevance.
## Worklist
Worklist.
## Action
Action.
## Output
Output.
'@
$superPath = Join-Path $fixtureSkills 'al-code-review.md'
Set-Content -LiteralPath $superPath -Value $duplicateSuper -Encoding utf8NoBOM
Assert-ThrowsLike -Pattern '*duplicate sub-skill*' -Action {
& $generator -BCQualityRoot $fixtureRoot -IndexPath (Join-Path $tmp 'invalid.json')
}
$nestedLeaf = $leaf.Replace('id: al-leaf-review', 'id: al-nested-review').Replace(
'outputs: [findings-report]',
"outputs: [findings-report]`nsub-skills:`n - microsoft/skills/review/al-leaf-review.md"
)
Set-Content -LiteralPath (Join-Path $fixtureSkills 'al-nested-review.md') -Value $nestedLeaf -Encoding utf8NoBOM
$nestedSuper = @'
---
kind: action-skill
id: al-code-review
version: 1
title: Review
description: Test super-skill.
inputs: [file-path]
outputs: [findings-report]
sub-skills:
- microsoft/skills/review/al-nested-review.md
---
# Review
## Source
Source.
## Relevance
Relevance.
## Worklist
Worklist.
## Action
Action.
## Output
Output.
'@
Set-Content -LiteralPath $superPath -Value $nestedSuper -Encoding utf8NoBOM
Assert-ThrowsLike -Pattern '*Nested super-skills are not supported*' -Action {
& $generator -BCQualityRoot $fixtureRoot -IndexPath (Join-Path $tmp 'nested.json')
}
Remove-Item -LiteralPath (Join-Path $fixtureSkills 'al-nested-review.md') -Force
$validSuper = @'
---
kind: action-skill
id: al-code-review
version: 1
title: Review
description: Test super-skill.
inputs: [file-path]
outputs: [findings-report]
sub-skills:
- microsoft/skills/review/al-leaf-review.md
---
# Review
## Source
Source.
## Relevance
Relevance.
## Worklist
Worklist.
## Action
Action.
## Output
Output.
'@
Set-Content -LiteralPath $superPath -Value $validSuper -Encoding utf8NoBOM
$customSkills = Join-Path -Path $fixtureRoot -ChildPath 'custom/skills/review'
New-Item -ItemType Directory -Path $customSkills -Force | Out-Null
$customLeaf = $leaf.Replace('title: Leaf', 'title: Custom Leaf')
Set-Content -LiteralPath (Join-Path $customSkills 'custom-leaf-review.md') -Value $customLeaf -Encoding utf8NoBOM
$layeredPath = Join-Path $tmp 'layered.json'
& $generator -BCQualityRoot $fixtureRoot -IndexPath $layeredPath | Out-Null
$layeredIndex = Get-Content -LiteralPath $layeredPath -Raw | ConvertFrom-Json
$layeredLeaves = @($layeredIndex.skills | Where-Object id -eq 'al-leaf-review')
if ($layeredLeaves.Count -ne 2) {
throw "Expected both layered al-leaf-review implementations, found $($layeredLeaves.Count)."
}
if ((@($layeredLeaves.layer | Sort-Object) -join ',') -cne 'custom,microsoft') {
throw 'Layered al-leaf-review implementations did not preserve custom and microsoft records.'
}
$resolved = & $resolver -BCQualityRoot $fixtureRoot -IndexPath $layeredPath -SuperSkillPath (
'microsoft/skills/review/al-code-review.md'
)
if ($resolved.subSkills.Count -ne 1 -or
$resolved.subSkills[0].path -cne 'custom/skills/review/custom-leaf-review.md') {
throw 'The custom implementation did not win the layered leaf slot.'
}
$microsoftOnly = & $resolver -BCQualityRoot $fixtureRoot -IndexPath $layeredPath -SuperSkillPath (
'microsoft/skills/review/al-code-review.md'
) -EnabledLayers microsoft
if ($microsoftOnly.subSkills.Count -ne 1 -or
$microsoftOnly.subSkills[0].path -cne 'microsoft/skills/review/al-leaf-review.md') {
throw 'Disabling the custom layer did not fall back to the Microsoft implementation.'
}
$customDisabled = & $resolver -BCQualityRoot $fixtureRoot -IndexPath $layeredPath -SuperSkillPath (
'microsoft/skills/review/al-code-review.md'
) -DisabledSkills 'custom/skills/review/custom-leaf-review.md'
if ($customDisabled.subSkills.Count -ne 1 -or
$customDisabled.subSkills[0].path -cne 'microsoft/skills/review/al-leaf-review.md') {
throw 'Disabling the custom implementation did not fall back to Microsoft.'
}
Set-Content -LiteralPath (Join-Path $customSkills 'duplicate-leaf-review.md') -Value $customLeaf -Encoding utf8NoBOM
Assert-ThrowsLike -Pattern '*Duplicate action-skill IDs within a layer: custom:al-leaf-review*' -Action {
& $generator -BCQualityRoot $fixtureRoot -IndexPath (Join-Path $tmp 'duplicate-layer.json')
}
}
finally {
Remove-Item -LiteralPath $tmp -Recurse -Force -ErrorAction SilentlyContinue
}
Write-Output "Skill-index check PASSED: deterministic, schema-valid, layered overrides resolved, and all $($expectedLeaves.Count) review leaves preserved in order."

View file

@ -381,6 +381,20 @@ def validate_action_skill(path: Path, parsed: Parsed, report: Report) -> None:
bad = [x for x in ss if not x.endswith(".md")]
if bad:
report.error(path, "R20", f"sub-skills entries must end in '.md': {bad}", 1)
non_canonical = [
x for x in ss
if "\\" in x or x.startswith("/") or ".." in Path(x).parts or x.startswith("./")
]
if non_canonical:
report.error(
path,
"R20",
f"sub-skills entries must be canonical repo-relative paths: {non_canonical}",
1,
)
duplicates = sorted({x for x in ss if ss.count(x) > 1})
if duplicates:
report.error(path, "R20", f"sub-skills contains duplicate paths: {duplicates}", 1)
# R21 five required sections, in order, each exactly once
heads = [h for h, _ in headings_in_order(parsed.body)]
@ -565,7 +579,13 @@ class SkillRecord:
skill_id: str | None
def validate_sub_skills_registry(path: Path, fm: dict[str, Any], root: Path, report: Report) -> None:
def validate_sub_skills_registry(
path: Path,
fm: dict[str, Any],
root: Path,
action_skills_by_path: dict[str, dict[str, Any]],
report: Report,
) -> None:
"""R26: a super-skill's declared `sub-skills` must exactly match the
`al-*-review.md` leaf files present in the same directory (set equality,
ordering-agnostic). This keeps the registered leaf list the single source
@ -598,6 +618,17 @@ def validate_sub_skills_registry(path: Path, fm: dict[str, Any], root: Path, rep
f"sub-skills entry is not a sibling 'al-*-review.md' leaf: {entry}", 1,
)
for entry in ss:
leaf = action_skills_by_path.get(entry)
if leaf is None:
if (root / entry).exists():
report.error(path, "R26", f"sub-skills entry is not an action skill: {entry}", 1)
continue
if is_non_empty_list_of_str(leaf.get("sub-skills")):
report.error(path, "R26", f"nested super-skill is not permitted in v1 composition: {entry}", 1)
if leaf.get("outputs") != ["findings-report"]:
report.error(path, "R26", f"sub-skill must produce findings-report: {entry}", 1)
# Sibling leaves on disk that were never registered ('forgot to wire it up').
for leaf in sorted(leaves - declared):
report.error(path, "R26", f"leaf not registered in sub-skills: {leaf}", 1)
@ -657,12 +688,16 @@ def run(root: Path) -> Report:
if domain_dir.is_dir():
validate_samples_in_domain(domain_dir, root, report)
# Third pass: R24 unique ids within kind
# Third pass: R24 unique ids within kind. Layered action-skill overrides
# may share an id, but two definitions in one layer are ambiguous.
by_kind: dict[str, dict[str, list[Path]]] = {}
for rec in skill_records:
if rec.skill_id is None:
continue
by_kind.setdefault(rec.kind, {}).setdefault(rec.skill_id, []).append(rec.path)
scope = rec.kind
if rec.kind == "action-skill":
scope = f"{rec.kind}:{rec.path.relative_to(root).parts[0]}"
by_kind.setdefault(scope, {}).setdefault(rec.skill_id, []).append(rec.path)
for kind, by_id in by_kind.items():
for sid, paths in by_id.items():
if len(paths) > 1:
@ -670,9 +705,13 @@ def run(root: Path) -> Report:
others = [q.relative_to(root).as_posix() for q in paths if q != p]
report.error(p, "R24", f"skill id '{sid}' ({kind}) is not unique; also defined in: {others}")
# Fourth pass: R26 sub-skills registry matches leaf files on disk
# Fourth pass: R26 sub-skills registry matches compatible leaf files on disk
action_skills_by_path = {
path.relative_to(root).as_posix(): fm
for path, fm in action_skill_fms
}
for path, fm in action_skill_fms:
validate_sub_skills_registry(path, fm, root, report)
validate_sub_skills_registry(path, fm, root, action_skills_by_path, report)
return report

View file

@ -12,7 +12,26 @@ jobs:
steps:
- name: Check out repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
- name: Collect changed paths
shell: pwsh
env:
BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }}
run: |
$paths = if (-not $env:BASE_SHA -or $env:BASE_SHA -match '^0+$') {
@(git diff-tree --no-commit-id --name-only -r $env:GITHUB_SHA)
} else {
@(git diff --name-only $env:BASE_SHA $env:GITHUB_SHA)
}
$paths | Set-Content -LiteralPath "$env:RUNNER_TEMP/changed-paths.txt" -Encoding utf8NoBOM
- name: Validate review evaluation corpus
shell: pwsh
run: ./tools/Test-ReviewFixtures.ps1 -Root . -PrepareDirectory "$env:RUNNER_TEMP/bcquality-review-fixtures"
run: |
./tools/Test-ReviewFixtures.ps1 -Root . `
-PrepareDirectory "$env:RUNNER_TEMP/bcquality-review-fixtures" `
-ChangedPathsFile "$env:RUNNER_TEMP/changed-paths.txt" `
-CoverageReportPath "$env:RUNNER_TEMP/review-coverage.json"
Get-Content -LiteralPath "$env:RUNNER_TEMP/review-coverage.json"

18
.github/workflows/skill-index.yml vendored Normal file
View file

@ -0,0 +1,18 @@
name: Validate skill index and report schemas
on:
pull_request:
branches: [main]
push:
branches: [main]
jobs:
validate-contract:
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Validate skill-index generator and schemas
shell: pwsh
run: ./.github/scripts/Test-SkillIndex.ps1 -Root .

View file

@ -1,4 +1,4 @@
# BCQuality
# BC Quality - Don’t teach one agent. Teach the ecosystem. 🤝
Quality skills and knowledge that help AI tools make better Business Central
development decisions: catch BC-specific defects, avoid misleading advice,
@ -62,26 +62,32 @@ the review can still discover knowledge by reading the folders.
| I want to... | Start here |
| --- | --- |
| Choose direct reading, a supplied skill, or my own agent | [Ways to use BCQuality](docs/using-bcquality.md#choose-how-to-use-bcquality) |
| Review a file, changes, a branch, or a particular concern | [Using BCQuality](docs/using-bcquality.md) |
| Resolve setup problems, incomplete reviews, or incorrect findings | [Troubleshooting and support](docs/troubleshooting.md) |
| Browse the available guidance | [Knowledge by domain](docs/using-bcquality.md#knowledge-by-domain) |
| Configure the plugin or use my organization's rules | [Customizing BCQuality](docs/customizing-bcquality.md) |
| Contribute knowledge or improve a rule | [Contributing](docs/contributing.md) |
| Connect a host, agent, or CI integration | [How agents consume BCQuality](docs/agent-consumption.md) |
| Contribute knowledge or improve a rule | [Your first contribution](docs/contributing.md#your-first-contribution) |
| Connect a host, agent, or CI integration | [Minimal integration example](docs/agent-consumption.md#try-a-minimal-integration) |
[All documentation and technical references](docs/README.md).
## Scope
Today's curated content focuses on **technical AL code review**. It augments
Today's curated content covers **technical AL code review** and a focused
**Supply Chain Management (SCM)** functional domain. It augments
the agent's judgment; it is not an exhaustive BC manual or a substitute for
compilation, analyzers, tests, or human review. See
[coverage and limits](docs/using-bcquality.md#coverage-and-limits) for the
available domains and the difference between a folder review and a comparison.
Mechanical issues already enforced by the AL compiler or standard analyzers are
intentionally left to those deterministic tools rather than duplicated here.
Functional areas such as Finance, Supply Chain Management, Manufacturing, Jobs,
Warehousing, and Service, and technologies such as PowerShell, pipelines, and
Power Platform, remain valid future scope, **not current coverage claims**.
The [SCM domain](microsoft/knowledge/scm/) covers selected inventory, costing,
reservation, tracking, and warehouse/posting workflows, not exhaustive supply
chain validation. Broader functional coverage such as Finance, Manufacturing,
Jobs, and Service, and technologies such as PowerShell, pipelines, and Power
Platform, remain valid future scope, **not current coverage claims**.
## What's in this repo

View file

@ -0,0 +1,12 @@
codeunit 50100 "Rental Profile Install"
{
Subtype = Install;
trigger OnInstallAppPerDatabase()
var
RentalProfile: Record Profile;
begin
RentalProfile.Init();
RentalProfile.Insert(true);
end;
}

View file

@ -0,0 +1,6 @@
profile "RENTAL MANAGER"
{
Caption = 'Rental Manager';
Description = 'Manages rental agreements and equipment availability.';
RoleCenter = "Business Manager Role Center";
}

View file

@ -0,0 +1,26 @@
---
bc-version: [all]
domain: appsource
keywords: [profile-object, profile-table, install-codeunit, role-center, page-customization]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Define profiles as AL objects
## Description
Profiles delivered by a Marketplace extension must be declared as AL `profile` objects. A profile object is validated with its Role Center and page customizations when the extension is compiled and is registered through extension synchronization. Inserting profile-table records from install or setup code bypasses that object lifecycle.
## Best Practice
Declare each app-owned profile with the `profile` object and set its `RoleCenter`, user-facing caption, and optional customizations in AL. Let installation and synchronization register the object.
See sample: [`define-profiles-as-al-objects.good.al`](define-profiles-as-al-objects.good.al).
## Anti Pattern
Install, upgrade, or setup code that creates an app-owned profile by inserting a `Profile` table record. Detection signal: a `Record Profile` variable followed by `Insert` in profile provisioning code.
See sample: [`define-profiles-as-al-objects.bad.al`](define-profiles-as-al-objects.bad.al).

View file

@ -0,0 +1,7 @@
codeunit 50100 "Rental Audit"
{
procedure SetCreatedAt(var RentalAgreement: Record "Rental Agreement")
begin
RentalAgreement."Created At" := CurrentDateTime() + 7200000;
end;
}

View file

@ -0,0 +1,7 @@
codeunit 50100 "Rental Audit"
{
procedure SetCreatedAt(var RentalAgreement: Record "Rental Agreement")
begin
RentalAgreement."Created At" := CurrentDateTime();
end;
}

View file

@ -0,0 +1,26 @@
---
bc-version: [all]
domain: appsource
keywords: [datetime, time-zone, utc, currentdatetime, locale, regional-settings]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Do not hard-code time-zone offsets
## Description
Marketplace extensions run for users and services in many time zones. Adding a fixed offset to a `DateTime` assumes one locale, ignores daylight-saving transitions, and changes an absolute timestamp into an incorrect value for other regions.
## Best Practice
Store and compare `DateTime` values without a manually applied regional offset. Business Central stores `DateTime` values in UTC and presents them according to the client time zone. Keep service contracts time-zone explicit and perform a conversion only when the business requirement identifies a particular zone.
See sample: [`do-not-hard-code-time-zone-offsets.good.al`](do-not-hard-code-time-zone-offsets.good.al).
## Anti Pattern
Adding or subtracting a fixed duration solely to convert `CurrentDateTime` or another timestamp to an assumed local time. Detection signals include fixed hour-sized millisecond values near `DateTime` assignments and comments naming a specific time zone; confirm the duration is an offset rather than a legitimate deadline or schedule interval.
See sample: [`do-not-hard-code-time-zone-offsets.bad.al`](do-not-hard-code-time-zone-offsets.bad.al).

View file

@ -0,0 +1,21 @@
codeunit 50100 "Rental Service"
{
[ServiceEnabled]
procedure CloseAgreement(AgreementNo: Code[20]): Boolean
var
RentalAgreement: Record "Rental Agreement";
begin
if not Confirm(CloseAgreementQst, false, AgreementNo) then
exit(false);
RentalAgreement.Get(AgreementNo);
RentalAgreement.Closed := true;
RentalAgreement.Modify(true);
Message(AgreementClosedMsg, AgreementNo);
exit(true);
end;
var
CloseAgreementQst: Label 'Close rental agreement %1?';
AgreementClosedMsg: Label 'Rental agreement %1 was closed.';
}

View file

@ -0,0 +1,15 @@
codeunit 50100 "Rental Service"
{
[ServiceEnabled]
procedure CloseAgreement(AgreementNo: Code[20]): Boolean
var
RentalAgreement: Record "Rental Agreement";
begin
if not RentalAgreement.Get(AgreementNo) then
exit(false);
RentalAgreement.Closed := true;
RentalAgreement.Modify(true);
exit(true);
end;
}

View file

@ -0,0 +1,26 @@
---
bc-version: [all]
domain: appsource
keywords: [web-service, serviceenabled, guiallowed, message, confirm, strmenu]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Keep web-service paths free of UI calls
## Description
Pages and codeunits exposed as web services run without an interactive client. Calls that require a UI callback, including `Confirm`, `StrMenu`, and modal pages, can terminate the service request instead of completing the operation. `Message` does not raise the callback error: the message is suppressed and logged, making it ineffective for communicating a service result.
## Best Practice
Keep service entry points and every procedure they call free of interactive UI. Return data through the service contract and report validation failures with service-safe error handling. When a procedure is shared with an interactive client, guard UI-only behavior with `GuiAllowed` while preserving the underlying operation.
See sample: [`keep-web-service-paths-free-of-ui-calls.good.al`](keep-web-service-paths-free-of-ui-calls.good.al).
## Anti Pattern
A web-service-exposed page or codeunit calls an interactive UI method directly or indirectly. Detection signals include `Message`, `Confirm`, `StrMenu`, `Page.RunModal`, and confirmation-dialog pages on a service call path. Treat `Message` as suppressed and ineffective, not as a callback failure. Do not flag a controlled `Error` solely because it returns a service fault.
See sample: [`keep-web-service-paths-free-of-ui-calls.bad.al`](keep-web-service-paths-free-of-ui-calls.bad.al).

View file

@ -0,0 +1,15 @@
pageextension 50100 "Rental Customer List" extends "Customer List"
{
actions
{
addafter("Customer Ledger Entries")
{
action(OpenRentalAgreements)
{
ApplicationArea = All;
Caption = 'Rental Agreements';
RunObject = page "Rental Agreement List";
}
}
}
}

View file

@ -0,0 +1,15 @@
pageextension 50100 "Rental Customer List" extends "Customer List"
{
actions
{
addlast(Processing)
{
action(OpenRentalAgreements)
{
ApplicationArea = All;
Caption = 'Rental Agreements';
RunObject = page "Rental Agreement List";
}
}
}
}

View file

@ -0,0 +1,26 @@
---
bc-version: [all]
domain: appsource
keywords: [pageextension, actions, addfirst, addlast, addbefore, addafter]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Place page extension actions with addfirst or addlast
## Description
Place new page-extension actions at the beginning or end of an existing action group with `addfirst` or `addlast`. Anchoring a new action relative to a specific base-app action with `addbefore` or `addafter` couples the extension to an implementation detail that can move or disappear between Business Central releases.
## Best Practice
Choose the semantic action area or group and append or prepend the extension's actions. This keeps placement deterministic without depending on the continued existence of one neighboring action.
See sample: [`place-page-extension-actions-with-addfirst-or-addlast.good.al`](place-page-extension-actions-with-addfirst-or-addlast.good.al).
## Anti Pattern
Using `addbefore` or `addafter` to place newly added actions next to a specific action from another app. The syntax is valid AL, but the placement anchor is brittle for a Marketplace extension.
See sample: [`place-page-extension-actions-with-addfirst-or-addlast.bad.al`](place-page-extension-actions-with-addfirst-or-addlast.bad.al).

View file

@ -0,0 +1,20 @@
page 50100 "Rental Agreement List"
{
PageType = List;
SourceTable = "Rental Agreement";
ApplicationArea = All;
layout
{
area(Content)
{
repeater(Agreements)
{
field("No."; Rec."No.")
{
ApplicationArea = All;
}
}
}
}
}

View file

@ -0,0 +1,21 @@
page 50100 "Rental Agreement List"
{
PageType = List;
SourceTable = "Rental Agreement";
ApplicationArea = All;
UsageCategory = Lists;
layout
{
area(Content)
{
repeater(Agreements)
{
field("No."; Rec."No.")
{
ApplicationArea = All;
}
}
}
}
}

View file

@ -0,0 +1,26 @@
---
bc-version: [all]
domain: appsource
keywords: [usagecategory, tell-me, search, page, report, discoverability]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Set UsageCategory on searchable entry points
## Description
Pages and reports that users are expected to open directly must set `UsageCategory`. Without it, the object is absent from Tell Me and users cannot bookmark it from the web client. Supporting objects such as list parts, dialogs, API pages, and objects reached only through another page do not need to be searchable entry points.
## Best Practice
Set `UsageCategory` to the category that matches the entry point, such as `Lists`, `Tasks`, `ReportsAndAnalysis`, or `Documents`. Also set the appropriate object-level `ApplicationArea` so search results respect feature visibility.
See sample: [`set-usagecategory-on-searchable-entry-points.good.al`](set-usagecategory-on-searchable-entry-points.good.al).
## Anti Pattern
A user-facing page or report intended for direct discovery omits `UsageCategory` or sets it to `None`. Do not infer intent from the object type alone; require evidence that the object is a direct user entry point.
See sample: [`set-usagecategory-on-searchable-entry-points.bad.al`](set-usagecategory-on-searchable-entry-points.bad.al).

View file

@ -0,0 +1,10 @@
codeunit 50100 "Rental Period Defaults"
{
procedure GetPolicyStartDate(): Date
var
PolicyStartDate: Date;
begin
Evaluate(PolicyStartDate, '01/31/2025');
exit(PolicyStartDate);
end;
}

View file

@ -0,0 +1,7 @@
codeunit 50100 "Rental Period Defaults"
{
procedure GetPolicyStartDate(): Date
begin
exit(20250131D);
end;
}

View file

@ -0,0 +1,26 @@
---
bc-version: [all]
domain: appsource
keywords: [date-literal, invariant-date, dateformula, localization, appsourcecop]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Use invariant date literals
## Description
Write fixed dates in AL with the invariant `yyyymmddD` syntax. A locale-dependent text value parsed with `Evaluate` can change meaning or fail under another user's regional settings, which makes the Marketplace extension unreliable across markets.
## Best Practice
Represent a fixed date directly as an AL date literal, such as `20250131D`. Use `CalcDate` with a date formula when the value is relative rather than fixed.
See sample: [`use-invariant-date-literals.good.al`](use-invariant-date-literals.good.al).
## Anti Pattern
Building a fixed date by passing localized text such as `01/02/2025` to `Evaluate`. Detection signal: `Evaluate` converting a hard-coded or label-backed formatted string into a `Date`.
See sample: [`use-invariant-date-literals.bad.al`](use-invariant-date-literals.bad.al).

View file

@ -8,12 +8,13 @@ of BCQuality's internal protocol is needed.
| Goal | Guide |
| --- | --- |
| Choose direct reading, a supplied skill, or my own agent | [Ways to use BCQuality](using-bcquality.md#choose-how-to-use-bcquality) |
| Review an app, file, changes, or branch | [Using BCQuality](using-bcquality.md) |
| Understand a report and its limitations | [Reading your results](using-bcquality.md#reading-your-results) |
| Find a particular rule or example | [Knowledge by domain](using-bcquality.md#knowledge-by-domain) |
| Fix setup problems or report an incorrect finding | [Troubleshooting and support](troubleshooting.md) |
| Select layers, add company rules, or maintain a fork | [Customizing BCQuality](customizing-bcquality.md) |
| Add or improve shared knowledge | [Contributing](contributing.md) |
| Add or improve shared knowledge | [Your first contribution](contributing.md#your-first-contribution) |
## Integration and technical reference
@ -22,6 +23,7 @@ prerequisites for using the plugin.
| Reference | Purpose |
| --- | --- |
| [Minimal integration example](agent-consumption.md#try-a-minimal-integration) | A bootstrap prompt connecting your agent to a BCQuality checkout and an app folder. |
| [How agents consume BCQuality](agent-consumption.md) | Architecture, repository structure, routing, and delivery of findings. |
| [Standalone runner](standalone-runner.md) | Optional model selection, scheduling, retries, and telemetry. |
| [Global skills](../skills/README.md) | Host adapters versus internal protocol files. |

View file

@ -10,6 +10,65 @@ mental model.
This is the operational reference for integration authors. Partners using
the installed plugin do not need to implement this flow themselves.
## Try a minimal integration
**"Invoke `skills/entry.md`" means ask your agent to read and follow that
instruction document.** It is not a shell command, HTTP endpoint, or executable
library. Your host must be able to read files, enumerate directories, and
execute the selected skills as instructed. Merely mentioning BCQuality does
not make its content available to the model.
For a first integration, create or reuse a dedicated BCQuality checkout.
For example, in PowerShell:
```powershell
git clone https://github.com/microsoft/BCQuality.git "C:\Knowledge\BCQuality"
```
Give the host access to **both** that content directory and your own app
directory. The plugin is not required for this route. Replace the paths and
BC version below with your actual values, then send this prompt to the agent:
```text
BCQuality root: C:\Knowledge\BCQuality
Review input: folder-path = C:\Repos\MyBusinessCentralApp
Read BCQuality's skills\entry.md and follow it with this task context:
task-context:
goal: Review the complete AL app without changing its source files.
inputs-available: [folder-path]
technologies: [al]
bc-version: 28
enabled-layers: [microsoft, community, custom]
disabled-skills: []
Resolve BCQuality instructions, knowledge, and index preparation against the
BCQuality root, not the app directory. Pass the actual review-input path above
when a dispatched skill accepts folder-path.
Follow Entry's preparation and dispatch instructions. Execute every dispatched
action skill with its exact input subset, reading READ and DO on demand.
Return each complete findings report unchanged. If Entry returns no-match or
failed, return that dispatch record unchanged instead of inventing a review.
```
`inputs-available` lists input **types**; the `Review input` line binds the type
to the actual app directory. It is not an extra Entry schema field. Omit
`bc-version` when unknown rather than guessing it; add localization or
application-area context only when known. Keep the two roots distinct so index
preparation operates on BCQuality, not your app.
Expect Entry to select the action skills and the agent to execute them.
A broad review normally returns the Microsoft coordinator's report with
domain `sub-results`, plus any separately dispatched reports. Each report
must retain its outcome, including incomplete or failed work; see
[reading results](using-bcquality.md#reading-your-results). A dispatch record
alone is not a completed review.
This prompt delegates the existing protocol rather than implementing new
routing logic. For repeatable runs, [pin the checkout](customizing-bcquality.md#updates-and-versions).
Add scheduling, retries, and rendering only when needed, using the
[runner contract](standalone-runner.md).
## The actors
- **Orchestrator** — the tool that triggers work. Lives *outside* BCQuality. Knows *when* to run something, not *what* to run.

View file

@ -6,6 +6,34 @@ Partners are welcome to contribute shared knowledge, examples, skills, and
documentation. To report an incorrect finding without preparing a change,
use the [support guide](troubleshooting.md#reporting-a-problem).
## Your first contribution
You can propose shared guidance without write access to the upstream
repository. Use this path for a correction or a new article:
1. Search the [existing knowledge](using-bcquality.md#knowledge-by-domain) and
[open issues](https://github.com/microsoft/BCQuality/issues). Correct or
extend an existing article when it already owns the concern; add a new
article only for a distinct concern that meets the admission test below.
2. [Fork BCQuality](https://github.com/microsoft/BCQuality/fork) into your GitHub
account or organization, clone your fork, and create a working branch from
the current upstream `main`. Make edits in that branch, not the plugin cache.
3. Choose the [owning layer and domain](#choose-the-right-destination), then
edit the article or use the [shared-article starter](#shared-article-starter).
A contribution intended for everyone does not belong in `custom/`.
4. Add supporting sources and relevant good/bad samples. For a false positive,
explain the valid pattern and the mistaken finding the rule should prevent.
5. Run the [documented checks](#before-opening-a-pr), then commit and push
your branch to your fork.
6. On GitHub, open a pull request with **base repository
`microsoft/BCQuality`, base branch `main`**, and your fork's working branch
as the head. Explain why the change is needed and respond to review by
pushing further commits to the same branch.
Merged content is not automatically loaded into an existing agent session.
Consumers must pick up the updated content through their installation or
checkout; see [updates and versions](customizing-bcquality.md#updates-and-versions).
## What belongs here
BCQuality is a remedial knowledge base. A knowledge file exists because a
@ -13,7 +41,9 @@ capable LLM **would get something wrong, or miss something, without it**, not
simply because the topic is important. Apply this admission test:
> If this file did not exist, would a modern LLM reviewing or generating BC
> code make a mistake this file would have prevented?
> code make a BC-specific mistake that the configured compiler, analyzers, and
> tests would not reliably catch, or would it misinterpret or incorrectly
> remediate one of their diagnostics?
Good candidates encode a BC-specific mechanic that models get wrong, a
version-dependent behavior, or a misleading interpretation of an analyzer
@ -28,6 +58,15 @@ transactions short" does not earn a separate knowledge file merely by being
sound advice. Negative clarifications that prevent false positives are as
valuable as rules that catch defects.
Do not add knowledge whose anti-pattern is fully and deterministically detected
by the AL compiler or a standard analyzer. This applies to authoring as well as
review: an authoring agent should compile with the consuming app's actual
ruleset and correct the resulting diagnostics instead of carrying prose copies
of analyzer rules in context. Analyzer-related knowledge belongs here only when
it adds a BC-specific exception, version boundary, cross-object implication, or
remediation constraint that the diagnostic itself cannot establish. Merely
explaining why a deterministic rule exists is not sufficient.
**Skills hold discovery and execution mechanics; knowledge files hold BC
facts.** Correct or extend a knowledge article when a BC fact is missing or
wrong. Do not hide that fact in a skill's instructions. A genuine routing,
@ -69,6 +108,20 @@ to catch in `Anti Pattern`; those are the normative sections. Explain
legitimate exceptions so a reviewer does not turn a useful rule into a false
positive. Code fences are not allowed in knowledge articles.
### Shared-article starter
Use [caption-required-on-page-fields.md](../microsoft/knowledge/style/caption-required-on-page-fields.md)
as a complete shared-knowledge example. It demonstrates all six metadata
fields, a clear concern, normative guidance and exceptions, linked good/bad
samples, and authoritative sources.
For a new concern, follow that structure but choose your own descriptive
filename, domain, applicability, keywords, and guidance. Replace its sources
and sample links with ones supporting your concern; do not duplicate the
caption rule. If you are correcting caption guidance itself, edit the
existing article instead. Use a company-only rule only in your fork's Custom
layer, following the separate [customization example](customizing-bcquality.md#add-an-organization-specific-rule).
### Sources and examples
When adding or changing a platform claim, link the authoritative source that
@ -108,12 +161,15 @@ If PyYAML is not installed in your development environment, install it with
```powershell
python .github\scripts\validate_frontmatter.py --root .
pwsh .\tools\Test-ReviewFixtures.ps1 -Root .
pwsh .\tools\Test-ReviewContract.ps1 -Root .
```
The first command checks schema, sections, naming, sample references, and
skill registration. The second checks that every review leaf has a valid
positive/clean sample pair. Neither proves a model will find every defect.
See [evaluation](../evaluation/README.md) for optional model-based scoring.
positive/clean sample pair. The third checks the cross-surface findings-report
contract and its bounded range-normalization cases. None proves a model will
find every defect. See [evaluation](../evaluation/README.md) for optional
model-based scoring.
In the PR description, explain the mistake being prevented, supporting
evidence, applicable BC versions, and why the chosen domain owns it. For a

View file

@ -44,6 +44,15 @@ Otherwise the layers are additive. A matching filename alone does not suppress
an article; the [READ contract](../skills/read.md#layer-precedence) governs
knowledge conflicts. Review reports record displaced knowledge in `suppressed`.
Action skills use the same layer order but override by frontmatter `id`. A
custom leaf with the same `id` as a Community or Microsoft leaf replaces that
leaf in every super-skill slot while its layer is enabled. The files may have
different names. IDs must remain unique within each layer. Disabling the custom
layer or the custom skill path makes composition fall back to the next enabled
implementation. Hosts should build the skill index and use
`tools/Resolve-SkillWorklist.ps1`; they must not implement this selection from
filenames.
Layer selection is **not an access-control boundary**. A plugin installation
still contains excluded layers on disk. An integration requiring genuine
exclusion must remove denied files from its own content copy before the agent

View file

@ -12,6 +12,9 @@ Use the built-in standalone plugin when the host's default execution is
sufficient. Build a runner when you need explicit control over cost, latency,
concurrency, or integration with another review surface.
Start with the [minimal integration example](agent-consumption.md#try-a-minimal-integration)
to connect your agent to the content before adding runner-specific behavior.
## Keep BCQuality current
For plugin installation, use the [quick start](../README.md#quick-start).
@ -46,13 +49,24 @@ only result.
action skills to run. Do not reproduce its routing logic.
3. Execute every dispatched action skill with the exact input subset in its
dispatch record. Read `skills/read.md` and `skills/do.md` on demand.
4. When an action skill declares `sub-skills`, execute every relevant leaf as a
discrete invocation. Leaves are independent and may be scheduled serially
4. When an action skill declares `sub-skills`, resolve its ordered leaf slots
with `tools/Resolve-SkillWorklist.ps1`, passing the enabled layers and
disabled skill paths from the task context. Execute every resolved leaf as
a discrete invocation. Leaves are independent and may be scheduled serially
or concurrently.
5. Collect each complete findings-report into `sub-results` in the declared
5. Capture the exact Task return as the immutable raw audit payload and primary
transport. Preserve it unchanged in private artifacts or host logs. Before
the full DO acceptance gate, create a normalized candidate only for DO's
bounded optional-range case, record that normalization separately in private
telemetry, and accept the candidate only if the entire copy passes the
unchanged strict gate. Use `tools/Validate-FindingsReport.ps1`, passing the
exact source paths and fully retrieved article paths; pass `-SkillKind super`
for the final rolled-up report. The accepted report contains no undeclared
telemetry fields.
6. Collect each accepted findings-report into `sub-results` in the declared
`sub-skills` order, not completion order. Run the super-skill self-review
only after all leaves have finished.
6. Apply the DO composition, failure, deduplication, reference-integrity, and
7. Apply the DO composition, failure, deduplication, reference-integrity, and
outcome rules. Return strict JSON before rendering it for people or another
system.
@ -82,7 +96,18 @@ A compatible runner:
- invokes every worklisted leaf exactly once unless a documented retry replaces
a failed attempt;
- resolves same-ID leaf implementations by `custom > community > microsoft`,
preserves declared slot order, and falls back when a higher layer is disabled;
- keeps leaf contexts isolated and passes only the inputs they declare;
- preserves each raw Task return unchanged for audit and distinguishes it from
any normalized accepted copy;
- removes only an optional range whose positive integer bounds contain the
primary line but start before it, and only when the complete report has no
other defect and the finding has no `suggested-code`;
- records normalization only in private runner telemetry and never adds fields
to the findings-report;
- rejects reversed, invalid, or out-of-bounds ranges, range mismatches attached
to `suggested-code`, and every repair outside DO's bounded exception;
- preserves every leaf report, including failed reports, in `sub-results`;
- excludes unreliable findings from failed leaves and returns `partial` when
only part of the review is reliable;

View file

@ -2,10 +2,55 @@
[Documentation](README.md) | [Quick start](../README.md#quick-start) | [Troubleshooting](troubleshooting.md)
BCQuality supplies knowledge and reusable skills to your AI host. The plugin
currently exposes `al-code-review`; the examples below use that skill. The
host supplies authentication, model access, tools, permissions, and rendering.
Installing BCQuality does not install a Business Central extension or an agent.
BCQuality is knowledge you can read and reuse, plus skills that tell an agent
how to apply it. You do not need an AI tool to read the articles. When using
an agent, your host supplies authentication, model access, tools, permissions,
and rendering; BCQuality does not install a BC extension or an agent.
## Choose how to use BCQuality
| Path | What to do |
| --- | --- |
| Read the knowledge yourself | Browse [knowledge by domain](#knowledge-by-domain), or search the repository for an AL concept. Read the article and its samples. No installation required. |
| Use a supplied skill | Follow the [plugin quick start](../README.md#quick-start). The currently exposed skill, `al-code-review`, performs reviews and returns findings. |
| Use your own agent or workflow | Supply selected articles as context, as described below, or use the [integration bootstrap](agent-consumption.md#try-a-minimal-integration) to execute BCQuality action skills without the plugin. |
### Read and reuse an article
Start with a concern, such as `SetLoadFields`, and search within
`microsoft/BCQuality` on GitHub or open its domain folder. For example,
[partial-record guidance](../microsoft/knowledge/performance/use-setloadfields-for-partial-records.md)
explains the concern and links good/bad samples.
Before applying an article, read its frontmatter, the small metadata block at
the top:
| Field | How to read it |
| --- | --- |
| `bc-version` | `[24..]` means BC 24 and later; `[26..28]` means BC 26 through 28; `[all]` means every version. Use your target BC major version, not your extension's version. |
| `technologies` | `[al]` means the guidance applies to AL; multiple values identify the technologies the article covers. |
| `countries` | `[w1]` means worldwide; a code such as `[dk]` limits the guidance to that localization. |
| `application-area` | `[all]` means any application area; a named area narrows applicability. |
| `domain` and `keywords` | Help you find the topic; they are not instructions or additional requirements. |
Read `Description` for context, then `Best Practice` and `Anti Pattern` for the
rule and its exceptions. Follow any sample and source links. Do not turn a
sample into a production implementation without considering your own context.
The [READ reference](../skills/read.md) defines the precise matching rules.
To use an article with your own agent, give it access to the full article and
relevant samples, not just a title or index row. For example, replace the
bracketed values in this prompt:
> Read [article URL or local path] and its linked samples. Apply the relevant
> guidance while implementing [task] for BC [major version]. Explain which
> guidance you used, cite the article, and identify any missing context.
A URL only works if the host can retrieve it; otherwise provide the files
directly. This is ordinary reuse of knowledge for explanation or code writing,
**not a packaged code-generation skill or a complete BCQuality review**.
For the structured review process, invoke a supplied skill or follow the
integration protocol. The remaining sections describe the review workflow.
## Hosts and prerequisites
@ -40,6 +85,7 @@ branch names with ones in your project.
| Uncommitted changes | Use the installed al-code-review skill to review my staged and unstaged tracked changes against HEAD, without changing files. Identify any untracked AL files not included in that diff. |
| Branch changes | Use the installed al-code-review skill to review changes on this branch since its merge base with `origin/main`. Exclude uncommitted changes and do not edit files. |
| Focused review | Use the installed al-code-review skill to review performance in the app in this folder, without changing files. Return the complete performance findings report. |
| Supply chain code | Use the installed al-code-review skill to review SCM posting, inventory, reservations, item tracking, and warehouse workflows in this app folder, without changing files. Return the complete Supply Chain Management findings report. |
| Agent SDK code | Use the installed al-code-review skill to review Agent SDK implementation and usage in this app folder, without changing files. Return the complete Agents findings report. |
For Git comparisons, the named base ref must exist locally. If it is missing,
@ -138,7 +184,7 @@ using your normal compilation, analyzer, test, and human-review workflow.
## Coverage and limits
The Microsoft broad review composes the 16 Microsoft domains listed below.
The Microsoft broad review composes the Microsoft domains listed below.
The Community Agents review is a separate skill selected by the request, not
a nested part of that coordinator. All current review leaves accept app
folders, files, and diffs; request an Agent SDK review explicitly when that
@ -148,8 +194,38 @@ Available knowledge is **not** a promise that every rule will run. Selection
depends on the task, target context, enabled layers, and source evidence.
A whole-folder review is a current-state snapshot: detecting a published API
removal or another comparison-only regression requires an actual baseline.
The corpus is technical AL guidance, not exhaustive functional validation or
AppSource certification.
The corpus combines technical AL guidance with targeted functional-domain
invariants, not exhaustive functional validation or AppSource certification.
The SCM leaf owns selected inventory/value, application, reservation, tracking,
and warehouse/posting invariants. It prunes unrelated AL using the actual
tables, codeunits, fields, and operations in scope; an item caption or a broad
`ApplicationArea` alone is not an SCM review signal. Missing workflow context
must not be replaced with an assumed posting defect. Manufacturing, assembly,
planning, and other supply-chain areas are covered only where an article
explicitly names the shared interface or invariant.
SCM owns Item/Value/Capacity/Warehouse and inventory-application posting
records. Pure G/L, customer/vendor/detailed/VAT and financial-only posting
mutations belong to Finance, even when that domain is not enabled. Equivalent
findings for one inventory-originated posting bypass have one SCM primary
owner; distinct independent financial defects remain separate.
The Finance leaf reviews journal posting, financial ledger changes,
applications, and posting-linked dimension handling. It prunes unrelated code
at the leaf rather than changing broad-review orchestration. Finance articles
use `application-area: [all]` so missing application-area context does not
weaken applicable findings; resolved records and operations supply the
narrowing. Finance owns financial ledgers, not Item, Value, Capacity, Warehouse,
or inventory-application records owned by SCM. It also does not own generic
custom-table or master Default Dimension wiring. Request a focused "Finance
posting review" when only this domain is needed.
BCQuality intentionally does not duplicate mechanical diagnostics already
enforced by the AL compiler or standard analyzers. Run the consuming app's
normal compiler and analyzer pipeline alongside review and authoring. Knowledge
may still discuss a diagnostic when BC-specific context is needed to avoid a
false positive or choose a correct remediation.
### Knowledge by domain
@ -164,12 +240,15 @@ Each article describes one concern. Where samples exist, use its linked
| Data modeling | [Data modeling](../microsoft/knowledge/data-modeling/) |
| Error handling | [Error handling](../microsoft/knowledge/error-handling/) |
| Events | [Events](../microsoft/knowledge/events/) |
| Finance | [Finance](../microsoft/knowledge/finance/) |
| Interfaces | [Interfaces](../microsoft/knowledge/interfaces/) |
| Performance | [Performance](../microsoft/knowledge/performance/) |
| Privacy | [Privacy](../microsoft/knowledge/privacy/) |
| Query objects | [Query](../microsoft/knowledge/query/) |
| Reporting | [Reporting](../microsoft/knowledge/reporting/) |
| Security | [Security](../microsoft/knowledge/security/) |
| Style | [Style](../microsoft/knowledge/style/) |
| Supply Chain Management | [SCM](../microsoft/knowledge/scm/) |
| Telemetry | [Telemetry](../microsoft/knowledge/telemetry/) |
| Testing | [Testing](../microsoft/knowledge/testing/) |
| User interface | [UI](../microsoft/knowledge/ui/) |

View file

@ -2,10 +2,41 @@
The evaluation is convention-driven. The harness discovers every `<layer>/skills/review/al-<domain>-review.md` leaf across the enabled `microsoft`, `community`, and `custom` layers. Duplicate domains resolve with `custom > community > microsoft` precedence. For each selected leaf, the harness finds paired knowledge across the same layers, applies the same precedence to duplicate article slugs, selects the first article (by filename) with both `.bad.al` and `.good.al` companions, and derives the expected positive and clean control automatically. Adding a conforming leaf requires no scoring-contract edit.
`review-fixtures.json` contains only global thresholds and optional exceptional overrides. An override may select a different article or add context when the generic convention cannot express a scenario. It should remain empty in the normal case.
`review-fixtures.json` contains only global thresholds and optional exceptional overrides. An override may select a different `article`, add context when the generic convention cannot express a scenario, or use an `articles` array when one domain needs explicit regression coverage for several paired articles. Specify either `article` or `articles`, not both. The first selected article retains the stable `<domain>-bad` and `<domain>-good` manifest IDs; additional articles use slug-qualified IDs. Overrides should remain empty in the normal case.
CI also measures selected paired articles against every effective article that
has both AL companions. A changed paired article must be selected by the
domain convention or an override. When adding it would not provide a useful
deterministic regression, add a narrow `coverageWaivers` entry with its exact
article path and a non-empty reason. Waivers are reviewable exceptions, not a
substitute for domain coverage. The generated coverage report includes totals
and per-domain ratios; the ratio is informational, while changed-file coverage
is mandatory.
Model-facing preparation hashes case IDs, neutralizes `Good`/`Bad` object-name tokens, and removes full-line sample comments so neither the article slug, domain, nor expected outcome reveals the answer.
The SCM `articles` override deliberately selects every rule in the initial
functional domain, producing nine positive cases and nine clean controls.
Business context is executable: document/status `TestField` guards,
calculated-revaluation fields, source-transfer base quantities, warehouse
reconciliation steps, and additional-demand promising parameters survive
neutralization. Do not move those preconditions into comments or generic
"posting" helper names; removing them can turn a real defect into a valid
alternative workflow. The clean pairs exercise the supported APIs selected by
the same routing cues, not merely unrelated code that contains no SCM tokens.
The Finance override deliberately covers every paired Finance article, not
only the first filename. Its shared context supplies the target version and
localization but deliberately omits application area, as production callers
often do. Finance applicability must come from its source-surface gate, not
an artificial evaluation-only area hint. Scenario prerequisites live in
executable AL: the document-balance cases check the template setting, and the
VAT cases encode the imported net/VAT/gross totals and applicable VAT mode.
Do not move these prerequisites into comments that preparation removes.
Clean samples also retain supported operational edits, temporary ledger/set
buffers, legitimate entry-number APIs, and reads of individual shortcut
dimensions so these exceptions are exercised rather than blanket-excluded.
## Validate the corpus
```powershell
@ -14,6 +45,13 @@ pwsh ./tools/Test-ReviewFixtures.ps1 -Root .
This credential-free check proves every selected leaf maps to a same-named knowledge domain with at least one complete AL sample pair and that all configured overrides are valid.
To reproduce the changed-file gate and emit the same measurable report as CI:
```powershell
git diff --name-only origin/main...HEAD | Set-Content .changed-paths.txt
pwsh ./tools/Test-ReviewFixtures.ps1 -Root . -ChangedPathsFile .changed-paths.txt -CoverageReportPath .coverage.json
```
## Run a fast-model evaluation
1. Prepare neutral inputs:
@ -26,7 +64,7 @@ This credential-free check proves every selected leaf maps to a same-named knowl
2. For a fast/small model, use one fresh invocation per `request-case-*.json`. Each request embeds the exact leaf instructions, that domain's candidate index rows with authoritative paths, and one opaque case. The model opens only matching articles and copies finding IDs from `candidateArticles[].path`. Save each response with the matching `result-case-*.json` name in the same directory.
`request-<domain>.json` files provide optional two-case leaf batches and identify the selected layer-owned skill path; save those as `result-<domain>.json`. Directory scoring prefers `result-case-*.json` when present and otherwise falls back to `result-*.json`. `review-request.json` is an optional all-domains stress test for larger models. Neither batch form is the preferred fast-model profile.
`request-<domain>.json` files provide optional leaf batches containing every selected case for that domain and identify the selected layer-owned skill path; save those as `result-<domain>.json`. A normal convention-selected domain has one bad/good pair, while an `articles` override contributes one pair per listed article. Directory scoring prefers `result-case-*.json` when present and otherwise falls back to `result-*.json`. `review-request.json` is an optional all-domains stress test for larger models. Neither batch form is the preferred fast-model profile.
3. Save only this result shape:
@ -36,7 +74,7 @@ This credential-free check proves every selected leaf maps to a same-named knowl
{
"id": "case-a1b2c3d4",
"findings": [
{ "id": "microsoft/knowledge/appsource/object-affixes-prevent-collisions.md" }
{ "id": "microsoft/knowledge/appsource/permission-sets-cover-setup-and-usage-without-super.md" }
]
}
]

View file

@ -3,43 +3,180 @@
"selection": "first-paired-al-article",
"minimumExpectedRecall": 1.0,
"minimumCleanRate": 1.0,
"coverageWaivers": [],
"overrides": {
"agents": {
"article": "wire-all-three-agent-interfaces"
},
"appsource": {
"context": "AppSourceCop mandatoryAffixes is configured to ABC."
},
"breaking-changes": {
"article": "do-not-expose-sensitive-data-through-public-api"
},
"data-modeling": {
"articles": [
"activate-new-price-calculation-handler-via-onfindsupportedsetup",
"check-blocked-in-referencing-code-not-in-master",
"code-must-not-change-workdate",
"custom-document-dispatch-must-not-bypass-report-selections",
"document-print-and-email-actions-call-report-selections-directly",
"extend-find-entries-navigate-for-new-document-types",
"extend-price-source-type-must-sync-document-subset-enum",
"extend-report-selection-usage-for-new-document-types",
"new-price-source-must-add-candidate-and-trigger-recalculation",
"pictures-must-use-media-not-blob",
"report-barcodes-must-use-barcode-module-and-production-font-name",
"table-design-must-match-bc-table-type-conventions",
"transferfields-mirrored-fields-must-match-type-and-length"
]
},
"error-handling": {
"articles": [
"collect-validation-errors-with-errorbehavior",
"defensive-vs-offensive-code-must-match-blast-radius",
"log-writes-must-survive-rollback"
]
},
"events": {
"article": "reset-ishandled-only-when-the-value-can-carry-over"
},
"finance": {
"articles": [
"apply-ledger-entries-through-application-codeunits",
"change-ledger-due-dates-through-entry-edit",
"do-not-edit-shared-dimension-sets",
"do-not-modify-or-delete-posted-ledger-entries",
"normal-vat-journal-amount-includes-vat",
"post-ledger-entries-through-posting-codeunits",
"preserve-journal-batch-document-balance",
"reverse-transactions-by-transaction-number",
"write-dimensions-as-dimension-set-entries"
],
"context": "Target Business Central 28, worldwide standard application (w1). Review each file as an independent source input."
},
"interfaces": {
"article": "set-defaultimplementation-on-enum"
},
"performance": {
"article": "use-isempty-for-existence-check"
"articles": [
"use-isempty-for-existence-check",
"job-queue-category-code-serializes-conflicting-jobs",
"job-queue-external-effects-must-be-idempotent",
"job-queue-handlers-must-not-require-ui",
"job-queue-handlers-must-propagate-failures",
"job-queue-on-hold-does-not-stop-running-work",
"store-scheduled-task-id-to-avoid-duplicate-tasks",
"design-covering-keys-from-read-pattern",
"review-overlapping-keys-before-adding-an-index",
"setcurrentkey-sets-sort-order-not-index-hint",
"preserve-buffered-inserts-by-separating-target-reads",
"aggregate-before-persisting-intermediate-results",
"cache-repeated-filtered-results-with-explicit-scope",
"avoid-repeating-unchanged-validation",
"avoid-get-inside-loop-on-large-table",
"isempty-before-findset-is-extra-round-trip",
"query-results-bypass-primary-key-cache",
"calcsums-instead-of-calcfields-in-loop",
"use-setautocalcfields-for-per-row-flowfields",
"temporary-tables-have-no-database-cost",
"use-setloadfields-for-partial-records",
"prefer-modifyall-over-per-row-modify",
"al-methods-limited-during-write-transactions",
"avoid-user-prompts-inside-transactions"
]
},
"privacy": {
"article": "no-pii-in-telemetry-message-string"
},
"query": {
"articles": [
"dataitemtablefilter-cannot-be-overwritten-at-runtime",
"reopening-query-resets-cursor-but-keeps-filters",
"set-query-filters-before-open",
"setfilter-overwrites-query-columnfilter"
]
},
"reporting": {
"articles": [
"clear-report-variable-before-independent-runmodal",
"currreport-break-ends-the-current-trigger",
"currreport-quit-rolls-back-and-skips-onpostreport",
"currreport-skip-does-not-stop-trigger-code",
"report-output-in-a-loop-needs-one-client-download",
"reportextension-dataitem-trigger-order-is-explicit",
"reportextension-report-triggers-run-after-base-triggers",
"settableview-cannot-broaden-dataitemtableview",
"stop-when-runrequestpage-returns-empty-parameters"
]
},
"scm": {
"articles": [
"post-item-ledger-changes-through-item-journals",
"post-revaluation-through-the-item-journal-batch",
"change-item-applications-through-posting-routines",
"cancel-reservations-through-reservation-management",
"transfer-item-tracking-through-source-reservation-codeunits",
"reconcile-warehouse-adjustments-with-the-item-ledger",
"post-transfers-through-shipment-and-receipt-codeunits",
"use-date-aware-availability-for-promising",
"carry-out-requisition-actions-through-the-standard-workflow",
"derive-base-quantities-through-the-line-unit-of-measure"
]
},
"security": {
"articles": [
"al-has-no-built-in-htmlencode",
"do-not-concatenate-external-text-into-setfilter",
"exposed-objects-must-be-in-a-permission-set"
]
},
"style": {
"article": "label-comment-explains-placeholders"
"articles": [
"label-comment-explains-placeholders",
"dateformula-evaluate-needs-language-independent-literals",
"al-comments-must-not-restate-what-code-already-shows",
"pages-must-not-contain-business-logic"
]
},
"telemetry": {
"article": "telemetry-event-id-stable-unique"
},
"testing": {
"article": "ui-handlers-in-tests"
"articles": [
"ui-handlers-in-tests",
"reset-per-test-state-before-the-isinitialized-guard",
"asserterror-needs-expectederror-and-code",
"bcpt-scenarios-must-be-app-specific",
"commit-shared-test-fixture-inside-lazy-initialize",
"given-blocks-must-cover-full-precondition-chain",
"table-relation-test-exclude-known-invalid-relations-via-event",
"test-feature-scenario-tags",
"test-one-when-per-test",
"transactionmodel-attribute-governs-test-transactions",
"ui-test-codeunit-naming",
"use-assert-isfalse-not-asserterror-for-boolean-checks"
]
},
"ui": {
"articles": [
"default-descending-sort-on-historical-pages",
"page-design-must-match-bc-page-type-conventions"
]
},
"upgrade": {
"article": "initvalue-does-not-update-existing-rows",
"articles": [
"initvalue-does-not-update-existing-rows",
"upgrade-tag-logic-must-not-nest-deeply"
],
"context": "The extended table existed in the previous app version and already contains rows."
},
"web-services": {
"article": "expose-systemid-as-the-api-key"
"articles": [
"expose-systemid-as-the-api-key",
"handle-httpclient-platform-failure-before-response-access",
"check-http-status-before-consuming-response-body",
"check-json-null-before-converting-values",
"format-exchanged-values-with-standard-format-9",
"api-page-least-privilege-write-access"
]
}
}
}

View file

@ -0,0 +1,13 @@
codeunit 50104 "Import File Reader"
{
procedure ImportFile()
var
ImportFile: File;
InStream: InStream;
begin
ImportFile.WriteMode(false);
ImportFile.TextMode(true);
ImportFile.Open('C:\Import\data.txt'); // fails in SaaS — no local filesystem
ImportFile.CreateInStream(InStream);
end;
}

View file

@ -0,0 +1,24 @@
codeunit 50104 "Import File Reader"
{
procedure ImportFile()
var
TempBlob: Codeunit "Temp Blob";
FromInStream: InStream;
ToOutStream: OutStream;
InStream: InStream;
begin
if not UploadIntoStream('All Files (*.*)|*.*', FromInStream) then
exit;
TempBlob.CreateOutStream(ToOutStream);
CopyStream(ToOutStream, FromInStream);
TempBlob.CreateInStream(InStream);
ParseStream(InStream);
end;
local procedure ParseStream(var InStream: InStream)
begin
// parse InStream content here
end;
}

View file

@ -0,0 +1,28 @@
---
bc-version: [all]
domain: appsource
keywords: [file-datatype, saas, onprem, uploadintostream, downloadfromstream, instream, outstream, streaming]
technologies: [al]
countries: [w1]
application-area: [all]
---
# The File data type's direct I/O methods are OnPrem-only
> Contributions welcome — open a PR to refine or extend this article.
## Description
The classic `File` variable type — `Open`/`Create`/`Read`/`Write`/`Close` against a path on the local or server filesystem — is scoped OnPrem-only. Code targeting Business Central Online that calls `File.Open`, `File.Create`, `File.Read`, or `File.Write` fails to compile against a Cloud-scoped project; it does not compile successfully and fail or get silently skipped at runtime. Separately, and regardless of the compile-time scoping, no server/local filesystem path is available to an extension actually running in Business Central Online.
## Best Practice
Use the stream-based equivalents: `UploadIntoStream` to read user-selected file content into an `InStream`, and `DownloadFromStream` to write an `OutStream`'s content to a file the user saves. Stage the content in a `TempBlob` between the stream and the rest of the parsing/formatting code.
See sample: [`file-datatype-saas.good.al`](file-datatype-saas.good.al).
## Anti Pattern
Opening a hardcoded or user-supplied filesystem path with the `File` variable type. This is a strong signal the code was written for on-premises only, or copied from material that predates the cloud-first streaming APIs.
See sample: [`file-datatype-saas.bad.al`](file-datatype-saas.bad.al).

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`](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`](object-affixes-prevent-collisions.bad.al).

View file

@ -0,0 +1,37 @@
---
bc-version: [all]
domain: appsource
keywords: [version, release, app-json, semver, al-go, appsource]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Update the app version at every release
## Description
At every release — a branch merged to `main`, a tagged release build, or an AppSource submission — the app's version is consciously updated, not left to the pipeline alone.
| Version part | Owner | When |
|---|---|---|
| Major | Developer decision | Breaking change (schema, API, removed objects) |
| Minor | Developer decision | Every release with new functionality |
| Build / Revision | AL-Go pipeline | Automatic — never hand-edited |
The app's stable identity is its `id` in `app.json`; the version identifies which release — which code state — of that app is deployed. Two customer environments running "the same" version with different code is an undiagnosable support case. AppSource's actual requirement is strict full-version ordering — the complete version must be greater than the previously submitted version — which an AL-Go-generated build/revision increment can satisfy on its own; AppSource does not require major.minor itself to change. Treating major.minor as a deliberate, human-decided compatibility signal is still valuable practice — it is a statement about what changed that no pipeline can make on its own — just not a platform-enforced requirement.
## Best Practice
Before the release merge:
app.json: "version": "1.3.0.0" (new functionality -> minor bump, by team convention)
AL-Go settings: "repoVersion": "1.3" (where used)
Then: feature branch -> main via PR, tag, release.
"Feature branches never touch the version" and "every merge to main is a release" are workflow choices your team can adopt for compatibility clarity — not something AppSource itself requires.
## Anti Pattern
Branch merged to main and released.
app.json still says "version": "1.2.0.0" -- same as the previous release.
Two different code states now share one version identity.

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`](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`](two-level-namespace-replaces-object-affix-not-extension-member-affix.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`](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`](namespace-is-part-of-published-object-identity.bad.al).

View file

@ -0,0 +1,9 @@
codeunit 50103 "Order Confirmation Notifier"
{
procedure Send(ToAddress: Text; Subject: Text; Body: Text)
var
Mail: Codeunit Mail;
begin
Mail.CreateMessage(ToAddress, '', '', Subject, Body, false, false);
end;
}

View file

@ -0,0 +1,11 @@
codeunit 50103 "Order Confirmation Notifier"
{
procedure Send(ToAddress: Text; Subject: Text; Body: Text)
var
Email: Codeunit Email;
EmailMessage: Codeunit "Email Message";
begin
EmailMessage.Create(ToAddress, Subject, Body, true);
Email.Send(EmailMessage, Enum::"Email Scenario"::Default);
end;
}

View file

@ -0,0 +1,28 @@
---
bc-version: [all]
domain: breaking-changes
keywords: [email, codeunit-mail, email-message, email-scenario, email-account, smtp, sending-email]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Send email through the Email module, not Codeunit Mail (397)
> Contributions welcome — open a PR to refine or extend this article.
## Description
Older AL code sends email by calling `Codeunit Mail (397)`. Business Central's current extensibility model is a different, richer object set — `Codeunit Email`, `Codeunit "Email Message"`, `enum "Email Scenario"`, and the `Email Account`/`Email Connector` interface (Microsoft 365, Current User, SMTP, or a custom connector). `Codeunit "Email Message"` is the in-memory object you build the message on; it is not itself the persisted Sent/Outbox/Draft record — that storage is managed separately once the message is queued or sent. New code built on `Codeunit Mail` inherits its SMTP-era, single-connector assumptions and leaves no Sent/Outbox trail behind.
## Best Practice
Build on `Codeunit Email` and `Codeunit "Email Message"`. Route the message through an `Email Scenario` so different document types can use different accounts without the calling code needing to know which account that is, and get a tracked Sent/Outbox/Draft record for free.
See sample: [`prefer-email-module.good.al`](prefer-email-module.good.al).
## Anti Pattern
Calling `Codeunit Mail`'s `CreateMessage`. It still compiles and runs, but current `Codeunit Mail`'s own implementation of `CreateMessage` no longer sends anything by itself — it only raises integration events for a legacy subscriber to act on — so building new code on it means depending on whatever compatibility shim happens to still be wired up, with no first-class connector selection and no queryable Sent/Outbox/Draft record. `Send` and `GetErrorDesc` are not current members of `Codeunit Mail` at all; do not reference them.
See sample: [`prefer-email-module.bad.al`](prefer-email-module.bad.al).

View file

@ -0,0 +1,74 @@
enumextension 50102 "Sample Price Calc Handler Ext" extends "Price Calculation Handler"
{
value(50102; "Sample Special Price")
{
Caption = 'Sample Special Price';
Implementation = "Price Calculation" = "Sample Price Calc - Special";
}
}
// Demonstration-only AL: every method below is stubbed. This article is
// about activating a handler through OnFindSupportedSetup, not about the
// "Price Calculation" interface's own pricing logic.
codeunit 50103 "Sample Price Calc - Special" implements "Price Calculation"
{
procedure Init(LineWithPrice: Interface "Line With Price"; PriceCalculationSetup: Record "Price Calculation Setup")
begin
end;
procedure GetLine(var Line: Variant)
begin
end;
procedure ApplyDiscount()
begin
end;
procedure ApplyPrice(CalledByFieldNo: Integer)
begin
end;
procedure CountDiscount(ShowAll: Boolean) Result: Integer
begin
end;
procedure CountPrice(ShowAll: Boolean) Result: Integer
begin
end;
procedure FindDiscount(var TempPriceListLine: Record "Price List Line"; ShowAll: Boolean) Found: Boolean
begin
end;
procedure FindPrice(var TempPriceListLine: Record "Price List Line"; ShowAll: Boolean) Found: Boolean
begin
end;
procedure IsDiscountExists(ShowAll: Boolean) Result: Boolean
begin
end;
procedure IsPriceExists(ShowAll: Boolean) Result: Boolean
begin
end;
procedure PickDiscount()
begin
end;
procedure PickPrice()
begin
end;
procedure ShowPrices(var TempPriceListLine: Record "Price List Line")
begin
end;
}
// WRONG: no subscriber to Price Calculation Mgt.'s OnFindSupportedSetup.
// "Sample Special Price" is a real, working implementation of the Price
// Calculation interface - it simply has no Price Calculation Setup row
// naming it, so Price Calculation Mgt. never selects it for any sale,
// purchase, or job line, whether through the Default fallback or through
// a "Dtld. Price Calculation Setup" row. It ships invisible until someone
// notices and configures a setup row for it by hand.

View file

@ -0,0 +1,90 @@
enumextension 50102 "Sample Price Calc Handler Ext" extends "Price Calculation Handler"
{
value(50102; "Sample Special Price")
{
Caption = 'Sample Special Price';
Implementation = "Price Calculation" = "Sample Price Calc - Special";
}
}
// Demonstration-only AL: every method below is stubbed. This article is
// about activating a handler through OnFindSupportedSetup, not about the
// "Price Calculation" interface's own pricing logic.
codeunit 50103 "Sample Price Calc - Special" implements "Price Calculation"
{
procedure Init(LineWithPrice: Interface "Line With Price"; PriceCalculationSetup: Record "Price Calculation Setup")
begin
end;
procedure GetLine(var Line: Variant)
begin
end;
procedure ApplyDiscount()
begin
end;
procedure ApplyPrice(CalledByFieldNo: Integer)
begin
end;
procedure CountDiscount(ShowAll: Boolean) Result: Integer
begin
end;
procedure CountPrice(ShowAll: Boolean) Result: Integer
begin
end;
procedure FindDiscount(var TempPriceListLine: Record "Price List Line"; ShowAll: Boolean) Found: Boolean
begin
end;
procedure FindPrice(var TempPriceListLine: Record "Price List Line"; ShowAll: Boolean) Found: Boolean
begin
end;
procedure IsDiscountExists(ShowAll: Boolean) Result: Boolean
begin
end;
procedure IsPriceExists(ShowAll: Boolean) Result: Boolean
begin
end;
procedure PickDiscount()
begin
end;
procedure PickPrice()
begin
end;
procedure ShowPrices(var TempPriceListLine: Record "Price List Line")
begin
end;
}
codeunit 50104 "Sample Price Calc Setup Install"
{
[EventSubscriber(ObjectType::Codeunit, Codeunit::"Price Calculation Mgt.", 'OnFindSupportedSetup', '', false, false)]
local procedure AddSampleSpecialPriceSetup(var TempPriceCalculationSetup: Record "Price Calculation Setup" temporary)
begin
TempPriceCalculationSetup.Init();
TempPriceCalculationSetup.Code := 'SAMPLE-SPECIAL';
TempPriceCalculationSetup.Method := TempPriceCalculationSetup.Method::"Lowest Price";
TempPriceCalculationSetup.Type := TempPriceCalculationSetup.Type::Sale;
TempPriceCalculationSetup."Asset Type" := TempPriceCalculationSetup."Asset Type"::" ";
TempPriceCalculationSetup.Implementation := TempPriceCalculationSetup.Implementation::"Sample Special Price";
TempPriceCalculationSetup.Enabled := true;
// Default := true here because this row is meant as the fallback
// for Method = Lowest Price / Type = Sale / Asset Type = " " (all)
// - the combination Price Calculation Mgt.'s FindSetup selects via
// its own SetRange(Default, true) branch when no "Dtld. Price
// Calculation Setup" row names a more specific match. A handler
// meant to be picked only through such a specific, explicit
// detailed-setup row would not need Default := true at all.
TempPriceCalculationSetup.Default := true;
TempPriceCalculationSetup.Insert();
end;
}

View file

@ -0,0 +1,98 @@
---
bc-version: [all]
domain: data-modeling
keywords: [price-calculation, price-calculation-handler, price-calculation-setup, integration-event, pricing]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Activate a new Price Calculation Handler through OnFindSupportedSetup, not just by implementing it
## Description
`enum 7011 "Price Calculation Handler"` (`implements "Price
Calculation"`) is how a new pricing engine plugs into Business Central —
extend the enum with a value pointing at a codeunit that implements the
`Price Calculation` interface. That alone does not make the new handler
usable on any document. `codeunit 7001 "Price Calculation Mgt."` decides
which handler applies to a given line by looking up `table 7006 "Price
Calculation Setup"`, a table of `(Code, Method, Type, Asset Type,
Implementation, Enabled, Default)` rows populated at startup by its own
`OnFindSupportedSetup` event — every implementation codeunit is expected
to subscribe to that event and insert its own setup row(s). A handler
enum value with no matching setup row is real and selectable in the enum
itself, but never chosen for any actual sale, purchase, or job line,
because `Price Calculation Mgt.` has no setup row that names it.
`FindSetup` resolves a handler in two stages, and only the second one
looks at `Default`. It first asks `codeunit 7004 "Price Calculation Dtld.
Setup"` to match the line against `table 7008 "Dtld. Price Calculation
Setup"` ("Detailed Price Calculation Setup", keyed to an exact
`Method`/`Type`/`Asset Type`/`Source`/`Asset No.` combination via its own
`"Setup Code"`); on a match it does `PriceCalculationSetup.Get(...
"Setup Code")` directly, with no `Default` filter. Only when no detailed
row matches does it fall back to `SetRange(Default, true)` plus
`SetRange(Method, ...)` to pick the one catch-all row for that
combination. A row without `Default := true` is invisible to *that*
fallback, but not invisible outright — a detailed-setup row can still
select it by naming its `Code`. A row whose `Method` matches neither path
is invisible either way — same symptom, different cause.
## Best Practice
Ship a new `Price Calculation Handler` value together with an
`OnFindSupportedSetup` subscriber that inserts at least one `Price
Calculation Setup` record naming it as the `Implementation`, for the
relevant `Method` (e.g. `"Lowest Price"`), `Type` (`Sale`/`Purchase`), and
`Asset Type`. `Default := true` is required only when this row is the
*fallback* for that combination — the row `FindSetup`'s own
`SetRange(Default, true)` branch selects when no more specific setup
applies. A handler meant to be selected only for specific customers or
items should instead be reachable through a matching `"Dtld. Price
Calculation Setup"` row; `FindSetup` resolves that before it ever checks
`Default`, so it needs no `Default := true`.
See sample: [`activate-new-price-calculation-handler-via-onfindsupportedsetup.good.al`](activate-new-price-calculation-handler-via-onfindsupportedsetup.good.al).
## Anti Pattern
Extending `Price Calculation Handler` and implementing the `Price
Calculation` interface, without subscribing to `OnFindSupportedSetup` to
insert a setup record. The new handler exists, compiles, and can even be
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`](activate-new-price-calculation-handler-via-onfindsupportedsetup.bad.al).
## Source
BCApps (`src/Layers/W1/BaseApp/Pricing/Calculation/`):
`PriceCalculationHandler.Enum.al` (`enum 7011 "Price Calculation Handler"
implements "Price Calculation"`); `PriceCalculationMgt.Codeunit.al`
(`OnFindSupportedSetup(var TempPriceCalculationSetup: Record "Price
Calculation Setup" temporary)`, and `FindSetup(...): Boolean`, which
first calls `PriceCalculationDtldSetup.FindSetup(DtldPriceCalcSetup)` and
on a match does `PriceCalculationSetup.Get(... "Setup Code")` with no
`Default` filter — only on failure does it fall back to
`SetRange(Enabled, true)`, `SetRange(Default, true)`, `SetRange(Method,
...)`); `PriceCalculationSetup.Table.al` (`table 7006 "Price Calculation
Setup"`: `Code`, `Method`, `Type`, `"Asset Type"`, `Implementation`,
`Enabled`, `Default`); `PriceCalculationDtldSetup.Codeunit.al` (`codeunit
7004 "Price Calculation Dtld. Setup"`, `FindSetup(var DtldPriceCalcSetup:
Record "Dtld. Price Calculation Setup"): Boolean`, matching progressively
looser `Source Group`/`Source No.`/`Asset Type`/`Asset No.` combinations —
never `Default`); `DtldPriceCalculationSetup.Table.al` (`table 7008 "Dtld.
Price Calculation Setup"`, Caption "Detailed Price Calculation Setup",
`"Setup Code"` relates to `"Price Calculation Setup".Code where(Enabled =
const(true))` — no `Default` condition).
Microsoft Learn, "Extending Price Calculations": "Each codeunit that
implements the Price Calculation interface must subscribe to the
OnFindSupportedSetup() event... to fill the price calculation setup
table." Same article: "You can enter detailed setup records for
non-default setup lines... If a matching setup is found its
implementation is used... If there is no matching setup exception, we
use the default implementation."
(https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/devenv-extending-best-price-calculations)

View file

@ -0,0 +1,22 @@
codeunit 50101 "Meter Jnl.-Post"
{
procedure Post(var MeterJnlLine: Record "Meter Journal Line")
var
MeterLedgEntry: Record "Meter Ledger Entry";
begin
// Validation, Journal access, and posting all mixed in one routine.
if not Confirm('Post journal lines?') then
exit;
if MeterJnlLine.FindSet() then
repeat
if MeterJnlLine.Quantity = 0 then
Error('Quantity must not be zero.');
MeterLedgEntry.Init();
MeterLedgEntry.TransferFields(MeterJnlLine);
MeterLedgEntry.Insert();
MeterJnlLine.Delete();
until MeterJnlLine.Next() = 0;
end;
}

View file

@ -0,0 +1,42 @@
codeunit 50100 "Meter Jnl.-Check Line"
{
procedure CheckLine(var MeterJnlLine: Record "Meter Journal Line")
begin
// Reads setup/dimension data only, shows no UI beyond errors.
if MeterJnlLine.Quantity = 0 then
Error('Quantity must not be zero.');
end;
}
codeunit 50101 "Meter Jnl.-Post Line"
{
procedure PostLine(var MeterJnlLine: Record "Meter Journal Line")
var
MeterLedgEntry: Record "Meter Ledger Entry";
begin
// Posts exactly one journal line; never touches the Journal table.
MeterLedgEntry.Init();
MeterLedgEntry.TransferFields(MeterJnlLine);
MeterLedgEntry.Insert();
end;
}
codeunit 50102 "Meter Jnl.-Post Batch"
{
procedure PostBatch(var MeterJnlLine: Record "Meter Journal Line")
var
CheckLine: Codeunit "Meter Jnl.-Check Line";
PostLine: Codeunit "Meter Jnl.-Post Line";
begin
if MeterJnlLine.FindSet() then
repeat
CheckLine.CheckLine(MeterJnlLine);
until MeterJnlLine.Next() = 0;
if MeterJnlLine.FindSet() then
repeat
PostLine.PostLine(MeterJnlLine);
MeterJnlLine.Delete();
until MeterJnlLine.Next() = 0;
end;
}

View file

@ -0,0 +1,28 @@
---
bc-version: [all]
domain: data-modeling
keywords: [posting-routine, check-line, post-line, post-batch, companion-codeunit, yes-no-wrapper, journal]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Split posting routines into Check Line / Post Line / Post Batch
> Contributions welcome — open a PR to refine or extend this article.
## Description
Business Central's own journal-based posting routines consistently follow a three-codeunit split, each with one primary responsibility — `Codeunit "Gen. Jnl.-Check Line"` / `"Gen. Jnl.-Post Line"` / `"Gen. Jnl.-Post Batch"` for the general journal, and the same `<Journal>-Check Line` / `<Journal>-Post Line` / `<Journal>-Post Batch` shape repeated for Item, Resource, Job, Fixed Asset, Insurance, and Cost Accounting journals: `Check Line` validates one line, `Post Line` posts exactly one journal line — `Gen. Jnl.-Post Line` itself can write more than one G/L Entry per call (a balancing entry, VAT, currency rounding, deferrals), so "exactly one line" describes its input, not a one-entry-out guarantee — and `Post Batch` loops both across the journal. A document posting routine (posting one document at a time) calls `Post Line` directly and skips `Post Batch`. This is the standard shape to evaluate a new journal-based posting routine against, not a platform-enforced constraint — a routine with a genuinely different transaction/reuse shape may legitimately organize itself differently, and the three codeunits' responsibilities are a useful default split, not a guarantee that every implementation keeps them non-overlapping. But a new routine that blurs this split without a specific reason either misses functionality other code expects to call directly, or exposes an interaction surface it shouldn't.
## Best Practice
`Check Line` reads setup/dimension data only on its first call and shows no UI beyond errors. `Post Line` only operates on the record passed to it — never the Journal table — so it can be called directly by other posting code, including a document posting routine. `Post Batch` is the only one of the three that reads and updates the Journal table, and it is the only one invoked from the Post action on a journal page. A `-Post` document codeunit is never called directly from a page; a page calls a `-Post (Yes/No)` confirmation wrapper instead, so the same `-Post` codeunit can also run unattended from a batch-posting report.
See sample: [`check-post-line-batch-pattern.good.al`](check-post-line-batch-pattern.good.al).
## Anti Pattern
A single monolithic posting codeunit that reads the Journal table, validates lines, writes ledger entries, and shows confirmation dialogs all in one procedure. It cannot be reused by another posting routine without fabricating journal records, and it cannot run unattended because it insists on user interaction.
See sample: [`check-post-line-batch-pattern.bad.al`](check-post-line-batch-pattern.bad.al).

View file

@ -0,0 +1,9 @@
codeunit 50101 "Batch Job Runner"
{
procedure AdvanceToNextBusinessDay()
begin
// Anti-pattern: repurposes the user's session WorkDate as a
// scratch variable for unrelated business logic.
WorkDate(CalcDate('<1D>', WorkDate()));
end;
}

View file

@ -0,0 +1,11 @@
codeunit 50100 "Posting Date Helper"
{
procedure GetDefaultPostingDate(): Date
var
PostingDate: Date;
begin
// Read the work date to default a value; never write to it.
PostingDate := WorkDate();
exit(PostingDate);
end;
}

View file

@ -0,0 +1,58 @@
---
bc-version: [all]
domain: data-modeling
keywords: [workdate, session-setting, user-control, side-effect]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Application code must not change the WorkDate
## Description
The work date is a per-user session setting the user controls from the
client (the date shown in the top-right corner, used to default posting
dates and date filters). Business logic unrelated to that setting must not
call `WorkDate(NewDate)` as a side effect of doing something else — that
silently changes what the user sees and defaults to for the rest of their
session, a surprising, hard-to-trace behavior change the user never asked
for and has no visibility into. This is not a blanket ban on the setter
itself: BCApps' own demo-data generators legitimately save the current
work date, set a specific one to backdate the data they create, and
restore it afterward (see `CreateDemoEDocsBE.Codeunit.al`'s
`WorkDate(SampleInvoiceDate)` / `WorkDate(SavedWorkDate)` pair), and test
codeunits routinely set `WorkDate` deliberately to control the date context
a test runs under (hundreds of calls across BCApps' test suite, for
example `SustainabilityPostingTest.Codeunit.al`). Both are the code's
*actual purpose*, not a side effect of something unrelated.
This is a call-direction distinction for the read side: reading the
current work date via `WorkDate` (or `WorkDate()` with no argument) is
always fine.
## Best Practice
Read the work date to default a value. Only write to it when changing it
*is* the operation being performed — implementing the user's own
work-date/settings action, or a test or demo-data routine that deliberately
establishes a date context (saving and restoring the prior value if the
routine must leave the session as it found it). Business logic that exists
to do something else must never write `WorkDate` as an incidental side
effect; if a calculation needs a specific date, pass or compute that date
as a local variable instead.
See sample: [`code-must-not-change-workdate.good.al`](code-must-not-change-workdate.good.al).
## Anti Pattern
Setting the work date from within a codeunit, report, or page action whose
purpose is unrelated to the user's date preference — for example, a
posting or calculation routine that calls `WorkDate(SomeDate)` to make its
own logic simpler. This changes session state the user owns for the
duration of a call that was never about the work date, and never restores
it. This is a different case from a test or demo-data routine explicitly
declaring a date context: the anti-pattern is unrelated logic silently
mutating state it does not own, not the setter form itself.
See sample: [`code-must-not-change-workdate.bad.al`](code-must-not-change-workdate.bad.al).

View file

@ -0,0 +1,20 @@
codeunit 50102 "Sample Posted Invoice Send"
{
procedure SendPostedInvoice(SalesInvoiceHeader: Record "Sales Invoice Header")
var
Customer: Record Customer;
begin
Customer.Get(SalesInvoiceHeader."Bill-to Customer No.");
Customer.TestField("E-Mail");
// WRONG: the report is hardcoded instead of resolved through the
// registered "S.Invoice" usage in Report Selections. This alone is
// the defect - no hand-built email is needed for it: a Report
// Selections row or a per-customer "Document Layouts" override
// that points this usage at a different report or layout is
// silently ignored, and the only way to change what this code
// prints is a code change and a new release.
SalesInvoiceHeader.SetRecFilter();
Report.RunModal(Report::"Standard Sales - Invoice", false, false, SalesInvoiceHeader);
end;
}

View file

@ -0,0 +1,31 @@
codeunit 50102 "Sample Posted Invoice Send"
{
procedure SendPostedInvoice(SalesInvoiceHeader: Record "Sales Invoice Header")
var
ReportSelections: Record "Report Selections";
ReportDistributionMgt: Codeunit "Report Distribution Management";
begin
// Custom validation specific to this dispatch stays here...
CheckReadyToSend(SalesInvoiceHeader);
// ...but dispatch goes through the registered usage. "S.Invoice"
// resolves to a report built on "Sales Invoice Header" (by default
// report 1306 "Standard Sales - Invoice"), so the record passed in
// matches what the selected report expects, and per-account
// report/layout overrides and email attachment/body configuration
// on Report Selections all apply automatically.
SalesInvoiceHeader.SetRecFilter();
ReportSelections.SendEmailToCust(
"Report Selection Usage"::"S.Invoice".AsInteger(), SalesInvoiceHeader, SalesInvoiceHeader."No.",
ReportDistributionMgt.GetFullDocumentTypeText(SalesInvoiceHeader), true,
SalesInvoiceHeader."Bill-to Customer No.");
end;
local procedure CheckReadyToSend(SalesInvoiceHeader: Record "Sales Invoice Header")
var
Customer: Record Customer;
begin
Customer.Get(SalesInvoiceHeader."Bill-to Customer No.");
Customer.TestField("E-Mail");
end;
}

View file

@ -0,0 +1,69 @@
---
bc-version: [all]
domain: data-modeling
keywords: [report-selections, document-layouts, custom-report-layout, email-attachment, bespoke-dispatch]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Custom document dispatch must not bypass Report Selections
## Description
A codeunit that hardcodes which report to run (`Report.RunModal(MyReportId, ...)`),
or builds its own email directly, instead of registering the document
through `table 77 "Report Selections"` and calling its own
Print/Email procedures, works for the one case it was written for — and
loses everything the platform's registry provides for free. Either
bypass is a defect on its own: a hardcoded report ignores the registered
report and any per-account layout override even when no email is
involved, and a hand-built email ignores the registry's attachment and
email-body configuration even when the report itself came from it. `Report
Selections` carries its own attachment/email-body configuration per usage
(`"Use for Email Attachment"`, `"Use for Email Body"`, `"Email Body Layout
Code"`, `"Email Body Layout Type"`), plus a separate per-usage layout
override, `"Custom Report Layout Code"`, and
`table 9657 "Custom Report Selection"` (the "Document Layouts" page on the
Customer/Vendor card) lets one specific account override the report or
layout without touching code at all. None of that exists for a document
whose dispatch was hand-rolled: there is no registry row to point
"Document Layouts" at, so an admin who goes looking for where to change
this document's layout — the same place they'd look for every other
document in the system — finds nothing, because the document was never
registered there.
## Best Practice
Register the document under a `Report Selection Usage` value (see
`extend-report-selection-usage-for-new-document-types.md`) and dispatch
through `Report Selections`' own Print/Email procedures (see
`document-print-and-email-actions-call-report-selections-directly.md`),
even when the surrounding business logic — which counterparty to use,
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`](custom-document-dispatch-must-not-bypass-report-selections.good.al).
## Anti Pattern
A codeunit that runs a hardcoded report ID, or builds its own email
message directly, for a document that has (or should have) a
`Report Selections` usage — each is independently a bypass, and the
sample shows the first on its own. It works for the default case, but the report/layout cannot be changed per account
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`](custom-document-dispatch-must-not-bypass-report-selections.bad.al).
## Source
BCApps `ReportSelections.Table.al` (table 77 — field 7,
`"Custom Report Layout Code"`; fields 19–26 for email attachment/body
configuration; `SendEmailToCust`/`PrintWithDialogForCust` as the
registry-backed dispatch entry points) and
`CustomReportSelection.Table.al` (table 9657, the per-account override
backing the "Document Layouts" page) — both under
`src/Layers/W1/BaseApp/Foundation/Reporting/`.

View file

@ -0,0 +1,13 @@
table 50100 "Course"
{
fields
{
field(1; "No."; Code[20]) { }
field(10; "Global Dimension 1 Code"; Code[20])
{
// No CaptionClass, no OnValidate call into DimensionManagement.
// Accepts any value; never becomes a Default Dimension record.
TableRelation = "Dimension Value".Code;
}
}
}

View file

@ -0,0 +1,89 @@
// Master data: Default Dimension records, no Dimension Set ID field.
table 50100 "Course"
{
fields
{
field(1; "No."; Code[20]) { }
field(10; "Global Dimension 1 Code"; Code[20])
{
CaptionClass = '1,1,1';
TableRelation = "Dimension Value".Code where(
"Global Dimension No." = const(1), Blocked = const(false));
trigger OnValidate()
var
DimMgt: Codeunit DimensionManagement;
begin
DimMgt.ValidateDimValueCode(1, "Global Dimension 1 Code");
DimMgt.SaveDefaultDim(Database::Course, "No.", 1, "Global Dimension 1 Code");
end;
}
}
trigger OnDelete()
var
DimMgt: Codeunit DimensionManagement;
begin
DimMgt.DeleteDefaultDim(Database::Course, "No.");
end;
}
// Transactional/document data: a single Dimension Set ID, inherited from the
// related master record and overridable via shortcut dimension fields.
table 50101 "Course Registration Header"
{
fields
{
field(1; "No."; Code[20]) { }
field(2; "Customer No."; Code[20])
{
TableRelation = Customer;
trigger OnValidate()
begin
UpdateDimensionSetID();
end;
}
field(10; "Shortcut Dimension 1 Code"; Code[20])
{
CaptionClass = '1,1,1';
TableRelation = "Dimension Value".Code where(
"Global Dimension No." = const(1), Blocked = const(false));
trigger OnValidate()
var
DimMgt: Codeunit DimensionManagement;
begin
DimMgt.ValidateShortcutDimValues(1, "Shortcut Dimension 1 Code", "Dimension Set ID");
end;
}
field(480; "Dimension Set ID"; Integer)
{
Editable = false;
TableRelation = "Dimension Set Entry"."Dimension Set ID";
}
}
local procedure UpdateDimensionSetID()
var
Customer: Record Customer;
DimMgt: Codeunit DimensionManagement;
DefaultDimSource: List of [Dictionary of [Integer, Code[20]]];
GlobalDim2Code: Code[20];
begin
// Recompute from scratch (InheritFromDimSetID = 0) whether or not the
// customer lookup succeeds. Passing the existing "Dimension Set ID"
// here would inherit dimensions from whichever record the document
// was previously linked to, and exiting early on a failed Get would
// leave that same stale data in place — both defeat the point of
// this procedure. Clearing the shortcut field and recomputing with
// an empty source list (when the customer doesn't exist) correctly
// clears the document's dimensions instead of leaving old ones.
"Shortcut Dimension 1 Code" := '';
if Customer.Get("Customer No.") then
DimMgt.AddDimSource(DefaultDimSource, Database::Customer, "Customer No.");
"Dimension Set ID" :=
DimMgt.GetDefaultDimID(
DefaultDimSource, '', "Shortcut Dimension 1 Code", GlobalDim2Code, 0, 0);
end;
}

View file

@ -0,0 +1,35 @@
---
bc-version: [all]
domain: data-modeling
keywords: [dimensions, dimensionmanagement, global-dimension, shortcut-dimension, default-dimension, validatedimvaluecode, getdefaultdimid]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Wire dimension support through DimensionManagement, not ad hoc fields
> Contributions welcome — open a PR to refine or extend this article.
## Description
Adding dimension support to a custom table is not just a matter of adding a `Code[20]` field, and master tables and document/transactional tables wire into `Codeunit "Dimension Management"` through two different models — treating them as one mechanism is itself the mistake this article corrects:
- **Master data** (a custom master table, e.g. "Course") persists **Default Dimension** records: each shortcut dimension field validates through `ValidateDimValueCode`, then the result is saved via `SaveDefaultDim`, and `DeleteDefaultDim` removes them again in `OnDelete`. Both `ValidateDimValueCode` and `SaveDefaultDim` take the shortcut dimension *number* (1-8, matching `General Ledger Setup`'s "Shortcut Dimension N Code" fields) as their first/third argument respectively — not the AL field ID of the table field being validated. The master record itself carries no `Dimension Set ID` field.
- **Transactional/document data** (a custom document or journal-line table) carries a single **`Dimension Set ID`** field — a pointer to a shared, deduplicated set of dimension values in `Dimension Set Entry`, assembled from whatever the document inherited plus whatever the user overrode. A document does not acquire that ID by calling `SaveDefaultDim`; it builds a source list with `AddDimSource` (naming the related master table and its key, e.g. `Database::Customer`), then calls `GetDefaultDimID` to compute a new `Dimension Set ID` that inherits the master's Default Dimension records. Editing a shortcut dimension field directly on the document validates through `ValidateShortcutDimValues`, which updates the same `Dimension Set ID` in place rather than writing a separate Default Dimension record.
Skipping the model that actually matches the table's kind produces a field that looks correct in the designer but silently fails to save, validate, or carry through to postings — or, for a document, one that never picks up the customer's/vendor's own dimensions at all.
## Best Practice
For a master table, validate each shortcut dimension field through `ValidateDimValueCode`, save the result with `SaveDefaultDim`, and delete the matching Default Dimension records in `OnDelete`.
For a document table, when the field that attaches the document to a master record changes (e.g. `Customer No.`), call `AddDimSource` naming that master table and key, then `GetDefaultDimID` to compute the document's new `Dimension Set ID`, inheriting the master's Default Dimension records. Pass `0` for `GetDefaultDimID`'s `InheritFromDimSetID` argument in this case — passing the document's *existing* `Dimension Set ID` instead inherits whatever dimensions were already in it, so a value the previous linked record supplied can survive into the new one even where the new record has no default for that dimension. Run this same recompute — clear the shortcut field, call `GetDefaultDimID` with no source added — when the lookup on the new key fails (blank or an invalid value), too: exiting early instead leaves the previous record's dimensions in place, which is the same staleness bug the `InheritFromDimSetID = 0` rule exists to prevent. Validate the document's own Shortcut Dimension fields through `ValidateShortcutDimValues`, which updates that same `Dimension Set ID` in place rather than persisting a separate Default Dimension record.
See sample: [`dimension-management-wiring.good.al`](dimension-management-wiring.good.al).
## Anti Pattern
Adding a dimension-looking field with only a `TableRelation` to Dimension Value, and no call into `DimensionManagement` at all. The field accepts input but never becomes a real Default Dimension record, so it does not validate against blocked values and does not flow into postings.
See sample: [`dimension-management-wiring.bad.al`](dimension-management-wiring.bad.al).

View file

@ -0,0 +1,14 @@
table 50100 "Period Stats"
{
fields
{
field(1; "Period Start"; Date) { }
field(2; Flow; Enum "Some Flow") { }
}
keys
{
// Table already shipped with key(PK; "Period Start").
// Adding Flow here breaks every upgrade with AS0009.
key(PK; Flow, "Period Start") { Clustered = true; }
}
}

View file

@ -0,0 +1,24 @@
table 50100 "Period Stats"
{
fields
{
field(1; "Period Start"; Date) { }
}
keys
{
key(PK; "Period Start") { Clustered = true; }
}
}
table 50101 "Period Stats By Flow"
{
fields
{
field(1; "Period Start"; Date) { }
field(2; Flow; Enum "Some Flow") { }
}
keys
{
key(PK; Flow, "Period Start") { Clustered = true; }
}
}

View file

@ -0,0 +1,28 @@
---
bc-version: [all]
domain: data-modeling
keywords: [primary-key, clustered-key, table-design, appsource, breaking-change, schema-upgrade]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Never Change a Published Table's Primary or Clustered Key Field List
> Contributions welcome — open a PR to refine or extend this article.
## Description
Once a table has shipped — to AppSource, or to any customer environment that has already upgraded onto it — its primary key, and any other key marked `Clustered = true`, is frozen. This includes adding a field to the key, not only removing or reordering one: Business Central identifies existing rows by their key value, so any change to which fields compose that key invalidates every row already stored under the old shape, and the platform's upgrade validation rejects it outright (`AS0009`). This is easy to trip over because it doesn't look like the well-known "don't delete a field" mistake — the field being added is often brand new, and folding a new discriminating dimension straight into the existing key feels like the natural, un-denormalized way to model it. On an unpublished table that is correct; on a published one it is a breaking schema change regardless of which direction the field list changed, and there is no in-place fix once the upgrade is rejected, only reverting the key to its published shape.
## Best Practice
Leave a published table's key exactly as shipped. Model a new discriminating dimension as a separate table with its own key instead of adding a field to the existing key, and branch orchestration code by the new dimension rather than filtering one shared table on an extra key field.
See sample: [`do-not-change-primary-key.good.al`](do-not-change-primary-key.good.al).
## Anti Pattern
Adding a field to a published table's primary or clustered key to distinguish a new case. This fails AppSource validation or any customer upgrade with `AS0009` as soon as rows already exist under the old key shape, whether the field is being added, removed, or reordered.
See sample: [`do-not-change-primary-key.bad.al`](do-not-change-primary-key.bad.al).

View file

@ -0,0 +1,45 @@
page 50101 "Sample Posted Invoice Card"
{
PageType = Card;
SourceTable = "Sales Invoice Header";
ApplicationArea = All;
Editable = false;
actions
{
area(Processing)
{
action(EmailDocument)
{
ApplicationArea = All;
Caption = 'Email';
Image = Email;
trigger OnAction()
var
SalesInvoiceHeader: Record "Sales Invoice Header";
DocumentSendingProfile: Record "Document Sending Profile";
ReportDistributionMgt: Codeunit "Report Distribution Management";
begin
// WRONG: this is a plain, on-demand "Email" button, not
// part of a combined Post-and-Send action - but this
// loads the customer's ACTUAL assigned profile (or the
// tenant default, if none is assigned - the same lookup
// Sales-Post and Send performs) and calls Send on it, so
// the outcome now silently depends on that profile. A
// profile set up for Post-and-Send printing only (say,
// Printer = Yes, "E-Mail" = No) turns this button into a
// silent no-op, with no indication an unrelated setup
// field is why.
SalesInvoiceHeader := Rec;
CurrPage.SetSelectionFilter(SalesInvoiceHeader);
DocumentSendingProfile.GetDefaultForCustomer(Rec."Bill-to Customer No.", DocumentSendingProfile);
DocumentSendingProfile.Send(
"Report Selection Usage"::"S.Invoice".AsInteger(), SalesInvoiceHeader, Rec."No.",
Rec."Bill-to Customer No.", ReportDistributionMgt.GetFullDocumentTypeText(Rec),
SalesInvoiceHeader.FieldNo("Bill-to Customer No."), SalesInvoiceHeader.FieldNo("No."));
end;
}
}
}
}

View file

@ -0,0 +1,63 @@
page 50101 "Sample Posted Invoice Card"
{
PageType = Card;
SourceTable = "Sales Invoice Header";
ApplicationArea = All;
Editable = false;
actions
{
area(Processing)
{
action(EmailDocument)
{
ApplicationArea = All;
Caption = 'Email';
Image = Email;
trigger OnAction()
var
SalesInvoiceHeader: Record "Sales Invoice Header";
ReportSelections: Record "Report Selections";
ReportDistributionMgt: Codeunit "Report Distribution Management";
begin
// Calls Report Selections directly - the button's outcome
// depends only on this customer's registered report/layout,
// not on any Document Sending Profile setting. Calling
// DocumentSendingProfile.TrySendToEMail(...) instead would
// also be correct, because it never reads the customer's
// assigned profile: it only uses a local record that it
// never retrieves with Get, and sets its "E-Mail" option
// itself. The
// anti-pattern is Get/GetDefaultForCustomer followed by
// Send, which makes the outcome depend on that profile.
// "S.Invoice" resolves to a report on "Sales Invoice
// Header", which is the record passed here.
SalesInvoiceHeader := Rec;
CurrPage.SetSelectionFilter(SalesInvoiceHeader);
ReportSelections.SendEmailToCust(
"Report Selection Usage"::"S.Invoice".AsInteger(), SalesInvoiceHeader, Rec."No.",
ReportDistributionMgt.GetFullDocumentTypeText(Rec), true, Rec."Bill-to Customer No.");
end;
}
action(PrintDocument)
{
ApplicationArea = All;
Caption = 'Print';
Image = Print;
trigger OnAction()
var
SalesInvoiceHeader: Record "Sales Invoice Header";
ReportSelections: Record "Report Selections";
begin
SalesInvoiceHeader := Rec;
CurrPage.SetSelectionFilter(SalesInvoiceHeader);
ReportSelections.PrintWithDialogForCust(
"Report Selection Usage"::"S.Invoice", SalesInvoiceHeader, true,
SalesInvoiceHeader.FieldNo("Bill-to Customer No."));
end;
}
}
}
}

View file

@ -0,0 +1,100 @@
---
bc-version: [all]
domain: data-modeling
keywords: [report-selections, document-sending-profile, print, email, post-and-send]
technologies: [al]
countries: [w1]
application-area: [all]
---
# A document's own Print/Email actions call Report Selections directly; Document Sending Profile is scoped to Post-and-Send
## Description
`table 60 "Document Sending Profile"` is not a general gateway for every
print/email path — it exists specifically for the combined **Post and
Send** action: "You can set each customer up with a preferred method of
sending sales documents, so that you do not have to select a sending
option every time you choose the Post and Send action" (Microsoft Learn,
"Set Up Document Sending Profiles"). A document's own, ordinary
Print/Email actions are unaffected by any *configured* profile either
way: the unposted Sales Order's "Print Confirmation"/"Email
Confirmation" (`codeunit "Document-Print"`,
`PrintSalesOrder`/`EmailSalesHeader`) and the posted `Purch. Inv.
Header`'s `PrintRecords` call `Report Selections` literally directly
(`PrintWithDialogForCust`/`SendEmailToCust`/`PrintWithDialogForVend`),
while the posted `Sales Invoice Header`'s `PrintRecords`/`EmailRecords`
and the unposted `Purchase Header`'s `PrintRecords` go through
`DocumentSendingProfile.TrySendToPrinter`/`TrySendToEMail`/
`TrySendToPrinterVendor` instead. Those three helpers each declare a
fresh, local, never-`Get`'d profile record, hardcode its
`Printer`/`"E-Mail"` field to a "Yes" option themselves, and feed it into
`SendToPrinter`/`SendToEMailGroupedMultipleSelection` — which resolve
into Report Selections just like the direct route. The table is a
throwaway options carrier here, not the counterparty's configuration.
Only a genuinely configured profile changes the outcome, and that only
happens for the combined Post-and-Send flow: `Sales-Post and Send` loads
the customer's assigned profile (`Get(Customer."Document Sending
Profile")`, or the tenant default) before `Sales Invoice
Header.SendProfile` → `DocumentSendingProfile.Send`, which gates
`SendToPrinter`/`SendToEMail`/`SendToDisk` on whatever that record holds.
Whether a document needs outbound distribution isn't determined by
Customer vs. Vendor, but by whether it's genuinely *outbound* to that
party: a posted Purchase Invoice records what a vendor already billed,
so the posted `Purch. Inv. Header` has only a bare `PrintRecords`; a
Purchase *Order* is still outbound before posting, so the rich
`SendProfile`/`SendRecords`/`PrintRecords` triplet lives there instead.
## Best Practice
For a document's own interactive Print/Email actions, either call the
relevant `Report Selections` procedure directly —
`PrintForCust`/`PrintWithDialogForCust`/`SendEmailToCust` for a
customer-facing document, `PrintWithDialogForVend`/`SendEmailToVendor`
for a vendor-facing one — or call one of `Document Sending Profile`'s
stateless `TrySendToPrinter`/`TrySendToEMail`/`TrySendToPrinterVendor`
helpers, using the usage value registered per
`extend-report-selection-usage-for-new-document-types.md`. Both are
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`](document-print-and-email-actions-call-report-selections-directly.good.al).
## Anti Pattern
Loading the counterparty's *actually assigned* `Document Sending
Profile` (or the tenant default, via `Get`/`GetDefaultForCustomer`/
`GetDefaultForVendor` — the same lookup `Sales-Post and Send` performs)
and calling `Send`/`SendVendor` on it from a plain, on-demand "Email"
button, instead of `ReportSelections.SendEmailToCust`/`SendEmailToVendor`
directly. The button's outcome now silently depends on a profile
configured for Post-and-Send — if its `"E-Mail"` option is `No`,
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`](document-print-and-email-actions-call-report-selections-directly.bad.al).
## Source
BCApps `DocumentPrint.Codeunit.al` (`EmailSalesHeader`/`DoPrintSalesHeader`/
`PrintSalesOrder` → `ReportSelections.SendEmailToCust`/`PrintForCust`/
`PrintWithDialogForCust` directly), `SalesInvoiceHeader.Table.al`
(`PrintRecords`/`EmailRecords`, lines 1453/1528 → `TrySendToPrinter`/
`TrySendToEMail`, lines 1462/1541, on a local never-`Get`'d record),
`PurchaseHeader.Table.al` (`PrintRecords` line 6357 →
`TrySendToPrinterVendor` line 6374; `SendProfile` line 6387 →
`SendVendor` line 6403), `PurchInvHeader.Table.al` (`PrintRecords` →
`ReportSelection.PrintWithDialogForVend` directly, no send capability),
`SalesPostandSend.Codeunit.al`/`SalesPost.Codeunit.al`
(`ConfirmPostAndSend` loads `Get(Customer."Document Sending
Profile")`/`GetDefault`; `SendPostedDocumentRecord` line 7660 →
`SalesInvHeader.SendProfile` lines 7680/7699 →
`DocumentSendingProfile.Send`), `DocumentSendingProfile.Table.al` (table
60; `TrySendToPrinter`/`TrySendToEMail` lines 536/562,
`TrySendToPrinterVendor` line 552, `GetDefaultForCustomer` line 195,
`Send`/`SendVendor` lines 482/506) — all under `src/Layers/W1/BaseApp/`.
Microsoft Learn, "Set Up Document Sending Profiles": https://learn.microsoft.com/dynamics365/business-central/sales-how-setup-document-send-profiles

View file

@ -0,0 +1,35 @@
table 50104 "Sample Posted Document Header"
{
DataClassification = CustomerContent;
fields
{
field(1; "No."; Code[20]) { Caption = 'No.'; }
field(2; "Posting Date"; Date) { Caption = 'Posting Date'; }
}
keys
{
key(PK; "No.") { Clustered = true; }
}
}
codeunit 50103 "Sample Navigate Subscribers"
{
// WRONG: registers the row, so it appears in the Find Entries result
// list with a correct table name and record count - but there is no
// OnBeforeShowRecords subscriber for this table. ShowRecords()'s own
// case statement has no branch and no else for it either, so
// selecting this row and choosing "Show records" does nothing,
// silently, with no error.
[EventSubscriber(ObjectType::Page, Page::Navigate, 'OnAfterFindRecords', '', false, false)]
local procedure OnAfterFindRecords(var DocumentEntry: Record "Document Entry"; DocNoFilter: Text; PostingDateFilter: Text)
var
SampleDocHeader: Record "Sample Posted Document Header";
begin
SampleDocHeader.SetFilter("No.", DocNoFilter);
SampleDocHeader.SetFilter("Posting Date", PostingDateFilter);
DocumentEntry.InsertIntoDocEntry(
Database::"Sample Posted Document Header", SampleDocHeader.TableCaption(), SampleDocHeader.Count());
end;
}

View file

@ -0,0 +1,68 @@
table 50104 "Sample Posted Document Header"
{
DataClassification = CustomerContent;
fields
{
field(1; "No."; Code[20]) { Caption = 'No.'; }
field(2; "Posting Date"; Date) { Caption = 'Posting Date'; }
}
keys
{
key(PK; "No.") { Clustered = true; }
}
}
page 50104 "Sample Posted Document"
{
PageType = Card;
SourceTable = "Sample Posted Document Header";
UsageCategory = None;
ApplicationArea = All;
layout
{
area(Content)
{
field("No."; Rec."No.") { ApplicationArea = All; }
field("Posting Date"; Rec."Posting Date") { ApplicationArea = All; }
}
}
}
codeunit 50103 "Sample Navigate Subscribers"
{
[EventSubscriber(ObjectType::Page, Page::Navigate, 'OnAfterFindRecords', '', false, false)]
local procedure OnAfterFindRecords(var DocumentEntry: Record "Document Entry"; DocNoFilter: Text; PostingDateFilter: Text)
var
SampleDocHeader: Record "Sample Posted Document Header";
begin
SampleDocHeader.SetFilter("No.", DocNoFilter);
SampleDocHeader.SetFilter("Posting Date", PostingDateFilter);
DocumentEntry.InsertIntoDocEntry(
Database::"Sample Posted Document Header", SampleDocHeader.TableCaption(), SampleDocHeader.Count());
end;
// Without this second subscriber, the row added above shows up in the
// Find Entries result list with a correct count, but "Show records"
// has nothing to open it with - see the .bad.al sample.
[EventSubscriber(ObjectType::Page, Page::Navigate, 'OnBeforeShowRecords', '', false, false)]
local procedure OnBeforeShowRecords(var TempDocumentEntry: Record "Document Entry" temporary; DocNoFilter: Text; PostingDateFilter: Text; ItemTrackingSearch: Boolean; ContactNo: Code[250]; ExtDocNo: Code[250]; var IsHandled: Boolean)
var
SampleDocHeader: Record "Sample Posted Document Header";
begin
if TempDocumentEntry."Table ID" <> Database::"Sample Posted Document Header" then
exit;
SampleDocHeader.SetFilter("No.", DocNoFilter);
SampleDocHeader.SetFilter("Posting Date", PostingDateFilter);
if TempDocumentEntry."No. of Records" = 1 then begin
SampleDocHeader.FindFirst();
Page.Run(Page::"Sample Posted Document", SampleDocHeader);
end else
Page.Run(0, SampleDocHeader);
IsHandled := true;
end;
}

View file

@ -0,0 +1,93 @@
---
bc-version: [all]
domain: data-modeling
keywords: [navigate, find-entries, document-entry, integration-event, drill-down]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Extend Find Entries (Navigate) for new document or transaction tables
## Description
`page 344 Navigate` (caption "Find entries") lets a user enter a document
number and posting date and see, across every document and ledger entry
table BC knows about, how many matching records exist — then drill into
any of those rows. It works over a temporary `table "Document Entry"`
that gets populated, one row per source table, by dozens of separate
lookups hardcoded into the page (`Rec.InsertIntoDocEntry(Database::"Sales
Invoice Header", ...)` and similar, one per table). A new custom document
or transaction table is invisible to Find Entries by default — nobody
searching by document number will ever see it in the result list — until
it registers itself.
Registration is a two-sided integration event, and only implementing one
side produces a page that is worse than not participating at all. The
`OnAfterFindRecords` event lets a subscriber add a row to the result list
for a custom table. But the subsequent "show records" action, `procedure
ShowRecords`, resolves which page to open through its own hardcoded `case
Rec."Table ID" of` — the same shape as the row-population code, and just
as unaware of any table added by an extension. That `case` statement has
no `else` branch. A custom table's row can appear in the result list,
with a correct count, and be entirely un-clickable: the user selects it,
chooses "Show records", and nothing happens, silently.
## Best Practice
Subscribe to both `Navigate::OnAfterFindRecords` and
`Navigate::OnBeforeShowRecords` together, as one unit of work, for any
custom table that should be searchable by document number:
- In `OnAfterFindRecords`, filter the custom table by the given
`DocNoFilter`/`PostingDateFilter` and call
`DocumentEntry.InsertIntoDocEntry(Database::"My Table", TableCaption,
Count)` to add it to the result list.
- In `OnBeforeShowRecords`, check whether
`TempDocumentEntry."Table ID" = Database::"My Table"`; if so, re-apply
the same filters, open the appropriate card or list page, and set
`IsHandled := true` so the page's own unrelated `case` statement is
never reached for this table.
- If `OnAfterFindRecords` filters the custom table by a field that is not
already that table's own unique key — for example an external
reference number received from a counterparty, rather than the
table's own `No.` — add a key combining that field with `Posting Date`,
the same way BCApps does for `Purch. Inv. Header`'s `"Vendor Invoice
No."` (see Source). This does not apply when filtering the table's own
primary key, which is already unique on its own: `Sales Invoice
Header` filters `"No."` and `"Posting Date"` through two separate,
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`](extend-find-entries-navigate-for-new-document-types.good.al).
## Anti Pattern
Subscribing only to `OnAfterFindRecords` (or only to
`OnBeforeShowRecords`). Registering the row without handling its
drill-down produces a search result that looks complete — the table name
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`](extend-find-entries-navigate-for-new-document-types.bad.al).
## Source
BCApps `Navigate.Page.al` (page 344, `src/Layers/W1/BaseApp/Foundation/Navigate/`):
- `[IntegrationEvent(true, false)] local procedure OnAfterFindRecords(var DocumentEntry: Record "Document Entry"; DocNoFilter: Text; PostingDateFilter: Text)`
- `[IntegrationEvent(true, false)] local procedure OnBeforeShowRecords(var TempDocumentEntry: Record "Document Entry" temporary; DocNoFilter: Text; PostingDateFilter: Text; ItemTrackingSearch: Boolean; ContactNo: Code[250]; ExtDocNo: Code[250]; var IsHandled: Boolean)`
- `procedure ShowRecords()`'s `case Rec."Table ID" of ... end;` has no `else` branch — confirmed by reading the full case block, which ends directly with `end;` followed by `OnAfterShowRecords(...)`.
BCApps `DocumentEntry.Table.al` (table backing page 344):
`procedure InsertIntoDocEntry(DocTableID: Integer; DocTableName: Text; DocNoOfRecords: Integer)` — the registration entry point called from `OnAfterFindRecords` subscribers.
BCApps `SalesInvoiceHeader.Table.al` (`src/Layers/W1/BaseApp/Sales/History/`):
`key(Key1; "No.")` (`Clustered = true`) and `key(Key9; "Posting Date")` are
two separate, uncombined keys — no compound key exists between them.
BCApps `PurchInvHeader.Table.al` (`src/Layers/W1/BaseApp/Purchases/History/`):
`key(Key4; "Vendor Invoice No.", "Posting Date")` — a compound key
combining a non-unique, externally-supplied reference number with
`Posting Date`, distinct from `key(Key1; "No.")`, its own unique primary
key.

View file

@ -0,0 +1,14 @@
enumextension 50100 "Sample Price Source Ext" extends "Price Source Type"
{
value(50100; "Sample.LoyaltyTier")
{
Caption = 'Loyalty Tier';
Implementation = "Price Source" = "Price Source - Customer", "Price Source Group" = "Price Source Group - Customer";
}
}
// WRONG: no matching value was added to "Sales Price Source Type" (or the
// purchase/job equivalents). "Sample.LoyaltyTier" compiles, installs, and
// is a real value on "Price Source Type" - it just never appears as an
// Applies-to Type option on the Sales Price List page, because that page
// is driven by the separate subset enum, not the base one.

View file

@ -0,0 +1,19 @@
enumextension 50100 "Sample Price Source Ext" extends "Price Source Type"
{
value(50100; "Sample.LoyaltyTier")
{
Caption = 'Loyalty Tier';
Implementation = "Price Source" = "Price Source - Customer", "Price Source Group" = "Price Source Group - Customer";
}
}
enumextension 50101 "Sample Sales Price Source Ext" extends "Sales Price Source Type"
{
// Same numeric ID (50100) as the Price Source Type value above. That
// match is what makes "Sample.LoyaltyTier" show up as a selectable
// Applies-to Type on an actual sales price list.
value(50100; "Sample.LoyaltyTier")
{
Caption = 'Loyalty Tier';
}
}

View file

@ -0,0 +1,68 @@
---
bc-version: [all]
domain: data-modeling
keywords: [price-calculation, price-source, price-source-type, enumextension, pricing]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Extend Price Source Type and its matching document subset enum together, with the same ID
## Description
`enum 7003 "Price Source Type"` (`implements "Price Source", "Price Source
Group"`) is the base list of who a price can apply to — Customer, Vendor,
Customer Price Group, Campaign, and so on. It is not, by itself, what
drives the "Applies-to Type" field on an actual sales, purchase, or job
price list. Each document area has its own subset enum —
`enum 7006 "Sales Price Source Type"`, the equivalent purchase and job
enums — and these are what the price list pages actually expose. Every
value the two enums share today uses the identical numeric ID: `All
Customers`/`Customer`/`Customer Price Group`/`Customer Disc.
Group`/`Campaign`/`Contact` are 10/11/12/13/50/51 in both `Price Source
Type` and `Sales Price Source Type`.
Adding a new value to `Price Source Type` alone does nothing for a sales
price list: the base enum and the document subset enum are two separate
extensible enums, linked only by convention, not by any platform
mechanism that keeps their IDs in sync. Give the new value a different ID
in each enum, or extend only the base enum, and the source is real and
selectable in some contexts (the base enum is used elsewhere, such as
the generic `Price Source` table) but absent from the specific document
price list a developer actually tested against.
## Best Practice
When a new price source should be usable in a sales, purchase, or job
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`](extend-price-source-type-must-sync-document-subset-enum.good.al).
## Anti Pattern
Extending `Price Source Type` with a new value intended for sales price
lists, without extending `Sales Price Source Type` with a value of the
same ID — or giving it a different ID. Either way, the new source is
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`](extend-price-source-type-must-sync-document-subset-enum.bad.al).
## Source
BCApps (`src/Layers/W1/BaseApp/`): `Pricing/Source/PriceSourceType.Enum.al`
(`enum 7003 "Price Source Type"`, values `10/11/12/13/50/51` for `All
Customers`/`Customer`/`Customer Price Group`/`Customer Disc.
Group`/`Campaign`/`Contact`) and `Sales/Pricing/SalesPriceSourceType.Enum.al`
(`enum 7006 "Sales Price Source Type"`, the same six values at the same
six IDs). Microsoft Learn, "Extending Price Calculations": "The Price
Source Type enum implements the Applies-to Type field in the header of
the price list. Additionally, the Sales Price Source Type, Purchase Price
Source Type, and Job Price Source Type are subsets of the Price Source
Type enum... For compatibility, the new value must have the same ID in
both enums."
(https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/devenv-extending-best-price-calculations)

View file

@ -0,0 +1,47 @@
enumextension 50100 "Sample Report Selection Usage Ext" extends "Report Selection Usage"
{
value(50100; "Sample.SettlementDoc")
{
Caption = 'Sample Settlement Document';
}
}
report 50100 "Sample Settlement Document"
{
UsageCategory = ReportsAndAnalysis;
ApplicationArea = All;
dataset
{
dataitem(Customer; Customer)
{
column(No_Customer; "No.") { }
}
}
}
codeunit 50100 "Sample Report Selection Install"
{
procedure InstallDefaultReportSelection()
var
ReportSelections: Record "Report Selections";
begin
ReportSelections.InsertRecord(
"Report Selection Usage"::"Sample.SettlementDoc", '1', Report::"Sample Settlement Document");
// Registration ends here. No enumextension was added to
// "Custom Report Selection Sales" (or "Report Selection Usage
// Vendor"), and no subscriber was added to
// OnAfterOnMapTableUsageValueToPageValue, OnValidateUsage2OnCaseElse,
// or OnAfterFilterCustomerUsageReportSelections /
// OnAfterFilterVendorUsageReportSelections.
//
// The tenant-wide default works, so the gap isn't visible in
// testing - but on the Document Layouts page for a specific
// customer or vendor: an existing row for this usage shows blank in
// the Usage column (no map event), a user cannot pick this usage
// from the Usage dropdown at all (no validate event and no
// page-facing enum value to pick), and "Copy from Report Selection"
// never lists it either (no filter event). No error, no visible
// sign that anything is missing.
end;
}

View file

@ -0,0 +1,87 @@
enumextension 50100 "Sample Report Selection Usage Ext" extends "Report Selection Usage"
{
value(50100; "Sample.SettlementDoc")
{
Caption = 'Sample Settlement Document';
}
}
// This document is only ever issued to a customer, so only the customer-side
// page-facing enum is extended - not the vendor-side one too. This mirrors
// BCApps' ReportSelectionHandlerCZZ, which extends "Custom Report Selection
// Sales" for its customer-only usages and "Report Selection Usage Vendor"
// for its vendor-only usages, never both for the same one-sided value.
enumextension 50101 "Sample Cust. Rep. Sel. Sales Ext" extends "Custom Report Selection Sales"
{
value(50100; "Sample.SettlementDoc")
{
Caption = 'Sample Settlement Document';
}
}
report 50100 "Sample Settlement Document"
{
UsageCategory = ReportsAndAnalysis;
ApplicationArea = All;
dataset
{
dataitem(Customer; Customer)
{
column(No_Customer; "No.") { }
}
}
}
codeunit 50100 "Sample Report Selection Install"
{
procedure InstallDefaultReportSelection()
var
ReportSelections: Record "Report Selections";
begin
ReportSelections.InsertRecord(
"Report Selection Usage"::"Sample.SettlementDoc", '1', Report::"Sample Settlement Document");
end;
}
codeunit 50101 "Sample Report Selection Subscribers"
{
// Customer-only document: all three subscribers below are on
// "Customer Report Selections" only. There are no matching subscribers
// on "Vendor Report Selections" - subscribing there too would be the
// overbroad mistake this sample avoids (see the .bad.al companion and
// the article's Anti Pattern #2).
// 1) Map: lets an existing row display in the Usage column instead of
// showing blank.
[EventSubscriber(ObjectType::Page, Page::"Customer Report Selections", 'OnAfterOnMapTableUsageValueToPageValue', '', false, false)]
local procedure AddSampleUsageOnAfterOnMapTableUsageValueToPageValue(var Usage2: Enum "Custom Report Selection Sales"; CustomReportSelection: Record "Custom Report Selection")
begin
if CustomReportSelection.Usage = "Report Selection Usage"::"Sample.SettlementDoc" then
Usage2 := "Custom Report Selection Sales"::"Sample.SettlementDoc";
end;
// 2) Validate: lets a user pick the new value from the Usage dropdown.
[EventSubscriber(ObjectType::Page, Page::"Customer Report Selections", 'OnValidateUsage2OnCaseElse', '', false, false)]
local procedure AddSampleUsageOnValidateUsage2OnCaseElse(var CustomReportSelection: Record "Custom Report Selection"; ReportUsage: Option)
begin
if ReportUsage = "Custom Report Selection Sales"::"Sample.SettlementDoc".AsInteger() then
CustomReportSelection.Usage := "Report Selection Usage"::"Sample.SettlementDoc";
end;
// 3) Filter: wires "Copy from Report Selection" - the piece most
// guidance stops at, appending to whatever filter already exists rather
// than replacing it.
[EventSubscriber(ObjectType::Page, Page::"Customer Report Selections", 'OnAfterFilterCustomerUsageReportSelections', '', false, false)]
local procedure AddSampleUsageOnAfterFilterCustomerUsageReportSelections(var ReportSelections: Record "Report Selections")
begin
ReportSelections.SetFilter(Usage, GetUsageFilter(ReportSelections));
end;
local procedure GetUsageFilter(var ReportSelections: Record "Report Selections") UsageFilter: Text
begin
UsageFilter := Format("Report Selection Usage"::"Sample.SettlementDoc");
if ReportSelections.GetFilter(Usage) <> '' then
UsageFilter := StrSubstNo('%1|%2', ReportSelections.GetFilter(Usage), UsageFilter);
end;
}

View file

@ -0,0 +1,99 @@
---
bc-version: [all]
domain: data-modeling
keywords: [report-selections, report-selection-usage, enumextension, document-layouts, custom-report-selection]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Register a new document type through Report Selections, and wire it into Document Layouts correctly
## Description
A custom document that needs printing/emailing should be registered
through `table 77 "Report Selections"`. `enum 77 "Report Selection Usage"`
is `Extensible = true` for exactly this: add a value via `enumextension`,
then `ReportSelections.InsertRecord(Usage, Sequence, ReportID)` for a
tenant-wide default — the mechanism every standard document uses.
That alone does not make the value usable in "Document Layouts"
(`page 9657 "Customer Report Selections"` / `page 9658 "Vendor Report
Selections"`, table 9657 "Custom Report Selection"). Both pages hide
`enum 77` behind their own page-facing enum — `enum 9657 "Custom Report
Selection Sales"` (customer) / `enum 9658 "Report Selection Usage Vendor"`
(vendor) — in a field named `Usage2`. A new value stays invisible there
until that page enum is extended too and three events are handled:
`OnAfterOnMapTableUsageValueToPageValue` / `OnMapTableUsageValueToPage
ValueOnCaseElse` (Usage column display), `OnValidateUsage2OnCaseElse`
(picking it from the dropdown), and `OnAfterFilterCustomerUsageReport
Selections` / `OnAfterFilterVendorUsageReportSelections` (the **"Copy from
Report Selection"** action only — a hardcoded-list filter, nothing more).
Which side(s) need this depends on the counterparty the document actually
applies to — not "always both." BCApps' `ReportSelectionHandlerCZZ`
(Advance Payments) partitions strictly: `"Sales Advance..."` usages get
only the customer-side triad, `"Purchase Advance..."` only the vendor-side
triad. `ReportSelectionHandlerCZC` (Compensation) subscribes both sides —
legitimately, since that document posts to both ledgers, not by default.
## Best Practice
1. Add the usage value (`enumextension ... extends "Report Selection
Usage"`) and register the tenant-wide default.
2. Decide which counterparty(ies) apply — customer, vendor, or both.
3. For each applicable side, extend the matching page enum
(`"Custom Report Selection Sales"` / `"Report Selection Usage Vendor"`)
and subscribe to that page's map, validate, and filter events —
appending with `StrSubstNo('%1|%2', ReportSelections.GetFilter(Usage),
UsageFilter)`, never overwriting.
4. Do not subscribe the other side for a one-sided document: skip the
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`](extend-report-selection-usage-for-new-document-types.good.al)
(customer-only document — only the customer-side enum and triad added).
## Anti Pattern
1. Register the usage value but add no page-enum extension and no
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`](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
usages, and clutters the other counterparty's picker with a value that
will never resolve a report there.
## Source
`ReportSelections.Table.al` (table 77, `InsertRecord` line 344),
`ReportSelectionUsage.Enum.al` (enum 77, `Extensible = true`),
`CustomReportSelection.Table.al` (table 9657) — all under
`src/Layers/W1/BaseApp/Foundation/Reporting/`.
`CustomerReportSelections.Page.al` (page 9657, `.../Sales/Setup/`):
`FilterCustomerUsageReportSelections` (307),
`OnAfterFilterCustomerUsageReportSelections` (335),
`OnAfterOnMapTableUsageValueToPageValue` (325),
`OnValidateUsage2OnCaseElse` (330); enum `CustomReportSelectionSales.Enum.al`
(9657, same folder). `VendorReportSelections.Page.al` (page 9658,
`.../Purchases/Setup/`): `FilterVendorUsageReportSelections` (281),
`OnAfterFilterVendorUsageReportSelections` (296),
`OnMapTableUsageValueToPageValueOnCaseElse` (301),
`OnValidateUsage2OnCaseElse` (306); enum `ReportSelectionUsageVendor.Enum.al`
(9658, same folder).
Partitioning precedent: `.../AdvancePaymentsLocalization/app/Src/Codeunits/
ReportSelectionHandlerCZZ.Codeunit.al` (codeunit 31420) — customer-only
triad (47, 58, 69) for `"Sales Advance..."`, vendor-only triad (80, 91,
102) for `"Purchase Advance..."`, never both for one usage. Enum
extensions: `CustomReportSelSalesCZZ.EnumExt.al` (31008), `ReportSelUsage
VendorCZZ.EnumExt.al` (11708).
Contrast (two-sided): `.../CompensationLocalization/app/Src/Codeunits/
ReportSelectionHandlerCZC.Codeunit.al` (codeunit 11765) subscribes both
triads (16/27/38, 44/55/66) for `"Compensation CZC"`, which posts to both
a customer and a vendor ledger. (Lines as of `main`; may shift by version.)

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

@ -0,0 +1,12 @@
codeunit 50130 "Sample Item Ledger Lookup"
{
procedure GetPostedItemLedgerEntries(var SalesHeader: Record "Sales Header"; var ItemLedgerEntry: Record "Item Ledger Entry")
var
LibrarySales: Codeunit "Library - Sales";
InvoiceNo: Code[20];
begin
InvoiceNo := LibrarySales.PostSalesDocument(SalesHeader, true, true);
ItemLedgerEntry.SetRange("Document No.", InvoiceNo);
if ItemLedgerEntry.FindSet() then;
end;
}

View file

@ -0,0 +1,13 @@
codeunit 50130 "Sample Item Ledger Lookup"
{
procedure GetPostedItemLedgerEntries(var SalesHeader: Record "Sales Header"; var ItemLedgerEntry: Record "Item Ledger Entry")
var
LibrarySales: Codeunit "Library - Sales";
ShippingNo: Code[20];
begin
LibrarySales.PostSalesDocument(SalesHeader, true, true);
ShippingNo := SalesHeader."Last Shipping No.";
ItemLedgerEntry.SetRange("Document No.", ShippingNo);
if ItemLedgerEntry.FindSet() then;
end;
}

View file

@ -0,0 +1,26 @@
---
bc-version: [all]
domain: data-modeling
keywords: [item-ledger-entry, document-no, last-shipping-no, ship-and-invoice, posting, sales-order]
technologies: [al]
countries: [w1]
application-area: [all]
---
# After Ship-and-Invoice posting, Item Ledger Entry carries the shipment document number
## Description
Posting a sales order with both Ship and Invoice in one call creates the Item Ledger Entry during the shipment leg of that combined post, so the entry's `Document No.` is stamped with the value assigned to the shipment — `Sales Header."Last Shipping No."` — not the posted sales invoice number the posting call returns. Code that filters Item Ledger Entry by the invoice number instead finds nothing: `SetRange`/`FindSet` simply return zero rows, with no error to signal the mistake.
## Best Practice
After posting a sales order with Ship and Invoice together, read `SalesHeader."Last Shipping No."` (populated during the post) and filter Item Ledger Entry by that value, not by the invoice number the posting routine returns.
See sample: [`item-ledger-entry-document-no-follows-last-shipping-no.good.al`](item-ledger-entry-document-no-follows-last-shipping-no.good.al).
## Anti Pattern
Filtering Item Ledger Entry by the posted sales invoice number after a combined Ship-and-Invoice post. The filter compiles and runs without error but matches zero rows, because the entry belongs to the shipment leg of the posting, not the invoice leg.
See sample: [`item-ledger-entry-document-no-follows-last-shipping-no.bad.al`](item-ledger-entry-document-no-follows-last-shipping-no.bad.al).

View file

@ -0,0 +1,25 @@
tableextension 50105 "Sample Sales Line Ext" extends "Sales Line"
{
fields
{
// WRONG: no OnValidate trigger. The field is registered as a
// price source below via OnAfterAddSources, so new lines price
// correctly - but changing this field on an existing line never
// triggers a recalculation (e.g. via UpdateUnitPrice), so the
// unit price silently keeps its old value.
field(50100; "Sample Loyalty Customer No."; Code[20])
{
Caption = 'Sample Loyalty Customer No.';
TableRelation = Customer;
}
}
}
codeunit 50106 "Sample Sales Line Price Sources"
{
[EventSubscriber(ObjectType::Codeunit, Codeunit::"Sales Line - Price", 'OnAfterAddSources', '', false, false)]
local procedure AddLoyaltyCustomerSource(SalesHeader: Record "Sales Header"; SalesLine: Record "Sales Line"; PriceType: Enum "Price Type"; var PriceSourceList: Codeunit "Price Source List")
begin
PriceSourceList.Add(Enum::"Price Source Type"::Customer, SalesLine."Sample Loyalty Customer No.");
end;
}

View file

@ -0,0 +1,38 @@
tableextension 50105 "Sample Sales Line Ext" extends "Sales Line"
{
fields
{
field(50100; "Sample Loyalty Customer No."; Code[20])
{
Caption = 'Sample Loyalty Customer No.';
TableRelation = Customer;
trigger OnValidate()
begin
// Second half of the wiring: without this call, changing
// the field on an existing line never re-runs price
// calculation, even though the source is already a known
// candidate via OnAfterAddSources below.
//
// UpdateUnitPriceByField(CalledByFieldNo) only recalculates
// if PlanPriceCalcByField(CalledByFieldNo) was already
// called for that same field - calling it alone is a
// silent no-op. UpdateUnitPrice(CalledByFieldNo) does both
// steps in the right order (plan, then update) in one
// call; it's the same method the base app itself calls
// from outside Sales Line to trigger recalculation for a
// field it just changed.
UpdateUnitPrice(FieldNo("Sample Loyalty Customer No."));
end;
}
}
}
codeunit 50106 "Sample Sales Line Price Sources"
{
[EventSubscriber(ObjectType::Codeunit, Codeunit::"Sales Line - Price", 'OnAfterAddSources', '', false, false)]
local procedure AddLoyaltyCustomerSource(SalesHeader: Record "Sales Header"; SalesLine: Record "Sales Line"; PriceType: Enum "Price Type"; var PriceSourceList: Codeunit "Price Source List")
begin
PriceSourceList.Add(Enum::"Price Source Type"::Customer, SalesLine."Sample Loyalty Customer No.");
end;
}

View file

@ -0,0 +1,97 @@
---
bc-version: [all]
domain: data-modeling
keywords: [price-calculation, price-source, onafteraddsources, recalculation, pricing]
technologies: [al]
countries: [w1]
application-area: [all]
---
# A new price source needs both a calculation candidate and a recalculation trigger
## Description
Making a custom field usable as a price source on a sales line is two
separate, independent pieces of wiring, and doing only one produces a
line that looks like it's using the new source without ever actually
being priced by it. `codeunit "Sales Line - Price"` publishes
`OnAfterAddSources(SalesHeader: Record "Sales Header"; SalesLine: Record
"Sales Line"; PriceType: Enum "Price Type"; var PriceSourceList: Codeunit
"Price Source List")` — subscribing here and calling
`PriceSourceList.Add(SourceType, SourceNo)` makes the source a candidate
the calculation considers. But nothing about that subscription causes
the price to be *recalculated* when the source field's value changes on
an existing line. That's the second, separate piece, and it needs to be
wired correctly: `Sales Line`'s `procedure
UpdateUnitPriceByField(CalledByFieldNo: Integer)` only recalculates if
the field was already *planned* — internally it exits immediately unless
`procedure PlanPriceCalcByField(CurrPriceFieldNo: Integer)` was already
called for that same field number. Calling `UpdateUnitPriceByField` on
its own, without a matching `PlanPriceCalcByField` call first, compiles
fine and looks correct, but silently recalculates nothing. `Sales Line`
also exposes `procedure UpdateUnitPrice(CalledByFieldNo: Integer)`, a
convenience wrapper that does both steps in the right order (plan, then
update) in one call — this is the method the base app itself calls from
*outside* `Sales Line` to trigger recalculation for a field it just
changed (see `Inventory/Item/Catalog/ItemReferenceManagement.Codeunit.al`:
`SalesLine.UpdateUnitPrice(SalesLine.FieldNo("Item Reference No."))`), and
it's what a custom price source field's own trigger should call too — the
same way Microsoft's own Location example is wired from a `Sales Line`
validation event, not from the price source registration itself.
Add the source without wiring recalculation, and the failure hides
easily: a *new* line still prices correctly, because the field already
holds its value when calculation first runs on insert. The gap only
shows up when someone *changes* the source field's value on an existing
line — the price silently keeps its old value until something unrelated
happens to trigger recalculation.
## Best Practice
Wire both halves together whenever a field becomes a price source: an
`OnAfterAddSources` subscriber that adds it via `PriceSourceList.Add`, and
a trigger on the field itself (its own `OnValidate`, or a matching
`OnAfterValidate` integration event) that calls
`SalesLine.UpdateUnitPrice(SalesLine.FieldNo(<TheField>))`. Calling
`UpdateUnitPriceByField` directly, without first calling
`PlanPriceCalcByField` for that same field number, is *not* equivalent —
it exits immediately and recalculates nothing. `UpdateUnitPrice` does
both calls, in the correct order, in one step.
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
Subscribing to `OnAfterAddSources` to register a custom field as a price
source, without also triggering recalculation (via `UpdateUnitPrice`, or
the `PlanPriceCalcByField` + `UpdateUnitPriceByField` pair) from that
field's own 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`](new-price-source-must-add-candidate-and-trigger-recalculation.bad.al).
## Source
BCApps (`src/Layers/W1/BaseApp/`): `Sales/Pricing/SalesLinePrice.Codeunit.al`
(`local procedure OnAfterAddSources(SalesHeader: Record "Sales Header";
SalesLine: Record "Sales Line"; PriceType: Enum "Price Type"; var
PriceSourceList: Codeunit "Price Source List")`); `Pricing/Source/PriceSourceList.Codeunit.al`
(`procedure Add(SourceType: Enum "Price Source Type"; SourceNo: Code[20])`);
`Sales/Document/SalesLine.Table.al` (`procedure
PlanPriceCalcByField(CurrPriceFieldNo: Integer)`; `procedure
UpdateUnitPrice(CalledByFieldNo: Integer)`; `procedure
UpdateUnitPriceByField(CalledByFieldNo: Integer)`, which exits immediately
unless `FieldCausedPriceCalculation` already equals `CalledByFieldNo` —
the state `PlanPriceCalcByField` sets). External, idiomatic use of the
one-call form: `Inventory/Item/Catalog/ItemReferenceManagement.Codeunit.al`
(`SalesLine.UpdateUnitPrice(SalesLine.FieldNo("Item Reference No."))`).
Microsoft Learn, "Extending Price Calculations" (Location example): "To
recalculate the price, we can subscribe to events that pass the sales
line by reference... We'll call the UpdateUnitPriceByLocationCode()
method, which is a simplified version of the UpdateUnitPriceByField()
method... To add the location in the source list for price calculations,
we'll subscribe to the OnAfterAddSources event of Codeunit 'Sales Line -
Price,' and add the Location Code as a source."
(https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/devenv-extending-best-price-calculations)

View file

@ -0,0 +1,11 @@
table 50111 "Sample Item Card"
{
fields
{
field(1; "No."; Code[20]) { }
field(50; Picture; BLOB)
{
Caption = 'Picture';
}
}
}

View file

@ -0,0 +1,11 @@
table 50110 "Sample Item Card"
{
fields
{
field(1; "No."; Code[20]) { }
field(50; Picture; Media)
{
Caption = 'Picture';
}
}
}

View file

@ -0,0 +1,49 @@
---
bc-version: [all]
domain: data-modeling
keywords: [blob, media, mediaset, picture-field, image-field, table-design]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Pictures must be stored in a Media/MediaSet field, not BLOB
## Description
`BLOB` is still a valid AL field type for arbitrary binary data, but it is
not the right choice for storing pictures or images. The current
recommendation is the `Media` field type for a single image, or
`MediaSet` when a record needs several independent images (e.g. multiple
product photos) — `MediaSet` is a collection of separately-imported media
objects, each with its own identity; it does not generate resized variants
or thumbnails on its own, and displaying more than one item still requires
custom page handling. Media/MediaSet integrate with the platform's
picture control and media repository, which a plain `BLOB` field does not
— but any derived preview or thumbnail image still has to be generated
explicitly and stored in its own field, regardless of which type holds the
source image.
`BLOB` remains the correct choice for genuinely arbitrary binary payloads
that are not images and don't benefit from the media pipeline (e.g. a raw
file attachment blob unrelated to picture rendering).
## Best Practice
Use `Media` for a single image, or `MediaSet` for multiple independent
images, for any field that holds a picture.
See sample: [`pictures-must-use-media-not-blob.good.al`](pictures-must-use-media-not-blob.good.al).
## Anti Pattern
A `BLOB` field named "Picture" compiles and stores the image bytes, but
it misses the picture control integration and media repository that a
`Media`/`MediaSet` field provides for free — the anti pattern is choosing
`BLOB` for image storage out of habit rather than recognizing that the
field is holding a picture, not generic binary data. A related anti
pattern: assuming `MediaSet` gives automatic image variants or thumbnails
because it sounds like a collection with derived versions — it is only a
collection of independently-imported media objects.
See sample: [`pictures-must-use-media-not-blob.bad.al`](pictures-must-use-media-not-blob.bad.al).

View file

@ -0,0 +1,41 @@
report 50110 "Sample Item Barcode Label"
{
UsageCategory = Tasks;
ApplicationArea = All;
Caption = 'Sample Item Barcode Label';
dataset
{
dataitem(Item; Item)
{
column(No_; "No.") { }
column(Barcode; BarcodeText) { }
trigger OnAfterGetRecord()
var
BarcodeFontProvider: Interface "Barcode Font Provider";
begin
// WRONG: a one-dimensional IDAutomation provider path that
// calls EncodeFont without ValidateInput. "Barcode Font
// Provider" (1D) declares both, and IDAutomation 1D
// Provider's EncodeFont does not validate on its own - it
// hands the text straight to the font encoder. Code 39
// accepts only 0-9, A-Z, space and - . $ / + % *, but an
// Item "No." can legally contain characters outside that
// set (e.g. "_" or "#"). Such a value is never rejected;
// it silently reaches the font as an unscannable barcode.
BarcodeFontProvider := Enum::"Barcode Font Provider"::IDAutomation1D;
BarcodeText := BarcodeFontProvider.EncodeFont("No.", BarcodeSymbology);
end;
}
}
var
BarcodeSymbology: Enum "Barcode Symbology";
BarcodeText: Text;
trigger OnInitReport()
begin
BarcodeSymbology := Enum::"Barcode Symbology"::Code39;
end;
}

View file

@ -0,0 +1,54 @@
report 50110 "Sample Item Barcode Label"
{
UsageCategory = Tasks;
ApplicationArea = All;
Caption = 'Sample Item Barcode Label';
dataset
{
dataitem(Item; Item)
{
column(No_; "No.") { }
column(Barcode1D; BarcodeText) { }
column(Barcode2D; QRCodeText) { }
trigger OnAfterGetRecord()
var
BarcodeFontProvider: Interface "Barcode Font Provider";
BarcodeFontProvider2D: Interface "Barcode Font Provider 2D";
begin
// One-dimensional: "Barcode Font Provider" declares both
// ValidateInput and EncodeFont - call both.
BarcodeFontProvider := Enum::"Barcode Font Provider"::IDAutomation1D;
BarcodeFontProvider.ValidateInput("No.", BarcodeSymbology);
BarcodeText := BarcodeFontProvider.EncodeFont("No.", BarcodeSymbology);
// Two-dimensional: "Barcode Font Provider 2D" declares only
// EncodeFont - there is no ValidateInput to call here.
BarcodeFontProvider2D := Enum::"Barcode Font Provider 2D"::IDAutomation2D;
QRCodeText := BarcodeFontProvider2D.EncodeFont("No.", BarcodeSymbology2D);
end;
}
}
var
BarcodeSymbology: Enum "Barcode Symbology";
BarcodeSymbology2D: Enum "Barcode Symbology 2D";
BarcodeText: Text;
QRCodeText: Text;
trigger OnInitReport()
begin
BarcodeSymbology := Enum::"Barcode Symbology"::Code39;
BarcodeSymbology2D := Enum::"Barcode Symbology 2D"::"QR-Code";
end;
// Layout requirement (can't be enforced in AL, so it's stated here):
// the Barcode1D column's text box must use the real, purchased font
// name - IDAutomationHC39M for Code 39 - never an evaluation name
// like "IDAutomationSHC39M Demo". Per Microsoft Learn, using the
// evaluation name in a Business Central online production
// environment means "the barcode won't render" at all. The
// Barcode2D column's font name is IDAutomation2D (IDAutomation2D
// MaxiCode for Maxicode specifically).
}

View file

@ -0,0 +1,99 @@
---
bc-version: [all]
domain: data-modeling
keywords: [barcode, qr-code, barcode-font-provider, barcode-font-provider-2d, report-layout, saas, idautomation, code-39, checksum]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Generate report barcodes through the Barcode module, with the production font name
## Description
Business Central's barcode support lives in the System Application's
`Barcode` module (`src/System Application/App/Barcode`): `interface
"Barcode Font Provider"` / `"Barcode Font Provider 2D"`, `enum "Barcode
Symbology"` / `"Barcode Symbology 2D"`, and built-in implementations
(`codeunit 9215`/`9221`). A report encodes a data string via this API;
the layout then displays it using a barcode *font*.
The two interfaces are not symmetric: `"Barcode Font Provider"` (1D)
declares both `ValidateInput` and `EncodeFont`; `"Barcode Font Provider
2D"` declares only `EncodeFont` (see Source). BCApps' `Item GTIN Label`
report reflects that split exactly — it validates then encodes through
the 1D provider, but only encodes through the 2D provider, for the same
"No." value.
On Business Central online this needs no setup ("the IDAutomation fonts
are automatically available as part of the service" — Microsoft Learn),
unlike on-premises, where fonts must be purchased and installed. That
ease hides a SaaS-specific trap the API doesn't cover: naming the actual
font. IDAutomation ships both a purchased font and a same-looking
evaluation font per version (Code 39: `IDAutomationHC39M` purchased vs.
`IDAutomationSHC39M Demo`) — per Microsoft Learn, "be sure to use the
purchased font name... If you use the evaluation font name, the barcode
won't render." The wrong name produces nothing, in the layout not AL, so
no reviewer catches it reading the object.
## Best Practice
Encode through the real API, matching the calls to what the chosen
interface actually declares. One-dimensional: declare `Interface
"Barcode Font Provider"` and call both `ValidateInput` and `EncodeFont`
— skipping validation lets a value outside the character set, or one
needing a checksum setting never applied, reach the font unchecked.
Two-dimensional: declare `Interface "Barcode Font Provider 2D"` and call
`EncodeFont` alone — there is no `ValidateInput` on this interface.
Treat naming the production font in the layout as equally required, not
an afterthought. Two-dimensional symbologies other than Maxicode use
`IDAutomation2D` (Maxicode: `IDAutomation2D MaxiCode`); one-dimensional
symbologies use the purchased version name (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`](report-barcodes-must-use-barcode-module-and-production-font-name.good.al).
## Anti Pattern
Constructing a barcode string by hand where that construction has a
concrete, independently provable defect: a source value that can contain
characters outside the symbology's character set is never validated, a
checksum the symbology or setup requires is never applied, or there is
concrete evidence of an incompatible font binding.
The delimiter itself is not the defect. `*value*` is a documented, valid
Code 39 form for IDAutomation fonts (Microsoft Learn's font table and
IDAutomation's own manual both give `*` as start/stop); the `(`/`)` that
IDAutomation 1D Provider's encoder emits (BCApps test:
`EncodeFont('1234', Code39) = '(1234)'`) is an alternative start/stop
form the same fonts accept, used to keep `*` out of the human-readable
text. Never flag delimiter choice alone.
The same validation gap exists when the module *is* used: a 1D path that
calls `EncodeFont` on `"Barcode Font Provider"` without `ValidateInput`
(IDAutomation 1D Provider's `EncodeFont` does not validate on its own).
The sample shows this variant, visible in AL alone. A last version:
encoding correctly but naming the evaluation font, which BC online
refuses to render.
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
BCApps (`src/System Application/App/Barcode/src/`):
`Barcode Provider/Font/BarcodeFontProvider.Interface.al` (1D:
`ValidateInput` + `EncodeFont`); `IDAutomation 1D Provider/
IDAutomation1DProvider.Codeunit.al` (`EncodeFont` goes straight to the
symbology encoder; only `ValidateInput` calls `IsValidInput`); `Barcode Provider 2D/Font/BarcodeFontProvider2D.Interface.al`
(2D: only `EncodeFont`). `IDAutomation 1D Provider/Encoders/IDA1DCode39Encoder.Codeunit.al`
(`codeunit 9204`, regex accepts literal `*`; `EncodeFont` → `DotNet FontEncoder.Code39`).
1D/2D split: `.../Inventory/Item/ItemGTINLabel.Report.al` (`report 6625`,
validates+encodes 1D, only encodes 2D). Encoder output form: `IDA1DCode39Test.Codeunit.al`
(`codeunit 135044`): `EncodeFontSuccessTest('1234', Code39, '(1234)')`.
Microsoft Learn "Adding Barcodes to Reports" and "Barcode Fonts with
Business Central Online" — quoted above, incl. the Code39 row ("`*` is
used for both start and stop delimiters"). IDAutomation, "Code 39 Font
User Manual" (https://idautomation.com/barcode-fonts/code-39/fontnames/):
`*` start/stop, or parentheses to keep `*` out of the human-readable text.

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

@ -0,0 +1,17 @@
table 50121 "Sample Ledger Entry"
{
fields
{
// Anti-pattern: a Ledger table's key must never be user-editable.
field(1; "Entry No."; Integer) { }
field(2; "Posting Date"; Date) { }
field(3; Amount; Decimal) { }
}
keys
{
key(PK; "Entry No.") { Clustered = true; }
}
// No AutoIncrement, no guard against manual insert/delete — a user or
// integration can renumber or remove entries, breaking the Ledger
// type's audit-trail guarantee.
}

View file

@ -0,0 +1,16 @@
table 50120 "Sample Ledger Entry"
{
fields
{
// Ledger primary key: Integer "Entry No.", set only by posting.
field(1; "Entry No."; Integer) { AutoIncrement = true; }
field(2; "Posting Date"; Date) { }
field(3; Amount; Decimal) { }
}
keys
{
key(PK; "Entry No.") { Clustered = true; }
}
// No user-facing Insert/Delete/Modify path is exposed; rows are
// created exclusively by the posting routine.
}

View file

@ -0,0 +1,99 @@
---
bc-version: [all]
domain: data-modeling
keywords: [tables, table-design, naming-conventions, primary-key, master-table, ledger-table, journal-table, register-table, document-table, setup-table, subsidiary-table, supplemental-table]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Tables must match one of Business Central's nine table-type conventions
## Description
Business Central's Base Application follows nine recurring table types —
Master, Supplemental, Subsidiary, Ledger, Register, Journal, Document,
Document History, and Setup. Each type fixes a naming pattern, a
primary-key shape, and a set of associated pages. A new or extended table
whose design doesn't match the conventions of its own type is either
misclassified or built inconsistently with the rest of the application,
and should be flagged in review even if it compiles. Before assigning a
primary key or naming a new table, first identify which of the nine types
it is — that answer fixes almost every other design decision.
## Best Practice
Match the table's design to its type:
1. **Master** (Customer, Item) — one record is the subject; primary key
`Code[20]` named `No.`; description field in `DataCaptionFields`; Card +
List (+ Statistics) pages.
2. **Supplemental** (Currency, Language) — used across functional areas;
primary key `Code[10]` named `Code`; one List page, plural name, set as
`LookupPageID`.
3. **Subsidiary** (Item Vendor) — subsidiary to a Master/Supplemental
table; primary key is the parent key field(s), optionally + `Line No.`;
page shape depends on whether the table carries its own identity: a
pure parent-join table (Item Vendor) typically gets a plain List page
filtered by the calling page, while a subsidiary table that supplements
a master record with its own identity — parent key + own code, e.g.
Ship-to Address, Customer/Vendor Bank Account — commonly gets a
List+Card pair instead, for direct editing of that record.
4. **Ledger** (Cust. Ledger Entry) — transactional record of a functional
area; primary key `Integer` `Entry No.`, always auto-generated by
posting, never user-editable, no free add/delete; List page as
`LookupPageID`/`DrillDownPageID`.
5. **Register** (G/L Register) — table of contents for its Ledger, one row
per posting run; primary key `Integer` `No.`, auto-generated; carries
`From Entry No.`/`To Entry No.`; List page with a link to the Ledger.
6. **Journal** (Resource Journal Line) — where users enter data before
posting to a Ledger; primary key Template + Batch + `Integer` `Line No.`;
Worksheet page with `AutoSplitKey`, a Posting action, and a link to the
Ledger.
7. **Document** (Sales Header/Line) — posts to Ledgers via Journals, not
directly; Header primary key `Code[20]` `No.` (or + `Option Document
Type`); Line primary key = Header key renamed `<Document> No.` +
`Integer Line No.`; Document/Card page with a Posting action and a lines
subpage.
8. **Document History** (Posted Sales Invoice Header/Line) — posted copy of
a Document table, created during posting; mirrors the source table's
fields; never user-editable; same page shape but the Line-equivalent is
a List page, not a Worksheet.
9. **Setup** (General Ledger Setup) — exactly one record for a functional
area; primary key `Code[10]` named `Primary Key`, always blank; one page
with the key field hidden, whose `OnOpenPage` creates the singleton the
first time it's opened (`Reset()` → `Get()` → if not found, `Init()` →
`Insert()`) rather than assuming the record pre-exists.
A table named "Setup" that holds more than one record follows Subsidiary
rules instead — the name alone is not proof of type. When a table's
identity can't be resolved from its definition alone (e.g. a "Setup"-named
table with a real business-field key and no page), say so explicitly
rather than forcing a classification; settling it requires checking actual
row cardinality or call sites, not just the object definition.
These nine types cover Business Central's *business-record* tables — they
are not an exhaustive catalogue of every legitimate table shape. A
temporary/buffer table, a work queue, a log or telemetry table, a
cross-reference/mapping table with no business meaning of its own, or a
staging/working table used only inside one process is not required to fit
any of the nine, and forcing one into the nearest-looking type (usually
Ledger, because it has an `Integer` key, or Subsidiary, because it has a
composite key) produces a harmful redesign recommendation for a table that
was never meant to carry that type's guarantees. Apply this rule only when
the table's name, fields, or usage genuinely establish it as one of the
nine business-record types; when nothing points that way, this rule simply
does not apply — that is not the same as an unresolved classification.
See sample: [`table-design-must-match-bc-table-type-conventions.good.al`](table-design-must-match-bc-table-type-conventions.good.al).
## Anti Pattern
A table that mixes conventions from two types — for example, a "Ledger"
table with a user-editable primary key that lets users freely insert or
delete rows — is not "flexible", it is either misclassified or has skipped
a design step. A Ledger table's `Entry No.` must come only from the
posting routine; exposing it as an editable field breaks the type's core
guarantee that entries are an immutable, sequential audit trail.
See sample: [`table-design-must-match-bc-table-type-conventions.bad.al`](table-design-must-match-bc-table-type-conventions.bad.al).

View file

@ -0,0 +1,12 @@
codeunit 50100 "Tax Posting Helper"
{
procedure GetTaxAccount(var Setup: Record "Sales & Receivables Setup"): Code[20]
var
DefaultTaxAccountTxt: Label 'DEFAULT-TAX';
begin
Setup.Get();
if Setup."Tax Account No." <> '' then
exit(Setup."Tax Account No.");
exit(DefaultTaxAccountTxt);
end;
}

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