420 lines
9.4 KiB
Go
420 lines
9.4 KiB
Go
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)
|
|
}
|
|
}
|