package ledger import ( "bytes" "fmt" "io" "os" "strconv" "strings" ) type File string func NewFile(p string) (File, error) { f := File(p) _, err := f.Deltas() return f, err } func (file File) Deltas(like ...Like) ([]Delta, error) { transactions, err := file.transactions() if err != nil { return nil, err } result := make([]Delta, 0, len(transactions)*2) for _, transaction := range transactions { sums := map[string]float64{} for _, acc := range transaction.recipients { sums[acc.currency] += acc.value delta := newDelta( transaction.date, transaction.description, acc.name, acc.value, acc.currency, ) if likes(like).all(delta) { result = append(result, delta) } } for currency, value := range sums { if value == 0 { continue } if transaction.payee == "" { return nil, fmt.Errorf("didnt find net zero and no dumping ground payee set: %+v", transaction) } delta := newDelta( transaction.date, transaction.description, transaction.payee, -1.0*value, currency, ) if likes(like).all(delta) { result = append(result, delta) } } } return result, nil } type transaction struct { date string description string payee string recipients []transactionRecipient } func (t transaction) empty() bool { return fmt.Sprint(t) == fmt.Sprint(transaction{}) } type transactionRecipient struct { name string value float64 currency string } func (t transactionRecipient) empty() bool { return t == (transactionRecipient{}) } func (file File) transactions() ([]transaction, error) { f, err := os.Open(string(file)) if err != nil { return nil, err } defer f.Close() var r io.Reader = f result := make([]transaction, 0) for { newr, one, err := readTransaction(r) if !one.empty() { result = append(result, one) } if err == io.EOF { return result, nil } if err != nil { return result, err } r = newr } } func readTransaction(r io.Reader) (io.Reader, transaction, error) { result := transaction{} var err error if r, err = readTransactionLeadingWhitespace(r); err != nil { return r, transaction{}, err } if result.date, err = readTransactionDate(r); err != nil { if err == io.EOF { return r, transaction{}, err } return nil, result, fmt.Errorf("did not find transaction date: %w", err) } if result.description, err = readTransactionDescription(r); err != nil { return nil, result, fmt.Errorf("did not find transaction description: %w", err) } for { newR, recipient, err := readTransactionRecipient(r) r = newR if !recipient.empty() { if recipient.currency == "" { result.payee = recipient.name } else { result.recipients = append(result.recipients, recipient) } } if err != nil || recipient.empty() { return r, result, err } } } // from "\s*.* read \s*" func readTransactionLeadingWhitespace(r io.Reader) (io.Reader, error) { for { b, err := readOne(r) if err != nil { return r, err } if b != 0 && !isSpaceByte(b) { return io.MultiReader(bytes.NewReader([]byte{b}), r), nil } } } // from "20yy-mm-dd.* read "20yy-mm-dd" func readTransactionDate(r io.Reader) (string, error) { startOfDate := []byte{} for len(startOfDate) == 0 { b, err := readOne(r) if err != nil { return "", err } if '0' <= b && b <= '9' { startOfDate = append(startOfDate, b) } } restOfDate := make([]byte, len("2006-01-02")-len(startOfDate)) if _, err := r.Read(restOfDate); err != nil { return "", err } return fmt.Sprintf("%s%s", startOfDate, restOfDate), nil } // from "\s*.*\n.*" read \s*.*\n.*" func readTransactionDescription(r io.Reader) (string, error) { result := make([]byte, 0, 16) for { b, err := readOne(r) if err != nil { return "", err } if b == '\n' { break } result = append(result, b) } return strings.TrimSpace(string(result)), nil } // from "\s+.*\n?.*" read "\s+.*\n?" func readTransactionRecipient(r io.Reader) (io.Reader, transactionRecipient, error) { if b, err := readOne(r); err != nil { return r, transactionRecipient{}, err } else if !isSpaceByte(b) { return io.MultiReader(bytes.NewReader([]byte{b}), r), transactionRecipient{}, nil } r, err := readTransactionLeadingWhitespace(r) if err != nil { return r, transactionRecipient{}, err } result := transactionRecipient{} for { b, err := readOne(r) if err != nil { if result.empty() { return nil, result, err } return nil, result, fmt.Errorf("failed to read transaction recipient name after skipping whitespace: %w", err) } else if !isSpaceByte(b) { result.name += string([]byte{b}) continue } r = io.MultiReader(bytes.NewReader([]byte{b}), r) break } if result.name == "" { return nil, result, fmt.Errorf("did not parse any name for transaction recipient") } // read "NAME:NAME", now "(\s+|\n).*" for { b, err := readOne(r) if err != nil { return r, result, err } if isSpaceByte(b) && b != '\n' { continue } r = io.MultiReader(bytes.NewReader([]byte{b}), r) break } // read "\s+", now "(\n|\$1.00)" if b, err := readOne(r); err != nil { return r, result, err } else if isSpaceByte(b) { return r, result, err } else if newr, value, currency, err := readTransactionValueCurrency(io.MultiReader(bytes.NewReader([]byte{b}), r)); err != nil { return r, result, fmt.Errorf("failed to read transaction recipient's value and currency: %w", err) } else { r = newr result.value = value result.currency = currency } return r, result, err } func readOne(r io.Reader) (byte, error) { var firstByte [1]byte _, err := r.Read(firstByte[:]) return firstByte[0], err } func isSpaceByte(b byte) bool { return len(bytes.TrimSpace([]byte{b})) == 0 } func isNumericByte(b byte) bool { return '0' <= b && b <= '9' || b == '.' || b == '-' } // from "[^\n]*\n?.*" read "[^\n]*\n?" func readTransactionValueCurrency(r io.Reader) (io.Reader, float64, string, error) { restOfLine := make([]byte, 0, 16) for { b, err := readOne(r) if b == '\n' || err == io.EOF { break } if err != nil { return r, 0, "", err } restOfLine = append(restOfLine, b) } restOfLine = bytes.TrimSpace(restOfLine) if len(restOfLine) == 0 { return r, 0, "", nil } firstNonNumericIdx := -1 firstNumericIdx := -1 for i := range restOfLine { if !isNumericByte(restOfLine[i]) && firstNonNumericIdx == -1 { firstNonNumericIdx = i } if isNumericByte(restOfLine[i]) && firstNumericIdx == -1 { firstNumericIdx = i } } if firstNonNumericIdx == -1 || firstNumericIdx == -1 { return r, 0, "", fmt.Errorf("needed numeric and non-numeric bytes in %q", restOfLine) } var valueS, currency string if firstNonNumericIdx < firstNumericIdx { currency = string(restOfLine[firstNonNumericIdx:firstNumericIdx]) valueS = string(restOfLine[firstNumericIdx:]) } else { valueS = string(restOfLine[firstNumericIdx:firstNonNumericIdx]) currency = string(restOfLine[firstNonNumericIdx:]) } valueS = strings.TrimSpace(valueS) currency = strings.TrimSpace(currency) value, err := strconv.ParseFloat(valueS, 64) if err != nil { return r, 0, "", fmt.Errorf("value %q is not a float: %w", valueS, err) } return r, value, currency, nil }