mv /ana, /ledger to /src/

This commit is contained in:
bel
2023-10-28 09:29:39 -06:00
parent ec6d868ff7
commit ea13bf7e4a
35 changed files with 12 additions and 11 deletions

92
src/ledger/balances.go Normal file
View File

@@ -0,0 +1,92 @@
package ledger
import (
"fmt"
"maps"
"regexp"
"strings"
)
type Balances map[string]Balance
type Balance map[Currency]float64
func (balances Balances) Like(pattern string) Balances {
result := make(Balances)
p := regexp.MustCompile(pattern)
for k, v := range balances {
if p.MatchString(k) {
result[k] = maps.Clone(v)
}
}
return result
}
func (balances Balances) WithBPIs(bpis BPIs) Balances {
return balances.WithBPIsAt(bpis, "9")
}
func (balances Balances) WithBPIsAt(bpis BPIs, date string) Balances {
result := make(Balances)
for k, v := range balances {
if _, ok := result[k]; !ok {
result[k] = make(Balance)
}
for k2, v2 := range v {
scalar := 1.0
if k2 != USD {
scalar = bpis[k2].Lookup(date)
}
result[k][USD] += v2 * scalar
}
}
return result
}
func (balances Balances) PushAll(other Balances) {
for k, v := range other {
if _, ok := balances[k]; !ok {
balances[k] = make(Balance)
}
for k2, v2 := range v {
if _, ok := balances[k][k2]; !ok {
balances[k][k2] = 0
}
balances[k][k2] += v2
}
}
}
func (balances Balances) Push(d Delta) {
if _, ok := balances[d.Name]; !ok {
balances[d.Name] = make(Balance)
}
balances[d.Name].Push(d)
}
func (balance Balance) Push(d Delta) {
if _, ok := balance[d.Currency]; !ok {
balance[d.Currency] = 0
}
balance[d.Currency] += d.Value
}
func (balances Balances) Debug() string {
result := []string{}
for k, v := range balances {
result = append(result, fmt.Sprintf("%s:[%s]", k, v.Debug()))
}
return strings.Join(result, " ")
}
func (balance Balance) Debug() string {
result := []string{}
for k, v := range balance {
if k == USD {
result = append(result, fmt.Sprintf("%s%.2f", k, v))
} else {
result = append(result, fmt.Sprintf("%.3f %s", v, k))
}
}
return strings.Join(result, " + ")
}

View File

@@ -0,0 +1,54 @@
package ledger
import "testing"
func TestBalances(t *testing.T) {
t.Run("push", func(t *testing.T) {
b := make(Balances)
b.Push(Delta{Name: "x", Currency: "y", Value: 0.1})
b.Push(Delta{Name: "x", Currency: "y", Value: 1.2})
b.Push(Delta{Name: "x", Currency: "z", Value: 2.3})
ba := b["x"]
if ba["y"] != 1.3 {
t.Error(ba["y"])
}
if ba["z"] != 2.3 {
t.Error(ba["z"])
}
})
t.Run("pushall", func(t *testing.T) {
a := make(Balances)
a.Push(Delta{Name: "a", Currency: USD, Value: 0.1})
a.Push(Delta{Name: "ab", Currency: USD, Value: 1.2})
b := make(Balances)
b.Push(Delta{Name: "b", Currency: USD, Value: 2.3})
b.Push(Delta{Name: "ab", Currency: USD, Value: 3.4})
b.PushAll(a)
if len(a) != 2 {
t.Error("modified original", len(a), a)
}
if a["a"][USD] != 0.1 {
t.Error("modified original a", a["a"])
}
if a["ab"][USD] != 1.2 {
t.Error("modified original ab", a["ab"])
}
if len(b) != 3 {
t.Error("didnt union names", len(b), b)
}
if b["a"][USD] != 0.1 {
t.Error("didnt pull other unique", b["a"])
}
if b["b"][USD] != 2.3 {
t.Error("didnt retain unique", b["b"])
}
if b["ab"][USD] != 4.6 {
t.Error("didnt sum other", b["ab"])
}
})
}

64
src/ledger/bpi.go Normal file
View File

