Business Central Custom Teams Adaptive CardπŸ’³

BC is awash with Microsoft Teams touch points. My contribution to the sub topic is to furnish users with a card of financial data. This is a central way to distribute the information and provides an option outside of a regular report view. Here is an example of the end result:

Thankfully the standard BC API comes with an endpoint which is highly suitable – agedaccountsreceivable_get. There are a few others in the BC standard API list which fit the bill too: cashFlowStatement, agedAccountsPayable, trialBalance etc.

The handy thing about the agedAccountsReceivable endpoint is that it comes with a “Total” section πŸ‘ check out the example payload:

"value": [
        {
            "@odata.etag": "W/\"JzE5OzkxNDA2OTk1MDYwNDE0ODYxNzMxOzAwOyc=\"",
            "customerId": "00000000-0000-0000-0000-000000000000",
            "customerNumber": "",
            "name": "Total",
            "currencyCode": "",
            "balanceDue": 44005.48,
            "currentAmount": 0,
            "period1Amount": 1236.6,
            "period2Amount": 15109.44,
            "period3Amount": 27659.44,
            "agedAsOfDate": "2023-05-24",
            "periodLengthFilter": "30D"
        }
    ]

Things look decent. However, a minor drawback of the standard API is that it breaks the data up differently to the standard BC report you get in the UI

The report has 5 buckets in total, whereas the API uses 4 buckets (ageing bands). The totals are the exact same it is just how the values are split. The API uses period3Amount as anything 61 days and older. The report has the ability to display the data in a few other ways. Not a big draw back but something to consider. I will explain how to get around this after we have the standard version firing data into Teams πŸ”₯

Microsoft kindly provide a few adaptive card templates. It was in fact this template which caught my eye and gave me the idea for this post: https://github.com/pnp/AdaptiveCards-Templates/tree/main/samples/payslip. If you’re new to the concept merely pick up a template and open up https://adaptivecards.io/designer/ and paste in the example json. I have a specific one for you to use if you want to achieve the same result as me. It is handy to use the designer to verify your final JSON object validates. There is of course a need for various pieces of text to be swapped out for dynamic values from BC data.

{
    "type": "AdaptiveCard",
    "$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
    "version": "1.4",
    "__comment": "Elements used",
    "body": [
        {
            "type": "Container"
        },
        {
            "type": "TextBlock",
            "text": "Aged Debtors Report",
            "wrap": true,
            "horizontalAlignment": "Center",
            "size": "ExtraLarge",
            "fontType": "Default",
            "weight": "Bolder"
        },
        {
            "type": "TextBlock",
            "text": "As of ",
            "wrap": true,
            "horizontalAlignment": "Center",
            "size": "Medium",
            "fontType": "Default"
        },
        {
            "type": "Container",
            "items": [
                {
                    "type": "Image",
                    "url": "https://raw.githubusercontent.com/pnp/AdaptiveCards-Templates/main/samples/payslip/assets/img_spacer.png"
                },
                {
                    "type": "TextBlock",
                    "text": "By Ageing Band",
                    "wrap": true,
                    "weight": "Bolder",
                    "color": "Accent",
                    "size": "Large"
                }
            ],
            "spacing": "ExtraLarge"
        },
        {
            "type": "Container",
            "items": [
                {
                    "type": "TextBlock",
                    "wrap": true,
                    "weight": "Bolder"
                },
                {
                    "type": "ColumnSet",
                    "columns": [
                        {
                            "type": "Column",
                            "width": "stretch",
                            "items": [
                                {
                                    "type": "TextBlock",
                                    "text": "\n\n0 Days\n\n30 Days\n\n60 Days\n\n61+ Days",
                                    "wrap": true,
                                    "spacing": "Small",
                                    "weight": "Bolder"
                                },
                                {
                                    "type": "Container"
                                }
                            ]
                        },
                        {
                            "type": "Column",
                            "width": "stretch",
                            "items": [
                                {
                                    "type": "TextBlock",
                                    "text": "\n\nΒ£\n\nΒ£\n\nΒ£\n\nΒ£",
                                    "wrap": true,
                                    "spacing": "Small",
                                    "horizontalAlignment": "Right",
                                    "weight": "Bolder"
                                }
                            ]
                        }
                    ]
                }
            ],
            "spacing": "Medium"
        },
        {
            "type": "ColumnSet",
            "columns": [
                {
                    "type": "Column",
                    "width": "stretch",
                    "items": [
                        {
                            "type": "TextBlock",
                            "text": "\n\nTOTAL OUTSTANDING",
                            "wrap": true,
                            "weight": "Bolder",
                            "size": "Large",
                            "color": "Accent",
                            "spacing": "ExtraLarge"
                        }
                    ]
                },
                {
                    "type": "Column",
                    "width": "stretch",
                    "items": [
                        {
                            "type": "TextBlock",
                            "text": "\n\nΒ£",
                            "wrap": true,
                            "weight": "Bolder",
                            "size": "Medium",
                            "color": "Accent",
                            "horizontalAlignment": "Right",
                            "spacing": "ExtraLarge"
                        }
                    ]
                }
            ]
        },
        {
            "type": "ColumnSet",
            "columns": [
                {
                    "type": "Column",
                    "width": "stretch",
                    "items": [
                        {
                            "type": "ActionSet",
                            "actions": [
                                {
                                    "type": "Action.OpenUrl",
                                    "title": "πŸ‘† Open in System",
                                    "url": "https://businesscentral.dynamics.com/<tenantID>/Sandbox?report=120"
                                }
                            ]
                        }
                    ]
                }
            ],
            "spacing": "ExtraLarge",
            "separator": true
        }
    ],
    "backgroundImage": {
        "horizontalAlignment": "Center"
    }
}

