Timeclock Integration - Asure API

Thank you!!! I am going to give this a shot. Have a great weekend!

Is this happy pattern documented anywhere? I briefly skimmed the Tech Refs and didn’t see one dedicated to integrating a third-party API. I need to learn the basics of how to integrate this API with Epicor.

No, I would love for Epicor to have some kind of platform engineering model that would promote things like the Backend For Frontend (BFF), user dev/ops, etc. I’m certain there are people on the inside who want this too, but I imagine it’s a big cultural shift for a product with this much longevity. :person_shrugging:

I am trying to find anything that can help. Is there a functions tech ref guide? I just spent ten minutes searching epicweb and coming up empty. Looking for any information that documents how to use functions, or third party API integration.

For example, once I have a function published, what is the easiest way to test it? I have to pass in an EmpID and a Date value to my function. If it works, it should show a messagebox with the hours information for the date I entered. I should be able to make labor edits if I can get the messagebox showing the correct values.

I am testing my published function as shown below. I am testing it using a UBAQ BPM that just fires the function. When I pass in known values for empid and date, I get an error: Unexpected character encountered while parsing value: <. Path ‘’, line 0, position 0.
I think this might be due to the way the date is passed in. My API wants YYYY-MM-DD, and epicor has a DateTime variable. Am I on the right track her?

var client = new RestClient(URL);
client.Timeout = -1;
var request = new RestRequest("/webapi/employees/" + empid + "/punches/" + datereq, Method.GET);
request.AddHeader("Authorization", "Basic MYAUTHSTRING");
IRestResponse myResponse = client.Execute(request);


// Assuming responseData is a JSON string received from the API response
string responseData = myResponse.Content;

// Parse the JSON response
JArray responseDataArray = JArray.Parse(responseData);

// Check if responseData is not null and it's an array
if (responseDataArray != null && responseDataArray.Count > 0)
{
    // Initialize a dictionary to store the sum of hours per report date
    Dictionary<string, double> sumOfHoursPerDate = new Dictionary<string, double>();

    // Specify the date you want to filter for
    string desiredDate = datereq.ToString();

    // Iterate through each object in the responseData array
    foreach (JObject data in responseDataArray)
    {
        // Check if data has PunchSets property and it's an array
        if (data["PunchSets"] is JArray punchSets)
        {
            // Iterate through PunchSets array
            foreach (JObject punchHours in punchSets)
            {
                // Extract report date and hours
                string reportDate = ((string)punchHours["ReportDate"]).Split('T')[0]; // Assuming ReportDate is in ISO 8601 format
                double hours = (double)punchHours["Hours"];

                // Check if the report date matches the desired date
                if (reportDate == desiredDate)
                {
                    // If report date already exists in the sumOfHoursPerDate dictionary, add hours to existing sum
                    if (sumOfHoursPerDate.ContainsKey(reportDate))
                    {
                        sumOfHoursPerDate[reportDate] += hours;
                    }
                    else // Otherwise, create a new entry for the report date
                    {
                        sumOfHoursPerDate[reportDate] = hours;
                    }
                }
            }
        }
    }

    // Log the sum of hours for the desired date
    if (sumOfHoursPerDate.ContainsKey(desiredDate))
    {
        Console.WriteLine("Sum of hours for the desired date (" + desiredDate + "): " + sumOfHoursPerDate[desiredDate]);
        MyHours = (decimal)sumOfHoursPerDate[desiredDate];
    }
    else
    {
        Console.WriteLine("No data found for the desired date: " + desiredDate);
        MyHours = 0;
    }
}
else
{
    Console.WriteLine("Response data is null or empty.");
}

post a sample of the response

Do I need to use the above or can I stick with the basic auth code from postman? If I need to use the above, how do I integrate it with what I already have?

I changed my custom code in the functionso that I can rule out some of the parsing code:

DateTime dateTimeValue = DateTime.ParseExact(datereq.ToString(), "M/d/yyyy h:mm:ss tt", null);
string formattedDate = dateTimeValue.ToString("yyyy-MM-dd");
var client = new RestClient(URL);
client.Timeout = -1;
myRequest = "/webapi/employees/" + empid + "/punches/" + formattedDate;
var request = new RestRequest(myRequest, Method.GET);
request.AddHeader("Authorization", "Basic MYBASICAUTHSTRING");
IRestResponse myIResponse = client.Execute(request);

// Assuming responseData is a JSON string received from the API response
string responseData = myIResponse.Content;
myResponse = responseData; //this gets spit out in a messagebox as shown below.

