Calculated fields

The form question set endpoint will sometimes return questions as calculated fields. These questions represent form worksheet fields that require the employee to calculate a value using values from other questions. Calculated fields are returned to help provide a complete context of the worksheet that the employee is completing.

Calculated fields, if displayed, should be disabled to prevent users from setting their value, instead the value should be derived using the calculation provided in the calculation object. Calculated questions are not required when submitting question responses to the (fillpdf POST) endpoint and will be ignored if provided. Current clients can log into Symmetry's Client Support Center to view a demo of this element in the API.

Below is an example of a calculated field from the form question set endpoint.

{
    "id": "worksheetB_line8",
    "questionText": "Line 8. Divide the amount on line 7 by $1,000, round any fraction to the nearest whole number enter this number on line 1b of the DE 4. Complete Worksheet C, if needed, otherwise stop here",
    "htmlType": "INPUT",
    "displayType": "DOLLAR",
    "required": {
        "whenRequired": "NEVER"
    },
    "calculation": {
        "formula": "worksheetB_line7 / 1000",
        "roundingMode": "HALF_UP",
        "scale": 0
    },
    "isCalculated": true
}

All questions returned from SPF API will contain a boolean flag, isCalculated, that will have a value of true if that question is a calculated field. These questions will not provide a validationRegexnorvalidationErrorMessage, and instead returning a calculation object.

The calculation represents a formula, scaling, and rounding mode to calculate the value for a calculated field. Calculation is only returned if isCalculated is true and will always contain a formula and a scale, with a rounding mode returned when required by the particular form. The formula will contain the question IDs, for which, the question's value should be substituted to process the calculation. Please note, the formula can contain values from a previous answer. If you choose to implement calculated fields, as you collect answers to questions, it will be necessary to compare the previously answered question ID to see if it is a value included in the calculation formula.

📘

Supported rounding modes

SPF API supports 3 different rounding modes:

CEILING: Rounding should be performed to the next largest value, for example rounding up.
Ex: 1.5 -> 2

FLOOR: Rounding should be performed to the next smallest value, for example rounding down.
Ex: 1.5 -> 1

HALF_UP: Rounding should be performed to the nearest value with ties rounding up.
Ex:
1.2 -> 1
1.7 -> 2
1.5 -> 2

The API uses the Java definition of HALF_UP rounding: Rounding mode to round towards "nearest neighbor" unless both neighbors are equidistant, in which case round up. Behaves as for RoundingMode.UP if the discarded fraction is >= 0.5; otherwise, behaves as for RoundingMode.DOWN.

Note that it is possible to receive negative numbers, but it will likely depend on the other variables in the expression. Many forms do not allow for negative values on fields, in which there is an expected default value of 0, which would be included in the expression. There maybe some forms that do allow negative values.

Ex:
-2.5 -> -3
-2.3 -> -2
-2.7 -> -3


Processing a calculation

The process for resolving the value of a calculated field involves substituting the question IDs in the formula with the associated values that the user has provided and evaluating the resulting equation.

📘

Formula symbols

In addition to question IDs and numerical values, SPF API supports the following symbols in calculation formulas:

Symbol tokenDescription
(Open parenthesis
)Close parenthesis
+Addition
-Subtraction
*Multiplication
/Division
?Ternary condition operator
:Ternary expression operator
<Less than
>Greater than
<=Less than or equal
==Equals
>=Greater than or equal
!=Not equals
|Logical OR
||Short-circuiting logical OR
&Logical AND
&&Short-circuiting logical AND
nullNull, None, Nil, NaN, etc.

📘

Question values

Question values in a calculation can be null, numeric, or boolean.


Full example

/*
 *  Converts the calculation object of a given question into an anonymous function
 *  that can be run to return the result.
 *
 *  Calculation object from API:
 *  {
 *    "formula": "( field1 > field2 ) ? field1 - field2 : 0",
 *    "scale": 1,
 *    "roundingMode": "FLOOR"
 *  }
 *
 *  Converted to anonymous function:
 *  function() {
 *      var result = ( 4.5 > 2.0 ) ? 4.5 - 2.0 : 0;
 *      if (isNaN(result) || !isFinite(result)) { result = 0; }
 *      result = Math.floor(result * 10) / 10;
 *      return result;
 *  }
 */
