Simple Approval Workflow Extension

Very often Rossum's users need a simple approval workflow. At Rossum, we've created a simple serverless function extension in the Rossum extensions environment to allow for the following workflow:

  1. Documents are routed to a first queue where basic data capture is performed
  2. After confirming, the document is routed to a queue representing an approval group
  3. Approval metadata such as time of approval and approving user are added among the fields
  4. Document can be routed to multiple queues if multi-step approval is setup
  5. After the last approval is done, document is routed to exporting queue

🚧

Extensions environment add-on is needed in order to use this setup in production

Please reach out to your Rossum point of contact or contact us at [email protected] in order to get more information about the availability of this feature.

Approval Queues Setup

This simple approval workflow assumes that each queue will be used by users who belong to a specific approval level group. For example, Bob and Alice will open Rossum everyday and they will both see documents in their queue which is called "Approval level 2 (<= 1000$)". Afterwards, each of them will approve some of the documents.

In order to achieve the behavior described above, you should setup the following queues:

  1. Received invoices queue - data capture users extract data in this queue.
  2. Approval level 1 - approval level represents group of users who can approve invoice with maximum amount.
  3. Approval level 2
  4. Approval level 3
  5. Approval level N
  6. Approved invoices - invoices are routed to this queue after the last approval. Exporting to ERP will be done from this queue.

Approval Fields Setup in Schema

When working with approvals, you want to store the timestamp of the approval and the name of the approver. In order to have all such information, you should extend your extraction schema with a new section containing new fields:

  1. Time of approval for the given approval level
  2. Name of the approver for the given approval level
  3. Reject note - if some rejection workflow is needed
  4. Reject document dropdown - enables to reject documents to the previous queue after confirmation if needed.
754754

New approval workflow fields.

As you can see, the new approval fields are one-item enums which currently represents a read-only field in Rossum.

Moreover, for the purpose of this function, make sure you have fields with schema ID "cost_center" and "amount_total". Below you can copy the full schema including all the standard fields and fields used for the approval workflow.

