counterparty was a structured field only nlb could fill honestly. revolut and traderepublic invented one by running an IBAN-shaped regex over the description they had just built, and the two spellings disagreed -- SI56 1234 5678 9012 345 against SI56123456789012345 -- so a literal rule pattern that worked on one account silently matched nothing on another. It is gone from the model, the index, the rule keys, ls --wide and the rules screen. nlb now appends its IBAN column to the end of the description, where the other two already keep theirs, so match = "*SI56*" works everywhere. That changes those descriptions and with them their fingerprints, so a statement overlapping an already-imported period will re-add rather than dedupe those rows until the index is rebuilt. An index built by an older binary drops the column when it is opened. The index itself moves from .money/index.db up to index.db beside rules.toml. Nothing looks in the old location, so an existing one has to be moved by hand -- otherwise the tool quietly starts a fresh index and the manual tags in the old file, the only thing statements cannot reproduce, stay behind in it. The csv and cmd parsers are gone along with the [csv] and [cmd] config they carried. cmd shelled out to the Python extractors, which were ported to Go and deleted, so it bridged to nothing; csv was a generic column-mapped fallback that no account used, and between them they were the largest configuration surface in the tool. A bank is now described in Go, where it can be tested. The importer tests register their own three-column parser rather than borrow a bank's, so they stay about the directory walk, dedupe and per-file error reporting. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
116 lines
3.6 KiB
Go
116 lines
3.6 KiB
Go
// 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.
|
|
//
|
|
// Only the rule_* columns are ever written. Manual edits made in the TUI live
|
|
// in separate columns and survive any number of re-runs.
|
|
package rules
|
|
|
|
import (
|
|
"git.petrovv.com/nikola/money/internal/config"
|
|
"git.petrovv.com/nikola/money/internal/glob"
|
|
"git.petrovv.com/nikola/money/internal/model"
|
|
"git.petrovv.com/nikola/money/internal/store"
|
|
)
|
|
|
|
// Engine evaluates rules in file order; the first match wins.
|
|
type Engine struct {
|
|
rules []config.Rule
|
|
}
|
|
|
|
// New builds an engine from the parsed rules file.
|
|
func New(r *config.Rules) *Engine {
|
|
return &Engine{rules: r.Rule}
|
|
}
|
|
|
|
// Rules returns the ordered rules, as loaded from rules.toml.
|
|
func (e *Engine) Rules() []config.Rule { return e.rules }
|
|
|
|
// Match returns the first rule matching a transaction on the given account, or
|
|
// nil if none does.
|
|
func (e *Engine) Match(accountSlug string, t model.Transaction) *config.Rule {
|
|
if i := e.MatchIndex(accountSlug, t); i >= 0 {
|
|
return &e.rules[i]
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// MatchIndex returns the position of the first rule matching a transaction, or
|
|
// -1 if none does. Every pattern a rule sets must match: a rule with both
|
|
// match and type is an "and", not an "or".
|
|
//
|
|
// The position matters as well as the rule: because the first match wins, a
|
|
// rule that is fully shadowed by an earlier one never applies to anything, and
|
|
// only the index reveals that.
|
|
func (e *Engine) MatchIndex(accountSlug string, t model.Transaction) int {
|
|
var (
|
|
description = model.NormalizeDescription(t.Description)
|
|
kind = model.NormalizeDescription(t.Type)
|
|
)
|
|
for i := range e.rules {
|
|
r := &e.rules[i]
|
|
if r.Account != "" && r.Account != accountSlug {
|
|
continue
|
|
}
|
|
if r.Match != "" && !glob.Match(r.Match, description) {
|
|
continue
|
|
}
|
|
if r.Type != "" && !glob.Match(r.Type, kind) {
|
|
continue
|
|
}
|
|
return i
|
|
}
|
|
return -1
|
|
}
|
|
|
|
// Usage counts how many transactions each rule actually claims. A rule with a
|
|
// count of zero is dead: either nothing matches it, or an earlier rule takes
|
|
// everything it would have caught.
|
|
func (e *Engine) Usage(txns []model.Transaction) []int {
|
|
counts := make([]int, len(e.rules))
|
|
for _, t := range txns {
|
|
if i := e.MatchIndex(t.AccountSlug, t); i >= 0 {
|
|
counts[i]++
|
|
}
|
|
}
|
|
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) {
|
|
if r := e.Match(accountSlug, t); r != nil {
|
|
return r.Tag, r.Transfer
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
// Apply is the description-only shorthand, for callers that have nothing else.
|
|
func (e *Engine) Apply(accountSlug, description string) (tag string, transfer bool) {
|
|
return e.ApplyTxn(accountSlug, model.Transaction{Description: description})
|
|
}
|
|
|
|
// Retag recomputes rule verdicts for every transaction in the index and
|
|
// writes them back. It returns how many rows changed.
|
|
func (e *Engine) Retag(db *store.DB) (int, error) {
|
|
txns, err := db.Transactions(store.Filter{})
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
var changed []store.RuleAssignment
|
|
for _, t := range txns {
|
|
tag, transfer := e.ApplyTxn(t.AccountSlug, t)
|
|
if tag == t.RuleTag && transfer == t.RuleTransfer {
|
|
continue
|
|
}
|
|
changed = append(changed, store.RuleAssignment{ID: t.ID, Tag: tag, Transfer: transfer})
|
|
}
|
|
if len(changed) == 0 {
|
|
return 0, nil
|
|
}
|
|
if err := db.ApplyRuleResults(changed); err != nil {
|
|
return 0, err
|
|
}
|
|
return len(changed), nil
|
|
}
|