REFRACT Platform for Dynamics 365 Business Central

Developer Guide - VaultPDF Business Central

Extend VaultPDF with custom AL code. Subscribe to integration events, override templates, customize storage paths, and integrate with third-party systems.

Extension Architecture

VaultPDF is a standard Microsoft Dynamics 365 Business Central extension. It occupies object ID range 77100-77199 and uses published integration events to allow partners and customers to extend its functionality.

Extension Dependency

Partner and customer extensions that build on VaultPDF must declare a dependency in their app.json:

{
  "dependencies": [
    {
      "appId": "b6f75cab-2b7a-4998-ac28-9acc767b8b4d",
      "name": "VaultPDF",
      "publisher": "Refract Logic LLC",
      "version": "1.0.0.0"
    }
  ]
}

Versioning Strategy

VaultPDF uses Major.Minor.Patch versioning. Current version: 1.0.0.0.

SegmentTriggers Upgrade?Breaking Changes?
MajorAlwaysYes. Schema changes, removed objects, enum type changes. Upgrade codeunit required.
MinorSometimesRarely. New tables, new fields, new required settings keys. Usually no upgrade codeunit.
PatchNoNever. Bug fixes, compiler warnings, UI-only changes. Can be skipped.

Guidelines for extensions pinning VaultPDF:

  • Pin to the current Major version only, e.g. "version": "2.0". Minor upgrades install automatically without the customer's dependency changing
  • When VaultPDF releases a Major version, update the customer's dependency and test thoroughly
  • If a Major release changes field types (e.g., Text[20] to Enum), VaultPDF includes an upgrade codeunit to migrate existing data. Do not rely on Business Central auto-sync
  • Call Initialize Default Keys as part of every upgrade runbook on customer systems. It is idempotent and safe

Integration Events

VaultPDF publishes integration events via the codeunit "VaultPDF Template Header" (77100) and "VaultPDF Dispatcher" (77119). All events use [IntegrationEvent(false, false)] - no global subscriptions or bind-on-demand.

Subscribe to events using [EventSubscriber(...)]. Set IsHandled := true to override default behavior.

Template Management Events

OnBeforeGetDefaultLinesTableId

Override which Business Central table stores the lines for a document header. VaultPDF auto-detects lines tables for most standard BC documents. The following mappings are built in:

Header TableAuto-detected Lines Table
Sales Header (36)Sales Line (37)
Purchase Header (38)Purchase Line (39)
Sales Invoice Header (112)Sales Invoice Line (113)
Sales Cr.Memo Header (114)Sales Cr.Memo Line (115)
Purch. Inv. Header (122)Purch. Inv. Line (123)
Purch. Cr. Memo Hdr. (124)Purch. Cr. Memo Line (125)
Service Header (5900)Service Line (5902)
Job (167)Job Planning Line (1003)
Transfer Header (5740)Transfer Line (5741)

For custom document tables not in this list, subscribe to override:

[EventSubscriber(
  ObjectType::Codeunit, 
  Codeunit::"VaultPDF Template Header", 
  'OnBeforeGetDefaultLinesTableId', 
  '', 
  false, 
  false
)]
local procedure OnBeforeGetDefaultLinesTableId(
  HeaderTableId: Integer; 
  var LinesTableId: Integer; 
  var IsHandled: Boolean
)
begin
  if HeaderTableId <> Database::"My Custom Header" then
    exit;
  
  LinesTableId := Database::"My Custom Lines";
  IsHandled := true;
end;

OnBeforeFindParentFieldId

Override the field ID that links a lines table back to its parent header. VaultPDF uses field-name conventions (Document No., No.) to find the parent link field automatically. For non-standard field names, subscribe to provide the correct field ID.

[EventSubscriber(
  ObjectType::Codeunit,
  Codeunit::"VaultPDF Template Header",
  'OnBeforeFindParentFieldId',
  '',
  false,
  false
)]
local procedure OnBeforeFindParentFieldId(
  LinesTableId: Integer;
  HeaderTableId: Integer;
  var ParentFieldId: Integer;
  var IsHandled: Boolean)
begin
  if LinesTableId <> Database::"My Custom Lines" then
    exit;

  // Field 1 on "My Custom Lines" is "Parent Code" - the FK to "My Custom Header"
  ParentFieldId := 1;
  IsHandled := true;
end;

ParentFieldId must be the field number (integer) on the lines table whose value is the primary key of the header record. Setting IsHandled := true prevents VaultPDF's convention-based lookup from running.