[
  {
    "category": "section",
    "id": "invoice_info_section",
    "label": "Basic Information",
    "hidden": false,
    "children": [
      {
        "rir_field_names": [
          "document_type"
        ],
        "constraints": {
          "required": false
        },
        "default_value": null,
        "category": "datapoint",
        "id": "document_type",
        "label": "Document Type",
        "hidden": true,
        "type": "enum",
        "can_export": false,
        "options": [
          {
            "value": "tax_invoice",
            "label": "Tax Invoice"
          },
          {
            "value": "credit_note",
            "label": "Credit Note"
          },
          {
            "value": "proforma",
            "label": "Pro Forma Invoice"
          },
          {
            "value": "debit_note",
            "label": "Debit Note"
          },
          {
            "value": "receipt",
            "label": "Receipt"
          },
          {
            "value": "other",
            "label": "Other"
          }
        ]
      },
      {
        "rir_field_names": [
          "language"
        ],
        "constraints": {
          "required": false
        },
        "default_value": null,
        "category": "datapoint",
        "id": "language",
        "label": "Document Language",
        "hidden": true,
        "type": "enum",
        "can_export": false,
        "options": [
          {
            "value": "eng",
            "label": "EN"
          },
          {
            "value": "deu",
            "label": "DE"
          },
          {
            "value": "ces",
            "label": "CZ"
          },
          {
            "value": "slk",
            "label": "SK"
          },
          {
            "value": "fra",
            "label": "FR"
          },
          {
            "value": "other",
            "label": "Other"
          }
        ]
      },
      {
        "rir_field_names": [
          "document_id",
          "var_sym"
        ],
        "constraints": {
          "required": false
        },
        "default_value": null,
        "category": "datapoint",
        "id": "document_id",
        "label": "Document ID",
        "type": "string"
      },
      {
        "rir_field_names": [
          "order_id"
        ],
        "constraints": {
          "required": false
        },
        "default_value": null,
        "category": "datapoint",
        "id": "order_id",
        "label": "Order Number",
        "type": "string"
      },
      {
        "rir_field_names": [
          "customer_id"
        ],
        "constraints": {
          "required": false
        },
        "default_value": null,
        "category": "datapoint",
        "id": "customer_id",
        "label": "Customer ID",
        "hidden": true,
        "type": "string",
        "can_export": false
      },
      {
        "rir_field_names": [
          "date_issue"
        ],
        "constraints": {
          "required": false
        },
        "default_value": null,
        "category": "datapoint",
        "id": "date_issue",
        "label": "Issue Date",
        "type": "date",
        "format": "D.M.YYYY"
      },
      {
        "rir_field_names": [
          "date_due"
        ],
        "constraints": {
          "required": false
        },
        "default_value": null,
        "category": "datapoint",
        "id": "date_due",
        "label": "Due Date",
        "type": "date",
        "format": "D.M.YYYY"
      },
      {
        "rir_field_names": [
          "date_uzp"
        ],
        "constraints": {
          "required": false
        },
        "default_value": null,
        "category": "datapoint",
        "id": "date_uzp",
        "label": "Tax Point Date",
        "type": "date",
        "format": "D.M.YYYY"
      },
      {
        "rir_field_names": [
          "sender_order_id"
        ],
        "constraints": {
          "required": false
        },
        "default_value": null,
        "category": "datapoint",
        "id": "sender_order_id",
        "label": "Vendor Order ID",
        "hidden": true,
        "type": "string",
        "can_export": false
      },
      {
        "rir_field_names": [
          "delivery_note_id"
        ],
        "constraints": {
          "required": false
        },
        "default_value": null,
        "category": "datapoint",
        "id": "delivery_note_id",
        "label": "Delivery Note ID",
        "hidden": true,
        "type": "string",
        "can_export": false
      },
      {
        "rir_field_names": [
          "supply_place"
        ],
        "constraints": {
          "required": false
        },
        "default_value": null,
        "category": "datapoint",
        "id": "supply_place",
        "label": "Place of Supply",
        "hidden": true,
        "type": "string",
        "can_export": false
      }
    ],
    "icon": null
  },
  {
    "category": "section",
    "id": "payment_info_section",
    "label": "Payment Instructions",
    "hidden": false,
    "children": [
      {
        "rir_field_names": [
          "account_num"
        ],
        "constraints": {
          "required": false
        },
        "default_value": null,
        "category": "datapoint",
        "id": "account_num",
        "label": "Account Number",
        "hidden": false,
        "type": "string",
        "can_export": true
      },
      {
        "rir_field_names": [
          "bank_num"
        ],
        "constraints": {
          "required": false
        },
        "default_value": null,
        "category": "datapoint",
        "id": "bank_num",
        "label": "Bank Code",
        "hidden": false,
        "type": "string",
        "can_export": true
      },
      {
        "rir_field_names": [
          "iban"
        ],
        "constraints": {
          "required": false
        },
        "default_value": null,
        "category": "datapoint",
        "id": "iban",
        "label": "IBAN",
        "hidden": false,
        "type": "string",
        "can_export": true
      },
      {
        "rir_field_names": [
          "bic"
        ],
        "constraints": {
          "required": false
        },
        "default_value": null,
        "category": "datapoint",
        "id": "bic",
        "label": "BIC/SWIFT",
        "hidden": true,
        "type": "string"
      },
      {
        "rir_field_names": [
          "terms"
        ],
        "constraints": {
          "required": false
        },
        "default_value": null,
        "category": "datapoint",
        "id": "terms",
        "label": "Terms",
        "hidden": true,
        "type": "string",
        "can_export": false
      },
      {
        "rir_field_names": [
          "const_sym"
        ],
        "constraints": {
          "required": false
        },
        "default_value": null,
        "category": "datapoint",
        "id": "const_sym",
        "label": "Constant Symbol",
        "hidden": true,
        "type": "string",
        "can_export": false
      },
      {
        "rir_field_names": [
          "var_sym"
        ],
        "constraints": {
          "required": false
        },
        "default_value": null,
        "category": "datapoint",
        "id": "var_sym",
        "label": "Payment Reference",
        "hidden": true,
        "type": "string"
      },
      {
        "rir_field_names": [
          "spec_sym"
        ],
        "constraints": {
          "required": false
        },
        "default_value": null,
        "category": "datapoint",
        "id": "spec_sym",
        "label": "Specific Symbol",
        "hidden": true,
        "type": "string"
      },
      {
        "rir_field_names": [
          "payment_method"
        ],
        "constraints": {
          "required": false
        },
        "default_value": null,
        "category": "datapoint",
        "id": "payment_method",
        "label": "Payment Method",
        "hidden": true,
        "type": "string",
        "can_export": false
      },
      {
        "rir_field_names": [],
        "constraints": {
          "required": true
        },
        "default_value": null,
        "category": "datapoint",
        "id": "cost_center",
        "label": "Cost center",
        "hidden": false,
        "type": "enum",
        "can_export": true,
        "options": [
          {
            "value": "1",
            "label": "Cost center A"
          },
          {
            "value": "2",
            "label": "Cost center B"
          },
          {
            "value": "3",
            "label": "Cost center C"
          },
          {
            "value": "4",
            "label": "Cost center D"
          }
        ]
      }
    ],
    "icon": null
  },
  {
    "category": "section",
    "id": "amounts_section",
    "label": "VAT & Amounts",
    "hidden": false,
    "children": [
      {
        "rir_field_names": [
          "amount_total"
        ],
        "constraints": {
          "required": false
        },
        "default_value": null,
        "category": "datapoint",
        "id": "amount_total",
        "label": "Total Amount",
        "type": "number",
        "format": "# ##0.#"
      },
      {
        "rir_field_names": [
          "amount_due"
        ],
        "constraints": {
          "required": false
        },
        "default_value": null,
        "category": "datapoint",
        "id": "amount_due",
        "label": "Amount Due",
        "hidden": true,
        "type": "number",
        "can_export": false,
        "format": "# ##0.#"
      },
      {
        "rir_field_names": [
          "amount_total_base"
        ],
        "constraints": {
          "required": false
        },
        "default_value": null,
        "category": "datapoint",
        "id": "amount_total_base",
        "label": "Total Without Tax",
        "type": "number",
        "format": "# ##0.#"
      },
      {
        "rir_field_names": [
          "amount_total_tax"
        ],
        "constraints": {
          "required": false
        },
        "default_value": null,
        "category": "datapoint",
        "id": "amount_total_tax",
        "label": "Total Tax",
        "type": "number",
        "format": "# ##0.#"
      },
      {
        "rir_field_names": [
          "amount_rounding"
        ],
        "constraints": {
          "required": false
        },
        "default_value": null,
        "category": "datapoint",
        "id": "amount_rounding",
        "label": "Amount Rounding",
        "hidden": true,
        "type": "number",
        "can_export": false,
        "format": "# ##0.#"
      },
      {
        "rir_field_names": [
          "amount_paid"
        ],
        "constraints": {
          "required": false
        },
        "default_value": null,
        "category": "datapoint",
        "id": "amount_paid",
        "label": "Amount Paid",
        "hidden": true,
        "type": "number",
        "can_export": false,
        "format": "# ##0.#"
      },
      {
        "rir_field_names": [
          "currency"
        ],
        "constraints": {
          "required": false
        },
        "default_value": null,
        "category": "datapoint",
        "id": "currency",
        "label": "Currency",
        "type": "enum",
        "options": [
          {
            "value": "eur",
            "label": "EUR"
          },
          {
            "value": "usd",
            "label": "USD"
          },
          {
            "value": "gbp",
            "label": "GBP"
          },
          {
            "value": "czk",
            "label": "CZK"
          },
          {
            "value": "nok",
            "label": "NOK"
          },
          {
            "value": "sek",
            "label": "SEK"
          },
          {
            "value": "dkk",
            "label": "DKK"
          },
          {
            "value": "other",
            "label": "Other"
          }
        ]
      },
      {
        "category": "multivalue",
        "id": "tax_details",
        "label": "VAT Rates",
        "hidden": false,
        "children": {
          "category": "tuple",
          "id": "tax_detail",
          "label": "VAT Rates",
          "hidden": false,
          "children": [
            {
              "rir_field_names": [
                "tax_detail_rate"
              ],
              "constraints": {
                "required": false
              },
              "default_value": null,
              "category": "datapoint",
              "id": "tax_detail_rate",
              "label": "VAT Rate",
              "type": "number",
              "format": "# ##0.#"
            },
            {
              "rir_field_names": [
                "tax_detail_base"
              ],
              "constraints": {
                "required": false
              },
              "default_value": null,
              "category": "datapoint",
              "id": "tax_detail_base",
              "label": "VAT Base",
              "type": "number",
              "format": "# ##0.#"
            },
            {
              "rir_field_names": [
                "tax_detail_tax"
              ],
              "constraints": {
                "required": false
              },
              "default_value": null,
              "category": "datapoint",
              "id": "tax_detail_tax",
              "label": "VAT Amount",
              "type": "number",
              "format": "# ##0.#"
            },
            {
              "rir_field_names": [
                "tax_detail_total"
              ],
              "constraints": {
                "required": false
              },
              "default_value": null,
              "category": "datapoint",
              "id": "tax_detail_total",
              "label": "VAT Total",
              "hidden": true,
              "type": "number",
              "can_export": false,
              "format": "# ##0.#"
            },
            {
              "rir_field_names": [
                "tax_detail_code"
              ],
              "constraints": {
                "required": false
              },
              "default_value": null,
              "category": "datapoint",
              "id": "tax_detail_code",
              "label": "VAT Code",
              "hidden": true,
              "type": "string",
              "can_export": false
            }
          ],
          "rir_field_names": []
        },
        "min_occurrences": null,
        "max_occurrences": 4,
        "default_value": null,
        "show_grid_by_default": false,
        "rir_field_names": [
          "tax_details"
        ]
      }
    ],
    "icon": null
  },
  {
    "category": "section",
    "id": "vendor_section",
    "label": "Vendor & Customer",
    "hidden": false,
    "children": [
      {
        "rir_field_names": [
          "sender_name"
        ],
        "constraints": {
          "required": false
        },
        "default_value": null,
        "category": "datapoint",
        "id": "sender_name",
        "label": "Vendor Name",
        "hidden": false,
        "type": "string",
        "can_export": true
      },
      {
        "rir_field_names": [
          "sender_address"
        ],
        "constraints": {
          "required": false
        },
        "default_value": null,
        "category": "datapoint",
        "id": "sender_address",
        "label": "Vendor Address",
        "hidden": true,
        "type": "string",
        "can_export": false
      },
      {
        "rir_field_names": [
          "sender_ic"
        ],
        "constraints": {
          "required": false
        },
        "default_value": null,
        "category": "datapoint",
        "id": "sender_ic",
        "label": "Vendor Company ID",
        "hidden": true,
        "type": "string"
      },
      {
        "rir_field_names": [
          "sender_vat_id",
          "sender_dic"
        ],
        "constraints": {
          "required": false
        },
        "default_value": null,
        "category": "datapoint",
        "id": "sender_vat_id",
        "label": "Vendor VAT Number",
        "type": "string"
      },
      {
        "rir_field_names": [
          "sender_email"
        ],
        "constraints": {
          "required": false
        },
        "default_value": null,
        "category": "datapoint",
        "id": "sender_email",
        "label": "Vendor Email",
        "hidden": true,
        "type": "string",
        "can_export": false
      },
      {
        "rir_field_names": [
          "recipient_name"
        ],
        "constraints": {
          "required": false
        },
        "default_value": null,
        "category": "datapoint",
        "id": "recipient_name",
        "label": "Customer Name",
        "hidden": false,
        "type": "string",
        "can_export": true
      },
      {
        "rir_field_names": [
          "recipient_address"
        ],
        "constraints": {
          "required": false
        },
        "default_value": null,
        "category": "datapoint",
        "id": "recipient_address",
        "label": "Customer Address",
        "hidden": true,
        "type": "string",
        "can_export": false
      },
      {
        "rir_field_names": [
          "recipient_ic"
        ],
        "constraints": {
          "required": false
        },
        "default_value": null,
        "category": "datapoint",
        "id": "recipient_ic",
        "label": "Customer Company ID",
        "hidden": true,
        "type": "string"
      },
      {
        "rir_field_names": [
          "recipient_vat_id",
          "recipient_dic"
        ],
        "constraints": {
          "required": false
        },
        "default_value": null,
        "category": "datapoint",
        "id": "recipient_vat_id",
        "label": "Customer VAT Number",
        "type": "string"
      },
      {
        "rir_field_names": [
          "recipient_delivery_name"
        ],
        "constraints": {
          "required": false
        },
        "default_value": null,
        "category": "datapoint",
        "id": "recipient_delivery_name",
        "label": "Customer Delivery Name",
        "hidden": true,
        "type": "string",
        "can_export": false
      },
      {
        "rir_field_names": [
          "recipient_delivery_address"
        ],
        "constraints": {
          "required": false
        },
        "default_value": null,
        "category": "datapoint",
        "id": "recipient_delivery_address",
        "label": "Customer Delivery Address",
        "hidden": true,
        "type": "string",
        "can_export": false
      }
    ],
    "icon": null
  },
  {
    "category": "section",
    "id": "other_section",
    "label": "Other",
    "hidden": false,
    "children": [
      {
        "rir_field_names": [],
        "constraints": {
          "required": false
        },
        "default_value": null,
        "category": "datapoint",
        "id": "notes",
        "label": "Notes",
        "type": "string"
      }
    ],
    "icon": null
  },
  {
    "category": "section",
    "id": "line_items_section",
    "label": "Line Items",
    "hidden": false,
    "children": [
      {
        "category": "multivalue",
        "id": "line_items",
        "label": "Line Item",
        "hidden": false,
        "children": {
          "category": "tuple",
          "id": "line_item",
          "label": "Line Item",
          "hidden": false,
          "children": [
            {
              "rir_field_names": [
                "table_column_code"
              ],
              "constraints": {
                "required": false
              },
              "default_value": null,
              "category": "datapoint",
              "id": "item_code",
              "label": "Code",
              "hidden": true,
              "type": "string",
              "can_export": false
            },
            {
              "width": 50,
              "rir_field_names": [
                "table_column_description"
              ],
              "constraints": {
                "required": false
              },
              "default_value": null,
              "category": "datapoint",
              "id": "item_description",
              "label": "Description",
              "type": "string"
            },
            {
              "rir_field_names": [
                "table_column_quantity"
              ],
              "constraints": {
                "required": false
              },
              "default_value": null,
              "category": "datapoint",
              "id": "item_quantity",
              "label": "Quantity",
              "type": "number",
              "format": "# ##0.#"
            },
            {
              "rir_field_names": [
                "table_column_uom"
              ],
              "constraints": {
                "required": false
              },
              "default_value": null,
              "category": "datapoint",
              "id": "item_uom",
              "label": "UOM",
              "hidden": true,
              "type": "string",
              "can_export": false
            },
            {
              "rir_field_names": [
                "table_column_amount_base"
              ],
              "constraints": {
                "required": false
              },
              "default_value": null,
              "category": "datapoint",
              "id": "item_amount_base",
              "label": "Unit Price Without VAT",
              "type": "number",
              "format": "# ##0.#"
            },
            {
              "rir_field_names": [
                "table_column_rate"
              ],
              "constraints": {
                "required": false
              },
              "default_value": null,
              "category": "datapoint",
              "id": "item_rate",
              "label": "VAT Rate",
              "hidden": true,
              "type": "number",
              "can_export": false,
              "format": "# ##0.#"
            },
            {
              "rir_field_names": [
                "table_column_tax"
              ],
              "constraints": {
                "required": false
              },
              "default_value": null,
              "category": "datapoint",
              "id": "item_tax",
              "label": "VAT",
              "hidden": true,
              "type": "number",
              "can_export": false,
              "format": "# ##0.#"
            },
            {
              "rir_field_names": [
                "table_column_amount"
              ],
              "constraints": {
                "required": false
              },
              "default_value": null,
              "category": "datapoint",
              "id": "item_amount",
              "label": "Unit Price",
              "hidden": false,
              "type": "number",
              "can_export": true,
              "format": "# ##0.#"
            },
            {
              "width": 15,
              "rir_field_names": [
                "table_column_amount_total_base"
              ],
              "constraints": {
                "required": false
              },
              "default_value": null,
              "category": "datapoint",
              "id": "item_total_base",
              "label": "Total Base",
              "hidden": false,
              "type": "number",
              "can_export": true,
              "format": "# ##0.#",
              "aggregations": {
                "sum": {
                  "label": "Sum"
                }
              }
            },
            {
              "width": 15,
              "rir_field_names": [
                "table_column_amount_total"
              ],
              "constraints": {
                "required": false
              },
              "default_value": null,
              "category": "datapoint",
              "id": "item_amount_total",
              "label": "Total Amount",
              "hidden": false,
              "type": "number",
              "can_export": true,
              "format": "# ##0.#",
              "aggregations": {
                "sum": {
                  "label": "Sum"
                }
              }
            },
            {
              "rir_field_names": [
                "table_column_other"
              ],
              "constraints": {
                "required": false
              },
              "default_value": null,
              "category": "datapoint",
              "id": "item_other",
              "label": "Other",
              "hidden": true,
              "type": "string",
              "can_export": false
            }
          ],
          "rir_field_names": []
        },
        "min_occurrences": null,
        "max_occurrences": null,
        "default_value": null,
        "show_grid_by_default": false,
        "rir_field_names": [
          "line_items"
        ]
      }
    ],
    "icon": null
  },
  {
    "category": "section",
    "id": "approval_workflow",
    "label": "Approval workflow",
    "hidden": false,
    "children": [
      {
        "rir_field_names": [],
        "constraints": {
          "required": false
        },
        "default_value": null,
        "category": "datapoint",
        "id": "first_approval_time",
        "label": "First approval time",
        "type": "enum",
        "options": []
      },
      {
        "rir_field_names": [],
        "constraints": {
          "required": false
        },
        "default_value": null,
        "category": "datapoint",
        "id": "first_approval_user",
        "label": "First approval user",
        "type": "enum",
        "options": []
      },
      {
        "rir_field_names": [],
        "constraints": {
          "required": false
        },
        "default_value": null,
        "category": "datapoint",
        "id": "second_approval_time",
        "label": "Second approval time",
        "type": "enum",
        "options": []
      },
      {
        "rir_field_names": [],
        "constraints": {
          "required": false
        },
        "default_value": null,
        "category": "datapoint",
        "id": "second_approval_user",
        "label": "Second approval user",
        "type": "enum",
        "options": []
      },
      {
        "rir_field_names": [],
        "constraints": {
          "required": false
        },
        "default_value": null,
        "category": "datapoint",
        "id": "third_approval_time",
        "label": "Third approval time",
        "type": "enum",
        "options": []
      },
      {
        "rir_field_names": [],
        "constraints": {
          "required": false
        },
        "default_value": null,
        "category": "datapoint",
        "id": "third_approval_user",
        "label": "Third approval user",
        "type": "enum",
        "options": []
      },
      {
        "rir_field_names": [],
        "constraints": {
          "required": false
        },
        "default_value": null,
        "category": "datapoint",
        "id": "reject_note",
        "label": "Reject note",
        "type": "string"
      },
      {
        "rir_field_names": [],
        "constraints": {
          "required": false
        },
        "default_value": "false",
        "category": "datapoint",
        "id": "reject_enum",
        "label": "Reject document?",
        "type": "enum",
        "options": [
          {
            "value": "true",
            "label": "True"
          },
          {
            "value": "false",
            "label": "False"
          }
        ]
      }
    ],
    "icon": null
  }
]

