Thursday, January 15, 2026

Business Central SaaS Extension Design: Implementing Plant Tracking with AL Event Subscribers

 

Extending Business Central SaaS: Plant Tracking Using AL Extensions 

1️⃣ Problem Statement

In many manufacturing and service-oriented organizations, sales transactions need to be associated with a Plant (or operational unit).

Out of the box, Microsoft Dynamics 365 Business Central SaaS does not provide a standard Plant dimension on Sales documents that automatically flows through:

  • Sales Orders

  • Posted Sales Shipments

  • Posted Sales Invoices

  • Customer Ledger Entries

Challenges

  • No standard Plant master table

  • No Plant selection on Sales Orders

  • No automatic propagation during posting

  • SaaS environment prohibits base object modification

MicroCloud360 needed a clean, upgrade-safe, SaaS-compliant extension to address this requirement.


2️⃣ Functional Requirements

FR-01: Plant Master

  • Maintain a list of Plants

  • Each Plant contains:

    • Plant Id (uppercase, unique)

    • Plant Name

FR-02: Sales Order Integration

  • Add Plant Id field to Sales Order header

  • Field must be optional

  • Users can select only valid Plant Ids (dropdown lookup)

FR-03: Posting Behaviour

When a Sales Order is posted:

  • If Plant Id is blank → do nothing

  • If Plant Id is populated → copy it to:

    • Sales Shipment Header

    • Sales Invoice Header

    • Customer Ledger Entry

FR-04: Visibility

Plant Id must be visible on:

  • Posted Sales Shipment

  • Posted Sales Invoice

  • Customer Ledger Entries


3️⃣ High-Level Architecture Diagram


Explanation

  • Implemented as a pure AL extension

  • No modification of base application objects

  • Uses:

    • Custom tables

    • Table extensions

    • Page extensions

    • Event subscribers

  • Fully SaaS and upgrade safe


4️⃣ Data Model Diagram



Tables Involved

TablePurpose
Plant (Custom)Master data
Sales Header (36)Capture Plant Id
Sales Shipment Header (110)Persist Plant Id
Sales Invoice Header (112)Persist Plant Id
Customer Ledger Entry (21)Financial traceability

Relationship Concept

Plant ↑ Sales Header ↓ Posted Sales Docs ↓ Customer Ledger Entry

5️⃣ Posting Flow Diagram



Flow Description

Sales Order (Plant selected) ↓ Sales-Post Codeunit ↓ Sales Shipment Header ↓ Sales Invoice Header ↓ Customer Ledger Entry

Plant Id flows only if populated.


6️⃣ Event-Driven Design (Why This Matters)

Business Central SaaS does not allow direct modification of standard posting codeunits.

MicroCloud360 Approach

  • Subscribe to standard posting events

  • Inject logic after standard processing

  • Avoid breaking changes during upgrades

Events Used

EventPurpose
OnAfterInsertShipmentHeaderCopy Plant Id
OnAfterInsertInvoiceHeaderCopy Plant Id
Cust. Ledger Entry OnAfterInsertPopulate ledger

This follows Microsoft’s recommended SaaS extension pattern.


7️⃣ Key Code Snippets

Plant Id on Sales Header

field(50500; "Plant Id"; Code[10]) { TableRelation = "Plant"."Plant Id"; trigger OnValidate() begin "Plant Id" := UpperCase("Plant Id"); end; }

Posting Event Subscriber (Example)

[EventSubscriber(ObjectType::Codeunit, Codeunit::"Sales-Post", 'OnAfterInsertInvoiceHeader', '', false, false)] local procedure OnAfterInsertInvoiceHeader( var SalesHeader: Record "Sales Header"; var SalesInvHeader: Record "Sales Invoice Header") begin if SalesHeader."Plant Id" <> '' then SalesInvHeader."Plant Id" := SalesHeader."Plant Id"; end;

8️⃣ Testing & Results

Test Scenarios

ScenarioResult
Plant creationSaved in uppercase
Sales Order without PlantPosts successfully
Sales Order with PlantPlant copied to all posted records
Ledger reviewPlant Id visible

Outcome

  • No posting errors

  • No performance impact

  • No base code changes

  • Fully SaaS compliant


9️⃣ Best-Practice Notes

AL & SaaS Best Practices Used

  • One object per file

  • Unique object IDs

  • Shared field ID across tables

  • Event subscribers instead of code modification

  • No COMMIT statements

  • Optional business logic (non-blocking)

Naming Conventions

ItemConvention
PrefixCompany prefix (MicroCloud360)
Object namesMeaningful & < 30 chars
Field IDsConsistent across tables
Extension nameBusiness-driven

