Four removals in a row left both files describing things that are no longer there, and one claim that was never true: - The rules screen mock-up still had the transfer column, two commits after that column went. The keys table listed 4 twice, once as a view switch and once as the tagging row that replaced t; tagging is now a sentence saying plainly that no key does it and why 4 is the answer. - The rule builder mock-up was missing the account field entirely while the prose below it described moving between four fields. - glob was described as linear time with no backtracking. It is the two-pointer wildcard match, which backtracks by design -- the branch is right there in glob.go -- and is O(n*m) in the worst case. What it actually rules out is exponential blowup on patterns like *a*a*a*. - r on the rules screen was undocumented. It recounts against the index, which is what an import makes stale; it does not re-read rules.toml, so an edit made elsewhere still needs a restart. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
327 lines
13 KiB
Markdown
327 lines
13 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, 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
|
|
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.
|
|
|
|
## Usage
|
|
|
|
```
|
|
money # open the TUI (default)
|
|
money import # extract new transactions from every statement
|
|
money import --force # re-parse statements even if unchanged
|
|
money retag # re-apply rules.toml to everything already imported
|
|
money ls --untagged # what no rule has claimed yet
|
|
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 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 `i` in the TUI) after adding one.
|
|
|
|
### TUI keys
|
|
|
|
| Key | Action |
|
|
| --- | --- |
|
|
| `1` `2` `3` `4` `5` / `tab` | accounts · transactions · report · rule builder · rules |
|
|
| `enter` | open the selected account (accounts view) |
|
|
| `/` | filter by description |
|
|
| `u` | show only untagged transactions |
|
|
| `a` | clear the account filter |
|
|
| `i` | import · `r` re-apply rules · `q` quit |
|
|
|
|
There is no key 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.
|
|
|
|
### 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, and on the right is every
|
|
still-untagged description in the data, sorted alphabetically and grouped, with
|
|
a `▸` against each one the glob currently matches and a running
|
|
"*n* of *m* descriptions match" count.
|
|
|
|
```
|
|
glob Untagged description N
|
|
╭────────────────────────────╮ ACME PAYROLL JAN 1
|
|
│ *LIDL* │ BOLT RIDE 1
|
|
╰────────────────────────────╯ ▸ LIDL SOFIA 4412 2
|
|
vs. the description ▸ LIDL VARNA 9911 1
|
|
ZARA MLADOST 1
|
|
account
|
|
╭────────────────────────────╮
|
|
│ blank = every account │
|
|
╰────────────────────────────╯
|
|
blank = all accounts
|
|
|
|
▸ tag
|
|
╭────────────────────────────╮
|
|
│ groceries │
|
|
╰────────────────────────────╯
|
|
tab completes · ctrl+n: 1 more
|
|
|
|
note
|
|
╭────────────────────────────╮
|
|
│ optional │
|
|
╰────────────────────────────╯
|
|
why this rule exists
|
|
|
|
2 of 7 descriptions match
|
|
```
|
|
|
|
Only `gro` was typed in the tag field; `ceries` is the ghosted completion.
|
|
|
|
`tab` / `↑↓` move between the glob, account, tag and note fields, `pgup` /
|
|
`pgdn` scroll the list, and `enter` appends the rule to `rules.toml` and retags
|
|
immediately, so the rows it caught disappear from the list. `esc` goes back.
|
|
On a short window the form gives up its spacing and then its hints, so all four
|
|
fields stay on screen.
|
|
|
|
The account and tag fields complete as you type: the rest of the match is
|
|
ghosted in grey after the cursor, and `tab` (or `→` at the end of the line)
|
|
takes it. When several candidates share the prefix, the hint under the box says
|
|
how many, and `ctrl+n` / `ctrl+p` cycle through them. Nothing is committed until
|
|
you accept it, so a new tag is still just typed out in full. 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.
|
|
|
|
Leaving the account blank applies the rule everywhere; filling it in also
|
|
narrows the preview to that account. Rules are appended, so anything already in
|
|
`rules.toml` keeps precedence — the preview accounts for that automatically,
|
|
since it only ever lists transactions no existing rule has already tagged.
|
|
|
|
### Rules (`5`)
|
|
|
|
Every rule in file order, with the number of transactions it actually claims.
|
|
Rules that claim none are marked `✗`.
|
|
|
|
```
|
|
money · rules · 5 rules · 2 match nothing
|
|
|
|
# Pattern Account Tag Txns Note
|
|
1 *LIDL* (all) groceries 3 the weekly shop
|
|
2 ✗ *LIDL SOFIA* (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 claims
|
|
it first, so 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.
|
|
|
|
`d` deletes the selected rule, `p` deletes every rule marked `✗` at once, and
|
|
both ask for a `y` first. `r` recounts against what is currently in the index,
|
|
which is what you want after an import has added rows; `rules.toml` itself is
|
|
read at startup and whenever you save a rule from the builder, so an edit made
|
|
in another window needs a restart. Deleting edits `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.
|
|
|
|
## rules.toml
|
|
|
|
Rules are evaluated in file order and the **first match wins**, so put specific
|
|
rules above general ones. Patterns are globs (`*` and `?`) matched
|
|
case-insensitively, with whitespace collapsed.
|
|
|
|
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"
|
|
|
|
# Money moved between your own accounts is tagged like anything else. Both
|
|
# legs need a rule, and both count in the report -- one as an outflow, the
|
|
# other as an inflow.
|
|
[[rule]]
|
|
match = "*TO SAVINGS*"
|
|
tag = "transfer"
|
|
|
|
[[rule]]
|
|
match = "*FROM CHECKING*"
|
|
tag = "transfer"
|
|
|
|
# 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.
|
|
[[rule]]
|
|
match = "*SI56*"
|
|
tag = "transfer"
|
|
|
|
# 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.
|
|
|
|
## 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.
|
|
|
|
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 ./...
|
|
```
|