Dispatcher Events

OnBeforeGetDefaultFolderName

Override the SharePoint folder name where the dispatcher stores generated PDFs and payloads.

[EventSubscriber(
  ObjectType::Codeunit, 
  Codeunit::"VaultPDF Dispatcher", 
  'OnBeforeGetDefaultFolderName', 
  '', 
  false, 
  false
)]
local procedure OnBeforeGetDefaultFolderName(
  TableId: Integer; 
  var FolderName: Text; 
  var IsHandled: Boolean
)
begin
  if TableId <> Database::"My Custom Header" then
    exit;
  
  FolderName := 'CustomDocuments';
  IsHandled := true;
end;

Folder naming context:

The folder structure in SharePoint is {SourceSystem}/{FolderName}. For example, if VAULTPDF_SOURCE_SYSTEM = "RefractLogic" and you set FolderName := 'Invoices', documents store at RefractLogic/Invoices/.

OnBeforeGetDocumentIdentifier

Override the document identifier, which controls the PDF filename and the documentId in the dispatcher payload. The default uses the source record's primary key.

[EventSubscriber(
  ObjectType::Codeunit, 
  Codeunit::"VaultPDF Dispatcher", 
  'OnBeforeGetDocumentIdentifier', 
  '', 
  false, 
  false
)]
local procedure OnBeforeGetDocumentIdentifier(
  RecRef: RecordRef; 
  var DocNo: Text; 
  var IsHandled: Boolean
)
var
  MyHeader: Record "My Custom Header";
begin
  if RecRef.Number <> Database::"My Custom Header" then
    exit;
  
  RecRef.SetTable(MyHeader);
  DocNo := MyHeader."No." + '-' + MyHeader."Reference No.";
  IsHandled := true;
end;

OnAfterSendCompletionNotification

Published by "VaultPDF Batch Job Runner" (77132). Fires after an async batch job finishes and the HTTP webhook notification has been sent (or skipped). Use this for AL-based post-completion logic - logging, audit entries, secondary notifications - without the overhead of an HTTP endpoint.

[EventSubscriber(ObjectType::Codeunit, Codeunit::"VaultPDF Batch Job Runner",
  'OnAfterSendCompletionNotification', '', false, false)]
local procedure OnBatchJobComplete(BatchJobEntry: Record "VaultPDF Batch Job Entry")
begin
    case BatchJobEntry.Status of
        BatchJobEntry.Status::Completed:
            MyAuditLog.WriteSuccess(
                BatchJobEntry."Template Code",
                BatchJobEntry."Entry No.",
                BatchJobEntry."Completed At");
        BatchJobEntry.Status::Partial,
        BatchJobEntry.Status::Failed:
            MyAlertMgt.SendUrgentAlert(
                BatchJobEntry."Template Code",
                BatchJobEntry."Status Message");
    end;
end;

The event receives the fully-populated VaultPDF Batch Job Entry record. All fields are available: Status, Template Code, Entry No., Status Message, and completion timestamps. This event does not support IsHandled - it is observation-only.


Settings Management Events

The "VaultPDF Settings Mgt" codeunit (77110) publishes events for reading and transforming settings. Use these to inject values from Azure Key Vault, environment variables, or custom configuration systems instead of Business Central Isolated Storage.

OnBeforeGetSetting

Intercept settings reads and provide custom values. This event fires before the normal Business Central storage lookup.

[EventSubscriber(
  ObjectType::Codeunit, 
  Codeunit::"VaultPDF Settings Mgt", 
  'OnBeforeGetSetting', 
  '', 
  false, 
  false
)]
local procedure OnBeforeGetSetting(
  SettingKey: Code[100]; 
  var Value: Text; 
  var IsHandled: Boolean
)
var
  KeyVaultSecret: Text;
begin
  if SettingKey <> 'VAULTPDF_CLIENT_SECRET' then
    exit;
  
  // Customer's implementation to fetch from Azure Key Vault
  KeyVaultSecret := GetSecretFromKeyVault(SettingKey);
  Value := KeyVaultSecret;
  IsHandled := true;
end;

Common use cases:

  • Reading secrets from Azure Key Vault instead of Isolated Storage
  • Fetching configuration from a custom settings table
  • Overriding settings per company or per user
  • Rotating credentials without modifying the VaultPDF System Settings page

OnAfterGetSetting

This event fires after the setting value is read. Use it for logging, auditing, or post-processing without blocking the read operation.

