Files
money/internal/parser/parser.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

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
}