Home›Ledgira›Manual
Ledgira · Manual

Ledgira Manual

Ledgira — User Manual

Bank and credit-card statements turned into transactions you can reconcile and export. Version 4.1.3 [src: app/controllers/api.php:20].

About this manual

Every statement here was written by reading Ledgira'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 Ledgira is

A statement goes in as a PDF or an image. A vision model reads it. What comes out is a canonical set of transactions with signed integer-cent amounts, arithmetic a bookkeeper can check, and an export your accounting tool will take [src: app/src/Statement.php:3].

The model's job is reading the paper. Everything after that — the signing, the reconciliation, the running-balance check and the exports — is deterministic and happens with no network call [src: app/src/Statement.php:9].

Money and date parsing are reused verbatim from the same tested engine the rest of the catalog uses; only the statement-specific work lives here [src: app/src/Statement.php:12].

The sign convention — the one rule everything builds on

A credit is positive. A debit is negative [src: app/src/Statement.php:22].

For a current or savings account that reads the way you would expect: a deposit or interest payment increases the balance and is positive; a withdrawal, card purchase or fee decreases it and is negative [src: app/src/Statement.php:25].

For a credit card, the same rule is applied to what you owe. A purchase, interest charge or fee increases the balance owed and is therefore positive; a payment or refund decreases it and is negative [src: app/src/Statement.php:28].

That is deliberate rather than a quirk. It is the standard double-entry treatment of a liability account, and it is what makes one reconciliation identity hold for both kinds of statement [src: app/src/Statement.php:30]:

opening_balance + Σ(signed amounts) == closing_balance

Direction is derived from the sign, not stored alongside it [src: app/src/Statement.php:89]. The two cannot disagree because there is only one of them.

Reading what the model returned

Direction tokens are normalized from whatever a model emits — debit, dr, d, withdrawal, money out, paid out, w/d, -, and the credit equivalents cr, deposit, money in, paid in, + [src: app/src/Statement.php:57], with a prefix fallback so "debit transaction" and "credit adj." also land correctly [src: app/src/Statement.php:64].

Amounts resolve to signed cents. An explicit direction wins; where there is none, the sign the amount was printed with is trusted — so (45.00) and -45.00 are debits and 45.00 is a credit [src: app/src/Statement.php:74]. A value with no parseable number at all returns nothing rather than zero [src: app/src/Statement.php:76].

Both statement layouts are handled: a single amount column with a direction, and separate debit and credit columns [src: app/src/Statement.php:118].

A description that wraps onto a second printed line is joined, not lost (3.2.0). The model is told to keep a wrapped description with its transaction; if it still returns the continuation as a row of its own (text, but no date, no amount and no balance), that text is joined onto the transaction before it [src: app/src/Statement.php:149]. A balance-forward or total row is never joined, and the number joined is written to the audit trail.

A long statement is read over several replies (3.3.0). One model reply holds about 8,192 tokens, which a busy month or a year of transactions can outgrow. When a reply is cut off, every transaction it wrote whole is kept, together with the header (bank, period, balances), and the model is asked for the transactions after the last one it returned, up to 8 replies [src: app/src/Llm.php:253]. A line repeated at the join is dropped [src: app/src/Llm.php:320]. A line lost at the join would show in the running-balance check below. Each extra reply is another call at your provider's cost. A statement that 8 replies cannot finish is refused as before, with a note of how many lines were read, so you can split it.

Account numbers are reduced to their last four digits [src: app/src/Statement.php:106], so a full account or card number is not carried around.

Reconciliation

Ledgira adds up the signed amounts, adds them to the opening balance, and compares the result with the stated closing balance [src: app/src/Statement.php:197].

Three outcomes, and the third is the one that matters:

StatusMeaning
reconciledThe arithmetic lands on the closing balance, within tolerance
discrepantIt does not, and you are told by exactly how much
incompleteAn opening or closing balance is missing, so no claim is made

