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);
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
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
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
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
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"
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
PENDINGfor 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)