Files
nikolaandClaude Opus 5.5 a5f7541980 Name NLB uploads, delete statements, and stop importing on upload
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>
2026-10-02 21:48:29 +02:00

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
}