diff --git a/CLAUDE.md b/CLAUDE.md index 51d12d1..300b0b5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,22 +18,22 @@ internal/parser Parser interface + registry; nlb, revolut, traderepublic internal/store SQLite index (modernc.org/sqlite, no cgo) internal/importer directory walk, dedupe, balance checks internal/rules applies ordered rules to rule_* columns only -internal/report per-tag aggregation, transfers excluded +internal/report per-tag aggregation internal/tui Bubble Tea models ``` ## Invariants -**Rule verdicts and manual edits never share a column.** `rule_tag` / -`rule_transfer` are rewritten wholesale on every retag; `manual_tag` / -`manual_transfer` are only ever written by the user. Effective values are -`COALESCE(manual_*, rule_*)`. This is what makes `money retag` safe to run at -any time, and it is the first thing to preserve when touching the schema or the -rules engine. Covered by `TestManualTagSurvivesRetag`. +**Rule verdicts and manual edits never share a column.** `rule_tag` is +rewritten wholesale on every retag; `manual_tag` is only ever written by the +user. The effective tag is `COALESCE(manual_tag, rule_tag)`. This is what makes +`money retag` safe to run at any time, and it is the first thing to preserve +when touching the schema or the rules engine. Covered by +`TestManualTagSurvivesRetag`. **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 except manual tags and manual transfer marks. +everything except the tags set by hand. **Dedupe is by fingerprint**: `sha256(date | amount | normalised description | 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 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. `config.AppendRule` therefore appends — never prepends — so saving from the rule builder cannot shadow a rule the user wrote by hand. diff --git a/README.md b/README.md index a65cb3f..0470a3a 100644 --- a/README.md +++ b/README.md @@ -3,15 +3,14 @@ A personal finance tracker built around bank statements rather than manual entry. You keep a **data directory with one folder per account**, drop statement exports -into those folders, and run `money import`. Transactions are extracted, tagged by -glob rules you write, and movements between your own accounts are marked as -transfers so they never count as spending. +into those folders, and run `money import`. Transactions are extracted and +tagged by glob rules you write. ## Layout ``` ~/money/ # the data root (see "Where the data root lives") - rules.toml # tag + transfer rules, in order + rules.toml # tag rules, in order index.db # SQLite index (rebuildable; safe to delete*) checking/ account.toml # currency + how to parse this bank's exports @@ -22,8 +21,8 @@ transfers so they never count as spending. 2026-01.csv ``` -\* Deleting the index loses manual tags and manual transfer marks, which live -only there. Everything else is re-derived from the statements. +\* Deleting the index loses the tags you set by hand, which live only there. +Everything else is re-derived from the statements. ## 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 | | `enter` | open the selected account (accounts view) | | `t` | set the tag on the selected transaction | -| `x` | toggle transfer on the selected transaction | -| `c` | clear manual overrides, falling back to the rules | +| `c` | clear a tag set by hand, falling back to the rules | | `/` | filter by description | | `u` | show only untagged transactions | | `a` | clear the account filter | @@ -171,8 +169,8 @@ section heading and left alone. ## rules.toml -Rules are evaluated in file order and the **first match wins**, so put transfer -rules above general tag rules. Patterns are globs (`*` and `?`) matched +Rules are evaluated in file order and the **first match wins**, so put specific +rules above general ones. Patterns are globs (`*` and `?`) matched case-insensitively, with whitespace collapsed. A rule matches on `match` (the description) and `type` (the bank's own @@ -194,24 +192,23 @@ tag = "salary" match = "*ACME PAYROLL*" note = "paid on the 4th; the December one lands early" -# Money moved between your own accounts. Both legs need a rule. +# 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*" -transfer = true -tag = "transfer" +match = "*TO SAVINGS*" +tag = "transfer" [[rule]] -match = "*FROM CHECKING*" -transfer = true -tag = "transfer" +match = "*FROM CHECKING*" +tag = "transfer" -# Transfers 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. +# 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*" -transfer = true -tag = "transfer" +match = "*SI56*" +tag = "transfer" # A rule can be limited to one account, and can require several patterns. [[rule]] diff --git a/cmd/money/main.go b/cmd/money/main.go index 97032a2..1627d7b 100644 --- a/cmd/money/main.go +++ b/cmd/money/main.go @@ -256,27 +256,23 @@ func cmdLs(root string, args []string) error { w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) 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 { - fmt.Fprintln(w, "DATE\tACCOUNT\tAMOUNT\tCUR\tTAG\tT\tDESCRIPTION") + fmt.Fprintln(w, "DATE\tACCOUNT\tAMOUNT\tCUR\tTAG\tDESCRIPTION") } for _, t := range txns { - transfer := "" - if t.IsTransfer() { - transfer = "T" - } if *wide { balance := "" if t.BalanceMinor != nil { balance = model.FormatMinor(*t.BalanceMinor, t.MinorDigits) } - fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n", + fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n", t.Date, t.AccountSlug, t.FormatAmount(), t.Currency, balance, - t.Tag(), transfer, t.Type, t.Description) + t.Tag(), t.Type, t.Description) continue } - fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n", - t.Date, t.AccountSlug, t.FormatAmount(), t.Currency, t.Tag(), transfer, t.Description) + fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n", + t.Date, t.AccountSlug, t.FormatAmount(), t.Currency, t.Tag(), t.Description) } w.Flush() 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)) } w.Flush() - fmt.Println("\ntransfers between own accounts are excluded") return nil } diff --git a/internal/config/config.go b/internal/config/config.go index a0f0c56..bb9b78b 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -26,10 +26,9 @@ const ( // Rule is one entry in rules.toml. Rules are evaluated in file order and the // first one whose Match (and optional Account) matches wins. type Rule struct { - Match string `toml:"match"` - Tag string `toml:"tag"` - Transfer bool `toml:"transfer"` - Account string `toml:"account"` // optional: restrict to one account slug + Match string `toml:"match"` + Tag string `toml:"tag"` + Account string `toml:"account"` // optional: restrict to one account slug // Type matches the bank's own classification, e.g. Revolut's CARD_PAYMENT. // Optional; when set, it must match as well as Match. Type string `toml:"type"` @@ -60,8 +59,8 @@ func LoadRules(root string) (*Rules, error) { if rule.Match == "" && rule.Type == "" { return nil, fmt.Errorf("%s: rule %d has no match or type pattern", path, i+1) } - if rule.Tag == "" && !rule.Transfer { - return nil, fmt.Errorf("%s: rule %d (%q) sets neither tag nor transfer", path, i+1, rule.Match) + if rule.Tag == "" { + return nil, fmt.Errorf("%s: rule %d (%q) sets no tag", path, i+1, rule.Match) } } return &r, nil @@ -77,8 +76,8 @@ func AppendRule(root string, r Rule) error { if r.Match == "" && r.Type == "" { return fmt.Errorf("a rule needs a match or type pattern") } - if r.Tag == "" && !r.Transfer { - return fmt.Errorf("a rule needs a tag or transfer = true") + if r.Tag == "" { + return fmt.Errorf("a rule needs a tag") } path := filepath.Join(root, RulesFile) @@ -252,9 +251,6 @@ func formatRule(r Rule) string { write("type", r.Type) write("account", r.Account) write("tag", r.Tag) - if r.Transfer { - b.WriteString("transfer = true\n") - } // Last, so the patterns and the tag stay lined up above it however long // the note runs. write("note", r.Note) diff --git a/internal/config/config_test.go b/internal/config/config_test.go index f0a28fe..23dec95 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -181,7 +181,7 @@ func TestAppendRuleRejectsIncomplete(t *testing.T) { // Globs and tags containing quotes must survive the round trip. func TestAppendRuleQuotesValues(t *testing.T) { root := t.TempDir() - if err := AppendRule(root, Rule{Match: `*"ODD" PAYEE*`, Tag: "misc", Transfer: true}); err != nil { + if err := AppendRule(root, Rule{Match: `*"ODD" PAYEE*`, Tag: "misc"}); err != nil { t.Fatal(err) } loaded, err := LoadRules(root) @@ -191,9 +191,6 @@ func TestAppendRuleQuotesValues(t *testing.T) { if loaded.Rule[0].Match != `*"ODD" PAYEE*` { t.Errorf("match = %q, want the quotes preserved", loaded.Rule[0].Match) } - if !loaded.Rule[0].Transfer { - t.Error("transfer flag was not written") - } } // A note is documentation carried with the rule. It is a key rather than a @@ -267,9 +264,8 @@ tag = "salary" # Moving money to myself. [[rule]] -match = "*TO SAVINGS*" -transfer = true -tag = "transfer" +match = "*TO SAVINGS*" +tag = "transfer" ` // Deleting must edit the file textually: a user's comments and layout are not diff --git a/internal/importer/importer.go b/internal/importer/importer.go index 7eb0068..6c7044d 100644 --- a/internal/importer/importer.go +++ b/internal/importer/importer.go @@ -172,7 +172,7 @@ func importFile(root string, db *store.DB, acc *config.Account, accountID int64, Type: t.Type, BalanceMinor: t.BalanceMinor, } - txn.RuleTag, txn.RuleTransfer = engine.ApplyTxn(acc.Slug, txn) + txn.RuleTag = engine.ApplyTxn(acc.Slug, txn) added, err := db.InsertTransaction(txn) if err != nil { fr.Err = err diff --git a/internal/model/model.go b/internal/model/model.go index 5b2db80..c29b994 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -39,10 +39,8 @@ type Transaction struct { // transaction, when it reports one. Used to verify the balance chain. BalanceMinor *int64 - RuleTag string - ManualTag string - RuleTransfer bool - ManualTransfer *bool + RuleTag string + ManualTag string } // 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 } -// 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. func (t Transaction) FormatAmount() string { return FormatMinor(t.AmountMinor, t.MinorDigits) diff --git a/internal/report/report.go b/internal/report/report.go index 5378141..00294c3 100644 --- a/internal/report/report.go +++ b/internal/report/report.go @@ -1,7 +1,4 @@ // Package report aggregates transactions for the summary views. -// -// Transfers between the user's own accounts are excluded everywhere: moving -// money from checking to savings is not income and not spending. package report import ( @@ -33,9 +30,6 @@ func ByTag(txns []model.Transaction) []TagTotal { acc := map[key]*TagTotal{} for _, t := range txns { - if t.IsTransfer() { - continue - } tag := t.Tag() if tag == "" { tag = Untagged diff --git a/internal/rules/rules.go b/internal/rules/rules.go index 59b0561..19b2d43 100644 --- a/internal/rules/rules.go +++ b/internal/rules/rules.go @@ -1,9 +1,8 @@ // Package rules applies the ordered glob rules from rules.toml to -// transactions, deciding their automatic tag and whether they are a transfer -// between the user's own accounts. +// transactions, deciding their automatic tag. // -// Only the rule_* columns are ever written. Manual edits made in the TUI live -// in separate columns and survive any number of re-runs. +// Only rule_tag is ever written. A tag set by hand in the TUI lives in its own +// column and survives any number of re-runs. package rules import ( @@ -76,17 +75,16 @@ func (e *Engine) Usage(txns []model.Transaction) []int { return counts } -// ApplyTxn returns the tag and transfer flag for a transaction. An unmatched -// transaction gets an empty tag and is not a transfer. -func (e *Engine) ApplyTxn(accountSlug string, t model.Transaction) (tag string, transfer bool) { +// ApplyTxn returns the tag for a transaction, empty if no rule matches. +func (e *Engine) ApplyTxn(accountSlug string, t model.Transaction) string { if r := e.Match(accountSlug, t); r != nil { - return r.Tag, r.Transfer + return r.Tag } - return "", false + return "" } // Apply is the description-only shorthand, for callers that have nothing else. -func (e *Engine) Apply(accountSlug, description string) (tag string, transfer bool) { +func (e *Engine) Apply(accountSlug, description string) string { return e.ApplyTxn(accountSlug, model.Transaction{Description: description}) } @@ -99,11 +97,11 @@ func (e *Engine) Retag(db *store.DB) (int, error) { } var changed []store.RuleAssignment for _, t := range txns { - tag, transfer := e.ApplyTxn(t.AccountSlug, t) - if tag == t.RuleTag && transfer == t.RuleTransfer { + tag := e.ApplyTxn(t.AccountSlug, t) + if tag == t.RuleTag { continue } - changed = append(changed, store.RuleAssignment{ID: t.ID, Tag: tag, Transfer: transfer}) + changed = append(changed, store.RuleAssignment{ID: t.ID, Tag: tag}) } if len(changed) == 0 { return 0, nil diff --git a/internal/rules/rules_test.go b/internal/rules/rules_test.go index 0248f32..39525cc 100644 --- a/internal/rules/rules_test.go +++ b/internal/rules/rules_test.go @@ -128,13 +128,13 @@ func TestFirstMatchWins(t *testing.T) { {Match: "*LIDL EXPRESS*", Tag: "snacks"}, {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) } - 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) } - 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) } } @@ -187,17 +187,15 @@ func TestMatchIndex(t *testing.T) { func TestAccountScopedRule(t *testing.T) { 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"}, }}) - tag, transfer := engine.Apply("savings", "TRANSFER FROM CHECKING") - if tag != "transfer" || !transfer { - t.Errorf("savings: tag=%q transfer=%v, want transfer/true", tag, transfer) + if tag := engine.Apply("savings", "TRANSFER FROM CHECKING"); tag != "transfer" { + t.Errorf("savings: tag = %q, want transfer", tag) } - tag, transfer = engine.Apply("checking", "TRANSFER FROM CHECKING") - if tag != "misc" || transfer { - t.Errorf("checking: tag=%q transfer=%v, want misc/false", tag, transfer) + if tag := engine.Apply("checking", "TRANSFER FROM CHECKING"); tag != "misc" { + t.Errorf("checking: tag = %q, want misc", tag) } } @@ -210,13 +208,13 @@ func TestTypeRules(t *testing.T) { }}) // A rule setting several patterns requires all of them to match. - tag, _ := engine.ApplyTxn("checking", model.Transaction{ + tag := engine.ApplyTxn("checking", model.Transaction{ Description: "LIDL SOFIA", Type: "CARD_PAYMENT", }) if tag != "groceries" { t.Errorf("tag = %q, want groceries", tag) } - tag, _ = engine.ApplyTxn("checking", model.Transaction{ + tag = engine.ApplyTxn("checking", model.Transaction{ Description: "LIDL SOFIA", Type: "TRANSFER", }) if tag != "" { @@ -224,68 +222,9 @@ func TestTypeRules(t *testing.T) { } // Type alone works too. - if tag, _ := engine.ApplyTxn("checking", model.Transaction{ + if tag := engine.ApplyTxn("checking", model.Transaction{ Description: "WITHDRAWAL", Type: "ATM", }); tag != "cash" { t.Errorf("tag = %q, want cash", tag) } } - -// Transfers can also be overridden by hand, independently of the tag. -func TestManualTransferOverride(t *testing.T) { - db := testDB(t) - seed(t, db, "TRANSFER TO SAVINGS") - - engine := New(&config.Rules{Rule: []config.Rule{ - {Match: "*TO SAVINGS*", Tag: "transfer", Transfer: true}, - }}) - if _, err := engine.Retag(db); err != nil { - t.Fatal(err) - } - - txn := byDescription(t, db, "TRANSFER TO SAVINGS") - if !txn.IsTransfer() { - t.Fatal("expected the rule to mark this as a transfer") - } - - no := false - if err := db.SetManualTransfer(txn.ID, &no); err != nil { - t.Fatal(err) - } - if _, err := engine.Retag(db); err != nil { - t.Fatal(err) - } - if byDescription(t, db, "TRANSFER TO SAVINGS").IsTransfer() { - t.Error("manual transfer override was lost on retag") - } -} - -func TestUntaggedFilter(t *testing.T) { - db := testDB(t) - seed(t, db, "LIDL SOFIA", "SOME RANDOM SHOP") - - engine := New(&config.Rules{Rule: []config.Rule{{Match: "*LIDL*", Tag: "groceries"}}}) - if _, err := engine.Retag(db); err != nil { - t.Fatal(err) - } - - untagged, err := db.Transactions(store.Filter{Untagged: true}) - if err != nil { - t.Fatal(err) - } - if len(untagged) != 1 || untagged[0].Description != "SOME RANDOM SHOP" { - t.Fatalf("untagged = %+v, want only SOME RANDOM SHOP", untagged) - } - - // A manual tag must remove the row from the untagged view. - if err := db.SetManualTag(untagged[0].ID, "hobby"); err != nil { - t.Fatal(err) - } - untagged, err = db.Transactions(store.Filter{Untagged: true}) - if err != nil { - t.Fatal(err) - } - if len(untagged) != 0 { - t.Errorf("untagged = %+v, want none", untagged) - } -} diff --git a/internal/store/store.go b/internal/store/store.go index 09b2ba0..d2fe042 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 manual -// tags and manual transfer overrides, which live only here. +// rebuildable: delete index.db and re-import to get it back, except for the +// tags set by hand, which live only here. package store import ( @@ -52,8 +52,6 @@ CREATE TABLE IF NOT EXISTS transactions ( balance_minor INTEGER, rule_tag TEXT, manual_tag TEXT, - rule_transfer INTEGER NOT NULL DEFAULT 0, - manual_transfer INTEGER, UNIQUE(account_id, fingerprint) ); @@ -71,7 +69,7 @@ var migrations = []string{ // 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 // 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 // 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(` INSERT INTO transactions (account_id, source_file_id, fingerprint, date, description, amount_minor, - type, balance_minor, rule_tag, rule_transfer) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULLIF(?, ''), ?) + type, balance_minor, rule_tag) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULLIF(?, '')) ON CONFLICT(account_id, fingerprint) DO NOTHING`, t.AccountID, t.SourceFileID, t.Fingerprint, t.Date, t.Description, - t.AmountMinor, t.Type, balance, t.RuleTag, boolToInt(t.RuleTransfer)) + t.AmountMinor, t.Type, balance, t.RuleTag) if err != nil { return false, fmt.Errorf("insert transaction: %w", err) } @@ -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, t.fingerprint, t.date, t.description, t.amount_minor, COALESCE(s.path, ''), t.type, t.balance_minor, - COALESCE(t.rule_tag, ''), COALESCE(t.manual_tag, ''), - t.rule_transfer, t.manual_transfer + COALESCE(t.rule_tag, ''), COALESCE(t.manual_tag, '') FROM transactions t JOIN accounts a ON a.id = t.account_id LEFT JOIN source_files s ON s.id = t.source_file_id @@ -299,26 +296,19 @@ func (d *DB) Transactions(f Filter) ([]model.Transaction, error) { var out []model.Transaction for rows.Next() { var ( - t model.Transaction - ruleTransfer int - manualTransfer sql.NullInt64 - balance sql.NullInt64 + t model.Transaction + balance 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, &t.ManualTag, &ruleTransfer, &manualTransfer); err != nil { + &t.RuleTag, &t.ManualTag); err != nil { return nil, err } - t.RuleTransfer = ruleTransfer != 0 if balance.Valid { v := balance.Int64 t.BalanceMinor = &v } - if manualTransfer.Valid { - v := manualTransfer.Int64 != 0 - t.ManualTransfer = &v - } if needle != "" && !strings.Contains(model.NormalizeDescription(t.Description), needle) { continue } @@ -336,33 +326,21 @@ func (d *DB) SetManualTag(id int64, tag string) error { return err } -// SetManualTransfer sets the user's transfer override; nil restores the -// rule-derived value. -func (d *DB) SetManualTransfer(id int64, v *bool) error { - var arg any - if v != nil { - arg = boolToInt(*v) - } - _, err := d.sql.Exec(`UPDATE transactions SET manual_transfer = ? WHERE id = ?`, arg, id) - return err -} - -// ClearOverrides drops both manual overrides for a transaction. +// ClearOverrides drops the manual tag for a transaction, so the rules decide +// it again. func (d *DB) ClearOverrides(id int64) error { - _, err := d.sql.Exec( - `UPDATE transactions SET manual_tag = NULL, manual_transfer = NULL WHERE id = ?`, id) + _, err := d.sql.Exec(`UPDATE transactions SET manual_tag = NULL WHERE id = ?`, id) return err } // RuleAssignment is one row's recomputed rule verdict. type RuleAssignment struct { - ID int64 - Tag string - Transfer bool + ID int64 + Tag string } -// ApplyRuleResults rewrites rule_tag and rule_transfer for every listed row in -// a single transaction. Manual columns are never touched. +// ApplyRuleResults rewrites rule_tag for every listed row in a single +// transaction. The manual column is never touched. func (d *DB) ApplyRuleResults(rs []RuleAssignment) error { tx, err := d.sql.Begin() if err != nil { @@ -371,22 +349,21 @@ func (d *DB) ApplyRuleResults(rs []RuleAssignment) error { defer tx.Rollback() stmt, err := tx.Prepare( - `UPDATE transactions SET rule_tag = NULLIF(?, ''), rule_transfer = ? WHERE id = ?`) + `UPDATE transactions SET rule_tag = NULLIF(?, '') WHERE id = ?`) if err != nil { return err } defer stmt.Close() for _, r := range rs { - if _, err := stmt.Exec(r.Tag, boolToInt(r.Transfer), r.ID); err != nil { + if _, err := stmt.Exec(r.Tag, r.ID); err != nil { return fmt.Errorf("apply rules to txn %d: %w", r.ID, err) } } return tx.Commit() } -// Balance sums every transaction in an account, transfers included: a transfer -// really does move the account's money. +// Balance sums every transaction in an account. func (d *DB) Balance(accountID int64) (int64, error) { var v sql.NullInt64 err := d.sql.QueryRow( @@ -424,10 +401,3 @@ func (d *DB) Tags() ([]string, error) { } return out, rows.Err() } - -func boolToInt(b bool) int { - if b { - return 1 - } - return 0 -} diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 078a8ba..6d69dad 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -1,5 +1,5 @@ // 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 import ( @@ -191,7 +191,6 @@ func New(root string, db *store.DB, accounts []*config.Account, engine *rules.En {Title: "Account", Width: 12}, {Title: "Amount", Width: 12}, {Title: "Tag", Width: 14}, - {Title: "T", Width: 1}, {Title: "Description", Width: 40}, }), 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: "Account", Width: 12}, {Title: "Tag", Width: 14}, - {Title: "T", Width: 1}, {Title: "Txns", Width: 6}, {Title: "Note", Width: 24}, }), @@ -269,16 +267,12 @@ func (m *Model) reloadTxns() error { rows := make([]table.Row, 0, len(txns)) for _, t := range txns { - transfer := "" - if t.IsTransfer() { - transfer = "T" - } tag := t.Tag() if tag != "" && t.ManualTag != "" { tag += "*" // marks a manual override } 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 @@ -573,16 +567,12 @@ func (m *Model) reloadRuleList() error { if m.ruleUsage[i] == 0 { marker = "✗" } - transfer := "" - if r.Transfer { - transfer = "T" - } account := r.Account if account == "" { account = "(all)" } 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, }) } @@ -1116,30 +1106,8 @@ func (m *Model) updateNormal(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.text.Focus() 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": - // 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 { 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) case viewReport: - return "money · report · " + scope + " · transfers excluded" + return "money · report · " + scope default: return fmt.Sprintf("money · transactions · %s · %d rows", scope, len(m.txns)) } @@ -1421,6 +1389,6 @@ func (m *Model) help() string { case viewReport: return "1 accounts · 2 transactions · 4 new rule · 5 rules · u untagged · a all accounts · q quit" 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" } } diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index 342c2ed..2c5183b 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -15,6 +15,7 @@ import ( "git.petrovv.com/nikola/money/internal/config" "git.petrovv.com/nikola/money/internal/importer" "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/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) key(t, m, "2") cursorTo(t, m, "LIDL SOFIA") - key(t, m, "x") - txn := findTxn(t, db, "LIDL SOFIA") - if !txn.IsTransfer() { - t.Error("expected x to mark the row as a transfer") + key(t, m, "t") + typeText(t, m, "treats") + key(t, m, "enter") + 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") - txn = findTxn(t, db, "LIDL SOFIA") - if txn.ManualTransfer != nil || txn.ManualTag != "" { - t.Errorf("expected c to clear overrides, got manual tag %q transfer %v", txn.ManualTag, txn.ManualTransfer) + txn := findTxn(t, db, "LIDL SOFIA") + if txn.ManualTag != "" { + t.Errorf("expected c to clear the manual tag, got %q", txn.ManualTag) } if txn.Tag() != "groceries" { 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) - key(t, m, "2") - cursorTo(t, m, "LIDL SOFIA") - key(t, m, "x") // mark as a transfer key(t, m, "3") // report view view := m.View() - if strings.Contains(view, "groceries") { - t.Error("a transfer must not appear in the report") - } - if !strings.Contains(view, "TOTAL") { - t.Error("expected a TOTAL row in the report") + for _, want := range []string{"groceries", report.Untagged, "TOTAL"} { + if !strings.Contains(view, want) { + t.Errorf("report is missing %q:\n%s", want, view) + } } }