Upgrade Safety

  • Uses published events only

  • No breaking dependencies

  • Ready for future BC releases


🔚 Technical Implementation 

Recommended .al objects

Here is a clean, safe ID plan inside 50500–50599:

Tables

  • 50500 = table "MC360 Plant"

TableExtensions

  • 50501 = Sales Header Ext

  • 50502 = Sales Shipment Header Ext

  • 50503 = Sales Invoice Header Ext

  • 50504 = Cust. Ledger Entry Ext

PageExtensions

  • 50520 = Sales Order Ext

  • 50521 = Customer Ledger Entries Ext

  • 50522 = Posted Sales Shipment Ext

  • 50523 = Posted Sales Invoice Ext

Pages

  • 50510 = Plant List

  • 50511 = Plant Card

Codeunit

  • 50540 = Plant Posting Subs

PermissionSet

  • 50550 = MC360 PLANT


Files to be created under src folder

├── Tables/MC360Plant.Table.al

├── TableExtensions/MC360SalesHeaderExt.TableExt.al

├── TableExtensions/MC360SalesShipmentHeaderExt.TableExt.al

├── TableExtensions/MC360SalesInvoiceHeaderExt.TableExt.al

├── TableExtensions/MC360CustLedgerEntryExt.TableExt.al

├── PageExtensions/MC360SalesOrderExt.PageExt.al

├── PageExtensions/MC360CustLedgerEntriesExt.PageExt.al

├── PageExtensions/MC360PostedSalesShipmentExt.PageExt.al

├── PageExtensions/MC360PostedSalesInvoiceExt.PageExt.al

├── Pages/MC360PlantList.Page.al

├── Pages/MC360PlantCard.Page.al

└── PermissionSets/MC360Plant.PermissionSet.al


src/Tables/MC360Plant.Table.al

table 50500 "MC360 Plant" { Caption = 'Plant'; DataClassification = CustomerContent; fields { field(1; "Plant Id"; Code[10]) { Caption = 'Plant Id'; DataClassification = CustomerContent; NotBlank = true; trigger OnValidate() begin "Plant Id" := UpperCase("Plant Id"); end; } field(2; "Plant Name"; Text[30]) { Caption = 'Plant Name'; DataClassification = CustomerContent; } } keys { key(PK; "Plant Id") { Clustered = true; } } }

src/TableExtensions/MC360SalesHeaderExt.TableExt.al

tableextension 50500 "MC360 Sales Header Ext" extends "Sales Header" { fields { field(50500; "Plant Id"; Code[10]) { Caption = 'Plant Id'; DataClassification = CustomerContent; TableRelation = "MC360 Plant"."Plant Id"; trigger OnValidate() begin "Plant Id" := UpperCase("Plant Id"); end; } } }

src/TableExtensions/MC360SalesShipmentHeaderExt.TableExt.al

tableextension 50501 "MC360 Sales Shpt. Header Ext" extends "Sales Shipment Header" { fields { field(50500; "Plant Id"; Code[10]) { Caption = 'Plant Id'; DataClassification = CustomerContent; } } }

src/TableExtensions/MC360SalesInvoiceHeaderExt.TableExt.al

tableextension 50502 "MC360 Sales Inv. Header Ext" extends "Sales Invoice Header" { fields { field(50500; "Plant Id"; Code[10]) { Caption = 'Plant Id'; DataClassification = CustomerContent; } } }

src/TableExtensions/MC360CustLedgerEntryExt.TableExt.al

tableextension 50503 "MC360 Cust. Ledg. Entry Ext" extends "Cust. Ledger Entry" { fields { field(50500; "Plant Id"; Code[10]) { Caption = 'Plant Id'; DataClassification = CustomerContent; } } }

src/PageExtensions/MC360SalesOrderExt.PageExt.al

pageextension 50500 "MC360 Sales Order Ext" extends "Sales Order" { layout { addlast(General) { field("Plant Id"; Rec."Plant Id") { ApplicationArea = All; ToolTip = 'Select the Plant Id for this sales order (optional).'; } } } }

src/PageExtensions/MC360CustLedgerEntriesExt.PageExt.al

