diff --git a/cmd/cli/config.go b/cmd/cli/config.go index 36165e7..0696107 100644 --- a/cmd/cli/config.go +++ b/cmd/cli/config.go @@ -18,4 +18,5 @@ type Config struct { Compact bool GroupDate string NoPercent bool + CSV string } diff --git a/cmd/cli/main.go b/cmd/cli/main.go index e75e979..8b1fe1d 100644 --- a/cmd/cli/main.go +++ b/cmd/cli/main.go @@ -3,9 +3,11 @@ package cli import ( "bufio" "context" + "encoding/csv" "flag" "fmt" "io" + "log" "maps" "math" "os" @@ -35,6 +37,7 @@ func Main() { fs.BoolVar(&config.Query.NoRounding, "no-rounding", false, "no rounding") fs.BoolVar(&config.Compact, "c", false, "reg entries oneline") fs.StringVar(&config.Query.With, "w", "", "regexp for transactions") + fs.StringVar(&config.CSV, "csv", "", "if csv then the csv") fs.IntVar(&config.Query.Depth, "depth", 0, "depth grouping") fs.BoolVar(&config.Query.Normalize, "n", false, "normalize with default normalizer") fs.BoolVar(&config.Query.USDOnly, "usd", false, "filter to usd") @@ -50,27 +53,27 @@ func Main() { files := config.Files.Strings() if len(files) == 0 { - panic("must specify at least one file") + log.Fatalf("must specify at least one file") } ledgerFiles, err := ledger.NewFiles(files[0], files[1:]...) if err != nil { - panic(err) + log.Fatalf("%v", err) } positional := fs.Args() if len(positional) == 0 || len(positional[0]) < 3 { - panic("positional arguments required, ie bal|reg PATTERN MATCHING") + log.Fatalf("positional arguments required, ie bal|reg PATTERN MATCHING") } cmd := positional[0] q, err := BuildQuery(config, positional[1:]) if err != nil { - panic(err) + log.Fatalf("%v", err) } deltas, err := ledgerFiles.Deltas() if err != nil { - panic(err) + log.Fatalf("%v", err) } if period := config.Query.Period; !period.Empty() { @@ -98,7 +101,7 @@ func Main() { if config.BPI != "" { b, err := ledger.NewBPIs(config.BPI) if err != nil { - panic(err) + log.Fatalf("%v", err) } bpis = b if period := config.Query.Period; !period.Empty() { @@ -115,12 +118,12 @@ func Main() { if config.CPI != "" && config.CPIYear > 0 { c, err := ledger.NewBPIs(config.CPI) if err != nil { - panic(err) + log.Fatalf("%v", err) } cpi := c["CPI"] cpiy := cpi.Lookup(fmt.Sprintf("%d-06-01", config.CPIYear)) if cpiy == nil { - panic(fmt.Errorf("no cpi for year %d", config.CPIYear)) + log.Fatalf("no cpi for year %d", config.CPIYear) } for date, value := range cpi { @@ -261,7 +264,7 @@ func Main() { } fmt.Println(asciigraph.PlotMany(points, options...)) case "rec": // reconcile via teller // DEAD - panic("dead and bad") + log.Fatalf("dead and bad") byDate := map[string]ledger.Deltas{} for _, delta := range deltas { delta := delta @@ -273,13 +276,13 @@ func Main() { teller, err := teller.New() if err != nil { - panic(err) + log.Fatalf("%v", err) } client := cache.New(teller) accounts, err := client.Accounts(ctx) if err != nil { - panic(err) + log.Fatalf("%v", err) } inDay := func(date string, transaction bank.Transaction) bool { @@ -294,7 +297,7 @@ func Main() { for _, acc := range accounts { transactions, err := client.Transactions(ctx, acc) if err != nil { - panic(err) + log.Fatalf("%v", err) } for _, transaction := range transactions { @@ -306,7 +309,7 @@ func Main() { ts, err := time.ParseInLocation("2006-01-02", transaction.Date, time.Local) if err != nil { - panic(err) + log.Fatalf("%v", err) } dayBefore := ts.Add(-24 * time.Hour).Format("2006-01-02") dayAfter := ts.Add(24 * time.Hour).Format("2006-01-02") @@ -343,8 +346,76 @@ func Main() { FPrintBalancesFor(transaction[0].Description, w, "\t\t", balances, cumulative, config.Query.USDOnly, config.Query.Normalize, transaction[0].Date, config.Compact, maxAccW, "%s%.2f") } } + case "csv": // reconcile with given csv + if config.CSV == "" { + log.Fatalf("missing required -csv") + } + f, err := os.Open(config.CSV) + if err != nil { + log.Fatalf("cannot open csv %q: %w", config.CSV, err) + } + defer f.Close() + reader := csv.NewReader(f) + fields, err := reader.Read() + if err != nil { + log.Fatal(err) + } + + dateIdxs := []int{} + for i := range fields { + if strings.Contains(strings.ToLower(fields[i]), "date") { + dateIdxs = append(dateIdxs, i) + } + } + + descIdx := slices.IndexFunc(fields, func(field string) bool { + return strings.Contains(strings.ToLower(field), "desc") + }) + + amountIdx := slices.IndexFunc(fields, func(field string) bool { + return strings.Contains(strings.ToLower(field), "amount") + }) + + for { + line, err := reader.Read() + if err == io.EOF { + break + } + if err != nil { + log.Fatalf("failed to read csv line: %w", err) + } + + dates := []string{} + for _, dateIdx := range dateIdxs { + dates = append(dates, normalizeDate(line[dateIdx])) + } + dates = slices.Compact(dates) + desc := line[descIdx] + amount := line[amountIdx] + + matches := deltas.Like( + func(delta ledger.Delta) bool { + if delta.Date < slices.Min(dates) { + return false + } + if delta.Date > slices.Max(dates) { + return false + } + if delta.Currency != ledger.USD { + return false + } + if fmt.Sprintf("%.2f", -1*delta.Value) != amount { + return false + } + return true + }, + ) + if len(matches) == 0 { + log.Printf("%s %s %s", strings.Join(dates, "="), desc, amount) + } + } default: - panic("unknown command " + positional[0]) + log.Fatalf("unknown command %q", positional[0]) } } @@ -450,3 +521,15 @@ func FPrintBalances(w io.Writer, linePrefix string, balances, cumulatives ledger } } } + +func normalizeDate(date string) string { + for _, layout := range []string{ + "01/02/2006", + } { + if t, err := time.Parse(layout, date); err == nil { + return t.Format("2006-01-02") + } + } + log.Fatalf("cannot normalize date %q", date) + return "" +}