@@ -0,0 +1,64 @@
package ledger
import (
"bufio"
"bytes"
"io"
"os"
"strconv"
)
type BPIs map[Currency]BPI
type BPI map[string]float64
func NewBPIs(p string) (BPIs, error) {
f, err := os.Open(p)
if os.IsNotExist(err) {
return nil, nil
}
if err != nil {
return nil, err
}
defer f.Close()
result := make(map[Currency]BPI)
r := bufio.NewReader(f)
for {
line, err := readTransactionLine(r)
if len(line) > 0 {
fields := bytes.Fields(line)
if len(fields) > 3 {
date := string(fields[1])
currency := Currency(fields[len(fields)-2])
value, err := strconv.ParseFloat(string(fields[len(fields)-1][1:]), 64)
if err != nil {
return nil, err
}
if _, ok := result[currency]; !ok {
result[currency] = make(BPI)
}
result[currency][date] = value
}
}
if err == io.EOF {
return result, nil
} else if err != nil {
return result, err
}
}
}
func (bpi BPI) Lookup(date string) float64 {
var closestWithoutGoingOver string
for k := range bpi {
if k <= date && k > closestWithoutGoingOver {
closestWithoutGoingOver = k
}
}
if closestWithoutGoingOver == "" {
return 0
}
return bpi[closestWithoutGoingOver]
}

173
src/ledger/bpi_test.go Normal file
View File