[EventSubscriber(
  ObjectType::Codeunit, 
  Codeunit::"VaultPDF Settings Mgt", 
  'OnAfterGetSetting', 
  '', 
  false, 
  false
)]
local procedure OnAfterGetSetting(
  SettingKey: Code[100]; 
  var Value: Text
)
begin
  // Audit the setting read
  LogSettingAccess(SettingKey);
  
  // Transform the value (e.g., decrypt or normalize)
  Value := NormalizeValue(Value);
end;

SharePoint Provider Events

The "VaultPDF SharePoint Provider" codeunit (77117) publishes events covering JSON payload building, folder resolution, token acquisition, and upload lifecycle. These are independent of the Dispatcher events - they control the payload JSON file written to SharePoint, not the dispatch request.

OnBeforeBuildJson / OnAfterBuildJson

OnBeforeBuildJson fires before field mapping runs. Set IsHandled := true and populate JsonText to replace the entire payload. Use this for documents with complex non-standard data structures (e.g. three-way match, multi-table aggregates) where template field-mapping lines would be impractical.

[EventSubscriber(ObjectType::Codeunit, Codeunit::"VaultPDF SharePoint Provider",
  'OnBeforeBuildJson', '', false, false)]
local procedure OnBeforeBuildJson(var RecRef: RecordRef; var TemplateCode: Code[20];
  var JsonText: Text; var IsHandled: Boolean)
begin
  if TemplateCode <> 'MY-CUSTOM' then
    exit;
  JsonText := BuildMyCustomJson(RecRef);
  IsHandled := true;
end;

OnAfterBuildJson fires after field mapping completes with JsonText fully built. Modify it to inject additional fields without replacing the entire payload.

[EventSubscriber(ObjectType::Codeunit, Codeunit::"VaultPDF SharePoint Provider",
  'OnAfterBuildJson', '', false, false)]
local procedure OnAfterBuildJson(RecRef: RecordRef; TemplateCode: Code[20]; var JsonText: Text)
var
  Root: JsonObject;
begin
  if not Root.ReadFrom(JsonText) then
    exit;
  Root.Add('customField', 'injected-value');
  Root.WriteTo(JsonText);
end;

OnBeforeGetFolderName

Overrides the SharePoint upload folder for a specific template-and-record combination, evaluated after Template Setup folder resolution and before the default table-name fallback. Use this for dynamic per-record folder routing that goes beyond the template's Source System / Source Module path.

[EventSubscriber(ObjectType::Codeunit, Codeunit::"VaultPDF SharePoint Provider",
  'OnBeforeGetFolderName', '', false, false)]
local procedure OnBeforeGetFolderName(TableId: Integer; var FolderName: Text; var IsHandled: Boolean)
begin
  if TableId <> Database::"My Custom Header" then
    exit;
  FolderName := 'Archive/' + Format(Today, 0, '<Year4>');
  IsHandled := true;
end;

Distinction from Dispatcher's OnBeforeGetDefaultFolderName

The Dispatcher event (77119) controls where the generated PDF is stored. The SharePoint Provider event (77117) controls where the source payload JSON is uploaded. Both must resolve to the same logical folder path so the Dispatcher can locate the payload when rendering.

OnBeforeGetAccessToken / OnAfterGetAccessToken

Controls the OAuth token used for all Microsoft Graph calls in the SharePoint Provider (payload upload, template browser, template import).

OnBeforeGetAccessToken - set IsHandled := true and provide AccessToken to bypass the built-in client-credentials flow. Use for Managed Identity or Key Vault-backed token acquisition.

[EventSubscriber(ObjectType::Codeunit, Codeunit::"VaultPDF SharePoint Provider",
  'OnBeforeGetAccessToken', '', false, false)]
local procedure OnBeforeGetAccessToken(var AccessToken: Text; var IsHandled: Boolean)
begin
  AccessToken := FetchManagedIdentityToken('https://graph.microsoft.com');
  IsHandled := AccessToken <> '';
end;

OnAfterGetAccessToken fires after the token is obtained (or bypassed). Use for logging or token transformation.

OnBeforeUpload / OnAfterUpload / OnUploadError

Three events bracket the SharePoint HTTP PUT:

EventWhen it firesIsHandled
OnBeforeUploadBefore the HTTP request - set IsHandled := true to skip the real uploadYes
OnAfterUploadAfter a successful upload - receives FileName and computed SharePointUrlNo
OnUploadErrorAfter a failed upload - receives the raw error response bodyNo

