Home›Commissa›Manual
Commissa · Manual

Commissa Manual

Commissa — User Manual

Sales commission: schemes, statements, adjustments and disputes. Version 4.1.3 [src: app/controllers/api.php:18].

About this manual

Every statement here was written by reading Commissa'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 Commissa is

It computes what is owed to a sales representative. The source calls the engine the moat, because here the maths is the product [src: app/src/Commission.php:3].

It never moves money — no cards, no bank rails, no crypto, no payment processor of any kind [src: app/src/Commission.php:6].

Money is integer cents and commission rates are integer basis points, where 10,000 is 100%. Floats never touch a stored or computed amount [src: app/src/Commission.php:5].

Four schemes

[src: app/src/Commission.php:9]

  • Flat — one rate on every pound of a representative's won sales.
  • Tiered — progressive bands on cumulative won volume in the period, exactly like marginal tax brackets: say 5% up to £10,000, then 8% above. A single deal that straddles a band boundary is split across the bands correctly.
  • Category — a rate per product category, with a default for anything uncategorized [src: app/src/Commission.php:166].
  • Quota and accelerators (from 3.2) — bands on quota attainment instead of raw volume: say 8% up to 100% of quota, then 12% on everything above it [src: app/src/Commission.php:40]. See Quotas and accelerators below.

Only won deals earn commission [src: app/src/Commission.php:37].

The penny-exactness guarantee, and what it does and does not promise

The source states four penny-exact guarantees and says all four are covered by the test suite [src: app/src/Commission.php:21]. They are unusually strong, and one of them has a consequence people find surprising.

Why the lines always add up

Under flat and tiered schemes, a deal's commission is defined as C(volume after) − C(volume before), where C is the cumulative commission on cumulative volume [src: app/src/Commission.php:22].

Because the per-deal lines are consecutive deltas of one monotonic function, they telescope: the sum of the statement lines equals C(period total) exactly, for any tier structure and any deal sequence. No penny is ever created or lost at a band boundary [src: app/src/Commission.php:24].

The source notes this is the same split-invariant used in the consignment product, applied to progressive tiers [src: app/src/Commission.php:26].

Under a category scheme the guarantee is simpler: each deal is rounded independently, half up, and the lines sum to the period total by construction [src: app/src/Commission.php:28].

⚠️ The total is order-independent. The individual lines are not.

This follows from the definition and is worth stating plainly, because it is the question a representative will eventually ask.

A deal's line is the delta at the point in the sequence where that deal falls [src: app/src/Commission.php:158]. Two identical deals therefore earn different amounts depending on how much volume preceded them: the earlier one may sit entirely in a 5% band while the later one sits in the 8% band.

The period total is unaffected. Whatever order the deals arrive in, the statement sums to the same figure.

If a representative asks why their colleague's identical deal shows a larger commission, that is the answer: not a bug, and not favouritism — the bands are cumulative, so position in the sequence decides which band a deal falls in, while the period total is fixed regardless.

The product helps you explain it: each tiered line carries a label naming the band or bands it actually touched — "8%", or "5% → 8%" for a straddle [src: app/src/Commission.php:279].

Adjustments and net pay

Net payout is gross commission plus the signed sum of the period's adjustments — bonuses positive, clawbacks negative — in integer cents with a final safe clamp [src: app/src/Commission.php:30].

Adjustment kinds are bonus, clawback and manual [src: app/src/Commission.php:57].

Disputes

A dispute hangs off the deal whose commission is being questioned, and resolves into at most one adjustment — the same adjustments row the payout maths already reads, so a resolved dispute moves money through exactly one path [src: app/src/Database.php:161].

That is the right design: a dispute settlement is not a second, parallel way for money to reach a representative.

Two details from the schema worth knowing [src: app/src/Database.php:164]:

  • Representatives have no logins in this product. The person who raised a dispute is the user who recorded it; the representative is who it concerns.
  • The decision timestamp is what makes a decision once-only [src: app/src/Database.php:165].

Statements are derived until the period is closed