@@ -0,0 +1,173 @@
package ledger
import (
"os"
"path"
"testing"
)
func TestBPIs(t *testing.T) {
d := t.TempDir()
p := path.Join(d, "bpi.dat")
os.WriteFile(p, []byte(`
P 2019-08-08 09:58:35 GME $151.77
P 2021-08-22 07:02:46 GME $159.30
P 2021-08-24 11:05:51 GME $164.89
P 2021-08-25 12:27:03 GME $210.29
P 2021-08-25 15:43:06 GME $210.29
P 2021-08-26 12:22:37 GME $199.65
P 2021-08-30 07:11:21 GME $204.95
P 2021-08-30 07:11:33 GME $204.95
P 2021-09-01 13:32:01 GME $218.24
P 2021-09-08 08:56:22 GME $199.00
P 2021-09-10 09:21:08 GME $199.18
P 2021-09-21 10:37:09 GME $192.20
P 2021-09-29 06:41:32 GME $178.60
P 2021-10-25 19:55:43 GME $173.97
P 2021-10-27 17:29:22 GME $173.51
P 2021-10-28 21:26:18 GME $182.85
P 2021-11-06 13:32:31 GME $213.25
P 2021-11-09 09:13:39 GME $218.64
P 2021-11-09 09:18:24 GME $218.64
P 2021-11-23 10:13:23 GME $247.55
P 2021-12-09 13:57:02 GME $173.65
P 2021-12-09 13:58:38 GME $173.65
P 2022-03-07 14:16:43 GME $111.66
P 2022-03-17 11:22:43 GME $86.86
P 2022-03-24 15:46:46 GME $142.38
P 2022-03-30 09:31:32 GME $179.90
P 2022-04-02 15:32:55 GME $165.00
P 2022-04-02 15:33:16 GME $165.00
P 2022-04-02 15:33:18 GME $165.00
P 2022-04-02 15:34:52 GME $165.00
P 2022-04-02 15:36:31 GME $165.00
P 2022-04-02 15:36:44 GME $165.00
P 2022-04-02 15:37:14 GME $165.00
P 2022-04-02 15:49:49 GME $165.00
P 2022-04-02 15:53:54 GME $165.00
P 2022-04-02 15:56:34 GME $165.00
P 2022-04-02 15:56:51 GME $165.00
P 2022-04-02 15:57:48 GME $165.00
P 2022-04-02 15:57:57 GME $165.00
P 2022-04-02 15:58:13 GME $165.00
P 2022-04-02 15:59:48 GME $165.00
P 2022-04-02 16:01:35 GME $165.00
P 2022-04-02 16:04:25 GME $165.00
P 2022-04-02 16:04:31 GME $165.00
P 2022-04-02 16:07:43 GME $165.00
P 2022-04-02 16:07:47 GME $165.00
P 2022-04-02 16:12:41 GME $165.00
P 2022-04-02 16:13:17 GME $165.00
P 2022-04-02 16:13:29 GME $165.00
P 2022-04-02 16:18:19 GME $165.00
P 2022-04-02 16:19:01 GME $165.00
P 2022-04-06 08:12:08 GME $153.59
P 2022-04-24 13:29:11 GME $138.22
P 2022-04-26 12:37:28 GME $135.94
P 2022-04-26 12:37:53 GME $135.94
P 2022-04-26 12:38:15 GME $135.94
P 2022-04-26 12:40:24 GME $135.94
P 2022-04-26 12:40:26 GME $135.94
P 2022-05-06 08:01:16 GME $135.94
P 2022-05-06 08:01:54 GME $135.94
P 2022-05-06 08:02:11 GME $135.94
P 2022-05-11 16:11:33 GME $135.94
P 2022-05-17 11:06:12 GME $135.94
P 2022-05-17 12:54:19 GME $135.94
P 2022-05-18 07:43:33 GME $135.94
P 2022-05-27 12:11:19 GME $135.94
P 2022-05-27 12:12:45 GME $135.94
P 2022-06-04 15:09:21 GME $135.94
P 2022-06-04 15:18:50 GME $135.94
P 2022-06-13 15:46:58 GME $118.25
P 2022-06-14 18:35:40 GME $118.25
P 2022-06-19 09:51:51 GME $118.25
P 2022-06-21 21:44:19 GME $118.25
P 2022-06-25 10:15:35 GME $118.25
P 2022-07-06 10:30:13 GME $120.23
P 2022-07-06 10:30:34 GME $120.23
P 2022-07-07 14:49:14 GME $120.23
P 2022-07-07 14:50:14 GME $120.23
P 2022-07-13 11:10:49 GME $120.23
P 2022-08-02 10:14:48 GME $120.23
P 2022-08-03 15:56:22 GME $120.23
P 2022-08-10 16:11:26 GME $120.23
P 2022-08-13 10:55:37 GME $120.23
P 2022-08-30 15:16:05 GME $29.84
P 2022-08-30 15:20:02 GME $29.84
P 2022-08-31 08:48:40 GME $29.84
P 2022-09-06 15:40:03 GME $25.14
P 2022-09-11 10:34:00 GME $28.92
P 2022-09-24 09:25:43 GME $28.92
P 2022-09-25 09:34:50 GME $28.92
P 2022-09-25 09:34:58 GME $28.92
P 2022-09-25 09:35:05 GME $28.92
P 2022-09-25 09:35:22 GME $28.92
P 2022-09-25 09:36:27 GME $28.92
P 2022-09-25 10:45:01 GME $28.92
P 2022-09-25 10:46:19 GME $28.92
P 2022-09-25 10:47:04 GME $28.92
P 2022-10-01 09:21:11 GME $28.92
P 2022-10-25 21:38:31 GME $28.92
P 2022-11-06 12:06:23 GME $28.92
P 2022-11-27 07:22:51 GME $28.92
P 2022-12-03 10:15:51 GME $28.92
P 2022-12-03 10:16:14 GME $28.92
P 2022-12-03 10:16:50 GME $28.92
P 2022-12-09 13:53:17 GME $28.92
P 2022-12-15 08:12:13 GME $28.92
P 2022-12-15 16:42:12 GME $20.58
P 2022-12-19 01:50:26 GME $20.58
P 2022-12-23 23:18:00 GME $20.58
P 2022-12-31 14:13:53 GME $20.58
P 2023-01-15 08:29:13 GME $20.58
P 2023-02-03 09:00:53 GME $20.58
P 2023-02-07 06:21:34 GME $20.58
P 2023-02-15 14:18:12 GME $19.87
P 2023-03-04 09:45:23 GME $19.87
P 2023-04-20 08:29:52 GME $19.87
P 2023-04-28 11:32:48 GME $19.87
P 2023-05-31 19:48:34 GME $19.87
P 2023-05-31 19:56:07 GME $19.87
P 2023-06-03 06:40:16 GME $19.87
P 2023-06-24 10:10:34 GME $19.87
P 2023-07-08 09:20:10 GME $22.71
P 2023-07-21 19:12:42 GME $22.71
P 2023-07-21 19:13:37 GME $22.71
P 2023-07-30 10:10:44 GME $22.71
P 2023-08-03 19:46:01 GME $20.93
P 2023-08-03 19:46:27 GME $20.93
P 2023-08-19 09:17:49 GME $20.93
P 2023-08-19 09:17:59 GME $20.93
P 2023-09-08 21:50:52 GME $20.93
P 2023-09-08 21:52:14 GME $20.93
P 2023-09-12 14:18:17 GME $20.93
P 2023-09-14 07:33:55 GME $20.93
P 2023-09-16 11:02:49 GME $20.93
P 2023-09-16 11:07:45 GME $20.93
P 2023-09-18 11:06:04 GME $18.22
P 2023-09-23 07:05:07 GME $17.18
P 2023-10-05 16:28:36 GME $17.18
P 2023-10-17 16:41:30 GME $17.18
P 2023-10-22 07:33:56 GME $17.18
`), os.ModePerm)
got, err := NewBPIs(p)
if err != nil {
t.Fatal(err)
}
if got := got["USDC"].Lookup("2099-01-01"); got != 0 {
t.Error("default got != 0:", got)
}
if got := got["GME"].Lookup("2099-01-01"); got != 17.18 {
t.Errorf("shouldve returned latest but got %v", got)
}
if got := got["GME"].Lookup("2023-09-19"); got != 18.22 {
t.Errorf("shouldve returned one day before but got %v", got)
}
if got := got["GME"].Lookup("2023-09-18"); got != 18.22 {
t.Errorf("shouldve returned day of but got %v", got)
}
t.Log(got)
}

38
src/ledger/delta.go Normal file
View File