OnBeforeUpload is the primary seam for test extensions that need to mock SharePoint without real HTTP calls.


The "VaultPDF Gallery Context" codeunit (77111) is a SingleInstance context carrier that holds the source record, document number, and page ID between the moment the user clicks Process Document and when the Template Gallery modal opens.

OnBeforeSetContext

Fires before the gallery context is stored. Set IsHandled := true to provide a completely different context - for example, to redirect the gallery to a parent record when triggered from a child record.

[EventSubscriber(ObjectType::Codeunit, Codeunit::"VaultPDF Gallery Context",
  'OnBeforeSetContext', '', false, false)]
local procedure OnBeforeSetContext(var RecRef: RecordRef; var DocNo: Text;
  var PageId: Integer; var IsHandled: Boolean)
var
  ParentHeader: Record "My Parent Header";
begin
  if RecRef.Number <> Database::"My Child Line" then
    exit;
  // Load and substitute the parent record
  // RecRef.GetTable(ParentHeader); etc.
  IsHandled := true;
end;

OnAfterSetContext

Fires after the context is stored. Use for logging or injecting additional data into external systems without changing the context itself.


OnResolveQuerySection

Published by "VaultPDF JSON Builder", this is the most powerful extensibility point in the system. It allows a subscriber to inject an entire JSON section into the payload - built from any BC Query object - without field-mapping lines on the template.

When to use it: when you need to attach data from a multi-table join to the payload and individual field-mapping lines or hop chains would be impractical (e.g. all bank accounts for a vendor, all open invoices for a customer).

Setup on the template - add a template line with:

  • Section = Header (or Lines)
  • JSON Group = the key name under which the section will appear in the payload (e.g. vendor_bank)
  • Query Object ID = the BC Query object ID to resolve
  • Leave Table ID, Field ID, Link Field ID blank

Subscriber signature:

[EventSubscriber(ObjectType::Codeunit, Codeunit::"VaultPDF JSON Builder",
  'OnResolveQuerySection', '', false, false)]
local procedure HandleMyQuery(
  QueryObjectId: Integer;
  ParentRecRef: RecordRef;
  TemplateCode: Code[20];
  GroupName: Text;
  Section: Enum "VaultPDF Section";
  var SectionJson: JsonObject;
  var IsHandled: Boolean)

Populate SectionJson and set IsHandled := true. The JSON Builder writes SectionJson into the payload under GroupName. When IsHandled = false, the section is skipped.

Worked example - Vendor Bank Details (Query 77150):

The reference implementation ships in VaultPDF Query Subscriber (77115). Configure a Purchase Order template with Query Object ID = 77150 and JSON Group = vendor_bank to attach all bank accounts for the order's vendor.

[EventSubscriber(ObjectType::Codeunit, Codeunit::"VaultPDF JSON Builder",
  'OnResolveQuerySection', '', false, false)]
local procedure HandleVendorBankDetails(
  QueryObjectId: Integer; ParentRecRef: RecordRef;
  TemplateCode: Code[20]; GroupName: Text;
  Section: Enum "VaultPDF Section";
  var SectionJson: JsonObject; var IsHandled: Boolean)
var
  BankQuery: Query "VaultPDF Vendor Bank Details";
  VendorNo: Code[20];
  AccountObj: JsonObject;
  AccountsArray: JsonArray;
  FldRef: FieldRef;
begin
  if QueryObjectId <> Query::"VaultPDF Vendor Bank Details" then
    exit;

  FldRef := ParentRecRef.Field(2); // "Buy-from Vendor No." on Purchase Header
  VendorNo := CopyStr(Format(FldRef.Value), 1, 20);
  if VendorNo = '' then exit;

  BankQuery.SetFilter(BankQuery.VendorNo, '%1', VendorNo);
  BankQuery.Open();
  while BankQuery.Read() do begin
    Clear(AccountObj);
    AccountObj.Add('bank_code', BankQuery.BankCode);
    AccountObj.Add('bank_account_no', BankQuery.BankAccountNo);
    AccountObj.Add('iban', BankQuery.IBAN);
    AccountObj.Add('currency_code', BankQuery.BankCurrencyCode);
    AccountsArray.Add(AccountObj);
  end;
  BankQuery.Close();

  SectionJson.Add('accounts', AccountsArray);
  IsHandled := true;
end;

Resulting payload structure:

