diff --git a/CLAUDE.md b/CLAUDE.md index 14dcc75..1ccbaa2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,13 +11,14 @@ This file covers what the code assumes and why. ``` cmd/money/main.go subcommands; the TUI is the default -internal/config rules.toml, account.toml, XDG config, data-root resolution +internal/config rules.toml (rules + transfers), account.toml, XDG config, data-root resolution internal/model Account, Transaction, amount formatting, description normalisation internal/glob the `*` / `?` matcher used by rules (two-pointer, no exponential blowup) internal/parser Parser interface + registry; nlb, revolut, traderepublic internal/store SQLite index (modernc.org/sqlite, no cgo) internal/importer directory walk, dedupe, balance checks internal/rules applies ordered rules, writing rule_tag +internal/transfers pairs the two legs of a movement, writing the transfers table internal/report per-tag aggregation internal/tui Bubble Tea models ``` @@ -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 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 `index.db` at the root of the data directory and re-importing must reproduce 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 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.** -`Update` routes to `updateRules` before `updateNormal` whenever the view is -`viewRules`, or typing `q` would quit and `i` would start an import. Any new -full-screen input needs the same treatment. +**The builders are forms, so the global keymap must not apply there.** +`Update` routes to `updateRules` / `updateTransfers` before `updateNormal` +whenever the view is `viewRules` or `viewTransfers`, or typing `q` would quit +and `i` would start an import. Any new full-screen input needs the same +treatment. **In the rule builder, `tab` completes first and moves focus second.** The account and tag fields use `textinput.ShowSuggestions`, whose own `AcceptSuggestion` diff --git a/README.md b/README.md index 2c4c994..3319fc9 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ tagged by glob rules you write. ``` ~/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*) checking/ 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 import # extract new transactions from every statement money import --force # re-parse statements even if unchanged -money retag # re-apply rules.toml to everything already imported -money ls --untagged # what no rule has claimed yet +money retag # re-apply rules.toml: retag, and re-pair transfers +money ls --untagged # what neither a rule nor a transfer has claimed +money ls --untagged --uniq # the same, one row per distinct description money ls --wide # also show type and reported balance money ls --account checking --month 2026-01 money report --month 2026-01 # spending by tag @@ -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 `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 | 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) | | `/` | filter by description | -| `u` | show only untagged transactions | +| `u` | show only untagged transactions (matched transfer legs are not among them) | | `a` | clear the account filter | | `i` | import · `r` re-apply rules · `q` quit | @@ -94,18 +117,25 @@ nowhere else, so tagging what you are looking at means writing a rule for it on ### Rule builder (`4`) Writing rules by hand means guessing what a glob will catch. This screen shows -the answer as you type: the form is on the left, and on the right is every -still-untagged description in the data, sorted alphabetically and grouped, with -a `▸` against each one the glob currently matches and a running -"*n* of *m* descriptions match" count. +the answer as you type: the form is on the left, and on the right the +still-untagged descriptions the glob currently matches, marked `▸`, grouped and +sorted alphabetically, under a running "*n* of *m* descriptions match" count. + +The list narrows as you type, so what is on screen is what the rule would +claim — nothing else is left there to read past. The count keeps the context +the rows no longer can: *m* is everything still in view, so `2 of 7` says the +glob picked two descriptions out of seven, and `0 of 7` says the glob is wrong. +With the glob still empty there is nothing to filter by, and the list is every +untagged description in the data — which is the other question this screen +answers, and where you go looking for the next thing to write a rule for. ``` glob Untagged description N -╭────────────────────────────╮ ACME PAYROLL JAN 1 -│ *LIDL* │ BOLT RIDE 1 -╰────────────────────────────╯ ▸ LIDL SOFIA 4412 2 - vs. the description ▸ LIDL VARNA 9911 1 - ZARA MLADOST 1 +╭────────────────────────────╮ ▸ LIDL SOFIA 4412 2 +│ *LIDL* │ ▸ LIDL VARNA 9911 1 +╰────────────────────────────╯ + vs. the description + 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 narrows the preview to that account. Rules are appended, so anything already in `rules.toml` keeps precedence — the preview accounts for that automatically, -since it only ever lists transactions no existing rule has already tagged. +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`) @@ -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 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 are evaluated in file order and the **first match wins**, so put specific @@ -205,24 +335,6 @@ tag = "salary" match = "*ACME PAYROLL*" note = "paid on the 4th; the December one lands early" -# Money moved between your own accounts is tagged like anything else. Both -# legs need a rule, and both count in the report -- one as an outflow, the -# other as an inflow. -[[rule]] -match = "*TO SAVINGS*" -tag = "transfer" - -[[rule]] -match = "*FROM CHECKING*" -tag = "transfer" - -# Such movements are often only identifiable by the other side's account -# number, whatever the rest of the description happens to say. Every bank -# parser keeps that number in the description, so an ordinary glob finds it. -[[rule]] -match = "*SI56*" -tag = "transfer" - # A rule can be limited to one account, and can require several patterns. [[rule]] account = "checking" @@ -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 to run whenever you change it. A transaction no rule matches simply stays untagged, and shows up under `money ls --untagged`, the `u` view, and -`(untagged)` in the report. +`(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 diff --git a/cmd/money/main.go b/cmd/money/main.go index 044abf1..cd781da 100644 --- a/cmd/money/main.go +++ b/cmd/money/main.go @@ -8,7 +8,9 @@ package main import ( "flag" "fmt" + "io" "os" + "sort" "strings" "text/tabwriter" @@ -19,6 +21,7 @@ import ( "git.petrovv.com/nikola/money/internal/report" "git.petrovv.com/nikola/money/internal/rules" "git.petrovv.com/nikola/money/internal/store" + "git.petrovv.com/nikola/money/internal/transfers" "git.petrovv.com/nikola/money/internal/tui" ) @@ -27,9 +30,10 @@ const usage = `money - statement-driven personal finance tracker usage: money [--root DIR] [flags] 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 - retag re-apply rules.toml to everything already imported + retag re-apply rules.toml: retag everything and re-pair transfers ls list transactions report spending by tag accounts list accounts with balances @@ -137,24 +141,33 @@ func cmdConfig(root string, source config.RootSource) error { return nil } +// opened is everything a command needs: the index, the account folders on +// disk, and the two engines rules.toml describes. +type opened struct { + db *store.DB + accounts []*config.Account + engine *rules.Engine + links *transfers.Engine +} + // open loads the config and index that every command needs. -func open(root string) (*store.DB, []*config.Account, *rules.Engine, error) { +func open(root string) (*opened, error) { if _, err := os.Stat(root); err != nil { - return nil, nil, nil, fmt.Errorf("data root %s is not readable: %w", root, err) + return nil, fmt.Errorf("data root %s is not readable: %w", root, err) } accounts, err := config.LoadAccounts(root) if err != nil { - return nil, nil, nil, err + return nil, err } r, err := config.LoadRules(root) if err != nil { - return nil, nil, nil, err + return nil, err } db, err := store.Open(config.IndexPath(root)) if err != nil { - return nil, nil, nil, err + return nil, err } - return db, accounts, rules.New(r), nil + return &opened{db: db, accounts: accounts, engine: rules.New(r), links: transfers.New(r)}, nil } func cmdImport(root string, args []string) error { @@ -164,17 +177,17 @@ func cmdImport(root string, args []string) error { return err } - db, accounts, engine, err := open(root) + o, err := open(root) if err != nil { return err } - defer db.Close() + defer o.db.Close() - if len(accounts) == 0 { + if len(o.accounts) == 0 { return fmt.Errorf("no accounts found in %s (an account is a folder containing %s)", root, config.AccountFile) } - res, err := importer.Run(root, db, accounts, engine, importer.Options{Force: *force}) + res, err := importer.Run(root, o.db, o.accounts, o.engine, o.links, importer.Options{Force: *force}) if err != nil { return err } @@ -199,6 +212,7 @@ func cmdImport(root string, args []string) error { parsed, added, skipped := res.Total() fmt.Printf("\n%d new, %d duplicate, %d parsed; %d rows retagged\n", added, skipped, parsed, res.Retagged) + reportPairing(res.Paired, res.Unpaired) failures := res.Errs() for _, f := range failures { @@ -210,49 +224,88 @@ func cmdImport(root string, args []string) error { return nil } +// cmdRetag re-derives everything rules.toml decides: the tags and the transfer +// pairing. They are one command because they are one file, and leaving half of +// the derived state stale would be worse than not offering it at all. func cmdRetag(root string, _ []string) error { - db, _, engine, err := open(root) + o, err := open(root) if err != nil { return err } - defer db.Close() + defer o.db.Close() - n, err := engine.Retag(db) + n, err := o.engine.Retag(o.db) if err != nil { return err } fmt.Printf("%d transactions retagged\n", n) + + paired, unpaired, err := o.links.Link(o.db) + if err != nil { + return err + } + reportPairing(paired, unpaired) return nil } +// reportPairing prints the state of the transfer pairing. Unmatched legs are +// sent to stderr: a leg that never found its other side is money that left an +// account and cannot be shown to have arrived, which is a warning, not a +// statistic. +func reportPairing(paired, unpaired int) { + if paired == 0 && unpaired == 0 { + return + } + fmt.Printf("%d transfers matched\n", paired) + if unpaired > 0 { + fmt.Fprintf(os.Stderr, "warning: %d transfer leg(s) have no counterpart\n", unpaired) + } +} + func cmdLs(root string, args []string) error { fs := flag.NewFlagSet("ls", flag.ContinueOnError) account := fs.String("account", "", "only this account slug") month := fs.String("month", "", "only this month (YYYY-MM)") search := fs.String("search", "", "only descriptions containing this text") - untagged := fs.Bool("untagged", false, "only transactions 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)") 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 { 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 { return err } - defer db.Close() + defer o.db.Close() - txns, err := db.Transactions(store.Filter{ + f := store.Filter{ AccountSlug: *account, Month: *month, Search: *search, Untagged: *untagged, Limit: *limit, - }) + } + // Deduplicating first would make a limit mean "the distinct descriptions + // among the newest N rows", which is not what it says. Under --uniq it caps + // what is printed instead. + if *uniq { + f.Limit = 0 + } + txns, err := o.db.Transactions(f) if err != nil { return err } + if *uniq { + return printUniq(os.Stdout, txns, *limit) + } w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) if *wide { @@ -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", t.Date, t.AccountSlug, t.FormatAmount(), t.Currency, balance, - t.RuleTag, t.Type, t.Description) + t.DisplayTag(), t.Type, t.Description) continue } 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() fmt.Printf("\n%d transactions\n", len(txns)) 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 { fs := flag.NewFlagSet("report", flag.ContinueOnError) month := fs.String("month", "", "only this month (YYYY-MM)") @@ -287,13 +390,13 @@ func cmdReport(root string, args []string) error { return err } - db, _, _, err := open(root) + o, err := open(root) if err != nil { return err } - defer db.Close() + defer o.db.Close() - txns, err := db.Transactions(store.Filter{Month: *month, AccountSlug: *account}) + txns, err := o.db.Transactions(store.Filter{Month: *month, AccountSlug: *account}) if err != nil { return err } @@ -312,28 +415,44 @@ func cmdReport(root string, args []string) error { model.FormatMinor(c.Net(), c.Digits)) } 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 } func cmdAccounts(root string, _ []string) error { - db, _, _, err := open(root) + o, err := open(root) if err != nil { return err } - defer db.Close() + defer o.db.Close() - accounts, err := db.Accounts() + accounts, err := o.db.Accounts() if err != nil { return err } w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) fmt.Fprintln(w, "SLUG\tNAME\tBALANCE\tCUR\tTXNS") for _, a := range accounts { - bal, err := db.Balance(a.ID) + bal, err := o.db.Balance(a.ID) if err != nil { return err } - n, err := db.Count(a.ID) + n, err := o.db.Count(a.ID) if err != nil { return err } @@ -344,10 +463,10 @@ func cmdAccounts(root string, _ []string) error { } func cmdTUI(root string, _ []string) error { - db, accounts, engine, err := open(root) + o, err := open(root) if err != nil { return err } - defer db.Close() - return tui.Run(root, db, accounts, engine) + defer o.db.Close() + return tui.Run(root, o.db, o.accounts, o.engine, o.links) } diff --git a/cmd/money/main_test.go b/cmd/money/main_test.go new file mode 100644 index 0000000..996f5cd --- /dev/null +++ b/cmd/money/main_test.go @@ -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) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index bb9b78b..9e9a418 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -39,9 +39,40 @@ type Rule struct { Note string `toml:"note"` } -// Rules is the parsed rules.toml. +// Transfer is one entry in rules.toml describing money moved between two +// accounts the user owns. Both sides are named: a transfer is only ever a pair, +// which is what lets the report drop it without leaving half a movement behind. +// +// All four patterns are required. A one-sided definition would be a rule. +type Transfer struct { + FromAccount string `toml:"from_account"` + FromDesc string `toml:"from_desc"` // glob vs. the leaving leg's description + ToAccount string `toml:"to_account"` + ToDesc string `toml:"to_desc"` // glob vs. the arriving leg's description + // TolerancePct is how far short (or over) the arriving leg may be and still + // count as the same movement, as a percentage of the leg that left. It exists + // for routes where the bank takes a fee on the way, so the two statements + // genuinely disagree about the amount. + // + // Zero — the default, and what every definition written before this key + // existed means — requires the exact opposite amount. Keep it that way unless + // a route actually charges: the wider the tolerance, the more likely two + // unrelated movements in one window pair with each other. + // + // It applies within one currency only. Across currencies the amount is not + // checked at all, so there is nothing for a tolerance to loosen. + TolerancePct float64 `toml:"tolerance_pct"` + // Note is free text for the reader, exactly as on a Rule: it never takes + // part in matching. + Note string `toml:"note"` +} + +// Rules is the parsed rules.toml: the tagging rules and the transfer +// definitions, which share the file because they are both hand-maintained +// statements about the same transactions. type Rules struct { - Rule []Rule `toml:"rule"` + Rule []Rule `toml:"rule"` + Transfer []Transfer `toml:"transfer"` } // 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) } } + for i, t := range r.Transfer { + if err := checkTransfer(t); err != nil { + return nil, fmt.Errorf("%s: transfer %d: %w", path, i+1, err) + } + } return &r, nil } +// checkTransfer rejects a half-written definition. Both sides are needed to +// pair anything at all, so a missing one is refused on load rather than +// silently matching nothing. +func checkTransfer(t Transfer) error { + switch { + case t.FromAccount == "": + return fmt.Errorf("no from_account") + case t.FromDesc == "": + return fmt.Errorf("no from_desc pattern") + case t.ToAccount == "": + return fmt.Errorf("no to_account") + case t.ToDesc == "": + return fmt.Errorf("no to_desc pattern") + } + // A negative tolerance is a typo, and one at 100% or beyond would let any + // amount pair with any other — at which point the dates decide alone, which + // is the cross-currency rule and not something to arrive at by accident. + if t.TolerancePct < 0 || t.TolerancePct >= 100 { + return fmt.Errorf("tolerance_pct %g is not between 0 and 100", t.TolerancePct) + } + return nil +} + // AppendRule adds a rule to the end of rules.toml, creating the file if it is // not there yet. Appending rather than inserting means an existing rule always // keeps precedence, since the first match wins. @@ -80,6 +139,30 @@ func AppendRule(root string, r Rule) error { 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) existing, err := os.ReadFile(path) 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(formatRule(r)) + b.WriteString(block) return writeFileAtomic(root, path, b.String()) } // DeleteRules removes the rules at the given positions (0-based, as loaded by // LoadRules) from rules.toml. +func DeleteRules(root string, positions []int) (int, error) { + return deleteBlocks(root, "rule", positions) +} + +// DeleteTransfers removes the transfer definitions at the given positions +// (0-based, as loaded by LoadRules) from rules.toml. +func DeleteTransfers(root string, positions []int) (int, error) { + return deleteBlocks(root, "transfer", positions) +} + +// blockStart is where one array-of-tables entry begins in rules.toml. +type blockStart struct { + table string // "rule" or "transfer" + line int +} + +// blockStarts finds every [[table]] header in the file. Every kind is +// collected, not just the one being deleted: a block ends where the *next* +// block of any kind begins, so deleting a rule that happens to sit above a +// transfer must not swallow it. +func blockStarts(lines []string) []blockStart { + var out []blockStart + for i, line := range lines { + s := strings.TrimSpace(line) + if strings.HasPrefix(s, "[[") && strings.HasSuffix(s, "]]") { + out = append(out, blockStart{table: strings.TrimSpace(s[2 : len(s)-2]), line: i}) + } + } + return out +} + +// deleteBlocks removes entries of one table from rules.toml. // // The file is edited textually rather than re-serialised from the parsed -// rules, so comments, ordering and formatting the user put there by hand -// survive. A comment block sitting directly above a deleted rule goes with it, -// since it documents that rule; a comment separated by a blank line is treated +// values, so comments, ordering and formatting the user put there by hand +// survive. A comment block sitting directly above a deleted entry goes with it, +// since it documents that entry; a comment separated by a blank line is treated // as a section heading and left alone. -func DeleteRules(root string, positions []int) (int, error) { +func deleteBlocks(root, table string, positions []int) (int, error) { if len(positions) == 0 { return 0, nil } @@ -121,23 +236,25 @@ func DeleteRules(root string, positions []int) (int, error) { } lines := strings.Split(string(raw), "\n") - // Where each [[rule]] block begins. - var starts []int - for i, line := range lines { - if strings.TrimSpace(line) == "[[rule]]" { - starts = append(starts, i) + starts := blockStarts(lines) + // mine[p] is where the p-th entry of this table sits among all the blocks. + var mine []int + for k, s := range starts { + if s.table == table { + mine = append(mine, k) } } for _, p := range positions { - if p < 0 || p >= len(starts) { - return 0, fmt.Errorf("rule %d is out of range; %s holds %d rules", p+1, path, len(starts)) + if p < 0 || p >= len(mine) { + return 0, fmt.Errorf("%s %d is out of range; %s holds %d %ss", + table, p+1, path, len(mine), table) } } - // A rule owns the run of comment lines directly above it, with no blank + // 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. prefix := func(k int) int { - i := starts[k] + i := starts[k].line for i > 0 && strings.HasPrefix(strings.TrimSpace(lines[i-1]), "#") { i-- } @@ -145,12 +262,10 @@ func DeleteRules(root string, positions []int) (int, error) { } drop := map[int]bool{} - for k := range starts { - if !doomed[k] { - continue - } - // The block runs up to the next rule's comment prefix, so a comment - // introducing the following rule is not swept up with this one. + for p := range doomed { + k := mine[p] + // 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. end := len(lines) if k+1 < len(starts) { end = prefix(k + 1) @@ -158,9 +273,9 @@ func DeleteRules(root string, positions []int) (int, error) { for i := prefix(k); i < end; i++ { 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. - 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) } } @@ -173,14 +288,27 @@ func DeleteRules(root string, positions []int) (int, error) { } out := collapseBlankRuns(kept) - // Never write something that will not load again. + // Never write something that will not load again, and never let deleting + // one kind of block take a different kind with it. var check Rules if _, err := toml.Decode(out, &check); err != nil { return 0, fmt.Errorf("deleting from %s would produce invalid TOML: %w", path, err) } - if want := len(starts) - len(doomed); len(check.Rule) != want { - return 0, fmt.Errorf("deleting from %s would leave %d rules, expected %d", - path, len(check.Rule), want) + counts := map[string]int{"rule": len(check.Rule), "transfer": len(check.Transfer)} + for _, kind := range []string{"rule", "transfer"} { + want := 0 + for _, s := range starts { + if s.table == kind { + want++ + } + } + if kind == table { + want -= len(doomed) + } + if counts[kind] != want { + return 0, fmt.Errorf("deleting from %s would leave %d %ss, expected %d", + path, counts[kind], kind, want) + } } if err := writeFileAtomic(root, path, out); err != nil { @@ -238,15 +366,38 @@ func writeFileAtomic(dir, path, content string) error { return nil } +// writeKey renders one TOML key, skipping it when empty. The column is wide +// enough for the longest key either block uses, so the values line up. +func writeKey(b *strings.Builder, key, value string) { + if value != "" { + fmt.Fprintf(b, "%-12s = %s\n", key, strconv.Quote(value)) + } +} + +// formatTransfer renders a transfer as a TOML table, the two sides in the +// order money travels. +func formatTransfer(t Transfer) string { + var b strings.Builder + b.WriteString("[[transfer]]\n") + writeKey(&b, "from_account", t.FromAccount) + writeKey(&b, "from_desc", t.FromDesc) + writeKey(&b, "to_account", t.ToAccount) + writeKey(&b, "to_desc", t.ToDesc) + // Only when set: a zero written out would suggest the key is doing something + // when it is exactly the default every other definition already has. + if t.TolerancePct != 0 { + fmt.Fprintf(&b, "%-12s = %s\n", "tolerance_pct", + strconv.FormatFloat(t.TolerancePct, 'f', -1, 64)) + } + writeKey(&b, "note", t.Note) + return b.String() +} + // formatRule renders a rule as a TOML table, omitting empty fields. func formatRule(r Rule) string { var b strings.Builder b.WriteString("[[rule]]\n") - write := func(key, value string) { - if value != "" { - fmt.Fprintf(&b, "%-12s = %s\n", key, strconv.Quote(value)) - } - } + write := func(key, value string) { writeKey(&b, key, value) } write("match", r.Match) write("type", r.Type) write("account", r.Account) diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 23dec95..c604432 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -455,3 +455,221 @@ func TestLoadUserConfigMalformed(t *testing.T) { t.Error("expected an error for a malformed config file") } } + +func TestAppendTransfer(t *testing.T) { + root := t.TempDir() + existing := "[[rule]]\nmatch = \"*LIDL*\"\ntag = \"groceries\"\n" + if err := os.WriteFile(filepath.Join(root, RulesFile), []byte(existing), 0o644); err != nil { + t.Fatal(err) + } + + tr := Transfer{ + FromAccount: "nlb", FromDesc: "*TO REVOLUT*", + ToAccount: "revolut", ToDesc: "*FROM NLB*", + Note: `the monthly "top-up"`, + } + if err := AppendTransfer(root, tr); err != nil { + t.Fatal(err) + } + + loaded, err := LoadRules(root) + if err != nil { + t.Fatalf("the file no longer parses after appending: %v", err) + } + if len(loaded.Rule) != 1 { + t.Errorf("got %d rules, want the existing one untouched", len(loaded.Rule)) + } + if len(loaded.Transfer) != 1 || loaded.Transfer[0] != tr { + t.Errorf("transfers = %+v, want %+v", loaded.Transfer, tr) + } +} + +// Both sides are required: a one-sided definition can never pair anything, so +// it is refused rather than written and silently ignored. +func TestTransferNeedsBothSides(t *testing.T) { + full := Transfer{FromAccount: "a", FromDesc: "*OUT*", ToAccount: "b", ToDesc: "*IN*"} + cases := map[string]func(*Transfer){ + "no from_account": func(t *Transfer) { t.FromAccount = "" }, + "no from_desc": func(t *Transfer) { t.FromDesc = "" }, + "no to_account": func(t *Transfer) { t.ToAccount = "" }, + "no to_desc": func(t *Transfer) { t.ToDesc = "" }, + } + for name, break_ := range cases { + root := t.TempDir() + tr := full + break_(&tr) + if err := AppendTransfer(root, tr); err == nil { + t.Errorf("%s: expected an error", name) + } + if _, err := os.Stat(filepath.Join(root, RulesFile)); !os.IsNotExist(err) { + t.Errorf("%s: a rejected transfer must not create the file", name) + } + } + + // The same check applies to a file written by hand. + root := t.TempDir() + body := "[[transfer]]\nfrom_account = \"nlb\"\nfrom_desc = \"*OUT*\"\nto_account = \"revolut\"\n" + if err := os.WriteFile(filepath.Join(root, RulesFile), []byte(body), 0o644); err != nil { + t.Fatal(err) + } + if _, err := LoadRules(root); err == nil { + t.Error("expected a half-written transfer to be refused on load") + } +} + +// Rules and transfers share a file, so deleting one kind must not take a +// neighbouring block of the other kind with it. +func TestDeleteLeavesTheOtherKindAlone(t *testing.T) { + root := t.TempDir() + path := filepath.Join(root, RulesFile) + original := `[[rule]] +match = "*LIDL*" +tag = "groceries" + +# Moving money to the broker. +[[transfer]] +from_account = "nlb" +from_desc = "*TO TRADEREPUBLIC*" +to_account = "traderepublic" +to_desc = "*FROM NLB*" + +[[rule]] +match = "*ZARA*" +tag = "clothes" +` + if err := os.WriteFile(path, []byte(original), 0o644); err != nil { + t.Fatal(err) + } + + // Deleting the first rule must stop at the transfer that follows it. + if _, err := DeleteRules(root, []int{0}); err != nil { + t.Fatal(err) + } + loaded, err := LoadRules(root) + if err != nil { + t.Fatal(err) + } + if len(loaded.Rule) != 1 || loaded.Rule[0].Tag != "clothes" { + t.Errorf("rules = %+v, want only the clothes rule left", loaded.Rule) + } + if len(loaded.Transfer) != 1 { + t.Fatalf("transfers = %+v, want the transfer untouched", loaded.Transfer) + } + raw, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(raw), "# Moving money to the broker.") { + t.Errorf("rules.toml = %q, want the transfer's own comment kept", raw) + } + + // And deleting the transfer leaves the remaining rule alone. + if _, err := DeleteTransfers(root, []int{0}); err != nil { + t.Fatal(err) + } + loaded, err = LoadRules(root) + if err != nil { + t.Fatal(err) + } + if len(loaded.Transfer) != 0 || len(loaded.Rule) != 1 { + t.Errorf("after deleting the transfer: %d rules, %d transfers; want 1 and 0", + len(loaded.Rule), len(loaded.Transfer)) + } + if raw, err = os.ReadFile(path); err != nil { + t.Fatal(err) + } + if strings.Contains(string(raw), "Moving money to the broker") { + t.Errorf("rules.toml = %q, want the deleted transfer's comment gone with it", raw) + } +} + +func TestDeleteTransfersOutOfRange(t *testing.T) { + root := t.TempDir() + if err := AppendTransfer(root, Transfer{ + FromAccount: "a", FromDesc: "*OUT*", ToAccount: "b", ToDesc: "*IN*", + }); err != nil { + t.Fatal(err) + } + if _, err := DeleteTransfers(root, []int{3}); err == nil { + t.Error("expected an out-of-range position to be refused") + } +} + +// A tolerance survives the round trip through the file, and a definition +// without one keeps meaning what it always meant: exact amounts. +func TestAppendTransferWithTolerance(t *testing.T) { + root := t.TempDir() + tr := Transfer{ + FromAccount: "nlb", FromDesc: "*TO REVOLUT*", + ToAccount: "revolut", ToDesc: "*FROM NLB*", + TolerancePct: 1.5, + Note: "NLB takes a wire fee", + } + if err := AppendTransfer(root, tr); err != nil { + t.Fatal(err) + } + + body, err := os.ReadFile(filepath.Join(root, RulesFile)) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(body), "tolerance_pct = 1.5") { + t.Errorf("file = %q, want the tolerance written unquoted", body) + } + + loaded, err := LoadRules(root) + if err != nil { + t.Fatal(err) + } + if len(loaded.Transfer) != 1 || loaded.Transfer[0] != tr { + t.Errorf("transfers = %+v, want %+v", loaded.Transfer, tr) + } +} + +// The default is exact, so an unset tolerance must not be written out as a key +// suggesting the definition decided something. +func TestAppendTransferOmitsAZeroTolerance(t *testing.T) { + root := t.TempDir() + tr := Transfer{ + FromAccount: "nlb", FromDesc: "*TO REVOLUT*", + ToAccount: "revolut", ToDesc: "*FROM NLB*", + } + if err := AppendTransfer(root, tr); err != nil { + t.Fatal(err) + } + body, err := os.ReadFile(filepath.Join(root, RulesFile)) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(body), "tolerance_pct") { + t.Errorf("file = %q, want no tolerance key", body) + } +} + +// A negative tolerance is a typo, and one at 100% or beyond would let any +// amount pair with any other, leaving the dates to decide alone. +func TestTransferToleranceIsBounded(t *testing.T) { + full := Transfer{FromAccount: "a", FromDesc: "*OUT*", ToAccount: "b", ToDesc: "*IN*"} + for _, pct := range []float64{-1, 100, 250} { + root := t.TempDir() + tr := full + tr.TolerancePct = pct + if err := AppendTransfer(root, tr); err == nil { + t.Errorf("tolerance_pct %g: expected an error", pct) + } + if _, err := os.Stat(filepath.Join(root, RulesFile)); !os.IsNotExist(err) { + t.Errorf("tolerance_pct %g: a rejected transfer must not create the file", pct) + } + } + + // The same check applies to a file written by hand. + root := t.TempDir() + body := "[[transfer]]\nfrom_account = \"a\"\nfrom_desc = \"*OUT*\"\n" + + "to_account = \"b\"\nto_desc = \"*IN*\"\ntolerance_pct = 150\n" + if err := os.WriteFile(filepath.Join(root, RulesFile), []byte(body), 0o644); err != nil { + t.Fatal(err) + } + if _, err := LoadRules(root); err == nil { + t.Error("expected an out-of-range tolerance to be refused on load") + } +} diff --git a/internal/importer/importer.go b/internal/importer/importer.go index 6c7044d..b9cf7d3 100644 --- a/internal/importer/importer.go +++ b/internal/importer/importer.go @@ -19,6 +19,7 @@ import ( "git.petrovv.com/nikola/money/internal/parser" "git.petrovv.com/nikola/money/internal/rules" "git.petrovv.com/nikola/money/internal/store" + "git.petrovv.com/nikola/money/internal/transfers" ) // FileResult is what happened to one statement file. @@ -38,6 +39,11 @@ type FileResult struct { type Result struct { Files []FileResult Retagged int + // Paired and Unpaired are the state of the transfer pairing after the + // import: how many movements were matched, and how many legs a definition + // caught without finding the other side. + Paired int + Unpaired int } // Total counts new rows across every file. @@ -69,8 +75,12 @@ type Options struct { Force bool } -// Run imports every account under root into db and then applies the rules. -func Run(root string, db *store.DB, accounts []*config.Account, engine *rules.Engine, opts Options) (Result, error) { +// Run imports every account under root into db and then applies the rules and +// the transfer definitions. Both are derived from rules.toml and both are +// recomputed over the whole index, since a statement imported now can complete +// a transfer whose other leg arrived months ago. +func Run(root string, db *store.DB, accounts []*config.Account, engine *rules.Engine, + links *transfers.Engine, opts Options) (Result, error) { var res Result for _, acc := range accounts { accountID, err := db.UpsertAccount(model.Account{ @@ -106,6 +116,12 @@ func Run(root string, db *store.DB, accounts []*config.Account, engine *rules.En return res, err } res.Retagged = n + + paired, unpaired, err := links.Link(db) + if err != nil { + return res, err + } + res.Paired, res.Unpaired = paired, unpaired return res, nil } diff --git a/internal/importer/importer_test.go b/internal/importer/importer_test.go index 49775f4..58dc932 100644 --- a/internal/importer/importer_test.go +++ b/internal/importer/importer_test.go @@ -12,6 +12,7 @@ import ( "git.petrovv.com/nikola/money/internal/parser" "git.petrovv.com/nikola/money/internal/rules" "git.petrovv.com/nikola/money/internal/store" + "git.petrovv.com/nikola/money/internal/transfers" ) 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 { t.Helper() - res, err := Run(root, db, accounts, e, opts) + res, err := Run(root, db, accounts, e, transfers.New(&config.Rules{}), opts) if err != nil { t.Fatal(err) } @@ -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 { t.Fatalf("Run returned a fatal error, want a per-file report: %v", err) } diff --git a/internal/model/model.go b/internal/model/model.go index 47d382a..3e1087f 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -42,6 +42,39 @@ type Transaction struct { // RuleTag is the category, decided entirely by rules.toml. It is derived // state: `money retag` rewrites it wholesale. 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. diff --git a/internal/report/report.go b/internal/report/report.go index 66f9aac..093bf74 100644 --- a/internal/report/report.go +++ b/internal/report/report.go @@ -25,11 +25,25 @@ func (t TagTotal) Net() int64 { return t.In - t.Out } const Untagged = "(untagged)" // ByTag returns totals sorted by currency, then by largest outflow first. +// +// Matched transfer legs are left out entirely. Both legs of a transfer are +// dropped together, so a report over everything is unchanged in total by money +// the user moved between their own accounts; a report scoped to one account or +// one month may see only one leg, and dropping it is still right, since the +// money was not spent. What was left out is available from Excluded. +// +// A pair whose definition sets a tolerance_pct is dropped the same way, fee and +// all — so with such a definition in play the report *is* short by the fee, and +// callers are expected to show Excluded alongside it rather than leave that +// difference unexplained. func ByTag(txns []model.Transaction) []TagTotal { type key struct{ currency, tag string } acc := map[key]*TagTotal{} for _, t := range txns { + if t.IsTransferLeg() { + continue + } tag := t.RuleTag if tag == "" { tag = Untagged @@ -96,6 +110,87 @@ func Totals(rows []TagTotal) []CurrencyTotal { return out } +// TransferTotal is what ByTag left out for one currency. +// +// It counts legs rather than pairs on purpose: a report scoped to one account +// or month usually holds only one side of a movement, and claiming a whole +// transfer was excluded when only half of it was in view would be a lie. The +// same goes for the two directions, which is why they are separate totals: an +// exchange between currencies has its legs in different columns entirely, so +// one currency can see only what left and the other only what arrived. +type TransferTotal struct { + Currency string + Out int64 // sum of the leaving legs in view, positive + In int64 // sum of the arriving legs in view + Legs int + Digits int + // Fee is what those movements cost: the amount that left less the amount + // that arrived, for pairs whose legs are *both* in view. A definition with a + // tolerance_pct pairs legs that disagree, and since the pair leaves the + // report entirely, this is the only place that difference is still money + // rather than nothing. Zero when no definition allows a mismatch. + Fee int64 + // Pairs counts the movements Fee was computed from — complete pairs in this + // currency, not legs. A report scoped to one account or month usually holds + // one side of a movement, and half a pair cannot say what the other half + // received, so it contributes to Legs and Out/In but not here. + Pairs int +} + +// Excluded summarises the transfer legs ByTag dropped, so a report can account +// for the difference between its total and the account balances. +func Excluded(txns []model.Transaction) []TransferTotal { + // Both legs of a pair carry the same TransferID, so the ones whose partner + // is also in view can be found without going back to the index. + byID := map[int64][]model.Transaction{} + for _, t := range txns { + if t.IsTransferLeg() { + byID[*t.TransferID] = append(byID[*t.TransferID], t) + } + } + + acc := map[string]*TransferTotal{} + row := func(t model.Transaction) *TransferTotal { + r, ok := acc[t.Currency] + if !ok { + r = &TransferTotal{Currency: t.Currency, Digits: t.MinorDigits} + acc[t.Currency] = r + } + return r + } + + for _, t := range txns { + if !t.IsTransferLeg() { + continue + } + r := row(t) + r.Legs++ + if t.AmountMinor < 0 { + r.Out += -t.AmountMinor + } else { + r.In += t.AmountMinor + } + } + + for _, legs := range byID { + // An exchange has its legs in two currencies, and there is no rate here + // to subtract one from the other, so it has no fee to report. + if len(legs) != 2 || legs[0].Currency != legs[1].Currency { + continue + } + r := row(legs[0]) + r.Pairs++ + r.Fee -= legs[0].AmountMinor + legs[1].AmountMinor + } + + out := make([]TransferTotal, 0, len(acc)) + for _, r := range acc { + out = append(out, *r) + } + sort.Slice(out, func(i, j int) bool { return out[i].Currency < out[j].Currency }) + return out +} + // Months lists the distinct YYYY-MM present in txns, most recent first. func Months(txns []model.Transaction) []string { seen := map[string]bool{} diff --git a/internal/report/report_test.go b/internal/report/report_test.go new file mode 100644 index 0000000..df88f8e --- /dev/null +++ b/internal/report/report_test.go @@ -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) + } + } +} diff --git a/internal/store/store.go b/internal/store/store.go index 68b0c28..6d342be 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -1,6 +1,6 @@ // 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 -// tags set by hand, which live only here. +// rebuildable: delete index.db and re-import to get it back. Nothing lives +// only here. package store 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_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. @@ -186,10 +196,15 @@ func (d *DB) InsertTransaction(t model.Transaction) (bool, error) { // Filter narrows a transaction query. type Filter struct { AccountSlug string - Untagged bool // only rows with no effective tag - Month string // YYYY-MM - Search string // case-insensitive substring of the description - Limit int + // Untagged selects the rows still waiting for a verdict: no tag, and not a + // leg of a matched transfer. A paired leg has been accounted for by the + // transfer that claimed it, so listing it as untagged would ask the user to + // write a rule for something that is already spoken for and that the report + // leaves out anyway. + Untagged bool + Month string // YYYY-MM + Search string // case-insensitive substring of the description + Limit int } // Transactions returns rows matching f, newest first. @@ -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, t.fingerprint, t.date, t.description, t.amount_minor, COALESCE(s.path, ''), t.type, t.balance_minor, - COALESCE(t.rule_tag, '') + COALESCE(t.rule_tag, ''), x.id FROM transactions t JOIN accounts a ON a.id = t.account_id LEFT JOIN source_files s ON s.id = t.source_file_id + LEFT JOIN transfers x ON x.out_txn_id = t.id OR x.in_txn_id = t.id WHERE 1 = 1` var args []any if f.AccountSlug != "" { @@ -209,7 +225,7 @@ func (d *DB) Transactions(f Filter) ([]model.Transaction, error) { args = append(args, f.AccountSlug) } 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 != "" { q += ` AND substr(t.date, 1, 7) = ?` @@ -229,18 +245,23 @@ func (d *DB) Transactions(f Filter) ([]model.Transaction, error) { var out []model.Transaction for rows.Next() { var ( - t model.Transaction - balance sql.NullInt64 + t model.Transaction + balance sql.NullInt64 + transfer sql.NullInt64 ) if err := rows.Scan(&t.ID, &t.AccountID, &t.AccountSlug, &t.Currency, &t.MinorDigits, &t.Fingerprint, &t.Date, &t.Description, &t.AmountMinor, &t.SourcePath, - &t.Type, &balance, &t.RuleTag); err != nil { + &t.Type, &balance, &t.RuleTag, &transfer); err != nil { return nil, err } if balance.Valid { v := balance.Int64 t.BalanceMinor = &v } + if transfer.Valid { + v := transfer.Int64 + t.TransferID = &v + } if needle != "" && !strings.Contains(model.NormalizeDescription(t.Description), needle) { continue } @@ -282,6 +303,42 @@ func (d *DB) ApplyRuleResults(rs []RuleAssignment) error { 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. func (d *DB) Balance(accountID int64) (int64, error) { var v sql.NullInt64 diff --git a/internal/transfers/transfers.go b/internal/transfers/transfers.go new file mode 100644 index 0000000..690a8df --- /dev/null +++ b/internal/transfers/transfers.go @@ -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 +} diff --git a/internal/transfers/transfers_test.go b/internal/transfers/transfers_test.go new file mode 100644 index 0000000..c1f1658 --- /dev/null +++ b/internal/transfers/transfers_test.go @@ -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) + } +} diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 6c3fcab..8be40b2 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -6,6 +6,7 @@ import ( "fmt" "slices" "sort" + "strconv" "strings" "github.com/charmbracelet/bubbles/spinner" @@ -21,6 +22,7 @@ import ( "git.petrovv.com/nikola/money/internal/report" "git.petrovv.com/nikola/money/internal/rules" "git.petrovv.com/nikola/money/internal/store" + "git.petrovv.com/nikola/money/internal/transfers" ) type view int @@ -31,8 +33,10 @@ const ( viewReport viewRules viewRuleList + viewTransfers + viewTransferList - viewCount = 5 + viewCount = 7 ) // confirmation is a pending destructive action awaiting a y/n answer. @@ -44,6 +48,8 @@ const ( confirmNone confirmation = iota confirmDeleteRule confirmPruneRules + confirmDeleteTransfer + confirmPruneTransfers ) // input is the modal state: the transaction list is read-only until the user @@ -61,6 +67,7 @@ type Model struct { db *store.DB accounts []*config.Account engine *rules.Engine + links *transfers.Engine view view input input @@ -88,6 +95,10 @@ type Model struct { ruleReturn view // the view to go back to on esc untagged []descGroup ruleMatches int // untagged descriptions the current glob matches + // ruleCandidates is how many were in view before the glob filtered them, + // which the count needs: the preview now shows only matches, so the rows on + // screen can no longer say what they were chosen out of. + ruleCandidates int // Rule list: every rule with the number of transactions it actually // claims, so dead ones can be found and removed. @@ -95,6 +106,32 @@ type Model struct { ruleUsage []int confirm confirmation + // Transfer builder: the two sides of a movement on the left, and on the + // right the pairs it would form out of what is already imported, together + // with the legs it would catch but leave unpaired. + transferFrom textinput.Model + transferFromDesc textinput.Model + transferTo textinput.Model + transferToDesc textinput.Model + transferTolerance textinput.Model + transferNote textinput.Model + transferFocus int + transferTable table.Model + transferReturn view + // allTxns is every transaction in the index, held so the preview can pair + // across accounts without going back to the database on each keystroke. + allTxns []model.Transaction + previewPairs int + previewUnmatched int + // previewFees is what the draft's pairs would lose to fees. Every leaving + // leg is on one account, so there is a single currency to render it in. + previewFees int64 + previewFeeDigits int + + // Transfer list: every definition with what it currently pairs. + transferListTable table.Model + transferResult transfers.Result + filter store.Filter onlyUntagged bool @@ -120,14 +157,20 @@ var ( BorderForeground(lipgloss.Color("240")).Padding(0, 1).Width(ruleInputWidth + 2) focusedBoxStyle = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()). BorderForeground(lipgloss.Color("62")).Padding(0, 1).Width(ruleInputWidth + 2) + // flatBoxStyle is the same field with its border dropped, for a form too + // tall for the terminal. The padding puts the value in the same column the + // border's own padding would, so the fields still line up beside a boxed one. + flatBoxStyle = lipgloss.NewStyle().PaddingLeft(2).Width(ruleInputWidth + 2) hintStyle = lipgloss.NewStyle().Faint(true).PaddingLeft(2) matchCountStyle = lipgloss.NewStyle().Bold(true).PaddingLeft(2) ruleFormStyle = lipgloss.NewStyle().Width(ruleFormWidth) ) // Run starts the interface. -func Run(root string, db *store.DB, accounts []*config.Account, engine *rules.Engine) error { - m := New(root, db, accounts, engine) +func Run(root string, db *store.DB, accounts []*config.Account, engine *rules.Engine, + links *transfers.Engine) error { + + m := New(root, db, accounts, engine, links) if err := m.reload(); err != nil { return err } @@ -136,7 +179,9 @@ func Run(root string, db *store.DB, accounts []*config.Account, engine *rules.En } // New builds the root model. -func New(root string, db *store.DB, accounts []*config.Account, engine *rules.Engine) *Model { +func New(root string, db *store.DB, accounts []*config.Account, engine *rules.Engine, + links *transfers.Engine) *Model { + ti := textinput.New() ti.Prompt = "" ti.CharLimit = 64 @@ -176,6 +221,7 @@ func New(root string, db *store.DB, accounts []*config.Account, engine *rules.En db: db, accounts: accounts, engine: engine, + links: links, view: viewAccounts, text: ti, spinner: sp, @@ -210,6 +256,30 @@ func New(root string, db *store.DB, accounts []*config.Account, engine *rules.En {Title: "Txns", Width: 6}, {Title: "Note", Width: 24}, }), + transferFrom: completing(newInput("nlb")), + transferFromDesc: newInput("*TO REVOLUT*"), + transferTo: completing(newInput("revolut")), + transferToDesc: newInput("*FROM NLB*"), + transferTolerance: newInput("0"), + transferNote: newInput("optional"), + transferTable: newTable([]table.Column{ + {Title: " ", Width: 1}, + {Title: "Date", Width: 10}, + // Wide enough for both sides of an exchange, e.g. "500.00 → 977.90". + {Title: "Amount", Width: 20}, + {Title: "Movement", Width: 22}, + {Title: "Description", Width: 30}, + }), + transferListTable: newTable([]table.Column{ + {Title: "#", Width: 3}, + {Title: " ", Width: 1}, + {Title: "From", Width: 26}, + {Title: "To", Width: 26}, + {Title: "Pairs", Width: 6}, + {Title: "Unpaired", Width: 9}, + {Title: "Tol", Width: 6}, + {Title: "Note", Width: 20}, + }), reportTable: newTable([]table.Column{ {Title: "Tag", Width: 20}, {Title: "Cur", Width: 4}, @@ -267,7 +337,7 @@ func (m *Model) reloadTxns() error { rows := make([]table.Row, 0, len(txns)) for _, t := range txns { rows = append(rows, table.Row{ - t.Date, t.AccountSlug, t.FormatAmount(), t.RuleTag, t.Description, + t.Date, t.AccountSlug, t.FormatAmount(), t.DisplayTag(), t.Description, }) } // Keep the cursor in range after the row count shrinks (e.g. a new rule @@ -286,6 +356,15 @@ func (m *Model) reloadTxns() error { return nil } +// The two rows the report grows below TOTAL. They are bracketed like +// report.Untagged and model.TransferTag because they are the same kind of +// thing: a label the tool supplies for something no rule named. Neither is a +// tag, and neither can be matched or completed against. +const ( + transfersRow = "(transfers)" + feesRow = " ⤷ fees" +) + func (m *Model) reloadReport(txns []model.Transaction) { rows := report.ByTag(txns) out := make([]table.Row, 0, len(rows)+2) @@ -307,6 +386,26 @@ func (m *Model) reloadReport(txns []model.Transaction) { "", }) } + // Below the total, what ByTag held out — otherwise the report quietly + // disagrees with the account balances by the amount moved between accounts, + // and by any fee a tolerant definition swallowed along with it. + for _, x := range report.Excluded(txns) { + out = append(out, table.Row{ + transfersRow, x.Currency, + model.FormatMinor(x.Out, x.Digits), + model.FormatMinor(x.In, x.Digits), + model.FormatMinor(x.In-x.Out, x.Digits), + fmt.Sprintf("%d", x.Legs), + }) + if x.Fee != 0 { + out = append(out, table.Row{ + feesRow, x.Currency, + model.FormatMinor(x.Fee, x.Digits), "", + model.FormatMinor(-x.Fee, x.Digits), + fmt.Sprintf("%d", x.Pairs), + }) + } + } m.reportTable.SetRows(out) } @@ -365,16 +464,23 @@ func (m *Model) reloadUntagged() error { return nil } -// refreshRulePreview re-marks the preview against whatever is typed right now. -// It runs on every keystroke, so the glob is checked against descriptions only, -// never against the database. +// refreshRulePreview re-filters the preview against whatever is typed right +// now. It runs on every keystroke, so the glob is checked against descriptions +// only, never against the database. +// +// Once a glob is typed the list is exactly what the rule would claim: the +// non-matching rows go, rather than staying on as unmarked context. With the +// glob still empty there is nothing to filter by, so the list is everything +// still waiting for a rule — which is the other question this screen answers. +// Either way the count beside the form is measured against everything in view, +// so a glob that has narrowed the list to three of forty still says so. func (m *Model) refreshRulePreview() { var ( pattern = strings.TrimSpace(m.ruleGlob.Value()) account = strings.TrimSpace(m.ruleAccount.Value()) rows = make([]table.Row, 0, len(m.untagged)) ) - m.ruleMatches = 0 + m.ruleMatches, m.ruleCandidates = 0, 0 for _, g := range m.untagged { // An account filter narrows the preview the same way the saved rule @@ -382,8 +488,13 @@ func (m *Model) refreshRulePreview() { if account != "" && !g.Accounts[account] { continue } + m.ruleCandidates++ + marker := " " - if pattern != "" && glob.Match(pattern, model.NormalizeDescription(g.Description)) { + if pattern != "" { + if !glob.Match(pattern, model.NormalizeDescription(g.Description)) { + continue + } marker = "▸" m.ruleMatches++ } @@ -423,14 +534,9 @@ func (m *Model) saveRule() error { if err := config.AppendRule(m.root, r); err != nil { return err } - - // Re-read the file rather than appending to the in-memory engine, so what - // runs is exactly what is now on disk. - loaded, err := config.LoadRules(m.root) - if err != nil { + if err := m.reloadConfig(); err != nil { return fmt.Errorf("rule saved, but re-reading rules.toml failed: %w", err) } - m.engine = rules.New(loaded) n, err := m.engine.Retag(m.db) if err != nil { @@ -448,6 +554,19 @@ func (m *Model) saveRule() error { return m.reload() } +// reloadConfig re-reads rules.toml and rebuilds both engines from it, rather +// than patching the in-memory ones, so what runs is exactly what is now on +// disk. Both are rebuilt together because both come out of the same file. +func (m *Model) reloadConfig() error { + loaded, err := config.LoadRules(m.root) + if err != nil { + return err + } + m.engine = rules.New(loaded) + m.links = transfers.New(loaded) + return nil +} + func (m *Model) knownAccount(slug string) bool { return slices.Contains(m.accountSlugs(), slug) } @@ -528,12 +647,11 @@ func pendingCompletion(in *textinput.Model) string { return s } -// acceptCompletion takes the offered completion into the focused field, -// reporting whether there was one. The suggestion list is re-set afterwards -// because SetValue does not re-match it, which would otherwise leave ctrl+n -// cycling through candidates that no longer share the new prefix. -func (m *Model) acceptCompletion() bool { - in := m.ruleInputs()[m.ruleFocus] +// acceptCompletion takes the offered completion into the field, reporting +// whether there was one. The suggestion list is re-set afterwards because +// SetValue does not re-match it, which would otherwise leave ctrl+n cycling +// through candidates that no longer share the new prefix. +func acceptCompletion(in *textinput.Model) bool { s := pendingCompletion(in) if s == "" { return false @@ -615,12 +733,9 @@ func (m *Model) deleteRules(positions []int) error { if err != nil { return err } - - loaded, err := config.LoadRules(m.root) - if err != nil { + if err := m.reloadConfig(); err != nil { return fmt.Errorf("rules deleted, but re-reading rules.toml failed: %w", err) } - m.engine = rules.New(loaded) retagged, err := m.engine.Retag(m.db) if err != nil { @@ -688,6 +803,11 @@ func (m *Model) updateRuleList(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil case "4": return m, m.openRuleBuilder() + case "6": + return m, m.openTransferBuilder() + case "7": + m.openTransferList() + return m, nil case "d": i := m.ruleListTable.Cursor() @@ -747,18 +867,525 @@ func (m *Model) ruleInputs() []*textinput.Model { } // setRuleFocus moves the cursor between the form fields, wrapping around. -func (m *Model) setRuleFocus(i int) { - inputs := m.ruleInputs() +func (m *Model) setRuleFocus(i int) { m.ruleFocus = focusField(m.ruleInputs(), i) } + +// focusField gives the cursor to one field of a form and takes it from the +// rest, wrapping the index so tabbing past either end comes back round. +func focusField(inputs []*textinput.Model, i int) int { n := len(inputs) - m.ruleFocus = ((i % n) + n) % n + focus := ((i % n) + n) % n for j, in := range inputs { - if j == m.ruleFocus { + if j == focus { in.Focus() in.CursorEnd() continue } in.Blur() } + return focus +} + +// openTransferBuilder switches to the transfer builder, remembering where to +// return to and loading the transactions the preview pairs against. +func (m *Model) openTransferBuilder() tea.Cmd { + if m.view != viewTransfers { + m.transferReturn = m.view + } + m.view = viewTransfers + m.setTransferFocus(0) + + slugs := m.accountSlugs() + m.transferFrom.SetSuggestions(slugs) + m.transferTo.SetSuggestions(slugs) + + // The preview pairs across accounts, so it needs everything, not the + // filtered view the transaction list is showing. + txns, err := m.db.Transactions(store.Filter{}) + if err != nil { + m.err = err + } + m.allTxns = txns + m.refreshTransferPreview() + return textinput.Blink +} + +// transferInputs lists the form fields in tab order: the two sides in the +// order the money travels, then the tolerance and the note. +func (m *Model) transferInputs() []*textinput.Model { + return []*textinput.Model{ + &m.transferFrom, &m.transferFromDesc, + &m.transferTo, &m.transferToDesc, + &m.transferTolerance, &m.transferNote, + } +} + +// setTransferFocus moves the cursor between the form fields, wrapping around. +func (m *Model) setTransferFocus(i int) { + m.transferFocus = focusField(m.transferInputs(), i) +} + +// draftTransfer is the definition the form currently describes. A tolerance +// that does not parse yet previews as none at all — the field is retyped a +// character at a time, and "1." must not stop the preview from updating. +// saveTransfer is where a bad value is refused. +func (m *Model) draftTransfer() config.Transfer { + pct, _ := m.draftTolerance() + return config.Transfer{ + FromAccount: strings.TrimSpace(m.transferFrom.Value()), + FromDesc: strings.TrimSpace(m.transferFromDesc.Value()), + ToAccount: strings.TrimSpace(m.transferTo.Value()), + ToDesc: strings.TrimSpace(m.transferToDesc.Value()), + TolerancePct: pct, + Note: strings.TrimSpace(m.transferNote.Value()), + } +} + +// draftTolerance reads the tolerance field. Empty means none, which is what a +// definition without the key means too. +func (m *Model) draftTolerance() (float64, error) { + s := strings.TrimSpace(strings.TrimSuffix(strings.TrimSpace(m.transferTolerance.Value()), "%")) + if s == "" { + return 0, nil + } + pct, err := strconv.ParseFloat(s, 64) + if err != nil { + return 0, fmt.Errorf("tolerance %q is not a number", s) + } + return pct, nil +} + +// refreshTransferPreview re-pairs against whatever is typed right now. +// +// The draft is analysed *after* the definitions already on disk, exactly where +// saving would put it, so the preview cannot promise pairs that an existing +// definition would claim first. A half-written definition still previews: the +// side that is filled in shows its legs as unpaired, which is the fastest way +// to see that a glob is wrong. +func (m *Model) refreshTransferPreview() { + draft := m.draftTransfer() + defs := append(append([]config.Transfer(nil), m.links.Transfers()...), draft) + res := transfers.New(&config.Rules{Transfer: defs}).Analyze(m.allTxns) + mine := len(defs) - 1 + + type entry struct { + date string + row table.Row + } + var entries []entry + m.previewFees, m.previewFeeDigits = 0, 0 + for _, p := range res.Pairs { + if p.Def != mine { + continue + } + m.previewFees += p.Fee() + m.previewFeeDigits = p.Out.MinorDigits + // Whenever the two legs disagree, both numbers are worth seeing: across + // currencies they are the only place the rate the bank used shows up, + // and within one they are the fee a tolerance let through, which is + // exactly the thing to eyeball before saving the definition. + amount := model.FormatMinor(-p.Out.AmountMinor, p.Out.MinorDigits) + if p.In.Currency != p.Out.Currency || p.Fee() != 0 { + amount += " → " + model.FormatMinor(p.In.AmountMinor, p.In.MinorDigits) + } + entries = append(entries, entry{p.Out.Date, table.Row{ + "▸", p.Out.Date, amount, + p.Out.AccountSlug + " → " + p.In.AccountSlug, p.Out.Description, + }}) + } + for _, l := range res.Unmatched { + if l.Def != mine { + continue + } + amount, movement := l.Txn.AmountMinor, "? → "+l.Txn.AccountSlug + if l.Out { + amount, movement = -amount, l.Txn.AccountSlug+" → ?" + } + entries = append(entries, entry{l.Txn.Date, table.Row{ + "⚠", l.Txn.Date, model.FormatMinor(amount, l.Txn.MinorDigits), + movement, l.Txn.Description, + }}) + } + // Newest first, like the transaction list: the movement worth checking is + // usually the one that just came in. + sort.SliceStable(entries, func(i, j int) bool { return entries[i].date > entries[j].date }) + + rows := make([]table.Row, 0, len(entries)) + for _, e := range entries { + rows = append(rows, e.row) + } + m.previewPairs = res.Paired[mine] + m.previewUnmatched = res.Orphaned[mine] + + cursor := m.transferTable.Cursor() + m.transferTable.SetRows(rows) + if cursor >= len(rows) { + cursor = len(rows) - 1 + } + if cursor < 0 { + cursor = 0 + } + m.transferTable.SetCursor(cursor) +} + +// saveTransfer appends the composed definition to rules.toml, re-pairs, and +// leaves the two account fields filled in, since the next transfer written is +// usually the same route in the other direction. +func (m *Model) saveTransfer() error { + t := m.draftTransfer() + switch { + case t.FromAccount == "": + return fmt.Errorf("name the account the money leaves") + case t.FromDesc == "": + return fmt.Errorf("enter a glob for the leaving leg, e.g. *TO REVOLUT*") + case t.ToAccount == "": + return fmt.Errorf("name the account the money arrives in") + case t.ToDesc == "": + return fmt.Errorf("enter a glob for the arriving leg, e.g. *FROM NLB*") + } + for _, slug := range []string{t.FromAccount, t.ToAccount} { + if !m.knownAccount(slug) { + return fmt.Errorf("no account called %q", slug) + } + } + // draftTransfer swallowed this so the preview could keep up with typing; + // saving is where a value that never became a number has to be refused, + // rather than written out as a silent 0. + if _, err := m.draftTolerance(); err != nil { + return err + } + + if err := config.AppendTransfer(m.root, t); err != nil { + return err + } + if err := m.reloadConfig(); err != nil { + return fmt.Errorf("transfer saved, but re-reading rules.toml failed: %w", err) + } + + paired, unpaired, err := m.links.Link(m.db) + if err != nil { + return err + } + m.status = fmt.Sprintf("saved transfer %s → %s, %d matched, %d leg(s) unpaired", + t.FromAccount, t.ToAccount, paired, unpaired) + + m.transferFromDesc.SetValue("") + m.transferToDesc.SetValue("") + // Cleared with the globs rather than kept with the accounts: a tolerance + // carried silently into the next definition would loosen a route that never + // asked for one, and the reverse direction rarely charges the same fee. + m.transferTolerance.SetValue("") + m.transferNote.SetValue("") + // The accounts are kept, so land on the first field that was cleared. + m.setTransferFocus(1) + + if err := m.reload(); err != nil { + return err + } + txns, err := m.db.Transactions(store.Filter{}) + if err != nil { + return err + } + m.allTxns = txns + m.refreshTransferPreview() + return nil +} + +// openTransferList switches to the transfer list, remembering where to return. +func (m *Model) openTransferList() { + if m.view != viewTransferList { + m.transferReturn = m.view + } + m.view = viewTransferList + m.confirm = confirmNone + if err := m.reloadTransferList(); err != nil { + m.err = err + } +} + +// reloadTransferList recomputes what every definition currently pairs. +// +// Two counts are shown because they mean different things: a definition with +// no pairs and no legs matches nothing at all and can go, while one with +// unpaired legs is catching money leaving that never arrives — a wrong glob on +// the other side, a statement not imported yet, or a movement that genuinely +// went missing. +func (m *Model) reloadTransferList() error { + txns, err := m.db.Transactions(store.Filter{}) + if err != nil { + return err + } + defs := m.links.Transfers() + m.transferResult = m.links.Analyze(txns) + + rows := make([]table.Row, 0, len(defs)) + for i, t := range defs { + marker := " " + switch { + case m.transferResult.Orphaned[i] > 0: + marker = "⚠" + case m.transferResult.Paired[i] == 0: + marker = "✗" + } + rows = append(rows, table.Row{ + fmt.Sprintf("%d", i+1), marker, + t.FromAccount + " " + t.FromDesc, + t.ToAccount + " " + t.ToDesc, + fmt.Sprintf("%d", m.transferResult.Paired[i]), + fmt.Sprintf("%d", m.transferResult.Orphaned[i]), + formatTolerance(t.TolerancePct), + t.Note, + }) + } + + cursor := m.transferListTable.Cursor() + m.transferListTable.SetRows(rows) + if cursor >= len(rows) { + cursor = len(rows) - 1 + } + if cursor < 0 { + cursor = 0 + } + m.transferListTable.SetCursor(cursor) + return nil +} + +// formatTolerance renders a definition's tolerance for the list. The default +// is blank rather than "0%": every definition has it, so printing it down the +// whole column would bury the one or two rows where the amounts are actually +// allowed to disagree. +func formatTolerance(pct float64) string { + if pct == 0 { + return "" + } + return strconv.FormatFloat(pct, 'f', -1, 64) + "%" +} + +// unusedTransfers lists the positions of every definition that catches nothing +// at all. A definition with unpaired legs is deliberately not in here: it is +// doing something, just not completing it, and deleting it would hide the +// problem instead of fixing it. +func (m *Model) unusedTransfers() []int { + var out []int + for i, n := range m.transferResult.Paired { + if n == 0 && m.transferResult.Orphaned[i] == 0 { + out = append(out, i) + } + } + return out +} + +// unpairedLegs counts the legs no definition could complete. +func (m *Model) unpairedLegs() int { return len(m.transferResult.Unmatched) } + +// deleteTransfers removes definitions from rules.toml, then re-pairs so the +// counts on screen reflect the new file. +func (m *Model) deleteTransfers(positions []int) error { + n, err := config.DeleteTransfers(m.root, positions) + if err != nil { + return err + } + if err := m.reloadConfig(); err != nil { + return fmt.Errorf("transfers deleted, but re-reading rules.toml failed: %w", err) + } + + paired, unpaired, err := m.links.Link(m.db) + if err != nil { + return err + } + m.status = fmt.Sprintf("deleted %d transfer(s), %d matched, %d leg(s) unpaired", + n, paired, unpaired) + + if err := m.reload(); err != nil { + return err + } + return m.reloadTransferList() +} + +// updateTransferList drives the transfer list, including the confirmations. +func (m *Model) updateTransferList(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + if m.confirm != confirmNone { + pending := m.confirm + m.confirm = confirmNone + if msg.String() != "y" { + m.status = "cancelled" + return m, nil + } + m.err = nil + switch pending { + case confirmDeleteTransfer: + if i := m.transferListTable.Cursor(); i >= 0 && i < len(m.links.Transfers()) { + if err := m.deleteTransfers([]int{i}); err != nil { + m.err = err + } + } + case confirmPruneTransfers: + if err := m.deleteTransfers(m.unusedTransfers()); err != nil { + m.err = err + } + } + return m, nil + } + + switch msg.String() { + case "q", "ctrl+c": + return m, tea.Quit + case "esc": + m.view = m.transferReturn + return m, nil + case "1": + m.view = viewAccounts + return m, nil + case "2": + m.view = viewTxns + return m, nil + case "3": + m.view = viewReport + return m, nil + case "4": + return m, m.openRuleBuilder() + case "5": + m.openRuleList() + return m, nil + case "6": + return m, m.openTransferBuilder() + + case "d": + i := m.transferListTable.Cursor() + defs := m.links.Transfers() + if i < 0 || i >= len(defs) { + return m, nil + } + m.confirm = confirmDeleteTransfer + m.status = fmt.Sprintf("delete transfer %d (%s → %s), %d matched? y/n", + i+1, defs[i].FromAccount, defs[i].ToAccount, m.transferResult.Paired[i]) + return m, nil + + case "p": + unused := m.unusedTransfers() + if len(unused) == 0 { + m.status = "no transfers to prune; only ones matching nothing at all are pruned" + return m, nil + } + m.confirm = confirmPruneTransfers + m.status = fmt.Sprintf("delete all %d transfers that match nothing? y/n", len(unused)) + return m, nil + + case "r": + m.err = m.reloadTransferList() + m.status = "pairing refreshed" + return m, nil + } + + var cmd tea.Cmd + m.transferListTable, cmd = m.transferListTable.Update(msg) + return m, cmd +} + +// updateTransfers drives the transfer builder form. Like the rule builder it +// owns every printable key, so the global keymap must not reach it. +func (m *Model) updateTransfers(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "ctrl+c": + return m, tea.Quit + case "esc": + m.view = m.transferReturn + return m, nil + case "tab": + if acceptCompletion(m.transferInputs()[m.transferFocus]) { + m.refreshTransferPreview() + return m, nil + } + m.setTransferFocus(m.transferFocus + 1) + return m, nil + case "down": + m.setTransferFocus(m.transferFocus + 1) + return m, nil + case "shift+tab", "up": + m.setTransferFocus(m.transferFocus - 1) + return m, nil + case "right": + in := m.transferInputs()[m.transferFocus] + if in.Position() == len([]rune(in.Value())) && acceptCompletion(in) { + m.refreshTransferPreview() + return m, nil + } + case "pgdown": + m.transferTable.MoveDown(10) + return m, nil + case "pgup": + m.transferTable.MoveUp(10) + return m, nil + case "enter": + m.err = nil + if err := m.saveTransfer(); err != nil { + m.err = err + } + return m, nil + } + + var cmd tea.Cmd + inputs := m.transferInputs() + *inputs[m.transferFocus], cmd = inputs[m.transferFocus].Update(msg) + m.refreshTransferPreview() + return m, cmd +} + +// transfersView puts the form on the left and the live pairing on the right. +func (m *Model) transfersView() string { + return lipgloss.JoinHorizontal(lipgloss.Top, m.transferFormView(), m.transferTable.View()) +} + +func (m *Model) transferFormView() string { + // Six fields need more room than the rule builder's four, so the form gives + // ground in three stages: the blank lines first, then the hints on unfocused + // fields, then their borders. Labels and values never go — six anonymous + // boxes would be worse than a form that scrolls. + room := m.height - 6 + spaced := m.height <= 0 || room >= 37 + hints := m.height <= 0 || room >= 31 + boxed := m.height <= 0 || room >= 25 + + inputs := m.transferInputs() + field := func(i int, label, help string) string { + name := labelStyle.Render(" " + label) + box := flatBoxStyle.Render(inputs[i].View()) + if boxed { + box = boxStyle.Render(inputs[i].View()) + } + if i == m.transferFocus { + name = focusedLabelStyle.Render("▸ " + label) + box = focusedBoxStyle.Render(inputs[i].View()) + } + out := name + "\n" + box + "\n" + if hints || i == m.transferFocus { + out += hintStyle.Render(help) + "\n" + } + if spaced { + out += "\n" + } + return out + } + + var b strings.Builder + b.WriteString(field(0, "from account", completionHint(inputs[0], m.transferFocus == 0, "money leaves here"))) + b.WriteString(field(1, "from desc", "glob vs. the leaving leg")) + b.WriteString(field(2, "to account", completionHint(inputs[2], m.transferFocus == 2, "money arrives here"))) + b.WriteString(field(3, "to desc", "glob vs. the arriving leg")) + b.WriteString(field(4, "tolerance %", "0 = amounts must match exactly")) + b.WriteString(field(5, "note", "why this transfer exists")) + + summary := fmt.Sprintf("%d pairs · %d unpaired", m.previewPairs, m.previewUnmatched) + if m.previewPairs == 0 && m.previewUnmatched == 0 { + summary = "nothing matches yet" + } + // Fees are the reason the tolerance field exists, so the preview says what + // it is admitting rather than only how many pairs it bought. + if fee := m.previewFees; fee != 0 { + summary += fmt.Sprintf(" · %s in fees", model.FormatMinor(fee, m.previewFeeDigits)) + } + b.WriteString(matchCountStyle.Render(summary)) + + return ruleFormStyle.Render(b.String()) } // selected returns the transaction under the cursor, if any. @@ -779,9 +1406,9 @@ type importDoneMsg struct { // importCmd runs the import off the event loop. Only values are captured, and // the model is left untouched until the result comes back as a message. func (m *Model) importCmd() tea.Cmd { - root, db, accounts, engine := m.root, m.db, m.accounts, m.engine + root, db, accounts, engine, links := m.root, m.db, m.accounts, m.engine, m.links return func() tea.Msg { - res, err := importer.Run(root, db, accounts, engine, importer.Options{}) + res, err := importer.Run(root, db, accounts, engine, links, importer.Options{}) return importDoneMsg{res: res, err: err} } } @@ -809,13 +1436,17 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.input != inputNone { return m.updateInput(msg) } - // The rule builder is a form: every printable key belongs to the - // focused input, so the global single-letter keymap cannot apply. - if m.view == viewRules { + // The builders are forms: every printable key belongs to the focused + // input, so the global single-letter keymap cannot apply. + switch m.view { + case viewRules: return m.updateRules(msg) - } - if m.view == viewRuleList { + case viewTransfers: + return m.updateTransfers(msg) + case viewRuleList: return m.updateRuleList(msg) + case viewTransferList: + return m.updateTransferList(msg) } return m.updateNormal(msg) } @@ -834,7 +1465,7 @@ func (m *Model) updateRules(msg tea.KeyMsg) (tea.Model, tea.Cmd) { // Tab is overloaded on purpose: with a completion on offer it takes // it, and moving on is then one more tab. Without one it does what it // always did and moves to the next field. - if m.acceptCompletion() { + if acceptCompletion(m.ruleInputs()[m.ruleFocus]) { m.refreshRulePreview() return m, nil } @@ -851,7 +1482,7 @@ func (m *Model) updateRules(msg tea.KeyMsg) (tea.Model, tea.Cmd) { // completion the way a shell does. Anywhere else it falls through and // moves the cursor. in := m.ruleInputs()[m.ruleFocus] - if in.Position() == len([]rune(in.Value())) && m.acceptCompletion() { + if in.Position() == len([]rune(in.Value())) && acceptCompletion(in) { m.refreshRulePreview() return m, nil } @@ -890,6 +1521,9 @@ func (m *Model) finishImport(msg importDoneMsg) (tea.Model, tea.Cmd) { _, added, skipped := msg.res.Total() m.status = fmt.Sprintf("imported: %d new, %d duplicate", added, skipped) + if msg.res.Unpaired > 0 { + m.status += fmt.Sprintf(" · %d transfer leg(s) unpaired", msg.res.Unpaired) + } // A failed file and a warning are both worth surfacing, but the status // line only has room for the first thing that went wrong. @@ -932,6 +1566,8 @@ func (m *Model) resize() { m.reportTable.SetHeight(h) m.ruleTable.SetHeight(h) m.ruleListTable.SetHeight(h) + m.transferTable.SetHeight(h) + m.transferListTable.SetHeight(h) // The preview list gets whatever the form does not use. if m.width > 0 { @@ -942,11 +1578,24 @@ func (m *Model) resize() { } cols[1].Width = desc m.ruleTable.SetColumns(cols) + + cols = m.transferTable.Columns() + fixed := 0 + for _, c := range cols[:len(cols)-1] { + fixed += c.Width + 2 + } + desc = m.width - ruleFormWidth - fixed - 6 + if desc < 16 { + desc = 16 + } + cols[len(cols)-1].Width = desc + m.transferTable.SetColumns(cols) } // The free-text columns come last and get whatever is left over. m.stretchLastColumn(&m.txnTable, 20) m.stretchLastColumn(&m.ruleListTable, 12) + m.stretchLastColumn(&m.transferListTable, 12) } // stretchLastColumn widens a table's final column to fill the window, down to @@ -1020,6 +1669,11 @@ func (m *Model) updateNormal(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case "5": m.openRuleList() return m, nil + case "6": + return m, m.openTransferBuilder() + case "7": + m.openTransferList() + return m, nil case "tab": m.view = (m.view + 1) % viewCount switch m.view { @@ -1027,6 +1681,10 @@ func (m *Model) updateNormal(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, m.openRuleBuilder() case viewRuleList: m.openRuleList() + case viewTransfers: + return m, m.openTransferBuilder() + case viewTransferList: + m.openTransferList() } return m, nil @@ -1083,7 +1741,18 @@ func (m *Model) updateNormal(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.err = err return m, nil } - m.status = fmt.Sprintf("rules re-applied, %d rows changed", n) + // Tags and transfers are both derived from rules.toml, so one key + // re-derives both; leaving the pairing stale would quietly change what + // the report holds out. + paired, unpaired, err := m.links.Link(m.db) + if err != nil { + m.err = err + return m, nil + } + m.status = fmt.Sprintf("rules re-applied, %d rows changed, %d transfers matched", n, paired) + if unpaired > 0 { + m.status += fmt.Sprintf(", %d leg(s) unpaired", unpaired) + } m.err = m.reload() return m, nil @@ -1132,6 +1801,10 @@ func (m *Model) View() string { b.WriteString(m.rulesView()) case viewRuleList: b.WriteString(m.ruleListTable.View()) + case viewTransfers: + b.WriteString(m.transfersView()) + case viewTransferList: + b.WriteString(m.transferListTable.View()) } } b.WriteString("\n") @@ -1191,16 +1864,17 @@ func (m *Model) ruleFormView() string { } var b strings.Builder + inputs := m.ruleInputs() b.WriteString(field(0, "glob", "vs. the description")) - b.WriteString(field(1, "account", m.completionHint(1, "blank = all accounts"))) - b.WriteString(field(2, "tag", m.completionHint(2, "applied to matches"))) + b.WriteString(field(1, "account", completionHint(inputs[1], m.ruleFocus == 1, "blank = all accounts"))) + b.WriteString(field(2, "tag", completionHint(inputs[2], m.ruleFocus == 2, "applied to matches"))) b.WriteString(field(3, "note", "why this rule exists")) // The count is the whole point of the preview: it says what the rule will // do before it is written to disk. - summary := fmt.Sprintf("%d of %d descriptions match", m.ruleMatches, len(m.ruleTable.Rows())) + summary := fmt.Sprintf("%d of %d descriptions match", m.ruleMatches, m.ruleCandidates) if strings.TrimSpace(m.ruleGlob.Value()) == "" { - summary = fmt.Sprintf("%d untagged descriptions", len(m.ruleTable.Rows())) + summary = fmt.Sprintf("%d untagged descriptions", m.ruleCandidates) } b.WriteString(matchCountStyle.Render(summary)) @@ -1210,11 +1884,10 @@ func (m *Model) ruleFormView() string { // completionHint names the key that accepts the ghosted completion, replacing // the field's usual hint while one is on offer. The hint sits directly under // the box the ghost text appears in, which is where the question is asked. -func (m *Model) completionHint(i int, fallback string) string { - if i != m.ruleFocus { +func completionHint(in *textinput.Model, focused bool, fallback string) string { + if !focused { return fallback } - in := m.ruleInputs()[i] if pendingCompletion(in) == "" { return fallback } @@ -1255,7 +1928,9 @@ func (m *Model) emptyMessage() string { case m.filter.Search != "": return fmt.Sprintf("No transactions match /%s.\n\nPress / to change the search.", m.filter.Search) case m.onlyUntagged: - return "Nothing untagged.\n\nEvery transaction here has a tag. Press u to see them all." + return "Nothing untagged.\n\n" + + "Every transaction here has a tag or belongs to a transfer.\n" + + "Press u to see them all." case m.filter.AccountSlug != "": return fmt.Sprintf( "No transactions in %s.\n\nPress i to import, or a to see every account.", @@ -1274,6 +1949,15 @@ func (m *Model) emptyMessage() string { return "" } return "No rules yet.\n\nPress 4 to build one, or write rules.toml by hand." + + case viewTransferList: + if len(m.transferListTable.Rows()) > 0 { + return "" + } + return "No transfers yet.\n\n" + + "A transfer names both legs of money moved between your own accounts,\n" + + "so the report can leave the pair out instead of counting it as spending.\n" + + "Press 6 to build one, or write rules.toml by hand." } return "" } @@ -1301,6 +1985,14 @@ func (m *Model) title() string { return fmt.Sprintf("money · rules · %d rules, all in use", len(m.engine.Rules())) } return fmt.Sprintf("money · rules · %d rules · %d match nothing", len(m.engine.Rules()), unused) + case viewTransfers: + return "money · transfer builder · writes to rules.toml" + case viewTransferList: + n := len(m.links.Transfers()) + if legs := m.unpairedLegs(); legs > 0 { + return fmt.Sprintf("money · transfers · %d definitions · %d leg(s) unpaired", n, legs) + } + return fmt.Sprintf("money · transfers · %d definitions · every leg paired", n) case viewReport: return "money · report · " + scope default: @@ -1326,17 +2018,24 @@ func (m *Model) help() string { } switch m.view { case viewAccounts: - return "enter open · 2 transactions · 3 report · 4 new rule · 5 rules · i import · r retag · q quit" + return "enter open · 2 transactions · 3 report · 4 new rule · 5 rules · 6 new transfer · 7 transfers · i import · r retag · q quit" case viewRules: return "tab complete/next field · ↑↓ field · ctrl+n/p other completions · pgup/pgdn scroll list · enter save rule · esc back · ctrl+c quit" case viewRuleList: if m.confirm != confirmNone { return "y confirm · any other key cancels" } - return "d delete rule · p prune all unused · r refresh counts · 4 new rule · 1 accounts · esc back · q quit" + return "d delete rule · p prune all unused · r refresh counts · 4 new rule · 7 transfers · 1 accounts · esc back · q quit" + case viewTransfers: + return "tab complete/next field · ↑↓ field · pgup/pgdn scroll pairs · enter save transfer · esc back · ctrl+c quit" + case viewTransferList: + if m.confirm != confirmNone { + return "y confirm · any other key cancels" + } + return "d delete transfer · p prune all unmatched · r refresh pairing · 6 new transfer · 5 rules · 1 accounts · esc back · q quit" case viewReport: - return "1 accounts · 2 transactions · 4 new rule · 5 rules · u untagged · a all accounts · q quit" + return "1 accounts · 2 transactions · 4 new rule · 5 rules · 7 transfers · u untagged · a all accounts · q quit" default: - return "4 new rule · / search · u untagged · a all · i import · r retag · 5 rules · q quit" + return "4 new rule · / search · u untagged · a all · i import · r retag · 5 rules · 7 transfers · q quit" } } diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index f1c4901..2b1caee 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -18,6 +18,7 @@ import ( "git.petrovv.com/nikola/money/internal/report" "git.petrovv.com/nikola/money/internal/rules" "git.petrovv.com/nikola/money/internal/store" + "git.petrovv.com/nikola/money/internal/transfers" ) // 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) } - m := New(t.TempDir(), db, nil, engine) + m := New(t.TempDir(), db, nil, engine, transfers.New(&config.Rules{})) if err := m.reload(); err != nil { t.Fatal(err) } @@ -363,7 +364,7 @@ func newRuleModel(t *testing.T) (*Model, *store.DB, string) { m := New(root, db, []*config.Account{ {Slug: "checking", 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 { t.Fatal(err) } @@ -670,7 +671,7 @@ func TestRuleBuilderCompletesTag(t *testing.T) { // other candidates there are. m.ruleTag.SetValue("gro") 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") { 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"}}, {"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"}}, + {"transfers", "7", []string{"d delete transfer", "r refresh pairing", "6 new transfer", "q quit"}}, } { key(t, m, tc.key) 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") if help := m.help(); !strings.Contains(help, "enter save rule") || !strings.Contains(help, "esc back") { 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. @@ -1037,7 +1044,7 @@ func newEmptyModel(t *testing.T, accounts []*config.Account) *Model { } 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 { 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) 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()) + } +}