Remove the transfer flag

A transfer was a second verdict carried alongside the tag: a boolean set by
transfer = true in a rule or by x in the TUI, kept in its own pair of rule_
and manual_ columns, whose one real effect was to hold the row out of the
report. The rest of it was display -- a T column in the transaction list, in
the rules screen and in money ls.

Money moved between your own accounts is now tagged like anything else and
counts like anything else. The leg leaving checking is an outflow and the leg
arriving in savings is an inflow, so a report over the whole data root roughly
nets out while one scoped to a single account or month does not. That is the
price of one verdict per transaction instead of two.

A rule now needs a tag, and one that set only transfer = true is refused by
number on load. A leftover transfer key beside a tag is ignored, as unknown
TOML keys always were, and an index built by an older binary drops both
columns when it is opened.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-11 23:07:47 +02:00
co-authored by Claude Opus 5
parent 16c2585637
commit 5ca6cff87b
13 changed files with 114 additions and 277 deletions
+9 -9
View File
@@ -18,22 +18,22 @@ internal/parser Parser interface + registry; nlb, revolut, traderepublic
internal/store SQLite index (modernc.org/sqlite, no cgo) internal/store SQLite index (modernc.org/sqlite, no cgo)
internal/importer directory walk, dedupe, balance checks internal/importer directory walk, dedupe, balance checks
internal/rules applies ordered rules to rule_* columns only internal/rules applies ordered rules to rule_* columns only
internal/report per-tag aggregation, transfers excluded internal/report per-tag aggregation
internal/tui Bubble Tea models internal/tui Bubble Tea models
``` ```
## Invariants ## Invariants
**Rule verdicts and manual edits never share a column.** `rule_tag` / **Rule verdicts and manual edits never share a column.** `rule_tag` is
`rule_transfer` are rewritten wholesale on every retag; `manual_tag` / rewritten wholesale on every retag; `manual_tag` is only ever written by the
`manual_transfer` are only ever written by the user. Effective values are user. The effective tag is `COALESCE(manual_tag, rule_tag)`. This is what makes
`COALESCE(manual_*, rule_*)`. This is what makes `money retag` safe to run at `money retag` safe to run at any time, and it is the first thing to preserve
any time, and it is the first thing to preserve when touching the schema or the when touching the schema or the rules engine. Covered by
rules engine. Covered by `TestManualTagSurvivesRetag`. `TestManualTagSurvivesRetag`.
**Statements are the source of truth; the index is disposable.** Deleting **Statements are the source of truth; the index is disposable.** Deleting
`index.db` at the root of the data directory and re-importing must reproduce `index.db` at the root of the data directory and re-importing must reproduce
everything except manual tags and manual transfer marks. everything except the tags set by hand.
**Dedupe is by fingerprint**: `sha256(date | amount | normalised description | **Dedupe is by fingerprint**: `sha256(date | amount | normalised description |
ordinal)`, where the ordinal distinguishes identical lines *within one ordinal)`, where the ordinal distinguishes identical lines *within one
@@ -45,7 +45,7 @@ milliseconds. That is the checksum skip working, not a failure.
**Money is `int64` minor units**, never a float. Per-account currency, no **Money is `int64` minor units**, never a float. Per-account currency, no
conversion, and totals are never summed across currencies. conversion, and totals are never summed across currencies.
**First matching rule wins**, so transfer rules belong above general tag rules. **First matching rule wins**, so specific rules belong above general ones.
A rule setting both `match` and `type` requires both of them. A rule setting both `match` and `type` requires both of them.
`config.AppendRule` therefore appends — never prepends — so saving from the `config.AppendRule` therefore appends — never prepends — so saving from the
rule builder cannot shadow a rule the user wrote by hand. rule builder cannot shadow a rule the user wrote by hand.
+14 -17
View File
@@ -3,15 +3,14 @@
A personal finance tracker built around bank statements rather than manual entry. A personal finance tracker built around bank statements rather than manual entry.
You keep a **data directory with one folder per account**, drop statement exports You keep a **data directory with one folder per account**, drop statement exports
into those folders, and run `money import`. Transactions are extracted, tagged by into those folders, and run `money import`. Transactions are extracted and
glob rules you write, and movements between your own accounts are marked as tagged by glob rules you write.
transfers so they never count as spending.
## Layout ## Layout
``` ```
~/money/ # the data root (see "Where the data root lives") ~/money/ # the data root (see "Where the data root lives")
rules.toml # tag + transfer rules, in order rules.toml # tag rules, in order
index.db # SQLite index (rebuildable; safe to delete*) index.db # SQLite index (rebuildable; safe to delete*)
checking/ checking/
account.toml # currency + how to parse this bank's exports account.toml # currency + how to parse this bank's exports
@@ -22,8 +21,8 @@ transfers so they never count as spending.
2026-01.csv 2026-01.csv
``` ```
\* Deleting the index loses manual tags and manual transfer marks, which live \* Deleting the index loses the tags you set by hand, which live only there.
only there. Everything else is re-derived from the statements. Everything else is re-derived from the statements.
## Where the data root lives ## Where the data root lives
@@ -82,8 +81,7 @@ imported — creating an `account.toml` is not enough on its own. Run
| `1` `2` `3` `4` `5` / `tab` | accounts · transactions · report · rule builder · rules | | `1` `2` `3` `4` `5` / `tab` | accounts · transactions · report · rule builder · rules |
| `enter` | open the selected account (accounts view) | | `enter` | open the selected account (accounts view) |
| `t` | set the tag on the selected transaction | | `t` | set the tag on the selected transaction |
| `x` | toggle transfer on the selected transaction | | `c` | clear a tag set by hand, falling back to the rules |
| `c` | clear manual overrides, falling back to the rules |
| `/` | filter by description | | `/` | filter by description |
| `u` | show only untagged transactions | | `u` | show only untagged transactions |
| `a` | clear the account filter | | `a` | clear the account filter |
@@ -171,8 +169,8 @@ section heading and left alone.
## rules.toml ## rules.toml
Rules are evaluated in file order and the **first match wins**, so put transfer Rules are evaluated in file order and the **first match wins**, so put specific
rules above general tag rules. Patterns are globs (`*` and `?`) matched rules above general ones. Patterns are globs (`*` and `?`) matched
case-insensitively, with whitespace collapsed. case-insensitively, with whitespace collapsed.
A rule matches on `match` (the description) and `type` (the bank's own A rule matches on `match` (the description) and `type` (the bank's own
@@ -194,23 +192,22 @@ tag = "salary"
match = "*ACME PAYROLL*" match = "*ACME PAYROLL*"
note = "paid on the 4th; the December one lands early" note = "paid on the 4th; the December one lands early"
# Money moved between your own accounts. Both legs need a rule. # 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]] [[rule]]
match = "*TO SAVINGS*" match = "*TO SAVINGS*"
transfer = true
tag = "transfer" tag = "transfer"
[[rule]] [[rule]]
match = "*FROM CHECKING*" match = "*FROM CHECKING*"
transfer = true
tag = "transfer" tag = "transfer"
# Transfers are often only identifiable by the other side's account number, # Such movements are often only identifiable by the other side's account
# whatever the rest of the description happens to say. Every bank parser keeps # number, whatever the rest of the description happens to say. Every bank
# that number in the description, so an ordinary glob finds it. # parser keeps that number in the description, so an ordinary glob finds it.
[[rule]] [[rule]]
match = "*SI56*" match = "*SI56*"
transfer = true
tag = "transfer" tag = "transfer"
# A rule can be limited to one account, and can require several patterns. # A rule can be limited to one account, and can require several patterns.
+6 -11
View File
@@ -256,27 +256,23 @@ func cmdLs(root string, args []string) error {
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
if *wide { if *wide {
fmt.Fprintln(w, "DATE\tACCOUNT\tAMOUNT\tCUR\tBALANCE\tTAG\tT\tTYPE\tDESCRIPTION") fmt.Fprintln(w, "DATE\tACCOUNT\tAMOUNT\tCUR\tBALANCE\tTAG\tTYPE\tDESCRIPTION")
} else { } else {
fmt.Fprintln(w, "DATE\tACCOUNT\tAMOUNT\tCUR\tTAG\tT\tDESCRIPTION") fmt.Fprintln(w, "DATE\tACCOUNT\tAMOUNT\tCUR\tTAG\tDESCRIPTION")
} }
for _, t := range txns { for _, t := range txns {
transfer := ""
if t.IsTransfer() {
transfer = "T"
}
if *wide { if *wide {
balance := "" balance := ""
if t.BalanceMinor != nil { if t.BalanceMinor != nil {
balance = model.FormatMinor(*t.BalanceMinor, t.MinorDigits) balance = model.FormatMinor(*t.BalanceMinor, t.MinorDigits)
} }
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n", fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n",
t.Date, t.AccountSlug, t.FormatAmount(), t.Currency, balance, t.Date, t.AccountSlug, t.FormatAmount(), t.Currency, balance,
t.Tag(), transfer, t.Type, t.Description) t.Tag(), t.Type, t.Description)
continue continue
} }
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n", fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n",
t.Date, t.AccountSlug, t.FormatAmount(), t.Currency, t.Tag(), transfer, t.Description) t.Date, t.AccountSlug, t.FormatAmount(), t.Currency, t.Tag(), t.Description)
} }
w.Flush() w.Flush()
fmt.Printf("\n%d transactions\n", len(txns)) fmt.Printf("\n%d transactions\n", len(txns))
@@ -316,7 +312,6 @@ func cmdReport(root string, args []string) error {
model.FormatMinor(c.Net(), c.Digits)) model.FormatMinor(c.Net(), c.Digits))
} }
w.Flush() w.Flush()
fmt.Println("\ntransfers between own accounts are excluded")
return nil return nil
} }
+4 -8
View File
@@ -28,7 +28,6 @@ const (
type Rule struct { type Rule struct {
Match string `toml:"match"` Match string `toml:"match"`
Tag string `toml:"tag"` Tag string `toml:"tag"`
Transfer bool `toml:"transfer"`
Account string `toml:"account"` // optional: restrict to one account slug Account string `toml:"account"` // optional: restrict to one account slug
// Type matches the bank's own classification, e.g. Revolut's CARD_PAYMENT. // Type matches the bank's own classification, e.g. Revolut's CARD_PAYMENT.
// Optional; when set, it must match as well as Match. // Optional; when set, it must match as well as Match.
@@ -60,8 +59,8 @@ func LoadRules(root string) (*Rules, error) {
if rule.Match == "" && rule.Type == "" { if rule.Match == "" && rule.Type == "" {
return nil, fmt.Errorf("%s: rule %d has no match or type pattern", path, i+1) return nil, fmt.Errorf("%s: rule %d has no match or type pattern", path, i+1)
} }
if rule.Tag == "" && !rule.Transfer { if rule.Tag == "" {
return nil, fmt.Errorf("%s: rule %d (%q) sets neither tag nor transfer", path, i+1, rule.Match) return nil, fmt.Errorf("%s: rule %d (%q) sets no tag", path, i+1, rule.Match)
} }
} }
return &r, nil return &r, nil
@@ -77,8 +76,8 @@ func AppendRule(root string, r Rule) error {
if r.Match == "" && r.Type == "" { if r.Match == "" && r.Type == "" {
return fmt.Errorf("a rule needs a match or type pattern") return fmt.Errorf("a rule needs a match or type pattern")
} }
if r.Tag == "" && !r.Transfer { if r.Tag == "" {
return fmt.Errorf("a rule needs a tag or transfer = true") return fmt.Errorf("a rule needs a tag")
} }
path := filepath.Join(root, RulesFile) path := filepath.Join(root, RulesFile)
@@ -252,9 +251,6 @@ func formatRule(r Rule) string {
write("type", r.Type) write("type", r.Type)
write("account", r.Account) write("account", r.Account)
write("tag", r.Tag) write("tag", r.Tag)
if r.Transfer {
b.WriteString("transfer = true\n")
}
// Last, so the patterns and the tag stay lined up above it however long // Last, so the patterns and the tag stay lined up above it however long
// the note runs. // the note runs.
write("note", r.Note) write("note", r.Note)
+1 -5
View File
@@ -181,7 +181,7 @@ func TestAppendRuleRejectsIncomplete(t *testing.T) {
// Globs and tags containing quotes must survive the round trip. // Globs and tags containing quotes must survive the round trip.
func TestAppendRuleQuotesValues(t *testing.T) { func TestAppendRuleQuotesValues(t *testing.T) {
root := t.TempDir() root := t.TempDir()
if err := AppendRule(root, Rule{Match: `*"ODD" PAYEE*`, Tag: "misc", Transfer: true}); err != nil { if err := AppendRule(root, Rule{Match: `*"ODD" PAYEE*`, Tag: "misc"}); err != nil {
t.Fatal(err) t.Fatal(err)
} }
loaded, err := LoadRules(root) loaded, err := LoadRules(root)
@@ -191,9 +191,6 @@ func TestAppendRuleQuotesValues(t *testing.T) {
if loaded.Rule[0].Match != `*"ODD" PAYEE*` { if loaded.Rule[0].Match != `*"ODD" PAYEE*` {
t.Errorf("match = %q, want the quotes preserved", loaded.Rule[0].Match) t.Errorf("match = %q, want the quotes preserved", loaded.Rule[0].Match)
} }
if !loaded.Rule[0].Transfer {
t.Error("transfer flag was not written")
}
} }
// A note is documentation carried with the rule. It is a key rather than a // A note is documentation carried with the rule. It is a key rather than a
@@ -268,7 +265,6 @@ tag = "salary"
# Moving money to myself. # Moving money to myself.
[[rule]] [[rule]]
match = "*TO SAVINGS*" match = "*TO SAVINGS*"
transfer = true
tag = "transfer" tag = "transfer"
` `
+1 -1
View File
@@ -172,7 +172,7 @@ func importFile(root string, db *store.DB, acc *config.Account, accountID int64,
Type: t.Type, Type: t.Type,
BalanceMinor: t.BalanceMinor, BalanceMinor: t.BalanceMinor,
} }
txn.RuleTag, txn.RuleTransfer = engine.ApplyTxn(acc.Slug, txn) txn.RuleTag = engine.ApplyTxn(acc.Slug, txn)
added, err := db.InsertTransaction(txn) added, err := db.InsertTransaction(txn)
if err != nil { if err != nil {
fr.Err = err fr.Err = err
-11
View File
@@ -41,8 +41,6 @@ type Transaction struct {
RuleTag string RuleTag string
ManualTag string ManualTag string
RuleTransfer bool
ManualTransfer *bool
} }
// Tag is the effective category: a manual tag always beats a rule tag. // Tag is the effective category: a manual tag always beats a rule tag.
@@ -53,15 +51,6 @@ func (t Transaction) Tag() string {
return t.RuleTag return t.RuleTag
} }
// IsTransfer reports whether this line moves money between the user's own
// accounts and so must not count as spending. A manual override wins.
func (t Transaction) IsTransfer() bool {
if t.ManualTransfer != nil {
return *t.ManualTransfer
}
return t.RuleTransfer
}
// FormatAmount renders the amount using the account's minor-unit scale. // FormatAmount renders the amount using the account's minor-unit scale.
func (t Transaction) FormatAmount() string { func (t Transaction) FormatAmount() string {
return FormatMinor(t.AmountMinor, t.MinorDigits) return FormatMinor(t.AmountMinor, t.MinorDigits)
-6
View File
@@ -1,7 +1,4 @@
// Package report aggregates transactions for the summary views. // Package report aggregates transactions for the summary views.
//
// Transfers between the user's own accounts are excluded everywhere: moving
// money from checking to savings is not income and not spending.
package report package report
import ( import (
@@ -33,9 +30,6 @@ func ByTag(txns []model.Transaction) []TagTotal {
acc := map[key]*TagTotal{} acc := map[key]*TagTotal{}
for _, t := range txns { for _, t := range txns {
if t.IsTransfer() {
continue
}
tag := t.Tag() tag := t.Tag()
if tag == "" { if tag == "" {
tag = Untagged tag = Untagged
+11 -13
View File
@@ -1,9 +1,8 @@
// Package rules applies the ordered glob rules from rules.toml to // Package rules applies the ordered glob rules from rules.toml to
// transactions, deciding their automatic tag and whether they are a transfer // transactions, deciding their automatic tag.
// between the user's own accounts.
// //
// Only the rule_* columns are ever written. Manual edits made in the TUI live // Only rule_tag is ever written. A tag set by hand in the TUI lives in its own
// in separate columns and survive any number of re-runs. // column and survives any number of re-runs.
package rules package rules
import ( import (
@@ -76,17 +75,16 @@ func (e *Engine) Usage(txns []model.Transaction) []int {
return counts return counts
} }
// ApplyTxn returns the tag and transfer flag for a transaction. An unmatched // ApplyTxn returns the tag for a transaction, empty if no rule matches.
// transaction gets an empty tag and is not a transfer. func (e *Engine) ApplyTxn(accountSlug string, t model.Transaction) string {
func (e *Engine) ApplyTxn(accountSlug string, t model.Transaction) (tag string, transfer bool) {
if r := e.Match(accountSlug, t); r != nil { if r := e.Match(accountSlug, t); r != nil {
return r.Tag, r.Transfer return r.Tag
} }
return "", false return ""
} }
// Apply is the description-only shorthand, for callers that have nothing else. // Apply is the description-only shorthand, for callers that have nothing else.
func (e *Engine) Apply(accountSlug, description string) (tag string, transfer bool) { func (e *Engine) Apply(accountSlug, description string) string {
return e.ApplyTxn(accountSlug, model.Transaction{Description: description}) return e.ApplyTxn(accountSlug, model.Transaction{Description: description})
} }
@@ -99,11 +97,11 @@ func (e *Engine) Retag(db *store.DB) (int, error) {
} }
var changed []store.RuleAssignment var changed []store.RuleAssignment
for _, t := range txns { for _, t := range txns {
tag, transfer := e.ApplyTxn(t.AccountSlug, t) tag := e.ApplyTxn(t.AccountSlug, t)
if tag == t.RuleTag && transfer == t.RuleTransfer { if tag == t.RuleTag {
continue continue
} }
changed = append(changed, store.RuleAssignment{ID: t.ID, Tag: tag, Transfer: transfer}) changed = append(changed, store.RuleAssignment{ID: t.ID, Tag: tag})
} }
if len(changed) == 0 { if len(changed) == 0 {
return 0, nil return 0, nil
+11 -72
View File
@@ -128,13 +128,13 @@ func TestFirstMatchWins(t *testing.T) {
{Match: "*LIDL EXPRESS*", Tag: "snacks"}, {Match: "*LIDL EXPRESS*", Tag: "snacks"},
{Match: "*LIDL*", Tag: "groceries"}, {Match: "*LIDL*", Tag: "groceries"},
}}) }})
if tag, _ := engine.Apply("checking", "CARD LIDL EXPRESS 12"); tag != "snacks" { if tag := engine.Apply("checking", "CARD LIDL EXPRESS 12"); tag != "snacks" {
t.Errorf("tag = %q, want snacks (earlier rule must win)", tag) t.Errorf("tag = %q, want snacks (earlier rule must win)", tag)
} }
if tag, _ := engine.Apply("checking", "CARD LIDL 12"); tag != "groceries" { if tag := engine.Apply("checking", "CARD LIDL 12"); tag != "groceries" {
t.Errorf("tag = %q, want groceries", tag) t.Errorf("tag = %q, want groceries", tag)
} }
if tag, _ := engine.Apply("checking", "SOMETHING ELSE"); tag != "" { if tag := engine.Apply("checking", "SOMETHING ELSE"); tag != "" {
t.Errorf("tag = %q, want empty for an unmatched description", tag) t.Errorf("tag = %q, want empty for an unmatched description", tag)
} }
} }
@@ -187,17 +187,15 @@ func TestMatchIndex(t *testing.T) {
func TestAccountScopedRule(t *testing.T) { func TestAccountScopedRule(t *testing.T) {
engine := New(&config.Rules{Rule: []config.Rule{ engine := New(&config.Rules{Rule: []config.Rule{
{Match: "*TRANSFER*", Tag: "transfer", Transfer: true, Account: "savings"}, {Match: "*TRANSFER*", Tag: "transfer", Account: "savings"},
{Match: "*TRANSFER*", Tag: "misc"}, {Match: "*TRANSFER*", Tag: "misc"},
}}) }})
tag, transfer := engine.Apply("savings", "TRANSFER FROM CHECKING") if tag := engine.Apply("savings", "TRANSFER FROM CHECKING"); tag != "transfer" {
if tag != "transfer" || !transfer { t.Errorf("savings: tag = %q, want transfer", tag)
t.Errorf("savings: tag=%q transfer=%v, want transfer/true", tag, transfer)
} }
tag, transfer = engine.Apply("checking", "TRANSFER FROM CHECKING") if tag := engine.Apply("checking", "TRANSFER FROM CHECKING"); tag != "misc" {
if tag != "misc" || transfer { t.Errorf("checking: tag = %q, want misc", tag)
t.Errorf("checking: tag=%q transfer=%v, want misc/false", tag, transfer)
} }
} }
@@ -210,13 +208,13 @@ func TestTypeRules(t *testing.T) {
}}) }})
// A rule setting several patterns requires all of them to match. // A rule setting several patterns requires all of them to match.
tag, _ := engine.ApplyTxn("checking", model.Transaction{ tag := engine.ApplyTxn("checking", model.Transaction{
Description: "LIDL SOFIA", Type: "CARD_PAYMENT", Description: "LIDL SOFIA", Type: "CARD_PAYMENT",
}) })
if tag != "groceries" { if tag != "groceries" {
t.Errorf("tag = %q, want groceries", tag) t.Errorf("tag = %q, want groceries", tag)
} }
tag, _ = engine.ApplyTxn("checking", model.Transaction{ tag = engine.ApplyTxn("checking", model.Transaction{
Description: "LIDL SOFIA", Type: "TRANSFER", Description: "LIDL SOFIA", Type: "TRANSFER",
}) })
if tag != "" { if tag != "" {
@@ -224,68 +222,9 @@ func TestTypeRules(t *testing.T) {
} }
// Type alone works too. // Type alone works too.
if tag, _ := engine.ApplyTxn("checking", model.Transaction{ if tag := engine.ApplyTxn("checking", model.Transaction{
Description: "WITHDRAWAL", Type: "ATM", Description: "WITHDRAWAL", Type: "ATM",
}); tag != "cash" { }); tag != "cash" {
t.Errorf("tag = %q, want cash", tag) t.Errorf("tag = %q, want cash", tag)
} }
} }
// Transfers can also be overridden by hand, independently of the tag.
func TestManualTransferOverride(t *testing.T) {
db := testDB(t)
seed(t, db, "TRANSFER TO SAVINGS")
engine := New(&config.Rules{Rule: []config.Rule{
{Match: "*TO SAVINGS*", Tag: "transfer", Transfer: true},
}})
if _, err := engine.Retag(db); err != nil {
t.Fatal(err)
}
txn := byDescription(t, db, "TRANSFER TO SAVINGS")
if !txn.IsTransfer() {
t.Fatal("expected the rule to mark this as a transfer")
}
no := false
if err := db.SetManualTransfer(txn.ID, &no); err != nil {
t.Fatal(err)
}
if _, err := engine.Retag(db); err != nil {
t.Fatal(err)
}
if byDescription(t, db, "TRANSFER TO SAVINGS").IsTransfer() {
t.Error("manual transfer override was lost on retag")
}
}
func TestUntaggedFilter(t *testing.T) {
db := testDB(t)
seed(t, db, "LIDL SOFIA", "SOME RANDOM SHOP")
engine := New(&config.Rules{Rule: []config.Rule{{Match: "*LIDL*", Tag: "groceries"}}})
if _, err := engine.Retag(db); err != nil {
t.Fatal(err)
}
untagged, err := db.Transactions(store.Filter{Untagged: true})
if err != nil {
t.Fatal(err)
}
if len(untagged) != 1 || untagged[0].Description != "SOME RANDOM SHOP" {
t.Fatalf("untagged = %+v, want only SOME RANDOM SHOP", untagged)
}
// A manual tag must remove the row from the untagged view.
if err := db.SetManualTag(untagged[0].ID, "hobby"); err != nil {
t.Fatal(err)
}
untagged, err = db.Transactions(store.Filter{Untagged: true})
if err != nil {
t.Fatal(err)
}
if len(untagged) != 0 {
t.Errorf("untagged = %+v, want none", untagged)
}
}
+16 -46
View File
@@ -1,6 +1,6 @@
// Package store is the SQLite index over the statements. It is entirely // Package store is the SQLite index over the statements. It is entirely
// rebuildable: delete index.db and re-import to get it back, except for manual // rebuildable: delete index.db and re-import to get it back, except for the
// tags and manual transfer overrides, which live only here. // tags set by hand, which live only here.
package store package store
import ( import (
@@ -52,8 +52,6 @@ CREATE TABLE IF NOT EXISTS transactions (
balance_minor INTEGER, balance_minor INTEGER,
rule_tag TEXT, rule_tag TEXT,
manual_tag TEXT, manual_tag TEXT,
rule_transfer INTEGER NOT NULL DEFAULT 0,
manual_transfer INTEGER,
UNIQUE(account_id, fingerprint) UNIQUE(account_id, fingerprint)
); );
@@ -71,7 +69,7 @@ var migrations = []string{
// dropped are columns an older build created that this one no longer reads. // dropped are columns an older build created that this one no longer reads.
// Nothing indexes or constrains them, so they can simply go; leaving them // Nothing indexes or constrains them, so they can simply go; leaving them
// would keep a NOT NULL column alive that no INSERT here ever names. // would keep a NOT NULL column alive that no INSERT here ever names.
var dropped = []string{"counterparty"} var dropped = []string{"counterparty", "rule_transfer", "manual_transfer"}
// migrate brings an index created by an older build up to date, adding the // migrate brings an index created by an older build up to date, adding the
// columns it lacks and removing the ones it should no longer have. // columns it lacks and removing the ones it should no longer have.
@@ -237,11 +235,11 @@ func (d *DB) InsertTransaction(t model.Transaction) (bool, error) {
res, err := d.sql.Exec(` res, err := d.sql.Exec(`
INSERT INTO transactions INSERT INTO transactions
(account_id, source_file_id, fingerprint, date, description, amount_minor, (account_id, source_file_id, fingerprint, date, description, amount_minor,
type, balance_minor, rule_tag, rule_transfer) type, balance_minor, rule_tag)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULLIF(?, ''), ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULLIF(?, ''))
ON CONFLICT(account_id, fingerprint) DO NOTHING`, ON CONFLICT(account_id, fingerprint) DO NOTHING`,
t.AccountID, t.SourceFileID, t.Fingerprint, t.Date, t.Description, t.AccountID, t.SourceFileID, t.Fingerprint, t.Date, t.Description,
t.AmountMinor, t.Type, balance, t.RuleTag, boolToInt(t.RuleTransfer)) t.AmountMinor, t.Type, balance, t.RuleTag)
if err != nil { if err != nil {
return false, fmt.Errorf("insert transaction: %w", err) return false, fmt.Errorf("insert transaction: %w", err)
} }
@@ -267,8 +265,7 @@ func (d *DB) Transactions(f Filter) ([]model.Transaction, error) {
SELECT t.id, t.account_id, a.slug, a.currency, a.minor_digits, SELECT t.id, t.account_id, a.slug, a.currency, a.minor_digits,
t.fingerprint, t.date, t.description, t.amount_minor, t.fingerprint, t.date, t.description, t.amount_minor,
COALESCE(s.path, ''), t.type, t.balance_minor, COALESCE(s.path, ''), t.type, t.balance_minor,
COALESCE(t.rule_tag, ''), COALESCE(t.manual_tag, ''), COALESCE(t.rule_tag, ''), COALESCE(t.manual_tag, '')
t.rule_transfer, t.manual_transfer
FROM transactions t FROM transactions t
JOIN accounts a ON a.id = t.account_id JOIN accounts a ON a.id = t.account_id
LEFT JOIN source_files s ON s.id = t.source_file_id LEFT JOIN source_files s ON s.id = t.source_file_id
@@ -300,25 +297,18 @@ func (d *DB) Transactions(f Filter) ([]model.Transaction, error) {
for rows.Next() { for rows.Next() {
var ( var (
t model.Transaction t model.Transaction
ruleTransfer int
manualTransfer sql.NullInt64
balance sql.NullInt64 balance sql.NullInt64
) )
if err := rows.Scan(&t.ID, &t.AccountID, &t.AccountSlug, &t.Currency, &t.MinorDigits, if err := rows.Scan(&t.ID, &t.AccountID, &t.AccountSlug, &t.Currency, &t.MinorDigits,
&t.Fingerprint, &t.Date, &t.Description, &t.AmountMinor, &t.SourcePath, &t.Fingerprint, &t.Date, &t.Description, &t.AmountMinor, &t.SourcePath,
&t.Type, &balance, &t.Type, &balance,
&t.RuleTag, &t.ManualTag, &ruleTransfer, &manualTransfer); err != nil { &t.RuleTag, &t.ManualTag); err != nil {
return nil, err return nil, err
} }
t.RuleTransfer = ruleTransfer != 0
if balance.Valid { if balance.Valid {
v := balance.Int64 v := balance.Int64
t.BalanceMinor = &v t.BalanceMinor = &v
} }
if manualTransfer.Valid {
v := manualTransfer.Int64 != 0
t.ManualTransfer = &v
}
if needle != "" && !strings.Contains(model.NormalizeDescription(t.Description), needle) { if needle != "" && !strings.Contains(model.NormalizeDescription(t.Description), needle) {
continue continue
} }
@@ -336,21 +326,10 @@ func (d *DB) SetManualTag(id int64, tag string) error {
return err return err
} }
// SetManualTransfer sets the user's transfer override; nil restores the // ClearOverrides drops the manual tag for a transaction, so the rules decide
// rule-derived value. // it again.
func (d *DB) SetManualTransfer(id int64, v *bool) error {
var arg any
if v != nil {
arg = boolToInt(*v)
}
_, err := d.sql.Exec(`UPDATE transactions SET manual_transfer = ? WHERE id = ?`, arg, id)
return err
}
// ClearOverrides drops both manual overrides for a transaction.
func (d *DB) ClearOverrides(id int64) error { func (d *DB) ClearOverrides(id int64) error {
_, err := d.sql.Exec( _, err := d.sql.Exec(`UPDATE transactions SET manual_tag = NULL WHERE id = ?`, id)
`UPDATE transactions SET manual_tag = NULL, manual_transfer = NULL WHERE id = ?`, id)
return err return err
} }
@@ -358,11 +337,10 @@ func (d *DB) ClearOverrides(id int64) error {
type RuleAssignment struct { type RuleAssignment struct {
ID int64 ID int64
Tag string Tag string
Transfer bool
} }
// ApplyRuleResults rewrites rule_tag and rule_transfer for every listed row in // ApplyRuleResults rewrites rule_tag for every listed row in a single
// a single transaction. Manual columns are never touched. // transaction. The manual column is never touched.
func (d *DB) ApplyRuleResults(rs []RuleAssignment) error { func (d *DB) ApplyRuleResults(rs []RuleAssignment) error {
tx, err := d.sql.Begin() tx, err := d.sql.Begin()
if err != nil { if err != nil {
@@ -371,22 +349,21 @@ func (d *DB) ApplyRuleResults(rs []RuleAssignment) error {
defer tx.Rollback() defer tx.Rollback()
stmt, err := tx.Prepare( stmt, err := tx.Prepare(
`UPDATE transactions SET rule_tag = NULLIF(?, ''), rule_transfer = ? WHERE id = ?`) `UPDATE transactions SET rule_tag = NULLIF(?, '') WHERE id = ?`)
if err != nil { if err != nil {
return err return err
} }
defer stmt.Close() defer stmt.Close()
for _, r := range rs { for _, r := range rs {
if _, err := stmt.Exec(r.Tag, boolToInt(r.Transfer), r.ID); err != nil { if _, err := stmt.Exec(r.Tag, r.ID); err != nil {
return fmt.Errorf("apply rules to txn %d: %w", r.ID, err) return fmt.Errorf("apply rules to txn %d: %w", r.ID, err)
} }
} }
return tx.Commit() return tx.Commit()
} }
// Balance sums every transaction in an account, transfers included: a transfer // Balance sums every transaction in an account.
// really does move the account's money.
func (d *DB) Balance(accountID int64) (int64, error) { func (d *DB) Balance(accountID int64) (int64, error) {
var v sql.NullInt64 var v sql.NullInt64
err := d.sql.QueryRow( err := d.sql.QueryRow(
@@ -424,10 +401,3 @@ func (d *DB) Tags() ([]string, error) {
} }
return out, rows.Err() return out, rows.Err()
} }
func boolToInt(b bool) int {
if b {
return 1
}
return 0
}
+6 -38
View File
@@ -1,5 +1,5 @@
// Package tui is the terminal interface: browse accounts, tag transactions, // Package tui is the terminal interface: browse accounts, tag transactions,
// mark transfers by hand, and see where the money went. // and see where the money went.
package tui package tui
import ( import (
@@ -191,7 +191,6 @@ func New(root string, db *store.DB, accounts []*config.Account, engine *rules.En
{Title: "Account", Width: 12}, {Title: "Account", Width: 12},
{Title: "Amount", Width: 12}, {Title: "Amount", Width: 12},
{Title: "Tag", Width: 14}, {Title: "Tag", Width: 14},
{Title: "T", Width: 1},
{Title: "Description", Width: 40}, {Title: "Description", Width: 40},
}), }),
ruleGlob: newInput("*LIDL*"), ruleGlob: newInput("*LIDL*"),
@@ -209,7 +208,6 @@ func New(root string, db *store.DB, accounts []*config.Account, engine *rules.En
{Title: "Pattern", Width: 34}, {Title: "Pattern", Width: 34},
{Title: "Account", Width: 12}, {Title: "Account", Width: 12},
{Title: "Tag", Width: 14}, {Title: "Tag", Width: 14},
{Title: "T", Width: 1},
{Title: "Txns", Width: 6}, {Title: "Txns", Width: 6},
{Title: "Note", Width: 24}, {Title: "Note", Width: 24},
}), }),
@@ -269,16 +267,12 @@ func (m *Model) reloadTxns() error {
rows := make([]table.Row, 0, len(txns)) rows := make([]table.Row, 0, len(txns))
for _, t := range txns { for _, t := range txns {
transfer := ""
if t.IsTransfer() {
transfer = "T"
}
tag := t.Tag() tag := t.Tag()
if tag != "" && t.ManualTag != "" { if tag != "" && t.ManualTag != "" {
tag += "*" // marks a manual override tag += "*" // marks a manual override
} }
rows = append(rows, table.Row{ rows = append(rows, table.Row{
t.Date, t.AccountSlug, t.FormatAmount(), tag, transfer, t.Description, t.Date, t.AccountSlug, t.FormatAmount(), tag, t.Description,
}) })
} }
// Keep the cursor in range after the row count shrinks (e.g. tagging the // Keep the cursor in range after the row count shrinks (e.g. tagging the
@@ -573,16 +567,12 @@ func (m *Model) reloadRuleList() error {
if m.ruleUsage[i] == 0 { if m.ruleUsage[i] == 0 {
marker = "✗" marker = "✗"
} }
transfer := ""
if r.Transfer {
transfer = "T"
}
account := r.Account account := r.Account
if account == "" { if account == "" {
account = "(all)" account = "(all)"
} }
rows = append(rows, table.Row{ rows = append(rows, table.Row{
fmt.Sprintf("%d", i+1), marker, rulePattern(r), account, r.Tag, transfer, fmt.Sprintf("%d", i+1), marker, rulePattern(r), account, r.Tag,
fmt.Sprintf("%d", m.ruleUsage[i]), r.Note, fmt.Sprintf("%d", m.ruleUsage[i]), r.Note,
}) })
} }
@@ -1116,30 +1106,8 @@ func (m *Model) updateNormal(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.text.Focus() m.text.Focus()
return m, nil return m, nil
case "x":
// Toggle the manual transfer override away from whatever is in effect.
if m.view != viewTxns {
return m, nil
}
t, ok := m.selected()
if !ok {
return m, nil
}
v := !t.IsTransfer()
if err := m.db.SetManualTransfer(t.ID, &v); err != nil {
m.err = err
return m, nil
}
if v {
m.status = "marked as transfer"
} else {
m.status = "unmarked as transfer"
}
m.err = m.reloadTxns()
return m, nil
case "c": case "c":
// Drop manual overrides and fall back to what the rules say. // Drop the manual tag and fall back to what the rules say.
if m.view != viewTxns { if m.view != viewTxns {
return m, nil return m, nil
} }
@@ -1386,7 +1354,7 @@ func (m *Model) title() string {
} }
return fmt.Sprintf("money · rules · %d rules · %d match nothing", len(m.engine.Rules()), unused) return fmt.Sprintf("money · rules · %d rules · %d match nothing", len(m.engine.Rules()), unused)
case viewReport: case viewReport:
return "money · report · " + scope + " · transfers excluded" return "money · report · " + scope
default: default:
return fmt.Sprintf("money · transactions · %s · %d rows", scope, len(m.txns)) return fmt.Sprintf("money · transactions · %s · %d rows", scope, len(m.txns))
} }
@@ -1421,6 +1389,6 @@ func (m *Model) help() string {
case viewReport: 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 · u untagged · a all accounts · q quit"
default: default:
return "t tag · x transfer · c clear · / search · u untagged · a all · i import · r retag · 4 new rule · 5 rules · q quit" return "t tag · c clear · / search · u untagged · a all · i import · r retag · 4 new rule · 5 rules · q quit"
} }
} }
+17 -22
View File
@@ -15,6 +15,7 @@ import (
"git.petrovv.com/nikola/money/internal/config" "git.petrovv.com/nikola/money/internal/config"
"git.petrovv.com/nikola/money/internal/importer" "git.petrovv.com/nikola/money/internal/importer"
"git.petrovv.com/nikola/money/internal/model" "git.petrovv.com/nikola/money/internal/model"
"git.petrovv.com/nikola/money/internal/report"
"git.petrovv.com/nikola/money/internal/rules" "git.petrovv.com/nikola/money/internal/rules"
"git.petrovv.com/nikola/money/internal/store" "git.petrovv.com/nikola/money/internal/store"
) )
@@ -162,27 +163,23 @@ func TestTagPromptCancel(t *testing.T) {
} }
} }
func TestTransferToggleAndClear(t *testing.T) { // c drops a tag set by hand, restoring whatever the rules say.
func TestClearManualTag(t *testing.T) {
m, db := newTestModel(t) m, db := newTestModel(t)
key(t, m, "2") key(t, m, "2")
cursorTo(t, m, "LIDL SOFIA") cursorTo(t, m, "LIDL SOFIA")
key(t, m, "x") key(t, m, "t")
txn := findTxn(t, db, "LIDL SOFIA") typeText(t, m, "treats")
if !txn.IsTransfer() { key(t, m, "enter")
t.Error("expected x to mark the row as a transfer") if got := findTxn(t, db, "LIDL SOFIA").ManualTag; got != "treats" {
t.Fatalf("manual tag = %q, want treats", got)
} }
key(t, m, "x")
if findTxn(t, db, "LIDL SOFIA").IsTransfer() {
t.Error("expected a second x to unmark it")
}
// c drops the manual override, restoring the rule's verdict.
key(t, m, "c") key(t, m, "c")
txn = findTxn(t, db, "LIDL SOFIA") txn := findTxn(t, db, "LIDL SOFIA")
if txn.ManualTransfer != nil || txn.ManualTag != "" { if txn.ManualTag != "" {
t.Errorf("expected c to clear overrides, got manual tag %q transfer %v", txn.ManualTag, txn.ManualTransfer) t.Errorf("expected c to clear the manual tag, got %q", txn.ManualTag)
} }
if txn.Tag() != "groceries" { if txn.Tag() != "groceries" {
t.Errorf("tag after clearing = %q, want groceries from the rule", txn.Tag()) t.Errorf("tag after clearing = %q, want groceries from the rule", txn.Tag())
@@ -247,19 +244,17 @@ func TestSearchFilter(t *testing.T) {
} }
} }
func TestReportViewExcludesTransfers(t *testing.T) { // Every transaction reaches the report: the tagged ones under their tag, the
// rest under (untagged).
func TestReportViewCoversEveryTransaction(t *testing.T) {
m, _ := newTestModel(t) m, _ := newTestModel(t)
key(t, m, "2")
cursorTo(t, m, "LIDL SOFIA")
key(t, m, "x") // mark as a transfer
key(t, m, "3") // report view key(t, m, "3") // report view
view := m.View() view := m.View()
if strings.Contains(view, "groceries") { for _, want := range []string{"groceries", report.Untagged, "TOTAL"} {
t.Error("a transfer must not appear in the report") if !strings.Contains(view, want) {
t.Errorf("report is missing %q:\n%s", want, view)
} }
if !strings.Contains(view, "TOTAL") {
t.Error("expected a TOTAL row in the report")
} }
} }