Home›Expensa›Manual
Expensa · Manual

Expensa Manual

Expensa — User Manual

Expense reports built from receipts, on your own server. Version 4.1.3 [src: app/controllers/api.php:18].

About this manual

Every statement here was written by reading Expensa'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 Expensa is, and the one thing it is not

Upload receipt images or PDFs, an AI reads them, a person corrects what it read, and the result exports as CSV or PDF [src: app/controllers/v3.php:329].

There is no approval workflow. Nothing in Expensa approves, rejects or routes a report to a manager, and "reviewed" means only that a human has checked the extracted figures [src: app/controllers/v3.php:330]. That sentence is not a caveat buried in this manual — it is in the instructions the product gives to any AI agent connecting to it, so an agent cannot mistake the product for something it is not.

The shape of the thing

A report holds a name, a period and notes [src: app/src/Database.php:221]. Receipts belong to a report [src: app/src/Database.php:231], each carrying the file, what was read off it, and a status.

What happens to a receipt

Three stages, and they are kept deliberately separate.

A model reads the file. Expensa supports OpenAI, Anthropic and a local Ollama installation [src: app/src/Llm.php:212] — the last of which means receipts need never leave your own machine. Under the model field, Settings suggests model ids for the chosen provider; for messy or handwritten receipts it names Sonnet 5 (claude-sonnet-5), while the Anthropic default stays claude-haiku-4-5-20251001.

The result is canonicalized without a network call. Whatever shape the model returned becomes a clean record: integer-cent money, an ISO date, a currency code, and a category snapped to your configured list [src: app/src/Extraction.php:9]. That work is deterministic, so your exported totals are trustworthy regardless of which model produced them.

A person reviews it. Only then does the receipt become reviewed.

The separation is the point: nothing in the normalizing layer touches the database or the network, which is what makes every number in it testable on its own [src: app/src/Extraction.php:9].

What "canonical" means in practice

Each receipt ends with the same seven fields, whatever the model called them [src: app/src/Extraction.php:24].

Money parses from human formats — $1,234.56, 1.234,56 € — to integer cents [src: app/src/Extraction.php:71].

Dates go to ISO. A four-digit leading number is read as year-first; a component above twelve settles the ambiguity by itself; and only when neither does is the day-first setting consulted [src: app/src/Extraction.php:142]. That setting is yours [src: app/src/Database.php:199], and it exists because 04/03/2026 means two different days on two sides of an ocean.

Currency resolves from a code or a symbol [src: app/src/Extraction.php:177].

The category is always a real one. A model's free text is snapped to your configured list — exact match, then a keyword synonym, then a substring, then the fallback — so a receipt is never left blank and never lands on an off-list value [src: app/src/Extraction.php:276].

A payment method it does not recognize is kept, not discarded — cleaned and title-cased rather than thrown away [src: app/src/Extraction.php:249].

Status cannot skip a stage

Receipts run pending → extracted → reviewed [src: app/src/Extraction.php:32]. Saving a review yields "reviewed" only once a receipt has been extracted [src: app/src/Extraction.php:373]; a pending receipt stays pending.

Mileage and per-diem lines

Not every expense has a receipt. A mileage line is a distance × a rate per mile or kilometer; a per-diem line is a number of days × a day rate [src: app/src/Allowances.php:65]. Both rate tables are yours: Settings → Mileage and per-diem rates, one rate per line (Car | 0.45 | mile, Overnight away | 25.00) [src: app/src/Allowances.php:25]. Expensa ships no official rates and makes no claim about what any tax authority allows.

On a report, Add mileage or per diem picks one of your rates and takes the distance or the days (a half day is 0.5), a date and a route or purpose [src: app/controllers/app.php:713]. The amount is exact integer arithmetic, rounded half up (42.5 miles × 0.55 = 23.38), in your base currency. The line then behaves like any receipt: it waits to be approved, counts in the totals and appears in every export, with its quantity and rate in the API.

Spending limits (3.2.0)

Settings → Spending limits takes your own limits per category, in your base currency: Meals | 50.00 for a line, Meals | 75.00 | day for a day of a report. A line over a limit is flagged "over policy" on the report, with the reason, and never blocked; the approver decides [src: app/src/Policy.php:42]. A line in another currency is not compared (Expensa holds no exchange rates) and says so. Approve within policy approves in one click only the lines that are extracted, under every limit for their category, in the base currency, and not flagged as possible duplicates [src: app/controllers/app.php:789]. Everything else stays for a person.

