Implement API-Server
This commit is contained in:
commit
21cac8d2ed
12 changed files with 1541 additions and 0 deletions
41
go.mod
Normal file
41
go.mod
Normal file
|
@ -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
|
||||||
|
)
|
81
go.sum
Normal file
81
go.sum
Normal file
|
@ -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=
|
82
main.go
Normal file
82
main.go
Normal file
|
@ -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")
|
||||||
|
}
|
||||||
|
}
|
172
pkg/api/account.go
Normal file
172
pkg/api/account.go
Normal file
|
@ -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)
|
||||||
|
}
|
91
pkg/api/api.go
Normal file
91
pkg/api/api.go
Normal file
|
@ -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
|
||||||
|
}
|
131
pkg/api/transaction.go
Normal file
131
pkg/api/transaction.go
Normal file
|
@ -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)
|
||||||
|
}
|
13
pkg/database/constants.go
Normal file
13
pkg/database/constants.go
Normal file
|
@ -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"))
|
||||||
|
)
|
460
pkg/database/database.go
Normal file
460
pkg/database/database.go
Normal file
|
@ -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)
|
||||||
|
})
|
||||||
|
}
|
238
pkg/database/database_test.go
Normal file
238
pkg/database/database_test.go
Normal file
|
@ -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)
|
||||||
|
}
|
14
pkg/database/logger.go
Normal file
14
pkg/database/logger.go
Normal file
|
@ -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...)
|
||||||
|
}
|
124
pkg/database/schema.go
Normal file
124
pkg/database/schema.go
Normal file
|
@ -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...)
|
||||||
|
}
|
94
pkg/database/schema_test.go
Normal file
94
pkg/database/schema_test.go
Normal file
|
@ -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))
|
||||||
|
}
|
Loading…
Reference in a new issue