Supplia Manual
Supplia — User Manual
A supplier register with contract-renewal tracking. Version 4.1.3 [src: app/controllers/api.php:16].
About this manual
Every statement here was written by reading Supplia'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 Supplia is, and the three things it deliberately is not
A supplier and vendor register with contract-renewal tracking [src: app/src/Supply.php:19].
The source rules out three things explicitly [src: app/src/Supply.php:20]:
- It does not process, collect or move money. Payment terms are a free-text label only — no processor, no card, no bank rail, no crypto.
- It signs nothing [src: app/src/Supply.php:23].
- It holds no health data [src: app/src/Supply.php:23].
It does keep documents. A signed contract uploads onto its contract, and insurance certificates, certifications, licenses, NDAs and tax forms upload onto the supplier, each with an optional expiry date [src: app/controllers/v3.php:256]. The file's bytes decide its type (PDF, PNG, JPEG or WebP), the cap is 10 MB, the stored name is generated [src: app/controllers/v3.php:1038], and a file is served only to a signed-in user with document.read, re-checked against the stored bytes [src: app/controllers/v3.php:1142]. A document reference — a link to wherever else the contract lives — can still be recorded instead.
The last day to give notice
A contract can record its notice period: the days of notice its cancellation clause asks for before the renewal date (or the end date, when there is no renewal date). Supplia counts back to the last day to give notice [src: app/src/Supply.php:246], and that date — not the renewal date — is the one the dashboard, the renewal radar and the supplier page watch, because it is the day a rolling contract is decided [src: app/controllers/app.php:168]. It also goes into the calendar feed as its own event, and into the CSV export and the API.
The calendar feed carries end dates, renewal decisions, last days to give notice and document expiries for contracts that are not closed; a closed contract leaves the feed [src: app/controllers/v3.php:521].
Supplia sends no reminder emails. Its dates show on the dashboard, on the radar and in your own calendar through the feed.
Renewal reminders
From 3.2 Supplia can email a reminder before each contract's decision date: the last day to give notice when the contract records a notice period, otherwise its renewal date, otherwise its end date; a closed contract and a date already passed are never reminded [src: app/src/RenewalRemind.php:67]. Reminders are off until an administrator switches them on in Settings → Renewal reminders, with the steps (days before the date; 90, 60, 30 and 7 by default), a daily cap and a team address [src: app/controllers/v32.php:16].
Each step goes at most once per contract and date. A step owns the days from its own day until the next step's day, so an install that was switched off sends only the step it is in now, never the ones it missed [src: app/src/RenewalRemind.php:88]. If the date moves (a renewal is recorded), the steps start again. The claim for a step is written before the mail and is unique, so two page loads can never both send it; a refused send is logged and its step is tried again later [src: app/src/RenewalRemind.php:274].
Each contract can name its owner's email, a person or a shared inbox such as finance@, so a reminder does not leave with one person; without one, the team address is used [src: app/src/RenewalRemind.php:197]. Reminders go out a few at a time on dashboard loads [src: app/controllers/app.php:204] and from your scheduler at /cron/reminders?t=<token>, with the scheduled-backup token [src: app/controllers/v32.php:53]. Preview today shows what goes out today, what is held and why, what comes next, and the send log, refusals included [src: app/controllers/v32.php:45]; opening it sends nothing.
The team's copy (from 3.3). Switch on Send the team address a copy in Settings → Renewal reminders and every reminder that goes to a contract's own owner also goes to the team address, so a reminder no longer rests on one person's mailbox when they are away or have left; a contract whose owner address is the team address gets it once [src: app/src/RenewalRemind.php:209]. The copy says whose reminder it copies, has its own line in the send log and its own claim, so it too goes once, and it does not count toward the daily cap [src: app/src/RenewalRemind.php:340]. It is off until you switch it on.
Supplier documents that expire (from 3.3)
Each supplier's page has Certificates and documents: file an insurance certificate, a license, a certification or any other document with its expiry date, see which have lapsed or are close to it, and remove an old one when the renewed one arrives [src: app/views/vendor_view.php:133]. Documents filed against a contract stay on that contract.
In Settings → Renewal reminders, Also before a supplier's document expires puts those documents on the same schedule, off until you tick it [src: app/views/settings.php:60]. It watches documents filed on an active supplier with an expiry date [src: app/src/RenewalRemind.php:137], each step goes once per document and date, a renewed date starts the steps again, and the reminder goes to the team address, naming the document, the supplier, the date and the supplier's page [src: app/src/RenewalRemind.php:147]. Supplia records the date you typed; it cannot check whether a document is still valid before then. The documents also count on The next 120 days, on their expiry date [src: app/controllers/v32.php:83].
The next 120 days (from 3.2)
The Renewal radar opens with The next 120 days: four 30-day bands (next 30 days, 31–60, 61–90, 91–120), each with how many contracts fall in it, their value and the first few by name [src: app/controllers/v32.php:77]. A contract counts on the date you have to act, the same date the reminders use: the last day to give notice when a notice period is recorded (marked "notice"), else its renewal date, else its end date. Closed contracts are left out.
Search across stored contracts (from 3.2)
Search (in the menu) looks through contracts (title, supplier, notes, payment terms, document reference), suppliers, and the words inside every stored PDF: every word typed has to appear, in any order and any case, and each result shows the passage it was found in [src: app/controllers/v32.php:138]. Supplia reads the text a PDF already carries (anything saved or printed to PDF from a word processor or a browser), including subset fonts, and keeps it beside the file when the file is uploaded; files stored before 3.2 are read the first time Search is opened [src: app/controllers/v32.php:70]. The reader is part of Supplia, with no outside service and no extra software [src: app/src/PdfRead.php:27]. A scan or a photo has no text to read: it is found by its title and notes, and the Search page says how many such files there are. Removing a file removes its text too.
Key dates read from the contract, with your own AI key (from 3.2)
Off until you switch it on in Settings → Reading contracts with your own AI key. Supplia has no AI of its own: you choose OpenAI, Anthropic or your own Ollama server and enter your own key [src: app/src/ContractRead.php:27]. Then Read the key dates appears beside each file attached to a contract. It sends the contract's text when Supplia could read it (a scan goes as the file itself; an Ollama server cannot take a scanned PDF and says so) [src: app/src/ContractRead.php:130], and opens the contract form with what it found laid over it, a list of what would change, and nothing saved until you press Save [src: app/controllers/v32.php:253].
It proposes the title, start, end and renewal dates, whether the contract renews automatically, the notice period in days, the value and the payment terms. Anything it cannot trust is left as it was, with a note: a date it could not read, an end before the start, a value in another currency than the register's [src: app/src/ContractRead.php:54]. A notice period stated in months is proposed as 31 days a month, which gives the earlier cancel-by date, and the form says so [src: app/src/ContractRead.php:79].
The key goes only into the request to the service you chose. It is never shown again, never in an error message (anything shaped like a key is taken out of a provider's error) [src: app/src/ContractRead.php:195], never in the audit trail (which records each read: who, which contract, which service, whether it worked) [src: app/controllers/v32.php:215], and the shareable JSON backup leaves it out. The public demo sends nothing. Your provider bills your key for each read.
Contract status is derived, never stored
Only two states are ever written to the database: active and closed [src: app/src/Supply.php:49]. The four states you see — active, expiring, expired, closed — are worked out from that base plus the end date against today [src: app/src/Supply.php:272].
Nothing writes "expiring" or "expired" to a row, so a status can never go stale [src: app/src/Supply.php:274].
The derivation is short enough to state in full [src: app/src/Supply.php:275]:
| Condition | Status |
|---|---|
| Base is closed | closed — user-terminated, terminal, dates ignored |
| End date is in the past | expired |
| End date inside the notice window | expiring |
| Otherwise, including no end date | active |
The renewal windows
Dates are classified against three thresholds — notice, warning and critical — escalating toward expiry [src: app/src/Supply.php:185].
The thresholds are clamped into non-negative descending order before use [src: app/src/Supply.php:190], so a settings row where the warning window is smaller than the critical one cannot invert the buckets.
The date maths
Both endpoints are anchored at midnight UTC, so a daylight-saving transition can never add or drop a day and "days until" is exact with no off-by-one [src: app/src/Supply.php:10].
The database is never asked for "now" — today flows in from the application in your business's timezone [src: app/src/Supply.php:11].
Extending a contract
Extension pushes the end date out by whole months, and drags the renewal date along by the same span so the notice window keeps its shape [src: app/src/Supply.php:206].
A lapsed contract restarts from today
This is the rule worth knowing, and the source says exactly why it exists [src: app/src/Supply.php:219]:
A lapsed contract restarts from today: silently back-dating an extension would produce a contract that is "extended" and still expired, which is the bug this rule exists to stop.
So extending a contract that ran out last month gives you months from today, not months from the date it lapsed. You cannot extend backwards into the past [src: app/src/Supply.php:205].
Two refusals
Extending by less than one month or more than sixty is refused [src: app/src/Supply.php:213], and extending a contract with no end date is refused with an instruction rather than an error — set one first [src: app/src/Supply.php:217].
Month arithmetic is clamped
31 January plus one month is 28 or 29 February, never 3 March [src: app/src/Supply.php:229].
Money
Contract values are integer cents, and the spend rollup by category is summed in PHP with a final clamp, never in SQL — because a SQL SUM() over many integer rows can overflow under MySQL's strict mode [src: app/src/Supply.php:15].
Values clamp to a bound that fits a MySQL integer with headroom [src: app/src/Supply.php:30].
Exporting
CSV export carries a numeric-aware formula-injection guard [src: app/src/Supply.php:16].
Users and roles
Three roles [src: app/controllers/v3.php:34]:
| Permission | viewer | member | admin |
|---|---|---|---|
vendor.read, contract.read, document.read, report.read | yes | yes | yes |
vendor.write, contract.write, document.write | — | yes | yes |
| everything else | — | — | yes |
Administrative routes are gated by a wildcard permission — _sup_require('*') — which only the admin role satisfies [src: app/controllers/v3.php:37]. Running the product's own permission function confirms it: a viewer and a member are both refused, and only an admin passes.
The gate returns a 403 page with the permission named, rather than a silent no-op [src: app/controllers/v3.php:52].
Backups are an administrator's download
Both backup downloads — the JSON export and the raw SQLite file — take the admin permission [src: app/controllers/api.php:337]. Before 3.1.4 any signed-in user could take them, a viewer included.
What Supplia does not do
It does not move money [src: app/src/Supply.php:20]. Payment terms are a label.
It does not send reminder emails. Renewal dates, notice deadlines and document expiries show on the dashboard, the radar and the calendar feed.
It does not let a status go stale [src: app/src/Supply.php:274].
It does not extend a contract backwards [src: app/src/Supply.php:219].
It does not obey an inverted settings row [src: app/src/Supply.php:190].
It does not sum money in SQL [src: app/src/Supply.php:15].
Looking after your install (from 4.0)
Updates from inside Supplia
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; Supplia 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. Supplia is unavailable for about a minute while this runs. The update replaces Supplia'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 Supplia 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 Supplia 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: eight vendors with contracts in every renewal state to try things on. You can remove it in one click. [src: app/install/index.php:122]. 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 vendors and contracts? 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:74]. 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 Supplia 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 Supplia 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/supplia/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)
Supplia 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 Supplia 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 Supplia'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 Supplia from the console starts a fresh session [src: app/index.php:56].
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 Supplia'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 Supplia 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 Supplia 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 Supplia'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.