Compare commits

..

47 Commits

Author SHA1 Message Date
bel
72c9d79dd2 filter csv to matchies
All checks were successful
cicd / ci (push) Successful in 2m16s
2026-03-22 14:12:09 -06:00
bel
96703721e3 csv accepts depth 2026-03-22 14:11:11 -06:00
bel
7ac5c15ff8 id is val
All checks were successful
cicd / ci (push) Successful in 3m5s
2026-03-22 00:04:34 -06:00
bel
d743b6dbd0 separate unmatched list
Some checks failed
cicd / ci (push) Failing after 1m35s
2026-03-21 23:32:11 -06:00
Bel LaPointe
ca365ad39c can pass duration to period
All checks were successful
cicd / ci (push) Successful in 1m51s
2026-03-04 08:53:02 -07:00
Bel LaPointe
86b8e62862 empty string period is inf
All checks were successful
cicd / ci (push) Successful in 2m36s
2026-03-04 08:14:07 -07:00
Bel LaPointe
7262f5f69b find unique to ledger
All checks were successful
cicd / ci (push) Successful in 1m15s
2026-01-31 12:14:08 -07:00
Bel LaPointe
b2954c0461 no log confusing to read
All checks were successful
cicd / ci (push) Successful in 1m6s
2026-01-31 11:30:08 -07:00
Bel LaPointe
03b9a6d1f1 todo
All checks were successful
cicd / ci (push) Successful in 1m10s
2026-01-31 11:23:53 -07:00
Bel LaPointe
fa7bafa241 reconcile csv via --csv csv
Some checks failed
cicd / ci (push) Has been cancelled
2026-01-31 11:22:47 -07:00
Bel LaPointe
502e47d0bc ledger.Delta.OtherDates 2026-01-31 11:03:56 -07:00
Bel LaPointe
0eb0abf4a8 bpis to period as well
All checks were successful
cicd / ci (push) Successful in 2m29s
2026-01-28 09:53:04 -07:00
Bel LaPointe
49b7bd2f85 qt pay up <3
Some checks failed
cicd / ci (push) Failing after 13m10s
2026-01-21 21:00:24 -07:00
bel
045ab30dfa install script
All checks were successful
cicd / ci (push) Successful in 2m6s
2026-01-03 11:21:47 -07:00
Bel LaPointe
b1cf639f39 un-break shared
All checks were successful
cicd / ci (push) Successful in 3m5s
2025-10-31 07:28:26 -06:00
Bel LaPointe
cc546d9b2d fix
All checks were successful
cicd / ci (push) Successful in 1m14s
2025-10-26 13:11:09 -06:00
Bel LaPointe
0066a267a6 bal prints percents
All checks were successful
cicd / ci (push) Successful in 2m40s
2025-10-26 09:27:07 -06:00
Bel LaPointe
f58053ebe9 5% raise neat
All checks were successful
cicd / ci (push) Successful in 1m55s
2025-10-15 14:55:00 -06:00
Bel LaPointe
b30b1811ea shared flags
All checks were successful
cicd / ci (push) Successful in 1m53s
2025-09-04 22:18:04 -06:00
Bel LaPointe
3fb1ee4ea3 qt got a turtle rock promo~
All checks were successful
cicd / ci (push) Successful in 2m13s
2025-08-28 20:56:35 -06:00
Bel LaPointe
7a790fa31e group date
All checks were successful
cicd / ci (push) Successful in 1m51s
2025-08-23 08:25:08 -06:00
Bel LaPointe
774de58bf7 not u
All checks were successful
cicd / ci (push) Successful in 1m3s
2025-06-16 18:18:04 -06:00
Bel LaPointe
6aa549cb02 bpis predictFixedGrowth fixed
All checks were successful
cicd / ci (push) Successful in 1m15s
2025-06-16 18:12:11 -06:00
Bel LaPointe
fe7c3a9682 oop 3500 saved per mo not 3600
All checks were successful
cicd / ci (push) Successful in 2m45s
2025-06-16 17:58:38 -06:00
Bel LaPointe
36dcf70dbe from Asset to Bel:Asset
All checks were successful
cicd / ci (push) Successful in 1m1s
2025-06-16 17:57:06 -06:00
Bel LaPointe
989bc3c2ff update prediction default
All checks were successful
cicd / ci (push) Successful in 2m19s
2025-06-16 17:55:16 -06:00
Bel LaPointe
f22d7d958b WILDCARD
All checks were successful
cicd / ci (push) Successful in 1m28s
2025-05-25 13:04:27 -06:00
Bel LaPointe
6a4e10ee2b trigger on self 2025-05-25 13:04:06 -06:00
Bel LaPointe
09da985455 build on build/ 2025-05-25 13:03:36 -06:00
Bel LaPointe
f52832ee82 docker build 2025-05-25 13:02:59 -06:00
bel
0cafba0571 bel pay raise
Some checks failed
cicd / ci (push) Failing after 1m30s
2025-05-24 08:46:51 -06:00
Bel LaPointe
1a397dbf45 mmmm giving out security info wasnt great so rotate bank passwords
Some checks failed
cicd / ci (push) Failing after 15s
2025-05-23 23:36:02 -06:00
Bel LaPointe
b10283d752 multi-acc rate limited lookin pretty good~
Some checks failed
cicd / ci (push) Failing after 14s
2025-05-23 23:17:54 -06:00
Bel LaPointe
284613b5bc fix https
Some checks failed
cicd / ci (push) Failing after 14s
2025-05-23 23:01:15 -06:00
Bel LaPointe
929d15c5b7 teller accepts multi token in tokens.txt
Some checks failed
cicd / ci (push) Failing after 17s
2025-05-23 23:00:09 -06:00
Bel LaPointe
5e61378d63 resolved chase since dawn of time yay 2025-05-23 22:38:52 -06:00
Bel LaPointe
c948a32458 fix onedayoff checks day after
Some checks failed
cicd / ci (push) Failing after 15s
2025-05-23 22:24:05 -06:00
Bel LaPointe
35e2e40ce6 oooo onedayoff ok
Some checks failed
cicd / ci (push) Failing after 15s
2025-05-23 22:15:10 -06:00
Bel LaPointe
847cd736b6 at least round both wrong
Some checks failed
cicd / ci (push) Failing after 15s
2025-05-23 22:02:27 -06:00
Bel LaPointe
9cf8cb0736 rec ok with negatives/positives
Some checks failed
cicd / ci (push) Failing after 21s
2025-05-23 21:59:19 -06:00
Bel LaPointe
4997264f4c fix cache test 2025-05-23 21:56:36 -06:00
Bel LaPointe
f69a850bd8 got a silly ui that can yield a test token 2025-05-23 21:38:07 -06:00
Bel LaPointe
5a3d5e5610 stub teller.Init 2025-05-23 21:12:27 -06:00
Bel LaPointe
c38e8529af ready to get a real token 2025-05-23 21:09:26 -06:00
Bel LaPointe
7a946b7604 go run ./ cli rec to pull all from teller
Some checks failed
cicd / ci (push) Failing after 18s
2025-05-23 21:01:09 -06:00
Bel LaPointe
e0fa44eef7 test bank.cache
Some checks failed
cicd / ci (push) Failing after 17s
2025-05-23 20:32:49 -06:00
Bel LaPointe
6440b07d14 impl teller.Client returns bank.*
Some checks failed
cicd / ci (push) Failing after 14s
2025-05-23 20:15:59 -06:00
36 changed files with 1572 additions and 90 deletions

