Compare commits
44 Commits
7a946b7604
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
72c9d79dd2 | ||
|
|
96703721e3 | ||
|
|
7ac5c15ff8 | ||
|
|
d743b6dbd0 | ||
|
|
ca365ad39c | ||
|
|
86b8e62862 | ||
|
|
7262f5f69b | ||
|
|
b2954c0461 | ||
|
|
03b9a6d1f1 | ||
|
|
fa7bafa241 | ||
|
|
502e47d0bc | ||
|
|
0eb0abf4a8 | ||
|
|
49b7bd2f85 | ||
|
|
045ab30dfa | ||
|
|
b1cf639f39 | ||
|
|
cc546d9b2d | ||
|
|
0066a267a6 | ||
|
|
f58053ebe9 | ||
|
|
b30b1811ea | ||
|
|
3fb1ee4ea3 | ||
|
|
7a790fa31e | ||
|
|
774de58bf7 | ||
|
|
6aa549cb02 | ||
|
|
fe7c3a9682 | ||
|
|
36dcf70dbe | ||
|
|
989bc3c2ff | ||
|
|
f22d7d958b | ||
|
|
6a4e10ee2b | ||
|
|
09da985455 | ||
|
|
f52832ee82 | ||
|
|
0cafba0571 | ||
|
|
1a397dbf45 | ||
|
|
b10283d752 | ||
|
|
284613b5bc | ||
|
|
929d15c5b7 | ||
|
|
5e61378d63 | ||
|
|
c948a32458 | ||
|
|
35e2e40ce6 | ||
|
|
847cd736b6 | ||
|
|
9cf8cb0736 | ||
|
|
4997264f4c | ||
|
|
f69a850bd8 | ||
|
|
5a3d5e5610 | ||
|
|
c38e8529af |
@@ -6,6 +6,8 @@ on:
|
||||
paths:
|
||||
- 'cmd/**'
|
||||
- 'src/**'
|
||||
- 'build/**'
|
||||
- '.gitea/**'
|
||||
|
||||
jobs:
|
||||
ci:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM golang:1.21.3-alpine3.18 as builder
|
||||
FROM golang:1.23.9-alpine3.21 as builder
|
||||
|
||||
COPY ./ /go/src/ana-ledger
|
||||
WORKDIR /go/src/ana-ledger
|
||||
|
||||
@@ -15,5 +15,8 @@ type Config struct {
|
||||
Normalize bool
|
||||
USDOnly bool
|
||||
}
|
||||
Compact bool
|
||||
Compact bool
|
||||
GroupDate string
|
||||
NoPercent bool
|
||||
CSV string
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ package cli
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
@@ -63,17 +65,71 @@ func (period *Period) Set(s string) error {
|
||||
}
|
||||
|
||||
func (period *Period) setStartStop(s string) error {
|
||||
stop, err := period.setT(s, &period.Start)
|
||||
stop, err := period.setT(time.Now(), s, &period.Start)
|
||||
period.Stop = stop
|
||||
return err
|
||||
}
|
||||
|
||||
func (period *Period) setStop(s string) error {
|
||||
_, err := period.setT(s, &period.Stop)
|
||||
_, err := period.setT(time.Now(), s, &period.Stop)
|
||||
return err
|
||||
}
|
||||
|
||||
func (*Period) setT(s string, t *time.Time) (time.Time, error) {
|
||||
func (*Period) setT(now time.Time, s string, t *time.Time) (time.Time, error) {
|
||||
if s == "" {
|
||||
*t = time.Unix(0, 0)
|
||||
return time.Now().AddDate(100, 0, 0), nil
|
||||
}
|
||||
if submatches := regexp.MustCompile(`[+-]?(?P<years>[0-9]+y)?(?P<months>[0-9]+mo)?(?P<weeks>[0-9]+w)?(?P<days>[0-9]+d)?([0-9]+[a-z])?`).FindStringSubmatch(s); len(submatches) > 0 {
|
||||
s2 := s
|
||||
result := now
|
||||
scalar := time.Duration(1)
|
||||
if strings.HasPrefix(s2, "-") {
|
||||
scalar = -1
|
||||
}
|
||||
|
||||
for i, submatch := range submatches[1 : len(submatches)-1] {
|
||||
if len(submatch) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
unit := submatch[len(submatch)-1]
|
||||
if submatch[len(submatch)-1] == 'o' { // mo
|
||||
submatch = submatch[:len(submatch)-1]
|
||||
}
|
||||
|
||||
n, err := strconv.Atoi(submatch[:len(submatch)-1])
|
||||
if err != nil {
|
||||
return time.Time{}, err
|
||||
}
|
||||
|
||||
for i := 0; i < n; i++ {
|
||||
switch unit {
|
||||
case 'y':
|
||||
result = result.AddDate(int(scalar*1), 0, 0)
|
||||
case 'o':
|
||||
result = result.AddDate(0, int(scalar*1), 0)
|
||||
case 'w':
|
||||
result = result.AddDate(0, 0, int(scalar*7))
|
||||
case 'd':
|
||||
result = result.AddDate(0, 0, int(scalar*1))
|
||||
}
|
||||
}
|
||||
|
||||
s2 = strings.ReplaceAll(s2, submatches[i+1], "")
|
||||
}
|
||||
|
||||
if stdDuration := strings.TrimLeft(s2, "+-"); stdDuration != "" {
|
||||
if d, err := time.ParseDuration(stdDuration); err == nil {
|
||||
result = result.Add(scalar * d)
|
||||
}
|
||||
}
|
||||
|
||||
if result != now {
|
||||
*t = result
|
||||
return result.AddDate(100, 0, 0), nil
|
||||
}
|
||||
}
|
||||
if result, err := time.Parse("2006", s); err == nil {
|
||||
*t = result
|
||||
return result.AddDate(1, 0, 0).Add(-1 * time.Minute), nil
|
||||
|
||||
41
cmd/cli/flag_test.go
Normal file
41
cmd/cli/flag_test.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestPeriodSetT(t *testing.T) {
|
||||
now := time.Date(2010, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
cases := map[string]time.Time{
|
||||
"2001-02-03": time.Date(2001, 2, 3, 0, 0, 0, 0, time.UTC),
|
||||
"2001-02": time.Date(2001, 2, 1, 0, 0, 0, 0, time.UTC),
|
||||
"2001": time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC),
|
||||
"": time.Unix(0, 0),
|
||||
"1m": now.Add(1 * time.Minute),
|
||||
"+1m": now.Add(1 * time.Minute),
|
||||
"-1m": now.Add(-1 * time.Minute),
|
||||
"1h1m": now.Add(1*time.Hour + 1*time.Minute),
|
||||
"1d": now.Add(24 * time.Hour),
|
||||
"+1d": now.Add(24 * time.Hour),
|
||||
"-1d": now.Add(-24 * time.Hour),
|
||||
"1d1m": now.Add(24*time.Hour + 1*time.Minute),
|
||||
"+1d1m": now.Add(24*time.Hour + 1*time.Minute),
|
||||
"-1d1m": now.Add(-1*24*time.Hour + -1*1*time.Minute),
|
||||
"1y1mo1w1d1h": now.AddDate(1, 1, 7+1).Add(1 * time.Hour),
|
||||
}
|
||||
|
||||
for input, expect := range cases {
|
||||
input, expect := input, expect
|
||||
t.Run(input, func(t *testing.T) {
|
||||
p := &Period{}
|
||||
var got time.Time
|
||||
if _, err := p.setT(now, input, &got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Unix() != expect.Unix() {
|
||||
t.Errorf("expected %s but got %s", expect.String(), got.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
331
cmd/cli/main.go
331
cmd/cli/main.go
@@ -3,13 +3,17 @@ package cli
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/csv"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"maps"
|
||||
"math"
|
||||
"os"
|
||||
"os/signal"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
@@ -34,40 +38,43 @@ func Main() {
|
||||
fs.BoolVar(&config.Query.NoRounding, "no-rounding", false, "no rounding")
|
||||
fs.BoolVar(&config.Compact, "c", false, "reg entries oneline")
|
||||
fs.StringVar(&config.Query.With, "w", "", "regexp for transactions")
|
||||
fs.StringVar(&config.CSV, "csv", "", "if csv then the csv")
|
||||
fs.IntVar(&config.Query.Depth, "depth", 0, "depth grouping")
|
||||
fs.BoolVar(&config.Query.Normalize, "n", false, "normalize with default normalizer")
|
||||
fs.BoolVar(&config.Query.USDOnly, "usd", false, "filter to usd")
|
||||
fs.BoolVar(&config.Query.NoExchanging, "no-exchanging", true, "omit currency exchanges")
|
||||
fs.StringVar(&config.BPI, "bpi", "", "path to bpi")
|
||||
fs.StringVar(&config.CPI, "cpi", "", "path to cpi")
|
||||
fs.StringVar(&config.GroupDate, "group-date", "^....-..-..", "date grouping")
|
||||
fs.IntVar(&config.CPIYear, "cpiy", 0, "use cpi to convert usd to this year's value")
|
||||
fs.BoolVar(&config.NoPercent, "no-percent", false, "compute percent")
|
||||
if err := fs.Parse(os.Args[1:]); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
files := config.Files.Strings()
|
||||
if len(files) == 0 {
|
||||
panic("must specify at least one file")
|
||||
log.Fatalf("must specify at least one file")
|
||||
}
|
||||
ledgerFiles, err := ledger.NewFiles(files[0], files[1:]...)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
log.Fatalf("%v", err)
|
||||
}
|
||||
|
||||
positional := fs.Args()
|
||||
if len(positional) == 0 || len(positional[0]) < 3 {
|
||||
panic("positional arguments required, ie bal|reg PATTERN MATCHING")
|
||||
log.Fatalf("positional arguments required, ie bal|reg PATTERN MATCHING")
|
||||
}
|
||||
cmd := positional[0]
|
||||
|
||||
q, err := BuildQuery(config, positional[1:])
|
||||
if err != nil {
|
||||
panic(err)
|
||||
log.Fatalf("%v", err)
|
||||
}
|
||||
|
||||
deltas, err := ledgerFiles.Deltas()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
log.Fatalf("%v", err)
|
||||
}
|
||||
|
||||
if period := config.Query.Period; !period.Empty() {
|
||||
@@ -95,21 +102,29 @@ func Main() {
|
||||
if config.BPI != "" {
|
||||
b, err := ledger.NewBPIs(config.BPI)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
log.Fatalf("%v", err)
|
||||
}
|
||||
bpis = b
|
||||
if period := config.Query.Period; !period.Empty() {
|
||||
before := period.Stop.Format("2006-01-02")
|
||||
for _, timeToValue := range bpis {
|
||||
maps.DeleteFunc(timeToValue, func(k string, _ float64) bool {
|
||||
return k > before
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cpiNormalizer := ana.NewNormalizer()
|
||||
if config.CPI != "" && config.CPIYear > 0 {
|
||||
c, err := ledger.NewBPIs(config.CPI)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
log.Fatalf("%v", err)
|
||||
}
|
||||
cpi := c["CPI"]
|
||||
cpiy := cpi.Lookup(fmt.Sprintf("%d-06-01", config.CPIYear))
|
||||
if cpiy == nil {
|
||||
panic(fmt.Errorf("no cpi for year %d", config.CPIYear))
|
||||
log.Fatalf("no cpi for year %d", config.CPIYear)
|
||||
}
|
||||
|
||||
for date, value := range cpi {
|
||||
@@ -136,15 +151,39 @@ func Main() {
|
||||
}
|
||||
|
||||
switch cmd[:3] {
|
||||
case "bal":
|
||||
balances := deltas.Balances().
|
||||
case "bal": // balances
|
||||
balances := deltas.Group(ledger.GroupDate(config.GroupDate)).Balances().
|
||||
WithBPIs(bpis).
|
||||
KindaLike(q).
|
||||
KindaGroup(group).
|
||||
Nonzero().
|
||||
Normalize(cpiNormalizer, "9")
|
||||
FPrintBalances(w, "", balances, nil, config.Query.USDOnly, config.Query.Normalize, time.Now().Format("2006-01-02"), false, maxAccW)
|
||||
case "gra":
|
||||
|
||||
cumulatives := make(ledger.Balances)
|
||||
cumulativesFormat := "%s%.2f"
|
||||
if !config.NoPercent {
|
||||
var sum float64
|
||||
for key := range balances {
|
||||
if _, ok := cumulatives[key]; !ok {
|
||||
cumulatives[key] = make(ledger.Balance)
|
||||
}
|
||||
for currency, val := range balances[key] {
|
||||
if currency == ledger.USD {
|
||||
cumulatives[key][currency] = val
|
||||
sum += val
|
||||
} else {
|
||||
cumulatives[key][currency] = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
for key := range cumulatives {
|
||||
cumulatives[key][ledger.USD] = 100 * cumulatives[key][ledger.USD] / sum
|
||||
}
|
||||
cumulativesFormat = "%.0f%%"
|
||||
}
|
||||
|
||||
FPrintBalances(w, "", balances, cumulatives, config.Query.USDOnly, config.Query.Normalize, time.Now().Format("2006-01-02"), false, maxAccW, cumulativesFormat)
|
||||
case "gra": // graph
|
||||
dateGrouping := "^[0-9]{4}-[0-9]{2}"
|
||||
if period := config.Query.Period; !period.Empty() {
|
||||
day := time.Hour * 24
|
||||
@@ -225,7 +264,8 @@ func Main() {
|
||||
options = append(options, asciigraph.SeriesColors(seriesColors...))
|
||||
}
|
||||
fmt.Println(asciigraph.PlotMany(points, options...))
|
||||
case "rec":
|
||||
case "rec": // reconcile via teller // DEAD
|
||||
log.Fatalf("dead and bad")
|
||||
byDate := map[string]ledger.Deltas{}
|
||||
for _, delta := range deltas {
|
||||
delta := delta
|
||||
@@ -237,25 +277,28 @@ func Main() {
|
||||
|
||||
teller, err := teller.New()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
log.Fatalf("%v", err)
|
||||
}
|
||||
client := cache.New(teller)
|
||||
|
||||
accounts, err := client.Accounts(ctx)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
log.Fatalf("%v", err)
|
||||
}
|
||||
|
||||
inDay := func(date string, transaction bank.Transaction) bool {
|
||||
return slices.ContainsFunc(byDate[date], func(d ledger.Delta) bool {
|
||||
return d.Value == transaction.Amount
|
||||
v := fmt.Sprintf("%.2f", d.Value)
|
||||
nv := fmt.Sprintf("%.2f", -1.0*d.Value)
|
||||
a := fmt.Sprintf("%.2f", transaction.Amount)
|
||||
return v == a || nv == a
|
||||
})
|
||||
}
|
||||
|
||||
for _, acc := range accounts {
|
||||
transactions, err := client.Transactions(ctx, acc)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
log.Fatalf("%v", err)
|
||||
}
|
||||
|
||||
for _, transaction := range transactions {
|
||||
@@ -263,23 +306,27 @@ func Main() {
|
||||
continue
|
||||
}
|
||||
|
||||
err := "missing"
|
||||
msg := "missing"
|
||||
|
||||
t, _ := time.ParseInLocation(transaction.Date, "2006-01-02", time.Local)
|
||||
dayBefore := t.Add(-24 * time.Hour).Format("2006-01-02")
|
||||
dayAfter := t.Add(-24 * time.Hour).Format("2006-01-02")
|
||||
ts, err := time.ParseInLocation("2006-01-02", transaction.Date, time.Local)
|
||||
if err != nil {
|
||||
log.Fatalf("%v", err)
|
||||
}
|
||||
dayBefore := ts.Add(-24 * time.Hour).Format("2006-01-02")
|
||||
dayAfter := ts.Add(24 * time.Hour).Format("2006-01-02")
|
||||
if inDay(dayBefore, transaction) || inDay(dayAfter, transaction) {
|
||||
err = "1dayoff"
|
||||
msg = "1dayoff"
|
||||
}
|
||||
|
||||
prefix := " "
|
||||
if transaction.Status != "posted" {
|
||||
prefix = "! "
|
||||
}
|
||||
fmt.Printf("[%s] %s $%7.2f %s%s (%s)\n", err, transaction.Date, transaction.Amount, prefix, transaction.Details.CounterParty.Name, transaction.Description)
|
||||
fmt.Printf("[%s] %s $%7.2f %s%s (%s)\n", msg, transaction.Date, transaction.Amount, prefix, transaction.Details.CounterParty.Name, transaction.Description)
|
||||
}
|
||||
}
|
||||
case "reg":
|
||||
case "reg": // reg
|
||||
deltas = deltas.Group(ledger.GroupDate(config.GroupDate))
|
||||
transactions := deltas.Transactions()
|
||||
cumulative := make(ledger.Balances)
|
||||
for _, transaction := range transactions {
|
||||
@@ -297,24 +344,206 @@ func Main() {
|
||||
if shouldPrint {
|
||||
cumulative.PushAll(balances)
|
||||
cumulative = cumulative.Nonzero()
|
||||
FPrintBalancesFor(transaction[0].Description, w, "\t\t", balances, cumulative, config.Query.USDOnly, config.Query.Normalize, transaction[0].Date, config.Compact, maxAccW)
|
||||
FPrintBalancesFor(transaction[0].Description, w, "\t\t", balances, cumulative, config.Query.USDOnly, config.Query.Normalize, transaction[0].Date, config.Compact, maxAccW, "%s%.2f")
|
||||
}
|
||||
}
|
||||
case "csv": // reconcile with given csv
|
||||
deltas := deltas.Group(group)
|
||||
|
||||
if config.CSV == "" {
|
||||
log.Fatalf("missing required -csv")
|
||||
}
|
||||
f, err := os.Open(config.CSV)
|
||||
if err != nil {
|
||||
log.Fatalf("cannot open csv %q: %v", config.CSV, err)
|
||||
}
|
||||
defer f.Close()
|
||||
reader := csv.NewReader(f)
|
||||
fields, err := reader.Read()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
dateIdxs := []int{}
|
||||
for i := range fields {
|
||||
if strings.Contains(strings.ToLower(fields[i]), "date") {
|
||||
dateIdxs = append(dateIdxs, i)
|
||||
}
|
||||
}
|
||||
|
||||
descIdx := slices.IndexFunc(fields, func(field string) bool {
|
||||
return strings.Contains(strings.ToLower(field), "desc")
|
||||
})
|
||||
|
||||
amountIdx := slices.IndexFunc(fields, func(field string) bool {
|
||||
return strings.Contains(strings.ToLower(field), "amount")
|
||||
})
|
||||
|
||||
csvDeltas := make(ledger.Deltas, 0)
|
||||
for {
|
||||
line, err := reader.Read()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
log.Fatalf("failed to read csv line: %v", err)
|
||||
}
|
||||
|
||||
dates := []string{}
|
||||
for _, dateIdx := range dateIdxs {
|
||||
dates = append(dates, normalizeDate(line[dateIdx]))
|
||||
}
|
||||
dates = slices.Compact(dates)
|
||||
desc := line[descIdx]
|
||||
amount := line[amountIdx]
|
||||
|
||||
amountF, err := strconv.ParseFloat(amount, 32)
|
||||
if err != nil {
|
||||
log.Fatalf("non-float amount %q: %v", amount, err)
|
||||
}
|
||||
csvDeltas = append(csvDeltas, ledger.Delta{
|
||||
Date: dates[0],
|
||||
OtherDates: dates[1:],
|
||||
Name: desc,
|
||||
Value: amountF,
|
||||
Currency: ledger.USD,
|
||||
Description: desc,
|
||||
Payee: true,
|
||||
})
|
||||
}
|
||||
|
||||
matched := map[string]struct{}{}
|
||||
matchies := func(deltas ledger.Deltas, delta ledger.Delta) (ledger.Deltas, []string) {
|
||||
deltas = slices.Clone(deltas)
|
||||
dates := append([]string{delta.Date}, delta.OtherDates...)
|
||||
matches := deltas.Like(func(delta ledger.Delta) bool {
|
||||
return delta.Date >= slices.Min(dates)
|
||||
})
|
||||
matches = matches.Like(func(delta ledger.Delta) bool {
|
||||
return delta.Date <= slices.Max(dates)
|
||||
})
|
||||
matches = matches.Like(func(delta ledger.Delta) bool {
|
||||
return delta.Currency == ledger.USD
|
||||
})
|
||||
matches = matches.Like(func(d2 ledger.Delta) bool {
|
||||
return fmt.Sprintf("%.2f", d2.Value) == fmt.Sprintf("%.2f", delta.Value)
|
||||
})
|
||||
for _, match := range matches {
|
||||
if _, ok := matched[match.ID()]; !ok {
|
||||
matched[match.ID()] = struct{}{}
|
||||
break
|
||||
}
|
||||
}
|
||||
return matches, dates
|
||||
}
|
||||
|
||||
datesMatched := []string{}
|
||||
namesMatched := []string{}
|
||||
nonAssetNamesMatched := []string{}
|
||||
for _, csvDelta := range csvDeltas {
|
||||
matches, dates := matchies(deltas, csvDelta)
|
||||
if len(matches) == 0 {
|
||||
fmt.Printf("unique to csv | %s %s %.2f\n", strings.Join(dates, "="), csvDelta.Name, csvDelta.Value)
|
||||
}
|
||||
for _, match := range matches {
|
||||
datesMatched = append(datesMatched, match.Date)
|
||||
datesMatched = append(datesMatched, match.OtherDates...)
|
||||
namesMatched = append(namesMatched, match.Name)
|
||||
}
|
||||
}
|
||||
nonAssetNamesMatched = slices.DeleteFunc(slices.Clone(namesMatched), func(name string) bool {
|
||||
return strings.Contains(name, "Asset")
|
||||
})
|
||||
datesMatched = slices.DeleteFunc(datesMatched, func(a string) bool { return strings.TrimSpace(a) == "" })
|
||||
datesMatched = slices.Compact(datesMatched)
|
||||
namesMatched = slices.Compact(namesMatched)
|
||||
|
||||
transactions := deltas.Transactions()
|
||||
deltas = deltas.Like(func(delta ledger.Delta) bool {
|
||||
return delta.Date >= slices.Min(datesMatched)
|
||||
})
|
||||
deltas = deltas.Like(func(delta ledger.Delta) bool {
|
||||
return delta.Date <= slices.Max(datesMatched)
|
||||
})
|
||||
deltas = deltas.Like(func(delta ledger.Delta) bool {
|
||||
return slices.Contains(namesMatched, delta.Name)
|
||||
})
|
||||
deltas = deltas.Like(func(delta ledger.Delta) bool {
|
||||
xaction := transactions.Lookup(delta)
|
||||
if noXactionFound := len(xaction) == 0; noXactionFound {
|
||||
return false
|
||||
}
|
||||
|
||||
names := []string{}
|
||||
for _, delta := range xaction.Deltas() {
|
||||
names = append(names, delta.Name)
|
||||
}
|
||||
slices.Sort(names)
|
||||
_ = names
|
||||
|
||||
allTheSame := true
|
||||
for i := range xaction {
|
||||
for j := i + 1; j < len(xaction); j++ {
|
||||
allTheSame = allTheSame && (xaction[i].Name == xaction[j].Name)
|
||||
}
|
||||
}
|
||||
if allTheSame {
|
||||
return false
|
||||
}
|
||||
|
||||
if anyNameNotRelevant := slices.ContainsFunc(xaction.Deltas(), func(some ledger.Delta) bool {
|
||||
return !slices.Contains(namesMatched, delta.Name)
|
||||
}); anyNameNotRelevant {
|
||||
return false
|
||||
}
|
||||
|
||||
if hasNonAssetNameMatch := slices.ContainsFunc(xaction.Deltas(), func(some ledger.Delta) bool {
|
||||
return slices.Contains(nonAssetNamesMatched, delta.Name)
|
||||
}); !hasNonAssetNameMatch {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
deltasSum := deltas.Group(ledger.GroupDate(""), ledger.GroupName("")).Balances()[""][ledger.USD]
|
||||
csvSum := csvDeltas.Group(ledger.GroupDate(""), ledger.GroupName("")).Balances()[""][ledger.USD]
|
||||
if deltasSum != csvSum {
|
||||
log.Printf("csv sum %.2f but deltas sum %.2f", csvSum, deltasSum)
|
||||
}
|
||||
|
||||
for _, delta := range deltas {
|
||||
matches, dates := matchies(csvDeltas, delta)
|
||||
if len(matches) == 0 {
|
||||
fmt.Printf("unique to ledger | %s %s %.2f\n", strings.Join(dates, "="), delta.Description, delta.Value)
|
||||
}
|
||||
}
|
||||
|
||||
for _, delta := range deltas {
|
||||
if _, ok := matched[delta.ID()]; !ok {
|
||||
fmt.Printf("unmatched ledger | %s %s %.2f\n", delta.Date, delta.Description, delta.Value)
|
||||
}
|
||||
}
|
||||
for _, delta := range csvDeltas {
|
||||
if _, ok := matched[delta.ID()]; !ok {
|
||||
fmt.Printf("unmatched csv | %s %s %.2f\n", delta.Date, delta.Description, delta.Value)
|
||||
}
|
||||
}
|
||||
default:
|
||||
panic("unknown command " + positional[0])
|
||||
log.Fatalf("unknown command %q", positional[0])
|
||||
}
|
||||
}
|
||||
|
||||
func FPrintBalancesFor(description string, w io.Writer, linePrefix string, balances, cumulatives ledger.Balances, usdOnly, normalized bool, date string, compact bool, keyW int) {
|
||||
func FPrintBalancesFor(description string, w io.Writer, linePrefix string, balances, cumulatives ledger.Balances, usdOnly, normalized bool, date string, compact bool, keyW int, cumulativeFormat string) {
|
||||
if compact {
|
||||
FPrintBalances(w, date+"\t", balances, cumulatives, usdOnly, normalized, date, compact, keyW)
|
||||
FPrintBalances(w, date+"\t", balances, cumulatives, usdOnly, normalized, date, compact, keyW, cumulativeFormat)
|
||||
} else {
|
||||
fmt.Fprintf(w, "%s\t%s\n", date, description)
|
||||
FPrintBalances(w, linePrefix, balances, cumulatives, usdOnly, normalized, date, compact, keyW)
|
||||
FPrintBalances(w, linePrefix, balances, cumulatives, usdOnly, normalized, date, compact, keyW, cumulativeFormat)
|
||||
}
|
||||
}
|
||||
|
||||
func FPrintBalances(w io.Writer, linePrefix string, balances, cumulatives ledger.Balances, usdOnly, normalized bool, date string, fullKey bool, max int) {
|
||||
func FPrintBalances(w io.Writer, linePrefix string, balances, cumulatives ledger.Balances, usdOnly, normalized bool, date string, fullKey bool, max int, cumulativeFormat string) {
|
||||
maxes := map[ledger.Currency]float64{}
|
||||
keys := []string{}
|
||||
for k, v := range balances {
|
||||
@@ -329,9 +558,10 @@ func FPrintBalances(w io.Writer, linePrefix string, balances, cumulatives ledger
|
||||
|
||||
normalizer := ana.NewDefaultNormalizer()
|
||||
|
||||
format := fmt.Sprintf("%s%%-%ds\t%%s%%.2f (%%s%%.2f)\n", linePrefix, max)
|
||||
cumulativeFormat = strings.ReplaceAll(cumulativeFormat, "%", "%%")
|
||||
format := fmt.Sprintf("%s%%-%ds\t%%s%%.2f ("+cumulativeFormat+")\n", linePrefix, max)
|
||||
if normalized {
|
||||
format = fmt.Sprintf("%s%%-%ds\t%%s%%.2f (%%s%%.2f (%%.2f @%%.2f (%%s%%.0f)))\n", linePrefix, max)
|
||||
format = fmt.Sprintf("%s%%-%ds\t%%s%%.2f (%%s%%.2f (%%.2f @%%.2f ("+cumulativeFormat+")))\n", linePrefix, max)
|
||||
}
|
||||
for i, key := range keys {
|
||||
printableKey := key
|
||||
@@ -381,13 +611,40 @@ func FPrintBalances(w io.Writer, linePrefix string, balances, cumulatives ledger
|
||||
cumulative = value
|
||||
}
|
||||
|
||||
if !normalized {
|
||||
fmt.Fprintf(w, format, printableKey, printableCurrency, balances[key][currency], printableCurrency, cumulative)
|
||||
printingPercents := strings.Contains(cumulativeFormat, "%%%%")
|
||||
if !printingPercents {
|
||||
if !normalized {
|
||||
fmt.Fprintf(w, format,
|
||||
printableKey,
|
||||
printableCurrency, balances[key][currency],
|
||||
printableCurrency, cumulative,
|
||||
)
|
||||
} else {
|
||||
factor := normalizer.NormalizeFactor(key, date)
|
||||
trailingMax := maxes[currency] - math.Abs(balances[key][currency])
|
||||
fmt.Fprintf(w, format, printableKey, printableCurrency, balances[key][currency], printableCurrency, cumulative, cumulative*factor, factor, printableCurrency, factor*trailingMax)
|
||||
}
|
||||
} else {
|
||||
factor := normalizer.NormalizeFactor(key, date)
|
||||
trailingMax := maxes[currency] - math.Abs(balances[key][currency])
|
||||
fmt.Fprintf(w, format, printableKey, printableCurrency, balances[key][currency], printableCurrency, cumulative, cumulative*factor, factor, printableCurrency, factor*trailingMax)
|
||||
if !normalized {
|
||||
fmt.Fprintf(w, format, printableKey, printableCurrency, balances[key][currency], cumulative)
|
||||
} else {
|
||||
factor := normalizer.NormalizeFactor(key, date)
|
||||
trailingMax := maxes[currency] - math.Abs(balances[key][currency])
|
||||
fmt.Fprintf(w, format, printableKey, printableCurrency, balances[key][currency], printableCurrency, cumulative, cumulative*factor, factor, factor*trailingMax)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeDate(date string) string {
|
||||
for _, layout := range []string{
|
||||
"01/02/2006",
|
||||
} {
|
||||
if t, err := time.Parse(layout, date); err == nil {
|
||||
return t.Format("2006-01-02")
|
||||
}
|
||||
}
|
||||
log.Fatalf("cannot normalize date %q", date)
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
|
||||
<span>
|
||||
<label for="likeName">likeName</label>
|
||||
<input name="likeName" type="text" value="AssetAccount"/>
|
||||
<input name="likeName" type="text" value="Bel:AssetAccount"/>
|
||||
</span>
|
||||
|
||||
<span>
|
||||
@@ -72,7 +72,7 @@
|
||||
|
||||
<span>
|
||||
<label for="prediction">prediction</label>
|
||||
<input name="prediction" type="text" value="interest=AssetAccount:Cash \$ 0.02&prediction=contributions=AssetAccount:Bonds $ 1875&prediction=interest=AssetAccount:Monthly \$ 0.03&prediction=contributions=AssetAccount:Monthly $ 2500"/>
|
||||
<input name="prediction" type="text" value="interest=Bel:AssetAccount:Cash \$ 0.02&prediction=contributions=Bel:AssetAccount:Bonds $ 1916&prediction=interest=Bel:AssetAccount:Monthly \$ 0.03&prediction=contributions=Bel:AssetAccount:Monthly $ 3500"/>
|
||||
</span>
|
||||
|
||||
<span>
|
||||
@@ -82,7 +82,7 @@
|
||||
|
||||
<span>
|
||||
<label for="whatIf">whatIf</label>
|
||||
<input name="whatIf" type="text" value="AssetAccount:Cash $ -.10000"/>
|
||||
<input name="whatIf" type="text" value="Bel:AssetAccount:Cash $ -.10000"/>
|
||||
</span>
|
||||
|
||||
<span>
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
<li><a href="/api/trends">Where does the house money go?</a></li>
|
||||
<li><a href="/explore.html">Explore Bel's Money</a></li>
|
||||
<li><a href="/api/bal?x=y&mode=bal&likeName=AssetAccount&chart=stack&predictionMonths=120&bpi=true&zoomStart=YYYY-MM&prediction=interest=AssetAccount:Cash%20\$%200.02&prediction=contributions=AssetAccount:Bonds%20$%201875&prediction=interest=AssetAccount:Monthly%20\$%200.03&prediction=contributions=AssetAccount:Monthly%20$%202500&predictFixedGrowth=VBTLX=0.02&predictFixedGrowth=GLD=0.02&predictFixedGrowth=FXAIX=0.03&predictFixedGrowth=FSPSX=0.03&whatIf=AssetAccount:Cash%20$%20-.10000&=">Project Bel's Net Worth</a></li>
|
||||
<li><a href="/api/reg?x=y&mode=reg&likeName=Withdrawal:&chart=stack&predictionMonths=3&bpi=false&zoomStart=YYYY-MM&prediction=autoContributions=&predictFixedGrowth=VBTLX=0&whatIf=AssetAccount:Cash%20$%20-.10000&=">Expect Bel's Expenses</a></li>
|
||||
<!--<li><a href="/api/reg?x=y&mode=reg&likeName=Bel:Withdrawal:&chart=stack&predictionMonths=3&bpi=false&zoomStart=YYYY-MM&prediction=autoContributions=&predictFixedGrowth=VBTLX=0&whatIf=AssetAccount:Cash%20$%20-.10000&=">Expect Bel's Expenses</a></li>-->
|
||||
</ul>
|
||||
</body>
|
||||
<footer>
|
||||
|
||||
@@ -297,6 +297,11 @@ func (router Router) APIReg(w http.ResponseWriter, r *http.Request) {
|
||||
register := deltas.Register()
|
||||
predicted := make(ledger.Register)
|
||||
|
||||
bpis, err := router.bpis()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
if predictionMonths, err := strconv.ParseInt(r.URL.Query().Get("predictionMonths"), 10, 16); err == nil && predictionMonths > 0 {
|
||||
window := time.Hour * 24.0 * 365.0 / 12.0 * time.Duration(predictionMonths)
|
||||
// TODO whatif
|
||||
@@ -334,10 +339,6 @@ func (router Router) APIReg(w http.ResponseWriter, r *http.Request) {
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
bpis, err := router.bpis()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
bpis, err = ana.BPIsWithFixedGrowthPrediction(bpis, window, currency, rate)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
@@ -346,10 +347,6 @@ func (router Router) APIReg(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
if r.URL.Query().Get("bpi") == "true" {
|
||||
bpis, err := router.bpis()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
register = register.WithBPIs(bpis)
|
||||
predicted = predicted.WithBPIs(bpis)
|
||||
}
|
||||
|
||||
1
cmd/install_scratch.sh
Normal file
1
cmd/install_scratch.sh
Normal file
@@ -0,0 +1 @@
|
||||
CGO_ENABLED=1 CC=x86_64-linux-musl-gcc go build -ldflags="-linkmode external -extldflags -static" -o $HOME/Go/bin/ana-ledger
|
||||
26
cmd/main.go
26
cmd/main.go
@@ -1,14 +1,33 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
"gogs.inhome.blapointe.com/ana-ledger/cmd/cli"
|
||||
"gogs.inhome.blapointe.com/ana-ledger/cmd/http"
|
||||
"gogs.inhome.blapointe.com/ana-ledger/src/bank/teller"
|
||||
)
|
||||
|
||||
func main() {
|
||||
switch os.Args[1] {
|
||||
case "tel":
|
||||
ctx, can := signal.NotifyContext(context.Background(), syscall.SIGINT)
|
||||
defer can()
|
||||
|
||||
if c, err := teller.New(); err != nil {
|
||||
} else if _, err := c.Accounts(ctx); err != nil {
|
||||
} else {
|
||||
log.Println("teller already init")
|
||||
}
|
||||
|
||||
if err := teller.Init(ctx); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
case "http":
|
||||
os.Args = append([]string{os.Args[0]}, os.Args[2:]...)
|
||||
http.Main()
|
||||
@@ -19,13 +38,18 @@ func main() {
|
||||
files := os.Args[2:]
|
||||
os.Args = []string{os.Args[0], "cli"}
|
||||
for _, f := range files {
|
||||
os.Args = append(os.Args, "-f", f)
|
||||
if strings.HasPrefix(f, "-") {
|
||||
os.Args = append(os.Args, f)
|
||||
} else {
|
||||
os.Args = append(os.Args, "-f", f)
|
||||
}
|
||||
}
|
||||
os.Args = append(os.Args,
|
||||
"-w=^Housey",
|
||||
"--depth=1",
|
||||
"--usd",
|
||||
"-n",
|
||||
"--no-percent",
|
||||
"bal", "^Bel", "^Zach",
|
||||
)
|
||||
main()
|
||||
|
||||
1
go.mod
1
go.mod
@@ -8,6 +8,7 @@ require (
|
||||
github.com/go-echarts/go-echarts/v2 v2.3.1
|
||||
github.com/guptarohit/asciigraph v0.7.3
|
||||
golang.org/x/crypto v0.38.0
|
||||
golang.org/x/time v0.11.0
|
||||
)
|
||||
|
||||
require (
|
||||
|
||||
2
go.sum
2
go.sum
@@ -14,5 +14,7 @@ golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
||||
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg=
|
||||
golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ=
|
||||
golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0=
|
||||
golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
|
||||
gopkg.in/yaml.v3 v3.0.0 h1:hjy8E9ON/egN1tAYqKb61G10WtihqetD4sz2H+8nIeA=
|
||||
gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
@@ -19,11 +19,15 @@ type normalize struct {
|
||||
|
||||
func NewDefaultNormalizer() Normalizer {
|
||||
return NewNormalizer().
|
||||
With("^Zach", "2026-01-01", 156). // turtle up again!
|
||||
With("^Zach", "2025-09-01", 151). // turtle up
|
||||
With("^Zach", "2023-10-05", 139). // to turtle
|
||||
With("^Zach", "2021-12-30", 135). // at pluralsight
|
||||
With("^Zach", "2020-07-30", 120). // to pluralsight
|
||||
With("^Zach", "2019-07-16", 77). // at fedex
|
||||
With("^Zach", "2017-02-16", 49). // to fedex
|
||||
With("^Bel", "2025-10-01", 225). // render up
|
||||
With("^Bel", "2025-04-01", 214). // lc4 at render
|
||||
With("^Bel", "2023-12-05", 190). // to render
|
||||
With("^Bel", "2022-12-31", 154). // at q
|
||||
With("^Bel", "2022-06-30", 148). // at q
|
||||
|
||||
31
src/bank/cache/cache.go
vendored
31
src/bank/cache/cache.go
vendored
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path"
|
||||
"time"
|
||||
@@ -22,7 +23,11 @@ func New(client bank.Agg) Client {
|
||||
}
|
||||
|
||||
func (c Client) Accounts(ctx context.Context) ([]bank.Account, error) {
|
||||
if result := []bank.Account{}; fromCache("accounts", &result) == nil {
|
||||
k := "accounts"
|
||||
result := []bank.Account{}
|
||||
if err := fromCache(k, &result); err != nil {
|
||||
log.Printf("%q not in cache: %v", k, err)
|
||||
} else {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
@@ -31,13 +36,17 @@ func (c Client) Accounts(ctx context.Context) ([]bank.Account, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
toCache("accounts", result)
|
||||
toCache(k, result)
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (c Client) Transactions(ctx context.Context, a bank.Account) ([]bank.Transaction, error) {
|
||||
if result := []bank.Transaction{}; fromCache(path.Join("accounts", a.Account), &result) == nil {
|
||||
k := path.Join("accounts.d", a.Account)
|
||||
result := []bank.Transaction{}
|
||||
if err := fromCache(k, &result); err != nil {
|
||||
log.Printf("%q not in cache: %v", k, err)
|
||||
} else {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
@@ -46,7 +55,7 @@ func (c Client) Transactions(ctx context.Context, a bank.Account) ([]bank.Transa
|
||||
return nil, err
|
||||
}
|
||||
|
||||
toCache(path.Join("accounts", a.Account), result)
|
||||
toCache(k, result)
|
||||
|
||||
return result, nil
|
||||
}
|
||||
@@ -56,22 +65,32 @@ var (
|
||||
)
|
||||
|
||||
func toCache(k string, v interface{}) {
|
||||
if err := _toCache(k, v); err != nil {
|
||||
log.Printf("failed to cache %s: %v", k, err)
|
||||
}
|
||||
}
|
||||
|
||||
func _toCache(k string, v interface{}) error {
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
p := path.Join(d, k)
|
||||
os.MkdirAll(path.Dir(p), os.ModePerm)
|
||||
if err := os.WriteFile(p, b, os.ModePerm); err != nil {
|
||||
os.Remove(p)
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func fromCache(k string, ptr interface{}) error {
|
||||
p := path.Join(d, k)
|
||||
if stat, err := os.Stat(p); err != nil {
|
||||
return err
|
||||
} else if time.Since(stat.ModTime()) > time.Hour {
|
||||
} else if time.Since(stat.ModTime()) > 24*time.Hour {
|
||||
return fmt.Errorf("stale")
|
||||
}
|
||||
|
||||
|
||||
9
src/bank/cache/cache_integration_test.go
vendored
9
src/bank/cache/cache_integration_test.go
vendored
@@ -12,12 +12,11 @@ import (
|
||||
)
|
||||
|
||||
func Test(t *testing.T) {
|
||||
c, err := teller.New()
|
||||
tellerC, err := teller.New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
client := cache.New(c)
|
||||
client := cache.New(tellerC)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -25,7 +24,7 @@ func Test(t *testing.T) {
|
||||
i := i
|
||||
client := client
|
||||
t.Run(strconv.Itoa(i), func(t *testing.T) {
|
||||
accounts, err := c.Accounts(ctx)
|
||||
accounts, err := client.Accounts(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -33,7 +32,7 @@ func Test(t *testing.T) {
|
||||
for _, account := range accounts {
|
||||
account := account
|
||||
t.Run(account.Account, func(t *testing.T) {
|
||||
transactions, err := c.Transactions(ctx, account)
|
||||
transactions, err := client.Transactions(ctx, account)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
1
src/bank/teller/application_id.txt
Normal file
1
src/bank/teller/application_id.txt
Normal file
@@ -0,0 +1 @@
|
||||
app_pdvv33dtmta4fema66000
|
||||
64
src/bank/teller/init.go
Normal file
64
src/bank/teller/init.go
Normal file
@@ -0,0 +1,64 @@
|
||||
package teller
|
||||
|
||||
import (
|
||||
"context"
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"slices"
|
||||
"text/template"
|
||||
)
|
||||
|
||||
var (
|
||||
//go:embed application_id.txt
|
||||
applicationId string
|
||||
//go:embed init.html
|
||||
initHTML string
|
||||
)
|
||||
|
||||
func Init(ctx context.Context) error {
|
||||
environment := "development"
|
||||
if sandbox := !slices.Contains(os.Args, "forreal"); sandbox {
|
||||
environment = "sandbox"
|
||||
}
|
||||
fmt.Printf("environment=%q\n", environment)
|
||||
|
||||
newTokens := make(chan string)
|
||||
defer close(newTokens)
|
||||
s := &http.Server{
|
||||
Addr: ":20000",
|
||||
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
b, _ := io.ReadAll(r.Body)
|
||||
newTokens <- string(b)
|
||||
return
|
||||
}
|
||||
|
||||
t, err := template.New("initHTML").Parse(initHTML)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
if err := t.Execute(w, map[string]string{
|
||||
"applicationId": applicationId,
|
||||
"environment": environment,
|
||||
}); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}),
|
||||
}
|
||||
defer s.Close()
|
||||
go s.ListenAndServe()
|
||||
|
||||
fmt.Println("Open http://localhost:20000")
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
case newToken := <-newTokens:
|
||||
return fmt.Errorf("not impl: %q >> token.txt", newToken)
|
||||
}
|
||||
|
||||
return ctx.Err()
|
||||
}
|
||||
58
src/bank/teller/init.html
Normal file
58
src/bank/teller/init.html
Normal file
@@ -0,0 +1,58 @@
|
||||
<html>
|
||||
<head></head>
|
||||
<body>
|
||||
<button id="teller-connect">Connect to your bank</button>
|
||||
|
||||
<h3 id="log">
|
||||
</h3>
|
||||
|
||||
<script src="https://cdn.teller.io/connect/connect.js"></script>
|
||||
<script>
|
||||
function logme(msg) {
|
||||
document.getElementById("log").innerHTML += `<br>* ${msg}`
|
||||
}
|
||||
|
||||
function http(method, remote, callback, body) {
|
||||
var xmlhttp = new XMLHttpRequest();
|
||||
xmlhttp.onreadystatechange = function() {
|
||||
if (xmlhttp.readyState == XMLHttpRequest.DONE) {
|
||||
callback(xmlhttp.responseText, xmlhttp.status)
|
||||
}
|
||||
};
|
||||
xmlhttp.open(method, remote, true);
|
||||
if (typeof body == "undefined") {
|
||||
body = null
|
||||
}
|
||||
xmlhttp.send(body);
|
||||
}
|
||||
function callback(responseBody, responseStatus) {
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", function() {
|
||||
var tellerConnect = TellerConnect.setup({
|
||||
applicationId: "{{.applicationId}}",
|
||||
environment: "{{.environment}}",
|
||||
products: ["verify", "balance", "transactions"],
|
||||
onInit: function() {
|
||||
logme("Teller Connect has initialized")
|
||||
},
|
||||
onSuccess: function(enrollment) {
|
||||
logme(`User enrolled successfully: ${enrollment.accessToken}`)
|
||||
http("post", "/", callback, enrollment.accessToken)
|
||||
},
|
||||
onExit: function() {
|
||||
logme("User closed Teller Connect")
|
||||
},
|
||||
onFailure: function(failure) {
|
||||
logme(`Failed: type=${failure.type} code=${failure.code} message=${failure.message}`)
|
||||
},
|
||||
});
|
||||
|
||||
var el = document.getElementById("teller-connect");
|
||||
el.addEventListener("click", function() {
|
||||
tellerConnect.open();
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -5,10 +5,16 @@ import (
|
||||
"crypto/tls"
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gogs.inhome.blapointe.com/ana-ledger/src/bank"
|
||||
|
||||
"golang.org/x/time/rate"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
@@ -22,6 +28,8 @@ var (
|
||||
certificate []byte
|
||||
//go:embed private_key.pem
|
||||
privateKey []byte
|
||||
//go:embed token.txt
|
||||
Tokens string
|
||||
)
|
||||
|
||||
func New() (Client, error) {
|
||||
@@ -31,17 +39,33 @@ func New() (Client, error) {
|
||||
|
||||
func (c Client) Accounts(ctx context.Context) ([]bank.Account, error) {
|
||||
var result []bank.Account
|
||||
err := c.get(ctx, "https://api.teller.io/accounts", &result)
|
||||
return result, err
|
||||
for _, token := range strings.Fields(Tokens) {
|
||||
var more []bank.Account
|
||||
if err := c.get(ctx, "https://api.teller.io/accounts", token, &more); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range more {
|
||||
more[i].Token = token
|
||||
}
|
||||
result = append(result, more...)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (c Client) Transactions(ctx context.Context, a bank.Account) ([]bank.Transaction, error) {
|
||||
var result []bank.Transaction
|
||||
err := c.get(ctx, "https://api.teller.io/accounts/"+a.Account+"/transactions", &result)
|
||||
err := c.get(ctx, "https://api.teller.io/accounts/"+a.Account+"/transactions", a.Token, &result)
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (c Client) get(ctx context.Context, url string, ptr interface{}) error {
|
||||
var limiter = rate.NewLimiter(0.1, 1)
|
||||
|
||||
func (c Client) get(ctx context.Context, url, token string, ptr interface{}) error {
|
||||
if err := limiter.Wait(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.Printf("Teller.Get(%s, %s)", url, token)
|
||||
httpc := &http.Client{
|
||||
Timeout: time.Second,
|
||||
Transport: &http.Transport{
|
||||
@@ -54,7 +78,7 @@ func (c Client) get(ctx context.Context, url string, ptr interface{}) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.SetBasicAuth("test_token_bfu2cyvq3il6o", "") // TODO
|
||||
req.SetBasicAuth(token, "")
|
||||
req = req.WithContext(ctx)
|
||||
|
||||
resp, err := httpc.Do(req)
|
||||
@@ -63,5 +87,9 @@ func (c Client) get(ctx context.Context, url string, ptr interface{}) error {
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
return json.NewDecoder(resp.Body).Decode(ptr)
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
if err := json.Unmarshal(b, &ptr); err != nil {
|
||||
return fmt.Errorf("cannot unmarshal: %w: %s", err, b)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ import (
|
||||
)
|
||||
|
||||
func Test(t *testing.T) {
|
||||
teller.Tokens = "test_token_bfu2cyvq3il6o"
|
||||
|
||||
c, err := teller.New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -8,9 +8,13 @@ import (
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gogs.inhome.blapointe.com/ana-ledger/src/bank/teller"
|
||||
)
|
||||
|
||||
func TestIntegration(t *testing.T) {
|
||||
teller.Tokens = "test_token_bfu2cyvq3il6o"
|
||||
|
||||
//curl --cert certificate.pem --cert-key private_key.pem --auth test_token_bfu2cyvq3il6o: https://api.teller.io/accounts
|
||||
cert, err := tls.LoadX509KeyPair("./certificate.pem", "./private_key.pem")
|
||||
if err != nil {
|
||||
|
||||
2
src/bank/teller/token.txt
Normal file
2
src/bank/teller/token.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
token_2utqstwpn3pxwgvyno56hqdehq
|
||||
token_vr6dnzvfv7c24wuxtmnnzyiqbm
|
||||
@@ -13,6 +13,7 @@ type Account struct {
|
||||
} `json:"institution"`
|
||||
Name string `json:"last_four"`
|
||||
Account string `json:"id"`
|
||||
Token string `json:"__token"`
|
||||
}
|
||||
|
||||
type Transaction struct {
|
||||
|
||||
@@ -10,6 +10,7 @@ const (
|
||||
|
||||
type Delta struct {
|
||||
Date string
|
||||
OtherDates []string
|
||||
Name string
|
||||
Value float64
|
||||
Currency Currency
|
||||
@@ -23,9 +24,10 @@ type Delta struct {
|
||||
with []Delta
|
||||
}
|
||||
|
||||
func newDelta(transaction string, payee bool, d, desc, name string, v float64, c string, isSet bool, fileName string, lineNo int) Delta {
|
||||
func newDelta(transaction string, payee bool, d, desc, name string, v float64, c string, isSet bool, fileName string, lineNo int, otherDates []string) Delta {
|
||||
return Delta{
|
||||
Date: d,
|
||||
OtherDates: otherDates,
|
||||
Name: name,
|
||||
Value: v,
|
||||
Currency: Currency(c),
|
||||
@@ -39,6 +41,10 @@ func newDelta(transaction string, payee bool, d, desc, name string, v float64, c
|
||||
}
|
||||
}
|
||||
|
||||
func (delta Delta) ID() string {
|
||||
return fmt.Sprintf("%.2f", delta.Value)
|
||||
}
|
||||
|
||||
func (delta Delta) withWith(other Delta) Delta {
|
||||
other.with = nil
|
||||
delta.with = append(delta.with, other)
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
|
||||
func TestDelta(t *testing.T) {
|
||||
d := "2099-08-07"
|
||||
delta := newDelta("x", true, d, "", "name", 34.56, "$", false, "", 0)
|
||||
delta := newDelta("x", true, d, "", "name", 34.56, "$", false, "", 0, []string{"d2"})
|
||||
|
||||
if delta.Transaction != "x" {
|
||||
t.Error(delta.Transaction)
|
||||
@@ -17,6 +17,9 @@ func TestDelta(t *testing.T) {
|
||||
if delta.Date != d {
|
||||
t.Error(delta.Date)
|
||||
}
|
||||
if delta.OtherDates[0] != "d2" {
|
||||
t.Error(delta.OtherDates)
|
||||
}
|
||||
if delta.Name != "name" {
|
||||
t.Error(delta.Name)
|
||||
}
|
||||
|
||||
@@ -99,8 +99,12 @@ func (files Files) add(payee string, delta Delta) error {
|
||||
if delta.Currency != USD {
|
||||
currencyValue = fmt.Sprintf("%.2f %s", delta.Value, delta.Currency)
|
||||
}
|
||||
date := delta.Date
|
||||
for _, otherDate := range delta.OtherDates {
|
||||
date += "=" + otherDate
|
||||
}
|
||||
return files.append(fmt.Sprintf("%s %s\n%s%s%s%s\n%s%s",
|
||||
delta.Date, delta.Description,
|
||||
date, delta.Description,
|
||||
filesAppendDelim, delta.Name, filesAppendDelim+filesAppendDelim+filesAppendDelim, currencyValue,
|
||||
filesAppendDelim, payee,
|
||||
))
|
||||
|
||||
@@ -73,12 +73,13 @@ func TestFileAmend(t *testing.T) {
|
||||
},
|
||||
"payee": {
|
||||
from: `
|
||||
2006-01-02 description
|
||||
2006-01-02=2006-01-03 description
|
||||
recipient $3.45
|
||||
payee
|
||||
`,
|
||||
old: Delta{
|
||||
Date: "2006-01-02",
|
||||
OtherDates: []string{"2006-01-03"},
|
||||
Name: "payee",
|
||||
Value: -3.45,
|
||||
Currency: "$",
|
||||
@@ -86,16 +87,17 @@ func TestFileAmend(t *testing.T) {
|
||||
},
|
||||
now: Delta{
|
||||
Date: "2106-11-12",
|
||||
OtherDates: []string{"2006-01-03"},
|
||||
Name: "1payee",
|
||||
Value: -13.45,
|
||||
Currency: "T",
|
||||
Description: "1description",
|
||||
},
|
||||
want: `
|
||||
2006-01-02 description
|
||||
2006-01-02=2006-01-03 description
|
||||
payee $3.45
|
||||
recipient
|
||||
2106-11-12 1description
|
||||
2106-11-12=2006-01-03 1description
|
||||
1payee -13.45 T
|
||||
recipient`,
|
||||
},
|
||||
|
||||
@@ -16,8 +16,21 @@ import (
|
||||
|
||||
type Transaction Deltas
|
||||
|
||||
func (t Transaction) Deltas() Deltas {
|
||||
return Deltas(slices.Clone(t))
|
||||
}
|
||||
|
||||
type Transactions []Transaction
|
||||
|
||||
func (transactions Transactions) Lookup(delta Delta) Transaction {
|
||||
for i := range transactions {
|
||||
if transactions[i][0].Transaction == delta.Transaction {
|
||||
return slices.Clone(transactions[i])
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (transactions Transactions) Deltas() Deltas {
|
||||
result := make(Deltas, 0, len(transactions))
|
||||
for _, transaction := range transactions {
|
||||
@@ -106,6 +119,7 @@ func (transaction Transaction) Payee() string {
|
||||
|
||||
type transaction struct {
|
||||
date string
|
||||
otherDates []string
|
||||
description string
|
||||
payee string
|
||||
recipients []transactionRecipient
|
||||
@@ -142,6 +156,7 @@ func (t transaction) deltas() Deltas {
|
||||
recipient.isSet,
|
||||
t.fileName,
|
||||
t.lineNo+i,
|
||||
t.otherDates,
|
||||
))
|
||||
}
|
||||
for currency, value := range sums {
|
||||
@@ -162,6 +177,7 @@ func (t transaction) deltas() Deltas {
|
||||
false,
|
||||
t.fileName,
|
||||
t.lineNo,
|
||||
t.otherDates,
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -287,16 +303,17 @@ func _readTransaction(name string, r *bufio.Reader) (transaction, error) {
|
||||
return transaction{}, err
|
||||
}
|
||||
|
||||
dateDescriptionPattern := regexp.MustCompile(`^([0-9]+-[0-9]+-[0-9]+)\s+(.*)$`)
|
||||
dateDescriptionMatches := dateDescriptionPattern.FindAllSubmatch(firstLine, 4)
|
||||
dateDescriptionPattern := regexp.MustCompile(`^([0-9]+-[0-9]+-[0-9]+)((=[0-9]+-[0-9]+-[0-9]+)*)\s+(.*)$`)
|
||||
dateDescriptionMatches := dateDescriptionPattern.FindAllStringSubmatch(string(firstLine), 4)
|
||||
if len(dateDescriptionMatches) != 1 {
|
||||
return transaction{}, fmt.Errorf("bad first line: %v matches: %q", len(dateDescriptionMatches), firstLine)
|
||||
} else if len(dateDescriptionMatches[0]) != 3 {
|
||||
} else if len(dateDescriptionMatches[0]) != 5 {
|
||||
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]),
|
||||
date: dateDescriptionMatches[0][1],
|
||||
otherDates: strings.Split(strings.Trim(dateDescriptionMatches[0][2], "="), "="),
|
||||
description: dateDescriptionMatches[0][4],
|
||||
name: name,
|
||||
}
|
||||
|
||||
|
||||
@@ -58,12 +58,13 @@ func TestReadTransaction(t *testing.T) {
|
||||
},
|
||||
"verbose": {
|
||||
input: `
|
||||
2003-04-05 Reasoning here
|
||||
2003-04-05=2003-04-06=2003-04-07 Reasoning here
|
||||
A:B $1.00
|
||||
C:D $-1.00
|
||||
`,
|
||||
want: transaction{
|
||||
date: "2003-04-05",
|
||||
otherDates: []string{"2003-04-06", "2003-04-07"},
|
||||
description: "Reasoning here",
|
||||
payee: "A:B",
|
||||
recipients: []transactionRecipient{
|
||||
|
||||
27
vendor/golang.org/x/time/LICENSE
generated
vendored
Normal file
27
vendor/golang.org/x/time/LICENSE
generated
vendored
Normal file
@@ -0,0 +1,27 @@
|
||||
Copyright 2009 The Go Authors.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are
|
||||
met:
|
||||
|
||||
* Redistributions of source code must retain the above copyright
|
||||
notice, this list of conditions and the following disclaimer.
|
||||
* Redistributions in binary form must reproduce the above
|
||||
copyright notice, this list of conditions and the following disclaimer
|
||||
in the documentation and/or other materials provided with the
|
||||
distribution.
|
||||
* Neither the name of Google LLC nor the names of its
|
||||
contributors may be used to endorse or promote products derived from
|
||||
this software without specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
|
||||
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
|
||||
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
|
||||
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
|
||||
OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
|
||||
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
|
||||
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
|
||||
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
|
||||
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
22
vendor/golang.org/x/time/PATENTS
generated
vendored
Normal file
22
vendor/golang.org/x/time/PATENTS
generated
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
Additional IP Rights Grant (Patents)
|
||||
|
||||
"This implementation" means the copyrightable works distributed by
|
||||
Google as part of the Go project.
|
||||
|
||||
Google hereby grants to You a perpetual, worldwide, non-exclusive,
|
||||
no-charge, royalty-free, irrevocable (except as stated in this section)
|
||||
patent license to make, have made, use, offer to sell, sell, import,
|
||||
transfer and otherwise run, modify and propagate the contents of this
|
||||
implementation of Go, where such license applies only to those patent
|
||||
claims, both currently owned or controlled by Google and acquired in
|
||||
the future, licensable by Google that are necessarily infringed by this
|
||||
implementation of Go. This grant does not include claims that would be
|
||||
infringed only as a consequence of further modification of this
|
||||
implementation. If you or your agent or exclusive licensee institute or
|
||||
order or agree to the institution of patent litigation against any
|
||||
entity (including a cross-claim or counterclaim in a lawsuit) alleging
|
||||
that this implementation of Go or any code incorporated within this
|
||||
implementation of Go constitutes direct or contributory patent
|
||||
infringement, or inducement of patent infringement, then any patent
|
||||
rights granted to you under this License for this implementation of Go
|
||||
shall terminate as of the date such litigation is filed.
|
||||
427
vendor/golang.org/x/time/rate/rate.go
generated
vendored
Normal file
427
vendor/golang.org/x/time/rate/rate.go
generated
vendored
Normal file
@@ -0,0 +1,427 @@
|
||||
// Copyright 2015 The Go Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
// Package rate provides a rate limiter.
|
||||
package rate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Limit defines the maximum frequency of some events.
|
||||
// Limit is represented as number of events per second.
|
||||
// A zero Limit allows no events.
|
||||
type Limit float64
|
||||
|
||||
// Inf is the infinite rate limit; it allows all events (even if burst is zero).
|
||||
const Inf = Limit(math.MaxFloat64)
|
||||
|
||||
// Every converts a minimum time interval between events to a Limit.
|
||||
func Every(interval time.Duration) Limit {
|
||||
if interval <= 0 {
|
||||
return Inf
|
||||
}
|
||||
return 1 / Limit(interval.Seconds())
|
||||
}
|
||||
|
||||
// A Limiter controls how frequently events are allowed to happen.
|
||||
// It implements a "token bucket" of size b, initially full and refilled
|
||||
// at rate r tokens per second.
|
||||
// Informally, in any large enough time interval, the Limiter limits the
|
||||
// rate to r tokens per second, with a maximum burst size of b events.
|
||||
// As a special case, if r == Inf (the infinite rate), b is ignored.
|
||||
// See https://en.wikipedia.org/wiki/Token_bucket for more about token buckets.
|
||||
//
|
||||
// The zero value is a valid Limiter, but it will reject all events.
|
||||
// Use NewLimiter to create non-zero Limiters.
|
||||
//
|
||||
// Limiter has three main methods, Allow, Reserve, and Wait.
|
||||
// Most callers should use Wait.
|
||||
//
|
||||
// Each of the three methods consumes a single token.
|
||||
// They differ in their behavior when no token is available.
|
||||
// If no token is available, Allow returns false.
|
||||
// If no token is available, Reserve returns a reservation for a future token
|
||||
// and the amount of time the caller must wait before using it.
|
||||
// If no token is available, Wait blocks until one can be obtained
|
||||
// or its associated context.Context is canceled.
|
||||
//
|
||||
// The methods AllowN, ReserveN, and WaitN consume n tokens.
|
||||
//
|
||||
// Limiter is safe for simultaneous use by multiple goroutines.
|
||||
type Limiter struct {
|
||||
mu sync.Mutex
|
||||
limit Limit
|
||||
burst int
|
||||
tokens float64
|
||||
// last is the last time the limiter's tokens field was updated
|
||||
last time.Time
|
||||
// lastEvent is the latest time of a rate-limited event (past or future)
|
||||
lastEvent time.Time
|
||||
}
|
||||
|
||||
// Limit returns the maximum overall event rate.
|
||||
func (lim *Limiter) Limit() Limit {
|
||||
lim.mu.Lock()
|
||||
defer lim.mu.Unlock()
|
||||
return lim.limit
|
||||
}
|
||||
|
||||
// Burst returns the maximum burst size. Burst is the maximum number of tokens
|
||||
// that can be consumed in a single call to Allow, Reserve, or Wait, so higher
|
||||
// Burst values allow more events to happen at once.
|
||||
// A zero Burst allows no events, unless limit == Inf.
|
||||
func (lim *Limiter) Burst() int {
|
||||
lim.mu.Lock()
|
||||
defer lim.mu.Unlock()
|
||||
return lim.burst
|
||||
}
|
||||
|
||||
// TokensAt returns the number of tokens available at time t.
|
||||
func (lim *Limiter) TokensAt(t time.Time) float64 {
|
||||
lim.mu.Lock()
|
||||
tokens := lim.advance(t) // does not mutate lim
|
||||
lim.mu.Unlock()
|
||||
return tokens
|
||||
}
|
||||
|
||||
// Tokens returns the number of tokens available now.
|
||||
func (lim *Limiter) Tokens() float64 {
|
||||
return lim.TokensAt(time.Now())
|
||||
}
|
||||
|
||||
// NewLimiter returns a new Limiter that allows events up to rate r and permits
|
||||
// bursts of at most b tokens.
|
||||
func NewLimiter(r Limit, b int) *Limiter {
|
||||
return &Limiter{
|
||||
limit: r,
|
||||
burst: b,
|
||||
tokens: float64(b),
|
||||
}
|
||||
}
|
||||
|
||||
// Allow reports whether an event may happen now.
|
||||
func (lim *Limiter) Allow() bool {
|
||||
return lim.AllowN(time.Now(), 1)
|
||||
}
|
||||
|
||||
// AllowN reports whether n events may happen at time t.
|
||||
// Use this method if you intend to drop / skip events that exceed the rate limit.
|
||||
// Otherwise use Reserve or Wait.
|
||||
func (lim *Limiter) AllowN(t time.Time, n int) bool {
|
||||
return lim.reserveN(t, n, 0).ok
|
||||
}
|
||||
|
||||
// A Reservation holds information about events that are permitted by a Limiter to happen after a delay.
|
||||
// A Reservation may be canceled, which may enable the Limiter to permit additional events.
|
||||
type Reservation struct {
|
||||
ok bool
|
||||
lim *Limiter
|
||||
tokens int
|
||||
timeToAct time.Time
|
||||
// This is the Limit at reservation time, it can change later.
|
||||
limit Limit
|
||||
}
|
||||
|
||||
// OK returns whether the limiter can provide the requested number of tokens
|
||||
// within the maximum wait time. If OK is false, Delay returns InfDuration, and
|
||||
// Cancel does nothing.
|
||||
func (r *Reservation) OK() bool {
|
||||
return r.ok
|
||||
}
|
||||
|
||||
// Delay is shorthand for DelayFrom(time.Now()).
|
||||
func (r *Reservation) Delay() time.Duration {
|
||||
return r.DelayFrom(time.Now())
|
||||
}
|
||||
|
||||
// InfDuration is the duration returned by Delay when a Reservation is not OK.
|
||||
const InfDuration = time.Duration(math.MaxInt64)
|
||||
|
||||
// DelayFrom returns the duration for which the reservation holder must wait
|
||||
// before taking the reserved action. Zero duration means act immediately.
|
||||
// InfDuration means the limiter cannot grant the tokens requested in this
|
||||
// Reservation within the maximum wait time.
|
||||
func (r *Reservation) DelayFrom(t time.Time) time.Duration {
|
||||
if !r.ok {
|
||||
return InfDuration
|
||||
}
|
||||
delay := r.timeToAct.Sub(t)
|
||||
if delay < 0 {
|
||||
return 0
|
||||
}
|
||||
return delay
|
||||
}
|
||||
|
||||
// Cancel is shorthand for CancelAt(time.Now()).
|
||||
func (r *Reservation) Cancel() {
|
||||
r.CancelAt(time.Now())
|
||||
}
|
||||
|
||||
// CancelAt indicates that the reservation holder will not perform the reserved action
|
||||
// and reverses the effects of this Reservation on the rate limit as much as possible,
|
||||
// considering that other reservations may have already been made.
|
||||
func (r *Reservation) CancelAt(t time.Time) {
|
||||
if !r.ok {
|
||||
return
|
||||
}
|
||||
|
||||
r.lim.mu.Lock()
|
||||
defer r.lim.mu.Unlock()
|
||||
|
||||
if r.lim.limit == Inf || r.tokens == 0 || r.timeToAct.Before(t) {
|
||||
return
|
||||
}
|
||||
|
||||
// calculate tokens to restore
|
||||
// The duration between lim.lastEvent and r.timeToAct tells us how many tokens were reserved
|
||||
// after r was obtained. These tokens should not be restored.
|
||||
restoreTokens := float64(r.tokens) - r.limit.tokensFromDuration(r.lim.lastEvent.Sub(r.timeToAct))
|
||||
if restoreTokens <= 0 {
|
||||
return
|
||||
}
|
||||
// advance time to now
|
||||
tokens := r.lim.advance(t)
|
||||
// calculate new number of tokens
|
||||
tokens += restoreTokens
|
||||
if burst := float64(r.lim.burst); tokens > burst {
|
||||
tokens = burst
|
||||
}
|
||||
// update state
|
||||
r.lim.last = t
|
||||
r.lim.tokens = tokens
|
||||
if r.timeToAct == r.lim.lastEvent {
|
||||
prevEvent := r.timeToAct.Add(r.limit.durationFromTokens(float64(-r.tokens)))
|
||||
if !prevEvent.Before(t) {
|
||||
r.lim.lastEvent = prevEvent
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reserve is shorthand for ReserveN(time.Now(), 1).
|
||||
func (lim *Limiter) Reserve() *Reservation {
|
||||
return lim.ReserveN(time.Now(), 1)
|
||||
}
|
||||
|
||||
// ReserveN returns a Reservation that indicates how long the caller must wait before n events happen.
|
||||
// The Limiter takes this Reservation into account when allowing future events.
|
||||
// The returned Reservation’s OK() method returns false if n exceeds the Limiter's burst size.
|
||||
// Usage example:
|
||||
//
|
||||
// r := lim.ReserveN(time.Now(), 1)
|
||||
// if !r.OK() {
|
||||
// // Not allowed to act! Did you remember to set lim.burst to be > 0 ?
|
||||
// return
|
||||
// }
|
||||
// time.Sleep(r.Delay())
|
||||
// Act()
|
||||
//
|
||||
// Use this method if you wish to wait and slow down in accordance with the rate limit without dropping events.
|
||||
// If you need to respect a deadline or cancel the delay, use Wait instead.
|
||||
// To drop or skip events exceeding rate limit, use Allow instead.
|
||||
func (lim *Limiter) ReserveN(t time.Time, n int) *Reservation {
|
||||
r := lim.reserveN(t, n, InfDuration)
|
||||
return &r
|
||||
}
|
||||
|
||||
// Wait is shorthand for WaitN(ctx, 1).
|
||||
func (lim *Limiter) Wait(ctx context.Context) (err error) {
|
||||
return lim.WaitN(ctx, 1)
|
||||
}
|
||||
|
||||
// WaitN blocks until lim permits n events to happen.
|
||||
// It returns an error if n exceeds the Limiter's burst size, the Context is
|
||||
// canceled, or the expected wait time exceeds the Context's Deadline.
|
||||
// The burst limit is ignored if the rate limit is Inf.
|
||||
func (lim *Limiter) WaitN(ctx context.Context, n int) (err error) {
|
||||
// The test code calls lim.wait with a fake timer generator.
|
||||
// This is the real timer generator.
|
||||
newTimer := func(d time.Duration) (<-chan time.Time, func() bool, func()) {
|
||||
timer := time.NewTimer(d)
|
||||
return timer.C, timer.Stop, func() {}
|
||||
}
|
||||
|
||||
return lim.wait(ctx, n, time.Now(), newTimer)
|
||||
}
|
||||
|
||||
// wait is the internal implementation of WaitN.
|
||||
func (lim *Limiter) wait(ctx context.Context, n int, t time.Time, newTimer func(d time.Duration) (<-chan time.Time, func() bool, func())) error {
|
||||
lim.mu.Lock()
|
||||
burst := lim.burst
|
||||
limit := lim.limit
|
||||
lim.mu.Unlock()
|
||||
|
||||
if n > burst && limit != Inf {
|
||||
return fmt.Errorf("rate: Wait(n=%d) exceeds limiter's burst %d", n, burst)
|
||||
}
|
||||
// Check if ctx is already cancelled
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
}
|
||||
// Determine wait limit
|
||||
waitLimit := InfDuration
|
||||
if deadline, ok := ctx.Deadline(); ok {
|
||||
waitLimit = deadline.Sub(t)
|
||||
}
|
||||
// Reserve
|
||||
r := lim.reserveN(t, n, waitLimit)
|
||||
if !r.ok {
|
||||
return fmt.Errorf("rate: Wait(n=%d) would exceed context deadline", n)
|
||||
}
|
||||
// Wait if necessary
|
||||
delay := r.DelayFrom(t)
|
||||
if delay == 0 {
|
||||
return nil
|
||||
}
|
||||
ch, stop, advance := newTimer(delay)
|
||||
defer stop()
|
||||
advance() // only has an effect when testing
|
||||
select {
|
||||
case <-ch:
|
||||
// We can proceed.
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
// Context was canceled before we could proceed. Cancel the
|
||||
// reservation, which may permit other events to proceed sooner.
|
||||
r.Cancel()
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
// SetLimit is shorthand for SetLimitAt(time.Now(), newLimit).
|
||||
func (lim *Limiter) SetLimit(newLimit Limit) {
|
||||
lim.SetLimitAt(time.Now(), newLimit)
|
||||
}
|
||||
|
||||
// SetLimitAt sets a new Limit for the limiter. The new Limit, and Burst, may be violated
|
||||
// or underutilized by those which reserved (using Reserve or Wait) but did not yet act
|
||||
// before SetLimitAt was called.
|
||||
func (lim *Limiter) SetLimitAt(t time.Time, newLimit Limit) {
|
||||
lim.mu.Lock()
|
||||
defer lim.mu.Unlock()
|
||||
|
||||
tokens := lim.advance(t)
|
||||
|
||||
lim.last = t
|
||||
lim.tokens = tokens
|
||||
lim.limit = newLimit
|
||||
}
|
||||
|
||||
// SetBurst is shorthand for SetBurstAt(time.Now(), newBurst).
|
||||
func (lim *Limiter) SetBurst(newBurst int) {
|
||||
lim.SetBurstAt(time.Now(), newBurst)
|
||||
}
|
||||
|
||||
// SetBurstAt sets a new burst size for the limiter.
|
||||
func (lim *Limiter) SetBurstAt(t time.Time, newBurst int) {
|
||||
lim.mu.Lock()
|
||||
defer lim.mu.Unlock()
|
||||
|
||||
tokens := lim.advance(t)
|
||||
|
||||
lim.last = t
|
||||
lim.tokens = tokens
|
||||
lim.burst = newBurst
|
||||
}
|
||||
|
||||
// reserveN is a helper method for AllowN, ReserveN, and WaitN.
|
||||
// maxFutureReserve specifies the maximum reservation wait duration allowed.
|
||||
// reserveN returns Reservation, not *Reservation, to avoid allocation in AllowN and WaitN.
|
||||
func (lim *Limiter) reserveN(t time.Time, n int, maxFutureReserve time.Duration) Reservation {
|
||||
lim.mu.Lock()
|
||||
defer lim.mu.Unlock()
|
||||
|
||||
if lim.limit == Inf {
|
||||
return Reservation{
|
||||
ok: true,
|
||||
lim: lim,
|
||||
tokens: n,
|
||||
timeToAct: t,
|
||||
}
|
||||
}
|
||||
|
||||
tokens := lim.advance(t)
|
||||
|
||||
// Calculate the remaining number of tokens resulting from the request.
|
||||
tokens -= float64(n)
|
||||
|
||||
// Calculate the wait duration
|
||||
var waitDuration time.Duration
|
||||
if tokens < 0 {
|
||||
waitDuration = lim.limit.durationFromTokens(-tokens)
|
||||
}
|
||||
|
||||
// Decide result
|
||||
ok := n <= lim.burst && waitDuration <= maxFutureReserve
|
||||
|
||||
// Prepare reservation
|
||||
r := Reservation{
|
||||
ok: ok,
|
||||
lim: lim,
|
||||
limit: lim.limit,
|
||||
}
|
||||
if ok {
|
||||
r.tokens = n
|
||||
r.timeToAct = t.Add(waitDuration)
|
||||
|
||||
// Update state
|
||||
lim.last = t
|
||||
lim.tokens = tokens
|
||||
lim.lastEvent = r.timeToAct
|
||||
}
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
// advance calculates and returns an updated number of tokens for lim
|
||||
// resulting from the passage of time.
|
||||
// lim is not changed.
|
||||
// advance requires that lim.mu is held.
|
||||
func (lim *Limiter) advance(t time.Time) (newTokens float64) {
|
||||
last := lim.last
|
||||
if t.Before(last) {
|
||||
last = t
|
||||
}
|
||||
|
||||
// Calculate the new number of tokens, due to time that passed.
|
||||
elapsed := t.Sub(last)
|
||||
delta := lim.limit.tokensFromDuration(elapsed)
|
||||
tokens := lim.tokens + delta
|
||||
if burst := float64(lim.burst); tokens > burst {
|
||||
tokens = burst
|
||||
}
|
||||
return tokens
|
||||
}
|
||||
|
||||
// durationFromTokens is a unit conversion function from the number of tokens to the duration
|
||||
// of time it takes to accumulate them at a rate of limit tokens per second.
|
||||
func (limit Limit) durationFromTokens(tokens float64) time.Duration {
|
||||
if limit <= 0 {
|
||||
return InfDuration
|
||||
}
|
||||
|
||||
duration := (tokens / float64(limit)) * float64(time.Second)
|
||||
|
||||
// Cap the duration to the maximum representable int64 value, to avoid overflow.
|
||||
if duration > float64(math.MaxInt64) {
|
||||
return InfDuration
|
||||
}
|
||||
|
||||
return time.Duration(duration)
|
||||
}
|
||||
|
||||
// tokensFromDuration is a unit conversion function from a time duration to the number of tokens
|
||||
// which could be accumulated during that duration at a rate of limit tokens per second.
|
||||
func (limit Limit) tokensFromDuration(d time.Duration) float64 {
|
||||
if limit <= 0 {
|
||||
return 0
|
||||
}
|
||||
return d.Seconds() * float64(limit)
|
||||
}
|
||||
67
vendor/golang.org/x/time/rate/sometimes.go
generated
vendored
Normal file
67
vendor/golang.org/x/time/rate/sometimes.go
generated
vendored
Normal file
@@ -0,0 +1,67 @@
|
||||
// Copyright 2022 The Go Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package rate
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Sometimes will perform an action occasionally. The First, Every, and
|
||||
// Interval fields govern the behavior of Do, which performs the action.
|
||||
// A zero Sometimes value will perform an action exactly once.
|
||||
//
|
||||
// # Example: logging with rate limiting
|
||||
//
|
||||
// var sometimes = rate.Sometimes{First: 3, Interval: 10*time.Second}
|
||||
// func Spammy() {
|
||||
// sometimes.Do(func() { log.Info("here I am!") })
|
||||
// }
|
||||
type Sometimes struct {
|
||||
First int // if non-zero, the first N calls to Do will run f.
|
||||
Every int // if non-zero, every Nth call to Do will run f.
|
||||
Interval time.Duration // if non-zero and Interval has elapsed since f's last run, Do will run f.
|
||||
|
||||
mu sync.Mutex
|
||||
count int // number of Do calls
|
||||
last time.Time // last time f was run
|
||||
}
|
||||
|
||||
// Do runs the function f as allowed by First, Every, and Interval.
|
||||
//
|
||||
// The model is a union (not intersection) of filters. The first call to Do
|
||||
// always runs f. Subsequent calls to Do run f if allowed by First or Every or
|
||||
// Interval.
|
||||
//
|
||||
// A non-zero First:N causes the first N Do(f) calls to run f.
|
||||
//
|
||||
// A non-zero Every:M causes every Mth Do(f) call, starting with the first, to
|
||||
// run f.
|
||||
//
|
||||
// A non-zero Interval causes Do(f) to run f if Interval has elapsed since
|
||||
// Do last ran f.
|
||||
//
|
||||
// Specifying multiple filters produces the union of these execution streams.
|
||||
// For example, specifying both First:N and Every:M causes the first N Do(f)
|
||||
// calls and every Mth Do(f) call, starting with the first, to run f. See
|
||||
// Examples for more.
|
||||
//
|
||||
// If Do is called multiple times simultaneously, the calls will block and run
|
||||
// serially. Therefore, Do is intended for lightweight operations.
|
||||
//
|
||||
// Because a call to Do may block until f returns, if f causes Do to be called,
|
||||
// it will deadlock.
|
||||
func (s *Sometimes) Do(f func()) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.count == 0 ||
|
||||
(s.First > 0 && s.count < s.First) ||
|
||||
(s.Every > 0 && s.count%s.Every == 0) ||
|
||||
(s.Interval > 0 && time.Since(s.last) >= s.Interval) {
|
||||
f()
|
||||
s.last = time.Now()
|
||||
}
|
||||
s.count++
|
||||
}
|
||||
3
vendor/modules.txt
vendored
3
vendor/modules.txt
vendored
@@ -21,3 +21,6 @@ golang.org/x/sys/windows
|
||||
# golang.org/x/term v0.32.0
|
||||
## explicit; go 1.23.0
|
||||
golang.org/x/term
|
||||
# golang.org/x/time v0.11.0
|
||||
## explicit; go 1.23.0
|
||||
golang.org/x/time/rate
|
||||
|
||||
Reference in New Issue
Block a user