Beyond Standard Workflows: The Case for Customization

Acumatica’s standard workflow engine provides substantial capabilities for automating business processes, but organizations with unique requirements often need to extend beyond these standard features. My research into Acumatica implementations across industries has revealed that custom workflows frequently become necessary when organizations need to:

  1. Implement complex conditional logic that standard workflows cannot accommodate
  2. Integrate with external systems as part of the workflow process
  3. Incorporate specialized validation rules specific to the business
  4. Create multi-stage approval processes with dynamic routing
  5. Implement state-driven document processing with complex transitions

In this guide, I’ll explore the technical approaches to implementing custom workflows in Acumatica, drawing from real-world implementation patterns I’ve observed in my research.

State Machine Design Principles for Acumatica Workflows

Custom workflows in Acumatica are effectively implemented using state machine design principles. A state machine consists of:

  • States: The possible conditions an entity can exist in (e.g., Draft, Pending Approval, Approved, Rejected)
  • Transitions: The allowed movements between states
  • Guards: Conditions that must be met to allow a transition
  • Actions: Operations performed during a transition

Implementing States in Acumatica

States are typically represented as enumeration values in a custom field. For example, a purchase requisition approval workflow might include the following states:

public class RequisitionStatus
{
    public const string Draft = "DR";
    public const string PendingApproval = "PA";
    public const string ApprovedLevel1 = "A1";
    public const string ApprovedLevel2 = "A2";
    public const string Rejected = "RJ";
    public const string Completed = "CO";

    public class ListAttribute : PXStringListAttribute
    {
        public ListAttribute()
            : base(
                new string[] { Draft, PendingApproval, ApprovedLevel1, ApprovedLevel2, Rejected, Completed },
                new string[] { "Draft", "Pending Approval", "Approved (Level 1)", "Approved (Level 2)", "Rejected", "Completed" })
        { }
    }
}

This approach defines both the technical state values and their user-friendly display labels.

Defining Transition Rules

Transition rules determine which state changes are valid under which conditions. These rules are typically implemented in the graph (business logic controller) associated with the document.

For example:

// Define allowed transitions from each state
protected virtual void _(Events.RowSelected<MyDocument> e)
{
    if (e.Row == null) return;
    
    PXUIFieldAttribute.SetEnabled<MyDocument.status>(e.Cache, e.Row, false);
    
    bool isEditable = e.Row.Status == MyDocumentStatus.Draft || 
                      e.Row.Status == MyDocumentStatus.Rejected;
    
    // Enable/disable controls based on current state
    PXUIFieldAttribute.SetEnabled<MyDocument.description>(e.Cache, e.Row, isEditable);
    PXUIFieldAttribute.SetEnabled<MyDocument.amount>(e.Cache, e.Row, isEditable);
    
    // Setup action availability based on state
    Submit.SetEnabled(e.Row.Status == MyDocumentStatus.Draft);
    Approve.SetEnabled(e.Row.Status == MyDocumentStatus.PendingApproval);
    Reject.SetEnabled(e.Row.Status == MyDocumentStatus.PendingApproval);
    SendBack.SetEnabled(e.Row.Status == MyDocumentStatus.Approved);
}

This method configures the UI based on the current state, enabling and disabling fields and actions appropriately.

Implementing Transition Actions

Actions represent the operations that change a document’s state. In Acumatica, these are implemented as PXAction handlers:

public PXAction<MyDocument> Submit;
[PXButton]
[PXUIField(DisplayName = "Submit")]
protected virtual IEnumerable submit(PXAdapter adapter)
{
    foreach (MyDocument doc in adapter.Get<MyDocument>())
    {
        // Perform validation before state change
        if (doc.Amount <= 0)
        {
            throw new PXException("Amount must be greater than zero.");
        }
        
        // Update state
        doc.Status = MyDocumentStatus.PendingApproval;
        doc.SubmittedDate = PXTimeZoneInfo.Now;
        doc.SubmittedBy = PXAccess.GetUserID();
        
        // Perform additional workflow actions
        NotifyApprovers(doc);
        
        this.Save.Press();
        yield return doc;
    }
}

This pattern allows for validation, state updates, and additional processing within a single logical operation.

