3 Commits

Author SHA1 Message Date
8fbdd78cb3 Add Transaction to list after saving
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/pr/woodpecker Pipeline was successful
2022-02-15 09:14:06 +00:00
b3ff5cf055 Extract component for new Transaction 2022-02-15 09:14:06 +00:00
a09511061f Handle new Payees 2022-02-15 09:14:06 +00:00
70 changed files with 1367 additions and 2412 deletions

View File

@@ -4,23 +4,11 @@ type: docker
name: budgeteer name: budgeteer
steps: steps:
- name: Taskfile.dev PR
image: hub.javil.eu/budgeteer:dev
commands:
- task ci
when:
event:
- pull_request
- name: Taskfile.dev - name: Taskfile.dev
image: hub.javil.eu/budgeteer:dev image: hub.javil.eu/budgeteer:dev
pull: always pull: true
commands: commands:
- task ci - task
when:
event:
exclude:
- pull_request
- name: docker - name: docker
image: plugins/docker image: plugins/docker
@@ -36,26 +24,10 @@ steps:
tags: tags:
- latest - latest
when: when:
branch:
- master
event: event:
- push exclude:
- pull_request
- name: docker tag
image: plugins/docker
settings:
registry: hub.javil.eu
username:
from_secret: docker_user
password:
from_secret: docker_password
repo: hub.javil.eu/budgeteer
context: build
dockerfile: build/Dockerfile
auto_tag: true
when:
event:
- tag
image_pull_secrets: image_pull_secrets:
- hub.javil.eu - hub.javil.eu

View File

@@ -1,27 +0,0 @@
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
- lll
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,8 +2,5 @@
"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 ci - task
docker: docker:
image: plugins/docker image: plugins/docker

View File

@@ -33,7 +33,12 @@ tasks:
sources: sources:
- ./go.mod - ./go.mod
- ./go.sum - ./go.sum
- ./**/*.go - ./cmd/budgeteer/*.go
- ./*.go
- ./config/*.go
- ./http/*.go
- ./jwt/*.go
- ./postgres/*.go
- ./web/dist/**/* - ./web/dist/**/*
- ./postgres/schema/* - ./postgres/schema/*
generates: generates:
@@ -47,26 +52,14 @@ 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
- go test ./...
frontend: frontend:
desc: Build vue frontend desc: Build vue frontend
dir: web dir: web
@@ -92,8 +85,6 @@ 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,30 +1,23 @@
package bcrypt package bcrypt
import ( import "golang.org/x/crypto/bcrypt"
"fmt"
"golang.org/x/crypto/bcrypt" // Verifier verifys passwords using Bcrypt
) type Verifier struct {
cost int
// 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. // 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) { func (bv *Verifier) Hash(password string) (string, error) {
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) hash, err := bcrypt.GenerateFromPassword([]byte(password), bv.cost)
if err != nil { if err != nil {
return "", fmt.Errorf("hash password: %w", err) return "", err
} }
return string(hash), nil return string(hash[:]), nil
} }

View File

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

View File

@@ -4,17 +4,15 @@ 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,7 +17,6 @@ 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

@@ -1,17 +1,16 @@
FROM alpine as godeps FROM alpine as godeps
RUN apk --no-cache 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 --no-cache add go nodejs yarn bash curl git git-perl tmux RUN apk add go
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/web
ADD web/package.json web/yarn.lock /src/web/
RUN yarn
WORKDIR /src WORKDIR /src
COPY --from=godeps /root/go/bin/task /root/go/bin/sqlc /root/go/bin/golangci-lint /usr/local/bin/ ADD web/package.json /src/web/
RUN yarn
CMD /build.sh CMD /build.sh

View File

@@ -1,4 +1,4 @@
package server package http
import ( import (
"net/http" "net/http"
@@ -35,37 +35,3 @@ type TransactionsResponse struct {
Account postgres.Account Account postgres.Account
Transactions []postgres.GetTransactionsForAccountRow Transactions []postgres.GetTransactionsForAccountRow
} }
type EditAccountRequest struct {
Name string `json:"name"`
OnBudget bool `json:"onBudget"`
}
func (h *Handler) editAccount(c *gin.Context) {
accountID := c.Param("accountid")
accountUUID, err := uuid.Parse(accountID)
if err != nil {
c.AbortWithError(http.StatusBadRequest, err)
return
}
var request EditAccountRequest
err = c.BindJSON(&request)
if err != nil {
c.AbortWithError(http.StatusBadRequest, err)
return
}
updateParams := postgres.UpdateAccountParams{
Name: request.Name,
OnBudget: request.OnBudget,
ID: accountUUID,
}
account, err := h.Service.UpdateAccount(c.Request.Context(), updateParams)
if err != nil {
c.AbortWithError(http.StatusNotFound, err)
return
}
h.returnBudgetingData(c, account.BudgetID)
}

View File

@@ -1,8 +1,7 @@
package server package http
import ( import (
"encoding/json" "encoding/json"
"fmt"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strings" "strings"
@@ -11,54 +10,47 @@ 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"
txdb "github.com/DATA-DOG/go-txdb"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
txdb "github.com/DATA-DOG/go-txdb"
) )
func init() { //nolint:gochecknoinits func init() {
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 TestRegisterUser(t *testing.T) { //nolint:funlen func TestListTimezonesHandler(t *testing.T) {
t.Parallel() db, err := postgres.Connect("pgtx", "example")
database, err := postgres.Connect("pgtx", "example")
if err != nil { if err != nil {
fmt.Printf("could not connect to db: %s\n", err) t.Errorf("could not connect to db: %s", err)
t.Skip()
return return
} }
h := Handler{ h := Handler{
Service: database, Service: db,
TokenVerifier: &jwt.TokenVerifier{ TokenVerifier: &jwt.TokenVerifier{},
Secret: "this_is_my_demo_secret_for_unit_tests",
},
CredentialsVerifier: &bcrypt.Verifier{}, CredentialsVerifier: &bcrypt.Verifier{},
} }
recorder := httptest.NewRecorder() rr := httptest.NewRecorder()
context, engine := gin.CreateTestContext(recorder) c, engine := gin.CreateTestContext(rr)
h.LoadRoutes(engine) h.LoadRoutes(engine)
t.Run("RegisterUser", func(t *testing.T) { t.Run("RegisterUser", func(t *testing.T) {
t.Parallel() c.Request, err = http.NewRequest(http.MethodPost, "/api/v1/user/register", strings.NewReader(`{"password":"pass","email":"info@example.com","name":"Test"}`))
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(context) h.registerPost(c)
if recorder.Code != http.StatusOK { if rr.Code != http.StatusOK {
t.Errorf("handler returned wrong status code: got %v want %v", recorder.Code, http.StatusOK) t.Errorf("handler returned wrong status code: got %v want %v", rr.Code, http.StatusOK)
} }
var response LoginResponse var response LoginResponse
err = json.NewDecoder(recorder.Body).Decode(&response) err = json.NewDecoder(rr.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")
@@ -69,14 +61,13 @@ func TestRegisterUser(t *testing.T) { //nolint:funlen
}) })
t.Run("GetTransactions", func(t *testing.T) { t.Run("GetTransactions", func(t *testing.T) {
t.Parallel() c.Request, err = http.NewRequest(http.MethodGet, "/account/accountid/transactions", nil)
context.Request, err = http.NewRequest(http.MethodGet, "/account/accountid/transactions", nil) 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 TransactionsResponse var response TransactionsResponse
err = json.NewDecoder(recorder.Body).Decode(&response) err = json.NewDecoder(rr.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 server package http
import ( import (
"fmt" "fmt"

54
http/autocomplete.go Normal file
View File

@@ -0,0 +1,54 @@
package http
import (
"fmt"
"net/http"
"git.javil.eu/jacob1123/budgeteer/postgres"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
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"))
return
}
query := c.Request.URL.Query().Get("s")
searchParams := postgres.SearchCategoriesParams{
BudgetID: budgetUUID,
Search: "%" + query + "%",
}
categories, err := h.Service.SearchCategories(c.Request.Context(), searchParams)
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
c.JSON(http.StatusOK, categories)
}
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"))
return
}
query := c.Request.URL.Query().Get("s")
searchParams := postgres.SearchPayeesParams{
BudgetID: budgetUUID,
Search: query + "%",
}
payees, err := h.Service.SearchPayees(c.Request.Context(), searchParams)
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
c.JSON(http.StatusOK, payees)
}

View File

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

216
http/budgeting.go Normal file
View File

@@ -0,0 +1,216 @@
package http
import (
"fmt"
"net/http"
"strconv"
"time"
"git.javil.eu/jacob1123/budgeteer/postgres"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
func getFirstOfMonth(year, month int, location *time.Location) time.Time {
return time.Date(year, time.Month(month), 1, 0, 0, 0, 0, location)
}
func getFirstOfMonthTime(date time.Time) time.Time {
var monthM time.Month
year, monthM, _ := date.Date()
month := int(monthM)
return getFirstOfMonth(year, month, date.Location())
}
type CategoryWithBalance struct {
*postgres.GetCategoriesRow
Available postgres.Numeric
AvailableLastMonth postgres.Numeric
Activity postgres.Numeric
Assigned postgres.Numeric
}
func getDate(c *gin.Context) (time.Time, error) {
var year, month int
yearString := c.Param("year")
monthString := c.Param("month")
if yearString == "" && monthString == "" {
return getFirstOfMonthTime(time.Now()), nil
}
year, err := strconv.Atoi(yearString)
if err != nil {
return time.Time{}, fmt.Errorf("parse year: %w", err)
}
month, err = strconv.Atoi(monthString)
if err != nil {
return time.Time{}, fmt.Errorf("parse month: %w", err)
}
return getFirstOfMonth(year, month, time.Now().Location()), nil
}
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"))
return
}
budget, err := h.Service.GetBudget(c.Request.Context(), budgetUUID)
if err != nil {
c.AbortWithError(http.StatusBadRequest, err)
return
}
firstOfMonth, err := getDate(c)
if err != nil {
c.Redirect(http.StatusTemporaryRedirect, "/budget/"+budgetUUID.String())
return
}
categories, err := h.Service.GetCategories(c.Request.Context(), budgetUUID)
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
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))
return
}
// skip everything in the future
categoriesWithBalance, moneyUsed, err := h.calculateBalances(c, budget, firstOfNextMonth, firstOfMonth, categories, cumultativeBalances)
if err != nil {
return
}
availableBalance := postgres.NewZeroNumeric()
for _, cat := range categories {
if cat.ID != budget.IncomeCategoryID {
continue
}
availableBalance = moneyUsed
for _, bal := range cumultativeBalances {
if bal.CategoryID != cat.ID {
continue
}
if !bal.Date.Before(firstOfNextMonth) {
continue
}
availableBalance = availableBalance.Add(bal.Transactions)
}
}
data := struct {
Categories []CategoryWithBalance
AvailableBalance postgres.Numeric
}{categoriesWithBalance, availableBalance}
c.JSON(http.StatusOK, data)
}
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"))
return
}
budget, err := h.Service.GetBudget(c.Request.Context(), budgetUUID)
if err != nil {
c.AbortWithError(http.StatusNotFound, err)
return
}
accounts, err := h.Service.GetAccountsWithBalance(c.Request.Context(), budgetUUID)
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
data := struct {
Accounts []postgres.GetAccountsWithBalanceRow
Budget postgres.Budget
}{accounts, budget}
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) {
categoriesWithBalance := []CategoryWithBalance{}
hiddenCategory := CategoryWithBalance{
GetCategoriesRow: &postgres.GetCategoriesRow{
Name: "",
Group: "Hidden Categories",
},
Available: postgres.NewZeroNumeric(),
AvailableLastMonth: postgres.NewZeroNumeric(),
Activity: postgres.NewZeroNumeric(),
Assigned: postgres.NewZeroNumeric(),
}
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
if cat.Group == "Hidden Categories" {
hiddenCategory.Available = hiddenCategory.Available.Add(categoryWithBalance.Available)
hiddenCategory.AvailableLastMonth = hiddenCategory.AvailableLastMonth.Add(categoryWithBalance.AvailableLastMonth)
hiddenCategory.Activity = hiddenCategory.Activity.Add(categoryWithBalance.Activity)
hiddenCategory.Assigned = hiddenCategory.Assigned.Add(categoryWithBalance.Assigned)
continue
}
if cat.ID == budget.IncomeCategoryID {
continue
}
categoriesWithBalance = append(categoriesWithBalance, categoryWithBalance)
}
categoriesWithBalance = append(categoriesWithBalance, hiddenCategory)
return categoriesWithBalance, moneyUsed, nil
}

View File

@@ -1,14 +1,15 @@
package server package http
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 := MustGetToken(c).GetID() userID := c.MustGet("token").(budgeteer.Token).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 server package http
import ( import (
"errors" "errors"
@@ -11,10 +11,12 @@ 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
@@ -22,57 +24,63 @@ type Handler struct {
StaticFS http.FileSystem StaticFS http.FileSystem
} }
// Serve starts the http server. const (
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)
}
} }
type ErrorResponse struct { // LoadRoutes initializes all the routes
Message string
}
// LoadRoutes initializes all the routes.
func (h *Handler) LoadRoutes(router *gin.Engine) { 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)
router.Use(enableCachingForStaticFiles()) router.Use(enableCachingForStaticFiles())
router.NoRoute(h.ServeStatic) router.NoRoute(h.ServeStatic)
withLogin := router.Group("")
withLogin.Use(h.verifyLoginWithRedirect)
withBudget := router.Group("")
withBudget.Use(h.verifyLoginWithForbidden)
withBudget.GET("/budget/:budgetid/:year/:month", h.budgeting)
withBudget.GET("/budget/:budgetid/settings/clean-negative", h.cleanNegativeBudget)
api := router.Group("/api/v1") api := router.Group("/api/v1")
anonymous := api.Group("/user") unauthenticated := api.Group("/user")
anonymous.GET("/login", func(c *gin.Context) { c.Redirect(http.StatusPermanentRedirect, "/login") }) unauthenticated.GET("/login", func(c *gin.Context) { c.Redirect(http.StatusPermanentRedirect, "/login") })
anonymous.POST("/login", h.loginPost) unauthenticated.POST("/login", h.loginPost)
anonymous.POST("/register", h.registerPost) unauthenticated.POST("/register", h.registerPost)
authenticated := api.Group("") authenticated := api.Group("")
authenticated.Use(h.verifyLoginWithForbidden) authenticated.Use(h.verifyLoginWithForbidden)
authenticated.GET("/dashboard", h.dashboard) authenticated.GET("/dashboard", h.dashboard)
authenticated.GET("/account/:accountid/transactions", h.transactionsForAccount) authenticated.GET("/account/:accountid/transactions", h.transactionsForAccount)
authenticated.POST("/account/:accountid", h.editAccount)
authenticated.GET("/admin/clear-database", h.clearDatabase) authenticated.GET("/admin/clear-database", h.clearDatabase)
authenticated.GET("/budget/:budgetid", h.budgeting)
authenticated.GET("/budget/:budgetid/:year/:month", h.budgetingForMonth)
authenticated.GET("/budget/:budgetid/autocomplete/payees", h.autocompletePayee)
authenticated.GET("/budget/:budgetid/autocomplete/categories", h.autocompleteCategories)
authenticated.DELETE("/budget/:budgetid", h.deleteBudget)
authenticated.POST("/budget/:budgetid/import/ynab", h.importYNAB)
authenticated.POST("/budget/:budgetid/settings/clear", h.clearBudget)
budget := authenticated.Group("/budget") budget := authenticated.Group("/budget")
budget.POST("/new", h.newBudget) budget.POST("/new", h.newBudget)
budget.GET("/:budgetid", h.budgeting)
budget.GET("/:budgetid/:year/:month", h.budgetingForMonth)
budget.GET("/:budgetid/autocomplete/payees", h.autocompletePayee)
budget.GET("/:budgetid/autocomplete/categories", h.autocompleteCategories)
budget.DELETE("/:budgetid", h.deleteBudget)
budget.POST("/:budgetid/import/ynab", h.importYNAB)
budget.POST("/:budgetid/export/ynab/transactions", h.exportYNABTransactions)
budget.POST("/:budgetid/export/ynab/assignments", h.exportYNABAssignments)
budget.POST("/:budgetid/settings/clear", h.clearBudget)
budget.POST("/:budgetid/settings/clean-negative", h.cleanNegativeBudget)
transaction := authenticated.Group("/transaction") transaction := authenticated.Group("/transaction")
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)
} }
@@ -100,11 +108,7 @@ func (h *Handler) ServeStaticFile(c *gin.Context, fullPath string) {
return return
} }
if file, ok := file.(io.ReadSeeker); ok { http.ServeContent(c.Writer, c.Request, stat.Name(), stat.ModTime(), file.(io.ReadSeeker))
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,36 +1,29 @@
package server package http
import ( import (
"encoding/json" "encoding/json"
"fmt"
"strings" "strings"
"time" "time"
) )
type JSONDate time.Time type JSONDate time.Time
// UnmarshalJSON parses the JSONDate from a JSON input. // Implement Marshaler and Unmarshaler interface
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 fmt.Errorf("parse date: %w", err) return 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) {
result, err := json.Marshal(time.Time(j)) return json.Marshal(time.Time(j))
if err != nil {
return nil, fmt.Errorf("marshal date: %w", err)
}
return result, nil
} }
// Format formats the time using the regular time.Time mechanics.. // Maybe a Format function for printing your date
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 server package http
import ( import (
"context" "context"
@@ -8,34 +8,18 @@ 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"
) )
const ( func (h *Handler) verifyLogin(c *gin.Context) (budgeteer.Token, error) {
HeaderName = "Authorization" tokenString := c.GetHeader("Authorization")
Bearer = "Bearer " if len(tokenString) < 8 {
ParamName = "token" return nil, fmt.Errorf("no authorization header supplied")
)
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, &ErrorResponse{fmt.Sprintf("verify token '%s': %s", tokenString, err)} return nil, fmt.Errorf("verify token '%s': %w", tokenString, err)
} }
return token, nil return token, nil
@@ -44,12 +28,24 @@ func (h *Handler) verifyLogin(c *gin.Context) (budgeteer.Token, *ErrorResponse)
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.AbortWithStatusJSON(http.StatusForbidden, err) c.AbortWithError(http.StatusForbidden, err)
return return
} }
c.Set(ParamName, token) c.Set("token", token)
c.Next()
}
func (h *Handler) verifyLoginWithRedirect(c *gin.Context) {
token, err := h.verifyLogin(c)
if err != nil {
c.Redirect(http.StatusTemporaryRedirect, "/login")
c.Abort()
return
}
c.Set("token", token)
c.Next() c.Next()
} }
@@ -76,19 +72,19 @@ func (h *Handler) loginPost(c *gin.Context) {
return return
} }
token, err := h.TokenVerifier.CreateToken(&user) t, err := h.TokenVerifier.CreateToken(&user)
if err != nil { if err != nil {
c.AbortWithError(http.StatusUnauthorized, err) c.AbortWithError(http.StatusUnauthorized, err)
} }
go h.UpdateLastLogin(user.ID) go h.Service.UpdateLastLogin(context.Background(), 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{token, user, budgets}) c.JSON(http.StatusOK, LoginResponse{t, user, budgets})
} }
type LoginResponse struct { type LoginResponse struct {
@@ -105,20 +101,16 @@ type registerInformation struct {
func (h *Handler) registerPost(c *gin.Context) { func (h *Handler) registerPost(c *gin.Context) {
var register registerInformation var register registerInformation
err := c.BindJSON(&register) c.BindJSON(&register)
if err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{"error parsing body"})
return
}
if register.Email == "" || register.Password == "" || register.Name == "" { if register.Email == "" || register.Password == "" || register.Name == "" {
c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{"e-mail, password and name are required"}) c.AbortWithError(http.StatusBadRequest, fmt.Errorf("e-mail, password and name are required"))
return return
} }
_, err = h.Service.GetUserByUsername(c.Request.Context(), register.Email) _, err := h.Service.GetUserByUsername(c.Request.Context(), register.Email)
if err == nil { if err == nil {
c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{"email is already taken"}) c.AbortWithError(http.StatusBadRequest, fmt.Errorf("email is already taken"))
return return
} }
@@ -138,24 +130,17 @@ func (h *Handler) registerPost(c *gin.Context) {
c.AbortWithError(http.StatusInternalServerError, err) c.AbortWithError(http.StatusInternalServerError, err)
} }
token, err := h.TokenVerifier.CreateToken(&user) t, err := h.TokenVerifier.CreateToken(&user)
if err != nil { if err != nil {
c.AbortWithError(http.StatusUnauthorized, err) c.AbortWithError(http.StatusUnauthorized, err)
} }
go h.UpdateLastLogin(user.ID) go h.Service.UpdateLastLogin(context.Background(), 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{token, user, budgets}) c.JSON(http.StatusOK, LoginResponse{t, 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)
}
} }

105
http/transaction.go Normal file
View File

@@ -0,0 +1,105 @@
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
}
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
}*/
payeeID := payload.Payee.ID
if !payeeID.Valid && payload.Payee.Name != "" {
newPayee := postgres.CreatePayeeParams{
Name: payload.Payee.Name,
BudgetID: payload.BudgetID,
}
payee, err := h.Service.CreatePayee(c.Request.Context(), newPayee)
if err != nil {
c.AbortWithError(http.StatusInternalServerError, fmt.Errorf("create payee: %w", err))
}
payeeID = uuid.NullUUID{
UUID: payee.ID,
Valid: true,
}
}
//if !transactionUUID.Valid {
new := postgres.CreateTransactionParams{
Memo: payload.Memo,
Date: time.Time(payload.Date),
Amount: amount,
AccountID: payload.AccountID,
PayeeID: payeeID, //TODO handle new payee
CategoryID: payload.Category.ID, //TODO handle new category
Status: postgres.TransactionStatus(payload.State),
}
transaction, err := h.Service.CreateTransaction(c.Request.Context(), new)
if err != nil {
c.AbortWithError(http.StatusInternalServerError, fmt.Errorf("create transaction: %w", err))
return
}
c.JSON(http.StatusOK, transaction)
// }
/*
_, 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))
}*/
}

