Fix 5 merge-critical issues from Jesper's 2026-09-24 review round

- activate-new-price-calculation-handler-via-onfindsupportedsetup: Default
  := true is required only for the fallback branch of PriceCalculationMgt's
  two-stage FindSetup - a handler reachable via a specific Dtld. Price
  Calculation Setup row needs no Default. Softened the article and its
  worklist cue accordingly. Also fixed an undefined "Sample Price Calc -
  Special" codeunit referenced but never declared in the eval fixtures -
  added a real implementation of interface "Price Calculation" with stub
  methods.
- new-price-source-must-add-candidate-and-trigger-recalculation: the good
  fixture called UpdateUnitPriceByField directly, which is a silent no-op
  without a prior PlanPriceCalcByField call (FieldCausedPriceCalculation
  gating, verified against SalesLine.Table.al). Switched to the public
  UpdateUnitPrice wrapper, matching real BCApps usage in
  ItemReferenceManagement.Codeunit.al.
- report-barcodes-must-use-barcode-module-and-production-font-name: split
  the 1D (ValidateInput + EncodeFont) and 2D (EncodeFont only) Barcode Font
  Provider interfaces, which the article previously conflated. Reframed the
  Code 39 anti-pattern around demonstrable encoding/checksum mismatch
  (verified against IDA1DCode39Encoder.Codeunit.al's real '(value)' output)
  rather than rejecting all manual delimiter use, since '*' is a legitimate
  Code 39 start/stop character. Also fixed extend-find-entries-navigate-
  for-new-document-types' eval fixtures, which referenced an undefined
  "Sample Posted Document Header" table/page - declared both.

All claims re-verified against live microsoft/BCApps source. Validators:
frontmatter 0/0, review-fixtures 126/20 domains PASSED, knowledge-index
342/575 PASSED, skill-index 19 leaves PASSED.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Michael Dieringer 2026-09-24 21:10:19 +02:00
parent e22352b248
commit eb9cae0af4
12 changed files with 371 additions and 120 deletions

View file

@ -7,9 +7,68 @@ enumextension 50102 "Sample Price Calc Handler Ext" extends "Price Calculation H
}
}
// 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. It ships invisible until someone notices and
// configures a setup row for it by hand.
// 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

@ -7,6 +7,64 @@ enumextension 50102 "Sample Price Calc Handler Ext" extends "Price Calculation H
}
}
// 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)]
@ -19,6 +77,13 @@ codeunit 50104 "Sample Price Calc Setup Install"
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