pageextension 50501 "MC360 Cust. Ledger Entries Ext" extends "Customer Ledger Entries" { layout { // In standard objects this is usually the repeater control name. // If your symbols show a different repeater/container name, replace Control1 accordingly. addlast(Control1) { field("Plant Id"; Rec."Plant Id") { ApplicationArea = All; ToolTip = 'Shows the Plant Id carried from the originating sales document.'; } } } }

src/PageExtensions/MC360PostedSalesShipmentExt.PageExt.al

pageextension 50502 "MC360 Posted Sales Shipment Ext" extends "Posted Sales Shipment" { layout { addlast(General) { field("Plant Id"; Rec."Plant Id") { ApplicationArea = All; ToolTip = 'Shows the Plant Id copied from the Sales Order during posting.'; } } } }

src/PageExtensions/MC360PostedSalesInvoiceExt.PageExt.al

pageextension 50503 "MC360 Posted Sales Invoice Ext" extends "Posted Sales Invoice" { layout { addlast(General) { field("Plant Id"; Rec."Plant Id") { ApplicationArea = All; ToolTip = 'Shows the Plant Id copied from the Sales Order during posting.'; } } } }

src/Pages/MC360PlantList.Page.al

page 50510 "MC360 Plant List" { PageType = List; SourceTable = "MC360 Plant"; Caption = 'Plants'; ApplicationArea = All; UsageCategory = Lists; layout { area(content) { repeater(General) { field("Plant Id"; Rec."Plant Id") { ApplicationArea = All; ToolTip = 'Specifies the Plant Id.'; } field("Plant Name"; Rec."Plant Name") { ApplicationArea = All; ToolTip = 'Specifies the Plant Name.'; } } } } actions { area(Processing) { action(Card) { Caption = 'Card'; ApplicationArea = All; Image = EditLines; RunObject = page "MC360 Plant Card"; RunPageLink = "Plant Id" = field("Plant Id"); } } } }

src/Pages/MC360PlantCard.Page.al

page 50511 "MC360 Plant Card" { PageType = Card; SourceTable = "MC360 Plant"; Caption = 'Plant'; ApplicationArea = All; UsageCategory = Administration; layout { area(content) { group(General) { field("Plant Id"; Rec."Plant Id") { ApplicationArea = All; ToolTip = 'Specifies the Plant Id.'; } field("Plant Name"; Rec."Plant Name") { ApplicationArea = All; ToolTip = 'Specifies the Plant Name.'; } } } } }

src/PermissionSets/MC360Plant.PermissionSet.al

permissionset 50500 "MC360 PLANT" { Caption = 'MC360 Plant Tracking'; Assignable = true; Permissions = tabledata "MC360 Plant" = RIMD, table "MC360 Plant" = X; }

src/Codeunits/MC360PlantPostingSubs.Codeunit.al

codeunit 50504 "MC360 Plant Posting Subs" { Subtype = Normal; // Copy Sales Header Plant Id -> Sales Shipment Header [EventSubscriber(ObjectType::Codeunit, Codeunit::"Sales-Post", 'OnAfterInsertShipmentHeader', '', false, false)] local procedure OnAfterInsertShipmentHeader(var SalesHeader: Record "Sales Header"; var SalesShptHeader: Record "Sales Shipment Header") begin if SalesHeader."Plant Id" <> '' then SalesShptHeader."Plant Id" := SalesHeader."Plant Id"; end; // Copy Sales Header Plant Id -> Sales Invoice Header [EventSubscriber(ObjectType::Codeunit, Codeunit::"Sales-Post", 'OnAfterInsertInvoiceHeader', '', false, false)] local procedure OnAfterInsertInvoiceHeader(var SalesHeader: Record "Sales Header"; var SalesInvHeader: Record "Sales Invoice Header") begin if SalesHeader."Plant Id" <> '' then SalesInvHeader."Plant Id" := SalesHeader."Plant Id"; end; // Copy Posted Sales Invoice Plant Id -> Cust. Ledger Entry [EventSubscriber(ObjectType::Table, Database::"Cust. Ledger Entry", 'OnAfterInsertEvent', '', false, false)] local procedure CustLedgEntryOnAfterInsert(var Rec: Record "Cust. Ledger Entry"; RunTrigger: Boolean) var SalesInvHeader: Record "Sales Invoice Header"; begin if (Rec."Document No." = '') or (Rec."Plant Id" <> '') then exit; // Works when CLE Document No. = Posted Sales Invoice No. if SalesInvHeader.Get(Rec."Document No.") then begin if SalesInvHeader."Plant Id" <> '' then begin Rec."Plant Id" := SalesInvHeader."Plant Id"; Rec.Modify(false); end; end; end; }
Place this file under:
src/Codeunits/MC360PlantPostingSubs.Codeunit.al


And expand your app.json idRange to cover this object too (you already have 50500–50599, so you’re fine).


No comments:

Post a Comment

Business Central SaaS Extension Design: Implementing Plant Tracking with AL Event Subscribers

  Extending Business Central SaaS: Plant Tracking Using AL Extensions  1️⃣ Problem Statement In many manufacturing and service-oriented or...