56
http/util.go Normal file
View File

@@ -0,0 +1,56 @@
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
}

66
http/ynab-import.go Normal file
View File

@@ -0,0 +1,66 @@
package http
import (
"fmt"
"net/http"
"git.javil.eu/jacob1123/budgeteer/postgres"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
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"))
return
}
budgetUUID, err := uuid.Parse(budgetID)
if !succ {
c.AbortWithError(http.StatusBadRequest, err)
return
}
ynab, err := postgres.NewYNABImport(c.Request.Context(), h.Service.Queries, budgetUUID)
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
transactionsFile, err := c.FormFile("transactions")
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
transactions, err := transactionsFile.Open()
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
err = ynab.ImportTransactions(transactions)
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
assignmentsFile, err := c.FormFile("assignments")
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
assignments, err := assignmentsFile.Open()
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
err = ynab.ImportAssignments(assignments)
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
}

View File

@@ -10,12 +10,11 @@ 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
@@ -25,9 +24,10 @@ 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,27 +37,21 @@ 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(tv.Secret)) t, err := token.SignedString([]byte(secret))
if err != nil { if err != nil {
return "", fmt.Errorf("create token: %w", err) return "", err
} }
return t, nil return t, nil
} }
var ( // VerifyToken verifys a given string-token
ErrUnexpectedSigningMethod = fmt.Errorf("unexpected signing method") func (tv *TokenVerifier) VerifyToken(tokenString string) (budgeteer.Token, error) {
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("method '%v': %w", token.Header["alg"], ErrUnexpectedSigningMethod) return nil, fmt.Errorf("Unexpected signing method: %v", token.Header["alg"])
} }
return []byte(tv.Secret), nil return []byte(secret), nil
}) })
if err != nil { if err != nil {
return nil, fmt.Errorf("parse jwt: %w", err) return nil, fmt.Errorf("parse jwt: %w", err)
@@ -68,7 +62,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{ //nolint:forcetypeassert tkn := &Token{
username: claims["usr"].(string), username: claims["usr"].(string),
name: claims["name"].(string), name: claims["name"].(string),
expiry: claims["exp"].(float64), expiry: claims["exp"].(float64),
@@ -79,16 +73,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, ErrInvalidToken return nil, fmt.Errorf("Token is not valid")
} }
claims, ok := token.Claims.(jwt.MapClaims) claims, ok := token.Claims.(jwt.MapClaims)
if !ok { if !ok {
return nil, ErrInvalidToken return nil, fmt.Errorf("Claims are not of Type MapClaims")
} }
if !claims.VerifyExpiresAt(time.Now().Unix(), true) { if !claims.VerifyExpiresAt(time.Now().Unix(), true) {
return nil, ErrTokenExpired return nil, fmt.Errorf("Claims have expired")
} }
return claims, nil return claims, nil

View File

@@ -6,7 +6,6 @@ package postgres
import ( import (
"context" "context"
"git.javil.eu/jacob1123/budgeteer/postgres/numeric"
"github.com/google/uuid" "github.com/google/uuid"
) )
@@ -98,7 +97,7 @@ type GetAccountsWithBalanceRow struct {
ID uuid.UUID ID uuid.UUID
Name string Name string
OnBudget bool OnBudget bool
Balance numeric.Numeric Balance Numeric
} }
func (q *Queries) GetAccountsWithBalance(ctx context.Context, budgetID uuid.UUID) ([]GetAccountsWithBalanceRow, error) { func (q *Queries) GetAccountsWithBalance(ctx context.Context, budgetID uuid.UUID) ([]GetAccountsWithBalanceRow, error) {
@@ -128,76 +127,3 @@ func (q *Queries) GetAccountsWithBalance(ctx context.Context, budgetID uuid.UUID
} }
return items, nil return items, nil
} }
const searchAccounts = `-- name: SearchAccounts :many
SELECT accounts.id, accounts.budget_id, accounts.name, 'account' as type FROM accounts
WHERE accounts.budget_id = $1
AND accounts.name LIKE $2
ORDER BY accounts.name
`
type SearchAccountsParams struct {
BudgetID uuid.UUID
Search string
}
type SearchAccountsRow struct {
ID uuid.UUID
BudgetID uuid.UUID
Name string
Type interface{}
}
func (q *Queries) SearchAccounts(ctx context.Context, arg SearchAccountsParams) ([]SearchAccountsRow, error) {
rows, err := q.db.QueryContext(ctx, searchAccounts, arg.BudgetID, arg.Search)
if err != nil {
return nil, err
}
defer rows.Close()
var items []SearchAccountsRow
for rows.Next() {
var i SearchAccountsRow
if err := rows.Scan(
&i.ID,
&i.BudgetID,
&i.Name,
&i.Type,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const updateAccount = `-- name: UpdateAccount :one
UPDATE accounts
SET name = $1,
on_budget = $2
WHERE accounts.id = $3
RETURNING id, budget_id, name, on_budget
`
type UpdateAccountParams struct {
Name string
OnBudget bool
ID uuid.UUID
}
func (q *Queries) UpdateAccount(ctx context.Context, arg UpdateAccountParams) (Account, error) {
row := q.db.QueryRowContext(ctx, updateAccount, arg.Name, arg.OnBudget, arg.ID)
var i Account
err := row.Scan(
&i.ID,
&i.BudgetID,
&i.Name,
&i.OnBudget,
)
return i, err
}

View File

@@ -7,7 +7,6 @@ import (
"context" "context"
"time" "time"
"git.javil.eu/jacob1123/budgeteer/postgres/numeric"
"github.com/google/uuid" "github.com/google/uuid"
) )
@@ -22,7 +21,7 @@ RETURNING id, category_id, date, memo, amount
type CreateAssignmentParams struct { type CreateAssignmentParams struct {
Date time.Time Date time.Time
Amount numeric.Numeric Amount Numeric
CategoryID uuid.UUID CategoryID uuid.UUID
} }
@@ -54,49 +53,6 @@ func (q *Queries) DeleteAllAssignments(ctx context.Context, budgetID uuid.UUID)
return result.RowsAffected() return result.RowsAffected()
} }
const getAllAssignments = `-- name: GetAllAssignments :many
SELECT assignments.date, categories.name as category, category_groups.name as group, assignments.amount
FROM assignments
INNER JOIN categories ON categories.id = assignments.category_id
INNER JOIN category_groups ON categories.category_group_id = category_groups.id
WHERE category_groups.budget_id = $1
`
type GetAllAssignmentsRow struct {
Date time.Time
Category string
Group string
Amount numeric.Numeric
}
func (q *Queries) GetAllAssignments(ctx context.Context, budgetID uuid.UUID) ([]GetAllAssignmentsRow, error) {
rows, err := q.db.QueryContext(ctx, getAllAssignments, budgetID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetAllAssignmentsRow
for rows.Next() {
var i GetAllAssignmentsRow
if err := rows.Scan(
&i.Date,
&i.Category,
&i.Group,
&i.Amount,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getAssignmentsByMonthAndCategory = `-- name: GetAssignmentsByMonthAndCategory :many const getAssignmentsByMonthAndCategory = `-- name: GetAssignmentsByMonthAndCategory :many
SELECT date, category_id, budget_id, amount SELECT date, category_id, budget_id, amount
FROM assignments_by_month FROM assignments_by_month

View File

@@ -8,15 +8,11 @@ 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{})
if err != nil { q := s.WithTx(tx)
return nil, fmt.Errorf("begin transaction: %w", err) budget, err := q.CreateBudget(context, CreateBudgetParams{
}
transaction := s.WithTx(tx)
budget, err := transaction.CreateBudget(context, CreateBudgetParams{
Name: name, Name: name,
IncomeCategoryID: uuid.New(), IncomeCategoryID: uuid.New(),
}) })
@@ -25,12 +21,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 = transaction.LinkBudgetToUser(context, ub) _, err = q.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 := transaction.CreateCategoryGroup(context, CreateCategoryGroupParams{ group, err := q.CreateCategoryGroup(context, CreateCategoryGroupParams{
Name: "Inflow", Name: "Inflow",
BudgetID: budget.ID, BudgetID: budget.ID,
}) })
@@ -38,7 +34,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 := transaction.CreateCategory(context, CreateCategoryParams{ cat, err := q.CreateCategory(context, CreateCategoryParams{
Name: "Ready to Assign", Name: "Ready to Assign",
CategoryGroupID: group.ID, CategoryGroupID: group.ID,
}) })
@@ -46,7 +42,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 = transaction.SetInflowCategory(context, SetInflowCategoryParams{ err = q.SetInflowCategory(context, SetInflowCategoryParams{
IncomeCategoryID: cat.ID, IncomeCategoryID: cat.ID,
ID: budget.ID, ID: budget.ID,
}) })
@@ -54,10 +50,7 @@ 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)
} }
err = tx.Commit() tx.Commit()
if err != nil {
return nil, fmt.Errorf("commit: %w", err)
}
return &budget, nil return &budget, nil
} }

View File

