mv /ana, /ledger to /src/
This commit is contained in:
42
src/ana/legacy_bpi.go
Normal file
42
src/ana/legacy_bpi.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package ana
|
||||
|
||||
import (
|
||||
"maps"
|
||||
"regexp"
|
||||
"time"
|
||||
|
||||
"gogs.inhome.blapointe.com/ana-ledger/src/ledger"
|
||||
)
|
||||
|
||||
func BPIsWithFixedGrowthPrediction(bpis ledger.BPIs, window time.Duration, pattern string, apy float64) (ledger.BPIs, error) {
|
||||
last := map[ledger.Currency]struct {
|
||||
t string
|
||||
v float64
|
||||
}{}
|
||||
for currency, bpi := range bpis {
|
||||
for date, value := range bpi {
|
||||
if date > last[currency].t {
|
||||
was := last[currency]
|
||||
was.t = date
|
||||
was.v = value
|
||||
last[currency] = was
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result := make(ledger.BPIs)
|
||||
p := regexp.MustCompile(pattern)
|
||||
for currency, v := range bpis {
|
||||
result[currency] = maps.Clone(v)
|
||||
if p.MatchString(string(currency)) {
|
||||
for _, predictionTime := range predictionTimes(window) {
|
||||
k2 := predictionTime.Format("2006-01")
|
||||
was := last[currency]
|
||||
was.v *= 1.0 + (apy / 12.0)
|
||||
result[currency][k2] = was.v
|
||||
last[currency] = was
|
||||
}
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
35
src/ana/legacy_bpi_test.go
Normal file
35
src/ana/legacy_bpi_test.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package ana
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gogs.inhome.blapointe.com/ana-ledger/src/ledger"
|
||||
)
|
||||
|
||||
func TestBPIPrediction(t *testing.T) {
|
||||
t.Run("fixed growth", func(t *testing.T) {
|
||||
bpis := ledger.BPIs{
|
||||
ledger.USD: ledger.BPI{
|
||||
"2001-01": -1000,
|
||||
"2001-02": 100,
|
||||
},
|
||||
}
|
||||
|
||||
got, err := BPIsWithFixedGrowthPrediction(bpis, time.Hour*24*365, string(ledger.USD), 0.06)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
dates := []string{}
|
||||
for d := range got[ledger.USD] {
|
||||
dates = append(dates, d)
|
||||
}
|
||||
slices.Sort(dates)
|
||||
|
||||
for _, d := range dates {
|
||||
t.Logf("%s | %s %.2f", ledger.USD, d, got[ledger.USD][d])
|
||||
}
|
||||
})
|
||||
}
|
||||
45
src/ana/prediction.go
Normal file
45
src/ana/prediction.go
Normal file
@@ -0,0 +1,45 @@
|
||||
package ana
|
||||
|
||||
import (
|
||||
"maps"
|
||||
"time"
|
||||
|
||||
"gogs.inhome.blapointe.com/ana-ledger/src/ledger"
|
||||
)
|
||||
|
||||
type Prediction []Predictor
|
||||
|
||||
func NewPrediction(predictor ...Predictor) Prediction {
|
||||
return Prediction(predictor)
|
||||
}
|
||||
|
||||
func (prediction Prediction) Predict(register ledger.Register, window time.Duration) ledger.Register {
|
||||
return prediction.predict(register.Latest(), time.Now(), predictionTimes(window))
|
||||
}
|
||||
|
||||
func (prediction Prediction) predict(latest ledger.Balances, from time.Time, these []time.Time) ledger.Register {
|
||||
latestT := from
|
||||
result := make(ledger.Register)
|
||||
for i := range these {
|
||||
k := these[i].Format("2006-01")
|
||||
result[k] = make(ledger.Balances)
|
||||
for k2, v2 := range latest {
|
||||
result[k][k2] = maps.Clone(v2)
|
||||
}
|
||||
|
||||
elapsed := these[i].Sub(latestT)
|
||||
result[k] = prediction.predictOne(result[k], elapsed)
|
||||
|
||||
latest = result[k]
|
||||
latestT = these[i]
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (prediction Prediction) predictOne(balances ledger.Balances, elapsed time.Duration) ledger.Balances {
|
||||
for i := range prediction {
|
||||
next := prediction[i](balances, elapsed)
|
||||
balances = next
|
||||
}
|
||||
return balances
|
||||
}
|
||||
121
src/ana/prediction_test.go
Normal file
121
src/ana/prediction_test.go
Normal file
@@ -0,0 +1,121 @@
|
||||
package ana
|
||||
|
||||
import (
|
||||
"maps"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gogs.inhome.blapointe.com/ana-ledger/src/ledger"
|
||||
)
|
||||
|
||||
func TestPredictionPredict(t *testing.T) {
|
||||
inc := func(b ledger.Balances, _ time.Duration) ledger.Balances {
|
||||
result := make(ledger.Balances)
|
||||
for k, v := range b {
|
||||
result[k] = maps.Clone(v)
|
||||
for k2, v2 := range result[k] {
|
||||
result[k][k2] = v2 + 1
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
double := func(b ledger.Balances, _ time.Duration) ledger.Balances {
|
||||
result := make(ledger.Balances)
|
||||
for k, v := range b {
|
||||
result[k] = maps.Clone(v)
|
||||
for k2, v2 := range result[k] {
|
||||
result[k][k2] = v2 * 2
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
from := time.Now()
|
||||
month := time.Hour * 24 * 365 / 12
|
||||
these := []time.Time{time.Now().Add(time.Hour * 24 * 365 / 12)}
|
||||
theseK := these[0].Format("2006-01")
|
||||
thisMonth := time.Now().Add(-1 * time.Duration(time.Now().Day()) * time.Hour * 24)
|
||||
monthsAgo1 := thisMonth.Add(-1 * month).Format("2006-01")
|
||||
monthsAgo2 := thisMonth.Add(-2 * month).Format("2006-01")
|
||||
_, _ = inc, double
|
||||
|
||||
cases := map[string]struct {
|
||||
prediction Prediction
|
||||
given ledger.Balances
|
||||
want ledger.Register
|
||||
}{
|
||||
"empty": {
|
||||
want: ledger.Register{theseK: {}},
|
||||
},
|
||||
"double": {
|
||||
prediction: Prediction{double},
|
||||
given: ledger.Balances{"X": ledger.Balance{"X": 2}},
|
||||
want: ledger.Register{theseK: ledger.Balances{"X": ledger.Balance{"X": 4}}},
|
||||
},
|
||||
"inc": {
|
||||
prediction: Prediction{inc},
|
||||
given: ledger.Balances{"X": ledger.Balance{"X": 2}},
|
||||
want: ledger.Register{theseK: ledger.Balances{"X": ledger.Balance{"X": 3}}},
|
||||
},
|
||||
"inc, double": {
|
||||
prediction: Prediction{inc, double},
|
||||
given: ledger.Balances{"X": ledger.Balance{"X": 5}},
|
||||
want: ledger.Register{theseK: ledger.Balances{"X": ledger.Balance{"X": 12}}},
|
||||
},
|
||||
"double, inc": {
|
||||
prediction: Prediction{double, inc},
|
||||
given: ledger.Balances{"X": ledger.Balance{"X": 5}},
|
||||
want: ledger.Register{theseK: ledger.Balances{"X": ledger.Balance{"X": 11}}},
|
||||
},
|
||||
"contribution": {
|
||||
prediction: Prediction{
|
||||
NewAutoContributionPredictor(ledger.Register{
|
||||
"2001-01": ledger.Balances{"X": ledger.Balance{"X": 100}}, // too old
|
||||
"2001-02": ledger.Balances{"X": ledger.Balance{"X": 10000}}, // too old
|
||||
monthsAgo2: ledger.Balances{"X": ledger.Balance{"X": 100}},
|
||||
monthsAgo1: ledger.Balances{"X": ledger.Balance{"X": 700}}, // +600 once in 6 months
|
||||
}),
|
||||
},
|
||||
given: ledger.Balances{"X": ledger.Balance{"X": 5}},
|
||||
want: ledger.Register{theseK: ledger.Balances{"X": ledger.Balance{"X": 5}}}, // too few contributions
|
||||
},
|
||||
"interest": {
|
||||
prediction: Prediction{
|
||||
NewInterestPredictor("X", "X", 12),
|
||||
},
|
||||
given: ledger.Balances{"X": ledger.Balance{"X": 5}},
|
||||
want: ledger.Register{theseK: ledger.Balances{"X": ledger.Balance{"X": 10}}},
|
||||
},
|
||||
"interest, contribution": {
|
||||
prediction: Prediction{
|
||||
NewInterestPredictor("X", "X", 12),
|
||||
NewAutoContributionPredictor(ledger.Register{
|
||||
monthsAgo2: ledger.Balances{"X": ledger.Balance{"X": 100}},
|
||||
monthsAgo1: ledger.Balances{"X": ledger.Balance{"X": 700}},
|
||||
}),
|
||||
},
|
||||
given: ledger.Balances{"X": ledger.Balance{"X": 5}},
|
||||
want: ledger.Register{theseK: ledger.Balances{"X": ledger.Balance{"X": 10}}}, // too few contributions
|
||||
},
|
||||
"contribution, interest": {
|
||||
prediction: Prediction{
|
||||
NewAutoContributionPredictor(ledger.Register{
|
||||
monthsAgo2: ledger.Balances{"X": ledger.Balance{"X": 100}},
|
||||
monthsAgo1: ledger.Balances{"X": ledger.Balance{"X": 700}},
|
||||
}),
|
||||
NewInterestPredictor("X", "X", 12),
|
||||
},
|
||||
given: ledger.Balances{"X": ledger.Balance{"X": 5}},
|
||||
want: ledger.Register{theseK: ledger.Balances{"X": ledger.Balance{"X": 10}}}, // too few contributions
|
||||
},
|
||||
}
|
||||
|
||||
for name, d := range cases {
|
||||
c := d
|
||||
t.Run(name, func(t *testing.T) {
|
||||
got := c.prediction.predict(c.given, from, these)
|
||||
if got.Debug() != c.want.Debug() {
|
||||
t.Errorf("want\n\t%+v, got\n\t%+v", c.want.Debug(), got.Debug())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
159
src/ana/predictor.go
Normal file
159
src/ana/predictor.go
Normal file
@@ -0,0 +1,159 @@
|
||||
package ana
|
||||
|
||||
import (
|
||||
"maps"
|
||||
"math"
|
||||
"regexp"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"gogs.inhome.blapointe.com/ana-ledger/src/ledger"
|
||||
)
|
||||
|
||||
const (
|
||||
month = time.Hour * 24 * 365 / 12
|
||||
)
|
||||
|
||||
type Predictor func(ledger.Balances, time.Duration) ledger.Balances
|
||||
|
||||
func NewInterestPredictor(namePattern string, currencyPattern string, apy float64) Predictor {
|
||||
nameMatcher := regexp.MustCompile(namePattern)
|
||||
currencyMatcher := regexp.MustCompile(currencyPattern)
|
||||
return func(given ledger.Balances, delta time.Duration) ledger.Balances {
|
||||
result := maps.Clone(given)
|
||||
for k, v := range result {
|
||||
result[k] = maps.Clone(v)
|
||||
}
|
||||
|
||||
monthsPassed := float64(delta) / float64(month)
|
||||
scalar := math.Pow(1.0+apy/12.0, monthsPassed)
|
||||
for name := range result {
|
||||
if !nameMatcher.MatchString(name) {
|
||||
continue
|
||||
}
|
||||
for currency := range result[name] {
|
||||
if !currencyMatcher.MatchString(string(currency)) {
|
||||
continue
|
||||
}
|
||||
result[name][currency] *= scalar
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
func NewAutoContributionPredictor(reg ledger.Register) Predictor {
|
||||
monthlyRate := getMonthlyAutoContributionRates(reg)
|
||||
return NewContributionPredictor(monthlyRate)
|
||||
}
|
||||
|
||||
func NewContributionPredictor(monthlyRate ledger.Balances) Predictor {
|
||||
return func(given ledger.Balances, delta time.Duration) ledger.Balances {
|
||||
months := float64(delta) / float64(month)
|
||||
result := make(ledger.Balances)
|
||||
for k, v := range given {
|
||||
result[k] = maps.Clone(v)
|
||||
}
|
||||
for name := range monthlyRate {
|
||||
if _, ok := result[name]; !ok {
|
||||
result[name] = make(ledger.Balance)
|
||||
}
|
||||
for currency := range monthlyRate[name] {
|
||||
result[name][currency] += monthlyRate[name][currency] * months
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
func getMonthlyAutoContributionRates(reg ledger.Register) map[string]ledger.Balance {
|
||||
window := 6 * month
|
||||
window = 12 * month
|
||||
contributions := getRecentContributions(reg, window)
|
||||
result := make(map[string]ledger.Balance)
|
||||
for name := range contributions {
|
||||
result[name] = getMonthlyAutoContributionRate(contributions[name], window)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func getMonthlyAutoContributionRate(contributions []ledger.Balance, window time.Duration) ledger.Balance {
|
||||
currencies := map[ledger.Currency]int{}
|
||||
for _, balance := range contributions {
|
||||
for currency := range balance {
|
||||
currencies[currency] = 1
|
||||
}
|
||||
}
|
||||
result := make(ledger.Balance)
|
||||
for currency := range currencies {
|
||||
result[currency] = getMonthlyAutoContributionRateForCurrency(contributions, window, currency)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func getMonthlyAutoContributionRateForCurrency(contributions []ledger.Balance, window time.Duration, currency ledger.Currency) float64 {
|
||||
values := []float64{}
|
||||
for i := range contributions {
|
||||
if v, ok := contributions[i][currency]; ok {
|
||||
values = append(values, v)
|
||||
}
|
||||
}
|
||||
slices.Sort(values)
|
||||
if len(values) < 3 {
|
||||
return 0
|
||||
}
|
||||
|
||||
start := int(len(values) / 4)
|
||||
end := min(1+max(0, len(values)-len(values)/4), len(values))
|
||||
subvalues := values[start:end]
|
||||
sum := 0.0
|
||||
for _, v := range subvalues {
|
||||
sum += v
|
||||
}
|
||||
standard := sum / float64(end-start)
|
||||
standard = subvalues[len(subvalues)/2]
|
||||
totalIfAllWereStandard := standard * float64(len(contributions))
|
||||
result := totalIfAllWereStandard / float64(window) * float64(month)
|
||||
//log.Printf("%s: %v contributions of about %.2f over %v can TLDR as %.2f per month from %v", currency, len(contributions), standard, window, result, values[start:end])
|
||||
return result
|
||||
}
|
||||
|
||||
func getRecentContributions(reg ledger.Register, window time.Duration) map[string][]ledger.Balance {
|
||||
return getContributions(reg.Between(time.Now().Add(-1*window), time.Now()))
|
||||
}
|
||||
|
||||
func getContributions(reg ledger.Register) map[string][]ledger.Balance {
|
||||
contributions := make(map[string][]ledger.Balance)
|
||||
for _, name := range reg.Names() {
|
||||
contributions[name] = getContributionsFor(reg, name)
|
||||
}
|
||||
return contributions
|
||||
}
|
||||
|
||||
func getContributionsFor(reg ledger.Register, name string) []ledger.Balance {
|
||||
result := make([]ledger.Balance, 0)
|
||||
var last ledger.Balance
|
||||
for _, date := range reg.Dates() {
|
||||
if _, ok := reg[date][name]; !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
if last == nil {
|
||||
last = reg[date][name]
|
||||
continue
|
||||
}
|
||||
|
||||
result = append(result, make(ledger.Balance))
|
||||
this := result[len(result)-1]
|
||||
|
||||
for k, v := range last {
|
||||
this[k] = -1 * v
|
||||
}
|
||||
for k, v := range reg[date][name] {
|
||||
this[k] += v
|
||||
}
|
||||
|
||||
last = reg[date][name]
|
||||
}
|
||||
return result
|
||||
}
|
||||
129
src/ana/predictor_test.go
Normal file
129
src/ana/predictor_test.go
Normal file
@@ -0,0 +1,129 @@
|
||||
package ana
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gogs.inhome.blapointe.com/ana-ledger/src/ledger"
|
||||
)
|
||||
|
||||
func TestNewInterestPredictor(t *testing.T) {
|
||||
acc := "x"
|
||||
curr := ledger.USD
|
||||
cases := map[string]struct {
|
||||
apy float64
|
||||
given ledger.Balances
|
||||
delta time.Duration
|
||||
want ledger.Balances
|
||||
}{
|
||||
"zero": {
|
||||
apy: 0,
|
||||
given: ledger.Balances{acc: ledger.Balance{curr: 100}},
|
||||
delta: time.Hour * 24 * 365,
|
||||
want: ledger.Balances{acc: ledger.Balance{curr: 100}},
|
||||
},
|
||||
"50%": {
|
||||
apy: .5,
|
||||
given: ledger.Balances{acc: ledger.Balance{curr: 100}},
|
||||
delta: time.Hour * 24 * 365,
|
||||
want: ledger.Balances{acc: ledger.Balance{curr: 163.21}},
|
||||
},
|
||||
}
|
||||
|
||||
for name, d := range cases {
|
||||
c := d
|
||||
t.Run(name, func(t *testing.T) {
|
||||
predictor := NewInterestPredictor(acc, string(curr), c.apy)
|
||||
got := predictor(c.given, c.delta)
|
||||
if got.Debug() != c.want.Debug() {
|
||||
t.Errorf("want\n\t%+v, got\n\t%+v", c.want, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetContributions(t *testing.T) {
|
||||
input := ledger.Register{
|
||||
"2001-01": ledger.Balances{
|
||||
"a": ledger.Balance{"x": 1},
|
||||
"b": ledger.Balance{"z": 1},
|
||||
},
|
||||
"2001-02": ledger.Balances{
|
||||
"a": ledger.Balance{"y": 1},
|
||||
},
|
||||
"2001-03": ledger.Balances{
|
||||
"b": ledger.Balance{"z": 4},
|
||||
},
|
||||
}
|
||||
|
||||
got := getContributions(input)
|
||||
t.Logf("%+v", got)
|
||||
if len(got) != 2 {
|
||||
t.Error(len(got))
|
||||
}
|
||||
if len(got["a"]) != 1 {
|
||||
t.Error(len(got["a"]))
|
||||
} else if len(got["a"][0]) != 2 {
|
||||
t.Error(got["a"])
|
||||
} else if got["a"][0]["x"] != -1 {
|
||||
t.Error(got["a"][0])
|
||||
} else if got["a"][0]["y"] != 1 {
|
||||
t.Error(got["a"][0])
|
||||
}
|
||||
|
||||
if len(got["b"]) != 1 {
|
||||
t.Error(len(got["b"]))
|
||||
} else if len(got["b"][0]) != 1 {
|
||||
t.Error(got["b"])
|
||||
} else if got["b"][0]["z"] != 3 {
|
||||
t.Error(got["b"][0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetMonthlyAutoContributionRate(t *testing.T) {
|
||||
input := []ledger.Balance{
|
||||
ledger.Balance{"x": 2},
|
||||
ledger.Balance{"x": 4},
|
||||
ledger.Balance{"y": 3},
|
||||
}
|
||||
got := getMonthlyAutoContributionRate(input, time.Hour*24*365/4)
|
||||
if len(got) != 2 {
|
||||
t.Error(got)
|
||||
}
|
||||
if got["x"] != 0 { // too few contribution
|
||||
t.Error(got["x"])
|
||||
}
|
||||
if got["y"] != 0 { // too few contribution
|
||||
t.Error(got["y"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewContributionPredictor(t *testing.T) {
|
||||
name := "x"
|
||||
currency := ledger.USD
|
||||
predictor := NewContributionPredictor(map[string]ledger.Balance{
|
||||
name: {currency: 10},
|
||||
"y": {"XYZ": 3},
|
||||
})
|
||||
month := time.Hour * 24 * 365 / 12
|
||||
|
||||
if got := predictor(ledger.Balances{}, 2*month); got[name][currency] != 20 {
|
||||
t.Error(got[name])
|
||||
} else if got["y"]["XYZ"] != 6 {
|
||||
t.Error(got["y"])
|
||||
}
|
||||
|
||||
if got := predictor(ledger.Balances{name: {currency: 30}}, 2*month); got[name][currency] != 30+20 {
|
||||
t.Error(got)
|
||||
} else if got["y"]["XYZ"] != 6 {
|
||||
t.Error(got["y"])
|
||||
}
|
||||
|
||||
if got := predictor(ledger.Balances{"z": {"ABC": 100}}, 2*month); got[name][currency] != 20 {
|
||||
t.Error(got)
|
||||
} else if got["y"]["XYZ"] != 6 {
|
||||
t.Error(got["y"])
|
||||
} else if got["z"]["ABC"] != 100 {
|
||||
t.Error(got["z"])
|
||||
}
|
||||
}
|
||||
36
src/ana/window.go
Normal file
36
src/ana/window.go
Normal file
@@ -0,0 +1,36 @@
|
||||
package ana
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
func predictionTimes(window time.Duration) []time.Time {
|
||||
result := []time.Time{time.Now()}
|
||||
last := time.Now()
|
||||
for last.Before(time.Now().Add(window)) {
|
||||
last = last.Add(-1 * time.Hour * 24 * time.Duration(last.Day())).Add(time.Hour * 24 * 33)
|
||||
result = append(result, last)
|
||||
}
|
||||
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)
|
||||
}
|
||||
92
src/ledger/balances.go
Normal file
92
src/ledger/balances.go
Normal 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, " + ")
|
||||
}
|
||||
54
src/ledger/balances_test.go
Normal file
54
src/ledger/balances_test.go
Normal 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
64
src/ledger/bpi.go
Normal 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
173
src/ledger/bpi_test.go
Normal 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
38
src/ledger/delta.go
Normal 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
24
src/ledger/delta_test.go
Normal 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
68
src/ledger/deltas.go
Normal 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
67
src/ledger/deltas_test.go
Normal 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
151
src/ledger/file.go
Normal 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
277
src/ledger/file_test.go
Normal 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
30
src/ledger/group.go
Normal 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
13
src/ledger/group_test.go
Normal 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
49
src/ledger/like.go
Normal 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
38
src/ledger/like_test.go
Normal 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
118
src/ledger/register.go
Normal 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 + "}"
|
||||
}
|
||||
31
src/ledger/register_test.go
Normal file
31
src/ledger/register_test.go
Normal 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
1
src/ledger/testdata/bpi.bpi
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../../../../../Sync/Core/ledger/bpi.dat
|
||||
0
src/ledger/testdata/empty.dat
vendored
Normal file
0
src/ledger/testdata/empty.dat
vendored
Normal file
7
src/ledger/testdata/happy.dat
vendored
Normal file
7
src/ledger/testdata/happy.dat
vendored
Normal 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
1
src/ledger/testdata/macro.d
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../../../../../Sync/Core/ledger/eras/2022-/
|
||||
1
src/ledger/testdata/macro.dat
vendored
Symbolic link
1
src/ledger/testdata/macro.dat
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../../../../../Sync/Core/ledger/eras/2022-/fidelity.76.dat.txt
|
||||
3
src/ledger/testdata/one.dat
vendored
Normal file
3
src/ledger/testdata/one.dat
vendored
Normal 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
223
src/ledger/transaction.go
Normal 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)
|
||||
}
|
||||
}
|
||||
68
src/ledger/transaction_test.go
Normal file
68
src/ledger/transaction_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user