{
  "vendor_bank": {
    "accounts": [
      { "bank_code": "HSBC", "bank_account_no": "12345678", "iban": "GB29NWBK...", "currency_code": "GBP" }
    ]
  }
}

Extensible Enums

The following enums are marked as extensible and can be extended by partner or customer extensions:

EnumObject IDShipped Values
VaultPDF Template Type77102Template, Workflow
VaultPDF Workflow Enforcement77104None, Validate, Block
VaultPDF ESign Mode77105Sequential, Parallel
VaultPDF Dispatch Mode77189Standard, Per Group, Per Record, Single Array, Grouped Array

Extending an Enum

Example: Add a custom template type for internal approval workflows.

enumextension 77200 "My Template Types" extends "VaultPDF Template Type"
{
    value(100; "Custom Approval")
    {
        Caption = 'Custom Approval Process';
    }
}

After extending the enum, you can:

  1. Select Custom Approval as a template type in VaultPDF Templates
  2. Subscribe to OnBeforeGetDocumentIdentifier or other events to implement custom behavior for this type
  3. Surface new template settings fields specific to the customer's template type

Adding VaultPDF Actions to Custom Pages

VaultPDF ships page extensions for standard Business Central document pages (Sales Order, Purchase Invoice, etc.). To enable VaultPDF on a custom or non-standard page, you write a page extension - but you can use the Custom Page Registry to let an administrator toggle the buttons on or off at runtime without redeployment.

How the Registry Works

The registry does not inject UI dynamically. Business Central requires a compiled pageextension to add buttons to a page - that cannot be avoided. What the registry provides is a Visible property gate: the page extension reads IsPageRegistered(PageId) when the page opens and uses the result to show or hide the entire REFRACT Platform action group.

Without registryWith registry
Buttons are always visible once the extension is deployedAdmin can enable/disable buttons per page from VaultPDF System Settings → Custom Page Registry
Removing buttons requires redeploymentToggle Enabled = false on the registry record to hide buttons instantly, no redeploy
Different buttons for different page IDs requires separate binariesOne binary per page; registry controls visibility per environment

The Complete Boilerplate Pattern

This is the recommended pattern for all custom page extensions. Copy and adapt it - only change the extends target, the PageId constant, and the RecRef usage:

namespace RefractLogic.VaultPDF;

using Contoso.MyModule; // your namespace

pageextension 77201 "VaultPDF My Custom Page Ext" extends "My Custom Page"
{
    actions
    {
        addlast(Processing)
        {
            group(VaultPlatformGroup)
            {
                Caption = 'REFRACT Platform';
                Image = Certificate;
                Visible = VaultActionsVisible;

                action(VaultProcessDocument)
                {
                    ApplicationArea = All;
                    Caption = 'Process Document';
                    ToolTip = 'Generate a PDF for this record using the REFRACT Platform template gallery.';
                    Image = Approve;
                    Visible = VaultActionsVisible;
                    trigger OnAction()
                    var
                        PlatformActions: Codeunit "VaultPDF Platform Actions";
                        RecRef: RecordRef;
                    begin
                        RecRef.GetTable(Rec);
                        PlatformActions.ProcessDocument(RecRef, Rec."No.", 77201);
                    end;
                }
                action(VaultOpenDocument)
                {
                    ApplicationArea = All;
                    Caption = 'Open Document';
                    ToolTip = 'Open the latest PDF generated for this record.';
                    Image = Open;
                    Visible = VaultActionsVisible;
                    trigger OnAction()
                    var
                        PlatformActions: Codeunit "VaultPDF Platform Actions";
                        RecRef: RecordRef;
                    begin
                        RecRef.GetTable(Rec);
                        PlatformActions.OpenDocument(RecRef);
                    end;
                }
                action(VaultOpenWorkflowDocument)
                {
                    ApplicationArea = All;
                    Caption = 'Open Workflow Document';
                    Image = Certificate;
                    Visible = VaultActionsVisible;
                    trigger OnAction()
                    var
                        PlatformActions: Codeunit "VaultPDF Platform Actions";
                        RecRef: RecordRef;
                    begin
                        RecRef.GetTable(Rec);
                        PlatformActions.OpenWorkflowDocument(RecRef);
                    end;
                }
                action(VaultSyncWorkflow)
                {
                    ApplicationArea = All;
                    Caption = 'Sync Workflow Status';
                    Image = Refresh;
                    Visible = VaultActionsVisible;
                    trigger OnAction()
                    var
                        PlatformActions: Codeunit "VaultPDF Platform Actions";
                        RecRef: RecordRef;
                    begin
                        RecRef.GetTable(Rec);
                        PlatformActions.SyncWorkflowStatus(RecRef);
                    end;
                }
                action(VaultLifecyclePortal)
                {
                    ApplicationArea = All;
                    Caption = 'Open Lifecycle Workspace';
                    Image = InteractionLog;
                    Visible = VaultActionsVisible;
                    trigger OnAction()
                    var
                        PlatformActions: Codeunit "VaultPDF Platform Actions";
                        RecRef: RecordRef;
                    begin
                        RecRef.GetTable(Rec);
                        PlatformActions.OpenLifecyclePortal(RecRef);
                    end;
                }
            }
        }
        addlast(Promoted)
        {
            group(Category_VaultPlatform)
            {
                Caption = 'REFRACT Platform';
                Visible = VaultActionsVisible;
                actionref(VaultProcessDocument_Promoted; VaultProcessDocument) { }
                actionref(VaultOpenDocument_Promoted; VaultOpenDocument) { }
                actionref(VaultOpenWorkflowDocument_Promoted; VaultOpenWorkflowDocument) { }
                actionref(VaultSyncWorkflow_Promoted; VaultSyncWorkflow) { }
                actionref(VaultLifecyclePortal_Promoted; VaultLifecyclePortal) { }
            }
        }
    }

    var
        VaultActionsVisible: Boolean;

    trigger OnOpenPage()
    var
        PlatformActions: Codeunit "VaultPDF Platform Actions";
    begin
        VaultActionsVisible := PlatformActions.IsPageRegistered(77201);
    end;
}