Approval Hierarchy Implementation

Many custom workflows involve multi-level approvals based on organizational structure or authorization levels. Implementing this in Acumatica requires:

  1. Defining the approval hierarchy structure
  2. Determining the appropriate approvers at each stage
  3. Managing the approval flow through the hierarchy

Modeling Approval Hierarchies

Approvals typically follow either role-based patterns (based on user roles) or organizational patterns (based on reporting relationships). Both approaches can be modeled in Acumatica:

// Define approval level configuration
[PXTable]
public class ApprovalLevel : IBqlTable
{
    [PXDBIdentity]
    [PXUIField(DisplayName = "Level ID")]
    public virtual int? LevelID { get; set; }
    public abstract class levelID : PX.Data.BQL.BqlInt.Field<levelID> { }
    
    [PXDBInt]
    [PXUIField(DisplayName = "Sequence")]
    public virtual int? Sequence { get; set; }
    public abstract class sequence : PX.Data.BQL.BqlInt.Field<sequence> { }
    
    [PXDBString(8, IsUnicode = true)]
    [PXUIField(DisplayName = "Level Name")]
    public virtual string LevelName { get; set; }
    public abstract class levelName : PX.Data.BQL.BqlString.Field<levelName> { }
    
    [PXDBDecimal]
    [PXUIField(DisplayName = "Threshold Amount")]
    public virtual decimal? ThresholdAmount { get; set; }
    public abstract class thresholdAmount : PX.Data.BQL.BqlDecimal.Field<thresholdAmount> { }
    
    [PXDBInt]
    [PXSelector(typeof(Search<Users.pKID>),
        SubstituteKey = typeof(Users.username),
        DescriptionField = typeof(Users.fullName))]
    [PXUIField(DisplayName = "Approver")]
    public virtual int? ApproverID { get; set; }
    public abstract class approverID : PX.Data.BQL.BqlInt.Field<approverID> { }
}

This structure allows defining multiple approval levels with different thresholds and designated approvers.

Dynamic Approver Determination

The appropriate approver might vary based on document attributes. For example, different departments might have different approval chains. This logic can be encapsulated in a service class:

public class ApprovalService
{
    public static int? GetNextApproverID(MyDocument document, PXGraph graph)
    {
        // Get current approval level
        int currentLevel = document.ApprovalLevel ?? 0;
        
        // Find next approval level based on document attributes
        ApprovalLevel nextLevel = PXSelect<
            ApprovalLevel,
            Where<ApprovalLevel.sequence, Equal<Required<ApprovalLevel.sequence>>,
                And<ApprovalLevel.departmentID, Equal<Required<ApprovalLevel.departmentID>>,
                And<ApprovalLevel.thresholdAmount, GreaterEqual<Required<ApprovalLevel.thresholdAmount>>>>>
            >
            .Select(graph, currentLevel + 1, document.DepartmentID, document.TotalAmount)
            .FirstOrDefault();
            
        return nextLevel?.ApproverID;
    }
}

This method finds the next appropriate approver based on the document’s current level, department, and amount.

Notification Configuration

Workflow effectiveness depends on timely notifications to keep the process moving. Acumatica provides notification templates that can be leveraged in custom workflows.

Creating Notification Templates

Templates should be created through the Notification Templates screen (SM204003) with appropriate token substitution:

