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>
92 lines
2.8 KiB
Go
92 lines
2.8 KiB
Go
// Package parser turns a statement file into raw transactions.
|
|
//
|
|
// Every bank needs its own extraction logic, so parsers are looked up by name
|
|
// from a registry. Two are built in: "csv" for delimited exports with
|
|
// configurable columns, and "cmd" for shelling out to an external extractor
|
|
// (which is how the existing Python scripts are used until they are ported).
|
|
// Ported extractors register themselves here and become usable by putting
|
|
// their name in an account.toml.
|
|
package parser
|
|
|
|
import (
|
|
"fmt"
|
|
"sort"
|
|
"sync"
|
|
|
|
"git.petrovv.com/nikola/money/internal/config"
|
|
)
|
|
|
|
// RawTxn is one line as it came out of a statement, before fingerprinting,
|
|
// deduplication or tagging.
|
|
type RawTxn struct {
|
|
Date string // YYYY-MM-DD
|
|
Description string
|
|
AmountMinor int64 // signed; negative is an outflow
|
|
|
|
// Counterparty is the other side's account number, where the statement
|
|
// gives one. Optional.
|
|
Counterparty string
|
|
// Type is the bank's own classification of the transaction. Optional.
|
|
Type string
|
|
// BalanceMinor is the running balance after this transaction, when the
|
|
// statement reports one. Optional; enables the balance-chain check.
|
|
BalanceMinor *int64
|
|
}
|
|
|
|
// Parser extracts transactions from a single statement file.
|
|
type Parser interface {
|
|
// Parse reads the statement at path. digits is the account's minor-unit
|
|
// scale, so parsers can convert decimal strings without guessing.
|
|
Parse(path string, acc *config.Account) ([]RawTxn, error)
|
|
}
|
|
|
|
// Warner is an optional interface for parsers that legitimately drop rows --
|
|
// pending transactions, other currencies -- and want to say so. The importer
|
|
// collects the warnings from the most recent Parse call and reports them.
|
|
type Warner interface {
|
|
Warnings() []string
|
|
}
|
|
|
|
// Factory builds a parser from an account's config, validating it up front so
|
|
// a bad account.toml fails before any file is read.
|
|
type Factory func(acc *config.Account) (Parser, error)
|
|
|
|
var (
|
|
mu sync.RWMutex
|
|
registry = map[string]Factory{}
|
|
)
|
|
|
|
// Register adds a named parser. It panics on a duplicate name, since that can
|
|
// only be a programming error at init time.
|
|
func Register(name string, f Factory) {
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
if _, dup := registry[name]; dup {
|
|
panic("parser: duplicate registration of " + name)
|
|
}
|
|
registry[name] = f
|
|
}
|
|
|
|
// For builds the parser named by acc.Parser.
|
|
func For(acc *config.Account) (Parser, error) {
|
|
mu.RLock()
|
|
f, ok := registry[acc.Parser]
|
|
mu.RUnlock()
|
|
if !ok {
|
|
return nil, fmt.Errorf("account %s: unknown parser %q (available: %v)", acc.Slug, acc.Parser, Names())
|
|
}
|
|
return f(acc)
|
|
}
|
|
|
|
// Names lists the registered parsers, for error messages and `money parsers`.
|
|
func Names() []string {
|
|
mu.RLock()
|
|
defer mu.RUnlock()
|
|
out := make([]string, 0, len(registry))
|
|
for name := range registry {
|
|
out = append(out, name)
|
|
}
|
|
sort.Strings(out)
|
|
return out
|
|
}
|