Merge pull request 'Add code linting to build' (#13) from linting into master
All checks were successful
continuous-integration/drone/push Build is passing
ci/woodpecker/push/woodpecker Pipeline was successful

Reviewed-on: #13
This commit is contained in:
Jan Bader 2022-02-20 23:53:47 +01:00
commit a6eb2a2253
31 changed files with 600 additions and 540 deletions

View File

@ -8,7 +8,7 @@ steps:
image: hub.javil.eu/budgeteer:dev image: hub.javil.eu/budgeteer:dev
pull: true pull: true
commands: commands:
- task - task ci
- name: docker - name: docker
image: plugins/docker image: plugins/docker

26
.golangci.yml Normal file
View File

@ -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

View File

@ -2,5 +2,8 @@
"files.exclude": { "files.exclude": {
"**/node_modules": true, "**/node_modules": true,
"**/vendor": true "**/vendor": true
},
"gopls": {
"formatting.gofumpt": true,
} }
} }

View File

@ -4,7 +4,7 @@ pipeline:
image: hub.javil.eu/budgeteer:dev image: hub.javil.eu/budgeteer:dev
pull: true pull: true
commands: commands:
- task - task ci
docker: docker:
image: plugins/docker image: plugins/docker

View File

@ -33,12 +33,7 @@ tasks:
sources: sources:
- ./go.mod - ./go.mod
- ./go.sum - ./go.sum
- ./cmd/budgeteer/*.go - ./**/*.go
- ./*.go
- ./config/*.go
- ./http/*.go
- ./jwt/*.go
- ./postgres/*.go
- ./web/dist/**/* - ./web/dist/**/*
- ./postgres/schema/* - ./postgres/schema/*
generates: generates:
@ -52,14 +47,25 @@ tasks:
desc: Build budgeteer in dev mode desc: Build budgeteer in dev mode
deps: [gomod, sqlc] deps: [gomod, sqlc]
cmds: cmds:
- go vet
- go fmt
- golangci-lint run
- task: build - task: build
build-prod: build-prod:
desc: Build budgeteer in prod mode desc: Build budgeteer in prod mode
deps: [gomod, sqlc, frontend] deps: [gomod, sqlc, frontend]
cmds: cmds:
- go vet
- go fmt
- golangci-lint run
- task: build - task: build
ci:
desc: Run CI build
cmds:
- task: build-prod
frontend: frontend:
desc: Build vue frontend desc: Build vue frontend
dir: web dir: web
@ -85,6 +91,8 @@ tasks:
desc: Build budgeeter:dev desc: Build budgeeter:dev
sources: sources:
- ./docker/Dockerfile - ./docker/Dockerfile
- ./docker/build.sh
- ./web/package.json
cmds: cmds:
- docker build -t {{.IMAGE_NAME}}:dev . -f docker/Dockerfile - docker build -t {{.IMAGE_NAME}}:dev . -f docker/Dockerfile
- docker push {{.IMAGE_NAME}}:dev - docker push {{.IMAGE_NAME}}:dev

View File

@ -1,23 +1,30 @@
package bcrypt package bcrypt
import "golang.org/x/crypto/bcrypt" import (
"fmt"
// Verifier verifys passwords using Bcrypt "golang.org/x/crypto/bcrypt"
type Verifier struct { )
cost int
}
// Verify verifys a Password // Verifier verifys passwords using Bcrypt.
func (bv *Verifier) Verify(password string, hashOnDb string) error { type Verifier struct{}
return bcrypt.CompareHashAndPassword([]byte(hashOnDb), []byte(password))
}
// Hash calculates a hash to be stored on the database // Verify verifys a Password.
func (bv *Verifier) Hash(password string) (string, error) { func (bv *Verifier) Verify(password string, hashOnDB string) error {
hash, err := bcrypt.GenerateFromPassword([]byte(password), bv.cost) err := bcrypt.CompareHashAndPassword([]byte(hashOnDB), []byte(password))
if err != nil { if err != nil {
return "", err return fmt.Errorf("verify password: %w", err)
} }
return string(hash[:]), nil 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
} }

View File

@ -1,13 +1,16 @@
package main package main
import ( import (
"io/fs"
"log" "log"
"net/http"
"git.javil.eu/jacob1123/budgeteer/bcrypt" "git.javil.eu/jacob1123/budgeteer/bcrypt"
"git.javil.eu/jacob1123/budgeteer/config" "git.javil.eu/jacob1123/budgeteer/config"
"git.javil.eu/jacob1123/budgeteer/http"
"git.javil.eu/jacob1123/budgeteer/jwt" "git.javil.eu/jacob1123/budgeteer/jwt"
"git.javil.eu/jacob1123/budgeteer/postgres" "git.javil.eu/jacob1123/budgeteer/postgres"
"git.javil.eu/jacob1123/budgeteer/server"
"git.javil.eu/jacob1123/budgeteer/web"
) )
func main() { func main() {
@ -16,16 +19,24 @@ func main() {
log.Fatalf("Could not load config: %v", err) 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 { if err != nil {
log.Fatalf("Failed connecting to DB: %v", err) log.Fatalf("Failed connecting to DB: %v", err)
} }
h := &http.Handler{ static, err := fs.Sub(web.Static, "dist")
Service: q, if err != nil {
TokenVerifier: &jwt.TokenVerifier{}, panic("couldn't open static files")
CredentialsVerifier: &bcrypt.Verifier{},
} }
h.Serve() handler := &server.Handler{
Service: queries,
TokenVerifier: &jwt.TokenVerifier{
Secret: cfg.SessionSecret,
},
CredentialsVerifier: &bcrypt.Verifier{},
StaticFS: http.FS(static),
}
handler.Serve()
} }

View File

@ -4,15 +4,17 @@ import (
"os" "os"
) )
// Config contains all needed configurations // Config contains all needed configurations.
type Config struct { type Config struct {
DatabaseConnection string DatabaseConnection string
SessionSecret string
} }
// LoadConfig from path // LoadConfig from path.
func LoadConfig() (*Config, error) { func LoadConfig() (*Config, error) {
configuration := Config{ configuration := Config{
DatabaseConnection: os.Getenv("BUDGETEER_DB"), DatabaseConnection: os.Getenv("BUDGETEER_DB"),
SessionSecret: os.Getenv("BUDGETEER_SESSION_SECRET"),
} }
return &configuration, nil return &configuration, nil

View File

@ -17,6 +17,7 @@ services:
- ~/.cache:/.cache - ~/.cache:/.cache
environment: environment:
BUDGETEER_DB: postgres://budgeteer:budgeteer@db:5432/budgeteer BUDGETEER_DB: postgres://budgeteer:budgeteer@db:5432/budgeteer
BUDGETEER_SESSION_SECRET: random string for JWT authorization
depends_on: depends_on:
- db - db

View File

@ -2,15 +2,16 @@ FROM alpine as godeps
RUN apk add go RUN apk add go
RUN go install github.com/kyleconroy/sqlc/cmd/sqlc@latest 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/go-task/task/v3/cmd/task@latest
RUN go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest
FROM alpine FROM alpine
RUN apk add go RUN apk add go
RUN apk add nodejs yarn bash curl git git-perl tmux RUN apk add nodejs yarn bash curl git git-perl tmux
ADD docker/build.sh / ADD docker/build.sh /
COPY --from=godeps /root/go/bin/task /root/go/bin/sqlc /usr/local/bin/
RUN yarn global add @vue/cli RUN yarn global add @vue/cli
ENV PATH="/root/.yarn/bin/:${PATH}" ENV PATH="/root/.yarn/bin/:${PATH}"
WORKDIR /src WORKDIR /src
ADD web/package.json /src/web/ ADD web/package.json /src/web/
RUN yarn RUN yarn
COPY --from=godeps /root/go/bin/task /root/go/bin/sqlc /root/go/bin/golangci-lint /usr/local/bin/
CMD /build.sh CMD /build.sh

View File

@ -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))
}*/
}