@@ -0,0 +1,38 @@
package ledger
import "fmt"
type Currency string
const (
USD = Currency("$")
)
type Delta struct {
Date string
Name string
Value float64
Currency Currency
Description string
isSet bool
}
func newDelta(d, desc, name string, v float64, c string, isSet bool) Delta {
return Delta{
Date: d,
Name: name,
Value: v,
Currency: Currency(c),
Description: desc,
isSet: isSet,
}
}
func (delta Delta) Debug() string {
return fmt.Sprintf("{@%s %s:\"%s\" %s%.2f %s}", delta.Date, delta.Name, delta.Description, func() string {
if !delta.isSet {
return ""
}
return "= "
}(), delta.Value, delta.Currency)
}

24
src/ledger/delta_test.go Normal file
View File

@@ -0,0 +1,24 @@
package ledger
import (
"testing"
)
func TestDelta(t *testing.T) {
d := "2099-08-07"
delta := newDelta(d, "", "name", 34.56, "$", false)
if delta.Date != d {
t.Error(delta.Date)
}
if delta.Name != "name" {
t.Error(delta.Name)
}
if delta.Value != 34.56 {
t.Error(delta.Value)
}
if delta.Currency != USD {
t.Error(delta.Currency)
}
t.Log(delta)
}

68
src/ledger/deltas.go Normal file
View File

@@ -0,0 +1,68 @@
package ledger
import (
"slices"
)
type Deltas []Delta
func (deltas Deltas) Group(group ...Group) Deltas {
result := make(Deltas, 0, len(deltas))
for i := range deltas {
result = append(result, Groups(group).Each(deltas[i]))
}
return result
}
func (deltas Deltas) Like(like ...Like) Deltas {
result := make(Deltas, 0, len(deltas))
for i := range deltas {
if Likes(like).All(deltas[i]) {
result = append(result, deltas[i])
}
}
return result
}
func (deltas Deltas) Register() Register {
dateToBalances := make(Register)
for _, delta := range deltas {
if _, ok := dateToBalances[delta.Date]; !ok {
dateToBalances[delta.Date] = make(Balances)
}
dateToBalances[delta.Date].Push(delta)
}
dates := make([]string, 0, len(dateToBalances)+1)
for k := range dateToBalances {
dates = append(dates, k)
}
slices.Sort(dates)
for i := range dates {
if i == 0 {
continue
}
dateToBalances[dates[i]].PushAll(dateToBalances[dates[i-1]])
}
return dateToBalances
}
func (deltas Deltas) Balances() Balances {
result := make(Balances)
for _, delta := range deltas {
if _, ok := result[delta.Name]; !ok {
result[delta.Name] = make(Balance)
}
if _, ok := result[delta.Name][delta.Currency]; !ok {
result[delta.Name][delta.Currency] = 0
}
result[delta.Name][delta.Currency] += delta.Value
if result[delta.Name][delta.Currency] < 0.000000001 && result[delta.Name][delta.Currency] > -0.000000001 {
delete(result[delta.Name], delta.Currency)
if len(result[delta.Name]) == 0 {
delete(result, delta.Name)
}
}
}
return result
}

67
src/ledger/deltas_test.go Normal file
View File

@@ -0,0 +1,67 @@
package ledger
import "testing"
func TestDeltas(t *testing.T) {
t.Run("Groups", func(t *testing.T) {
deltas := Deltas{
{Name: "a1"},
{Name: "b1"},
{Name: "b2"},
{Name: "b3"},
}
deltas = deltas.Group(GroupName("^."))
if len(deltas) != 4 {
t.Error(len(deltas))
}
if deltas[0].Name != "a" {
t.Error(deltas[0].Name)
}
if deltas[1].Name != "b" {
t.Error(deltas[1].Name)
}
if deltas[2].Name != "b" {
t.Error(deltas[2].Name)
}
if deltas[3].Name != "b" {
t.Error(deltas[3].Name)
}
})
t.Run("balances", func(t *testing.T) {
deltas := Deltas{
{Name: "a", Value: 0},
{Name: "b", Value: 1},
{Name: "b", Value: -.999999999999999999999999},
{Name: "b", Value: 1.3},
}
balances := deltas.Balances()
if len(balances) != 1 {
t.Error(len(balances), balances)
}
if balances["b"][""] != 1.3 {
t.Error(balances["b"])
}
})
t.Run("register", func(t *testing.T) {
deltas := Deltas{
{Date: "a", Value: 0.1},
{Date: "a", Value: 2.2},
{Date: "b", Value: 4.3},
}
got := deltas.Register()
t.Logf("%+v", got)
if len(got) != 2 {
t.Error(len(got))
}
if int(10*got["a"][""][""]) != 23 {
t.Error(got["a"][""][""])
}
if int(10*got["b"][""][""]) != 66 {
t.Error(got["b"][""][""])
}
})
}

