Files
nikolaandClaude Opus 5.5 a5f7541980 Name NLB uploads, delete statements, and stop importing on upload
Four changes to statement handling in the web app, made together and
touching the same upload and statements-list code.

Name NLB uploads by statement date. parser.Namer is an optional
interface, like Warner, through which a parser names its statements;
nlb reads the "Datum izpiska" from the izpisek header and names it
izpisek_YYYY_MM_DD, lowercase, extension included -- ported from the
rename_izpiski.py it replaces. Uploads are staged as dotfiles, invisible
to import, so the parser can read them; two downloads of one statement
then meet under one name and the second is recognised as already there,
while a different statement of the same date is numbered _2 as the
script did. Only uploads are named: source_files records statements by
path, so renaming a file already in a folder would orphan its rows.

Delete a statement from the statements list. The file is removed from
disk for good -- the page says so before it asks -- and
store.ForgetSourceFile drops its transactions and their transfer rows.
A row two overlapping statements share is stored once, under the file
imported first, so it goes too; the account's other statements forget
their checksums and show as changed until the next Import re-reads them
and restores it. A file already gone from disk can be forgotten.

Upload and delete no longer import. Importing stays the user's call,
made with the Import button, so a batch can be put together and looked
over first. Delete still re-pairs transfers, which reads no statement.

Show rows and new rows per statement. The list read "0" for a file
whose rows an earlier, overlapping statement already held, which looked
like a file that failed to parse. source_files now records how many
transactions each statement holds, and the list reads "3 rows · 0 new".

This adds a column the code reads, so an index built by an earlier
version fails with "no such column: s.rows": delete index.db and import
again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 21:48:29 +02:00

257 lines
8.2 KiB
Go

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", 0)
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 tag is derived state: rules.toml is the only thing that decides it, so
// retagging rewrites every tag from the current rules however often it runs,
// including back to nothing when no rule matches any more.
func TestRetagRewritesEveryTag(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"},
{Match: "*SHOP*", Tag: "shopping"},
}})
n, err := engine.Retag(db)
if err != nil {
t.Fatal(err)
}
if n != 2 {
t.Errorf("retagged %d rows, want 2", n)
}
if got := byDescription(t, db, "LIDL SOFIA").RuleTag; got != "groceries" {
t.Errorf("tag = %q, want groceries", got)
}
// Re-running with the same rules changes nothing and says so.
if n, err := engine.Retag(db); err != nil || n != 0 {
t.Errorf("second retag: %d rows, %v; want 0 and no error", n, err)
}
// New rules win, and a row no rule matches loses its tag entirely.
engine = New(&config.Rules{Rule: []config.Rule{
{Match: "*LIDL*", Tag: "food"},
}})
if _, err := engine.Retag(db); err != nil {
t.Fatal(err)
}
if got := byDescription(t, db, "LIDL SOFIA").RuleTag; got != "food" {
t.Errorf("tag = %q, want food", got)
}
if got := byDescription(t, db, "SOME RANDOM SHOP").RuleTag; got != "" {
t.Errorf("tag = %q, want empty once no rule matches", got)
}
}
// The narrower rule claims what it names wherever it sits in the file: the
// catch-all above it must not swallow the exception written below.
func TestMoreSpecificRuleWins(t *testing.T) {
both := [][]config.Rule{
{{Match: "NIK*", Tag: "misc"}, {Match: "NIKOLA*", Tag: "family"}},
{{Match: "NIKOLA*", Tag: "family"}, {Match: "NIK*", Tag: "misc"}},
}
for _, rs := range both {
engine := New(&config.Rules{Rule: rs})
if tag := engine.Apply("checking", "NIKOLA PETROV"); tag != "family" {
t.Errorf("%q first: tag = %q, want family", rs[0].Match, tag)
}
if tag := engine.Apply("checking", "NIKI TODOROV"); tag != "misc" {
t.Errorf("%q first: tag = %q, want misc", rs[0].Match, tag)
}
}
}
// Specificity is measured on what the pattern spells out, so the wildcards
// around a literal do not buy it precedence, and a bare "*" is always last.
func TestSpecificityOrder(t *testing.T) {
engine := New(&config.Rules{Rule: []config.Rule{
{Match: "*", Tag: "other"},
{Match: "*LIDL*", Tag: "groceries"},
{Match: "*LIDL EXPRESS*", Tag: "snacks"},
}})
for _, c := range []struct{ desc, want string }{
{"CARD LIDL EXPRESS 12", "snacks"},
{"CARD LIDL 12", "groceries"},
{"SOMETHING ELSE", "other"},
} {
if tag := engine.Apply("checking", c.desc); tag != c.want {
t.Errorf("%q: tag = %q, want %q", c.desc, tag, c.want)
}
}
}
// Two rules that are equally specific are still decided by the file: the
// earlier one wins, which is what makes appending a rule safe.
func TestEqualSpecificityKeepsFileOrder(t *testing.T) {
engine := New(&config.Rules{Rule: []config.Rule{
{Match: "*ACME*", Tag: "first"},
{Match: "*ACME*", Tag: "second"},
}})
if tag := engine.Apply("checking", "ACME LTD"); tag != "first" {
t.Errorf("tag = %q, want first", 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 a rule that beats it
// already claimed everything it would have caught. Usage must report both as
// zero, indexed by file position however the rules are ordered for matching.
func TestUsageCountsWinnersOnly(t *testing.T) {
engine := New(&config.Rules{Rule: []config.Rule{
{Match: "*LIDL SOFIA*", Tag: "groceries"}, // the narrower of the two
{Match: "*LIDL*", Tag: "shadowed"}, // every LIDL row here is Sofia
{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 SOFIA 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])
}
}
}
// The index is the rule's position in rules.toml, not its position in the order
// it was tried in — that is the number the rules screen shows and deletes by.
func TestMatchIndex(t *testing.T) {
engine := New(&config.Rules{Rule: []config.Rule{
{Match: "*LIDL*", Tag: "groceries"},
{Match: "*LIDL EXPRESS*", Tag: "snacks"},
}})
if got := engine.MatchIndex("checking", model.Transaction{Description: "LIDL EXPRESS 1"}); got != 1 {
t.Errorf("index = %d, want 1", got)
}
if got := engine.MatchIndex("checking", model.Transaction{Description: "LIDL 1"}); got != 0 {
t.Errorf("index = %d, want 0", got)
}
if got := engine.MatchIndex("checking", model.Transaction{Description: "OTHER"}); got != -1 {
t.Errorf("index = %d, want -1 for no match", got)
}
}
// Naming an account is itself a narrowing, so the scoped rule beats the
// identical unscoped one even when the unscoped one is written first.
func TestAccountScopedRule(t *testing.T) {
engine := New(&config.Rules{Rule: []config.Rule{
{Match: "*TRANSFER*", Tag: "misc"},
{Match: "*TRANSFER*", Tag: "transfer", Account: "savings"},
}})
if tag := engine.Apply("savings", "TRANSFER FROM CHECKING"); tag != "transfer" {
t.Errorf("savings: tag = %q, want transfer", tag)
}
if tag := engine.Apply("checking", "TRANSFER FROM CHECKING"); tag != "misc" {
t.Errorf("checking: tag = %q, want misc", tag)
}
}
// 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)
}
}