A statement is computed from the deals, the scheme and the adjustments each time it is asked for [src: app/src/Auth.php:21].

That has a consequence the source states directly, and it is the reason for the permission rule below: changing a rate or a band boundary changes what every representative is owed, and it changes it retroactively for any open period recomputed afterward.

Closing a period (from 3.1.4) stops that for the period you have paid. A manager or an administrator closes a period once it has ended; every representative's statement is stored as it stands [src: app/controllers/v3.php:89], and from then on the period page, the statement, its PDF and CSV, the rep email, the API and the AI tools all read the stored statements [src: app/src/Commission.php:527]. Editing a deal, a rule or an adjustment afterward no longer changes them; the period page shows what today's figures would compute beside what was paid. Nothing can be added to or removed from a closed period, and it cannot be deleted [src: app/controllers/v3.php:73]; settle a difference in the next open period. Only an administrator can reopen a period, and the reopening is audited [src: app/controllers/v3.php:120].

Carrying a difference (from 3.2). Under After close, the period page lists each representative whose figures have moved since the close: what was paid, what today's figures compute, what has been carried already and what is left [src: app/controllers/v32.php:162]. Carry to <next period> posts what is left as one adjustment in the first open period after it, through the same adjustment writer as every other, with a note naming the closed period and both figures [src: app/controllers/v32.php:194]. The button carries only the amount the page showed; if the figures moved in the meantime, nothing is carried and the page says so. The closed statements stay what was paid.

The fingerprint. A closed statement carries a fingerprint (three groups of four characters) computed from the figures that were paid: each deal, each adjustment and the net [src: app/src/Commission.php:610]. It is printed on the statement and in the rep's email, and the API returns it, so a statement handed around later can be checked against the one Commissa froze.

The rep's statement link

Representatives have no logins, so from 3.2 each can have a statement link for one period: on the statement, Make a link for the rep (shown once, to copy) [src: app/controllers/v32.php:24]; the statement email carries a fresh one on its own [src: app/controllers/v3.php:217]. The link opens only that representative's statement for that period, with each deal and the rate applied to it, the adjustments and the net, and none of the manager's controls [src: app/controllers/v32.php:90]. Each deal line has Question this line: the representative writes what looks wrong and it becomes a dispute on the Disputes screen, one open question per line [src: app/controllers/v32.php:111]. A link works for 90 days; Switch links off ends every link to that statement at once [src: app/controllers/v32.php:67]. Only a SHA-256 of each link is stored, and the page asks search engines and browsers not to keep or pass it on.

Split deals (from 3.2)

A deal can be credited to more than one rep: Split with other reps on the deal form gives other reps a share in percent, and the deal's own rep keeps the rest [src: app/controllers/app.php:607]. The shares given away add up to less than 100%, each rep appears once, and the deal's own rep is not one of them; anything else is refused with a sentence. Each part is worked out to the cent (the others' shares rounded half up, the deal's own rep the rest), so the parts always add up to the deal [src: app/src/Commission.php:483].

Each rep's statement counts their share as the deal amount, at their own rates, tiers and quota [src: app/src/Commission.php:421]; the line says "40% of 20,000.00, split with Ana". A rep who only has a share of someone else's deal is on the period's run, can question that line from their link, and the API gives the line's share_bp and full_amount_cents. A split changed after a period was closed shows under After close for every rep it moves, ready to carry.

Holding commission until the customer pays (from 3.3)

Off until you switch it on in Settings → Hold commission until the customer pays [src: app/views/settings.php:58]. When it is on, a won deal earns commission in the period its customer paid on date falls in, not the period it was won in [src: app/src/Commission.php:426]. A won deal with no payment date is held: each rep's statement lists what is held for them, at their share of a split deal, and it earns nothing until the date is entered [src: app/src/Commission.php:452] [src: app/views/statement.php:141].

