package ledger import ( "bufio" "bytes" "errors" "fmt" "io" "os" "regexp" "slices" "strconv" "strings" "unicode" ) type Transaction Deltas type Transactions []Transaction func (transactions Transactions) Deltas() Deltas { result := make(Deltas, 0, len(transactions)) for _, transaction := range transactions { result = append(result, transaction...) } return result } func (deltas Deltas) Transactions() Transactions { m := make(map[string]Transaction) for _, d := range deltas { m[d.Transaction] = append(m[d.Transaction], d) } result := make(Transactions, 0, len(m)) for _, v := range m { result = append(result, v) } slices.SortFunc(result, func(a, b Transaction) int { if a[0].Date == b[0].Date { if a[0].Description == b[0].Description { return strings.Compare(a[0].Transaction, b[0].Transaction) } return strings.Compare(a[0].Description, b[0].Description) } return strings.Compare(a[0].Date, b[0].Date) }) return result } func (transactions Transactions) NotLike(like ...Like) Transactions { result := make(Transactions, 0, len(transactions)) for _, transaction := range transactions { if matching := (Deltas)(transaction).Like(like...); len(matching) == 0 { result = append(result, transaction) } } return result } func (transactions Transactions) Like(like ...Like) Transactions { result := make(Transactions, 0, len(transactions)) for _, transaction := range transactions { if matching := (Deltas)(transaction).Like(like...); len(matching) > 0 { result = append(result, transaction) } } return result } func (transaction Transaction) Payee() string { balances := Deltas(transaction).Balances() candidates := []string{} for name, balance := range balances { deltas := Deltas(transaction).Like(LikeName(`^` + name + `$`)) if len(deltas) != 1 { continue } everyoneElse := balances.NotLike(`^` + name + `$`).Group(`^`)[""] matches := true for currency, value := range balance { matches = matches && everyoneElse[currency]*-1 == value } if matches { candidates = append(candidates, name) } } slices.Sort(candidates) if len(candidates) == 0 { panic(balances) } for _, candidate := range candidates { if strings.HasPrefix(candidate, "Withdrawal") { return candidate } } return candidates[len(candidates)-1] } type transaction struct { date string otherDates []string description string payee string recipients []transactionRecipient name string fileName string lineNo int } func (t transaction) empty() bool { return fmt.Sprint(t) == fmt.Sprint(transaction{}) } type transactionRecipient struct { name string value float64 currency string isSet bool } func (t transaction) deltas() Deltas { result := []Delta{} sums := map[string]float64{} for i, recipient := range t.recipients { sums[recipient.currency] += recipient.value result = append(result, newDelta( t.name, true, t.date, t.description, recipient.name, recipient.value, recipient.currency, recipient.isSet, t.fileName, t.lineNo+i, t.otherDates, )) } for currency, value := range sums { if value == 0 { continue } if t.payee == "" { //return nil, fmt.Errorf("didnt find net zero and no dumping ground payee set: %+v", transaction) } else { result = append(result, newDelta( t.name, false, t.date, t.description, t.payee, -1.0*value, currency, false, t.fileName, t.lineNo, t.otherDates, )) } } for i := range result { for j := range result { if i != j { result[i] = result[i].withWith(result[j]) } } } return result } func (t transactionRecipient) empty() bool { return t == (transactionRecipient{}) } func (files Files) transactions() ([]transaction, error) { result := make([]transaction, 0) for _, path := range files.paths() { some, err := files._transactions(path) if err != nil { return nil, err } result = append(result, some...) } return result, nil } func (files Files) _transactions(file string) ([]transaction, error) { f, err := os.Open(string(file)) if os.IsNotExist(err) { return nil, nil } if err != nil { return nil, err } defer f.Close() r := bufio.NewReaderSize(f, 2048) result := make([]transaction, 0) for { name := fmt.Sprintf("%s/%d", file, len(result)) one, err := readTransaction(name, r) if !one.empty() { one.fileName = file one.lineNo = len(result) result = append(result, one) } if err == io.EOF { return result, nil } if err != nil { return result, err } } } func readTransaction(name string, r *bufio.Reader) (transaction, error) { result, err := _readTransaction(name, r) if err != nil && !errors.Is(err, io.EOF) { return result, err } if result.empty() { return result, err } if result.payee != "" && len(result.recipients) < 1 { return result, fmt.Errorf("found a transaction with payee but no recipeints: %+v", result) } if result.payee == "" { if len(result.recipients) < 2 { return result, fmt.Errorf("found a transaction with no payee and less than 2 recipeints: %+v", result) } func() { sumPerRecipient := map[string]float64{} recipients := []string{} for _, recipient := range result.recipients { recipients = append(recipients, recipient.name) sumPerRecipient[recipient.name] += recipient.value } slices.Sort(recipients) for _, k := range recipients { n := 0 for i := range result.recipients { if result.recipients[i].name == k { n += 1 } } if n != 1 { continue } v := sumPerRecipient[k] everyoneElse := 0.0 for j := range sumPerRecipient { if k != j { everyoneElse += sumPerRecipient[j] } } if -1.0*v == everyoneElse { result.payee = k result.recipients = slices.DeleteFunc(result.recipients, func(recipient transactionRecipient) bool { return recipient.name == k }) return } } return }() } return result, err } func _readTransaction(name string, r *bufio.Reader) (transaction, error) { readTransactionLeadingWhitespace(r) firstLine, err := readTransactionLine(r) if len(bytes.TrimSpace(firstLine)) == 0 { return transaction{}, err } dateDescriptionPattern := regexp.MustCompile(`^([0-9]+-[0-9]+-[0-9]+)((=[0-9]+-[0-9]+-[0-9]+)*)\s+(.*)$`) dateDescriptionMatches := dateDescriptionPattern.FindAllStringSubmatch(string(firstLine), 4) if len(dateDescriptionMatches) != 1 { return transaction{}, fmt.Errorf("bad first line: %v matches: %q", len(dateDescriptionMatches), firstLine) } else if len(dateDescriptionMatches[0]) != 5 { return transaction{}, fmt.Errorf("bad first line: %v submatches: %q", len(dateDescriptionMatches[0]), firstLine) } result := transaction{ date: dateDescriptionMatches[0][1], otherDates: strings.Split(strings.Trim(dateDescriptionMatches[0][2], "="), "="), description: dateDescriptionMatches[0][4], name: name, } for { name, value, currency, isSet, err := readTransactionName(r) if name != "" { if currency == "" { result.payee = name } else { result.recipients = append(result.recipients, transactionRecipient{ name: name, value: value, currency: currency, isSet: isSet, }) } } if name == "" || err != nil { return result, err } } } func readTransactionLeadingWhitespace(r *bufio.Reader) { b, err := r.Peek(2048) if err != nil && err != io.EOF { return } i := 0 for i < len(b) { if len(bytes.TrimSpace(b[:i])) != 0 { break } i++ } if i > 0 { r.Read(make([]byte, i-1)) } } func readTransactionLine(r *bufio.Reader) ([]byte, error) { for { b, err := _readTransactionLine(r) if err != nil || (len(bytes.TrimSpace(b)) > 0 && bytes.TrimSpace(b)[0] != '#') { return b, err } } } func _readTransactionLine(r *bufio.Reader) ([]byte, error) { b, err := r.Peek(2048) if len(b) == 0 { return nil, err } endOfLine := len(b) if idx := bytes.Index(b, []byte{'\n'}); idx > -1 { endOfLine = idx } b2 := make([]byte, endOfLine) n, err := r.Read(b2) if err == io.EOF { err = nil } if check, _ := r.Peek(1); len(check) == 1 && check[0] == '\n' { r.Read(make([]byte, 1)) } return b2[:n], err } func readTransactionName(r *bufio.Reader) (string, float64, string, bool, error) { line, err := readTransactionLine(r) if err != nil { return "", 0, "", false, err } if len(line) > 0 && !unicode.IsSpace(rune(line[0])) { r2 := *r *r = *bufio.NewReader(io.MultiReader(bytes.NewReader(append(line, '\n')), &r2)) return "", 0, "", false, nil } line = bytes.Split(line, []byte(";"))[0] // comment-free fields := bytes.Fields(line) isSet := false if len(fields) > 2 && string(fields[1]) == "=" { isSet = true fields = append(fields[:1], fields[2:]...) } switch len(fields) { case 1: // payee return string(fields[0]), 0, "", false, nil case 2: // payee $00.00 b := bytes.TrimLeft(fields[1], "$") value, err := strconv.ParseFloat(string(b), 64) if err != nil { return "", 0, "", isSet, fmt.Errorf("failed to parse value from $XX.YY from %q (%q): %w", line, fields[1], err) } return string(fields[0]), value, string(USD), isSet, nil case 3: // payee 00.00 XYZ value, err := strconv.ParseFloat(string(fields[1]), 64) if err != nil { return "", 0, "", false, fmt.Errorf("failed to parse value from XX.YY XYZ from %q (%q): %w", line, fields[1], err) } return string(fields[0]), value, string(fields[2]), isSet, nil default: return "", 0, "", isSet, fmt.Errorf("cannot interpret %q", line) } }