Key points in the boilerplate:

  • VaultActionsVisible is a page-level Boolean set in OnOpenPage via IsPageRegistered(PageId)
  • Every action and the group itself uses Visible = VaultActionsVisible - nothing is hidden by accident
  • PlatformActions.ProcessDocument(RecRef, Rec."No.", 77201) - pass the page ID (not the table ID) as the third argument so the Template Gallery filters to templates matching this page
  • 77201 in the IsPageRegistered call must match the page ID of the page this extension extends

Registering the Page

Deploy the extension and register the page so buttons appear:

Open VaultPDF System Settings in Business Central

Click Custom Page Registry in the action bar to open the registry page

Click New and fill in:

FieldValue
Page IDThe BC page ID this extension extends (e.g. 77201)
Action Group LabelLabel for the action group in the action bar (e.g. REFRACT Platform)
Enabledtrue - buttons are visible when this is enabled

Page Name is auto-filled from the Page ID.

Reload the custom page. The REFRACT Platform action group appears.

To hide the buttons without uninstalling the extension, set Enabled = false on the registry record. Buttons disappear the next time any user opens the page - no redeploy, no downtime.

Seeding the Registry Entry Automatically

If your extension should register its own pages on install, call EnsureRegistryEntry from your upgrade codeunit or from the InitializeDefaultSettings subscriber:

// In your own setup codeunit or an event subscriber on InitializeDefaultSettings:
local procedure EnsureMyPageRegistered()
var
    Registry: Record "VaultPDF Custom Page Registry";
begin
    if Registry.Get(77201) then exit; // Already registered

    Registry.Init();
    Registry."Page ID" := 77201;
    Registry."Action Group Label" := 'REFRACT Platform';
    Registry.Enabled := true;
    Registry.Insert(true);
end;

This is idempotent - safe to call on every upgrade. The if Registry.Get(...) then exit guard prevents duplicate records.


Workflow Enforcement

Built-in Enforcement Points

When Workflow Enforcement Mode = Block or Validate on a template, VaultPDF automatically checks the workflow state before allowing certain Business Central operations. The following operations are covered out of the box:

OperationBC EventNotes
Post a Sales documentSales-Post.OnBeforePostSalesDocSales Orders, Invoices, Credit Memos
Post a Purchase documentPurch.-Post.OnBeforePostPurchaseDocPurchase Orders, Invoices, Credit Memos
Post a Service OrderService-Post.OnBeforePostWithLinesFires before service invoice/credit memo creation
Ship a Transfer OrderTransferOrder-Post Shipment.OnBeforeTransferOrderPostShipmentFires on shipment; add a separate subscriber for receipt if needed
Unblock a VendorVendor.OnBeforeModifyEventOnly fires when removing the block (blocked → unblocked)
Unblock a CustomerCustomer.OnBeforeModifyEventSame direction guard
Unblock an ItemItem.OnBeforeModifyEventSame direction guard