Simple approval workflow components

The simple approval workflow is built on top of Rossum's custom functions. There are two key functions you will need to create:

  1. Routing function - moves the document to correct approval queue based on captured data
  2. Approval logging function - fills the time and user of the approval once document is confirmed

Routing function

In order to make the switching of the documents from one queue to a different queue work, you will need to:

  1. Create a new custom function, assigned to annotation_status.changed event action
  2. Update the queue_id and routing rules in the routing function configuration that matches your needs and queues. Keep in mind that the id of the routing groups should correspond to prefix of the fields in extraction schema (routing group with ID first_approval corresponds to fields first_approval_user and first_approval_time in the extraction schema)
  3. Update the approvedDocumentsQueueId variable that contains the ID of the queue where documents should be routed if all approvals are passed or if no routing group is found.
  4. Assign the function to all the queues - Received, Approval and even Approved queue
  5. Set the token_owner to some admin in your organization. This auth token will then be passed to the triggered hooks and used for authenticating requests made to the Rossum's API from the hook. Thanks to auth token, there is no need to hardcode user credentials inside the function code and call the login endpoint. See the image below which you can access on the extension detail in the Advanced settings.
13481348

Setting the access to the Rossum's API (setting token owner).

🚧

Routing function needs access to Internet

The routing function needs access to Rossum's public API. Please contact us at [email protected] where we would ask you about your specific use case and then we would enable access of all the custom functions to the Internet.

