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>
99 lines
3.2 KiB
Go
99 lines
3.2 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 that each one joins from an init. A parser becomes usable by
|
|
// putting its name in an account.toml; there is no generic column-mapped
|
|
// parser, because a statement layout is better described in Go, where it can
|
|
// be tested, than in a table of column indexes.
|
|
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
|
|
|
|
// 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
|
|
}
|
|
|
|
// Namer is an optional interface for parsers whose bank names its downloads
|
|
// unhelpfully. StatementName reads the statement at path and returns the name,
|
|
// without extension, it should be stored under — the upload's extension is
|
|
// kept, lowercased — or "" when the statement does not say, in which case it
|
|
// keeps the name it came with. Only an upload uses
|
|
// it; files already in a folder are never renamed, since the index records
|
|
// them by path.
|
|
type Namer interface {
|
|
StatementName(path string) (string, error)
|
|
}
|
|
|
|
// 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
|
|
}
|