151
src/ledger/file.go Normal file
View File

@@ -0,0 +1,151 @@
package ledger
import (
"fmt"
"io"
"os"
"sort"
"unicode"
)
var filesAppendDelim = "\t"
type Files []string
func NewFiles(p string, q ...string) (Files, error) {
f := Files(append([]string{p}, q...))
_, err := f.Deltas()
return f, err
}
func (files Files) Add(payee string, delta Delta) error {
currencyValue := fmt.Sprintf("%s%.2f", delta.Currency, delta.Value)
if delta.Currency != USD {
currencyValue = fmt.Sprintf("%.2f %s", delta.Value, delta.Currency)
}
return files.append(fmt.Sprintf("%s %s\n%s%s%s%s\n%s%s",
delta.Date, delta.Description,
filesAppendDelim, delta.Name, filesAppendDelim+filesAppendDelim+filesAppendDelim, currencyValue,
filesAppendDelim, payee,
))
}
func (files Files) append(s string) error {
if err := files.trimTrainlingWhitespace(); err != nil {
return err
}
f, err := os.OpenFile(string(files[0]), os.O_APPEND|os.O_CREATE|os.O_WRONLY, os.ModePerm)
if err != nil {
return err
}
defer f.Close()
fmt.Fprintf(f, "\n%s", s)
return f.Close()
}
func (files Files) trimTrainlingWhitespace() error {
idx, err := files._lastNonWhitespacePos()
if err != nil {
return err
}
if idx < 1 {
return nil
}
f, err := os.OpenFile(string(files[0]), os.O_CREATE|os.O_WRONLY, os.ModePerm)
if err != nil {
return err
}
defer f.Close()
return f.Truncate(int64(idx + 1))
}
func (files Files) _lastNonWhitespacePos() (int, error) {
f, err := os.Open(string(files[0]))
if os.IsNotExist(err) {
return -1, nil
}
if err != nil {
return -1, err
}
defer f.Close()
b, err := io.ReadAll(f)
if err != nil {
return -1, err
}
for i := len(b) - 1; i >= 0; i-- {
if !unicode.IsSpace(rune(b[i])) {
return i, nil
}
}
return len(b) - 1, nil
}
func (files Files) Deltas(like ...Like) (Deltas, error) {
transactions, err := files.transactions()
if err != nil {
return nil, err
}
sort.Slice(transactions, func(i, j int) bool {
return fmt.Sprintf("%s %s", transactions[i].date, transactions[i].description) < fmt.Sprintf("%s %s", transactions[j].date, transactions[j].description)
})
result := make(Deltas, 0, len(transactions)*2)
for _, transaction := range transactions {
sums := map[string]float64{}
for _, recipient := range transaction.recipients {
sums[recipient.currency] += recipient.value
delta := newDelta(
transaction.date,
transaction.description,
recipient.name,
recipient.value,
recipient.currency,
recipient.isSet,
)
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)
} else {
delta := newDelta(
transaction.date,
transaction.description,
transaction.payee,
-1.0*value,
currency,
false,
)
result = append(result, delta)
}
}
}
balances := make(Balances)
for i := range result {
if result[i].isSet {
var was float64
if m, ok := balances[result[i].Name]; ok {
was = m[result[i].Currency]
}
result[i].Value = result[i].Value - was
result[i].isSet = false
}
balances.Push(result[i])
}
for i := range result {
if result[i].isSet {
return nil, fmt.Errorf("failed to resolve isSet: %+v", result[i])
}
}
return result.Like(like...), nil
}

277
src/ledger/file_test.go Normal file
View File

