Compare commits
49 Commits
daac3907f4
...
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 | ||
|
|
7a946b7604 | ||
|
|
e0fa44eef7 | ||
|
|
6440b07d14 | ||
|
|
bae33f8c60 | ||
|
|
daa446cc02 |
@@ -6,6 +6,8 @@ on:
|
|||||||
paths:
|
paths:
|
||||||
- 'cmd/**'
|
- 'cmd/**'
|
||||||
- 'src/**'
|
- 'src/**'
|
||||||
|
- 'build/**'
|
||||||
|
- '.gitea/**'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
ci:
|
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
|
COPY ./ /go/src/ana-ledger
|
||||||
WORKDIR /go/src/ana-ledger
|
WORKDIR /go/src/ana-ledger
|
||||||
|
|||||||
@@ -15,5 +15,8 @@ type Config struct {
|
|||||||
Normalize bool
|
Normalize bool
|
||||||
USDOnly bool
|
USDOnly bool
|
||||||
}
|
}
|
||||||
Compact bool
|
Compact bool
|
||||||
|
GroupDate string
|
||||||
|
NoPercent bool
|
||||||
|
CSV string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package cli
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -63,17 +65,71 @@ func (period *Period) Set(s string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (period *Period) setStartStop(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
|
period.Stop = stop
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (period *Period) setStop(s string) error {
|
func (period *Period) setStop(s string) error {
|
||||||
_, err := period.setT(s, &period.Stop)
|
_, err := period.setT(time.Now(), s, &period.Stop)
|
||||||
return err
|
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 {
|
if result, err := time.Parse("2006", s); err == nil {
|
||||||
*t = result
|
*t = result
|
||||||
return result.AddDate(1, 0, 0).Add(-1 * time.Minute), nil
|
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())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
369
cmd/cli/main.go
369
cmd/cli/main.go
@@ -2,16 +2,26 @@ package cli
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"encoding/csv"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"log"
|
||||||
|
"maps"
|
||||||
"math"
|
"math"
|
||||||
"os"
|
"os"
|
||||||
|
"os/signal"
|
||||||
"slices"
|
"slices"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gogs.inhome.blapointe.com/ana-ledger/src/ana"
|
"gogs.inhome.blapointe.com/ana-ledger/src/ana"
|
||||||
|
"gogs.inhome.blapointe.com/ana-ledger/src/bank"
|
||||||
|
"gogs.inhome.blapointe.com/ana-ledger/src/bank/cache"
|
||||||
|
"gogs.inhome.blapointe.com/ana-ledger/src/bank/teller"
|
||||||
"gogs.inhome.blapointe.com/ana-ledger/src/ledger"
|
"gogs.inhome.blapointe.com/ana-ledger/src/ledger"
|
||||||
|
|
||||||
"github.com/guptarohit/asciigraph"
|
"github.com/guptarohit/asciigraph"
|
||||||
@@ -28,40 +38,43 @@ func Main() {
|
|||||||
fs.BoolVar(&config.Query.NoRounding, "no-rounding", false, "no rounding")
|
fs.BoolVar(&config.Query.NoRounding, "no-rounding", false, "no rounding")
|
||||||
fs.BoolVar(&config.Compact, "c", false, "reg entries oneline")
|
fs.BoolVar(&config.Compact, "c", false, "reg entries oneline")
|
||||||
fs.StringVar(&config.Query.With, "w", "", "regexp for transactions")
|
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.IntVar(&config.Query.Depth, "depth", 0, "depth grouping")
|
||||||
fs.BoolVar(&config.Query.Normalize, "n", false, "normalize with default normalizer")
|
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.USDOnly, "usd", false, "filter to usd")
|
||||||
fs.BoolVar(&config.Query.NoExchanging, "no-exchanging", true, "omit currency exchanges")
|
fs.BoolVar(&config.Query.NoExchanging, "no-exchanging", true, "omit currency exchanges")
|
||||||
fs.StringVar(&config.BPI, "bpi", "", "path to bpi")
|
fs.StringVar(&config.BPI, "bpi", "", "path to bpi")
|
||||||
fs.StringVar(&config.CPI, "cpi", "", "path to cpi")
|
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.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 {
|
if err := fs.Parse(os.Args[1:]); err != nil {
|
||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
files := config.Files.Strings()
|
files := config.Files.Strings()
|
||||||
if len(files) == 0 {
|
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:]...)
|
ledgerFiles, err := ledger.NewFiles(files[0], files[1:]...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
log.Fatalf("%v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
positional := fs.Args()
|
positional := fs.Args()
|
||||||
if len(positional) == 0 || len(positional[0]) < 3 {
|
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]
|
cmd := positional[0]
|
||||||
|
|
||||||
q, err := BuildQuery(config, positional[1:])
|
q, err := BuildQuery(config, positional[1:])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
log.Fatalf("%v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
deltas, err := ledgerFiles.Deltas()
|
deltas, err := ledgerFiles.Deltas()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
log.Fatalf("%v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if period := config.Query.Period; !period.Empty() {
|
if period := config.Query.Period; !period.Empty() {
|
||||||
@@ -89,21 +102,29 @@ func Main() {
|
|||||||
if config.BPI != "" {
|
if config.BPI != "" {
|
||||||
b, err := ledger.NewBPIs(config.BPI)
|
b, err := ledger.NewBPIs(config.BPI)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
log.Fatalf("%v", err)
|
||||||
}
|
}
|
||||||
bpis = b
|
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()
|
cpiNormalizer := ana.NewNormalizer()
|
||||||
if config.CPI != "" && config.CPIYear > 0 {
|
if config.CPI != "" && config.CPIYear > 0 {
|
||||||
c, err := ledger.NewBPIs(config.CPI)
|
c, err := ledger.NewBPIs(config.CPI)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
log.Fatalf("%v", err)
|
||||||
}
|
}
|
||||||
cpi := c["CPI"]
|
cpi := c["CPI"]
|
||||||
cpiy := cpi.Lookup(fmt.Sprintf("%d-06-01", config.CPIYear))
|
cpiy := cpi.Lookup(fmt.Sprintf("%d-06-01", config.CPIYear))
|
||||||
if cpiy == nil {
|
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 {
|
for date, value := range cpi {
|
||||||
@@ -130,15 +151,39 @@ func Main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
switch cmd[:3] {
|
switch cmd[:3] {
|
||||||
case "bal":
|
case "bal": // balances
|
||||||
balances := deltas.Balances().
|
balances := deltas.Group(ledger.GroupDate(config.GroupDate)).Balances().
|
||||||
WithBPIs(bpis).
|
WithBPIs(bpis).
|
||||||
KindaLike(q).
|
KindaLike(q).
|
||||||
KindaGroup(group).
|
KindaGroup(group).
|
||||||
Nonzero().
|
Nonzero().
|
||||||
Normalize(cpiNormalizer, "9")
|
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}"
|
dateGrouping := "^[0-9]{4}-[0-9]{2}"
|
||||||
if period := config.Query.Period; !period.Empty() {
|
if period := config.Query.Period; !period.Empty() {
|
||||||
day := time.Hour * 24
|
day := time.Hour * 24
|
||||||
@@ -219,7 +264,69 @@ func Main() {
|
|||||||
options = append(options, asciigraph.SeriesColors(seriesColors...))
|
options = append(options, asciigraph.SeriesColors(seriesColors...))
|
||||||
}
|
}
|
||||||
fmt.Println(asciigraph.PlotMany(points, options...))
|
fmt.Println(asciigraph.PlotMany(points, options...))
|
||||||
case "reg":
|
case "rec": // reconcile via teller // DEAD
|
||||||
|
log.Fatalf("dead and bad")
|
||||||
|
byDate := map[string]ledger.Deltas{}
|
||||||
|
for _, delta := range deltas {
|
||||||
|
delta := delta
|
||||||
|
byDate[delta.Date] = append(byDate[delta.Date], delta)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, can := signal.NotifyContext(context.Background(), syscall.SIGINT)
|
||||||
|
defer can()
|
||||||
|
|
||||||
|
teller, err := teller.New()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("%v", err)
|
||||||
|
}
|
||||||
|
client := cache.New(teller)
|
||||||
|
|
||||||
|
accounts, err := client.Accounts(ctx)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("%v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
inDay := func(date string, transaction bank.Transaction) bool {
|
||||||
|
return slices.ContainsFunc(byDate[date], func(d ledger.Delta) bool {
|
||||||
|
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 {
|
||||||
|
log.Fatalf("%v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, transaction := range transactions {
|
||||||
|
if inDay(transaction.Date, transaction) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := "missing"
|
||||||
|
|
||||||
|
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) {
|
||||||
|
msg = "1dayoff"
|
||||||
|
}
|
||||||
|
|
||||||
|
prefix := " "
|
||||||
|
if transaction.Status != "posted" {
|
||||||
|
prefix = "! "
|
||||||
|
}
|
||||||
|
fmt.Printf("[%s] %s $%7.2f %s%s (%s)\n", msg, transaction.Date, transaction.Amount, prefix, transaction.Details.CounterParty.Name, transaction.Description)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "reg": // reg
|
||||||
|
deltas = deltas.Group(ledger.GroupDate(config.GroupDate))
|
||||||
transactions := deltas.Transactions()
|
transactions := deltas.Transactions()
|
||||||
cumulative := make(ledger.Balances)
|
cumulative := make(ledger.Balances)
|
||||||
for _, transaction := range transactions {
|
for _, transaction := range transactions {
|
||||||
@@ -237,24 +344,206 @@ func Main() {
|
|||||||
if shouldPrint {
|
if shouldPrint {
|
||||||
cumulative.PushAll(balances)
|
cumulative.PushAll(balances)
|
||||||
cumulative = cumulative.Nonzero()
|
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:
|
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 {
|
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 {
|
} else {
|
||||||
fmt.Fprintf(w, "%s\t%s\n", date, description)
|
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{}
|
maxes := map[ledger.Currency]float64{}
|
||||||
keys := []string{}
|
keys := []string{}
|
||||||
for k, v := range balances {
|
for k, v := range balances {
|
||||||
@@ -269,9 +558,10 @@ func FPrintBalances(w io.Writer, linePrefix string, balances, cumulatives ledger
|
|||||||
|
|
||||||
normalizer := ana.NewDefaultNormalizer()
|
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 {
|
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 {
|
for i, key := range keys {
|
||||||
printableKey := key
|
printableKey := key
|
||||||
@@ -321,13 +611,40 @@ func FPrintBalances(w io.Writer, linePrefix string, balances, cumulatives ledger
|
|||||||
cumulative = value
|
cumulative = value
|
||||||
}
|
}
|
||||||
|
|
||||||
if !normalized {
|
printingPercents := strings.Contains(cumulativeFormat, "%%%%")
|
||||||
fmt.Fprintf(w, format, printableKey, printableCurrency, balances[key][currency], printableCurrency, cumulative)
|
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 {
|
} else {
|
||||||
factor := normalizer.NormalizeFactor(key, date)
|
if !normalized {
|
||||||
trailingMax := maxes[currency] - math.Abs(balances[key][currency])
|
fmt.Fprintf(w, format, printableKey, printableCurrency, balances[key][currency], cumulative)
|
||||||
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, 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 ""
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
cmd="bal"
|
cmd="bal"
|
||||||
case "$1" in
|
case "$1" in
|
||||||
bal|reg|gra|graph )
|
bal|reg|gra|graph|rec )
|
||||||
cmd="$1"
|
cmd="$1"
|
||||||
shift
|
shift
|
||||||
;;
|
;;
|
||||||
|
|||||||
@@ -47,7 +47,7 @@
|
|||||||
|
|
||||||
<span>
|
<span>
|
||||||
<label for="likeName">likeName</label>
|
<label for="likeName">likeName</label>
|
||||||
<input name="likeName" type="text" value="AssetAccount"/>
|
<input name="likeName" type="text" value="Bel:AssetAccount"/>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<span>
|
<span>
|
||||||
@@ -72,7 +72,7 @@
|
|||||||
|
|
||||||
<span>
|
<span>
|
||||||
<label for="prediction">prediction</label>
|
<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>
|
||||||
|
|
||||||
<span>
|
<span>
|
||||||
@@ -82,7 +82,7 @@
|
|||||||
|
|
||||||
<span>
|
<span>
|
||||||
<label for="whatIf">whatIf</label>
|
<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>
|
||||||
|
|
||||||
<span>
|
<span>
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
<li><a href="/api/trends">Where does the house money go?</a></li>
|
<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="/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/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>
|
</ul>
|
||||||
</body>
|
</body>
|
||||||
<footer>
|
<footer>
|
||||||
|
|||||||
@@ -297,6 +297,11 @@ func (router Router) APIReg(w http.ResponseWriter, r *http.Request) {
|
|||||||
register := deltas.Register()
|
register := deltas.Register()
|
||||||
predicted := make(ledger.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 {
|
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)
|
window := time.Hour * 24.0 * 365.0 / 12.0 * time.Duration(predictionMonths)
|
||||||
// TODO whatif
|
// TODO whatif
|
||||||
@@ -334,10 +339,6 @@ func (router Router) APIReg(w http.ResponseWriter, r *http.Request) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
bpis, err := router.bpis()
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
bpis, err = ana.BPIsWithFixedGrowthPrediction(bpis, window, currency, rate)
|
bpis, err = ana.BPIsWithFixedGrowthPrediction(bpis, window, currency, rate)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
panic(err)
|
||||||
@@ -346,10 +347,6 @@ func (router Router) APIReg(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if r.URL.Query().Get("bpi") == "true" {
|
if r.URL.Query().Get("bpi") == "true" {
|
||||||
bpis, err := router.bpis()
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
register = register.WithBPIs(bpis)
|
register = register.WithBPIs(bpis)
|
||||||
predicted = predicted.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
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
"os"
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
"gogs.inhome.blapointe.com/ana-ledger/cmd/cli"
|
"gogs.inhome.blapointe.com/ana-ledger/cmd/cli"
|
||||||
"gogs.inhome.blapointe.com/ana-ledger/cmd/http"
|
"gogs.inhome.blapointe.com/ana-ledger/cmd/http"
|
||||||
|
"gogs.inhome.blapointe.com/ana-ledger/src/bank/teller"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
switch os.Args[1] {
|
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":
|
case "http":
|
||||||
os.Args = append([]string{os.Args[0]}, os.Args[2:]...)
|
os.Args = append([]string{os.Args[0]}, os.Args[2:]...)
|
||||||
http.Main()
|
http.Main()
|
||||||
@@ -19,13 +38,18 @@ func main() {
|
|||||||
files := os.Args[2:]
|
files := os.Args[2:]
|
||||||
os.Args = []string{os.Args[0], "cli"}
|
os.Args = []string{os.Args[0], "cli"}
|
||||||
for _, f := range files {
|
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,
|
os.Args = append(os.Args,
|
||||||
"-w=^Housey",
|
"-w=^Housey",
|
||||||
"--depth=1",
|
"--depth=1",
|
||||||
"--usd",
|
"--usd",
|
||||||
"-n",
|
"-n",
|
||||||
|
"--no-percent",
|
||||||
"bal", "^Bel", "^Zach",
|
"bal", "^Bel", "^Zach",
|
||||||
)
|
)
|
||||||
main()
|
main()
|
||||||
|
|||||||
1
go.mod
1
go.mod
@@ -8,6 +8,7 @@ require (
|
|||||||
github.com/go-echarts/go-echarts/v2 v2.3.1
|
github.com/go-echarts/go-echarts/v2 v2.3.1
|
||||||
github.com/guptarohit/asciigraph v0.7.3
|
github.com/guptarohit/asciigraph v0.7.3
|
||||||
golang.org/x/crypto v0.38.0
|
golang.org/x/crypto v0.38.0
|
||||||
|
golang.org/x/time v0.11.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
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/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 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg=
|
||||||
golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ=
|
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 h1:hjy8E9ON/egN1tAYqKb61G10WtihqetD4sz2H+8nIeA=
|
||||||
gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
|||||||
@@ -19,11 +19,15 @@ type normalize struct {
|
|||||||
|
|
||||||
func NewDefaultNormalizer() Normalizer {
|
func NewDefaultNormalizer() Normalizer {
|
||||||
return NewNormalizer().
|
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", "2023-10-05", 139). // to turtle
|
||||||
With("^Zach", "2021-12-30", 135). // at pluralsight
|
With("^Zach", "2021-12-30", 135). // at pluralsight
|
||||||
With("^Zach", "2020-07-30", 120). // to pluralsight
|
With("^Zach", "2020-07-30", 120). // to pluralsight
|
||||||
With("^Zach", "2019-07-16", 77). // at fedex
|
With("^Zach", "2019-07-16", 77). // at fedex
|
||||||
With("^Zach", "2017-02-16", 49). // to 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", "2023-12-05", 190). // to render
|
||||||
With("^Bel", "2022-12-31", 154). // at q
|
With("^Bel", "2022-12-31", 154). // at q
|
||||||
With("^Bel", "2022-06-30", 148). // at q
|
With("^Bel", "2022-06-30", 148). // at q
|
||||||
|
|||||||
107
src/bank/cache/cache.go
vendored
Normal file
107
src/bank/cache/cache.go
vendored
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
package cache
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gogs.inhome.blapointe.com/ana-ledger/src/bank"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Client struct {
|
||||||
|
Client bank.Agg
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ bank.Agg = Client{}
|
||||||
|
|
||||||
|
func New(client bank.Agg) Client {
|
||||||
|
return Client{Client: client}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c Client) Accounts(ctx context.Context) ([]bank.Account, error) {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := c.Client.Accounts(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
toCache(k, result)
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c Client) Transactions(ctx context.Context, a bank.Account) ([]bank.Transaction, error) {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := c.Client.Transactions(ctx, a)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
toCache(k, result)
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
d = path.Join("/tmp/ana_ledger_bank_cache.d")
|
||||||
|
)
|
||||||
|
|
||||||
|
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 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()) > 24*time.Hour {
|
||||||
|
return fmt.Errorf("stale")
|
||||||
|
}
|
||||||
|
|
||||||
|
b, err := os.ReadFile(p)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(b, ptr); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
50
src/bank/cache/cache_integration_test.go
vendored
Normal file
50
src/bank/cache/cache_integration_test.go
vendored
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
//go:build integration
|
||||||
|
|
||||||
|
package cache_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strconv"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gogs.inhome.blapointe.com/ana-ledger/src/bank/cache"
|
||||||
|
"gogs.inhome.blapointe.com/ana-ledger/src/bank/teller"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Test(t *testing.T) {
|
||||||
|
tellerC, err := teller.New()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
client := cache.New(tellerC)
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
for i := 0; i < 2; i++ {
|
||||||
|
i := i
|
||||||
|
client := client
|
||||||
|
t.Run(strconv.Itoa(i), func(t *testing.T) {
|
||||||
|
accounts, err := client.Accounts(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, account := range accounts {
|
||||||
|
account := account
|
||||||
|
t.Run(account.Account, func(t *testing.T) {
|
||||||
|
transactions, err := client.Transactions(ctx, account)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, tr := range transactions {
|
||||||
|
t.Logf("[%d] %+v", i, tr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
})
|
||||||
|
client.Client = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
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>
|
||||||
95
src/bank/teller/teller.go
Normal file
95
src/bank/teller/teller.go
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
package teller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"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 {
|
||||||
|
cert tls.Certificate
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ bank.Agg = Client{}
|
||||||
|
|
||||||
|
var (
|
||||||
|
//go:embed certificate.pem
|
||||||
|
certificate []byte
|
||||||
|
//go:embed private_key.pem
|
||||||
|
privateKey []byte
|
||||||
|
//go:embed token.txt
|
||||||
|
Tokens string
|
||||||
|
)
|
||||||
|
|
||||||
|
func New() (Client, error) {
|
||||||
|
cert, err := tls.X509KeyPair(certificate, privateKey)
|
||||||
|
return Client{cert: cert}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c Client) Accounts(ctx context.Context) ([]bank.Account, error) {
|
||||||
|
var result []bank.Account
|
||||||
|
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", a.Token, &result)
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
|
||||||
|
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{
|
||||||
|
DisableKeepAlives: true,
|
||||||
|
TLSClientConfig: &tls.Config{Certificates: []tls.Certificate{c.cert}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequest(http.MethodGet, url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.SetBasicAuth(token, "")
|
||||||
|
req = req.WithContext(ctx)
|
||||||
|
|
||||||
|
resp, err := httpc.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
b, _ := io.ReadAll(resp.Body)
|
||||||
|
if err := json.Unmarshal(b, &ptr); err != nil {
|
||||||
|
return fmt.Errorf("cannot unmarshal: %w: %s", err, b)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
41
src/bank/teller/teller_integration_test.go
Normal file
41
src/bank/teller/teller_integration_test.go
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
//go:build integration
|
||||||
|
|
||||||
|
package teller_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gogs.inhome.blapointe.com/ana-ledger/src/bank/teller"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Test(t *testing.T) {
|
||||||
|
teller.Tokens = "test_token_bfu2cyvq3il6o"
|
||||||
|
|
||||||
|
c, err := teller.New()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
accounts, err := c.Accounts(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, account := range accounts {
|
||||||
|
account := account
|
||||||
|
t.Run(account.Account, func(t *testing.T) {
|
||||||
|
transactions, err := c.Transactions(ctx, account)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, tr := range transactions {
|
||||||
|
t.Logf("[%d] %+v", i, tr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
59
src/bank/teller/teller_manual_test.go
Normal file
59
src/bank/teller/teller_manual_test.go
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
//go:build manual
|
||||||
|
|
||||||
|
package teller_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/tls"
|
||||||
|
"io"
|
||||||
|
"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 {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
c := &http.Client{
|
||||||
|
Timeout: time.Second,
|
||||||
|
Transport: &http.Transport{
|
||||||
|
DisableKeepAlives: true,
|
||||||
|
TLSClientConfig: &tls.Config{Certificates: []tls.Certificate{cert}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
//curl --cert certificate.pem --cert-key private_key.pem --auth test_token_bfu2cyvq3il6o: https://api.teller.io/accounts
|
||||||
|
|
||||||
|
for _, url := range []string{
|
||||||
|
"https://api.teller.io/accounts",
|
||||||
|
"https://api.teller.io/accounts/acc_pdvv4810fi9hmrcn6g000/transactions",
|
||||||
|
} {
|
||||||
|
url := url
|
||||||
|
t.Run(url, func(t *testing.T) {
|
||||||
|
req, err := http.NewRequest(http.MethodGet, url, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
req.SetBasicAuth("test_token_bfu2cyvq3il6o", "")
|
||||||
|
|
||||||
|
resp, err := c.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
if code := resp.StatusCode; code >= 300 {
|
||||||
|
t.Fatalf("(%d) %s", code, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("(%d) %s", resp.StatusCode, body)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
2
src/bank/teller/token.txt
Normal file
2
src/bank/teller/token.txt
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
token_2utqstwpn3pxwgvyno56hqdehq
|
||||||
|
token_vr6dnzvfv7c24wuxtmnnzyiqbm
|
||||||
31
src/bank/types.go
Normal file
31
src/bank/types.go
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
package bank
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
|
type Agg interface {
|
||||||
|
Accounts(context.Context) ([]Account, error)
|
||||||
|
Transactions(context.Context, Account) ([]Transaction, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Account struct {
|
||||||
|
Institution struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
} `json:"institution"`
|
||||||
|
Name string `json:"last_four"`
|
||||||
|
Account string `json:"id"`
|
||||||
|
Token string `json:"__token"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Transaction struct {
|
||||||
|
Amount float64 `json:"amount,string"`
|
||||||
|
Details struct {
|
||||||
|
ProcessingStatus string `json:"processing_status"`
|
||||||
|
CounterParty struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
} `json:"counterparty"`
|
||||||
|
} `json:"details"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Date string `json:"date"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ const (
|
|||||||
|
|
||||||
type Delta struct {
|
type Delta struct {
|
||||||
Date string
|
Date string
|
||||||
|
OtherDates []string
|
||||||
Name string
|
Name string
|
||||||
Value float64
|
Value float64
|
||||||
Currency Currency
|
Currency Currency
|
||||||
@@ -23,9 +24,10 @@ type Delta struct {
|
|||||||
with []Delta
|
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{
|
return Delta{
|
||||||
Date: d,
|
Date: d,
|
||||||
|
OtherDates: otherDates,
|
||||||
Name: name,
|
Name: name,
|
||||||
Value: v,
|
Value: v,
|
||||||
Currency: Currency(c),
|
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 {
|
func (delta Delta) withWith(other Delta) Delta {
|
||||||
other.with = nil
|
other.with = nil
|
||||||
delta.with = append(delta.with, other)
|
delta.with = append(delta.with, other)
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
|
|
||||||
func TestDelta(t *testing.T) {
|
func TestDelta(t *testing.T) {
|
||||||
d := "2099-08-07"
|
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" {
|
if delta.Transaction != "x" {
|
||||||
t.Error(delta.Transaction)
|
t.Error(delta.Transaction)
|
||||||
@@ -17,6 +17,9 @@ func TestDelta(t *testing.T) {
|
|||||||
if delta.Date != d {
|
if delta.Date != d {
|
||||||
t.Error(delta.Date)
|
t.Error(delta.Date)
|
||||||
}
|
}
|
||||||
|
if delta.OtherDates[0] != "d2" {
|
||||||
|
t.Error(delta.OtherDates)
|
||||||
|
}
|
||||||
if delta.Name != "name" {
|
if delta.Name != "name" {
|
||||||
t.Error(delta.Name)
|
t.Error(delta.Name)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -99,8 +99,12 @@ func (files Files) add(payee string, delta Delta) error {
|
|||||||
if delta.Currency != USD {
|
if delta.Currency != USD {
|
||||||
currencyValue = fmt.Sprintf("%.2f %s", delta.Value, delta.Currency)
|
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",
|
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, delta.Name, filesAppendDelim+filesAppendDelim+filesAppendDelim, currencyValue,
|
||||||
filesAppendDelim, payee,
|
filesAppendDelim, payee,
|
||||||
))
|
))
|
||||||
|
|||||||
@@ -73,12 +73,13 @@ func TestFileAmend(t *testing.T) {
|
|||||||
},
|
},
|
||||||
"payee": {
|
"payee": {
|
||||||
from: `
|
from: `
|
||||||
2006-01-02 description
|
2006-01-02=2006-01-03 description
|
||||||
recipient $3.45
|
recipient $3.45
|
||||||
payee
|
payee
|
||||||
`,
|
`,
|
||||||
old: Delta{
|
old: Delta{
|
||||||
Date: "2006-01-02",
|
Date: "2006-01-02",
|
||||||
|
OtherDates: []string{"2006-01-03"},
|
||||||
Name: "payee",
|
Name: "payee",
|
||||||
Value: -3.45,
|
Value: -3.45,
|
||||||
Currency: "$",
|
Currency: "$",
|
||||||
@@ -86,16 +87,17 @@ func TestFileAmend(t *testing.T) {
|
|||||||
},
|
},
|
||||||
now: Delta{
|
now: Delta{
|
||||||
Date: "2106-11-12",
|
Date: "2106-11-12",
|
||||||
|
OtherDates: []string{"2006-01-03"},
|
||||||
Name: "1payee",
|
Name: "1payee",
|
||||||
Value: -13.45,
|
Value: -13.45,
|
||||||
Currency: "T",
|
Currency: "T",
|
||||||
Description: "1description",
|
Description: "1description",
|
||||||
},
|
},
|
||||||
want: `
|
want: `
|
||||||
2006-01-02 description
|
2006-01-02=2006-01-03 description
|
||||||
payee $3.45
|
payee $3.45
|
||||||
recipient
|
recipient
|
||||||
2106-11-12 1description
|
2106-11-12=2006-01-03 1description
|
||||||
1payee -13.45 T
|
1payee -13.45 T
|
||||||
recipient`,
|
recipient`,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -16,8 +16,21 @@ import (
|
|||||||
|
|
||||||
type Transaction Deltas
|
type Transaction Deltas
|
||||||
|
|
||||||
|
func (t Transaction) Deltas() Deltas {
|
||||||
|
return Deltas(slices.Clone(t))
|
||||||
|
}
|
||||||
|
|
||||||
type Transactions []Transaction
|
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 {
|
func (transactions Transactions) Deltas() Deltas {
|
||||||
result := make(Deltas, 0, len(transactions))
|
result := make(Deltas, 0, len(transactions))
|
||||||
for _, transaction := range transactions {
|
for _, transaction := range transactions {
|
||||||
@@ -106,6 +119,7 @@ func (transaction Transaction) Payee() string {
|
|||||||
|
|
||||||
type transaction struct {
|
type transaction struct {
|
||||||
date string
|
date string
|
||||||
|
otherDates []string
|
||||||
description string
|
description string
|
||||||
payee string
|
payee string
|
||||||
recipients []transactionRecipient
|
recipients []transactionRecipient
|
||||||
@@ -142,6 +156,7 @@ func (t transaction) deltas() Deltas {
|
|||||||
recipient.isSet,
|
recipient.isSet,
|
||||||
t.fileName,
|
t.fileName,
|
||||||
t.lineNo+i,
|
t.lineNo+i,
|
||||||
|
t.otherDates,
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
for currency, value := range sums {
|
for currency, value := range sums {
|
||||||
@@ -162,6 +177,7 @@ func (t transaction) deltas() Deltas {
|
|||||||
false,
|
false,
|
||||||
t.fileName,
|
t.fileName,
|
||||||
t.lineNo,
|
t.lineNo,
|
||||||
|
t.otherDates,
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -287,16 +303,17 @@ func _readTransaction(name string, r *bufio.Reader) (transaction, error) {
|
|||||||
return transaction{}, err
|
return transaction{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
dateDescriptionPattern := regexp.MustCompile(`^([0-9]+-[0-9]+-[0-9]+)\s+(.*)$`)
|
dateDescriptionPattern := regexp.MustCompile(`^([0-9]+-[0-9]+-[0-9]+)((=[0-9]+-[0-9]+-[0-9]+)*)\s+(.*)$`)
|
||||||
dateDescriptionMatches := dateDescriptionPattern.FindAllSubmatch(firstLine, 4)
|
dateDescriptionMatches := dateDescriptionPattern.FindAllStringSubmatch(string(firstLine), 4)
|
||||||
if len(dateDescriptionMatches) != 1 {
|
if len(dateDescriptionMatches) != 1 {
|
||||||
return transaction{}, fmt.Errorf("bad first line: %v matches: %q", len(dateDescriptionMatches), firstLine)
|
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)
|
return transaction{}, fmt.Errorf("bad first line: %v submatches: %q", len(dateDescriptionMatches[0]), firstLine)
|
||||||
}
|
}
|
||||||
result := transaction{
|
result := transaction{
|
||||||
date: string(dateDescriptionMatches[0][1]),
|
date: dateDescriptionMatches[0][1],
|
||||||
description: string(dateDescriptionMatches[0][2]),
|
otherDates: strings.Split(strings.Trim(dateDescriptionMatches[0][2], "="), "="),
|
||||||
|
description: dateDescriptionMatches[0][4],
|
||||||
name: name,
|
name: name,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -58,12 +58,13 @@ func TestReadTransaction(t *testing.T) {
|
|||||||
},
|
},
|
||||||
"verbose": {
|
"verbose": {
|
||||||
input: `
|
input: `
|
||||||
2003-04-05 Reasoning here
|
2003-04-05=2003-04-06=2003-04-07 Reasoning here
|
||||||
A:B $1.00
|
A:B $1.00
|
||||||
C:D $-1.00
|
C:D $-1.00
|
||||||
`,
|
`,
|
||||||
want: transaction{
|
want: transaction{
|
||||||
date: "2003-04-05",
|
date: "2003-04-05",
|
||||||
|
otherDates: []string{"2003-04-06", "2003-04-07"},
|
||||||
description: "Reasoning here",
|
description: "Reasoning here",
|
||||||
payee: "A:B",
|
payee: "A:B",
|
||||||
recipients: []transactionRecipient{
|
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
|
# golang.org/x/term v0.32.0
|
||||||
## explicit; go 1.23.0
|
## explicit; go 1.23.0
|
||||||
golang.org/x/term
|
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