@@ -118,8 +118,7 @@ func (q *Queries) GetCategoryGroups(ctx context.Context, budgetID uuid.UUID) ([]
} }
const searchCategories = `-- name: SearchCategories :many const searchCategories = `-- name: SearchCategories :many
SELECT CONCAT(category_groups.name, ' : ', categories.name) as name, categories.id, 'category' as type SELECT CONCAT(category_groups.name, ' : ', categories.name) as name, categories.id FROM categories
FROM categories
INNER JOIN category_groups ON categories.category_group_id = category_groups.id INNER JOIN category_groups ON categories.category_group_id = category_groups.id
WHERE category_groups.budget_id = $1 WHERE category_groups.budget_id = $1
AND categories.name LIKE $2 AND categories.name LIKE $2
@@ -134,7 +133,6 @@ type SearchCategoriesParams struct {
type SearchCategoriesRow struct { type SearchCategoriesRow struct {
Name interface{} Name interface{}
ID uuid.UUID ID uuid.UUID
Type interface{}
} }
func (q *Queries) SearchCategories(ctx context.Context, arg SearchCategoriesParams) ([]SearchCategoriesRow, error) { func (q *Queries) SearchCategories(ctx context.Context, arg SearchCategoriesParams) ([]SearchCategoriesRow, error) {
@@ -146,7 +144,7 @@ func (q *Queries) SearchCategories(ctx context.Context, arg SearchCategoriesPara
var items []SearchCategoriesRow var items []SearchCategoriesRow
for rows.Next() { for rows.Next() {
var i SearchCategoriesRow var i SearchCategoriesRow
if err := rows.Scan(&i.Name, &i.ID, &i.Type); err != nil { if err := rows.Scan(&i.Name, &i.ID); err != nil {
return nil, err return nil, err
} }
items = append(items, i) items = append(items, i)

View File

@@ -5,7 +5,7 @@ import (
"embed" "embed"
"fmt" "fmt"
_ "github.com/jackc/pgx/v4/stdlib" // needed for pg connection _ "github.com/jackc/pgx/v4/stdlib"
"github.com/pressly/goose/v3" "github.com/pressly/goose/v3"
) )
@@ -17,7 +17,7 @@ type Database struct {
*sql.DB *sql.DB
} }
// Connect connects to a database. // Connect 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

@@ -7,7 +7,6 @@ import (
"context" "context"
"time" "time"
"git.javil.eu/jacob1123/budgeteer/postgres/numeric"
"github.com/google/uuid" "github.com/google/uuid"
) )
@@ -24,10 +23,10 @@ ORDER BY COALESCE(ass.date, tra.date), COALESCE(ass.category_id, tra.category_id
type GetCumultativeBalancesRow struct { type GetCumultativeBalancesRow struct {
Date time.Time Date time.Time
CategoryID uuid.UUID CategoryID uuid.UUID
Assignments numeric.Numeric Assignments Numeric
AssignmentsCum numeric.Numeric AssignmentsCum Numeric
Transactions numeric.Numeric Transactions Numeric
TransactionsCum numeric.Numeric TransactionsCum Numeric
} }
func (q *Queries) GetCumultativeBalances(ctx context.Context, budgetID uuid.UUID) ([]GetCumultativeBalancesRow, error) { func (q *Queries) GetCumultativeBalances(ctx context.Context, budgetID uuid.UUID) ([]GetCumultativeBalancesRow, error) {

View File

@@ -7,7 +7,6 @@ import (
"fmt" "fmt"
"time" "time"
"git.javil.eu/jacob1123/budgeteer/postgres/numeric"
"github.com/google/uuid" "github.com/google/uuid"
) )
@@ -43,7 +42,7 @@ type Assignment struct {
CategoryID uuid.UUID CategoryID uuid.UUID
Date time.Time Date time.Time
Memo sql.NullString Memo sql.NullString
Amount numeric.Numeric Amount Numeric
} }
type AssignmentsByMonth struct { type AssignmentsByMonth struct {
@@ -82,7 +81,7 @@ type Transaction struct {
ID uuid.UUID ID uuid.UUID
Date time.Time Date time.Time
Memo string Memo string
Amount numeric.Numeric Amount Numeric
AccountID uuid.UUID AccountID uuid.UUID
CategoryID uuid.NullUUID CategoryID uuid.NullUUID
PayeeID uuid.NullUUID PayeeID uuid.NullUUID

129
postgres/numeric.go Normal file
View File

@@ -0,0 +1,129 @@
package postgres
import (
"fmt"
"math/big"
"github.com/jackc/pgtype"
)
type Numeric struct {
pgtype.Numeric
}
func NewZeroNumeric() Numeric {
return Numeric{pgtype.Numeric{Exp: 0, Int: big.NewInt(0), Status: pgtype.Present, NaN: false}}
}
func (n Numeric) GetFloat64() float64 {
if n.Status != pgtype.Present {
return 0
}
var balance float64
err := n.AssignTo(&balance)
if err != nil {
panic(err)
}
return balance
}
func (n Numeric) IsPositive() bool {
if n.Status != pgtype.Present {
return true
}
float := n.GetFloat64()
return float >= 0
}
func (n Numeric) IsZero() bool {
if n.Status != pgtype.Present {
return true
}
float := n.GetFloat64()
return float == 0
}
func (n Numeric) MatchExp(exp int32) Numeric {
diffExp := n.Exp - exp
factor := big.NewInt(0).Exp(big.NewInt(10), big.NewInt(int64(diffExp)), nil)
return Numeric{pgtype.Numeric{
Exp: exp,
Int: big.NewInt(0).Mul(n.Int, factor),
Status: n.Status,
NaN: n.NaN,
}}
}
func (n Numeric) Sub(o 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)
}
if left.Exp == right.Exp {
return Numeric{pgtype.Numeric{
Exp: left.Exp,
Int: big.NewInt(0).Sub(left.Int, right.Int),
}}
}
panic("Cannot subtract with different exponents")
}
func (n Numeric) Add(o 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)
}
if left.Exp == right.Exp {
return Numeric{pgtype.Numeric{
Exp: left.Exp,
Int: big.NewInt(0).Add(left.Int, right.Int),
}}
}
panic("Cannot add with different exponents")
}
func (n Numeric) MarshalJSON() ([]byte, error) {
if n.Int.Int64() == 0 {
return []byte("\"0\""), nil
}
s := fmt.Sprintf("%d", n.Int)
bytes := []byte(s)
exp := n.Exp
for exp > 0 {
bytes = append(bytes, byte('0'))
exp--
}
if exp == 0 {
return bytes, nil
}
length := int32(len(bytes))
var bytesWithSeparator []byte
exp = -exp
for length <= exp {
bytes = append(bytes, byte('0'))
length++
}
split := length - exp
bytesWithSeparator = append(bytesWithSeparator, bytes[:split]...)
if split == 1 && n.Int.Int64() < 0 {
bytesWithSeparator = append(bytesWithSeparator, byte('0'))
}
bytesWithSeparator = append(bytesWithSeparator, byte('.'))
bytesWithSeparator = append(bytesWithSeparator, bytes[split:]...)
return bytesWithSeparator, nil
}

View File

@@ -1,226 +0,0 @@
package numeric
import (
"fmt"
"math/big"
"strings"
"unicode/utf8"
"github.com/jackc/pgtype"
)
type Numeric struct {
pgtype.Numeric
}
func Zero() Numeric {
return Numeric{pgtype.Numeric{Exp: 0, Int: big.NewInt(0), Status: pgtype.Present, NaN: false}}
}
func FromInt64(value int64) Numeric {
return Numeric{Numeric: pgtype.Numeric{Int: big.NewInt(value), Status: pgtype.Present}}
}
func FromInt64WithExp(value int64, exp int32) Numeric {
return Numeric{Numeric: pgtype.Numeric{Int: big.NewInt(value), Exp: exp, Status: pgtype.Present}}
}
func (n Numeric) GetFloat64() float64 {
if n.Status != pgtype.Present {
return 0
}
var balance float64
err := n.AssignTo(&balance)
if err != nil {
panic(err)
}
return balance
}
func (n Numeric) IsPositive() bool {
if n.Status != pgtype.Present {
return true
}
float := n.GetFloat64()
return float >= 0
}
func (n Numeric) IsZero() bool {
if n.Status != pgtype.Present {
return true
}
float := n.GetFloat64()
return float == 0
}
func (n Numeric) MatchExp(exp int32) Numeric {
diffExp := n.Exp - exp
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),
Status: n.Status,
NaN: n.NaN,
}}
}
func (n Numeric) Sub(other Numeric) Numeric {
left := n
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 {
return Numeric{pgtype.Numeric{
Exp: left.Exp,
Int: big.NewInt(0).Sub(left.Int, right.Int),
}}
}
panic("Cannot subtract with different exponents")
}
func (n Numeric) Neg() Numeric {
return Numeric{pgtype.Numeric{Exp: n.Exp, Int: big.NewInt(-1 * n.Int.Int64()), Status: n.Status}}
}
func (n Numeric) Add(other Numeric) Numeric {
left := n
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 {
return Numeric{pgtype.Numeric{
Exp: left.Exp,
Int: big.NewInt(0).Add(left.Int, right.Int),
}}
}
panic("Cannot add with different exponents")
}
func (n Numeric) String() string {
if n.Int == nil || n.Int.Int64() == 0 {
return "0"
}
s := fmt.Sprintf("%d", n.Int)
bytes := []byte(s)
exp := n.Exp
for exp > 0 {
bytes = append(bytes, byte('0'))
exp--
}
if exp == 0 {
return string(bytes)
}
length := int32(len(bytes))
var bytesWithSeparator []byte
exp = -exp
for length <= exp {
if n.Int.Int64() < 0 {
bytes = append([]byte{bytes[0], byte('0')}, bytes[1:]...)
} else {
bytes = append([]byte{byte('0')}, bytes...)
}
length++
}
split := length - exp
bytesWithSeparator = append(bytesWithSeparator, bytes[:split]...)
if split == 1 && n.Int.Int64() < 0 {
bytesWithSeparator = append(bytesWithSeparator, byte('0'))
}
bytesWithSeparator = append(bytesWithSeparator, byte('.'))
bytesWithSeparator = append(bytesWithSeparator, bytes[split:]...)
return string(bytesWithSeparator)
}
func (n Numeric) MarshalJSON() ([]byte, error) {
if n.Int == nil || n.Int.Int64() == 0 {
return []byte("0"), nil
}
s := fmt.Sprintf("%d", n.Int)
bytes := []byte(s)
exp := n.Exp
for exp > 0 {
bytes = append(bytes, byte('0'))
exp--
}
if exp == 0 {
return bytes, nil
}
length := int32(len(bytes))
var bytesWithSeparator []byte
exp = -exp
for length <= exp {
if n.Int.Int64() < 0 {
bytes = append([]byte{bytes[0], byte('0')}, bytes[1:]...)
} else {
bytes = append([]byte{byte('0')}, bytes...)
}
length++
}
split := length - exp
bytesWithSeparator = append(bytesWithSeparator, bytes[:split]...)
if split == 1 && n.Int.Int64() < 0 {
bytesWithSeparator = append(bytesWithSeparator, byte('0'))
}
bytesWithSeparator = append(bytesWithSeparator, byte('.'))
bytesWithSeparator = append(bytesWithSeparator, bytes[split:]...)
return bytesWithSeparator, nil
}
func MustParse(text string) Numeric {
num, err := Parse(text)
if err != nil {
panic(err)
}
return num
}
func Parse(text string) (Numeric, error) {
// Unify decimal separator
text = strings.Replace(text, ",", ".", 1)
num := Numeric{}
err := num.Set(text)
if err != nil {
return num, fmt.Errorf("parse numeric %s: %w", text, err)
}
return num, nil
}
func ParseCurrency(text string) (Numeric, error) {
// Remove trailing currency
text = trimLastChar(text)
return Parse(text)
}
func trimLastChar(s string) string {
r, size := utf8.DecodeLastRuneInString(s)
if r == utf8.RuneError && (size == 0 || size == 1) {
size = 0
}
return s[:len(s)-size]
}

View File

@@ -1,118 +0,0 @@
package numeric_test
import (
"testing"
"git.javil.eu/jacob1123/budgeteer/postgres/numeric"
)
type TestCaseMarshalJSON struct {
Value numeric.Numeric
Result string
}
func TestMarshalJSON(t *testing.T) {
t.Parallel()
tests := []TestCaseMarshalJSON{
{numeric.Zero(), `0`},
{numeric.MustParse("1.23"), "1.23"},
{numeric.MustParse("1,24"), "1.24"},
{numeric.MustParse("1"), "1"},
{numeric.MustParse("10"), "10"},
{numeric.MustParse("100"), "100"},
{numeric.MustParse("1000"), "1000"},
{numeric.MustParse("0.1"), "0.1"},
{numeric.MustParse("0.01"), "0.01"},
{numeric.MustParse("0.001"), "0.001"},
{numeric.MustParse("0.0001"), "0.0001"},
{numeric.MustParse("-1"), "-1"},
{numeric.MustParse("-10"), "-10"},
{numeric.MustParse("-100"), "-100"},
{numeric.MustParse("-1000"), "-1000"},
{numeric.MustParse("-0.1"), "-0.1"},
{numeric.MustParse("-0.01"), "-0.01"},
{numeric.MustParse("-0.001"), "-0.001"},
{numeric.MustParse("-0.0001"), "-0.0001"},
{numeric.MustParse("123456789.12345"), "123456789.12345"},
{numeric.MustParse("123456789.12345"), "123456789.12345"},
{numeric.MustParse("-1.23"), "-1.23"},
{numeric.MustParse("-1,24"), "-1.24"},
{numeric.MustParse("-123456789.12345"), "-123456789.12345"},
}
for i := range tests {
test := tests[i]
t.Run(test.Result, func(t *testing.T) {
t.Parallel()
z := test.Value
result, err := z.MarshalJSON()
if err != nil {
t.Error(err)
return
}
if string(result) != test.Result {
t.Errorf("Expected %s, got %s", test.Result, string(result))
return
}
})
}
}
type TestCaseParse struct {
Result numeric.Numeric
Value string
}
func TestParse(t *testing.T) {
t.Parallel()
tests := []TestCaseParse{
{numeric.FromInt64WithExp(0, 0), `0`},
{numeric.FromInt64WithExp(1, 0), `1`},
{numeric.FromInt64WithExp(1, 1), `10`},
{numeric.FromInt64WithExp(1, 2), `100`},
{numeric.FromInt64WithExp(1, 3), `1000`},
{numeric.FromInt64WithExp(1, -1), `0.1`},
{numeric.FromInt64WithExp(1, -2), `0.01`},
{numeric.FromInt64WithExp(1, -3), `0.001`},
{numeric.FromInt64WithExp(1, -4), `0.0001`},
{numeric.FromInt64WithExp(-1, 0), `-1`},
{numeric.FromInt64WithExp(-1, 1), `-10`},
{numeric.FromInt64WithExp(-1, 2), `-100`},
{numeric.FromInt64WithExp(-1, 3), `-1000`},
{numeric.FromInt64WithExp(-1, -1), `-0.1`},
{numeric.FromInt64WithExp(-1, -2), `-0.01`},
{numeric.FromInt64WithExp(-1, -3), `-0.001`},
{numeric.FromInt64WithExp(-1, -4), `-0.0001`},
{numeric.FromInt64WithExp(123, -2), "1.23"},
{numeric.FromInt64WithExp(124, -2), "1,24"},
{numeric.FromInt64WithExp(12345678912345, -5), "123456789.12345"},
{numeric.FromInt64WithExp(0, 0), `-0`},
{numeric.FromInt64WithExp(-1, 0), `-1`},
{numeric.FromInt64WithExp(-1, 1), `-10`},
{numeric.FromInt64WithExp(-1, 2), `-100`},
{numeric.FromInt64WithExp(-123, -2), "-1.23"},
{numeric.FromInt64WithExp(-124, -2), "-1,24"},
{numeric.FromInt64WithExp(-12345678912345, -5), "-123456789.12345"},
}
for i := range tests {
test := tests[i]
t.Run(test.Value, func(t *testing.T) {
t.Parallel()
result, err := numeric.Parse(test.Value)
if err != nil {
t.Error(err)
return
}
if test.Result.Int.Int64() != result.Int.Int64() {
t.Errorf("Expected int %d, got %d", test.Result.Int, result.Int)
return
}
if test.Result.Exp != result.Exp {
t.Errorf("Expected exp %d, got %d", test.Result.Exp, result.Exp)
return
}
})
}
}

View File

