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:
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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"
|
||||||
`
|
`
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user