Both fields are free text, and a slug or tag that is slightly wrong produces a rule that silently catches nothing — or a second, near-identical tag. They now complete against what already exists: accounts 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. The completion is ghosted after the cursor and never committed until it is accepted, so inventing a new tag still works. tab takes it and moves focus only when there is nothing left to complete; ctrl+n/ctrl+p cycle an ambiguous prefix, and the hint under the box says how many candidates are left. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
327 lines
12 KiB
Markdown
327 lines
12 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, tagged by
|
|
glob rules you write, and movements between your own accounts are marked as
|
|
transfers so they never count as spending.
|
|
|
|
## Layout
|
|
|
|
```
|
|
~/money/ # the data root (see "Where the data root lives")
|
|
rules.toml # tag + transfer rules, in order
|
|
.money/index.db # SQLite index (rebuildable; safe to delete*)
|
|
checking/
|
|
account.toml # currency + how to parse this bank's exports
|
|
2026-01.csv
|
|
2026-02.csv
|
|
savings/
|
|
account.toml
|
|
2026-01.csv
|
|
```
|
|
|
|
\* Deleting the index loses manual tags and manual transfer marks, which live
|
|
only there. Everything else is re-derived from the statements.
|
|
|
|
## 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.
|
|
|
|
## 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 still needs a tag
|
|
money ls --wide # also show type, counterparty 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) |
|
|
| `t` | set the tag on the selected transaction |
|
|
| `x` | toggle transfer on the selected transaction |
|
|
| `c` | clear manual overrides, falling back to the rules |
|
|
| `/` | filter by description |
|
|
| `u` | show only untagged transactions |
|
|
| `a` | clear the account filter |
|
|
| `i` | import · `r` re-apply rules · `q` quit |
|
|
|
|
### 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
|
|
▸ tag
|
|
╭────────────────────────────╮
|
|
│ groceries │
|
|
╰────────────────────────────╯
|
|
tab completes · ctrl+n: 1 more
|
|
|
|
2 of 7 descriptions match
|
|
```
|
|
|
|
Only `gro` was typed in the tag field; `ceries` is the ghosted completion.
|
|
|
|
`tab` / `↑↓` move between the glob, account and tag 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.
|
|
|
|
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 T Txns
|
|
1 *LIDL* (all) groceries 3
|
|
2 ✗ *LIDL SOFIA* (all) shadowed 0
|
|
3 ✗ *OLD BANK NAME* (all) dead 0
|
|
4 *ZARA* (all) clothes 1
|
|
5 *КАУФЛАНД* checking groceries 1
|
|
```
|
|
|
|
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. 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 transfer
|
|
rules above general tag rules. Patterns are globs (`*` and `?`) matched
|
|
case-insensitively, with whitespace collapsed.
|
|
|
|
A rule matches on `match` (the description), `counterparty` (the other side's
|
|
account number) and `type` (the bank's own classification). Setting several is
|
|
an "and": all must match.
|
|
|
|
```toml
|
|
[[rule]]
|
|
tag = "groceries"
|
|
match = "*LIDL*"
|
|
|
|
[[rule]]
|
|
tag = "salary"
|
|
match = "*ACME PAYROLL*"
|
|
|
|
# Money moved between your own accounts. Both legs need a rule.
|
|
[[rule]]
|
|
match = "*TO SAVINGS*"
|
|
transfer = true
|
|
tag = "transfer"
|
|
|
|
[[rule]]
|
|
match = "*FROM CHECKING*"
|
|
transfer = true
|
|
tag = "transfer"
|
|
|
|
# Transfers are often only identifiable by the counterparty IBAN, whatever
|
|
# the description happens to say.
|
|
[[rule]]
|
|
counterparty = "SI56*"
|
|
transfer = true
|
|
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"
|
|
```
|
|
|
|
Editing rules never touches tags you set by hand: rule verdicts and manual
|
|
overrides are stored separately, and the manual one always wins. `money retag`
|
|
is therefore safe to run whenever you change the file.
|
|
|
|
## account.toml
|
|
|
|
Every account folder needs one. `currency` and `parser` are required.
|
|
|
|
```toml
|
|
name = "Main Checking"
|
|
currency = "EUR"
|
|
parser = "csv"
|
|
# minor_digits = 2 # decimal places for this currency
|
|
# include = ["*.csv"] # only treat matching files as statements
|
|
```
|
|
|
|
### `parser = "csv"`
|
|
|
|
Column positions are 0-based.
|
|
|
|
```toml
|
|
[csv]
|
|
delimiter = "," # default ","
|
|
skip_rows = 1 # header rows to drop
|
|
date = { col = 0, layout = "02.01.2006" } # Go reference layout
|
|
description = { col = 3 }
|
|
amount = { col = 4, decimal = ",", thousands = "." }
|
|
# invert = true # if outflows are written as positive
|
|
```
|
|
|
|
For statements with separate debit and credit columns, replace `amount`:
|
|
|
|
```toml
|
|
debit = { col = 4 } # both written as positive numbers
|
|
credit = { col = 5 }
|
|
```
|
|
|
|
Amount parsing is deliberately tolerant: `1.234,56`, `-45.20`, `45,20-`,
|
|
`(45.20)` and `45.20 EUR` all work.
|
|
|
|
### Bank-specific parsers
|
|
|
|
Three are built in, ported from the original Python extractors. They need no
|
|
`[csv]` block — the layout is baked in.
|
|
|
|
| `parser` | Statement | Notes |
|
|
| --- | --- | --- |
|
|
| `nlb` | NLB izpisek PDF | Wrapped descriptions and the counterparty IBAN are folded in from continuation lines. |
|
|
| `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.
|
|
|
|
**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.
|
|
|
|
### `parser = "cmd"`
|
|
|
|
Runs an external extractor and reads normalised CSV (`date,description,amount`)
|
|
from its stdout. This is how PDF statements and any existing Python extractor
|
|
are handled without porting them first.
|
|
|
|
```toml
|
|
parser = "cmd"
|
|
[cmd]
|
|
argv = ["python3", "../extract_bankx.py", "{{file}}"]
|
|
skip_rows = 1 # if the script prints a header
|
|
# layout = "2006-01-02" # date format the script emits (default)
|
|
```
|
|
|
|
`{{file}}` is replaced with the statement's path, and the command runs with the
|
|
account folder as its working directory, so relative script paths work.
|
|
|
|
## Adding a native parser
|
|
|
|
When a Python extractor is ported to Go, 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 ./...
|
|
```
|