Below is the routing function code that shows how to implement simple routing logic and switch document to another queue:

const https = require('https');

/* 
  Definition of routing groups where each group should be linked to one queue.
  The rules in routing groups define the parameters of the routing group.
  In this case it means:
    - Specific cost center has to be assigned in order to be routed to the group
    - The group can approve values of up to X amount total.
    
  Moreover, the routing groups should be sorted in ascending order based
  on approval amount.
*/
const routingGroups = [{
    "id": "first_approval",
    "queue_id": 95956,
    "rules": {
      "amount_total": 10000,
      "cost_center": [1, 2, 3, 4, 5, 6] 
    }
  },
  {
    "id": "second_approval",
    "queue_id": 95957,
    "rules": {
      "amount_total": 30000,
      "cost_center": [1, 2, 3, 4, 5, 6]
    }
  },
  {
    "id": "third_approval",
    "queue_id": 95958,
    "rules": {
      "amount_total": Infinity,
      "cost_center": [1, 2, 3, 4, 5, 6]
    }
  }
];

/* The queue where all approved documents should be routed for export */
const approvedDocumentsQueueId = 95959;
const targetStateInApprovedQueue = 'to_review'

/* Function for finding the next routing group. */
const findNextRoutingGroup = (content, routingGroups) => {

  const [costCenterDatapoint] = findBySchemaId(content, 'cost_center');
  const costCenter = costCenterDatapoint.content.value
  const [totalAmountDatapoint] = findBySchemaId(content, 'amount_total');
  const totalAmount = totalAmountDatapoint.content.normalized_value

  const possibleRoutingGroups = []

  for (var i = 0; i < routingGroups.length; i++) {

    const routingGroup = routingGroups[i];

    /* See whether the document already passed the current routing group */
    const [approvalLevel] = findBySchemaId(content, routingGroup['id'] + '_user')

    if ((costCenter && costCenter in routingGroup.rules.cost_center) && (!approvalLevel || approvalLevel.content.value == null || approvalLevel.content.value == "")) {
      possibleRoutingGroups.push(routingGroup);
    }

    /* Stop building the approval chain if the current group has to right for last approval. */
    if (totalAmount && totalAmount <= routingGroup.rules.amount_total) {
      break;
    }

  }

  return possibleRoutingGroups

}

