Pair transfers from rules.toml
The boolean transfer flag went two commits ago because a one-sided verdict let half a movement vanish and left the report unbalanced. This is what replaces it: a [[transfer]] block names both legs, and only a matched pair is dropped from the report -- both legs together, never one. Legs pair within five days, nearest date first, and a transaction belongs to at most one transfer, so the first definition to claim a leg keeps it, exactly as the first matching rule keeps a tag. The pairing is derived state like the tags: Engine.Link rewrites the whole transfers table from rules.toml, which is why retag re-derives both halves of what that file decides, and why it runs over the whole index rather than a filtered view -- pairing inside one would let a movement count as a transfer in one report and not in another. An unmatched leg is not a transfer and keeps counting, surfaced as a warning instead. Within one currency the amount is the evidence and must be the exact opposite. Across currencies it is not checked at all: there are no rates here, so the two numbers are unrelated and the dates carry the pairing alone. tolerance_pct is the one exception, per definition, for a route where the bank takes a fee and 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. The difference it admits is not forgiven -- the pair leaves the report entirely, so a fee hidden inside one would be spending that appears nowhere. Pair.Fee is what left less what arrived, and report.Excluded carries it out per currency alongside the legs. It counts only pairs whose legs are both in view, for the same reason it counts legs and not transfers: half a pair cannot say what the other half received. The screens: - 6 builds a definition against the index as you type, showing the pairs it would form and the legs it would catch but leave unpaired. Six fields need more room than the rule builder's four, so the form sheds its spacing, then its hints, then the borders on unfocused fields. - 7 lists every definition with what it pairs. Two counts, because they mean different things: an unpaired leg is a definition doing something and not finishing it, no pairs at all is dead weight. Tol names the tolerance, blank where amounts must agree. - 3 grows a (transfers) row under TOTAL, and a fees row beneath it, or the report silently disagrees with the account balances. Two things that are not part of transfers but are the same day's work: - ls --uniq lists each account and description once, normalised the way a glob sees them, which is the shape of "what still needs a rule?" -- fifty visits to one shop are one pattern to write, not fifty rows to read. - The rule builder's preview now filters to what the glob matches instead of marking matches in a full list. The count carries the context the rows no longer can: 2 of 7, measured against everything still in view. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -11,13 +11,14 @@ This file covers what the code assumes and why.
|
|||||||
|
|
||||||
```
|
```
|
||||||
cmd/money/main.go subcommands; the TUI is the default
|
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/model Account, Transaction, amount formatting, description normalisation
|
||||||
internal/glob the `*` / `?` matcher used by rules (two-pointer, no exponential blowup)
|
internal/glob the `*` / `?` matcher used by rules (two-pointer, no exponential blowup)
|
||||||
internal/parser Parser interface + registry; nlb, revolut, traderepublic
|
internal/parser Parser interface + registry; nlb, revolut, traderepublic
|
||||||
internal/store SQLite index (modernc.org/sqlite, no cgo)
|
internal/store SQLite index (modernc.org/sqlite, no cgo)
|
||||||
internal/importer directory walk, dedupe, balance checks
|
internal/importer directory walk, dedupe, balance checks
|
||||||
internal/rules applies ordered rules, writing rule_tag
|
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/report per-tag aggregation
|
||||||
internal/tui Bubble Tea models
|
internal/tui Bubble Tea models
|
||||||
```
|
```
|
||||||
@@ -30,6 +31,68 @@ 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
|
from anywhere else — the moment something does, the index stops being
|
||||||
reproducible and retag stops being safe. Covered by `TestRetagRewritesEveryTag`.
|
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
|
**Statements are the source of truth; the index is disposable.** Deleting
|
||||||
`index.db` at the root of the data directory and re-importing must reproduce
|
`index.db` at the root of the data directory and re-importing must reproduce
|
||||||
everything, with nothing lost.
|
everything, with nothing lost.
|
||||||
@@ -66,10 +129,11 @@ A rule setting both `match` and `type` requires both of them.
|
|||||||
`config.AppendRule` therefore appends — never prepends — so saving from the
|
`config.AppendRule` therefore appends — never prepends — so saving from the
|
||||||
rule builder cannot shadow a rule the user wrote by hand.
|
rule builder cannot shadow a rule the user wrote by hand.
|
||||||
|
|
||||||
**The rule builder is a form, so the global keymap must not apply there.**
|
**The builders are forms, so the global keymap must not apply there.**
|
||||||
`Update` routes to `updateRules` before `updateNormal` whenever the view is
|
`Update` routes to `updateRules` / `updateTransfers` before `updateNormal`
|
||||||
`viewRules`, or typing `q` would quit and `i` would start an import. Any new
|
whenever the view is `viewRules` or `viewTransfers`, or typing `q` would quit
|
||||||
full-screen input needs the same treatment.
|
and `i` would start an import. Any new full-screen input needs the same
|
||||||
|
treatment.
|
||||||
|
|
||||||
**In the rule builder, `tab` completes first and moves focus second.** The
|
**In the rule builder, `tab` completes first and moves focus second.** The
|
||||||
account and tag fields use `textinput.ShowSuggestions`, whose own `AcceptSuggestion`
|
account and tag fields use `textinput.ShowSuggestions`, whose own `AcceptSuggestion`
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ tagged by glob rules you write.
|
|||||||
|
|
||||||
```
|
```
|
||||||
~/money/ # the data root (see "Where the data root lives")
|
~/money/ # the data root (see "Where the data root lives")
|
||||||
rules.toml # tag rules, in order
|
rules.toml # tag rules and transfer definitions, in order
|
||||||
index.db # SQLite index (rebuildable; safe to delete*)
|
index.db # SQLite index (rebuildable; safe to delete*)
|
||||||
checking/
|
checking/
|
||||||
account.toml # currency + which parser reads this bank's exports
|
account.toml # currency + which parser reads this bank's exports
|
||||||
@@ -62,8 +62,9 @@ compiles only the files listed, which breaks the moment the package has two.
|
|||||||
money # open the TUI (default)
|
money # open the TUI (default)
|
||||||
money import # extract new transactions from every statement
|
money import # extract new transactions from every statement
|
||||||
money import --force # re-parse statements even if unchanged
|
money import --force # re-parse statements even if unchanged
|
||||||
money retag # re-apply rules.toml to everything already imported
|
money retag # re-apply rules.toml: retag, and re-pair transfers
|
||||||
money ls --untagged # what no rule has claimed yet
|
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 --wide # also show type and reported balance
|
||||||
money ls --account checking --month 2026-01
|
money ls --account checking --month 2026-01
|
||||||
money report --month 2026-01 # spending by tag
|
money report --month 2026-01 # spending by tag
|
||||||
@@ -76,14 +77,36 @@ 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
|
imported — creating an `account.toml` is not enough on its own. Run
|
||||||
`money import` (or press `i` in the TUI) after adding one.
|
`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 account and 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
|
||||||
|
ACCOUNT DESCRIPTION
|
||||||
|
checking KAUFLAND 4412 SOFIA
|
||||||
|
checking LIDL SOFIA 4412
|
||||||
|
savings INTEREST PAID
|
||||||
|
|
||||||
|
3 distinct descriptions
|
||||||
|
```
|
||||||
|
|
||||||
|
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`: a reported balance belongs to one transaction, and
|
||||||
|
there is nothing sensible to print for a whole group of them.
|
||||||
|
|
||||||
### TUI keys
|
### TUI keys
|
||||||
|
|
||||||
| Key | Action |
|
| 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) |
|
| `enter` | open the selected account (accounts view) |
|
||||||
| `/` | filter by description |
|
| `/` | 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 |
|
| `a` | clear the account filter |
|
||||||
| `i` | import · `r` re-apply rules · `q` quit |
|
| `i` | import · `r` re-apply rules · `q` quit |
|
||||||
|
|
||||||
@@ -94,18 +117,25 @@ nowhere else, so tagging what you are looking at means writing a rule for it on
|
|||||||
### Rule builder (`4`)
|
### Rule builder (`4`)
|
||||||
|
|
||||||
Writing rules by hand means guessing what a glob will catch. This screen shows
|
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
|
the answer as you type: the form is on the left, and on the right the
|
||||||
still-untagged description in the data, sorted alphabetically and grouped, with
|
still-untagged descriptions the glob currently matches, marked `▸`, grouped and
|
||||||
a `▸` against each one the glob currently matches and a running
|
sorted alphabetically, under a running "*n* of *m* descriptions match" count.
|
||||||
"*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
|
glob Untagged description N
|
||||||
╭────────────────────────────╮ ACME PAYROLL JAN 1
|
╭────────────────────────────╮ ▸ LIDL SOFIA 4412 2
|
||||||
│ *LIDL* │ BOLT RIDE 1
|
│ *LIDL* │ ▸ LIDL VARNA 9911 1
|
||||||
╰────────────────────────────╯ ▸ LIDL SOFIA 4412 2
|
╰────────────────────────────╯
|
||||||
vs. the description ▸ LIDL VARNA 9911 1
|
vs. the description
|
||||||
ZARA MLADOST 1
|
|
||||||
account
|
account
|
||||||
╭────────────────────────────╮
|
╭────────────────────────────╮
|
||||||
│ blank = every account │
|
│ blank = every account │
|
||||||
@@ -147,7 +177,9 @@ which is what stops `groceries` acquiring a `grocery` twin.
|
|||||||
Leaving the account blank applies the rule everywhere; filling it in also
|
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
|
narrows the preview to that account. Rules are appended, so anything already in
|
||||||
`rules.toml` keeps precedence — the preview accounts for that automatically,
|
`rules.toml` keeps precedence — the preview accounts for that automatically,
|
||||||
since it only ever lists transactions no existing rule has already tagged.
|
since it only ever lists transactions no existing rule has already tagged. Legs
|
||||||
|
of a matched transfer are left out too: they are already accounted for by the
|
||||||
|
definition that paired them, and the report leaves them out anyway.
|
||||||
|
|
||||||
### Rules (`5`)
|
### Rules (`5`)
|
||||||
|
|
||||||
@@ -180,6 +212,104 @@ 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
|
deleted rule goes with it, while one separated by a blank line is treated as a
|
||||||
section heading and left alone.
|
section heading and left alone.
|
||||||
|
|
||||||
|
### Transfer builder (`6`)
|
||||||
|
|
||||||
|
Same idea as the rule builder, for money moved between your own accounts. Both
|
||||||
|
sides are named, and the preview on the right shows what the pair would be:
|
||||||
|
`▸` for a movement it matches end to end, `⚠` for a leg it catches on one side
|
||||||
|
and cannot pair with anything on the other.
|
||||||
|
|
||||||
|
```
|
||||||
|
from account Date Amount Movement Description
|
||||||
|
╭────────────────────────────╮ ⚠ 2026-04-01 500.00 checking → ? TRANSFER TO SAVINGS
|
||||||
|
│ checking │ ▸ 2026-03-01 500.00 checking → savings TRANSFER TO SAVINGS
|
||||||
|
╰────────────────────────────╯
|
||||||
|
money leaves here
|
||||||
|
|
||||||
|
▸ from desc
|
||||||
|
╭────────────────────────────╮
|
||||||
|
│ *TO SAVINGS* │
|
||||||
|
╰────────────────────────────╯
|
||||||
|
glob vs. the leaving leg
|
||||||
|
|
||||||
|
to account
|
||||||
|
╭────────────────────────────╮
|
||||||
|
│ savings │
|
||||||
|
╰────────────────────────────╯
|
||||||
|
money arrives here
|
||||||
|
|
||||||
|
to desc
|
||||||
|
╭────────────────────────────╮
|
||||||
|
│ *FROM CHECKING* │
|
||||||
|
╰────────────────────────────╯
|
||||||
|
glob vs. the arriving leg
|
||||||
|
|
||||||
|
tolerance %
|
||||||
|
╭────────────────────────────╮
|
||||||
|
│ 0 │
|
||||||
|
╰────────────────────────────╯
|
||||||
|
0 = amounts must match exactly
|
||||||
|
|
||||||
|
note
|
||||||
|
╭────────────────────────────╮
|
||||||
|
│ optional │
|
||||||
|
╰────────────────────────────╯
|
||||||
|
why this transfer exists
|
||||||
|
|
||||||
|
1 pairs · 1 unpaired
|
||||||
|
```
|
||||||
|
|
||||||
|
The account fields complete exactly as the rule builder's do. `tab` / `↑↓` move
|
||||||
|
between fields, `pgup` / `pgdn` scroll the list, `enter` appends the definition
|
||||||
|
to `rules.toml` and re-pairs immediately, and `esc` goes back. Saving keeps both
|
||||||
|
account names in place and clears the two globs, since the next transfer you
|
||||||
|
write is usually the same route in the other direction. The tolerance is cleared
|
||||||
|
with them: carried over silently it would loosen a route that never asked for
|
||||||
|
one. Six fields need more room than the rule builder's four, so on a short
|
||||||
|
window this form gives up its spacing, then its hints, then the borders on the
|
||||||
|
fields you are not editing — every field stays on screen.
|
||||||
|
|
||||||
|
`tolerance %` is the one field worth previewing before you save. Type a
|
||||||
|
percentage and the pairs it buys appear immediately, showing both amounts —
|
||||||
|
`500.00 → 495.00` — with what they cost summarised beside the counts:
|
||||||
|
|
||||||
|
```
|
||||||
|
1 pairs · 0 unpaired · 5.00 in fees
|
||||||
|
```
|
||||||
|
|
||||||
|
A half-written definition previews too: fill in one side and its legs show up as
|
||||||
|
unpaired, which is the quickest way to see that a glob is wrong before you have
|
||||||
|
written the other half.
|
||||||
|
|
||||||
|
### Transfers (`7`)
|
||||||
|
|
||||||
|
Every definition in file order, with what it currently pairs.
|
||||||
|
|
||||||
|
```
|
||||||
|
money · transfers · 4 definitions · 1 leg(s) unpaired
|
||||||
|
|
||||||
|
# From To Pairs Unpaired Tol Note
|
||||||
|
1 ⚠ checking *TO SAVINGS* savings *FROM CHECKING* 11 1 monthly saving
|
||||||
|
2 checking *TO BROKER* traderepublic *FROM NLB* 4 0
|
||||||
|
3 checking *WIRE TO SAVINGS* savings *WIRE FROM CHECK* 2 0 1.5% the bank keeps a fee
|
||||||
|
4 ✗ checking *TO OLD BANK* savings *FROM OLD BANK* 0 0 closed in 2025
|
||||||
|
```
|
||||||
|
|
||||||
|
The two counts mean different things, which is the point of the screen. `⚠` is a
|
||||||
|
definition catching legs it cannot complete: a wrong glob on the other side, a
|
||||||
|
statement not imported yet, or a movement that genuinely went missing. `✗` is a
|
||||||
|
definition matching nothing at all, which is just dead weight.
|
||||||
|
|
||||||
|
`Tol` is the definition's `tolerance_pct`, and it qualifies the counts beside
|
||||||
|
it: row 3's two pairs were matched on slack rather than on the amount agreeing.
|
||||||
|
It is blank for every definition that requires the exact amount — which is the
|
||||||
|
default, so an empty column means nothing here is pairing on a mismatch.
|
||||||
|
|
||||||
|
`d` deletes the selected definition and `p` deletes every `✗` one at once, both
|
||||||
|
after a `y`. Definitions marked `⚠` are never pruned — they are doing something,
|
||||||
|
just not finishing it, and deleting one would hide the problem rather than fix
|
||||||
|
it. `r` re-pairs against what is currently in the index.
|
||||||
|
|
||||||
## rules.toml
|
## rules.toml
|
||||||
|
|
||||||
Rules are evaluated in file order and the **first match wins**, so put specific
|
Rules are evaluated in file order and the **first match wins**, so put specific
|
||||||
@@ -205,24 +335,6 @@ tag = "salary"
|
|||||||
match = "*ACME PAYROLL*"
|
match = "*ACME PAYROLL*"
|
||||||
note = "paid on the 4th; the December one lands early"
|
note = "paid on the 4th; the December one lands early"
|
||||||
|
|
||||||
# Money moved between your own accounts is tagged like anything else. Both
|
|
||||||
# legs need a rule, and both count in the report -- one as an outflow, the
|
|
||||||
# other as an inflow.
|
|
||||||
[[rule]]
|
|
||||||
match = "*TO SAVINGS*"
|
|
||||||
tag = "transfer"
|
|
||||||
|
|
||||||
[[rule]]
|
|
||||||
match = "*FROM CHECKING*"
|
|
||||||
tag = "transfer"
|
|
||||||
|
|
||||||
# Such movements are often only identifiable by the other side's account
|
|
||||||
# number, whatever the rest of the description happens to say. Every bank
|
|
||||||
# parser keeps that number in the description, so an ordinary glob finds it.
|
|
||||||
[[rule]]
|
|
||||||
match = "*SI56*"
|
|
||||||
tag = "transfer"
|
|
||||||
|
|
||||||
# A rule can be limited to one account, and can require several patterns.
|
# A rule can be limited to one account, and can require several patterns.
|
||||||
[[rule]]
|
[[rule]]
|
||||||
account = "checking"
|
account = "checking"
|
||||||
@@ -239,7 +351,138 @@ 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
|
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
|
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, and shows up under `money ls --untagged`, the `u` view, and
|
||||||
`(untagged)` in the report.
|
`(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
|
## account.toml
|
||||||
|
|
||||||
|
|||||||
+152
-33
@@ -8,7 +8,9 @@ package main
|
|||||||
import (
|
import (
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"os"
|
"os"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"text/tabwriter"
|
"text/tabwriter"
|
||||||
|
|
||||||
@@ -19,6 +21,7 @@ import (
|
|||||||
"git.petrovv.com/nikola/money/internal/report"
|
"git.petrovv.com/nikola/money/internal/report"
|
||||||
"git.petrovv.com/nikola/money/internal/rules"
|
"git.petrovv.com/nikola/money/internal/rules"
|
||||||
"git.petrovv.com/nikola/money/internal/store"
|
"git.petrovv.com/nikola/money/internal/store"
|
||||||
|
"git.petrovv.com/nikola/money/internal/transfers"
|
||||||
"git.petrovv.com/nikola/money/internal/tui"
|
"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]
|
usage: money [--root DIR] <command> [flags]
|
||||||
|
|
||||||
commands:
|
commands:
|
||||||
tui browse transactions, build and prune the rules that tag them (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
|
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
|
ls list transactions
|
||||||
report spending by tag
|
report spending by tag
|
||||||
accounts list accounts with balances
|
accounts list accounts with balances
|
||||||
@@ -137,24 +141,33 @@ func cmdConfig(root string, source config.RootSource) error {
|
|||||||
return nil
|
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.
|
// 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 {
|
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)
|
accounts, err := config.LoadAccounts(root)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
r, err := config.LoadRules(root)
|
r, err := config.LoadRules(root)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
db, err := store.Open(config.IndexPath(root))
|
db, err := store.Open(config.IndexPath(root))
|
||||||
if err != nil {
|
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 {
|
func cmdImport(root string, args []string) error {
|
||||||
@@ -164,17 +177,17 @@ func cmdImport(root string, args []string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
db, accounts, engine, err := open(root)
|
o, err := open(root)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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)
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -199,6 +212,7 @@ func cmdImport(root string, args []string) error {
|
|||||||
|
|
||||||
parsed, added, skipped := res.Total()
|
parsed, added, skipped := res.Total()
|
||||||
fmt.Printf("\n%d new, %d duplicate, %d parsed; %d rows retagged\n", added, skipped, parsed, res.Retagged)
|
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()
|
failures := res.Errs()
|
||||||
for _, f := range failures {
|
for _, f := range failures {
|
||||||
@@ -210,49 +224,88 @@ func cmdImport(root string, args []string) error {
|
|||||||
return nil
|
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 {
|
func cmdRetag(root string, _ []string) error {
|
||||||
db, _, engine, err := open(root)
|
o, err := open(root)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer db.Close()
|
defer o.db.Close()
|
||||||
|
|
||||||
n, err := engine.Retag(db)
|
n, err := o.engine.Retag(o.db)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
fmt.Printf("%d transactions retagged\n", n)
|
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
|
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 {
|
func cmdLs(root string, args []string) error {
|
||||||
fs := flag.NewFlagSet("ls", flag.ContinueOnError)
|
fs := flag.NewFlagSet("ls", flag.ContinueOnError)
|
||||||
account := fs.String("account", "", "only this account slug")
|
account := fs.String("account", "", "only this account slug")
|
||||||
month := fs.String("month", "", "only this month (YYYY-MM)")
|
month := fs.String("month", "", "only this month (YYYY-MM)")
|
||||||
search := fs.String("search", "", "only descriptions containing this text")
|
search := fs.String("search", "", "only descriptions containing this text")
|
||||||
untagged := fs.Bool("untagged", false, "only transactions no rule tagged")
|
untagged := fs.Bool("untagged", false, "only transactions no rule tagged and no transfer claimed")
|
||||||
limit := fs.Int("limit", 0, "maximum rows (0 = no limit)")
|
limit := fs.Int("limit", 0, "maximum rows (0 = no limit)")
|
||||||
wide := fs.Bool("wide", false, "also show type and reported balance")
|
wide := fs.Bool("wide", false, "also show type and reported balance")
|
||||||
|
uniq := fs.Bool("uniq", false, "one row per distinct account and description")
|
||||||
if err := fs.Parse(args); err != nil {
|
if err := fs.Parse(args); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if *uniq && *wide {
|
||||||
|
// Both add columns, but a reported balance belongs to one row 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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer db.Close()
|
defer o.db.Close()
|
||||||
|
|
||||||
txns, err := db.Transactions(store.Filter{
|
f := store.Filter{
|
||||||
AccountSlug: *account,
|
AccountSlug: *account,
|
||||||
Month: *month,
|
Month: *month,
|
||||||
Search: *search,
|
Search: *search,
|
||||||
Untagged: *untagged,
|
Untagged: *untagged,
|
||||||
Limit: *limit,
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if *uniq {
|
||||||
|
return printUniq(os.Stdout, txns, *limit)
|
||||||
|
}
|
||||||
|
|
||||||
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
||||||
if *wide {
|
if *wide {
|
||||||
@@ -268,17 +321,67 @@ func cmdLs(root string, args []string) error {
|
|||||||
}
|
}
|
||||||
fmt.Fprintf(w, "%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.Date, t.AccountSlug, t.FormatAmount(), t.Currency, balance,
|
||||||
t.RuleTag, t.Type, t.Description)
|
t.DisplayTag(), t.Type, t.Description)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
fmt.Fprintf(w, "%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\n",
|
||||||
t.Date, t.AccountSlug, t.FormatAmount(), t.Currency, t.RuleTag, t.Description)
|
t.Date, t.AccountSlug, t.FormatAmount(), t.Currency, t.DisplayTag(), t.Description)
|
||||||
}
|
}
|
||||||
w.Flush()
|
w.Flush()
|
||||||
fmt.Printf("\n%d transactions\n", len(txns))
|
fmt.Printf("\n%d transactions\n", len(txns))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// printUniq lists each account and 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.
|
||||||
|
//
|
||||||
|
// 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 {
|
||||||
|
type row struct{ account, desc string }
|
||||||
|
seen := map[row]bool{}
|
||||||
|
var rows []row
|
||||||
|
for _, t := range txns {
|
||||||
|
r := row{t.AccountSlug, model.NormalizeDescription(t.Description)}
|
||||||
|
if seen[r] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[r] = true
|
||||||
|
rows = append(rows, r)
|
||||||
|
}
|
||||||
|
// Grouped by account and alphabetical within it: the same payee under
|
||||||
|
// slightly different wordings then lands on adjacent lines, where one glob
|
||||||
|
// covering both is easy to see.
|
||||||
|
sort.Slice(rows, func(i, j int) bool {
|
||||||
|
if rows[i].account != rows[j].account {
|
||||||
|
return rows[i].account < rows[j].account
|
||||||
|
}
|
||||||
|
return rows[i].desc < rows[j].desc
|
||||||
|
})
|
||||||
|
|
||||||
|
total := len(rows)
|
||||||
|
if limit > 0 && len(rows) > limit {
|
||||||
|
rows = rows[:limit]
|
||||||
|
}
|
||||||
|
|
||||||
|
w := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0)
|
||||||
|
fmt.Fprintln(w, "ACCOUNT\tDESCRIPTION")
|
||||||
|
for _, r := range rows {
|
||||||
|
fmt.Fprintf(w, "%s\t%s\n", r.account, r.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 {
|
func cmdReport(root string, args []string) error {
|
||||||
fs := flag.NewFlagSet("report", flag.ContinueOnError)
|
fs := flag.NewFlagSet("report", flag.ContinueOnError)
|
||||||
month := fs.String("month", "", "only this month (YYYY-MM)")
|
month := fs.String("month", "", "only this month (YYYY-MM)")
|
||||||
@@ -287,13 +390,13 @@ func cmdReport(root string, args []string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
db, _, _, err := open(root)
|
o, err := open(root)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -312,28 +415,44 @@ func cmdReport(root string, args []string) error {
|
|||||||
model.FormatMinor(c.Net(), c.Digits))
|
model.FormatMinor(c.Net(), c.Digits))
|
||||||
}
|
}
|
||||||
w.Flush()
|
w.Flush()
|
||||||
|
|
||||||
|
// 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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func cmdAccounts(root string, _ []string) error {
|
func cmdAccounts(root string, _ []string) error {
|
||||||
db, _, _, err := open(root)
|
o, err := open(root)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer db.Close()
|
defer o.db.Close()
|
||||||
|
|
||||||
accounts, err := db.Accounts()
|
accounts, err := o.db.Accounts()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
||||||
fmt.Fprintln(w, "SLUG\tNAME\tBALANCE\tCUR\tTXNS")
|
fmt.Fprintln(w, "SLUG\tNAME\tBALANCE\tCUR\tTXNS")
|
||||||
for _, a := range accounts {
|
for _, a := range accounts {
|
||||||
bal, err := db.Balance(a.ID)
|
bal, err := o.db.Balance(a.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
n, err := db.Count(a.ID)
|
n, err := o.db.Count(a.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -344,10 +463,10 @@ func cmdAccounts(root string, _ []string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func cmdTUI(root string, _ []string) error {
|
func cmdTUI(root string, _ []string) error {
|
||||||
db, accounts, engine, err := open(root)
|
o, err := open(root)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer db.Close()
|
defer o.db.Close()
|
||||||
return tui.Run(root, db, accounts, engine)
|
return tui.Run(root, o.db, o.accounts, o.engine, o.links)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
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 same description on two accounts is two rows: a rule can be scoped to an
|
||||||
|
// account, and the two may well want different tags.
|
||||||
|
func TestUniqKeepsAccountsApart(t *testing.T) {
|
||||||
|
out := uniqOutput(t, 0,
|
||||||
|
txn("checking", "TRANSFER"),
|
||||||
|
txn("savings", "TRANSFER"),
|
||||||
|
)
|
||||||
|
if !strings.Contains(out, "2 distinct descriptions") {
|
||||||
|
t.Errorf("want a row per account:\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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+183
-32
@@ -39,9 +39,40 @@ type Rule struct {
|
|||||||
Note string `toml:"note"`
|
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 {
|
type Rules struct {
|
||||||
Rule []Rule `toml:"rule"`
|
Rule []Rule `toml:"rule"`
|
||||||
|
Transfer []Transfer `toml:"transfer"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// LoadRules reads rules.toml from the data root. A missing file is not an
|
// LoadRules reads rules.toml from the data root. A missing file is not an
|
||||||
@@ -63,9 +94,37 @@ func LoadRules(root string) (*Rules, error) {
|
|||||||
return nil, fmt.Errorf("%s: rule %d (%q) sets no tag", path, i+1, rule.Match)
|
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
|
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
|
// 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
|
// not there yet. Appending rather than inserting means an existing rule always
|
||||||
// keeps precedence, since the first match wins.
|
// keeps precedence, since the first match wins.
|
||||||
@@ -80,6 +139,30 @@ func AppendRule(root string, r Rule) error {
|
|||||||
return fmt.Errorf("a rule needs a tag")
|
return fmt.Errorf("a rule needs a tag")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return appendBlock(root, formatRule(r))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
path := filepath.Join(root, RulesFile)
|
||||||
existing, err := os.ReadFile(path)
|
existing, err := os.ReadFile(path)
|
||||||
if err != nil && !os.IsNotExist(err) {
|
if err != nil && !os.IsNotExist(err) {
|
||||||
@@ -92,20 +175,52 @@ func AppendRule(root string, r Rule) error {
|
|||||||
b.WriteString("\n")
|
b.WriteString("\n")
|
||||||
}
|
}
|
||||||
b.WriteString("\n")
|
b.WriteString("\n")
|
||||||
b.WriteString(formatRule(r))
|
b.WriteString(block)
|
||||||
|
|
||||||
return writeFileAtomic(root, path, b.String())
|
return writeFileAtomic(root, path, b.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeleteRules removes the rules at the given positions (0-based, as loaded by
|
// DeleteRules removes the rules at the given positions (0-based, as loaded by
|
||||||
// LoadRules) from rules.toml.
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// deleteBlocks removes entries of one table from rules.toml.
|
||||||
//
|
//
|
||||||
// The file is edited textually rather than re-serialised from the parsed
|
// The file is edited textually rather than re-serialised from the parsed
|
||||||
// rules, so comments, ordering and formatting the user put there by hand
|
// values, so comments, ordering and formatting the user put there by hand
|
||||||
// survive. A comment block sitting directly above a deleted rule goes with it,
|
// survive. A comment block sitting directly above a deleted entry goes with it,
|
||||||
// since it documents that rule; a comment separated by a blank line is treated
|
// since it documents that entry; a comment separated by a blank line is treated
|
||||||
// as a section heading and left alone.
|
// 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 {
|
if len(positions) == 0 {
|
||||||
return 0, nil
|
return 0, nil
|
||||||
}
|
}
|
||||||
@@ -121,23 +236,25 @@ func DeleteRules(root string, positions []int) (int, error) {
|
|||||||
}
|
}
|
||||||
lines := strings.Split(string(raw), "\n")
|
lines := strings.Split(string(raw), "\n")
|
||||||
|
|
||||||
// Where each [[rule]] block begins.
|
starts := blockStarts(lines)
|
||||||
var starts []int
|
// mine[p] is where the p-th entry of this table sits among all the blocks.
|
||||||
for i, line := range lines {
|
var mine []int
|
||||||
if strings.TrimSpace(line) == "[[rule]]" {
|
for k, s := range starts {
|
||||||
starts = append(starts, i)
|
if s.table == table {
|
||||||
|
mine = append(mine, k)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, p := range positions {
|
for _, p := range positions {
|
||||||
if p < 0 || p >= len(starts) {
|
if p < 0 || p >= len(mine) {
|
||||||
return 0, fmt.Errorf("rule %d is out of range; %s holds %d rules", p+1, path, len(starts))
|
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
|
// An entry owns the run of comment lines directly above it, with no blank
|
||||||
// line in between. Anything further up is a heading for what follows.
|
// line in between. Anything further up is a heading for what follows.
|
||||||
prefix := func(k int) int {
|
prefix := func(k int) int {
|
||||||
i := starts[k]
|
i := starts[k].line
|
||||||
for i > 0 && strings.HasPrefix(strings.TrimSpace(lines[i-1]), "#") {
|
for i > 0 && strings.HasPrefix(strings.TrimSpace(lines[i-1]), "#") {
|
||||||
i--
|
i--
|
||||||
}
|
}
|
||||||
@@ -145,12 +262,10 @@ func DeleteRules(root string, positions []int) (int, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
drop := map[int]bool{}
|
drop := map[int]bool{}
|
||||||
for k := range starts {
|
for p := range doomed {
|
||||||
if !doomed[k] {
|
k := mine[p]
|
||||||
continue
|
// The block runs up to the next block's comment prefix, so a comment
|
||||||
}
|
// introducing the following one is not swept up with this one.
|
||||||
// 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)
|
end := len(lines)
|
||||||
if k+1 < len(starts) {
|
if k+1 < len(starts) {
|
||||||
end = prefix(k + 1)
|
end = prefix(k + 1)
|
||||||
@@ -158,9 +273,9 @@ func DeleteRules(root string, positions []int) (int, error) {
|
|||||||
for i := prefix(k); i < end; i++ {
|
for i := prefix(k); i < end; i++ {
|
||||||
drop[i] = true
|
drop[i] = true
|
||||||
}
|
}
|
||||||
// Blank lines are the gap between rules, not part of either; leaving
|
// Blank lines are the gap between blocks, not part of either; leaving
|
||||||
// them avoids gluing the neighbours together.
|
// them avoids gluing the neighbours together.
|
||||||
for i := end - 1; i >= starts[k] && strings.TrimSpace(lines[i]) == ""; i-- {
|
for i := end - 1; i >= starts[k].line && strings.TrimSpace(lines[i]) == ""; i-- {
|
||||||
delete(drop, i)
|
delete(drop, i)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -173,14 +288,27 @@ func DeleteRules(root string, positions []int) (int, error) {
|
|||||||
}
|
}
|
||||||
out := collapseBlankRuns(kept)
|
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
|
var check Rules
|
||||||
if _, err := toml.Decode(out, &check); err != nil {
|
if _, err := toml.Decode(out, &check); err != nil {
|
||||||
return 0, fmt.Errorf("deleting from %s would produce invalid TOML: %w", path, err)
|
return 0, fmt.Errorf("deleting from %s would produce invalid TOML: %w", path, err)
|
||||||
}
|
}
|
||||||
if want := len(starts) - len(doomed); len(check.Rule) != want {
|
counts := map[string]int{"rule": len(check.Rule), "transfer": len(check.Transfer)}
|
||||||
return 0, fmt.Errorf("deleting from %s would leave %d rules, expected %d",
|
for _, kind := range []string{"rule", "transfer"} {
|
||||||
path, len(check.Rule), want)
|
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 {
|
if err := writeFileAtomic(root, path, out); err != nil {
|
||||||
@@ -238,15 +366,38 @@ func writeFileAtomic(dir, path, content string) error {
|
|||||||
return nil
|
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.
|
// formatRule renders a rule as a TOML table, omitting empty fields.
|
||||||
func formatRule(r Rule) string {
|
func formatRule(r Rule) string {
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
b.WriteString("[[rule]]\n")
|
b.WriteString("[[rule]]\n")
|
||||||
write := func(key, value string) {
|
write := func(key, value string) { writeKey(&b, key, value) }
|
||||||
if value != "" {
|
|
||||||
fmt.Fprintf(&b, "%-12s = %s\n", key, strconv.Quote(value))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
write("match", r.Match)
|
write("match", r.Match)
|
||||||
write("type", r.Type)
|
write("type", r.Type)
|
||||||
write("account", r.Account)
|
write("account", r.Account)
|
||||||
|
|||||||
@@ -455,3 +455,221 @@ func TestLoadUserConfigMalformed(t *testing.T) {
|
|||||||
t.Error("expected an error for a malformed config file")
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import (
|
|||||||
"git.petrovv.com/nikola/money/internal/parser"
|
"git.petrovv.com/nikola/money/internal/parser"
|
||||||
"git.petrovv.com/nikola/money/internal/rules"
|
"git.petrovv.com/nikola/money/internal/rules"
|
||||||
"git.petrovv.com/nikola/money/internal/store"
|
"git.petrovv.com/nikola/money/internal/store"
|
||||||
|
"git.petrovv.com/nikola/money/internal/transfers"
|
||||||
)
|
)
|
||||||
|
|
||||||
// FileResult is what happened to one statement file.
|
// FileResult is what happened to one statement file.
|
||||||
@@ -38,6 +39,11 @@ type FileResult struct {
|
|||||||
type Result struct {
|
type Result struct {
|
||||||
Files []FileResult
|
Files []FileResult
|
||||||
Retagged int
|
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.
|
// Total counts new rows across every file.
|
||||||
@@ -69,8 +75,12 @@ type Options struct {
|
|||||||
Force bool
|
Force bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run imports every account under root into db and then applies the rules.
|
// Run imports every account under root into db and then applies the rules and
|
||||||
func Run(root string, db *store.DB, accounts []*config.Account, engine *rules.Engine, opts Options) (Result, error) {
|
// 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
|
var res Result
|
||||||
for _, acc := range accounts {
|
for _, acc := range accounts {
|
||||||
accountID, err := db.UpsertAccount(model.Account{
|
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
|
return res, err
|
||||||
}
|
}
|
||||||
res.Retagged = n
|
res.Retagged = n
|
||||||
|
|
||||||
|
paired, unpaired, err := links.Link(db)
|
||||||
|
if err != nil {
|
||||||
|
return res, err
|
||||||
|
}
|
||||||
|
res.Paired, res.Unpaired = paired, unpaired
|
||||||
return res, nil
|
return res, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"git.petrovv.com/nikola/money/internal/parser"
|
"git.petrovv.com/nikola/money/internal/parser"
|
||||||
"git.petrovv.com/nikola/money/internal/rules"
|
"git.petrovv.com/nikola/money/internal/rules"
|
||||||
"git.petrovv.com/nikola/money/internal/store"
|
"git.petrovv.com/nikola/money/internal/store"
|
||||||
|
"git.petrovv.com/nikola/money/internal/transfers"
|
||||||
)
|
)
|
||||||
|
|
||||||
const accountTOML = `
|
const accountTOML = `
|
||||||
@@ -96,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 {
|
func mustRun(t *testing.T, root string, db *store.DB, accounts []*config.Account, e *rules.Engine, opts Options) Result {
|
||||||
t.Helper()
|
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 {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -190,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 {
|
if err != nil {
|
||||||
t.Fatalf("Run returned a fatal error, want a per-file report: %v", err)
|
t.Fatalf("Run returned a fatal error, want a per-file report: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,6 +42,39 @@ type Transaction struct {
|
|||||||
// RuleTag is the category, decided entirely by rules.toml. It is derived
|
// RuleTag is the category, decided entirely by rules.toml. It is derived
|
||||||
// state: `money retag` rewrites it wholesale.
|
// state: `money retag` rewrites it wholesale.
|
||||||
RuleTag string
|
RuleTag string
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
if t.IsTransferLeg() {
|
||||||
|
return TransferTag
|
||||||
|
}
|
||||||
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// FormatAmount renders the amount using the account's minor-unit scale.
|
// FormatAmount renders the amount using the account's minor-unit scale.
|
||||||
|
|||||||
@@ -25,11 +25,25 @@ func (t TagTotal) Net() int64 { return t.In - t.Out }
|
|||||||
const Untagged = "(untagged)"
|
const Untagged = "(untagged)"
|
||||||
|
|
||||||
// ByTag returns totals sorted by currency, then by largest outflow first.
|
// 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 {
|
func ByTag(txns []model.Transaction) []TagTotal {
|
||||||
type key struct{ currency, tag string }
|
type key struct{ currency, tag string }
|
||||||
acc := map[key]*TagTotal{}
|
acc := map[key]*TagTotal{}
|
||||||
|
|
||||||
for _, t := range txns {
|
for _, t := range txns {
|
||||||
|
if t.IsTransferLeg() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
tag := t.RuleTag
|
tag := t.RuleTag
|
||||||
if tag == "" {
|
if tag == "" {
|
||||||
tag = Untagged
|
tag = Untagged
|
||||||
@@ -96,6 +110,87 @@ func Totals(rows []TagTotal) []CurrencyTotal {
|
|||||||
return out
|
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.
|
// Months lists the distinct YYYY-MM present in txns, most recent first.
|
||||||
func Months(txns []model.Transaction) []string {
|
func Months(txns []model.Transaction) []string {
|
||||||
seen := map[string]bool{}
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+63
-6
@@ -1,6 +1,6 @@
|
|||||||
// Package store is the SQLite index over the statements. It is entirely
|
// Package store is the SQLite index over the statements. It is entirely
|
||||||
// rebuildable: delete index.db and re-import to get it back, except for the
|
// rebuildable: delete index.db and re-import to get it back. Nothing lives
|
||||||
// tags set by hand, which live only here.
|
// only here.
|
||||||
package store
|
package store
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -56,6 +56,16 @@ CREATE TABLE IF NOT EXISTS transactions (
|
|||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_txn_date ON transactions(date);
|
CREATE INDEX IF NOT EXISTS idx_txn_date ON transactions(date);
|
||||||
CREATE INDEX IF NOT EXISTS idx_txn_account ON transactions(account_id);
|
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)
|
||||||
|
);
|
||||||
`
|
`
|
||||||
|
|
||||||
// Open opens (creating if needed) the index at path.
|
// Open opens (creating if needed) the index at path.
|
||||||
@@ -186,7 +196,12 @@ func (d *DB) InsertTransaction(t model.Transaction) (bool, error) {
|
|||||||
// Filter narrows a transaction query.
|
// Filter narrows a transaction query.
|
||||||
type Filter struct {
|
type Filter struct {
|
||||||
AccountSlug string
|
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
|
Month string // YYYY-MM
|
||||||
Search string // case-insensitive substring of the description
|
Search string // case-insensitive substring of the description
|
||||||
Limit int
|
Limit int
|
||||||
@@ -198,10 +213,11 @@ func (d *DB) Transactions(f Filter) ([]model.Transaction, error) {
|
|||||||
SELECT t.id, t.account_id, a.slug, a.currency, a.minor_digits,
|
SELECT t.id, t.account_id, a.slug, a.currency, a.minor_digits,
|
||||||
t.fingerprint, t.date, t.description, t.amount_minor,
|
t.fingerprint, t.date, t.description, t.amount_minor,
|
||||||
COALESCE(s.path, ''), t.type, t.balance_minor,
|
COALESCE(s.path, ''), t.type, t.balance_minor,
|
||||||
COALESCE(t.rule_tag, '')
|
COALESCE(t.rule_tag, ''), x.id
|
||||||
FROM transactions t
|
FROM transactions t
|
||||||
JOIN accounts a ON a.id = t.account_id
|
JOIN accounts a ON a.id = t.account_id
|
||||||
LEFT JOIN source_files s ON s.id = t.source_file_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`
|
WHERE 1 = 1`
|
||||||
var args []any
|
var args []any
|
||||||
if f.AccountSlug != "" {
|
if f.AccountSlug != "" {
|
||||||
@@ -209,7 +225,7 @@ func (d *DB) Transactions(f Filter) ([]model.Transaction, error) {
|
|||||||
args = append(args, f.AccountSlug)
|
args = append(args, f.AccountSlug)
|
||||||
}
|
}
|
||||||
if f.Untagged {
|
if f.Untagged {
|
||||||
q += ` AND NULLIF(t.rule_tag, '') IS NULL`
|
q += ` AND NULLIF(t.rule_tag, '') IS NULL AND x.id IS NULL`
|
||||||
}
|
}
|
||||||
if f.Month != "" {
|
if f.Month != "" {
|
||||||
q += ` AND substr(t.date, 1, 7) = ?`
|
q += ` AND substr(t.date, 1, 7) = ?`
|
||||||
@@ -231,16 +247,21 @@ func (d *DB) Transactions(f Filter) ([]model.Transaction, error) {
|
|||||||
var (
|
var (
|
||||||
t model.Transaction
|
t model.Transaction
|
||||||
balance sql.NullInt64
|
balance sql.NullInt64
|
||||||
|
transfer sql.NullInt64
|
||||||
)
|
)
|
||||||
if err := rows.Scan(&t.ID, &t.AccountID, &t.AccountSlug, &t.Currency, &t.MinorDigits,
|
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.Fingerprint, &t.Date, &t.Description, &t.AmountMinor, &t.SourcePath,
|
||||||
&t.Type, &balance, &t.RuleTag); err != nil {
|
&t.Type, &balance, &t.RuleTag, &transfer); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if balance.Valid {
|
if balance.Valid {
|
||||||
v := balance.Int64
|
v := balance.Int64
|
||||||
t.BalanceMinor = &v
|
t.BalanceMinor = &v
|
||||||
}
|
}
|
||||||
|
if transfer.Valid {
|
||||||
|
v := transfer.Int64
|
||||||
|
t.TransferID = &v
|
||||||
|
}
|
||||||
if needle != "" && !strings.Contains(model.NormalizeDescription(t.Description), needle) {
|
if needle != "" && !strings.Contains(model.NormalizeDescription(t.Description), needle) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -282,6 +303,42 @@ func (d *DB) ApplyRuleResults(rs []RuleAssignment) error {
|
|||||||
return tx.Commit()
|
return tx.Commit()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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.
|
// Balance sums every transaction in an account.
|
||||||
func (d *DB) Balance(accountID int64) (int64, error) {
|
func (d *DB) Balance(accountID int64) (int64, error) {
|
||||||
var v sql.NullInt64
|
var v sql.NullInt64
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+751
-52
File diff suppressed because it is too large
Load Diff
+772
-5
@@ -18,6 +18,7 @@ import (
|
|||||||
"git.petrovv.com/nikola/money/internal/report"
|
"git.petrovv.com/nikola/money/internal/report"
|
||||||
"git.petrovv.com/nikola/money/internal/rules"
|
"git.petrovv.com/nikola/money/internal/rules"
|
||||||
"git.petrovv.com/nikola/money/internal/store"
|
"git.petrovv.com/nikola/money/internal/store"
|
||||||
|
"git.petrovv.com/nikola/money/internal/transfers"
|
||||||
)
|
)
|
||||||
|
|
||||||
// newTestModel builds a model over an index holding two transactions, one of
|
// newTestModel builds a model over an index holding two transactions, one of
|
||||||
@@ -59,7 +60,7 @@ func newTestModel(t *testing.T) (*Model, *store.DB) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
m := New(t.TempDir(), db, nil, engine)
|
m := New(t.TempDir(), db, nil, engine, transfers.New(&config.Rules{}))
|
||||||
if err := m.reload(); err != nil {
|
if err := m.reload(); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -363,7 +364,7 @@ func newRuleModel(t *testing.T) (*Model, *store.DB, string) {
|
|||||||
m := New(root, db, []*config.Account{
|
m := New(root, db, []*config.Account{
|
||||||
{Slug: "checking", Currency: "EUR", Parser: "revolut"},
|
{Slug: "checking", Currency: "EUR", Parser: "revolut"},
|
||||||
{Slug: "savings", Currency: "EUR", Parser: "revolut"},
|
{Slug: "savings", Currency: "EUR", Parser: "revolut"},
|
||||||
}, rules.New(&config.Rules{}))
|
}, rules.New(&config.Rules{}), transfers.New(&config.Rules{}))
|
||||||
if err := m.reload(); err != nil {
|
if err := m.reload(); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -670,7 +671,7 @@ func TestRuleBuilderCompletesTag(t *testing.T) {
|
|||||||
// other candidates there are.
|
// other candidates there are.
|
||||||
m.ruleTag.SetValue("gro")
|
m.ruleTag.SetValue("gro")
|
||||||
m.ruleTag.SetSuggestions(m.ruleTag.AvailableSuggestions())
|
m.ruleTag.SetSuggestions(m.ruleTag.AvailableSuggestions())
|
||||||
if hint := m.completionHint(2, "applied to matches"); !strings.Contains(hint, "tab") ||
|
if hint := completionHint(&m.ruleTag, true, "applied to matches"); !strings.Contains(hint, "tab") ||
|
||||||
!strings.Contains(hint, "1 more") {
|
!strings.Contains(hint, "1 more") {
|
||||||
t.Errorf("hint = %q, want it to name tab and the remaining candidate", hint)
|
t.Errorf("hint = %q, want it to name tab and the remaining candidate", hint)
|
||||||
}
|
}
|
||||||
@@ -994,6 +995,7 @@ func TestHelpLinesMentionEveryScreen(t *testing.T) {
|
|||||||
{"transactions", "2", []string{"4 new rule", "5 rules", "q quit"}},
|
{"transactions", "2", []string{"4 new rule", "5 rules", "q quit"}},
|
||||||
{"report", "3", []string{"4 new rule", "5 rules", "q quit"}},
|
{"report", "3", []string{"4 new rule", "5 rules", "q quit"}},
|
||||||
{"rules", "5", []string{"d delete rule", "p prune all unused", "4 new rule", "q quit"}},
|
{"rules", "5", []string{"d delete rule", "p prune all unused", "4 new rule", "q quit"}},
|
||||||
|
{"transfers", "7", []string{"d delete transfer", "r refresh pairing", "6 new transfer", "q quit"}},
|
||||||
} {
|
} {
|
||||||
key(t, m, tc.key)
|
key(t, m, tc.key)
|
||||||
help := m.help()
|
help := m.help()
|
||||||
@@ -1004,11 +1006,16 @@ func TestHelpLinesMentionEveryScreen(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The rule builder is a form, so it advertises its own keys instead.
|
// The builders are forms, so they advertise their own keys instead.
|
||||||
key(t, m, "4")
|
key(t, m, "4")
|
||||||
if help := m.help(); !strings.Contains(help, "enter save rule") || !strings.Contains(help, "esc back") {
|
if help := m.help(); !strings.Contains(help, "enter save rule") || !strings.Contains(help, "esc back") {
|
||||||
t.Errorf("rule builder help = %q", help)
|
t.Errorf("rule builder help = %q", help)
|
||||||
}
|
}
|
||||||
|
key(t, m, "esc")
|
||||||
|
key(t, m, "6")
|
||||||
|
if help := m.help(); !strings.Contains(help, "enter save transfer") || !strings.Contains(help, "esc back") {
|
||||||
|
t.Errorf("transfer builder help = %q", help)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// A help line longer than the window must wrap, not be cut off.
|
// A help line longer than the window must wrap, not be cut off.
|
||||||
@@ -1037,7 +1044,7 @@ func newEmptyModel(t *testing.T, accounts []*config.Account) *Model {
|
|||||||
}
|
}
|
||||||
t.Cleanup(func() { db.Close() })
|
t.Cleanup(func() { db.Close() })
|
||||||
|
|
||||||
m := New("/data/root", db, accounts, rules.New(&config.Rules{}))
|
m := New("/data/root", db, accounts, rules.New(&config.Rules{}), transfers.New(&config.Rules{}))
|
||||||
if err := m.reload(); err != nil {
|
if err := m.reload(); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -1145,3 +1152,763 @@ func findTxn(t *testing.T, db *store.DB, desc string) model.Transaction {
|
|||||||
t.Fatalf("no transaction with description %q", desc)
|
t.Fatalf("no transaction with description %q", desc)
|
||||||
return model.Transaction{}
|
return model.Transaction{}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// newTransferModel builds a model over a real data root holding one complete
|
||||||
|
// movement between two accounts, one leg whose counterpart never arrived, and
|
||||||
|
// ordinary spending that is neither.
|
||||||
|
func newTransferModel(t *testing.T) (*Model, *store.DB, string) {
|
||||||
|
t.Helper()
|
||||||
|
root := t.TempDir()
|
||||||
|
|
||||||
|
db, err := store.Open(config.IndexPath(root))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { db.Close() })
|
||||||
|
|
||||||
|
checking, err := db.UpsertAccount(model.Account{
|
||||||
|
Slug: "checking", Name: "Checking", Currency: "EUR", MinorDigits: 2,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
savings, err := db.UpsertAccount(model.Account{
|
||||||
|
Slug: "savings", Name: "Savings", Currency: "EUR", MinorDigits: 2,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
sourceID, err := db.SourceFile(checking, "checking/st.csv", "sha", "2026-01-01T00:00:00Z")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
seed := []struct {
|
||||||
|
account int64
|
||||||
|
date string
|
||||||
|
desc string
|
||||||
|
amount int64
|
||||||
|
}{
|
||||||
|
{checking, "2026-03-01", "TRANSFER TO SAVINGS", -50000},
|
||||||
|
{savings, "2026-03-02", "TRANSFER FROM CHECKING", 50000},
|
||||||
|
{checking, "2026-04-01", "TRANSFER TO SAVINGS", -50000}, // never arrived
|
||||||
|
{checking, "2026-03-05", "LIDL SOFIA 4412", -2000},
|
||||||
|
}
|
||||||
|
for i, s := range seed {
|
||||||
|
if _, err := db.InsertTransaction(model.Transaction{
|
||||||
|
AccountID: s.account,
|
||||||
|
SourceFileID: sourceID,
|
||||||
|
Fingerprint: fmt.Sprintf("xfer-%d", i),
|
||||||
|
Date: s.date,
|
||||||
|
Description: s.desc,
|
||||||
|
AmountMinor: s.amount,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
m := New(root, db, []*config.Account{
|
||||||
|
{Slug: "checking", Currency: "EUR", Parser: "revolut"},
|
||||||
|
{Slug: "savings", Currency: "EUR", Parser: "revolut"},
|
||||||
|
}, rules.New(&config.Rules{}), transfers.New(&config.Rules{}))
|
||||||
|
if err := m.reload(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m.Update(tea.WindowSizeMsg{Width: 140, Height: 40})
|
||||||
|
return m, db, root
|
||||||
|
}
|
||||||
|
|
||||||
|
// fillTransfer types a whole definition into the builder's fields.
|
||||||
|
func fillTransfer(m *Model, from, fromDesc, to, toDesc string) {
|
||||||
|
m.transferFrom.SetValue(from)
|
||||||
|
m.transferFromDesc.SetValue(fromDesc)
|
||||||
|
m.transferTo.SetValue(to)
|
||||||
|
m.transferToDesc.SetValue(toDesc)
|
||||||
|
m.refreshTransferPreview()
|
||||||
|
}
|
||||||
|
|
||||||
|
// The preview answers the question the form is asking: what would this pair,
|
||||||
|
// and what would it catch and fail to pair?
|
||||||
|
func TestTransferBuilderPreviewsPairsAndOrphans(t *testing.T) {
|
||||||
|
m, _, _ := newTransferModel(t)
|
||||||
|
key(t, m, "6")
|
||||||
|
|
||||||
|
if m.view != viewTransfers {
|
||||||
|
t.Fatal("expected 6 to open the transfer builder")
|
||||||
|
}
|
||||||
|
|
||||||
|
fillTransfer(m, "checking", "*TO SAVINGS*", "savings", "*FROM CHECKING*")
|
||||||
|
if m.previewPairs != 1 || m.previewUnmatched != 1 {
|
||||||
|
t.Fatalf("preview = %d pairs, %d unpaired; want 1 and 1", m.previewPairs, m.previewUnmatched)
|
||||||
|
}
|
||||||
|
|
||||||
|
var paired, orphaned int
|
||||||
|
for _, row := range m.transferTable.Rows() {
|
||||||
|
switch row[0] {
|
||||||
|
case "▸":
|
||||||
|
paired++
|
||||||
|
if row[3] != "checking → savings" {
|
||||||
|
t.Errorf("movement = %q, want the direction spelled out", row[3])
|
||||||
|
}
|
||||||
|
case "⚠":
|
||||||
|
orphaned++
|
||||||
|
if row[1] != "2026-04-01" {
|
||||||
|
t.Errorf("unpaired row = %v, want the April leg", row)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if paired != 1 || orphaned != 1 {
|
||||||
|
t.Errorf("rows = %d paired, %d unpaired; want 1 and 1", paired, orphaned)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A glob that catches nothing pairs nothing, rather than pairing loosely.
|
||||||
|
fillTransfer(m, "checking", "*NOTHING LIKE THIS*", "savings", "*FROM CHECKING*")
|
||||||
|
if m.previewPairs != 0 {
|
||||||
|
t.Errorf("pairs = %d, want none", m.previewPairs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Saving writes the definition and pairs immediately, and the report stops
|
||||||
|
// counting the pair as spending.
|
||||||
|
func TestTransferBuilderSavesAndPairs(t *testing.T) {
|
||||||
|
m, db, root := newTransferModel(t)
|
||||||
|
key(t, m, "6")
|
||||||
|
|
||||||
|
fillTransfer(m, "checking", "*TO SAVINGS*", "savings", "*FROM CHECKING*")
|
||||||
|
m.setTransferFocus(4)
|
||||||
|
m.transferNote.SetValue("monthly saving")
|
||||||
|
key(t, m, "enter")
|
||||||
|
|
||||||
|
loaded, err := config.LoadRules(root)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(loaded.Transfer) != 1 {
|
||||||
|
t.Fatalf("rules.toml holds %+v", loaded.Transfer)
|
||||||
|
}
|
||||||
|
if got := loaded.Transfer[0]; got.FromAccount != "checking" || got.ToDesc != "*FROM CHECKING*" ||
|
||||||
|
got.Note != "monthly saving" {
|
||||||
|
t.Errorf("saved transfer = %+v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
txns, err := db.Transactions(store.Filter{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
legs := 0
|
||||||
|
for _, txn := range txns {
|
||||||
|
if txn.IsTransferLeg() {
|
||||||
|
legs++
|
||||||
|
}
|
||||||
|
// The April leg never arrived, so it is not part of a transfer.
|
||||||
|
if txn.Date == "2026-04-01" && txn.IsTransferLeg() {
|
||||||
|
t.Error("an unpaired leg must not be recorded as a transfer")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if legs != 2 {
|
||||||
|
t.Errorf("%d legs paired in the index, want 2", legs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The paired movement is out of the report; the unpaired leg is not.
|
||||||
|
var out int64
|
||||||
|
for _, r := range report.ByTag(txns) {
|
||||||
|
out += r.Out
|
||||||
|
}
|
||||||
|
if want := int64(52000); out != want {
|
||||||
|
t.Errorf("outflow in the report = %d, want %d: the pair excluded, the orphan kept", out, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The two accounts were kept for the next definition, the globs cleared.
|
||||||
|
if m.transferFrom.Value() != "checking" || m.transferFromDesc.Value() != "" {
|
||||||
|
t.Errorf("after saving: from = %q, from_desc = %q",
|
||||||
|
m.transferFrom.Value(), m.transferFromDesc.Value())
|
||||||
|
}
|
||||||
|
if !strings.Contains(m.status, "1 leg(s) unpaired") {
|
||||||
|
t.Errorf("status = %q, want the unpaired leg reported", m.status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A half-written definition is refused, with the missing side named.
|
||||||
|
func TestTransferBuilderRejectsIncomplete(t *testing.T) {
|
||||||
|
m, _, root := newTransferModel(t)
|
||||||
|
key(t, m, "6")
|
||||||
|
|
||||||
|
m.transferFrom.SetValue("checking")
|
||||||
|
m.transferFromDesc.SetValue("*TO SAVINGS*")
|
||||||
|
m.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
||||||
|
if m.err == nil {
|
||||||
|
t.Fatal("expected saving half a transfer to fail")
|
||||||
|
}
|
||||||
|
if !strings.Contains(m.err.Error(), "arrives") {
|
||||||
|
t.Errorf("error = %v, want it to name the missing side", m.err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// An unknown account is refused too, before anything is written.
|
||||||
|
m.err = nil
|
||||||
|
fillTransfer(m, "checking", "*TO SAVINGS*", "nosuchaccount", "*FROM CHECKING*")
|
||||||
|
m.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
||||||
|
if m.err == nil || !strings.Contains(m.err.Error(), "nosuchaccount") {
|
||||||
|
t.Errorf("error = %v, want the unknown account named", m.err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(root, config.RulesFile)); !os.IsNotExist(err) {
|
||||||
|
t.Error("a rejected transfer must not write rules.toml")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The builder is a form, so single-letter global keys are ordinary text.
|
||||||
|
func TestTransferBuilderSwallowsGlobalKeys(t *testing.T) {
|
||||||
|
m, _, _ := newTransferModel(t)
|
||||||
|
key(t, m, "6")
|
||||||
|
|
||||||
|
typeText(t, m, "q1i")
|
||||||
|
if m.view != viewTransfers {
|
||||||
|
t.Fatal("typing must not switch views")
|
||||||
|
}
|
||||||
|
if got := m.transferFrom.Value(); got != "q1i" {
|
||||||
|
t.Errorf("from account = %q, want the typed characters", got)
|
||||||
|
}
|
||||||
|
if m.importing {
|
||||||
|
t.Error("typing i must not start an import")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The list is the screen that finds the problems: what pairs, what does not,
|
||||||
|
// and what matches nothing at all.
|
||||||
|
func TestTransferListShowsPairingAndUnpaired(t *testing.T) {
|
||||||
|
m, _, root := newTransferModel(t)
|
||||||
|
if err := config.AppendTransfer(root, config.Transfer{
|
||||||
|
FromAccount: "checking", FromDesc: "*TO SAVINGS*",
|
||||||
|
ToAccount: "savings", ToDesc: "*FROM CHECKING*",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := config.AppendTransfer(root, config.Transfer{
|
||||||
|
FromAccount: "checking", FromDesc: "*TO NOWHERE*",
|
||||||
|
ToAccount: "savings", ToDesc: "*FROM NOWHERE*", Note: "dead",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := m.reloadConfig(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
key(t, m, "7")
|
||||||
|
if m.view != viewTransferList {
|
||||||
|
t.Fatal("expected 7 to open the transfer list")
|
||||||
|
}
|
||||||
|
|
||||||
|
rows := m.transferListTable.Rows()
|
||||||
|
if len(rows) != 2 {
|
||||||
|
t.Fatalf("rows = %v, want one per definition", rows)
|
||||||
|
}
|
||||||
|
if rows[0][1] != "⚠" || rows[0][4] != "1" || rows[0][5] != "1" {
|
||||||
|
t.Errorf("first row = %v, want 1 pair, 1 unpaired and a warning marker", rows[0])
|
||||||
|
}
|
||||||
|
if rows[1][1] != "✗" || rows[1][4] != "0" || rows[1][5] != "0" {
|
||||||
|
t.Errorf("second row = %v, want it marked as matching nothing", rows[1])
|
||||||
|
}
|
||||||
|
if !strings.Contains(m.title(), "1 leg(s) unpaired") {
|
||||||
|
t.Errorf("title = %q, want the unpaired leg counted", m.title())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pruning takes the definition that matches nothing, and leaves the one
|
||||||
|
// that is merely unpaired: that one is doing something.
|
||||||
|
if got := m.unusedTransfers(); len(got) != 1 || got[0] != 1 {
|
||||||
|
t.Fatalf("unused = %v, want only the dead definition", got)
|
||||||
|
}
|
||||||
|
key(t, m, "p")
|
||||||
|
if m.confirm != confirmPruneTransfers {
|
||||||
|
t.Fatal("prune must ask first")
|
||||||
|
}
|
||||||
|
key(t, m, "y")
|
||||||
|
|
||||||
|
loaded, err := config.LoadRules(root)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(loaded.Transfer) != 1 || loaded.Transfer[0].FromDesc != "*TO SAVINGS*" {
|
||||||
|
t.Errorf("transfers = %+v, want the working one kept", loaded.Transfer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deleting a definition takes its pairing with it, so the legs count again.
|
||||||
|
func TestTransferListDeleteUnpairs(t *testing.T) {
|
||||||
|
m, db, root := newTransferModel(t)
|
||||||
|
key(t, m, "6")
|
||||||
|
fillTransfer(m, "checking", "*TO SAVINGS*", "savings", "*FROM CHECKING*")
|
||||||
|
key(t, m, "enter")
|
||||||
|
|
||||||
|
// The builder is a form, so leaving it takes esc rather than a view key.
|
||||||
|
key(t, m, "esc")
|
||||||
|
key(t, m, "7")
|
||||||
|
key(t, m, "d")
|
||||||
|
if m.confirm != confirmDeleteTransfer {
|
||||||
|
t.Fatal("delete must ask first")
|
||||||
|
}
|
||||||
|
if !strings.Contains(m.status, "checking → savings") {
|
||||||
|
t.Errorf("prompt = %q, want it to name the transfer", m.status)
|
||||||
|
}
|
||||||
|
key(t, m, "y")
|
||||||
|
|
||||||
|
loaded, err := config.LoadRules(root)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(loaded.Transfer) != 0 {
|
||||||
|
t.Fatalf("transfers = %+v, want it gone", loaded.Transfer)
|
||||||
|
}
|
||||||
|
|
||||||
|
txns, err := db.Transactions(store.Filter{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, txn := range txns {
|
||||||
|
if txn.IsTransferLeg() {
|
||||||
|
t.Fatalf("%s is still recorded as a transfer leg", txn.Description)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retagging re-derives both halves of what rules.toml decides.
|
||||||
|
func TestRetagAlsoRepairsThePairing(t *testing.T) {
|
||||||
|
m, db, root := newTransferModel(t)
|
||||||
|
if err := config.AppendTransfer(root, config.Transfer{
|
||||||
|
FromAccount: "checking", FromDesc: "*TO SAVINGS*",
|
||||||
|
ToAccount: "savings", ToDesc: "*FROM CHECKING*",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := m.reloadConfig(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
key(t, m, "r")
|
||||||
|
if !strings.Contains(m.status, "1 transfers matched") {
|
||||||
|
t.Errorf("status = %q, want the pairing reported", m.status)
|
||||||
|
}
|
||||||
|
txns, err := db.Transactions(store.Filter{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
legs := 0
|
||||||
|
for _, txn := range txns {
|
||||||
|
if txn.IsTransferLeg() {
|
||||||
|
legs++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if legs != 2 {
|
||||||
|
t.Errorf("%d legs paired after retag, want 2", legs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Six fields need more room than four, so the transfer form gives up its
|
||||||
|
// spacing, then its hints, then the borders on the fields not being edited. No
|
||||||
|
// field may disappear at any height, and the one with the cursor in it keeps
|
||||||
|
// its box however tight things get.
|
||||||
|
func TestTransferFormFitsShortTerminals(t *testing.T) {
|
||||||
|
m, _, _ := newTransferModel(t)
|
||||||
|
key(t, m, "6")
|
||||||
|
|
||||||
|
for _, height := range []int{50, 44, 38, 34, 30, 28} {
|
||||||
|
m.Update(tea.WindowSizeMsg{Width: 140, Height: height})
|
||||||
|
form := m.transferFormView()
|
||||||
|
if lines := strings.Count(form, "\n") + 1; lines > height-6 {
|
||||||
|
t.Errorf("at height %d the form is %d lines, want at most %d", height, lines, height-6)
|
||||||
|
}
|
||||||
|
for _, label := range []string{
|
||||||
|
"from account", "from desc", "to account", "to desc", "tolerance %", "note",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(form, label) {
|
||||||
|
t.Errorf("at height %d the %q field disappeared", height, label)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
boxes := strings.Count(form, "╭")
|
||||||
|
if boxes != 6 && boxes != 1 {
|
||||||
|
t.Errorf("at height %d there are %d input boxes, want 6 or just the focused 1", height, boxes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A paired leg has been accounted for, so it is not waiting for a tag: it must
|
||||||
|
// not turn up in the untagged view, nor in the rule builder's preview, which is
|
||||||
|
// the list of things still asking to be tagged.
|
||||||
|
func TestPairedLegsAreNotUntagged(t *testing.T) {
|
||||||
|
m, _, _ := newTransferModel(t)
|
||||||
|
key(t, m, "6")
|
||||||
|
fillTransfer(m, "checking", "*TO SAVINGS*", "savings", "*FROM CHECKING*")
|
||||||
|
key(t, m, "enter")
|
||||||
|
key(t, m, "esc")
|
||||||
|
|
||||||
|
key(t, m, "u")
|
||||||
|
var seen []string
|
||||||
|
for _, txn := range m.txns {
|
||||||
|
seen = append(seen, txn.Date+" "+txn.Description)
|
||||||
|
}
|
||||||
|
want := []string{"2026-04-01 TRANSFER TO SAVINGS", "2026-03-05 LIDL SOFIA 4412"}
|
||||||
|
if len(seen) != len(want) {
|
||||||
|
t.Fatalf("untagged = %v, want %v: only the unpaired leg and the shopping", seen, want)
|
||||||
|
}
|
||||||
|
for i, w := range want {
|
||||||
|
if seen[i] != w {
|
||||||
|
t.Errorf("untagged[%d] = %q, want %q", i, seen[i], w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The rule builder previews the same set.
|
||||||
|
key(t, m, "4")
|
||||||
|
all, _ := previewRows(m)
|
||||||
|
if len(all) != 2 {
|
||||||
|
t.Errorf("preview = %v, want the paired legs left out", all)
|
||||||
|
}
|
||||||
|
for _, desc := range all {
|
||||||
|
if desc == "TRANSFER FROM CHECKING" {
|
||||||
|
t.Error("the arriving leg of a matched transfer is still offered for tagging")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An exchange between two of your own accounts pairs on the dates alone, and
|
||||||
|
// the preview shows both amounts: they are the only place the rate appears.
|
||||||
|
func TestTransferBuilderShowsBothSidesOfAnExchange(t *testing.T) {
|
||||||
|
m, db, _ := newTransferModel(t)
|
||||||
|
|
||||||
|
bgn, err := db.UpsertAccount(model.Account{
|
||||||
|
Slug: "revolut", Name: "Revolut BGN", Currency: "BGN", MinorDigits: 2,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
source, err := db.SourceFile(bgn, "revolut/st.csv", "sha", "2026-01-01T00:00:00Z")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
accounts, err := db.Accounts()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var checking int64
|
||||||
|
for _, a := range accounts {
|
||||||
|
if a.Slug == "checking" {
|
||||||
|
checking = a.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, leg := range []struct {
|
||||||
|
account int64
|
||||||
|
fp string
|
||||||
|
date string
|
||||||
|
desc string
|
||||||
|
amount int64
|
||||||
|
}{
|
||||||
|
{checking, "x-out", "2026-05-01", "TRANSFER TO REVOLUT", -50000}, // EUR
|
||||||
|
{bgn, "x-in", "2026-05-02", "TOP-UP FROM CHECKING", 97790},
|
||||||
|
} {
|
||||||
|
if _, err := db.InsertTransaction(model.Transaction{
|
||||||
|
AccountID: leg.account,
|
||||||
|
SourceFileID: source,
|
||||||
|
Fingerprint: leg.fp,
|
||||||
|
Date: leg.date,
|
||||||
|
Description: leg.desc,
|
||||||
|
AmountMinor: leg.amount,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
key(t, m, "6")
|
||||||
|
fillTransfer(m, "checking", "*TO REVOLUT*", "revolut", "*FROM CHECKING*")
|
||||||
|
|
||||||
|
if m.previewPairs != 1 || m.previewUnmatched != 0 {
|
||||||
|
t.Fatalf("preview = %d pairs, %d unpaired; want the exchange paired",
|
||||||
|
m.previewPairs, m.previewUnmatched)
|
||||||
|
}
|
||||||
|
var amount string
|
||||||
|
for _, row := range m.transferTable.Rows() {
|
||||||
|
if row[0] == "▸" {
|
||||||
|
amount = row[2]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if amount != "500.00 → 977.90" {
|
||||||
|
t.Errorf("amount = %q, want both sides of the exchange", amount)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Saving it keeps both legs out of the report, each in its own currency.
|
||||||
|
key(t, m, "enter")
|
||||||
|
txns, err := db.Transactions(store.Filter{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
excluded := report.Excluded(txns)
|
||||||
|
if len(excluded) != 2 {
|
||||||
|
t.Fatalf("excluded = %+v, want a row per currency", excluded)
|
||||||
|
}
|
||||||
|
for _, x := range excluded {
|
||||||
|
if x.Legs != 1 {
|
||||||
|
t.Errorf("%s row = %+v, want the one leg it saw", x.Currency, x)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A paired leg reads as accounted for in the tag column rather than as a blank,
|
||||||
|
// but the label is display only: rule_tag stays what rules.toml made it, so
|
||||||
|
// retagging is still safe to run at any time.
|
||||||
|
func TestTransferLegsDisplayAsTags(t *testing.T) {
|
||||||
|
m, db, _ := newTransferModel(t)
|
||||||
|
key(t, m, "6")
|
||||||
|
fillTransfer(m, "checking", "*TO SAVINGS*", "savings", "*FROM CHECKING*")
|
||||||
|
key(t, m, "enter")
|
||||||
|
key(t, m, "esc")
|
||||||
|
|
||||||
|
var tags []string
|
||||||
|
for _, row := range m.txnTable.Rows() {
|
||||||
|
tags = append(tags, row[3])
|
||||||
|
}
|
||||||
|
// Newest first: the unpaired April leg, the shopping, then the two legs of
|
||||||
|
// the March movement.
|
||||||
|
want := []string{"", "", model.TransferTag, model.TransferTag}
|
||||||
|
if len(tags) != len(want) {
|
||||||
|
t.Fatalf("tags = %v, want %v", tags, want)
|
||||||
|
}
|
||||||
|
for i, w := range want {
|
||||||
|
if tags[i] != w {
|
||||||
|
t.Errorf("row %d (%s) tag = %q, want %q", i, m.txns[i].Description, tags[i], w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nothing was written: the index still holds no tag for either leg, and the
|
||||||
|
// label is not offered as a tag to complete against.
|
||||||
|
txns, err := db.Transactions(store.Filter{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, txn := range txns {
|
||||||
|
if txn.RuleTag != "" {
|
||||||
|
t.Errorf("%s has rule_tag %q; a transfer must never write one",
|
||||||
|
txn.Description, txn.RuleTag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
known, err := m.knownTags()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, tag := range known {
|
||||||
|
if tag == model.TransferTag {
|
||||||
|
t.Error("the transfer label must not be offered as a tag to write rules with")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A rule tag wins where there is one, since it is the user's own word.
|
||||||
|
key(t, m, "4")
|
||||||
|
m.ruleGlob.SetValue("*TO SAVINGS*")
|
||||||
|
m.setRuleFocus(2)
|
||||||
|
m.ruleTag.SetValue("saving")
|
||||||
|
key(t, m, "enter")
|
||||||
|
key(t, m, "esc")
|
||||||
|
for i, txn := range m.txns {
|
||||||
|
if txn.Description == "TRANSFER TO SAVINGS" && m.txnTable.Rows()[i][3] != "saving" {
|
||||||
|
t.Errorf("tag = %q, want the rule's own tag", m.txnTable.Rows()[i][3])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A route where the bank takes a fee is the reason tolerance_pct exists: the
|
||||||
|
// builder must write it, pair on it, and say what it is admitting -- and the
|
||||||
|
// report must then account for the difference, since the pair leaves it.
|
||||||
|
func TestTransferBuilderTolerance(t *testing.T) {
|
||||||
|
m, db, root := newTransferModel(t)
|
||||||
|
|
||||||
|
checking, err := db.UpsertAccount(model.Account{
|
||||||
|
Slug: "checking", Name: "Checking", Currency: "EUR", MinorDigits: 2,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
savings, err := db.UpsertAccount(model.Account{
|
||||||
|
Slug: "savings", Name: "Savings", Currency: "EUR", MinorDigits: 2,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
sourceID, err := db.SourceFile(checking, "checking/wire.csv", "sha2", "2026-01-01T00:00:00Z")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// 500.00 leaves, 495.00 arrives: the bank kept 5.00 on the way.
|
||||||
|
fee := []struct {
|
||||||
|
account int64
|
||||||
|
date string
|
||||||
|
desc string
|
||||||
|
amount int64
|
||||||
|
}{
|
||||||
|
{checking, "2026-05-01", "WIRE TO SAVINGS", -50000},
|
||||||
|
{savings, "2026-05-02", "WIRE FROM CHECKING", 49500},
|
||||||
|
}
|
||||||
|
for i, s := range fee {
|
||||||
|
if _, err := db.InsertTransaction(model.Transaction{
|
||||||
|
AccountID: s.account,
|
||||||
|
SourceFileID: sourceID,
|
||||||
|
Fingerprint: fmt.Sprintf("wire-%d", i),
|
||||||
|
Date: s.date,
|
||||||
|
Description: s.desc,
|
||||||
|
AmountMinor: s.amount,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := m.reload(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
key(t, m, "6")
|
||||||
|
fillTransfer(m, "checking", "*WIRE TO SAVINGS*", "savings", "*WIRE FROM CHECKING*")
|
||||||
|
if m.previewPairs != 0 {
|
||||||
|
t.Fatalf("previewPairs = %d, want 0 before a tolerance is given", m.previewPairs)
|
||||||
|
}
|
||||||
|
|
||||||
|
m.transferTolerance.SetValue("1")
|
||||||
|
m.refreshTransferPreview()
|
||||||
|
if m.previewPairs != 1 || m.previewFees != 500 {
|
||||||
|
t.Fatalf("preview = %d pairs, %d in fees; want 1 and 500",
|
||||||
|
m.previewPairs, m.previewFees)
|
||||||
|
}
|
||||||
|
// Both amounts show, exactly as they do for an exchange: the fee is the
|
||||||
|
// thing to eyeball before saving.
|
||||||
|
if want := "500.00 → 495.00"; !strings.Contains(m.transferTable.View(), want) {
|
||||||
|
t.Errorf("preview table does not show %q:\n%s", want, m.transferTable.View())
|
||||||
|
}
|
||||||
|
|
||||||
|
key(t, m, "enter")
|
||||||
|
if m.err != nil {
|
||||||
|
t.Fatalf("saving: %v", m.err)
|
||||||
|
}
|
||||||
|
loaded, err := config.LoadRules(root)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(loaded.Transfer) != 1 || loaded.Transfer[0].TolerancePct != 1 {
|
||||||
|
t.Fatalf("transfers = %+v, want the tolerance written", loaded.Transfer)
|
||||||
|
}
|
||||||
|
// Saving clears it: a tolerance carried into the next definition would
|
||||||
|
// loosen a route that never asked for one.
|
||||||
|
if v := m.transferTolerance.Value(); v != "" {
|
||||||
|
t.Errorf("tolerance field = %q after saving, want it cleared", v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The pair is gone from the report, so the fee it took has to be named.
|
||||||
|
var rows []string
|
||||||
|
for _, r := range m.reportTable.Rows() {
|
||||||
|
rows = append(rows, strings.Join(r, " "))
|
||||||
|
}
|
||||||
|
joined := strings.Join(rows, "\n")
|
||||||
|
if !strings.Contains(joined, transfersRow) {
|
||||||
|
t.Errorf("report has no transfers row:\n%s", joined)
|
||||||
|
}
|
||||||
|
if !strings.Contains(joined, feesRow) || !strings.Contains(joined, "5.00") {
|
||||||
|
t.Errorf("report does not account for the 5.00 fee:\n%s", joined)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A tolerance that is not a number is refused on save rather than written out
|
||||||
|
// as a silent zero -- but it must not stop the preview updating as it is typed.
|
||||||
|
func TestTransferBuilderRejectsABadTolerance(t *testing.T) {
|
||||||
|
m, _, root := newTransferModel(t)
|
||||||
|
key(t, m, "6")
|
||||||
|
fillTransfer(m, "checking", "*TO SAVINGS*", "savings", "*FROM CHECKING*")
|
||||||
|
|
||||||
|
m.transferTolerance.SetValue("1.")
|
||||||
|
m.refreshTransferPreview() // must not panic or wipe the preview
|
||||||
|
if m.previewPairs != 1 {
|
||||||
|
t.Errorf("previewPairs = %d, want the preview to survive a half-typed number", m.previewPairs)
|
||||||
|
}
|
||||||
|
|
||||||
|
m.transferTolerance.SetValue("a lot")
|
||||||
|
m.Update(tea.KeyMsg{Type: tea.KeyEnter}) // not key(), which fails on m.err
|
||||||
|
if m.err == nil {
|
||||||
|
t.Fatal("expected saving to be refused")
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(root, config.RulesFile)); !os.IsNotExist(err) {
|
||||||
|
t.Error("a rejected transfer must not be written")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The list is where a definition is judged, and a tolerance changes what its
|
||||||
|
// counts mean: those pairs were matched on slack rather than on the amount, so
|
||||||
|
// the column has to say which ones.
|
||||||
|
func TestTransferListShowsTheTolerance(t *testing.T) {
|
||||||
|
m, _, root := newTransferModel(t)
|
||||||
|
if err := config.AppendTransfer(root, config.Transfer{
|
||||||
|
FromAccount: "checking", FromDesc: "*TO SAVINGS*",
|
||||||
|
ToAccount: "savings", ToDesc: "*FROM CHECKING*",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := config.AppendTransfer(root, config.Transfer{
|
||||||
|
FromAccount: "checking", FromDesc: "*WIRE TO SAVINGS*",
|
||||||
|
ToAccount: "savings", ToDesc: "*WIRE FROM CHECKING*",
|
||||||
|
TolerancePct: 1.5,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := m.reloadConfig(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
key(t, m, "7")
|
||||||
|
rows := m.transferListTable.Rows()
|
||||||
|
if len(rows) != 2 {
|
||||||
|
t.Fatalf("rows = %v, want one per definition", rows)
|
||||||
|
}
|
||||||
|
// Blank, not "0%": every definition has the default, and printing it down
|
||||||
|
// the column would bury the row where amounts may actually disagree.
|
||||||
|
if rows[0][6] != "" {
|
||||||
|
t.Errorf("strict row shows tolerance %q, want it blank", rows[0][6])
|
||||||
|
}
|
||||||
|
if rows[1][6] != "1.5%" {
|
||||||
|
t.Errorf("tolerant row shows %q, want 1.5%%", rows[1][6])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Once a glob is typed the preview is the rule's answer, not a list to search
|
||||||
|
// by eye: the rows that do not match go, and the count says what they were
|
||||||
|
// chosen out of, since the rows on screen can no longer say it themselves.
|
||||||
|
func TestRuleBuilderPreviewShowsOnlyMatches(t *testing.T) {
|
||||||
|
m, _, _ := newRuleModel(t)
|
||||||
|
key(t, m, "4")
|
||||||
|
|
||||||
|
// With no glob there is nothing to filter by, so the screen answers its
|
||||||
|
// other question: everything still waiting for a rule.
|
||||||
|
if all, _ := previewRows(m); len(all) != 4 {
|
||||||
|
t.Fatalf("preview = %v, want every untagged description before a glob", all)
|
||||||
|
}
|
||||||
|
if !strings.Contains(m.ruleFormView(), "4 untagged descriptions") {
|
||||||
|
t.Errorf("summary = %q, want the untagged count", m.ruleFormView())
|
||||||
|
}
|
||||||
|
|
||||||
|
typeText(t, m, "*LIDL*")
|
||||||
|
all, matched := previewRows(m)
|
||||||
|
if len(all) != 2 || len(matched) != 2 {
|
||||||
|
t.Fatalf("preview = %v (matched %v), want only the two LIDL rows", all, matched)
|
||||||
|
}
|
||||||
|
for _, desc := range all {
|
||||||
|
if !strings.Contains(desc, "LIDL") {
|
||||||
|
t.Errorf("preview kept a non-matching row %q", desc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Two of the four still in view, not two of the two left on screen.
|
||||||
|
if m.ruleCandidates != 4 {
|
||||||
|
t.Errorf("candidates = %d, want 4", m.ruleCandidates)
|
||||||
|
}
|
||||||
|
if !strings.Contains(m.ruleFormView(), "2 of 4 descriptions match") {
|
||||||
|
t.Errorf("summary = %q, want the glob's selectivity", m.ruleFormView())
|
||||||
|
}
|
||||||
|
|
||||||
|
// A glob that matches nothing empties the list, and says so rather than
|
||||||
|
// leaving rows on screen that the rule would not claim.
|
||||||
|
m.ruleGlob.SetValue("*NOTHING*")
|
||||||
|
m.refreshRulePreview()
|
||||||
|
if all, _ := previewRows(m); len(all) != 0 {
|
||||||
|
t.Errorf("preview = %v, want nothing", all)
|
||||||
|
}
|
||||||
|
if !strings.Contains(m.ruleFormView(), "0 of 4 descriptions match") {
|
||||||
|
t.Errorf("summary = %q, want 0 of 4", m.ruleFormView())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user