@@ -58,7 +58,7 @@ func (q *Queries) GetPayees(ctx context.Context, budgetID uuid.UUID) ([]Payee, e
} }
const searchPayees = `-- name: SearchPayees :many const searchPayees = `-- name: SearchPayees :many
SELECT payees.id, payees.budget_id, payees.name, 'payee' as type FROM payees SELECT payees.id, payees.budget_id, payees.name FROM payees
WHERE payees.budget_id = $1 WHERE payees.budget_id = $1
AND payees.name LIKE $2 AND payees.name LIKE $2
ORDER BY payees.name ORDER BY payees.name
@@ -69,28 +69,16 @@ type SearchPayeesParams struct {
Search string Search string
} }
type SearchPayeesRow struct { func (q *Queries) SearchPayees(ctx context.Context, arg SearchPayeesParams) ([]Payee, error) {
ID uuid.UUID
BudgetID uuid.UUID
Name string
Type interface{}
}
func (q *Queries) SearchPayees(ctx context.Context, arg SearchPayeesParams) ([]SearchPayeesRow, error) {
rows, err := q.db.QueryContext(ctx, searchPayees, arg.BudgetID, arg.Search) rows, err := q.db.QueryContext(ctx, searchPayees, arg.BudgetID, arg.Search)
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer rows.Close() defer rows.Close()
var items []SearchPayeesRow var items []Payee
for rows.Next() { for rows.Next() {
var i SearchPayeesRow var i Payee
if err := rows.Scan( if err := rows.Scan(&i.ID, &i.BudgetID, &i.Name); err != nil {
&i.ID,
&i.BudgetID,
&i.Name,
&i.Type,
); err != nil {
return nil, err return nil, err
} }
items = append(items, i) items = append(items, i)

View File

@@ -20,16 +20,3 @@ LEFT JOIN transactions ON transactions.account_id = accounts.id AND transactions
WHERE accounts.budget_id = $1 WHERE accounts.budget_id = $1
GROUP BY accounts.id, accounts.name GROUP BY accounts.id, accounts.name
ORDER BY accounts.name; ORDER BY accounts.name;
-- name: SearchAccounts :many
SELECT accounts.id, accounts.budget_id, accounts.name, 'account' as type FROM accounts
WHERE accounts.budget_id = @budget_id
AND accounts.name LIKE @search
ORDER BY accounts.name;
-- name: UpdateAccount :one
UPDATE accounts
SET name = $1,
on_budget = $2
WHERE accounts.id = $3
RETURNING *;

View File

@@ -16,10 +16,3 @@ WHERE categories.id = assignments.category_id AND category_groups.budget_id = @b
SELECT * SELECT *
FROM assignments_by_month FROM assignments_by_month
WHERE assignments_by_month.budget_id = @budget_id; WHERE assignments_by_month.budget_id = @budget_id;
-- name: GetAllAssignments :many
SELECT assignments.date, categories.name as category, category_groups.name as group, assignments.amount
FROM assignments
INNER JOIN categories ON categories.id = assignments.category_id
INNER JOIN category_groups ON categories.category_group_id = category_groups.id
WHERE category_groups.budget_id = @budget_id;

View File

@@ -21,8 +21,7 @@ WHERE category_groups.budget_id = $1
ORDER BY category_groups.name, categories.name; ORDER BY category_groups.name, categories.name;
-- name: SearchCategories :many -- name: SearchCategories :many
SELECT CONCAT(category_groups.name, ' : ', categories.name) as name, categories.id, 'category' as type SELECT CONCAT(category_groups.name, ' : ', categories.name) as name, categories.id FROM categories
FROM categories
INNER JOIN category_groups ON categories.category_group_id = category_groups.id INNER JOIN category_groups ON categories.category_group_id = category_groups.id
WHERE category_groups.budget_id = @budget_id WHERE category_groups.budget_id = @budget_id
AND categories.name LIKE @search AND categories.name LIKE @search

View File

@@ -10,7 +10,7 @@ WHERE payees.budget_id = $1
ORDER BY name; ORDER BY name;
-- name: SearchPayees :many -- name: SearchPayees :many
SELECT payees.*, 'payee' as type FROM payees SELECT payees.* FROM payees
WHERE payees.budget_id = @budget_id WHERE payees.budget_id = @budget_id
AND payees.name LIKE @search AND payees.name LIKE @search
ORDER BY payees.name; ORDER BY payees.name;

View File

@@ -13,50 +13,41 @@ UPDATE transactions
SET date = $1, SET date = $1,
memo = $2, memo = $2,
amount = $3, amount = $3,
payee_id = $4, account_id = $4,
category_id = $5 payee_id = $5,
WHERE id = $6; category_id = $6
WHERE id = $7;
-- name: DeleteTransaction :exec -- name: DeleteTransaction :exec
DELETE FROM transactions DELETE FROM transactions
WHERE id = $1; WHERE id = $1;
-- name: GetAllTransactionsForBudget :many -- name: GetTransactionsForBudget :many
SELECT transactions.id, transactions.date, transactions.memo, SELECT transactions.id, transactions.date, transactions.memo, transactions.amount, transactions.group_id, transactions.status,
transactions.amount, transactions.group_id, transactions.status, accounts.name as account, COALESCE(payees.name, '') as payee, COALESCE(category_groups.name, '') as category_group, COALESCE(categories.name, '') as category
accounts.name as account, transactions.payee_id, transactions.category_id,
COALESCE(payees.name, '') as payee,
COALESCE(category_groups.name, '') as category_group,
COALESCE(categories.name, '') as category,
COALESCE((
SELECT CONCAT(otherAccounts.name)
FROM transactions otherTransactions
LEFT JOIN accounts otherAccounts ON otherAccounts.id = otherTransactions.account_id
WHERE otherTransactions.group_id = transactions.group_id
AND otherTransactions.id != transactions.id
), '')::text as transfer_account
FROM transactions FROM transactions
INNER JOIN accounts ON accounts.id = transactions.account_id INNER JOIN accounts ON accounts.id = transactions.account_id
LEFT JOIN payees ON payees.id = transactions.payee_id LEFT JOIN payees ON payees.id = transactions.payee_id
LEFT JOIN categories ON categories.id = transactions.category_id LEFT JOIN categories ON categories.id = transactions.category_id
LEFT JOIN category_groups ON category_groups.id = categories.category_group_id LEFT JOIN category_groups ON category_groups.id = categories.category_group_id
WHERE accounts.budget_id = $1 WHERE accounts.budget_id = $1
ORDER BY transactions.date DESC; ORDER BY transactions.date DESC
LIMIT 200;
-- name: GetTransactionsForAccount :many -- name: GetTransactionsForAccount :many
SELECT transactions.id, transactions.date, transactions.memo, SELECT transactions.id, transactions.date, transactions.memo,
transactions.amount, transactions.group_id, transactions.status, transactions.amount, transactions.group_id, transactions.status,
accounts.name as account, transactions.payee_id, transactions.category_id, accounts.name as account,
COALESCE(payees.name, '') as payee, COALESCE(payees.name, '') as payee,
COALESCE(category_groups.name, '') as category_group, COALESCE(category_groups.name, '') as category_group,
COALESCE(categories.name, '') as category, COALESCE(categories.name, '') as category,
COALESCE(( (
SELECT CONCAT(otherAccounts.name) SELECT CONCAT(otherAccounts.name)
FROM transactions otherTransactions FROM transactions otherTransactions
LEFT JOIN accounts otherAccounts ON otherAccounts.id = otherTransactions.account_id LEFT JOIN accounts otherAccounts ON otherAccounts.id = otherTransactions.account_id
WHERE otherTransactions.group_id = transactions.group_id WHERE otherTransactions.group_id = transactions.group_id
AND otherTransactions.id != transactions.id AND otherTransactions.id != transactions.id
), '')::text as transfer_account ) as transfer_account
FROM transactions FROM transactions
INNER JOIN accounts ON accounts.id = transactions.account_id INNER JOIN accounts ON accounts.id = transactions.account_id
LEFT JOIN payees ON payees.id = transactions.payee_id LEFT JOIN payees ON payees.id = transactions.payee_id

View File

@@ -7,7 +7,6 @@ import (
"context" "context"
"time" "time"
"git.javil.eu/jacob1123/budgeteer/postgres/numeric"
"github.com/google/uuid" "github.com/google/uuid"
) )
@@ -21,7 +20,7 @@ RETURNING id, date, memo, amount, account_id, category_id, payee_id, group_id, s
type CreateTransactionParams struct { type CreateTransactionParams struct {
Date time.Time Date time.Time
Memo string Memo string
Amount numeric.Numeric Amount Numeric
AccountID uuid.UUID AccountID uuid.UUID
PayeeID uuid.NullUUID PayeeID uuid.NullUUID
CategoryID uuid.NullUUID CategoryID uuid.NullUUID
@@ -80,82 +79,6 @@ func (q *Queries) DeleteTransaction(ctx context.Context, id uuid.UUID) error {
return err return err
} }
const getAllTransactionsForBudget = `-- name: GetAllTransactionsForBudget :many
SELECT transactions.id, transactions.date, transactions.memo,
transactions.amount, transactions.group_id, transactions.status,
accounts.name as account, transactions.payee_id, transactions.category_id,
COALESCE(payees.name, '') as payee,
COALESCE(category_groups.name, '') as category_group,
COALESCE(categories.name, '') as category,
COALESCE((
SELECT CONCAT(otherAccounts.name)
FROM transactions otherTransactions
LEFT JOIN accounts otherAccounts ON otherAccounts.id = otherTransactions.account_id
WHERE otherTransactions.group_id = transactions.group_id
AND otherTransactions.id != transactions.id
), '')::text as transfer_account
FROM transactions
INNER JOIN accounts ON accounts.id = transactions.account_id
LEFT JOIN payees ON payees.id = transactions.payee_id
LEFT JOIN categories ON categories.id = transactions.category_id
LEFT JOIN category_groups ON category_groups.id = categories.category_group_id
WHERE accounts.budget_id = $1
ORDER BY transactions.date DESC
`
type GetAllTransactionsForBudgetRow struct {
ID uuid.UUID
Date time.Time
Memo string
Amount numeric.Numeric
GroupID uuid.NullUUID
Status TransactionStatus
Account string
PayeeID uuid.NullUUID
CategoryID uuid.NullUUID
Payee string
CategoryGroup string
Category string
TransferAccount string
}
func (q *Queries) GetAllTransactionsForBudget(ctx context.Context, budgetID uuid.UUID) ([]GetAllTransactionsForBudgetRow, error) {
rows, err := q.db.QueryContext(ctx, getAllTransactionsForBudget, budgetID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetAllTransactionsForBudgetRow
for rows.Next() {
var i GetAllTransactionsForBudgetRow
if err := rows.Scan(
&i.ID,
&i.Date,
&i.Memo,
&i.Amount,
&i.GroupID,
&i.Status,
&i.Account,
&i.PayeeID,
&i.CategoryID,
&i.Payee,
&i.CategoryGroup,
&i.Category,
&i.TransferAccount,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getTransaction = `-- name: GetTransaction :one const getTransaction = `-- name: GetTransaction :one
SELECT id, date, memo, amount, account_id, category_id, payee_id, group_id, status FROM transactions SELECT id, date, memo, amount, account_id, category_id, payee_id, group_id, status FROM transactions
WHERE id = $1 WHERE id = $1
@@ -215,17 +138,17 @@ func (q *Queries) GetTransactionsByMonthAndCategory(ctx context.Context, budgetI
const getTransactionsForAccount = `-- name: GetTransactionsForAccount :many const getTransactionsForAccount = `-- name: GetTransactionsForAccount :many
SELECT transactions.id, transactions.date, transactions.memo, SELECT transactions.id, transactions.date, transactions.memo,
transactions.amount, transactions.group_id, transactions.status, transactions.amount, transactions.group_id, transactions.status,
accounts.name as account, transactions.payee_id, transactions.category_id, accounts.name as account,
COALESCE(payees.name, '') as payee, COALESCE(payees.name, '') as payee,
COALESCE(category_groups.name, '') as category_group, COALESCE(category_groups.name, '') as category_group,
COALESCE(categories.name, '') as category, COALESCE(categories.name, '') as category,
COALESCE(( (
SELECT CONCAT(otherAccounts.name) SELECT CONCAT(otherAccounts.name)
FROM transactions otherTransactions FROM transactions otherTransactions
LEFT JOIN accounts otherAccounts ON otherAccounts.id = otherTransactions.account_id LEFT JOIN accounts otherAccounts ON otherAccounts.id = otherTransactions.account_id
WHERE otherTransactions.group_id = transactions.group_id WHERE otherTransactions.group_id = transactions.group_id
AND otherTransactions.id != transactions.id AND otherTransactions.id != transactions.id
), '')::text as transfer_account ) as transfer_account
FROM transactions FROM transactions
INNER JOIN accounts ON accounts.id = transactions.account_id INNER JOIN accounts ON accounts.id = transactions.account_id
LEFT JOIN payees ON payees.id = transactions.payee_id LEFT JOIN payees ON payees.id = transactions.payee_id
@@ -240,16 +163,14 @@ type GetTransactionsForAccountRow struct {
ID uuid.UUID ID uuid.UUID
Date time.Time Date time.Time
Memo string Memo string
Amount numeric.Numeric Amount Numeric
GroupID uuid.NullUUID GroupID uuid.NullUUID
Status TransactionStatus Status TransactionStatus
Account string Account string
PayeeID uuid.NullUUID
CategoryID uuid.NullUUID
Payee string Payee string
CategoryGroup string CategoryGroup string
Category string Category string
TransferAccount string TransferAccount interface{}
} }
func (q *Queries) GetTransactionsForAccount(ctx context.Context, accountID uuid.UUID) ([]GetTransactionsForAccountRow, error) { func (q *Queries) GetTransactionsForAccount(ctx context.Context, accountID uuid.UUID) ([]GetTransactionsForAccountRow, error) {
@@ -269,8 +190,6 @@ func (q *Queries) GetTransactionsForAccount(ctx context.Context, accountID uuid.
&i.GroupID, &i.GroupID,
&i.Status, &i.Status,
&i.Account, &i.Account,
&i.PayeeID,
&i.CategoryID,
&i.Payee, &i.Payee,
&i.CategoryGroup, &i.CategoryGroup,
&i.Category, &i.Category,
@@ -289,20 +208,82 @@ func (q *Queries) GetTransactionsForAccount(ctx context.Context, accountID uuid.
return items, nil return items, nil
} }
const getTransactionsForBudget = `-- name: GetTransactionsForBudget :many
SELECT transactions.id, transactions.date, transactions.memo, transactions.amount, transactions.group_id, transactions.status,
accounts.name as account, COALESCE(payees.name, '') as payee, COALESCE(category_groups.name, '') as category_group, COALESCE(categories.name, '') as category
FROM transactions
INNER JOIN accounts ON accounts.id = transactions.account_id
LEFT JOIN payees ON payees.id = transactions.payee_id
LEFT JOIN categories ON categories.id = transactions.category_id
LEFT JOIN category_groups ON category_groups.id = categories.category_group_id
WHERE accounts.budget_id = $1
ORDER BY transactions.date DESC
LIMIT 200
`
type GetTransactionsForBudgetRow struct {
ID uuid.UUID
Date time.Time
Memo string
Amount Numeric
GroupID uuid.NullUUID
Status TransactionStatus
Account string
Payee string
CategoryGroup string
Category string
}
func (q *Queries) GetTransactionsForBudget(ctx context.Context, budgetID uuid.UUID) ([]GetTransactionsForBudgetRow, error) {
rows, err := q.db.QueryContext(ctx, getTransactionsForBudget, budgetID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetTransactionsForBudgetRow
for rows.Next() {
var i GetTransactionsForBudgetRow
if err := rows.Scan(
&i.ID,
&i.Date,
&i.Memo,
&i.Amount,
&i.GroupID,
&i.Status,
&i.Account,
&i.Payee,
&i.CategoryGroup,
&i.Category,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const updateTransaction = `-- name: UpdateTransaction :exec const updateTransaction = `-- name: UpdateTransaction :exec
UPDATE transactions UPDATE transactions
SET date = $1, SET date = $1,
memo = $2, memo = $2,
amount = $3, amount = $3,
payee_id = $4, account_id = $4,
category_id = $5 payee_id = $5,
WHERE id = $6 category_id = $6
WHERE id = $7
` `
type UpdateTransactionParams struct { type UpdateTransactionParams struct {
Date time.Time Date time.Time
Memo string Memo string
Amount numeric.Numeric Amount Numeric
AccountID uuid.UUID
PayeeID uuid.NullUUID PayeeID uuid.NullUUID
CategoryID uuid.NullUUID CategoryID uuid.NullUUID
ID uuid.UUID ID uuid.UUID
@@ -313,6 +294,7 @@ func (q *Queries) UpdateTransaction(ctx context.Context, arg UpdateTransactionPa
arg.Date, arg.Date,
arg.Memo, arg.Memo,
arg.Amount, arg.Amount,
arg.AccountID,
arg.PayeeID, arg.PayeeID,
arg.CategoryID, arg.CategoryID,
arg.ID, arg.ID,

View File

@@ -1,144 +0,0 @@
package postgres
import (
"context"
"encoding/csv"
"fmt"
"io"
"git.javil.eu/jacob1123/budgeteer/postgres/numeric"
"github.com/google/uuid"
)
type YNABExport struct {
queries *Queries
budgetID uuid.UUID
}
func NewYNABExport(context context.Context, queries *Queries, budgetID uuid.UUID) (*YNABExport, error) {
return &YNABExport{
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€
//
// Activity and Available are not imported, since they are determined by the transactions and historic assignments.
func (ynab *YNABExport) ExportAssignments(context context.Context, w io.Writer) error {
csv := csv.NewWriter(w)
csv.Comma = '\t'
assignments, err := ynab.queries.GetAllAssignments(context, ynab.budgetID)
if err != nil {
return fmt.Errorf("load assignments: %w", err)
}
count := 0
for _, assignment := range assignments {
row := []string{
assignment.Date.Format("Jan 2006"),
assignment.Group + ": " + assignment.Category,
assignment.Group,
assignment.Category,
assignment.Amount.String() + "€",
numeric.Zero().String() + "€",
numeric.Zero().String() + "€",
}
err := csv.Write(row)
if err != nil {
return fmt.Errorf("write assignment: %w", err)
}
count++
}
csv.Flush()
fmt.Printf("Exported %d assignments\n", count)
return nil
}
// ImportTransactions expects a TSV-file as exported by YNAB in the following format:
// "Account" "Flag" "Date" "Payee" "Category Group/Category" "Category Group" "Category" "Memo" "Outflow" "Inflow" "Cleared"
// "Cash" "" "11.12.2021" "Transfer : Checking" "" "" "" "Brought to bank" 500,00€ 0,00€ "Cleared".
func (ynab *YNABExport) ExportTransactions(context context.Context, w io.Writer) error {
csv := csv.NewWriter(w)
csv.Comma = '\t'
transactions, err := ynab.queries.GetAllTransactionsForBudget(context, ynab.budgetID)
if err != nil {
return fmt.Errorf("load transactions: %w", err)
}
header := []string{
"Account",
"Flag",
"Date",
"Payee",
"Category Group/Category",
"Category Group",
"Category",
"Memo",
"Outflow",
"Inflow",
"Cleared",
}
err = csv.Write(header)
if err != nil {
return fmt.Errorf("write transaction: %w", err)
}
count := 0
for _, transaction := range transactions {
row := GetTransactionRow(transaction)
err := csv.Write(row)
if err != nil {
return fmt.Errorf("write transaction: %w", err)
}
count++
}
csv.Flush()
fmt.Printf("Exported %d transactions\n", count)
return nil
}
func GetTransactionRow(transaction GetAllTransactionsForBudgetRow) []string {
row := []string{
transaction.Account,
"", // Flag
transaction.Date.Format("02.01.2006"),
}
if transaction.TransferAccount != "" {
row = append(row, "Transfer : "+transaction.TransferAccount)
} else {
row = append(row, transaction.Payee)
}
if transaction.CategoryGroup != "" && transaction.Category != "" {
row = append(row,
transaction.CategoryGroup+": "+transaction.Category,
transaction.CategoryGroup,
transaction.Category)
} else {
row = append(row, "", "", "")
}
row = append(row, transaction.Memo)
if transaction.Amount.IsPositive() {
row = append(row, numeric.Zero().String()+"€", transaction.Amount.String()+"€")
} else {
row = append(row, transaction.Amount.String()[1:]+"€", numeric.Zero().String()+"€")
}
return append(row, string(transaction.Status))
}

View File

@@ -7,12 +7,13 @@ import (
"io" "io"
"strings" "strings"
"time" "time"
"unicode/utf8"
"git.javil.eu/jacob1123/budgeteer/postgres/numeric"
"github.com/google/uuid" "github.com/google/uuid"
) )
type YNABImport struct { type YNABImport struct {
Context context.Context
accounts []Account accounts []Account
payees []Payee payees []Payee
categories []GetCategoriesRow categories []GetCategoriesRow
@@ -21,70 +22,73 @@ type YNABImport struct {
budgetID uuid.UUID budgetID uuid.UUID
} }
func NewYNABImport(context context.Context, queries *Queries, budgetID uuid.UUID) (*YNABImport, error) { func NewYNABImport(context context.Context, q *Queries, budgetID uuid.UUID) (*YNABImport, error) {
accounts, err := queries.GetAccounts(context, budgetID) accounts, err := q.GetAccounts(context, budgetID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
payees, err := queries.GetPayees(context, budgetID) payees, err := q.GetPayees(context, budgetID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
categories, err := queries.GetCategories(context, budgetID) categories, err := q.GetCategories(context, budgetID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
categoryGroups, err := queries.GetCategoryGroups(context, budgetID) categoryGroups, err := q.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: queries, queries: q,
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(context context.Context, r io.Reader) error { func (ynab *YNABImport) ImportAssignments(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("read from tsv: %w", err) return fmt.Errorf("could not 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("parse date %s: %w", dateString, err) return fmt.Errorf("could not 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(context, categoryGroup, categoryName) category, err := ynab.GetCategory(categoryGroup, categoryName)
if err != nil { if err != nil {
return fmt.Errorf("get category %s/%s: %w", categoryGroup, categoryName, err) return fmt.Errorf("could not 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("parse amount %s: %w", amountString, err) return fmt.Errorf("could not parse amount %s: %w", amountString, err)
} }
if amount.Int.Int64() == 0 { if amount.Int.Int64() == 0 {
@@ -96,9 +100,9 @@ func (ynab *YNABImport) ImportAssignments(context context.Context, r io.Reader)
CategoryID: category.UUID, CategoryID: category.UUID,
Amount: amount, Amount: amount,
} }
_, err = ynab.queries.CreateAssignment(context, assignment) _, err = ynab.queries.CreateAssignment(ynab.Context, assignment)
if err != nil { if err != nil {
return fmt.Errorf("save assignment %v: %w", assignment, err) return fmt.Errorf("could not save assignment %v: %w", assignment, err)
} }
count++ count++
@@ -117,210 +121,188 @@ type Transfer struct {
} }
// ImportTransactions expects a TSV-file as exported by YNAB in the following format: // ImportTransactions expects a TSV-file as exported by YNAB in the following format:
// "Account" "Flag" "Date" "Payee" "Category Group/Category" "Category Group" "Category" "Memo" "Outflow" "Inflow" "Cleared"
// "Cash" "" "11.12.2021" "Transfer : Checking" "" "" "" "Brought to bank" 500,00€ 0,00€ "Cleared". func (ynab *YNABImport) ImportTransactions(r io.Reader) error {
func (ynab *YNABImport) ImportTransactions(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("read from tsv: %w", err) return fmt.Errorf("could not 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:] {
transaction, err := ynab.GetTransaction(context, record) accountName := record[0]
account, err := ynab.GetAccount(accountName)
if err != nil { if err != nil {
return err return fmt.Errorf("could not get account %s: %w", accountName, err)
} }
payeeName := record[3] //flag := record[1]
// Transaction is a transfer
if strings.HasPrefix(payeeName, "Transfer : ") { dateString := record[2]
err = ynab.ImportTransferTransaction(context, payeeName, transaction.CreateTransactionParams, date, err := time.Parse("02.01.2006", dateString)
&openTransfers, transaction.Account, transaction.Amount)
} else {
err = ynab.ImportRegularTransaction(context, payeeName, transaction.CreateTransactionParams)
}
if err != nil { if err != nil {
return err return fmt.Errorf("could not parse date %s: %w", dateString, err)
} }
count++ categoryGroup, categoryName := record[5], record[6] //also in 4 joined by :
} category, err := ynab.GetCategory(categoryGroup, categoryName)
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 { if err != nil {
return fmt.Errorf("save transaction %v: %w", openTransfer.CreateTransactionParams, err) return fmt.Errorf("could not get category %s/%s: %w", categoryGroup, categoryName, err)
} }
}
fmt.Printf("Imported %d transactions\n", count) memo := record[7]
return nil 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)
}
type NewTransaction struct { statusEnum := TransactionStatusUncleared
CreateTransactionParams status := record[10]
Account *Account switch status {
} case "Cleared":
statusEnum = TransactionStatusCleared
case "Reconciled":
statusEnum = TransactionStatusReconciled
case "Uncleared":
}
func (ynab *YNABImport) GetTransaction(context context.Context, record []string) (NewTransaction, error) { transaction := CreateTransactionParams{
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, 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
}
func (ynab *YNABImport) ImportRegularTransaction(context context.Context, payeeName string, payeeName := record[3]
transaction CreateTransactionParams) error { if strings.HasPrefix(payeeName, "Transfer : ") {
payeeID, err := ynab.GetPayee(context, payeeName) // Transaction is a transfer to
if err != nil { transferToAccountName := payeeName[11:]
return fmt.Errorf("get payee %s: %w", payeeName, err) transferToAccount, err := ynab.GetAccount(transferToAccountName)
} if err != nil {
transaction.PayeeID = payeeID return fmt.Errorf("Could not get transfer account %s: %w", transferToAccountName, err)
}
_, err = ynab.queries.CreateTransaction(context, transaction) transfer := Transfer{
if err != nil { transaction,
return fmt.Errorf("save transaction %v: %w", transaction, err) 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)
}
} 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
} }
func (ynab *YNABImport) ImportTransferTransaction(context context.Context, payeeName string, func trimLastChar(s string) string {
transaction CreateTransactionParams, openTransfers *[]Transfer, r, size := utf8.DecodeLastRuneInString(s)
account *Account, amount numeric.Numeric) error { if r == utf8.RuneError && (size == 0 || size == 1) {
transferToAccountName := payeeName[11:] size = 0
transferToAccount, err := ynab.GetAccount(context, transferToAccountName)
if err != nil {
return fmt.Errorf("get transfer account %s: %w", transferToAccountName, err)
} }
return s[:len(s)-size]
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 GetAmount(inflow string, outflow string) (numeric.Numeric, error) { func GetAmount(inflow string, outflow string) (Numeric, error) {
in, err := numeric.ParseCurrency(inflow) // Remove trailing currency
if err != nil { inflow = strings.Replace(trimLastChar(inflow), ",", ".", 1)
return in, fmt.Errorf("parse inflow: %w", err) outflow = strings.Replace(trimLastChar(outflow), ",", ".", 1)
}
if !in.IsZero() { num := Numeric{}
return in, nil err := num.Set(inflow)
if err != nil {
return num, fmt.Errorf("Could not parse inflow %s: %w", inflow, err)
} }
// if inflow is zero, use outflow // if inflow is zero, use outflow
out, err := numeric.ParseCurrency("-" + outflow) if num.Int.Int64() != 0 {
if err != nil { return num, nil
return out, fmt.Errorf("parse outflow: %w", err)
} }
return out, nil
err = num.Set("-" + outflow)
if err != nil {
return num, fmt.Errorf("Could not parse outflow %s: %w", inflow, err)
}
return num, nil
} }
func (ynab *YNABImport) GetAccount(context context.Context, name string) (*Account, error) { func (ynab *YNABImport) GetAccount(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(context, CreateAccountParams{Name: name, BudgetID: ynab.budgetID}) account, err := ynab.queries.CreateAccount(ynab.Context, CreateAccountParams{Name: name, BudgetID: ynab.budgetID})
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -329,7 +311,7 @@ func (ynab *YNABImport) GetAccount(context context.Context, name string) (*Accou
return &account, nil return &account, nil
} }
func (ynab *YNABImport) GetPayee(context context.Context, name string) (uuid.NullUUID, error) { func (ynab *YNABImport) GetPayee(name string) (uuid.NullUUID, error) {
if name == "" { if name == "" {
return uuid.NullUUID{}, nil return uuid.NullUUID{}, nil
} }
@@ -340,7 +322,7 @@ func (ynab *YNABImport) GetPayee(context context.Context, name string) (uuid.Nul
} }
} }
payee, err := ynab.queries.CreatePayee(context, CreatePayeeParams{Name: name, BudgetID: ynab.budgetID}) payee, err := ynab.queries.CreatePayee(ynab.Context, CreatePayeeParams{Name: name, BudgetID: ynab.budgetID})
if err != nil { if err != nil {
return uuid.NullUUID{}, err return uuid.NullUUID{}, err
} }
@@ -349,7 +331,7 @@ func (ynab *YNABImport) GetPayee(context context.Context, name string) (uuid.Nul
return uuid.NullUUID{UUID: payee.ID, Valid: true}, nil return uuid.NullUUID{UUID: payee.ID, Valid: true}, nil
} }
func (ynab *YNABImport) GetCategory(context context.Context, group string, name string) (uuid.NullUUID, error) { //nolint func (ynab *YNABImport) GetCategory(group string, name string) (uuid.NullUUID, error) {
if group == "" || name == "" { if group == "" || name == "" {
return uuid.NullUUID{}, nil return uuid.NullUUID{}, nil
} }
@@ -360,25 +342,32 @@ func (ynab *YNABImport) GetCategory(context context.Context, group string, name
} }
} }
var categoryGroup CategoryGroup for _, categoryGroup := range ynab.categoryGroups {
for _, existingGroup := range ynab.categoryGroups { if categoryGroup.Name == group {
if existingGroup.Name == group { createCategory := CreateCategoryParams{Name: name, CategoryGroupID: categoryGroup.ID}
categoryGroup = existingGroup 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
} }
} }
if categoryGroup.Name == "" { categoryGroup, err := ynab.queries.CreateCategoryGroup(ynab.Context, CreateCategoryGroupParams{Name: group, BudgetID: ynab.budgetID})
newGroup := CreateCategoryGroupParams{Name: group, BudgetID: ynab.budgetID} if err != nil {
var err error return uuid.NullUUID{}, err
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)
newCategory := CreateCategoryParams{Name: name, CategoryGroupID: categoryGroup.ID} category, err := ynab.queries.CreateCategory(ynab.Context, 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,72 +0,0 @@
package server
import (
"net/http"
"strings"
"git.javil.eu/jacob1123/budgeteer/postgres"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
func (h *Handler) autocompleteCategories(c *gin.Context) {
budgetID := c.Param("budgetid")
budgetUUID, err := uuid.Parse(budgetID)
if err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{"budgetid missing from URL"})
return
}
query := c.Request.URL.Query().Get("s")
searchParams := postgres.SearchCategoriesParams{
BudgetID: budgetUUID,
Search: "%" + query + "%",
}
categories, err := h.Service.SearchCategories(c.Request.Context(), searchParams)
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
c.JSON(http.StatusOK, categories)
}
func (h *Handler) autocompletePayee(c *gin.Context) {
budgetID := c.Param("budgetid")
budgetUUID, err := uuid.Parse(budgetID)
if err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{"budgetid missing from URL"})
return
}
query := c.Request.URL.Query().Get("s")
transferPrefix := "Transfer"
if strings.HasPrefix(query, transferPrefix) {
searchParams := postgres.SearchAccountsParams{
BudgetID: budgetUUID,
Search: "%" + strings.Trim(query[len(transferPrefix):], " \t\n:") + "%",
}
accounts, err := h.Service.SearchAccounts(c.Request.Context(), searchParams)
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
c.JSON(http.StatusOK, accounts)
} else {
searchParams := postgres.SearchPayeesParams{
BudgetID: budgetUUID,
Search: query + "%",
}
payees, err := h.Service.SearchPayees(c.Request.Context(), searchParams)
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
c.JSON(http.StatusOK, payees)
}
}

View File

@@ -1,222 +0,0 @@
package server
import (
"fmt"
"net/http"
"strconv"
"time"
"git.javil.eu/jacob1123/budgeteer/postgres"
"git.javil.eu/jacob1123/budgeteer/postgres/numeric"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
func getFirstOfMonth(year, month int, location *time.Location) time.Time {
return time.Date(year, time.Month(month), 1, 0, 0, 0, 0, location)
}
func getFirstOfMonthTime(date time.Time) time.Time {
var monthM time.Month
year, monthM, _ := date.Date()
month := int(monthM)
return getFirstOfMonth(year, month, date.Location())
}
type CategoryWithBalance struct {
*postgres.GetCategoriesRow
Available numeric.Numeric
AvailableLastMonth numeric.Numeric
Activity numeric.Numeric
Assigned numeric.Numeric
}
func NewCategoryWithBalance(category *postgres.GetCategoriesRow) CategoryWithBalance {
return CategoryWithBalance{
GetCategoriesRow: category,
Available: numeric.Zero(),
AvailableLastMonth: numeric.Zero(),
Activity: numeric.Zero(),
Assigned: numeric.Zero(),
}
}
func getDate(c *gin.Context) (time.Time, error) {
var year, month int
yearString := c.Param("year")
monthString := c.Param("month")
if yearString == "" && monthString == "" {
return getFirstOfMonthTime(time.Now()), nil
}
year, err := strconv.Atoi(yearString)
if err != nil {
return time.Time{}, fmt.Errorf("parse year: %w", err)
}
month, err = strconv.Atoi(monthString)
if err != nil {
return time.Time{}, fmt.Errorf("parse month: %w", err)
}
return getFirstOfMonth(year, month, time.Now().Location()), nil
}
func (h *Handler) budgetingForMonth(c *gin.Context) {
budgetID := c.Param("budgetid")
budgetUUID, err := uuid.Parse(budgetID)
if err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{"budgetid missing from URL"})
return
}
budget, err := h.Service.GetBudget(c.Request.Context(), budgetUUID)
if err != nil {
c.AbortWithError(http.StatusBadRequest, err)
return
}
firstOfMonth, err := getDate(c)
if err != nil {
c.Redirect(http.StatusTemporaryRedirect, "/budget/"+budgetUUID.String())
return
}
categories, err := h.Service.GetCategories(c.Request.Context(), budgetUUID)
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
firstOfNextMonth := firstOfMonth.AddDate(0, 1, 0)
cumultativeBalances, err := h.Service.GetCumultativeBalances(c.Request.Context(), budgetUUID)
if err != nil {
c.AbortWithStatusJSON(http.StatusInternalServerError, ErrorResponse{fmt.Sprintf("error loading balances: %s", err)})
return
}
categoriesWithBalance, moneyUsed := h.calculateBalances(
budget, firstOfNextMonth, firstOfMonth, categories, cumultativeBalances)
availableBalance := h.getAvailableBalance(categories, budget, moneyUsed, cumultativeBalances, firstOfNextMonth)
data := struct {
Categories []CategoryWithBalance
AvailableBalance numeric.Numeric
}{categoriesWithBalance, availableBalance}
c.JSON(http.StatusOK, data)
}
func (*Handler) getAvailableBalance(categories []postgres.GetCategoriesRow, budget postgres.Budget,
moneyUsed numeric.Numeric, cumultativeBalances []postgres.GetCumultativeBalancesRow,
firstOfNextMonth time.Time) numeric.Numeric {
availableBalance := numeric.Zero()
for _, cat := range categories {
if cat.ID != budget.IncomeCategoryID {
continue
}
availableBalance = moneyUsed
for _, bal := range cumultativeBalances {
if bal.CategoryID != cat.ID {
continue
}
if !bal.Date.Before(firstOfNextMonth) {
continue
}
availableBalance = availableBalance.Add(bal.Transactions)
}
}
return availableBalance
}
type BudgetingResponse struct {
Accounts []postgres.GetAccountsWithBalanceRow
Budget postgres.Budget
}
func (h *Handler) budgeting(c *gin.Context) {
budgetID := c.Param("budgetid")
budgetUUID, err := uuid.Parse(budgetID)
if err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{"budgetid missing from URL"})
return
}
h.returnBudgetingData(c, budgetUUID)
}
func (h *Handler) returnBudgetingData(c *gin.Context, budgetUUID uuid.UUID) {
budget, err := h.Service.GetBudget(c.Request.Context(), budgetUUID)
if err != nil {
c.AbortWithError(http.StatusNotFound, err)
return
}
accounts, err := h.Service.GetAccountsWithBalance(c.Request.Context(), budgetUUID)
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
data := BudgetingResponse{accounts, budget}
c.JSON(http.StatusOK, data)
}
func (h *Handler) calculateBalances(budget postgres.Budget,
firstOfNextMonth time.Time, firstOfMonth time.Time, categories []postgres.GetCategoriesRow,
cumultativeBalances []postgres.GetCumultativeBalancesRow) ([]CategoryWithBalance, numeric.Numeric) {
categoriesWithBalance := []CategoryWithBalance{}
moneyUsed := numeric.Zero()
for i := range categories {
cat := &categories[i]
// do not show hidden categories
categoryWithBalance := h.CalculateCategoryBalances(cat, cumultativeBalances,
firstOfNextMonth, &moneyUsed, firstOfMonth, budget)
if cat.ID == budget.IncomeCategoryID {
continue
}
categoriesWithBalance = append(categoriesWithBalance, categoryWithBalance)
}
return categoriesWithBalance, moneyUsed
}
func (*Handler) CalculateCategoryBalances(cat *postgres.GetCategoriesRow,
cumultativeBalances []postgres.GetCumultativeBalancesRow, firstOfNextMonth time.Time,
moneyUsed *numeric.Numeric, firstOfMonth time.Time, budget postgres.Budget) CategoryWithBalance {
categoryWithBalance := NewCategoryWithBalance(cat)
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 = numeric.Zero()
}
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,141 +0,0 @@
package server
import (
"context"
"fmt"
"net/http"
"time"
"git.javil.eu/jacob1123/budgeteer/postgres"
"git.javil.eu/jacob1123/budgeteer/postgres/numeric"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
type NewTransactionPayload struct {
Date JSONDate `json:"date"`
Payee struct {
ID uuid.NullUUID
Name string
Type string
} `json:"payee"`
CategoryID uuid.NullUUID `json:"categoryId"`
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
}
amount, err := numeric.Parse(payload.Amount)
if err != nil {
c.AbortWithError(http.StatusBadRequest, fmt.Errorf("amount: %w", err))
return
}
transactionID := c.Param("transactionid")
if transactionID != "" {
h.UpdateTransaction(payload, amount, transactionID, c)
return
}
newTransaction := postgres.CreateTransactionParams{
Memo: payload.Memo,
Date: time.Time(payload.Date),
Amount: amount,
Status: postgres.TransactionStatus(payload.State),
CategoryID: payload.CategoryID,
AccountID: payload.AccountID,
}
if payload.Payee.Type == "account" {
err := h.CreateTransferForOtherAccount(newTransaction, amount, payload, c)
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
} else {
payeeID, err := GetPayeeID(c.Request.Context(), payload, h)
if err != nil {
c.AbortWithError(http.StatusInternalServerError, fmt.Errorf("create payee: %w", err))
}
newTransaction.PayeeID = payeeID
}
transaction, err := h.Service.CreateTransaction(c.Request.Context(), newTransaction)
if err != nil {
c.AbortWithError(http.StatusInternalServerError, fmt.Errorf("create transaction: %w", err))
return
}
c.JSON(http.StatusOK, transaction)
}
func (h *Handler) UpdateTransaction(payload NewTransactionPayload, amount numeric.Numeric, transactionID string, c *gin.Context) {
transactionUUID := uuid.MustParse(transactionID)
if amount.IsZero() {
err := h.Service.DeleteTransaction(c.Request.Context(), transactionUUID)
if err != nil {
c.AbortWithError(http.StatusInternalServerError, fmt.Errorf("delete transaction: %w", err))
}
return
}
editTransaction := postgres.UpdateTransactionParams{
Memo: payload.Memo,
Date: time.Time(payload.Date),
Amount: amount,
PayeeID: payload.Payee.ID,
CategoryID: payload.CategoryID,
ID: transactionUUID,
}
err := h.Service.UpdateTransaction(c.Request.Context(), editTransaction)
if err != nil {
c.AbortWithError(http.StatusInternalServerError, fmt.Errorf("edit transaction: %w", err))
}
}
func (h *Handler) CreateTransferForOtherAccount(newTransaction postgres.CreateTransactionParams, amount numeric.Numeric, payload NewTransactionPayload, c *gin.Context) error {
newTransaction.GroupID = uuid.NullUUID{UUID: uuid.New(), Valid: true}
newTransaction.Amount = amount.Neg()
newTransaction.AccountID = payload.Payee.ID.UUID
// transfer does not need category. Either it's account is off-budget or no category was supplied.
newTransaction.CategoryID = uuid.NullUUID{}
_, err := h.Service.CreateTransaction(c.Request.Context(), newTransaction)
if err != nil {
return fmt.Errorf("create transfer transaction: %w", err)
}
return nil
}
func GetPayeeID(context context.Context, payload NewTransactionPayload, h *Handler) (uuid.NullUUID, error) {
payeeID := payload.Payee.ID
if payeeID.Valid {
return payeeID, nil
}
if payload.Payee.Name == "" {
return uuid.NullUUID{}, nil
}
newPayee := postgres.CreatePayeeParams{
Name: payload.Payee.Name,
BudgetID: payload.BudgetID,
}
payee, err := h.Service.CreatePayee(context, newPayee)
if err != nil {
return uuid.NullUUID{}, fmt.Errorf("create payee: %w", err)
}
return uuid.NullUUID{UUID: payee.ID, Valid: true}, nil
}

View File

@@ -1,117 +0,0 @@
package server
import (
"net/http"
"git.javil.eu/jacob1123/budgeteer/postgres"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
func (h *Handler) importYNAB(c *gin.Context) {
budgetID, succ := c.Params.Get("budgetid")
if !succ {
c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{"no budget_id specified"})
return
}
budgetUUID, err := uuid.Parse(budgetID)
if !succ {
c.AbortWithError(http.StatusBadRequest, err)
return
}
ynab, err := postgres.NewYNABImport(c.Request.Context(), h.Service.Queries, budgetUUID)
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
transactionsFile, err := c.FormFile("transactions")
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
transactions, err := transactionsFile.Open()
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
err = ynab.ImportTransactions(c.Request.Context(), transactions)
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
assignmentsFile, err := c.FormFile("assignments")
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
assignments, err := assignmentsFile.Open()
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
err = ynab.ImportAssignments(c.Request.Context(), assignments)
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
}
func (h *Handler) exportYNABTransactions(c *gin.Context) {
budgetID, succ := c.Params.Get("budgetid")
if !succ {
c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{"no budget_id specified"})
return
}
budgetUUID, err := uuid.Parse(budgetID)
if !succ {
c.AbortWithError(http.StatusBadRequest, err)
return
}
ynab, err := postgres.NewYNABExport(c.Request.Context(), h.Service.Queries, budgetUUID)
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
err = ynab.ExportTransactions(c.Request.Context(), c.Writer)
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
}
func (h *Handler) exportYNABAssignments(c *gin.Context) {
budgetID, succ := c.Params.Get("budgetid")
if !succ {
c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{"no budget_id specified"})
return
}
budgetUUID, err := uuid.Parse(budgetID)
if !succ {
c.AbortWithError(http.StatusBadRequest, err)
return
}
ynab, err := postgres.NewYNABExport(c.Request.Context(), h.Service.Queries, budgetUUID)
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
err = ynab.ExportAssignments(c.Request.Context(), c.Writer)
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
}

View File

@@ -7,11 +7,9 @@ packages:
queries: "postgres/queries/" queries: "postgres/queries/"
overrides: overrides:
- go_type: - go_type:
import: "git.javil.eu/jacob1123/budgeteer/postgres/numeric" type: "Numeric"
type: Numeric
db_type: "pg_catalog.numeric" db_type: "pg_catalog.numeric"
- go_type: - go_type:
import: "git.javil.eu/jacob1123/budgeteer/postgres/numeric" type: "Numeric"
type: Numeric
db_type: "pg_catalog.numeric" db_type: "pg_catalog.numeric"
nullable: true nullable: true

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

@@ -11,7 +11,6 @@
"@mdi/font": "5.9.55", "@mdi/font": "5.9.55",
"@vueuse/core": "^7.6.1", "@vueuse/core": "^7.6.1",
"autoprefixer": "^10.4.2", "autoprefixer": "^10.4.2",
"file-saver": "^2.0.5",
"pinia": "^2.0.11", "pinia": "^2.0.11",
"postcss": "^8.4.6", "postcss": "^8.4.6",
"tailwindcss": "^3.0.18", "tailwindcss": "^3.0.18",
@@ -19,7 +18,6 @@
"vue-router": "^4.0.12" "vue-router": "^4.0.12"
}, },
"devDependencies": { "devDependencies": {
"@types/file-saver": "^2.0.5",
"@vitejs/plugin-vue": "^2.0.0", "@vitejs/plugin-vue": "^2.0.0",
"@vue/cli-plugin-babel": "5.0.0-beta.7", "@vue/cli-plugin-babel": "5.0.0-beta.7",
"@vue/cli-plugin-typescript": "~4.5.0", "@vue/cli-plugin-typescript": "~4.5.0",

View File

@@ -1,38 +1,43 @@
<script lang="ts" setup> <script lang="ts" setup>
import { ref, watch } from "vue" import { defineComponent, PropType, ref, watch } from "vue"
import { GET } from "../api"; import { GET } from "../api";
import { useBudgetsStore } from "../stores/budget"; import { useBudgetsStore } from "../stores/budget";
export interface Suggestion { export interface Suggestion {
ID: string ID: string
Name: string Name: string
Type: string }
interface Data {
Selected: Suggestion | undefined
SearchQuery: String
Suggestions: Suggestion[]
} }
const props = defineProps<{ const props = defineProps<{
text: String, modelValue: Suggestion | undefined,
id: String | undefined, type: String
model: String,
type?: string | undefined,
}>(); }>();
const SearchQuery = ref(props.text || ""); const Selected = ref<Suggestion | undefined>(props.modelValue || undefined);
const SearchQuery = ref(props.modelValue?.Name || "");
const Suggestions = ref<Array<Suggestion>>([]); const Suggestions = ref<Array<Suggestion>>([]);
const emit = defineEmits(["update:id", "update:text", "update:type"]); const emit = defineEmits(["update:modelValue"]);
watch(SearchQuery, () => { watch(SearchQuery, () => {
load(SearchQuery.value); load(SearchQuery.value);
}); });
function saveTransaction(e: MouseEvent) {
e.preventDefault();
};
function load(text: String) { function load(text: String) {
emit('update:id', null); emit('update:modelValue', { ID: null, Name: text });
emit('update:text', text);
emit('update:type', undefined);
if (text == "") { if (text == "") {
Suggestions.value = []; Suggestions.value = [];
return; return;
} }
const budgetStore = useBudgetsStore(); const budgetStore = useBudgetsStore();
GET("/budget/" + budgetStore.CurrentBudgetID + "/autocomplete/" + props.model + "?s=" + text) GET("/budget/" + budgetStore.CurrentBudgetID + "/autocomplete/" + props.type + "?s=" + text)
.then(x => x.json()) .then(x => x.json())
.then(x => { .then(x => {
let suggestions = x || []; let suggestions = x || [];
@@ -43,6 +48,7 @@ function load(text: String) {
}); });
}; };
function keypress(e: KeyboardEvent) { function keypress(e: KeyboardEvent) {
console.log(e.key);
if (e.key == "Enter") { if (e.key == "Enter") {
const selected = Suggestions.value[0]; const selected = Suggestions.value[0];
selectElement(selected); selectElement(selected);
@@ -51,13 +57,13 @@ function keypress(e: KeyboardEvent) {
const currentIndex = inputElements.indexOf(el); const currentIndex = inputElements.indexOf(el);
const nextElement = inputElements[currentIndex < inputElements.length - 1 ? currentIndex + 1 : 0]; const nextElement = inputElements[currentIndex < inputElements.length - 1 ? currentIndex + 1 : 0];
(<HTMLInputElement>nextElement).focus(); (<HTMLInputElement>nextElement).focus();
} }
}; };
function selectElement(element: Suggestion) { function selectElement(element: Suggestion) {
emit('update:id', element.ID); Selected.value = element;
emit('update:text', element.Name);
emit('update:type', element.Type);
Suggestions.value = []; Suggestions.value = [];
emit('update:modelValue', element);
}; };
function select(e: MouseEvent) { function select(e: MouseEvent) {
const target = (<HTMLInputElement>e.target); const target = (<HTMLInputElement>e.target);
@@ -69,9 +75,8 @@ function select(e: MouseEvent) {
selectElement(selected); selectElement(selected);
}; };
function clear() { function clear() {
emit('update:id', null); Selected.value = undefined;
emit('update:text', SearchQuery.value); emit('update:modelValue', { ID: null, Name: SearchQuery.value });
emit('update:type', undefined);
}; };
</script> </script>
@@ -80,10 +85,10 @@ function clear() {
<input <input
class="border-b-2 border-black" class="border-b-2 border-black"
@keypress="keypress" @keypress="keypress"
v-if="id == undefined" v-if="Selected == undefined"
v-model="SearchQuery" v-model="SearchQuery"
/> />
<span @click="clear" v-if="id != undefined" class="bg-gray-300">{{ text }}</span> <span @click="clear" v-if="Selected != undefined" class="bg-gray-300">{{ Selected.Name }}</span>
<div v-if="Suggestions.length > 0" class="absolute bg-gray-400 w-64 p-2"> <div v-if="Suggestions.length > 0" class="absolute bg-gray-400 w-64 p-2">
<span <span
v-for="suggestion in Suggestions" v-for="suggestion in Suggestions"

View File

@@ -1,10 +0,0 @@
<script lang="ts" setup>
</script>
<template>
<button
class="px-4 py-2 text-base font-medium rounded-md shadow-sm focus:outline-none focus:ring-2"
>
<slot></slot>
</button>
</template>

View File

@@ -1,33 +0,0 @@
<script lang="ts" setup>
const props = defineProps(["modelValue"]);
const emit = defineEmits(['update:modelValue']);
function dateToYYYYMMDD(d: Date) : string {
// alternative implementations in https://stackoverflow.com/q/23593052/1850609
//return new Date(d.getTime() - (d.getTimezoneOffset() * 60 * 1000)).toISOString().split('T')[0];
return d.toISOString().split('T')[0];
}
function updateValue(event: Event) {
const target = event.target as HTMLInputElement;
emit('update:modelValue', target.valueAsDate);
}
function selectAll(event: FocusEvent) {
// Workaround for Safari bug
// http://stackoverflow.com/questions/1269722/selecting-text-on-focus-using-jquery-not-working-in-safari-and-chrome
setTimeout(function () {
const target = event.target as HTMLInputElement;
target.select()
}, 0)
}
</script>
<template>
<input
type="date"
ref="input"
v-bind:value="dateToYYYYMMDD(modelValue)"
@input="updateValue"
@focus="selectAll"
/>
</template>

View File

@@ -1,58 +0,0 @@
<script lang="ts" setup>
import Card from '../components/Card.vue';
import { ref } from "vue";
const props = defineProps<{
buttonText: string,
}>();
const emit = defineEmits<{
(e: 'submit'): void,
(e: 'open'): void,
}>();
const visible = ref(false);
function closeDialog() {
visible.value = false;
};
function openDialog() {
emit("open");
visible.value = true;
};
function submitDialog() {
visible.value = false;
emit("submit");
}
</script>
<template>
<button @click="openDialog">
<slot name="placeholder">
<Card>
<p class="w-24 text-center text-6xl">+</p>
<span class="text-lg" dark>{{ buttonText }}</span>
</Card>
</slot>
</button>
<div
v-if="visible"
class="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full"
>
<div class="relative top-20 mx-auto p-5 border w-96 shadow-lg rounded-md bg-white">
<div class="mt-3 text-center">
<h3 class="mt-3 text-lg leading-6 font-medium text-gray-900">{{ buttonText }}</h3>
<slot></slot>
<div class="grid grid-cols-2 gap-6">
<button
@click="closeDialog"
class="px-4 py-2 bg-red-500 text-white text-base font-medium rounded-md shadow-sm hover:bg-green-600 focus:outline-none focus:ring-2 focus:ring-green-300"
>Close</button>
<button
@click="submitDialog"
class="px-4 py-2 bg-green-500 text-white text-base font-medium rounded-md shadow-sm hover:bg-green-600 focus:outline-none focus:ring-2 focus:ring-green-300"
>Save</button>
</div>
</div>
</div>
</div>
</template>

View File

@@ -1,60 +0,0 @@
<script lang="ts" setup>
import { computed, ref } from "vue";
import Autocomplete from './Autocomplete.vue'
import { useAccountStore } from '../stores/budget-account'
import DateInput from "./DateInput.vue";
const props = defineProps<{
transactionid: string
}>()
const accountStore = useAccountStore();
const TX = accountStore.Transactions.get(props.transactionid)!;
const payeeType = ref<string|undefined>(undefined);
const payload = computed(() => JSON.stringify({
date: TX.Date.toISOString().split("T")[0],
payee: {
Name: TX.Payee,
ID: TX.PayeeID,
Type: payeeType.value,
},
categoryId: TX.CategoryID,
memo: TX.Memo,
amount: TX.Amount.toString(),
state: "Uncleared"
}));
function saveTransaction(e: MouseEvent) {
e.preventDefault();
accountStore.editTransaction(TX.ID, payload.value);
}
</script>
<template>
<tr>
<td style="width: 90px;" class="text-sm">
<DateInput class="border-b-2 border-black" v-model="TX.Date" />
</td>
<td style="max-width: 150px;">
<Autocomplete v-model:text="TX.Payee" v-model:id="TX.PayeeID" v-model:type="payeeType" model="payees" />
</td>
<td style="max-width: 200px;">
<Autocomplete v-model:text="TX.Category" v-model:id="TX.CategoryID" model="categories" />
</td>
<td>
<input class="block w-full border-b-2 border-black" type="text" v-model="TX.Memo" />
</td>
<td style="width: 80px;" class="text-right">
<input
class="text-right block w-full border-b-2 border-black"
type="currency"
v-model="TX.Amount"
/>
</td>
<td style="width: 20px;">
<input type="submit" @click="saveTransaction" value="Save" />
</td>
<td style="width: 20px;"></td>
</tr>
</template>

View File

@@ -1,43 +1,27 @@
<script lang="ts" setup> <script lang="ts" setup>
import { computed, ref } from "vue"; import { computed, ref } from "vue";
import Autocomplete, { Suggestion } from '../components/Autocomplete.vue' import Autocomplete, { Suggestion } from '../components/Autocomplete.vue'
import { Transaction, useAccountStore } from '../stores/budget-account' import { useAccountStore } from '../stores/budget-account'
import DateInput from "./DateInput.vue";
const props = defineProps<{ const props = defineProps<{
budgetid: string budgetid: string
accountid: string accountid: string
}>() }>()
const TX = ref<Transaction>({ const TransactionDate = ref(new Date().toISOString().substring(0, 10));
Date: new Date(), const Payee = ref<Suggestion | undefined>(undefined);
Memo: "", const Category = ref<Suggestion | undefined>(undefined);
Amount: 0, const Memo = ref("");
Payee: "", const Amount = ref("0");
PayeeID: undefined,
Category: "",
CategoryID: undefined,
CategoryGroup: "",
GroupID: "",
ID: "",
Status: "Uncleared",
TransferAccount: "",
});
const payeeType = ref<string|undefined>(undefined);
const payload = computed(() => JSON.stringify({ const payload = computed(() => JSON.stringify({
budgetId: props.budgetid, budget_id: props.budgetid,
accountId: props.accountid, account_id: props.accountid,
date: TX.value.Date.toISOString().split("T")[0], date: TransactionDate.value,
payee: { payee: Payee.value,
Name: TX.value.Payee, category: Category.value,
ID: TX.value.PayeeID, memo: Memo.value,
Type: payeeType.value, amount: Amount.value,
},
categoryId: TX.value.CategoryID,
memo: TX.value.Memo,
amount: TX.value.Amount.toString(),
state: "Uncleared" state: "Uncleared"
})); }));
@@ -51,22 +35,22 @@ function saveTransaction(e: MouseEvent) {
<template> <template>
<tr> <tr>
<td style="width: 90px;" class="text-sm"> <td style="width: 90px;" class="text-sm">
<DateInput class="border-b-2 border-black" v-model="TX.Date" /> <input class="border-b-2 border-black" type="date" v-model="TransactionDate" />
</td> </td>
<td style="max-width: 150px;"> <td style="max-width: 150px;">
<Autocomplete v-model:text="TX.Payee" v-model:id="TX.PayeeID" v-model:type="payeeType" model="payees" /> <Autocomplete v-model="Payee" type="payees" />
</td> </td>
<td style="max-width: 200px;"> <td style="max-width: 200px;">
<Autocomplete v-model:text="TX.Category" v-model:id="TX.CategoryID" model="categories" /> <Autocomplete v-model="Category" type="categories" />
</td> </td>
<td> <td>
<input class="block w-full border-b-2 border-black" type="text" v-model="TX.Memo" /> <input class="block w-full border-b-2 border-black" type="text" v-model="Memo" />
</td> </td>
<td style="width: 80px;" class="text-right"> <td style="width: 80px;" class="text-right">
<input <input
class="text-right block w-full border-b-2 border-black" class="text-right block w-full border-b-2 border-black"
type="currency" type="currency"
v-model="TX.Amount" v-model="Amount"
/> />
</td> </td>
<td style="width: 20px;"> <td style="width: 20px;">

View File

@@ -1,28 +1,24 @@
<script lang="ts" setup> <script lang="ts" setup>
import { computed, ref } from "vue"; import { computed } from "vue";
import { useBudgetsStore } from "../stores/budget"; import { useBudgetsStore } from "../stores/budget";
import { Transaction } from "../stores/budget-account"; import { Transaction } from "../stores/budget-account";
import Currency from "./Currency.vue"; import Currency from "./Currency.vue";
import TransactionEditRow from "./TransactionEditRow.vue";
import { formatDate } from "../date";
const props = defineProps<{ const props = defineProps<{
transaction: Transaction, transaction: Transaction,
index: number, index: number,
}>(); }>();
const edit = ref(false);
const CurrentBudgetID = computed(()=> useBudgetsStore().CurrentBudgetID); const CurrentBudgetID = computed(()=> useBudgetsStore().CurrentBudgetID);
</script> </script>
<template> <template>
<tr v-if="!edit" class="{{new Date(transaction.Date) > new Date() ? 'future' : ''}}" <tr class="{{transaction.Date.After now ? 'future' : ''}}"
:class="[index % 6 < 3 ? 'bg-gray-300' : 'bg-gray-100']"> :class="[index % 6 < 3 ? 'bg-gray-300' : 'bg-gray-100']">
<!--:class="[index % 6 < 3 ? index % 6 === 1 ? 'bg-gray-400' : 'bg-gray-300' : index % 6 !== 4 ? 'bg-gray-100' : '']">--> <!--:class="[index % 6 < 3 ? index % 6 === 1 ? 'bg-gray-400' : 'bg-gray-300' : index % 6 !== 4 ? 'bg-gray-100' : '']">-->
<td>{{ formatDate(transaction.Date) }}</td> <td style="width: 90px;">{{ transaction.Date.substring(0, 10) }}</td>
<td>{{ transaction.TransferAccount ? "Transfer : " + transaction.TransferAccount : transaction.Payee }}</td> <td style="max-width: 150px;">{{ transaction.TransferAccount ? "Transfer : " + transaction.TransferAccount : transaction.Payee }}</td>
<td> <td style="max-width: 200px;">
{{ transaction.CategoryGroup ? transaction.CategoryGroup + " : " + transaction.Category : "" }} {{ transaction.CategoryGroup ? transaction.CategoryGroup + " : " + transaction.Category : "" }}
</td> </td>
<td> <td>
@@ -33,12 +29,11 @@ const CurrentBudgetID = computed(()=> useBudgetsStore().CurrentBudgetID);
<td> <td>
<Currency class="block" :value="transaction.Amount" /> <Currency class="block" :value="transaction.Amount" />
</td> </td>
<td> <td style="width: 20px;">
{{ transaction.Status == "Reconciled" ? "✔" : (transaction.Status == "Uncleared" ? "" : "*") }} {{ transaction.Status == "Reconciled" ? "✔" : (transaction.Status == "Uncleared" ? "" : "*") }}
</td> </td>
<td class="text-right">{{ transaction.GroupID ? "☀" : "" }}<a @click="edit = true;"></a></td> <td style="width: 20px;">{{ transaction.GroupID ? "☀" : "" }}</td>
</tr> </tr>
<TransactionEditRow v-if="edit" :transactionid="transaction.ID" />
</template> </template>
<style> <style>

View File

@@ -1,7 +0,0 @@
export function formatDate(date: Date): string {
return date.toLocaleDateString(undefined, { // you can use undefined as first argument
year: "numeric",
month: "2-digit",
day: "2-digit",
});
}

View File

@@ -1,44 +0,0 @@
<script lang="ts" setup>
import { computed, ref } from 'vue';
import Modal from '../components/Modal.vue';
import { useAccountStore } from '../stores/budget-account';
const accountStore = useAccountStore();
const CurrentAccount = computed(() => accountStore.CurrentAccount);
const accountName = ref("");
const accountOnBudget = ref(true);
function editAccount(e : any) {
accountStore.EditAccount(CurrentAccount.value?.ID ?? "", accountName.value, accountOnBudget.value);
}
function openEditAccount(e : any) {
accountName.value = CurrentAccount.value?.Name ?? "";
accountOnBudget.value = CurrentAccount.value?.OnBudget ?? true;
}
</script>
<template>
<Modal button-text="Edit Account" @open="openEditAccount" @submit="editAccount">
<template v-slot:placeholder></template>
<div class="mt-2 px-7 py-3">
<input
class="border-2"
type="text"
v-model="accountName"
placeholder="Account name"
required
/>
</div>
<div class="mt-2 px-7 py-3">
<input
class="border-2"
type="checkbox"
v-model="accountOnBudget"
required
/>
<label>On Budget</label>
</div>
</Modal>
</template>

View File

@@ -1,18 +1,36 @@
<script lang="ts" setup> <script lang="ts" setup>
import Modal from '../components/Modal.vue'; import Card from '../components/Card.vue';
import { ref } from "vue"; import { ref } from "vue";
import { useBudgetsStore } from '../stores/budget'; import { useBudgetsStore } from '../stores/budget';
const dialog = ref(false);
const budgetName = ref(""); const budgetName = ref("");
function saveBudget() { function saveBudget() {
useBudgetsStore().NewBudget(budgetName.value); useBudgetsStore().NewBudget(budgetName.value);
dialog.value = false;
};
function newBudget() {
dialog.value = true;
}; };
</script> </script>
<template> <template>
<Modal button-text="New Budget" @submit="saveBudget"> <Card>
<div class="mt-2 px-7 py-3"> <p class="w-24 text-center text-6xl">+</p>
<input class="border-2" type="text" v-model="budgetName" placeholder="Budget name" required /> <button class="text-lg" dark @click="newBudget">New Budget</button>
</Card>
<div v-if="dialog" justify="center">
<div>
<div>
<span class="text-h5">New Budget</span>
</div>
<div>
<input type="text" v-model="budgetName" label="Budget name" required />
</div>
<div>
<button @click="dialog = false">Close</button>
<button @click="saveBudget">Save</button>
</div>
</div> </div>
</Modal> </div>
</template> </template>

View File

@@ -4,23 +4,19 @@ import Currency from "../components/Currency.vue";
import TransactionRow from "../components/TransactionRow.vue"; import TransactionRow from "../components/TransactionRow.vue";
import TransactionInputRow from "../components/TransactionInputRow.vue"; import TransactionInputRow from "../components/TransactionInputRow.vue";
import { useAccountStore } from "../stores/budget-account"; import { useAccountStore } from "../stores/budget-account";
import EditAccount from "../dialogs/EditAccount.vue";
const props = defineProps<{ const props = defineProps<{
budgetid: string budgetid: string
accountid: string accountid: string
}>() }>()
const accountStore = useAccountStore(); const accountStore = useAccountStore();
const CurrentAccount = computed(() => accountStore.CurrentAccount); const CurrentAccount = computed(() => accountStore.CurrentAccount);
const TransactionsList = computed(() => accountStore.TransactionsList); const TransactionsList = computed(() => accountStore.TransactionsList);
</script> </script>
<template> <template>
<h1 class="inline">{{ CurrentAccount?.Name }}</h1> <h1>{{ CurrentAccount?.Name }}</h1>
<EditAccount />
<p> <p>
Current Balance: Current Balance:
<Currency :value="CurrentAccount?.Balance" /> <Currency :value="CurrentAccount?.Balance" />
@@ -33,7 +29,7 @@ const TransactionsList = computed(() => accountStore.TransactionsList);
<td>Memo</td> <td>Memo</td>
<td class="text-right">Amount</td> <td class="text-right">Amount</td>
<td style="width: 20px;"></td> <td style="width: 20px;"></td>
<td style="width: 40px;"></td> <td style="width: 20px;"></td>
</tr> </tr>
<TransactionInputRow :budgetid="budgetid" :accountid="accountid" /> <TransactionInputRow :budgetid="budgetid" :accountid="accountid" />
<TransactionRow <TransactionRow

View File

@@ -5,6 +5,11 @@ import { useBudgetsStore } from "../stores/budget"
import { useAccountStore } from "../stores/budget-account" import { useAccountStore } from "../stores/budget-account"
import { useSettingsStore } from "../stores/settings" import { useSettingsStore } from "../stores/settings"
const props = defineProps<{
budgetid: string,
accountid: string,
}>();
const ExpandMenu = computed(() => useSettingsStore().Menu.Expand); const ExpandMenu = computed(() => useSettingsStore().Menu.Expand);
const budgetStore = useBudgetsStore(); const budgetStore = useBudgetsStore();
@@ -25,7 +30,7 @@ const OffBudgetAccountsBalance = computed(() => accountStore.OffBudgetAccountsBa
{{CurrentBudgetName}} {{CurrentBudgetName}}
</span> </span>
<span class="bg-orange-200 rounded-lg m-1 p-1 px-3 flex flex-col"> <span class="bg-orange-200 rounded-lg m-1 p-1 px-3 flex flex-col">
<router-link :to="'/budget/'+CurrentBudgetID+'/budgeting'">Budget</router-link><br /> <router-link :to="'/budget/'+budgetid+'/budgeting'">Budget</router-link><br />
<!--<router-link :to="'/budget/'+CurrentBudgetID+'/reports'">Reports</router-link>--> <!--<router-link :to="'/budget/'+CurrentBudgetID+'/reports'">Reports</router-link>-->
<!--<router-link :to="'/budget/'+CurrentBudgetID+'/all-accounts'">All Accounts</router-link>--> <!--<router-link :to="'/budget/'+CurrentBudgetID+'/all-accounts'">All Accounts</router-link>-->
</span> </span>
@@ -35,7 +40,7 @@ const OffBudgetAccountsBalance = computed(() => accountStore.OffBudgetAccountsBa
<Currency :class="ExpandMenu?'md:inline':'md:hidden'" :value="OnBudgetAccountsBalance" /> <Currency :class="ExpandMenu?'md:inline':'md:hidden'" :value="OnBudgetAccountsBalance" />
</div> </div>
<div v-for="account in OnBudgetAccounts" class="flex flex-row justify-between"> <div v-for="account in OnBudgetAccounts" class="flex flex-row justify-between">
<router-link :to="'/budget/'+CurrentBudgetID+'/account/'+account.ID">{{account.Name}}</router-link> <router-link :to="'/budget/'+budgetid+'/account/'+account.ID">{{account.Name}}</router-link>
<Currency :class="ExpandMenu?'md:inline':'md:hidden'" :value="account.Balance" /> <Currency :class="ExpandMenu?'md:inline':'md:hidden'" :value="account.Balance" />
</div> </div>
</li> </li>
@@ -45,7 +50,7 @@ const OffBudgetAccountsBalance = computed(() => accountStore.OffBudgetAccountsBa
<Currency :class="ExpandMenu?'md:inline':'md:hidden'" :value="OffBudgetAccountsBalance" /> <Currency :class="ExpandMenu?'md:inline':'md:hidden'" :value="OffBudgetAccountsBalance" />
</div> </div>
<div v-for="account in OffBudgetAccounts" class="flex flex-row justify-between"> <div v-for="account in OffBudgetAccounts" class="flex flex-row justify-between">
<router-link :to="'/budget/'+CurrentBudgetID+'/account/'+account.ID">{{account.Name}}</router-link> <router-link :to="'/budget/'+budgetid+'/account/'+account.ID">{{account.Name}}</router-link>
<Currency :class="ExpandMenu?'md:inline':'md:hidden'" :value="account.Balance" /> <Currency :class="ExpandMenu?'md:inline':'md:hidden'" :value="account.Balance" />
</div> </div>
</li> </li>

View File

@@ -1,5 +1,5 @@
<script lang="ts" setup> <script lang="ts" setup>
import { computed, defineProps, onMounted, ref, watchEffect } from "vue"; import { computed, defineProps, onMounted, watchEffect } from "vue";
import Currency from "../components/Currency.vue"; import Currency from "../components/Currency.vue";
import { useBudgetsStore } from "../stores/budget"; import { useBudgetsStore } from "../stores/budget";
import { useAccountStore } from "../stores/budget-account"; import { useAccountStore } from "../stores/budget-account";
@@ -14,19 +14,11 @@ const props = defineProps<{
const budgetsStore = useBudgetsStore(); const budgetsStore = useBudgetsStore();
const CurrentBudgetID = computed(() => budgetsStore.CurrentBudgetID); const CurrentBudgetID = computed(() => budgetsStore.CurrentBudgetID);
const accountStore = useAccountStore(); const categoriesForMonth = useAccountStore().CategoriesForMonth;
const categoriesForMonth = accountStore.CategoriesForMonthAndGroup; const Categories = computed(() => {
return [...categoriesForMonth(selected.value.Year, selected.value.Month)];
function GetCategories(group : string) {
return [...categoriesForMonth(selected.value.Year, selected.value.Month, group)];
};
const groupsForMonth = accountStore.CategoryGroupsForMonth;
const GroupsForMonth = computed(() => {
return [...groupsForMonth(selected.value.Year, selected.value.Month)];
}); });
const previous = computed(() => ({ const previous = computed(() => ({
Year: new Date(selected.value.Year, selected.value.Month - 1, 1).getFullYear(), Year: new Date(selected.value.Year, selected.value.Month - 1, 1).getFullYear(),
Month: new Date(selected.value.Year, selected.value.Month - 1, 1).getMonth(), Month: new Date(selected.value.Year, selected.value.Month - 1, 1).getMonth(),
@@ -52,18 +44,6 @@ watchEffect(() => {
onMounted(() => { onMounted(() => {
useSessionStore().setTitle("Budget for " + selected.value.Month + "/" + selected.value.Year); useSessionStore().setTitle("Budget for " + selected.value.Month + "/" + selected.value.Year);
}) })
const expandedGroups = ref<Map<string, boolean>>(new Map<string, boolean>())
function toggleGroup(group : {Name : string, Expand: boolean}) {
console.log(expandedGroups.value);
expandedGroups.value.set(group.Name, !(expandedGroups.value.get(group.Name) ?? group.Expand))
}
function getGroupState(group : {Name : string, Expand: boolean}) : boolean {
return expandedGroups.value.get(group.Name) ?? group.Expand;
}
</script> </script>
<template> <template>
@@ -81,6 +61,7 @@ function getGroupState(group : {Name : string, Expand: boolean}) : boolean {
</div> </div>
<table class="container col-lg-12" id="content"> <table class="container col-lg-12" id="content">
<tr> <tr>
<th>Group</th>
<th>Category</th> <th>Category</th>
<th></th> <th></th>
<th></th> <th></th>
@@ -89,25 +70,23 @@ function getGroupState(group : {Name : string, Expand: boolean}) : boolean {
<th>Activity</th> <th>Activity</th>
<th>Available</th> <th>Available</th>
</tr> </tr>
<tbody v-for="group in GroupsForMonth"> <tr v-for="category in Categories">
<a class="text-lg font-bold" @click="toggleGroup(group)">{{ (getGroupState(group) ? "" : "+") + " " + group.Name }}</a> <td>{{ category.Group }}</td>
<tr v-for="category in GetCategories(group.Name)" v-if="getGroupState(group)"> <td>{{ category.Name }}</td>
<td>{{ category.Name }}</td> <td></td>
<td></td> <td></td>
<td></td> <td class="text-right">
<td class="text-right"> <Currency :value="category.AvailableLastMonth" />
<Currency :value="category.AvailableLastMonth" /> </td>
</td> <td class="text-right">
<td class="text-right"> <Currency :value="category.Assigned" />
<Currency :value="category.Assigned" /> </td>
</td> <td class="text-right">
<td class="text-right"> <Currency :value="category.Activity" />
<Currency :value="category.Activity" /> </td>
</td> <td class="text-right">
<td class="text-right"> <Currency :value="category.Available" />
<Currency :value="category.Available" /> </td>
</td> </tr>
</tr>
</tbody>
</table> </table>
</template> </template>

View File

@@ -5,7 +5,6 @@ import { useSessionStore } from "../stores/session";
const error = ref(""); const error = ref("");
const login = ref({ user: "", password: "" }); const login = ref({ user: "", password: "" });
const router = useRouter(); // has to be called in setup
onMounted(() => { onMounted(() => {
useSessionStore().setTitle("Login"); useSessionStore().setTitle("Login");
@@ -13,11 +12,10 @@ onMounted(() => {
function formSubmit(e: MouseEvent) { function formSubmit(e: MouseEvent) {
e.preventDefault(); e.preventDefault();
useSessionStore().login(login.value) useSessionStore().login(login)
.then(x => { .then(x => {
error.value = ""; error.value = "";
router.replace("/dashboard"); useRouter().replace("/dashboard");
return x;
}) })
.catch(x => error.value = "The entered credentials are invalid!"); .catch(x => error.value = "The entered credentials are invalid!");
@@ -28,17 +26,23 @@ function formSubmit(e: MouseEvent) {
<template> <template>
<div> <div>
<input type="text" v-model="login.user" <input
type="text"
v-model="login.user"
placeholder="Username" placeholder="Username"
class="border-2 border-black rounded-lg block px-2 my-2 w-48" /> class="border-2 border-black rounded-lg block px-2 my-2 w-48"
<input type="password" v-model="login.password" />
<input
type="password"
v-model="login.password"
placeholder="Password" placeholder="Password"
class="border-2 border-black rounded-lg block px-2 my-2 w-48" /> class="border-2 border-black rounded-lg block px-2 my-2 w-48"
/>
</div> </div>
<div>{{ error }}</div> <div>{{ error }}</div>
<button type="submit" @click="formSubmit" class="bg-blue-300 rounded-lg p-2 w-48">Login</button> <button type="submit" @click="formSubmit" class="bg-blue-300 rounded-lg p-2 w-48">Login</button>
<p> <p>
New user? New user?
<router-link to="/register">Register</router-link> instead! <router-link to="/register">Register</router-link>instead!
</p> </p>
</template> </template>

View File

@@ -1,25 +1,16 @@
<script lang="ts" setup> <script lang="ts" setup>
import { onMounted, ref } from "vue"; import { ref } from 'vue';
import { useRouter } from "vue-router"; import { useSessionStore } from '../stores/session';
import { useSessionStore } from "../stores/session";
const error = ref(""); const error = ref("");
const login = ref({ email: "", password: "", name: "" }); const login = ref({ email: "", password: "", name: "" });
const router = useRouter(); // has to be called in setup const showPassword = ref(false);
onMounted(() => { function formSubmit(e: FormDataEvent) {
useSessionStore().setTitle("Login");
});
function formSubmit(e: MouseEvent) {
e.preventDefault(); e.preventDefault();
useSessionStore().register(login.value) useSessionStore().register(login)
.then(x => { .then(() => error.value = "")
error.value = ""; .catch(() => error.value = "Something went wrong!");
router.replace("/dashboard");
return x;
})
.catch(x => error.value = "The entered credentials are invalid!");
// TODO display invalidCredentials // TODO display invalidCredentials
// TODO redirect to dashboard on success // TODO redirect to dashboard on success
@@ -27,21 +18,44 @@ function formSubmit(e: MouseEvent) {
</script> </script>
<template> <template>
<div> <v-container>
<input type="text" v-model="login.name" <v-row>
placeholder="Name" <v-col cols="12">
class="border-2 border-black rounded-lg block px-2 my-2 w-48" /> <v-text-field v-model="login.email" type="text" label="E-Mail" />
<input type="text" v-model="login.email" </v-col>
placeholder="Email" <v-col cols="12">
class="border-2 border-black rounded-lg block px-2 my-2 w-48" /> <v-text-field v-model="login.name" type="text" label="Name" />
<input type="password" v-model="login.password" </v-col>
placeholder="Password" <v-col cols="6">
class="border-2 border-black rounded-lg block px-2 my-2 w-48" /> <v-text-field
</div> v-model="login.password"
<div>{{ error }}</div> label="Password"
<button type="submit" @click="formSubmit" class="bg-blue-300 rounded-lg p-2 w-48">Register</button> :append-icon="showPassword ? 'mdi-eye' : 'mdi-eye-off'"
<p> :type="showPassword ? 'text' : 'password'"
Existing user? @click:append="showPassword = showPassword"
<router-link to="/login">Login</router-link> instead! :error-message="error"
</p> error-count="2"
error
/>
</v-col>
<v-col cols="6">
<v-text-field
v-model="login.password"
label="Repeat password"
:append-icon="showPassword ? 'mdi-eye' : 'mdi-eye-off'"
:type="showPassword ? 'text' : 'password'"
@click:append="showPassword = showPassword"
:error-message="error"
error-count="2"
error
/>
</v-col>
</v-row>
<div class="form-group">{{ error }}</div>
<v-btn type="submit" @click="formSubmit">Register</v-btn>
<p>
Existing user?
<router-link to="/login">Login</router-link>instead!
</p>
</v-container>
</template> </template>

View File

@@ -4,9 +4,6 @@ import { useRouter } from "vue-router";
import { DELETE, POST } from "../api"; import { DELETE, POST } from "../api";
import { useBudgetsStore } from "../stores/budget"; import { useBudgetsStore } from "../stores/budget";
import { useSessionStore } from "../stores/session"; import { useSessionStore } from "../stores/session";
import Card from "../components/Card.vue";
import Button from "../components/Button.vue";
import { saveAs } from 'file-saver';
const transactionsFile = ref<File | undefined>(undefined); const transactionsFile = ref<File | undefined>(undefined);
const assignmentsFile = ref<File | undefined>(undefined); const assignmentsFile = ref<File | undefined>(undefined);
@@ -16,10 +13,6 @@ onMounted(() => {
useSessionStore().setTitle("Settings"); useSessionStore().setTitle("Settings");
}); });
const budgetStore = useBudgetsStore();
const CurrentBudgetID = computed(() => budgetStore.CurrentBudgetID);
const CurrentBudgetName = computed(() => budgetStore.CurrentBudgetName);
function gotAssignments(e: Event) { function gotAssignments(e: Event) {
const input = (<HTMLInputElement>e.target); const input = (<HTMLInputElement>e.target);
if (input.files != null) if (input.files != null)
@@ -31,17 +24,19 @@ function gotTransactions(e: Event) {
transactionsFile.value = input.files[0]; transactionsFile.value = input.files[0];
}; };
function deleteBudget() { function deleteBudget() {
if (CurrentBudgetID.value == null) const currentBudgetID = useBudgetsStore().CurrentBudgetID;
if (currentBudgetID == null)
return; return;
DELETE("/budget/" + CurrentBudgetID.value); DELETE("/budget/" + currentBudgetID);
const budgetStore = useSessionStore(); const budgetStore = useSessionStore();
budgetStore.Budgets.delete(CurrentBudgetID.value); budgetStore.Budgets.delete(currentBudgetID);
useRouter().push("/") useRouter().push("/")
}; };
function clearBudget() { function clearBudget() {
POST("/budget/" + CurrentBudgetID.value + "/settings/clear", null) const currentBudgetID = useBudgetsStore().CurrentBudgetID;
POST("/budget/" + currentBudgetID + "/settings/clear", null)
}; };
function cleanNegative() { function cleanNegative() {
// <a href="/budget/{{.Budget.ID}}/settings/clean-negative">Fix all historic negative category-balances</a> // <a href="/budget/{{.Budget.ID}}/settings/clean-negative">Fix all historic negative category-balances</a>
@@ -56,70 +51,75 @@ function ynabImport() {
const budgetStore = useBudgetsStore(); const budgetStore = useBudgetsStore();
budgetStore.ImportYNAB(formData); budgetStore.ImportYNAB(formData);
}; };
function ynabExport() {
const timeStamp = new Date().toISOString();
POST("/budget/"+CurrentBudgetID.value+"/export/ynab/assignments", "")
.then(x => x.text())
.then(x => {
var blob = new Blob([x], {type: "text/plain;charset=utf-8"});
saveAs(blob, timeStamp + " " + CurrentBudgetName.value + " - Budget.tsv");
})
POST("/budget/"+CurrentBudgetID.value+"/export/ynab/transactions", "")
.then(x => x.text())
.then(x => {
var blob = new Blob([x], {type: "text/plain;charset=utf-8"});
saveAs(blob, timeStamp + " " + CurrentBudgetName.value + " - Transactions.tsv");
})
}
</script> </script>
<template> <template>
<div> <v-container>
<h1>Danger Zone</h1> <h1>Danger Zone</h1>
<div class="grid md:grid-cols-2 gap-6"> <v-row>
<Card class="flex-col p-3"> <v-col cols="12" md="6" xl="3">
<h2 class="text-lg font-bold">Clear Budget</h2> <v-card>
<p>This removes transactions and assignments to start from scratch. Accounts and categories are kept. Not undoable!</p> <v-card-header>
<v-card-header-text>
<v-card-title>Clear Budget</v-card-title>
<v-card-subtitle>This removes transactions and assignments to start from scratch. Accounts and categories are kept. Not undoable!</v-card-subtitle>
</v-card-header-text>
</v-card-header>
<v-card-actions class="justify-center">
<v-btn @click="clearBudget">Clear budget</v-btn>
</v-card-actions>
</v-card>
</v-col>
<v-col cols="12" md="6" xl="3">
<v-card>
<v-card-header>
<v-card-header-text>
<v-card-title>Delete Budget</v-card-title>
<v-card-subtitle>This deletes the whole bugdet including all transactions, assignments, accounts and categories. Not undoable!</v-card-subtitle>
</v-card-header-text>
</v-card-header>
<v-card-actions class="justify-center">
<v-btn @click="deleteBudget">Delete budget</v-btn>
</v-card-actions>
</v-card>
</v-col>
<v-col cols="12" md="6" xl="3">
<v-card>
<v-card-header>
<v-card-header-text>
<v-card-title>Fix all historic negative category-balances</v-card-title>
<v-card-subtitle>This restores YNABs functionality, that would substract any overspent categories' balances from next months inflows.</v-card-subtitle>
</v-card-header-text>
</v-card-header>
<v-card-actions class="justify-center">
<v-btn @click="cleanNegative">Fix negative</v-btn>
</v-card-actions>
</v-card>
</v-col>
<v-col cols="12" xl="6">
<v-card>
<v-card-header>
<v-card-header-text>
<v-card-title>Import YNAB Budget</v-card-title>
</v-card-header-text>
</v-card-header>
<Button class="bg-red-500" @click="clearBudget">Clear budget</Button> <label for="transactions_file">
</Card> Transaktionen:
<Card class="flex-col p-3"> <input type="file" @change="gotTransactions" accept="text/*" />
<h2 class="text-lg font-bold">Delete Budget</h2> </label>
<p>This deletes the whole bugdet including all transactions, assignments, accounts and categories. Not undoable!</p> <br />
<Button class="bg-red-500" @click="deleteBudget">Delete budget</button> <label for="assignments_file">
</Card> Budget:
<Card class="flex-col p-3"> <input type="file" @change="gotAssignments" accept="text/*" />
<h2 class="text-lg font-bold">Fix all historic negative category-balances</h2> </label>
<p>This restores YNABs functionality, that would substract any overspent categories' balances from next months inflows.</p>
<Button class="bg-orange-500" @click="cleanNegative">Fix negative</button>
</Card>
<Card class="flex-col p-3">
<h2 class="text-lg font-bold">Import YNAB Budget</h2>
<div class="flex flex-row"> <v-card-actions class="justify-center">
<div> <v-btn :disabled="filesIncomplete" @click="ynabImport">Importieren</v-btn>
<label for="transactions_file"> </v-card-actions>
Transaktionen: </v-card>
<input type="file" @change="gotTransactions" accept="text/*" /> </v-col>
</label> </v-row>
<br /> <v-card></v-card>
<label for="assignments_file"> </v-container>
Budget:
<input type="file" @change="gotAssignments" accept="text/*" />
</label>
</div>
<Button class="bg-blue-500" :disabled="filesIncomplete" @click="ynabImport">Importieren</Button>
</div>
</Card>
<Card class="flex-col p-3">
<h2 class="text-lg font-bold">Export as YNAB TSV</h2>
<div class="flex flex-row">
<Button class="bg-blue-500" @click="ynabExport">Export</Button>
</div>
</Card>
</div>
</div>
</template> </template>

View File

@@ -1,6 +1,5 @@
import { defineStore } from "pinia" import { defineStore } from "pinia"
import { GET, POST } from "../api"; import { GET, POST } from "../api";
import { useBudgetsStore } from "./budget";
import { useSessionStore } from "./session"; import { useSessionStore } from "./session";
interface State { interface State {
@@ -8,22 +7,20 @@ interface State {
CurrentAccountID: string | null, CurrentAccountID: string | null,
Categories: Map<string, Category>, Categories: Map<string, Category>,
Months: Map<number, Map<number, Map<string, Category>>>, Months: Map<number, Map<number, Map<string, Category>>>,
Transactions: Map<string, Transaction>, Transactions: any[],
Assignments: [] Assignments: []
} }
export interface Transaction { export interface Transaction {
ID: string, ID: string,
Date: Date, Date: string,
TransferAccount: string, TransferAccount: string,
CategoryGroup: string, CategoryGroup: string,
Category: string, Category: string,
CategoryID: string | undefined,
Memo: string, Memo: string,
Status: string, Status: string,
GroupID: string, GroupID: string,
Payee: string, Payee: string,
PayeeID: string | undefined,
Amount: number, Amount: number,
} }
@@ -32,7 +29,6 @@ export interface Account {
Name: string Name: string
OnBudget: boolean OnBudget: boolean
Balance: number Balance: number
Transactions: string[]
} }
export interface Category { export interface Category {
@@ -51,40 +47,19 @@ export const useAccountStore = defineStore("budget/account", {
CurrentAccountID: null, CurrentAccountID: null,
Months: new Map<number, Map<number, Map<string, Category>>>(), Months: new Map<number, Map<number, Map<string, Category>>>(),
Categories: new Map<string, Category>(), Categories: new Map<string, Category>(),
Transactions: new Map<string, Transaction>(), Transactions: [],
Assignments: [] Assignments: []
}), }),
getters: { getters: {
AccountsList(state) { AccountsList(state) {
return [...state.Accounts.values()]; return [...state.Accounts.values()];
}, },
AllCategoriesForMonth: (state) => (year: number, month: number) => { CategoriesForMonth: (state) => (year: number, month: number) => {
const yearMap = state.Months.get(year); const yearMap = state.Months.get(year);
const monthMap = yearMap?.get(month); const monthMap = yearMap?.get(month);
console.log("MTH", monthMap)
return [...monthMap?.values() || []]; return [...monthMap?.values() || []];
}, },
CategoryGroupsForMonth(state) {
return (year: number, month: number) => {
const categories = this.AllCategoriesForMonth(year, month);
const categoryGroups = [];
let prev = undefined;
for (const category of categories) {
if(category.Group != prev)
categoryGroups.push({
Name: category.Group,
Expand: category.Group != "Hidden Categories",
});
prev = category.Group;
}
return categoryGroups;
}
},
CategoriesForMonthAndGroup(state) {
return (year: number, month: number, group : string) => {
const categories = this.AllCategoriesForMonth(year, month);
return categories.filter(x => x.Group == group);
}
},
CurrentAccount(state): Account | undefined { CurrentAccount(state): Account | undefined {
if (state.CurrentAccountID == null) if (state.CurrentAccountID == null)
return undefined; return undefined;
@@ -103,10 +78,8 @@ export const useAccountStore = defineStore("budget/account", {
OffBudgetAccountsBalance(state): number { OffBudgetAccountsBalance(state): number {
return this.OffBudgetAccounts.reduce((prev, curr) => prev + Number(curr.Balance), 0); return this.OffBudgetAccounts.reduce((prev, curr) => prev + Number(curr.Balance), 0);
}, },
TransactionsList(state) : Transaction[] { TransactionsList(state) {
return this.CurrentAccount!.Transactions.map(x => { return (state.Transactions || []);
return this.Transactions.get(x)!
});
} }
}, },
actions: { actions: {
@@ -115,35 +88,22 @@ export const useAccountStore = defineStore("budget/account", {
return return
this.CurrentAccountID = accountid; this.CurrentAccountID = accountid;
const account = this.CurrentAccount; if (this.CurrentAccount == undefined)
if (account == undefined)
return return
useSessionStore().setTitle(account.Name); useSessionStore().setTitle(this.CurrentAccount.Name);
await this.FetchAccount(account); await this.FetchAccount(accountid);
}, },
async FetchAccount(account: Account) { async FetchAccount(accountid: string) {
const result = await GET("/account/" + account.ID + "/transactions"); const result = await GET("/account/" + accountid + "/transactions");
const response = await result.json(); const response = await result.json();
account.Transactions = []; this.Transactions = response.Transactions;
for (const transaction of response.Transactions) {
transaction.Date = new Date(transaction.Date);
this.Transactions.set(transaction.ID, transaction);
account.Transactions.push(transaction.ID);
}
}, },
async FetchMonthBudget(budgetid: string, year: number, month: number) { async FetchMonthBudget(budgetid: string, year: number, month: number) {
const result = await GET("/budget/" + budgetid + "/" + year + "/" + month); const result = await GET("/budget/" + budgetid + "/" + year + "/" + month);
const response = await result.json(); const response = await result.json();
if(response.Categories == undefined || response.Categories.length <= 0)
return;
this.addCategoriesForMonth(year, month, response.Categories); this.addCategoriesForMonth(year, month, response.Categories);
}, },
async EditAccount(accountid : string, name : string, onBudget : boolean) {
const result = await POST("/account/" + accountid, JSON.stringify({name: name, onBudget: onBudget}));
const response = await result.json();
useBudgetsStore().MergeBudgetingData(response);
},
addCategoriesForMonth(year: number, month: number, categories: Category[]): void { addCategoriesForMonth(year: number, month: number, categories: Category[]): void {
this.$patch((state) => { this.$patch((state) => {
const yearMap = state.Months.get(year) || new Map<number, Map<string, Category>>(); const yearMap = state.Months.get(year) || new Map<number, Map<string, Category>>();
@@ -162,12 +122,7 @@ export const useAccountStore = defineStore("budget/account", {
async saveTransaction(payload: string) { async saveTransaction(payload: string) {
const result = await POST("/transaction/new", payload); const result = await POST("/transaction/new", payload);
const response = await result.json(); const response = await result.json();
this.CurrentAccount?.Transactions.unshift(response); this.Transactions.unshift(response);
},
async editTransaction(transactionid : string, payload: string) {
const result = await POST("/transaction/" + transactionid, payload);
const response = await result.json();
this.CurrentAccount?.Transactions.unshift(response);
} }
} }

View File

@@ -51,9 +51,6 @@ export const useBudgetsStore = defineStore('budget', {
async FetchBudget(budgetid: string) { async FetchBudget(budgetid: string) {
const result = await GET("/budget/" + budgetid); const result = await GET("/budget/" + budgetid);
const response = await result.json(); const response = await result.json();
this.MergeBudgetingData(response);
},
MergeBudgetingData(response : any) {
for (const account of response.Accounts || []) { for (const account of response.Accounts || []) {
useAccountStore().Accounts.set(account.ID, account); useAccountStore().Accounts.set(account.ID, account);
} }

View File

@@ -20,12 +20,12 @@ export interface Budget {
export const useSessionStore = defineStore('session', { export const useSessionStore = defineStore('session', {
state: () => ({ state: () => ({
Session: useStorage<Session | null>('session', null, undefined, { serializer: StorageSerializers.object }), Session: useStorage<Session>('session', null, undefined, { serializer: StorageSerializers.object }),
Budgets: useStorage<Map<string, Budget>>('budgets', new Map<string, Budget>(), undefined, { serializer: StorageSerializers.map }), Budgets: useStorage<Map<string, Budget>>('budgets', new Map<string, Budget>(), undefined, { serializer: StorageSerializers.map }),
}), }),
getters: { getters: {
BudgetsList: (state) => [ ...state.Budgets.values() ], BudgetsList: (state) => [ ...state.Budgets.values() ],
AuthHeaders: (state) => ({'Authorization': 'Bearer ' + state.Session?.Token}), AuthHeaders: (state) => ({'Authorization': 'Bearer ' + state.Session.Token}),
LoggedIn: (state) => state.Session != null, LoggedIn: (state) => state.Session != null,
}, },
actions: { actions: {
@@ -36,26 +36,21 @@ export const useSessionStore = defineStore('session', {
this.Session = { this.Session = {
User: x.User, User: x.User,
Token: x.Token, Token: x.Token,
} },
for (const budget of x.Budgets) { this.Budgets = x.Budgets;
this.Budgets.set(budget.ID, budget);
}
}, },
async login(login: any) { async login(login: any) {
const response = await POST("/user/login", JSON.stringify(login)); const response = await POST("/user/login", JSON.stringify(login));
const result = await response.json(); const result = await response.json();
this.loginSuccess(result); return this.loginSuccess(result);
return result;
}, },
async register(login : any) { async register(login : any) {
const response = await POST("/user/register", JSON.stringify(login)); const response = await POST("/user/register", JSON.stringify(login));
const result = await response.json(); const result = await response.json();
this.loginSuccess(result); return this.loginSuccess(result);
return result;
}, },
logout() { logout() {
this.Session = null; this.$reset()
this.Budgets.clear();
}, },
} }
}) })

View File

@@ -1141,11 +1141,6 @@
"@types/qs" "*" "@types/qs" "*"
"@types/serve-static" "*" "@types/serve-static" "*"
"@types/file-saver@^2.0.5":
version "2.0.5"
resolved "https://registry.yarnpkg.com/@types/file-saver/-/file-saver-2.0.5.tgz#9ee342a5d1314bb0928375424a2f162f97c310c7"
integrity sha512-zv9kNf3keYegP5oThGLaPk8E081DFDuwfqjtiTzm6PoxChdJ1raSuADf2YGCVIyrSynLrgc8JWv296s7Q7pQSQ==
"@types/glob@^7.1.1": "@types/glob@^7.1.1":
version "7.2.0" version "7.2.0"
resolved "https://registry.yarnpkg.com/@types/glob/-/glob-7.2.0.tgz#bc1b5bf3aa92f25bd5dd39f35c57361bdce5b2eb" resolved "https://registry.yarnpkg.com/@types/glob/-/glob-7.2.0.tgz#bc1b5bf3aa92f25bd5dd39f35c57361bdce5b2eb"
@@ -4142,11 +4137,6 @@ figures@^2.0.0:
dependencies: dependencies:
escape-string-regexp "^1.0.5" escape-string-regexp "^1.0.5"
file-saver@^2.0.5:
version "2.0.5"
resolved "https://registry.yarnpkg.com/file-saver/-/file-saver-2.0.5.tgz#d61cfe2ce059f414d899e9dd6d4107ee25670c38"
integrity sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA==
file-uri-to-path@1.0.0: file-uri-to-path@1.0.0:
version "1.0.0" version "1.0.0"
resolved "https://registry.yarnpkg.com/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz#553a7b8446ff6f684359c445f1e37a05dacc33dd" resolved "https://registry.yarnpkg.com/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz#553a7b8446ff6f684359c445f1e37a05dacc33dd"