215 lines
4.9 KiB
Go
215 lines
4.9 KiB
Go
package ledger
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"regexp"
|
|
"strconv"
|
|
"unicode"
|
|
)
|
|
|
|
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 (files Files) transactions() ([]transaction, error) {
|
|
result := make([]transaction, 0)
|
|
for i := range files {
|
|
some, err := files._transactions(files[i])
|
|
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 err != nil {
|
|
return nil, err
|
|
}
|
|
defer f.Close()
|
|
|
|
r := bufio.NewReaderSize(f, 2048)
|
|
|
|
result := make([]transaction, 0)
|
|
for {
|
|
one, err := readTransaction(r)
|
|
if !one.empty() {
|
|
result = append(result, one)
|
|
}
|
|
if err == io.EOF {
|
|
return result, nil
|
|
}
|
|
if err != nil {
|
|
return result, err
|
|
}
|
|
}
|
|
}
|
|
|
|
func readTransaction(r *bufio.Reader) (transaction, error) {
|
|
result, err := _readTransaction(r)
|
|
if err != nil {
|
|
return result, err
|
|
}
|
|
if result.empty() {
|
|
return result, nil
|
|
}
|
|
if result.payee == "" && len(result.recipients) < 2 {
|
|
return result, fmt.Errorf("found a transaction with no payee and less than 2 recipeints: %+v", result)
|
|
}
|
|
if result.payee != "" && len(result.recipients) < 1 {
|
|
return result, fmt.Errorf("found a transaction with payee but no recipeints: %+v", result)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func _readTransaction(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]+)\s+(.*)$`)
|
|
dateDescriptionMatches := dateDescriptionPattern.FindAllSubmatch(firstLine, 4)
|
|
if len(dateDescriptionMatches) != 1 {
|
|
return transaction{}, fmt.Errorf("bad first line: %v matches: %q", len(dateDescriptionMatches), firstLine)
|
|
} else if len(dateDescriptionMatches[0]) != 3 {
|
|
return transaction{}, fmt.Errorf("bad first line: %v submatches: %q", len(dateDescriptionMatches[0]), firstLine)
|
|
}
|
|
result := transaction{
|
|
date: string(dateDescriptionMatches[0][1]),
|
|
description: string(dateDescriptionMatches[0][2]),
|
|
}
|
|
|
|
for {
|
|
name, value, currency, err := readTransactionAccount(r)
|
|
if name != "" {
|
|
if currency == "" {
|
|
result.payee = name
|
|
} else {
|
|
result.recipients = append(result.recipients, transactionRecipient{
|
|
name: name,
|
|
value: value,
|
|
currency: currency,
|
|
})
|
|
}
|
|
}
|
|
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 readTransactionAccount(r *bufio.Reader) (string, float64, string, error) {
|
|
line, err := readTransactionLine(r)
|
|
if err != nil {
|
|
return "", 0, "", 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, "", nil
|
|
}
|
|
|
|
fields := bytes.Fields(line)
|
|
switch len(fields) {
|
|
case 1: // payee
|
|
return string(fields[0]), 0, "", nil
|
|
case 2: // $00.00
|
|
b := bytes.TrimLeft(fields[1], "$")
|
|
value, err := strconv.ParseFloat(string(b), 64)
|
|
if err != nil {
|
|
return "", 0, "", fmt.Errorf("failed to parse value from $XX.YY from %q (%q): %w", line, fields[1], err)
|
|
}
|
|
return string(fields[0]), value, string(USD), nil
|
|
case 3: // 00.00 XYZ
|
|
value, err := strconv.ParseFloat(string(fields[1]), 64)
|
|
if err != nil {
|
|
return "", 0, "", 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]), nil
|
|
case 4: // = ($00.00 OR 00.00 XYZ)
|
|
//return "", 0, "", fmt.Errorf("not impl: %q", line)
|
|
return string(fields[0]), 0.01, string(USD), nil
|
|
default:
|
|
return "", 0, "", fmt.Errorf("cannot interpret %q", line)
|
|
}
|
|
}
|