Compare commits

..

11 Commits

Author SHA1 Message Date
bel
e23cce19ad mobile 2024-03-03 09:14:17 -07:00
Bel LaPointe
a8157b2f1e gomod 2023-04-10 11:19:02 -06:00
Bel LaPointe
0939bc0c41 invert 2021-09-07 16:07:51 -06:00
Bel LaPointe
3e9349078c ui can create transaction, submits text instead of html 2021-08-30 11:14:41 -04:00
Bel LaPointe
1345071f0a add post transactions endpoint 2021-08-30 10:55:59 -04:00
Bel LaPointe
03ec3247f9 ledger.newtransaction 2021-08-30 10:14:35 -04:00
Bel LaPointe
8a7f97d230 add float 2021-08-09 09:54:11 -06:00
Bel LaPointe
273b466b43 pretty format money 2021-08-09 09:53:28 -06:00
Bel LaPointe
01fda04fde use dest dir for temp dir 2021-08-08 09:55:58 -06:00
bel
114a17245e log 2021-08-03 21:38:22 -06:00
bel
f7dd025347 gitignore exe 2021-08-03 21:33:07 -06:00
17 changed files with 329 additions and 31 deletions

1
.gitignore vendored Normal file → Executable file
View File

@@ -1 +1,2 @@
**/*.sw* **/*.sw*
ledger-ui

0
balance.go Normal file → Executable file
View File

10
go.mod Normal file
View File

@@ -0,0 +1,10 @@
module gogs.inhome.blapointe.com/bel/ledger-ui
go 1.20
require (
github.com/google/uuid v1.3.0
gogs.inhome.blapointe.com/local/args v0.0.0-20230410154220-44370f257b34
)
require gopkg.in/yaml.v2 v2.4.0 // indirect

8
go.sum Normal file
View File

@@ -0,0 +1,8 @@
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
gogs.inhome.blapointe.com/local/args v0.0.0-20230410154220-44370f257b34 h1:0tuX5dfOksiOQD1vbJjVNVTVxTTIng7UrUdSLF5T+Ao=
gogs.inhome.blapointe.com/local/args v0.0.0-20230410154220-44370f257b34/go.mod h1:YG9n3Clg7683ohkVnJK2hdX8bBS9EojIsd1qPZumX0Y=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=

18
ledger.go Normal file → Executable file
View File