Enter the date on the deal (a blank stays blank, never today; anything that is not a date is refused) [src: app/controllers/app.php:598], in a CSV import column called paid date, payment date or similar [src: app/controllers/api.php:356], through the API with POST /api/deals/{id}/paid [src: app/controllers/api.php:91], or with the AI connection's record_deal. A deal paid later counts in the later period, at the tier its place in that period gives it. A closed period keeps the statement it froze, as it does for any other change after the close. A disputed deal settles into the period its commission was earned in [src: app/controllers/v3.php:290].

From 3.2 each representative can have a quota for each pay period. On the period's page, Quotas for <period> lists every active representative with a box for their quota; blank means none. Every value is checked before anything is saved, so a typo saves nothing, and each change is recorded in the audit trail with the old and new amounts [src: app/controllers/v32.php:277]. Copy from the previous period fills in last period's quotas for anyone who has none yet [src: app/controllers/v32.php:301]. Setting a quota is an administrator's action, like the rates, because it changes what a quota scheme pays [src: app/controllers/v32.php:315].

Attainment is won volume divided by quota. It shows on the statement (screen, PDF, the rep's link and email), under each name on the period page, and in the API, on any scheme: a representative on a flat rate with a quota sees their attainment and is paid exactly as before [src: app/src/Commission.php:549].

A quota scheme (Rules → New rule → Scheme: Quota and accelerators) takes bands whose "up to" is a share of the quota, where 100 is the quota itself; the top band has no bound. For each representative and period the bounds become amounts using that period's quota, and then the same progressive engine as a tiered scheme runs, so the lines still add up to the period total to the cent and a deal that crosses the quota is split, "8% → 12%" [src: app/src/Commission.php:197]. The form offers two starting shapes, Flat accelerator (one rate to quota, a higher rate above) and Stepped accelerator (the rate steps up at 50%, 100% and 150% of quota); both fill in example rates for you to replace [src: app/src/Commission.php:48]. A band's rate runs from 0 to 100%, and its "up to" from above 0 to 1000% of quota; anything else is refused with a sentence saying why [src: app/controllers/app.php:442].

With no quota set for the period, a quota scheme pays its first band's rate on all the representative's sales, and the statement and the period page say "no quota set" so it is not missed.

A quota change on a closed period does not touch the statements that were paid: the difference shows under After close, ready to carry, like any other correction.

Approving payouts before a period closes

Settings → Approve payouts before a period closes (off until you switch it on) [src: app/controllers/app.php:1030]. When it is on, the period page shows where the approval stands: waiting, approved (by whom, when, for what total), or changed since approval. Once the period has ended, an administrator presses Approve payouts of <total>; the approval records the total and a digest of every representative's net [src: app/src/Commission.php:631]. The button approves only the total the page showed, so a change in another tab approves nothing [src: app/controllers/v32.php:372].

Closing the period then checks the approval against the payouts as they stand: with no approval, or if any representative's net has moved since, the close is refused and says which [src: app/controllers/v3.php:96]. Managers still record deals, adjust and close; approving is the administrator's [src: app/controllers/v32.php:389]. Every approval is in the audit trail.

The payroll file

A closed period's page has Payroll CSV: one row per representative with a net to pay, from the frozen figures, so the file matches the statements [src: app/controllers/v32.php:410]. The columns are Employee ID, Employee name, Employee email, Earning type ("Commission"), Amount (a plain decimal, no thousands separator), Currency, Period start, Period end and Reference (the period's name and the statement's fingerprint). The Employee ID is the representative's Payroll employee ID, set on their record. An open period has no payroll file: close it first [src: app/controllers/v32.php:430]. A negative net (clawbacks larger than the commission) is listed as a negative amount; your payroll decides how it is recovered. Map the columns once in your payroll's import; Commissa does not connect to a payroll service.

Users and roles

Three roles: viewer, manager, admin [src: app/src/Auth.php:27].

A manager runs the plan — deals, adjustments, disputes, periods and representatives [src: app/src/Auth.php:28]. Rules stay with the owner.

Permissionviewermanageradmin
.view permissionsyesyesyes
audit.viewyesyesyes
deal.*, adjustment.*, dispute.*, period.*, rep.*—yesyes
view.save—yesyes
schemes, rates, tier bands, settings, users——yes

Why rules are admin-only

A specific reason rather than a generic one [src: app/src/Auth.php:19]:

A rate or a band boundary changes what every representative is owed, and it changes it retroactively for any period recomputed afterwards — the statement is derived, never stored. Deciding what the plan pays is an owner's decision; running the plan is the manager's.

(From 3.1.4 a closed period is the exception: its statements are stored at the close and a later rule change does not reach them.)

The audit trail is granted to a viewer explicitly — audit.view is named in the viewer's list rather than arriving through the wildcard [src: app/src/Auth.php:27].

The plan's change log (from 3.3)

Every change to a commission rule is kept on the Rules page under Change log: who made it, when, what changed and why [src: app/controllers/v33.php:87]. What changed is written in words, line by line, before → after: a rate ("Rate: 5% → 6%"), a band ("Band up to 10,000.00 USD: 5% → 6%"), a category's rate, who the rule applies to, the date it takes effect or its name; a band whose bound moved reads as one band removed and one added [src: app/controllers/v33.php:63]. A new rule is listed with everything it says and a deleted one with everything it said, including a rep's own rules removed with the rep [src: app/controllers/v33.php:24]. The rule form asks Why this change (optional, up to 500 characters) and the reason is kept with the entry [src: app/controllers/v33.php:78]. A save that changes nothing leaves no entry, and an entry is never edited. Anyone who can see the rules can read it. A rep is named as they are named today, so anonymizing a rep also takes their name out of the log [src: app/controllers/v33.php:100].

The API and agent access

Six tools are exposed to an agent [src: app/controllers/v3.php:367]: listing representatives, representative detail, a statement preview, a period report and listing disputes on the read side; recording a deal on the write side.

An agent can therefore add the inputs and read the results. It cannot change the scheme, the rates or the bands — those are admin-only for the reason above.

The agent endpoint reports the same version as the rest of the product [src: app/controllers/v3.php:366].

What Commissa does not do

It does not move money [src: app/src/Commission.php:6]. It computes what is owed; paying it happens elsewhere.

It does not lose or invent a penny at a band boundary [src: app/src/Commission.php:24].

It does not give a representative a login [src: app/src/Database.php:164].

It does not let a dispute pay out through a second path [src: app/src/Database.php:162].

It does not store a statement for an open period [src: app/src/Auth.php:21] — which is why a rule change is retroactive for open periods, and why rules are the owner's. A closed period's statements are stored [src: app/controllers/v3.php:89].

Data requests: what an erase removes and what it keeps (4.1.1)

Data requests (admin) exports everything held about a rep, or erases them in two stages. The email goes whenever you ask, from the rep, the statement emails sent to them and the frozen statements. The name only goes once the rep is archived and no period still shows a non-zero payout for them; the full erase then replaces the name and payroll id on the rep, the subject and text of the statement emails, their name on the disputes they raised from their statement link, and their name and payroll id in the frozen statements of closed periods. Every deal, adjustment and amount stays.

Kept: the text of the pay disputes they raised and the decisions on them, and the audit trail's lines that name them, because the disputes are the record behind an adjustment and the audit trail cannot be edited.

Looking after your install (from 4.0)

Updates from inside Commissa

Updates in the menu opens the Updates page (/updates) [src: app/views/updates.php]. Paste your license key once and press Save key: either key works, the one in your Lemon Squeezy receipt or the one on your order page (it starts with OWX-). Check for updates asks the store for the newest release your license covers; Commissa never checks by itself.

When a new version is ready, Back up and update to that version backs up your data and the current version, installs the new one and checks it. If the update is interrupted, or the new version's database step or that check fails, the previous version is put back by itself. Commissa is unavailable for about a minute while this runs. The update replaces Commissa's files, including any you edited, except .htaccess, .user.ini and robots.txt: an edited copy of those is kept, with the new version's copy beside it as .new to compare and merge. The Health page lists any file that differs from the version installed, so you can see that before you update. A copy of the previous version (data/updates/code-<version>/) and a backup of your data from just before the update stay in data/updates/ [src: app/src/Updater.php]. If Commissa shows an error page after an update anyway, that is your way back: copy the files from code-<version>/ over the app's files, then restore the backup on the backup page. Updating from the page needs PHP's sodium and Phar extensions; if one is missing, the Updates and Health pages say so and updates stay by hand. Where Commissa is hosted for you, the host updates it and the page says so.

Reaching 4.0 from a 3.x version is the last update done by hand: replace the files as before (config.php and data/ stay, and the database migrates itself on the next page load). From 4.0 on, the Updates page does it.

Starting with sample data

The installer offers Start with sample data: a sales team with deals, commission schemes and a pay period to try things on. You can remove it in one click. [src: app/install/index.php:132]. It is ticked by default; untick it to start empty. The sample is added when you open the first page after the installer.

When you are done with it, press the remove button in the sample banner. It asks "Remove the sample reps, deals and schemes? Your own records stay." Only the sample goes: your own records stay, and a sample record that one of your own records uses is kept and named in the report [src: app/src/SampleData.php].

Backups by themselves, and a copy somewhere else

On the Backup & restore page (/restore), Back up once a day by itself is on by default; untick it to stop [src: app/views/restore.php:83]. The first visit at least 24 hours after the last backup starts the next one, after the page has loaded, so no cron job is needed. The newest 14 stay in data/backups/, with credentials left out of them (a restore keeps the live values for those). Back up now takes one straight away.

To keep a copy somewhere else as well, fill in an S3-compatible bucket (Backblaze B2, Wasabi, AWS S3 or your own MinIO): the bucket address (an https endpoint), the region, the bucket name, an optional folder, and the access key ID and secret key. Leave the address empty to keep backups on this server only. The copy goes only to the bucket you set; nothing is sent to Ownware. Once a week Commissa reads the newest backup back through the restore's own dry run and keeps the result for the Health page [src: app/src/BackupTargets.php].

On Nginx: keep data/ private

On Apache, the .htaccess that ships with Commissa keeps data/ (your backups included), src/, config.php and the other private files off the web. Nginx does not read .htaccess, so add these three blocks to your server block, above the block that passes .php files to PHP (Nginx uses the first one that matches):

location ~ ^/(src|data|bin|tests|controllers|deploy|cron)/ { deny all; }
location ~ ^/views/.+\.php$ { deny all; }
location ~ (^|/)(config\.php|\.demo-mode)$|\.sqlite$ { deny all; }

Then open /config.php in a browser: it should answer 403. Only the template files under views/ are closed, not the folder: the app's own saved-view addresses start with /views/ too, and they must reach it.

A complete server block for a new site (PHP-FPM 8.3; change the name, the folder and the socket to yours):

server {
    listen 80;
    server_name example.com;
    root /var/www/commissa/app;
    index index.php;

    location ~ ^/(src|data|bin|tests|controllers|deploy|cron)/ { deny all; }
    location ~ ^/views/.+\.php$ { deny all; }
    location ~ (^|/)(config\.php|\.demo-mode)$|\.sqlite$ { deny all; }

    location = /install/ { rewrite ^ /install/index.php last; }
    location / { try_files $uri /index.php?$query_string; }

    location ~ \.php$ {
        try_files $uri =404;
        fastcgi_pass unix:/run/php/php8.3-fpm.sock;
        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
    }
}

The Health page

Health in the menu (/health) shows, one line each, whether the install is looking after itself: the PHP version and extensions, disk space, whether data/ is writable, email, the age of the last backup, the offsite copy, the weekly check, whether an update is waiting, and any file that differs from the version installed. Each line says what to do when it is not fine. The page only reads: it never changes anything and never contacts anything [src: app/src/Health.php].

Connect it to other tools (Zapier, Make, n8n)

Commissa sends a webhook the moment something happens and accepts calls to its API, so Zapier, Make and n8n can connect to it without a plugin. The steps, the events and example recipes are in API.md, in the section "Connect it to Zapier, Make or n8n"; the API page of Commissa on the store shows the same file [src: app/API.md].

New in 4.1.2: sign-in limits

New in 4.1.2 (security update): Two-step codes are now limited (10 wrong per person or per address in 10 minutes), and wrong passwords count per account as well as per address. An installed app whose database does not answer keeps its web installer closed instead of showing the install form. On Ownware Cloud, opening the app from the console starts a fresh session.

Wrong passwords are counted per address and, since 4.1.2, per account too (the email typed, in any case, stored only as a hash). After 10 wrong passwords in 10 minutes the sign-in at /login answers "Too many attempts — wait a few minutes and try again." for that address, and for that account from any address. A right password clears both counts [src: app/src/LoginRate.php:40].

Wrong two-step codes (for people who turned two-step sign-in on under /security) are counted per person and per address. After 10 wrong codes in 10 minutes the code step answers "Too many wrong codes. Wait ten minutes, then sign in again. A recovery code works in place of the code." even for the right code, and the half-finished sign-in is dropped, so the password is asked again [src: app/src/LoginRate.php:91]. The right code clears that person's count; an address's count runs out ten minutes after its wrong codes. Before 4.1.2 the code step had no limit.

If you are locked out: wait ten minutes, then sign in again with your password and a current code, or one of your recovery codes (each works once). If you lost both the authenticator and the recovery codes, whoever runs the server can turn two-step sign-in off for you in Commissa's database (by default the SQLite file in data/, or your MySQL database): UPDATE users SET totp_secret = '' WHERE email = 'you@example.com'; Then sign in with your password and turn it on again under /security.

On Ownware Cloud, opening Commissa from the console starts a fresh session [src: app/index.php:58].

The web installer stays closed when the database does not answer: with config.php in place, /install now answers 503 "This app is already installed (config.php exists), but its database is not answering, so the installer stays closed." and writes nothing until the database answers again. Before 4.1.2 a database outage (or a missing SQLite file) showed the install form there, and whoever found it could set up a new administrator. To install again from scratch, remove config.php first [src: app/install/index.php:21].

New in 4.1.3: security update

New in 4.1.3 (security update): no other site can put Commissa's pages in a frame; the /mcp address refuses requests from other sites' pages; company sign-in is stricter; and the audit CSV treats one more first character as text. Nothing else changed: no feature, screen, setting or database table, and your data, config.php and data/ are not touched.

No other site can frame these pages. Every page now answers Content-Security-Policy: frame-ancestors 'self' and X-Frame-Options: SAMEORIGIN, so a page on another site cannot show Commissa in a hidden frame and trick a signed-in person into pressing a button [src: app/index.php:4]. The installer answers frame-ancestors 'none' and X-Frame-Options: DENY [src: app/install/index.php:4]. If you had Commissa framed inside another site of your own, that frame now stays empty; link to it instead.

/mcp refuses other sites. A request whose Origin header names another site gets 403 before anything is read. A desktop or server client sends no Origin and is served as before, and so is a page at Commissa's own address [src: app/src/Mcp.php:272].

Single sign-on. The sign-in now uses PKCE (S256), so an intercepted sign-in code is of no use on its own; a sign-in that comes back to a different browser session is asked to start again [src: app/src/Oidc.php:228]. With Google as the provider, a company address needs a Google Workspace account of that same domain (the token's hd claim), and Google must report the address as verified, so a personal Google account made with a work address no longer gets in [src: app/src/Oidc.php:215]. Gmail addresses work as before, other providers are unchanged, and password sign-in is not affected.

The audit CSV. A cell that starts with a carriage return now opens in a spreadsheet as text, as cells starting with = + - @ or TAB already did [src: app/src/AuditLog.php:138].

Docker. The Dockerfile now installs PHP's zip extension, so a Docker install can make the backup with files [src: app/Dockerfile:7].

Also changed, with no effect on a running install: the corner card on a public demo offers only the purchase, and API.md no longer shows the config example of a product we retired.

← Back to Commissa · Quickstart · API · Test run