exports.rossum_hook_request_handler = async ({
  action,
  event,
  annotation: {
    id,
    previous_status,
    status,
    queue,
    metadata
  },
  rossum_authorization_token
}) => {
  try {

    const messages = [];
    const operations = [];

    if (event == 'annotation_status' && previous_status == 'exporting' && status == 'exported') {

      /* Annotation data is needed in order to route to the correct group. */
      const annotationData = await getAnnotationData(id, rossum_authorization_token);

      /* Unless a routing group is found, we can make document ready for export in the final queue. */
      let finalQueue = approvedDocumentsQueueId;
      let finalState = 'to_review'

      /* Fetch the queue history from annotation in case the document will be rejected
      and should be routed to the previous routing group.*/
      let previousQueues = metadata.previous_queues ? metadata.previous_queues : [];

      /* Route document to previous queue if it is rejected. */
      const [rejectBoolean] = findBySchemaId(annotationData.content, 'reject_enum');

      if (rejectBoolean && rejectBoolean.content.value == "true") {
        finalQueue = previousQueues.pop()
      } else {
        const nextRoutingGroups = findNextRoutingGroup(annotationData.content, routingGroups)

        if (nextRoutingGroups.length > 0) {
          finalQueue = nextRoutingGroups[0].queue_id;
        }
        else{
          // route to a specific state in the approved queue
          finalState = targetStateInApprovedQueue;
        }

        /* Add the current queue to the history since the document will be routed further. */
        previousQueues.push(queue.split("/").pop())

      }

      /* Patching the annotation to the correct queue + state */
      await switchAnnotationToDifferentQueue(id, finalQueue, finalState, previousQueues, rossum_authorization_token)

    }

    return {
      messages,
      operations
    }

  } catch (e) {
    // In case of exception, create and return error message. This may be useful for debugging.
    const messages = [
      createMessage('error', 'Serverless Function: ' + e.message)
    ];
    return {
      messages,
    };
  }
};