Duplicate detection

Expensa flags a receipt that repeats an earlier one — same vendor, same date, same amount, with vendor names compared after normalizing case and spacing, because "UBER" and "Uber " are one merchant [src: app/controllers/v3.php:162].

Three deliberate choices are worth knowing.

It flags; it never blocks [src: app/src/Database.php:116]. The verdict is advisory, and the report exports either way.

Only the later claim is flagged. The search is restricted to receipts raised before this one, and the source explains why: matching in both directions flagged both halves of every pair, which told the operator the original was suspect too and doubled the warnings they had to clear — when the first claim is the one that is fine [src: app/controllers/v3.php:170].

Absence is not a match. A receipt with no amount or no date is never a duplicate of anything [src: app/controllers/v3.php:160].

Category rules — what the install learns

Expensa can remember that a given vendor belongs in a given category [src: app/src/Database.php:160], and a hit counter shows you how often each rule has been used [src: app/src/Database.php:163].

Two guards keep the feature from turning into a liability.

A rule cannot invent a category. Its category is normalized through the product's own snapping function against your configured list, so a rule is a shortcut into the existing vocabulary and never a way to widen it [src: app/controllers/v3.php:240].

A rule never overwrites what was read off the paper. It fills a category only where the extractor left one empty or fell back to "Other" — because otherwise a single bad rule silently rewrites history across the whole register [src: app/controllers/v3.php:262].

Totals, and the currency question

Totals are computed per currency, and a report carries a flag when more than one is present [src: app/src/Extraction.php:309].

Expensa performs no currency conversion. There is no exchange rate anywhere in the aggregation: a report with euros and dollars in it produces a euro total and a dollar total, not a combined figure [src: app/src/Extraction.php:317]. That is a deliberate refusal rather than a missing feature — a converted total is only as good as the rate and the date behind it, and the product holds neither.

Receipts with no amount yet are excluded from the totals and counted separately as unpriced [src: app/src/Extraction.php:311], so a total never quietly treats a missing figure as zero.

Within each currency, categories are totaled and the largest is listed first [src: app/src/Extraction.php:317].

Importing expense lines

Import takes a CSV of expense lines, from a spreadsheet or exported from another expense app, into a new report. Columns are matched by their header: a date ("Date", "Transaction date", "Timestamp"…), a merchant ("Merchant", "Payee", "Description"…) and an amount ("Amount", "Total"…) are needed, and a category, currency, tax and payment method are picked up when present [src: app/controllers/api.php:280]. You see what would be imported and what would be skipped, with the reason, before anything is written, and the lines arrive reviewed, since a person keyed them [src: app/controllers/api.php:303]. Since 3.3.0 that dry run applies exactly the checks the import does, so a row it counts as ready is a row the import takes [src: app/controllers/api.php:361].

Since 3.3.0 a date written with its time, as many apps export it ("2026-03-04 12:34:56", "2026-03-04T08:10:00Z", "04/03/2026 09:10"), 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:147]. A time that is not a real time ("25:99") leaves the row skipped, with the reason. The same reading applies wherever Expensa takes a date: the receipt form, the API and MCP.

Exporting

Three named layouts [src: app/src/ExportPresets.php:30]: Expensa's own full-width CSV, a QuickBooks Online three-column bank CSV, and a Xero bank-statement CSV.

Both accounting layouts follow the debit convention their importers expect — money spent is negative [src: app/src/ExportPresets.php:16] — and the Xero layout writes dates as dd/mm/yyyy, which is what its UK and Australian default expects [src: app/src/ExportPresets.php:18].

There is no API connection to any accounting package, and the source is emphatic that the product's own marketing must keep saying so: nothing is pushed anywhere, there is no OAuth, and there is no vendor that can disappear [src: app/src/ExportPresets.php:10]. You get a file their importer recognizes, and you upload it yourself.

No preset invents a figure — an amount with no value stays empty rather than becoming 0.00 [src: app/src/ExportPresets.php:22].

Every receipt in one download (3.2.0)