@@ -0,0 +1,277 @@
package ledger
import (
"os"
"path"
"path/filepath"
"testing"
)
func TestFileAdd(t *testing.T) {
filesAppendDelim = " "
payee := "name:3"
delta := Delta{
Date: "2999-88-77",
Description: "66",
Name: "name:1",
Value: 2.00,
Currency: USD,
}
cases := map[string]struct {
given []byte
want string
}{
"no file": {
given: nil,
want: `
2999-88-77 66
name:1 $2.00
name:3`,
},
"empty file": {
given: []byte{},
want: `
2999-88-77 66
name:1 $2.00
name:3`,
},
"happy without trailing whitespace": {
given: []byte(`
2000-01-02 desc
name:1 $1.00
name:2 $-1.00`),
want: `
2000-01-02 desc
name:1 $1.00
name:2 $-1.00
2999-88-77 66
name:1 $2.00
name:3`,
},
"happy with trailing newline": {
given: []byte(`
2000-01-02 desc
name:1 $1.00
name:2 $-1.00
`),
want: `
2000-01-02 desc
name:1 $1.00
name:2 $-1.00
2999-88-77 66
name:1 $2.00
name:3`,
},
}
for name, d := range cases {
c := d
t.Run(name, func(t *testing.T) {
p := path.Join(t.TempDir(), "input")
if c.given != nil {
if err := os.WriteFile(p, []byte(c.given), os.ModePerm); err != nil {
t.Fatal(err)
}
}
f, err := NewFiles(p)
if err != nil {
t.Fatal(err)
}
if err := f.Add(payee, delta); err != nil {
t.Fatal(err)
}
if got, err := os.ReadFile(p); err != nil {
t.Fatal(err)
} else if string(got) != c.want {
t.Errorf("wanted\n\t%s, got\n\t%s", c.want, got)
}
})
}
}
func TestFileTestdataMacroWithBPI(t *testing.T) {
paths, err := filepath.Glob("./testdata/macro.d/*")
if err != nil {
t.Fatal(err)
}
t.Log(paths)
f, err := NewFiles(paths[0], paths[1:]...)
if err != nil {
t.Fatal(err)
}
deltas, err := f.Deltas()
if err != nil {
t.Fatal(err)
}
bpis, err := NewBPIs("./testdata/bpi.bpi")
if err != nil {
t.Fatal(err)
}
t.Run("bal like", func(t *testing.T) {
bal := deltas.Like(LikeName(`^AssetAccount:Bond`)).Balances().WithBPIs(bpis)
for k, v := range bal {
t.Logf("%s: %+v", k, v)
}
})
t.Run("reg like", func(t *testing.T) {
reg := deltas.Like(LikeName(`^AssetAccount:Bond`)).Register()
for k, v := range reg {
t.Logf("%s: %+v", k, v.WithBPIs(bpis))
}
})
}
func TestFileTestdata(t *testing.T) {
t.Run("macro.d", func(t *testing.T) {
paths, err := filepath.Glob("./testdata/macro.d/*")
if err != nil {
t.Fatal(err)
}
f, err := NewFiles(paths[0], paths[1:]...)
if err != nil {
t.Fatal(err)
}
deltas, err := f.Deltas()
if err != nil {
t.Fatal(err)
}
t.Run("deltas", func(t *testing.T) {
for i := range deltas {
t.Logf("%+v", deltas[i].Debug())
}
})
t.Run("balances", func(t *testing.T) {
balances := deltas.Balances()
for k, v := range balances {
t.Logf("%s: %+v", k, v)
}
})
t.Run("balances like", func(t *testing.T) {
balances := deltas.Like(LikeName(`^AssetAccount:`)).Balances()
for k, v := range balances {
t.Logf("%s: %+v", k, v)
}
})
})
t.Run("single files", func(t *testing.T) {
paths, err := filepath.Glob("./testdata/*.dat")
if err != nil {
t.Fatal(err)
}
for _, pathd := range paths {
path := pathd
t.Run(path, func(t *testing.T) {
f, err := NewFiles(path)
if err != nil {
t.Fatal(err)
}
deltas, err := f.Deltas()
if err != nil {
t.Fatal(err)
}
t.Run("deltas", func(t *testing.T) {
for i := range deltas {
t.Logf("%+v", deltas[i].Debug())
}
})
t.Run("balances", func(t *testing.T) {
balances := deltas.Balances()
for k, v := range balances {
t.Logf("%s: %+v", k, v)
}
})
t.Run("balances like", func(t *testing.T) {
balances := deltas.Like(LikeName(`AssetAccount:Cash:Fidelity76`)).Balances()
for k, v := range balances {
t.Logf("%s: %+v", k, v)
}
})
})
}
})
}
func TestFileDeltas(t *testing.T) {
happy := []Delta{
{
Date: "2022-12-12",
Name: "AssetAccount:Cash:Fidelity76",
Value: -97.92,
Currency: USD,
Description: "Electricity / Power Bill TG2PJ-2PLP5",
},
{
Date: "2022-12-12",
Name: "Withdrawal:0:SharedHome:DominionEnergy",
Value: 97.92,
Currency: USD,
Description: "Electricity / Power Bill TG2PJ-2PLP5",
},
{
Date: "2022-12-12",
Name: "AssetAccount:Cash:Fidelity76",
Value: -1.00,
Currency: USD,
Description: "Test pay chase TG32S-BT2FF",
},
{
Date: "2022-12-12",
Name: "Debts:Credit:ChaseFreedomUltdVisa",
Value: 1.00,
Currency: USD,
Description: "Test pay chase TG32S-BT2FF",
},
}
cases := map[string][]Delta{
"empty": nil,
"one": happy[:2],
"happy": happy[:],
}
for name, d := range cases {
want := d
t.Run(name, func(t *testing.T) {
f, err := NewFiles("./testdata/" + name + ".dat")
if err != nil {
t.Fatal(err)
}
deltas, err := f.Deltas()
if err != nil {
t.Fatal(err)
}
if len(deltas) != len(want) {
t.Error(len(deltas))
}
for i := range want {
if i >= len(deltas) {
break
}
if want[i] != deltas[i] {
t.Errorf("[%d] \n\twant=%s, \n\t got=%s", i, want[i].Debug(), deltas[i].Debug())
}
}
})
}
}

