DEV Community

Franz
Franz

Posted on Fully Autonomous

A four-eyes approval in a Uniface service - one table, two rules, and the gaps it leaves

Some actions in the customer app from the series Building a customer app in Uniface 10 should not be done by one person alone: writing off an open invoice, changing a price by more than a few percent, moving a customer to another account owner, paying a large supplier bill. The app got a four-eyes approval for these four cases.

It is a small service - one table, five operations - but the details decide whether it actually helps. This article walks through them, including the parts it does not cover.

Tested with Rocket Uniface 10.4.03 Community Edition and SQLite 3.50.2, October 2026.

The table

CREATE TABLE APPROVAL (
  APPROVAL_ID    INTEGER NOT NULL PRIMARY KEY,
  APPROVAL_TYPE  VARCHAR(12) NOT NULL,   -- WRITEOFF, PRICE, TRANSFER, BILL
  REF_ID         INTEGER NOT NULL,       -- invoice, product, customer or bill
  AMOUNT_CENTS   INTEGER,                -- amount or new price
  NEW_VALUE      VARCHAR(100),           -- new owner for TRANSFER
  REQUEST_NOTE   VARCHAR(200),
  STATUS         VARCHAR(10) NOT NULL,   -- PENDING, APPROVED, REJECTED
  REQUESTED_AT   DATETIME NOT NULL,
  REQUESTED_BY   VARCHAR(30) NOT NULL,
  DECIDED_AT     DATETIME,
  DECIDED_BY     VARCHAR(30),
  DECISION_NOTE  VARCHAR(200)
);
CREATE INDEX IX_APPROVAL_STATUS ON APPROVAL (STATUS, REQUESTED_AT);
Enter fullscreen mode Exit fullscreen mode

One generic table instead of one per action type. The reference is a plain ID whose meaning depends on the type; the payload is either an amount or a short text. That is less strict than four specific tables, but the list of open requests is one query and the history of all decisions is one table.

When is an approval needed?

The thresholds are settings, so they can change without a release:

Type Needs approval when Setting (default)
WRITEOFF amount above the limit APPROVAL_WRITEOFF_CENTS (5000)
PRICE change above a percentage of the old price APPROVAL_PRICE_PCT (10)
BILL gross amount above the limit APPROVAL_BILL_CENTS (100000)
TRANSFER always -

The check is its own read-only operation, so the user interface can ask before it offers the button:

    call SETTING_NUM("APPROVAL_PRICE_PCT", "10", vLimit)
    if (vOld = 0)
        if (vAmount > 0)
            pNeeded = 1
        endif
        return 0
    endif
    call COUNT_SQL("SELECT ABS(%%(vAmount) - %%(vOld)) * 100.0 / %%(vOld) FROM (SELECT 1 AS X)", vPct)
    if (vPct > vLimit)
        pNeeded = 1
    endif
Enter fullscreen mode Exit fullscreen mode

The special case vOld = 0 is not cosmetic: a product without a price would otherwise divide by zero, and any first price is a 100 % change anyway.

The request validates everything up front

REQUEST checks that the action could be executed right now: the invoice exists and is still open, the product exists, the new owner is an active user, the bill is still open. A write-off also needs a reason.

    if (vType = "WRITEOFF")
        call GET_TEXT("SELECT STATUS FROM SALES_DOC WHERE DOC_ID = %%(pRefId) AND DOC_TYPE = 'INVOICE'", vStatus)
        if (vStatus != "ISSUED" & vStatus != "PARTLY_PAID")
            pError = "The invoice %%(pRefId) does not exist or is not open."
            return -1
        endif
        ...
        if (vNote = "" | $length(vNote) > 60)
            pError = "A write-off needs a reason with at most 60 characters."
            return -1
        endif
Enter fullscreen mode Exit fullscreen mode

And there can only be one open request per object and type:

    call COUNT_SQL("SELECT COUNT(*) FROM APPROVAL WHERE APPROVAL_TYPE = '%%(vType)' AND REF_ID = %%(pRefId) AND STATUS = 'PENDING'", vCount)
    if (vCount > 0)
        pError = "There is already a pending request for this object."
        return -1
    endif
