ana-ledger/ledger/transaction.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)
}
}