Ownware
Home›Approva›Manual
Approva · Manual

Approva Manual, as shipped in the download

Approva — User Manual

Purchase requests and approvals you run yourself. Version 3.1.4 [src: app/controllers/api.php:16].

About this manual

Every statement here was written by reading Approva's own source, and each one carries the file it came from in a bracketed src marker. Paths are relative to the folder this docs directory sits in. If a sentence disagrees with the application, the application is right and this manual has a bug — the markers exist so you can check rather than take our word for it.

Nothing is described that the code does not do. Where something is deliberately limited, that is said plainly rather than left out.

What Approva is

Somebody raises a purchase request; the right number of people approve it; a purchase order comes out the other end [src: app/index.php:98]. The value of the thing is that the approvals cannot be faked, and most of this manual is about how that is enforced [src: app/src/Purchase.php:370].

Where the chain ends is worth stating at the start, because the product states it to the requester in the decision e-mail: nothing has been ordered or paid, and Approva never places an order or pays a vendor [src: app/src/Purchase.php:302].

Roles

Three: requester, approver and admin [src: app/src/Purchase.php:30]. Approving is reserved to approvers and admins [src: app/src/Purchase.php:32], and routes are gated by role rather than by a permission map [src: app/src/Auth.php:39].

Segregation of duties

This is the control the product exists for, and the source says so in those words [src: app/src/Purchase.php:370].

Whether a person may record a decision on a request is answered by one function, which returns either the reason they may not or nothing at all [src: app/src/Purchase.php:389]. The reasons are checked in order, so the most specific one wins: not an approver, then your own request, then the delegation rules, then having already decided, then the request no longer being open [src: app/src/Purchase.php:370].

You cannot approve your own request. That check sits second, before anything about delegation, so no arrangement of delegations can route around it [src: app/src/Purchase.php:397].

You cannot decide twice. A person already recorded against a request is refused [src: app/src/Purchase.php:414].

The source is candid about why the function is written as a pure, isolated piece of logic: the guard was real and correctly placed, and had no test coverage at all — and a control nobody has tested is a control nobody can trust [src: app/src/Purchase.php:376].

Delegation, and the one-seat rule

An approver going away can delegate their seat for a window [src: app/src/Purchase.php:378].

Two rules keep the separation intact, and both are worth understanding because they are what stops delegation becoming a loophole.

A delegation is never usable on a request the delegator raised [src: app/src/Purchase.php:402]. Without that, an approver could raise a request and have their own delegate approve it — self-approval by proxy.

A delegation moves a seat; it does not add one. The delegator and the delegate can never both be counted on the same request [src: app/src/Purchase.php:407].

That second rule runs in both directions, and the source notes the asymmetry was not free. If somebody has already decided on your behalf while you were away, your own seat is spent and you are refused [src: app/src/Purchase.php:421]. The comment records that this was found by an adversarial pass — without it, a delegator could add a second approval after his own delegate had already been counted [src: app/src/Purchase.php:418].

Someone who is an approver in their own right decides as themselves; the delegation only comes into play where their own role would be refused [src: app/src/Purchase.php:399].

How many approvals a request needs

One, plus one more for every spending threshold the total crosses [src: app/src/Purchase.php:177].

Thresholds can be set per cost centre rather than only company-wide [src: app/src/Purchase.php:206], and the reasoning is stated plainly: with a single global list you either drown one department in signatures or wave another through [src: app/src/Purchase.php:188].

Two things about that are worth knowing. A cost centre with no rule of its own inherits the company list [src: app/src/Purchase.php:222], so an existing installation's requests keep exactly the number of signatures they had [src: app/src/Purchase.php:196]. And the routing changes only which threshold list is used — it is not a second approval mechanism, and the chain, the seats and the segregation rules are untouched by it [src: app/src/Purchase.php:192].

The number of thresholds is not fixed at two. The count is whatever you configure: one level to begin with, and one more for every configured threshold the total meets or exceeds [src: app/src/Purchase.php:15]. The setting is a free list rather than a pair of boxes [src: app/src/Database.php:198], duplicates are removed and the list is sorted before use [src: app/src/Purchase.php:167].

The rule is frozen at submission

When a request is submitted, the number of approvals it needs and a label for the rule that produced it are written onto the request [src: app/controllers/app.php:438], and reads prefer that stored value over recomputing one [src: app/controllers/app.php:26].

The source explains what this prevents, and it is worth understanding before you touch a threshold. Required levels used to be derived on every read, so editing a threshold silently re-decided every request ever raised: one approved under a one-signature rule turned back into "partially approved" the moment somebody tightened the rule, and the register stopped matching what had actually happened [src: app/src/Database.php:116].

So changing a threshold changes future requests. Requests already submitted keep the rule they were submitted under, and the label tells you which one that was [src: app/src/Purchase.php:287].

