package parser import ( "encoding/csv" "fmt" "io" "os" "strings" "time" "git.petrovv.com/nikola/money/internal/config" ) func init() { Register("csv", newCSVParser) } // csvParser reads a delimited statement using column positions from // account.toml. Amounts come either from one signed column, or from a // debit/credit pair. type csvParser struct { cfg config.CSVConfig digits int } func newCSVParser(acc *config.Account) (Parser, error) { if acc.CSV == nil { return nil, fmt.Errorf("account %s: parser \"csv\" requires a [csv] section", acc.Slug) } cfg := *acc.CSV if cfg.Amount == nil && cfg.Debit == nil && cfg.Credit == nil { return nil, fmt.Errorf("account %s: [csv] needs either amount or debit/credit columns", acc.Slug) } if cfg.Amount != nil && (cfg.Debit != nil || cfg.Credit != nil) { return nil, fmt.Errorf("account %s: [csv] sets both amount and debit/credit; pick one", acc.Slug) } if cfg.Date.Layout == "" { return nil, fmt.Errorf("account %s: [csv] date needs a layout, e.g. layout = \"02.01.2006\"", acc.Slug) } return &csvParser{cfg: cfg, digits: acc.Digits()}, nil } func (p *csvParser) Parse(path string, acc *config.Account) ([]RawTxn, error) { f, err := os.Open(path) if err != nil { return nil, err } defer f.Close() r := csv.NewReader(f) r.FieldsPerRecord = -1 // statements are ragged more often than not r.LazyQuotes = true if d := p.cfg.Delimiter; d != "" { runes := []rune(d) if len(runes) != 1 { return nil, fmt.Errorf("delimiter %q must be a single character", d) } r.Comma = runes[0] } var out []RawTxn for row := 0; ; row++ { rec, err := r.Read() if err == io.EOF { break } if err != nil { return nil, fmt.Errorf("%s: row %d: %w", path, row+1, err) } if row < p.cfg.SkipRows { continue } if isBlank(rec) { continue } txn, err := p.row(rec) if err != nil { return nil, fmt.Errorf("%s: row %d: %w", path, row+1, err) } out = append(out, txn) } return out, nil } func (p *csvParser) row(rec []string) (RawTxn, error) { var t RawTxn raw, err := field(rec, p.cfg.Date.Col) if err != nil { return t, fmt.Errorf("date column: %w", err) } d, err := time.Parse(p.cfg.Date.Layout, strings.TrimSpace(raw)) if err != nil { return t, fmt.Errorf("date %q does not match layout %q", raw, p.cfg.Date.Layout) } t.Date = d.Format("2006-01-02") desc, err := field(rec, p.cfg.Description.Col) if err != nil { return t, fmt.Errorf("description column: %w", err) } t.Description = strings.TrimSpace(desc) switch { case p.cfg.Amount != nil: raw, err := field(rec, p.cfg.Amount.Col) if err != nil { return t, fmt.Errorf("amount column: %w", err) } v, err := ParseAmount(raw, p.cfg.Amount.Decimal, p.cfg.Amount.Thousands, p.digits) if err != nil { return t, err } t.AmountMinor = v default: // Debit/credit pair: exactly one of the two carries a value, and both // are written as positive numbers. debit, err := p.optional(rec, p.cfg.Debit) if err != nil { return t, fmt.Errorf("debit column: %w", err) } credit, err := p.optional(rec, p.cfg.Credit) if err != nil { return t, fmt.Errorf("credit column: %w", err) } if debit != 0 && credit != 0 { return t, fmt.Errorf("both debit (%d) and credit (%d) are set", debit, credit) } t.AmountMinor = credit - abs(debit) } if p.cfg.Invert { t.AmountMinor = -t.AmountMinor } return t, nil } // optional parses a column that may legitimately be blank, as debit/credit // columns always are for half the rows. func (p *csvParser) optional(rec []string, c *config.Column) (int64, error) { if c == nil { return 0, nil } raw, err := field(rec, c.Col) if err != nil { return 0, err } if strings.TrimSpace(raw) == "" { return 0, nil } return ParseAmount(raw, c.Decimal, c.Thousands, p.digits) } func field(rec []string, i int) (string, error) { if i < 0 || i >= len(rec) { return "", fmt.Errorf("index %d out of range, row has %d columns", i, len(rec)) } return rec[i], nil } func isBlank(rec []string) bool { for _, f := range rec { if strings.TrimSpace(f) != "" { return false } } return true } func abs(v int64) int64 { if v < 0 { return -v } return v }