This returns a fail to get resource:

Response: <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=iso-8859-1"/>
<title>500 - Internal server error.</title>
<style type="text/css">
<!--
body{margin:0;font-size:.7em;font-family:Verdana, Arial, Helvetica, sans-serif;background:#EEEEEE;}
fieldset{padding:0 15px 10px 15px;} 
h1{font-size:2.4em;margin:0;color:#FFF;}
h2{font-size:1.7em;margin:0;color:#CC0000;} 
h3{font-size:1.2em;margin:10px 0 0 0;color:#000000;} 
#header{width:96%;margin:0 0 0 0;padding:6px 2% 6px 2%;font-family:"trebuchet MS", Verdana, sans-serif;color:#FFF;
background-color:#555555;}
#content{margin:0 0 0 2%;position:relative;}
.content-container{background:#FFF;width:96%;margin-top:8px;padding:10px;position:relative;}
-->
</style>
</head>
<body>
<div id="header"><h1>Server Error</h1></div>
<div id="content">
 <div class="content-container"><fieldset>
  <h2>500 - Internal server error.</h2>
  <h3>There is a problem with the resource you are looking for, and it cannot be displayed.</h3>
 </fieldset></div>
</div>
</body>
</html>

I am not sure why I get this. I can still connect in postman and return the correct data.

Using the function or the string from Postman is the same thing.

For the error, add a ToString() there:

string responseData = myIResponse.Content.ToString();

Helped him privately so I could get a look at it.

//Just me mocking your inputs
DateTime datereq = DateTime.ParseExact("2024-03-12", "yyyy-MM-dd", null);
string URL = "<REDACTED>";
string myRequest = "";
string empid = "<REDACTED>";
string myResponse = "";
decimal MyHours = 0;
string user = "<REDACTED>";
string pass = "<REDACTED>";

Func<string, string, string> GetBasicAuthHeader = (username, password) =>
{
return "Basic " + Convert.ToBase64String(System.Text.UTF8Encoding.UTF8.GetBytes($"{username}:{password}"));
};






DateTime dateTimeValue = DateTime.ParseExact(datereq.ToString(), "M/d/yyyy h:mm:ss tt", null);
string formattedDate = dateTimeValue.ToString("yyyy-MM-dd");
var client = new RestClient(URL);
client.Timeout = -1;
myRequest = "webapi/employees/" + empid + "/punch/" + formattedDate;

output2 = myRequest;

var request = new RestRequest(myRequest, Method.GET);

//request.AddHeader("Authorization", "Basic <REDACTED>");
request.AddHeader("Authorization", GetBasicAuthHeader(user, pass));




IRestResponse myIResponse = client.Execute(request);

// Assuming responseData is a JSON string received from the API response
string responseData = myIResponse.Content;
myResponse = responseData;


//Me getting data out
output = myResponse;

//Didn't check from here, but you are now getting data

@NateS , we can continue discussion here again.

Now that you are running the function in swagger, I’d add some extra output variables, and some try / catches to populate them on errors. Also you could use an output variable to populates some debug data throughout the code as well.

Like a progress report lol.

try
{
   //Function code goes here

   extraOutputVariable1 += "Message1" + Environment.Newline;

//more code etc

   extraOutputVariable1 += "Message2" + Environment.Newline;

//and so on...
}
catch (Exception wth)
{
   extraOutputVariable2 = wth.Message;
}
1 Like

Thank you so much @klincecum! With some syntax issues out of the way, I think I have a working function!

This function takes the Employee ID and Date as inputs, and outputs the hours from the timeclock for that employee and date.

To sum up the steps:

  1. Create an API in Epicor so that you can test the function in Swagger (REST API Help in my bookmarks). This is only necessary for testing and can be disabled later. You also need to set the access scope when you create this API key. I called my API Key AsureAPI, and I called the Access Scope AsureAPIAS. I know, I am great at naming. The Access Scope has all the libraries (functions) I am creating for this Asure integration. I am not sure if I need them all in there, but just to play it safe I added all four of the functions that were suggested earlier in this post. In retrospect, I will probably do all of it in one function, so I will adjust this scope later on. The Scope also contains access to the BAQ that I am using to test the function outside of swagger. As you add more functions to your library, or make new libraries, comes back to this access scope to add them.

  2. Create a new Library for a Function. Go to Functions Maintenance, add a new library. Check off Custom Code Widgets and Custom Code Functions. In this case I am not reading or writing to Epicor, so I left DB access = none. Add you references. I added the assemblies: Newtonsoft.Json, RestSharp.Json, RestSharp.Serializers.Newtonsoft.Json, and System.Text.Json. I don’t know which of these I actually need, but it is working with these in place. I did not add any table, service, or library references. In Security I added our company to the authorized company list.

  3. Add a new function. Mine was a widget function with code. Add in the input and output parameters for the function. In my case, I am inputting empid and date. I am outputting the hours from the timeclock for that date, and an extra variable for debugging. Now go design your function!

  4. Create the function in a custom code block. This code was partially created from Postman Code snippets, and Test code*.

*The test code generated in Postman was in javascript. In postman, I was able to use a “Test” to parse out the hours. I used very similar code in my final working version. Chat GPT converted from javascript to C#, and @klincecum helped clean up some dumb syntax issues.

  1. continued… Depending on the API there could be different forms of authorization. Apparently, there is a more secure way than the Basic Auth I used. I will get there by the end of this thread. I entered the Asure API user and password in Postman, that is how I got the basic auth value that I redacted in the code below. Don’t confuse the epicor API you use for testing the function, with the third-party API that you are calling from Epicor. This is what I ended up with:
try
{
    DateTime dateTimeValue = DateTime.ParseExact(datereq.ToString(), "M/d/yyyy h:mm:ss tt", null);
//    output = dateTimeValue.ToString();
    var client = new RestClient(URL);
    //client.Timeout = -1;
    string formattedDate = dateTimeValue.ToString("yyyy-MM-dd");
    myRequest = "webapi/employees/" + empid + "/punch/" + formattedDate;
//    output2 = myRequest;
    var request = new RestRequest(myRequest, Method.GET);
    request.AddHeader("Authorization", "Basic MYAUTHSTRING");
    IRestResponse myIResponse = client.Execute(request);
    
    // Assuming responseData is a JSON string received from the API response
    string responseData = myIResponse.Content;
    myResponse = responseData;    
//    output3 = myResponse;

// Parse the JSON response
var responseDataArray = JArray.Parse(myIResponse.Content);

// Check if responseData is not null and it's an array
if (responseDataArray != null && responseDataArray.Count > 0)
{
    // Initialize a dictionary to store the sum of hours per report date
    Dictionary<string, double> sumOfHoursPerDate = new Dictionary<string, double>();

    // Specify the date you want to filter for
    string desiredDate = formattedDate;

    // Iterate through each object in the responseData array
    foreach (JObject data in responseDataArray)
    {
        // Check if data has PunchSets property and it's an array
        if (data["PunchSets"] is JArray punchSets)
        {
            // Iterate through PunchSets array
            foreach (JObject punchHours in punchSets)
            {
                // Extract report date and hours
                string reportDate = DateTime.Parse(((string)punchHours["ReportDate"]).Split('T')[0]).ToString("yyyy-MM-dd"); // Assuming ReportDate is in ISO 8601 format
                
                double hours = (double)punchHours["Hours"];

                // Check if the report date matches the desired date
                if (reportDate == desiredDate)
                {
                    // If report date already exists in the sumOfHoursPerDate dictionary, add hours to existing sum
                    if (sumOfHoursPerDate.ContainsKey(reportDate))
                    {
                        sumOfHoursPerDate[reportDate] += hours;
                    }
                    else // Otherwise, create a new entry for the report date
                    {
                        sumOfHoursPerDate[reportDate] = hours;
                    }
                }
            }
        }
    }

    // Log the sum of hours for the desired date
    if (sumOfHoursPerDate.ContainsKey(desiredDate))
    {
        Console.WriteLine("Sum of hours for the desired date (" + desiredDate + "): " + sumOfHoursPerDate[desiredDate]);
        TCHours = (decimal)sumOfHoursPerDate[desiredDate];
    }
    else
    {
        Console.WriteLine("No data found for the desired date: " + desiredDate);
        TCHours =0;
    }
    
}
else
{
    Console.WriteLine("Response data is null or empty.");
}

}
catch (Exception wtf)
{
    DebugMsg += wtf.Message;
}
  1. continued… After this custom code block, I added a show message widget that displays the input and output parameters. In the code you can see the commented out “output”’ variables that I had used while debugging. These were output parameters, but once I got it working, I removed them.

  2. Save and deploy the function. Actions > Promote Library to Production. Create a UBAQ to test the function. I set the UBAQ to just pull the top record from LaborDtl, then perform a custom action that just triggers the function widget. With Advanced BPM Updates, use the BPM Directives Configuration to create a new Custom Action. In the base processing, pass in values you want for emp ID and date into the function widget. Testing the BAQ should show you the messagebox from the function. Or it will show an error. In my case it was almost always related to the format of the date. Testing in a BAQ is not ideal, so try out Swagger. It comes with Epicor. My link is: https://redacted.epicorsaas.com/redacted/apps/resthelp/#/home

  3. Setup swagger to test your function. Open Rest Help API in your browser for the environment that you are working on. Pilot, in my case. Open the Epicor Functions form and look for the library that you created that contains the function. If you forgot to deploy your function, you won’t see it here!

  4. Click “Post”, then “Try it Out” to see the API Key and input parameters. Copy and paste your API key from Epicor. Type in the input parameters into the quotes. Click “Execute” and review the response below. In my case, a successful response is just showing the hours from the timeclock for the user and date.

  5. Inevitably this won’t work the first time. Repeat testing with swagger until it works. My process was to deploy the function, go to swagger and click “Execute”. Review the response, then go back to demote the function and design it again. Adding in extra output parameters helped me narrow down the issues. In the end it was due to the format of the input date. In my case, I can use the BAQ to test my function and I am passing the date parameter as: BpmFunc.Date(“2024/03/12”)

My next steps will be to funnel the time clock hours value into a labor detail. Then I need to automate this to occur on a scheduled basis for some employees (or maybe a trigger?) I may also create a function to pull the empID key from Asure based on the epicor emp ID so I don’t have to save the key in epicor.

Thank you all for your help! This isn’t over yet. I will post back as I get farther along. Does anyone see any issue with what I have so far?

Just as a quick aside… I accidentally saved my first function as “function1” instead of giving it a meaningful name. Do I have to completely rebuild the function to rename it? Would it be a good “Idea” to allow function renaming if it is not deployed?

EDIT Scratch that. I just found Actions > Copy Function.

I guess it depends on the situation, but I would like that option.

In the meantime, besides using copy function, you can export the library as an “efxj” file, which is json.

Open in a text editor, use the replace feature, and re-import.

1 Like

The way that JSON data and APIs work is a little convoluted. For example, for this timeclock integration I started at pulling individual employee records, and then realized I need a department level total. I found the API call to pull out the department level hours. But it includes a lot of extra data. After testing the function in REST API Help, I was able to implement the function in a custom code widget in my sample UBAQ. I wanted to loop my new function for each department. I pulled the department keys and names from the API, then hard-coded them into the UBAQ function caller:

 Dictionary<string, string> departmentKeys = new Dictionary<string, string>
            {
                {"Quality Control", "791866ff-f69d-4217-b744-3270a06ce846"},
                {"Sheet Metal", "9736c126-e903-42fc-b943-6518b62bbec3"},
                {"Shipping", "5edc6acb-176a-4f8a-b7b9-6974cb8e9eb4"},
                {"Horizontal", "8157cce9-133e-4f0e-917c-b17857e92512"},
                {"Burr Bench", "00916604-403f-433a-84eb-c9c9b93dbd76"},
                {"Maintenance", "8d94bf71-6734-4990-96c7-da4f41cea9d9"},
                {"N.C. Mill", "ffdd73c8-e840-4641-9b9c-e49901c65b33"},
                {"Drivers", "7e3000a3-6315-4527-8fab-efad773c0ae5"},
                {"Lathe Department", "04e6a140-12f1-4a92-aab5-f625737a5cc2"},
                {"Bridgeport", "9c230560-724e-40e1-8ebd-fa970b9eb00d"}
            };

            foreach (var department in departmentKeys)
            {
                deptName = department.Key;
                deptKey = department.Value;
                var response = this.InvokeFunction("AsureJson","GetDeptHrs", deptKey, myPullDate, deptName);
                DebugMsg += response[0].ToString();
                deptHrs = (decimal)response[0];
                
            }

Then inside my function I have another custom code widget:

try
{
    // Base URL for the API
    var URL = "REDACTED";

    // Initialize RestClient
    var client = new RestClient(URL);

    // Parse myDate to match the expected format for the API call
    if (!DateTime.TryParseExact(myDate.ToString(), "M/d/yyyy h:mm:ss tt", null, System.Globalization.DateTimeStyles.None, out DateTime dateTimeValue))
    {
        // Debug message for invalid date format
        myDebugMsg += "[ERROR] Invalid date format";
        return; // Exit the try block as date parsing failed
    }
    string formattedDate = dateTimeValue.ToString("yyyy-MM-dd");

    // Debug message for formatted date
    myDebugMsg += "Formatted Date: " + formattedDate + " | ";

    // Construct the API request URL
    var myRequest = "webapi/timecards/level/" + deptKey + "/payrolldata/" + formattedDate;

    // Create a RestRequest with the constructed URL and GET method
    var request = new RestRequest(myRequest, Method.GET);

    // Add authorization header
    request.AddHeader("Authorization", "Basic REDACTED");

    // Execute the request
    IRestResponse myIResponse = client.Execute(request);

    // Check if request was successful
    if (myIResponse.StatusCode != System.Net.HttpStatusCode.OK)
    {
        // Debug message for failed API request
        myDebugMsg += "[ERROR] API request failed: " + myIResponse.ErrorMessage + " | ";
        return; // Exit the try block as API request failed
    }

    // Extract response content
    string responseData = myIResponse.Content;

    // Debug message for successful API response
    myDebugMsg += "API Response: " + responseData + " | ";

    // Parse the response data into a JSON array
    JArray jsonData;
    try
    {
        jsonData = JArray.Parse(responseData);
    }
    catch (Newtonsoft.Json.JsonReaderException ex)
    {
        // Debug message for JSON parsing error
        myDebugMsg += "[ERROR] JSON parsing error: " + ex.Message + " | ";
        return; // Exit the try block as JSON parsing failed
    }

    // Calculate the total hours for selected PayTypeIDs
    int totalMinutes = 0;

    List<string> excludedPayTypeIDs = new List<string> { "JURY", "HOL", "BER", "SICK/VACA" };

    foreach (JObject record in jsonData)
    {
        // Extract report date and hours
        string reportDate = DateTime.Parse(((string)record["ReportDate"]).Split('T')[0]).ToString("yyyy-MM-dd");
        if (reportDate == formattedDate)
        {
            string payTypeId = record["PayTypeId"].ToString();
            int minutes = Convert.ToInt32(record["Minutes"]);

            if (!excludedPayTypeIDs.Contains(payTypeId))
            {
                totalMinutes += minutes;
            }
            else
            {
                totalMinutes -= minutes;
            }
        }
    }

    // Calculate department hours
    deptHrs = ((decimal)totalMinutes / 60);
}
catch (Exception ex)
{
    // Debug message for other exceptions
    myDebugMsg += "[ERROR] Exception: " + ex.Message + " | ";
}

The last thing the function does is show a messagebox with the department name, and hours. The UBAQ loop ensures that every department generates a call, and a messagebox. This is pretty cool and opens up a lot of options. This is the first time I have used functions in Epicor without ripping my hair out!

I modified the above a little bit to include the Epicor Department Code (JCDept.JCDept) in the name. For example, “9:QualityControl”…

To tie this all up I want to pull my timeclock data into another BAQ that calculates the productivity at the department level. I already have a BAQ that returns just this data. The trouble with my approach is that my data is output to a messagebox.

Instead of showing a message box, how can I include the results in my current productivity BAQ? I think I need a calculated field that points to the function calling the API. However, my current implementation puts all the departments in the testing UBAQ as a hard coded dictionary. This is fine for reference, but I don’t know how to replicate that inside a calculated field. :thinking:

I have the function parsing the JCDept code from the name and holding it for future use like this:

 // Split the input data based on ":"
            string[] parts = deptName.Split(':');
    
            // Ensure that there are exactly two parts
            if (parts.Length != 2)
            {
                // Debug message for JSON parsing error
                myDebugMsg += "Invalid input format parsing dept code from name.";
                return;
            }
    
            // Extract department code and name
            deptCode = parts[0];

Is there any way to call a function that I created in Epicor, from a BAQ calculated field? I haven’t found any syntax for this. I would like to do this:

AsureJson.GetDeptHrs(‘04e6a140-12f1-4a92-aab5-f625737a5cc2’,‘2024-03-18’,‘2:Lathe Department’)

Yes, but it will have to be a UBAQ.

You would add a Post-Processing BPM on the GetList for the BAQ, and populate the field there.

I see where you are going with this. I think I can make this work with some syntax help.
Normally, I would get at the BAQ results from the RunCustomAction, where I would use:

var ttResults_xRow = (from ttResults_Row in queryResultDataset.Results select ttResults_Row).FirstOrDefault();

However, the queryResultDataset ‘does not exist in the current context.’ So, I took a look at the results inside an expression editor. I see this:

I tried replacing queryResultsDataset.Results with just result, and with resultResultsRow. I think I just have the syntax off for this part of the call. Can you see what I have wrong?

Yep, but not at the office yet.

Running Late In A Hurry GIF by JC Property Professionals