Requests raised before the freeze existed carry no locked value and keep deriving exactly as they did [src: app/src/Database.php:120].

Status

Approva stores two statuses and displays six [src: app/src/Purchase.php:38].

The stored pair is draft and submitted [src: app/src/Purchase.php:36]. Everything else — pending, partially approved, approved, rejected, purchase order issued — is worked out from the request, its approvals, whether a purchase order exists, and how many approvals were required [src: app/src/Purchase.php:350].

The order of that derivation matters: a draft is a draft; then any rejection makes it rejected; then an issued purchase order makes it issued; then enough approvals make it approved; then some approvals make it partially approved; otherwise it is pending [src: app/src/Purchase.php:350].

A decision can only be recorded while a request is pending or partially approved [src: app/src/Purchase.php:363]. Because status is derived rather than stored, a request cannot sit in the database claiming to be awaiting approval after it has been decided.

References and purchase order numbers

Requests carry a reference beginning PR- [src: app/src/Purchase.php:41] with a six-character body drawn from an alphabet without look-alike characters [src: app/src/Purchase.php:42].

Purchase order numbers are a zero-padded sequence [src: app/src/Purchase.php:46] — sequential rather than random, because a purchase order number is an accounting artefact and gaps in it are questions somebody has to answer.

Money

Money is held as integer cents throughout, and the class states that as its first rule [src: app/src/Purchase.php:9]. Line amounts are computed from quantity and unit price [src: app/src/Purchase.php:124] and totalled from the lines [src: app/src/Purchase.php:136], so no total is the result of floating-point arithmetic.

Delegation screens

Setting up a delegation requires being an approver or admin — a requester reaching it is refused with an explanation rather than shown an empty page [src: app/controllers/v3.php:489]. The same check guards creating one [src: app/controllers/v3.php:504].

The API and agent access

Machine access is a REST interface behind a key [src: app/index.php:91]. Requests can be listed, fetched and created; submitted, approved and rejected; a purchase order generated [src: app/index.php:98]; and orders, vendors and cost centres read [src: app/index.php:99].

Nine tools are exposed to an agent, of which five are read-only and four write [src: app/controllers/v3.php:229]: listing and fetching requests, a pending report, vendors and cost centres on the read side; creating, submitting, approving and rejecting on the write side.

The important sentence is in the instructions the agent is given: the key acts as its user, and the same approval guards apply [src: app/controllers/v3.php:227] — an agent cannot approve the requester's own request twice, or its own at all [src: app/controllers/v3.php:228].

That is the right design. An automation that could bypass segregation of duties would defeat the only control the product exists to provide.

Who is told

A submitted request mails the notifications inbox set in Settings. From 3.1.4, with "Tell the approvers" switched on (it ships off), each person who can sign the request is also asked at their own address, with the reference, who raised it, the vendor, the total, how many sign-offs the rule needs and a link to decide [src: app/controllers/v3.php:156]. Who counts is decided by the Approve button's own rule, so the requester, anyone who has already decided and anyone whose seat is spent are never asked [src: app/controllers/v3.php:137]. They sign in to decide; nothing is approved from the email.

Backups

Both backup downloads — the JSON export and the raw SQLite file — are an administrator's; a signed-in requester or approver gets a 403 [src: app/controllers/api.php:327], and so do the restore page, a restore and minting the scheduled-backup token [src: app/controllers/v3.php:763]. The JSON export and the scheduled backup leave out the same list of credentials: password hashes, API-key hashes, webhook secrets, the SMTP password, the SSO client secret and every two-factor seed and recovery code [src: app/controllers/api.php:339].

What Approva does not do

It does not decide whether a purchase is wise. It records who asked, enforces who may approve, counts the signatures the amount requires, and issues a number [src: app/src/Purchase.php:350]. Whether the thing should be bought is a judgement it neither makes nor stores an opinion about — no column on the request holds one [src: app/src/Database.php:203].

It does not order anything and it does not pay anybody. The source says so to the requester in plain words: nothing has been ordered or paid, and Approva never places an order or pays a vendor [src: app/src/Purchase.php:302]. There is no payments table in the schema [src: app/src/Database.php:183]. A purchase order comes out, and what happens next belongs to your finance system.

The delegation window is a date range, not a permission grant [src: app/src/Database.php:312]. A delegate acts within it under the rules above, and cannot use it on the delegator's own requests [src: app/src/Purchase.php:402] or where the delegator's seat is already spent [src: app/src/Purchase.php:407].

← Back to Approva · Quickstart · API · Test run

Affiliate program
Recommend tools people own — earn 35% on every sale. 90-day tracking, instant delivery, payouts by Lemon Squeezy.
Become an affiliate →