@@ -8,6 +8,7 @@ import (
"io/ioutil" "io/ioutil"
"os" "os"
"path" "path"
"time"
) )
type Ledger struct { type Ledger struct {
@@ -24,6 +25,21 @@ func NewLedger(path string) (Ledger, error) {
}, err }, err
} }
func (ledger Ledger) NewTransaction() error {
transactions, err := ledger.Transactions()
if err != nil {
return err
}
transactions = append(transactions, Transaction{
Date: time.Now().Format("2006-01-02"),
Description: "?",
Payer: "?",
Payee: "?",
Amount: 0,
})
return ledger.SetTransactions(transactions)
}
func (ledger Ledger) SetTransaction(i int, transaction Transaction) error { func (ledger Ledger) SetTransaction(i int, transaction Transaction) error {
transactions, err := ledger.Transactions() transactions, err := ledger.Transactions()
if err != nil { if err != nil {
@@ -37,7 +53,7 @@ func (ledger Ledger) SetTransaction(i int, transaction Transaction) error {
} }
func (ledger Ledger) SetTransactions(transactions []Transaction) error { func (ledger Ledger) SetTransactions(transactions []Transaction) error {
f, err := ioutil.TempFile(os.TempDir(), path.Base(ledger.path)+".*") f, err := ioutil.TempFile(path.Dir(ledger.path), path.Base(ledger.path)+".*")
if err != nil { if err != nil {
return err return err
} }

55
ledger_test.go Normal file → Executable file
View File

@@ -2,7 +2,7 @@ package main
import ( import (
"fmt" "fmt"
"io" "io/ioutil"
"os" "os"
"path" "path"
"testing" "testing"
@@ -10,8 +10,19 @@ import (
"github.com/google/uuid" "github.com/google/uuid"
) )
func testLedgerFile(t *testing.T) string {
path := path.Join(t.TempDir(), t.Name()+".dat")
if b, err := ioutil.ReadFile("./testdata/ledger.dat"); err != nil {
t.Fatal(err)
} else if err := ioutil.WriteFile(path, b, os.ModePerm); err != nil {
t.Fatal(err)
}
return path
}
func TestLedgerTransactionsBalances(t *testing.T) { func TestLedgerTransactionsBalances(t *testing.T) {
ledger, err := NewLedger("./testdata/ledger.dat") path := testLedgerFile(t)
ledger, err := NewLedger(path)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -67,21 +78,7 @@ func TestLedgerSetTransaction(t *testing.T) {
Amount: 1.23, Amount: 1.23,
} }
} }
path := path.Join(t.TempDir(), "ledger.dat") path := testLedgerFile(t)
a, err := os.Open("./testdata/ledger.dat")
if err != nil {
t.Fatal(err)
}
b, err := os.Create(path)
if err != nil {
t.Fatal(err)
}
if _, err := io.Copy(b, a); err != nil {
t.Fatal(err)
}
a.Close()
b.Close()
ledger, err := NewLedger(path) ledger, err := NewLedger(path)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -109,3 +106,27 @@ func TestLedgerSetTransaction(t *testing.T) {
t.Fatal(want, got) t.Fatal(want, got)
} }
} }
func TestLedgerNewTransaction(t *testing.T) {
path := testLedgerFile(t)
ledger, err := NewLedger(path)
if err != nil {
t.Fatal(err)
}
transactions, err := ledger.Transactions()
if err != nil {
t.Fatal(err)
}
wantTransactions := append(transactions, newStubTransaction())
ledger.NewTransaction()
if err != nil {
t.Fatal(err)
}
gotTransactions, err := ledger.Transactions()
if err != nil {
t.Fatal(err)
}
if fmt.Sprint(wantTransactions) != fmt.Sprint(gotTransactions) {
t.Fatal(wantTransactions, gotTransactions)
}
}

4
main.go Normal file → Executable file
View File

@@ -2,7 +2,8 @@ package main
import ( import (
"fmt" "fmt"
"local/args" "gogs.inhome.blapointe.com/local/args"
"log"
"net/http" "net/http"
) )
@@ -18,6 +19,7 @@ func main() {
if err != nil { if err != nil {
panic(err) panic(err)
} }
log.Println("listening on", as.GetInt("p"))
if err := http.ListenAndServe(":"+fmt.Sprint(as.GetInt("p")), Server{ledger: ledger, debug: as.GetBool("debug")}); err != nil { if err := http.ListenAndServe(":"+fmt.Sprint(as.GetInt("p")), Server{ledger: ledger, debug: as.GetBool("debug")}); err != nil {
panic(err) panic(err)
} }

0
public/balances.json Normal file → Executable file
View File

80
public/index.html Normal file → Executable file
View File

@@ -1,7 +1,17 @@
<html> <html>
<header> <header>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/water.css@2/out/dark.css"> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/water.css@2/out/dark.css">
<style> <style>
body > div {
width: 100%;
}
.center {
text-align: center;
}
.right {
text-align: right;
}
#transactions > tbody > tr > td:first-child input { #transactions > tbody > tr > td:first-child input {
padding: 1ch; padding: 1ch;
display: inline-block; display: inline-block;
@@ -30,13 +40,22 @@
loadBalances(body.Balances) loadBalances(body.Balances)
}, null) }, null)
} }
function prettyMoney(money) {
try {
var f = parseFloat(money)
if (f) {
return Math.round(100.0*f) / 100.0
}
} catch { }
return money;
}
function loadBalances(balances) { function loadBalances(balances) {
var innerHTML = "" var innerHTML = ""
innerHTML += "<table>" innerHTML += "<table>"
for(var k in balances) { for(var k in balances) {
if(k.startsWith("Asset")) if(k.startsWith("Asset"))
innerHTML += `<tr><td>${k}</td><td>${balances[k]}</td></tr>` innerHTML += `<tr><td>${k}</td><td>${prettyMoney(balances[k])}</td></tr>`
} }
innerHTML += "</table>" innerHTML += "</table>"
@@ -68,7 +87,7 @@
one += " <tr>" one += " <tr>"
one += " <td></td><td></td>" one += " <td></td><td></td>"
for(var key of ["Payee", "Amount"]) for(var key of ["Payee", "Amount"])
one += ` <td contenteditable key=${JSON.stringify(key)}>${transaction[key]}</td>` one += ` <td contenteditable key=${JSON.stringify(key)}>${prettyMoney(transaction[key])}</td>`
one += " </tr>" one += " </tr>"
one += " <tr>" one += " <tr>"
one += " <td></td><td></td>" one += " <td></td><td></td>"
@@ -84,6 +103,25 @@
document.getElementById("transactions").innerHTML = innerHTML document.getElementById("transactions").innerHTML = innerHTML
} }
function newTransaction() {
var today = new Date()
var year = today.getFullYear()
var month = today.getMonth()+1
var day = today.getDate()
if (day < 10) {
day = `0${day}`
}
if (month < 10) {
month = `0${month}`
}
http("post", "/api/transactions", () => {init()}, JSON.stringify({
Date: `${year}-${month}-${day}`,
Description: "?",
Payer: "?",
Payee: "?",
Amount: 0,
}))
}
function saveTransaction(row) { function saveTransaction(row) {
const inputs = row.getElementsByTagName("td") const inputs = row.getElementsByTagName("td")
var kvs = {} var kvs = {}
@@ -92,29 +130,48 @@
if (!key) { if (!key) {
continue continue
} }
const value = inputs[i].innerHTML const value = (inputs[i].textContent || inputs[i].innerText).replaceAll("\n", " ").trim()
kvs[key] = value kvs[key] = value
if (!isNaN(value)) if (!isNaN(value))
kvs[key] = parseFloat(value) kvs[key] = parseFloat(value)
} }
http("put", "/api/transactions", () => {init()}, JSON.stringify(kvs)) http("put", "/api/transactions", () => {init()}, JSON.stringify(kvs))
} }
function setRowKeyValue(row, wantkey, wantvalue) { function getRowKeyValue(row, wantkey) {
const inputs = row.getElementsByTagName("td")
for (var i = 0; i < inputs.length; i++) {
const key = inputs[i].getAttribute("key")
if (key == wantkey) {
return inputs[i].innerText + ""
}
}
return null
}
function setRowKeyValues(row, wantKeysWantValues) {
const inputs = row.getElementsByTagName("td") const inputs = row.getElementsByTagName("td")
var kvs = {} var kvs = {}
for (var i = 0; i < inputs.length; i++) { for (var i = 0; i < inputs.length; i++) {
const key = inputs[i].getAttribute("key") const key = inputs[i].getAttribute("key")
if (key == wantkey) { if (key in wantKeysWantValues) {
inputs[i].innerHTML = wantvalue inputs[i].innerHTML = wantKeysWantValues[key]
break
} }
} }
saveTransaction(row) saveTransaction(row)
} }
zachsPayment = (x) => setRowKeyValue(x, "Payer", "AssetAccount:Zach") zachsPayment = (x) => {
belsPayment = (x) => setRowKeyValue(x, "Payer", "AssetAccount:Bel") setRowKeyValues(x, {
zachsCharge = (x) => setRowKeyValue(x, "Payee", "AssetAccount:Zach") "Payee": getRowKeyValue(x, "Payer"),
belsCharge = (x) => setRowKeyValue(x, "Payee", "AssetAccount:Bel") "Payer": "AssetAccount:Zach",
})
}
belsPayment = (x) => {
setRowKeyValues(x, {
"Payee": getRowKeyValue(x, "Payer"),
"Payer": "AssetAccount:Bel",
})
}
zachsCharge = (x) => setRowKeyValues(x, {"Payee": "AssetAccount:Zach"})
belsCharge = (x) => setRowKeyValues(x, {"Payee": "AssetAccount:Bel"})
function http(method, remote, callback, body) { function http(method, remote, callback, body) {
var xmlhttp = new XMLHttpRequest(); var xmlhttp = new XMLHttpRequest();
xmlhttp.onreadystatechange = function() { xmlhttp.onreadystatechange = function() {
@@ -136,6 +193,7 @@
<summary>Balances</summary> <summary>Balances</summary>
<div id="balances"></div> <div id="balances"></div>
</details> </details>
<div class="right"><button onclick="newTransaction();">New Transaction</button></div>
<table id="transactions"></table> <table id="transactions"></table>
</body> </body>
<footer> <footer>

26
server.go Normal file → Executable file
View File

@@ -22,6 +22,8 @@ func (server Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
switch r.Method + r.URL.Path { switch r.Method + r.URL.Path {
case "GET/api/transactions": case "GET/api/transactions":
server.getTransactions(w, r) server.getTransactions(w, r)
case "POST/api/transactions":
server.postTransactions(w, r)
case "PUT/api/transactions": case "PUT/api/transactions":
server.putTransactions(w, r) server.putTransactions(w, r)
case "GET/api/balances": case "GET/api/balances":
@@ -50,6 +52,30 @@ func (server Server) getTransactions(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(map[string]interface{}{"Transactions": transactions}) json.NewEncoder(w).Encode(map[string]interface{}{"Transactions": transactions})
} }
func (server Server) postTransactions(w http.ResponseWriter, r *http.Request) {
var request struct {
Transaction
}
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
server.err(w, r, err)
return
}
if err := server.ledger.NewTransaction(); err != nil {
server.err(w, r, err)
return
}
transactions, err := server.ledger.Transactions()
if err != nil {
server.err(w, r, err)
return
}
if err := server.ledger.SetTransaction(len(transactions)-1, request.Transaction); err != nil {
server.err(w, r, err)
return
}
json.NewEncoder(w).Encode(map[string]interface{}{"ok": true})
}
func (server Server) putTransactions(w http.ResponseWriter, r *http.Request) { func (server Server) putTransactions(w http.ResponseWriter, r *http.Request) {
var request struct { var request struct {
IDX int `json:"idx"` IDX int `json:"idx"`

48
server_test.go Normal file
View File

@@ -0,0 +1,48 @@
package main
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestServerPostTransactions(t *testing.T) {
path := testLedgerFile(t)
ledger, err := NewLedger(path)
if err != nil {
t.Fatal(err)
}
server := Server{ledger: ledger}
r := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{
"Date": "2099-02-03",
"Description": "test",
"Payer": "payer",
"Payee": "payee",
"Amount": 1.02
}`))
w := httptest.NewRecorder()
server.postTransactions(w, r)
if w.Code != http.StatusOK {
t.Fatal(w.Code)
}
if s := strings.TrimSpace(string(w.Body.Bytes())); s != `{"ok":true}` {
t.Fatal(s)
}
transactions, err := ledger.Transactions()
if err != nil {
t.Fatal(err)
}
if transactions[len(transactions)-1] != (Transaction{
Date: "2099-02-03",
Description: "test",
Payer: "payer",
Payee: "payee",
Amount: 1.02,
}) {
t.Fatal(transactions[len(transactions)-1])
}
}

75
testdata/2018-.b.dat vendored Normal file
View File

@@ -0,0 +1,75 @@
2021-08-07 UTAH INFRASTRUCTURE
Withdrawal:UTAHINFRASTRUCTURE $30.00
AssetAccount:Chase:1049
2021-08-07 HARMONS - OREM
Withdrawal:HARMONSOREM $8.73
AssetAccount:Chase:1049
2021-08-07 MACEY'S 8TH NORTH
Withdrawal:MACEYS8THNORTH $18.05
AssetAccount:Chase:1049
2021-08-07 BEEHIVE BROADBAND LL
Withdrawal:BEEHIVEBROADBANDLL $56.38
AssetAccount:Chase:1049
2021-08-07 CULLIGAN WATER CONDI
Withdrawal:CULLIGANWATERCONDI $35.00
AssetAccount:Chase:1049
2021-08-09 Payment
Withdrawal:Payment $121.38
AssetAccount:Bel
2021-08-12 HARMONS - OREM
Withdrawal:HARMONSOREM $12.82
AssetAccount:Chase:1049
2021-08-12 COSTCO WHSE #0484
Withdrawal:COSTCOWHSE0484 $76.02
AssetAccount:Chase:1049
2021-08-12 TRADER JOE'S #352
Withdrawal:TRADERJOES352 $64.57
AssetAccount:Chase:1049
2021-08-12 TARGET T-1754
Withdrawal:TARGETT1754 $56.12
AssetAccount:Chase:1049
2021-08-13 TARGET T-1754
Withdrawal:TARGETT1754 $44.66
AssetAccount:Chase:1049
2021-08-15 TRADER JOE'S #352
Withdrawal:TRADERJOES352 $7.70
AssetAccount:Chase:1049
2021-08-15 WALGREENS #11150
Withdrawal:WALGREENS11150 $16.96
AssetAccount:Chase:1049
2021-08-15 HARMONS - OREM
Withdrawal:HARMONSOREM $35.30
AssetAccount:Chase:1049
2021-08-16 Payment
Withdrawal:Payment $140.49
AssetAccount:Bel
2021-08-17 Payment
Withdrawal:Payment $20.63
AssetAccount:Bel
2021-08-19 REDMOND FARMS STORE
Withdrawal:REDMONDFARMSSTORE $42.07
AssetAccount:Chase:1049
2021-08-19 TRADER JOE'S #352
Withdrawal:TRADERJOES352 $52.93
AssetAccount:Chase:1049
2021-08-19 COSTCO WHSE #0484
Withdrawal:COSTCOWHSE0484 $140.93
AssetAccount:Chase:1049
2021-08-23 Payment
Withdrawal:Payment $457.21
AssetAccount:Bel
2021-08-28 TARGET T-1754
Withdrawal:TARGETT1754 $95.34
AssetAccount:Chase:1049
2021-09-01 MACEY'S 8TH NORTH
Withdrawal:MACEYS8THNORTH $16.41
AssetAccount:Chase:1049
2021-09-01 COSTCO WHSE #0484
Withdrawal:COSTCOWHSE0484 $167.81
AssetAccount:Chase:1049
2021-09-03 WALGREENS #12294
Withdrawal:WALGREENS12294 $16.60
AssetAccount:Chase:1049
2021-09-03 TST* COSTA VIDA - 01
Withdrawal:TSTCOSTAVIDA01 $20.78
AssetAccount:Chase:1049

2
testdata/ledger-groceries-custom.dat vendored Normal file → Executable file
View File

@@ -1,5 +1,5 @@
2021-01-01 groceries 2021-01-01 groceries
Withdrawal:Target $10 Withdrawal:Target $10.0000001
AssetAccount:Chase:8824 AssetAccount:Chase:8824
2021-01-05 bel vidya 2021-01-05 bel vidya
Withdrawal:Target $1 Withdrawal:Target $1

0
testdata/ledger.dat vendored Normal file → Executable file
View File

0
testdata/ledger.json vendored Normal file → Executable file
View File

15
transaction.go Normal file → Executable file
View File

@@ -8,6 +8,7 @@ import (
"io" "io"
"strconv" "strconv"
"strings" "strings"
"time"
) )
type Transaction struct { type Transaction struct {
@@ -18,6 +19,20 @@ type Transaction struct {
Amount float32 Amount float32
} }
const (
UnknownAccount = "?"
)
func newStubTransaction() Transaction {
return Transaction{
Date: time.Now().Format("2006-01-02"),
Description: UnknownAccount,
Payer: UnknownAccount,
Payee: UnknownAccount,
Amount: 0,
}
}
func readTransaction(r io.Reader) (Transaction, error) { func readTransaction(r io.Reader) (Transaction, error) {
lines := make([][]byte, 3) lines := make([][]byte, 3)
for i := range lines { for i := range lines {

18
transaction_test.go Normal file → Executable file
View File

@@ -6,6 +6,24 @@ import (
"testing" "testing"
) )
func TestNewStubTransaction(t *testing.T) {
want := Transaction{
Date: "1",
Amount: 0,
Payer: UnknownAccount,
Payee: UnknownAccount,
Description: UnknownAccount,
}
got := newStubTransaction()
if got.Date == "" {
t.Fatal(got.Date)
}
got.Date = want.Date
if want != got {
t.Fatal(want, got)
}
}
func TestWords(t *testing.T) { func TestWords(t *testing.T) {
input := ` input := `
hello world hello world