Cashora Manual
Cashora — User Manual
A petty-cash ledger: boxes, floats, disbursements and replenishments. Version 4.1.3 [src: app/controllers/api.php:19].
About this manual
Every statement here was written by reading Cashora'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 Cashora is, and what it is not
A petty-cash ledger. Each box has an opening float; disbursements take money out and replenishments put it back [src: app/controllers/v3.php:68].
It records cash movements only. The source states the positioning without hedging: it is not a payment processor, it never touches cards, bank rails or crypto, and it documents money that has already changed hands [src: app/src/Cash.php:13].
The invariant
A box can never go below zero. A payout larger than the box holds is refused — and it is refused for an agent exactly as it is for a person at the tin [src: app/controllers/v3.php:70].
The refusal is not a code. It is a sentence that tells you what to do: the box holds this much, a payout of that much would take it below zero, which a cash box cannot do — and if the cash really was there, record the replenishment that topped it up first, then the disbursement [src: app/controllers/app.php:439].
One rule, one function, three callers
The invariant lives in a single pure function, and the screen, the REST API and the agent endpoint all call it [src: app/controllers/v3.php:6]. An agent therefore gets the identical refusal sentence a person gets, word for word [src: app/controllers/v3.php:8].
The REST documentation says the same thing to an integrator: a payout that would take a box below zero is refused with 422, "the same rule, and the same sentence, the browser form applies" [src: app/controllers/api.php:177].
A check is not a claim — and the product learned that the hard way
This is the part worth reading, because it is the difference between a rule and a guarantee.
Checking the balance and then writing the row is not enough. Between the read and the write, another payout can land. Two clerks emptying the same tin in the same moment both see enough cash, both write, and the box goes negative — the one thing the product promises cannot happen [src: app/controllers/app.php:447].
The source records that this was proven, not theorized: an audit interleaved two reads before either write, and box 4 holding €267.10 allowed both payouts of €267.10, landing at −€267.10 [src: app/controllers/app.php:450].
The fix is one serialized critical section per box [src: app/controllers/app.php:453]:
- On SQLite, the write lock is taken up front with
BEGIN IMMEDIATE— a deferred transaction would try to upgrade mid-way and deadlock the second writer instead of queueing it [src: app/controllers/app.php:454]. - On MySQL, the box row is locked with
SELECT … FOR UPDATE, so a concurrent payout on the same box waits and then re-reads the balance the winner left behind [src: app/controllers/app.php:456].
Boxes are independent, so payouts on different boxes never block each other [src: app/controllers/app.php:457].
And the rule itself is not duplicated inside the locking code — the balance still comes from the snapshot and the sentence still comes from the refusal function. The atomic writer only decides when they are read [src: app/controllers/app.php:459].
The money
Integer cents throughout, with sums computed in PHP and clamped, never in SQL — because a SQL SUM() over many integer rows can overflow and fail under MySQL's strict mode [src: app/src/Cash.php:10].
Values are clamped to a bound that fits a MySQL integer with headroom [src: app/src/Cash.php:21].
Thirty currencies are offered; storage is the three-letter code and display prefixes a symbol where one is known, otherwise showing the code itself [src: app/src/Cash.php:24], [src: app/src/Cash.php:30].
Balance, float and variance
A box's balance is its opening float, less disbursements, plus replenishments [src: app/src/Cash.php:76].
Variance is the balance measured against the imprest float [src: app/src/Cash.php:86] — the gap between what the tin should hold under the float system and what the ledger says it holds — with a label attached [src: app/src/Cash.php:91] and a status combining both figures [src: app/src/Cash.php:102].
For a period report, the opening figure is the float adjusted by everything before the period [src: app/src/Cash.php:114], and the closing figure follows from the period's own movements [src: app/src/Cash.php:123].
Categories and export
Spending rolls up by category [src: app/src/Cash.php:133].
CSV export is hardened in the same engine as the rest of the money maths [src: app/src/Cash.php:168], so the exporters and the screen cannot disagree about a figure.
The journal for your accounts (from 3.2)
On Period report, Journal for your accounts turns a month of a box into the journal the bookkeeper posts: each category's spend debited to its expense account and the month's total credited to the box's cash account, and each top-up debited to the box's cash account and credited to the funding account [src: app/src/JournalCsv.php:54]. It comes in three layouts [src: app/src/JournalCsv.php:26]: a generic one (Date, Account, Description, Debit, Credit), QuickBooks Online (Journal No, Journal Date, Account, Debits, Credits, Description, one journal number per box and month) and Xero manual journal (Narration, Date, Description, AccountCode, and one signed Amount: debits positive, credits negative, dates day/month/year) [src: app/src/JournalCsv.php:86]. Amounts carry no thousands separator, so an importer reads them as numbers [src: app/src/JournalCsv.php:113].
The account names are yours: set a category's account on Categories, a box's Cash account on the box's edit page, and the Funding account (where top-ups come from) in Settings. A blank one falls back to a plain name: the category's own name, "Petty Cash", "Bank" [src: app/controllers/v32.php:205]. Each journal balances to the cent or is not exported, and boxes counting different currencies are exported one box at a time; a month with no movements says so instead of giving an empty file. It is a file for your accounting package's journal import: nothing is sent anywhere, and no accounting package is connected [src: app/controllers/v32.php:246].
The month's receipts in one download (from 3.2)
On Period report, each box has Receipts (.zip): every receipt photo or PDF attached to that box's payouts in the month, named by date, payee and amount so they sort by date in any folder, with an index.csv listing every payout and whether it has a receipt [src: app/controllers/v32.php:209]. It is written by Cashora itself; no zip extension is needed on the server.
Deliberate omissions, recorded as decisions
The source lists three things the product does not have, and says they are recorded "so they read as decisions rather than gaps" [src: app/controllers/v3.php:11]:
- No email, carried over from the previous version's opt-out.
- No calendar feed — a petty-cash ledger has no future-dated events, because the transaction date is history [src: app/controllers/v3.php:13].
- No data-subject kit, because there is no data-subject table to serve one from [src: app/controllers/v3.php:14].
Documented here because a buyer comparing products should be able to tell a considered absence from an oversight, and these are the former.
The API and agent access
record_entry is the only tool that writes; every other tool is read-only [src: app/controllers/v3.php:72].
The agent instructions carry the invariant in full [src: app/controllers/v3.php:70], and the write tool repeats it: a payout larger than the box holds is refused, because a cash box cannot go below zero [src: app/controllers/v3.php:141].
The REST interface takes decimals in and returns integer cents [src: app/controllers/api.php:177], and signs its webhook [src: app/controllers/api.php:177].
Backups, and the receipt files
Both database backups — the JSON export and the raw SQLite file — are an administrator's download (settings.edit) [src: app/controllers/api.php:291]. They carry table rows only. A receipt photo or PDF is a file under data/uploads/receipts/ that its disbursement row points at, so from 3.1.4 the receipt files download beside them as one archive, Download receipt files (.tar), also for administrators only [src: app/controllers/api.php:307]. To move the ledger to a new server, restore the database and extract that archive into data/uploads/receipts/.
The JSON export and the scheduled backup leave out the same list of credentials: password hashes, API-key hashes, webhook secrets, the SSO client secret, the backup-token hash, each box's hand-in link and every two-factor seed and recovery code [src: app/controllers/v3.php:448].
The approval limit (from 3.2)
Settings → Approval limit (blank by default, meaning none). With a limit set, a payout above it recorded by anyone but an administrator is not taken from the box: it becomes a request [src: app/controllers/app.php:522]. Payouts within the limit, and an administrator's own, are recorded at once as before [src: app/controllers/app.php:510]. A request the box could not pay now (not enough in it, or a closed month) is refused at once with the same sentence as any payout.
Requests wait on Approvals (the menu shows how many), on the box's page and under Needs your action. An administrator approves or declines each; a decline needs a reason, which the person who asked sees [src: app/controllers/v32.php:342]. Approving records the payout through the same writer as every other, so the box still cannot go below zero and a closed month still refuses it; if the cash is no longer there, the request goes back to waiting and the page says why. A request is decided once, however many times the button is pressed [src: app/controllers/v32.php:286]. The same limit applies through the REST API (answering 202 with the request) and the AI tool, and a CSV import cannot carry a payout above it for anyone but an administrator [src: app/controllers/api.php:411]. The limit is one amount, read in each box's own currency.
Receipt photos, and reading them with your own AI key (from 3.2)
The payout form takes the receipt photo itself: on a phone, Receipt photo opens the camera. The photo is kept while the form is being filled in (for that sign-in, a day at most) and saved with the payout, or with its request when it waits for approval [src: app/controllers/v32.php:362]. A photo larger than your server accepts (PHP's upload_max_filesize, often 2 MB unless your host raised it; Cashora takes up to 8 MB) is refused with a sentence naming the limit, and nothing is saved.
Reading is off until you switch it on in Settings → Read receipts with your own AI key. Cashora has no AI of its own: you choose OpenAI, Anthropic or your own Ollama server and enter your own key [src: app/src/ReceiptRead.php:28]. Then Read the receipt on the payout form sends that one photo to that service and fills in the payee, date, amount and category for the person to check before saving [src: app/controllers/app.php:584]. It only fills fields that are empty; the category is one of your own active categories or none [src: app/src/ReceiptRead.php:38]; and anything it cannot trust stays empty with a note: a total it could not read, a date that is unclear or in the future, or a receipt in another currency than the box [src: app/src/ReceiptRead.php:57]. Totals are read as printed, "12,50" and "1.234,56" included [src: app/src/ReceiptRead.php:94].
The key goes only into the request to the service you chose. It is never shown again, never in an error message (a provider's error text has anything shaped like a key taken out) [src: app/src/ReceiptRead.php:181], never in the audit trail (which records each read: who, which service, and whether it worked) [src: app/controllers/v32.php:394], and the shareable JSON backup leaves it out. The public demo sends no photo anywhere. Your provider bills your key for each read.
Counting the tin
A count records what is physically in the box against what the ledger says should be there, with expected frozen beside counted at that moment; a difference is recorded as a difference, and no entry is invented to hide it [src: app/controllers/v3.php:529].
Closing the month
From 3.2, each box's month is counted and closed on the box page, once the month has ended (or on its last day) [src: app/src/MonthClose.php:64]. The custodian counts the tin, note by note and coin by coin for the common currencies or as one total, and a reviewer who is not the custodian checks it; both names are recorded [src: app/controllers/v32.php:38]. The count is an ordinary count, expected frozen beside counted, and any difference needs a reason: it is recorded as it is, never balanced away [src: app/controllers/v32.php:20].
The close freezes the month's figures (opening balance, paid out, topped up, balance at month end) [src: app/src/MonthClose.php:94] and locks the month for that box: nothing dated in it can be added, deleted or re-categorized, through the screen, the REST API, the AI tool, the import or the bulk action, and the refusal says to record a correction in an open month [src: app/src/MonthClose.php:81]. A late receipt can still be attached to a payout that has none; a receipt already on file is not replaced or removed. An administrator can reopen a month; the reopening is audited, and closing it again replaces its figures [src: app/controllers/v32.php:96].
The month pack is one PDF for the bookkeeper: the month's figures, the count sheet (by denomination when counted that way) with the custodian's and reviewer's names and signature lines, and every payout with its receipt status, counting those with none [src: app/controllers/v32.php:133].
Receipts handed in on a box's link (from 3.3)
Somebody who spends from a box but has no account, such as a volunteer or a new starter, can hand in a receipt on the box's own link. The link is off until a custodian or an administrator makes it under Handed in on the box's page [src: app/controllers/v33.php:111]. It is long and random, it opens only that box, and it stops working while the box is archived [src: app/controllers/v33.php:17]. New link retires the old one at once and Switch the link off closes it; each change is in the audit trail [src: app/controllers/v33.php:116].
The page behind the link asks for a name, what was paid, the day (today or earlier), what it was for, and a photo of the receipt (PNG, JPEG, WebP or PDF, under 8 MB); it shows nothing about the box but its name [src: app/controllers/v33.php:50]. It asks search engines not to index it [src: app/controllers/v33.php:69]. One box's link takes up to 20 hand-ins an hour, then asks people to try again later [src: app/controllers/v33.php:86].
A hand-in moves nothing in the box. It waits under Handed in, with its photo, for the custodian [src: app/controllers/v33.php:40]. Record as a payout opens the ordinary payout form filled in from the hand-in, so every rule of that form still applies: the box cannot go below zero, a closed month refuses it, and above the approval limit it becomes a request [src: app/controllers/v33.php:125]. Saving attaches the hand-in's photo unless you take another, and a hand-in is recorded once, however many times the form is sent [src: app/controllers/v33.php:144]. Turn down records nothing and removes the photo [src: app/controllers/v33.php:159]. Viewers see neither the link nor the hand-ins.
What Cashora does not do
It does not move money [src: app/src/Cash.php:13]. It records cash that has already changed hands.
It does not let a box go negative — for a person, an integrator or an agent [src: app/controllers/v3.php:70], and not under concurrency either [src: app/controllers/app.php:453].
It does not re-implement its own rule in three places [src: app/controllers/v3.php:6]. There is one function, and everything calls it.
It does not sum money in SQL [src: app/src/Cash.php:10].
It does not email, publish a calendar or ship a data-subject kit [src: app/controllers/v3.php:11] — each for a stated reason.
Looking after your install (from 4.0)
Updates from inside Cashora
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; Cashora 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. Cashora is unavailable for about a minute while this runs. The update replaces Cashora'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 Cashora 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 Cashora 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: petty-cash boxes with a month of spending and top-ups to try things on. You can remove it in one click. [src: app/install/index.php:129]. 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 cash boxes and transactions? 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 (open /restore; it has no menu link), Back up once a day by itself is on by default; untick it to stop [src: app/views/restore.php:78]. 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 Cashora 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 Cashora 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/cashora/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)
Cashora 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 Cashora 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 Cashora'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 Cashora 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 Cashora'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 Cashora 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 Cashora 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 Cashora'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.