30
src/ledger/group.go Normal file
View File

@@ -0,0 +1,30 @@
package ledger
import "regexp"
type Group func(Delta) Delta
type Groups []Group
func (groups Groups) Each(d Delta) Delta {
for i := range groups {
d = groups[i](d)
}
return d
}
func GroupDate(pattern string) Group {
p := regexp.MustCompile(pattern)
return func(d Delta) Delta {
d.Date = p.FindString(d.Date)
return d
}
}
func GroupName(pattern string) Group {
p := regexp.MustCompile(pattern)
return func(d Delta) Delta {
d.Name = p.FindString(d.Name)
return d
}
}

13
src/ledger/group_test.go Normal file
View File

@@ -0,0 +1,13 @@
package ledger
import "testing"
func TestGroup(t *testing.T) {
if got := GroupDate("^20..")(Delta{Date: "2021-01"}); got.Date != "2021" {
t.Error(got)
}
if got := GroupName("^[^:]*")(Delta{Name: "a:b:c"}); got.Name != "a" {
t.Error(got)
}
}

49
src/ledger/like.go Normal file
View File

@@ -0,0 +1,49 @@
package ledger
import (
"regexp"
)
type Like func(Delta) bool
type Likes []Like
func LikeBefore(date string) Like {
return func(d Delta) bool {
return date >= d.Date
}
}
func LikeAfter(date string) Like {
return func(d Delta) bool {
return date <= d.Date
}
}
func LikeName(pattern string) Like {
return func(d Delta) bool {
return like(pattern, d.Name)
}
}
func like(pattern string, other string) bool {
return regexp.MustCompile(pattern).MatchString(other)
}
func (likes Likes) Any(delta Delta) bool {
for i := range likes {
if likes[i](delta) {
return true
}
}
return false
}
func (likes Likes) All(delta Delta) bool {
for i := range likes {
if !likes[i](delta) {
return false
}
}
return true
}

38
src/ledger/like_test.go Normal file
View File

@@ -0,0 +1,38 @@
package ledger
import "testing"
func TestLikeBeforeAfter(t *testing.T) {
if got := LikeBefore("9")(Delta{Date: "2021"}); !got {
t.Error("got 2021 is NOT before 9")
}
if got := LikeBefore("1")(Delta{Date: "2021"}); got {
t.Error("got 2021 IS before 1")
}
if got := LikeAfter("9")(Delta{Date: "2021"}); got {
t.Error("got 2021 IS after 9")
}
if got := LikeAfter("1")(Delta{Date: "2021"}); !got {
t.Error("got 2021 is NOT after 1")
}
}
func TestLikeName(t *testing.T) {
delta := Delta{Name: "x"}
if got := LikeName("^x$")(delta); !got {
t.Error(got)
}
if got := LikeName("^y$")(delta); got {
t.Error(got)
}
}
func TestLikesAll(t *testing.T) {
delta := Delta{Name: "x"}
if likes := (Likes{LikeName("^x$")}); !likes.All(delta) {
t.Error(likes.All(delta))
}
if likes := (Likes{LikeName("^y$")}); likes.All(delta) {
t.Error(likes.All(delta))
}
}

118
src/ledger/register.go Normal file
View File

@@ -0,0 +1,118 @@
package ledger
import (
"fmt"
"maps"
"slices"
"time"
)
type Register map[string]Balances
func (register Register) Latest() Balances {
dates := register.Dates()
if len(dates) == 0 {
return nil
}
date := dates[len(dates)-1]
return register[date]
}
func (register Register) WithBPIs(bpis BPIs) Register {
result := make(Register)
for d := range register {
result[d] = register[d].WithBPIsAt(bpis, d)
}
return result
}
func (register Register) PushAll(other Register) {
for date := range other {
if _, ok := register[date]; !ok {
register[date] = make(Balances)
}
register[date].PushAll(other[date])
}
}
func (register Register) Names() []string {
names := map[string]int{}
for _, v := range register {
for name := range v {
names[name] = 1
}
}
result := make([]string, 0, len(register))
for k := range names {
result = append(result, k)
}
slices.Sort(result)
return result
}
func (register Register) Dates() []string {
result := make([]string, 0, len(register))
for k := range register {
result = append(result, k)
}
slices.Sort(result)
return result
}
func (register Register) Between(start, end time.Time) Register {
result := make(Register)
for k := range register {
t := mustDateToTime(k)
if t.Before(start) || t.After(end) {
continue
}
result[k] = maps.Clone(register[k])
}
return result
}
func (register Register) Times() []time.Time {
dates := register.Dates()
result := make([]time.Time, len(dates))
for i := range dates {
result[i] = mustDateToTime(dates[i])
}
return result
}
func mustDateToTime(s string) time.Time {
result, err := dateToTime(s)
if err != nil {
panic(err)
}
return result
}
func dateToTime(s string) (time.Time, error) {
for _, layout := range []string{
"2006-01-02",
"2006-01",
} {
if t, err := time.ParseInLocation(layout, s, time.Local); err == nil {
return t, err
}
}
return time.Time{}, fmt.Errorf("no layout matching %q", s)
}
func (register Register) Debug() string {
result := "{"
for _, date := range register.Dates() {
result += "{" + date
for name, balance := range register[date] {
result += "{" + name
for cur, v := range balance {
result += fmt.Sprintf("%s=%.2f ", cur, v)
}
result += "}"
}
result += "}"
}
return result + "}"
}