The above has various sections missing so that you can copy/paste without any validation issues. If you set everything as I have it then grab the JSON template from my github. The below images show what I have added in and the formulas used. Only part which is not covered by an image is the OpenUrl action which requires a tenantID and the database name you want to access. These could be variables in the overall flow if you find that easier:

Want to have 1 extra ageing band bucket? Sadly the codeunit which runs this functionality doesn’t have the necessary aspects for us to alter it by events. Instead I elected to extend the table and copy the API page and codeunit. You can find the code on my github https://github.com/JAng13sea/Blogs/BCTeamsARCard

Final version looks like this πŸ‘ just like the BC report but Teamsified! Yep, that’s a word now πŸ˜…

Business Central and the case of the missing approvals πŸ•΅οΈ

In absence of “water cooler” style chats I’ve found me and colleagues having Teams channel based chats on all sorts of BC related topics. One of those was to do with an instance where approvals go missing 😲. Seems to be if using BC standard workflows and quote documents. What’s the full story here? Well a quote document might not stay like that forever – all being well it will be pushed through the “Make Order” function. Let’s use purchase quotes as an example. The purchase header table stores all the different purchase documents. Once you make a purchase quote into a purchase order, it merely moves it in the purchase header table to a different document type and document no. etc. How does this involve approvals? Well approvals use the RecordId and the quote moving to an order does not alter that. Below images show the original purchase quote approval entry and then how it looks after the “Make Order” function:

Why does this matter? Well in this case it looks like the approval took place at order stage, but what if you don’t do PO approvals. Could be a little confusing, and we don’t like confusing, where possible 😡

The other thing that was noticed is that you can’t rely on archive records to save the day πŸ¦Έβ€β™€οΈ . You just don’t get any access to approvals relating to the original record. Furthermore, if you tried to add a page action how can you access the record because the values are now for the Purchase Order 😱

What can be done to solve this? Well in my colleagues case it was decided that the deletion of the approval entry for the purchase quote could be skipped. In standard BC it recreates the approval entry record for the PO after the quote has been converted, and if an approval exists. I’m not writing this to talk about that solution though…

πŸ“ If that solution is of more interest then checkout codeunit 96 “Purch.-Quote to Order” and this section of code:

ApprovalsMgmt.CopyApprovalEntryQuoteToOrder(RecordId, PurchOrderHeader."No.", PurchOrderHeader.RecordId);

        IsHandled := false;
        OnBeforeDeletePurchQuote(Rec, PurchOrderHeader, IsHandled);
        if not IsHandled then begin
            ApprovalsMgmt.DeleteApprovalEntries(RecordId);
            PurchCommentLine.DeleteComments("Document Type".AsInteger(), "No.");
            DeleteLinks();
            Delete();
            PurchQuoteLine.DeleteAll();
        end;

Naturally, I wanted to know does this circumstance also take place with Power Automate BC approvals! Happy to say that things play out a little differently but still require a solution. The core difference is that the “Make Order” function does not alter the approval entry. Granted, if you’re using Power Automate BC approvals then a different table stores the approval entry values. The behavior works in the users favour though. The below shows the same purchase quote as used in the first example but after it has become an order. The drawback is a user cannot navigate to the record because it has now changed (“Record” page action allows this usually):

Just add a page action on the archive purchase quote pages and job done? Well I certainly thought so. However, navigating from a RecordId causes a few complications. One of the nice things about the standard BC approvals table is that you get the below fields included:

