File order decided precedence, so a narrow rule had to be written above the broad one it carves an exception out of -- an ordering constraint the file cannot show and the user has to remember. *NIKOLA* below *NIK* silently matched nothing, and a catch-all * could only ever be the last line. Engine.New now sorts once and MatchIndex walks that order: most literal characters first, then fewest *, then account-scoped over unscoped. Literals are what a rule commits to and a * is what it gives up, so a bare * is tried last wherever it sits. The sort is stable, so equally specific rules keep file order and the earlier one wins -- which is all position decides now, and why AppendRule can keep appending without displacing a rule written by hand. The two orders must not be confused: Rules(), Usage and MatchIndex still speak in file positions, because that is what the rules screen numbers and what DeleteRules deletes by. A shadowed rule still reports zero usage, but a zero no longer says anything about where the rule sits. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
597 lines
26 KiB
Markdown
597 lines
26 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
|
|
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: 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 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.
|
|
|
|
`--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.
|
|
|
|
### TUI keys
|
|
|
|
| Key | Action |
|
|
| --- | --- |
|
|
| `1` `2` `3` `4` `5` `6` `7` / `tab` | accounts · transactions · report · rule builder · rules · transfer builder · transfers |
|
|
| `enter` | open the selected account (accounts view) |
|
|
| `/` | filter by description |
|
|
| `u` | show only untagged transactions (matched transfer legs are not among them) |
|
|
| `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 the
|
|
still-untagged descriptions the glob currently matches, marked `▸`, grouped and
|
|
sorted alphabetically, 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.
|
|
|
|
```
|
|
glob Untagged description N
|
|
╭────────────────────────────╮ ▸ LIDL SOFIA 4412 2
|
|
│ *LIDL* │ ▸ LIDL VARNA 9911 1
|
|
╰────────────────────────────╯
|
|
vs. the description
|
|
|
|
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. 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.
|
|
|
|
### 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 `✗`.
|
|
|
|
```
|
|
money · 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.
|
|
|
|
`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.
|
|
|
|
### Transfer builder (`6`)
|
|
|
|
Same idea as the rule builder, for money moved between your own accounts. Both
|
|
sides are named, and the preview on the right 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.
|
|
|
|
```
|
|
from account Date Amount Movement Description
|
|
╭────────────────────────────╮ ⚠ 2026-04-01 500.00 checking → ? TRANSFER TO SAVINGS
|
|
│ checking │ ▸ 2026-03-01 500.00 checking → savings TRANSFER TO SAVINGS
|
|
╰────────────────────────────╯
|
|
money leaves here
|
|
|
|
▸ from desc
|
|
╭────────────────────────────╮
|
|
│ *TO SAVINGS* │
|
|
╰────────────────────────────╯
|
|
glob vs. the leaving leg
|
|
|
|
to account
|
|
╭────────────────────────────╮
|
|
│ savings │
|
|
╰────────────────────────────╯
|
|
money arrives here
|
|
|
|
to desc
|
|
╭────────────────────────────╮
|
|
│ *FROM CHECKING* │
|
|
╰────────────────────────────╯
|
|
glob vs. the arriving leg
|
|
|
|
tolerance %
|
|
╭────────────────────────────╮
|
|
│ 0 │
|
|
╰────────────────────────────╯
|
|
0 = amounts must match exactly
|
|
|
|
note
|
|
╭────────────────────────────╮
|
|
│ optional │
|
|
╰────────────────────────────╯
|
|
why this transfer exists
|
|
|
|
1 pairs · 1 unpaired
|
|
```
|
|
|
|
The account fields complete exactly as the rule builder's do. `tab` / `↑↓` move
|
|
between fields, `pgup` / `pgdn` scroll the list, `enter` appends the definition
|
|
to `rules.toml` and re-pairs immediately, and `esc` goes back. 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. Six fields need more room than the rule builder's four, so on a short
|
|
window this form gives up its spacing, then its hints, then the borders on the
|
|
fields you are not editing — every field stays on screen.
|
|
|
|
`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.
|
|
|
|
```
|
|
money · 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.
|
|
|
|
`d` deletes the selected definition and `p` deletes every `✗` one at once, both
|
|
after a `y`. Definitions marked `⚠` are never pruned — they are doing something,
|
|
just not finishing it, and deleting one would hide the problem rather than fix
|
|
it. `r` re-pairs against 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 TUI'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:
|
|
|
|
```
|
|
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.
|
|
|
|
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 ./...
|
|
```
|