// --- HELPER FUNCTIONS ---

// Return datapoints matching a schema id.
// @param {Object} content - the annotation content tree (see https://api.elis.rossum.ai/docs/#annotation-data)
// @param {string} schemaId - the field's ID as defined in the extraction schema(see https://api.elis.rossum.ai/docs/#document-schema)
// @returns {Array} - the list of datapoints matching the schema ID

const findBySchemaId = (content, schemaId) =>
  content.reduce(
    (results, dp) =>
    dp.schema_id === schemaId ? [...results, dp] :
    dp.children ? [...results, ...findBySchemaId(dp.children, schemaId)] :
    results,
    [],
  );

// Create a message which will be shown to the user
// @param {number} datapointId - the id of the datapoint where the message will appear (null for "global" messages).
// @param {String} messageType - the type of the message, any of {info|warning|error}. Errors prevent confirmation in the UI.
// @param {String} messageContent - the message shown to the user
// @returns {Object} - the JSON message definition (see https://api.elis.rossum.ai/docs/#annotation-content-event-response-format)

const createMessage = (type, content, datapointId = null) => ({
  content: content,
  type: type,
  id: datapointId,
});

// Replace the value of the datapoint with a new value.
// @param {Object} datapoint - the content of the datapoint
// @param {string} - the new value of the datapoint
// @return {Object} - the JSON replace operation definition (see https://api.elis.rossum.ai/docs/#annotation-content-event-response-format)

