Compare commits

...

2 Commits

Author SHA1 Message Date
Bel LaPointe
fa7bafa241 reconcile csv via --csv csv
Some checks failed
cicd / ci (push) Has been cancelled
2026-01-31 11:22:47 -07:00
Bel LaPointe
502e47d0bc ledger.Delta.OtherDates 2026-01-31 11:03:56 -07:00
8 changed files with 126 additions and 26 deletions

View File

@@ -18,4 +18,5 @@ type Config struct {
Compact bool Compact bool
GroupDate string GroupDate string
NoPercent bool NoPercent bool
CSV string
} }

View File

@@ -3,9 +3,11 @@ package cli
import ( import (
"bufio" "bufio"
"context" "context"
"encoding/csv"
"flag" "flag"
"fmt" "fmt"
"io" "io"
"log"
"maps" "maps"
"math" "math"
"os" "os"
@@ -35,6 +37,7 @@ func Main() {
fs.BoolVar(&config.Query.NoRounding, "no-rounding", false, "no rounding") fs.BoolVar(&config.Query.NoRounding, "no-rounding", false, "no rounding")
fs.BoolVar(&config.Compact, "c", false, "reg entries oneline") fs.BoolVar(&config.Compact, "c", false, "reg entries oneline")
fs.StringVar(&config.Query.With, "w", "", "regexp for transactions") 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.IntVar(&config.Query.Depth, "depth", 0, "depth grouping")
fs.BoolVar(&config.Query.Normalize, "n", false, "normalize with default normalizer") 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.USDOnly, "usd", false, "filter to usd")
@@ -50,27 +53,27 @@ func Main() {
files := config.Files.Strings() files := config.Files.Strings()
if len(files) == 0 { 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:]...) ledgerFiles, err := ledger.NewFiles(files[0], files[1:]...)
if err != nil { if err != nil {
panic(err) log.Fatalf("%v", err)
} }
positional := fs.Args() positional := fs.Args()
if len(positional) == 0 || len(positional[0]) < 3 { 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] cmd := positional[0]
q, err := BuildQuery(config, positional[1:]) q, err := BuildQuery(config, positional[1:])
if err != nil { if err != nil {
panic(err) log.Fatalf("%v", err)
} }
deltas, err := ledgerFiles.Deltas() deltas, err := ledgerFiles.Deltas()
if err != nil { if err != nil {
panic(err) log.Fatalf("%v", err)
} }
if period := config.Query.Period; !period.Empty() { if period := config.Query.Period; !period.Empty() {
@@ -98,7 +101,7 @@ func Main() {
if config.BPI != "" { if config.BPI != "" {
b, err := ledger.NewBPIs(config.BPI) b, err := ledger.NewBPIs(config.BPI)
if err != nil { if err != nil {
panic(err) log.Fatalf("%v", err)
} }
bpis = b bpis = b
if period := config.Query.Period; !period.Empty() { if period := config.Query.Period; !period.Empty() {
@@ -115,12 +118,12 @@ func Main() {
if config.CPI != "" && config.CPIYear > 0 { if config.CPI != "" && config.CPIYear > 0 {
c, err := ledger.NewBPIs(config.CPI) c, err := ledger.NewBPIs(config.CPI)
if err != nil { if err != nil {
panic(err) log.Fatalf("%v", err)
} }
cpi := c["CPI"] cpi := c["CPI"]
cpiy := cpi.Lookup(fmt.Sprintf("%d-06-01", config.CPIYear)) cpiy := cpi.Lookup(fmt.Sprintf("%d-06-01", config.CPIYear))
if cpiy == nil { 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 { for date, value := range cpi {
@@ -261,7 +264,7 @@ func Main() {
} }
fmt.Println(asciigraph.PlotMany(points, options...)) fmt.Println(asciigraph.PlotMany(points, options...))
case "rec": // reconcile via teller // DEAD case "rec": // reconcile via teller // DEAD
panic("dead and bad") log.Fatalf("dead and bad")
byDate := map[string]ledger.Deltas{} byDate := map[string]ledger.Deltas{}
for _, delta := range deltas { for _, delta := range deltas {
delta := delta delta := delta
@@ -273,13 +276,13 @@ func Main() {
teller, err := teller.New() teller, err := teller.New()
if err != nil { if err != nil {
panic(err) log.Fatalf("%v", err)
} }
client := cache.New(teller) client := cache.New(teller)
accounts, err := client.Accounts(ctx) accounts, err := client.Accounts(ctx)
if err != nil { if err != nil {
panic(err) log.Fatalf("%v", err)
} }
inDay := func(date string, transaction bank.Transaction) bool { inDay := func(date string, transaction bank.Transaction) bool {
@@ -294,7 +297,7 @@ func Main() {
for _, acc := range accounts { for _, acc := range accounts {
transactions, err := client.Transactions(ctx, acc) transactions, err := client.Transactions(ctx, acc)
if err != nil { if err != nil {
panic(err) log.Fatalf("%v", err)
} }
for _, transaction := range transactions { for _, transaction := range transactions {
@@ -306,7 +309,7 @@ func Main() {
ts, err := time.ParseInLocation("2006-01-02", transaction.Date, time.Local) ts, err := time.ParseInLocation("2006-01-02", transaction.Date, time.Local)
if err != nil { if err != nil {
panic(err) log.Fatalf("%v", err)
} }
dayBefore := ts.Add(-24 * time.Hour).Format("2006-01-02") dayBefore := ts.Add(-24 * time.Hour).Format("2006-01-02")
dayAfter := 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") 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: 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 ""
}

View File

@@ -10,6 +10,7 @@ const (
type Delta struct { type Delta struct {
Date string Date string
OtherDates []string
Name string Name string
Value float64 Value float64
Currency Currency Currency Currency
@@ -23,9 +24,10 @@ type Delta struct {
with []Delta with []Delta
} }
func newDelta(transaction string, payee bool, d, desc, name string, v float64, c string, isSet bool, fileName string, lineNo int) Delta { func newDelta(transaction string, payee bool, d, desc, name string, v float64, c string, isSet bool, fileName string, lineNo int, otherDates []string) Delta {
return Delta{ return Delta{
Date: d, Date: d,
OtherDates: otherDates,
Name: name, Name: name,
Value: v, Value: v,
Currency: Currency(c), Currency: Currency(c),

View File

@@ -6,7 +6,7 @@ import (
func TestDelta(t *testing.T) { func TestDelta(t *testing.T) {
d := "2099-08-07" d := "2099-08-07"
delta := newDelta("x", true, d, "", "name", 34.56, "$", false, "", 0) delta := newDelta("x", true, d, "", "name", 34.56, "$", false, "", 0, []string{"d2"})
if delta.Transaction != "x" { if delta.Transaction != "x" {
t.Error(delta.Transaction) t.Error(delta.Transaction)
@@ -17,6 +17,9 @@ func TestDelta(t *testing.T) {
if delta.Date != d { if delta.Date != d {
t.Error(delta.Date) t.Error(delta.Date)
} }
if delta.OtherDates[0] != "d2" {
t.Error(delta.OtherDates)
}
if delta.Name != "name" { if delta.Name != "name" {
t.Error(delta.Name) t.Error(delta.Name)
} }

View File

@@ -99,8 +99,12 @@ func (files Files) add(payee string, delta Delta) error {
if delta.Currency != USD { if delta.Currency != USD {
currencyValue = fmt.Sprintf("%.2f %s", delta.Value, delta.Currency) currencyValue = fmt.Sprintf("%.2f %s", delta.Value, delta.Currency)
} }
date := delta.Date
for _, otherDate := range delta.OtherDates {
date += "=" + otherDate
}
return files.append(fmt.Sprintf("%s %s\n%s%s%s%s\n%s%s", return files.append(fmt.Sprintf("%s %s\n%s%s%s%s\n%s%s",
delta.Date, delta.Description, date, delta.Description,
filesAppendDelim, delta.Name, filesAppendDelim+filesAppendDelim+filesAppendDelim, currencyValue, filesAppendDelim, delta.Name, filesAppendDelim+filesAppendDelim+filesAppendDelim, currencyValue,
filesAppendDelim, payee, filesAppendDelim, payee,
)) ))

View File

@@ -73,12 +73,13 @@ func TestFileAmend(t *testing.T) {
}, },
"payee": { "payee": {
from: ` from: `
2006-01-02 description 2006-01-02=2006-01-03 description
recipient $3.45 recipient $3.45
payee payee
`, `,
old: Delta{ old: Delta{
Date: "2006-01-02", Date: "2006-01-02",
OtherDates: []string{"2006-01-03"},
Name: "payee", Name: "payee",
Value: -3.45, Value: -3.45,
Currency: "$", Currency: "$",
@@ -86,16 +87,17 @@ func TestFileAmend(t *testing.T) {
}, },
now: Delta{ now: Delta{
Date: "2106-11-12", Date: "2106-11-12",
OtherDates: []string{"2006-01-03"},
Name: "1payee", Name: "1payee",
Value: -13.45, Value: -13.45,
Currency: "T", Currency: "T",
Description: "1description", Description: "1description",
}, },
want: ` want: `
2006-01-02 description 2006-01-02=2006-01-03 description
payee $3.45 payee $3.45
recipient recipient
2106-11-12 1description 2106-11-12=2006-01-03 1description
1payee -13.45 T 1payee -13.45 T
recipient`, recipient`,
}, },

View File

@@ -106,6 +106,7 @@ func (transaction Transaction) Payee() string {
type transaction struct { type transaction struct {
date string date string
otherDates []string
description string description string
payee string payee string
recipients []transactionRecipient recipients []transactionRecipient
@@ -142,6 +143,7 @@ func (t transaction) deltas() Deltas {
recipient.isSet, recipient.isSet,
t.fileName, t.fileName,
t.lineNo+i, t.lineNo+i,
t.otherDates,
)) ))
} }
for currency, value := range sums { for currency, value := range sums {
@@ -162,6 +164,7 @@ func (t transaction) deltas() Deltas {
false, false,
t.fileName, t.fileName,
t.lineNo, t.lineNo,
t.otherDates,
)) ))
} }
} }
@@ -287,16 +290,17 @@ func _readTransaction(name string, r *bufio.Reader) (transaction, error) {
return transaction{}, err return transaction{}, err
} }
dateDescriptionPattern := regexp.MustCompile(`^([0-9]+-[0-9]+-[0-9]+)\s+(.*)$`) dateDescriptionPattern := regexp.MustCompile(`^([0-9]+-[0-9]+-[0-9]+)((=[0-9]+-[0-9]+-[0-9]+)*)\s+(.*)$`)
dateDescriptionMatches := dateDescriptionPattern.FindAllSubmatch(firstLine, 4) dateDescriptionMatches := dateDescriptionPattern.FindAllStringSubmatch(string(firstLine), 4)
if len(dateDescriptionMatches) != 1 { if len(dateDescriptionMatches) != 1 {
return transaction{}, fmt.Errorf("bad first line: %v matches: %q", len(dateDescriptionMatches), firstLine) return transaction{}, fmt.Errorf("bad first line: %v matches: %q", len(dateDescriptionMatches), firstLine)
} else if len(dateDescriptionMatches[0]) != 3 { } else if len(dateDescriptionMatches[0]) != 5 {
return transaction{}, fmt.Errorf("bad first line: %v submatches: %q", len(dateDescriptionMatches[0]), firstLine) return transaction{}, fmt.Errorf("bad first line: %v submatches: %q", len(dateDescriptionMatches[0]), firstLine)
} }
result := transaction{ result := transaction{
date: string(dateDescriptionMatches[0][1]), date: dateDescriptionMatches[0][1],
description: string(dateDescriptionMatches[0][2]), otherDates: strings.Split(strings.Trim(dateDescriptionMatches[0][2], "="), "="),
description: dateDescriptionMatches[0][4],
name: name, name: name,
} }

View File

@@ -58,12 +58,13 @@ func TestReadTransaction(t *testing.T) {
}, },
"verbose": { "verbose": {
input: ` input: `
2003-04-05 Reasoning here 2003-04-05=2003-04-06=2003-04-07 Reasoning here
A:B $1.00 A:B $1.00
C:D $-1.00 C:D $-1.00
`, `,
want: transaction{ want: transaction{
date: "2003-04-05", date: "2003-04-05",
otherDates: []string{"2003-04-06", "2003-04-07"},
description: "Reasoning here", description: "Reasoning here",
payee: "A:B", payee: "A:B",
recipients: []transactionRecipient{ recipients: []transactionRecipient{