444 lines
13 KiB
Go
444 lines
13 KiB
Go
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.StringVar(&config.GroupDate, "group-date", "^....-..-..", "date grouping")
|
|
fs.IntVar(&config.CPIYear, "cpiy", 0, "use cpi to convert usd to this year's value")
|
|
fs.BoolVar(&config.NoPercent, "no-percent", false, "compute percent")
|
|
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
|
|
balances := deltas.Group(ledger.GroupDate(config.GroupDate)).Balances().
|
|
WithBPIs(bpis).
|
|
KindaLike(q).
|
|
KindaGroup(group).
|
|
Nonzero().
|
|
Normalize(cpiNormalizer, "9")
|
|
|
|
cumulatives := make(ledger.Balances)
|
|
cumulativesFormat := "%s%.2f"
|
|
if !config.NoPercent {
|
|
var sum float64
|
|
for key := range balances {
|
|
if _, ok := cumulatives[key]; !ok {
|
|
cumulatives[key] = make(ledger.Balance)
|
|
}
|
|
for currency, val := range balances[key] {
|
|
if currency == ledger.USD {
|
|
cumulatives[key][currency] = val
|
|
sum += val
|
|
} else {
|
|
cumulatives[key][currency] = 0
|
|
}
|
|
}
|
|
}
|
|
for key := range cumulatives {
|
|
cumulatives[key][ledger.USD] = 100 * cumulatives[key][ledger.USD] / sum
|
|
}
|
|
cumulativesFormat = "%.0f%%"
|
|
}
|
|
|
|
FPrintBalances(w, "", balances, cumulatives, config.Query.USDOnly, config.Query.Normalize, time.Now().Format("2006-01-02"), false, maxAccW, cumulativesFormat)
|
|
case "gra": // graph
|
|
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": // reconcile via teller // DEAD
|
|
panic("dead and bad")
|
|
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
|
|
}
|
|
|
|
msg := "missing"
|
|
|
|
ts, err := time.ParseInLocation("2006-01-02", transaction.Date, time.Local)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
dayBefore := ts.Add(-24 * time.Hour).Format("2006-01-02")
|
|
dayAfter := ts.Add(24 * time.Hour).Format("2006-01-02")
|
|
if inDay(dayBefore, transaction) || inDay(dayAfter, transaction) {
|
|
msg = "1dayoff"
|
|
}
|
|
|
|
prefix := " "
|
|
if transaction.Status != "posted" {
|
|
prefix = "! "
|
|
}
|
|
fmt.Printf("[%s] %s $%7.2f %s%s (%s)\n", msg, transaction.Date, transaction.Amount, prefix, transaction.Details.CounterParty.Name, transaction.Description)
|
|
}
|
|
}
|
|
case "reg": // reg
|
|
deltas = deltas.Group(ledger.GroupDate(config.GroupDate))
|
|
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, "%s%.2f")
|
|
}
|
|
}
|
|
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, cumulativeFormat string) {
|
|
if compact {
|
|
FPrintBalances(w, date+"\t", balances, cumulatives, usdOnly, normalized, date, compact, keyW, cumulativeFormat)
|
|
} else {
|
|
fmt.Fprintf(w, "%s\t%s\n", date, description)
|
|
FPrintBalances(w, linePrefix, balances, cumulatives, usdOnly, normalized, date, compact, keyW, cumulativeFormat)
|
|
}
|
|
}
|
|
|
|
func FPrintBalances(w io.Writer, linePrefix string, balances, cumulatives ledger.Balances, usdOnly, normalized bool, date string, fullKey bool, max int, cumulativeFormat string) {
|
|
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()
|
|
|
|
cumulativeFormat = strings.ReplaceAll(cumulativeFormat, "%", "%%")
|
|
format := fmt.Sprintf("%s%%-%ds\t%%s%%.2f ("+cumulativeFormat+")\n", linePrefix, max)
|
|
if normalized {
|
|
format = fmt.Sprintf("%s%%-%ds\t%%s%%.2f (%%s%%.2f (%%.2f @%%.2f ("+cumulativeFormat+")))\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
|
|
}
|
|
|
|
printingPercents := strings.Contains(cumulativeFormat, "%%%%")
|
|
if !printingPercents {
|
|
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)
|
|
}
|
|
} else {
|
|
if !normalized {
|
|
fmt.Fprintf(w, format, printableKey, printableCurrency, balances[key][currency], 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, factor*trailingMax)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|