export const processCalculation = (calculationObj, allAnswersForForm) => {
    let processedFormula = "";

    // split formula into individual tokens and get question IDs
    const questionIds = getQuestionIds(calculationObj.formula.split(" "));

    // create variables for question IDs
    questionIds.forEach(questionId => {
        const value = allAnswersForForm[questionId] === undefined 
            ? "null" : (isValueBoolean(allAnswersForForm[questionId]) 
                ? "(String('" + allAnswersForForm[questionId] + "').toLowerCase() === 'true')" : "Number('" + allAnswersForForm[questionId] + "'.split(',').join(''))");
      
        processedFormula += "var " + questionId +  " = " + value + "; ";
    });

    // add calculation formula
    processedFormula += "var result = " + calculationObj.formula + "; ";

    // make sure result is a finite number, otherwise set to 0
    processedFormula += "if (isNaN(result) || !isFinite(result)) { result = 0; } ";

    // process rounding mode and/or scale if necessary
    if (calculationObj.hasOwnProperty("scale") || calculationObj.hasOwnProperty("roundingMode")) {
        processedFormula += handleRoundingAndScale(calculationObj.roundingMode, calculationObj.scale);
    }

    // return result of formula from anonymous function
    processedFormula += "return result;";

    // create and call anonymous function to process, returning result
    const calculationFunction = new Function(processedFormula);
    return calculationFunction();
}

const handleRoundingAndScale = (roundingMode, scale) => {
    if (scale === undefined || isNaN(parseInt(scale)) || parseInt(scale) < 0) {
        scale = 0;
    }
    else {
        scale = parseInt(scale);
    }

    // scale formula: round(value * (10 ^ scale)) / (10 ^ scale)
    const scalingFactor = Math.pow(10, scale);
    let processedFormulaPart = "result = ";
    if (roundingMode !== undefined) {
        if (roundingMode === "CEILING") {
            processedFormulaPart += scale > 0 ? ("Math.ceil(result * " + scalingFactor + ") / " + scalingFactor + "; ") : "Math.ceil(result); ";
        }
        else if (roundingMode === "FLOOR") {
            processedFormulaPart += scale > 0 ? ("Math.floor(result * " + scalingFactor + ") / " + scalingFactor + "; ") : "Math.floor(result); ";
        }
        else {
            processedFormulaPart += scale > 0 ? ("Math.round(result * " + scalingFactor + ") / " + scalingFactor + "; ") : "Math.round(result); ";
        }

        if (scale === 0) {
            // drop any remaining fraction if scale is 0
            processedFormulaPart += "Math.trunc(result); ";
        }
    }
    else {
        // no rounding needed, just set scale
        processedFormulaPart += scale > 0 ? ("Math.floor(result * " + scalingFactor + ") / " + scalingFactor + "; ") : "Math.trunc(result); ";
    }

    return processedFormulaPart;
}

const isSymbol = (token) => {
    switch (token) {
        case "(":    // open parenthesis
        case ")":    // close parenthesis
        case "+":    // addition
        case "-":    // subtraction
        case "*":    // multiplication
        case "/":    // division
        case "?":    // ternary condition operator
        case ":":    // ternary expression operator
        case "<":    // less than
        case ">":    // greater than
        case "<=":   // less than or equal
        case ">=":   // greater than or equal
        case "==":   // equals
        case "!=":   // not equals
        case "|":    // logical OR
        case "||":   // short-circuiting logical OR
        case "&":    // logical AND
        case "&&":   // short-circuiting logical AND
        case "null": // null, none, NaN, etc
            return true;
        default:
            return false;
    }
}

const isValueBoolean = (value) => {
    switch (value) {
        case "true":
        case "false":
            return true;
        default:
            return false;
    }
}

const getQuestionIds = (tokens) => {
    const questionIds = [];
    if (tokens) {
        tokens.forEach((token) => {
            // check if token is a symbol or a digit
            if (!isSymbol(token) && isNaN(token)) {
                questionIds.push(token);
            }
        });
    }

    // remove duplicates and return
    return [ ...new Set(questionIds) ];
}


Jump to top