Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
656e7e1de4 | ||
|
|
6477147988 | ||
|
|
442684be60 | ||
|
|
5289250400 | ||
|
|
ebf7770569 | ||
|
|
ef6a231d98 | ||
|
|
93491016cd | ||
|
|
02814d09e5 | ||
|
|
5ca6cff87b | ||
|
|
16c2585637 |
@@ -2,5 +2,4 @@
|
||||
/cmd/money/money
|
||||
|
||||
# Never commit a data root that happens to live inside the repo.
|
||||
.money/
|
||||
*.db
|
||||
|
||||
@@ -11,29 +11,108 @@ This file covers what the code assumes and why.
|
||||
|
||||
```
|
||||
cmd/money/main.go subcommands; the TUI is the default
|
||||
internal/config rules.toml, account.toml, XDG config, data-root resolution
|
||||
internal/config rules.toml (rules + transfers), account.toml, XDG config, data-root resolution
|
||||
internal/model Account, Transaction, amount formatting, description normalisation
|
||||
internal/glob the `*` / `?` matcher used by rules (linear time, no backtracking)
|
||||
internal/parser Parser interface + registry; csv, cmd, nlb, revolut, traderepublic
|
||||
internal/glob the `*` / `?` matcher used by rules (two-pointer, no exponential blowup)
|
||||
internal/parser Parser interface + registry; nlb, revolut, traderepublic
|
||||
internal/store SQLite index (modernc.org/sqlite, no cgo)
|
||||
internal/importer directory walk, dedupe, balance checks
|
||||
internal/rules applies ordered rules to rule_* columns only
|
||||
internal/report per-tag aggregation, transfers excluded
|
||||
internal/rules applies ordered rules, writing rule_tag
|
||||
internal/transfers pairs the two legs of a movement, writing the transfers table
|
||||
internal/report per-tag aggregation
|
||||
internal/tui Bubble Tea models
|
||||
```
|
||||
|
||||
## Invariants
|
||||
|
||||
**Rule verdicts and manual edits never share a column.** `rule_tag` /
|
||||
`rule_transfer` are rewritten wholesale on every retag; `manual_tag` /
|
||||
`manual_transfer` are only ever written by the user. Effective values are
|
||||
`COALESCE(manual_*, rule_*)`. This is what makes `money retag` safe to run at
|
||||
any time, and it is the first thing to preserve when touching the schema or the
|
||||
rules engine. Covered by `TestManualTagSurvivesRetag`.
|
||||
**Every tag comes from rules.toml.** There is no way to tag a transaction by
|
||||
hand; `rule_tag` is derived state that `Engine.Retag` rewrites wholesale, which
|
||||
is what makes `money retag` safe to run at any time. Nothing may write a tag
|
||||
from anywhere else — the moment something does, the index stops being
|
||||
reproducible and retag stops being safe. Covered by `TestRetagRewritesEveryTag`.
|
||||
|
||||
**A transfer is a pair or it is nothing.** A `[[transfer]]` block names both
|
||||
legs (`from_account` + `from_desc`, `to_account` + `to_desc`), and only a
|
||||
matched pair is dropped from the report — both legs together, never one. This
|
||||
is the whole reason the earlier boolean `transfer` flag was removed and this
|
||||
replaced it: a one-sided verdict let half a movement vanish and left the report
|
||||
unbalanced. An unmatched leg therefore keeps counting, and is surfaced as a
|
||||
warning instead: `⚠` on the transfers screen, on stderr from `import`/`retag`.
|
||||
Nothing may start excluding a single leg on the strength of one side matching.
|
||||
|
||||
Legs pair within `transfers.WindowDays`, nearest date first. **Within one
|
||||
currency the amount is the evidence and must be the exact opposite**, unless
|
||||
the definition sets `tolerance_pct` — a per-definition opt-in for a route where
|
||||
the bank takes a fee on the way, so the two statements genuinely disagree. It
|
||||
defaults to zero and belongs on the one definition that charges: a global or
|
||||
default tolerance would loosen every route that does not. **Across currencies
|
||||
the amount is not checked at all**: there are no exchange rates here, so the two
|
||||
numbers are unrelated and the dates carry the pairing alone. A tolerance means
|
||||
nothing there and is ignored. That asymmetry is the design, not an oversight —
|
||||
do not "fix" the cross-currency case by inventing a rate, and do not turn the
|
||||
tolerance into the default. Like rules, the first definition claims a leg and a
|
||||
transaction belongs to at most one transfer.
|
||||
|
||||
**A tolerated mismatch is a fee, and a fee is money, so it is reported rather
|
||||
than forgiven.** That is the condition on the tolerance existing at all: the
|
||||
pair leaves the report entirely, so a difference swallowed inside one would be
|
||||
spending that never appears anywhere. `Pair.Fee` is what left less what
|
||||
arrived, and `report.Excluded` carries it out per currency alongside the legs —
|
||||
named on the `money report` line and on its own row under the TUI's report.
|
||||
Nothing may pair on a mismatch without that difference reaching `Excluded`.
|
||||
It is a fee and not a tag: no rule produces it, `report.ByTag` never sees it,
|
||||
and it must not be turned into a synthetic transaction to make the total
|
||||
reconcile. `Excluded` counts it only for pairs whose legs are *both* in view,
|
||||
for the same reason it counts legs and not pairs — half a pair cannot say what
|
||||
the other half received.
|
||||
|
||||
**`(transfer)` in a tag column is display only.** `Transaction.DisplayTag`
|
||||
falls back to `model.TransferTag` for a paired leg so the column does not read
|
||||
as a blank waiting for a rule, but nothing writes it: `rule_tag` stays what
|
||||
rules.toml made it, `store.Tags` never returns it, and no rule can be built
|
||||
from it. It is the same kind of label as `report.Untagged`. Do not "persist"
|
||||
it — that is precisely the second verdict this design exists to avoid.
|
||||
|
||||
**A paired leg is not untagged.** `store.Filter{Untagged: true}` means "no tag
|
||||
*and* no transfer", so a leg never turns up in `money ls --untagged`, the `u`
|
||||
view or the rule builder's preview asking to be tagged — it is spoken for, and
|
||||
the report drops it regardless. Covered by `TestPairedLegsAreNotUntagged`. An
|
||||
*unpaired* leg is untagged like anything else, which is how it gets noticed.
|
||||
|
||||
**The pairing is derived state, exactly like the tags.** `Engine.Link` rewrites
|
||||
the whole `transfers` table from rules.toml, so `money retag` re-derives both
|
||||
halves of what that file decides. It runs over the whole index deliberately —
|
||||
pairing within a filtered view would let a movement count as a transfer in one
|
||||
report and not in another. Note the asymmetry with a schema *column*: a new
|
||||
table is created by `CREATE TABLE IF NOT EXISTS` in `store.Open`, so adding one
|
||||
does not force an index rebuild.
|
||||
|
||||
**Rules and transfers share rules.toml, so textual deletion is block-aware.**
|
||||
`config.deleteBlocks` finds every `[[...]]` header, not only the kind being
|
||||
deleted, because a block ends where the *next* block of any kind begins —
|
||||
otherwise deleting a rule would swallow a transfer that follows it. Covered by
|
||||
`TestDeleteLeavesTheOtherKindAlone`.
|
||||
|
||||
**Statements are the source of truth; the index is disposable.** Deleting
|
||||
`.money/index.db` and re-importing must reproduce everything except manual tags
|
||||
and manual transfer marks.
|
||||
`index.db` at the root of the data directory and re-importing must reproduce
|
||||
everything, with nothing lost.
|
||||
|
||||
**The index is a cache, so there are no migrations.** `store.Open` applies
|
||||
`schema` and nothing else — no `ALTER TABLE`, no version column, no repair of
|
||||
an index an older build wrote. Changing the schema costs one line in the
|
||||
release note: delete `index.db` and import again. The two directions are not
|
||||
symmetric, which is worth knowing before you assume something is broken:
|
||||
|
||||
- *Removing* a column leaves an older index still working, carrying the dead
|
||||
column and its data unread.
|
||||
- *Adding* one the code reads breaks every command against an older index with
|
||||
`query transactions: SQL logic error: no such column: …` until it is deleted.
|
||||
|
||||
That asymmetry holds only because every statement names its columns —
|
||||
**no `SELECT *`, and nothing may depend on column order.** Keep it that way.
|
||||
Do not reintroduce migration machinery either; if re-parsing ever becomes too
|
||||
expensive to ask for, that is a decision to revisit deliberately rather than a
|
||||
helper to slip back in.
|
||||
|
||||
**Dedupe is by fingerprint**: `sha256(date | amount | normalised description |
|
||||
ordinal)`, where the ordinal distinguishes identical lines *within one
|
||||
@@ -45,15 +124,31 @@ milliseconds. That is the checksum skip working, not a failure.
|
||||
**Money is `int64` minor units**, never a float. Per-account currency, no
|
||||
conversion, and totals are never summed across currencies.
|
||||
|
||||
**First matching rule wins**, so transfer rules belong above general tag rules.
|
||||
A rule setting several of `match` / `counterparty` / `type` requires all of them.
|
||||
`config.AppendRule` therefore appends — never prepends — so saving from the
|
||||
rule builder cannot shadow a rule the user wrote by hand.
|
||||
**The most specific rule wins, not the topmost.** `rules.Engine` sorts the
|
||||
rules once in `New` and matches in that order: most literal characters first,
|
||||
then fewest `*`, then account-scoped over unscoped, with a *stable* sort so
|
||||
equally specific rules keep file order and the earlier one still wins. That is
|
||||
what lets `*NIKOLA*` carve an exception out of `*NIK*` from anywhere in the
|
||||
file, and a catch-all `*` sit wherever it reads best. A rule setting both
|
||||
`match` and `type` requires both of them, and both count towards its literals.
|
||||
|
||||
**The rule builder is a form, so the global keymap must not apply there.**
|
||||
`Update` routes to `updateRules` before `updateNormal` whenever the view is
|
||||
`viewRules`, or typing `q` would quit and `i` would start an import. Any new
|
||||
full-screen input needs the same treatment.
|
||||
The two orders must not be confused. `Engine.rules` stays in file order and
|
||||
`Rules()`, `Usage` and `MatchIndex` all speak in file positions, because that
|
||||
is what the rules screen numbers, what `config.DeleteRules` deletes by, and
|
||||
what the user can point at in rules.toml; only `Engine.order` is sorted.
|
||||
Anything new that reports a rule must report its file position too.
|
||||
|
||||
`config.AppendRule` still appends rather than prepends, but that now only
|
||||
settles ties: a saved rule cannot displace an equally specific one written by
|
||||
hand, while a narrower one is meant to take precedence and does.
|
||||
|
||||
**The builders are forms, so the global keymap must not apply there.**
|
||||
`Update` routes to `updateRules` / `updateTransfers` before `updateNormal`
|
||||
whenever the view is `viewRules` or `viewTransfers`, or typing `q` would quit
|
||||
and `i` would start an import. Any new full-screen input needs the same
|
||||
treatment. It cuts the other way too: a command *inside* a builder has to be a
|
||||
chord — `ctrl+s` re-sorts the preview — because every printable key belongs to
|
||||
the field being typed in.
|
||||
|
||||
**In the rule builder, `tab` completes first and moves focus second.** The
|
||||
account and tag fields use `textinput.ShowSuggestions`, whose own `AcceptSuggestion`
|
||||
@@ -65,8 +160,8 @@ field navigation, and lets `ctrl+n` / `ctrl+p` through to the input for cycling.
|
||||
it afterwards or `ctrl+n` would offer candidates that no longer fit the value.
|
||||
|
||||
**A rule's usage count is how many transactions it wins, not how many its glob
|
||||
could match** — `Engine.Usage` counts by `MatchIndex`, so a rule shadowed by an
|
||||
earlier one correctly reports zero. That is what makes the rules screen able to
|
||||
could match** — `Engine.Usage` counts by `MatchIndex`, so a rule shadowed by a
|
||||
more specific one correctly reports zero. That is what makes the rules screen able to
|
||||
find dead rules at all.
|
||||
|
||||
**A rule's `note` is documentation that round-trips.** It is a TOML key rather
|
||||
@@ -74,12 +169,31 @@ than a `#` comment so `LoadRules` can return it, the builder can write it and
|
||||
the rules screen can show it. It never takes part in matching — `rules.Engine`
|
||||
does not look at it — and it must stay that way.
|
||||
|
||||
**`config.DeleteRules` edits rules.toml textually, never by re-serialising the
|
||||
parsed rules**, because comments and formatting are not recoverable from
|
||||
`[]Rule`. A rule owns the comment lines directly above it; a comment separated
|
||||
by a blank line is a heading for what follows and stays. Both writers go
|
||||
through `writeFileAtomic`, and deletion re-parses the result before replacing
|
||||
the file.
|
||||
**`config.DeleteRules` and `config.ReplaceRule` edit rules.toml textually, never
|
||||
by re-serialising the parsed rules**, because comments and formatting are not
|
||||
recoverable from `[]Rule`. A rule owns the comment lines directly above it, so
|
||||
deleting takes them with it; a comment separated by a blank line is a heading
|
||||
for what follows and stays. Replacing keeps them — they say why the rule is
|
||||
there, which editing its glob rarely changes — and rewrites only the rule's own
|
||||
lines, leaving its *position* alone: position still breaks ties, so a rule that
|
||||
moved could start beating an equally specific one it never used to. Every
|
||||
writer goes through `writeFileAtomic`, and both editors re-parse the result
|
||||
before replacing the file.
|
||||
|
||||
**An edit must not lose what the builder does not show.** The form has four
|
||||
fields and a `Rule` has five, so `saveRule` carries `Type` through from the rule
|
||||
being edited and the form says it is doing so. Nothing may round-trip a rule
|
||||
through those four fields alone — a pattern the user was never shown is not a
|
||||
pattern they chose to remove. A new field on `Rule` needs the same treatment or
|
||||
a field of its own. Covered by `TestRuleEditKeepsTypePattern`.
|
||||
|
||||
**The rule builder's preview is what the rule is judged against, which is not
|
||||
always "what is untagged".** For a new rule those are the same thing. For an
|
||||
edit they are not: the rule's own transactions are tagged, so an untagged-only
|
||||
preview would be empty for a rule that works. `reloadPreviewGroups` adds them
|
||||
back, and `refreshRulePreview` keeps the ones the new glob stops catching on
|
||||
screen marked `−` instead of dropping them silently, because giving one up is
|
||||
the decision being made.
|
||||
|
||||
## Adding a bank parser
|
||||
|
||||
@@ -100,6 +214,15 @@ compresses runs of spaces, so columns shift with font and page size — derive
|
||||
positions from the header line (`traderepublic`) or from the line being parsed
|
||||
(`nlb`).
|
||||
|
||||
**The other side's account number belongs in the description, not in a field
|
||||
of its own.** There used to be a `Counterparty` field; only `nlb` could fill it
|
||||
honestly, `revolut` and `traderepublic` invented it with an IBAN-shaped regex
|
||||
over the description, and the two disagreed on spacing, so one rule pattern
|
||||
could not serve both. Now `nlb` appends its IBAN column to the end of the
|
||||
description — at the end, and not in the position it held on the page, so an
|
||||
IBAN wrapped across continuation lines stays contiguous for a glob to match.
|
||||
A new parser must do the same rather than reintroduce a structured field.
|
||||
|
||||
## Verifying
|
||||
|
||||
```
|
||||
@@ -115,6 +238,11 @@ money --root /tmp/.../demo import # must report 0 new
|
||||
money --root /tmp/.../demo ls --wide
|
||||
```
|
||||
|
||||
If you changed the schema, delete that root's `index.db` first. Nothing
|
||||
migrates it, so a demo root left over from an earlier build either carries dead
|
||||
columns or fails with `no such column`, depending on which way the schema
|
||||
moved.
|
||||
|
||||
### Driving the TUI in tests
|
||||
|
||||
Prefer feeding `tea.Msg` values to `Model.Update` directly — that is how every
|
||||
|
||||
@@ -3,18 +3,17 @@
|
||||
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.
|
||||
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 + transfer rules, in order
|
||||
.money/index.db # SQLite index (rebuildable; safe to delete*)
|
||||
rules.toml # tag rules and transfer definitions, in order
|
||||
index.db # SQLite index (rebuildable; safe to delete*)
|
||||
checking/
|
||||
account.toml # currency + how to parse this bank's exports
|
||||
account.toml # currency + which parser reads this bank's exports
|
||||
2026-01.csv
|
||||
2026-02.csv
|
||||
savings/
|
||||
@@ -22,8 +21,10 @@ transfers so they never count as spending.
|
||||
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.
|
||||
\* 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
|
||||
|
||||
@@ -61,9 +62,10 @@ compiles only the files listed, which breaks the moment the package has two.
|
||||
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 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
|
||||
@@ -75,35 +77,76 @@ 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` / `tab` | accounts · transactions · report · rule builder · rules |
|
||||
| `1` `2` `3` `4` `5` `6` `7` / `tab` | accounts · transactions · report · rule builder · rules · transfer builder · transfers |
|
||||
| `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 |
|
||||
| `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 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.
|
||||
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 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.
|
||||
|
||||
```
|
||||
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
|
||||
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 │
|
||||
@@ -127,6 +170,15 @@ 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.
|
||||
|
||||
`ctrl+s` switches the list between by name and by count, most seen first — a
|
||||
chord rather than a letter, because every printable key belongs to the field you
|
||||
are typing in. The `↓` in the header 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 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
|
||||
@@ -137,47 +189,194 @@ the folders on disk and the index; tags from every tag in use plus any named in
|
||||
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.
|
||||
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
|
||||
|
||||
`e` on the rules screen (`5`) opens the selected rule in this same form with the
|
||||
fields filled in, and `enter` 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, and `esc`
|
||||
leaves the rule as it was.
|
||||
|
||||
### Rules (`5`)
|
||||
|
||||
Every rule in file order, with the number of transactions it actually claims.
|
||||
Rules that claim none are marked `✗`.
|
||||
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 T Txns Note
|
||||
1 *LIDL* (all) groceries 3 the weekly shop
|
||||
2 ✗ *LIDL SOFIA* (all) shadowed 0
|
||||
# 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 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.
|
||||
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. 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.
|
||||
`e` opens the selected rule in the builder to edit it, `d` deletes it, and `p`
|
||||
deletes every rule marked `✗` at once; both deletions 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.
|
||||
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. 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
|
||||
|
||||
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.
|
||||
**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.
|
||||
|
||||
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.
|
||||
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
|
||||
@@ -195,24 +394,6 @@ tag = "salary"
|
||||
match = "*ACME PAYROLL*"
|
||||
note = "paid on the 4th; the December one lands early"
|
||||
|
||||
# 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"
|
||||
@@ -225,9 +406,142 @@ 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.
|
||||
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
|
||||
|
||||
@@ -236,43 +550,20 @@ Every account folder needs one. `currency` and `parser` are required.
|
||||
```toml
|
||||
name = "Main Checking"
|
||||
currency = "EUR"
|
||||
parser = "csv"
|
||||
parser = "nlb"
|
||||
# minor_digits = 2 # decimal places for this currency
|
||||
# include = ["*.csv"] # only treat matching files as statements
|
||||
```
|
||||
|
||||
### `parser = "csv"`
|
||||
### Parsers
|
||||
|
||||
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.
|
||||
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 and the counterparty IBAN are folded in from continuation lines. |
|
||||
| `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. |
|
||||
|
||||
@@ -286,33 +577,19 @@ 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.
|
||||
|
||||
### `parser = "cmd"`
|
||||
## Adding a parser
|
||||
|
||||
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:
|
||||
A new bank means a new parser. Drop it in `internal/parser` and register it —
|
||||
no other package changes:
|
||||
|
||||
```go
|
||||
func init() {
|
||||
|
||||
+156
-43
@@ -8,7 +8,9 @@ package main
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
|
||||
@@ -19,6 +21,7 @@ import (
|
||||
"git.petrovv.com/nikola/money/internal/report"
|
||||
"git.petrovv.com/nikola/money/internal/rules"
|
||||
"git.petrovv.com/nikola/money/internal/store"
|
||||
"git.petrovv.com/nikola/money/internal/transfers"
|
||||
"git.petrovv.com/nikola/money/internal/tui"
|
||||
)
|
||||
|
||||
@@ -27,9 +30,10 @@ const usage = `money - statement-driven personal finance tracker
|
||||
usage: money [--root DIR] <command> [flags]
|
||||
|
||||
commands:
|
||||
tui browse and tag transactions, build and prune rules (default)
|
||||
tui browse transactions, build the rules that tag them and the
|
||||
transfers that pair them across accounts (default)
|
||||
import extract transactions from every statement into the index
|
||||
retag re-apply rules.toml to everything already imported
|
||||
retag re-apply rules.toml: retag everything and re-pair transfers
|
||||
ls list transactions
|
||||
report spending by tag
|
||||
accounts list accounts with balances
|
||||
@@ -137,24 +141,33 @@ func cmdConfig(root string, source config.RootSource) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// opened is everything a command needs: the index, the account folders on
|
||||
// disk, and the two engines rules.toml describes.
|
||||
type opened struct {
|
||||
db *store.DB
|
||||
accounts []*config.Account
|
||||
engine *rules.Engine
|
||||
links *transfers.Engine
|
||||
}
|
||||
|
||||
// open loads the config and index that every command needs.
|
||||
func open(root string) (*store.DB, []*config.Account, *rules.Engine, error) {
|
||||
func open(root string) (*opened, error) {
|
||||
if _, err := os.Stat(root); err != nil {
|
||||
return nil, nil, nil, fmt.Errorf("data root %s is not readable: %w", root, err)
|
||||
return nil, fmt.Errorf("data root %s is not readable: %w", root, err)
|
||||
}
|
||||
accounts, err := config.LoadAccounts(root)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
return nil, err
|
||||
}
|
||||
r, err := config.LoadRules(root)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
return nil, err
|
||||
}
|
||||
db, err := store.Open(config.IndexPath(root))
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
return nil, err
|
||||
}
|
||||
return db, accounts, rules.New(r), nil
|
||||
return &opened{db: db, accounts: accounts, engine: rules.New(r), links: transfers.New(r)}, nil
|
||||
}
|
||||
|
||||
func cmdImport(root string, args []string) error {
|
||||
@@ -164,17 +177,17 @@ func cmdImport(root string, args []string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
db, accounts, engine, err := open(root)
|
||||
o, err := open(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer db.Close()
|
||||
defer o.db.Close()
|
||||
|
||||
if len(accounts) == 0 {
|
||||
if len(o.accounts) == 0 {
|
||||
return fmt.Errorf("no accounts found in %s (an account is a folder containing %s)", root, config.AccountFile)
|
||||
}
|
||||
|
||||
res, err := importer.Run(root, db, accounts, engine, importer.Options{Force: *force})
|
||||
res, err := importer.Run(root, o.db, o.accounts, o.engine, o.links, importer.Options{Force: *force})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -199,6 +212,7 @@ func cmdImport(root string, args []string) error {
|
||||
|
||||
parsed, added, skipped := res.Total()
|
||||
fmt.Printf("\n%d new, %d duplicate, %d parsed; %d rows retagged\n", added, skipped, parsed, res.Retagged)
|
||||
reportPairing(res.Paired, res.Unpaired)
|
||||
|
||||
failures := res.Errs()
|
||||
for _, f := range failures {
|
||||
@@ -210,79 +224,163 @@ func cmdImport(root string, args []string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// cmdRetag re-derives everything rules.toml decides: the tags and the transfer
|
||||
// pairing. They are one command because they are one file, and leaving half of
|
||||
// the derived state stale would be worse than not offering it at all.
|
||||
func cmdRetag(root string, _ []string) error {
|
||||
db, _, engine, err := open(root)
|
||||
o, err := open(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer db.Close()
|
||||
defer o.db.Close()
|
||||
|
||||
n, err := engine.Retag(db)
|
||||
n, err := o.engine.Retag(o.db)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("%d transactions retagged\n", n)
|
||||
|
||||
paired, unpaired, err := o.links.Link(o.db)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
reportPairing(paired, unpaired)
|
||||
return nil
|
||||
}
|
||||
|
||||
// reportPairing prints the state of the transfer pairing. Unmatched legs are
|
||||
// sent to stderr: a leg that never found its other side is money that left an
|
||||
// account and cannot be shown to have arrived, which is a warning, not a
|
||||
// statistic.
|
||||
func reportPairing(paired, unpaired int) {
|
||||
if paired == 0 && unpaired == 0 {
|
||||
return
|
||||
}
|
||||
fmt.Printf("%d transfers matched\n", paired)
|
||||
if unpaired > 0 {
|
||||
fmt.Fprintf(os.Stderr, "warning: %d transfer leg(s) have no counterpart\n", unpaired)
|
||||
}
|
||||
}
|
||||
|
||||
func cmdLs(root string, args []string) error {
|
||||
fs := flag.NewFlagSet("ls", flag.ContinueOnError)
|
||||
account := fs.String("account", "", "only this account slug")
|
||||
month := fs.String("month", "", "only this month (YYYY-MM)")
|
||||
search := fs.String("search", "", "only descriptions containing this text")
|
||||
untagged := fs.Bool("untagged", false, "only transactions with no effective tag")
|
||||
untagged := fs.Bool("untagged", false, "only transactions no rule tagged and no transfer claimed")
|
||||
limit := fs.Int("limit", 0, "maximum rows (0 = no limit)")
|
||||
wide := fs.Bool("wide", false, "also show type, counterparty and reported balance")
|
||||
wide := fs.Bool("wide", false, "also show type and reported balance")
|
||||
uniq := fs.Bool("uniq", false, "one row per distinct description")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
if *uniq && *wide {
|
||||
// --wide's columns all belong to a single transaction, and nothing
|
||||
// sensible can be printed for a whole group of them.
|
||||
return fmt.Errorf("--uniq and --wide cannot be combined")
|
||||
}
|
||||
|
||||
db, _, _, err := open(root)
|
||||
o, err := open(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer db.Close()
|
||||
defer o.db.Close()
|
||||
|
||||
txns, err := db.Transactions(store.Filter{
|
||||
f := store.Filter{
|
||||
AccountSlug: *account,
|
||||
Month: *month,
|
||||
Search: *search,
|
||||
Untagged: *untagged,
|
||||
Limit: *limit,
|
||||
})
|
||||
}
|
||||
// Deduplicating first would make a limit mean "the distinct descriptions
|
||||
// among the newest N rows", which is not what it says. Under --uniq it caps
|
||||
// what is printed instead.
|
||||
if *uniq {
|
||||
f.Limit = 0
|
||||
}
|
||||
txns, err := o.db.Transactions(f)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if *uniq {
|
||||
return printUniq(os.Stdout, txns, *limit)
|
||||
}
|
||||
|
||||
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
||||
if *wide {
|
||||
fmt.Fprintln(w, "DATE\tACCOUNT\tAMOUNT\tCUR\tBALANCE\tTAG\tT\tTYPE\tCOUNTERPARTY\tDESCRIPTION")
|
||||
fmt.Fprintln(w, "DATE\tACCOUNT\tAMOUNT\tCUR\tBALANCE\tTAG\tTYPE\tDESCRIPTION")
|
||||
} else {
|
||||
fmt.Fprintln(w, "DATE\tACCOUNT\tAMOUNT\tCUR\tTAG\tT\tDESCRIPTION")
|
||||
fmt.Fprintln(w, "DATE\tACCOUNT\tAMOUNT\tCUR\tTAG\tDESCRIPTION")
|
||||
}
|
||||
for _, t := range txns {
|
||||
transfer := ""
|
||||
if t.IsTransfer() {
|
||||
transfer = "T"
|
||||
}
|
||||
if *wide {
|
||||
balance := ""
|
||||
if t.BalanceMinor != nil {
|
||||
balance = model.FormatMinor(*t.BalanceMinor, t.MinorDigits)
|
||||
}
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n",
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n",
|
||||
t.Date, t.AccountSlug, t.FormatAmount(), t.Currency, balance,
|
||||
t.Tag(), transfer, t.Type, t.Counterparty, t.Description)
|
||||
t.DisplayTag(), t.Type, t.Description)
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n",
|
||||
t.Date, t.AccountSlug, t.FormatAmount(), t.Currency, t.Tag(), transfer, t.Description)
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n",
|
||||
t.Date, t.AccountSlug, t.FormatAmount(), t.Currency, t.DisplayTag(), t.Description)
|
||||
}
|
||||
w.Flush()
|
||||
fmt.Printf("\n%d transactions\n", len(txns))
|
||||
return nil
|
||||
}
|
||||
|
||||
// printUniq lists each description once, which is the shape of the question
|
||||
// "what still needs a rule?" — fifty visits to one shop are one pattern to
|
||||
// write, not fifty rows to read.
|
||||
//
|
||||
// The account is deliberately not part of the key. A rule matches on the
|
||||
// description and only optionally narrows to an account, so the same payee
|
||||
// seen on two accounts is still one pattern; splitting the row per account
|
||||
// would overstate the work left and disagree with the rule builder's list,
|
||||
// which groups the same way. Use --account to scope the listing instead.
|
||||
//
|
||||
// Descriptions are printed as rules.Engine matches them: normalised, since that
|
||||
// is the string a glob is actually tested against, so a pattern written from
|
||||
// this list behaves the way the list reads. It also means two statements that
|
||||
// differ only in spacing or case collapse to the one row they deserve.
|
||||
func printUniq(out io.Writer, txns []model.Transaction, limit int) error {
|
||||
seen := map[string]bool{}
|
||||
var rows []string
|
||||
for _, t := range txns {
|
||||
desc := model.NormalizeDescription(t.Description)
|
||||
if seen[desc] {
|
||||
continue
|
||||
}
|
||||
seen[desc] = true
|
||||
rows = append(rows, desc)
|
||||
}
|
||||
// Alphabetical, so the same payee under slightly different wordings lands
|
||||
// on adjacent lines, where one glob covering both is easy to see.
|
||||
sort.Strings(rows)
|
||||
|
||||
total := len(rows)
|
||||
if limit > 0 && len(rows) > limit {
|
||||
rows = rows[:limit]
|
||||
}
|
||||
|
||||
w := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0)
|
||||
fmt.Fprintln(w, "DESCRIPTION")
|
||||
for _, desc := range rows {
|
||||
fmt.Fprintln(w, desc)
|
||||
}
|
||||
w.Flush()
|
||||
|
||||
if len(rows) < total {
|
||||
fmt.Fprintf(out, "\n%d of %d distinct descriptions\n", len(rows), total)
|
||||
return nil
|
||||
}
|
||||
fmt.Fprintf(out, "\n%d distinct descriptions\n", total)
|
||||
return nil
|
||||
}
|
||||
|
||||
func cmdReport(root string, args []string) error {
|
||||
fs := flag.NewFlagSet("report", flag.ContinueOnError)
|
||||
month := fs.String("month", "", "only this month (YYYY-MM)")
|
||||
@@ -291,13 +389,13 @@ func cmdReport(root string, args []string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
db, _, _, err := open(root)
|
||||
o, err := open(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer db.Close()
|
||||
defer o.db.Close()
|
||||
|
||||
txns, err := db.Transactions(store.Filter{Month: *month, AccountSlug: *account})
|
||||
txns, err := o.db.Transactions(store.Filter{Month: *month, AccountSlug: *account})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -316,29 +414,44 @@ func cmdReport(root string, args []string) error {
|
||||
model.FormatMinor(c.Net(), c.Digits))
|
||||
}
|
||||
w.Flush()
|
||||
fmt.Println("\ntransfers between own accounts are excluded")
|
||||
|
||||
// Say what was held out, or the report silently disagrees with the account
|
||||
// balances by exactly the amount moved between accounts. Both directions
|
||||
// are named: they came off the OUT and IN columns respectively, and an
|
||||
// exchange puts only one of them in this currency.
|
||||
for _, x := range report.Excluded(txns) {
|
||||
line := fmt.Sprintf("\ntransfers excluded: %d legs in %s, %s out, %s in",
|
||||
x.Legs, x.Currency,
|
||||
model.FormatMinor(x.Out, x.Digits), model.FormatMinor(x.In, x.Digits))
|
||||
// A fee went out with the pair that carried it, so unless it is named
|
||||
// here it is money that left the report without ever being spent.
|
||||
if x.Fee != 0 {
|
||||
line += fmt.Sprintf(", %s in fees", model.FormatMinor(x.Fee, x.Digits))
|
||||
}
|
||||
fmt.Println(line)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func cmdAccounts(root string, _ []string) error {
|
||||
db, _, _, err := open(root)
|
||||
o, err := open(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer db.Close()
|
||||
defer o.db.Close()
|
||||
|
||||
accounts, err := db.Accounts()
|
||||
accounts, err := o.db.Accounts()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
||||
fmt.Fprintln(w, "SLUG\tNAME\tBALANCE\tCUR\tTXNS")
|
||||
for _, a := range accounts {
|
||||
bal, err := db.Balance(a.ID)
|
||||
bal, err := o.db.Balance(a.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, err := db.Count(a.ID)
|
||||
n, err := o.db.Count(a.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -349,10 +462,10 @@ func cmdAccounts(root string, _ []string) error {
|
||||
}
|
||||
|
||||
func cmdTUI(root string, _ []string) error {
|
||||
db, accounts, engine, err := open(root)
|
||||
o, err := open(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer db.Close()
|
||||
return tui.Run(root, db, accounts, engine)
|
||||
defer o.db.Close()
|
||||
return tui.Run(root, o.db, o.accounts, o.engine, o.links)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.petrovv.com/nikola/money/internal/model"
|
||||
)
|
||||
|
||||
func txn(account, desc string) model.Transaction {
|
||||
return model.Transaction{AccountSlug: account, Description: desc}
|
||||
}
|
||||
|
||||
func uniqOutput(t *testing.T, limit int, txns ...model.Transaction) string {
|
||||
t.Helper()
|
||||
var b strings.Builder
|
||||
if err := printUniq(&b, txns, limit); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// The point of the listing is one row per pattern still to write, so fifty
|
||||
// visits to one shop must not be fifty rows.
|
||||
func TestUniqCollapsesRepeats(t *testing.T) {
|
||||
out := uniqOutput(t, 0,
|
||||
txn("checking", "LIDL SOFIA 4412"),
|
||||
txn("checking", "LIDL SOFIA 4412"),
|
||||
txn("checking", "ZARA"),
|
||||
)
|
||||
if n := strings.Count(out, "LIDL SOFIA 4412"); n != 1 {
|
||||
t.Errorf("the shop appears %d times, want once:\n%s", n, out)
|
||||
}
|
||||
if !strings.Contains(out, "2 distinct descriptions") {
|
||||
t.Errorf("count is wrong:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// Descriptions are printed as a glob sees them, so two statements differing
|
||||
// only in spacing or case are the one pattern they really are -- and what is
|
||||
// printed is exactly what a rule written from it will match.
|
||||
func TestUniqNormalisesLikeAGlob(t *testing.T) {
|
||||
out := uniqOutput(t, 0,
|
||||
txn("checking", "Lidl Sofia 4412"),
|
||||
txn("checking", "LIDL SOFIA 4412"),
|
||||
)
|
||||
if !strings.Contains(out, "LIDL SOFIA 4412") {
|
||||
t.Errorf("want the normalised description:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "1 distinct descriptions") {
|
||||
t.Errorf("spacing and case should not make a second row:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// The account is not part of the key. 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 the rule builder's list groups the same
|
||||
// way, which is the list this one has to agree with.
|
||||
func TestUniqIgnoresTheAccount(t *testing.T) {
|
||||
out := uniqOutput(t, 0,
|
||||
txn("checking", "TRANSFER"),
|
||||
txn("savings", "TRANSFER"),
|
||||
)
|
||||
if !strings.Contains(out, "1 distinct descriptions") {
|
||||
t.Errorf("want one row for one description:\n%s", out)
|
||||
}
|
||||
if strings.Contains(out, "checking") || strings.Contains(out, "savings") {
|
||||
t.Errorf("the account has no column here:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// The limit caps the rows printed, and says so, rather than silently cutting
|
||||
// the list down to a number that looks complete.
|
||||
func TestUniqLimitReportsWhatItHeldBack(t *testing.T) {
|
||||
out := uniqOutput(t, 1,
|
||||
txn("checking", "ZARA"),
|
||||
txn("checking", "LIDL"),
|
||||
)
|
||||
if !strings.Contains(out, "1 of 2 distinct descriptions") {
|
||||
t.Errorf("want the total alongside the limit:\n%s", out)
|
||||
}
|
||||
// Alphabetical within an account, so near-identical wordings land together.
|
||||
if !strings.Contains(out, "LIDL") || strings.Contains(out, "ZARA") {
|
||||
t.Errorf("want the first row alphabetically:\n%s", out)
|
||||
}
|
||||
}
|
||||
+295
-112
@@ -18,23 +18,18 @@ const (
|
||||
RulesFile = "rules.toml"
|
||||
// AccountFile is the per-account config inside each account folder.
|
||||
AccountFile = "account.toml"
|
||||
// StateDir holds the rebuildable SQLite index.
|
||||
StateDir = ".money"
|
||||
// IndexFile is the SQLite index inside StateDir.
|
||||
// IndexFile is the rebuildable SQLite index, at the root of the data
|
||||
// directory alongside rules.toml.
|
||||
IndexFile = "index.db"
|
||||
)
|
||||
|
||||
// Rule is one entry in rules.toml. Rules are evaluated in file order and the
|
||||
// first one whose Match (and optional Account) matches wins.
|
||||
// Rule is one entry in rules.toml. The most specific rule that matches wins,
|
||||
// with file order breaking ties between equally specific ones; rules.Engine
|
||||
// owns that ordering.
|
||||
type Rule struct {
|
||||
Match string `toml:"match"`
|
||||
Tag string `toml:"tag"`
|
||||
Transfer bool `toml:"transfer"`
|
||||
Account string `toml:"account"` // optional: restrict to one account slug
|
||||
// Counterparty matches the other side's account number, which for
|
||||
// movements between the user's own accounts is often the only reliable
|
||||
// signal. Optional; when set, it must match as well as Match.
|
||||
Counterparty string `toml:"counterparty"`
|
||||
// Type matches the bank's own classification, e.g. Revolut's CARD_PAYMENT.
|
||||
// Optional; when set, it must match as well as Match.
|
||||
Type string `toml:"type"`
|
||||
@@ -45,9 +40,40 @@ type Rule struct {
|
||||
Note string `toml:"note"`
|
||||
}
|
||||
|
||||
// Rules is the parsed rules.toml.
|
||||
// Transfer is one entry in rules.toml describing money moved between two
|
||||
// accounts the user owns. Both sides are named: a transfer is only ever a pair,
|
||||
// which is what lets the report drop it without leaving half a movement behind.
|
||||
//
|
||||
// All four patterns are required. A one-sided definition would be a rule.
|
||||
type Transfer struct {
|
||||
FromAccount string `toml:"from_account"`
|
||||
FromDesc string `toml:"from_desc"` // glob vs. the leaving leg's description
|
||||
ToAccount string `toml:"to_account"`
|
||||
ToDesc string `toml:"to_desc"` // glob vs. the arriving leg's description
|
||||
// TolerancePct is how far short (or over) the arriving leg may be and still
|
||||
// count as the same movement, as a percentage of the leg that left. It exists
|
||||
// for routes where the bank takes a fee on the way, so the two statements
|
||||
// genuinely disagree about the amount.
|
||||
//
|
||||
// Zero — the default, and what every definition written before this key
|
||||
// existed means — requires the exact opposite amount. Keep it that way unless
|
||||
// a route actually charges: the wider the tolerance, the more likely two
|
||||
// unrelated movements in one window pair with each other.
|
||||
//
|
||||
// It applies within one currency only. Across currencies the amount is not
|
||||
// checked at all, so there is nothing for a tolerance to loosen.
|
||||
TolerancePct float64 `toml:"tolerance_pct"`
|
||||
// Note is free text for the reader, exactly as on a Rule: it never takes
|
||||
// part in matching.
|
||||
Note string `toml:"note"`
|
||||
}
|
||||
|
||||
// Rules is the parsed rules.toml: the tagging rules and the transfer
|
||||
// definitions, which share the file because they are both hand-maintained
|
||||
// statements about the same transactions.
|
||||
type Rules struct {
|
||||
Rule []Rule `toml:"rule"`
|
||||
Transfer []Transfer `toml:"transfer"`
|
||||
}
|
||||
|
||||
// LoadRules reads rules.toml from the data root. A missing file is not an
|
||||
@@ -62,30 +88,143 @@ func LoadRules(root string) (*Rules, error) {
|
||||
return nil, fmt.Errorf("%s: %w", path, err)
|
||||
}
|
||||
for i, rule := range r.Rule {
|
||||
if rule.Match == "" && rule.Counterparty == "" && rule.Type == "" {
|
||||
return nil, fmt.Errorf("%s: rule %d has no match, counterparty or type pattern", path, i+1)
|
||||
if rule.Match == "" && rule.Type == "" {
|
||||
return nil, fmt.Errorf("%s: rule %d has no match or type pattern", path, i+1)
|
||||
}
|
||||
if rule.Tag == "" && !rule.Transfer {
|
||||
return nil, fmt.Errorf("%s: rule %d (%q) sets neither tag nor transfer", path, i+1, rule.Match)
|
||||
if rule.Tag == "" {
|
||||
return nil, fmt.Errorf("%s: rule %d (%q) sets no tag", path, i+1, rule.Match)
|
||||
}
|
||||
}
|
||||
for i, t := range r.Transfer {
|
||||
if err := checkTransfer(t); err != nil {
|
||||
return nil, fmt.Errorf("%s: transfer %d: %w", path, i+1, err)
|
||||
}
|
||||
}
|
||||
return &r, nil
|
||||
}
|
||||
|
||||
// checkTransfer rejects a half-written definition. Both sides are needed to
|
||||
// pair anything at all, so a missing one is refused on load rather than
|
||||
// silently matching nothing.
|
||||
func checkTransfer(t Transfer) error {
|
||||
switch {
|
||||
case t.FromAccount == "":
|
||||
return fmt.Errorf("no from_account")
|
||||
case t.FromDesc == "":
|
||||
return fmt.Errorf("no from_desc pattern")
|
||||
case t.ToAccount == "":
|
||||
return fmt.Errorf("no to_account")
|
||||
case t.ToDesc == "":
|
||||
return fmt.Errorf("no to_desc pattern")
|
||||
}
|
||||
// A negative tolerance is a typo, and one at 100% or beyond would let any
|
||||
// amount pair with any other — at which point the dates decide alone, which
|
||||
// is the cross-currency rule and not something to arrive at by accident.
|
||||
if t.TolerancePct < 0 || t.TolerancePct >= 100 {
|
||||
return fmt.Errorf("tolerance_pct %g is not between 0 and 100", t.TolerancePct)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AppendRule adds a rule to the end of rules.toml, creating the file if it is
|
||||
// not there yet. Appending rather than inserting means an existing rule always
|
||||
// keeps precedence, since the first match wins.
|
||||
// not there yet. Position no longer decides precedence — the most specific rule
|
||||
// wins, see rules.Engine — but it still breaks ties, so appending rather than
|
||||
// inserting keeps a saved rule from displacing an equally specific one the user
|
||||
// wrote by hand.
|
||||
//
|
||||
// The file is rewritten through a temporary file so a failure part-way cannot
|
||||
// leave the user with a truncated config.
|
||||
func AppendRule(root string, r Rule) error {
|
||||
if r.Match == "" && r.Counterparty == "" && r.Type == "" {
|
||||
return fmt.Errorf("a rule needs a match, counterparty or type pattern")
|
||||
if err := checkRule(r); err != nil {
|
||||
return err
|
||||
}
|
||||
if r.Tag == "" && !r.Transfer {
|
||||
return fmt.Errorf("a rule needs a tag or transfer = true")
|
||||
return appendBlock(root, formatRule(r))
|
||||
}
|
||||
|
||||
// checkRule rejects a rule that would match nothing or decide nothing.
|
||||
func checkRule(r Rule) error {
|
||||
if r.Match == "" && r.Type == "" {
|
||||
return fmt.Errorf("a rule needs a match or type pattern")
|
||||
}
|
||||
if r.Tag == "" {
|
||||
return fmt.Errorf("a rule needs a tag")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReplaceRule rewrites the rule at the given position (0-based, as loaded by
|
||||
// LoadRules) with r, leaving it where it sits. Position no longer decides
|
||||
// precedence, but it still breaks ties, so an edited rule that moved could
|
||||
// start losing to — or start beating — an equally specific one it never used to.
|
||||
//
|
||||
// Like DeleteRules this edits the file textually rather than re-serialising the
|
||||
// parsed rules, because comments and formatting are not recoverable from
|
||||
// []Rule. Only the rule's own lines are replaced: the comments directly above
|
||||
// it say why it is there, which editing its glob rarely changes, so they stay.
|
||||
func ReplaceRule(root string, pos int, r Rule) error {
|
||||
if err := checkRule(r); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
path := filepath.Join(root, RulesFile)
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read %s: %w", path, err)
|
||||
}
|
||||
lines := strings.Split(string(raw), "\n")
|
||||
|
||||
starts := blockStarts(lines)
|
||||
rules, transfers := blocksOf(starts, "rule"), blocksOf(starts, "transfer")
|
||||
if pos < 0 || pos >= len(rules) {
|
||||
return fmt.Errorf("rule %d is out of range; %s holds %d rules", pos+1, path, len(rules))
|
||||
}
|
||||
|
||||
from, to := blockExtent(lines, starts, rules[pos])
|
||||
kept := make([]string, 0, len(lines))
|
||||
kept = append(kept, lines[:from]...)
|
||||
kept = append(kept, strings.Split(strings.TrimRight(formatRule(r), "\n"), "\n")...)
|
||||
kept = append(kept, lines[to:]...)
|
||||
out := strings.Join(kept, "\n")
|
||||
|
||||
// Never write something that will not load again, and never let editing one
|
||||
// rule disturb a neighbour — of either kind, since both live in this file.
|
||||
var check Rules
|
||||
if _, err := toml.Decode(out, &check); err != nil {
|
||||
return fmt.Errorf("editing %s would produce invalid TOML: %w", path, err)
|
||||
}
|
||||
if len(check.Rule) != len(rules) || len(check.Transfer) != len(transfers) {
|
||||
return fmt.Errorf("editing %s would leave %d rules and %d transfers, expected %d and %d",
|
||||
path, len(check.Rule), len(check.Transfer), len(rules), len(transfers))
|
||||
}
|
||||
if check.Rule[pos] != r {
|
||||
return fmt.Errorf("editing %s would leave rule %d as %+v, expected %+v",
|
||||
path, pos+1, check.Rule[pos], r)
|
||||
}
|
||||
|
||||
return writeFileAtomic(root, path, out)
|
||||
}
|
||||
|
||||
// AppendTransfer adds a transfer definition to the end of rules.toml. Order
|
||||
// matters for transfers as it does for rules: an earlier definition claims a
|
||||
// transaction first, so appending cannot steal a leg from one already written.
|
||||
func AppendTransfer(root string, t Transfer) error {
|
||||
if err := checkTransfer(t); err != nil {
|
||||
// A missing side reads better as what the form still wants; anything
|
||||
// else already says what is wrong with what was typed.
|
||||
if rest, ok := strings.CutPrefix(err.Error(), "no "); ok {
|
||||
return fmt.Errorf("a transfer needs %s", rest)
|
||||
}
|
||||
return err
|
||||
}
|
||||
return appendBlock(root, formatTransfer(t))
|
||||
}
|
||||
|
||||
// appendBlock adds a rendered TOML table to the end of rules.toml, creating the
|
||||
// file if it is not there yet.
|
||||
//
|
||||
// The file is rewritten through a temporary file so a failure part-way cannot
|
||||
// leave the user with a truncated config.
|
||||
func appendBlock(root, block string) error {
|
||||
path := filepath.Join(root, RulesFile)
|
||||
existing, err := os.ReadFile(path)
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
@@ -98,20 +237,91 @@ func AppendRule(root string, r Rule) error {
|
||||
b.WriteString("\n")
|
||||
}
|
||||
b.WriteString("\n")
|
||||
b.WriteString(formatRule(r))
|
||||
b.WriteString(block)
|
||||
|
||||
return writeFileAtomic(root, path, b.String())
|
||||
}
|
||||
|
||||
// DeleteRules removes the rules at the given positions (0-based, as loaded by
|
||||
// LoadRules) from rules.toml.
|
||||
func DeleteRules(root string, positions []int) (int, error) {
|
||||
return deleteBlocks(root, "rule", positions)
|
||||
}
|
||||
|
||||
// DeleteTransfers removes the transfer definitions at the given positions
|
||||
// (0-based, as loaded by LoadRules) from rules.toml.
|
||||
func DeleteTransfers(root string, positions []int) (int, error) {
|
||||
return deleteBlocks(root, "transfer", positions)
|
||||
}
|
||||
|
||||
// blockStart is where one array-of-tables entry begins in rules.toml.
|
||||
type blockStart struct {
|
||||
table string // "rule" or "transfer"
|
||||
line int
|
||||
}
|
||||
|
||||
// blockStarts finds every [[table]] header in the file. Every kind is
|
||||
// collected, not just the one being deleted: a block ends where the *next*
|
||||
// block of any kind begins, so deleting a rule that happens to sit above a
|
||||
// transfer must not swallow it.
|
||||
func blockStarts(lines []string) []blockStart {
|
||||
var out []blockStart
|
||||
for i, line := range lines {
|
||||
s := strings.TrimSpace(line)
|
||||
if strings.HasPrefix(s, "[[") && strings.HasSuffix(s, "]]") {
|
||||
out = append(out, blockStart{table: strings.TrimSpace(s[2 : len(s)-2]), line: i})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// blocksOf lists where the entries of one table sit among all the blocks, so a
|
||||
// position in rules.toml as LoadRules numbers it can be turned into a position
|
||||
// in the file.
|
||||
func blocksOf(starts []blockStart, table string) []int {
|
||||
var out []int
|
||||
for k, s := range starts {
|
||||
if s.table == table {
|
||||
out = append(out, k)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// commentPrefix walks back from a block's header over the comment lines it
|
||||
// owns. An entry owns the run of comments directly above it, with no blank line
|
||||
// in between; anything further up is a heading for what follows.
|
||||
func commentPrefix(lines []string, header int) int {
|
||||
i := header
|
||||
for i > 0 && strings.HasPrefix(strings.TrimSpace(lines[i-1]), "#") {
|
||||
i--
|
||||
}
|
||||
return i
|
||||
}
|
||||
|
||||
// blockExtent returns the k-th block's own lines as a half-open range: from its
|
||||
// header to the last line it owns, leaving out the comments introducing the
|
||||
// next block and the blank lines between the two, which belong to neither.
|
||||
func blockExtent(lines []string, starts []blockStart, k int) (from, to int) {
|
||||
from = starts[k].line
|
||||
to = len(lines)
|
||||
if k+1 < len(starts) {
|
||||
to = commentPrefix(lines, starts[k+1].line)
|
||||
}
|
||||
for to > from+1 && strings.TrimSpace(lines[to-1]) == "" {
|
||||
to--
|
||||
}
|
||||
return from, to
|
||||
}
|
||||
|
||||
// deleteBlocks removes entries of one table from rules.toml.
|
||||
//
|
||||
// The file is edited textually rather than re-serialised from the parsed
|
||||
// rules, so comments, ordering and formatting the user put there by hand
|
||||
// survive. A comment block sitting directly above a deleted rule goes with it,
|
||||
// since it documents that rule; a comment separated by a blank line is treated
|
||||
// values, so comments, ordering and formatting the user put there by hand
|
||||
// survive. A comment block sitting directly above a deleted entry goes with it,
|
||||
// since it documents that entry; a comment separated by a blank line is treated
|
||||
// as a section heading and left alone.
|
||||
func DeleteRules(root string, positions []int) (int, error) {
|
||||
func deleteBlocks(root, table string, positions []int) (int, error) {
|
||||
if len(positions) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
@@ -127,48 +337,26 @@ func DeleteRules(root string, positions []int) (int, error) {
|
||||
}
|
||||
lines := strings.Split(string(raw), "\n")
|
||||
|
||||
// Where each [[rule]] block begins.
|
||||
var starts []int
|
||||
for i, line := range lines {
|
||||
if strings.TrimSpace(line) == "[[rule]]" {
|
||||
starts = append(starts, i)
|
||||
}
|
||||
}
|
||||
starts := blockStarts(lines)
|
||||
// mine[p] is where the p-th entry of this table sits among all the blocks.
|
||||
mine := blocksOf(starts, table)
|
||||
for _, p := range positions {
|
||||
if p < 0 || p >= len(starts) {
|
||||
return 0, fmt.Errorf("rule %d is out of range; %s holds %d rules", p+1, path, len(starts))
|
||||
if p < 0 || p >= len(mine) {
|
||||
return 0, fmt.Errorf("%s %d is out of range; %s holds %d %ss",
|
||||
table, p+1, path, len(mine), table)
|
||||
}
|
||||
}
|
||||
|
||||
// A rule owns the run of comment lines directly above it, with no blank
|
||||
// line in between. Anything further up is a heading for what follows.
|
||||
prefix := func(k int) int {
|
||||
i := starts[k]
|
||||
for i > 0 && strings.HasPrefix(strings.TrimSpace(lines[i-1]), "#") {
|
||||
i--
|
||||
}
|
||||
return i
|
||||
}
|
||||
|
||||
drop := map[int]bool{}
|
||||
for k := range starts {
|
||||
if !doomed[k] {
|
||||
continue
|
||||
}
|
||||
// The block runs up to the next rule's comment prefix, so a comment
|
||||
// introducing the following rule is not swept up with this one.
|
||||
end := len(lines)
|
||||
if k+1 < len(starts) {
|
||||
end = prefix(k + 1)
|
||||
}
|
||||
for i := prefix(k); i < end; i++ {
|
||||
for p := range doomed {
|
||||
k := mine[p]
|
||||
// A rule's own comments document it and go with it; blockExtent leaves
|
||||
// out the blank lines below it, which are the gap between blocks and
|
||||
// part of neither, so the neighbours are not glued together.
|
||||
_, end := blockExtent(lines, starts, k)
|
||||
for i := commentPrefix(lines, starts[k].line); i < end; i++ {
|
||||
drop[i] = true
|
||||
}
|
||||
// Blank lines are the gap between rules, not part of either; leaving
|
||||
// them avoids gluing the neighbours together.
|
||||
for i := end - 1; i >= starts[k] && strings.TrimSpace(lines[i]) == ""; i-- {
|
||||
delete(drop, i)
|
||||
}
|
||||
}
|
||||
|
||||
kept := make([]string, 0, len(lines))
|
||||
@@ -179,14 +367,27 @@ func DeleteRules(root string, positions []int) (int, error) {
|
||||
}
|
||||
out := collapseBlankRuns(kept)
|
||||
|
||||
// Never write something that will not load again.
|
||||
// Never write something that will not load again, and never let deleting
|
||||
// one kind of block take a different kind with it.
|
||||
var check Rules
|
||||
if _, err := toml.Decode(out, &check); err != nil {
|
||||
return 0, fmt.Errorf("deleting from %s would produce invalid TOML: %w", path, err)
|
||||
}
|
||||
if want := len(starts) - len(doomed); len(check.Rule) != want {
|
||||
return 0, fmt.Errorf("deleting from %s would leave %d rules, expected %d",
|
||||
path, len(check.Rule), want)
|
||||
counts := map[string]int{"rule": len(check.Rule), "transfer": len(check.Transfer)}
|
||||
for _, kind := range []string{"rule", "transfer"} {
|
||||
want := 0
|
||||
for _, s := range starts {
|
||||
if s.table == kind {
|
||||
want++
|
||||
}
|
||||
}
|
||||
if kind == table {
|
||||
want -= len(doomed)
|
||||
}
|
||||
if counts[kind] != want {
|
||||
return 0, fmt.Errorf("deleting from %s would leave %d %ss, expected %d",
|
||||
path, counts[kind], kind, want)
|
||||
}
|
||||
}
|
||||
|
||||
if err := writeFileAtomic(root, path, out); err != nil {
|
||||
@@ -244,64 +445,48 @@ func writeFileAtomic(dir, path, content string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// writeKey renders one TOML key, skipping it when empty. The column is wide
|
||||
// enough for the longest key either block uses, so the values line up.
|
||||
func writeKey(b *strings.Builder, key, value string) {
|
||||
if value != "" {
|
||||
fmt.Fprintf(b, "%-12s = %s\n", key, strconv.Quote(value))
|
||||
}
|
||||
}
|
||||
|
||||
// formatTransfer renders a transfer as a TOML table, the two sides in the
|
||||
// order money travels.
|
||||
func formatTransfer(t Transfer) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("[[transfer]]\n")
|
||||
writeKey(&b, "from_account", t.FromAccount)
|
||||
writeKey(&b, "from_desc", t.FromDesc)
|
||||
writeKey(&b, "to_account", t.ToAccount)
|
||||
writeKey(&b, "to_desc", t.ToDesc)
|
||||
// Only when set: a zero written out would suggest the key is doing something
|
||||
// when it is exactly the default every other definition already has.
|
||||
if t.TolerancePct != 0 {
|
||||
fmt.Fprintf(&b, "%-12s = %s\n", "tolerance_pct",
|
||||
strconv.FormatFloat(t.TolerancePct, 'f', -1, 64))
|
||||
}
|
||||
writeKey(&b, "note", t.Note)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// formatRule renders a rule as a TOML table, omitting empty fields.
|
||||
func formatRule(r Rule) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("[[rule]]\n")
|
||||
write := func(key, value string) {
|
||||
if value != "" {
|
||||
fmt.Fprintf(&b, "%-12s = %s\n", key, strconv.Quote(value))
|
||||
}
|
||||
}
|
||||
write := func(key, value string) { writeKey(&b, key, value) }
|
||||
write("match", r.Match)
|
||||
write("counterparty", r.Counterparty)
|
||||
write("type", r.Type)
|
||||
write("account", r.Account)
|
||||
write("tag", r.Tag)
|
||||
if r.Transfer {
|
||||
b.WriteString("transfer = true\n")
|
||||
}
|
||||
// Last, so the patterns and the tag stay lined up above it however long
|
||||
// the note runs.
|
||||
write("note", r.Note)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// Column locates one field in a CSV row.
|
||||
type Column struct {
|
||||
Col int `toml:"col"`
|
||||
Layout string `toml:"layout"` // date only, Go reference layout
|
||||
Decimal string `toml:"decimal"` // amount only, default "."
|
||||
Thousands string `toml:"thousands"` // amount only, default ""
|
||||
}
|
||||
|
||||
// CSVConfig describes how to read a delimited statement.
|
||||
type CSVConfig struct {
|
||||
Delimiter string `toml:"delimiter"`
|
||||
SkipRows int `toml:"skip_rows"`
|
||||
Encoding string `toml:"encoding"` // "" or "utf-8"; other encodings unsupported for now
|
||||
Date Column `toml:"date"`
|
||||
Description Column `toml:"description"`
|
||||
Amount *Column `toml:"amount"` // single signed column...
|
||||
Debit *Column `toml:"debit"` // ...or a debit/credit pair
|
||||
Credit *Column `toml:"credit"`
|
||||
// Invert flips the sign of the parsed amount, for statements that report
|
||||
// outflows as positive numbers.
|
||||
Invert bool `toml:"invert"`
|
||||
}
|
||||
|
||||
// CmdConfig runs an external extractor (e.g. one of the existing Python
|
||||
// scripts) and reads normalised CSV from its stdout.
|
||||
type CmdConfig struct {
|
||||
// Argv is the command to run. The literal token "{{file}}" is replaced
|
||||
// with the absolute path of the statement being imported.
|
||||
Argv []string `toml:"argv"`
|
||||
// Layout is the date layout the script emits; defaults to 2006-01-02.
|
||||
Layout string `toml:"layout"`
|
||||
// SkipRows skips leading rows of the script's output (e.g. a header).
|
||||
SkipRows int `toml:"skip_rows"`
|
||||
}
|
||||
|
||||
// Account is a parsed account.toml.
|
||||
type Account struct {
|
||||
Slug string // folder name, filled in by LoadAccounts
|
||||
@@ -313,8 +498,6 @@ type Account struct {
|
||||
// Include restricts which files in the folder are treated as statements.
|
||||
// Defaults to every regular file except account.toml and dotfiles.
|
||||
Include []string `toml:"include"`
|
||||
CSV *CSVConfig `toml:"csv"`
|
||||
Cmd *CmdConfig `toml:"cmd"`
|
||||
}
|
||||
|
||||
// Digits returns the configured minor-unit scale, defaulting to 2.
|
||||
@@ -376,7 +559,7 @@ func loadAccount(dir, slug, cfgPath string) (*Account, error) {
|
||||
|
||||
// IndexPath returns the location of the SQLite index for a data root.
|
||||
func IndexPath(root string) string {
|
||||
return filepath.Join(root, StateDir, IndexFile)
|
||||
return filepath.Join(root, IndexFile)
|
||||
}
|
||||
|
||||
// UserConfig is the small file in the user's config directory that says where
|
||||
|
||||
@@ -181,7 +181,7 @@ func TestAppendRuleRejectsIncomplete(t *testing.T) {
|
||||
// Globs and tags containing quotes must survive the round trip.
|
||||
func TestAppendRuleQuotesValues(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if err := AppendRule(root, Rule{Match: `*"ODD" PAYEE*`, Tag: "misc", Transfer: true}); err != nil {
|
||||
if err := AppendRule(root, Rule{Match: `*"ODD" PAYEE*`, Tag: "misc"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
loaded, err := LoadRules(root)
|
||||
@@ -191,9 +191,6 @@ func TestAppendRuleQuotesValues(t *testing.T) {
|
||||
if loaded.Rule[0].Match != `*"ODD" PAYEE*` {
|
||||
t.Errorf("match = %q, want the quotes preserved", loaded.Rule[0].Match)
|
||||
}
|
||||
if !loaded.Rule[0].Transfer {
|
||||
t.Error("transfer flag was not written")
|
||||
}
|
||||
}
|
||||
|
||||
// A note is documentation carried with the rule. It is a key rather than a
|
||||
@@ -268,7 +265,6 @@ tag = "salary"
|
||||
# Moving money to myself.
|
||||
[[rule]]
|
||||
match = "*TO SAVINGS*"
|
||||
transfer = true
|
||||
tag = "transfer"
|
||||
`
|
||||
|
||||
@@ -459,3 +455,358 @@ func TestLoadUserConfigMalformed(t *testing.T) {
|
||||
t.Error("expected an error for a malformed config file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendTransfer(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
existing := "[[rule]]\nmatch = \"*LIDL*\"\ntag = \"groceries\"\n"
|
||||
if err := os.WriteFile(filepath.Join(root, RulesFile), []byte(existing), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
tr := Transfer{
|
||||
FromAccount: "nlb", FromDesc: "*TO REVOLUT*",
|
||||
ToAccount: "revolut", ToDesc: "*FROM NLB*",
|
||||
Note: `the monthly "top-up"`,
|
||||
}
|
||||
if err := AppendTransfer(root, tr); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
loaded, err := LoadRules(root)
|
||||
if err != nil {
|
||||
t.Fatalf("the file no longer parses after appending: %v", err)
|
||||
}
|
||||
if len(loaded.Rule) != 1 {
|
||||
t.Errorf("got %d rules, want the existing one untouched", len(loaded.Rule))
|
||||
}
|
||||
if len(loaded.Transfer) != 1 || loaded.Transfer[0] != tr {
|
||||
t.Errorf("transfers = %+v, want %+v", loaded.Transfer, tr)
|
||||
}
|
||||
}
|
||||
|
||||
// Both sides are required: a one-sided definition can never pair anything, so
|
||||
// it is refused rather than written and silently ignored.
|
||||
func TestTransferNeedsBothSides(t *testing.T) {
|
||||
full := Transfer{FromAccount: "a", FromDesc: "*OUT*", ToAccount: "b", ToDesc: "*IN*"}
|
||||
cases := map[string]func(*Transfer){
|
||||
"no from_account": func(t *Transfer) { t.FromAccount = "" },
|
||||
"no from_desc": func(t *Transfer) { t.FromDesc = "" },
|
||||
"no to_account": func(t *Transfer) { t.ToAccount = "" },
|
||||
"no to_desc": func(t *Transfer) { t.ToDesc = "" },
|
||||
}
|
||||
for name, break_ := range cases {
|
||||
root := t.TempDir()
|
||||
tr := full
|
||||
break_(&tr)
|
||||
if err := AppendTransfer(root, tr); err == nil {
|
||||
t.Errorf("%s: expected an error", name)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, RulesFile)); !os.IsNotExist(err) {
|
||||
t.Errorf("%s: a rejected transfer must not create the file", name)
|
||||
}
|
||||
}
|
||||
|
||||
// The same check applies to a file written by hand.
|
||||
root := t.TempDir()
|
||||
body := "[[transfer]]\nfrom_account = \"nlb\"\nfrom_desc = \"*OUT*\"\nto_account = \"revolut\"\n"
|
||||
if err := os.WriteFile(filepath.Join(root, RulesFile), []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := LoadRules(root); err == nil {
|
||||
t.Error("expected a half-written transfer to be refused on load")
|
||||
}
|
||||
}
|
||||
|
||||
// Rules and transfers share a file, so deleting one kind must not take a
|
||||
// neighbouring block of the other kind with it.
|
||||
func TestDeleteLeavesTheOtherKindAlone(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
path := filepath.Join(root, RulesFile)
|
||||
original := `[[rule]]
|
||||
match = "*LIDL*"
|
||||
tag = "groceries"
|
||||
|
||||
# Moving money to the broker.
|
||||
[[transfer]]
|
||||
from_account = "nlb"
|
||||
from_desc = "*TO TRADEREPUBLIC*"
|
||||
to_account = "traderepublic"
|
||||
to_desc = "*FROM NLB*"
|
||||
|
||||
[[rule]]
|
||||
match = "*ZARA*"
|
||||
tag = "clothes"
|
||||
`
|
||||
if err := os.WriteFile(path, []byte(original), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Deleting the first rule must stop at the transfer that follows it.
|
||||
if _, err := DeleteRules(root, []int{0}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
loaded, err := LoadRules(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(loaded.Rule) != 1 || loaded.Rule[0].Tag != "clothes" {
|
||||
t.Errorf("rules = %+v, want only the clothes rule left", loaded.Rule)
|
||||
}
|
||||
if len(loaded.Transfer) != 1 {
|
||||
t.Fatalf("transfers = %+v, want the transfer untouched", loaded.Transfer)
|
||||
}
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(raw), "# Moving money to the broker.") {
|
||||
t.Errorf("rules.toml = %q, want the transfer's own comment kept", raw)
|
||||
}
|
||||
|
||||
// And deleting the transfer leaves the remaining rule alone.
|
||||
if _, err := DeleteTransfers(root, []int{0}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
loaded, err = LoadRules(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(loaded.Transfer) != 0 || len(loaded.Rule) != 1 {
|
||||
t.Errorf("after deleting the transfer: %d rules, %d transfers; want 1 and 0",
|
||||
len(loaded.Rule), len(loaded.Transfer))
|
||||
}
|
||||
if raw, err = os.ReadFile(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(raw), "Moving money to the broker") {
|
||||
t.Errorf("rules.toml = %q, want the deleted transfer's comment gone with it", raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteTransfersOutOfRange(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if err := AppendTransfer(root, Transfer{
|
||||
FromAccount: "a", FromDesc: "*OUT*", ToAccount: "b", ToDesc: "*IN*",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := DeleteTransfers(root, []int{3}); err == nil {
|
||||
t.Error("expected an out-of-range position to be refused")
|
||||
}
|
||||
}
|
||||
|
||||
// A tolerance survives the round trip through the file, and a definition
|
||||
// without one keeps meaning what it always meant: exact amounts.
|
||||
func TestAppendTransferWithTolerance(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
tr := Transfer{
|
||||
FromAccount: "nlb", FromDesc: "*TO REVOLUT*",
|
||||
ToAccount: "revolut", ToDesc: "*FROM NLB*",
|
||||
TolerancePct: 1.5,
|
||||
Note: "NLB takes a wire fee",
|
||||
}
|
||||
if err := AppendTransfer(root, tr); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
body, err := os.ReadFile(filepath.Join(root, RulesFile))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(body), "tolerance_pct = 1.5") {
|
||||
t.Errorf("file = %q, want the tolerance written unquoted", body)
|
||||
}
|
||||
|
||||
loaded, err := LoadRules(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(loaded.Transfer) != 1 || loaded.Transfer[0] != tr {
|
||||
t.Errorf("transfers = %+v, want %+v", loaded.Transfer, tr)
|
||||
}
|
||||
}
|
||||
|
||||
// The default is exact, so an unset tolerance must not be written out as a key
|
||||
// suggesting the definition decided something.
|
||||
func TestAppendTransferOmitsAZeroTolerance(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
tr := Transfer{
|
||||
FromAccount: "nlb", FromDesc: "*TO REVOLUT*",
|
||||
ToAccount: "revolut", ToDesc: "*FROM NLB*",
|
||||
}
|
||||
if err := AppendTransfer(root, tr); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, err := os.ReadFile(filepath.Join(root, RulesFile))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(body), "tolerance_pct") {
|
||||
t.Errorf("file = %q, want no tolerance key", body)
|
||||
}
|
||||
}
|
||||
|
||||
// A negative tolerance is a typo, and one at 100% or beyond would let any
|
||||
// amount pair with any other, leaving the dates to decide alone.
|
||||
func TestTransferToleranceIsBounded(t *testing.T) {
|
||||
full := Transfer{FromAccount: "a", FromDesc: "*OUT*", ToAccount: "b", ToDesc: "*IN*"}
|
||||
for _, pct := range []float64{-1, 100, 250} {
|
||||
root := t.TempDir()
|
||||
tr := full
|
||||
tr.TolerancePct = pct
|
||||
if err := AppendTransfer(root, tr); err == nil {
|
||||
t.Errorf("tolerance_pct %g: expected an error", pct)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, RulesFile)); !os.IsNotExist(err) {
|
||||
t.Errorf("tolerance_pct %g: a rejected transfer must not create the file", pct)
|
||||
}
|
||||
}
|
||||
|
||||
// The same check applies to a file written by hand.
|
||||
root := t.TempDir()
|
||||
body := "[[transfer]]\nfrom_account = \"a\"\nfrom_desc = \"*OUT*\"\n" +
|
||||
"to_account = \"b\"\nto_desc = \"*IN*\"\ntolerance_pct = 150\n"
|
||||
if err := os.WriteFile(filepath.Join(root, RulesFile), []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := LoadRules(root); err == nil {
|
||||
t.Error("expected an out-of-range tolerance to be refused on load")
|
||||
}
|
||||
}
|
||||
|
||||
// Editing rewrites the rule where it sits: the position is what still breaks
|
||||
// ties between equally specific rules, so a rule that moved could start beating
|
||||
// one it never used to.
|
||||
func TestReplaceRuleKeepsPositionAndComments(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
path := filepath.Join(root, RulesFile)
|
||||
if err := os.WriteFile(path, []byte(rulesWithComments), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
edited := Rule{Match: "*LIDL SOFIA*", Tag: "groceries", Account: "checking", Note: "just the branch"}
|
||||
if err := ReplaceRule(root, 0, edited); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
loaded, err := LoadRules(root)
|
||||
if err != nil {
|
||||
t.Fatalf("the file no longer parses after editing: %v", err)
|
||||
}
|
||||
if len(loaded.Rule) != 3 {
|
||||
t.Fatalf("got %d rules, want the same 3: %+v", len(loaded.Rule), loaded.Rule)
|
||||
}
|
||||
if loaded.Rule[0] != edited {
|
||||
t.Errorf("rule 1 = %+v, want %+v", loaded.Rule[0], edited)
|
||||
}
|
||||
if loaded.Rule[1].Match != "*PAYROLL*" || loaded.Rule[2].Match != "*TO SAVINGS*" {
|
||||
t.Errorf("the rest = %+v, want them where they were", loaded.Rule[1:])
|
||||
}
|
||||
|
||||
// The comment above a rule says why it is there, which editing its glob
|
||||
// does not change.
|
||||
text, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, want := range []string{"# Rules for my accounts.", "# Weekly shop.", "# Moving money to myself."} {
|
||||
if !strings.Contains(string(text), want) {
|
||||
t.Errorf("comment %q was lost:\n%s", want, text)
|
||||
}
|
||||
}
|
||||
if strings.Contains(string(text), `"*LIDL*"`) {
|
||||
t.Errorf("the old pattern is still there:\n%s", text)
|
||||
}
|
||||
}
|
||||
|
||||
// A rule and the transfer below it share the file, so a replacement has to end
|
||||
// where the next block of any kind begins.
|
||||
func TestReplaceRuleLeavesTheOtherKindAlone(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
path := filepath.Join(root, RulesFile)
|
||||
original := `[[rule]]
|
||||
match = "*LIDL*"
|
||||
tag = "groceries"
|
||||
|
||||
# Moving money to the broker.
|
||||
[[transfer]]
|
||||
from_account = "nlb"
|
||||
from_desc = "*TO TRADEREPUBLIC*"
|
||||
to_account = "traderepublic"
|
||||
to_desc = "*FROM NLB*"
|
||||
|
||||
[[rule]]
|
||||
match = "*ZARA*"
|
||||
tag = "clothes"
|
||||
`
|
||||
if err := os.WriteFile(path, []byte(original), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := ReplaceRule(root, 0, Rule{Match: "*KAUFLAND*", Tag: "groceries"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
loaded, err := LoadRules(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(loaded.Transfer) != 1 || loaded.Transfer[0].FromDesc != "*TO TRADEREPUBLIC*" {
|
||||
t.Errorf("transfers = %+v, want the definition untouched", loaded.Transfer)
|
||||
}
|
||||
if len(loaded.Rule) != 2 || loaded.Rule[0].Match != "*KAUFLAND*" || loaded.Rule[1].Match != "*ZARA*" {
|
||||
t.Errorf("rules = %+v, want only the first one rewritten", loaded.Rule)
|
||||
}
|
||||
raw, _ := os.ReadFile(path)
|
||||
if !strings.Contains(string(raw), "# Moving money to the broker.") {
|
||||
t.Errorf("the transfer's comment was lost:\n%s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
// Editing a rule that sets a type keeps it: the caller decides what the rule
|
||||
// becomes, and a pattern left out of the replacement is a pattern removed.
|
||||
func TestReplaceRuleWritesEveryPattern(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
body := "[[rule]]\nmatch = \"*SPOTIFY*\"\ntype = \"CARD_PAYMENT\"\ntag = \"music\"\n"
|
||||
if err := os.WriteFile(filepath.Join(root, RulesFile), []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
kept := Rule{Match: "*SPOTIFY*", Type: "CARD_PAYMENT", Tag: "subscriptions"}
|
||||
if err := ReplaceRule(root, 0, kept); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
loaded, err := LoadRules(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if loaded.Rule[0] != kept {
|
||||
t.Errorf("rule = %+v, want %+v", loaded.Rule[0], kept)
|
||||
}
|
||||
|
||||
// A type-only rule is a rule; a rule with neither pattern is not.
|
||||
if err := ReplaceRule(root, 0, Rule{Type: "CARD_PAYMENT", Tag: "cards"}); err != nil {
|
||||
t.Errorf("a type-only rule was refused: %v", err)
|
||||
}
|
||||
if err := ReplaceRule(root, 0, Rule{Tag: "nothing"}); err == nil {
|
||||
t.Error("expected a rule with no pattern to be rejected")
|
||||
}
|
||||
if err := ReplaceRule(root, 0, Rule{Match: "*X*"}); err == nil {
|
||||
t.Error("expected a rule with no tag to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplaceRuleOutOfRange(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if err := AppendRule(root, Rule{Match: "*LIDL*", Tag: "groceries"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, pos := range []int{-1, 1, 7} {
|
||||
if err := ReplaceRule(root, pos, Rule{Match: "*X*", Tag: "x"}); err == nil {
|
||||
t.Errorf("position %d: expected an error", pos)
|
||||
}
|
||||
}
|
||||
loaded, _ := LoadRules(root)
|
||||
if len(loaded.Rule) != 1 || loaded.Rule[0].Match != "*LIDL*" {
|
||||
t.Errorf("rules = %+v, want the file untouched", loaded.Rule)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"git.petrovv.com/nikola/money/internal/parser"
|
||||
"git.petrovv.com/nikola/money/internal/rules"
|
||||
"git.petrovv.com/nikola/money/internal/store"
|
||||
"git.petrovv.com/nikola/money/internal/transfers"
|
||||
)
|
||||
|
||||
// FileResult is what happened to one statement file.
|
||||
@@ -38,6 +39,11 @@ type FileResult struct {
|
||||
type Result struct {
|
||||
Files []FileResult
|
||||
Retagged int
|
||||
// Paired and Unpaired are the state of the transfer pairing after the
|
||||
// import: how many movements were matched, and how many legs a definition
|
||||
// caught without finding the other side.
|
||||
Paired int
|
||||
Unpaired int
|
||||
}
|
||||
|
||||
// Total counts new rows across every file.
|
||||
@@ -69,8 +75,12 @@ type Options struct {
|
||||
Force bool
|
||||
}
|
||||
|
||||
// Run imports every account under root into db and then applies the rules.
|
||||
func Run(root string, db *store.DB, accounts []*config.Account, engine *rules.Engine, opts Options) (Result, error) {
|
||||
// Run imports every account under root into db and then applies the rules and
|
||||
// the transfer definitions. Both are derived from rules.toml and both are
|
||||
// recomputed over the whole index, since a statement imported now can complete
|
||||
// a transfer whose other leg arrived months ago.
|
||||
func Run(root string, db *store.DB, accounts []*config.Account, engine *rules.Engine,
|
||||
links *transfers.Engine, opts Options) (Result, error) {
|
||||
var res Result
|
||||
for _, acc := range accounts {
|
||||
accountID, err := db.UpsertAccount(model.Account{
|
||||
@@ -106,6 +116,12 @@ func Run(root string, db *store.DB, accounts []*config.Account, engine *rules.En
|
||||
return res, err
|
||||
}
|
||||
res.Retagged = n
|
||||
|
||||
paired, unpaired, err := links.Link(db)
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
res.Paired, res.Unpaired = paired, unpaired
|
||||
return res, nil
|
||||
}
|
||||
|
||||
@@ -169,11 +185,10 @@ func importFile(root string, db *store.DB, acc *config.Account, accountID int64,
|
||||
Date: t.Date,
|
||||
Description: t.Description,
|
||||
AmountMinor: t.AmountMinor,
|
||||
Counterparty: t.Counterparty,
|
||||
Type: t.Type,
|
||||
BalanceMinor: t.BalanceMinor,
|
||||
}
|
||||
txn.RuleTag, txn.RuleTransfer = engine.ApplyTxn(acc.Slug, txn)
|
||||
txn.RuleTag = engine.ApplyTxn(acc.Slug, txn)
|
||||
added, err := db.InsertTransaction(txn)
|
||||
if err != nil {
|
||||
fr.Err = err
|
||||
|
||||
@@ -1,28 +1,66 @@
|
||||
package importer
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.petrovv.com/nikola/money/internal/config"
|
||||
"git.petrovv.com/nikola/money/internal/parser"
|
||||
"git.petrovv.com/nikola/money/internal/rules"
|
||||
"git.petrovv.com/nikola/money/internal/store"
|
||||
"git.petrovv.com/nikola/money/internal/transfers"
|
||||
)
|
||||
|
||||
const accountTOML = `
|
||||
name = "Checking"
|
||||
currency = "EUR"
|
||||
parser = "csv"
|
||||
[csv]
|
||||
skip_rows = 1
|
||||
date = { col = 0, layout = "2006-01-02" }
|
||||
description = { col = 1 }
|
||||
amount = { col = 2 }
|
||||
parser = "test"
|
||||
`
|
||||
|
||||
// These tests are about the directory walk, the checksum skip, dedupe and
|
||||
// per-file error reporting -- not about any bank's layout. Driving them with a
|
||||
// real bank parser would drag that bank's quirks (Revolut's fee folding and
|
||||
// COMPLETED filter, the PDF parsers' dependency on pdftotext) into every
|
||||
// fixture, so they register the smallest parser that will do instead.
|
||||
func init() {
|
||||
parser.Register("test", func(acc *config.Account) (parser.Parser, error) {
|
||||
return testParser{digits: acc.Digits()}, nil
|
||||
})
|
||||
}
|
||||
|
||||
// testParser reads "date,description,amount" with one header row.
|
||||
type testParser struct{ digits int }
|
||||
|
||||
func (p testParser) Parse(path string, acc *config.Account) ([]parser.RawTxn, error) {
|
||||
body, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var txns []parser.RawTxn
|
||||
for i, line := range strings.Split(strings.TrimSpace(string(body)), "\n") {
|
||||
if i == 0 || strings.TrimSpace(line) == "" {
|
||||
continue // header
|
||||
}
|
||||
fields := strings.Split(line, ",")
|
||||
if len(fields) != 3 {
|
||||
return nil, fmt.Errorf("row %d: got %d fields, want 3", i+1, len(fields))
|
||||
}
|
||||
if _, err := time.Parse("2006-01-02", fields[0]); err != nil {
|
||||
return nil, fmt.Errorf("row %d: %w", i+1, err)
|
||||
}
|
||||
amount, err := parser.ParseAmount(fields[2], ".", "", p.digits)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("row %d: %w", i+1, err)
|
||||
}
|
||||
txns = append(txns, parser.RawTxn{Date: fields[0], Description: fields[1], AmountMinor: amount})
|
||||
}
|
||||
return txns, nil
|
||||
}
|
||||
|
||||
// newRoot builds a data root with one account and the given statement files.
|
||||
func newRoot(t *testing.T, statements map[string]string) (string, *store.DB, []*config.Account, *rules.Engine) {
|
||||
t.Helper()
|
||||
@@ -59,7 +97,7 @@ func write(t *testing.T, path, body string) {
|
||||
|
||||
func mustRun(t *testing.T, root string, db *store.DB, accounts []*config.Account, e *rules.Engine, opts Options) Result {
|
||||
t.Helper()
|
||||
res, err := Run(root, db, accounts, e, opts)
|
||||
res, err := Run(root, db, accounts, e, transfers.New(&config.Rules{}), opts)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -153,7 +191,7 @@ not-a-date,BROKEN,-1.00
|
||||
`,
|
||||
})
|
||||
|
||||
res, err := Run(root, db, accounts, engine, Options{})
|
||||
res, err := Run(root, db, accounts, engine, transfers.New(&config.Rules{}), Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("Run returned a fatal error, want a per-file report: %v", err)
|
||||
}
|
||||
|
||||
+31
-20
@@ -33,37 +33,48 @@ type Transaction struct {
|
||||
SourceFileID int64
|
||||
SourcePath string
|
||||
|
||||
// Counterparty is the other side's account number (an IBAN, where the
|
||||
// statement gives one). Transfers between the user's own accounts are
|
||||
// often only distinguishable by it.
|
||||
Counterparty string
|
||||
// Type is the bank's own classification, e.g. Revolut's CARD_PAYMENT.
|
||||
Type string
|
||||
// BalanceMinor is the running balance the statement reported after this
|
||||
// transaction, when it reports one. Used to verify the balance chain.
|
||||
BalanceMinor *int64
|
||||
|
||||
// RuleTag is the category, decided entirely by rules.toml. It is derived
|
||||
// state: `money retag` rewrites it wholesale.
|
||||
RuleTag string
|
||||
ManualTag string
|
||||
RuleTransfer bool
|
||||
ManualTransfer *bool
|
||||
|
||||
// TransferID names the matched movement this transaction is one leg of, or
|
||||
// nil when it is not part of one. Derived from the [[transfer]] blocks in
|
||||
// rules.toml and rewritten wholesale alongside the tags.
|
||||
TransferID *int64
|
||||
}
|
||||
|
||||
// Tag is the effective category: a manual tag always beats a rule tag.
|
||||
func (t Transaction) Tag() string {
|
||||
if t.ManualTag != "" {
|
||||
return t.ManualTag
|
||||
}
|
||||
// IsTransferLeg reports whether this transaction was paired with its opposite
|
||||
// number on another account, and so is money moved rather than money spent.
|
||||
func (t Transaction) IsTransferLeg() bool { return t.TransferID != nil }
|
||||
|
||||
// TransferTag is shown in the tag column for a matched transfer leg that no
|
||||
// rule has tagged, so a paired leg reads as accounted for rather than as a
|
||||
// blank waiting to be filled in. It is bracketed like report.Untagged because
|
||||
// it is the same kind of thing: a label the tool supplies, not one the user
|
||||
// wrote.
|
||||
const TransferTag = "(transfer)"
|
||||
|
||||
// DisplayTag is the tag to show for a transaction.
|
||||
//
|
||||
// It is display only. Nothing writes it back: rule_tag stays exactly what
|
||||
// rules.toml made it, so a transfer can never smuggle a tag into the index and
|
||||
// `money retag` remains safe to run at any time. A rule tag wins when there is
|
||||
// one, since that is the user's own word for the transaction and the transfers
|
||||
// screen is where the pairing is explained anyway.
|
||||
func (t Transaction) DisplayTag() string {
|
||||
if t.RuleTag != "" {
|
||||
return t.RuleTag
|
||||
}
|
||||
|
||||
// IsTransfer reports whether this line moves money between the user's own
|
||||
// accounts and so must not count as spending. A manual override wins.
|
||||
func (t Transaction) IsTransfer() bool {
|
||||
if t.ManualTransfer != nil {
|
||||
return *t.ManualTransfer
|
||||
}
|
||||
return t.RuleTransfer
|
||||
if t.IsTransferLeg() {
|
||||
return TransferTag
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// FormatAmount renders the amount using the account's minor-unit scale.
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
package parser
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/csv"
|
||||
"fmt"
|
||||
"io"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.petrovv.com/nikola/money/internal/config"
|
||||
)
|
||||
|
||||
func init() {
|
||||
Register("cmd", newCmdParser)
|
||||
}
|
||||
|
||||
// FileToken is replaced with the statement's absolute path in a cmd parser's argv.
|
||||
const FileToken = "{{file}}"
|
||||
|
||||
// cmdRunTimeout bounds an extractor run so a hung script cannot wedge an import.
|
||||
const cmdRunTimeout = 2 * time.Minute
|
||||
|
||||
// cmdParser runs an external extractor and reads normalised CSV from its
|
||||
// stdout: date,description,amount with any further columns ignored. This is
|
||||
// the bridge that lets the existing Python extractors be used unchanged.
|
||||
type cmdParser struct {
|
||||
cfg config.CmdConfig
|
||||
digits int
|
||||
}
|
||||
|
||||
func newCmdParser(acc *config.Account) (Parser, error) {
|
||||
if acc.Cmd == nil || len(acc.Cmd.Argv) == 0 {
|
||||
return nil, fmt.Errorf("account %s: parser \"cmd\" requires [cmd] with a non-empty argv", acc.Slug)
|
||||
}
|
||||
cfg := *acc.Cmd
|
||||
if cfg.Layout == "" {
|
||||
cfg.Layout = "2006-01-02"
|
||||
}
|
||||
if !hasFileToken(cfg.Argv) {
|
||||
return nil, fmt.Errorf("account %s: [cmd] argv must contain %s so the script knows which file to read",
|
||||
acc.Slug, FileToken)
|
||||
}
|
||||
return &cmdParser{cfg: cfg, digits: acc.Digits()}, nil
|
||||
}
|
||||
|
||||
func hasFileToken(argv []string) bool {
|
||||
for _, a := range argv {
|
||||
if strings.Contains(a, FileToken) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (p *cmdParser) Parse(path string, acc *config.Account) ([]RawTxn, error) {
|
||||
argv := make([]string, len(p.cfg.Argv))
|
||||
for i, a := range p.cfg.Argv {
|
||||
argv[i] = strings.ReplaceAll(a, FileToken, path)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), cmdRunTimeout)
|
||||
defer cancel()
|
||||
|
||||
cmd := exec.CommandContext(ctx, argv[0], argv[1:]...)
|
||||
cmd.Dir = acc.Dir
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
msg := strings.TrimSpace(stderr.String())
|
||||
if ctx.Err() == context.DeadlineExceeded {
|
||||
return nil, fmt.Errorf("extractor %v timed out after %s", argv, cmdRunTimeout)
|
||||
}
|
||||
if msg != "" {
|
||||
return nil, fmt.Errorf("extractor %v failed: %w: %s", argv, err, msg)
|
||||
}
|
||||
return nil, fmt.Errorf("extractor %v failed: %w", argv, err)
|
||||
}
|
||||
|
||||
return p.parseOutput(&stdout, argv)
|
||||
}
|
||||
|
||||
func (p *cmdParser) parseOutput(out io.Reader, argv []string) ([]RawTxn, error) {
|
||||
r := csv.NewReader(out)
|
||||
r.FieldsPerRecord = -1
|
||||
r.LazyQuotes = true
|
||||
|
||||
var txns []RawTxn
|
||||
for row := 0; ; row++ {
|
||||
rec, err := r.Read()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("extractor %v: output row %d: %w", argv, row+1, err)
|
||||
}
|
||||
if row < p.cfg.SkipRows || isBlank(rec) {
|
||||
continue
|
||||
}
|
||||
if len(rec) < 3 {
|
||||
return nil, fmt.Errorf("extractor %v: output row %d has %d columns, want date,description,amount",
|
||||
argv, row+1, len(rec))
|
||||
}
|
||||
d, err := time.Parse(p.cfg.Layout, strings.TrimSpace(rec[0]))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("extractor %v: output row %d: date %q does not match layout %q",
|
||||
argv, row+1, rec[0], p.cfg.Layout)
|
||||
}
|
||||
amount, err := ParseAmount(rec[2], ".", "", p.digits)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("extractor %v: output row %d: %w", argv, row+1, err)
|
||||
}
|
||||
txns = append(txns, RawTxn{
|
||||
Date: d.Format("2006-01-02"),
|
||||
Description: strings.TrimSpace(rec[1]),
|
||||
AmountMinor: amount,
|
||||
})
|
||||
}
|
||||
return txns, nil
|
||||
}
|
||||
@@ -1,175 +0,0 @@
|
||||
package parser
|
||||
|
||||
import (
|
||||
"encoding/csv"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.petrovv.com/nikola/money/internal/config"
|
||||
)
|
||||
|
||||
func init() {
|
||||
Register("csv", newCSVParser)
|
||||
}
|
||||
|
||||
// csvParser reads a delimited statement using column positions from
|
||||
// account.toml. Amounts come either from one signed column, or from a
|
||||
// debit/credit pair.
|
||||
type csvParser struct {
|
||||
cfg config.CSVConfig
|
||||
digits int
|
||||
}
|
||||
|
||||
func newCSVParser(acc *config.Account) (Parser, error) {
|
||||
if acc.CSV == nil {
|
||||
return nil, fmt.Errorf("account %s: parser \"csv\" requires a [csv] section", acc.Slug)
|
||||
}
|
||||
cfg := *acc.CSV
|
||||
if cfg.Amount == nil && cfg.Debit == nil && cfg.Credit == nil {
|
||||
return nil, fmt.Errorf("account %s: [csv] needs either amount or debit/credit columns", acc.Slug)
|
||||
}
|
||||
if cfg.Amount != nil && (cfg.Debit != nil || cfg.Credit != nil) {
|
||||
return nil, fmt.Errorf("account %s: [csv] sets both amount and debit/credit; pick one", acc.Slug)
|
||||
}
|
||||
if cfg.Date.Layout == "" {
|
||||
return nil, fmt.Errorf("account %s: [csv] date needs a layout, e.g. layout = \"02.01.2006\"", acc.Slug)
|
||||
}
|
||||
return &csvParser{cfg: cfg, digits: acc.Digits()}, nil
|
||||
}
|
||||
|
||||
func (p *csvParser) Parse(path string, acc *config.Account) ([]RawTxn, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
r := csv.NewReader(f)
|
||||
r.FieldsPerRecord = -1 // statements are ragged more often than not
|
||||
r.LazyQuotes = true
|
||||
if d := p.cfg.Delimiter; d != "" {
|
||||
runes := []rune(d)
|
||||
if len(runes) != 1 {
|
||||
return nil, fmt.Errorf("delimiter %q must be a single character", d)
|
||||
}
|
||||
r.Comma = runes[0]
|
||||
}
|
||||
|
||||
var out []RawTxn
|
||||
for row := 0; ; row++ {
|
||||
rec, err := r.Read()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: row %d: %w", path, row+1, err)
|
||||
}
|
||||
if row < p.cfg.SkipRows {
|
||||
continue
|
||||
}
|
||||
if isBlank(rec) {
|
||||
continue
|
||||
}
|
||||
txn, err := p.row(rec)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: row %d: %w", path, row+1, err)
|
||||
}
|
||||
out = append(out, txn)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (p *csvParser) row(rec []string) (RawTxn, error) {
|
||||
var t RawTxn
|
||||
|
||||
raw, err := field(rec, p.cfg.Date.Col)
|
||||
if err != nil {
|
||||
return t, fmt.Errorf("date column: %w", err)
|
||||
}
|
||||
d, err := time.Parse(p.cfg.Date.Layout, strings.TrimSpace(raw))
|
||||
if err != nil {
|
||||
return t, fmt.Errorf("date %q does not match layout %q", raw, p.cfg.Date.Layout)
|
||||
}
|
||||
t.Date = d.Format("2006-01-02")
|
||||
|
||||
desc, err := field(rec, p.cfg.Description.Col)
|
||||
if err != nil {
|
||||
return t, fmt.Errorf("description column: %w", err)
|
||||
}
|
||||
t.Description = strings.TrimSpace(desc)
|
||||
|
||||
switch {
|
||||
case p.cfg.Amount != nil:
|
||||
raw, err := field(rec, p.cfg.Amount.Col)
|
||||
if err != nil {
|
||||
return t, fmt.Errorf("amount column: %w", err)
|
||||
}
|
||||
v, err := ParseAmount(raw, p.cfg.Amount.Decimal, p.cfg.Amount.Thousands, p.digits)
|
||||
if err != nil {
|
||||
return t, err
|
||||
}
|
||||
t.AmountMinor = v
|
||||
default:
|
||||
// Debit/credit pair: exactly one of the two carries a value, and both
|
||||
// are written as positive numbers.
|
||||
debit, err := p.optional(rec, p.cfg.Debit)
|
||||
if err != nil {
|
||||
return t, fmt.Errorf("debit column: %w", err)
|
||||
}
|
||||
credit, err := p.optional(rec, p.cfg.Credit)
|
||||
if err != nil {
|
||||
return t, fmt.Errorf("credit column: %w", err)
|
||||
}
|
||||
if debit != 0 && credit != 0 {
|
||||
return t, fmt.Errorf("both debit (%d) and credit (%d) are set", debit, credit)
|
||||
}
|
||||
t.AmountMinor = credit - abs(debit)
|
||||
}
|
||||
|
||||
if p.cfg.Invert {
|
||||
t.AmountMinor = -t.AmountMinor
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// optional parses a column that may legitimately be blank, as debit/credit
|
||||
// columns always are for half the rows.
|
||||
func (p *csvParser) optional(rec []string, c *config.Column) (int64, error) {
|
||||
if c == nil {
|
||||
return 0, nil
|
||||
}
|
||||
raw, err := field(rec, c.Col)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return 0, nil
|
||||
}
|
||||
return ParseAmount(raw, c.Decimal, c.Thousands, p.digits)
|
||||
}
|
||||
|
||||
func field(rec []string, i int) (string, error) {
|
||||
if i < 0 || i >= len(rec) {
|
||||
return "", fmt.Errorf("index %d out of range, row has %d columns", i, len(rec))
|
||||
}
|
||||
return rec[i], nil
|
||||
}
|
||||
|
||||
func isBlank(rec []string) bool {
|
||||
for _, f := range rec {
|
||||
if strings.TrimSpace(f) != "" {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func abs(v int64) int64 {
|
||||
if v < 0 {
|
||||
return -v
|
||||
}
|
||||
return v
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
package parser
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"git.petrovv.com/nikola/money/internal/config"
|
||||
)
|
||||
|
||||
func writeFile(t *testing.T, name, content string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), name)
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func TestCSVSignedAmountColumn(t *testing.T) {
|
||||
path := writeFile(t, "st.csv", `Date,Ref,Description,Amount
|
||||
02.01.2026,X1,LIDL SOFIA 1234,"-45,20"
|
||||
03.01.2026,X2,ACME PAYROLL,"1.500,00"
|
||||
|
||||
04.01.2026,X3,COFFEE,"-3,50"
|
||||
`)
|
||||
acc := &config.Account{Slug: "checking", Currency: "EUR", Parser: "csv", CSV: &config.CSVConfig{
|
||||
SkipRows: 1,
|
||||
Date: config.Column{Col: 0, Layout: "02.01.2006"},
|
||||
Description: config.Column{Col: 2},
|
||||
Amount: &config.Column{Col: 3, Decimal: ",", Thousands: "."},
|
||||
}}
|
||||
p, err := For(acc)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := p.Parse(path, acc)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []RawTxn{
|
||||
{Date: "2026-01-02", Description: "LIDL SOFIA 1234", AmountMinor: -4520},
|
||||
{Date: "2026-01-03", Description: "ACME PAYROLL", AmountMinor: 150000},
|
||||
{Date: "2026-01-04", Description: "COFFEE", AmountMinor: -350},
|
||||
}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("got %d txns, want %d: %+v", len(got), len(want), got)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Errorf("txn %d = %+v, want %+v", i, got[i], want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCSVDebitCreditPair(t *testing.T) {
|
||||
path := writeFile(t, "st.csv", `2026-02-01;RENT;800.00;
|
||||
2026-02-05;SALARY;;2500.00
|
||||
`)
|
||||
acc := &config.Account{Slug: "checking", Currency: "EUR", Parser: "csv", CSV: &config.CSVConfig{
|
||||
Delimiter: ";",
|
||||
Date: config.Column{Col: 0, Layout: "2006-01-02"},
|
||||
Description: config.Column{Col: 1},
|
||||
Debit: &config.Column{Col: 2},
|
||||
Credit: &config.Column{Col: 3},
|
||||
}}
|
||||
p, err := For(acc)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := p.Parse(path, acc)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("got %d txns, want 2: %+v", len(got), got)
|
||||
}
|
||||
if got[0].AmountMinor != -80000 {
|
||||
t.Errorf("debit row = %d, want -80000", got[0].AmountMinor)
|
||||
}
|
||||
if got[1].AmountMinor != 250000 {
|
||||
t.Errorf("credit row = %d, want 250000", got[1].AmountMinor)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCSVConfigErrors(t *testing.T) {
|
||||
cases := map[string]*config.Account{
|
||||
"no csv section": {Slug: "a", Parser: "csv"},
|
||||
"no amount columns": {Slug: "a", Parser: "csv", CSV: &config.CSVConfig{
|
||||
Date: config.Column{Layout: "2006-01-02"},
|
||||
}},
|
||||
"amount and debit together": {Slug: "a", Parser: "csv", CSV: &config.CSVConfig{
|
||||
Date: config.Column{Layout: "2006-01-02"},
|
||||
Amount: &config.Column{Col: 1},
|
||||
Debit: &config.Column{Col: 2},
|
||||
}},
|
||||
"no date layout": {Slug: "a", Parser: "csv", CSV: &config.CSVConfig{
|
||||
Amount: &config.Column{Col: 1},
|
||||
}},
|
||||
}
|
||||
for name, acc := range cases {
|
||||
if _, err := For(acc); err == nil {
|
||||
t.Errorf("%s: expected an error", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnknownParser(t *testing.T) {
|
||||
if _, err := For(&config.Account{Slug: "a", Parser: "nope"}); err == nil {
|
||||
t.Error("expected an error for an unregistered parser")
|
||||
}
|
||||
}
|
||||
+17
-10
@@ -18,8 +18,8 @@ func init() {
|
||||
// nlbParser reads an NLB izpisek PDF.
|
||||
//
|
||||
// A transaction starts on a line beginning with a dd.mm.yy date and ends with
|
||||
// the signed amount and the running balance. Long descriptions and the
|
||||
// counterparty account wrap onto indented continuation lines below.
|
||||
// the signed amount and the running balance. Long descriptions and the other
|
||||
// side's account number wrap onto indented continuation lines below.
|
||||
type nlbParser struct {
|
||||
digits int
|
||||
}
|
||||
@@ -37,7 +37,9 @@ var (
|
||||
`(?P<balance>[-\x{2212}]?[\d.,]+[-\x{2212}]?)\s*$`)
|
||||
// Columns inside a line are separated by four or more spaces.
|
||||
nlbFieldSplit = regexp.MustCompile(`\s{4,}`)
|
||||
// A Slovenian IBAN, which is the counterparty account when present.
|
||||
// A Slovenian IBAN, which NLB prints in a column of its own. It is split
|
||||
// out only so that its wrapped fragments can be rejoined; the result goes
|
||||
// onto the end of the description, where every other parser keeps it.
|
||||
nlbAccount = regexp.MustCompile(`^SI\d{2}(?:\s?\d{4}){3}\s?\d{3}$`)
|
||||
)
|
||||
|
||||
@@ -64,7 +66,10 @@ func (p *nlbParser) Parse(path string, acc *config.Account) ([]RawTxn, error) {
|
||||
func parseNLBText(text string, digits int) ([]RawTxn, error) {
|
||||
var (
|
||||
txns []RawTxn
|
||||
accts []string // counterparty per transaction, built up alongside
|
||||
// The account column per transaction, built up alongside because it
|
||||
// wraps in fragments of its own and has to be rejoined before it can
|
||||
// be appended to the description.
|
||||
trailing []string
|
||||
descAt int // description column of the last transaction line
|
||||
)
|
||||
|
||||
@@ -84,7 +89,7 @@ func parseNLBText(text string, digits int) ([]RawTxn, error) {
|
||||
return nil, fmt.Errorf("line %d: %w", i+1, err)
|
||||
}
|
||||
txns = append(txns, txn)
|
||||
accts = append(accts, account)
|
||||
trailing = append(trailing, account)
|
||||
descAt = col
|
||||
continue
|
||||
}
|
||||
@@ -104,20 +109,22 @@ func parseNLBText(text string, digits int) ([]RawTxn, error) {
|
||||
last := len(txns) - 1
|
||||
txns[last].Description += " " + parts[0]
|
||||
if len(parts) > 1 {
|
||||
accts[last] = strings.TrimSpace(accts[last] + " " + parts[1])
|
||||
trailing[last] = strings.TrimSpace(trailing[last] + " " + parts[1])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The account column goes last rather than in the position it occupied on
|
||||
// the page, so that an IBAN wrapped over several lines stays contiguous
|
||||
// and a glob can match it.
|
||||
for i := range txns {
|
||||
txns[i].Counterparty = accts[i]
|
||||
txns[i].Description = strings.Join(strings.Fields(txns[i].Description), " ")
|
||||
txns[i].Description = strings.Join(strings.Fields(txns[i].Description+" "+trailing[i]), " ")
|
||||
}
|
||||
return txns, nil
|
||||
}
|
||||
|
||||
// parseNLBLine parses one transaction line, returning the transaction, the
|
||||
// counterparty account found among its columns, and the column at which the
|
||||
// account number found among its columns, and the column at which the
|
||||
// description starts, which is where its wrapped lines will sit.
|
||||
func parseNLBLine(line string, digits int) (RawTxn, string, int, error) {
|
||||
trimmed := strings.TrimRight(line, " \t\r")
|
||||
@@ -154,7 +161,7 @@ func parseNLBLine(line string, digits int) (RawTxn, string, int, error) {
|
||||
return RawTxn{}, "", 0, fmt.Errorf("balance %q: %w", balance, err)
|
||||
}
|
||||
|
||||
// The middle holds the description and, sometimes, the counterparty IBAN.
|
||||
// The middle holds the description and, sometimes, the IBAN.
|
||||
var (
|
||||
account string
|
||||
desc []string
|
||||
|
||||
@@ -4,7 +4,7 @@ import "testing"
|
||||
|
||||
// A page as pdftotext -layout renders it: a header, transaction lines ending
|
||||
// in amount and balance, and indented continuation lines carrying wrapped
|
||||
// descriptions and the counterparty IBAN.
|
||||
// descriptions and the other side's IBAN.
|
||||
// The description column sits at 15, which is what the script's
|
||||
// CONTINUATION_INDENT tells us about the real layout.
|
||||
const nlbPage = ` NLB d.d.
|
||||
@@ -35,14 +35,12 @@ func TestParseNLBText(t *testing.T) {
|
||||
if first.Date != "2026-01-02" {
|
||||
t.Errorf("date = %q, want 2026-01-02", first.Date)
|
||||
}
|
||||
// The continuation line is appended to the description.
|
||||
if first.Description != "PLACILO S KARTICO LIDL SOFIA 4412" {
|
||||
// The continuation line is appended to the description, and the IBAN in
|
||||
// its second column goes last so that it stays contiguous however many
|
||||
// lines it wrapped over.
|
||||
if first.Description != "PLACILO S KARTICO LIDL SOFIA 4412 SI56 1234 5678 9012 345" {
|
||||
t.Errorf("description = %q", first.Description)
|
||||
}
|
||||
// ...and the IBAN in its second column becomes the counterparty.
|
||||
if first.Counterparty != "SI56 1234 5678 9012 345" {
|
||||
t.Errorf("counterparty = %q", first.Counterparty)
|
||||
}
|
||||
if first.AmountMinor != -4520 {
|
||||
t.Errorf("amount = %d, want -4520", first.AmountMinor)
|
||||
}
|
||||
@@ -57,9 +55,9 @@ func TestParseNLBText(t *testing.T) {
|
||||
t.Errorf("description = %q", txns[1].Description)
|
||||
}
|
||||
|
||||
// A transaction with no continuation line keeps an empty counterparty.
|
||||
if txns[2].Counterparty != "" {
|
||||
t.Errorf("counterparty = %q, want empty", txns[2].Counterparty)
|
||||
// A transaction with no continuation line gets nothing appended.
|
||||
if txns[2].Description != "PRENOS NA VARCEVALNI" {
|
||||
t.Errorf("description = %q, want no trailing account number", txns[2].Description)
|
||||
}
|
||||
|
||||
// A trailing minus marks a negative balance.
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
// Package parser turns a statement file into raw transactions.
|
||||
//
|
||||
// Every bank needs its own extraction logic, so parsers are looked up by name
|
||||
// from a registry. Two are built in: "csv" for delimited exports with
|
||||
// configurable columns, and "cmd" for shelling out to an external extractor
|
||||
// (which is how the existing Python scripts are used until they are ported).
|
||||
// Ported extractors register themselves here and become usable by putting
|
||||
// their name in an account.toml.
|
||||
// from a registry that each one joins from an init. A parser becomes usable by
|
||||
// putting its name in an account.toml; there is no generic column-mapped
|
||||
// parser, because a statement layout is better described in Go, where it can
|
||||
// be tested, than in a table of column indexes.
|
||||
package parser
|
||||
|
||||
import (
|
||||
@@ -23,9 +22,6 @@ type RawTxn struct {
|
||||
Description string
|
||||
AmountMinor int64 // signed; negative is an outflow
|
||||
|
||||
// Counterparty is the other side's account number, where the statement
|
||||
// gives one. Optional.
|
||||
Counterparty string
|
||||
// Type is the bank's own classification of the transaction. Optional.
|
||||
Type string
|
||||
// BalanceMinor is the running balance after this transaction, when the
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -36,9 +35,6 @@ type revolutParser struct {
|
||||
warnings []string
|
||||
}
|
||||
|
||||
// revolutIBAN finds a counterparty account inside a description.
|
||||
var revolutIBAN = regexp.MustCompile(`\b[A-Z]{2}\d{2}[A-Z0-9]{11,30}\b`)
|
||||
|
||||
// Columns the parser needs; a missing one is a hard error rather than a
|
||||
// silently empty field.
|
||||
var revolutColumns = []string{
|
||||
@@ -164,18 +160,26 @@ func (p *revolutParser) row(get func(string) string, digits int) (RawTxn, error)
|
||||
desc += fmt.Sprintf(" (fee %s)", model.FormatMinor(fee, digits))
|
||||
}
|
||||
|
||||
counterparty := revolutIBAN.FindString(desc)
|
||||
|
||||
return RawTxn{
|
||||
Date: d.Format("2006-01-02"),
|
||||
Description: desc,
|
||||
AmountMinor: amount - fee,
|
||||
Counterparty: counterparty,
|
||||
Type: strings.TrimSpace(get("Type")),
|
||||
BalanceMinor: &balance,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// isBlank reports whether a CSV record holds nothing but whitespace, which is
|
||||
// what a trailing newline in the export reads as.
|
||||
func isBlank(rec []string) bool {
|
||||
for _, f := range rec {
|
||||
if strings.TrimSpace(f) != "" {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// headerIndex maps a Revolut column name to its position.
|
||||
type headerIndex map[string]int
|
||||
|
||||
|
||||
@@ -1,12 +1,24 @@
|
||||
package parser
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.petrovv.com/nikola/money/internal/config"
|
||||
)
|
||||
|
||||
// writeFile drops a statement in a temporary directory and returns its path.
|
||||
func writeFile(t *testing.T, name, content string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), name)
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
// A Revolut export: out of date order, mixed currencies, a pending row, a row
|
||||
// with a fee, and a transfer carrying an IBAN.
|
||||
const revolutCSV = `Type,Product,Started Date,Completed Date,Description,Amount,Fee,Currency,State,Balance
|
||||
@@ -60,9 +72,6 @@ func TestRevolutParse(t *testing.T) {
|
||||
if !strings.Contains(transfer.Description, "(fee 0.35)") {
|
||||
t.Errorf("description = %q, want a fee note", transfer.Description)
|
||||
}
|
||||
if transfer.Counterparty != "SI56123456789012345" {
|
||||
t.Errorf("counterparty = %q, want the IBAN from the description", transfer.Counterparty)
|
||||
}
|
||||
if transfer.BalanceMinor == nil || *transfer.BalanceMinor != 73441 {
|
||||
t.Errorf("balance = %v, want 73441", transfer.BalanceMinor)
|
||||
}
|
||||
|
||||
@@ -32,7 +32,6 @@ var (
|
||||
trAmount = regexp.MustCompile(`[-\x{2212}]?€\s?[-\x{2212}]?[\d,]+\.\d{2}`)
|
||||
trToken = regexp.MustCompile(`\S+`)
|
||||
trFullDate = regexp.MustCompile(`^\d{2} [A-Z][a-z]{2} \d{4}$`)
|
||||
trIBAN = regexp.MustCompile(`\b[A-Z]{2}\d{2}[A-Z0-9]{11,30}\b`)
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -197,7 +196,6 @@ func parseTradeRepublicBlock(lines []string, cols map[string]int, digits int) (R
|
||||
Date: d.Format("2006-01-02"),
|
||||
Description: desc,
|
||||
AmountMinor: amount,
|
||||
Counterparty: trIBAN.FindString(desc),
|
||||
Type: strings.Join(words["TYPE"], " "),
|
||||
BalanceMinor: amounts["BALANCE"],
|
||||
}, true, nil
|
||||
|
||||
@@ -120,9 +120,6 @@ func TestParseTradeRepublicText(t *testing.T) {
|
||||
if transfer.Description != "Standing order to sav-ings SI56123456789012345" {
|
||||
t.Errorf("description = %q, want the wrapped fragment joined without a space", transfer.Description)
|
||||
}
|
||||
if transfer.Counterparty != "SI56123456789012345" {
|
||||
t.Errorf("counterparty = %q", transfer.Counterparty)
|
||||
}
|
||||
if transfer.AmountMinor != -50000 {
|
||||
t.Errorf("amount = %d, want -50000", transfer.AmountMinor)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
// Package report aggregates transactions for the summary views.
|
||||
//
|
||||
// Transfers between the user's own accounts are excluded everywhere: moving
|
||||
// money from checking to savings is not income and not spending.
|
||||
package report
|
||||
|
||||
import (
|
||||
@@ -28,15 +25,26 @@ func (t TagTotal) Net() int64 { return t.In - t.Out }
|
||||
const Untagged = "(untagged)"
|
||||
|
||||
// ByTag returns totals sorted by currency, then by largest outflow first.
|
||||
//
|
||||
// Matched transfer legs are left out entirely. Both legs of a transfer are
|
||||
// dropped together, so a report over everything is unchanged in total by money
|
||||
// the user moved between their own accounts; a report scoped to one account or
|
||||
// one month may see only one leg, and dropping it is still right, since the
|
||||
// money was not spent. What was left out is available from Excluded.
|
||||
//
|
||||
// A pair whose definition sets a tolerance_pct is dropped the same way, fee and
|
||||
// all — so with such a definition in play the report *is* short by the fee, and
|
||||
// callers are expected to show Excluded alongside it rather than leave that
|
||||
// difference unexplained.
|
||||
func ByTag(txns []model.Transaction) []TagTotal {
|
||||
type key struct{ currency, tag string }
|
||||
acc := map[key]*TagTotal{}
|
||||
|
||||
for _, t := range txns {
|
||||
if t.IsTransfer() {
|
||||
if t.IsTransferLeg() {
|
||||
continue
|
||||
}
|
||||
tag := t.Tag()
|
||||
tag := t.RuleTag
|
||||
if tag == "" {
|
||||
tag = Untagged
|
||||
}
|
||||
@@ -102,6 +110,87 @@ func Totals(rows []TagTotal) []CurrencyTotal {
|
||||
return out
|
||||
}
|
||||
|
||||
// TransferTotal is what ByTag left out for one currency.
|
||||
//
|
||||
// It counts legs rather than pairs on purpose: a report scoped to one account
|
||||
// or month usually holds only one side of a movement, and claiming a whole
|
||||
// transfer was excluded when only half of it was in view would be a lie. The
|
||||
// same goes for the two directions, which is why they are separate totals: an
|
||||
// exchange between currencies has its legs in different columns entirely, so
|
||||
// one currency can see only what left and the other only what arrived.
|
||||
type TransferTotal struct {
|
||||
Currency string
|
||||
Out int64 // sum of the leaving legs in view, positive
|
||||
In int64 // sum of the arriving legs in view
|
||||
Legs int
|
||||
Digits int
|
||||
// Fee is what those movements cost: the amount that left less the amount
|
||||
// that arrived, for pairs whose legs are *both* in view. A definition with a
|
||||
// tolerance_pct pairs legs that disagree, and since the pair leaves the
|
||||
// report entirely, this is the only place that difference is still money
|
||||
// rather than nothing. Zero when no definition allows a mismatch.
|
||||
Fee int64
|
||||
// Pairs counts the movements Fee was computed from — complete pairs in this
|
||||
// currency, not legs. A report scoped to one account or month usually holds
|
||||
// one side of a movement, and half a pair cannot say what the other half
|
||||
// received, so it contributes to Legs and Out/In but not here.
|
||||
Pairs int
|
||||
}
|
||||
|
||||
// Excluded summarises the transfer legs ByTag dropped, so a report can account
|
||||
// for the difference between its total and the account balances.
|
||||
func Excluded(txns []model.Transaction) []TransferTotal {
|
||||
// Both legs of a pair carry the same TransferID, so the ones whose partner
|
||||
// is also in view can be found without going back to the index.
|
||||
byID := map[int64][]model.Transaction{}
|
||||
for _, t := range txns {
|
||||
if t.IsTransferLeg() {
|
||||
byID[*t.TransferID] = append(byID[*t.TransferID], t)
|
||||
}
|
||||
}
|
||||
|
||||
acc := map[string]*TransferTotal{}
|
||||
row := func(t model.Transaction) *TransferTotal {
|
||||
r, ok := acc[t.Currency]
|
||||
if !ok {
|
||||
r = &TransferTotal{Currency: t.Currency, Digits: t.MinorDigits}
|
||||
acc[t.Currency] = r
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
for _, t := range txns {
|
||||
if !t.IsTransferLeg() {
|
||||
continue
|
||||
}
|
||||
r := row(t)
|
||||
r.Legs++
|
||||
if t.AmountMinor < 0 {
|
||||
r.Out += -t.AmountMinor
|
||||
} else {
|
||||
r.In += t.AmountMinor
|
||||
}
|
||||
}
|
||||
|
||||
for _, legs := range byID {
|
||||
// An exchange has its legs in two currencies, and there is no rate here
|
||||
// to subtract one from the other, so it has no fee to report.
|
||||
if len(legs) != 2 || legs[0].Currency != legs[1].Currency {
|
||||
continue
|
||||
}
|
||||
r := row(legs[0])
|
||||
r.Pairs++
|
||||
r.Fee -= legs[0].AmountMinor + legs[1].AmountMinor
|
||||
}
|
||||
|
||||
out := make([]TransferTotal, 0, len(acc))
|
||||
for _, r := range acc {
|
||||
out = append(out, *r)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Currency < out[j].Currency })
|
||||
return out
|
||||
}
|
||||
|
||||
// Months lists the distinct YYYY-MM present in txns, most recent first.
|
||||
func Months(txns []model.Transaction) []string {
|
||||
seen := map[string]bool{}
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
package report
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.petrovv.com/nikola/money/internal/model"
|
||||
)
|
||||
|
||||
func leg(id int64, amount int64, transferID *int64) model.Transaction {
|
||||
return model.Transaction{
|
||||
ID: id,
|
||||
AccountSlug: "nlb",
|
||||
Currency: "EUR",
|
||||
MinorDigits: 2,
|
||||
Date: "2026-03-06",
|
||||
AmountMinor: amount,
|
||||
RuleTag: "moving",
|
||||
TransferID: transferID,
|
||||
}
|
||||
}
|
||||
|
||||
// A matched transfer is money moved, not money spent, so neither leg reaches
|
||||
// the report at all.
|
||||
func TestByTagLeavesTransferLegsOut(t *testing.T) {
|
||||
id := int64(1)
|
||||
rows := ByTag([]model.Transaction{
|
||||
leg(1, -50000, &id),
|
||||
leg(2, 50000, &id),
|
||||
{Currency: "EUR", MinorDigits: 2, AmountMinor: -2000, RuleTag: "groceries"},
|
||||
})
|
||||
|
||||
if len(rows) != 1 || rows[0].Tag != "groceries" {
|
||||
t.Fatalf("rows = %+v, want only the groceries row", rows)
|
||||
}
|
||||
if rows[0].Out != 2000 {
|
||||
t.Errorf("out = %d, want only the shopping", rows[0].Out)
|
||||
}
|
||||
}
|
||||
|
||||
// An unpaired leg is not a transfer, so it still counts. Money that left an
|
||||
// account and cannot be shown to have arrived must not quietly vanish from the
|
||||
// report.
|
||||
func TestByTagKeepsUnpairedLegs(t *testing.T) {
|
||||
rows := ByTag([]model.Transaction{leg(1, -50000, nil)})
|
||||
if len(rows) != 1 || rows[0].Out != 50000 {
|
||||
t.Fatalf("rows = %+v, want the unpaired leg counted", rows)
|
||||
}
|
||||
}
|
||||
|
||||
// A report scoped to one account or month usually holds one side of a
|
||||
// movement, so the summary counts legs rather than claiming whole transfers.
|
||||
func TestExcludedCountsLegsInView(t *testing.T) {
|
||||
id := int64(1)
|
||||
got := Excluded([]model.Transaction{
|
||||
leg(1, -50000, &id),
|
||||
{Currency: "EUR", MinorDigits: 2, AmountMinor: -2000, RuleTag: "groceries"},
|
||||
})
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("excluded = %+v, want one currency", got)
|
||||
}
|
||||
if got[0].Legs != 1 || got[0].Out != 50000 || got[0].In != 0 || got[0].Currency != "EUR" {
|
||||
t.Errorf("excluded = %+v, want 1 leg and 50000 out in EUR", got[0])
|
||||
}
|
||||
}
|
||||
|
||||
// An exchange has its legs in two currencies, so each side reports only what it
|
||||
// saw. Summing the two would be meaningless: there are no rates here.
|
||||
func TestExcludedSplitsAnExchangeByCurrency(t *testing.T) {
|
||||
id := int64(1)
|
||||
arriving := leg(2, 97790, &id)
|
||||
arriving.Currency = "BGN"
|
||||
|
||||
got := Excluded([]model.Transaction{leg(1, -50000, &id), arriving})
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("excluded = %+v, want a row per currency", got)
|
||||
}
|
||||
bgn, eur := got[0], got[1]
|
||||
if bgn.Currency != "BGN" || bgn.In != 97790 || bgn.Out != 0 {
|
||||
t.Errorf("BGN row = %+v, want only the arriving leg", bgn)
|
||||
}
|
||||
if eur.Currency != "EUR" || eur.Out != 50000 || eur.In != 0 {
|
||||
t.Errorf("EUR row = %+v, want only the leaving leg", eur)
|
||||
}
|
||||
}
|
||||
|
||||
// A definition with a tolerance pairs legs that disagree, and the pair leaves
|
||||
// the report with the difference inside it. That difference is real money, so
|
||||
// the summary has to name it or it is simply lost.
|
||||
func TestExcludedReportsTheFee(t *testing.T) {
|
||||
id := int64(1)
|
||||
got := Excluded([]model.Transaction{leg(1, -50000, &id), leg(2, 49500, &id)})
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("excluded = %+v, want one currency", got)
|
||||
}
|
||||
if got[0].Fee != 500 || got[0].Pairs != 1 || got[0].Legs != 2 {
|
||||
t.Errorf("excluded = %+v, want a 5.00 fee over 1 pair and 2 legs", got[0])
|
||||
}
|
||||
}
|
||||
|
||||
// Half a pair cannot say what the other half received, so a report scoped to
|
||||
// one account or month reports the leg it saw and claims no fee at all.
|
||||
func TestExcludedClaimsNoFeeFromHalfAPair(t *testing.T) {
|
||||
id := int64(1)
|
||||
got := Excluded([]model.Transaction{leg(1, -50000, &id)})
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("excluded = %+v, want one currency", got)
|
||||
}
|
||||
if got[0].Fee != 0 || got[0].Pairs != 0 || got[0].Legs != 1 {
|
||||
t.Errorf("excluded = %+v, want no fee and no complete pair", got[0])
|
||||
}
|
||||
}
|
||||
|
||||
// Without a tolerance the two legs are exact opposites, so there is nothing to
|
||||
// report and the line stays as it was.
|
||||
func TestExcludedReportsNoFeeForAnExactPair(t *testing.T) {
|
||||
id := int64(1)
|
||||
got := Excluded([]model.Transaction{leg(1, -50000, &id), leg(2, 50000, &id)})
|
||||
if len(got) != 1 || got[0].Fee != 0 || got[0].Pairs != 1 {
|
||||
t.Errorf("excluded = %+v, want one pair and no fee", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The legs of an exchange are in different units, so subtracting one from the
|
||||
// other would produce a number meaning nothing.
|
||||
func TestExcludedClaimsNoFeeAcrossCurrencies(t *testing.T) {
|
||||
id := int64(1)
|
||||
arriving := leg(2, 97790, &id)
|
||||
arriving.Currency = "BGN"
|
||||
|
||||
for _, row := range Excluded([]model.Transaction{leg(1, -50000, &id), arriving}) {
|
||||
if row.Fee != 0 || row.Pairs != 0 {
|
||||
t.Errorf("%s row = %+v, want no fee across currencies", row.Currency, row)
|
||||
}
|
||||
}
|
||||
}
|
||||
+94
-32
@@ -1,29 +1,94 @@
|
||||
// Package rules applies the ordered glob rules from rules.toml to
|
||||
// transactions, deciding their automatic tag and whether they are a transfer
|
||||
// between the user's own accounts.
|
||||
//
|
||||
// Only the rule_* columns are ever written. Manual edits made in the TUI live
|
||||
// in separate columns and survive any number of re-runs.
|
||||
// Package rules applies the glob rules from rules.toml to transactions,
|
||||
// deciding their tag. The most specific rule that fits wins, so a general rule
|
||||
// and the narrower one carving an exception out of it can be written in either
|
||||
// order. It is the only thing that decides a tag, so rule_tag is derived state
|
||||
// and Retag can rewrite it wholesale at any time.
|
||||
package rules
|
||||
|
||||
import (
|
||||
"sort"
|
||||
|
||||
"git.petrovv.com/nikola/money/internal/config"
|
||||
"git.petrovv.com/nikola/money/internal/glob"
|
||||
"git.petrovv.com/nikola/money/internal/model"
|
||||
"git.petrovv.com/nikola/money/internal/store"
|
||||
)
|
||||
|
||||
// Engine evaluates rules in file order; the first match wins.
|
||||
// Engine evaluates rules most specific first; the first match wins.
|
||||
type Engine struct {
|
||||
// rules is file order, which is what Rules and Usage report and what the
|
||||
// rules screen numbers and deletes by. Evaluation does not use it.
|
||||
rules []config.Rule
|
||||
// order indexes into rules, most specific first. Specificity decides
|
||||
// precedence rather than position, so "*NIKOLA*" claims what it names
|
||||
// wherever it sits relative to the "*NIK*" that would otherwise swallow it.
|
||||
order []int
|
||||
}
|
||||
|
||||
// New builds an engine from the parsed rules file.
|
||||
// New builds an engine from the parsed rules file, working out once which rule
|
||||
// is tried before which.
|
||||
func New(r *config.Rules) *Engine {
|
||||
return &Engine{rules: r.Rule}
|
||||
e := &Engine{rules: r.Rule, order: make([]int, len(r.Rule))}
|
||||
for i := range e.order {
|
||||
e.order[i] = i
|
||||
}
|
||||
// Stable, so rules of equal specificity keep file order between them and
|
||||
// the earlier one still wins.
|
||||
sort.SliceStable(e.order, func(a, b int) bool {
|
||||
return moreSpecific(e.rules[e.order[a]], e.rules[e.order[b]])
|
||||
})
|
||||
return e
|
||||
}
|
||||
|
||||
// Rules returns the ordered rules, as loaded from rules.toml.
|
||||
// moreSpecific reports whether a is tried before b.
|
||||
//
|
||||
// The literal characters a rule spells out are the evidence: they are what it
|
||||
// commits to, and what it hands to a '*' is what it gives up. So "*NIKOLA*"
|
||||
// beats "*NIK*", and a bare "*" sits last of all — the catch-all can be written
|
||||
// anywhere in the file and still catch only what nothing else wanted.
|
||||
//
|
||||
// Two rules spelling out the same amount are separated by what else they pin
|
||||
// down: fewer '*' first, since "NIKOLA" also fixes both ends where "*NIKOLA*"
|
||||
// does not, and then an account-scoped rule over one that applies everywhere.
|
||||
// Rules that tie on all three are left to file order by the stable sort.
|
||||
func moreSpecific(a, b config.Rule) bool {
|
||||
if x, y := literals(a), literals(b); x != y {
|
||||
return x > y
|
||||
}
|
||||
if x, y := stars(a), stars(b); x != y {
|
||||
return x < y
|
||||
}
|
||||
return a.Account != "" && b.Account == ""
|
||||
}
|
||||
|
||||
// literals counts the characters a rule pins down exactly, across every pattern
|
||||
// it sets: a rule with both match and type has to satisfy both, so both count.
|
||||
// '?' is not one of them — it fixes a length, not a character.
|
||||
func literals(r config.Rule) int {
|
||||
n := 0
|
||||
for _, c := range r.Match + r.Type {
|
||||
if c != '*' && c != '?' {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// stars counts the unbounded wildcards, the only ones that let a pattern match
|
||||
// a run of any length.
|
||||
func stars(r config.Rule) int {
|
||||
n := 0
|
||||
for _, c := range r.Match + r.Type {
|
||||
if c == '*' {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// Rules returns the rules in file order, as loaded from rules.toml. That is the
|
||||
// order the rules screen shows and deletes by; it is no longer the order they
|
||||
// are tried in.
|
||||
func (e *Engine) Rules() []config.Rule { return e.rules }
|
||||
|
||||
// Match returns the first rule matching a transaction on the given account, or
|
||||
@@ -35,20 +100,21 @@ func (e *Engine) Match(accountSlug string, t model.Transaction) *config.Rule {
|
||||
return nil
|
||||
}
|
||||
|
||||
// MatchIndex returns the position of the first rule matching a transaction, or
|
||||
// MatchIndex returns the file position of the rule claiming a transaction, or
|
||||
// -1 if none does. Every pattern a rule sets must match: a rule with both
|
||||
// match and counterparty is an "and", not an "or".
|
||||
// match and type is an "and", not an "or".
|
||||
//
|
||||
// The position matters as well as the rule: because the first match wins, a
|
||||
// rule that is fully shadowed by an earlier one never applies to anything, and
|
||||
// only the index reveals that.
|
||||
// Rules are tried most specific first, so the answer is the narrowest rule that
|
||||
// fits and not merely the topmost. The returned index is still the position in
|
||||
// rules.toml, because that is what the caller can point the user at — and it is
|
||||
// what reveals a rule that is fully shadowed by a more specific one and so
|
||||
// never applies to anything.
|
||||
func (e *Engine) MatchIndex(accountSlug string, t model.Transaction) int {
|
||||
var (
|
||||
description = model.NormalizeDescription(t.Description)
|
||||
counterparty = model.NormalizeDescription(t.Counterparty)
|
||||
kind = model.NormalizeDescription(t.Type)
|
||||
)
|
||||
for i := range e.rules {
|
||||
for _, i := range e.order {
|
||||
r := &e.rules[i]
|
||||
if r.Account != "" && r.Account != accountSlug {
|
||||
continue
|
||||
@@ -56,9 +122,6 @@ func (e *Engine) MatchIndex(accountSlug string, t model.Transaction) int {
|
||||
if r.Match != "" && !glob.Match(r.Match, description) {
|
||||
continue
|
||||
}
|
||||
if r.Counterparty != "" && !glob.Match(r.Counterparty, counterparty) {
|
||||
continue
|
||||
}
|
||||
if r.Type != "" && !glob.Match(r.Type, kind) {
|
||||
continue
|
||||
}
|
||||
@@ -67,9 +130,9 @@ func (e *Engine) MatchIndex(accountSlug string, t model.Transaction) int {
|
||||
return -1
|
||||
}
|
||||
|
||||
// Usage counts how many transactions each rule actually claims. A rule with a
|
||||
// count of zero is dead: either nothing matches it, or an earlier rule takes
|
||||
// everything it would have caught.
|
||||
// Usage counts how many transactions each rule actually claims, indexed by file
|
||||
// position. A rule with a count of zero is dead: either nothing matches it, or
|
||||
// a more specific rule takes everything it would have caught.
|
||||
func (e *Engine) Usage(txns []model.Transaction) []int {
|
||||
counts := make([]int, len(e.rules))
|
||||
for _, t := range txns {
|
||||
@@ -80,17 +143,16 @@ func (e *Engine) Usage(txns []model.Transaction) []int {
|
||||
return counts
|
||||
}
|
||||
|
||||
// ApplyTxn returns the tag and transfer flag for a transaction. An unmatched
|
||||
// transaction gets an empty tag and is not a transfer.
|
||||
func (e *Engine) ApplyTxn(accountSlug string, t model.Transaction) (tag string, transfer bool) {
|
||||
// ApplyTxn returns the tag for a transaction, empty if no rule matches.
|
||||
func (e *Engine) ApplyTxn(accountSlug string, t model.Transaction) string {
|
||||
if r := e.Match(accountSlug, t); r != nil {
|
||||
return r.Tag, r.Transfer
|
||||
return r.Tag
|
||||
}
|
||||
return "", false
|
||||
return ""
|
||||
}
|
||||
|
||||
// Apply is the description-only shorthand, for callers that have nothing else.
|
||||
func (e *Engine) Apply(accountSlug, description string) (tag string, transfer bool) {
|
||||
func (e *Engine) Apply(accountSlug, description string) string {
|
||||
return e.ApplyTxn(accountSlug, model.Transaction{Description: description})
|
||||
}
|
||||
|
||||
@@ -103,11 +165,11 @@ func (e *Engine) Retag(db *store.DB) (int, error) {
|
||||
}
|
||||
var changed []store.RuleAssignment
|
||||
for _, t := range txns {
|
||||
tag, transfer := e.ApplyTxn(t.AccountSlug, t)
|
||||
if tag == t.RuleTag && transfer == t.RuleTransfer {
|
||||
tag := e.ApplyTxn(t.AccountSlug, t)
|
||||
if tag == t.RuleTag {
|
||||
continue
|
||||
}
|
||||
changed = append(changed, store.RuleAssignment{ID: t.ID, Tag: tag, Transfer: transfer})
|
||||
changed = append(changed, store.RuleAssignment{ID: t.ID, Tag: tag})
|
||||
}
|
||||
if len(changed) == 0 {
|
||||
return 0, nil
|
||||
|
||||
+98
-142
@@ -66,92 +66,114 @@ func byDescription(t *testing.T, db *store.DB, desc string) model.Transaction {
|
||||
return model.Transaction{}
|
||||
}
|
||||
|
||||
// A manual tag must outlive any number of rule changes and re-runs. This is
|
||||
// the property that makes retag safe to run at will.
|
||||
func TestManualTagSurvivesRetag(t *testing.T) {
|
||||
// A tag is derived state: rules.toml is the only thing that decides it, so
|
||||
// retagging rewrites every tag from the current rules however often it runs,
|
||||
// including back to nothing when no rule matches any more.
|
||||
func TestRetagRewritesEveryTag(t *testing.T) {
|
||||
db := testDB(t)
|
||||
seed(t, db, "LIDL SOFIA", "SOME RANDOM SHOP")
|
||||
|
||||
engine := New(&config.Rules{Rule: []config.Rule{
|
||||
{Match: "*LIDL*", Tag: "groceries"},
|
||||
}})
|
||||
if _, err := engine.Retag(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// The user hand-tags the row the rules did not match, and overrides the
|
||||
// one they did.
|
||||
shop := byDescription(t, db, "SOME RANDOM SHOP")
|
||||
if err := db.SetManualTag(shop.ID, "hobby"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
lidl := byDescription(t, db, "LIDL SOFIA")
|
||||
if err := db.SetManualTag(lidl.ID, "party supplies"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Rules change completely, and are re-applied twice for good measure.
|
||||
engine = New(&config.Rules{Rule: []config.Rule{
|
||||
{Match: "*LIDL*", Tag: "food"},
|
||||
{Match: "*SHOP*", Tag: "shopping"},
|
||||
}})
|
||||
for range 2 {
|
||||
n, err := engine.Retag(db)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n != 2 {
|
||||
t.Errorf("retagged %d rows, want 2", n)
|
||||
}
|
||||
if got := byDescription(t, db, "LIDL SOFIA").RuleTag; got != "groceries" {
|
||||
t.Errorf("tag = %q, want groceries", got)
|
||||
}
|
||||
|
||||
// Re-running with the same rules changes nothing and says so.
|
||||
if n, err := engine.Retag(db); err != nil || n != 0 {
|
||||
t.Errorf("second retag: %d rows, %v; want 0 and no error", n, err)
|
||||
}
|
||||
|
||||
// New rules win, and a row no rule matches loses its tag entirely.
|
||||
engine = New(&config.Rules{Rule: []config.Rule{
|
||||
{Match: "*LIDL*", Tag: "food"},
|
||||
}})
|
||||
if _, err := engine.Retag(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := byDescription(t, db, "LIDL SOFIA").RuleTag; got != "food" {
|
||||
t.Errorf("tag = %q, want food", got)
|
||||
}
|
||||
|
||||
shop = byDescription(t, db, "SOME RANDOM SHOP")
|
||||
if got := shop.Tag(); got != "hobby" {
|
||||
t.Errorf("manual tag = %q, want %q (manual tags must win)", got, "hobby")
|
||||
}
|
||||
if shop.RuleTag != "shopping" {
|
||||
t.Errorf("rule_tag = %q, want %q (rules must still be recorded underneath)", shop.RuleTag, "shopping")
|
||||
}
|
||||
|
||||
lidl = byDescription(t, db, "LIDL SOFIA")
|
||||
if got := lidl.Tag(); got != "party supplies" {
|
||||
t.Errorf("manual override = %q, want %q", got, "party supplies")
|
||||
}
|
||||
|
||||
// Clearing the override falls back to whatever the rules now say.
|
||||
if err := db.ClearOverrides(lidl.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := byDescription(t, db, "LIDL SOFIA").Tag(); got != "food" {
|
||||
t.Errorf("after clearing override, tag = %q, want %q", got, "food")
|
||||
if got := byDescription(t, db, "SOME RANDOM SHOP").RuleTag; got != "" {
|
||||
t.Errorf("tag = %q, want empty once no rule matches", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFirstMatchWins(t *testing.T) {
|
||||
// The narrower rule claims what it names wherever it sits in the file: the
|
||||
// catch-all above it must not swallow the exception written below.
|
||||
func TestMoreSpecificRuleWins(t *testing.T) {
|
||||
both := [][]config.Rule{
|
||||
{{Match: "NIK*", Tag: "misc"}, {Match: "NIKOLA*", Tag: "family"}},
|
||||
{{Match: "NIKOLA*", Tag: "family"}, {Match: "NIK*", Tag: "misc"}},
|
||||
}
|
||||
for _, rs := range both {
|
||||
engine := New(&config.Rules{Rule: rs})
|
||||
if tag := engine.Apply("checking", "NIKOLA PETROV"); tag != "family" {
|
||||
t.Errorf("%q first: tag = %q, want family", rs[0].Match, tag)
|
||||
}
|
||||
if tag := engine.Apply("checking", "NIKI TODOROV"); tag != "misc" {
|
||||
t.Errorf("%q first: tag = %q, want misc", rs[0].Match, tag)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Specificity is measured on what the pattern spells out, so the wildcards
|
||||
// around a literal do not buy it precedence, and a bare "*" is always last.
|
||||
func TestSpecificityOrder(t *testing.T) {
|
||||
engine := New(&config.Rules{Rule: []config.Rule{
|
||||
{Match: "*LIDL EXPRESS*", Tag: "snacks"},
|
||||
{Match: "*", Tag: "other"},
|
||||
{Match: "*LIDL*", Tag: "groceries"},
|
||||
{Match: "*LIDL EXPRESS*", Tag: "snacks"},
|
||||
}})
|
||||
if tag, _ := engine.Apply("checking", "CARD LIDL EXPRESS 12"); tag != "snacks" {
|
||||
t.Errorf("tag = %q, want snacks (earlier rule must win)", tag)
|
||||
for _, c := range []struct{ desc, want string }{
|
||||
{"CARD LIDL EXPRESS 12", "snacks"},
|
||||
{"CARD LIDL 12", "groceries"},
|
||||
{"SOMETHING ELSE", "other"},
|
||||
} {
|
||||
if tag := engine.Apply("checking", c.desc); tag != c.want {
|
||||
t.Errorf("%q: tag = %q, want %q", c.desc, tag, c.want)
|
||||
}
|
||||
if tag, _ := engine.Apply("checking", "CARD LIDL 12"); tag != "groceries" {
|
||||
t.Errorf("tag = %q, want groceries", tag)
|
||||
}
|
||||
if tag, _ := engine.Apply("checking", "SOMETHING ELSE"); tag != "" {
|
||||
}
|
||||
|
||||
// Two rules that are equally specific are still decided by the file: the
|
||||
// earlier one wins, which is what makes appending a rule safe.
|
||||
func TestEqualSpecificityKeepsFileOrder(t *testing.T) {
|
||||
engine := New(&config.Rules{Rule: []config.Rule{
|
||||
{Match: "*ACME*", Tag: "first"},
|
||||
{Match: "*ACME*", Tag: "second"},
|
||||
}})
|
||||
if tag := engine.Apply("checking", "ACME LTD"); tag != "first" {
|
||||
t.Errorf("tag = %q, want first", tag)
|
||||
}
|
||||
if tag := engine.Apply("checking", "SOMETHING ELSE"); tag != "" {
|
||||
t.Errorf("tag = %q, want empty for an unmatched description", tag)
|
||||
}
|
||||
}
|
||||
|
||||
// A rule can be dead two ways: nothing matches it, or an earlier rule already
|
||||
// claimed everything it would have caught. Usage must report both as zero.
|
||||
func TestUsageCountsFirstMatchOnly(t *testing.T) {
|
||||
// A rule can be dead two ways: nothing matches it, or a rule that beats it
|
||||
// already claimed everything it would have caught. Usage must report both as
|
||||
// zero, indexed by file position however the rules are ordered for matching.
|
||||
func TestUsageCountsWinnersOnly(t *testing.T) {
|
||||
engine := New(&config.Rules{Rule: []config.Rule{
|
||||
{Match: "*LIDL*", Tag: "groceries"}, // claims both LIDL rows
|
||||
{Match: "*LIDL SOFIA*", Tag: "shadowed"}, // fully shadowed by the above
|
||||
{Match: "*LIDL SOFIA*", Tag: "groceries"}, // the narrower of the two
|
||||
{Match: "*LIDL*", Tag: "shadowed"}, // every LIDL row here is Sofia
|
||||
{Match: "*NOTHING MATCHES ME*", Tag: "no"}, // matches nothing at all
|
||||
{Match: "*PAYROLL*", Tag: "salary"}, // claims one row
|
||||
}})
|
||||
|
||||
txns := []model.Transaction{
|
||||
{AccountSlug: "checking", Description: "LIDL SOFIA 4412"},
|
||||
{AccountSlug: "checking", Description: "LIDL VARNA 9911"},
|
||||
{AccountSlug: "checking", Description: "LIDL SOFIA 9911"},
|
||||
{AccountSlug: "checking", Description: "ACME PAYROLL"},
|
||||
{AccountSlug: "checking", Description: "UNMATCHED SHOP"},
|
||||
}
|
||||
@@ -168,64 +190,57 @@ func TestUsageCountsFirstMatchOnly(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The index is the rule's position in rules.toml, not its position in the order
|
||||
// it was tried in — that is the number the rules screen shows and deletes by.
|
||||
func TestMatchIndex(t *testing.T) {
|
||||
engine := New(&config.Rules{Rule: []config.Rule{
|
||||
{Match: "*LIDL EXPRESS*", Tag: "snacks"},
|
||||
{Match: "*LIDL*", Tag: "groceries"},
|
||||
{Match: "*LIDL EXPRESS*", Tag: "snacks"},
|
||||
}})
|
||||
|
||||
if got := engine.MatchIndex("checking", model.Transaction{Description: "LIDL EXPRESS 1"}); got != 0 {
|
||||
t.Errorf("index = %d, want 0", got)
|
||||
}
|
||||
if got := engine.MatchIndex("checking", model.Transaction{Description: "LIDL 1"}); got != 1 {
|
||||
if got := engine.MatchIndex("checking", model.Transaction{Description: "LIDL EXPRESS 1"}); got != 1 {
|
||||
t.Errorf("index = %d, want 1", got)
|
||||
}
|
||||
if got := engine.MatchIndex("checking", model.Transaction{Description: "LIDL 1"}); got != 0 {
|
||||
t.Errorf("index = %d, want 0", got)
|
||||
}
|
||||
if got := engine.MatchIndex("checking", model.Transaction{Description: "OTHER"}); got != -1 {
|
||||
t.Errorf("index = %d, want -1 for no match", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Naming an account is itself a narrowing, so the scoped rule beats the
|
||||
// identical unscoped one even when the unscoped one is written first.
|
||||
func TestAccountScopedRule(t *testing.T) {
|
||||
engine := New(&config.Rules{Rule: []config.Rule{
|
||||
{Match: "*TRANSFER*", Tag: "transfer", Transfer: true, Account: "savings"},
|
||||
{Match: "*TRANSFER*", Tag: "misc"},
|
||||
{Match: "*TRANSFER*", Tag: "transfer", Account: "savings"},
|
||||
}})
|
||||
|
||||
tag, transfer := engine.Apply("savings", "TRANSFER FROM CHECKING")
|
||||
if tag != "transfer" || !transfer {
|
||||
t.Errorf("savings: tag=%q transfer=%v, want transfer/true", tag, transfer)
|
||||
if tag := engine.Apply("savings", "TRANSFER FROM CHECKING"); tag != "transfer" {
|
||||
t.Errorf("savings: tag = %q, want transfer", tag)
|
||||
}
|
||||
tag, transfer = engine.Apply("checking", "TRANSFER FROM CHECKING")
|
||||
if tag != "misc" || transfer {
|
||||
t.Errorf("checking: tag=%q transfer=%v, want misc/false", tag, transfer)
|
||||
if tag := engine.Apply("checking", "TRANSFER FROM CHECKING"); tag != "misc" {
|
||||
t.Errorf("checking: tag = %q, want misc", tag)
|
||||
}
|
||||
}
|
||||
|
||||
// Movements between the user's own accounts are often only identifiable by
|
||||
// the counterparty IBAN, so rules can match on it.
|
||||
func TestCounterpartyAndTypeRules(t *testing.T) {
|
||||
// Besides the description, a rule can match the bank's own classification of
|
||||
// the transaction, on its own or alongside a description glob.
|
||||
func TestTypeRules(t *testing.T) {
|
||||
engine := New(&config.Rules{Rule: []config.Rule{
|
||||
{Counterparty: "SI56123456789012345", Tag: "transfer", Transfer: true},
|
||||
{Type: "CARD_PAYMENT", Match: "*LIDL*", Tag: "groceries"},
|
||||
{Type: "ATM", Tag: "cash"},
|
||||
}})
|
||||
|
||||
// Counterparty alone is enough, whatever the description says.
|
||||
tag, transfer := engine.ApplyTxn("checking", model.Transaction{
|
||||
Description: "Standing order", Counterparty: "SI56123456789012345",
|
||||
})
|
||||
if tag != "transfer" || !transfer {
|
||||
t.Errorf("tag=%q transfer=%v, want transfer/true", tag, transfer)
|
||||
}
|
||||
|
||||
// A rule setting several patterns requires all of them to match.
|
||||
tag, _ = engine.ApplyTxn("checking", model.Transaction{
|
||||
tag := engine.ApplyTxn("checking", model.Transaction{
|
||||
Description: "LIDL SOFIA", Type: "CARD_PAYMENT",
|
||||
})
|
||||
if tag != "groceries" {
|
||||
t.Errorf("tag = %q, want groceries", tag)
|
||||
}
|
||||
tag, _ = engine.ApplyTxn("checking", model.Transaction{
|
||||
tag = engine.ApplyTxn("checking", model.Transaction{
|
||||
Description: "LIDL SOFIA", Type: "TRANSFER",
|
||||
})
|
||||
if tag != "" {
|
||||
@@ -233,68 +248,9 @@ func TestCounterpartyAndTypeRules(t *testing.T) {
|
||||
}
|
||||
|
||||
// Type alone works too.
|
||||
if tag, _ := engine.ApplyTxn("checking", model.Transaction{
|
||||
if tag := engine.ApplyTxn("checking", model.Transaction{
|
||||
Description: "WITHDRAWAL", Type: "ATM",
|
||||
}); tag != "cash" {
|
||||
t.Errorf("tag = %q, want cash", tag)
|
||||
}
|
||||
}
|
||||
|
||||
// Transfers can also be overridden by hand, independently of the tag.
|
||||
func TestManualTransferOverride(t *testing.T) {
|
||||
db := testDB(t)
|
||||
seed(t, db, "TRANSFER TO SAVINGS")
|
||||
|
||||
engine := New(&config.Rules{Rule: []config.Rule{
|
||||
{Match: "*TO SAVINGS*", Tag: "transfer", Transfer: true},
|
||||
}})
|
||||
if _, err := engine.Retag(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
txn := byDescription(t, db, "TRANSFER TO SAVINGS")
|
||||
if !txn.IsTransfer() {
|
||||
t.Fatal("expected the rule to mark this as a transfer")
|
||||
}
|
||||
|
||||
no := false
|
||||
if err := db.SetManualTransfer(txn.ID, &no); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := engine.Retag(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if byDescription(t, db, "TRANSFER TO SAVINGS").IsTransfer() {
|
||||
t.Error("manual transfer override was lost on retag")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUntaggedFilter(t *testing.T) {
|
||||
db := testDB(t)
|
||||
seed(t, db, "LIDL SOFIA", "SOME RANDOM SHOP")
|
||||
|
||||
engine := New(&config.Rules{Rule: []config.Rule{{Match: "*LIDL*", Tag: "groceries"}}})
|
||||
if _, err := engine.Retag(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
untagged, err := db.Transactions(store.Filter{Untagged: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(untagged) != 1 || untagged[0].Description != "SOME RANDOM SHOP" {
|
||||
t.Fatalf("untagged = %+v, want only SOME RANDOM SHOP", untagged)
|
||||
}
|
||||
|
||||
// A manual tag must remove the row from the untagged view.
|
||||
if err := db.SetManualTag(untagged[0].ID, "hobby"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
untagged, err = db.Transactions(store.Filter{Untagged: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(untagged) != 0 {
|
||||
t.Errorf("untagged = %+v, want none", untagged)
|
||||
}
|
||||
}
|
||||
|
||||
+82
-123
@@ -1,6 +1,6 @@
|
||||
// Package store is the SQLite index over the statements. It is entirely
|
||||
// rebuildable: delete .money/index.db and re-import to get it back, except for
|
||||
// manual tags and manual transfer overrides, which live only here.
|
||||
// rebuildable: delete index.db and re-import to get it back. Nothing lives
|
||||
// only here.
|
||||
package store
|
||||
|
||||
import (
|
||||
@@ -48,77 +48,35 @@ CREATE TABLE IF NOT EXISTS transactions (
|
||||
date TEXT NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
amount_minor INTEGER NOT NULL,
|
||||
counterparty TEXT NOT NULL DEFAULT '',
|
||||
type TEXT NOT NULL DEFAULT '',
|
||||
balance_minor INTEGER,
|
||||
rule_tag TEXT,
|
||||
manual_tag TEXT,
|
||||
rule_transfer INTEGER NOT NULL DEFAULT 0,
|
||||
manual_transfer INTEGER,
|
||||
UNIQUE(account_id, fingerprint)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_txn_date ON transactions(date);
|
||||
CREATE INDEX IF NOT EXISTS idx_txn_account ON transactions(account_id);
|
||||
|
||||
-- One row per matched movement between the user's own accounts, derived from
|
||||
-- the [[transfer]] blocks in rules.toml. A leg belongs to at most one transfer,
|
||||
-- which UNIQUE enforces rather than trusting the pairing to be well behaved.
|
||||
CREATE TABLE IF NOT EXISTS transfers (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
def_index INTEGER NOT NULL,
|
||||
out_txn_id INTEGER NOT NULL UNIQUE REFERENCES transactions(id),
|
||||
in_txn_id INTEGER NOT NULL UNIQUE REFERENCES transactions(id)
|
||||
);
|
||||
`
|
||||
|
||||
// migrations bring an index created by an older build up to date. SQLite
|
||||
// errors on a duplicate column, which is how we detect "already applied".
|
||||
var migrations = []string{
|
||||
`ALTER TABLE transactions ADD COLUMN counterparty TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE transactions ADD COLUMN type TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE transactions ADD COLUMN balance_minor INTEGER`,
|
||||
}
|
||||
|
||||
// migrate applies any column that this index is missing.
|
||||
func migrate(db *sql.DB) error {
|
||||
have, err := columns(db, "transactions")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, stmt := range migrations {
|
||||
name := addedColumn(stmt)
|
||||
if have[name] {
|
||||
continue
|
||||
}
|
||||
if _, err := db.Exec(stmt); err != nil {
|
||||
return fmt.Errorf("migrate (%s): %w", stmt, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func columns(db *sql.DB, table string) (map[string]bool, error) {
|
||||
rows, err := db.Query(`SELECT name FROM pragma_table_info(?)`, table)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("inspect %s: %w", table, err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := map[string]bool{}
|
||||
for rows.Next() {
|
||||
var name string
|
||||
if err := rows.Scan(&name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[name] = true
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// addedColumn pulls the column name out of an ADD COLUMN statement.
|
||||
func addedColumn(stmt string) string {
|
||||
_, rest, ok := strings.Cut(stmt, "ADD COLUMN ")
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
name, _, _ := strings.Cut(rest, " ")
|
||||
return name
|
||||
}
|
||||
|
||||
// Open opens (creating if needed) the index at path.
|
||||
//
|
||||
// There is no schema migration: the index caches what the statements and
|
||||
// rules.toml already say, so a build that changes the schema is answered by
|
||||
// deleting index.db and importing again, not by patching the old one in place.
|
||||
// Until it is deleted, queries against it fail on the columns it lacks.
|
||||
func Open(path string) (*DB, error) {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return nil, fmt.Errorf("create state dir: %w", err)
|
||||
return nil, fmt.Errorf("create index dir: %w", err)
|
||||
}
|
||||
sqlDB, err := sql.Open("sqlite", path)
|
||||
if err != nil {
|
||||
@@ -128,10 +86,6 @@ func Open(path string) (*DB, error) {
|
||||
sqlDB.Close()
|
||||
return nil, fmt.Errorf("apply schema: %w", err)
|
||||
}
|
||||
if err := migrate(sqlDB); err != nil {
|
||||
sqlDB.Close()
|
||||
return nil, err
|
||||
}
|
||||
return &DB{sql: sqlDB}, nil
|
||||
}
|
||||
|
||||
@@ -214,8 +168,8 @@ func (d *DB) SourceFileSHA(accountID int64, path string) (string, bool, error) {
|
||||
// InsertTransaction adds a transaction unless its fingerprint already exists
|
||||
// for that account. It reports whether a new row was created.
|
||||
//
|
||||
// Existing rows are deliberately left untouched: re-importing an overlapping
|
||||
// statement must never clobber tags the user set by hand.
|
||||
// Existing rows are deliberately left untouched, so re-importing a statement
|
||||
// that overlaps one already imported adds nothing rather than duplicating it.
|
||||
func (d *DB) InsertTransaction(t model.Transaction) (bool, error) {
|
||||
var balance any
|
||||
if t.BalanceMinor != nil {
|
||||
@@ -224,11 +178,11 @@ func (d *DB) InsertTransaction(t model.Transaction) (bool, error) {
|
||||
res, err := d.sql.Exec(`
|
||||
INSERT INTO transactions
|
||||
(account_id, source_file_id, fingerprint, date, description, amount_minor,
|
||||
counterparty, type, balance_minor, rule_tag, rule_transfer)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NULLIF(?, ''), ?)
|
||||
type, balance_minor, rule_tag)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULLIF(?, ''))
|
||||
ON CONFLICT(account_id, fingerprint) DO NOTHING`,
|
||||
t.AccountID, t.SourceFileID, t.Fingerprint, t.Date, t.Description,
|
||||
t.AmountMinor, t.Counterparty, t.Type, balance, t.RuleTag, boolToInt(t.RuleTransfer))
|
||||
t.AmountMinor, t.Type, balance, t.RuleTag)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("insert transaction: %w", err)
|
||||
}
|
||||
@@ -242,7 +196,12 @@ func (d *DB) InsertTransaction(t model.Transaction) (bool, error) {
|
||||
// Filter narrows a transaction query.
|
||||
type Filter struct {
|
||||
AccountSlug string
|
||||
Untagged bool // only rows with no effective tag
|
||||
// Untagged selects the rows still waiting for a verdict: no tag, and not a
|
||||
// leg of a matched transfer. A paired leg has been accounted for by the
|
||||
// transfer that claimed it, so listing it as untagged would ask the user to
|
||||
// write a rule for something that is already spoken for and that the report
|
||||
// leaves out anyway.
|
||||
Untagged bool
|
||||
Month string // YYYY-MM
|
||||
Search string // case-insensitive substring of the description
|
||||
Limit int
|
||||
@@ -253,12 +212,12 @@ func (d *DB) Transactions(f Filter) ([]model.Transaction, error) {
|
||||
q := `
|
||||
SELECT t.id, t.account_id, a.slug, a.currency, a.minor_digits,
|
||||
t.fingerprint, t.date, t.description, t.amount_minor,
|
||||
COALESCE(s.path, ''), t.counterparty, t.type, t.balance_minor,
|
||||
COALESCE(t.rule_tag, ''), COALESCE(t.manual_tag, ''),
|
||||
t.rule_transfer, t.manual_transfer
|
||||
COALESCE(s.path, ''), t.type, t.balance_minor,
|
||||
COALESCE(t.rule_tag, ''), x.id
|
||||
FROM transactions t
|
||||
JOIN accounts a ON a.id = t.account_id
|
||||
LEFT JOIN source_files s ON s.id = t.source_file_id
|
||||
LEFT JOIN transfers x ON x.out_txn_id = t.id OR x.in_txn_id = t.id
|
||||
WHERE 1 = 1`
|
||||
var args []any
|
||||
if f.AccountSlug != "" {
|
||||
@@ -266,7 +225,7 @@ func (d *DB) Transactions(f Filter) ([]model.Transaction, error) {
|
||||
args = append(args, f.AccountSlug)
|
||||
}
|
||||
if f.Untagged {
|
||||
q += ` AND COALESCE(NULLIF(t.manual_tag, ''), NULLIF(t.rule_tag, '')) IS NULL`
|
||||
q += ` AND NULLIF(t.rule_tag, '') IS NULL AND x.id IS NULL`
|
||||
}
|
||||
if f.Month != "" {
|
||||
q += ` AND substr(t.date, 1, 7) = ?`
|
||||
@@ -287,24 +246,21 @@ func (d *DB) Transactions(f Filter) ([]model.Transaction, error) {
|
||||
for rows.Next() {
|
||||
var (
|
||||
t model.Transaction
|
||||
ruleTransfer int
|
||||
manualTransfer sql.NullInt64
|
||||
balance sql.NullInt64
|
||||
transfer sql.NullInt64
|
||||
)
|
||||
if err := rows.Scan(&t.ID, &t.AccountID, &t.AccountSlug, &t.Currency, &t.MinorDigits,
|
||||
&t.Fingerprint, &t.Date, &t.Description, &t.AmountMinor, &t.SourcePath,
|
||||
&t.Counterparty, &t.Type, &balance,
|
||||
&t.RuleTag, &t.ManualTag, &ruleTransfer, &manualTransfer); err != nil {
|
||||
&t.Type, &balance, &t.RuleTag, &transfer); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
t.RuleTransfer = ruleTransfer != 0
|
||||
if balance.Valid {
|
||||
v := balance.Int64
|
||||
t.BalanceMinor = &v
|
||||
}
|
||||
if manualTransfer.Valid {
|
||||
v := manualTransfer.Int64 != 0
|
||||
t.ManualTransfer = &v
|
||||
if transfer.Valid {
|
||||
v := transfer.Int64
|
||||
t.TransferID = &v
|
||||
}
|
||||
if needle != "" && !strings.Contains(model.NormalizeDescription(t.Description), needle) {
|
||||
continue
|
||||
@@ -317,39 +273,14 @@ func (d *DB) Transactions(f Filter) ([]model.Transaction, error) {
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// SetManualTag sets (or, with an empty tag, clears) the user's tag override.
|
||||
func (d *DB) SetManualTag(id int64, tag string) error {
|
||||
_, err := d.sql.Exec(`UPDATE transactions SET manual_tag = NULLIF(?, '') WHERE id = ?`, tag, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// SetManualTransfer sets the user's transfer override; nil restores the
|
||||
// rule-derived value.
|
||||
func (d *DB) SetManualTransfer(id int64, v *bool) error {
|
||||
var arg any
|
||||
if v != nil {
|
||||
arg = boolToInt(*v)
|
||||
}
|
||||
_, err := d.sql.Exec(`UPDATE transactions SET manual_transfer = ? WHERE id = ?`, arg, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// ClearOverrides drops both manual overrides for a transaction.
|
||||
func (d *DB) ClearOverrides(id int64) error {
|
||||
_, err := d.sql.Exec(
|
||||
`UPDATE transactions SET manual_tag = NULL, manual_transfer = NULL WHERE id = ?`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// RuleAssignment is one row's recomputed rule verdict.
|
||||
type RuleAssignment struct {
|
||||
ID int64
|
||||
Tag string
|
||||
Transfer bool
|
||||
}
|
||||
|
||||
// ApplyRuleResults rewrites rule_tag and rule_transfer for every listed row in
|
||||
// a single transaction. Manual columns are never touched.
|
||||
// ApplyRuleResults rewrites rule_tag for every listed row in a single
|
||||
// transaction. The manual column is never touched.
|
||||
func (d *DB) ApplyRuleResults(rs []RuleAssignment) error {
|
||||
tx, err := d.sql.Begin()
|
||||
if err != nil {
|
||||
@@ -358,22 +289,57 @@ func (d *DB) ApplyRuleResults(rs []RuleAssignment) error {
|
||||
defer tx.Rollback()
|
||||
|
||||
stmt, err := tx.Prepare(
|
||||
`UPDATE transactions SET rule_tag = NULLIF(?, ''), rule_transfer = ? WHERE id = ?`)
|
||||
`UPDATE transactions SET rule_tag = NULLIF(?, '') WHERE id = ?`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer stmt.Close()
|
||||
|
||||
for _, r := range rs {
|
||||
if _, err := stmt.Exec(r.Tag, boolToInt(r.Transfer), r.ID); err != nil {
|
||||
if _, err := stmt.Exec(r.Tag, r.ID); err != nil {
|
||||
return fmt.Errorf("apply rules to txn %d: %w", r.ID, err)
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// Balance sums every transaction in an account, transfers included: a transfer
|
||||
// really does move the account's money.
|
||||
// TransferLink is one matched pair, as decided by the transfer definitions.
|
||||
type TransferLink struct {
|
||||
DefIndex int
|
||||
OutID int64
|
||||
InID int64
|
||||
}
|
||||
|
||||
// ReplaceTransfers rewrites the whole pairing in one transaction. Like the
|
||||
// tags, it is derived from a file the user edits, so it is replaced wholesale
|
||||
// rather than patched: a definition removed from rules.toml must take its pairs
|
||||
// with it.
|
||||
func (d *DB) ReplaceTransfers(links []TransferLink) error {
|
||||
tx, err := d.sql.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
if _, err := tx.Exec(`DELETE FROM transfers`); err != nil {
|
||||
return fmt.Errorf("clear transfers: %w", err)
|
||||
}
|
||||
stmt, err := tx.Prepare(
|
||||
`INSERT INTO transfers (def_index, out_txn_id, in_txn_id) VALUES (?, ?, ?)`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer stmt.Close()
|
||||
|
||||
for _, l := range links {
|
||||
if _, err := stmt.Exec(l.DefIndex, l.OutID, l.InID); err != nil {
|
||||
return fmt.Errorf("link transfer %d→%d: %w", l.OutID, l.InID, err)
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// Balance sums every transaction in an account.
|
||||
func (d *DB) Balance(accountID int64) (int64, error) {
|
||||
var v sql.NullInt64
|
||||
err := d.sql.QueryRow(
|
||||
@@ -392,11 +358,11 @@ func (d *DB) Count(accountID int64) (int, error) {
|
||||
return n, err
|
||||
}
|
||||
|
||||
// Tags lists every effective tag in use, for completion in the TUI.
|
||||
// Tags lists every tag in use, for completion in the TUI.
|
||||
func (d *DB) Tags() ([]string, error) {
|
||||
rows, err := d.sql.Query(`
|
||||
SELECT DISTINCT COALESCE(NULLIF(manual_tag, ''), NULLIF(rule_tag, '')) AS tag
|
||||
FROM transactions WHERE tag IS NOT NULL ORDER BY tag`)
|
||||
SELECT DISTINCT rule_tag FROM transactions
|
||||
WHERE rule_tag IS NOT NULL AND rule_tag != '' ORDER BY rule_tag`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -411,10 +377,3 @@ func (d *DB) Tags() ([]string, error) {
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func boolToInt(b bool) int {
|
||||
if b {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
// Package transfers pairs the two legs of money moved between the user's own
|
||||
// accounts, as described by the [[transfer]] blocks in rules.toml.
|
||||
//
|
||||
// A transfer is always a pair. One leg on its own is not a transfer, it is an
|
||||
// unmatched leg: money that left an account and cannot be shown to have
|
||||
// arrived. That distinction is the whole point of pairing here rather than
|
||||
// flagging single transactions, because only a complete pair can be dropped
|
||||
// from the report without unbalancing it.
|
||||
package transfers
|
||||
|
||||
import (
|
||||
"math"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"git.petrovv.com/nikola/money/internal/config"
|
||||
"git.petrovv.com/nikola/money/internal/glob"
|
||||
"git.petrovv.com/nikola/money/internal/model"
|
||||
"git.petrovv.com/nikola/money/internal/store"
|
||||
)
|
||||
|
||||
// WindowDays is how far apart the two legs may be dated. A transfer between
|
||||
// two banks is one movement seen twice, but the statements rarely agree on the
|
||||
// day: the money leaves on Friday and lands on Monday.
|
||||
const WindowDays = 5
|
||||
|
||||
// Engine pairs legs using the definitions in file order. As with rules, the
|
||||
// first definition to claim a transaction keeps it, so an earlier definition
|
||||
// can never have a leg stolen by a later one.
|
||||
type Engine struct {
|
||||
defs []config.Transfer
|
||||
}
|
||||
|
||||
// New builds an engine from the parsed rules file.
|
||||
func New(r *config.Rules) *Engine { return &Engine{defs: r.Transfer} }
|
||||
|
||||
// Transfers returns the ordered definitions, as loaded from rules.toml.
|
||||
func (e *Engine) Transfers() []config.Transfer { return e.defs }
|
||||
|
||||
// Pair is one matched movement: the leg that left and the leg that arrived.
|
||||
type Pair struct {
|
||||
Def int // index of the definition that claimed it
|
||||
Out model.Transaction
|
||||
In model.Transaction
|
||||
}
|
||||
|
||||
// Fee is what the movement lost on the way: the amount that left, less the
|
||||
// amount that arrived. It is non-zero only for a definition carrying a
|
||||
// tolerance_pct, and it is money genuinely spent — a pair leaves the report
|
||||
// entirely, so this is the one number that has to be reported separately or it
|
||||
// vanishes with the legs. Negative would mean more arrived than left.
|
||||
//
|
||||
// Across currencies it is always zero: the two amounts are in different units,
|
||||
// so subtracting them would produce a number that means nothing.
|
||||
func (p Pair) Fee() int64 {
|
||||
if p.In.Currency != p.Out.Currency {
|
||||
return 0
|
||||
}
|
||||
return -(p.Out.AmountMinor + p.In.AmountMinor)
|
||||
}
|
||||
|
||||
// Leg is a transaction a definition caught on one side but could not pair.
|
||||
type Leg struct {
|
||||
Def int
|
||||
Txn model.Transaction
|
||||
// Out reports which side it was caught on: true for the leaving leg
|
||||
// (from_account, from_desc), false for the arriving one.
|
||||
Out bool
|
||||
}
|
||||
|
||||
// Result is what a definition set makes of a set of transactions.
|
||||
type Result struct {
|
||||
Pairs []Pair
|
||||
Unmatched []Leg
|
||||
// Paired and Orphaned are per-definition counts, positionally matching the
|
||||
// definitions. A definition with no pairs and no orphans matches nothing at
|
||||
// all; one with orphans is catching transactions but not completing them.
|
||||
Paired []int
|
||||
Orphaned []int
|
||||
}
|
||||
|
||||
// Analyze pairs every leg it can. Transactions are claimed at most once across
|
||||
// the whole run, so the result is a partition, not a set of overlapping
|
||||
// interpretations.
|
||||
func (e *Engine) Analyze(txns []model.Transaction) Result {
|
||||
res := Result{
|
||||
Paired: make([]int, len(e.defs)),
|
||||
Orphaned: make([]int, len(e.defs)),
|
||||
}
|
||||
|
||||
// Deterministic input order: the index returns newest first, and pairing
|
||||
// walks forward in time so the earliest leg gets the earliest counterpart.
|
||||
ordered := append([]model.Transaction(nil), txns...)
|
||||
sort.Slice(ordered, func(i, j int) bool {
|
||||
if ordered[i].Date != ordered[j].Date {
|
||||
return ordered[i].Date < ordered[j].Date
|
||||
}
|
||||
return ordered[i].ID < ordered[j].ID
|
||||
})
|
||||
|
||||
claimed := map[int64]bool{}
|
||||
for d := range e.defs {
|
||||
def := &e.defs[d]
|
||||
var outs, ins []model.Transaction
|
||||
for _, t := range ordered {
|
||||
if claimed[t.ID] {
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case t.AmountMinor < 0 && matches(def.FromAccount, def.FromDesc, t):
|
||||
outs = append(outs, t)
|
||||
case t.AmountMinor > 0 && matches(def.ToAccount, def.ToDesc, t):
|
||||
ins = append(ins, t)
|
||||
}
|
||||
}
|
||||
|
||||
used := map[int64]bool{}
|
||||
for _, out := range outs {
|
||||
j := bestCounterpart(out, ins, used, def.TolerancePct)
|
||||
if j < 0 {
|
||||
continue
|
||||
}
|
||||
in := ins[j]
|
||||
used[in.ID], used[out.ID] = true, true
|
||||
claimed[in.ID], claimed[out.ID] = true, true
|
||||
res.Pairs = append(res.Pairs, Pair{Def: d, Out: out, In: in})
|
||||
res.Paired[d]++
|
||||
}
|
||||
|
||||
for _, t := range outs {
|
||||
if !used[t.ID] {
|
||||
res.Unmatched = append(res.Unmatched, Leg{Def: d, Txn: t, Out: true})
|
||||
res.Orphaned[d]++
|
||||
}
|
||||
}
|
||||
for _, t := range ins {
|
||||
if !used[t.ID] {
|
||||
res.Unmatched = append(res.Unmatched, Leg{Def: d, Txn: t})
|
||||
res.Orphaned[d]++
|
||||
}
|
||||
}
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// matches reports whether a transaction is on the named account and its
|
||||
// description fits the glob. Descriptions are normalised the same way the
|
||||
// tagging rules normalise them, so one pattern behaves the same in both places.
|
||||
func matches(account, pattern string, t model.Transaction) bool {
|
||||
return t.AccountSlug == account && glob.Match(pattern, model.NormalizeDescription(t.Description))
|
||||
}
|
||||
|
||||
// bestCounterpart finds the arriving leg for out, dated within the window. The
|
||||
// closest date wins, so two identical monthly transfers pair up in order
|
||||
// instead of crossing over.
|
||||
//
|
||||
// Within one currency the amount is the evidence: an exact opposite is near
|
||||
// proof that two legs are one movement, so it is what is required by default.
|
||||
// tolerancePct widens that, and only that, for a route where the bank takes a
|
||||
// fee on the way and the two statements therefore disagree. The difference it
|
||||
// admits is not forgiven — it is real money, it is reported as the pair's Fee,
|
||||
// and report.Excluded carries it out of the report so it cannot be lost inside
|
||||
// a transfer. Which is why the default stays zero: every percent of slack is
|
||||
// also a percent more chance of pairing two unrelated movements.
|
||||
//
|
||||
// Across currencies there is no such evidence. The tool holds no exchange
|
||||
// rates, so the two numbers are unrelated and the dates carry the pairing on
|
||||
// their own. That is weaker, and it is meant to be: it pairs an exchange
|
||||
// between your own accounts, and it will pick the wrong counterpart if the
|
||||
// same route is used twice inside one window. A tolerance means nothing there
|
||||
// and is ignored.
|
||||
func bestCounterpart(out model.Transaction, ins []model.Transaction, used map[int64]bool, tolerancePct float64) int {
|
||||
outDay, ok := day(out.Date)
|
||||
if !ok {
|
||||
return -1
|
||||
}
|
||||
allowed := allowance(out.AmountMinor, tolerancePct)
|
||||
|
||||
best, bestGap, bestOff := -1, 0, int64(0)
|
||||
for j, in := range ins {
|
||||
if used[in.ID] {
|
||||
continue
|
||||
}
|
||||
off := int64(0)
|
||||
if in.Currency == out.Currency {
|
||||
off = in.AmountMinor + out.AmountMinor
|
||||
if off < 0 {
|
||||
off = -off
|
||||
}
|
||||
if off > allowed {
|
||||
continue
|
||||
}
|
||||
}
|
||||
inDay, ok := day(in.Date)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
gap := int(inDay.Sub(outDay).Hours() / 24)
|
||||
if gap < 0 {
|
||||
gap = -gap
|
||||
}
|
||||
if gap > WindowDays {
|
||||
continue
|
||||
}
|
||||
// Strictly closer, so an equal gap keeps the candidate already found.
|
||||
// ins is in date order, which makes that the earlier one. Under a
|
||||
// tolerance an equal gap can still be decided on the amount, and the
|
||||
// nearer amount is the better evidence; with no tolerance every
|
||||
// candidate is exact and this never fires.
|
||||
if best < 0 || gap < bestGap || (gap == bestGap && off < bestOff) {
|
||||
best, bestGap, bestOff = j, gap, off
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
// allowance is how far the arriving leg may miss the leaving one, in minor
|
||||
// units. It is a share of the amount that left, not of the difference, so the
|
||||
// same percentage means the same thing on a large transfer as on a small one.
|
||||
//
|
||||
// Rounded rather than truncated: at 1% of 10.00 a truncating allowance would be
|
||||
// 0.09 and miss the 0.10 fee the percentage was chosen to admit.
|
||||
func allowance(outMinor int64, pct float64) int64 {
|
||||
if pct <= 0 {
|
||||
return 0
|
||||
}
|
||||
if outMinor < 0 {
|
||||
outMinor = -outMinor
|
||||
}
|
||||
return int64(math.Round(float64(outMinor) * pct / 100))
|
||||
}
|
||||
|
||||
// day parses a statement date. An unparseable one cannot be windowed, so it
|
||||
// simply never pairs rather than pairing wrongly.
|
||||
func day(s string) (time.Time, bool) {
|
||||
t, err := time.Parse("2006-01-02", s)
|
||||
return t, err == nil
|
||||
}
|
||||
|
||||
// Link recomputes the pairing over every transaction in the index and writes it
|
||||
// back, replacing whatever was there. Like Retag, it is derived state rewritten
|
||||
// wholesale, so it is safe to run at any time.
|
||||
//
|
||||
// It runs over the whole index deliberately: pairing inside a filtered view
|
||||
// would let a movement count as a transfer in one report and not in another.
|
||||
func (e *Engine) Link(db *store.DB) (pairs, unmatched int, err error) {
|
||||
txns, err := db.Transactions(store.Filter{})
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
res := e.Analyze(txns)
|
||||
|
||||
links := make([]store.TransferLink, 0, len(res.Pairs))
|
||||
for _, p := range res.Pairs {
|
||||
links = append(links, store.TransferLink{DefIndex: p.Def, OutID: p.Out.ID, InID: p.In.ID})
|
||||
}
|
||||
if err := db.ReplaceTransfers(links); err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
return len(res.Pairs), len(res.Unmatched), nil
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
package transfers
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.petrovv.com/nikola/money/internal/config"
|
||||
"git.petrovv.com/nikola/money/internal/model"
|
||||
)
|
||||
|
||||
// txn builds a transaction the way the index hands them out.
|
||||
func txn(id int64, account, date, desc string, amount int64) model.Transaction {
|
||||
return model.Transaction{
|
||||
ID: id,
|
||||
AccountSlug: account,
|
||||
Currency: "EUR",
|
||||
MinorDigits: 2,
|
||||
Date: date,
|
||||
Description: desc,
|
||||
AmountMinor: amount,
|
||||
}
|
||||
}
|
||||
|
||||
func engine(defs ...config.Transfer) *Engine {
|
||||
return New(&config.Rules{Transfer: defs})
|
||||
}
|
||||
|
||||
var topUp = config.Transfer{
|
||||
FromAccount: "nlb", FromDesc: "*TO REVOLUT*",
|
||||
ToAccount: "revolut", ToDesc: "*FROM NLB*",
|
||||
}
|
||||
|
||||
// The two legs of one movement, dated a weekend apart, are one transfer.
|
||||
func TestPairsTheTwoLegs(t *testing.T) {
|
||||
res := engine(topUp).Analyze([]model.Transaction{
|
||||
txn(1, "nlb", "2026-03-06", "TRANSFER TO REVOLUT LTD", -50000),
|
||||
txn(2, "revolut", "2026-03-09", "Top-up from NLB", 50000),
|
||||
txn(3, "nlb", "2026-03-07", "LIDL SOFIA", -2000),
|
||||
})
|
||||
|
||||
if len(res.Pairs) != 1 {
|
||||
t.Fatalf("pairs = %+v, want exactly one", res.Pairs)
|
||||
}
|
||||
if res.Pairs[0].Out.ID != 1 || res.Pairs[0].In.ID != 2 {
|
||||
t.Errorf("paired %d→%d, want 1→2", res.Pairs[0].Out.ID, res.Pairs[0].In.ID)
|
||||
}
|
||||
if len(res.Unmatched) != 0 {
|
||||
t.Errorf("unmatched = %+v, want none; the shopping matches no side", res.Unmatched)
|
||||
}
|
||||
if res.Paired[0] != 1 || res.Orphaned[0] != 0 {
|
||||
t.Errorf("counts = %d paired, %d orphaned; want 1 and 0", res.Paired[0], res.Orphaned[0])
|
||||
}
|
||||
}
|
||||
|
||||
// Money that left and never arrived is the case the whole feature exists to
|
||||
// surface: it is not a transfer, it is one leg on its own.
|
||||
func TestLegWithoutACounterpartIsUnmatched(t *testing.T) {
|
||||
res := engine(topUp).Analyze([]model.Transaction{
|
||||
txn(1, "nlb", "2026-03-06", "TRANSFER TO REVOLUT LTD", -50000),
|
||||
})
|
||||
|
||||
if len(res.Pairs) != 0 {
|
||||
t.Fatalf("pairs = %+v, want none", res.Pairs)
|
||||
}
|
||||
if len(res.Unmatched) != 1 || res.Unmatched[0].Txn.ID != 1 || !res.Unmatched[0].Out {
|
||||
t.Fatalf("unmatched = %+v, want the leaving leg", res.Unmatched)
|
||||
}
|
||||
if res.Paired[0] != 0 || res.Orphaned[0] != 1 {
|
||||
t.Errorf("counts = %d paired, %d orphaned; want 0 and 1", res.Paired[0], res.Orphaned[0])
|
||||
}
|
||||
}
|
||||
|
||||
// A definition that catches nothing at all is dead, and reports as such
|
||||
// separately from one that catches legs it cannot pair.
|
||||
func TestDefinitionMatchingNothing(t *testing.T) {
|
||||
res := engine(topUp).Analyze([]model.Transaction{
|
||||
txn(1, "nlb", "2026-03-06", "LIDL SOFIA", -2000),
|
||||
})
|
||||
if res.Paired[0] != 0 || res.Orphaned[0] != 0 {
|
||||
t.Errorf("counts = %d paired, %d orphaned; want both zero", res.Paired[0], res.Orphaned[0])
|
||||
}
|
||||
}
|
||||
|
||||
// Within one currency the amount is the evidence, so by default it must agree
|
||||
// exactly: a movement that arrives short a fee is not the same movement
|
||||
// leaving, unless the definition says the route charges one.
|
||||
func TestAmountMustAgreeWithinACurrency(t *testing.T) {
|
||||
res := engine(topUp).Analyze([]model.Transaction{
|
||||
txn(1, "nlb", "2026-03-06", "TRANSFER TO REVOLUT LTD", -50000),
|
||||
txn(2, "revolut", "2026-03-07", "Top-up from NLB", 49500),
|
||||
})
|
||||
if len(res.Pairs) != 0 {
|
||||
t.Errorf("pairs = %+v, want none: the amounts differ", res.Pairs)
|
||||
}
|
||||
if len(res.Unmatched) != 2 {
|
||||
t.Errorf("unmatched = %+v, want both legs reported", res.Unmatched)
|
||||
}
|
||||
}
|
||||
|
||||
// Across currencies the amounts are unrelated -- there are no exchange rates
|
||||
// here -- so the dates carry the pairing on their own.
|
||||
func TestCrossCurrencyPairsOnDateAlone(t *testing.T) {
|
||||
arrived := txn(2, "revolut", "2026-03-07", "Top-up from NLB", 97790)
|
||||
arrived.Currency = "BGN"
|
||||
|
||||
res := engine(topUp).Analyze([]model.Transaction{
|
||||
txn(1, "nlb", "2026-03-06", "TRANSFER TO REVOLUT LTD", -50000),
|
||||
arrived,
|
||||
})
|
||||
if len(res.Pairs) != 1 {
|
||||
t.Fatalf("pairs = %+v, want the exchange paired", res.Pairs)
|
||||
}
|
||||
if res.Pairs[0].In.Currency == res.Pairs[0].Out.Currency {
|
||||
t.Error("expected the pair to span two currencies")
|
||||
}
|
||||
|
||||
// The window still bounds it.
|
||||
late := arrived
|
||||
late.Date = "2026-03-20"
|
||||
res = engine(topUp).Analyze([]model.Transaction{
|
||||
txn(1, "nlb", "2026-03-06", "TRANSFER TO REVOLUT LTD", -50000),
|
||||
late,
|
||||
})
|
||||
if len(res.Pairs) != 0 {
|
||||
t.Errorf("pairs = %+v, want none beyond the window", res.Pairs)
|
||||
}
|
||||
}
|
||||
|
||||
// With no amount to go on, the nearest date decides, so two exchanges in flight
|
||||
// at once pair in order rather than crossing over.
|
||||
func TestCrossCurrencyPicksTheNearestDate(t *testing.T) {
|
||||
arriving := func(id int64, date string, amount int64) model.Transaction {
|
||||
in := txn(id, "revolut", date, "Top-up from NLB", amount)
|
||||
in.Currency = "BGN"
|
||||
return in
|
||||
}
|
||||
res := engine(topUp).Analyze([]model.Transaction{
|
||||
txn(1, "nlb", "2026-03-02", "TRANSFER TO REVOLUT LTD", -50000),
|
||||
txn(3, "nlb", "2026-03-10", "TRANSFER TO REVOLUT LTD", -20000),
|
||||
arriving(2, "2026-03-03", 97790),
|
||||
arriving(4, "2026-03-11", 39116),
|
||||
})
|
||||
if len(res.Pairs) != 2 {
|
||||
t.Fatalf("pairs = %+v, want both exchanges paired", res.Pairs)
|
||||
}
|
||||
for _, p := range res.Pairs {
|
||||
if p.In.ID != p.Out.ID+1 {
|
||||
t.Errorf("paired %d->%d, want each exchange with its own counterpart",
|
||||
p.Out.ID, p.In.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Legs further apart than the window are not the same movement.
|
||||
func TestWindowBoundsThePairing(t *testing.T) {
|
||||
inside := engine(topUp).Analyze([]model.Transaction{
|
||||
txn(1, "nlb", "2026-03-01", "TRANSFER TO REVOLUT LTD", -50000),
|
||||
txn(2, "revolut", "2026-03-06", "Top-up from NLB", 50000),
|
||||
})
|
||||
if len(inside.Pairs) != 1 {
|
||||
t.Errorf("pairs = %+v, want one at exactly the window", inside.Pairs)
|
||||
}
|
||||
|
||||
outside := engine(topUp).Analyze([]model.Transaction{
|
||||
txn(1, "nlb", "2026-03-01", "TRANSFER TO REVOLUT LTD", -50000),
|
||||
txn(2, "revolut", "2026-03-07", "Top-up from NLB", 50000),
|
||||
})
|
||||
if len(outside.Pairs) != 0 {
|
||||
t.Errorf("pairs = %+v, want none beyond the window", outside.Pairs)
|
||||
}
|
||||
}
|
||||
|
||||
// Two identical monthly transfers must pair in order rather than crossing over,
|
||||
// or the dates in the report would be wrong even though the totals were right.
|
||||
func TestIdenticalTransfersPairInOrder(t *testing.T) {
|
||||
res := engine(topUp).Analyze([]model.Transaction{
|
||||
txn(4, "revolut", "2026-04-02", "Top-up from NLB", 50000),
|
||||
txn(1, "nlb", "2026-03-01", "TRANSFER TO REVOLUT LTD", -50000),
|
||||
txn(3, "nlb", "2026-04-01", "TRANSFER TO REVOLUT LTD", -50000),
|
||||
txn(2, "revolut", "2026-03-02", "Top-up from NLB", 50000),
|
||||
})
|
||||
if len(res.Pairs) != 2 {
|
||||
t.Fatalf("pairs = %+v, want two", res.Pairs)
|
||||
}
|
||||
for _, p := range res.Pairs {
|
||||
if p.In.ID != p.Out.ID+1 {
|
||||
t.Errorf("paired %d→%d, want each transfer with its own month", p.Out.ID, p.In.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A transaction belongs to one transfer. The first definition to claim a leg
|
||||
// keeps it, exactly as the first matching rule keeps a tag.
|
||||
func TestFirstDefinitionClaimsTheLeg(t *testing.T) {
|
||||
broad := config.Transfer{
|
||||
FromAccount: "nlb", FromDesc: "*TRANSFER*",
|
||||
ToAccount: "revolut", ToDesc: "*NLB*",
|
||||
}
|
||||
res := engine(topUp, broad).Analyze([]model.Transaction{
|
||||
txn(1, "nlb", "2026-03-06", "TRANSFER TO REVOLUT LTD", -50000),
|
||||
txn(2, "revolut", "2026-03-07", "Top-up from NLB", 50000),
|
||||
})
|
||||
if len(res.Pairs) != 1 || res.Pairs[0].Def != 0 {
|
||||
t.Fatalf("pairs = %+v, want one claimed by the first definition", res.Pairs)
|
||||
}
|
||||
if res.Paired[1] != 0 || res.Orphaned[1] != 0 {
|
||||
t.Errorf("the shadowed definition reports %d paired, %d orphaned; want zero",
|
||||
res.Paired[1], res.Orphaned[1])
|
||||
}
|
||||
}
|
||||
|
||||
// Direction is part of the definition: the arriving leg is an inflow and the
|
||||
// leaving leg an outflow, so a definition written backwards pairs nothing.
|
||||
func TestDirectionMatters(t *testing.T) {
|
||||
backwards := config.Transfer{
|
||||
FromAccount: "revolut", FromDesc: "*FROM NLB*",
|
||||
ToAccount: "nlb", ToDesc: "*TO REVOLUT*",
|
||||
}
|
||||
res := engine(backwards).Analyze([]model.Transaction{
|
||||
txn(1, "nlb", "2026-03-06", "TRANSFER TO REVOLUT LTD", -50000),
|
||||
txn(2, "revolut", "2026-03-07", "Top-up from NLB", 50000),
|
||||
})
|
||||
if len(res.Pairs) != 0 {
|
||||
t.Errorf("pairs = %+v, want none: neither leg is on the side it was named for", res.Pairs)
|
||||
}
|
||||
if len(res.Unmatched) != 0 {
|
||||
t.Errorf("unmatched = %+v, want none either", res.Unmatched)
|
||||
}
|
||||
}
|
||||
|
||||
// Money moved inside one account (a savings pocket, say) is still a pair.
|
||||
func TestSameAccountBothSides(t *testing.T) {
|
||||
pocket := config.Transfer{
|
||||
FromAccount: "revolut", FromDesc: "*TO VAULT*",
|
||||
ToAccount: "revolut", ToDesc: "*FROM VAULT*",
|
||||
}
|
||||
res := engine(pocket).Analyze([]model.Transaction{
|
||||
txn(1, "revolut", "2026-03-06", "Move to Vault", -10000),
|
||||
txn(2, "revolut", "2026-03-06", "Move from Vault", 10000),
|
||||
})
|
||||
if len(res.Pairs) != 1 {
|
||||
t.Errorf("pairs = %+v, want one within the account", res.Pairs)
|
||||
}
|
||||
}
|
||||
|
||||
// tolerant is the same route on a bank that takes a fee on the way: 1% of the
|
||||
// leaving leg, so a 500.00 transfer may arrive as little as 495.00.
|
||||
var tolerant = config.Transfer{
|
||||
FromAccount: "nlb", FromDesc: "*TO REVOLUT*",
|
||||
ToAccount: "revolut", ToDesc: "*FROM NLB*",
|
||||
TolerancePct: 1,
|
||||
}
|
||||
|
||||
// A route that charges pairs anyway, and says what it cost.
|
||||
func TestToleranceAdmitsAFee(t *testing.T) {
|
||||
res := engine(tolerant).Analyze([]model.Transaction{
|
||||
txn(1, "nlb", "2026-03-06", "TRANSFER TO REVOLUT LTD", -50000),
|
||||
txn(2, "revolut", "2026-03-07", "Top-up from NLB", 49500),
|
||||
})
|
||||
if len(res.Pairs) != 1 {
|
||||
t.Fatalf("pairs = %+v, want the fee tolerated", res.Pairs)
|
||||
}
|
||||
if fee := res.Pairs[0].Fee(); fee != 500 {
|
||||
t.Errorf("fee = %d, want 500: the pair leaves the report, so the fee has to be reported", fee)
|
||||
}
|
||||
}
|
||||
|
||||
// The tolerance is a bound, not an invitation: past it the legs are still two
|
||||
// separate things.
|
||||
func TestToleranceStopsAtItsBound(t *testing.T) {
|
||||
res := engine(tolerant).Analyze([]model.Transaction{
|
||||
txn(1, "nlb", "2026-03-06", "TRANSFER TO REVOLUT LTD", -50000),
|
||||
txn(2, "revolut", "2026-03-07", "Top-up from NLB", 49499),
|
||||
})
|
||||
if len(res.Pairs) != 0 {
|
||||
t.Errorf("pairs = %+v, want none: 5.01 is more than 1%% of 500.00", res.Pairs)
|
||||
}
|
||||
}
|
||||
|
||||
// The allowance is a share of the amount that left, so the same percentage
|
||||
// means the same thing on a small transfer as on a large one -- and it is
|
||||
// rounded, or 1% of 10.00 would admit 0.09 and miss the 0.10 fee it was
|
||||
// chosen for.
|
||||
func TestToleranceScalesWithTheAmount(t *testing.T) {
|
||||
res := engine(tolerant).Analyze([]model.Transaction{
|
||||
txn(1, "nlb", "2026-03-06", "TRANSFER TO REVOLUT LTD", -1000),
|
||||
txn(2, "revolut", "2026-03-07", "Top-up from NLB", 990),
|
||||
})
|
||||
if len(res.Pairs) != 1 || res.Pairs[0].Fee() != 10 {
|
||||
t.Fatalf("pairs = %+v, want a 0.10 fee on 10.00 tolerated", res.Pairs)
|
||||
}
|
||||
|
||||
res = engine(tolerant).Analyze([]model.Transaction{
|
||||
txn(1, "nlb", "2026-03-06", "TRANSFER TO REVOLUT LTD", -1000),
|
||||
txn(2, "revolut", "2026-03-07", "Top-up from NLB", 950),
|
||||
})
|
||||
if len(res.Pairs) != 0 {
|
||||
t.Errorf("pairs = %+v, want none: 0.50 is 5%% of 10.00", res.Pairs)
|
||||
}
|
||||
}
|
||||
|
||||
// A tolerance belongs to the definition that declares it and to no other, so
|
||||
// one tolerant route cannot loosen a strict one written beside it.
|
||||
func TestToleranceIsPerDefinition(t *testing.T) {
|
||||
strict := config.Transfer{
|
||||
FromAccount: "nlb", FromDesc: "*TO SAVINGS*",
|
||||
ToAccount: "savings", ToDesc: "*FROM NLB*",
|
||||
}
|
||||
res := engine(tolerant, strict).Analyze([]model.Transaction{
|
||||
txn(1, "nlb", "2026-03-06", "TRANSFER TO REVOLUT LTD", -50000),
|
||||
txn(2, "revolut", "2026-03-07", "Top-up from NLB", 49500),
|
||||
txn(3, "nlb", "2026-03-06", "TRANSFER TO SAVINGS", -50000),
|
||||
txn(4, "savings", "2026-03-07", "FROM NLB", 49500),
|
||||
})
|
||||
if len(res.Pairs) != 1 || res.Pairs[0].Def != 0 {
|
||||
t.Fatalf("pairs = %+v, want only the tolerant definition to pair", res.Pairs)
|
||||
}
|
||||
if len(res.Unmatched) != 2 {
|
||||
t.Errorf("unmatched = %+v, want both legs of the strict route reported", res.Unmatched)
|
||||
}
|
||||
}
|
||||
|
||||
// Across currencies the amounts are in different units, so a fee cannot be
|
||||
// computed from them -- subtracting one from the other would be a number
|
||||
// meaning nothing.
|
||||
func TestNoFeeAcrossCurrencies(t *testing.T) {
|
||||
arrived := txn(2, "revolut", "2026-03-07", "Top-up from NLB", 97790)
|
||||
arrived.Currency = "BGN"
|
||||
|
||||
res := engine(tolerant).Analyze([]model.Transaction{
|
||||
txn(1, "nlb", "2026-03-06", "TRANSFER TO REVOLUT LTD", -50000),
|
||||
arrived,
|
||||
})
|
||||
if len(res.Pairs) != 1 {
|
||||
t.Fatalf("pairs = %+v, want the exchange paired", res.Pairs)
|
||||
}
|
||||
if fee := res.Pairs[0].Fee(); fee != 0 {
|
||||
t.Errorf("fee = %d, want 0 across currencies", fee)
|
||||
}
|
||||
}
|
||||
|
||||
// With slack in the amount two candidates can sit the same number of days
|
||||
// away, and then the nearer amount is the better evidence.
|
||||
func TestEqualGapPrefersTheNearerAmount(t *testing.T) {
|
||||
res := engine(tolerant).Analyze([]model.Transaction{
|
||||
txn(1, "nlb", "2026-03-06", "TRANSFER TO REVOLUT LTD", -50000),
|
||||
txn(2, "revolut", "2026-03-05", "Top-up from NLB", 49600),
|
||||
txn(3, "revolut", "2026-03-07", "Top-up from NLB", 50000),
|
||||
})
|
||||
if len(res.Pairs) != 1 {
|
||||
t.Fatalf("pairs = %+v, want one", res.Pairs)
|
||||
}
|
||||
if res.Pairs[0].In.ID != 3 {
|
||||
t.Errorf("paired with %d, want 3: same gap, exact amount", res.Pairs[0].In.ID)
|
||||
}
|
||||
}
|
||||
+1029
-195
File diff suppressed because it is too large
Load Diff
+1073
-109
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user