gofio

http server for overview for accounts from FIO bank
git clone git://gtms.dev/gofio.git
Log | Files | Refs

commit ecdd81d185df7c2520f85bc28c3f982e2a99932a
parent 44a7b24af5654585091f2a020fa7b102c6f4452f
Author: Tomas Nemec <nemi@skaut.cz>
Date:   Sun,  1 Aug 2021 00:38:00 +0200

feat: multi acounts

cli takes -tokens file with tokens listed one by line. each line has to
be in format:
`<token> <name> [<config>]`
`config` is comma separated string without spaces

currently available config is `onlyIncome` that will filter all outcome
from balance page

Diffstat:
M.gitignore | 1+
Afio.go | 95+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mfio/api.go | 5-----
Mfio/fio.go | 5+++++
Mfio/model.go | 1+
Mmain.go | 179++++++++++++++++++++++++++++++-------------------------------------------------
Atemplate.go | 112+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atoken.go | 45+++++++++++++++++++++++++++++++++++++++++++++
Atoken_test.go | 73+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aweb/balance.template.html | 158+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aweb/card.template.html | 5+++++
Aweb/dashboard.template.html | 89+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dweb/index.html | 66------------------------------------------------------------------
13 files changed, 651 insertions(+), 183 deletions(-)

diff --git a/.gitignore b/.gitignore @@ -1,3 +1,4 @@ token +tokens t.json gofio diff --git a/fio.go b/fio.go @@ -0,0 +1,95 @@ +package main + +import ( + "errors" + "fio/fio" + "fmt" + "log" + "strings" + "time" + + "github.com/leekchan/accounting" +) + +type account struct { + name string + config config + fio *fio.Fio + root *fio.Root +} + +func (acc *account) needsReload() bool { + return acc.root == nil || acc.hasNew() +} + +func (acc *account) reload(force bool) error { + if force || acc.needsReload() { + log.Printf("Reloading '%s'", acc.name) + if !acc.loadRoot() { + return errors.New("Failed to load root for '" + acc.name + "'") + } else { + reverse(acc.root.AccountStatement.TransactionList.Transactions) + } + } + return nil +} + +func (acc *account) hasNew() bool { + last := acc.fio.Last() + if last == nil { + return false + } + return len(last.AccountStatement.TransactionList.Transactions) > 0 +} + +func (acc *account) loadCard(template []byte, isActive bool) (string, error) { + err := acc.reload(false) + if err != nil { + return "", err + } + ac := accounting.Accounting{Symbol: acc.root.AccountStatement.Info.Currency, Precision: 2, Thousand: " ", Format: "%v %s"} + // templating + card := string(template) + card = strings.Replace(card, "{{LINK}}", "/"+acc.name, -1) + var active string + if isActive { + active = "active" + } + card = strings.Replace(card, "{{ACTIVE}}", active, -1) + card = strings.Replace(card, "{{NAME}}", acc.name, -1) + card = strings.Replace(card, "{{ACC_ID}}", formatAccId(acc.root), -1) + closingBal := ac.FormatMoney(acc.root.AccountStatement.Info.ClosingBalance) + card = strings.Replace(card, "{{BALANCE}}", fmt.Sprintf("%v", closingBal), -1) + + return card, nil +} + +func (acc *account) loadBalance(template []byte, cards string) (string, error) { + err := acc.reload(false) + if err != nil { + return "", err + } + // templating + file := string(template) + file = strings.Replace(file, "{{CARDS}}", cards, -1) + file = strings.Replace(file, "{{NAME}}", acc.name, -1) + file = strings.Replace(file, "{{LIST}}", formatList(acc.root, acc.config), -1) + + return string(file), nil +} + +func (acc *account) loadRoot() bool { + from := time.Date(2021, time.January, 1, 0, 0, 0, 0, time.UTC) + to := time.Date(2021, time.December, 31, 0, 0, 0, 0, time.UTC) + + acc.root = acc.fio.Period(from, to) + return acc.root != nil +} + +func reverse(numbers []*fio.Transaction) []*fio.Transaction { + for i := 0; i < len(numbers)/2; i++ { + j := len(numbers) - i - 1 + numbers[i], numbers[j] = numbers[j], numbers[i] + } + return numbers +} diff --git a/fio/api.go b/fio/api.go @@ -2,16 +2,11 @@ package fio import ( "encoding/json" - "fmt" "io" "log" "net/http" - "time" ) -func fmtDate(time time.Time) string { - return fmt.Sprintf("%d-%d-%d", time.Year(), time.Month(), time.Day()) -} func get(url string) *Root { resp, err := http.Get(url) diff --git a/fio/fio.go b/fio/fio.go @@ -10,6 +10,10 @@ type Fio struct { Token string } +func fmtDate(time time.Time) string { + return fmt.Sprintf("%d-%d-%d", time.Year(), time.Month(), time.Day()) +} + func (f *Fio) Period(from time.Time, to time.Time) *Root { return get(fmt.Sprintf("%s/periods/%s/%s/%s/transactions.json", f.Base_url, f.Token, fmtDate(from), fmtDate(to))) } @@ -17,6 +21,7 @@ func (f *Fio) Period(from time.Time, to time.Time) *Root { func (f *Fio) Statement(year int, id int) *Root { return get(fmt.Sprintf("%s/by_id/%s/%d/%d/transactions.json", f.Base_url, f.Token, year, id)) } + func (f *Fio) Last() *Root { return get(fmt.Sprintf("%s/last/%s/transactions.json", f.Base_url, f.Token)) } diff --git a/fio/model.go b/fio/model.go @@ -41,6 +41,7 @@ type Transaction struct { BeneficiaryAccount *Column `json:"column2"` BeneficiaryName *Column `json:"column10"` BeneficiaryMessage *Column `json:"column16"` + BeneficiaryComment *Column `json:"column25"` User *Column `json:"column7"` Type *Column `json:"column8"` KS *Column `json:"column4,omitempty"` diff --git a/main.go b/main.go @@ -9,146 +9,101 @@ import ( "net/http" "os" "strings" - "time" - - "github.com/leekchan/accounting" ) const base_url = "https://www.fio.cz/ib_api/rest/" -var tokenFile = flag.String("t", "", "fio api token") -var token string +var ( + tokensFile = flag.String("t", "tokens", "fio api token") + port = flag.String("port", "3000", "serving port") + + templates map[string][]byte + accs []*account + lastUri string = "/" +) -var page string +func checkErr(err error, msg ...interface{}) { + if err != nil { + log.Fatal(msg...) + } +} func main() { flag.Parse() - if *tokenFile == "" { + // Tokens + if *tokensFile == "" { log.Fatal("Token file was not provided") - os.Exit(1) } + file, err := os.Open(*tokensFile) + checkErr(err, "Cannot read token file") + tokens, err := parseTokens(file) + checkErr(err, "Cannot parse token file") - data, err := os.ReadFile(*tokenFile) - token = strings.TrimSuffix(string(data), "\n") - if err != nil { - log.Fatal("Cannot read token file") - os.Exit(1) - } - - http.HandleFunc("/", serveFiles) + // templates + templates = loadTemplates([]string{"dashboard", "balance", "card"}) - log.Println("Listening on :3000...") - err = http.ListenAndServe(":3000", nil) - if err != nil { - log.Fatal(err) - os.Exit(1) - } -} -func serveFiles(w http.ResponseWriter, r *http.Request) { - index, _ := os.ReadFile("./web/index.html") - fio := &fio.Fio{Base_url: base_url, Token: token} - - if page != "" && !hasNew(fio) { - log.Println("No new updates") - fmt.Fprint(w, page) - return + // accounts + for _, token := range tokens { + accs = append(accs, &account{name: token.name, config: token.config, fio: &fio.Fio{Base_url: base_url, Token: token.token}}) } - root := data(fio) - if root == nil { - log.Println("Nil root") - fmt.Fprint(w, page) - return + http.HandleFunc("/", serveDashboard()) + for _, acc := range accs { + http.HandleFunc("/"+acc.name, serveBalance(acc.name)) } - log.Println("Serving new") - ac := accounting.Accounting{Symbol: root.AccountStatement.Info.Currency, Precision: 2, Thousand: " ", Format: "%v %s"} - // Templating - file := string(index) - file = strings.Replace(file, "{{ACC_ID}}", formatAccId(root), -1) - file = strings.Replace(file, "{{ACC_IBAN}}", root.AccountStatement.Info.Iban, -1) - file = strings.Replace(file, "{{ACC_BAL}}", fmt.Sprintf("%v", ac.FormatMoney(root.AccountStatement.Info.ClosingBalance)), -1) - file = strings.Replace(file, "{{LIST}}", formatList(root), -1) - // serving - page = string(file) - fmt.Fprint(w, page) -} -func formatAccId(root *fio.Root) string { - return fmt.Sprintf("%s / %s", root.AccountStatement.Info.AccountId, root.AccountStatement.Info.BankId) + log.Printf("Listening on :%s...\n", *port) + err = http.ListenAndServe(":"+*port, nil) + checkErr(err, err) } -func formatList(root *fio.Root) string { - ac := accounting.Accounting{Precision: 2, Thousand: " ", Format: "%v %s"} - headers := make(map[int]string) - var head bytes.Buffer - var rows bytes.Buffer - for _, v := range reverse(root.AccountStatement.TransactionList.Transactions) { - columns := []*fio.Column{ - v.Date, - v.Money, - v.BeneficiaryAccount, - v.BeneficiaryMessage, - v.Type, - } +// DASHBOARD +func serveDashboard() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + file := loadDashboard(templates["dashboard"]) + fmt.Fprint(w, file) + } +} - rows.WriteString("<tr>") - for _, c := range columns { - if c == nil { - rows.WriteString("<td></td>") - continue - } - if _, in := headers[c.Id]; !in { - headers[c.Id] = c.Name - head.WriteString(fmt.Sprintf("<th>%s</th>", c.Name)) - } - switch c.Id { - case 1: - moneyClass := "positive" - if c.Value.(float64) < 0 { - moneyClass = "negative" - } - ac.Symbol = v.Currency.Value.(string) - rows.WriteString(fmt.Sprintf("<td class='%s %s'>%v</td>", "money", moneyClass, ac.FormatMoney(c.Value))) - case 2: - rows.WriteString(fmt.Sprintf("<td><span>%v</span> / <span>%v</span></td>", c.Value, v.BankCode.Value)) - default: - rows.WriteString(fmt.Sprintf("<td>%v</td>", c.Value)) - } +// BALANCE +func serveBalance(name string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + acc := getAcc(name) + cards := accCards(acc.name) + balance, err := acc.loadBalance(templates["balance"], cards) + if err != nil { + log.Print(err) + http.Redirect(w, r, "/", http.StatusTemporaryRedirect) + return } - rows.WriteString("</tr>") - + fmt.Fprint(w, balance) } - - return fmt.Sprintf(` - <thead> - %s - </thead> - <tbody> - %s - </tbody> -`, head.String(), rows.String()) } -func data(fio *fio.Fio) *fio.Root { - from := time.Date(2021, time.January, 1, 0, 0, 0, 0, time.UTC) - to := time.Date(2021, time.December, 31, 0, 0, 0, 0, time.UTC) - - return fio.Period(from, to) +func loadDashboard(template []byte) string { + cards := accCards("") + return strings.Replace(string(template), "{{CARDS}}", cards, -1) } -func hasNew(fio *fio.Fio) bool { - last := fio.Last() - if last == nil { - return false +func accCards(activeAcc string) string { + var cardBuf bytes.Buffer + for _, acc := range accs { + card, err := acc.loadCard(templates["card"], activeAcc == acc.name) + if err != nil { + log.Print(err) + continue + } + cardBuf.WriteString(card) } - return len(last.AccountStatement.TransactionList.Transactions) > 0 + return cardBuf.String() } -func reverse(numbers []*fio.Transaction) []*fio.Transaction { - for i := 0; i < len(numbers)/2; i++ { - j := len(numbers) - i - 1 - numbers[i], numbers[j] = numbers[j], numbers[i] +func getAcc(name string) *account { + for _, acc := range accs { + if acc.name == name { + return acc + } } - return numbers + return nil } diff --git a/template.go b/template.go @@ -0,0 +1,112 @@ +package main + +import ( + "bytes" + "fio/fio" + "fmt" + "os" + "sort" + + "github.com/leekchan/accounting" +) + +func loadTemplates(list []string) (templates map[string][]byte) { + templates = make(map[string][]byte, len(list)) + for _, filename := range list { + file, err := os.ReadFile("./web/" + filename + ".template.html") + checkErr(err, "Template '"+filename+"' failed to load") + templates[filename] = file + } + return +} + +func formatAccId(root *fio.Root) string { + return fmt.Sprintf("%s / %s", root.AccountStatement.Info.AccountId, root.AccountStatement.Info.BankId) +} + +func formatList(root *fio.Root, config config) string { + ac := accounting.Accounting{Precision: 2, Thousand: " ", Format: "%v %s"} + headers := make(map[int]string) + var rows bytes.Buffer + for _, v := range root.AccountStatement.TransactionList.Transactions { + columns := []*fio.Column{ + v.Date, + v.Money, + v.BeneficiaryAccount, + v.BeneficiaryMessage, + v.BeneficiaryComment, + v.Type, + } + + var row bytes.Buffer + var skipRow bool + for i, c := range columns { + if c == nil { + row.WriteString("<td></td>") + // skip header + if _, in := headers[i]; !in { + headers[i] = "" + } + continue + } + + // header + if n, in := headers[i]; !in || in && n == "" { + headers[i] = c.Name + } + + // Row + switch c.Id { + case 1: + moneyClass := "positive" + if c.Value.(float64) < 0 { + if config.onlyIncome { + skipRow = true + } + moneyClass = "negative" + } + ac.Symbol = v.Currency.Value.(string) + row.WriteString(fmt.Sprintf("<td class='%s %s'>%v</td>", "money", moneyClass, ac.FormatMoney(c.Value))) + case 2: + row.WriteString(fmt.Sprintf("<td><span>%v</span> / <span>%v</span></td>", c.Value, v.BankCode.Value)) + default: + row.WriteString(fmt.Sprintf("<td>%v</td>", c.Value)) + } + } + + if !skipRow { + rows.WriteString("<tr>") + rows.WriteString(row.String()) + rows.WriteString("</tr>") + } + } + + var head bytes.Buffer + indexes := make([]int, 0, len(headers)) + for i := range headers { + indexes = append(indexes, i) + } + sort.Ints(indexes) + for _, i := range indexes { + name := headers[i] + head.WriteString(fmt.Sprintf("<th>%s</th>", name)) + } + + return fmt.Sprintf(` + <thead> + %s + </thead> + <tbody> + %s + </tbody> + `, head.String(), rows.String()) +} + +func contains(s []int, e int) bool { + for _, a := range s { + if a == e { + return true + } + } + return false +} diff --git a/token.go b/token.go @@ -0,0 +1,45 @@ +package main + +import ( + "bufio" + "io" + "strings" +) + +type token struct { + name string + token string + config config +} + +type config struct { + onlyIncome bool +} + +func parseTokens(tokens io.Reader) ([]token, error) { + var o []token + scanner := bufio.NewScanner(tokens) + for scanner.Scan() { + text := scanner.Text() + if strings.HasPrefix(text, "#") { + continue + } + split := strings.Split(text, " ") + token := token{name: split[1], token: split[0]} + // config + if len(split) > 2 { + configs := strings.Split(split[2], ",") + for _, c := range configs { + switch c { + case "onlyIncome": + token.config = config{onlyIncome: true} + } + } + } + o = append(o, token) + } + if err := scanner.Err(); err != nil { + return []token{}, err + } + return o, nil +} diff --git a/token_test.go b/token_test.go @@ -0,0 +1,73 @@ +package main + +import ( + "fmt" + "strings" + "testing" +) + +func TestTokenParse(t *testing.T) { + tokensRaw := `token1 A +token2 B +token3 C` + expected := []token{{name: "A", token: "token1"}, {name: "B", token: "token2"}, {name: "C", token: "token3"}} + + actual, err := parseTokens(strings.NewReader(tokensRaw)) + if err != nil { + t.Error(err) + } + + if len(actual) != 3 { + t.Errorf("Expected 3 outputs, got %d", len(actual)) + } + if fmt.Sprint(actual) != fmt.Sprint(expected) { + t.Errorf("Maps mismatch, \nexpected %v,\ngot %v", expected, actual) + } +} + +func TestTokenParseWithComment(t *testing.T) { + tokensRaw := `token1 A +token2 B +#token3 C` + expected := []token{{name: "A", token: "token1"}, {name: "B", token: "token2"}} + + actual, err := parseTokens(strings.NewReader(tokensRaw)) + if err != nil { + t.Error(err) + } + + if len(actual) != 2 { + t.Errorf("Expected 2 outputs, got %d", len(actual)) + } + if fmt.Sprint(actual) != fmt.Sprint(expected) { + t.Errorf("Maps mismatch, \nexpected %v,\ngot %v", expected, actual) + } +} + +func TestTokenParseWithConfig(t *testing.T) { + tokensRaw := `token1 A onlyIncome` + expected := []token{{name: "A", token: "token1", config: config{onlyIncome: true}}} + + actual, err := parseTokens(strings.NewReader(tokensRaw)) + if err != nil { + t.Error(err) + } + + if fmt.Sprint(actual) != fmt.Sprint(expected) { + t.Errorf("Maps mismatch, \nexpected %v,\ngot %v", expected, actual) + } +} + +func TestTokenParseWithDifferentConfig(t *testing.T) { + tokensRaw := `token1 A banana` + expected := []token{{name: "A", token: "token1", config: config{onlyIncome: false}}} + + actual, err := parseTokens(strings.NewReader(tokensRaw)) + if err != nil { + t.Error(err) + } + + if fmt.Sprint(actual) != fmt.Sprint(expected) { + t.Errorf("Maps mismatch, \nexpected %v,\ngot %v", expected, actual) + } +} diff --git a/web/balance.template.html b/web/balance.template.html @@ -0,0 +1,158 @@ +<!doctype html> +<html> + +<head> + <meta charset="utf-8"> + <title>Milion</title> + <link rel="stylesheet" type="text/css" href="tabs.css" /> + <style> + body { + font-family: monospace; + margin: 0; + font-size: 12px; + } + + h1 { + text-align: center; + text-transform: uppercase; + } + + table { + border-collapse: collapse; + width: 100%; + } + + table tr:hover td { + background-color: #e6e6e6; + } + + table.head tr td:first-child { + text-align: right + } + + table.head tr td:last-child { + text-align: left + } + + th, + td { + padding: 8px; + text-align: left; + border-bottom: 1px solid #ddd; + white-space: nowrap; + } + + td.positive { + color: green; + } + + td.negative { + color: red; + } + + td.money { + text-align: right; + } + + @import url("https://fonts.googleapis.com/css2?family=Open+Sans:wght@600;700&display=swap"); + + * { + box-sizing: border-box; + } + + body { + margin: 0; + font-family: monospace; + } + + .mono { + font-family: monospace; + } + + .page-contain { + height: auto; + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: center; + background: #627084; + font-family: "Open Sans", sans-serif; + } + + .data-card { + display: flex; + flex-direction: column; + max-width: 20em; + overflow: hidden; + border-radius: 0.5em; + text-decoration: none; + background: white; + margin: 1em; + padding: 1em; + box-shadow: 0 1.5em 2.5em -0.5em rgba(0, 0, 0, 0.1); + transition: transform 0.45s ease; + } + + .data-card h3 { + color: #2e3c40; + font-size: 2em; + font-weight: 600; + line-height: 1; + padding-bottom: 0.5em; + margin: 0 0 0.142857143em; + border-bottom: 2px solid #363e49; + transition: border 0.45s ease; + } + + .data-card h4 { + color: #627084; + text-transform: uppercase; + font-size: 1.125em; + font-weight: 700; + line-height: 1; + letter-spacing: 0.1em; + margin-top: 0; + margin-bottom: 0; + } + + .data-card p { + color: #627084; + margin: 0; + } + + .data-card:hover { + transform: scale(1.02); + } + + .data-card.active { + background: #363e49; + } + + .data-card:hover h3 { + border-bottom-color: darkorange; + } + + .data-card.active h3 { + color: #ffffff; + border-bottom-color: darkorange; + } + + .data-card.active h4 { + color: #ffffff; + } + + .data-card.active p { + color: #ffffff; + } + </style> +</head> + +<body> + <section class="page-contain"> + {{CARDS}} + </section> + <h1>{{NAME}}</h1> + <table>{{LIST}}</table> +</body> + +</html> diff --git a/web/card.template.html b/web/card.template.html @@ -0,0 +1,5 @@ +<a href="{{LINK}}" class="data-card {{ACTIVE}}"> + <h3 class="mono">{{BALANCE}}</h3> + <h4>{{NAME}}</h4> + <p>{{ACC_ID}}</p> +</a> diff --git a/web/dashboard.template.html b/web/dashboard.template.html @@ -0,0 +1,89 @@ +<!doctype html> +<html> + +<head> + <meta charset="utf-8"> + <title>Milion</title> + <style> + @import url("https://fonts.googleapis.com/css2?family=Open+Sans:wght@600;700&display=swap"); + + * { + box-sizing: border-box; + } + + body { + margin: 0; + font-family: monospace; + } + + .mono { + font-family: monospace; + } + + .page-contain { + display: flex; + min-height: 100vh; + flex-wrap: wrap; + align-items: center; + justify-content: center; + background: #627084; + font-family: "Open Sans", sans-serif; + } + + .data-card { + display: flex; + flex-direction: column; + max-width: 20em; + overflow: hidden; + border-radius: 0.5em; + text-decoration: none; + background: white; + margin: 1em; + padding: 2em 2.5em; + box-shadow: 0 1.5em 2.5em -0.5em rgba(0, 0, 0, 0.1); + transition: transform 0.45s ease; + } + + .data-card h3 { + color: #2e3c40; + font-size: 2em; + font-weight: 600; + line-height: 1; + padding-bottom: 0.5em; + margin: 0 0 0.142857143em; + border-bottom: 2px solid #363e49; + transition: border 0.45s ease; + } + + .data-card h4 { + color: #627084; + text-transform: uppercase; + font-size: 1.125em; + font-weight: 700; + line-height: 1; + letter-spacing: 0.1em; + margin-top: 0; + } + + .data-card p { + color: #627084; + margin: 0; + } + + .data-card:hover { + transform: scale(1.02); + } + + .data-card:hover h3 { + border-bottom-color: darkorange; + } + </style> +</head> + +<body> + <section class="page-contain"> + {{CARDS}} + </section> +</body> + +</html> diff --git a/web/index.html b/web/index.html @@ -1,66 +0,0 @@ -<!doctype html> -<html> - -<head> - <meta charset="utf-8"> - <title>Milion</title> - <style> - table { - border-collapse: collapse; - width: 100%; - } - - table tr:hover td { - background-color: #e6e6e6; - } - - table.head tr td:first-child { - text-align: right - } - - table.head tr td:last-child { - text-align: left - } - - th, - td { - padding: 8px; - text-align: left; - border-bottom: 1px solid #ddd; - } - - td.positive { - color: green; - } - - td.negative { - color: red; - } - - td.money { - text-align: right; - } - </style> -</head> - -<body> - <h1>Milion</h1> - <table class="head"> - <tbody> - <tr> - <td>Číslo účtu:</td> - <td>{{ACC_ID}}</td> - </tr> - <tr> - <td>IBAN:</td> - <td>{{ACC_IBAN}}</td> - </tr> - <tr> - <td>Disponibilní zůstatek:</td> - <td>{{ACC_BAL}}</td> - </tr> - </table> - <table>{{LIST}}</table> -</body> - -</html>