Ownware
Home›Expensa›Manual
Expensa · Manual

Expensa Manual, as shipped in the download

Expensa — User Manual

Expense reports built from receipts, on your own server. Version 3.1.4 [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:272].

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:273]. 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:204]. Receipts belong to a report [src: app/src/Database.php:214], 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:201] — the last of which means receipts need never leave your own machine.

The result is canonicalised 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 normalising 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:182], 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:173].

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

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

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:369]; a pending receipt stays pending.

Duplicate detection

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

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

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

Category rules — what the install learns

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

Two guards keep the feature from turning into a liability.

A rule cannot invent a category. Its category is normalised 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:183].

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

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

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:313]. 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:307], so a total never quietly treats a missing figure as zero.

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

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 recognises, 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].

Spreadsheet formula injection

Every export passes through a guard that neutralises 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:384].

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

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

Telling the claimant

Expensa can e-mail the person who submitted the receipts when their report is reviewed [src: app/src/SubmitterMail.php:3].

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:239], because "was the claimant actually told?" is the question behind every chased expense.

Users, roles and what each may do

Three roles. This table was produced by executing the product's own permission function against every permission the routes ask for, rather than by reading its map.

Permissionviewermemberadmin
report.viewyesyesyes
report.exportyesyesyes
audit.viewyesyesyes
report.edit—yesyes
receipt.edit—yesyes
receipt.upload—yesyes
views.save—yesyes
settings.edit——yes
users.manage——yes

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

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

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:277]:

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

What Expensa does not do

It does not approve anything [src: app/controllers/v3.php:273]. There is no manager, no routing and no decision. If you need approvals before money is committed, that is a different product.

It does not convert currencies [src: app/src/Extraction.php:313]. 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 recognises, and you upload it.

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

← Back to Expensa · Quickstart · API · Test run

Affiliate program
Recommend tools people own — earn 35% on every sale. 90-day tracking, instant delivery, payouts by Lemon Squeezy.
Become an affiliate →