Extending Enforcement to Additional Operations

VaultPDF Workflow Enforcer.CheckBeforeOperation (Access = Public) is the single enforcement entry point. Call it from any AL event subscriber to apply the same Block/Validate rules to any operation.

The method is fully table-agnostic - it reads the workflow state from the Record Link attached to the passed RecRef and evaluates the enforcement mode configured on the matching template.

Signature:

procedure CheckBeforeOperation(RecRef: RecordRef; OperationLabel: Text)
  • RecRef - a RecordRef pointing to the document record (e.g. the Service Header, Assembly Header, or custom record)
  • OperationLabel - a short description used in the error/warning message shown to the user (e.g. 'post this assembly order')

Example: Add enforcement for Assembly Order posting

// In a new subscriber codeunit in your extension:
[EventSubscriber(ObjectType::Codeunit, Codeunit::"Assembly-Post", 'OnBeforePostAssemblyDoc', '', false, false)]
local procedure CheckBeforeAssemblyPost(var AssemblyHeader: Record "Assembly Header"; var SuppressCommit: Boolean)
var
    Enforcer: Codeunit "VaultPDF Workflow Enforcer";
    RecRef: RecordRef;
begin
    RecRef.GetTable(AssemblyHeader);
    Enforcer.CheckBeforeOperation(RecRef, 'post this assembly order');
end;

Example: Add enforcement for a custom document posting codeunit

[EventSubscriber(ObjectType::Codeunit, Codeunit::"My Custom Posting", 'OnBeforePostMyDoc', '', false, false)]
local procedure CheckBeforeMyDocPost(var MyHeader: Record "My Custom Header")
var
    Enforcer: Codeunit "VaultPDF Workflow Enforcer";
    RecRef: RecordRef;
begin
    RecRef.GetTable(MyHeader);
    Enforcer.CheckBeforeOperation(RecRef, 'post this document');
end;

How enforcement behaves after you call CheckBeforeOperation:

ConditionBlock modeValidate mode
No VaultPDF link on the recordPasses silently - enforcement does not applySame
VaultPDF link exists but not a workflow documentPasses silentlySame
Workflow completePasses unconditionallySame
Workflow pendingError - operation is blockedWarning dialog - user can proceed or cancel
Workflow rejectedError - operation is blockedWarning dialog - user can proceed or cancel
No GUI (background Job Queue)Error regardless of modeError (same as Block - no dialog possible in headless context)

Conditionally Exempting Specific Records

Subscribe to OnBeforeWorkflowEnforcement and set IsHandled := true to bypass enforcement for specific records, operations, or conditions - without modifying the enforcement codeunit:

[EventSubscriber(ObjectType::Codeunit, Codeunit::"VaultPDF Workflow Enforcer",
  'OnBeforeWorkflowEnforcement', '', false, false)]
local procedure ExemptInternalTransfers(RecRef: RecordRef; OperationLabel: Text; var IsHandled: Boolean)
var
    TransferHeader: Record "Transfer Header";
begin
    // Allow internal warehouse transfers to bypass enforcement
    if RecRef.Number <> Database::"Transfer Header" then exit;
    RecRef.SetTable(TransferHeader);
    if TransferHeader."Transfer-from Code" = TransferHeader."Transfer-to Code" then
        IsHandled := true; // Same location - no enforcement needed
end;

Setting IsHandled := true causes CheckBeforeOperation to exit immediately without evaluating any workflow state. Use this pattern for:

  • Records that should always be exempt (internal movements, system-generated documents)
  • Company-specific bypass rules (e.g., a specific department code is always allowed to post)
  • Temporary bypass during data migration or initial setup

Dispatcher Payload Reference

For the complete payload structure, all field descriptions, and templateSettings fields, see the Event & Payload Reference page.


Event Subscription Best Practices

Use Clear, Descriptive Procedure Names

// Good
local procedure OnBeforeGetDocumentIdentifier_InvoiceNumbering(...)

// Avoid
local procedure MyOverride(...)

Log Errors Explicitly

begin
  if not FetchValueFromKeyVault(SettingKey, KeyVaultValue) then begin
    Session.LogMessage('0001', 
      StrSubstNo('Failed to fetch key vault secret: %1', SettingKey), 
      Verbosity::Error);
    exit;
  end;
end;

Avoid Blocking Operations in OnAfterGetSetting

The OnAfterGetSetting event should not throw errors or prevent normal operation. Use it only for logging and non-critical transformations.