const createReplaceOperation = (datapoint, newValue, options) => ({
  op: 'replace',
  id: datapoint.id,
  value: {
    content: {
      value: newValue,
    },
    options: options
  }
});

const getAnnotationData = async (annotationId, authToken) => {

  const options = {
    hostname: 'example.app.rossum.ai',
    path: `/api/v1/annotations/${annotationId}/content`,
    method: 'GET',
    headers: {
      'Authorization': `token ${authToken}`,
      'Content-Type': 'application/json; charset=UTF-8'
    }
  };

  let dataString = '';

  // Define how to process response
  const response = await new Promise((resolve, reject) => {
    const req = https.request(options, function(res) {
      res.on('data', chunk => {
        dataString += chunk;
      });
      res.on('end', () => {
        resolve({
          statusCode: 200,
          body: dataString
        });
      });
    });

    req.on('error', (e) => {
      reject({
        statusCode: 500,
        body: 'Something went wrong!'
      });
    });

    // Execute request
    req.end()

  });

  parsedResponse = JSON.parse(dataString);

  return parsedResponse
}

const switchAnnotationToDifferentQueue = async (annotationId, queueId, targetStatus, previousQueues, authToken) => {

  const options = {
    hostname: 'example.app.rossum.ai',
    path: `/api/v1/annotations/${annotationId}`,
    method: 'PATCH',
    headers: {
      'Authorization': `token ${authToken}`,
      'Content-Type': 'application/json; charset=UTF-8'
    }
  };

  let payload = {
    'queue': `https://example.app.rossum.ai/api/v1/queues/${queueId}`,
    'status': targetStatus,
    'metadata': {
      'previous_queues': previousQueues
    }

  };

  let dataString = '';

  // Define how to process response
  const response = await new Promise((resolve, reject) => {
    const req = https.request(options, function(res) {
      res.on('data', chunk => {
        dataString += chunk;
      });
      res.on('end', () => {
        resolve({
          statusCode: 200,
          body: dataString
        });
      });
    });

    req.on('error', (e) => {
      reject({
        statusCode: 500,
        body: 'Something went wrong!'
      });
    });

    // Fill request with payload
    req.write(JSON.stringify(payload))

    // Execute request
    req.end()

  });

}

Approval metadata filling function

Another component of the simple approval workflow is a function that updates the approval metadata after confirming the document. In order to make the function work properly, you should:

  1. Assign the function to event action annotation_content.exported and annotation_content.user_update
  2. Enable passing of modifiers metadata to the hook by setting the sideload attribute of a hook to ["modifiers"]. The user object of the modifier containing the name of the user, etc. will be added to the hook notification. This can now be done over the UI - as you can see on the image below.
  3. Assign the function to all queues except the "Approved invoices" queue
  4. Copy the same routing groups config from the routing function.
13481348

The objects to be sideloaded can now be setup directly in the UI.

The example code of the approval metadata filling function as shown below.

/*
Config for routing groups.
*/
const routingGroups = [{
    "id": "first_approval",
    "queue_id": 95956,
    "rules": {
      "amount_total": 10000,
      "cost_center": [1, 2, 3, 4, 5, 6]
    }
  },
  {
    "id": "second_approval",
    "queue_id": 95957,
    "rules": {
      "amount_total": 30000,
      "cost_center": [1, 2, 3, 4, 5, 6]
    }
  },
  {
    "id": "third_approval",
    "queue_id": 95958,
    "rules": {
      "amount_total": Infinity,
      "cost_center": [1, 2, 3, 4, 5, 6]
    }
  }
];

/*
This function fills in the approval metadata based on the routing groups defined
in the config above.
*/