The report's Export menu has All receipts (ZIP): each receipt file, numbered in the report's order and named by date and vendor, plus an index.csv listing every line (a mileage or per-diem line is listed as having no receipt, and a missing file says so) [src: app/controllers/v3.php:1069]. Only a file in the app's own upload folder goes in: a stored path pointing anywhere else is left out [src: app/controllers/v3.php:1044]. The ZIP is written by Expensa itself, with no PHP extension, one file at a time, so a report full of photos is never held in memory whole. A claimant can download their own reports only.

Spreadsheet formula injection

Every export passes through a guard that neutralizes cells a spreadsheet would otherwise execute. A cell beginning =, +, -, @, tab or carriage return can run as a formula in Excel, LibreOffice or Google Sheets — the source's own example is a vendor named =cmd|'/c calc'!A1 — so such a value is prefixed with an apostrophe and renders as literal text [src: app/src/Extraction.php:388].

Plain numbers are deliberately left alone, so your amount and tax columns still import as numbers rather than text [src: app/src/Extraction.php:391].

The escaper is shared rather than copied, and the source says why in a line worth quoting: a second copy is how one export ends up injection-safe and the other does not [src: app/src/Extraction.php:404].

Telling the claimant

Expensa can e-mail the person who submitted the receipts when their report is reviewed [src: app/src/SubmitterMail.php:3], and (3.2.0) when it is marked paid, with the date and the reference [src: app/src/SubmitterMail.php:92]; each has its own switch.

It ships off and takes two switches to enable [src: app/src/SubmitterMail.php:9]. It sends through your own SMTP rather than any relay of ours, and carries only what the claimant already knows — their report's name, its period and their own totals [src: app/src/SubmitterMail.php:11]. A mail failure never breaks the review that triggered it [src: app/src/SubmitterMail.php:12].

Every attempt is recorded, refusals included [src: app/src/Database.php:256], because "was the claimant actually told?" is the question behind every chased expense.

Claimants: staff who see only their own reports

Give a member of staff the claimant role and they can create reports, add receipts (on a phone: Take a photo of a receipt, as many shots as needed, then one upload), add mileage and per-diem lines and correct their own lines, on their own reports only [src: app/controllers/v3.php:79]. A colleague's report does not exist for them: every report and receipt route loads its report through one ownership check and answers "not found" otherwise [src: app/controllers/v3.php:94]. Their home screen is their own list.

A claimant never approves. Their save is a correction, not a review: it leaves a line "to approve", and changing a line that was already approved sends it back [src: app/controllers/app.php:609]. They cannot mark anything paid, and they see no rules, audit trail, subject search, import, settings or team.

The claim link: staff without an account (3.3.0)

Settings → Claim link makes a private link for people who claim expenses without an account (temporary staff, a volunteer, a contractor): they give their name, optionally an email to hear when it is paid, a note, and up to 10 receipt photos or PDFs [src: app/controllers/v3.php:1125]. Each claim becomes a new report in their name (they are its submitter, so "Tell the person who submitted" reaches them), with its receipts waiting to be read and approved like any other. Nothing is extracted or approved on arrival, and nobody with the link sees anything already claimed. The report says it came through the claim link, and from whom, so the approver can check. The link is off until switched on, off on the public demo, compared in constant time, and replaceable (the old one then says "not found") [src: app/controllers/v3.php:1105]. Everyone together can send 60 receipts an hour, a hidden field turns away form-filling robots, and the link is left out of the shareable JSON backup like other credentials.

Paid: closing the loop

Once every line of a report is approved, an approver (member or admin) can Mark paid with the date the money went out and an optional payment reference [src: app/controllers/app.php:754]. The report shows who recorded it, the Reports list has a Paid tab, the report.paid webhook fires, and the claimant can be told (below). A paid report's lines are closed: nothing is added, changed or deleted until an approver marks it unpaid [src: app/controllers/v3.php:111].

Users, roles and what each may do

Four roles (the claimant is new in 3.2.0) [src: app/controllers/v3.php:56]. The claimant's grants apply to their own reports only.