// Good - logging only
local procedure OnAfterGetSetting_Logging(SettingKey: Code[100]; var Value: Text)
begin
  LogSettingRead(SettingKey);  // Non-blocking
end;

// Avoid - blocking logic
local procedure OnAfterGetSetting_Bad(SettingKey: Code[100]; var Value: Text)
begin
  if ValidateSecretExpiry(Value) = false then
    Error('Secret expired');  // Blocks normal flow
end;

Common Extension Scenarios

Override the default SharePoint folder naming convention to match the customer's document filing structure.

[EventSubscriber(ObjectType::Codeunit, Codeunit::"VaultPDF Dispatcher", 'OnBeforeGetDefaultFolderName', '', false, false)]
local procedure OnBeforeGetDefaultFolderName(TableId: Integer; var FolderName: Text; var IsHandled: Boolean)
var
  CompanyInfo: Record "Company Information";
begin
  CompanyInfo.Get();
  
  case TableId of
    Database::"Sales Invoice Header":
      FolderName := 'AR/' + Format(Today, 0, '<Year4>') + '/' + Format(Today, 0, '<Month,2>');
    Database::"Purchase Invoice Header":
      FolderName := 'AP/' + Format(Today, 0, '<Year4>') + '/' + Format(Today, 0, '<Month,2>');
    else
      exit;
  end;
  
  IsHandled := true;
end;

Fetch dispatcher credentials from Azure Key Vault instead of storing them in Business Central Isolated Storage.

[EventSubscriber(ObjectType::Codeunit, Codeunit::"VaultPDF Settings Mgt", 'OnBeforeGetSetting', '', false, false)]
local procedure OnBeforeGetSetting_KeyVault(SettingKey: Code[100]; var Value: Text; var IsHandled: Boolean)
var
  AzureKeyVault: Codeunit "Azure Key Vault";
  SecretName: Text;
begin
  if SettingKey not in ['VAULTPDF_DISPATCHER_CLIENT_SECRET', 'VAULTPDF_CLIENT_SECRET'] then
    exit;
  
  SecretName := 'VaultPDF-' + SettingKey;
  
  if not AzureKeyVault.GetSecret(SecretName, Value) then begin
    Session.LogMessage('0002', StrSubstNo('Key Vault secret not found: %1', SecretName), Verbosity::Error);
    exit;
  end;
  
  IsHandled := true;
end;

Automatically register templates into VaultPDF from a JSON configuration file when a custom extension installs.

[EventSubscriber(ObjectType::Codeunit, Codeunit::"Upgrade BC Codeunit", 'OnRun', '', false, false)]
local procedure OnUpgrade_RegisterTemplates()
var
  TemplateRegistration: Codeunit "My Template Registration";
begin
  TemplateRegistration.RegisterDefaultTemplates();
end;

codeunit 77202 "My Template Registration"
{
  procedure RegisterDefaultTemplates()
  begin
    // Read templates from JSON and populate VaultPDF Templates table
  end;
}

Maintain separate VaultPDF credentials per company without modifying the global System Settings.

[EventSubscriber(ObjectType::Codeunit, Codeunit::"VaultPDF Settings Mgt", 'OnBeforeGetSetting', '', false, false)]
local procedure OnBeforeGetSetting_MultiCompany(SettingKey: Code[100]; var Value: Text; var IsHandled: Boolean)
var
  CompanySettings: Record "My Company VaultPDF Settings";
begin
  if not CompanySettings.Get(CompanyName, SettingKey) then
    exit;
  
  Value := CompanySettings."Setting Value";
  IsHandled := true;
end;

Complete Integration Event Reference

For the full 22-event table with codeunit IDs, IsHandled flags, and one-line purpose for every event, see the Event & Payload Reference page.


Troubleshooting Extension Issues

Event Not Firing

Check:

  • Codeunit ID is correct (77100, 77110, 77119)
  • Event name spelling matches exactly
  • All filter parameters match (usually all empty strings)
  • Extension publishes VaultPDF as a dependency in app.json

IsHandled Not Working

Ensure you set IsHandled := true before setting the return variable:

// Correct
IsHandled := true;
DocNo := MyCustomValue;

// Incorrect - IsHandled set after
DocNo := MyCustomValue;
IsHandled := true;

Changes Not Appearing

  • Restart the Business Central service (or reload the browser)
  • Verify the extension is installed in Extension Management
  • Check that the customer's extension is published after VaultPDF

On this page