Cargora Manual
Cargora — User Manual
Purchase orders matched against what actually turned up. Version 4.1.3 [src: app/controllers/api.php:101].
About this manual
Every statement here was written by reading Cargora'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 Cargora is
Purchase orders, packing slips and delivery notes go in as documents. A vision model reads them. Then the product does the thing a receiving desk actually needs: it matches a purchase order against the slips delivered for it and tells you what is short, what is over, and what never arrived [src: app/controllers/v3.php:25].
The matching engine is pure and offline — feed it two sets of line items and it returns a report the screen and the CSV exporter both render, with no database and no network involved [src: app/src/Reconcile.php:13].
How lines are matched
By SKU, compared with every space removed and upper-cased, so ab 12 matches AB12; punctuation still counts [src: app/src/Reconcile.php:31].
Duplicate SKUs within one document are aggregated — quantities summed, and the first non-null unit price kept [src: app/src/Reconcile.php:10]. Three lines of the same part on one slip become one line of three times the quantity.
A line with no SKU cannot be matched and is skipped [src: app/src/Reconcile.php:39]. The agent instructions say the same thing outright [src: app/controllers/v3.php:26], because it is the single most common reason a line does not appear in a report.
Supplier SKUs: when their part number is not yours (3.2.0)
A supplier's own codes rarely equal yours, so a delivery can show every line twice: "not delivered" under your SKU, "unexpected" under theirs. On the reconciliation, Same item beside an unexpected line picks the order line it really is [src: app/controllers/v320.php:43]. Cargora remembers it for that supplier: from then on their code is read as yours [src: app/src/Reconcile.php:327] in every reconciliation, PO completeness and three-way match, and the report shows their code beside yours. Nothing is guessed: both SKUs have to be on the two documents in front of the reviewer, and Supplier SKUs lists every alias with who confirmed it, so any of them can be forgotten.
What a reconciliation tells you
For every SKU on either document: what was ordered, what was shipped, the difference, whether the unit prices agree, and which SKUs appear on one document but not the other [src: app/src/Reconcile.php:6].
The quantity delta is slip minus purchase order, so a negative number is short [src: app/src/Reconcile.php:104] and a positive one is over [src: app/src/Reconcile.php:110].
Price agreement has three answers, not two
A price is reported as match, mismatch, or unknown — and unknown is what you get when either side did not state a price [src: app/src/Reconcile.php:108].
That third answer matters. A product that reported a missing price as agreement would quietly pass exactly the lines you most need to look at.
Prices must agree exactly; there is no cents of slack [src: app/src/Reconcile.php:21]. Quantities carry only enough tolerance to absorb floating-point noise [src: app/src/Reconcile.php:23].
Deliveries that arrive in pieces
A purchase order rarely arrives in one van, and the completeness view is built for that [src: app/src/Reconcile.php:201].
Cumulative receipt is computed by reconciling the purchase order against every slip's lines concatenated — because the aggregation step already sums quantities per SKU, the same code that matches one slip matches ten [src: app/src/Reconcile.php:188].
Each line lands in one of four states [src: app/src/Reconcile.php:273]:
| State | Meaning |
|---|---|
not_received | Nothing has arrived against this line |
outstanding | Some arrived; some is still owed |
complete | The ordered quantity has arrived |
over | More arrived than was ordered |
Quantities are cumulative; prices are not
This is the design decision worth understanding, and the source explains it well [src: app/src/Reconcile.php:192].
Quantities add up across deliveries, so a cumulative figure is meaningful. A cumulative unit price would not be — the aggregation keeps the first non-null price it meets, so it would silently be whichever slip happened to land first.
So price agreement is reported per slip, against the slip that actually charged it [src: app/src/Reconcile.php:194], and each line carries its own breakdown by slip [src: app/src/Reconcile.php:285].
The reason given is the practical one: that is the only form in which a price gap is actionable, because you dispute it with one delivery, not with an average [src: app/src/Reconcile.php:195].
The supplier invoice: three-way match (3.2.0)
A Supplier Invoice is a document type of its own. On the invoice, Link to this PO names the purchase order it bills; Three-way match then sets every invoice line against the order, everything received across its deliveries, and what earlier invoices on the same PO already billed [src: app/controllers/v320.php:119], and puts the answer at the top: pay this much [src: app/src/Reconcile.php:360].
- A line received and billed at the ordered price (or lower) is paid in full.
- A line billed for more than arrived is paid for what arrived (short).
- A line priced above the PO is held whole, for a dispute; so is one never ordered, or never received, or already billed on an earlier invoice.
- A line with no unit price on the invoice or the PO is left to check: Cargora never divides a line total by a quantity to invent a price.
- Lines with no SKU (freight, fees) cannot be matched and are totaled apart.
The match exports as CSV, the figure to pay first. It tells you what to pay; paying is still yours.
Prices since the last order (3.2.0)
"The supplier quietly raised prices" is caught on the document itself. On a purchase order or a supplier invoice, Prices since the last order from <supplier> sets each SKU's unit price against the same supplier's most recent earlier order or invoice in the same currency, with the reference and date it came from, and marks each one up (with the percentage), down, the same, or not priced before [src: app/controllers/v320.php:304]. The supplier's own SKUs are read through the ones you confirmed. A line with no SKU or no unit price is not compared, because a price is never worked out from a total [src: app/src/Reconcile.php:492]. The first document from a supplier shows no comparison.
A receiving day at once (3.2.0)
The upload takes one document or up to 50, and Let the model tell leaves each file's type (PO, packing slip, delivery note or supplier invoice) to be read on extraction, for a mixed pile. Extract waiting documents on the list reads them ten per click, oldest first [src: app/controllers/v320.php:166].
The email inbox (3.2.0)
Have suppliers, or your own team, send paperwork to a mailbox of your own. Off until you turn it on in Settings → Email inbox; it reads the unread messages over IMAP, keeps every PDF or image attachment as a document whose type is read on extraction, records the sender, marks each message read, and can extract on arrival [src: app/src/Inbox.php:241] [src: app/controllers/v320.php:251]. Run php bin/fetch-inbox.php from cron, or press Check inbox now. Use a mailbox that receives only paperwork.
Signing off a delivery that disagrees (3.2.0)
A reconciliation with a difference shows awaiting sign-off until an administrator records a decision: accepted as delivered, disputed with the supplier, or resolved (credit, replacement, re-delivery) [src: app/controllers/v320.php:206]. A dispute or a resolution needs a note. The decision, who took it and when are kept on the reconciliation, shown on the list and written to the audit trail; it can be changed. A clean delivery needs no sign-off.
Dates on documents (3.3.0)
A document date written with its time ("2026-03-04T08:10:00Z", "2026-03-04 12:34:56"), in the model's reply or in the edit form, is read as the date it shows. The time and any time zone are dropped, never used to move the day [src: app/src/Extraction.php:174]. Before, such a value read as no date, and the document was left undated. A time that is not a real time ("25:99") still makes it no date. Numeric dates otherwise follow the day-first setting.
Users and roles
Three roles. The source names two assets it is guarding, and the second is specific to this product [src: app/src/Perms.php:5]:
A quantity on a matched line is a claim against a supplier. Correcting "received 90" to "received 120" silently changes what the business says it is owed, and deleting a purchase order destroys the evidence a delivery dispute rests on. So correcting lines is the receiving desk's daily job, and deleting the record is not [src: app/src/Perms.php:8].
The first asset is the LLM API key, because extraction spends the buyer's own AI credits [src: app/src/Perms.php:7].
This table was produced by executing the product's own permission function against every permission the routes ask for.
| Permission | viewer | member | admin |
|---|---|---|---|
documents.view | yes | yes | yes |
reconcile.view | yes | yes | yes |
exports.run | yes | yes | yes |
documents.upload | — | yes | yes |
documents.edit | — | yes | yes |
extraction.run | — | yes | yes |
reconcile.run | — | yes | yes |
documents.delete | — | — | yes |
settings.view / settings.manage | — | — | yes |
team.view / team.manage | — | — | yes |
audit.view | — | — | yes |
Note that extraction is a member permission here. Reading a delivery note is part of the receiving desk's daily work [src: app/src/Perms.php:15], so the spend sits with the desk rather than with an administrator. Deleting a document does not [src: app/src/Perms.php:16].
A viewer reads documents, reconciliations and the completeness view, and can export [src: app/src/Perms.php:14].
⚠️ Backups follow the model here
Worth stating because several products in this family do not: Cargora's database backups are permission-gated, requiring the settings permission rather than a bare sign-in [src: app/controllers/api.php:324]. A viewer cannot download the database.
The API and agent access
Six tools are exposed to an agent, of which one writes [src: app/controllers/v3.php:30]: listing documents, document detail, match status, discrepancies and export rows on the read side; updating a line item on the write side.
Extraction is not available to an agent — it spends the account owner's AI credits and stays a deliberate human action [src: app/controllers/v3.php:28].
The instructions also tell an agent the matching rule and its main failure mode up front: lines match by SKU, ignoring case and spaces, and a line with no SKU cannot be matched [src: app/controllers/v3.php:26].
What Cargora does not do
It does not let an agent spend your money [src: app/controllers/v3.php:28].
It does not match on description or price. SKU is the key, and a line without one is skipped rather than guessed at [src: app/src/Reconcile.php:39].
It does not report a price as agreeing when it does not know. Missing prices produce unknown [src: app/src/Reconcile.php:108].
It does not average prices across deliveries [src: app/src/Reconcile.php:192], because an averaged price is not something you can take to a supplier.
It does not raise a claim, hold stock or pay an invoice. It tells you what was ordered, what arrived, what the invoice bills and how much of it to pay. What you do about it is yours.
Looking after your install (from 4.0)
Updates from inside Cargora
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; Cargora 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. Cargora is unavailable for about a minute while this runs. The update replaces Cargora'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 Cargora 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 Cargora 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 purchase order and two packing slips, one with discrepancies to try things on. You can remove it in one click. [src: app/install/index.php:138]. 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 documents and reconciliations? 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 page (/restore, linked from Settings), Back up once a day by itself is on by default; untick it to stop [src: app/views/restore.php:126]. 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 Cargora 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 Cargora 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/cargora/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)
Cargora 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 Cargora 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 Cargora'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 Cargora from the console starts a fresh session [src: app/index.php:63].
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 Cargora'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 Cargora 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 Cargora 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 Cargora'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.