Files
ana-ledger/src/ledger/transaction.go
2026-01-31 11:03:56 -07:00

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)
}
}