package rules import ( "path/filepath" "testing" "git.petrovv.com/nikola/money/internal/config" "git.petrovv.com/nikola/money/internal/model" "git.petrovv.com/nikola/money/internal/store" ) func testDB(t *testing.T) *store.DB { t.Helper() db, err := store.Open(filepath.Join(t.TempDir(), "index.db")) if err != nil { t.Fatal(err) } t.Cleanup(func() { db.Close() }) return db } // seed inserts transactions with no rule verdict yet. func seed(t *testing.T, db *store.DB, descriptions ...string) int64 { t.Helper() accountID, err := db.UpsertAccount(model.Account{ Slug: "checking", Name: "Checking", Currency: "EUR", MinorDigits: 2, }) if err != nil { t.Fatal(err) } sourceID, err := db.SourceFile(accountID, "checking/st.csv", "sha", "2026-01-01T00:00:00Z") if err != nil { t.Fatal(err) } for i, d := range descriptions { added, err := db.InsertTransaction(model.Transaction{ AccountID: accountID, SourceFileID: sourceID, Fingerprint: d, Date: "2026-01-0" + string(rune('1'+i)), Description: d, AmountMinor: -1000, }) if err != nil { t.Fatal(err) } if !added { t.Fatalf("seed %q was not inserted", d) } } return accountID } func byDescription(t *testing.T, db *store.DB, desc string) model.Transaction { t.Helper() txns, err := db.Transactions(store.Filter{}) if err != nil { t.Fatal(err) } for _, x := range txns { if x.Description == desc { return x } } t.Fatalf("no transaction with description %q", desc) return model.Transaction{} } // A manual tag must outlive any number of rule changes and re-runs. This is // the property that makes retag safe to run at will. func TestManualTagSurvivesRetag(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) } // The user hand-tags the row the rules did not match, and overrides the // one they did. shop := byDescription(t, db, "SOME RANDOM SHOP") if err := db.SetManualTag(shop.ID, "hobby"); err != nil { t.Fatal(err) } lidl := byDescription(t, db, "LIDL SOFIA") if err := db.SetManualTag(lidl.ID, "party supplies"); err != nil { t.Fatal(err) } // Rules change completely, and are re-applied twice for good measure. engine = New(&config.Rules{Rule: []config.Rule{ {Match: "*LIDL*", Tag: "food"}, {Match: "*SHOP*", Tag: "shopping"}, }}) for range 2 { if _, err := engine.Retag(db); err != nil { t.Fatal(err) } } shop = byDescription(t, db, "SOME RANDOM SHOP") if got := shop.Tag(); got != "hobby" { t.Errorf("manual tag = %q, want %q (manual tags must win)", got, "hobby") } if shop.RuleTag != "shopping" { t.Errorf("rule_tag = %q, want %q (rules must still be recorded underneath)", shop.RuleTag, "shopping") } lidl = byDescription(t, db, "LIDL SOFIA") if got := lidl.Tag(); got != "party supplies" { t.Errorf("manual override = %q, want %q", got, "party supplies") } // Clearing the override falls back to whatever the rules now say. if err := db.ClearOverrides(lidl.ID); err != nil { t.Fatal(err) } if got := byDescription(t, db, "LIDL SOFIA").Tag(); got != "food" { t.Errorf("after clearing override, tag = %q, want %q", got, "food") } } func TestFirstMatchWins(t *testing.T) { engine := New(&config.Rules{Rule: []config.Rule{ {Match: "*LIDL EXPRESS*", Tag: "snacks"}, {Match: "*LIDL*", Tag: "groceries"}, }}) 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" { t.Errorf("tag = %q, want groceries", tag) } if tag, _ := engine.Apply("checking", "SOMETHING ELSE"); tag != "" { t.Errorf("tag = %q, want empty for an unmatched description", tag) } } // A rule can be dead two ways: nothing matches it, or an earlier rule already // claimed everything it would have caught. Usage must report both as zero. func TestUsageCountsFirstMatchOnly(t *testing.T) { engine := New(&config.Rules{Rule: []config.Rule{ {Match: "*LIDL*", Tag: "groceries"}, // claims both LIDL rows {Match: "*LIDL SOFIA*", Tag: "shadowed"}, // fully shadowed by the above {Match: "*NOTHING MATCHES ME*", Tag: "no"}, // matches nothing at all {Match: "*PAYROLL*", Tag: "salary"}, // claims one row }}) txns := []model.Transaction{ {AccountSlug: "checking", Description: "LIDL SOFIA 4412"}, {AccountSlug: "checking", Description: "LIDL VARNA 9911"}, {AccountSlug: "checking", Description: "ACME PAYROLL"}, {AccountSlug: "checking", Description: "UNMATCHED SHOP"}, } usage := engine.Usage(txns) want := []int{2, 0, 0, 1} if len(usage) != len(want) { t.Fatalf("usage has %d entries, want %d", len(usage), len(want)) } for i := range want { if usage[i] != want[i] { t.Errorf("rule %d used by %d transactions, want %d", i+1, usage[i], want[i]) } } } func TestMatchIndex(t *testing.T) { engine := New(&config.Rules{Rule: []config.Rule{ {Match: "*LIDL EXPRESS*", Tag: "snacks"}, {Match: "*LIDL*", Tag: "groceries"}, }}) if got := engine.MatchIndex("checking", model.Transaction{Description: "LIDL EXPRESS 1"}); got != 0 { t.Errorf("index = %d, want 0", got) } if got := engine.MatchIndex("checking", model.Transaction{Description: "LIDL 1"}); got != 1 { t.Errorf("index = %d, want 1", got) } if got := engine.MatchIndex("checking", model.Transaction{Description: "OTHER"}); got != -1 { t.Errorf("index = %d, want -1 for no match", got) } } func TestAccountScopedRule(t *testing.T) { engine := New(&config.Rules{Rule: []config.Rule{ {Match: "*TRANSFER*", Tag: "transfer", Transfer: true, 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) } tag, transfer = engine.Apply("checking", "TRANSFER FROM CHECKING") if tag != "misc" || transfer { t.Errorf("checking: tag=%q transfer=%v, want misc/false", tag, transfer) } } // Besides the description, a rule can match the bank's own classification of // the transaction, on its own or alongside a description glob. func TestTypeRules(t *testing.T) { engine := New(&config.Rules{Rule: []config.Rule{ {Type: "CARD_PAYMENT", Match: "*LIDL*", Tag: "groceries"}, {Type: "ATM", Tag: "cash"}, }}) // A rule setting several patterns requires all of them to match. 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{ Description: "LIDL SOFIA", Type: "TRANSFER", }) if tag != "" { t.Errorf("tag = %q, want empty: the type does not match", tag) } // Type alone works too. 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) } }