Files
money/internal/config/config.go
T
nikolaandClaude Opus 5 b0026c5a79 Add money: statement-driven personal finance tracker
A data directory holds one folder per account. Statements dropped into
those folders are parsed into a rebuildable SQLite index, categorised by
ordered glob rules in rules.toml, and browsed or hand-tagged in a Bubble
Tea TUI. Movements between the user's own accounts are marked as
transfers by the same rules and excluded from spending totals.

Manual tags and transfer marks are stored separately from the rule-derived
ones and always win, so editing rules.toml and re-running retag never
destroys hand edits.

Parsers are pluggable. Three are ported from the Python extractors they
replace -- nlb and traderepublic read PDFs via pdftotext -layout, revolut
reads the CSV export -- alongside a configurable-column CSV parser and a
cmd parser that shells out to an external script.

Both ports fix two latent bugs in the originals: the sign character class
rejected the typographic minus U+2212 that some PDF fonts emit, and NLB's
hardcoded continuation indent broke when pdftotext compressed runs of
spaces, so the threshold is now measured from the description column.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 00:37:20 +02:00

180 lines
5.8 KiB
Go

// Package config loads the two hand-edited files the tool reads: the
// data-root-wide rules.toml, and one account.toml per account folder.
package config
import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"github.com/BurntSushi/toml"
)
const (
// RulesFile is the rules file at the root of the data directory.
RulesFile = "rules.toml"
// AccountFile is the per-account config inside each account folder.
AccountFile = "account.toml"
// StateDir holds the rebuildable SQLite index.
StateDir = ".money"
// IndexFile is the SQLite index inside StateDir.
IndexFile = "index.db"
)
// Rule is one entry in rules.toml. Rules are evaluated in file order and the
// first one whose Match (and optional Account) matches wins.
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
// Counterparty matches the other side's account number, which for
// movements between the user's own accounts is often the only reliable
// signal. Optional; when set, it must match as well as Match.
Counterparty string `toml:"counterparty"`
// Type matches the bank's own classification, e.g. Revolut's CARD_PAYMENT.
// Optional; when set, it must match as well as Match.
Type string `toml:"type"`
}
// Rules is the parsed rules.toml.
type Rules struct {
Rule []Rule `toml:"rule"`
}
// LoadRules reads rules.toml from the data root. A missing file is not an
// error: it just means nothing is tagged automatically yet.
func LoadRules(root string) (*Rules, error) {
path := filepath.Join(root, RulesFile)
var r Rules
if _, err := toml.DecodeFile(path, &r); err != nil {
if os.IsNotExist(err) {
return &r, nil
}
return nil, fmt.Errorf("%s: %w", path, err)
}
for i, rule := range r.Rule {
if rule.Match == "" && rule.Counterparty == "" && rule.Type == "" {
return nil, fmt.Errorf("%s: rule %d has no match, counterparty 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)
}
}
return &r, nil
}
// Column locates one field in a CSV row.
type Column struct {
Col int `toml:"col"`
Layout string `toml:"layout"` // date only, Go reference layout
Decimal string `toml:"decimal"` // amount only, default "."
Thousands string `toml:"thousands"` // amount only, default ""
}
// CSVConfig describes how to read a delimited statement.
type CSVConfig struct {
Delimiter string `toml:"delimiter"`
SkipRows int `toml:"skip_rows"`
Encoding string `toml:"encoding"` // "" or "utf-8"; other encodings unsupported for now
Date Column `toml:"date"`
Description Column `toml:"description"`
Amount *Column `toml:"amount"` // single signed column...
Debit *Column `toml:"debit"` // ...or a debit/credit pair
Credit *Column `toml:"credit"`
// Invert flips the sign of the parsed amount, for statements that report
// outflows as positive numbers.
Invert bool `toml:"invert"`
}
// CmdConfig runs an external extractor (e.g. one of the existing Python
// scripts) and reads normalised CSV from its stdout.
type CmdConfig struct {
// Argv is the command to run. The literal token "{{file}}" is replaced
// with the absolute path of the statement being imported.
Argv []string `toml:"argv"`
// Layout is the date layout the script emits; defaults to 2006-01-02.
Layout string `toml:"layout"`
// SkipRows skips leading rows of the script's output (e.g. a header).
SkipRows int `toml:"skip_rows"`
}
// Account is a parsed account.toml.
type Account struct {
Slug string // folder name, filled in by LoadAccounts
Dir string // absolute path to the account folder
Name string `toml:"name"`
Currency string `toml:"currency"`
MinorDigits *int `toml:"minor_digits"`
Parser string `toml:"parser"`
// Include restricts which files in the folder are treated as statements.
// Defaults to every regular file except account.toml and dotfiles.
Include []string `toml:"include"`
CSV *CSVConfig `toml:"csv"`
Cmd *CmdConfig `toml:"cmd"`
}
// Digits returns the configured minor-unit scale, defaulting to 2.
func (a *Account) Digits() int {
if a.MinorDigits != nil {
return *a.MinorDigits
}
return 2
}
// LoadAccounts finds every account folder under root. A folder is an account
// if it contains an account.toml.
func LoadAccounts(root string) ([]*Account, error) {
entries, err := os.ReadDir(root)
if err != nil {
return nil, fmt.Errorf("read data root %s: %w", root, err)
}
var accounts []*Account
for _, e := range entries {
if !e.IsDir() || strings.HasPrefix(e.Name(), ".") {
continue
}
dir := filepath.Join(root, e.Name())
cfgPath := filepath.Join(dir, AccountFile)
if _, err := os.Stat(cfgPath); err != nil {
continue // not an account folder
}
a, err := loadAccount(dir, e.Name(), cfgPath)
if err != nil {
return nil, err
}
accounts = append(accounts, a)
}
sort.Slice(accounts, func(i, j int) bool { return accounts[i].Slug < accounts[j].Slug })
return accounts, nil
}
func loadAccount(dir, slug, cfgPath string) (*Account, error) {
var a Account
if _, err := toml.DecodeFile(cfgPath, &a); err != nil {
return nil, fmt.Errorf("%s: %w", cfgPath, err)
}
a.Slug = slug
a.Dir = dir
if a.Name == "" {
a.Name = slug
}
if a.Currency == "" {
return nil, fmt.Errorf("%s: currency is required", cfgPath)
}
if a.Parser == "" {
return nil, fmt.Errorf("%s: parser is required", cfgPath)
}
if a.Digits() < 0 || a.Digits() > 8 {
return nil, fmt.Errorf("%s: minor_digits must be between 0 and 8", cfgPath)
}
return &a, nil
}
// IndexPath returns the location of the SQLite index for a data root.
func IndexPath(root string) string {
return filepath.Join(root, StateDir, IndexFile)
}