View File

@@ -6,6 +6,8 @@ on:
paths:
- 'cmd/**'
- 'src/**'
- 'build/**'
- '.gitea/**'
jobs:
ci:

View File

@@ -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

View File

@@ -15,5 +15,8 @@ type Config struct {
Normalize bool
USDOnly bool
}
Compact bool
Compact bool
GroupDate string
NoPercent bool
CSV string
}

View File

@@ -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
View 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())
}
})
}
}

View File

@@ -2,16 +2,26 @@ package cli
import (
"bufio"
"context"
"encoding/csv"
"flag"
"fmt"
"io"
"log"
"maps"
"math"
"os"
"os/signal"
"slices"
"strconv"
"strings"
"syscall"
"time"
"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"
"github.com/guptarohit/asciigraph"
@@ -28,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() {
@@ -89,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 {
@@ -130,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
@@ -219,7 +264,69 @@ func Main() {
options = append(options, asciigraph.SeriesColors(seriesColors...))
}
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()
cumulative := make(ledger.Balances)
for _, transaction := range transactions {
@@ -237,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 {
@@ -269,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
@@ -321,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 ""
}

View File

@@ -2,7 +2,7 @@
cmd="bal"
case "$1" in
bal|reg|gra|graph )
bal|reg|gra|graph|rec )
cmd="$1"
shift
;;

View File

@@ -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>

View File

@@ -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>

View File

@@ -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
View 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

View File

@@ -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
View File

@@ -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
View File

@@ -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=

View File

@@ -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

107
src/bank/cache/cache.go vendored Normal file
View 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
}

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

View File

@@ -0,0 +1 @@
app_pdvv33dtmta4fema66000

64
src/bank/teller/init.go Normal file
View 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
View 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
View 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
}

View File

@@ -1,53 +1,41 @@
//go:build integration
package teller_test
import (
"crypto/tls"
"io"
"net/http"
"context"
"testing"
"time"
"gogs.inhome.blapointe.com/ana-ledger/src/bank/teller"
)
func TestIntegration(t *testing.T) {
//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")
func Test(t *testing.T) {
teller.Tokens = "test_token_bfu2cyvq3il6o"
c, err := teller.New()
if err != nil {
t.Fatal(err)
}
c := &http.Client{
Timeout: time.Second,
Transport: &http.Transport{
DisableKeepAlives: true,
TLSClientConfig: &tls.Config{Certificates: []tls.Certificate{cert}},
},
ctx := context.Background()
accounts, err := c.Accounts(ctx)
if err != nil {
t.Fatal(err)
}
//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)
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)
}
req.SetBasicAuth("test_token_bfu2cyvq3il6o", "")
resp, err := c.Do(req)
if err != nil {
t.Fatal(err)
for i, tr := range transactions {
t.Logf("[%d] %+v", i, tr)
}
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)
})
break
}
}

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

View File

@@ -0,0 +1,2 @@
token_2utqstwpn3pxwgvyno56hqdehq
token_vr6dnzvfv7c24wuxtmnnzyiqbm

31
src/bank/types.go Normal file
View 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"`
}

View File

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

View File

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

View File

@@ -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,
))

View File

@@ -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`,
},

View File

@@ -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,
}

View File

@@ -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
View 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
View 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
View 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 Reservations 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
View 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
View File

@@ -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