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/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.
+14 -17
View File
@@ -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,23 +192,22 @@ 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"
[[rule]]
match = "*FROM CHECKING*"
transfer = true
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"
# 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)
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
}
+4 -8
View File
@@ -28,7 +28,6 @@ const (
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
// Type matches the bank's own classification, e.g. Revolut's CARD_PAYMENT.
// 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 == "" {
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)
+1 -5
View File
@@ -181,7 +181,7 @@ func TestAppendRuleRejectsIncomplete(t *testing.T) {
// Globs and tags containing quotes must survive the round trip.
func TestAppendRuleQuotesValues(t *testing.T) {
root := t.TempDir()
if err := AppendRule(root, Rule{Match: `*"ODD" PAYEE*`, Tag: "misc", Transfer: true}); err != nil {
if err := AppendRule(root, Rule{Match: `*"ODD" PAYEE*`, Tag: "misc"}); err != nil {
t.Fatal(err)
}
loaded, err := LoadRules(root)
@@ -191,9 +191,6 @@ func TestAppendRuleQuotesValues(t *testing.T) {
if loaded.Rule[0].Match != `*"ODD" PAYEE*` {
t.Errorf("match = %q, want the quotes preserved", loaded.Rule[0].Match)
}
if !loaded.Rule[0].Transfer {
t.Error("transfer flag was not written")
}
}
// A note is documentation carried with the rule. It is a key rather than a
@@ -268,7 +265,6 @@ tag = "salary"
# Moving money to myself.
[[rule]]
match = "*TO SAVINGS*"
transfer = true
tag = "transfer"
`
+1 -1
View File
@@ -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
-11
View File
@@ -41,8 +41,6 @@ type Transaction struct {
RuleTag string
ManualTag string
RuleTransfer bool
ManualTransfer *bool
}
// 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)
-6
View File
@@ -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
+11 -13
View File
@@ -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
+11 -72
View File
@@ -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)
}
}
+16 -46
View File
@@ -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
@@ -300,25 +297,18 @@ func (d *DB) Transactions(f Filter) ([]model.Transaction, error) {
for rows.Next() {
var (
t model.Transaction
ruleTransfer int
manualTransfer sql.NullInt64
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,21 +326,10 @@ 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
}
@@ -358,11 +337,10 @@ func (d *DB) ClearOverrides(id int64) error {
type RuleAssignment struct {
ID int64
Tag string
Transfer bool
}
// ApplyRuleResults rewrites rule_tag and rule_transfer for every listed row in
// a single transaction. Manual columns are never touched.
// ApplyRuleResults rewrites rule_tag for every listed row in a single
// transaction. The manual column is never touched.
func (d *DB) ApplyRuleResults(rs []RuleAssignment) error {
tx, err := d.sql.Begin()
if err != nil {
@@ -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
}
+6 -38
View File
@@ -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"
}
}
+17 -22
View File
@@ -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")
for _, want := range []string{"groceries", report.Untagged, "TOTAL"} {
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")
}
}