Subject: [Document #{$Document.RefNbr}] requires your approval
 
Body:
Dear {$Approver.FullName},

Document #{$Document.RefNbr} requires your approval.

Amount: {$Document.Amount}
Description: {$Document.Description}
Submitted by: {$Document.SubmittedBy}

Please click the link below to review this document:
{$RecordLink}

Thank you,
Acumatica Notification System

Triggering Notifications Programmatically

Custom workflows need to trigger these notifications from code:

protected virtual void SendApprovalNotification(MyDocument doc, int? approverID)
{
    if (approverID == null) return;
    
    // Create notification
    Notification notification = CreateNotificationRecord(
        "APPROVAL_REQUEST",  // Template name
        doc,
        approverID);
    
    // Send notification
    NotificationProcessor.SendNotification(Base, notification);
}

protected virtual Notification CreateNotificationRecord(string notificationCD, MyDocument doc, int? approverID)
{
    NotificationSetup setup = PXSelect<
        NotificationSetup,
        Where<NotificationSetup.notificationCD, Equal<Required<NotificationSetup.notificationCD>>>>
        .Select(this, notificationCD);
        
    if (setup == null) return null;
    
    Notification notification = new Notification();
    notification.SetupID = setup.SetupID;
    notification.RefNoteID = doc.NoteID;
    notification.NotificationCD = notificationCD;
    notification.RecipientID = approverID;
    
    return notification;
}

This approach uses Acumatica’s notification framework to send consistent, template-based communications.

Custom Action Development

Standard button actions often need augmentation with custom logic for complex workflows.

Adding Custom Actions to Data Entry Screens

Custom actions are added to screens by defining PXAction properties in the graph:

public PXAction<MyDocument> EscalateApproval;
[PXButton]
[PXUIField(DisplayName = "Escalate")]
protected virtual IEnumerable escalateApproval(PXAdapter adapter)
{
    foreach (MyDocument doc in adapter.Get<MyDocument>())
    {
        if (doc.Status != MyDocumentStatus.PendingApproval)
        {
            throw new PXException("Only documents in Pending Approval status can be escalated.");
        }
        
        // Get manager of current approver
        int? managerID = GetManagerID(doc.CurrentApproverID);
        if (managerID == null)
        {
            throw new PXException("No manager found for escalation.");
        }
        
        // Update approver
        doc.CurrentApproverID = managerID;
        doc.LastEscalationDate = PXTimeZoneInfo.Now;
        
        // Notify new approver
        SendApprovalNotification(doc, managerID);
        
        this.Save.Press();
        yield return doc;
    }
}

private int? GetManagerID(int? employeeID)
{
    if (employeeID == null) return null;
    
    // Query organizational structure to find manager
    EPEmployee employee = PXSelect<
        EPEmployee,
        Where<EPEmployee.userID, Equal<Required<EPEmployee.userID>>>>
        .Select(this, employeeID)
        .FirstOrDefault();
        
    return employee?.ManagerID;
}

This pattern encapsulates the entire escalation process within a single action, including validation, state changes, and notifications.

Integrating External Systems in Workflows

Many workflows need to interact with external systems. For example, a purchase approval might need to check budget availability in a separate financial planning system.

Implementing External Service Calls

External system integration is typically implemented through service classes:

public class BudgetValidationService
{
    public static bool ValidateBudgetAvailability(string departmentID, string accountID, decimal amount)
    {
        // Create HTTP client for budget system API
        using (HttpClient client = new HttpClient())
        {
            client.BaseAddress = new Uri("https://budgetsystem.example.com/api/");
            client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
            
            // Create request payload
            var payload = new
            {
                DepartmentID = departmentID,
                AccountID = accountID,
                RequestedAmount = amount
            };
            
            // Call API
            HttpResponseMessage response = client.PostAsJsonAsync("budget/validate", payload).Result;
            
            if (response.IsSuccessStatusCode)
            {
                // Parse response
                var result = response.Content.ReadAsAsync<BudgetValidationResult>().Result;
                return result.IsApproved;
            }
            
            // Log error
            PX.Data.PXTrace.WriteError("Budget validation failed: " + response.ReasonPhrase);
            return false;
        }
    }
    
    private class BudgetValidationResult
    {
        public bool IsApproved { get; set; }
        public decimal AvailableBudget { get; set; }
        public string Message { get; set; }
    }
}

This service can then be called from workflow actions:

// Inside Submit action
if (!BudgetValidationService.ValidateBudgetAvailability(
    doc.DepartmentID, 
    doc.AccountID, 
    doc.Amount))
{
    throw new PXException("Budget validation failed. Insufficient funds available.");
}

Using Background Processing for Long-Running Tasks

When external integrations might take significant time, background processing should be used to avoid blocking the UI:

public PXAction<MyDocument> ProcessWithExternalSystem;
[PXButton]
[PXUIField(DisplayName = "Process")]
protected virtual IEnumerable processWithExternalSystem(PXAdapter adapter)
{
    List<MyDocument> documents = adapter.Get<MyDocument>().ToList();
    
    // Create a background processing delegate
    PXLongOperation.StartOperation(this, delegate()
    {
        foreach (MyDocument doc in documents)
        {
            try
            {
                // Initialize a new graph instance for background processing
                MyDocumentEntry graph = PXGraph.CreateInstance<MyDocumentEntry>();
                
                // Retrieve the latest version of the document
                MyDocument current = graph.Document.Search<MyDocument.docNbr>(doc.DocNbr);
                if (current != null && current.Status == MyDocumentStatus.ReadyForProcessing)
                {
                    // Call external service
                    bool success = ExternalProcessingService.Process(current);
                    
                    if (success)
                    {
                        // Update document status
                        current.Status = MyDocumentStatus.Processed;
                        current.ProcessedDate = PXTimeZoneInfo.Now;
                        graph.Document.Update(current);
                        graph.Actions.PressSave();
                    }
                    else
                    {
                        // Handle failure
                        current.Status = MyDocumentStatus.ProcessingFailed;
                        current.LastErrorMessage = "External processing failed";
                        graph.Document.Update(current);
                        graph.Actions.PressSave();
                    }
                }
            }
            catch (Exception ex)
            {
                PXTrace.WriteError(ex);
            }
        }
    });
    
    return documents;
}

This approach allows long-running external operations to proceed without affecting user experience.

Case Study: Custom Contract Approval Workflow

To illustrate these principles in action, consider a contract approval workflow I analyzed at a professional services firm. The workflow needed to:

  1. Route contracts through department-specific approval paths
  2. Vary approval requirements based on contract value and risk level
  3. Integrate with an external legal review system
  4. Support conditional routing based on contract terms
  5. Enable escalation for time-sensitive contracts

The implementation used a state machine design with the following states:

  • Draft: Initial contract creation
  • Department Review: First-level departmental approval
  • Legal Review: Integration with external legal system
  • Executive Review: High-value contract review by executives
  • Finance Review: Review of financial terms
  • Approved: Final approved state
  • Executed: Contract signed by all parties
  • Rejected: Contract rejected at any stage

Key technical elements included:

  1. Dynamic approval path determination:

    • Based on contract department, value, and risk score
    • Used a configurable matrix stored in custom tables
    • Recalculated after any material change to the contract
  2. Conditional state transitions:

    • Legal review bypassed for low-risk contracts below certain value
    • Executive review required only for contracts exceeding departmental thresholds
    • Finance review mandatory for all contracts with non-standard payment terms
  3. External system integration:

    • Contracts pushed to legal review system via API
    • Background process monitored for completion
    • Legal feedback integrated back into contract record
  4. Notifications and reminders:

    • Escalating notification schedule for pending approvals
    • Manager notifications after specified idle periods
    • Digest reports of pending actions for each approver

The workflow significantly reduced contract processing time while improving compliance and visibility. Average contract approval time decreased from 12 days to 4 days, with high-risk contracts receiving appropriate scrutiny while routine contracts moved quickly through the process.

Moving Forward with Custom Workflow Development

When implementing custom workflows in Acumatica, follow these best practices:

  1. Map the workflow completely before coding:

    • Document all possible states
    • Define valid transitions between states
    • Identify required validations for each transition
    • Plan notifications and user interactions
  2. Use consistent design patterns:

    • Implement state as enumeration fields
    • Encapsulate transition logic in action handlers
    • Separate business rules from UI code
    • Use service classes for external integrations
  3. Design for maintainability:

    • Document validation rules and approval formulas
    • Create configuration screens for adjustable parameters
    • Implement comprehensive logging for troubleshooting
    • Design for extensibility as requirements evolve
  4. Focus on user experience:

    • Provide clear visual indicators of document status
    • Enable only valid actions at each workflow stage
    • Deliver timely notifications to keep processes moving
    • Provide visibility into the complete approval chain

Custom workflows represent a significant investment, but when properly implemented, they transform business processes and provide substantial returns through improved efficiency, compliance, and visibility.

Have you implemented custom workflows in your Acumatica environment? What challenges did you encounter? Connect with me on LinkedIn to continue the conversation.