commit 21cac8d2ed5416ebbdba9679ef1fc5244c31417c Author: Knut Ahlers Date: Mon Jan 15 23:49:29 2024 +0100 Implement API-Server diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..5cab34b --- /dev/null +++ b/go.mod @@ -0,0 +1,41 @@ +module git.luzifer.io/luzifer/accounting + +go 1.21.5 + +require ( + github.com/Luzifer/go_helpers/v2 v2.22.0 + github.com/Luzifer/rconfig/v2 v2.5.0 + github.com/glebarez/sqlite v1.10.0 + github.com/google/uuid v1.5.0 + github.com/gorilla/mux v1.8.1 + github.com/pkg/errors v0.9.1 + github.com/sirupsen/logrus v1.9.3 + github.com/stretchr/testify v1.8.4 + gorm.io/driver/postgres v1.5.4 + gorm.io/gorm v1.25.5 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/glebarez/go-sqlite v1.21.2 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect + github.com/jackc/pgx/v5 v5.4.3 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/mattn/go-isatty v0.0.17 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/rogpeppe/go-internal v1.12.0 // indirect + github.com/spf13/pflag v1.0.5 // indirect + golang.org/x/crypto v0.14.0 // indirect + golang.org/x/sys v0.13.0 // indirect + golang.org/x/text v0.13.0 // indirect + gopkg.in/validator.v2 v2.0.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + modernc.org/libc v1.22.5 // indirect + modernc.org/mathutil v1.5.0 // indirect + modernc.org/memory v1.5.0 // indirect + modernc.org/sqlite v1.23.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..bf046eb --- /dev/null +++ b/go.sum @@ -0,0 +1,81 @@ +github.com/Luzifer/go_helpers/v2 v2.22.0 h1:rJrZkJDzAiq4J0RUbwPI7kQ5rUy7BYQ/GUpo3fSM0y0= +github.com/Luzifer/go_helpers/v2 v2.22.0/go.mod h1:cIIqMPu3NT8/6kHke+03hVznNDLLKVGA74Lz47CWJyA= +github.com/Luzifer/rconfig/v2 v2.5.0 h1:zx5lfQbNX3za4VegID97IeY+M+BmfgHxWJTYA94sxok= +github.com/Luzifer/rconfig/v2 v2.5.0/go.mod h1:eGWUPQeCPv/Pr/p0hjmwFgI20uqvwi/Szen69hUzGzU= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo= +github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k= +github.com/glebarez/sqlite v1.10.0 h1:u4gt8y7OND/cCei/NMHmfbLxF6xP2wgKcT/BJf2pYkc= +github.com/glebarez/sqlite v1.10.0/go.mod h1:IJ+lfSOmiekhQsFTJRx/lHtGYmCdtAiTaf5wI9u5uHA= +github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ= +github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo= +github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU= +github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= +github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.4.3 h1:cxFyXhxlvAifxnkKKdlxv8XqUf59tDlYjnV5YYfsJJY= +github.com/jackc/pgx/v5 v5.4.3/go.mod h1:Ig06C2Vu0t5qXC60W8sqIthScaEnFvojjj9dSljmHRA= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng= +github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= +github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +golang.org/x/crypto v0.14.0 h1:wBqGXzWJW6m1XrIKlAH0Hs1JJ7+9KBwnIO8v66Q9cHc= +golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= +golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/validator.v2 v2.0.1 h1:xF0KWyGWXm/LM2G1TrEjqOu4pa6coO9AlWSf3msVfDY= +gopkg.in/validator.v2 v2.0.1/go.mod h1:lIUZBlB3Im4s/eYp39Ry/wkR02yOPhZ9IwIRBjuPuG8= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/postgres v1.5.4 h1:Iyrp9Meh3GmbSuyIAGyjkN+n9K+GHX9b9MqsTL4EJCo= +gorm.io/driver/postgres v1.5.4/go.mod h1:Bgo89+h0CRcdA33Y6frlaHHVuTdOf87pmyzwW9C/BH0= +gorm.io/gorm v1.25.5 h1:zR9lOiiYf09VNh5Q1gphfyia1JpiClIWG9hQaxB/mls= +gorm.io/gorm v1.25.5/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= +modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE= +modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY= +modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ= +modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E= +modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds= +modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU= +modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM= +modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk= diff --git a/main.go b/main.go new file mode 100644 index 0000000..5595f14 --- /dev/null +++ b/main.go @@ -0,0 +1,82 @@ +package main + +import ( + "net/http" + "os" + "time" + + "git.luzifer.io/luzifer/accounting/pkg/api" + "git.luzifer.io/luzifer/accounting/pkg/database" + "github.com/gorilla/mux" + "github.com/pkg/errors" + "github.com/sirupsen/logrus" + + httpHelper "github.com/Luzifer/go_helpers/v2/http" + "github.com/Luzifer/rconfig/v2" +) + +var ( + cfg = struct { + DatabaseConnection string `flag:"database-connection" default:"file::memory:?cache=shared" description:"Connection string for the selected database type"` + DatabaseType string `flag:"database-type" default:"sqlite" description:"Type of the database to connect to (postgres, sqlite)"` + Listen string `flag:"listen" default:":3000" description:"Port/IP to listen on"` + LogLevel string `flag:"log-level" default:"info" description:"Log level (debug, info, warn, error, fatal)"` + VersionAndExit bool `flag:"version" default:"false" description:"Prints current version and exits"` + }{} + + version = "dev" +) + +func initApp() error { + rconfig.AutoEnv(true) + if err := rconfig.ParseAndValidate(&cfg); err != nil { + return errors.Wrap(err, "parsing cli options") + } + + l, err := logrus.ParseLevel(cfg.LogLevel) + if err != nil { + return errors.Wrap(err, "parsing log-level") + } + logrus.SetLevel(l) + + return nil +} + +func main() { + var err error + if err = initApp(); err != nil { + logrus.WithError(err).Fatal("initializing app") + } + + if cfg.VersionAndExit { + logrus.WithField("version", version).Info("accounting") + os.Exit(0) + } + + dbc, err := database.New(cfg.DatabaseType, cfg.DatabaseConnection) + if err != nil { + logrus.WithError(err).Fatal("connecting to database") + } + + router := mux.NewRouter() + api.RegisterHandler(router.PathPrefix("/api").Subrouter(), dbc, logrus.StandardLogger()) + + var hdl http.Handler = router + hdl = httpHelper.GzipHandler(hdl) + hdl = httpHelper.NewHTTPLogHandlerWithLogger(hdl, logrus.StandardLogger()) + + server := &http.Server{ + Addr: cfg.Listen, + Handler: hdl, + ReadHeaderTimeout: time.Second, + } + + logrus. + WithField("version", version). + WithField("addr", cfg.Listen). + Info("accounting starting") + + if err = server.ListenAndServe(); err != nil { + logrus.WithError(err).Fatal("running HTTP server") + } +} diff --git a/pkg/api/account.go b/pkg/api/account.go new file mode 100644 index 0000000..58ab326 --- /dev/null +++ b/pkg/api/account.go @@ -0,0 +1,172 @@ +package api + +import ( + "encoding/json" + "errors" + "net/http" + "strconv" + + "git.luzifer.io/luzifer/accounting/pkg/database" + "github.com/google/uuid" + "github.com/gorilla/mux" + "gorm.io/gorm" +) + +func (a apiServer) handleCreateAccount(w http.ResponseWriter, r *http.Request) { + var payload struct { + Name string `json:"name"` + Type database.AccountType `json:"type"` + } + + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + a.errorResponse(w, err, "parsing body", http.StatusBadRequest) + return + } + + if payload.Name == "" { + a.errorResponse(w, errors.New("empty name"), "validating request", http.StatusBadRequest) + return + } + + if !payload.Type.IsValid() { + a.errorResponse(w, errors.New("invalid account type"), "validating request", http.StatusBadRequest) + return + } + + acc, err := a.dbc.CreateAccount(payload.Name, payload.Type) + if err != nil { + a.errorResponse(w, err, "creating account", http.StatusInternalServerError) + return + } + + u, err := a.router.Get("GetAccount").URL("id", acc.ID.String()) + if err != nil { + a.errorResponse(w, err, "getting redirect url", http.StatusInternalServerError) + return + } + + http.Redirect(w, r, u.String(), http.StatusTemporaryRedirect) +} + +func (a apiServer) handleGetAccount(w http.ResponseWriter, r *http.Request) { + accid, err := uuid.Parse(mux.Vars(r)["id"]) + if err != nil { + a.errorResponse(w, err, "parsing id", http.StatusBadRequest) + return + } + + acc, err := a.dbc.GetAccount(accid) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + a.errorResponse(w, err, "getting account", http.StatusNotFound) + return + } + a.errorResponse(w, err, "getting account", http.StatusInternalServerError) + return + } + + a.jsonResponse(w, http.StatusOK, acc) +} + +func (a apiServer) handleListAccounts(w http.ResponseWriter, r *http.Request) { + var payload any + if r.URL.Query().Has("with-balances") { + accs, err := a.dbc.ListAccountBalances() + if err != nil { + a.errorResponse(w, err, "getting account balances", http.StatusInternalServerError) + return + } + payload = accs + } else { + at := database.AccountType(r.URL.Query().Get("account-type")) + if at.IsValid() { + accs, err := a.dbc.ListAccountsByType(at) + if err != nil { + a.errorResponse(w, err, "getting accounts", http.StatusInternalServerError) + return + } + payload = accs + } else { + accs, err := a.dbc.ListAccounts() + if err != nil { + a.errorResponse(w, err, "getting accounts", http.StatusInternalServerError) + return + } + payload = accs + } + } + + a.jsonResponse(w, http.StatusOK, payload) +} + +func (a apiServer) handleTransferMoney(w http.ResponseWriter, r *http.Request) { + var ( + amount float64 + err error + from, to, category uuid.UUID + ) + + if from, err = uuid.Parse(mux.Vars(r)["id"]); err != nil { + a.errorResponse(w, err, "parsing id", http.StatusBadRequest) + return + } + + if to, err = uuid.Parse(mux.Vars(r)["to"]); err != nil { + a.errorResponse(w, err, "parsing to", http.StatusBadRequest) + return + } + + if amount, err = strconv.ParseFloat(mux.Vars(r)["to"], 64); err != nil { + a.errorResponse(w, err, "parsing amount", http.StatusBadRequest) + return + } + + if r.URL.Query().Has("category") { + if category, err = uuid.Parse(mux.Vars(r)["category"]); err != nil { + a.errorResponse(w, err, "parsing category", http.StatusBadRequest) + return + } + } + + if category == uuid.Nil { + if err = a.dbc.TransferMoney(from, to, amount); err != nil { + a.errorResponse(w, err, "transferring money", http.StatusInternalServerError) + return + } + } + + if err = a.dbc.TransferMoneyWithCategory(from, to, amount, category); err != nil { + a.errorResponse(w, err, "transferring money", http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusNoContent) +} + +func (a apiServer) handleUpdateAccount(w http.ResponseWriter, r *http.Request) { + var ( + acctID uuid.UUID + err error + ) + + if acctID, err = uuid.Parse(mux.Vars(r)["id"]); err != nil { + a.errorResponse(w, err, "parsing id", http.StatusBadRequest) + return + } + + if r.URL.Query().Has("name") { + if err = a.dbc.UpdateAccountName(acctID, r.URL.Query().Get("name")); err != nil { + a.errorResponse(w, err, "renaming account", http.StatusInternalServerError) + return + } + } + + if r.URL.Query().Has("hidden") { + if err = a.dbc.UpdateAccountHidden(acctID, r.URL.Query().Get("hidden") == "true"); err != nil { + a.errorResponse(w, err, "updating account visibility", http.StatusInternalServerError) + return + } + } + + w.WriteHeader(http.StatusNoContent) +} diff --git a/pkg/api/api.go b/pkg/api/api.go new file mode 100644 index 0000000..2c6d846 --- /dev/null +++ b/pkg/api/api.go @@ -0,0 +1,91 @@ +// Package api defines an HTTP API for the database interface +package api + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + + "git.luzifer.io/luzifer/accounting/pkg/database" + "github.com/gorilla/mux" + "github.com/sirupsen/logrus" +) + +type ( + apiServer struct { + router *mux.Router + dbc *database.Client + log *logrus.Logger + } +) + +// RegisterHandler takes a (Sub)Router and registers the API onto that +// router +func RegisterHandler(apiRouter *mux.Router, dbc *database.Client, logger *logrus.Logger) { + as := apiServer{apiRouter, dbc, logger} + + apiRouter. + HandleFunc("/accounts", as.handleListAccounts). + Methods(http.MethodGet) + apiRouter. + HandleFunc("/accounts", as.handleCreateAccount). + Methods(http.MethodPost) + apiRouter. + HandleFunc("/accounts/{id}", as.handleGetAccount). + Methods(http.MethodGet). + Name("GetAccount") + apiRouter. + HandleFunc("/accounts/{id}", as.handleUpdateAccount). + Methods(http.MethodPatch) + apiRouter. + HandleFunc("/accounts/{id}/transactions", as.handleListTransactionsByAccount). + Methods(http.MethodGet) + apiRouter. + HandleFunc("/accounts/{id}/transfer/{to}", as.handleTransferMoney). + Methods(http.MethodPut) + + apiRouter. + HandleFunc("/transactions", as.handleCreateTransaction). + Methods(http.MethodPost) + apiRouter. + HandleFunc("/transactions/{id}", as.handleDeleteTransaction). + Methods(http.MethodDelete) + apiRouter. + HandleFunc("/transactions/{id}", as.handleGetTransactionByID). + Methods(http.MethodGet). + Name("GetTransactionByID") + apiRouter. + HandleFunc("/transactions/{id}", as.handleUpdateTransaction). + Methods(http.MethodPatch) +} + +func (a apiServer) errorResponse(w http.ResponseWriter, err error, desc string, status int) { + switch status { + case http.StatusBadRequest: + a.log.WithError(err).Debug(desc) + + case http.StatusNotFound: + // No need to log that + + default: + a.log.WithError(err).Error(desc) + } + + a.jsonResponse(w, status, struct { + Error string `json:"error"` + }{fmt.Sprintf("%s: %s", desc, err)}) +} + +func (apiServer) jsonResponse(w http.ResponseWriter, status int, data any) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-cache") + + body := new(bytes.Buffer) + if err := json.NewEncoder(body).Encode(data); err != nil { + http.Error(w, fmt.Sprintf("encoding response: %s", err), http.StatusInternalServerError) + } + + w.WriteHeader(status) + body.WriteTo(w) //nolint:errcheck,gosec,revive +} diff --git a/pkg/api/transaction.go b/pkg/api/transaction.go new file mode 100644 index 0000000..c47f13c --- /dev/null +++ b/pkg/api/transaction.go @@ -0,0 +1,131 @@ +package api + +import ( + "encoding/json" + "errors" + "net/http" + "time" + + "git.luzifer.io/luzifer/accounting/pkg/database" + "github.com/google/uuid" + "github.com/gorilla/mux" + "gorm.io/gorm" +) + +func (a apiServer) handleCreateTransaction(w http.ResponseWriter, r *http.Request) { + var payload database.Transaction + + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + a.errorResponse(w, err, "parsing body", http.StatusBadRequest) + return + } + + if payload.ID != uuid.Nil { + a.errorResponse(w, errors.New("transaction id must be unset"), "validating request", http.StatusBadRequest) + return + } + + tx, err := a.dbc.CreateTransaction(payload) + if err != nil { + a.errorResponse(w, err, "creating transaction", http.StatusInternalServerError) + return + } + + u, err := a.router.Get("GetTransactionByID").URL("id", tx.ID.String()) + if err != nil { + a.errorResponse(w, err, "getting redirect url", http.StatusInternalServerError) + return + } + + http.Redirect(w, r, u.String(), http.StatusTemporaryRedirect) +} + +func (a apiServer) handleDeleteTransaction(w http.ResponseWriter, r *http.Request) { + txid, err := uuid.Parse(mux.Vars(r)["id"]) + if err != nil { + a.errorResponse(w, err, "parsing id", http.StatusBadRequest) + return + } + + if err = a.dbc.DeleteTransaction(txid); err != nil { + a.errorResponse(w, err, "deleting transaction", http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusNoContent) +} + +func (a apiServer) handleGetTransactionByID(w http.ResponseWriter, r *http.Request) { + txid, err := uuid.Parse(mux.Vars(r)["id"]) + if err != nil { + a.errorResponse(w, err, "parsing id", http.StatusBadRequest) + return + } + + tx, err := a.dbc.GetTransactionByID(txid) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + a.errorResponse(w, err, "getting transaction", http.StatusNotFound) + return + } + a.errorResponse(w, err, "getting transaction", http.StatusInternalServerError) + return + } + + a.jsonResponse(w, http.StatusOK, tx) +} + +func (a apiServer) handleListTransactionsByAccount(w http.ResponseWriter, r *http.Request) { + accid, err := uuid.Parse(mux.Vars(r)["id"]) + if err != nil { + a.errorResponse(w, err, "parsing id", http.StatusBadRequest) + return + } + + var since time.Time + if v, err := time.Parse(time.RFC3339, r.URL.Query().Get("since")); err == nil { + since = v + } + + txs, err := a.dbc.ListTransactionsByAccount(accid, since) + if err != nil { + a.errorResponse(w, err, "getting transactions", http.StatusInternalServerError) + return + } + + a.jsonResponse(w, http.StatusOK, txs) +} + +func (a apiServer) handleUpdateTransaction(w http.ResponseWriter, r *http.Request) { + var ( + txID uuid.UUID + err error + ) + + if txID, err = uuid.Parse(mux.Vars(r)["id"]); err != nil { + a.errorResponse(w, err, "parsing id", http.StatusBadRequest) + return + } + + if r.URL.Query().Has("cleared") { + if err = a.dbc.UpdateTransactionCleared(txID, r.URL.Query().Get("cleared") == "true"); err != nil { + a.errorResponse(w, err, "updating transaction cleared", http.StatusInternalServerError) + return + } + } + + if r.URL.Query().Has("category") { + cat, err := uuid.Parse(r.URL.Query().Get("category")) + if err != nil { + a.errorResponse(w, err, "parsing category id", http.StatusBadRequest) + return + } + + if err = a.dbc.UpdateTransactionCategory(txID, cat); err != nil { + a.errorResponse(w, err, "updating transaction category", http.StatusInternalServerError) + return + } + } + + w.WriteHeader(http.StatusNoContent) +} diff --git a/pkg/database/constants.go b/pkg/database/constants.go new file mode 100644 index 0000000..5ab8d4e --- /dev/null +++ b/pkg/database/constants.go @@ -0,0 +1,13 @@ +package database + +import "github.com/google/uuid" + +const constAcctIDNamespace = "17de217e-94d7-4a9b-8833-ecca7f0eb6ca" + +var ( + // UnallocatedMoney is a category UUID which is automatically created + // during database migration phase and therefore always available + UnallocatedMoney = uuid.NewSHA1(uuid.MustParse(constAcctIDNamespace), []byte("unallocated-money")) + + invalidAcc = uuid.NewSHA1(uuid.MustParse(constAcctIDNamespace), []byte("INVALID ACCOUNT")) +) diff --git a/pkg/database/database.go b/pkg/database/database.go new file mode 100644 index 0000000..7badc44 --- /dev/null +++ b/pkg/database/database.go @@ -0,0 +1,460 @@ +// Package database has a database client to access the transactions +// and account database together with helpers to interact with those +// tables +package database + +import ( + "errors" + "fmt" + "time" + + "github.com/Luzifer/go_helpers/v2/backoff" + "github.com/glebarez/sqlite" + "github.com/google/uuid" + "github.com/sirupsen/logrus" + "gorm.io/driver/postgres" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +const dbMaxRetries = 5 + +type ( + // Client is the database client + Client struct { + db *gorm.DB + } +) + +// New creates a new database client for the given DSN +func New(dbtype, dsn string) (*Client, error) { + var conn gorm.Dialector + switch dbtype { + case "cockroach", "crdb", "postgres", "postgresql": + conn = postgres.Open(dsn) + + case "sqlite", "sqlite3": + conn = sqlite.Open(dsn) + + default: + return nil, fmt.Errorf("unknown db-type %s", dbtype) + } + + db, err := gorm.Open(conn, &gorm.Config{ + Logger: logger.New(loggerWriter{logrus.StandardLogger().WriterLevel(logrus.TraceLevel)}, logger.Config{ + SlowThreshold: time.Second, + Colorful: false, + IgnoreRecordNotFoundError: false, + ParameterizedQueries: false, + LogLevel: logger.Info, + }), + }) + if err != nil { + return nil, fmt.Errorf("opening database: %w", err) + } + + if err = db.AutoMigrate( + &Account{}, + &Transaction{}, + ); err != nil { + return nil, fmt.Errorf("migrating database schema: %w", err) + } + + if err = db.Save(&Account{ + BaseModel: BaseModel{ + ID: UnallocatedMoney, + }, + Name: "Unallocated Money", + Type: AccountTypeCategory, + Hidden: false, + }).Error; err != nil { + return nil, fmt.Errorf("ensuring unallocated money category: %w", err) + } + + return &Client{ + db: db, + }, nil +} + +// CreateAccount creates and returns a new account of the given type +func (c *Client) CreateAccount(name string, accType AccountType) (a Account, err error) { + a = Account{ + Name: name, + Type: accType, + } + + if !accType.IsValid() { + return a, fmt.Errorf("invalid account type %s", accType) + } + + if err = c.retryTx(func(db *gorm.DB) error { + return db.Save(&a).Error + }); err != nil { + return a, fmt.Errorf("creating account: %w", err) + } + + return a, nil +} + +// CreateTransaction takes a prepared transaction and stores it +func (c *Client) CreateTransaction(tx Transaction) (ntx Transaction, err error) { + if err = tx.Validate(c); err != nil { + return tx, fmt.Errorf("validating transaction: %w", err) + } + + if err = c.retryTx(func(db *gorm.DB) error { + return db.Save(&tx).Error + }); err != nil { + return tx, fmt.Errorf("creating transaction: %w", err) + } + + return tx, nil +} + +// DeleteTransaction deletes a transaction +func (c *Client) DeleteTransaction(id uuid.UUID) (err error) { + if err = c.retryTx(func(db *gorm.DB) error { + return db.Delete(&Transaction{}, "id = ?", id).Error + }); err != nil { + return fmt.Errorf("deleting transaction: %w", err) + } + + return nil +} + +// GetAccount retrieves an Account using its ID +func (c *Client) GetAccount(id uuid.UUID) (a Account, err error) { + if err = c.retryRead(func(db *gorm.DB) error { + return db.First(&a, "id = ?", id).Error + }); err != nil { + return a, fmt.Errorf("fetching account: %w", err) + } + + return a, nil +} + +// GetTransactionByID returns a single transaction by its ID +func (c *Client) GetTransactionByID(id uuid.UUID) (tx Transaction, err error) { + if err = c.retryRead(func(db *gorm.DB) error { + return db.First(&tx, "id = ?", id).Error + }); err != nil { + return tx, fmt.Errorf("getting transaction: %w", err) + } + + return tx, nil +} + +// ListAccountBalances returns a list of accounts with their +// corresponding balance +func (c *Client) ListAccountBalances() (a []AccountBalance, err error) { + accs, err := c.ListAccounts() + if err != nil { + return nil, fmt.Errorf("listing accounts: %w", err) + } + + for _, acc := range accs { + if err = c.retryRead(func(db *gorm.DB) error { + q := db. + Model(&Transaction{}) + + if acc.Type == AccountTypeCategory { + q = q.Where("category = ?", acc.ID) + } else { + q = q.Where("account = ?", acc.ID) + } + + ab := AccountBalance{ + Account: acc, + Balance: 0, + } + + var v *float64 + if err = q. + Select("sum(amount)"). + Scan(&v). + Error; err != nil { + return fmt.Errorf("getting sum: %w", err) + } + + if v != nil { + ab.Balance = *v + } + + a = append(a, ab) + return nil + }); err != nil { + return nil, fmt.Errorf("getting account balance for %s: %w", acc.ID, err) + } + } + + return a, nil +} + +// ListAccounts returns a list of all accounts +func (c *Client) ListAccounts() (a []Account, err error) { + if err = c.retryRead(func(db *gorm.DB) error { + return db.Find(&a, "hidden = ?", false).Error + }); err != nil { + return a, fmt.Errorf("listing accounts: %w", err) + } + + return a, nil +} + +// ListAccountsByType returns a list of all accounts of the given type +func (c *Client) ListAccountsByType(at AccountType) (a []Account, err error) { + if err = c.retryRead(func(db *gorm.DB) error { + return db.Find(&a, "type = ?", at).Error + }); err != nil { + return a, fmt.Errorf("listing accounts: %w", err) + } + + return a, nil +} + +// ListTransactionsByAccount retrieves all transactions for an account +// or category +func (c *Client) ListTransactionsByAccount(acc uuid.UUID, since time.Time) (txs []Transaction, err error) { + if err = c.retryRead(func(db *gorm.DB) error { + return db. + Where("time >= ?", since). + Find(&txs, "account = ? OR category = ?", acc, acc). + Error + }); err != nil { + return txs, fmt.Errorf("listing transactions: %w", err) + } + + return txs, nil +} + +// TransferMoney creates new Transactions for the given account +// transfer. The account type of the from and to account must match +// for this to work. +func (c *Client) TransferMoney(from, to uuid.UUID, amount float64) (err error) { + var fromAcc, toAcc Account + + if fromAcc, err = c.GetAccount(from); err != nil { + return fmt.Errorf("getting source account: %w", err) + } + + if toAcc, err = c.GetAccount(to); err != nil { + return fmt.Errorf("getting target account: %w", err) + } + + if fromAcc.Type != toAcc.Type { + return fmt.Errorf("account type mismatch: %s != %s", fromAcc.Type, toAcc.Type) + } + + var txs []*Transaction + switch fromAcc.Type { + case AccountTypeBudget, AccountTypeTracking: + // Create TX with null-category + txs = []*Transaction{ + { + Time: time.Now().UTC(), + Description: fmt.Sprintf("Transfer: %s → %s", fromAcc.Name, toAcc.Name), + Amount: -amount, + Account: uuid.NullUUID{UUID: from, Valid: true}, + Category: uuid.NullUUID{}, + Cleared: false, + }, + { + Time: time.Now().UTC(), + Description: fmt.Sprintf("Transfer: %s → %s", fromAcc.Name, toAcc.Name), + Amount: amount, + Account: uuid.NullUUID{UUID: to, Valid: true}, + Category: uuid.NullUUID{}, + Cleared: false, + }, + } + + case AccountTypeCategory: + // Create TX with null-account + txs = []*Transaction{ + { + Time: time.Now().UTC(), + Description: fmt.Sprintf("Transfer: %s → %s", fromAcc.Name, toAcc.Name), + Amount: -amount, + Account: uuid.NullUUID{}, + Category: uuid.NullUUID{UUID: from, Valid: true}, + Cleared: false, + }, + { + Time: time.Now().UTC(), + Description: fmt.Sprintf("Transfer: %s → %s", fromAcc.Name, toAcc.Name), + Amount: amount, + Account: uuid.NullUUID{}, + Category: uuid.NullUUID{UUID: to, Valid: true}, + Cleared: false, + }, + } + } + + if err = c.retryTx(func(tx *gorm.DB) (err error) { + for _, t := range txs { + if err = tx.Save(t).Error; err != nil { + return fmt.Errorf("saving transaction: %w", err) + } + } + + return nil + }); err != nil { + return fmt.Errorf("creating transactions: %w", err) + } + + return nil +} + +// TransferMoneyWithCategory creates new Transactions for the given +// account transfer. This is not possible for category type accounts. +func (c *Client) TransferMoneyWithCategory(from, to uuid.UUID, amount float64, category uuid.UUID) (err error) { + var fromAcc, toAcc Account + + if fromAcc, err = c.GetAccount(from); err != nil { + return fmt.Errorf("getting source account: %w", err) + } + + if toAcc, err = c.GetAccount(to); err != nil { + return fmt.Errorf("getting target account: %w", err) + } + + if fromAcc.Type == AccountTypeCategory || toAcc.Type == AccountTypeCategory { + return fmt.Errorf("transfer contained category-type account") + } + + if err = c.retryTx(func(tx *gorm.DB) (err error) { + fromTx := Transaction{ + Time: time.Now().UTC(), + Description: fmt.Sprintf("Transfer: %s → %s", fromAcc.Name, toAcc.Name), + Amount: -amount, + Account: uuid.NullUUID{UUID: from, Valid: true}, + Category: uuid.NullUUID{}, + Cleared: false, + } + + if fromAcc.Type == AccountTypeBudget { + fromTx.Category = uuid.NullUUID{UUID: category, Valid: true} + } + + toTx := Transaction{ + Time: time.Now().UTC(), + Description: fmt.Sprintf("Transfer: %s → %s", fromAcc.Name, toAcc.Name), + Amount: amount, + Account: uuid.NullUUID{UUID: to, Valid: true}, + Category: uuid.NullUUID{}, + Cleared: false, + } + + if toAcc.Type == AccountTypeBudget { + toTx.Category = uuid.NullUUID{UUID: category, Valid: true} + } + + for _, t := range []*Transaction{&fromTx, &toTx} { + if err = tx.Save(t).Error; err != nil { + return fmt.Errorf("saving transaction: %w", err) + } + } + + return nil + }); err != nil { + return fmt.Errorf("creating transactions: %w", err) + } + + return nil +} + +// UpdateAccountHidden updates the hidden flag for the given Account +func (c *Client) UpdateAccountHidden(id uuid.UUID, hidden bool) (err error) { + if err = c.retryTx(func(db *gorm.DB) error { + return db. + Model(&Account{}). + Where("id = ?", id). + Update("hidden", hidden). + Error + }); err != nil { + return fmt.Errorf("updating account: %w", err) + } + + return nil +} + +// UpdateAccountName sets a new name for the given account ID +func (c *Client) UpdateAccountName(id uuid.UUID, name string) (err error) { + if err = c.retryTx(func(db *gorm.DB) error { + return db. + Model(&Account{}). + Where("id = ?", id). + Update("name", name). + Error + }); err != nil { + return fmt.Errorf("updating account: %w", err) + } + + return nil +} + +// UpdateTransactionCategory modifies the category of the given +// transaction. (It is not possible to remove a category with this) +func (c *Client) UpdateTransactionCategory(id uuid.UUID, cat uuid.UUID) (err error) { + if err = c.retryTx(func(db *gorm.DB) error { + var tx Transaction + if err = db.First(&tx, "id = ?", id).Error; err != nil { + return fmt.Errorf("fetching transaction: %w", err) + } + + tx.Category = uuid.NullUUID{UUID: cat, Valid: true} + if err = tx.Validate(c); err != nil { + return fmt.Errorf("validating transaction: %w", err) + } + + if err = db. + Save(&tx). + Error; err != nil { + return fmt.Errorf("saving transaction: %w", err) + } + + return nil + }); err != nil { + return fmt.Errorf("updating transaction: %w", err) + } + + return nil +} + +// UpdateTransactionCleared modifies the "cleared" flag for the given +// transaction +func (c *Client) UpdateTransactionCleared(id uuid.UUID, cleared bool) (err error) { + if err = c.retryTx(func(db *gorm.DB) error { + return db. + Model(&Transaction{}). + Where("id = ?", id). + Update("cleared", cleared). + Error + }); err != nil { + return fmt.Errorf("updating transaction: %w", err) + } + + return nil +} + +func (c *Client) retryRead(fn func(db *gorm.DB) error) error { + //nolint:wrapcheck + return backoff.NewBackoff(). + WithMaxIterations(dbMaxRetries). + Retry(func() error { + err := fn(c.db) + if errors.Is(err, gorm.ErrRecordNotFound) { + return backoff.NewErrCannotRetry(err) + } + return err + }) +} + +func (c *Client) retryTx(fn func(db *gorm.DB) error) error { + //nolint:wrapcheck + return backoff.NewBackoff(). + WithMaxIterations(dbMaxRetries). + Retry(func() error { + return c.db.Transaction(fn) + }) +} diff --git a/pkg/database/database_test.go b/pkg/database/database_test.go new file mode 100644 index 0000000..7fc1639 --- /dev/null +++ b/pkg/database/database_test.go @@ -0,0 +1,238 @@ +package database + +import ( + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const testDSN = "file::memory:?cache=shared" + +// const testDSN = "/tmp/test.db" +func TestCreateDB(t *testing.T) { + _, err := New("sqlite", testDSN) + require.NoError(t, err) + + _, err = New("IDoNotExist", testDSN) + require.Error(t, err) +} + +func TestAccountManagement(t *testing.T) { + dbc, err := New("sqlite", testDSN) + require.NoError(t, err) + + // Try to create invalid account type + _, err = dbc.CreateAccount("test", AccountType("foobar")) + require.Error(t, err) + + // Create account for testing and validate ID + act, err := dbc.CreateAccount("test", AccountTypeBudget) + assert.NoError(t, err) + assert.NotEqual(t, uuid.Nil, act.ID) + + // Store ID + actID := act.ID + + // Fetch account by ID + act, err = dbc.GetAccount(actID) + assert.NoError(t, err) + assert.Equal(t, actID, act.ID) + assert.Equal(t, "test", act.Name) + + // List all accounts + accs, err := dbc.ListAccounts() + assert.NoError(t, err) + assert.Len(t, accs, 2) + + // Hide account and list again + assert.NoError(t, dbc.UpdateAccountHidden(actID, true)) + accs, err = dbc.ListAccounts() + assert.NoError(t, err) + assert.Len(t, accs, 1) + + // Unhide account and list again + assert.NoError(t, dbc.UpdateAccountHidden(actID, false)) + accs, err = dbc.ListAccounts() + assert.NoError(t, err) + assert.Len(t, accs, 2) + + // List accounts from other type + accs, err = dbc.ListAccountsByType(AccountTypeCategory) + assert.NoError(t, err) + assert.Len(t, accs, 1) + + // List accounts from existing type + accs, err = dbc.ListAccountsByType(AccountTypeBudget) + assert.NoError(t, err) + assert.Len(t, accs, 1) + + // Rename account + assert.NoError(t, dbc.UpdateAccountName(actID, "renamed")) + act, err = dbc.GetAccount(actID) + assert.NoError(t, err) + assert.Equal(t, actID, act.ID) + assert.Equal(t, "renamed", act.Name) +} + +//nolint:funlen +func TestTransactions(t *testing.T) { + dbc, err := New("sqlite", testDSN) + require.NoError(t, err) + + checkAcctBal := func(bals []AccountBalance, act uuid.UUID, bal float64) { + for _, b := range bals { + if b.ID == act { + assert.Equal(t, bal, b.Balance) + return + } + } + + t.Errorf("account %s balance not found", act) + } + + // Set up some accounts for testing + tb1, err := dbc.CreateAccount("test1", AccountTypeBudget) + require.NoError(t, err) + tb2, err := dbc.CreateAccount("test2", AccountTypeBudget) + require.NoError(t, err) + tt, err := dbc.CreateAccount("test", AccountTypeTracking) + require.NoError(t, err) + tc, err := dbc.CreateAccount("test", AccountTypeCategory) + require.NoError(t, err) + + // Try to enter an invalid tx + _, err = dbc.CreateTransaction(Transaction{ + Payee: "ACME Inc.", + Description: "Monthly Income", + Amount: 1000, + Account: uuid.NullUUID{UUID: tb1.ID, Valid: true}, + Category: uuid.NullUUID{UUID: UnallocatedMoney, Valid: true}, + Cleared: true, + }) + require.Error(t, err) + + // Lets earn some money + tx, err := dbc.CreateTransaction(Transaction{ + Time: time.Now(), + Payee: "ACME Inc.", + Description: "Monthly Income", + Amount: 1000, + Account: uuid.NullUUID{UUID: tb1.ID, Valid: true}, + Category: uuid.NullUUID{UUID: UnallocatedMoney, Valid: true}, + Cleared: true, + }) + require.NoError(t, err) + assert.NotEqual(t, uuid.Nil, tx.ID) + + // Now we should have money… + bals, err := dbc.ListAccountBalances() + require.NoError(t, err) + checkAcctBal(bals, tb1.ID, 1000) + checkAcctBal(bals, tb2.ID, 0) + checkAcctBal(bals, tt.ID, 0) + checkAcctBal(bals, tc.ID, 0) + checkAcctBal(bals, UnallocatedMoney, 1000) + + // Lets redistribute the money + require.NoError(t, dbc.TransferMoney(UnallocatedMoney, tc.ID, 500)) + bals, err = dbc.ListAccountBalances() + require.NoError(t, err) + checkAcctBal(bals, tb1.ID, 1000) + checkAcctBal(bals, tb2.ID, 0) + checkAcctBal(bals, tt.ID, 0) + checkAcctBal(bals, tc.ID, 500) + checkAcctBal(bals, UnallocatedMoney, 500) + + // Now transfer some money to another budget account + require.NoError(t, dbc.TransferMoney(tb1.ID, tb2.ID, 100)) + bals, err = dbc.ListAccountBalances() + require.NoError(t, err) + checkAcctBal(bals, tb1.ID, 900) + checkAcctBal(bals, tb2.ID, 100) + checkAcctBal(bals, tt.ID, 0) + checkAcctBal(bals, tc.ID, 500) + checkAcctBal(bals, UnallocatedMoney, 500) + + // And some to a tracking account (needs category) + require.NoError(t, dbc.TransferMoneyWithCategory(tb1.ID, tt.ID, 100, tc.ID)) + bals, err = dbc.ListAccountBalances() + require.NoError(t, err) + checkAcctBal(bals, tb1.ID, 800) + checkAcctBal(bals, tb2.ID, 100) + checkAcctBal(bals, tt.ID, 100) + checkAcctBal(bals, tc.ID, 400) + checkAcctBal(bals, UnallocatedMoney, 500) + + // We might also spend money + lltx, err := dbc.CreateTransaction(Transaction{ + Time: time.Now(), + Payee: "Landlord", + Description: "Rent", + Amount: -100, + Account: uuid.NullUUID{UUID: tb1.ID, Valid: true}, + Category: uuid.NullUUID{UUID: tc.ID, Valid: true}, + Cleared: false, + }) + require.NoError(t, err) + assert.False(t, lltx.Cleared) + bals, err = dbc.ListAccountBalances() + require.NoError(t, err) + checkAcctBal(bals, tb1.ID, 700) + checkAcctBal(bals, tb2.ID, 100) + checkAcctBal(bals, tt.ID, 100) + checkAcctBal(bals, tc.ID, 300) + checkAcctBal(bals, UnallocatedMoney, 500) + + // List transactions + txs, err := dbc.ListTransactionsByAccount(tb1.ID, time.Time{}) + require.NoError(t, err) + assert.Len(t, txs, 4) + + txs, err = dbc.ListTransactionsByAccount(UnallocatedMoney, time.Time{}) + require.NoError(t, err) + assert.Len(t, txs, 2) + + // Oh, wrong category + require.NoError(t, dbc.UpdateTransactionCategory(lltx.ID, UnallocatedMoney)) + bals, err = dbc.ListAccountBalances() + require.NoError(t, err) + checkAcctBal(bals, tb1.ID, 700) + checkAcctBal(bals, tb2.ID, 100) + checkAcctBal(bals, tt.ID, 100) + checkAcctBal(bals, tc.ID, 400) + checkAcctBal(bals, UnallocatedMoney, 400) + + // Lets try to move it to a broken category + require.Error(t, dbc.UpdateTransactionCategory(lltx.ID, tt.ID)) + + // Lets try to move an account instead of a tx + require.Error(t, dbc.UpdateTransactionCategory(tb1.ID, UnallocatedMoney)) + + // Clear the tx + require.NoError(t, dbc.UpdateTransactionCleared(lltx.ID, true)) + lltx, err = dbc.GetTransactionByID(lltx.ID) + require.NoError(t, err) + assert.True(t, lltx.Cleared) + + // We made an error and didn't pay the landlord + require.NoError(t, dbc.DeleteTransaction(lltx.ID)) + bals, err = dbc.ListAccountBalances() + require.NoError(t, err) + checkAcctBal(bals, tb1.ID, 800) + checkAcctBal(bals, tb2.ID, 100) + checkAcctBal(bals, tt.ID, 100) + checkAcctBal(bals, tc.ID, 400) + checkAcctBal(bals, UnallocatedMoney, 500) + + // Get a deleted transaction + _, err = dbc.GetTransactionByID(lltx.ID) + require.Error(t, err) + + // List transactions + txs, err = dbc.ListTransactionsByAccount(tb1.ID, time.Time{}) + require.NoError(t, err) + assert.Len(t, txs, 3) +} diff --git a/pkg/database/logger.go b/pkg/database/logger.go new file mode 100644 index 0000000..f693516 --- /dev/null +++ b/pkg/database/logger.go @@ -0,0 +1,14 @@ +package database + +import ( + "fmt" + "io" +) + +type ( + loggerWriter struct{ io.Writer } +) + +func (l loggerWriter) Printf(format string, data ...any) { + fmt.Fprintf(l.Writer, format, data...) +} diff --git a/pkg/database/schema.go b/pkg/database/schema.go new file mode 100644 index 0000000..23dbdca --- /dev/null +++ b/pkg/database/schema.go @@ -0,0 +1,124 @@ +package database + +import ( + "errors" + "fmt" + "time" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +type ( + // Account represents a budget, tracking or category account - in + // general something holding money through the sum of transactions + Account struct { + BaseModel + Name string `json:"name"` + Type AccountType `json:"type"` + Hidden bool `json:"hidden"` + } + + // AccountBalance wraps an Account and adds the balance + AccountBalance struct { + Account + Balance float64 `json:"balance"` + } + + // AccountType represents the type of an account + AccountType string + + // Transaction represents some money movement between, from + // or to accounts + Transaction struct { + BaseModel + Time time.Time `json:"time"` + Payee string `json:"payee"` + Description string `json:"description"` + Amount float64 `json:"amount"` + Account uuid.NullUUID `gorm:"type:uuid" json:"account"` + Category uuid.NullUUID `gorm:"type:uuid" json:"category"` + Cleared bool `json:"cleared"` + } + + // BaseModel is used internally in all other models for common fields + BaseModel struct { + ID uuid.UUID `gorm:"type:uuid" json:"id"` + CreatedAt time.Time `json:"-"` + UpdatedAt time.Time `json:"-"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + } +) + +// Known values of the AccountType enum +const ( + AccountTypeBudget AccountType = "budget" + AccountTypeCategory AccountType = "category" + AccountTypeTracking AccountType = "tracking" +) + +// IsValid checks whether the given AccountType belongs to the known +// types +func (a AccountType) IsValid() bool { + for _, kat := range []AccountType{ + AccountTypeBudget, + AccountTypeCategory, + AccountTypeTracking, + } { + if kat == a { + return true + } + } + + return false +} + +// BeforeCreate ensures the object UUID is filled +func (b *BaseModel) BeforeCreate(*gorm.DB) (err error) { + b.ID = uuid.New() + return nil +} + +// Validate executes some basic checks on the transaction +func (t Transaction) Validate(c *Client) (err error) { + var errs []error + + if t.Time.IsZero() { + errs = append(errs, fmt.Errorf("time is zero")) + } + + if !t.Account.Valid && !t.Category.Valid { + errs = append(errs, fmt.Errorf("account and category are null")) + } + + if t.Amount == 0 { + errs = append(errs, fmt.Errorf("amount is zero")) + } + + var acc, cat Account + if t.Account.Valid { + if acc, err = c.GetAccount(t.Account.UUID); err != nil { + return fmt.Errorf("fetching account: %w", err) + } + } + + if t.Category.Valid { + if cat, err = c.GetAccount(t.Category.UUID); err != nil { + return fmt.Errorf("fetching category: %w", err) + } + } + + if acc.Type == AccountTypeBudget && !t.Category.Valid { + errs = append(errs, fmt.Errorf("budget account transactions need a category")) + } + + if acc.Type == AccountTypeTracking && t.Category.Valid { + errs = append(errs, fmt.Errorf("tracking account transactions must not have a category")) + } + + if t.Category.Valid && cat.Type != AccountTypeCategory { + errs = append(errs, fmt.Errorf("category is not of type category")) + } + + return errors.Join(errs...) +} diff --git a/pkg/database/schema_test.go b/pkg/database/schema_test.go new file mode 100644 index 0000000..f0a889f --- /dev/null +++ b/pkg/database/schema_test.go @@ -0,0 +1,94 @@ +package database + +import ( + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestTransactionValidateErrs(t *testing.T) { + dbc, err := New("sqlite", testDSN) + require.NoError(t, err) + + // We need one test account of each type + actB, err := dbc.CreateAccount("test", AccountTypeBudget) + require.NoError(t, err) + actT, err := dbc.CreateAccount("test", AccountTypeTracking) + require.NoError(t, err) + + assert.Error(t, Transaction{ + Time: time.Time{}, // ERR: Zero time + Payee: "test", + Description: "test", + Amount: 10, + Account: uuid.NullUUID{UUID: actB.ID, Valid: true}, + Category: uuid.NullUUID{UUID: UnallocatedMoney, Valid: true}, + Cleared: false, + }.Validate(dbc)) + + assert.Error(t, Transaction{ + Time: time.Now(), + Payee: "test", + Description: "test", + Amount: 10, + Account: uuid.NullUUID{}, // ERR: Both null + Category: uuid.NullUUID{}, // ERR: Both null + }.Validate(dbc)) + + assert.Error(t, Transaction{ + Time: time.Now(), + Payee: "test", + Description: "test", + Amount: 0, // ERR: Zero + Account: uuid.NullUUID{UUID: actB.ID, Valid: true}, + Category: uuid.NullUUID{UUID: UnallocatedMoney, Valid: true}, + }.Validate(dbc)) + + assert.Error(t, Transaction{ + Time: time.Now(), + Payee: "test", + Description: "test", + Amount: 50, + Account: uuid.NullUUID{UUID: actB.ID, Valid: true}, + Category: uuid.NullUUID{}, // ERR: Budget without cat + }.Validate(dbc)) + + assert.Error(t, Transaction{ + Time: time.Now(), + Payee: "test", + Description: "test", + Amount: 50, + Account: uuid.NullUUID{UUID: actT.ID, Valid: true}, + Category: uuid.NullUUID{UUID: UnallocatedMoney, Valid: true}, // ERR: Tracking with cat + }.Validate(dbc)) + + assert.Error(t, Transaction{ + Time: time.Now(), + Payee: "test", + Description: "test", + Amount: 50, + Account: uuid.NullUUID{UUID: actB.ID, Valid: true}, + Category: uuid.NullUUID{UUID: actT.ID, Valid: true}, // ERR: Cat is not cat + }.Validate(dbc)) + + assert.Error(t, Transaction{ + Time: time.Now(), + Payee: "test", + Description: "test", + Amount: 50, + Account: uuid.NullUUID{UUID: invalidAcc, Valid: true}, // ERR: Account does not exist + Category: uuid.NullUUID{UUID: UnallocatedMoney, Valid: true}, + }.Validate(dbc)) + + assert.Error(t, Transaction{ + Time: time.Now(), + Payee: "test", + Description: "test", + Amount: 50, + Account: uuid.NullUUID{UUID: actB.ID, Valid: true}, + Category: uuid.NullUUID{UUID: invalidAcc, Valid: true}, // ERR: Cat does not exist + }.Validate(dbc)) +}