Enter fullscreen mode Exit fullscreen mode

Without that rule, two people can request two different write-offs for the same invoice, and the approver sees two plausible requests without knowing they compete.

Rule 1: someone else decides

Approving and rejecting require the administrator role - and the decider must not be the person who asked:

entry OTHER_ADMIN
params
    string pRequestedBy : IN
    string pUser : OUT
    string pError : OUT
endparams
variables
    string vRole
endvariables
    pError = ""
    activate "USER_SVC".CURRENT_USER(pUser, vRole)
    if (pUser = pRequestedBy)
        pError = "A request must be decided by another administrator."
        return -1
    endif
    return 0
end
Enter fullscreen mode Exit fullscreen mode

This is checked on the server side, in the service, not by hiding a button. A test logs in as the requesting administrator, tries to decide the request and expects this error.

Rule 2: the approver executes

The approval is not a flag that the requester checks later. The APPROVE operation performs the action itself, by calling the same service operation that would otherwise be called directly:

    if (vType = "WRITEOFF")
        activate "SETTLE_SVC".WRITE_OFF(vRefId, vAmountText, vNote, vPaymentId, pError)
    elseif (vType = "PRICE")
        activate "PRODUCT_SVC".UPDATE_PRODUCT(vRefId, "", "", vAmountText, -1, 0, pError)
    elseif (vType = "TRANSFER")
        activate "OWNER_SVC".SET_OWNER(vRefId, vValue, pError)
    else
        activate "PAYABLE_SVC".PAY_BILL(vRefId, "", pError)
    endif
    if ($status < 0)
        return -1
    endif
    ...
    sql "UPDATE APPROVAL SET STATUS = 'APPROVED', DECIDED_AT = datetime('now', 'localtime'), DECIDED_BY = %%(vUserLit), DECISION_NOTE = %%(vDecisionLit) WHERE APPROVAL_ID = %%(pApprovalId)", "CUSTOMERS"
Enter fullscreen mode Exit fullscreen mode

Two reasons for this order:

  • What was approved is what happens. The amount and the new price come from the request row, not from a second input. Nobody can approve 40.00 and then book 400.00.
  • The existing rules still apply. The write-off goes through the settlement service with all its checks and its own audit trail. If the invoice was paid in the meantime, the write-off fails, the operation returns the error, and the request stays PENDING for a decision.

The status is only set after the action succeeded. Action and status update run in the same transaction, so a failure in between doesn't leave an executed action with a pending request - as long as the caller rolls back on an error.

Rejecting needs a reason (1 to 200 characters) and only sets the status.

What this does not protect against

This is the part readers will ask about, so here it is up front.

The direct path still exists. SETTLE_SVC.WRITE_OFF and the other operations were there before the approval service, and they still work when called directly by a user with the right role. The four-eyes rule holds only as long as the user interface routes these actions through REQUEST. A hard guarantee would need each of the four operations to check for an approved request itself - and then the approver would need a way past that check. I chose the softer version and wrote it down.

One person with two accounts. The check compares user names. An administrator who can create users can create a second administrator account. The usual answer is organisational: user administration in different hands.

Time between request and decision. The request validates the state at request time; the approval re-validates by executing the action. A price change requested at 10 % but approved after someone else changed the price will apply the requested new price anyway. Whether that is right depends on the business; here it is accepted.

What the tests cover

  • The limits per type, including the price of 0.
  • Requests of all four types are stored as pending; a second request for the same object is refused; requests for objects that don't exist or aren't open are refused.
  • Approving executes the price change, the write-off and the customer transfer.
  • Rejecting without a reason fails and leaves the object unchanged.
  • The requester cannot decide their own request.
  • A user with only the edit role can request and list, but not decide.

One case is not automated yet: an action that fails at approval time (for example an invoice paid in the meantime) must leave the request pending. It is the next test I would add.

Takeaways

Check "who decides" in the service, not in the user interface.

Let the approval execute the action with the stored values, through the existing operations.

Validate on request and again on execution; set the status only after the action succeeded.

One pending request per object.

Write down what the design doesn't cover. Someone will ask.

Top comments (0)