package cli import ( "bufio" "context" "flag" "fmt" "io" "math" "os" "os/signal" "slices" "strings" "syscall" "time" "gogs.inhome.blapointe.com/ana-ledger/src/ana" "gogs.inhome.blapointe.com/ana-ledger/src/bank" "gogs.inhome.blapointe.com/ana-ledger/src/bank/cache" "gogs.inhome.blapointe.com/ana-ledger/src/bank/teller" "gogs.inhome.blapointe.com/ana-ledger/src/ledger" "github.com/guptarohit/asciigraph" "golang.org/x/crypto/ssh/terminal" ) func Main() { var config Config fs := flag.NewFlagSet(os.Args[0], flag.ContinueOnError) fs.Var(&config.Files, "f", "paths to files") fs.Var(&config.Query.Period, "period", "period can be YYYY, YYYY-mm, YYYY-mm-dd, x..y") fs.StringVar(&config.Query.Sort, "S", "", "sort ie date") 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.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") fs.BoolVar(&config.Query.NoExchanging, "no-exchanging", true, "omit currency exchanges") fs.StringVar(&config.BPI, "bpi", "", "path to bpi") fs.StringVar(&config.CPI, "cpi", "", "path to cpi") fs.IntVar(&config.CPIYear, "cpiy", 0, "use cpi to convert usd to this year's value") if err := fs.Parse(os.Args[1:]); err != nil { panic(err) } files := config.Files.Strings() if len(files) == 0 { panic("must specify at least one file") } ledgerFiles, err := ledger.NewFiles(files[0], files[1:]...) if err != nil { panic(err) } positional := fs.Args() if len(positional) == 0 || len(positional[0]) < 3 { panic("positional arguments required, ie bal|reg PATTERN MATCHING") } cmd := positional[0] q, err := BuildQuery(config, positional[1:]) if err != nil { panic(err) } deltas, err := ledgerFiles.Deltas() if err != nil { panic(err) } if period := config.Query.Period; !period.Empty() { after := period.Start.Format("2006-01-02") before := period.Stop.Format("2006-01-02") deltas = deltas.Like( ledger.LikeAfter(after), ledger.LikeBefore(before), ) } pattern := ".*" if depth := config.Query.Depth; depth > 0 { patterns := []string{} pattern = "" for i := 0; i < depth; i++ { pattern += "[^:]*:" patterns = append([]string{strings.Trim(pattern, ":")}, patterns...) } pattern = strings.Join(patterns, "|") } group := ledger.GroupName(pattern) bpis := make(ledger.BPIs) if config.BPI != "" { b, err := ledger.NewBPIs(config.BPI) if err != nil { panic(err) } bpis = b } cpiNormalizer := ana.NewNormalizer() if config.CPI != "" && config.CPIYear > 0 { c, err := ledger.NewBPIs(config.CPI) if err != nil { panic(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)) } for date, value := range cpi { cpiNormalizer = cpiNormalizer.With(".*", date, value/(*cpiy)) } } if config.Query.Normalize { deltas = ana.NewDefaultNormalizer().Normalize(deltas) } if config.Query.With != "" { deltas = deltas.Like(ledger.LikeWith(config.Query.With)) } w := bufio.NewWriter(os.Stdout) defer w.Flush() maxAccW := 0 for _, delta := range deltas.Like(q).Group(group) { if accW := len(delta.Name); accW > maxAccW { maxAccW = accW } } switch cmd[:3] { case "bal": balances := deltas.Balances(). WithBPIs(bpis). KindaLike(q). KindaGroup(group). Nonzero(). Normalize(cpiNormalizer, "9") FPrintBalances(w, "", balances, nil, config.Query.USDOnly, config.Query.Normalize, time.Now().Format("2006-01-02"), false, maxAccW) case "gra": dateGrouping := "^[0-9]{4}-[0-9]{2}" if period := config.Query.Period; !period.Empty() { day := time.Hour * 24 year := day * 365 r := period.Stop.Sub(period.Start) if r > 10*year { dateGrouping = "^[0-9]{4}" } else if r > 5*year { } else if r > year { dateGrouping = "^[0-9]{4}-[0-9]{2}-[0-9]" } else { dateGrouping = "^[0-9]{4}-[0-9]{2}-[0-9]{2}" } } deltas = deltas.Group(ledger.GroupDate(dateGrouping)) transactions := deltas.Transactions() cumulative := make(ledger.Balances) data := map[string][]float64{} pushData := func() { soFar := 0 for _, v := range data { soFar = len(v) } for k, balance := range cumulative { for curr, v := range balance { if curr != ledger.USD { continue } if _, ok := data[k]; !ok { data[k] = make([]float64, soFar) } data[k] = append(data[k], v) } } } for i, transaction := range transactions { if i > 0 && transactions[i-1][0].Date != transaction[0].Date { pushData() } balances := ledger.Deltas(transaction). Like(q). Group(group). Balances(). WithBPIsAt(bpis, transaction[0].Date). Nonzero(). Normalize(cpiNormalizer, transaction[0].Date) cumulative.PushAll(balances) } pushData() labels := []string{} for k := range data { labels = append(labels, k) } slices.Sort(labels) points := [][]float64{} for _, k := range labels { points = append(points, data[k]) } for i := range points { for j := range points[i] { points[i][j] /= 1000.0 } } options := []asciigraph.Option{asciigraph.Precision(0)} if _, h, err := terminal.GetSize(0); err == nil && h > 4 { options = append(options, asciigraph.Height(h-4)) } if len(labels) < 256 { seriesColors := make([]asciigraph.AnsiColor, len(labels)) for i := range seriesColors { seriesColors[i] = asciigraph.AnsiColor(i) } options = append(options, asciigraph.SeriesLegends(labels...)) options = append(options, asciigraph.SeriesColors(seriesColors...)) } fmt.Println(asciigraph.PlotMany(points, options...)) case "rec": byDate := map[string]ledger.Deltas{} for _, delta := range deltas { delta := delta byDate[delta.Date] = append(byDate[delta.Date], delta) } ctx, can := signal.NotifyContext(context.Background(), syscall.SIGINT) defer can() teller, err := teller.New() if err != nil { panic(err) } client := cache.New(teller) accounts, err := client.Accounts(ctx) if err != nil { panic(err) } inDay := func(date string, transaction bank.Transaction) bool { return slices.ContainsFunc(byDate[date], func(d ledger.Delta) bool { v := fmt.Sprintf("%.2f", d.Value) nv := fmt.Sprintf("%.2f", -1.0*d.Value) a := fmt.Sprintf("%.2f", transaction.Amount) return v == a || nv == a }) } for _, acc := range accounts { transactions, err := client.Transactions(ctx, acc) if err != nil { panic(err) } for _, transaction := range transactions { if inDay(transaction.Date, transaction) { continue } err := "missing" t, _ := time.ParseInLocation(transaction.Date, "2006-01-02", time.Local) dayBefore := t.Add(-24 * time.Hour).Format("2006-01-02") dayAfter := t.Add(-24 * time.Hour).Format("2006-01-02") if inDay(dayBefore, transaction) || inDay(dayAfter, transaction) { err = "1dayoff" } prefix := " " if transaction.Status != "posted" { prefix = "! " } fmt.Printf("[%s] %s $%7.2f %s%s (%s)\n", err, transaction.Date, transaction.Amount, prefix, transaction.Details.CounterParty.Name, transaction.Description) } } case "reg": transactions := deltas.Transactions() cumulative := make(ledger.Balances) for _, transaction := range transactions { balances := ledger.Deltas(transaction).Like(q).Group(group).Balances().WithBPIsAt(bpis, transaction[0].Date).Nonzero().Normalize(cpiNormalizer, transaction[0].Date) shouldPrint := false shouldPrint = shouldPrint || len(balances) > 2 if config.Query.NoExchanging { shouldPrint = shouldPrint || len(balances) > 1 for _, v := range balances { shouldPrint = shouldPrint || len(v) == 1 } } else { shouldPrint = shouldPrint || len(balances) > 0 } if shouldPrint { cumulative.PushAll(balances) cumulative = cumulative.Nonzero() FPrintBalancesFor(transaction[0].Description, w, "\t\t", balances, cumulative, config.Query.USDOnly, config.Query.Normalize, transaction[0].Date, config.Compact, maxAccW) } } default: panic("unknown command " + positional[0]) } } func FPrintBalancesFor(description string, w io.Writer, linePrefix string, balances, cumulatives ledger.Balances, usdOnly, normalized bool, date string, compact bool, keyW int) { if compact { FPrintBalances(w, date+"\t", balances, cumulatives, usdOnly, normalized, date, compact, keyW) } else { fmt.Fprintf(w, "%s\t%s\n", date, description) FPrintBalances(w, linePrefix, balances, cumulatives, usdOnly, normalized, date, compact, keyW) } } func FPrintBalances(w io.Writer, linePrefix string, balances, cumulatives ledger.Balances, usdOnly, normalized bool, date string, fullKey bool, max int) { maxes := map[ledger.Currency]float64{} keys := []string{} for k, v := range balances { keys = append(keys, k) for k2, v2 := range v { if math.Abs(v2) > math.Abs(maxes[k2]) { maxes[k2] = math.Abs(v2) } } } slices.Sort(keys) normalizer := ana.NewDefaultNormalizer() format := fmt.Sprintf("%s%%-%ds\t%%s%%.2f (%%s%%.2f)\n", linePrefix, max) if normalized { format = fmt.Sprintf("%s%%-%ds\t%%s%%.2f (%%s%%.2f (%%.2f @%%.2f (%%s%%.0f)))\n", linePrefix, max) } for i, key := range keys { printableKey := key if fullKey { } else if i == 0 { } else { commonPrefixLen := func() int { j := 0 n := len(keys[i]) if n2 := len(keys[i-1]); n2 < n { n = n2 } for j = 0; j < n; j++ { if keys[i-1][j] != keys[i][j] { break } } for keys[i][j] != ':' && j > 0 { j -= 1 } return j }() printableKey = strings.Repeat(" ", commonPrefixLen) + keys[i][commonPrefixLen:] } currencies := []ledger.Currency{} for currency := range balances[key] { currencies = append(currencies, currency) } slices.Sort(currencies) for _, currency := range currencies { printableCurrency := currency format := format if printableCurrency != "$" { printableCurrency += " " format = strings.ReplaceAll(format, "%.2f", "%.3f") } if usdOnly && printableCurrency != "$" { continue } cumulative := balances[key][currency] if balance, ok := cumulatives[key]; !ok { } else if value, ok := balance[currency]; !ok { } else { cumulative = value } if !normalized { fmt.Fprintf(w, format, printableKey, printableCurrency, balances[key][currency], printableCurrency, cumulative) } else { factor := normalizer.NormalizeFactor(key, date) trailingMax := maxes[currency] - math.Abs(balances[key][currency]) fmt.Fprintf(w, format, printableKey, printableCurrency, balances[key][currency], printableCurrency, cumulative, cumulative*factor, factor, printableCurrency, factor*trailingMax) } } } }