View File

@ -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
}

View File

@ -10,11 +10,12 @@ import (
"github.com/google/uuid" "github.com/google/uuid"
) )
// TokenVerifier verifies Tokens // TokenVerifier verifies Tokens.
type TokenVerifier struct { type TokenVerifier struct {
Secret string
} }
// Token contains everything to authenticate a user // Token contains everything to authenticate a user.
type Token struct { type Token struct {
username string username string
name string name string
@ -24,10 +25,9 @@ type Token struct {
const ( const (
expiration = 72 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) { func (tv *TokenVerifier) CreateToken(user *postgres.User) (string, error) {
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"usr": user.Email, "usr": user.Email,
@ -37,21 +37,27 @@ func (tv *TokenVerifier) CreateToken(user *postgres.User) (string, error) {
}) })
// Generate encoded token and send it as response. // Generate encoded token and send it as response.
t, err := token.SignedString([]byte(secret)) t, err := token.SignedString([]byte(tv.Secret))
if err != nil { if err != nil {
return "", err return "", fmt.Errorf("create token: %w", err)
} }
return t, nil return t, nil
} }
// VerifyToken verifys a given string-token var (
func (tv *TokenVerifier) VerifyToken(tokenString string) (budgeteer.Token, error) { 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) { token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { 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 { if err != nil {
return nil, fmt.Errorf("parse jwt: %w", err) 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) return nil, fmt.Errorf("verify jwt: %w", err)
} }
tkn := &Token{ tkn := &Token{ //nolint:forcetypeassert
username: claims["usr"].(string), username: claims["usr"].(string),
name: claims["name"].(string), name: claims["name"].(string),
expiry: claims["exp"].(float64), 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) { func verifyToken(token *jwt.Token) (jwt.MapClaims, error) {
if !token.Valid { if !token.Valid {
return nil, fmt.Errorf("Token is not valid") return nil, ErrInvalidToken
} }
claims, ok := token.Claims.(jwt.MapClaims) claims, ok := token.Claims.(jwt.MapClaims)
if !ok { if !ok {
return nil, fmt.Errorf("Claims are not of Type MapClaims") return nil, ErrInvalidToken
} }
if !claims.VerifyExpiresAt(time.Now().Unix(), true) { if !claims.VerifyExpiresAt(time.Now().Unix(), true) {
return nil, fmt.Errorf("Claims have expired") return nil, ErrTokenExpired
} }
return claims, nil return claims, nil

View File

@ -8,11 +8,15 @@ import (
"github.com/google/uuid" "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) { func (s *Database) NewBudget(context context.Context, name string, userID uuid.UUID) (*Budget, error) {
tx, err := s.BeginTx(context, &sql.TxOptions{}) tx, err := s.BeginTx(context, &sql.TxOptions{})
q := s.WithTx(tx) if err != nil {
budget, err := q.CreateBudget(context, CreateBudgetParams{ return nil, fmt.Errorf("begin transaction: %w", err)
}
transaction := s.WithTx(tx)
budget, err := transaction.CreateBudget(context, CreateBudgetParams{
Name: name, Name: name,
IncomeCategoryID: uuid.New(), 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} ub := LinkBudgetToUserParams{UserID: userID, BudgetID: budget.ID}
_, err = q.LinkBudgetToUser(context, ub) _, err = transaction.LinkBudgetToUser(context, ub)
if err != nil { if err != nil {
return nil, fmt.Errorf("link budget to user: %w", err) return nil, fmt.Errorf("link budget to user: %w", err)
} }
group, err := q.CreateCategoryGroup(context, CreateCategoryGroupParams{ group, err := transaction.CreateCategoryGroup(context, CreateCategoryGroupParams{
Name: "Inflow", Name: "Inflow",
BudgetID: budget.ID, 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) 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", Name: "Ready to Assign",
CategoryGroupID: group.ID, 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) return nil, fmt.Errorf("create ready to assign category: %w", err)
} }
err = q.SetInflowCategory(context, SetInflowCategoryParams{ err = transaction.SetInflowCategory(context, SetInflowCategoryParams{
IncomeCategoryID: cat.ID, IncomeCategoryID: cat.ID,
ID: budget.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) 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 return &budget, nil
} }

View File

@ -5,7 +5,7 @@ import (
"embed" "embed"
"fmt" "fmt"
_ "github.com/jackc/pgx/v4/stdlib" _ "github.com/jackc/pgx/v4/stdlib" // needed for pg connection
"github.com/pressly/goose/v3" "github.com/pressly/goose/v3"
) )
@ -17,7 +17,7 @@ type Database struct {
*sql.DB *sql.DB
} }
// Connect to a database // Connect connects to a database.
func Connect(typ string, connString string) (*Database, error) { func Connect(typ string, connString string) (*Database, error) {
conn, err := sql.Open(typ, connString) conn, err := sql.Open(typ, connString)
if err != nil { if err != nil {

View File

@ -45,7 +45,7 @@ func (n Numeric) IsZero() bool {
func (n Numeric) MatchExp(exp int32) Numeric { func (n Numeric) MatchExp(exp int32) Numeric {
diffExp := n.Exp - exp 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{ return Numeric{pgtype.Numeric{
Exp: exp, Exp: exp,
Int: big.NewInt(0).Mul(n.Int, factor), 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 left := n
right := o right := other
if n.Exp < o.Exp { if n.Exp < other.Exp {
right = o.MatchExp(n.Exp) right = other.MatchExp(n.Exp)
} else if n.Exp > o.Exp { } else if n.Exp > other.Exp {
left = n.MatchExp(o.Exp) left = n.MatchExp(other.Exp)
} }
if left.Exp == right.Exp { if left.Exp == right.Exp {
@ -72,13 +72,14 @@ func (n Numeric) Sub(o Numeric) Numeric {
panic("Cannot subtract with different exponents") panic("Cannot subtract with different exponents")
} }
func (n Numeric) Add(o Numeric) Numeric {
func (n Numeric) Add(other Numeric) Numeric {
left := n left := n
right := o right := other
if n.Exp < o.Exp { if n.Exp < other.Exp {
right = o.MatchExp(n.Exp) right = other.MatchExp(n.Exp)
} else if n.Exp > o.Exp { } else if n.Exp > other.Exp {
left = n.MatchExp(o.Exp) left = n.MatchExp(other.Exp)
} }
if left.Exp == right.Exp { if left.Exp == right.Exp {

View File

@ -13,7 +13,6 @@ import (
) )
type YNABImport struct { type YNABImport struct {
Context context.Context
accounts []Account accounts []Account
payees []Payee payees []Payee
categories []GetCategoriesRow categories []GetCategoriesRow
@ -22,73 +21,70 @@ type YNABImport struct {
budgetID uuid.UUID budgetID uuid.UUID
} }
func NewYNABImport(context context.Context, q *Queries, budgetID uuid.UUID) (*YNABImport, error) { func NewYNABImport(context context.Context, queries *Queries, budgetID uuid.UUID) (*YNABImport, error) {
accounts, err := q.GetAccounts(context, budgetID) accounts, err := queries.GetAccounts(context, budgetID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
payees, err := q.GetPayees(context, budgetID) payees, err := queries.GetPayees(context, budgetID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
categories, err := q.GetCategories(context, budgetID) categories, err := queries.GetCategories(context, budgetID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
categoryGroups, err := q.GetCategoryGroups(context, budgetID) categoryGroups, err := queries.GetCategoryGroups(context, budgetID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
return &YNABImport{ return &YNABImport{
Context: context,
accounts: accounts, accounts: accounts,
payees: payees, payees: payees,
categories: categories, categories: categories,
categoryGroups: categoryGroups, categoryGroups: categoryGroups,
queries: q, queries: queries,
budgetID: budgetID, budgetID: budgetID,
}, nil }, nil
} }
// ImportAssignments expects a TSV-file as exported by YNAB in the following format: // ImportAssignments expects a TSV-file as exported by YNAB in the following format:
//"Month" "Category Group/Category" "Category Group" "Category" "Budgeted" "Activity" "Available" // "Month" "Category Group/Category" "Category Group" "Category" "Budgeted" "Activity" "Available"
//"Apr 2019" "Income: Next Month" "Income" "Next Month" 0,00€ 0,00€ 0,00€ // "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 // Activity and Available are not imported, since they are determined by the transactions and historic assignments.
func (ynab *YNABImport) ImportAssignments(r io.Reader) error { func (ynab *YNABImport) ImportAssignments(context context.Context, r io.Reader) error {
csv := csv.NewReader(r) csv := csv.NewReader(r)
csv.Comma = '\t' csv.Comma = '\t'
csv.LazyQuotes = true csv.LazyQuotes = true
csvData, err := csv.ReadAll() csvData, err := csv.ReadAll()
if err != nil { if err != nil {
return fmt.Errorf("could not read from tsv: %w", err) return fmt.Errorf("read from tsv: %w", err)
} }
count := 0 count := 0
for _, record := range csvData[1:] { for _, record := range csvData[1:] {
dateString := record[0] dateString := record[0]
date, err := time.Parse("Jan 2006", dateString) date, err := time.Parse("Jan 2006", dateString)
if err != nil { 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 : categoryGroup, categoryName := record[2], record[3] // also in 1 joined by :
category, err := ynab.GetCategory(categoryGroup, categoryName) category, err := ynab.GetCategory(context, categoryGroup, categoryName)
if err != nil { 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] amountString := record[4]
amount, err := GetAmount(amountString, "0,00€") amount, err := GetAmount(amountString, "0,00€")
if err != nil { 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 { if amount.Int.Int64() == 0 {
@ -100,9 +96,9 @@ func (ynab *YNABImport) ImportAssignments(r io.Reader) error {
CategoryID: category.UUID, CategoryID: category.UUID,
Amount: amount, Amount: amount,
} }
_, err = ynab.queries.CreateAssignment(ynab.Context, assignment) _, err = ynab.queries.CreateAssignment(context, assignment)
if err != nil { if err != nil {
return fmt.Errorf("could not save assignment %v: %w", assignment, err) return fmt.Errorf("save assignment %v: %w", assignment, err)
} }
count++ count++
@ -120,40 +116,79 @@ type Transfer struct {
ToAccount string ToAccount string
} }
// ImportTransactions expects a TSV-file as exported by YNAB in the following format: // ImportTransactions expects a TSV-file as exported by YNAB.
func (ynab *YNABImport) ImportTransactions(context context.Context, r io.Reader) error {
func (ynab *YNABImport) ImportTransactions(r io.Reader) error {
csv := csv.NewReader(r) csv := csv.NewReader(r)
csv.Comma = '\t' csv.Comma = '\t'
csv.LazyQuotes = true csv.LazyQuotes = true
csvData, err := csv.ReadAll() csvData, err := csv.ReadAll()
if err != nil { if err != nil {
return fmt.Errorf("could not read from tsv: %w", err) return fmt.Errorf("read from tsv: %w", err)
} }
var openTransfers []Transfer var openTransfers []Transfer
count := 0 count := 0
for _, record := range csvData[1:] { for _, record := range csvData[1:] {
accountName := record[0] transaction, err := ynab.GetTransaction(context, record)
account, err := ynab.GetAccount(accountName)
if err != nil { if err != nil {
return fmt.Errorf("could not get account %s: %w", accountName, err) return err
} }
//flag := record[1] payeeName := record[3]
// Transaction is a transfer
if strings.HasPrefix(payeeName, "Transfer : ") {
err = ynab.ImportTransferTransaction(context, payeeName, transaction.CreateTransactionParams,
&openTransfers, transaction.Account, transaction.Amount)
} else {
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(context, openTransfer.CreateTransactionParams)
if err != nil {
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] dateString := record[2]
date, err := time.Parse("02.01.2006", dateString) date, err := time.Parse("02.01.2006", dateString)
if err != nil { if err != nil {
return fmt.Errorf("could not parse date %s: %w", dateString, err) return NewTransaction{}, fmt.Errorf("parse date %s: %w", dateString, err)
} }
categoryGroup, categoryName := record[5], record[6] //also in 4 joined by : categoryGroup, categoryName := record[5], record[6] // also in 4 joined by :
category, err := ynab.GetCategory(categoryGroup, categoryName) category, err := ynab.GetCategory(context, categoryGroup, categoryName)
if err != nil { if err != nil {
return fmt.Errorf("could not get category %s/%s: %w", categoryGroup, categoryName, err) return NewTransaction{}, fmt.Errorf("get category %s/%s: %w", categoryGroup, categoryName, err)
} }
memo := record[7] memo := record[7]
@ -162,7 +197,7 @@ func (ynab *YNABImport) ImportTransactions(r io.Reader) error {
inflow := record[9] inflow := record[9]
amount, err := GetAmount(inflow, outflow) amount, err := GetAmount(inflow, outflow)
if err != nil { if err != nil {
return fmt.Errorf("could not parse amount from (%s/%s): %w", inflow, outflow, err) return NewTransaction{}, fmt.Errorf("parse amount from (%s/%s): %w", inflow, outflow, err)
} }
statusEnum := TransactionStatusUncleared statusEnum := TransactionStatusUncleared
@ -175,33 +210,52 @@ func (ynab *YNABImport) ImportTransactions(r io.Reader) error {
case "Uncleared": case "Uncleared":
} }
transaction := CreateTransactionParams{ return NewTransaction{
CreateTransactionParams: CreateTransactionParams{
Date: date, Date: date,
Memo: memo, Memo: memo,
AccountID: account.ID, AccountID: account.ID,
CategoryID: category, CategoryID: category,
Amount: amount, Amount: amount,
Status: statusEnum, Status: statusEnum,
} },
Account: account,
}, nil
}
payeeName := record[3] func (ynab *YNABImport) ImportRegularTransaction(context context.Context, payeeName string,
if strings.HasPrefix(payeeName, "Transfer : ") { transaction CreateTransactionParams) error {
// Transaction is a transfer to payeeID, err := ynab.GetPayee(context, payeeName)
transferToAccountName := payeeName[11:]
transferToAccount, err := ynab.GetAccount(transferToAccountName)
if err != nil { if err != nil {
return fmt.Errorf("Could not get transfer account %s: %w", transferToAccountName, err) 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{ transfer := Transfer{
transaction, transaction,
transferToAccount, transferToAccount,
accountName, account.Name,
transferToAccountName, transferToAccountName,
} }
found := false found := false
for i, openTransfer := range openTransfers { for i, openTransfer := range *openTransfers {
if openTransfer.TransferToAccount.ID != transfer.AccountID { if openTransfer.TransferToAccount.ID != transfer.AccountID {
continue continue
} }
@ -213,54 +267,29 @@ func (ynab *YNABImport) ImportTransactions(r io.Reader) error {
} }
fmt.Printf("Matched transfers from %s to %s over %f\n", account.Name, transferToAccount.Name, amount.GetFloat64()) fmt.Printf("Matched transfers from %s to %s over %f\n", account.Name, transferToAccount.Name, amount.GetFloat64())
openTransfers[i] = openTransfers[len(openTransfers)-1] transfers := *openTransfers
openTransfers = openTransfers[:len(openTransfers)-1] transfers[i] = transfers[len(transfers)-1]
*openTransfers = transfers[:len(transfers)-1]
found = true found = true
groupID := uuid.New() groupID := uuid.New()
transfer.GroupID = uuid.NullUUID{UUID: groupID, Valid: true} transfer.GroupID = uuid.NullUUID{UUID: groupID, Valid: true}
openTransfer.GroupID = uuid.NullUUID{UUID: groupID, Valid: true} openTransfer.GroupID = uuid.NullUUID{UUID: groupID, Valid: true}
_, err = ynab.queries.CreateTransaction(ynab.Context, transfer.CreateTransactionParams) _, err = ynab.queries.CreateTransaction(context, transfer.CreateTransactionParams)
if err != nil { if err != nil {
return fmt.Errorf("could not save transaction %v: %w", transfer.CreateTransactionParams, err) return fmt.Errorf("save transaction %v: %w", transfer.CreateTransactionParams, err)
} }
_, err = ynab.queries.CreateTransaction(ynab.Context, openTransfer.CreateTransactionParams) _, err = ynab.queries.CreateTransaction(context, openTransfer.CreateTransactionParams)
if err != nil { 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)
} }
break break
} }
if !found { if !found {
openTransfers = append(openTransfers, transfer) *openTransfers = append(*openTransfers, transfer)
} }
} 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)
}
}
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)
if err != nil {
return fmt.Errorf("could not save transaction %v: %w", openTransfer.CreateTransactionParams, err)
}
}
fmt.Printf("Imported %d transactions\n", count)
return nil return nil
} }
@ -280,7 +309,7 @@ func GetAmount(inflow string, outflow string) (Numeric, error) {
num := Numeric{} num := Numeric{}
err := num.Set(inflow) err := num.Set(inflow)
if err != nil { 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 // if inflow is zero, use outflow
@ -290,19 +319,19 @@ func GetAmount(inflow string, outflow string) (Numeric, error) {
err = num.Set("-" + outflow) err = num.Set("-" + outflow)
if err != nil { 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 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 { for _, acc := range ynab.accounts {
if acc.Name == name { if acc.Name == name {
return &acc, nil 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 { if err != nil {
return nil, err return nil, err
} }
@ -311,7 +340,7 @@ func (ynab *YNABImport) GetAccount(name string) (*Account, error) {
return &account, nil 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 == "" { if name == "" {
return uuid.NullUUID{}, nil 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 { if err != nil {
return uuid.NullUUID{}, err 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 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 == "" { if group == "" || name == "" {
return uuid.NullUUID{}, nil return uuid.NullUUID{}, nil
} }
@ -342,32 +371,25 @@ func (ynab *YNABImport) GetCategory(group string, name string) (uuid.NullUUID, e
} }
} }
for _, categoryGroup := range ynab.categoryGroups { var categoryGroup CategoryGroup
if categoryGroup.Name == group { for _, existingGroup := range ynab.categoryGroups {
createCategory := CreateCategoryParams{Name: name, CategoryGroupID: categoryGroup.ID} if existingGroup.Name == group {
category, err := ynab.queries.CreateCategory(ynab.Context, createCategory) categoryGroup = existingGroup
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
} }
} }
categoryGroup, err := ynab.queries.CreateCategoryGroup(ynab.Context, CreateCategoryGroupParams{Name: group, BudgetID: ynab.budgetID}) if categoryGroup.Name == "" {
newGroup := CreateCategoryGroupParams{Name: group, BudgetID: ynab.budgetID}
var err error
categoryGroup, err = ynab.queries.CreateCategoryGroup(context, newGroup)
if err != nil { if err != nil {
return uuid.NullUUID{}, err 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 { if err != nil {
return uuid.NullUUID{}, err return uuid.NullUUID{}, err
} }

View File

@ -1,4 +1,4 @@
package http package server
import ( import (
"net/http" "net/http"

View File

@ -1,4 +1,4 @@
package http package server
import ( import (
"encoding/json" "encoding/json"
@ -10,47 +10,53 @@ import (
"git.javil.eu/jacob1123/budgeteer/bcrypt" "git.javil.eu/jacob1123/budgeteer/bcrypt"
"git.javil.eu/jacob1123/budgeteer/jwt" "git.javil.eu/jacob1123/budgeteer/jwt"
"git.javil.eu/jacob1123/budgeteer/postgres" "git.javil.eu/jacob1123/budgeteer/postgres"
"github.com/gin-gonic/gin"
txdb "github.com/DATA-DOG/go-txdb" 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") txdb.Register("pgtx", "pgx", "postgres://budgeteer_test:budgeteer_test@localhost:5432/budgeteer_test")
} }
func TestListTimezonesHandler(t *testing.T) { func TestRegisterUser(t *testing.T) { //nolint:funlen
db, err := postgres.Connect("pgtx", "example") t.Parallel()
database, err := postgres.Connect("pgtx", "example")
if err != nil { if err != nil {
t.Errorf("could not connect to db: %s", err) t.Errorf("could not connect to db: %s", err)
return return
} }
h := Handler{ h := Handler{
Service: db, Service: database,
TokenVerifier: &jwt.TokenVerifier{}, TokenVerifier: &jwt.TokenVerifier{
Secret: "this_is_my_demo_secret_for_unit_tests",
},
CredentialsVerifier: &bcrypt.Verifier{}, CredentialsVerifier: &bcrypt.Verifier{},
} }
rr := httptest.NewRecorder() recorder := httptest.NewRecorder()
c, engine := gin.CreateTestContext(rr) context, engine := gin.CreateTestContext(recorder)
h.LoadRoutes(engine) h.LoadRoutes(engine)
t.Run("RegisterUser", func(t *testing.T) { 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 { if err != nil {
t.Errorf("error creating request: %s", err) t.Errorf("error creating request: %s", err)
return return
} }
h.registerPost(c) h.registerPost(context)
if rr.Code != http.StatusOK { if recorder.Code != http.StatusOK {
t.Errorf("handler returned wrong status code: got %v want %v", rr.Code, http.StatusOK) t.Errorf("handler returned wrong status code: got %v want %v", recorder.Code, http.StatusOK)
} }
var response LoginResponse var response LoginResponse
err = json.NewDecoder(rr.Body).Decode(&response) err = json.NewDecoder(recorder.Body).Decode(&response)
if err != nil { if err != nil {
t.Error(err.Error()) t.Error(err.Error())
t.Error("Error registering") t.Error("Error registering")
@ -61,13 +67,14 @@ func TestListTimezonesHandler(t *testing.T) {
}) })
t.Run("GetTransactions", func(t *testing.T) { t.Run("GetTransactions", func(t *testing.T) {
c.Request, err = http.NewRequest(http.MethodGet, "/account/accountid/transactions", nil) t.Parallel()
if rr.Code != http.StatusOK { context.Request, err = http.NewRequest(http.MethodGet, "/account/accountid/transactions", nil)
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 TransactionsResponse var response TransactionsResponse
err = json.NewDecoder(rr.Body).Decode(&response) err = json.NewDecoder(recorder.Body).Decode(&response)
if err != nil { if err != nil {
t.Error(err.Error()) t.Error(err.Error())
t.Error("Error retreiving list of transactions.") t.Error("Error retreiving list of transactions.")

View File

@ -1,4 +1,4 @@
package http package server
import ( import (
"fmt" "fmt"

View File

@ -1,7 +1,6 @@
package http package server
import ( import (
"fmt"
"net/http" "net/http"
"git.javil.eu/jacob1123/budgeteer/postgres" "git.javil.eu/jacob1123/budgeteer/postgres"
@ -13,7 +12,7 @@ func (h *Handler) autocompleteCategories(c *gin.Context) {
budgetID := c.Param("budgetid") budgetID := c.Param("budgetid")
budgetUUID, err := uuid.Parse(budgetID) budgetUUID, err := uuid.Parse(budgetID)
if err != nil { if err != nil {
c.AbortWithError(http.StatusBadRequest, fmt.Errorf("budgetid missing from URL")) c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{"budgetid missing from URL"})
return return
} }
@ -35,7 +34,7 @@ func (h *Handler) autocompletePayee(c *gin.Context) {
budgetID := c.Param("budgetid") budgetID := c.Param("budgetid")
budgetUUID, err := uuid.Parse(budgetID) budgetUUID, err := uuid.Parse(budgetID)
if err != nil { if err != nil {
c.AbortWithError(http.StatusBadRequest, fmt.Errorf("budgetid missing from URL")) c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{"budgetid missing from URL"})
return return
} }

View File

@ -1,10 +1,8 @@
package http package server
import ( import (
"fmt"
"net/http" "net/http"
"git.javil.eu/jacob1123/budgeteer"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
@ -14,18 +12,17 @@ type newBudgetInformation struct {
func (h *Handler) newBudget(c *gin.Context) { func (h *Handler) newBudget(c *gin.Context) {
var newBudget newBudgetInformation var newBudget newBudgetInformation
err := c.BindJSON(&newBudget) if err := c.BindJSON(&newBudget); err != nil {
if err != nil {
c.AbortWithError(http.StatusNotAcceptable, err) c.AbortWithError(http.StatusNotAcceptable, err)
return return
} }
if newBudget.Name == "" { if newBudget.Name == "" {
c.AbortWithError(http.StatusNotAcceptable, fmt.Errorf("Budget name is needed")) c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{"budget name is required"})
return return
} }
userID := c.MustGet("token").(budgeteer.Token).GetID() userID := MustGetToken(c).GetID()
budget, err := h.Service.NewBudget(c.Request.Context(), newBudget.Name, userID) budget, err := h.Service.NewBudget(c.Request.Context(), newBudget.Name, userID)
if err != nil { if err != nil {
c.AbortWithError(http.StatusInternalServerError, err) c.AbortWithError(http.StatusInternalServerError, err)

View File

@ -1,4 +1,4 @@
package http package server
import ( import (
"fmt" "fmt"
@ -55,7 +55,7 @@ func (h *Handler) budgetingForMonth(c *gin.Context) {
budgetID := c.Param("budgetid") budgetID := c.Param("budgetid")
budgetUUID, err := uuid.Parse(budgetID) budgetUUID, err := uuid.Parse(budgetID)
if err != nil { if err != nil {
c.AbortWithError(http.StatusBadRequest, fmt.Errorf("budgetid missing from URL")) c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{"budgetid missing from URL"})
return return
} }
@ -80,16 +80,25 @@ func (h *Handler) budgetingForMonth(c *gin.Context) {
firstOfNextMonth := firstOfMonth.AddDate(0, 1, 0) firstOfNextMonth := firstOfMonth.AddDate(0, 1, 0)
cumultativeBalances, err := h.Service.GetCumultativeBalances(c.Request.Context(), budgetUUID) cumultativeBalances, err := h.Service.GetCumultativeBalances(c.Request.Context(), budgetUUID)
if err != nil { 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 return
} }
// skip everything in the future categoriesWithBalance, moneyUsed := h.calculateBalances(
categoriesWithBalance, moneyUsed, err := h.calculateBalances(c, budget, firstOfNextMonth, firstOfMonth, categories, cumultativeBalances) budget, firstOfNextMonth, firstOfMonth, categories, cumultativeBalances)
if err != nil {
return
}
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() availableBalance := postgres.NewZeroNumeric()
for _, cat := range categories { for _, cat := range categories {
if cat.ID != budget.IncomeCategoryID { if cat.ID != budget.IncomeCategoryID {
@ -109,20 +118,14 @@ func (h *Handler) budgetingForMonth(c *gin.Context) {
availableBalance = availableBalance.Add(bal.Transactions) availableBalance = availableBalance.Add(bal.Transactions)
} }
} }
return availableBalance
data := struct {
Categories []CategoryWithBalance
AvailableBalance postgres.Numeric
}{categoriesWithBalance, availableBalance}
c.JSON(http.StatusOK, data)
} }
func (h *Handler) budgeting(c *gin.Context) { func (h *Handler) budgeting(c *gin.Context) {
budgetID := c.Param("budgetid") budgetID := c.Param("budgetid")
budgetUUID, err := uuid.Parse(budgetID) budgetUUID, err := uuid.Parse(budgetID)
if err != nil { if err != nil {
c.AbortWithError(http.StatusBadRequest, fmt.Errorf("budgetid missing from URL")) c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{"budgetid missing from URL"})
return return
} }
@ -146,7 +149,9 @@ func (h *Handler) budgeting(c *gin.Context) {
c.JSON(http.StatusOK, data) 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{} categoriesWithBalance := []CategoryWithBalance{}
hiddenCategory := CategoryWithBalance{ hiddenCategory := CategoryWithBalance{
GetCategoriesRow: &postgres.GetCategoriesRow{ GetCategoriesRow: &postgres.GetCategoriesRow{
@ -162,39 +167,9 @@ func (h *Handler) calculateBalances(c *gin.Context, budget postgres.Budget, firs
moneyUsed := postgres.NewZeroNumeric() moneyUsed := postgres.NewZeroNumeric()
for i := range categories { for i := range categories {
cat := &categories[i] 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 // do not show hidden categories
categoryWithBalance := h.CalculateCategoryBalances(cat, cumultativeBalances,
firstOfNextMonth, &moneyUsed, firstOfMonth, hiddenCategory, budget)
if cat.Group == "Hidden Categories" { if cat.Group == "Hidden Categories" {
hiddenCategory.Available = hiddenCategory.Available.Add(categoryWithBalance.Available) hiddenCategory.Available = hiddenCategory.Available.Add(categoryWithBalance.Available)
hiddenCategory.AvailableLastMonth = hiddenCategory.AvailableLastMonth.Add(categoryWithBalance.AvailableLastMonth) 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) 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
} }

View File

@ -1,15 +1,14 @@
package http package server
import ( import (
"net/http" "net/http"
"git.javil.eu/jacob1123/budgeteer"
"git.javil.eu/jacob1123/budgeteer/postgres" "git.javil.eu/jacob1123/budgeteer/postgres"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
func (h *Handler) dashboard(c *gin.Context) { 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) budgets, err := h.Service.GetBudgetsForUser(c.Request.Context(), userID)
if err != nil { if err != nil {
return return

View File

@ -1,4 +1,4 @@
package http package server
import ( import (
"errors" "errors"
@ -11,12 +11,10 @@ import (
"git.javil.eu/jacob1123/budgeteer" "git.javil.eu/jacob1123/budgeteer"
"git.javil.eu/jacob1123/budgeteer/bcrypt" "git.javil.eu/jacob1123/budgeteer/bcrypt"
"git.javil.eu/jacob1123/budgeteer/postgres" "git.javil.eu/jacob1123/budgeteer/postgres"
"git.javil.eu/jacob1123/budgeteer/web"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
// Handler handles incoming requests // Handler handles incoming requests.
type Handler struct { type Handler struct {
Service *postgres.Database Service *postgres.Database
TokenVerifier budgeteer.TokenVerifier TokenVerifier budgeteer.TokenVerifier
@ -24,25 +22,22 @@ type Handler struct {
StaticFS http.FileSystem StaticFS http.FileSystem
} }
const ( // Serve starts the http server.
expiration = 72
)
// Serve starts the http server
func (h *Handler) Serve() { func (h *Handler) Serve() {
router := gin.Default() router := gin.Default()
h.LoadRoutes(router) h.LoadRoutes(router)
router.Run(":1323")
if err := router.Run(":1323"); err != nil {
panic(err)
}
} }
// LoadRoutes initializes all the routes type ErrorResponse struct {
func (h *Handler) LoadRoutes(router *gin.Engine) { Message string
static, err := fs.Sub(web.Static, "dist") }
if err != nil {
panic("couldn't open static files")
}
h.StaticFS = http.FS(static)
// LoadRoutes initializes all the routes.
func (h *Handler) LoadRoutes(router *gin.Engine) {
router.Use(enableCachingForStaticFiles()) router.Use(enableCachingForStaticFiles())
router.NoRoute(h.ServeStatic) router.NoRoute(h.ServeStatic)
@ -81,6 +76,7 @@ func (h *Handler) LoadRoutes(router *gin.Engine) {
transaction.POST("/new", h.newTransaction) transaction.POST("/new", h.newTransaction)
transaction.POST("/:transactionid", h.newTransaction) transaction.POST("/:transactionid", h.newTransaction)
} }
func (h *Handler) ServeStatic(c *gin.Context) { func (h *Handler) ServeStatic(c *gin.Context) {
h.ServeStaticFile(c, c.Request.URL.Path) h.ServeStaticFile(c, c.Request.URL.Path)
} }
@ -108,7 +104,11 @@ func (h *Handler) ServeStaticFile(c *gin.Context, fullPath string) {
return 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 { func enableCachingForStaticFiles() gin.HandlerFunc {

View File

@ -1,29 +1,36 @@
package http package server
import ( import (
"encoding/json" "encoding/json"
"fmt"
"strings" "strings"
"time" "time"
) )
type JSONDate time.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 { func (j *JSONDate) UnmarshalJSON(b []byte) error {
s := strings.Trim(string(b), "\"") s := strings.Trim(string(b), "\"")
t, err := time.Parse("2006-01-02", s) t, err := time.Parse("2006-01-02", s)
if err != nil { if err != nil {
return err return fmt.Errorf("parse date: %w", err)
} }
*j = JSONDate(t) *j = JSONDate(t)
return nil return nil
} }
// MarshalJSON converts the JSONDate to a JSON in ISO format.
func (j JSONDate) MarshalJSON() ([]byte, error) { 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 { func (j JSONDate) Format(s string) string {
t := time.Time(j) t := time.Time(j)
return t.Format(s) return t.Format(s)

View File

@ -1,4 +1,4 @@
package http package server
import ( import (
"context" "context"
@ -8,18 +8,34 @@ import (
"git.javil.eu/jacob1123/budgeteer" "git.javil.eu/jacob1123/budgeteer"
"git.javil.eu/jacob1123/budgeteer/postgres" "git.javil.eu/jacob1123/budgeteer/postgres"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/google/uuid"
) )
func (h *Handler) verifyLogin(c *gin.Context) (budgeteer.Token, error) { const (
tokenString := c.GetHeader("Authorization") HeaderName = "Authorization"
if len(tokenString) < 8 { Bearer = "Bearer "
return nil, fmt.Errorf("no authorization header supplied") 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:] tokenString = tokenString[7:]
token, err := h.TokenVerifier.VerifyToken(tokenString) token, err := h.TokenVerifier.VerifyToken(tokenString)
if err != nil { 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 return token, nil
@ -28,12 +44,12 @@ func (h *Handler) verifyLogin(c *gin.Context) (budgeteer.Token, error) {
func (h *Handler) verifyLoginWithForbidden(c *gin.Context) { func (h *Handler) verifyLoginWithForbidden(c *gin.Context) {
token, err := h.verifyLogin(c) token, err := h.verifyLogin(c)
if err != nil { if err != nil {
//c.Header("WWW-Authenticate", "Bearer") // c.Header("WWW-Authenticate", "Bearer")
c.AbortWithError(http.StatusForbidden, err) c.AbortWithStatusJSON(http.StatusForbidden, err)
return return
} }
c.Set("token", token) c.Set(ParamName, token)
c.Next() c.Next()
} }
@ -45,7 +61,7 @@ func (h *Handler) verifyLoginWithRedirect(c *gin.Context) {
return return
} }
c.Set("token", token) c.Set(ParamName, token)
c.Next() c.Next()
} }
@ -72,19 +88,19 @@ func (h *Handler) loginPost(c *gin.Context) {
return return
} }
t, err := h.TokenVerifier.CreateToken(&user) token, err := h.TokenVerifier.CreateToken(&user)
if err != nil { if err != nil {
c.AbortWithError(http.StatusUnauthorized, err) 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) budgets, err := h.Service.GetBudgetsForUser(c.Request.Context(), user.ID)
if err != nil { if err != nil {
return return
} }
c.JSON(http.StatusOK, LoginResponse{t, user, budgets}) c.JSON(http.StatusOK, LoginResponse{token, user, budgets})
} }
type LoginResponse struct { type LoginResponse struct {
@ -101,16 +117,20 @@ type registerInformation struct {
func (h *Handler) registerPost(c *gin.Context) { func (h *Handler) registerPost(c *gin.Context) {
var register registerInformation var register registerInformation
c.BindJSON(&register) err := c.BindJSON(&register)
if err != nil {
if register.Email == "" || register.Password == "" || register.Name == "" { c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{"error parsing body"})
c.AbortWithError(http.StatusBadRequest, fmt.Errorf("e-mail, password and name are required"))
return 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 { if err == nil {
c.AbortWithError(http.StatusBadRequest, fmt.Errorf("email is already taken")) c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{"email is already taken"})
return return
} }
@ -130,17 +150,24 @@ func (h *Handler) registerPost(c *gin.Context) {
c.AbortWithError(http.StatusInternalServerError, err) c.AbortWithError(http.StatusInternalServerError, err)
} }
t, err := h.TokenVerifier.CreateToken(&user) token, err := h.TokenVerifier.CreateToken(&user)
if err != nil { if err != nil {
c.AbortWithError(http.StatusUnauthorized, err) 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) budgets, err := h.Service.GetBudgetsForUser(c.Request.Context(), user.ID)
if err != nil { if err != nil {
return 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)
}
} }

60
server/transaction.go Normal file
View File

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

View File

@ -1,7 +1,6 @@
package http package server
import ( import (
"fmt"
"net/http" "net/http"
"git.javil.eu/jacob1123/budgeteer/postgres" "git.javil.eu/jacob1123/budgeteer/postgres"
@ -12,7 +11,7 @@ import (
func (h *Handler) importYNAB(c *gin.Context) { func (h *Handler) importYNAB(c *gin.Context) {
budgetID, succ := c.Params.Get("budgetid") budgetID, succ := c.Params.Get("budgetid")
if !succ { if !succ {
c.AbortWithError(http.StatusBadRequest, fmt.Errorf("no budget_id specified")) c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{"no budget_id specified"})
return return
} }
@ -40,7 +39,7 @@ func (h *Handler) importYNAB(c *gin.Context) {
return return
} }
err = ynab.ImportTransactions(transactions) err = ynab.ImportTransactions(c.Request.Context(), transactions)
if err != nil { if err != nil {
c.AbortWithError(http.StatusInternalServerError, err) c.AbortWithError(http.StatusInternalServerError, err)
return return
@ -58,7 +57,7 @@ func (h *Handler) importYNAB(c *gin.Context) {
return return
} }
err = ynab.ImportAssignments(assignments) err = ynab.ImportAssignments(c.Request.Context(), assignments)
if err != nil { if err != nil {
c.AbortWithError(http.StatusInternalServerError, err) c.AbortWithError(http.StatusInternalServerError, err)
return return

View File

@ -5,7 +5,7 @@ import (
"github.com/google/uuid" "github.com/google/uuid"
) )
// Token contains data that authenticates a user // Token contains data that authenticates a user.
type Token interface { type Token interface {
GetUsername() string GetUsername() string
GetName() string GetName() string
@ -13,7 +13,7 @@ type Token interface {
GetID() uuid.UUID GetID() uuid.UUID
} }
// TokenVerifier verifies a Token // TokenVerifier verifies a Token.
type TokenVerifier interface { type TokenVerifier interface {
VerifyToken(string) (Token, error) VerifyToken(string) (Token, error)
CreateToken(*postgres.User) (string, error) CreateToken(*postgres.User) (string, error)

View File

@ -12,7 +12,7 @@ onMounted(() => {
function formSubmit(e: MouseEvent) { function formSubmit(e: MouseEvent) {
e.preventDefault(); e.preventDefault();
useSessionStore().login(login) useSessionStore().login(login.value)
.then(x => { .then(x => {
error.value = ""; error.value = "";
useRouter().replace("/dashboard"); useRouter().replace("/dashboard");