The last mile: permission sets so people can use the app, install and upgrade codeunits so data is right on day one and every version after, automated tests, and how to publish a per-tenant extension to production safely.
This is the final part of AL development from scratch. Pallet Manager works: it has its table, pages, Item fields, logic, a report, a query, an XMLport and an API. On your sandbox, signed in as yourself with SUPER, everything is fine.
That is the trap. The moment a normal user opens the Pallet Types list in production, they get a permissions error. The first time you ship version 1.1 with a new field, the existing records have nothing in it. And the first time someone "just tweaks" the pallet calculation, nobody notices that it now rounds the wrong way.
This part covers the four things that turn working code into an app you can put in front of a business:
Business Central's security model is simple at its core: a user can do nothing unless a permission set they hold allows it. Microsoft's own permission sets (D365 BASIC, D365 BUS FULL ACCESS and so on) know nothing about your objects. Unless you ship permission sets of your own, nobody except a SUPER user can use your app. The full security model is in permissions in Business Central; here is what you need to build one.
In AL, a permission set is an object, written and versioned with the rest of the code. As throughout the series, complete listings start with the optional namespace line (see part 1); our own objects share the namespace, so they need no using to see each other.
namespace BusinessBoxed.PalletManager;
permissionset 50100 "BBX Pallet View"
{
Assignable = true;
Caption = 'Pallet Manager - View';
Permissions =
tabledata "BBX Pallet Type" = R,
table "BBX Pallet Type" = X,
page "BBX Pallet Type List" = X,
page "BBX Pallet Type Card" = X,
page "BBX Pallet Type FactBox" = X,
report "BBX Pallet Requirements" = X,
query "BBX Items by Pallet Type" = X,
codeunit "BBX Pallet Mgt." = X;
}
And the set for people who maintain pallet types, building on the first:
namespace BusinessBoxed.PalletManager;
permissionset 50101 "BBX Pallet Edit"
{
Assignable = true;
Caption = 'Pallet Manager - Edit';
IncludedPermissionSets = "BBX Pallet View";
Permissions =
tabledata "BBX Pallet Type" = RIMD,
page "BBX Pallet Type API" = X,
query "BBX Item Pallets API" = X,
xmlport "BBX Pallet Type Import" = X;
}
| Property | What it does | Example / default |
|---|---|---|
| Assignable | Whether an administrator can assign the set to a user or security group. Set it to false for "building block" sets that are only meant to be included in other sets. | Assignable = true; |
| Caption | The readable name administrators see. Translated like any other caption. | Caption = 'Pallet Manager - View'; |
| IncludedPermissionSets | Other permission sets this one contains. Lets you build Edit on top of View, so a change to View flows into Edit automatically. | IncludedPermissionSets = "BBX Pallet View"; |
| ExcludedPermissionSets | Permission sets whose permissions are taken away from this one. | Rarely needed in a small app |
| Permissions | The list of object permissions, separated by commas. | See below |
Keep permission set names short. The name becomes the permission set's ID in the client (the "Role ID"), which is limited to 20 characters.
For tabledata (the data in a table), there are four letters: Read, Insert, Modify and Delete. For every other object type (table, page, report, codeunit, query, xmlport), there is one letter: X for execute, meaning the user can run or open it.
Note the difference between table "BBX Pallet Type" = X and tabledata "BBX Pallet Type" = R. The first lets the user use the table object; the second lets them read the records in it. You almost always need both.
Lowercase letters mean indirect permission: tabledata "Item Ledger Entry" = r means the user can read item ledger entries, but only through code that is itself allowed to give that access, never by opening the table directly. This is how Business Central lets an order clerk post an order (which writes ledger entries) without letting them edit ledger entries by hand.
Our two fields on Item, "BBX Default Pallet Type" and "BBX Units per Pallet", don't need their own permissions. Fields added by a table extension are covered by the base table's permissions, so anyone who can modify items can fill them in. They do, however, need to read BBX Pallet Type to look up a pallet type, which is one reason "BBX Pallet View" is worth assigning broadly.
You don't have to type every object. In VS Code, open the command palette (Ctrl+Shift+P), type "permission set" and run AL: Generate permission set containing current extension objects. It produces a permission set with every object in your app, which you then split into sensible sets: View for most people, Edit for the few who maintain the data. Re-run it whenever you add objects, or the new objects will be missing from your sets and the first user to hit one will tell you.
If every user who has D365 BASIC should also be able to view pallet types, you can extend Microsoft's set instead of asking administrators to assign yours:
namespace BusinessBoxed.PalletManager;
// Add a using for the namespace of the D365 BASIC permission set:
// press F12 (Go to Definition) on it to see which namespace it is in.
permissionsetextension 50100 "BBX D365 Basic" extends "D365 BASIC"
{
IncludedPermissionSets = "BBX Pallet View";
}
If a permission set refers to Microsoft objects, for example tabledata Item, it needs the matching using line too (using Microsoft.Inventory.Item;), exactly like a codeunit would. Use permission set extensions sparingly. They are convenient, but they change what a standard permission set means in that tenant, which can surprise an auditor reviewing who can see what.
Sometimes your code needs to touch data the user isn't allowed to touch directly. A codeunit can declare that with its own Permissions property:
namespace BusinessBoxed.PalletManager;
using Microsoft.Inventory.Ledger;
codeunit 50100 "BBX Pallet Mgt."
{
Permissions = tabledata "Item Ledger Entry" = r;
...
}
Combined with an indirect permission in the user's set, this lets the code read item ledger entries on the user's behalf, for example to work out how many pallets were shipped last month, without giving the user free access to the table.
You will see entitlement objects in Microsoft's code. Entitlements link permissions to licences and are only for apps published on AppSource, so a per-tenant extension like Pallet Manager doesn't use them.
When Pallet Manager is installed in a company for the first time, the Pallet Type table is empty. The user opens the list and has to invent pallet types before anything works. Better to arrive with the common ones already set up. That is what an install codeunit is for: code that runs when the app is installed.
namespace BusinessBoxed.PalletManager;
using System.Upgrade;
codeunit 50102 "BBX Pallet Install"
{
Subtype = Install;
trigger OnInstallAppPerCompany()
var
AppInfo: ModuleInfo;
UpgradeTag: Codeunit "Upgrade Tag";
begin
NavApp.GetCurrentModuleInfo(AppInfo);
if AppInfo.DataVersion() = Version.Create(0, 0, 0, 0) then
InsertDefaultPalletTypes();
UpgradeTag.SetAllUpgradeTags();
end;
local procedure InsertDefaultPalletTypes()
var
UKStandardTxt: Label 'UK standard pallet';
EuroTxt: Label 'Euro pallet';
HalfTxt: Label 'Half pallet';
begin
InsertPalletType('UK-STD', UKStandardTxt, 1200, 1000, 1000);
InsertPalletType('EURO', EuroTxt, 1200, 800, 1000);
InsertPalletType('HALF', HalfTxt, 800, 600, 500);
end;
local procedure InsertPalletType(PalletCode: Code[20]; PalletDescription: Text[100]; LengthMm: Integer; WidthMm: Integer; MaxWeightKg: Decimal)
var
PalletType: Record "BBX Pallet Type";
begin
if PalletType.Get(PalletCode) then
exit;
PalletType.Init();
PalletType.Code := PalletCode;
PalletType.Description := PalletDescription;
PalletType.Material := PalletType.Material::Wood;
PalletType."Length (mm)" := LengthMm;
PalletType."Width (mm)" := WidthMm;
PalletType."Max. Weight (kg)" := MaxWeightKg;
PalletType.Insert(true);
end;
}
Subtype = Install tells the platform to run this codeunit's install triggers when the app is installed.OnInstallAppPerCompany runs once for each company in the environment. Pallet types are per-company data, so this is the right trigger.OnInstallAppPerDatabase, the other install trigger, runs once for the whole environment. Use it for data that isn't per company, such as a table with DataPerCompany = false.NavApp.GetCurrentModuleInfo gives you information about your own app. Its DataVersion() is the version of the app whose data is in the database: 0.0.0.0 on a genuinely fresh install, or the old version number if the app was uninstalled and is now being reinstalled with its data still there. Checking it stops you inserting defaults on top of a customer's real data after a reinstall.if PalletType.Get(PalletCode) then exit; makes the code safe to run twice. Install and upgrade code should always be safe to run twice.using System.Upgrade; under the namespace is there because the "Upgrade Tag" codeunit lives in Microsoft's System.Upgrade namespace. Without namespaces in your own file you can leave it out.UpgradeTag.SetAllUpgradeTags() marks every upgrade step as already done on a fresh install, because a fresh install has no old data to fix. More on that in the next section.Note that the descriptions are Labels, so they are translated if the app ships in other languages, while the codes (UK-STD, EURO) are data identifiers and stay as they are.
Install code runs when an app is installed, not when a new company is created later. If new companies should also get default pallet types, subscribe to the company initialisation event as well, or give users an action that creates the defaults.
Install code handles day one. Upgrade code handles every version after. Whenever a new version of your app changes what the data should look like, an upgrade codeunit fixes up the existing data when that version is installed over the old one.
A realistic example: version 1.0.0.0 of Pallet Manager shipped without the Material field. Version 1.1.0.0 adds it. Every pallet type created under 1.0 now has a blank material, and the report from part 8 groups by material, so all the old pallet types land in a blank group. The upgrade should set the existing records to Wood, which is what all of them were anyway.
namespace BusinessBoxed.PalletManager;
using System.Upgrade;
codeunit 50103 "BBX Pallet Upgrade"
{
Subtype = Upgrade;
trigger OnUpgradePerCompany()
begin
SetDefaultMaterial();
end;
local procedure SetDefaultMaterial()
var
PalletType: Record "BBX Pallet Type";
UpgradeTag: Codeunit "Upgrade Tag";
begin
if UpgradeTag.HasUpgradeTag(DefaultMaterialTag()) then
exit;
PalletType.SetRange(Material, PalletType.Material::" ");
PalletType.ModifyAll(Material, PalletType.Material::Wood);
UpgradeTag.SetUpgradeTag(DefaultMaterialTag());
end;
local procedure DefaultMaterialTag(): Code[250]
begin
exit('BBX-PalletDefaultMaterial-20261106');
end;
[EventSubscriber(ObjectType::Codeunit, Codeunit::"Upgrade Tag", 'OnGetPerCompanyUpgradeTags', '', false, false)]
local procedure RegisterPerCompanyTags(var PerCompanyUpgradeTags: List of [Code[250]])
begin
PerCompanyUpgradeTags.Add(DefaultMaterialTag());
end;
}
Upgrade code runs every time a new version is installed: 1.1, then 1.2, then 1.3. Without protection, the 1.1 data fix runs again on every one of them, and on the day it no longer makes sense, it quietly damages data. An upgrade tag is a record that says "this step has been done in this company". The pattern is always:
Registering the tag through OnGetPerCompanyUpgradeTags is what lets SetAllUpgradeTags, in our install codeunit, mark it as done on fresh installs, where there is nothing to fix. Give each tag a unique name that includes your prefix, what the step does and a date; it is stored in the database for ever.
| Trigger | When it runs | Use it for |
|---|---|---|
| OnCheckPreconditionsPerDatabase / PerCompany | First, before anything changes. | Checking it is safe to upgrade. Calling Error here stops the upgrade, and the old version stays installed. |
| OnUpgradePerDatabase | Once for the environment. | Data that isn't per company. |
| OnUpgradePerCompany | Once for each company. | Most data fixes, like ours. |
| OnValidateUpgradePerDatabase / PerCompany | Last, after the upgrade code. | Checking the result. An error here rolls the whole upgrade back. |
The upgrade runs in one transaction: if any trigger errors, nothing is changed and the old version stays. That is reassuring, but it also means a slow upgrade over a big table holds everything up, so keep upgrade code efficient. ModifyAll, as above, is far quicker than looping through records one at a time when you don't need the table's triggers to run.
Check the version before the upgrade with NavApp.GetCurrentModuleInfo and DataVersion(), as in the install codeunit, but use upgrade tags to decide whether a step runs. Tags are more reliable than version comparisons, especially when versions are skipped.
Sooner or later you will want to remove something. You can't simply delete a field from a table in a published app: in production, Business Central won't accept a new version that drops a field, because that would destroy data. You retire it in stages, with the obsolete properties:
field(9; "Pallet Height"; Integer)
{
Caption = 'Pallet Height';
DataClassification = CustomerContent;
ObsoleteState = Pending;
ObsoleteReason = 'Replaced by the Height (mm) field.';
ObsoleteTag = '1.2';
}
The full process, including tables, enums and procedures, is in changing a live extension without losing data.
Every change you make to Pallet Manager from now on risks breaking something that worked. Automated tests are how you find out in seconds, not from a warehouse manager three weeks later.
Tests live in their own app, "Pallet Manager Tests", with its own ID range (50150 to 50199), never in the app you ship. The test app depends on Pallet Manager and on Microsoft's test libraries. Its app.json dependencies look like this:
"dependencies": [
{
"id": "{palletManagerAppId}",
"name": "Pallet Manager",
"publisher": "Business Boxed Limited",
"version": "1.0.0.0"
},
{
"id": "{libraryAssertAppId}",
"name": "Library Assert",
"publisher": "Microsoft",
"version": "{bcVersion}"
},
{
"id": "{testsTestLibrariesAppId}",
"name": "Tests-TestLibraries",
"publisher": "Microsoft",
"version": "{bcVersion}"
}
]
Take the app IDs and versions from the symbols in your .alpackages folder, or let VS Code's code completion fill them in. Tests run in an environment with Microsoft's test toolkit installed: usually a Docker container created with BcContainerHelper, or the pipeline described below. Never install test apps in production.
If you use namespaces, give the test app its own, BusinessBoxed.PalletManager.Test, and add using BusinessBoxed.PalletManager; to each test codeunit so it can see the app's objects, as in the listing below. If you don't use namespaces in the app, leave them out of the tests as well.
In part 7, "BBX Pallet Mgt." got a CalcPalletsNeeded procedure: given an item and a quantity, it returns how many whole pallets are needed, using the item's "BBX Units per Pallet" and rounding up, and it raises an error if the item has no units per pallet set. That is exactly the kind of rule worth pinning down with tests, because "round up" quietly turning into "round to nearest" would under-order pallets for every odd quantity.
namespace BusinessBoxed.PalletManager.Test;
using BusinessBoxed.PalletManager;
using Microsoft.Inventory.Item;
using System.TestLibraries.Utilities;
// "Library - Inventory": add a using for its namespace;
// press F12 (Go to Definition) on it to see which namespace it is in.
codeunit 50150 "BBX Pallet Mgt. Tests"
{
Subtype = Test;
TestPermissions = Disabled;
var
Assert: Codeunit Assert;
LibraryInventory: Codeunit "Library - Inventory";
PalletMgt: Codeunit "BBX Pallet Mgt.";
RoundsUpMsg: Label 'Pallets needed should round up to whole pallets.', Locked = true;
ExactMsg: Label 'An exact multiple should need no extra pallet.', Locked = true;
[Test]
procedure PalletsNeededRoundsUp()
var
Item: Record Item;
PalletsNeeded: Integer;
begin
// [GIVEN] An item that fits 40 units on a pallet
CreateItemWithUnitsPerPallet(Item, 40);
// [WHEN] We work out the pallets needed for 81 units
PalletsNeeded := PalletMgt.CalcPalletsNeeded(Item, 81);
// [THEN] 3 pallets are needed, not 2
Assert.AreEqual(3, PalletsNeeded, RoundsUpMsg);
end;
[Test]
procedure PalletsNeededForExactMultiple()
var
Item: Record Item;
begin
// [GIVEN] An item that fits 40 units on a pallet
CreateItemWithUnitsPerPallet(Item, 40);
// [WHEN] We work out the pallets needed for 80 units
// [THEN] Exactly 2 pallets are needed
Assert.AreEqual(2, PalletMgt.CalcPalletsNeeded(Item, 80), ExactMsg);
end;
[Test]
procedure PalletsNeededFailsWithoutUnitsPerPallet()
var
Item: Record Item;
begin
// [GIVEN] An item with no units per pallet set
CreateItemWithUnitsPerPallet(Item, 0);
// [WHEN] We work out the pallets needed
asserterror PalletMgt.CalcPalletsNeeded(Item, 10);
// [THEN] An error is raised
Assert.ExpectedErrorCode('Dialog');
end;
local procedure CreateItemWithUnitsPerPallet(var Item: Record Item; UnitsPerPallet: Integer)
begin
LibraryInventory.CreateItem(Item);
Item."BBX Units per Pallet" := UnitsPerPallet;
Item.Modify(true);
end;
}
Subtype = Test makes this a test codeunit. Only test codeunits can contain [Test] procedures.[Test] marks a procedure as a test. Each one runs separately, and it passes unless something errors.TestPermissions = Disabled runs the tests without permission checks. To prove your permission sets are complete, write separate tests that run with restricted permissions.Assert codeunit, from Microsoft's Library Assert app, does the checking: AreEqual, AreNotEqual, IsTrue, IsFalse, RecordIsEmpty, ExpectedError and more. When a check fails, the test fails with your message.asserterror runs a statement that is expected to fail. If it doesn't fail, the test fails. Assert.ExpectedErrorCode('Dialog') then checks it was an ordinary Error call; Assert.ExpectedError can check the message text instead.LibraryInventory.CreateItem gives you a valid item with a unit of measure and posting groups, which would take twenty lines to build yourself.If the code under test shows a Message or asks a Confirm question, the test can't click a button. It fails, unless you provide a handler that answers for it. The ConfirmAndBlockPalletType procedure from part 7 asks "Block pallet type ...?" and then confirms with a message. The test below goes in the same codeunit, with a BlockedMsg label and a small CreatePalletType helper added alongside the others:
[Test]
[HandlerFunctions('ConfirmYesHandler,MessageHandler')]
procedure BlockingPalletTypeAsksFirst()
var
PalletType: Record "BBX Pallet Type";
begin
// [GIVEN] A pallet type that is not blocked
CreatePalletType(PalletType);
// [WHEN] The user blocks it and answers Yes
PalletMgt.ConfirmAndBlockPalletType(PalletType);
// [THEN] It is blocked
PalletType.Get(PalletType.Code);
Assert.IsTrue(PalletType.Blocked, BlockedMsg);
end;
[ConfirmHandler]
procedure ConfirmYesHandler(Question: Text[1024]; var Reply: Boolean)
begin
Reply := true;
end;
[MessageHandler]
procedure MessageHandler(Message: Text[1024])
begin
end;
The [HandlerFunctions] attribute lists the handlers this test expects to be used. A handler that is listed but never called also fails the test, which is useful: it proves the confirmation was actually asked.
For more patterns (library codeunits of your own, test isolation, testing pages with TestPage variables), see AL test patterns.
Every build you ship needs a higher version in app.json than the one installed. The format is major.minor.build.revision:
"version": "1.1.0.0"
Business Central won't install a version over the same or a higher one, so a forgotten version bump is the most common reason an upload "doesn't work".
Pallet Manager is a per-tenant extension (PTE): built for one customer and uploaded to their tenant, not sold on AppSource. The difference matters for how it is managed, as explained in Global, PTE or Dev. In particular, never publish from VS Code to production. VS Code publishes to sandboxes as a development extension; production gets the built .app file, uploaded deliberately.
To upload it:
Ctrl+Shift+B in VS Code, or let the pipeline build it) to get the .app file.Every new version follows the same route. The install or upgrade codeunits run as part of the deployment.
In your sandbox, launch.json lets you set schemaUpdateMode to Synchronize, Recreate or ForceSync. Recreate throws away the data in your tables when their structure changes, which is fine for a sandbox full of test data.
Production has no such options. A new version may only make non-destructive changes to the database: add tables, add fields, make text fields longer. If the new version would delete a field, change a field's type, shorten a field, or change a primary key, the deployment fails and the old version stays. That is the platform protecting your customer's data, and it is why the obsolete process above exists. Plan table design carefully in version 1.0; it is far harder to change once real data is in it.
Building the app, running the tests and producing the .app file by hand is fine for your first extension and a liability by your fifth. AL-Go for GitHub (or a similar pipeline in Azure DevOps) builds every change, runs the test app, checks the code with the analysers, numbers the version and gives you a ready-to-upload file. The tests from this part are what make that pipeline worth having. See AL-Go, test codeunits and a pipeline you can trust.
app.json is higher than anything installed.ModifyAll would do. On a big table the deployment slows to a crawl..app file instead.Over ten parts, you have built a complete, small but real Business Central app: a project set up properly, a table with the right data types, keys, FlowFields and triggers, fields on a standard table, an enum others can extend, list, card and FactBox pages with actions, page extensions on standard pages, codeunits that work with records and events, a report, a query and an XMLport, a versioned API, permission sets, install and upgrade code, tests and a safe route to production.
That covers the basics every Business Central developer uses every day. Everything more advanced (posting routines, document patterns, interfaces, telemetry, performance tuning) builds on exactly these pieces. The best next step is to build something small that your own business actually needs, using this series as the checklist.
Previous: part 9, API pages and API queries. Back to the series contents, where every part is listed.
Want help with your first extension? Book a 30-minute call.