It never says "reconciled" without both balances. Without them the status is incomplete and the comparison simply is not attempted [src: app/src/Statement.php:208].

The delta is reported as computed minus stated, and the source spells out how to read the sign: a positive delta means the transactions add up to more than the closing balance says [src: app/src/Statement.php:195].

The running-balance check

Separately, Ledgira verifies the statement's own running-balance column against the transactions [src: app/src/Statement.php:238], flagging each inconsistent row with what it expected, what the statement stated, and the difference.

It works two ways and picks automatically. With an opening balance it checks absolutely — each running figure should equal the opening plus everything up to that row. Without one it checks the deltas — each running figure should differ from the previous by exactly that row's amount [src: app/src/Statement.php:232].

Rows with no stated running balance are skipped, because they carry no claim to verify [src: app/src/Statement.php:234]. Nothing is invented to fill a gap.

This is the check that finds a single mis-read digit. The totals can reconcile while one row is wrong and another is wrong by the opposite amount; the running column will not.

Receipts on statement lines (3.2.0)

Receipts (on an extracted bank or card statement) lists the lines a receipt is expected for: payments out of a bank account, charges on a card (a supplier statement has none) [src: app/controllers/app.php:1182]. Attach a PDF or photo to a line; the page counts how many have one, and Missing receipts (CSV) lists the rest, to send to whoever holds them. A receipt keeps the date and amount of its line, so re-extracting the statement (which replaces its lines) puts it back on the line with the same date and amount; when no single line matches it is kept and shown as not linked, never guessed [src: app/src/Receipts.php:28]. Removing a receipt, or deleting the statement, deletes the file.

Also fixed in 3.2.0: saving a supplier statement's header turned it into a bank statement; it now stays a supplier statement.

Status

Four statuses [src: app/src/Statement.php:43], moved by a pure transition rule [src: app/src/Statement.php:283]. A fresh extraction always lands on extracted from whatever state it was in; review is only meaningful once extracted; an unknown event changes nothing.

Importing transactions from a CSV

Import adds the lines of a bank's own CSV export to a statement you pick, after any lines already on it, and then recomputes the reconciliation [src: app/controllers/api.php:253]. Columns are matched by their header: a date ("Date", "Posting date", "Timestamp"…) and a description are needed, and the amount can be one signed column or separate money-out and money-in columns; a balance and a category are picked up when present [src: app/controllers/api.php:229]. You see what would be imported and what would be skipped, with the reason, before anything is written. 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:346].

Since 3.3.0 a date written with its time, as many bank and card exports write it ("2026-09-10 08:15:02", "2026-09-11T16:00:00Z"), 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:101]. A time that is not a real time ("24:10") leaves the row skipped, with the reason. The same reading applies wherever Ledgira takes a date.

Exporting

CSV to RFC 4180, QIF for both bank and credit-card account types, and JSON [src: app/src/Statement.php:17]. The QIF writer emits the account-type header your accounting package expects [src: app/src/Statement.php:370].

Excel (3.2.0). The Export menu also gives an Excel workbook (.xlsx): the CSV's columns, amounts and balances as numbers so a column sums, everything else as text, and a description starting with "=" stays text [src: app/src/Xlsx.php:16]. It needs no PHP extension.

A year at once (3.2.0). The upload takes up to 24 statements in one go; Extract waiting statements on the list reads them five per click, oldest first [src: app/controllers/app.php:419].

Suggested categories (3.2.0). With Settings → "suggest a category" on (the default), the model adds a category to each line, from the categories you already use when one fits [src: app/src/Llm.php:219]. A suggestion is marked "model"; your payee rules replace it, and your own choice always wins. Accept the suggestions on a statement makes them yours and teaches a payee rule for each, so the next statement is categorized by the rules [src: app/controllers/app.php:1118].

Supplier statements (3.2.0). Upload a supplier's statement as the account type "Supplier statement", extract it, and paste your ledger lines for that supplier (reference, date, amount). Check lists which invoices agree, which amounts differ, what is on their statement but not in your books, and what you have that they do not; payments are listed apart [src: app/src/SupplierCheck.php:73]. Nothing is changed in either place.

