diff --git a/.drone.yml b/.drone.yml index 7f6e6f0..60faecb 100644 --- a/.drone.yml +++ b/.drone.yml @@ -8,7 +8,7 @@ steps: image: hub.javil.eu/budgeteer:dev pull: true commands: - - task + - task ci - name: docker image: plugins/docker diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..c8a3043 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,26 @@ +linters: + enable-all: true + disable: + - golint + - scopelint + - maligned + - interfacer + - wsl + - forbidigo + - nlreturn + - testpackage + - ifshort + - exhaustivestruct + - gci # not working, shows errors on freshly formatted file + - varnamelen +linters-settings: + errcheck: + exclude-functions: + - io/ioutil.ReadFile + - io.Copy(*bytes.Buffer) + - (*github.com/gin-gonic/gin.Context).AbortWithError + - (*github.com/gin-gonic/gin.Context).AbortWithError + - io.Copy(os.Stdout) + varnamelen: + ignore-decls: + - c *gin.Context diff --git a/.vscode/settings.json b/.vscode/settings.json index 153b65f..b61973e 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -2,5 +2,8 @@ "files.exclude": { "**/node_modules": true, "**/vendor": true + }, + "gopls": { + "formatting.gofumpt": true, } } \ No newline at end of file diff --git a/.woodpecker.yml b/.woodpecker.yml index 03a5388..2476945 100644 --- a/.woodpecker.yml +++ b/.woodpecker.yml @@ -4,7 +4,7 @@ pipeline: image: hub.javil.eu/budgeteer:dev pull: true commands: - - task + - task ci docker: image: plugins/docker diff --git a/Taskfile.yml b/Taskfile.yml index 3306503..3ddda78 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -33,12 +33,7 @@ tasks: sources: - ./go.mod - ./go.sum - - ./cmd/budgeteer/*.go - - ./*.go - - ./config/*.go - - ./http/*.go - - ./jwt/*.go - - ./postgres/*.go + - ./**/*.go - ./web/dist/**/* - ./postgres/schema/* generates: @@ -52,14 +47,25 @@ tasks: desc: Build budgeteer in dev mode deps: [gomod, sqlc] cmds: + - go vet + - go fmt + - golangci-lint run - task: build build-prod: desc: Build budgeteer in prod mode deps: [gomod, sqlc, frontend] cmds: + - go vet + - go fmt + - golangci-lint run - task: build + ci: + desc: Run CI build + cmds: + - task: build-prod + frontend: desc: Build vue frontend dir: web @@ -85,6 +91,8 @@ tasks: desc: Build budgeeter:dev sources: - ./docker/Dockerfile + - ./docker/build.sh + - ./web/package.json cmds: - docker build -t {{.IMAGE_NAME}}:dev . -f docker/Dockerfile - docker push {{.IMAGE_NAME}}:dev diff --git a/bcrypt/verifier.go b/bcrypt/verifier.go index 60c7a11..8df284f 100644 --- a/bcrypt/verifier.go +++ b/bcrypt/verifier.go @@ -1,23 +1,30 @@ -package bcrypt - -import "golang.org/x/crypto/bcrypt" - -// Verifier verifys passwords using Bcrypt -type Verifier struct { - cost int -} - -// Verify verifys a Password -func (bv *Verifier) Verify(password string, hashOnDb string) error { - return bcrypt.CompareHashAndPassword([]byte(hashOnDb), []byte(password)) -} - -// Hash calculates a hash to be stored on the database -func (bv *Verifier) Hash(password string) (string, error) { - hash, err := bcrypt.GenerateFromPassword([]byte(password), bv.cost) - if err != nil { - return "", err - } - - return string(hash[:]), nil -} +package bcrypt + +import ( + "fmt" + + "golang.org/x/crypto/bcrypt" +) + +// Verifier verifys passwords using Bcrypt. +type Verifier struct{} + +// Verify verifys a Password. +func (bv *Verifier) Verify(password string, hashOnDB string) error { + err := bcrypt.CompareHashAndPassword([]byte(hashOnDB), []byte(password)) + if err != nil { + return fmt.Errorf("verify password: %w", err) + } + + return nil +} + +// Hash calculates a hash to be stored on the database. +func (bv *Verifier) Hash(password string) (string, error) { + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return "", fmt.Errorf("hash password: %w", err) + } + + return string(hash), nil +} diff --git a/cmd/budgeteer/main.go b/cmd/budgeteer/main.go index cb57d20..af132b3 100644 --- a/cmd/budgeteer/main.go +++ b/cmd/budgeteer/main.go @@ -1,13 +1,16 @@ package main import ( + "io/fs" "log" + "net/http" "git.javil.eu/jacob1123/budgeteer/bcrypt" "git.javil.eu/jacob1123/budgeteer/config" - "git.javil.eu/jacob1123/budgeteer/http" "git.javil.eu/jacob1123/budgeteer/jwt" "git.javil.eu/jacob1123/budgeteer/postgres" + "git.javil.eu/jacob1123/budgeteer/server" + "git.javil.eu/jacob1123/budgeteer/web" ) func main() { @@ -16,16 +19,24 @@ func main() { log.Fatalf("Could not load config: %v", err) } - q, err := postgres.Connect("pgx", cfg.DatabaseConnection) + queries, err := postgres.Connect("pgx", cfg.DatabaseConnection) if err != nil { log.Fatalf("Failed connecting to DB: %v", err) } - h := &http.Handler{ - Service: q, - TokenVerifier: &jwt.TokenVerifier{}, - CredentialsVerifier: &bcrypt.Verifier{}, + static, err := fs.Sub(web.Static, "dist") + if err != nil { + panic("couldn't open static files") } - h.Serve() + handler := &server.Handler{ + Service: queries, + TokenVerifier: &jwt.TokenVerifier{ + Secret: cfg.SessionSecret, + }, + CredentialsVerifier: &bcrypt.Verifier{}, + StaticFS: http.FS(static), + } + + handler.Serve() } diff --git a/config/config.go b/config/config.go index 0f17ee5..5e98f3e 100644 --- a/config/config.go +++ b/config/config.go @@ -1,19 +1,21 @@ -package config - -import ( - "os" -) - -// Config contains all needed configurations -type Config struct { - DatabaseConnection string -} - -// LoadConfig from path -func LoadConfig() (*Config, error) { - configuration := Config{ - DatabaseConnection: os.Getenv("BUDGETEER_DB"), - } - - return &configuration, nil -} +package config + +import ( + "os" +) + +// Config contains all needed configurations. +type Config struct { + DatabaseConnection string + SessionSecret string +} + +// LoadConfig from path. +func LoadConfig() (*Config, error) { + configuration := Config{ + DatabaseConnection: os.Getenv("BUDGETEER_DB"), + SessionSecret: os.Getenv("BUDGETEER_SESSION_SECRET"), + } + + return &configuration, nil +} diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index f747b77..2d907be 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -17,6 +17,7 @@ services: - ~/.cache:/.cache environment: BUDGETEER_DB: postgres://budgeteer:budgeteer@db:5432/budgeteer + BUDGETEER_SESSION_SECRET: random string for JWT authorization depends_on: - db diff --git a/docker/Dockerfile b/docker/Dockerfile index 83ef4e2..4c930c3 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -2,15 +2,16 @@ FROM alpine as godeps RUN apk add go RUN go install github.com/kyleconroy/sqlc/cmd/sqlc@latest RUN go install github.com/go-task/task/v3/cmd/task@latest +RUN go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest FROM alpine RUN apk add go RUN apk add nodejs yarn bash curl git git-perl tmux ADD docker/build.sh / -COPY --from=godeps /root/go/bin/task /root/go/bin/sqlc /usr/local/bin/ RUN yarn global add @vue/cli ENV PATH="/root/.yarn/bin/:${PATH}" WORKDIR /src ADD web/package.json /src/web/ RUN yarn +COPY --from=godeps /root/go/bin/task /root/go/bin/sqlc /root/go/bin/golangci-lint /usr/local/bin/ CMD /build.sh diff --git a/http/transaction.go b/http/transaction.go deleted file mode 100644 index a565275..0000000 --- a/http/transaction.go +++ /dev/null @@ -1,89 +0,0 @@ -package http - -import ( - "fmt" - "net/http" - "time" - - "git.javil.eu/jacob1123/budgeteer/postgres" - "github.com/gin-gonic/gin" - "github.com/google/uuid" -) - -type NewTransactionPayload struct { - Date JSONDate `json:"date"` - Payee struct { - ID uuid.NullUUID - Name string - } `json:"payee"` - Category struct { - ID uuid.NullUUID - Name string - } `json:"category"` - Memo string `json:"memo"` - Amount string `json:"amount"` - BudgetID uuid.UUID `json:"budget_id"` - AccountID uuid.UUID `json:"account_id"` - State string `json:"state"` -} - -func (h *Handler) newTransaction(c *gin.Context) { - var payload NewTransactionPayload - err := c.BindJSON(&payload) - if err != nil { - c.AbortWithError(http.StatusInternalServerError, err) - return - } - - fmt.Printf("%v\n", payload) - - amount := postgres.Numeric{} - amount.Set(payload.Amount) - - /*transactionUUID, err := getNullUUIDFromParam(c, "transactionid") - if err != nil { - c.AbortWithError(http.StatusInternalServerError, fmt.Errorf("parse transaction id: %w", err)) - return - }*/ - - //if !transactionUUID.Valid { - new := postgres.CreateTransactionParams{ - Memo: payload.Memo, - Date: time.Time(payload.Date), - Amount: amount, - AccountID: payload.AccountID, - PayeeID: payload.Payee.ID, //TODO handle new payee - CategoryID: payload.Category.ID, //TODO handle new category - Status: postgres.TransactionStatus(payload.State), - } - _, err = h.Service.CreateTransaction(c.Request.Context(), new) - if err != nil { - c.AbortWithError(http.StatusInternalServerError, fmt.Errorf("create transaction: %w", err)) - } - - return - // } - /* - _, delete := c.GetPostForm("delete") - if delete { - err = h.Service.DeleteTransaction(c.Request.Context(), transactionUUID.UUID) - if err != nil { - c.AbortWithError(http.StatusInternalServerError, fmt.Errorf("delete transaction: %w", err)) - } - return - } - - update := postgres.UpdateTransactionParams{ - ID: transactionUUID.UUID, - Memo: payload.Memo, - Date: time.Time(payload.Date), - Amount: amount, - AccountID: transactionAccountID, - PayeeID: payload.Payee.ID, //TODO handle new payee - CategoryID: payload.Category.ID, //TODO handle new category - } - err = h.Service.UpdateTransaction(c.Request.Context(), update) - if err != nil { - c.AbortWithError(http.StatusInternalServerError, fmt.Errorf("update transaction: %w", err)) - }*/ -} diff --git a/http/util.go b/http/util.go deleted file mode 100644 index 7c2031e..0000000 --- a/http/util.go +++ /dev/null @@ -1,56 +0,0 @@ -package http - -import ( - "fmt" - - "github.com/gin-gonic/gin" - "github.com/google/uuid" -) - -func getUUID(c *gin.Context, name string) (uuid.UUID, error) { - value, succ := c.GetPostForm(name) - if !succ { - return uuid.UUID{}, fmt.Errorf("not set") - } - - id, err := uuid.Parse(value) - if err != nil { - return uuid.UUID{}, fmt.Errorf("not a valid uuid: %w", err) - } - - return id, nil -} - -func getNullUUIDFromParam(c *gin.Context, name string) (uuid.NullUUID, error) { - value := c.Param(name) - if value == "" { - return uuid.NullUUID{}, nil - } - - id, err := uuid.Parse(value) - if err != nil { - return uuid.NullUUID{}, fmt.Errorf("not a valid uuid: %w", err) - } - - return uuid.NullUUID{ - UUID: id, - Valid: true, - }, nil -} - -func getNullUUIDFromForm(c *gin.Context, name string) (uuid.NullUUID, error) { - value, succ := c.GetPostForm(name) - if !succ || value == "" { - return uuid.NullUUID{}, nil - } - - id, err := uuid.Parse(value) - if err != nil { - return uuid.NullUUID{}, fmt.Errorf("not a valid uuid: %w", err) - } - - return uuid.NullUUID{ - UUID: id, - Valid: true, - }, nil -} diff --git a/jwt/login.go b/jwt/login.go index e15d961..bbb44c8 100644 --- a/jwt/login.go +++ b/jwt/login.go @@ -10,11 +10,12 @@ import ( "github.com/google/uuid" ) -// TokenVerifier verifies Tokens +// TokenVerifier verifies Tokens. type TokenVerifier struct { + Secret string } -// Token contains everything to authenticate a user +// Token contains everything to authenticate a user. type Token struct { username string name string @@ -24,10 +25,9 @@ type Token struct { const ( expiration = 72 - secret = "uditapbzuditagscwxuqdflgzpbu´ßiaefnlmzeßtrubiadern" ) -// CreateToken creates a new token from username and name +// CreateToken creates a new token from username and name. func (tv *TokenVerifier) CreateToken(user *postgres.User) (string, error) { token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ "usr": user.Email, @@ -37,21 +37,27 @@ func (tv *TokenVerifier) CreateToken(user *postgres.User) (string, error) { }) // Generate encoded token and send it as response. - t, err := token.SignedString([]byte(secret)) + t, err := token.SignedString([]byte(tv.Secret)) if err != nil { - return "", err + return "", fmt.Errorf("create token: %w", err) } return t, nil } -// VerifyToken verifys a given string-token -func (tv *TokenVerifier) VerifyToken(tokenString string) (budgeteer.Token, error) { +var ( + ErrUnexpectedSigningMethod = fmt.Errorf("unexpected signing method") + ErrInvalidToken = fmt.Errorf("token is invalid") + ErrTokenExpired = fmt.Errorf("token has expired") +) + +// VerifyToken verifys a given string-token. +func (tv *TokenVerifier) VerifyToken(tokenString string) (budgeteer.Token, error) { //nolint:ireturn token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { - return nil, fmt.Errorf("Unexpected signing method: %v", token.Header["alg"]) + return nil, fmt.Errorf("method '%v': %w", token.Header["alg"], ErrUnexpectedSigningMethod) } - return []byte(secret), nil + return []byte(tv.Secret), nil }) if err != nil { return nil, fmt.Errorf("parse jwt: %w", err) @@ -62,7 +68,7 @@ func (tv *TokenVerifier) VerifyToken(tokenString string) (budgeteer.Token, error return nil, fmt.Errorf("verify jwt: %w", err) } - tkn := &Token{ + tkn := &Token{ //nolint:forcetypeassert username: claims["usr"].(string), name: claims["name"].(string), expiry: claims["exp"].(float64), @@ -73,16 +79,16 @@ func (tv *TokenVerifier) VerifyToken(tokenString string) (budgeteer.Token, error func verifyToken(token *jwt.Token) (jwt.MapClaims, error) { if !token.Valid { - return nil, fmt.Errorf("Token is not valid") + return nil, ErrInvalidToken } claims, ok := token.Claims.(jwt.MapClaims) if !ok { - return nil, fmt.Errorf("Claims are not of Type MapClaims") + return nil, ErrInvalidToken } if !claims.VerifyExpiresAt(time.Now().Unix(), true) { - return nil, fmt.Errorf("Claims have expired") + return nil, ErrTokenExpired } return claims, nil diff --git a/postgres/budgetservice.go b/postgres/budgetservice.go index ab78aa4..82372dd 100644 --- a/postgres/budgetservice.go +++ b/postgres/budgetservice.go @@ -8,11 +8,15 @@ import ( "github.com/google/uuid" ) -// NewBudget creates a budget and adds it to the current user +// NewBudget creates a budget and adds it to the current user. func (s *Database) NewBudget(context context.Context, name string, userID uuid.UUID) (*Budget, error) { tx, err := s.BeginTx(context, &sql.TxOptions{}) - q := s.WithTx(tx) - budget, err := q.CreateBudget(context, CreateBudgetParams{ + if err != nil { + return nil, fmt.Errorf("begin transaction: %w", err) + } + + transaction := s.WithTx(tx) + budget, err := transaction.CreateBudget(context, CreateBudgetParams{ Name: name, IncomeCategoryID: uuid.New(), }) @@ -21,12 +25,12 @@ func (s *Database) NewBudget(context context.Context, name string, userID uuid.U } ub := LinkBudgetToUserParams{UserID: userID, BudgetID: budget.ID} - _, err = q.LinkBudgetToUser(context, ub) + _, err = transaction.LinkBudgetToUser(context, ub) if err != nil { return nil, fmt.Errorf("link budget to user: %w", err) } - group, err := q.CreateCategoryGroup(context, CreateCategoryGroupParams{ + group, err := transaction.CreateCategoryGroup(context, CreateCategoryGroupParams{ Name: "Inflow", BudgetID: budget.ID, }) @@ -34,7 +38,7 @@ func (s *Database) NewBudget(context context.Context, name string, userID uuid.U return nil, fmt.Errorf("create inflow category_group: %w", err) } - cat, err := q.CreateCategory(context, CreateCategoryParams{ + cat, err := transaction.CreateCategory(context, CreateCategoryParams{ Name: "Ready to Assign", CategoryGroupID: group.ID, }) @@ -42,7 +46,7 @@ func (s *Database) NewBudget(context context.Context, name string, userID uuid.U return nil, fmt.Errorf("create ready to assign category: %w", err) } - err = q.SetInflowCategory(context, SetInflowCategoryParams{ + err = transaction.SetInflowCategory(context, SetInflowCategoryParams{ IncomeCategoryID: cat.ID, ID: budget.ID, }) @@ -50,7 +54,10 @@ func (s *Database) NewBudget(context context.Context, name string, userID uuid.U return nil, fmt.Errorf("set inflow category: %w", err) } - tx.Commit() + err = tx.Commit() + if err != nil { + return nil, fmt.Errorf("commit: %w", err) + } return &budget, nil } diff --git a/postgres/conn.go b/postgres/conn.go index 046e127..6c04480 100644 --- a/postgres/conn.go +++ b/postgres/conn.go @@ -5,7 +5,7 @@ import ( "embed" "fmt" - _ "github.com/jackc/pgx/v4/stdlib" + _ "github.com/jackc/pgx/v4/stdlib" // needed for pg connection "github.com/pressly/goose/v3" ) @@ -17,7 +17,7 @@ type Database struct { *sql.DB } -// Connect to a database +// Connect connects to a database. func Connect(typ string, connString string) (*Database, error) { conn, err := sql.Open(typ, connString) if err != nil { diff --git a/postgres/numeric.go b/postgres/numeric.go index 3d477b6..b4e5631 100644 --- a/postgres/numeric.go +++ b/postgres/numeric.go @@ -45,7 +45,7 @@ func (n Numeric) IsZero() bool { func (n Numeric) MatchExp(exp int32) Numeric { diffExp := n.Exp - exp - factor := big.NewInt(0).Exp(big.NewInt(10), big.NewInt(int64(diffExp)), nil) + factor := big.NewInt(0).Exp(big.NewInt(10), big.NewInt(int64(diffExp)), nil) //nolint:gomnd return Numeric{pgtype.Numeric{ Exp: exp, Int: big.NewInt(0).Mul(n.Int, factor), @@ -54,13 +54,13 @@ func (n Numeric) MatchExp(exp int32) Numeric { }} } -func (n Numeric) Sub(o Numeric) Numeric { +func (n Numeric) Sub(other Numeric) Numeric { left := n - right := o - if n.Exp < o.Exp { - right = o.MatchExp(n.Exp) - } else if n.Exp > o.Exp { - left = n.MatchExp(o.Exp) + right := other + if n.Exp < other.Exp { + right = other.MatchExp(n.Exp) + } else if n.Exp > other.Exp { + left = n.MatchExp(other.Exp) } if left.Exp == right.Exp { @@ -72,13 +72,14 @@ func (n Numeric) Sub(o Numeric) Numeric { panic("Cannot subtract with different exponents") } -func (n Numeric) Add(o Numeric) Numeric { + +func (n Numeric) Add(other Numeric) Numeric { left := n - right := o - if n.Exp < o.Exp { - right = o.MatchExp(n.Exp) - } else if n.Exp > o.Exp { - left = n.MatchExp(o.Exp) + right := other + if n.Exp < other.Exp { + right = other.MatchExp(n.Exp) + } else if n.Exp > other.Exp { + left = n.MatchExp(other.Exp) } if left.Exp == right.Exp { diff --git a/postgres/ynab-import.go b/postgres/ynab-import.go index a9b8891..8aa512b 100644 --- a/postgres/ynab-import.go +++ b/postgres/ynab-import.go @@ -13,7 +13,6 @@ import ( ) type YNABImport struct { - Context context.Context accounts []Account payees []Payee categories []GetCategoriesRow @@ -22,73 +21,70 @@ type YNABImport struct { budgetID uuid.UUID } -func NewYNABImport(context context.Context, q *Queries, budgetID uuid.UUID) (*YNABImport, error) { - accounts, err := q.GetAccounts(context, budgetID) +func NewYNABImport(context context.Context, queries *Queries, budgetID uuid.UUID) (*YNABImport, error) { + accounts, err := queries.GetAccounts(context, budgetID) if err != nil { return nil, err } - payees, err := q.GetPayees(context, budgetID) + payees, err := queries.GetPayees(context, budgetID) if err != nil { return nil, err } - categories, err := q.GetCategories(context, budgetID) + categories, err := queries.GetCategories(context, budgetID) if err != nil { return nil, err } - categoryGroups, err := q.GetCategoryGroups(context, budgetID) + categoryGroups, err := queries.GetCategoryGroups(context, budgetID) if err != nil { return nil, err } return &YNABImport{ - Context: context, accounts: accounts, payees: payees, categories: categories, categoryGroups: categoryGroups, - queries: q, + queries: queries, budgetID: budgetID, }, nil - } // ImportAssignments expects a TSV-file as exported by YNAB in the following format: -//"Month" "Category Group/Category" "Category Group" "Category" "Budgeted" "Activity" "Available" -//"Apr 2019" "Income: Next Month" "Income" "Next Month" 0,00€ 0,00€ 0,00€ +// "Month" "Category Group/Category" "Category Group" "Category" "Budgeted" "Activity" "Available" +// "Apr 2019" "Income: Next Month" "Income" "Next Month" 0,00€ 0,00€ 0,00€ // -// Activity and Available are not imported, since they are determined by the transactions and historic assignments -func (ynab *YNABImport) ImportAssignments(r io.Reader) error { +// Activity and Available are not imported, since they are determined by the transactions and historic assignments. +func (ynab *YNABImport) ImportAssignments(context context.Context, r io.Reader) error { csv := csv.NewReader(r) csv.Comma = '\t' csv.LazyQuotes = true csvData, err := csv.ReadAll() if err != nil { - return fmt.Errorf("could not read from tsv: %w", err) + return fmt.Errorf("read from tsv: %w", err) } count := 0 for _, record := range csvData[1:] { - dateString := record[0] date, err := time.Parse("Jan 2006", dateString) if err != nil { - return fmt.Errorf("could not parse date %s: %w", dateString, err) + return fmt.Errorf("parse date %s: %w", dateString, err) } - categoryGroup, categoryName := record[2], record[3] //also in 1 joined by : - category, err := ynab.GetCategory(categoryGroup, categoryName) + categoryGroup, categoryName := record[2], record[3] // also in 1 joined by : + category, err := ynab.GetCategory(context, categoryGroup, categoryName) if err != nil { - return fmt.Errorf("could not get category %s/%s: %w", categoryGroup, categoryName, err) + return fmt.Errorf("get category %s/%s: %w", categoryGroup, categoryName, err) } amountString := record[4] amount, err := GetAmount(amountString, "0,00€") if err != nil { - return fmt.Errorf("could not parse amount %s: %w", amountString, err) + return fmt.Errorf("parse amount %s: %w", amountString, err) } if amount.Int.Int64() == 0 { @@ -100,9 +96,9 @@ func (ynab *YNABImport) ImportAssignments(r io.Reader) error { CategoryID: category.UUID, Amount: amount, } - _, err = ynab.queries.CreateAssignment(ynab.Context, assignment) + _, err = ynab.queries.CreateAssignment(context, assignment) if err != nil { - return fmt.Errorf("could not save assignment %v: %w", assignment, err) + return fmt.Errorf("save assignment %v: %w", assignment, err) } count++ @@ -120,150 +116,183 @@ type Transfer struct { ToAccount string } -// ImportTransactions expects a TSV-file as exported by YNAB in the following format: - -func (ynab *YNABImport) ImportTransactions(r io.Reader) error { +// ImportTransactions expects a TSV-file as exported by YNAB. +func (ynab *YNABImport) ImportTransactions(context context.Context, r io.Reader) error { csv := csv.NewReader(r) csv.Comma = '\t' csv.LazyQuotes = true csvData, err := csv.ReadAll() if err != nil { - return fmt.Errorf("could not read from tsv: %w", err) + return fmt.Errorf("read from tsv: %w", err) } var openTransfers []Transfer count := 0 for _, record := range csvData[1:] { - accountName := record[0] - account, err := ynab.GetAccount(accountName) + transaction, err := ynab.GetTransaction(context, record) if err != nil { - return fmt.Errorf("could not get account %s: %w", accountName, err) - } - - //flag := record[1] - - dateString := record[2] - date, err := time.Parse("02.01.2006", dateString) - if err != nil { - return fmt.Errorf("could not parse date %s: %w", dateString, err) - } - - categoryGroup, categoryName := record[5], record[6] //also in 4 joined by : - category, err := ynab.GetCategory(categoryGroup, categoryName) - if err != nil { - return fmt.Errorf("could not get category %s/%s: %w", categoryGroup, categoryName, err) - } - - memo := record[7] - - outflow := record[8] - inflow := record[9] - amount, err := GetAmount(inflow, outflow) - if err != nil { - return fmt.Errorf("could not parse amount from (%s/%s): %w", inflow, outflow, err) - } - - statusEnum := TransactionStatusUncleared - status := record[10] - switch status { - case "Cleared": - statusEnum = TransactionStatusCleared - case "Reconciled": - statusEnum = TransactionStatusReconciled - case "Uncleared": - } - - transaction := CreateTransactionParams{ - Date: date, - Memo: memo, - AccountID: account.ID, - CategoryID: category, - Amount: amount, - Status: statusEnum, + return err } payeeName := record[3] + // Transaction is a transfer if strings.HasPrefix(payeeName, "Transfer : ") { - // Transaction is a transfer to - transferToAccountName := payeeName[11:] - transferToAccount, err := ynab.GetAccount(transferToAccountName) - if err != nil { - return fmt.Errorf("Could not get transfer account %s: %w", transferToAccountName, err) - } - - transfer := Transfer{ - transaction, - transferToAccount, - accountName, - transferToAccountName, - } - - found := false - for i, openTransfer := range openTransfers { - if openTransfer.TransferToAccount.ID != transfer.AccountID { - continue - } - if openTransfer.AccountID != transfer.TransferToAccount.ID { - continue - } - if openTransfer.Amount.GetFloat64() != -1*transfer.Amount.GetFloat64() { - continue - } - - fmt.Printf("Matched transfers from %s to %s over %f\n", account.Name, transferToAccount.Name, amount.GetFloat64()) - openTransfers[i] = openTransfers[len(openTransfers)-1] - openTransfers = openTransfers[:len(openTransfers)-1] - found = true - - groupID := uuid.New() - transfer.GroupID = uuid.NullUUID{UUID: groupID, Valid: true} - openTransfer.GroupID = uuid.NullUUID{UUID: groupID, Valid: true} - - _, err = ynab.queries.CreateTransaction(ynab.Context, transfer.CreateTransactionParams) - if err != nil { - return fmt.Errorf("could not save transaction %v: %w", transfer.CreateTransactionParams, err) - } - _, err = ynab.queries.CreateTransaction(ynab.Context, openTransfer.CreateTransactionParams) - if err != nil { - return fmt.Errorf("could not save transaction %v: %w", openTransfer.CreateTransactionParams, err) - } - break - } - - if !found { - openTransfers = append(openTransfers, transfer) - } + err = ynab.ImportTransferTransaction(context, payeeName, transaction.CreateTransactionParams, + &openTransfers, transaction.Account, transaction.Amount) } else { - payeeID, err := ynab.GetPayee(payeeName) - if err != nil { - return fmt.Errorf("could not get payee %s: %w", payeeName, err) - } - transaction.PayeeID = payeeID - - _, err = ynab.queries.CreateTransaction(ynab.Context, transaction) - if err != nil { - return fmt.Errorf("could not save transaction %v: %w", transaction, err) - } + err = ynab.ImportRegularTransaction(context, payeeName, transaction.CreateTransactionParams) + } + if err != nil { + return err } count++ } for _, openTransfer := range openTransfers { - fmt.Printf("Saving unmatched transfer from %s to %s on %s over %f as regular transaction\n", openTransfer.FromAccount, openTransfer.ToAccount, openTransfer.Date, openTransfer.Amount.GetFloat64()) - _, err = ynab.queries.CreateTransaction(ynab.Context, openTransfer.CreateTransactionParams) + fmt.Printf("Saving unmatched transfer from %s to %s on %s over %f as regular transaction\n", + openTransfer.FromAccount, openTransfer.ToAccount, openTransfer.Date, openTransfer.Amount.GetFloat64()) + _, err = ynab.queries.CreateTransaction(context, openTransfer.CreateTransactionParams) if err != nil { - return fmt.Errorf("could not save transaction %v: %w", openTransfer.CreateTransactionParams, err) + return fmt.Errorf("save transaction %v: %w", openTransfer.CreateTransactionParams, err) } - } + fmt.Printf("Imported %d transactions\n", count) return nil } +type NewTransaction struct { + CreateTransactionParams + Account *Account +} + +func (ynab *YNABImport) GetTransaction(context context.Context, record []string) (NewTransaction, error) { + accountName := record[0] + account, err := ynab.GetAccount(context, accountName) + if err != nil { + return NewTransaction{}, fmt.Errorf("get account %s: %w", accountName, err) + } + + // flag := record[1] + + dateString := record[2] + date, err := time.Parse("02.01.2006", dateString) + if err != nil { + return NewTransaction{}, fmt.Errorf("parse date %s: %w", dateString, err) + } + + categoryGroup, categoryName := record[5], record[6] // also in 4 joined by : + category, err := ynab.GetCategory(context, categoryGroup, categoryName) + if err != nil { + return NewTransaction{}, fmt.Errorf("get category %s/%s: %w", categoryGroup, categoryName, err) + } + + memo := record[7] + + outflow := record[8] + inflow := record[9] + amount, err := GetAmount(inflow, outflow) + if err != nil { + return NewTransaction{}, fmt.Errorf("parse amount from (%s/%s): %w", inflow, outflow, err) + } + + statusEnum := TransactionStatusUncleared + status := record[10] + switch status { + case "Cleared": + statusEnum = TransactionStatusCleared + case "Reconciled": + statusEnum = TransactionStatusReconciled + case "Uncleared": + } + + return NewTransaction{ + CreateTransactionParams: CreateTransactionParams{ + Date: date, + Memo: memo, + AccountID: account.ID, + CategoryID: category, + Amount: amount, + Status: statusEnum, + }, + Account: account, + }, nil +} + +func (ynab *YNABImport) ImportRegularTransaction(context context.Context, payeeName string, + transaction CreateTransactionParams) error { + payeeID, err := ynab.GetPayee(context, payeeName) + if err != nil { + return fmt.Errorf("get payee %s: %w", payeeName, err) + } + transaction.PayeeID = payeeID + + _, err = ynab.queries.CreateTransaction(context, transaction) + if err != nil { + return fmt.Errorf("save transaction %v: %w", transaction, err) + } + return nil +} + +func (ynab *YNABImport) ImportTransferTransaction(context context.Context, payeeName string, + transaction CreateTransactionParams, openTransfers *[]Transfer, + account *Account, amount Numeric) error { + transferToAccountName := payeeName[11:] + transferToAccount, err := ynab.GetAccount(context, transferToAccountName) + if err != nil { + return fmt.Errorf("get transfer account %s: %w", transferToAccountName, err) + } + + transfer := Transfer{ + transaction, + transferToAccount, + account.Name, + transferToAccountName, + } + + found := false + for i, openTransfer := range *openTransfers { + if openTransfer.TransferToAccount.ID != transfer.AccountID { + continue + } + if openTransfer.AccountID != transfer.TransferToAccount.ID { + continue + } + if openTransfer.Amount.GetFloat64() != -1*transfer.Amount.GetFloat64() { + continue + } + + fmt.Printf("Matched transfers from %s to %s over %f\n", account.Name, transferToAccount.Name, amount.GetFloat64()) + transfers := *openTransfers + transfers[i] = transfers[len(transfers)-1] + *openTransfers = transfers[:len(transfers)-1] + found = true + + groupID := uuid.New() + transfer.GroupID = uuid.NullUUID{UUID: groupID, Valid: true} + openTransfer.GroupID = uuid.NullUUID{UUID: groupID, Valid: true} + + _, err = ynab.queries.CreateTransaction(context, transfer.CreateTransactionParams) + if err != nil { + return fmt.Errorf("save transaction %v: %w", transfer.CreateTransactionParams, err) + } + _, err = ynab.queries.CreateTransaction(context, openTransfer.CreateTransactionParams) + if err != nil { + return fmt.Errorf("save transaction %v: %w", openTransfer.CreateTransactionParams, err) + } + break + } + + if !found { + *openTransfers = append(*openTransfers, transfer) + } + return nil +} + func trimLastChar(s string) string { r, size := utf8.DecodeLastRuneInString(s) if r == utf8.RuneError && (size == 0 || size == 1) { @@ -280,7 +309,7 @@ func GetAmount(inflow string, outflow string) (Numeric, error) { num := Numeric{} err := num.Set(inflow) if err != nil { - return num, fmt.Errorf("Could not parse inflow %s: %w", inflow, err) + return num, fmt.Errorf("parse inflow %s: %w", inflow, err) } // if inflow is zero, use outflow @@ -290,19 +319,19 @@ func GetAmount(inflow string, outflow string) (Numeric, error) { err = num.Set("-" + outflow) if err != nil { - return num, fmt.Errorf("Could not parse outflow %s: %w", inflow, err) + return num, fmt.Errorf("parse outflow %s: %w", inflow, err) } return num, nil } -func (ynab *YNABImport) GetAccount(name string) (*Account, error) { +func (ynab *YNABImport) GetAccount(context context.Context, name string) (*Account, error) { for _, acc := range ynab.accounts { if acc.Name == name { return &acc, nil } } - account, err := ynab.queries.CreateAccount(ynab.Context, CreateAccountParams{Name: name, BudgetID: ynab.budgetID}) + account, err := ynab.queries.CreateAccount(context, CreateAccountParams{Name: name, BudgetID: ynab.budgetID}) if err != nil { return nil, err } @@ -311,7 +340,7 @@ func (ynab *YNABImport) GetAccount(name string) (*Account, error) { return &account, nil } -func (ynab *YNABImport) GetPayee(name string) (uuid.NullUUID, error) { +func (ynab *YNABImport) GetPayee(context context.Context, name string) (uuid.NullUUID, error) { if name == "" { return uuid.NullUUID{}, nil } @@ -322,7 +351,7 @@ func (ynab *YNABImport) GetPayee(name string) (uuid.NullUUID, error) { } } - payee, err := ynab.queries.CreatePayee(ynab.Context, CreatePayeeParams{Name: name, BudgetID: ynab.budgetID}) + payee, err := ynab.queries.CreatePayee(context, CreatePayeeParams{Name: name, BudgetID: ynab.budgetID}) if err != nil { return uuid.NullUUID{}, err } @@ -331,7 +360,7 @@ func (ynab *YNABImport) GetPayee(name string) (uuid.NullUUID, error) { return uuid.NullUUID{UUID: payee.ID, Valid: true}, nil } -func (ynab *YNABImport) GetCategory(group string, name string) (uuid.NullUUID, error) { +func (ynab *YNABImport) GetCategory(context context.Context, group string, name string) (uuid.NullUUID, error) { //nolint if group == "" || name == "" { return uuid.NullUUID{}, nil } @@ -342,32 +371,25 @@ func (ynab *YNABImport) GetCategory(group string, name string) (uuid.NullUUID, e } } - for _, categoryGroup := range ynab.categoryGroups { - if categoryGroup.Name == group { - createCategory := CreateCategoryParams{Name: name, CategoryGroupID: categoryGroup.ID} - category, err := ynab.queries.CreateCategory(ynab.Context, createCategory) - if err != nil { - return uuid.NullUUID{}, err - } - - getCategory := GetCategoriesRow{ - ID: category.ID, - CategoryGroupID: category.CategoryGroupID, - Name: category.Name, - Group: categoryGroup.Name, - } - ynab.categories = append(ynab.categories, getCategory) - return uuid.NullUUID{UUID: category.ID, Valid: true}, nil + var categoryGroup CategoryGroup + for _, existingGroup := range ynab.categoryGroups { + if existingGroup.Name == group { + categoryGroup = existingGroup } } - categoryGroup, err := ynab.queries.CreateCategoryGroup(ynab.Context, CreateCategoryGroupParams{Name: group, BudgetID: ynab.budgetID}) - if err != nil { - return uuid.NullUUID{}, err + if categoryGroup.Name == "" { + newGroup := CreateCategoryGroupParams{Name: group, BudgetID: ynab.budgetID} + var err error + categoryGroup, err = ynab.queries.CreateCategoryGroup(context, newGroup) + if err != nil { + return uuid.NullUUID{}, err + } + ynab.categoryGroups = append(ynab.categoryGroups, categoryGroup) } - ynab.categoryGroups = append(ynab.categoryGroups, categoryGroup) - category, err := ynab.queries.CreateCategory(ynab.Context, CreateCategoryParams{Name: name, CategoryGroupID: categoryGroup.ID}) + newCategory := CreateCategoryParams{Name: name, CategoryGroupID: categoryGroup.ID} + category, err := ynab.queries.CreateCategory(context, newCategory) if err != nil { return uuid.NullUUID{}, err } diff --git a/http/account.go b/server/account.go similarity index 98% rename from http/account.go rename to server/account.go index 03296c2..18cfe41 100644 --- a/http/account.go +++ b/server/account.go @@ -1,4 +1,4 @@ -package http +package server import ( "net/http" diff --git a/http/account_test.go b/server/account_test.go similarity index 56% rename from http/account_test.go rename to server/account_test.go index df8a430..fe79cba 100644 --- a/http/account_test.go +++ b/server/account_test.go @@ -1,4 +1,4 @@ -package http +package server import ( "encoding/json" @@ -10,47 +10,53 @@ import ( "git.javil.eu/jacob1123/budgeteer/bcrypt" "git.javil.eu/jacob1123/budgeteer/jwt" "git.javil.eu/jacob1123/budgeteer/postgres" - "github.com/gin-gonic/gin" - txdb "github.com/DATA-DOG/go-txdb" + "github.com/gin-gonic/gin" ) -func init() { +func init() { //nolint:gochecknoinits txdb.Register("pgtx", "pgx", "postgres://budgeteer_test:budgeteer_test@localhost:5432/budgeteer_test") } -func TestListTimezonesHandler(t *testing.T) { - db, err := postgres.Connect("pgtx", "example") +func TestRegisterUser(t *testing.T) { //nolint:funlen + t.Parallel() + database, err := postgres.Connect("pgtx", "example") if err != nil { t.Errorf("could not connect to db: %s", err) return } h := Handler{ - Service: db, - TokenVerifier: &jwt.TokenVerifier{}, + Service: database, + TokenVerifier: &jwt.TokenVerifier{ + Secret: "this_is_my_demo_secret_for_unit_tests", + }, CredentialsVerifier: &bcrypt.Verifier{}, } - rr := httptest.NewRecorder() - c, engine := gin.CreateTestContext(rr) + recorder := httptest.NewRecorder() + context, engine := gin.CreateTestContext(recorder) h.LoadRoutes(engine) t.Run("RegisterUser", func(t *testing.T) { - c.Request, err = http.NewRequest(http.MethodPost, "/api/v1/user/register", strings.NewReader(`{"password":"pass","email":"info@example.com","name":"Test"}`)) + t.Parallel() + context.Request, err = http.NewRequest( + http.MethodPost, + "/api/v1/user/register", + strings.NewReader(`{"password":"pass","email":"info@example.com","name":"Test"}`)) if err != nil { t.Errorf("error creating request: %s", err) return } - h.registerPost(c) + h.registerPost(context) - if rr.Code != http.StatusOK { - t.Errorf("handler returned wrong status code: got %v want %v", rr.Code, http.StatusOK) + if recorder.Code != http.StatusOK { + t.Errorf("handler returned wrong status code: got %v want %v", recorder.Code, http.StatusOK) } var response LoginResponse - err = json.NewDecoder(rr.Body).Decode(&response) + err = json.NewDecoder(recorder.Body).Decode(&response) if err != nil { t.Error(err.Error()) t.Error("Error registering") @@ -61,13 +67,14 @@ func TestListTimezonesHandler(t *testing.T) { }) t.Run("GetTransactions", func(t *testing.T) { - c.Request, err = http.NewRequest(http.MethodGet, "/account/accountid/transactions", nil) - if rr.Code != http.StatusOK { - t.Errorf("handler returned wrong status code: got %v want %v", rr.Code, http.StatusOK) + t.Parallel() + context.Request, err = http.NewRequest(http.MethodGet, "/account/accountid/transactions", nil) + if recorder.Code != http.StatusOK { + t.Errorf("handler returned wrong status code: got %v want %v", recorder.Code, http.StatusOK) } var response TransactionsResponse - err = json.NewDecoder(rr.Body).Decode(&response) + err = json.NewDecoder(recorder.Body).Decode(&response) if err != nil { t.Error(err.Error()) t.Error("Error retreiving list of transactions.") diff --git a/http/admin.go b/server/admin.go similarity index 99% rename from http/admin.go rename to server/admin.go index c14248a..7d19da3 100644 --- a/http/admin.go +++ b/server/admin.go @@ -1,4 +1,4 @@ -package http +package server import ( "fmt" diff --git a/http/autocomplete.go b/server/autocomplete.go similarity index 85% rename from http/autocomplete.go rename to server/autocomplete.go index 2a510c1..46dfb3c 100644 --- a/http/autocomplete.go +++ b/server/autocomplete.go @@ -1,7 +1,6 @@ -package http +package server import ( - "fmt" "net/http" "git.javil.eu/jacob1123/budgeteer/postgres" @@ -13,7 +12,7 @@ func (h *Handler) autocompleteCategories(c *gin.Context) { budgetID := c.Param("budgetid") budgetUUID, err := uuid.Parse(budgetID) if err != nil { - c.AbortWithError(http.StatusBadRequest, fmt.Errorf("budgetid missing from URL")) + c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{"budgetid missing from URL"}) return } @@ -35,7 +34,7 @@ func (h *Handler) autocompletePayee(c *gin.Context) { budgetID := c.Param("budgetid") budgetUUID, err := uuid.Parse(budgetID) if err != nil { - c.AbortWithError(http.StatusBadRequest, fmt.Errorf("budgetid missing from URL")) + c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{"budgetid missing from URL"}) return } diff --git a/http/budget.go b/server/budget.go similarity index 67% rename from http/budget.go rename to server/budget.go index 5b93c40..682ac75 100644 --- a/http/budget.go +++ b/server/budget.go @@ -1,10 +1,8 @@ -package http +package server import ( - "fmt" "net/http" - "git.javil.eu/jacob1123/budgeteer" "github.com/gin-gonic/gin" ) @@ -14,18 +12,17 @@ type newBudgetInformation struct { func (h *Handler) newBudget(c *gin.Context) { var newBudget newBudgetInformation - err := c.BindJSON(&newBudget) - if err != nil { + if err := c.BindJSON(&newBudget); err != nil { c.AbortWithError(http.StatusNotAcceptable, err) return } if newBudget.Name == "" { - c.AbortWithError(http.StatusNotAcceptable, fmt.Errorf("Budget name is needed")) + c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{"budget name is required"}) return } - userID := c.MustGet("token").(budgeteer.Token).GetID() + userID := MustGetToken(c).GetID() budget, err := h.Service.NewBudget(c.Request.Context(), newBudget.Name, userID) if err != nil { c.AbortWithError(http.StatusInternalServerError, err) diff --git a/http/budgeting.go b/server/budgeting.go similarity index 61% rename from http/budgeting.go rename to server/budgeting.go index 431c3b7..0645b2b 100644 --- a/http/budgeting.go +++ b/server/budgeting.go @@ -1,4 +1,4 @@ -package http +package server import ( "fmt" @@ -55,7 +55,7 @@ func (h *Handler) budgetingForMonth(c *gin.Context) { budgetID := c.Param("budgetid") budgetUUID, err := uuid.Parse(budgetID) if err != nil { - c.AbortWithError(http.StatusBadRequest, fmt.Errorf("budgetid missing from URL")) + c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{"budgetid missing from URL"}) return } @@ -80,16 +80,25 @@ func (h *Handler) budgetingForMonth(c *gin.Context) { firstOfNextMonth := firstOfMonth.AddDate(0, 1, 0) cumultativeBalances, err := h.Service.GetCumultativeBalances(c.Request.Context(), budgetUUID) if err != nil { - c.AbortWithError(http.StatusInternalServerError, fmt.Errorf("load balances: %w", err)) + c.AbortWithStatusJSON(http.StatusInternalServerError, ErrorResponse{fmt.Sprintf("error loading balances: %s", err)}) return } - // skip everything in the future - categoriesWithBalance, moneyUsed, err := h.calculateBalances(c, budget, firstOfNextMonth, firstOfMonth, categories, cumultativeBalances) - if err != nil { - return - } + categoriesWithBalance, moneyUsed := h.calculateBalances( + budget, firstOfNextMonth, firstOfMonth, categories, cumultativeBalances) + availableBalance := h.getAvailableBalance(categories, budget, moneyUsed, cumultativeBalances, firstOfNextMonth) + + data := struct { + Categories []CategoryWithBalance + AvailableBalance postgres.Numeric + }{categoriesWithBalance, availableBalance} + c.JSON(http.StatusOK, data) +} + +func (*Handler) getAvailableBalance(categories []postgres.GetCategoriesRow, budget postgres.Budget, + moneyUsed postgres.Numeric, cumultativeBalances []postgres.GetCumultativeBalancesRow, + firstOfNextMonth time.Time) postgres.Numeric { availableBalance := postgres.NewZeroNumeric() for _, cat := range categories { if cat.ID != budget.IncomeCategoryID { @@ -109,20 +118,14 @@ func (h *Handler) budgetingForMonth(c *gin.Context) { availableBalance = availableBalance.Add(bal.Transactions) } } - - data := struct { - Categories []CategoryWithBalance - AvailableBalance postgres.Numeric - }{categoriesWithBalance, availableBalance} - c.JSON(http.StatusOK, data) - + return availableBalance } func (h *Handler) budgeting(c *gin.Context) { budgetID := c.Param("budgetid") budgetUUID, err := uuid.Parse(budgetID) if err != nil { - c.AbortWithError(http.StatusBadRequest, fmt.Errorf("budgetid missing from URL")) + c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{"budgetid missing from URL"}) return } @@ -146,7 +149,9 @@ func (h *Handler) budgeting(c *gin.Context) { c.JSON(http.StatusOK, data) } -func (h *Handler) calculateBalances(c *gin.Context, budget postgres.Budget, firstOfNextMonth time.Time, firstOfMonth time.Time, categories []postgres.GetCategoriesRow, cumultativeBalances []postgres.GetCumultativeBalancesRow) ([]CategoryWithBalance, postgres.Numeric, error) { +func (h *Handler) calculateBalances(budget postgres.Budget, + firstOfNextMonth time.Time, firstOfMonth time.Time, categories []postgres.GetCategoriesRow, + cumultativeBalances []postgres.GetCumultativeBalancesRow) ([]CategoryWithBalance, postgres.Numeric) { categoriesWithBalance := []CategoryWithBalance{} hiddenCategory := CategoryWithBalance{ GetCategoriesRow: &postgres.GetCategoriesRow{ @@ -162,39 +167,9 @@ func (h *Handler) calculateBalances(c *gin.Context, budget postgres.Budget, firs moneyUsed := postgres.NewZeroNumeric() for i := range categories { cat := &categories[i] - categoryWithBalance := CategoryWithBalance{ - GetCategoriesRow: cat, - Available: postgres.NewZeroNumeric(), - AvailableLastMonth: postgres.NewZeroNumeric(), - Activity: postgres.NewZeroNumeric(), - Assigned: postgres.NewZeroNumeric(), - } - for _, bal := range cumultativeBalances { - if bal.CategoryID != cat.ID { - continue - } - - if !bal.Date.Before(firstOfNextMonth) { - continue - } - - moneyUsed = moneyUsed.Sub(bal.Assignments) - categoryWithBalance.Available = categoryWithBalance.Available.Add(bal.Assignments) - categoryWithBalance.Available = categoryWithBalance.Available.Add(bal.Transactions) - if !categoryWithBalance.Available.IsPositive() && bal.Date.Before(firstOfMonth) { - moneyUsed = moneyUsed.Add(categoryWithBalance.Available) - categoryWithBalance.Available = postgres.NewZeroNumeric() - } - - if bal.Date.Before(firstOfMonth) { - categoryWithBalance.AvailableLastMonth = categoryWithBalance.Available - } else if bal.Date.Before(firstOfNextMonth) { - categoryWithBalance.Activity = bal.Transactions - categoryWithBalance.Assigned = bal.Assignments - } - } - // do not show hidden categories + categoryWithBalance := h.CalculateCategoryBalances(cat, cumultativeBalances, + firstOfNextMonth, &moneyUsed, firstOfMonth, hiddenCategory, budget) if cat.Group == "Hidden Categories" { hiddenCategory.Available = hiddenCategory.Available.Add(categoryWithBalance.Available) hiddenCategory.AvailableLastMonth = hiddenCategory.AvailableLastMonth.Add(categoryWithBalance.AvailableLastMonth) @@ -212,5 +187,45 @@ func (h *Handler) calculateBalances(c *gin.Context, budget postgres.Budget, firs categoriesWithBalance = append(categoriesWithBalance, hiddenCategory) - return categoriesWithBalance, moneyUsed, nil + return categoriesWithBalance, moneyUsed +} + +func (*Handler) CalculateCategoryBalances(cat *postgres.GetCategoriesRow, + cumultativeBalances []postgres.GetCumultativeBalancesRow, firstOfNextMonth time.Time, + moneyUsed *postgres.Numeric, firstOfMonth time.Time, hiddenCategory CategoryWithBalance, + budget postgres.Budget) CategoryWithBalance { + categoryWithBalance := CategoryWithBalance{ + GetCategoriesRow: cat, + Available: postgres.NewZeroNumeric(), + AvailableLastMonth: postgres.NewZeroNumeric(), + Activity: postgres.NewZeroNumeric(), + Assigned: postgres.NewZeroNumeric(), + } + for _, bal := range cumultativeBalances { + if bal.CategoryID != cat.ID { + continue + } + + // skip everything in the future + if !bal.Date.Before(firstOfNextMonth) { + continue + } + + *moneyUsed = moneyUsed.Sub(bal.Assignments) + categoryWithBalance.Available = categoryWithBalance.Available.Add(bal.Assignments) + categoryWithBalance.Available = categoryWithBalance.Available.Add(bal.Transactions) + if !categoryWithBalance.Available.IsPositive() && bal.Date.Before(firstOfMonth) { + *moneyUsed = moneyUsed.Add(categoryWithBalance.Available) + categoryWithBalance.Available = postgres.NewZeroNumeric() + } + + if bal.Date.Before(firstOfMonth) { + categoryWithBalance.AvailableLastMonth = categoryWithBalance.Available + } else if bal.Date.Before(firstOfNextMonth) { + categoryWithBalance.Activity = bal.Transactions + categoryWithBalance.Assigned = bal.Assignments + } + } + + return categoryWithBalance } diff --git a/http/dashboard.go b/server/dashboard.go similarity index 78% rename from http/dashboard.go rename to server/dashboard.go index f752b10..64667d8 100644 --- a/http/dashboard.go +++ b/server/dashboard.go @@ -1,15 +1,14 @@ -package http +package server import ( "net/http" - "git.javil.eu/jacob1123/budgeteer" "git.javil.eu/jacob1123/budgeteer/postgres" "github.com/gin-gonic/gin" ) func (h *Handler) dashboard(c *gin.Context) { - userID := c.MustGet("token").(budgeteer.Token).GetID() + userID := MustGetToken(c).GetID() budgets, err := h.Service.GetBudgetsForUser(c.Request.Context(), userID) if err != nil { return diff --git a/http/http.go b/server/http.go similarity index 87% rename from http/http.go rename to server/http.go index ade5c22..569464b 100644 --- a/http/http.go +++ b/server/http.go @@ -1,4 +1,4 @@ -package http +package server import ( "errors" @@ -11,12 +11,10 @@ import ( "git.javil.eu/jacob1123/budgeteer" "git.javil.eu/jacob1123/budgeteer/bcrypt" "git.javil.eu/jacob1123/budgeteer/postgres" - "git.javil.eu/jacob1123/budgeteer/web" - "github.com/gin-gonic/gin" ) -// Handler handles incoming requests +// Handler handles incoming requests. type Handler struct { Service *postgres.Database TokenVerifier budgeteer.TokenVerifier @@ -24,25 +22,22 @@ type Handler struct { StaticFS http.FileSystem } -const ( - expiration = 72 -) - -// Serve starts the http server +// Serve starts the http server. func (h *Handler) Serve() { router := gin.Default() h.LoadRoutes(router) - router.Run(":1323") + + if err := router.Run(":1323"); err != nil { + panic(err) + } } -// LoadRoutes initializes all the routes -func (h *Handler) LoadRoutes(router *gin.Engine) { - static, err := fs.Sub(web.Static, "dist") - if err != nil { - panic("couldn't open static files") - } - h.StaticFS = http.FS(static) +type ErrorResponse struct { + Message string +} +// LoadRoutes initializes all the routes. +func (h *Handler) LoadRoutes(router *gin.Engine) { router.Use(enableCachingForStaticFiles()) router.NoRoute(h.ServeStatic) @@ -81,6 +76,7 @@ func (h *Handler) LoadRoutes(router *gin.Engine) { transaction.POST("/new", h.newTransaction) transaction.POST("/:transactionid", h.newTransaction) } + func (h *Handler) ServeStatic(c *gin.Context) { h.ServeStaticFile(c, c.Request.URL.Path) } @@ -108,7 +104,11 @@ func (h *Handler) ServeStaticFile(c *gin.Context, fullPath string) { return } - http.ServeContent(c.Writer, c.Request, stat.Name(), stat.ModTime(), file.(io.ReadSeeker)) + if file, ok := file.(io.ReadSeeker); ok { + http.ServeContent(c.Writer, c.Request, stat.Name(), stat.ModTime(), file) + } else { + panic("File does not implement ReadSeeker") + } } func enableCachingForStaticFiles() gin.HandlerFunc { diff --git a/http/json-date.go b/server/json-date.go similarity index 50% rename from http/json-date.go rename to server/json-date.go index d9e0180..869069d 100644 --- a/http/json-date.go +++ b/server/json-date.go @@ -1,29 +1,36 @@ -package http +package server import ( "encoding/json" + "fmt" "strings" "time" ) type JSONDate time.Time -// Implement Marshaler and Unmarshaler interface +// UnmarshalJSON parses the JSONDate from a JSON input. func (j *JSONDate) UnmarshalJSON(b []byte) error { s := strings.Trim(string(b), "\"") t, err := time.Parse("2006-01-02", s) if err != nil { - return err + return fmt.Errorf("parse date: %w", err) } *j = JSONDate(t) return nil } +// MarshalJSON converts the JSONDate to a JSON in ISO format. func (j JSONDate) MarshalJSON() ([]byte, error) { - return json.Marshal(time.Time(j)) + result, err := json.Marshal(time.Time(j)) + if err != nil { + return nil, fmt.Errorf("marshal date: %w", err) + } + + return result, nil } -// Maybe a Format function for printing your date +// Format formats the time using the regular time.Time mechanics.. func (j JSONDate) Format(s string) string { t := time.Time(j) return t.Format(s) diff --git a/http/session.go b/server/session.go similarity index 59% rename from http/session.go rename to server/session.go index a76298a..67590f5 100644 --- a/http/session.go +++ b/server/session.go @@ -1,4 +1,4 @@ -package http +package server import ( "context" @@ -8,18 +8,34 @@ import ( "git.javil.eu/jacob1123/budgeteer" "git.javil.eu/jacob1123/budgeteer/postgres" "github.com/gin-gonic/gin" + "github.com/google/uuid" ) -func (h *Handler) verifyLogin(c *gin.Context) (budgeteer.Token, error) { - tokenString := c.GetHeader("Authorization") - if len(tokenString) < 8 { - return nil, fmt.Errorf("no authorization header supplied") +const ( + HeaderName = "Authorization" + Bearer = "Bearer " + ParamName = "token" +) + +func MustGetToken(c *gin.Context) budgeteer.Token { //nolint:ireturn + token := c.MustGet(ParamName) + if token, ok := token.(budgeteer.Token); !ok { + return token + } + + panic("Token is not a valid Token") +} + +func (h *Handler) verifyLogin(c *gin.Context) (budgeteer.Token, *ErrorResponse) { //nolint:ireturn + tokenString := c.GetHeader(HeaderName) + if len(tokenString) <= len(Bearer) { + return nil, &ErrorResponse{"no authorization header supplied"} } tokenString = tokenString[7:] token, err := h.TokenVerifier.VerifyToken(tokenString) if err != nil { - return nil, fmt.Errorf("verify token '%s': %w", tokenString, err) + return nil, &ErrorResponse{fmt.Sprintf("verify token '%s': %s", tokenString, err)} } return token, nil @@ -28,12 +44,12 @@ func (h *Handler) verifyLogin(c *gin.Context) (budgeteer.Token, error) { func (h *Handler) verifyLoginWithForbidden(c *gin.Context) { token, err := h.verifyLogin(c) if err != nil { - //c.Header("WWW-Authenticate", "Bearer") - c.AbortWithError(http.StatusForbidden, err) + // c.Header("WWW-Authenticate", "Bearer") + c.AbortWithStatusJSON(http.StatusForbidden, err) return } - c.Set("token", token) + c.Set(ParamName, token) c.Next() } @@ -45,7 +61,7 @@ func (h *Handler) verifyLoginWithRedirect(c *gin.Context) { return } - c.Set("token", token) + c.Set(ParamName, token) c.Next() } @@ -72,19 +88,19 @@ func (h *Handler) loginPost(c *gin.Context) { return } - t, err := h.TokenVerifier.CreateToken(&user) + token, err := h.TokenVerifier.CreateToken(&user) if err != nil { c.AbortWithError(http.StatusUnauthorized, err) } - go h.Service.UpdateLastLogin(context.Background(), user.ID) + go h.UpdateLastLogin(user.ID) budgets, err := h.Service.GetBudgetsForUser(c.Request.Context(), user.ID) if err != nil { return } - c.JSON(http.StatusOK, LoginResponse{t, user, budgets}) + c.JSON(http.StatusOK, LoginResponse{token, user, budgets}) } type LoginResponse struct { @@ -101,16 +117,20 @@ type registerInformation struct { func (h *Handler) registerPost(c *gin.Context) { var register registerInformation - c.BindJSON(®ister) - - if register.Email == "" || register.Password == "" || register.Name == "" { - c.AbortWithError(http.StatusBadRequest, fmt.Errorf("e-mail, password and name are required")) + err := c.BindJSON(®ister) + if err != nil { + c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{"error parsing body"}) return } - _, err := h.Service.GetUserByUsername(c.Request.Context(), register.Email) + if register.Email == "" || register.Password == "" || register.Name == "" { + c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{"e-mail, password and name are required"}) + return + } + + _, err = h.Service.GetUserByUsername(c.Request.Context(), register.Email) if err == nil { - c.AbortWithError(http.StatusBadRequest, fmt.Errorf("email is already taken")) + c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{"email is already taken"}) return } @@ -130,17 +150,24 @@ func (h *Handler) registerPost(c *gin.Context) { c.AbortWithError(http.StatusInternalServerError, err) } - t, err := h.TokenVerifier.CreateToken(&user) + token, err := h.TokenVerifier.CreateToken(&user) if err != nil { c.AbortWithError(http.StatusUnauthorized, err) } - go h.Service.UpdateLastLogin(context.Background(), user.ID) + go h.UpdateLastLogin(user.ID) budgets, err := h.Service.GetBudgetsForUser(c.Request.Context(), user.ID) if err != nil { return } - c.JSON(http.StatusOK, LoginResponse{t, user, budgets}) + c.JSON(http.StatusOK, LoginResponse{token, user, budgets}) +} + +func (h *Handler) UpdateLastLogin(userID uuid.UUID) { + _, err := h.Service.UpdateLastLogin(context.Background(), userID) + if err != nil { + fmt.Printf("Error updating last login: %s", err) + } } diff --git a/server/transaction.go b/server/transaction.go new file mode 100644 index 0000000..072f8ad --- /dev/null +++ b/server/transaction.go @@ -0,0 +1,60 @@ +package server + +import ( + "fmt" + "net/http" + "time" + + "git.javil.eu/jacob1123/budgeteer/postgres" + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +type NewTransactionPayload struct { + Date JSONDate `json:"date"` + Payee struct { + ID uuid.NullUUID + Name string + } `json:"payee"` + Category struct { + ID uuid.NullUUID + Name string + } `json:"category"` + Memo string `json:"memo"` + Amount string `json:"amount"` + BudgetID uuid.UUID `json:"budgetId"` + AccountID uuid.UUID `json:"accountId"` + State string `json:"state"` +} + +func (h *Handler) newTransaction(c *gin.Context) { + var payload NewTransactionPayload + err := c.BindJSON(&payload) + if err != nil { + c.AbortWithError(http.StatusBadRequest, err) + return + } + + fmt.Printf("%v\n", payload) + + amount := postgres.Numeric{} + err = amount.Set(payload.Amount) + if err != nil { + c.AbortWithError(http.StatusBadRequest, fmt.Errorf("amount: %w", err)) + return + } + + newTransaction := postgres.CreateTransactionParams{ + Memo: payload.Memo, + Date: time.Time(payload.Date), + Amount: amount, + AccountID: payload.AccountID, + PayeeID: payload.Payee.ID, + CategoryID: payload.Category.ID, + Status: postgres.TransactionStatus(payload.State), + } + _, err = h.Service.CreateTransaction(c.Request.Context(), newTransaction) + if err != nil { + c.AbortWithError(http.StatusInternalServerError, fmt.Errorf("create transaction: %w", err)) + } +} diff --git a/http/ynab-import.go b/server/ynab-import.go similarity index 84% rename from http/ynab-import.go rename to server/ynab-import.go index 948b442..349449e 100644 --- a/http/ynab-import.go +++ b/server/ynab-import.go @@ -1,7 +1,6 @@ -package http +package server import ( - "fmt" "net/http" "git.javil.eu/jacob1123/budgeteer/postgres" @@ -12,7 +11,7 @@ import ( func (h *Handler) importYNAB(c *gin.Context) { budgetID, succ := c.Params.Get("budgetid") if !succ { - c.AbortWithError(http.StatusBadRequest, fmt.Errorf("no budget_id specified")) + c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{"no budget_id specified"}) return } @@ -40,7 +39,7 @@ func (h *Handler) importYNAB(c *gin.Context) { return } - err = ynab.ImportTransactions(transactions) + err = ynab.ImportTransactions(c.Request.Context(), transactions) if err != nil { c.AbortWithError(http.StatusInternalServerError, err) return @@ -58,7 +57,7 @@ func (h *Handler) importYNAB(c *gin.Context) { return } - err = ynab.ImportAssignments(assignments) + err = ynab.ImportAssignments(c.Request.Context(), assignments) if err != nil { c.AbortWithError(http.StatusInternalServerError, err) return diff --git a/token.go b/token.go index 1a9cff3..944a25a 100644 --- a/token.go +++ b/token.go @@ -5,7 +5,7 @@ import ( "github.com/google/uuid" ) -// Token contains data that authenticates a user +// Token contains data that authenticates a user. type Token interface { GetUsername() string GetName() string @@ -13,7 +13,7 @@ type Token interface { GetID() uuid.UUID } -// TokenVerifier verifies a Token +// TokenVerifier verifies a Token. type TokenVerifier interface { VerifyToken(string) (Token, error) CreateToken(*postgres.User) (string, error) diff --git a/web/src/pages/Login.vue b/web/src/pages/Login.vue index be401cc..000c7a9 100644 --- a/web/src/pages/Login.vue +++ b/web/src/pages/Login.vue @@ -12,7 +12,7 @@ onMounted(() => { function formSubmit(e: MouseEvent) { e.preventDefault(); - useSessionStore().login(login) + useSessionStore().login(login.value) .then(x => { error.value = ""; useRouter().replace("/dashboard");