View File

@@ -0,0 +1,31 @@
package ledger
import (
"testing"
"time"
)
func TestRegisterBetween(t *testing.T) {
reg := make(Register)
reg["2001-01"] = make(Balances)
reg["2002-01"] = make(Balances)
reg["2003-01"] = make(Balances)
reg["2004-01"] = make(Balances)
reg["2005-01"] = make(Balances)
start, _ := time.ParseInLocation("2006-01", "2002-01", time.Local)
end, _ := time.ParseInLocation("2006-01", "2004-01", time.Local)
got := reg.Between(start, end)
if len(got) != 3 {
t.Error(len(got))
}
if _, ok := got["2002-01"]; !ok {
t.Error("2002-01")
}
if _, ok := got["2003-01"]; !ok {
t.Error("2003-01")
}
if _, ok := got["2004-01"]; !ok {
t.Error("2004-01")
}
}

1
src/ledger/testdata/bpi.bpi vendored Symbolic link
View File

@@ -0,0 +1 @@
../../../../../../../Sync/Core/ledger/bpi.dat

0
src/ledger/testdata/empty.dat vendored Normal file
View File

7
src/ledger/testdata/happy.dat vendored Normal file
View File

@@ -0,0 +1,7 @@
2022-12-12 Electricity / Power Bill TG2PJ-2PLP5
AssetAccount:Cash:Fidelity76 $-97.92
Withdrawal:0:SharedHome:DominionEnergy
2022-12-12 Test pay chase TG32S-BT2FF
AssetAccount:Cash:Fidelity76 $-1.00
Debts:Credit:ChaseFreedomUltdVisa

1
src/ledger/testdata/macro.d vendored Symbolic link
View File

@@ -0,0 +1 @@
../../../../../../../Sync/Core/ledger/eras/2022-/

1
src/ledger/testdata/macro.dat vendored Symbolic link
View File

@@ -0,0 +1 @@
../../../../../../../Sync/Core/ledger/eras/2022-/fidelity.76.dat.txt

3
src/ledger/testdata/one.dat vendored Normal file
View File

@@ -0,0 +1,3 @@
2022-12-12 Electricity / Power Bill TG2PJ-2PLP5
AssetAccount:Cash:Fidelity76 $-97.92
Withdrawal:0:SharedHome:DominionEnergy

223
src/ledger/transaction.go Normal file
View File

@@ -0,0 +1,223 @@
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
isSet bool
}
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 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 {
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, 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
}
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)
}
}

View File

@@ -0,0 +1,68 @@
package ledger
import (
"bufio"
"fmt"
"io"
"strings"
"testing"
)
func TestReadTransaction(t *testing.T) {
cases := map[string]struct {
input string
want transaction
err error
}{
"empty": {
input: "",
want: transaction{},
err: io.EOF,
},
"white space": {
input: " ",
want: transaction{},
err: io.EOF,
},
"verbose": {
input: `
2003-04-05 Reasoning here
A:B $1.00
C:D $-1.00
`,
want: transaction{
date: "2003-04-05",
description: "Reasoning here",
payee: "",
recipients: []transactionRecipient{
{
name: "A:B",
value: 1.0,
currency: "$",
},
{
name: "C:D",
value: -1.0,
currency: "$",
},
},
},
err: io.EOF,
},
}
for name, d := range cases {
c := d
t.Run(name, func(t *testing.T) {
r := bufio.NewReader(strings.NewReader(c.input))
got, err := readTransaction(r)
if err != c.err {
t.Error(err)
}
if fmt.Sprintf("%+v", got) != fmt.Sprintf("%+v", c.want) {
t.Errorf("want\n\t%+v, got\n\t%+v", c.want, got)
}
})
}
}