table 454 "Approval Entry"
{
    Caption = 'Approval Entry';
    ReplicateData = true;

    fields
    {
        field(1; "Table ID"; Integer)
        {
            Caption = 'Table ID';
        }
        field(2; "Document Type"; Enum "Approval Document Type")
        {
            Caption = 'Document Type';
        }
        field(3; "Document No."; Code[20])
        {
            Caption = 'Document No.';
        }

Sensible things to have included and additions that the “Workflow Webhook Entry” table (one used for Power Automate BC approvals) could benefit from. That was therefore my starting point. In addition to those fields being added the standard BC function for populating those fields also came in handy as part of a an event subscriber. Yes, the subscriber is handling many more possible records, but that gives you the chance to pick and choose the code for your circumstance:

[EventSubscriber(ObjectType::Table, Database::"Workflow Webhook Entry", 'OnAfterInsertEvent', '', false, false)]
    local procedure AddRecordDetails(var Rec: Record "Workflow Webhook Entry")
    var
        RecRef: RecordRef;
        WFWHEntry: Record "Workflow Webhook Entry";
        Customer: Record Customer;
        GenJournalBatch: Record "Gen. Journal Batch";
        GenJournalLine: Record "Gen. Journal Line";
        PurchaseHeader: Record "Purchase Header";
        SalesHeader: Record "Sales Header";
        IncomingDocument: Record "Incoming Document";
        Vendor: Record Vendor;
        EnumAssignmentMgt: Codeunit "Enum Assignment Management";
    begin
        RecRef.get(Rec."Record ID");
        WFWHEntry := Rec;
        WFWHEntry."Table ID" := RecRef.Number;
        case RecRef.Number of
            DATABASE::"Purchase Header":
                begin
                    RecRef.SetTable(PurchaseHeader);
                    WFWHEntry."Doc. Type" := EnumAssignmentMgt.GetPurchApprovalDocumentType(PurchaseHeader."Document Type");
                    WFWHEntry."No." := PurchaseHeader."No.";
                end;
            DATABASE::"Sales Header":
                begin
                    RecRef.SetTable(SalesHeader);
                    WFWHEntry."Doc. Type" := EnumAssignmentMgt.GetSalesApprovalDocumentType(SalesHeader."Document Type");
                    WFWHEntry."No." := SalesHeader."No.";
                end;
            DATABASE::Customer:
                begin
                    RecRef.SetTable(Customer);
                    WFWHEntry."No." := Customer."No.";
                end;
            DATABASE::"Gen. Journal Batch":
                RecRef.SetTable(GenJournalBatch);
            DATABASE::"Gen. Journal Line":
                begin
                    RecRef.SetTable(GenJournalLine);
                    case GenJournalLine."Document Type" of
                        GenJournalLine."Document Type"::Invoice:
                            WFWHEntry."Doc. Type" := WFWHEntry."Doc. Type"::Invoice;
                        GenJournalLine."Document Type"::"Credit Memo":
                            WFWHEntry."Doc. Type" := WFWHEntry."Doc. Type"::"Credit Memo";
                        else
                            WFWHEntry."Doc. Type" := GenJournalLine."Document Type";
                    end;
                    WFWHEntry."No." := GenJournalLine."Document No.";
                end;
            DATABASE::"Incoming Document":
                begin
                    RecRef.SetTable(IncomingDocument);
                    WFWHEntry."No." := Format(IncomingDocument."Entry No.");
                end;
            DATABASE::Vendor:
                begin
                    RecRef.SetTable(Vendor);
                    WFWHEntry."No." := Vendor."No.";
                end;
        end;
        WFWHEntry.Modify();
    end;
}
A new page action was the final step. This is because I’m handling the scenario from fresh. If I in fact had a populated table there would be a need to add values to the newly created fields. This page action leans on the concept of those fields being populated in order to navigate to a corresponding record. Here is my page action code:
pageextension 50154 "Archived PQ List" extends "Purchase Quote Archives"
{
    actions
    {
        addLast(navigation)
        {
            action("Archived Approvals")
            {
                CaptionML = ENU = 'Archived Approvals';
                ApplicationArea = All;
                Image = Archive;
                Promoted = true;
                PromotedCategory = Process;
                PromotedIsBig = true;
                PromotedOnly = true;
                ToolTipML = ENU = 'Review approvals for this purchase quote';

                trigger OnAction()
                var

                    FlowEntries: Record "Workflow Webhook Entry";
                    ApprovalEntries: Record "Approval Entry";
                begin
                    FlowEntries.setrange("Table ID", DATABASE::"Purchase Header");
                    FlowEntries.setrange("Doc. Type", Rec."Document Type");
                    FlowEntries.setrange("No.", Rec."No.");
                    RunModal(830, FlowEntries);
                end;
            }
        }

        
    }
}

It wasn’t quite the open and shut case I hoped for but I think we can conclude that the case of the missing approval is SOLVED! Case closed πŸ”