Permissionviewerclaimantmemberadmin
report.viewyesownyesyes
report.exportyesownyesyes
report.create—yesyesyes
receipt.upload—ownyesyes
receipt.edit—own, never approvesyesyes
receipt.extract—ownyesyes
audit.viewyes—yesyes
rules.viewyes—yesyes
gdpr.exportyes—yesyes
report.edit——yesyes
receipt.approve——yesyes
rules.edit——yesyes
report.pay——yesyes
views.save——yesyes
settings.edit———yes
users.manage———yes

3.2.0 gave approving, paying, rules and subject export their own names so the claimant could be granted exactly its share; viewer and member keep precisely what they had.

Note the viewer row: a viewer can export. That is intended. The source names the seat — the accountant or bookkeeper, who reads the reports and the totals and exports them but changes nothing, because the person who reconciles the books is usually not the person who claimed the expense [src: app/controllers/v3.php:52].

Adding people, and two guards on the role system

People are added by e-mail invitation, and an invitation defaults to the viewer role rather than to anything privileged [src: app/src/Database.php:152].

Upgrading never demotes anybody. The role column was added to installations that predate it with a default of admin, and the source explains the choice: an existing install being upgraded must not have its people silently demoted out of their own product, while new accounts are always chosen explicitly [src: app/src/Database.php:106].

The last admin cannot be removed. Demoting or deactivating the only administrator would lock everybody out permanently, so the attempt is refused [src: app/src/RolesKit.php:69], and the check runs on the route that saves a user's role [src: app/controllers/v3.php:881].

There can be as many administrators as you want; the guard only refuses the removal of the final one.

The API and agent access

The REST interface sits behind a key [src: app/controllers/api.php:18]. Reports and receipts can be listed, fetched, created and updated, and categories read.

Five tools are exposed to an agent, of which two write [src: app/controllers/v3.php:334]:

ToolWrites?What it does
list_reportsnoReports with state, receipt counts and per-currency totals
report_detailnoOne report with its receipts
category_totalsnoCategory breakdown
create_reportyesOpens a report
receipt_updateyesCorrects what was read off a receipt

Receipt files cannot be uploaded through this endpoint — that stays in the browser [src: app/controllers/v3.php:333].

What Expensa does not do

It has no approval chain [src: app/controllers/v3.php:330]. There is no manager routing and no policy engine: a member or admin checks each line (Approve), records that a claim was paid, and that is all. If you need multi-step approvals before money is committed, that is a different product.

It does not hold official mileage or per-diem rates. The rates are the ones you enter.

It does not convert currencies [src: app/src/Extraction.php:317]. Mixed-currency reports produce one total per currency and no combined figure.

It does not connect to your accounting package [src: app/src/ExportPresets.php:10]. It writes a file that package's importer recognizes, and you upload it.

It does not decide what a receipt means. A model reads it, the product canonicalizes what came back, and a person confirms it. Duplicate detection flags and never blocks [src: app/src/Database.php:116]; the judgment stays with you.

Subject data export, and why there is no erase (4.1.1)

A receipt's vendor is often a person: a driver, a sole trader, a contractor. Subject data export (for whoever holds the export permission) finds every receipt naming them and exports it, with the report it belongs to, as JSON. The name searched for is never written to the audit trail.

There is deliberately no "erase this person" button. A receipt is a business record backing a tax deduction; erasing accounting records is a retention decision between you and your tax authority.

Kept: everything, because there is no erase: the receipts and the reports that claim them, with the claimant's name and email, back tax deductions.

Looking after your install (from 4.0)

Updates from inside Expensa

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; Expensa 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. Expensa is unavailable for about a minute while this runs. The update replaces Expensa'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 Expensa 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 Expensa 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: two expense reports with categorized receipts to try things on. You can remove it in one click. [src: app/install/index.php:141]. 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 expense reports and receipts? 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:86]. 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 Expensa 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 Expensa 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/expensa/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)

Expensa 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 Expensa 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. Accepting an invite starts a fresh session. Single sign-on starts a fresh session. 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 Expensa'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.

Accepting an invite now starts a fresh session (a new session id), as every other sign-in already did [src: app/controllers/v3.php:932].

Single sign-on now starts a fresh session too [src: app/controllers/v3.php:977].

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

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:22].

New in 4.1.3: security update

New in 4.1.3 (security update): no other site can put Expensa'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 Expensa 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 Expensa 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 Expensa'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 Expensa · Quickstart · API · Test run