QBO and OFX (3.2.0). Two more files for accounting packages [src: app/src/Ofx.php:24]: OFX 1.02, the bank-statement format most packages import, and QBO, QuickBooks Web Connect, which is the same OFX carrying your bank's Intuit number (Settings → QuickBooks bank ID; left blank, the file carries none). A card statement is written as a credit-card account, anything else as a bank account. Amounts keep Ledgira's sign, so a debit is never turned into income; each transaction gets a stable id (LG<statement>-<transaction>), so importing the same file twice adds nothing; the closing balance goes in as the ledger balance; text is written in the Windows-1252 character set the file's header names.

Users and roles

Three roles [src: app/src/Perms.php:20]. The source names the asset it is protecting, and it is not the data:

the LLM API key. Extraction spends the buyer's own AI credits, so whoever holds the key holds the spend; and whoever can delete a statement can remove the evidence a set of books was built from. Reviewing and categorising, by contrast, is the daily bookkeeping job [src: app/src/Perms.php:5].

This table was produced by executing the product's own permission function against every permission the routes ask for.

Permissionviewermemberadmin
dashboard.viewyesyesyes
statements.viewyesyesyes
transactions.viewyesyesyes
exports.runyesyesyes
transactions.edit—yesyes
rules.manage—yesyes
statements.upload—yesyes
extraction.run——yes only
statements.delete——yes
settings.view / settings.manage——yes
team.view / team.manage——yes
audit.view——yes

Note the gap between uploading and extracting. A bookkeeper can upload a statement, but only an admin can run the extraction that spends AI credits [src: app/controllers/app.php:619]. That separation is the whole point of the role map here.

A viewer can run exports — that is deliberate, since exporting is how an accountant gets the data out without being able to change it.

Grants below admin are enumerated rather than wildcarded [src: app/src/Perms.php:14].

Backups are for an administrator only

Both backup downloads require the settings permission, which only an administrator holds [src: app/controllers/api.php:209], [src: app/controllers/api.php:221]. Until 3.1.4 they were gated by sign-in alone, so a viewer could download the whole database; a viewer or member now gets a 403, and the Backups card on the Security page is shown only to administrators.

A backup contains every table with only credential columns redacted, so every transaction, the team roster and the audit trail come out in full — which is why it is an administrator's download.

The API and agent access

Six tools are exposed to an agent, of which one writes [src: app/controllers/v3.php:22]: listing statements, statement detail, listing transactions, category totals and export rows on the read side; updating a transaction on the write side.

The instructions given to an agent state two things worth repeating:

Direction is derived from the sign and the two cannot be set independently [src: app/controllers/v3.php:19].

Extraction is not available to an agent at all — it spends the account owner's AI credits and stays a deliberate human action [src: app/controllers/v3.php:20].

What Ledgira does not do

It does not let an agent spend your money [src: app/controllers/v3.php:20], and it does not let a bookkeeper do it either [src: app/controllers/app.php:619].

It does not connect to your bank. It reads statements you already have.

It does not claim a reconciliation it cannot prove. Missing an opening or closing balance produces incomplete, not a pass [src: app/src/Statement.php:208].

It does not invent a running balance for a row that does not state one [src: app/src/Statement.php:234].

It does not do your bookkeeping. It produces canonical, checked, exportable transactions; what they mean in your accounts is yours.

Looking after your install (from 4.0)

Updates from inside Ledgira

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; Ledgira 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. Ledgira is unavailable for about a minute while this runs. The update replaces Ledgira'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 Ledgira 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 Ledgira 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 bank statements with their transactions 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 statements? 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:90]. 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 Ledgira 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 Ledgira 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/ledgira/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)

Ledgira 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 Ledgira 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 Ledgira'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 Ledgira 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 Ledgira'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 Ledgira 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 Ledgira 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 Ledgira'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 Ledgira · Quickstart · API · Test run