Implement API-Server

This commit is contained in:
Knut Ahlers 2024-01-15 23:49:29 +01:00
commit 21cac8d2ed
Signed by: luzifer
SSH key fingerprint: SHA256:/xtE5lCgiRDQr8SLxHMS92ZBlACmATUmF1crK16Ks4E
12 changed files with 1541 additions and 0 deletions

41
go.mod Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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)
})
}

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

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