The Accounts screen gets an Add statements panel: pick an account, drop files on it or choose them, and they are saved into that account's folder and imported. The folder stays the source of truth -- an upload only puts a file where `money import` looks, then runs the same import as the Import button, so deleting index.db and re-importing still loses nothing. Files travel base64 inside JSON rather than as multipart. There is no auth, and the JSON-only rule is what keeps another site's form from posting here; multipart is exactly what such a form can send. An upload never replaces a statement: identical contents are a no-op and different ones are refused with 409. Names import would not read back -- not a plain file name, dotfiles, account.toml, outside the account's include patterns -- are refused, and a batch is checked whole before any of it is written. Files are written through a dotfile and renamed, so a concurrent import never reads half of one. The overview now lists the account folders on disk, read fresh so one created after startup is a valid target; it replaces the configured count the empty accounts screen used. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
684 lines
30 KiB
Markdown
684 lines
30 KiB
Markdown
# money
|
||
|
||
A personal finance tracker built around bank statements rather than manual entry.
|
||
|
||
You keep a **data directory with one folder per account**, drop statement exports
|
||
into those folders, and run `money import`. Transactions are extracted and
|
||
tagged by glob rules you write.
|
||
|
||
## Layout
|
||
|
||
```
|
||
~/money/ # the data root (see "Where the data root lives")
|
||
rules.toml # tag rules and transfer definitions, in order
|
||
index.db # SQLite index (rebuildable; safe to delete*)
|
||
checking/
|
||
account.toml # currency + which parser reads this bank's exports
|
||
2026-01.csv
|
||
2026-02.csv
|
||
savings/
|
||
account.toml
|
||
2026-01.csv
|
||
```
|
||
|
||
\* Nothing lives only in the index: it is derived entirely from the statements
|
||
plus rules.toml, so deleting it and re-importing gets you exactly what you had.
|
||
It is a cache and nothing more, which is why it is never migrated — if a new
|
||
build changes its shape, delete it and run `money import` again.
|
||
|
||
## Where the data root lives
|
||
|
||
Checked in order, first one wins:
|
||
|
||
1. `--root DIR` on the command line, before or after the command
|
||
2. `$MONEY_ROOT`
|
||
3. the `root` key in `$XDG_CONFIG_HOME/money/config.toml`, which is
|
||
`~/.config/money/config.toml` unless `XDG_CONFIG_HOME` says otherwise
|
||
4. `~/money`
|
||
|
||
So the usual setup is to write the config file once and never pass a flag again:
|
||
|
||
```toml
|
||
# ~/.config/money/config.toml
|
||
root = "~/documents/finances"
|
||
```
|
||
|
||
A leading `~` is expanded and relative paths are made absolute. `money config`
|
||
prints the resolved root and which of the four rules chose it.
|
||
|
||
## Install
|
||
|
||
```
|
||
go install ./cmd/money
|
||
```
|
||
|
||
Give it the package path, not `cmd/money/main.go`. Naming the file puts the go
|
||
tool in file mode: it names the binary after the source file (`main`) and
|
||
compiles only the files listed, which breaks the moment the package has two.
|
||
|
||
That build runs `pdftotext` from `PATH` for the PDF parsers, so the machine
|
||
needs poppler-utils. To deploy one file with nothing to install alongside it,
|
||
build with `pdftotext` inside:
|
||
|
||
```
|
||
scripts/build-bundled.sh # → dist/money-linux-amd64
|
||
scripts/build-bundled.sh arm64 # → dist/money-linux-arm64
|
||
```
|
||
|
||
It needs podman or docker: `pdftotext` is compiled in a container from a
|
||
checksum-pinned poppler release (`scripts/pdftotext.Containerfile`), as a fully
|
||
static musl executable with only what text extraction needs, then embedded with
|
||
`go build -tags bundled`. The result runs on any Linux of that architecture.
|
||
The first PDF import writes the embedded copy to `~/.cache/money/` (or
|
||
`$XDG_CACHE_HOME/money/`) and runs it from there; `money config` says which
|
||
`pdftotext` is in use. Only Linux on amd64 and arm64 is offered — elsewhere,
|
||
install poppler-utils.
|
||
|
||
Building arm64 from an x86 machine runs the container under emulation, so the
|
||
host needs qemu-user-static and the first build is slow; the executable is then
|
||
cached under `internal/parser/bundled/` and later builds only rebuild money.
|
||
|
||
Poppler is GPL, so a bundled binary is GPL-licensed as a whole. That matters
|
||
only if you hand the binary to someone else.
|
||
|
||
## Usage
|
||
|
||
```
|
||
money # the web app on 127.0.0.1:8080 (same as `money serve`)
|
||
money serve --addr :8080 # listen on every interface (there is no login)
|
||
money import # extract new transactions from every statement
|
||
money import --force # re-parse statements even if unchanged
|
||
money retag # re-apply rules.toml: retag, and re-pair transfers
|
||
money ls --untagged # what neither a rule nor a transfer has claimed
|
||
money ls --untagged --uniq # the same, one row per distinct description
|
||
money ls --wide # also show type and reported balance
|
||
money ls --account checking --month 2026-01
|
||
money report --month 2026-01 # spending by tag
|
||
money report --sort count # ordered by number of transactions, not amount
|
||
money accounts # balances
|
||
money parsers # available statement parsers
|
||
money config # which data root is in use, and why
|
||
```
|
||
|
||
An account folder appears in the app only once its statements have been
|
||
imported — creating an `account.toml` is not enough on its own. Run
|
||
`money import` (or press Import in the web app) after adding one, or add its
|
||
first statements from the Accounts screen, which imports them as it saves them.
|
||
|
||
`--uniq` turns `ls` into a list of patterns still to write rather than a list of
|
||
rows to read: one line per distinct description, since fifty visits to the same
|
||
shop are one glob, not fifty lines. Descriptions print exactly as a glob sees
|
||
them — upper-cased with whitespace collapsed — so two statements that worded the
|
||
same payee differently collapse into the one row they deserve, and a pattern
|
||
written from the list matches what the list showed you.
|
||
|
||
```
|
||
$ money ls --untagged --uniq
|
||
DESCRIPTION
|
||
INTEREST PAID
|
||
KAUFLAND 4412 SOFIA
|
||
LIDL SOFIA 4412
|
||
|
||
3 distinct descriptions
|
||
```
|
||
|
||
The account is not part of what makes a row distinct. A rule matches on the
|
||
description and only optionally narrows to an account, so one payee seen on two
|
||
accounts is still one pattern to write — and it is one row in the rule builder
|
||
on `4`, which groups the same way. Pass `--account` to scope the listing
|
||
instead.
|
||
|
||
It composes with the other filters (`--account`, `--month`, `--search`), and
|
||
`--limit` caps the rows printed, saying how many it held back. It cannot be
|
||
combined with `--wide`, whose columns all belong to a single transaction.
|
||
|
||
## The web app
|
||
|
||
`money serve`, which is also what `money` runs with no command, puts seven
|
||
screens in a browser: accounts, transactions, report, rule builder, rules,
|
||
transfer builder and transfers. It runs the same code as the CLI: amounts are formatted,
|
||
globs matched and transfers paired on the server, and the page only shows the
|
||
answers. It is one binary with the page built in; nothing else to deploy.
|
||
|
||
```
|
||
money # http://127.0.0.1:8080
|
||
money serve --root /srv/money --addr 0.0.0.0:8080
|
||
```
|
||
|
||
**There is no authentication.** Anyone who can reach the port can read every
|
||
transaction, rewrite `rules.toml` and start an import, which is why it listens
|
||
on loopback unless `--addr` says otherwise. To use it from elsewhere, put it
|
||
behind a reverse proxy that does the logging in, or reach it over SSH / a VPN.
|
||
Requests that change anything must be sent as JSON, so another website open in
|
||
the same browser cannot post a form to it.
|
||
|
||
The server outlives hand edits to `rules.toml`, so it treats the file the way a
|
||
fresh command would:
|
||
|
||
- **Retag and Import re-read `rules.toml`** (and Import the account folders)
|
||
before running, exactly as `money retag` / `money import` would. Until then a
|
||
banner says the file on disk no longer matches what the index was derived
|
||
from.
|
||
- **Edits and deletes check the file first.** They go by rule position, and the
|
||
page also sends the rule it showed you; if `rules.toml` no longer holds that
|
||
rule there (another tab, a hand edit) the change is refused and you are asked
|
||
to reload, rather than editing whichever rule moved into its place.
|
||
|
||
Shift-click **Import** for `import --force`.
|
||
|
||
### Adding statements from the browser
|
||
|
||
The Accounts screen (`1`) has an **Add statements** panel: pick the account,
|
||
drop files on it (or click to choose them) and press **Upload and import**. The
|
||
files are saved into that account's folder, exactly where you would have copied
|
||
them by hand, and then imported — the folder stays the source of truth, so
|
||
deleting `index.db` and re-importing still gets everything back.
|
||
|
||
An upload never replaces a statement. A file whose name is already in the folder
|
||
is skipped if its contents are identical and refused if they differ; rename it
|
||
or remove the old one first. Names import would not read back — dotfiles,
|
||
`account.toml`, anything outside an account's `include` patterns — are refused
|
||
too, and a batch with one bad file writes none of them. The folder itself must
|
||
already exist with an `account.toml`: an upload adds statements to an account,
|
||
it does not create one.
|
||
|
||
### Keys
|
||
|
||
| Key | Action |
|
||
| --- | --- |
|
||
| `1` – `7` | accounts · transactions · report · rule builder · rules · transfer builder · transfers |
|
||
| `/` | filter by description |
|
||
| `u` | show only untagged transactions (matched transfer legs are not among them) |
|
||
| `a` | clear the account filter |
|
||
| `←` `→` | move the report's period (report) |
|
||
| `s` | change how the report is sorted (report) |
|
||
| `ctrl+s` | sort the rule builder's preview by name / by count |
|
||
| `i` | import · `r` retag |
|
||
|
||
Inside a form every printable key belongs to the field you are typing in, so
|
||
the single-letter keys only work outside one; that is why the builder's sort is
|
||
a chord. Clicking an account on `1` filters the transaction list to it.
|
||
|
||
There is no control that tags a transaction. Tags come from `rules.toml` and
|
||
nowhere else, so tagging what you are looking at means writing a rule for it on
|
||
`4` — which is why that screen shows you what a glob catches before you save.
|
||
|
||
### Report (`3`)
|
||
|
||
The report opens on **last month**, not on everything you have ever imported:
|
||
an all-time total is the one number a spending report is least often asked for,
|
||
and the month that has just ended is the last one your statements can be
|
||
complete for. If you have not downloaded that month yet, the screen says the
|
||
period is empty rather than quietly showing you a different one.
|
||
|
||
The time axis beside the totals runs from the widest window down to the oldest
|
||
month in the index — `all time`, `this year`, `last 12 months`, `last 3
|
||
months`, `this month`, `last month`, then one entry per month. Click one, or
|
||
step along it with `←` and `→`.
|
||
|
||
The three rolling windows run to the end of *this* month rather than to the
|
||
last complete one — you ask for "last 3 months" to see what is happening now,
|
||
and leaving out the days since the 1st would answer a different question. Each
|
||
month below `last month` is a month the index actually holds; months that a
|
||
named window above already covers are not repeated.
|
||
|
||
Click a column heading, or press `s` to cycle, to change how the rows are
|
||
arranged; the marked heading says which one they are arranged by: `Out ▾`
|
||
largest spend first (the default question a spending report answers), then
|
||
`In ▾`, `Net ▾` lowest first so the biggest losses lead, `N ▾` most
|
||
transactions first, and `Tag ▴` A→Z. `money report` takes the same choice as
|
||
`--sort out|in|net|count|tag`.
|
||
|
||
Currency is always the outer grouping and no sort changes that — there are no
|
||
exchange rates here, so two currencies interleaved by amount would invite a
|
||
comparison between numbers that cannot be compared. Rows that tie fall back to
|
||
the tag, so they keep a fixed position rather than shuffling between reloads.
|
||
The sort rearranges rows; it never changes which rows there are.
|
||
|
||
The period narrows the report and only the report. The account, the search and
|
||
the untagged toggle are shared with the transaction list, so opening on last
|
||
month does not hide the rest of the index from `2`.
|
||
|
||
`money report` takes `--month` instead; there is no command-line equivalent of
|
||
the wider windows.
|
||
|
||
### Rule builder (`4`)
|
||
|
||
Writing rules by hand means guessing what a glob will catch. This screen shows
|
||
the answer as you type: the form is on the left — glob, account, tag, note —
|
||
and on the right the still-untagged descriptions the glob currently matches,
|
||
marked `▸`, grouped by description, under a running "*n* of *m* descriptions
|
||
match" count.
|
||
|
||
The list narrows as you type, so what is on screen is what the rule would
|
||
claim — nothing else is left there to read past. The count keeps the context
|
||
the rows no longer can: *m* is everything still in view, so `2 of 7` says the
|
||
glob picked two descriptions out of seven, and `0 of 7` says the glob is wrong.
|
||
With the glob still empty there is nothing to filter by, and the list is every
|
||
untagged description in the data — which is the other question this screen
|
||
answers, and where you go looking for the next thing to write a rule for.
|
||
Clicking a description fills the glob with `*THAT DESCRIPTION*`, as a starting
|
||
point to narrow down.
|
||
|
||
`enter` (or **Save rule**) appends the rule to `rules.toml` and retags
|
||
immediately, so the rows it caught disappear from the list. The account stays
|
||
filled in, since the next rule is usually for the same one.
|
||
|
||
Clicking the column headings, or `ctrl+s`, switches the list between by name and
|
||
by count, most seen first; the `↓` says which column the order is read from.
|
||
The two answer different questions: by name finds the payee you are looking at,
|
||
by count finds the rule worth writing next, since one pattern claiming forty
|
||
rows is worth more than the first of forty claiming one. Ties keep the
|
||
alphabetical order, so the list does not reshuffle under you, and the choice
|
||
lasts until you change it.
|
||
|
||
The account and tag fields offer completions as you type. Accounts come from
|
||
the folders on disk and the index; tags from every tag in use plus any named in
|
||
`rules.toml`, so a tag is completable from the moment a rule mentions it —
|
||
which is what stops `groceries` acquiring a `grocery` twin. A new tag is just
|
||
typed out in full.
|
||
|
||
Leaving the account blank applies the rule everywhere; filling it in also
|
||
narrows the preview to that account. The preview lists only transactions no
|
||
existing rule has already tagged, so it answers "what is still waiting for a
|
||
rule" rather than "what would this rule win". Those differ when the new rule is
|
||
narrower than an existing one: a `*LIDL SOFIA*` written while `*LIDL*` is
|
||
already tagging those rows previews as nothing and still takes them on save,
|
||
because the more specific rule wins. Legs of a matched transfer are left out
|
||
too: they are already accounted for by the definition that paired them, and the
|
||
report leaves them out anyway.
|
||
|
||
#### Editing a rule
|
||
|
||
**Edit** on the rules screen (`5`) opens the rule in this same form with the
|
||
fields filled in, and saving rewrites that rule where it sits instead of
|
||
appending a new one. It keeps its position, since position still breaks ties
|
||
between equally specific rules; `rules.toml` is edited textually, so the
|
||
comments and formatting around it survive, exactly as when deleting.
|
||
|
||
An edit is judged against different rows to a new rule. What the rule already
|
||
claims is tagged, so none of it would be in an untagged preview and a rule that
|
||
works perfectly would preview as nothing; those rows are put back, and any the
|
||
new glob stops catching stays on screen marked `−`, under a "*n* no longer
|
||
claimed" line, rather than quietly disappearing the way an ordinary non-match
|
||
does. Narrowing `*LIDL*` to `*LIDL SOFIA*` therefore shows you the Varna branch
|
||
you are about to hand back to whatever rule catches it next.
|
||
|
||
The form has no `type` field, so a rule that sets one carries it through
|
||
unchanged rather than losing it; it is shown under the glob as `+ type:… ·
|
||
kept`. Saving lands back on the rules screen with the new counts; **Cancel**,
|
||
`esc`, or leaving the screen leaves the rule as it was.
|
||
|
||
### Rules (`5`)
|
||
|
||
Every rule in file order — the order they are written in, not the order they
|
||
are tried in — with the number of transactions it actually claims. Rules that
|
||
claim none are marked `✗`.
|
||
|
||
```
|
||
Rules · 5 rules · 2 match nothing
|
||
|
||
# Pattern Account Tag Txns Note
|
||
1 *LIDL SOFIA* (all) groceries 3 the weekly shop
|
||
2 ✗ *LIDL* (all) shadowed 0
|
||
3 ✗ *OLD BANK NAME* (all) dead 0 closed in 2025
|
||
4 *ZARA* (all) clothes 1
|
||
5 *КАУФЛАНД* checking groceries 1 4412 is the branch
|
||
```
|
||
|
||
The count is how many transactions the rule *wins*, not how many its glob could
|
||
match. Rule 2 above matches `LIDL SOFIA 4412` perfectly well, but rule 1 spells
|
||
out more of it and so claims it first; with nothing else here that says `LIDL`,
|
||
rule 2 is dead weight — which is the point of the screen. A rule can therefore
|
||
reach zero either by matching nothing or by being shadowed, and both are worth
|
||
deleting. Note that shadowing has nothing to do with the numbering: rule 2 would
|
||
be just as dead written above rule 1, because precedence is decided by how
|
||
specific a rule is and not by where it sits.
|
||
|
||
**Edit** opens a rule in the builder, **Delete** deletes it, and **Prune
|
||
unused** deletes every rule marked `✗` at once; both deletions ask first.
|
||
**Refresh counts** recounts against what is currently in the index. Editing and
|
||
deleting both work on `rules.toml` textually, so your comments, ordering and
|
||
formatting survive. A comment sitting directly above a deleted rule goes with
|
||
it, while one separated by a blank line is treated as a section heading and
|
||
left alone; an edited rule keeps its comments, since they say why it is there
|
||
and changing its glob rarely changes that.
|
||
|
||
### Transfer builder (`6`)
|
||
|
||
Same idea as the rule builder, for money moved between your own accounts. The
|
||
form names both sides — from account, from desc, to account, to desc — plus an
|
||
optional tolerance and note, and the preview beside it shows what the pair
|
||
would be: `▸` for a movement it matches end to end, `⚠` for a leg it catches on
|
||
one side and cannot pair with anything on the other.
|
||
|
||
```
|
||
Date Amount Movement Description
|
||
⚠ 2026-04-01 500.00 checking → ? TRANSFER TO SAVINGS
|
||
▸ 2026-03-01 500.00 checking → savings TRANSFER TO SAVINGS
|
||
|
||
1 pairs · 1 unpaired
|
||
```
|
||
|
||
The account fields complete exactly as the rule builder's do. `enter` (or
|
||
**Save transfer**) appends the definition to `rules.toml` and re-pairs
|
||
immediately. Saving keeps both account names in place and clears the two globs,
|
||
since the next transfer you write is usually the same route in the other
|
||
direction. The tolerance is cleared with them: carried over silently it would
|
||
loosen a route that never asked for one.
|
||
|
||
`Tolerance %` is the one field worth previewing before you save. Type a
|
||
percentage and the pairs it buys appear immediately, showing both amounts —
|
||
`500.00 → 495.00` — with what they cost summarised beside the counts:
|
||
|
||
```
|
||
1 pairs · 0 unpaired · 5.00 in fees
|
||
```
|
||
|
||
A half-written definition previews too: fill in one side and its legs show up as
|
||
unpaired, which is the quickest way to see that a glob is wrong before you have
|
||
written the other half.
|
||
|
||
### Transfers (`7`)
|
||
|
||
Every definition in file order, with what it currently pairs.
|
||
|
||
```
|
||
Transfers · 4 definitions · 1 leg(s) unpaired
|
||
|
||
# From To Pairs Unpaired Tol Note
|
||
1 ⚠ checking *TO SAVINGS* savings *FROM CHECKING* 11 1 monthly saving
|
||
2 checking *TO BROKER* traderepublic *FROM NLB* 4 0
|
||
3 checking *WIRE TO SAVINGS* savings *WIRE FROM CHECK* 2 0 1.5% the bank keeps a fee
|
||
4 ✗ checking *TO OLD BANK* savings *FROM OLD BANK* 0 0 closed in 2025
|
||
```
|
||
|
||
The two counts mean different things, which is the point of the screen. `⚠` is a
|
||
definition catching legs it cannot complete: a wrong glob on the other side, a
|
||
statement not imported yet, or a movement that genuinely went missing. `✗` is a
|
||
definition matching nothing at all, which is just dead weight.
|
||
|
||
`Tol` is the definition's `tolerance_pct`, and it qualifies the counts beside
|
||
it: row 3's two pairs were matched on slack rather than on the amount agreeing.
|
||
It is blank for every definition that requires the exact amount — which is the
|
||
default, so an empty column means nothing here is pairing on a mismatch.
|
||
|
||
**Delete** deletes a definition and **Prune unmatched** deletes every `✗` one at
|
||
once, both after asking. Definitions marked `⚠` are never pruned — they are
|
||
doing something, just not finishing it, and deleting one would hide the problem
|
||
rather than fix it. **Refresh pairing** re-reads what is currently in the index.
|
||
|
||
## rules.toml
|
||
|
||
**The most specific rule wins**, so `*NIKOLA*` claims what it names even with a
|
||
broad `*NIK*` sitting above it, and a catch-all can be written anywhere without
|
||
swallowing the file. Patterns are globs (`*` and `?`) matched case-insensitively,
|
||
with whitespace collapsed.
|
||
|
||
Specificity is measured on what a rule spells out, in this order:
|
||
|
||
1. how many literal characters its patterns pin down — `*LIDL SOFIA*` (10)
|
||
beats `*LIDL*` (4), and a bare `*` (0) is tried last of all. A rule setting
|
||
both `match` and `type` has to satisfy both, so both count.
|
||
2. how few `*` it uses, the only wildcard that swallows a run of any length:
|
||
`LIDL` fixes both ends where `*LIDL*` does not, so it goes first. (`?` is
|
||
not counted either way: it fixes a length, not a character.)
|
||
3. whether it names an `account`, which is a narrowing the unscoped rule with
|
||
the same patterns does not have.
|
||
|
||
Rules that tie on all three fall back to file order, and the earlier one wins —
|
||
which is what keeps appending a rule from disturbing one already written.
|
||
|
||
A rule matches on `match` (the description) and `type` (the bank's own
|
||
classification). Setting both is an "and": both must match.
|
||
|
||
`note` is free text for you, never for the matcher: why the rule is there, or
|
||
what the unrecognisable payee behind the glob actually is. It shows in the last
|
||
column of the rules screen. Ordinary `#` comments still work and are preserved
|
||
on delete; a `note` differs in that it survives a round trip through the tool,
|
||
so the rule builder can write one and the rules screen can show it.
|
||
|
||
```toml
|
||
[[rule]]
|
||
tag = "groceries"
|
||
match = "*LIDL*"
|
||
|
||
[[rule]]
|
||
tag = "salary"
|
||
match = "*ACME PAYROLL*"
|
||
note = "paid on the 4th; the December one lands early"
|
||
|
||
# A rule can be limited to one account, and can require several patterns.
|
||
[[rule]]
|
||
account = "checking"
|
||
tag = "rent"
|
||
match = "*STANDING ORDER 4471*"
|
||
|
||
[[rule]]
|
||
type = "CARD_PAYMENT"
|
||
match = "*LIDL*"
|
||
tag = "groceries"
|
||
```
|
||
|
||
Every tag comes from this file, so a tag is never something you have to keep
|
||
safe: `money retag` recomputes all of them from the current rules and is safe
|
||
to run whenever you change it. A transaction no rule matches simply stays
|
||
untagged, and shows up under `money ls --untagged`, the `u` view, and
|
||
`(untagged)` in the report — unless it is one leg of a matched transfer, which
|
||
is already spoken for and is left out of all three.
|
||
|
||
### Transfers
|
||
|
||
Money moved between your own accounts is one movement the statements show
|
||
twice: an outflow leaving one account and an inflow arriving in another. A
|
||
`[[transfer]]` block names both legs, in the same file and with the same globs
|
||
as everything else.
|
||
|
||
```toml
|
||
[[transfer]]
|
||
from_account = "checking"
|
||
from_desc = "*TO SAVINGS*"
|
||
to_account = "savings"
|
||
to_desc = "*FROM CHECKING*"
|
||
note = "monthly saving, usually lands the next working day"
|
||
|
||
# Such movements are often only identifiable by the other side's account
|
||
# number, whatever the rest of the description happens to say. Every bank
|
||
# parser keeps that number in the description, so an ordinary glob finds it.
|
||
[[transfer]]
|
||
from_account = "checking"
|
||
from_desc = "*SI56*"
|
||
to_account = "traderepublic"
|
||
to_desc = "*FROM NLB*"
|
||
```
|
||
|
||
All four `_account` / `_desc` keys are required; `note` and `tolerance_pct` are
|
||
optional, and `note`, as on a rule, never takes part in matching. `from_desc` is
|
||
matched against outflows on `from_account` and `to_desc` against inflows on
|
||
`to_account`, so a definition written backwards pairs nothing.
|
||
|
||
Two legs are the same movement when they match one definition and are dated no
|
||
more than **5 days** apart. Within one currency they must also be the exact
|
||
opposite amount, because a movement that arrives short is not obviously the same
|
||
movement leaving.
|
||
|
||
#### When the bank takes a fee
|
||
|
||
Some routes charge, and then the two statements really do disagree: 500.00
|
||
leaves and 495.00 arrives. `tolerance_pct` says how far short (or over) the
|
||
arriving leg may be, as a percentage of the leg that left:
|
||
|
||
```toml
|
||
[[transfer]]
|
||
from_account = "checking"
|
||
from_desc = "*WIRE TO SAVINGS*"
|
||
to_account = "savings"
|
||
to_desc = "*WIRE FROM CHECKING*"
|
||
tolerance_pct = 1.5
|
||
note = "the bank keeps a wire fee"
|
||
```
|
||
|
||
It belongs to that one definition and loosens nothing else — every other route
|
||
still needs the exact amount. It defaults to 0, which is what every definition
|
||
written without the key means, and it must stay under 100: at 100% any amount
|
||
would pair with any other and the dates would be deciding alone. Keep it as
|
||
tight as the fee actually requires. Every percent of slack is also a percent
|
||
more chance of pairing two unrelated movements that happen to fall in the same
|
||
five days, and where two candidates are the same number of days away the nearer
|
||
amount wins.
|
||
|
||
**The difference is not forgiven, it is reported.** A pair leaves the report
|
||
entirely, so a fee hidden inside one would be spending that appears nowhere at
|
||
all. `money report` names it on the excluded line and the web app's report gives it
|
||
its own row (see below). Across currencies there is no fee to compute — the two
|
||
numbers are in different units — so a tolerance there means nothing and is
|
||
ignored.
|
||
|
||
**Across currencies the amount is not checked at all.** An exchange between two
|
||
of your own accounts — the same money in EUR and in BGN — has two unrelated
|
||
numbers, and the tool holds no exchange rates, so the dates carry the pairing on
|
||
their own and the nearest one wins. That is a weaker rule than the
|
||
same-currency one, deliberately: use the same route twice inside one window and
|
||
it will pair the wrong two legs. The transfer builder shows both amounts for
|
||
such a pair (`500.00 → 977.90`), which is the only place the rate the bank
|
||
actually used appears.
|
||
|
||
Definitions are evaluated in file order and each transaction belongs to at most
|
||
one transfer, so — exactly as with rules — the first definition to claim a leg
|
||
keeps it.
|
||
|
||
**A matched pair leaves the report entirely.** Both legs go together, so a
|
||
report over everything is unchanged in total by money you shuffled between your
|
||
own accounts, and `money report` says what it held out, per currency and per
|
||
direction — an exchange puts its two legs in different currencies, so each side
|
||
reports only the leg it saw:
|
||
|
||
```
|
||
transfers excluded: 4 legs in EUR, 2000.00 out, 1500.00 in
|
||
transfers excluded: 1 legs in BGN, 0.00 out, 977.90 in
|
||
```
|
||
|
||
Where a `tolerance_pct` definition let a fee through, the line says so too, and
|
||
that is the number your spending is short by:
|
||
|
||
```
|
||
transfers excluded: 2 legs in EUR, 500.00 out, 495.00 in, 5.00 in fees
|
||
```
|
||
|
||
The report screen (`3`) shows the same thing under its `TOTAL`, the fee on its
|
||
own row (for whichever period it is on):
|
||
|
||
```
|
||
TAG CUR OUT IN NET N
|
||
groceries EUR 20.00 0.00 -20.00 1
|
||
TOTAL EUR 20.00 0.00 -20.00
|
||
(transfers) EUR 500.00 495.00 -5.00 2
|
||
⤷ fees EUR 5.00 -5.00 1
|
||
```
|
||
|
||
The fee is counted only for pairs whose legs are *both* in the report you are
|
||
looking at, for the same reason the line counts legs and not transfers: filter
|
||
to one account or one month and you usually have one side of a movement, and
|
||
half a pair cannot say what the other half received.
|
||
|
||
A paired leg shows as `(transfer)` in the tag column of the transaction list
|
||
and of `money ls`, so it reads as accounted for rather than as a blank waiting
|
||
for a rule. That label is display only — nothing writes it to the index, and it
|
||
is never offered as a tag to complete against. A rule tag wins where there is
|
||
one, since that is your own word for it.
|
||
|
||
An *unpaired* leg is not a transfer and still counts, tagged like anything
|
||
else. That is deliberate: money that left an account and cannot be shown to
|
||
have arrived is exactly what you want to see, not something to quietly drop.
|
||
`money import` and `money retag` report unpaired legs on stderr, and the
|
||
transfers screen (`7`) shows which definition they belong to.
|
||
|
||
The pairing is derived state, like the tags: it lives in the index, is
|
||
recomputed wholesale by `money retag` and after every import, and disappears
|
||
with the definition that made it.
|
||
|
||
## account.toml
|
||
|
||
Every account folder needs one. `currency` and `parser` are required.
|
||
|
||
```toml
|
||
name = "Main Checking"
|
||
currency = "EUR"
|
||
parser = "nlb"
|
||
# minor_digits = 2 # decimal places for this currency
|
||
# include = ["*.csv"] # only treat matching files as statements
|
||
```
|
||
|
||
### Parsers
|
||
|
||
Three are built in, ported from the original Python extractors. A statement
|
||
layout is described in Go rather than in a table of column indexes, so there is
|
||
nothing else to configure — `parser` names one of these and that is all.
|
||
|
||
| `parser` | Statement | Notes |
|
||
| --- | --- | --- |
|
||
| `nlb` | NLB izpisek PDF | Wrapped descriptions are folded in from continuation lines, and the IBAN column is appended to the description. |
|
||
| `traderepublic` | Trade Republic PDF | Handles both the single-line and the stacked layout by measuring column positions. |
|
||
| `revolut` | `account-statement*.csv` | Skips non-COMPLETED rows, folds the fee into the amount. |
|
||
|
||
```toml
|
||
name = "Trade Republic"
|
||
currency = "EUR"
|
||
parser = "traderepublic"
|
||
```
|
||
|
||
The two PDF parsers shell out to `pdftotext -layout` (poppler-utils), exactly
|
||
as the Python versions did; its layout reconstruction is what makes the
|
||
column-based parsing work. A build made with `scripts/build-bundled.sh`
|
||
carries its own copy instead (see Install).
|
||
|
||
Amount parsing is shared and deliberately tolerant: `1.234,56`, `-45.20`,
|
||
`45,20-`, `(45.20)` and `45.20 EUR` all work, whichever parser reads them.
|
||
|
||
**Revolut and multiple currencies.** One export can hold several currencies,
|
||
but an account here has exactly one. Rows in other currencies are skipped and
|
||
reported at import. To keep them, give that currency its own account folder
|
||
with its own `currency` and `minor_digits` (JPY wants `minor_digits = 0`) and
|
||
put a copy or symlink of the export in it.
|
||
|
||
## Adding a parser
|
||
|
||
A new bank means a new parser. Drop it in `internal/parser` and register it —
|
||
no other package changes:
|
||
|
||
```go
|
||
func init() {
|
||
parser.Register("bankx", func(acc *config.Account) (parser.Parser, error) {
|
||
return &bankXParser{digits: acc.Digits()}, nil
|
||
})
|
||
}
|
||
|
||
func (p *bankXParser) Parse(path string, acc *config.Account) ([]parser.RawTxn, error) {
|
||
// ... return date (YYYY-MM-DD), description, and signed minor units
|
||
}
|
||
```
|
||
|
||
It becomes usable by setting `parser = "bankx"` in an account.toml. Use
|
||
`parser.ParseAmount` for the decimal handling rather than rolling your own.
|
||
|
||
## Balance checks
|
||
|
||
Statements that report a running balance are verified on import: each balance
|
||
must equal the previous one plus the transaction amount, the same check all
|
||
three original scripts made. A break means a row was missed or misparsed, and
|
||
is reported as a warning without stopping the import.
|
||
|
||
## Deduplication
|
||
|
||
Each transaction gets a fingerprint from its date, amount, normalised
|
||
description, and its ordinal among identical lines in the same statement. So
|
||
two identical purchases on one day are both kept, while re-importing an
|
||
overlapping statement adds nothing. Unchanged files are skipped by checksum
|
||
before parsing.
|
||
|
||
## Development
|
||
|
||
```
|
||
go build ./... && go test ./...
|
||
```
|