@ -23,13 +23,21 @@ Implementation, Enabled, Default)` rows populated at startup by its own
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. A setup
row that exists but doesn't match is just as invisible: `FindSetup`
filters candidates with `SetRange(Default, true)` and `SetRange(Method,
DtldPriceCalcSetup.Method)` (a document's blank Method is normalized to
`"Lowest Price"` before that filter runs), so a row inserted without
`Default := true`, or with a `Method` that doesn't match, is never
selected either — same symptom, different cause.
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
@ -37,8 +45,13 @@ 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` — with `Default := true`, since `FindSetup` only considers
rows where `Default` is set when resolving a handler for a line.
`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).
@ -58,25 +71,28 @@ See sample: [`activate-new-price-calculation-handler-via-onfindsupportedsetup.ba
BCApps (`src/Layers/W1/BaseApp/Pricing/Calculation/`):
`PriceCalculationHandler.Enum.al` (`enum 7011 "Price Calculation Handler"
implements "Price Calculation"`); `PriceCalculationMgt.Codeunit.al`
(`local procedure OnFindSupportedSetup(var TempPriceCalculationSetup:
Record "Price Calculation Setup" temporary)`, called during setup
resolution, and `procedure FindSetup(...)`, which requires
`SetRange(Default, true)` and a matching `SetRange(Method,
DtldPriceCalcSetup.Method)` before a row can be selected);
`PriceCalculationSetup.Table.al` (`table 7006 "Price Calculation Setup"`:
`Code` (Code[100]), `Method` (Enum "Price Calculation Method"), `Type`
(Enum "Price Type"), `"Asset Type"` (Enum "Price Asset Type"),
`Implementation` (Enum "Price Calculation Handler"), `Enabled` (Boolean),
`Default` (Boolean)).
(`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).
BCApps (`src/Layers/W1/BaseApp/Pricing/PriceList/`): `PriceType.Enum.al`
(`enum 7009 "Price Type"`: `Any`(0)/`Sale`(1)/`Purchase`(2)).
Microsoft Learn, "Extending Price Calculations": "For the new codeunit,
you must extend the Price Calculation Handler enum that implements Price
Calculation interface... Afterwards you can insert a record in the Price
Calculation Setup table... Each codeunit that implements the Price
Calculation interface must subscribe to the OnFindSupportedSetup() event
of the Price Calculation Mgt codeunit to fill the price calculation setup
table with new options."
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

@ -1,3 +1,19 @@
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

View file

@ -1,3 +1,36 @@
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)]

View file

@ -5,8 +5,8 @@ tableextension 50105 "Sample Sales Line Ext" extends "Sales Line"
// 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
// calls UpdateUnitPriceByField, so the unit price silently keeps
// its old value.
// 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.';

View file

@ -13,7 +13,16 @@ tableextension 50105 "Sample Sales Line Ext" extends "Sales Line"
// the field on an existing line never re-runs price
// calculation, even though the source is already a known
// candidate via OnAfterAddSources below.
UpdateUnitPriceByField(FieldNo("Sample Loyalty Customer No."));
//
// 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;
}
}

View file

@ -21,11 +21,23 @@ being priced by it. `codeunit "Sales Line - Price"` publishes
`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: `Sales Line`'s own
`procedure UpdateUnitPriceByField(CalledByFieldNo: Integer)` must be
called from the source field's own trigger — the same way Microsoft's
own Location example is wired from a `Sales Line` validation event, not
from the price source registration itself.
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
@ -40,17 +52,22 @@ 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.UpdateUnitPriceByField(SalesLine.FieldNo(<TheField>))`.
`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 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.
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).
@ -62,7 +79,13 @@ 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
UpdateUnitPriceByField(CalledByFieldNo: Integer)`).
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

View file

@ -14,11 +14,23 @@ report 50110 "Sample Item Barcode Label"
trigger OnAfterGetRecord()
begin
// WRONG: hand-rolled "encoding" instead of the Barcode
// module's provider/encoder API. This produces a string
// that looks like a Code 39 barcode (asterisk delimiters)
// but carries none of the platform's actual character-set
// or checksum handling - wrong regardless of which font
// is applied to it in the layout.
// module's provider/encoder API. This is not wrong merely
// because the delimiter was added by hand - Code 39's own
// symbology does use "*" as its start/stop character
// (Microsoft Learn, "Barcode Fonts with Business Central
// Online"). It's wrong because it's demonstrably mismatched
// with what encoding "No." through the real API would
// produce:
// - it skips ValidateInput, so a "No." value outside Code
// 39's character set, or one that needs a checksum this
// code never applies, reaches the font unvalidated;
// - IDAutomation 1D Provider's own EncodeFont output for
// Code 39 wraps the value in "(" / ")", not literal "*"
// (BCApps' own encoder test: EncodeFont('1234', Code39)
// = '(1234)') - the paired font maps those parentheses to
// the real start/stop glyph, so a string built with
// literal asterisks is simply the wrong characters for
// that font, on top of carrying no real checksum.
BarcodeText := '*' + "No." + '*';
end;
}

View file

@ -9,32 +9,46 @@ report 50110 "Sample Item Barcode Label"
dataitem(Item; Item)
{
column(No_; "No.") { }
column(Barcode; BarcodeText) { }
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 Barcode column's text box must use the real, purchased font
// 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.
// environment means "the barcode won't render" at all. The
// Barcode2D column's font name is IDAutomation2D (IDAutomation2D
// MaxiCode for Maxicode specifically).
}

View file

@ -1,7 +1,7 @@
---
bc-version: [all]
domain: data-modeling
keywords: [barcode, qr-code, barcode-font-provider, report-layout, saas, idautomation]
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]
@ -12,85 +12,89 @@ application-area: [all]
## Description
Business Central's barcode support lives in the System Application's
`Barcode` module (`src/System Application/App/Barcode`), not in a
project's own code: `interface "Barcode Font Provider"` /
`"Barcode Font Provider 2D"`, `enum "Barcode Symbology"` /
`"Barcode Symbology 2D"` (Code39, Code128, EAN-13, QR-Code, Data Matrix,
and more), and built-in implementations
(`codeunit 9215 "IDAutomation 1D Provider"`,
`codeunit 9221 "IDAutomation 2D Provider"`). A report encodes a data
string into a barcode string via this API; the layout then displays that
string using a barcode *font*.
`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*.
On Business Central online, this is available with no setup at all:
"With Business Central online, the IDAutomation fonts are automatically
available as part of the service. So you can start adding barcodes to
reports right away." (Microsoft Learn, "Adding Barcodes to Reports") —
unlike on-premises, where the fonts must be purchased and installed on
the server.
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.
That ease hides a SaaS-specific trap in the one manual step the API
doesn't cover: naming the actual font in the report layout. IDAutomation
ships both a purchased font and a same-looking evaluation font per
version (Code 39: `IDAutomationHC39M` purchased vs.
`IDAutomationSHC39M Demo` evaluation). Per Microsoft Learn ("Barcode
Fonts with Business Central Online"): "When you're applying barcode font
in the report layout for a Business Central online production
environment, be sure to use the purchased font name; not the evaluation
font name. If you use the evaluation font name, the barcode won't
render." Getting the font name wrong doesn't distort the barcode — it
produces nothing, in a step that lives in the layout file, not in AL, so
no compiler or reviewer catches it by reading the report object. Nothing
in the cited documentation says what an evaluation font name does outside
a production environment — the claim here is scoped exactly as
Microsoft states it: wrong in production, full stop.
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 — declare the provider via its interface and
enum, then call `ValidateInput`/`EncodeFont` — and treat naming the
production font in the layout as an equally required part of the same
task, not an afterthought left to whoever happens to touch the `.docx`/
`.rdl` file. For a two-dimensional symbology other than Maxicode, the
font name to specify is literally `IDAutomation2D` (Maxicode itself uses
`IDAutomation2D MaxiCode`); for a one-dimensional symbology, use the
purchased version name for that specific font (e.g. `IDAutomationHC39M`
for Code 39), never a name containing `Demo`.
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 — string concatenation, manual
delimiters — instead of going through the Barcode module's provider
interface. It can look right (asterisks around a value, resembling
Code 39) while carrying none of the platform's actual character-set
handling or checksum logic, so it's wrong regardless of which font is
later applied to it.
Constructing a barcode string by hand instead of using the module's
provider/encoder API — not because a manual delimiter is inherently
wrong (Code 39's own symbology does use `*` as start/stop; Microsoft
Learn's font table says so), but because hand-rolled construction is
demonstrably mismatched with what the real encoder produces: it skips
`ValidateInput` (so a value outside the character set, or needing a
checksum/extended-charset setting never applied, reaches the font
unvalidated), and IDAutomation 1D Provider's own Code 39 output is
wrapped in `(`/`)`, not literal `*` (BCApps test:
`EncodeFont('1234', Code39) = '(1234)'`) — the paired font maps those
parentheses to the real start/stop glyph, so `'*' + value + '*'` is
simply the wrong characters, plus no checksum.
A second version of the same underlying mistake: encoding correctly
through the real API, but naming the evaluation font instead of the
purchased one in the layout. Both produce a report that looks complete
in review and testing and fails silently — the first because the encoded
data was never a real barcode, the second because Business Central
online refuses to render it at all.
Flag demonstrably invalid or mismatched hand construction, not manual
delimiter use as a category — a custom provider paired with a font that
genuinely expects literal `*` delimiters is a different, legitimate case.
A second version of the same mistake: encoding correctly, but naming the
evaluation font instead of the purchased one. Both look complete in
review and fail silently — the first because the data was never a real
barcode, the second because BC online refuses to render it.
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 System Application (`src/System Application/App/Barcode/src/`):
`Barcode Provider/Font/BarcodeFontProvider.Interface.al`
(`ValidateInput(InputText: Text; BarcodeSymbology: Enum "Barcode Symbology")`,
`EncodeFont(InputText: Text; BarcodeSymbology: Enum "Barcode Symbology"): Text`),
`Barcode Provider/Font/BarcodeFontProvider.Enum.al`
(`value(0; IDAutomation1D)`), `Barcode Provider/BarcodeSymbology.Enum.al`
(`value(100; Code39)`). Real BaseApp usage:
`src/Layers/W1/BaseApp/Inventory/Item/ItemGTINLabel.Report.al`
(`report 6625 "Item GTIN Label"`, lines 44-64).
BCApps (`src/System Application/App/Barcode/src/`):
`Barcode Provider/Font/BarcodeFontProvider.Interface.al` (1D:
`ValidateInput` + `EncodeFont`); `Barcode Provider 2D/Font/
BarcodeFontProvider2D.Interface.al` (2D: only `EncodeFont`); both read
fresh from source. `IDAutomation 1D Provider/Encoders/
IDA1DCode39Encoder.Codeunit.al` (`codeunit 9204`, regex accepts literal
`*` as plain input; `EncodeFont` → `DotNet FontEncoder.Code39`). Split
and delimiter mismatch both confirmed live: `.../Inventory/Item/
ItemGTINLabel.Report.al` (`report 6625`, validates+encodes 1D, only
encodes 2D, same value) and `.../Test/Barcode/.../IDA1DCode39Test.
Codeunit.al` (`codeunit 135044`): `EncodeFontSuccessTest('1234', Code39,
'(1234)')` — wrapped in `(`/`)`, never literal `*`.
Microsoft Learn: "Adding Barcodes to Reports"
(https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/devenv-report-add-barcodes)
and "Barcode Fonts with Business Central Online"
(https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/devenv-report-barcode-fonts)
— both quoted verbatim above.
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").