exports.rossum_hook_request_handler = async ({
  action,
  event,
  annotation: {
    id,
    status,
    content,
    queue
  },
  modifiers
}) => {
  try {

    const messages = [];
    const operations = [];

    /*
    When exporting the document in approval workflow queues, we should
    fill in the approval time and username to the correct fields.
    */
    if (event == 'annotation_content' && action == 'export') {

      const [routingGroup] = findCurrentRoutingGroupByQueueId(routingGroups, queue.split('/').pop())

      const routingGroupId = (routingGroup) ? routingGroup.id : null;

      /* Check whether the document is currently being rejected */
      const [rejectEnum] = findBySchemaId(content, 'reject_enum');

      /* Handle the rejection of the document */
      if (rejectEnum && rejectEnum.content.value == 'true') {

        messages.push(createMessage('warning', 'Document was rejected', rejectEnum.id))

        if (routingGroupId) {
          const [approvalTime] = findBySchemaId(content, routingGroupId + '_time');

          const [approvalUser] = findBySchemaId(content, routingGroupId + '_user');

          if (approvalTime && approvalUser) {
            operations.push(createReplaceOperation(approvalTime, "", []));

            operations.push(createReplaceOperation(approvalUser, "", []));
          }
        }
      } else {

        /* If document is not rejected -> update the metadata based on the current routing group */
        if (routingGroupId) {
          const [approvalTime] = findBySchemaId(content, routingGroupId + '_time');

          /* Compute the current datetime */
          const today = new Date();
          const date = today.getFullYear() + '-' + (today.getMonth() + 1) + '-' + today.getDate();
          const time = today.getHours() + ":" + today.getMinutes() + ":" + today.getSeconds();
          const dateTime = date + ' ' + time;

          /* Set it as the only options of the field ~ aka "Read-only" field */
          const dateTimeOptions = [{
            value: dateTime,
            label: dateTime
          }]

          /* Set the approving user */
          const [approvalUser] = findBySchemaId(content, routingGroupId + '_user');

          const userOptions = [{
            value: modifiers[0].id,
            label: modifiers[0].username
          }]

          if (approvalTime && approvalUser) {
            operations.push(createReplaceOperation(approvalTime, dateTime, dateTimeOptions));
            operations.push(createReplaceOperation(approvalUser, modifiers[0].id, userOptions));
          }
        }
      }
    }

    return {
      messages,
      operations
    }

  } catch (e) {
    // In case of exception, create and return error message. This may be useful for debugging.
    const messages = [
      createMessage('error', 'Serverless Function: ' + e.message)
    ];
    return {
      messages,
    };
  }
};


// --- HELPER FUNCTIONS ---

// Return the current routing group defined by the queue ID.
// @param {Object} routingGroups - a list of the routing groups.
// @param {string} queueId - the current queue ID.
// @returns {Array} - the list of matching routing groups. Should be 1 in most cases.

const findCurrentRoutingGroupByQueueId = (routingGroups, queueId) =>
  routingGroups.filter(routingGroup => routingGroup.queue_id == queueId)


// Return datapoints matching a schema id.
// @param {Object} content - the annotation content tree (see https://api.elis.rossum.ai/docs/#annotation-data)
// @param {string} schemaId - the field's ID as defined in the extraction schema(see https://api.elis.rossum.ai/docs/#document-schema)
// @returns {Array} - the list of datapoints matching the schema ID

const findBySchemaId = (content, schemaId) =>
  content.reduce(
    (results, dp) =>
    dp.schema_id === schemaId ? [...results, dp] :
    dp.children ? [...results, ...findBySchemaId(dp.children, schemaId)] :
    results,
    [],
  );

// Create a message which will be shown to the user
// @param {number} datapointId - the id of the datapoint where the message will appear (null for "global" messages).
// @param {String} messageType - the type of the message, any of {info|warning|error}. Errors prevent confirmation in the UI.
// @param {String} messageContent - the message shown to the user
// @returns {Object} - the JSON message definition (see https://api.elis.rossum.ai/docs/#annotation-content-event-response-format)

const createMessage = (type, content, datapointId = null) => ({
  content: content,
  type: type,
  id: datapointId,
});

// Replace the value of the datapoint with a new value.
// @param {Object} datapoint - the content of the datapoint
// @param {string} - the new value of the datapoint
// @return {Object} - the JSON replace operation definition (see https://api.elis.rossum.ai/docs/#annotation-content-event-response-format)

const createReplaceOperation = (datapoint, newValue, options) => ({
  op: 'replace',
  id: datapoint.id,
  value: {
    content: {
      value: newValue,
    },
    options: options
  }
});

// Return a datapoint by its ID.
// @param {Object} content - the annotation content tree (see https://api.elis.rossum.ai/docs/#annotation-data)
// @param {string} datapointId - the datapoints ID. (https://api.elis.rossum.ai/docs/#common-attributes)
// @returns {Array} - datapoints matched by its ID (should return only one datapoint)

const findByDatapointId = (content, datapointId) =>
  content.reduce(
    (results, dp) =>
    dp.id === datapointId ? [...results, dp] :
    dp.children ? [...results, ...findByDatapointId(dp.children, datapointId)] :
    results,
    [],
  );

🚧

Hide "Confirmed" tab

In order to make the transition between queues smooth, you should disable the Confirmed tab because the documents should be routed to a different routing group right after clicking "Confirm".

🚧

Set synchronous export of the documents

When confirming the document, by default the document is switched to the "exporting" status where it can stay for some time until it is exported. Since we won't be implementing any extra exporting behavior, we can move the document right to exported status. You can set that over the API by setting "asynchronous_export" to false in the queue settings.

How To Manage Approved Invoices

Once the document is moved to "Approved invoices" queue, you have several options:

  1. Keep the documents in "To review" tab where someone still has to click on "Confirm" button in order to push the document the the ERP
  2. Move the documents automatically to "Confirmed" tab - in this tab you will have a robot set up that will hourly call the "/export" endpoint with POST method which will move the documents from "confirmed" status to "exported" status while downloading the data that should be inserted to the ERP system. Or have another hook on event annotation_content.exported that is triggered once the document enters the status "exporting".