5 Commits

Author SHA1 Message Date
2843d8a2f1 Save all unmatched transfers as regular transactions
Some checks failed
continuous-integration/drone/pr Build is failing
continuous-integration/drone/push Build is passing
2022-01-10 10:10:02 +00:00
843dcd2536 Fix logging wrong objects 2022-01-10 10:10:02 +00:00
a147830e12 Also fetch GroupID and highlight groups in transactions-view 2022-01-10 10:10:02 +00:00
b0776023b4 Fix migration 2022-01-10 10:10:02 +00:00
0b95cdc1d9 Reword clear actions description 2022-01-10 10:10:02 +00:00
162 changed files with 4060 additions and 13903 deletions

View File

@ -7,5 +7,4 @@ config.example.json
.gitignore .gitignore
.vscode/ .vscode/
budgeteer budgeteer
budgeteer.exe budgeteer.exe
**/node_modules/

View File

@ -4,23 +4,10 @@ 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
commands: commands:
- task ci - task build
when:
event:
exclude:
- pull_request
- name: docker - name: docker
image: plugins/docker image: plugins/docker
@ -32,30 +19,9 @@ steps:
from_secret: docker_password from_secret: docker_password
repo: hub.javil.eu/budgeteer repo: hub.javil.eu/budgeteer
context: build context: build
dockerfile: docker/Dockerfile dockerfile: build/Dockerfile
tags: tags:
- latest - latest
when:
branch:
- master
event:
- push
- 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: docker/Dockerfile
auto_tag: true
when:
event:
- tag
image_pull_secrets: image_pull_secrets:
- hub.javil.eu - hub.javil.eu

10
.earthignore Normal file
View File

@ -0,0 +1,10 @@
build/
.git/
docker-compose.yml
README.md
Earthfile
config.example.json
.gitignore
.vscode/
budgeteer
budgeteer.exe

8
.gitignore vendored
View File

@ -4,7 +4,6 @@
# Unignore all with extensions # Unignore all with extensions
!*.* !*.*
!Dockerfile
# Unignore all dirs # Unignore all dirs
!*/ !*/
@ -86,9 +85,4 @@ GitHub.sublime-settings
# Support for Project snippet scope # Support for Project snippet scope
!.vscode/*.code-snippets !.vscode/*.code-snippets
# End of https://www.toptal.com/developers/gitignore/api/visualstudiocode,sublimetext,go # End of https://www.toptal.com/developers/gitignore/api/visualstudiocode,sublimetext,go
node_modules
.DS_Store
dist
dist-ssr
*.local

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

13
.vscode/settings.json vendored
View File

@ -1,13 +0,0 @@
{
"files.exclude": {
"**/node_modules": true,
"**/vendor": true,
"**/*.sql.go": true,
".task/": true,
"build/": true,
"web/dist/": true
},
"gopls": {
"formatting.gofumpt": true,
}
}

3
Dockerfile.dev Normal file
View File

@ -0,0 +1,3 @@
FROM golang:1.17
RUN go install github.com/kyleconroy/sqlc/cmd/sqlc@latest
RUN go install github.com/go-task/task/v3/cmd/task@latest

21
Earthfile Normal file
View File

@ -0,0 +1,21 @@
FROM golang:1.17
WORKDIR /src
build:
COPY go.mod go.sum .
RUN go mod download
COPY . .
RUN --mount=type=cache,target=/root/.cache/go-build go build -o build/budgeteer ./cmd/budgeteer
SAVE ARTIFACT build/budgeteer /budgeteer AS LOCAL build/budgeteer
docker:
WORKDIR /app
COPY +build/budgeteer .
ENTRYPOINT ["/app/budgeteer"]
SAVE IMAGE budgeteer:latest
run:
LOCALLY
WITH DOCKER --load=+docker
RUN docker-compose up -d
END

View File

@ -1,20 +1,3 @@
# Budgeteer # Budgeteer
Budgeting Web-Application Budgeting Web-Application
## Data structure
1 User
N Budgets
AccountID[]
CategoryID[]
PayeeID[]
N Accounts
TransactionID[]
N Categories
AssignmentID[]
N Payees
N Transactions
N Assignments

View File

@ -1,12 +1,9 @@
version: '3' version: '3'
vars:
IMAGE_NAME: hub.javil.eu/budgeteer
tasks: tasks:
default: default:
cmds: cmds:
- task: build-prod - task: build
sqlc: sqlc:
desc: sqlc code generation desc: sqlc code generation
@ -30,11 +27,17 @@ tasks:
build: build:
desc: Build budgeteer desc: Build budgeteer
deps: [gomod, sqlc]
sources: sources:
- ./go.mod - ./go.mod
- ./go.sum - ./go.sum
- ./**/*.go - ./cmd/budgeteer/*.go
- ./web/dist/**/* - ./*.go
- ./config/*.go
- ./http/*.go
- ./jwt/*.go
- ./postgres/*.go
- ./web/**/*
- ./postgres/schema/* - ./postgres/schema/*
generates: generates:
- build/budgeteer{{exeExt}} - build/budgeteer{{exeExt}}
@ -43,69 +46,15 @@ tasks:
cmds: cmds:
- go build -o ./build/budgeteer{{exeExt}} ./cmd/budgeteer - go build -o ./build/budgeteer{{exeExt}} ./cmd/budgeteer
build-dev:
desc: Build budgeteer in dev mode
deps: [gomod, sqlc]
cmds:
- go vet
- go fmt
- golangci-lint run
- task: build
build-prod:
desc: Build budgeteer in prod mode
deps: [gomod, sqlc, frontend]
cmds:
- go vet
- go fmt
- golangci-lint run
- task: build
ci:
desc: Run CI build
cmds:
- task: build-prod
- go test ./...
frontend:
desc: Build vue frontend
dir: web
sources:
- web/src/**/*
generates:
- web/dist/**/*
cmds:
- yarn
- yarn build
- yarn run vue-tsc --noEmit
docker: docker:
desc: Build budgeeter:latest desc: Build budgeeter:latest
deps: [build-prod] deps: [build]
sources: sources:
- ./build/budgeteer{{exeExt}} - ./build/budgeteer
- ./build/Dockerfile
cmds: cmds:
- docker build -t {{.IMAGE_NAME}}:latest ./build - docker build -t budgeteer:latest -t hub.javil.eu/budgeteer:latest ./build
- docker push {{.IMAGE_NAME}}:latest
dev-docker:
desc: Build budgeeter:dev
sources:
- ./docker/Dockerfile
- ./docker/build.sh
- ./web/package.json
cmds:
- docker build -t {{.IMAGE_NAME}}:dev . -f docker/Dockerfile.dev
- docker push {{.IMAGE_NAME}}:dev
run: run:
desc: Start budgeteer
deps: [build-dev]
cmds:
- ./build/budgeteer{{exeExt}}
rundocker:
desc: Start docker-compose desc: Start docker-compose
deps: [docker] deps: [docker]
cmds: cmds:

View File

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

View File

@ -1,17 +1,13 @@
package main package main
import ( import (
"fmt"
"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() {
@ -20,27 +16,19 @@ 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) bv := &bcrypt.Verifier{}
q, err := postgres.Connect(cfg.DatabaseHost, cfg.DatabaseUser, cfg.DatabasePassword, cfg.DatabaseName)
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") tv := &jwt.TokenVerifier{}
if err != nil {
panic("couldn't open static files")
}
tokenVerifier, err := jwt.NewTokenVerifier(cfg.SessionSecret) h := &http.Handler{
if err != nil { Service: q,
panic(fmt.Errorf("couldn't create token verifier: %w", err)) TokenVerifier: tv,
CredentialsVerifier: bv,
} }
h.Serve()
handler := &server.Handler{
Service: queries,
TokenVerifier: tokenVerifier,
CredentialsVerifier: &bcrypt.Verifier{},
StaticFS: http.FS(static),
}
handler.Serve()
} }

6
config.example.json Normal file
View File

@ -0,0 +1,6 @@
{
"DatabaseHost": "localhost",
"DatabaseUser": "user",
"DatabasePassword": "thisismypassword",
"DatabaseName": "budgeteer"
}

View File

@ -1,21 +1,29 @@
package config package config
import ( import (
"os" "os"
) )
// Config contains all needed configurations. // Config contains all needed configurations
type Config struct { type Config struct {
DatabaseConnection string DatabaseUser string
SessionSecret string DatabaseHost string
} DatabasePassword string
DatabaseName string
// LoadConfig from path. }
func LoadConfig() (*Config, error) {
configuration := Config{ // LoadConfig from path
DatabaseConnection: os.Getenv("BUDGETEER_DB"), func LoadConfig() (*Config, error) {
SessionSecret: os.Getenv("BUDGETEER_SESSION_SECRET"), configuration := Config{
} DatabaseUser: os.Getenv("BUDGETEER_DB_USER"),
DatabaseHost: os.Getenv("BUDGETEER_DB_HOST"),
return &configuration, nil DatabasePassword: os.Getenv("BUDGETEER_DB_PASS"),
} DatabaseName: os.Getenv("BUDGETEER_DB_NAME"),
}
if configuration.DatabaseName == "" {
configuration.DatabaseName = "budgeteer"
}
return &configuration, nil
}

View File

@ -2,7 +2,7 @@ version: '3.7'
services: services:
app: app:
image: hub.javil.eu/budgeteer:latest image: budgeteer:latest
container_name: budgeteer container_name: budgeteer
ports: ports:
- 1323:1323 - 1323:1323

View File

@ -1,17 +0,0 @@
FROM alpine as godeps
RUN apk --no-cache add go
RUN go install github.com/kyleconroy/sqlc/cmd/sqlc@latest
RUN go install github.com/go-task/task/v3/cmd/task@latest
RUN go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest
FROM alpine
RUN apk --no-cache add go nodejs yarn bash curl git git-perl tmux
ADD docker/dev.sh /
RUN yarn global add @vue/cli
ENV PATH="/root/.yarn/bin/:${PATH}"
WORKDIR /src/web
ADD web/package.json web/yarn.lock /src/web/
RUN yarn
WORKDIR /src
COPY --from=godeps /root/go/bin/task /root/go/bin/sqlc /root/go/bin/golangci-lint /usr/local/bin/
CMD /dev.sh

View File

@ -1,7 +0,0 @@
#!/bin/sh
tmux new-session -d -s watch 'cd web; yarn dev'
tmux split-window;
tmux send 'task -w run' ENTER;
tmux split-window;
tmux a;

View File

@ -1,43 +0,0 @@
version: '3.7'
services:
app:
image: hub.javil.eu/budgeteer:dev
container_name: budgeteer
stdin_open: true # docker run -i
tty: true # docker run -t
ports:
- 1323:1323
- 3000:3000
user: '1000'
volumes:
- ~/budgeteer:/src
- ~/.gitconfig:/.gitconfig
- ~/.go:/go
- ~/.cache:/.cache
environment:
BUDGETEER_DB: postgres://budgeteer:budgeteer@db:5432/budgeteer
BUDGETEER_SESSION_SECRET: random string for JWT authorization
depends_on:
- db
db:
image: postgres:14
ports:
- 5432:5432
volumes:
- db:/var/lib/postgresql/data
environment:
POSTGRES_USER: budgeteer
POSTGRES_PASSWORD: budgeteer
POSTGRES_DBE: budgeteer
adminer:
image: adminer
ports:
- 1424:8080
depends_on:
- db
volumes:
db:

2
go.mod
View File

@ -11,8 +11,6 @@ require (
golang.org/x/crypto v0.0.0-20211108221036-ceb1ce70b4fa golang.org/x/crypto v0.0.0-20211108221036-ceb1ce70b4fa
) )
require github.com/DATA-DOG/go-txdb v0.1.5
require ( require (
github.com/gin-contrib/sse v0.1.0 // indirect github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-playground/locales v0.13.0 // indirect github.com/go-playground/locales v0.13.0 // indirect

3
go.sum
View File

@ -4,8 +4,6 @@ github.com/Azure/go-ansiterm v0.0.0-20170929234023-d6e3b3328b78/go.mod h1:LmzpDX
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/ClickHouse/clickhouse-go v1.5.1/go.mod h1:EaI/sW7Azgz9UATzd5ZdZHRUhHgv5+JMS9NSr2smCJI= github.com/ClickHouse/clickhouse-go v1.5.1/go.mod h1:EaI/sW7Azgz9UATzd5ZdZHRUhHgv5+JMS9NSr2smCJI=
github.com/DATA-DOG/go-txdb v0.1.5 h1:kKzz+LYk9qw1+fMyo8/9yDQiNXrJ2HbfX/TY61HkkB4=
github.com/DATA-DOG/go-txdb v0.1.5/go.mod h1:DhAhxMXZpUJVGnT+p9IbzJoRKvlArO2pkHjnGX7o0n0=
github.com/Masterminds/semver/v3 v3.1.1 h1:hLg3sBzpNErnxhQtUy/mmLR2I9foDujNK030IGemrRc= github.com/Masterminds/semver/v3 v3.1.1 h1:hLg3sBzpNErnxhQtUy/mmLR2I9foDujNK030IGemrRc=
github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs= github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs=
github.com/Microsoft/go-winio v0.5.0/go.mod h1:JPGBdM1cNvN/6ISo+n8V5iA4v8pBzdOpzfwIujj1a84= github.com/Microsoft/go-winio v0.5.0/go.mod h1:JPGBdM1cNvN/6ISo+n8V5iA4v8pBzdOpzfwIujj1a84=
@ -74,7 +72,6 @@ github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+
github.com/go-playground/validator/v10 v10.4.1 h1:pH2c5ADXtd66mxoE0Zm9SUhxE20r7aM3F26W0hOn+GE= github.com/go-playground/validator/v10 v10.4.1 h1:pH2c5ADXtd66mxoE0Zm9SUhxE20r7aM3F26W0hOn+GE=
github.com/go-playground/validator/v10 v10.4.1/go.mod h1:nlOn6nFhuKACm19sB/8EGNn9GlaMV7XkbRSipzJ0Ii4= github.com/go-playground/validator/v10 v10.4.1/go.mod h1:nlOn6nFhuKACm19sB/8EGNn9GlaMV7XkbRSipzJ0Ii4=
github.com/go-sql-driver/mysql v1.4.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= github.com/go-sql-driver/mysql v1.4.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE=
github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=

54
http/account.go Normal file
View File

@ -0,0 +1,54 @@
package http
import (
"net/http"
"git.javil.eu/jacob1123/budgeteer/postgres"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
type AccountData struct {
AlwaysNeededData
Account *postgres.Account
Categories []postgres.GetCategoriesRow
Transactions []postgres.GetTransactionsForAccountRow
}
func (h *Handler) account(c *gin.Context) {
data := c.MustGet("data").(AlwaysNeededData)
accountID := c.Param("accountid")
accountUUID, err := uuid.Parse(accountID)
if err != nil {
c.Redirect(http.StatusTemporaryRedirect, "/login")
return
}
account, err := h.Service.GetAccount(c.Request.Context(), accountUUID)
if err != nil {
c.AbortWithError(http.StatusNotFound, err)
return
}
categories, err := h.Service.GetCategories(c.Request.Context(), data.Budget.ID)
if err != nil {
c.AbortWithError(http.StatusNotFound, err)
return
}
transactions, err := h.Service.GetTransactionsForAccount(c.Request.Context(), accountUUID)
if err != nil {
c.AbortWithError(http.StatusNotFound, err)
return
}
d := AccountData{
data,
&account,
categories,
transactions,
}
c.HTML(http.StatusOK, "account.html", d)
}

19
http/accounts.go Normal file
View File

@ -0,0 +1,19 @@
package http
import (
"net/http"
"github.com/gin-gonic/gin"
)
type AccountsData struct {
AlwaysNeededData
}
func (h *Handler) accounts(c *gin.Context) {
d := AccountsData{
c.MustGet("data").(AlwaysNeededData),
}
c.HTML(http.StatusOK, "accounts.html", d)
}

View File

@ -1,4 +1,4 @@
package server package http
import ( import (
"fmt" "fmt"
@ -9,7 +9,18 @@ import (
"github.com/pressly/goose/v3" "github.com/pressly/goose/v3"
) )
type AdminData struct {
}
func (h *Handler) admin(c *gin.Context) {
d := AdminData{}
c.HTML(http.StatusOK, "admin.html", d)
}
func (h *Handler) clearDatabase(c *gin.Context) { func (h *Handler) clearDatabase(c *gin.Context) {
d := AdminData{}
if err := goose.Reset(h.Service.DB, "schema"); err != nil { if err := goose.Reset(h.Service.DB, "schema"); err != nil {
c.AbortWithError(http.StatusInternalServerError, err) c.AbortWithError(http.StatusInternalServerError, err)
} }
@ -17,26 +28,30 @@ func (h *Handler) clearDatabase(c *gin.Context) {
if err := goose.Up(h.Service.DB, "schema"); err != nil { if err := goose.Up(h.Service.DB, "schema"); err != nil {
c.AbortWithError(http.StatusInternalServerError, err) c.AbortWithError(http.StatusInternalServerError, err)
} }
c.HTML(http.StatusOK, "admin.html", d)
} }
func (h *Handler) deleteBudget(c *gin.Context) { type SettingsData struct {
AlwaysNeededData
}
func (h *Handler) settings(c *gin.Context) {
d := SettingsData{
c.MustGet("data").(AlwaysNeededData),
}
c.HTML(http.StatusOK, "settings.html", d)
}
func (h *Handler) clearBudget(c *gin.Context) {
budgetID := c.Param("budgetid") budgetID := c.Param("budgetid")
budgetUUID, err := uuid.Parse(budgetID) budgetUUID, err := uuid.Parse(budgetID)
if err != nil { if err != nil {
c.AbortWithStatus(http.StatusBadRequest) c.Redirect(http.StatusTemporaryRedirect, "/login")
return return
} }
h.clearBudgetData(c, budgetUUID)
err = h.Service.DeleteBudget(c.Request.Context(), budgetUUID)
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
}
func (h *Handler) clearBudgetData(c *gin.Context, budgetUUID uuid.UUID) {
rows, err := h.Service.DeleteAllAssignments(c.Request.Context(), budgetUUID) rows, err := h.Service.DeleteAllAssignments(c.Request.Context(), budgetUUID)
if err != nil { if err != nil {
c.AbortWithError(http.StatusInternalServerError, err) c.AbortWithError(http.StatusInternalServerError, err)
@ -54,17 +69,6 @@ func (h *Handler) clearBudgetData(c *gin.Context, budgetUUID uuid.UUID) {
fmt.Printf("Deleted %d transactions\n", rows) fmt.Printf("Deleted %d transactions\n", rows)
} }
func (h *Handler) clearBudget(c *gin.Context) {
budgetID := c.Param("budgetid")
budgetUUID, err := uuid.Parse(budgetID)
if err != nil {
c.AbortWithStatus(http.StatusBadRequest)
return
}
h.clearBudgetData(c, budgetUUID)
}
func (h *Handler) cleanNegativeBudget(c *gin.Context) { func (h *Handler) cleanNegativeBudget(c *gin.Context) {
/*budgetID := c.Param("budgetid") /*budgetID := c.Param("budgetid")
budgetUUID, err := uuid.Parse(budgetID) budgetUUID, err := uuid.Parse(budgetID)
@ -109,4 +113,5 @@ func (h *Handler) cleanNegativeBudget(c *gin.Context) {
break break
} }
}*/ }*/
} }

View File

@ -0,0 +1,57 @@
package http
import (
"net/http"
"git.javil.eu/jacob1123/budgeteer/postgres"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
type AlwaysNeededData struct {
Budget postgres.Budget
Accounts []postgres.GetAccountsWithBalanceRow
OnBudgetAccounts []postgres.GetAccountsWithBalanceRow
OffBudgetAccounts []postgres.GetAccountsWithBalanceRow
}
func (h *Handler) getImportantData(c *gin.Context) {
budgetID := c.Param("budgetid")
budgetUUID, err := uuid.Parse(budgetID)
if err != nil {
c.Redirect(http.StatusTemporaryRedirect, "/login")
c.Abort()
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
}
var onBudgetAccounts, offBudgetAccounts []postgres.GetAccountsWithBalanceRow
for _, account := range accounts {
if account.OnBudget {
onBudgetAccounts = append(onBudgetAccounts, account)
} else {
offBudgetAccounts = append(offBudgetAccounts, account)
}
}
base := AlwaysNeededData{
Accounts: accounts,
OnBudgetAccounts: onBudgetAccounts,
OffBudgetAccounts: offBudgetAccounts,
Budget: budget,
}
c.Set("data", base)
c.Next()
}

64
http/budget.go Normal file
View File

@ -0,0 +1,64 @@
package http
import (
"net/http"
"git.javil.eu/jacob1123/budgeteer"
"git.javil.eu/jacob1123/budgeteer/postgres"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
type AllAccountsData struct {
AlwaysNeededData
Account *postgres.Account
Categories []postgres.GetCategoriesRow
Transactions []postgres.GetTransactionsForBudgetRow
}
func (h *Handler) allAccounts(c *gin.Context) {
budgetID := c.Param("budgetid")
budgetUUID, err := uuid.Parse(budgetID)
if err != nil {
c.Redirect(http.StatusTemporaryRedirect, "/login")
return
}
categories, err := h.Service.GetCategories(c.Request.Context(), budgetUUID)
if err != nil {
c.AbortWithError(http.StatusNotFound, err)
return
}
transactions, err := h.Service.GetTransactionsForBudget(c.Request.Context(), budgetUUID)
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
d := AllAccountsData{
c.MustGet("data").(AlwaysNeededData),
&postgres.Account{
Name: "All accounts",
},
categories,
transactions,
}
c.HTML(http.StatusOK, "account.html", d)
}
func (h *Handler) newBudget(c *gin.Context) {
budgetName, succ := c.GetPostForm("name")
if !succ {
c.AbortWithStatus(http.StatusNotAcceptable)
return
}
userID := c.MustGet("token").(budgeteer.Token).GetID()
_, err := h.Service.NewBudget(c.Request.Context(), budgetName, userID)
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
}

182
http/budgeting.go Normal file
View File

@ -0,0 +1,182 @@
package http
import (
"fmt"
"net/http"
"strconv"
"time"
"git.javil.eu/jacob1123/budgeteer/postgres"
"github.com/gin-gonic/gin"
)
type BudgetingData struct {
AlwaysNeededData
Categories []CategoryWithBalance
AvailableBalance float64
Date time.Time
Next time.Time
Previous time.Time
}
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 float64
AvailableLastMonth float64
Activity float64
Assigned float64
}
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) budgeting(c *gin.Context) {
alwaysNeededData := c.MustGet("data").(AlwaysNeededData)
budgetUUID := alwaysNeededData.Budget.ID
firstOfMonth, err := getDate(c)
if err != nil {
c.Redirect(http.StatusTemporaryRedirect, "/budget/"+budgetUUID.String())
return
}
firstOfNextMonth := firstOfMonth.AddDate(0, 1, 0)
firstOfPreviousMonth := firstOfMonth.AddDate(0, -1, 0)
d := BudgetingData{
AlwaysNeededData: alwaysNeededData,
Date: firstOfMonth,
Next: firstOfNextMonth,
Previous: firstOfPreviousMonth,
}
categories, err := h.Service.GetCategories(c.Request.Context(), budgetUUID)
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, alwaysNeededData.Budget, firstOfNextMonth, firstOfMonth, categories, cumultativeBalances)
if err != nil {
return
}
d.Categories = categoriesWithBalance
data := c.MustGet("data").(AlwaysNeededData)
var availableBalance float64 = 0
for _, cat := range categories {
if cat.ID != data.Budget.IncomeCategoryID {
continue
}
availableBalance = moneyUsed
for _, bal := range cumultativeBalances {
if bal.CategoryID != cat.ID {
continue
}
if !bal.Date.Before(firstOfNextMonth) {
continue
}
availableBalance += bal.Transactions.GetFloat64()
}
}
d.AvailableBalance = availableBalance
c.HTML(http.StatusOK, "budgeting.html", d)
}
func (h *Handler) calculateBalances(c *gin.Context, budget postgres.Budget, firstOfNextMonth time.Time, firstOfMonth time.Time, categories []postgres.GetCategoriesRow, cumultativeBalances []postgres.GetCumultativeBalancesRow) ([]CategoryWithBalance, float64, error) {
categoriesWithBalance := []CategoryWithBalance{}
hiddenCategory := CategoryWithBalance{
GetCategoriesRow: &postgres.GetCategoriesRow{
Name: "",
Group: "Hidden Categories",
},
}
var moneyUsed float64 = 0
for i := range categories {
cat := &categories[i]
categoryWithBalance := CategoryWithBalance{
GetCategoriesRow: cat,
}
for _, bal := range cumultativeBalances {
if bal.CategoryID != cat.ID {
continue
}
if !bal.Date.Before(firstOfNextMonth) {
continue
}
moneyUsed -= bal.Assignments.GetFloat64()
categoryWithBalance.Available += bal.Assignments.GetFloat64()
categoryWithBalance.Available += bal.Transactions.GetFloat64()
if categoryWithBalance.Available < 0 && bal.Date.Before(firstOfMonth) {
moneyUsed += categoryWithBalance.Available
categoryWithBalance.Available = 0
}
if bal.Date.Before(firstOfMonth) {
categoryWithBalance.AvailableLastMonth = categoryWithBalance.Available
} else if bal.Date.Before(firstOfNextMonth) {
categoryWithBalance.Activity = bal.Transactions.GetFloat64()
categoryWithBalance.Assigned = bal.Assignments.GetFloat64()
}
}
// do not show hidden categories
if cat.Group == "Hidden Categories" {
hiddenCategory.Available += categoryWithBalance.Available
hiddenCategory.AvailableLastMonth += categoryWithBalance.AvailableLastMonth
hiddenCategory.Activity += categoryWithBalance.Activity
hiddenCategory.Assigned += 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
@ -17,7 +18,7 @@ func (h *Handler) dashboard(c *gin.Context) {
d := DashboardData{ d := DashboardData{
Budgets: budgets, Budgets: budgets,
} }
c.JSON(http.StatusOK, d) c.HTML(http.StatusOK, "dashboard.html", d)
} }
type DashboardData struct { type DashboardData struct {

101
http/http.go Normal file
View File

@ -0,0 +1,101 @@
package http
import (
"io/fs"
"net/http"
"strings"
"time"
"git.javil.eu/jacob1123/budgeteer"
"git.javil.eu/jacob1123/budgeteer/bcrypt"
"git.javil.eu/jacob1123/budgeteer/postgres"
"git.javil.eu/jacob1123/budgeteer/web"
"github.com/gin-gonic/gin"
)
// Handler handles incoming requests
type Handler struct {
Service *postgres.Database
TokenVerifier budgeteer.TokenVerifier
CredentialsVerifier *bcrypt.Verifier
}
const (
expiration = 72
authCookie = "authentication"
)
// Serve starts the HTTP Server
func (h *Handler) Serve() {
router := gin.Default()
router.FuncMap["now"] = time.Now
templates, err := NewTemplates(router.FuncMap)
if err != nil {
panic(err)
}
router.HTMLRender = templates
static, err := fs.Sub(web.Static, "static")
if err != nil {
panic("couldn't open static files")
}
router.Use(enableCachingForStaticFiles())
router.StaticFS("/static", http.FS(static))
router.GET("/", func(c *gin.Context) { c.HTML(http.StatusOK, "index.html", nil) })
router.GET("/login", h.login)
router.GET("/register", h.register)
withLogin := router.Group("")
withLogin.Use(h.verifyLoginWithRedirect)
withLogin.GET("/dashboard", h.dashboard)
withLogin.GET("/admin", h.admin)
withLogin.GET("/admin/clear-database", h.clearDatabase)
withBudget := router.Group("")
withBudget.Use(h.verifyLoginWithRedirect)
withBudget.Use(h.getImportantData)
withBudget.GET("/budget/:budgetid", h.budgeting)
withBudget.GET("/budget/:budgetid/:year/:month", h.budgeting)
withBudget.GET("/budget/:budgetid/all-accounts", h.allAccounts)
withBudget.GET("/budget/:budgetid/accounts", h.accounts)
withBudget.GET("/budget/:budgetid/account/:accountid", h.account)
withBudget.GET("/budget/:budgetid/settings", h.settings)
withBudget.GET("/budget/:budgetid/settings/clear", h.clearBudget)
withBudget.GET("/budget/:budgetid/settings/clean-negative", h.cleanNegativeBudget)
withBudget.GET("/budget/:budgetid/transaction/:transactionid", h.transaction)
api := router.Group("/api/v1")
unauthenticated := api.Group("/user")
unauthenticated.GET("/login", func(c *gin.Context) { c.Redirect(http.StatusPermanentRedirect, "/login") })
unauthenticated.POST("/login", h.loginPost)
unauthenticated.POST("/register", h.registerPost)
authenticated := api.Group("")
authenticated.Use(h.verifyLoginWithRedirect)
user := authenticated.Group("/user")
user.GET("/logout", logout)
budget := authenticated.Group("/budget")
budget.POST("/new", h.newBudget)
transaction := authenticated.Group("/transaction")
transaction.POST("/new", h.newTransaction)
transaction.POST("/:transactionid", h.newTransaction)
transaction.POST("/import/ynab", h.importYNAB)
router.Run(":1323")
}
func enableCachingForStaticFiles() gin.HandlerFunc {
return func(c *gin.Context) {
if strings.HasPrefix(c.Request.RequestURI, "/static/") {
c.Header("Cache-Control", "max-age=86400")
}
}
}

122
http/session.go Normal file
View File

@ -0,0 +1,122 @@
package http
import (
"context"
"fmt"
"net/http"
"time"
"git.javil.eu/jacob1123/budgeteer"
"git.javil.eu/jacob1123/budgeteer/postgres"
"github.com/gin-gonic/gin"
)
func (h *Handler) verifyLogin(c *gin.Context) (budgeteer.Token, error) {
tokenString, err := c.Cookie(authCookie)
if err != nil {
return nil, fmt.Errorf("get cookie: %w", err)
}
token, err := h.TokenVerifier.VerifyToken(tokenString)
if err != nil {
c.SetCookie(authCookie, "", -1, "", "", false, false)
return nil, fmt.Errorf("verify token '%s': %w", tokenString, err)
}
return token, nil
}
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()
}
func (h *Handler) login(c *gin.Context) {
if _, err := h.verifyLogin(c); err == nil {
c.Redirect(http.StatusTemporaryRedirect, "/dashboard")
return
}
c.HTML(http.StatusOK, "login.html", nil)
}
func (h *Handler) register(c *gin.Context) {
if _, err := h.verifyLogin(c); err == nil {
c.Redirect(http.StatusTemporaryRedirect, "/dashboard")
return
}
c.HTML(http.StatusOK, "register.html", nil)
}
func logout(c *gin.Context) {
clearLogin(c)
}
func clearLogin(c *gin.Context) {
c.SetCookie(authCookie, "", -1, "", "", false, true)
}
func (h *Handler) loginPost(c *gin.Context) {
username, _ := c.GetPostForm("username")
password, _ := c.GetPostForm("password")
user, err := h.Service.GetUserByUsername(c.Request.Context(), username)
if err != nil {
c.AbortWithError(http.StatusUnauthorized, err)
return
}
if err = h.CredentialsVerifier.Verify(password, user.Password); err != nil {
c.AbortWithError(http.StatusUnauthorized, err)
return
}
t, err := h.TokenVerifier.CreateToken(&user)
if err != nil {
c.AbortWithError(http.StatusUnauthorized, err)
}
go h.Service.UpdateLastLogin(context.Background(), user.ID)
maxAge := (int)((expiration * time.Hour).Seconds())
c.SetCookie(authCookie, t, maxAge, "", "", false, true)
c.JSON(http.StatusOK, map[string]string{
"token": t,
})
}
func (h *Handler) registerPost(c *gin.Context) {
email, _ := c.GetPostForm("email")
password, _ := c.GetPostForm("password")
name, _ := c.GetPostForm("name")
_, err := h.Service.GetUserByUsername(c.Request.Context(), email)
if err == nil {
c.AbortWithStatus(http.StatusUnauthorized)
return
}
hash, err := h.CredentialsVerifier.Hash(password)
if err != nil {
c.AbortWithError(http.StatusUnauthorized, err)
return
}
createUser := postgres.CreateUserParams{
Name: name,
Password: hash,
Email: email,
}
_, err = h.Service.CreateUser(c.Request.Context(), createUser)
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
}
}

45
http/templates.go Normal file
View File

@ -0,0 +1,45 @@
package http
import (
"fmt"
"html/template"
"io/fs"
"git.javil.eu/jacob1123/budgeteer/web"
"github.com/gin-gonic/gin/render"
)
type Templates struct {
templates map[string]*template.Template
}
func NewTemplates(funcMap template.FuncMap) (*Templates, error) {
templates, err := fs.Glob(web.Templates, "*.tpl")
if err != nil {
return nil, fmt.Errorf("glob: %w", err)
}
result := &Templates{
templates: make(map[string]*template.Template, 0),
}
pages, err := fs.Glob(web.Templates, "*.html")
for _, page := range pages {
allTemplates := append(templates, page)
tpl, err := template.New(page).Funcs(funcMap).ParseFS(web.Templates, allTemplates...)
fmt.Printf("page: %s, templates: %v\n", page, templates)
if err != nil {
return nil, err
}
result.templates[page] = tpl
}
return result, nil
}
func (tpl *Templates) Instance(name string, obj interface{}) render.Render {
return render.HTML{
Template: tpl.templates[name],
Name: name,
Data: obj,
}
}

62
http/transaction-edit.go Normal file
View File

@ -0,0 +1,62 @@
package http
import (
"net/http"
"git.javil.eu/jacob1123/budgeteer/postgres"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
type TransactionData struct {
AlwaysNeededData
Transaction *postgres.Transaction
Account *postgres.Account
Categories []postgres.GetCategoriesRow
Payees []postgres.Payee
}
func (h *Handler) transaction(c *gin.Context) {
data := c.MustGet("data").(AlwaysNeededData)
transactionID := c.Param("transactionid")
transactionUUID, err := uuid.Parse(transactionID)
if err != nil {
c.Redirect(http.StatusTemporaryRedirect, "/login")
return
}
transaction, err := h.Service.GetTransaction(c.Request.Context(), transactionUUID)
if err != nil {
c.AbortWithError(http.StatusNotFound, err)
return
}
account, err := h.Service.GetAccount(c.Request.Context(), transaction.AccountID)
if err != nil {
c.AbortWithError(http.StatusNotFound, err)
return
}
categories, err := h.Service.GetCategories(c.Request.Context(), data.Budget.ID)
if err != nil {
c.AbortWithError(http.StatusNotFound, err)
return
}
payees, err := h.Service.GetPayees(c.Request.Context(), data.Budget.ID)
if err != nil {
c.AbortWithError(http.StatusNotFound, err)
return
}
d := TransactionData{
data,
&transaction,
&account,
categories,
payees,
}
c.HTML(http.StatusOK, "transaction.html", d)
}

98
http/transaction.go Normal file
View File

@ -0,0 +1,98 @@
package http
import (
"fmt"
"net/http"
"time"
"git.javil.eu/jacob1123/budgeteer/postgres"
"github.com/gin-gonic/gin"
)
func (h *Handler) newTransaction(c *gin.Context) {
transactionMemo, _ := c.GetPostForm("memo")
transactionAccountID, err := getUUID(c, "account_id")
if err != nil {
c.AbortWithError(http.StatusNotAcceptable, fmt.Errorf("account_id: %w", err))
return
}
transactionCategoryID, err := getNullUUIDFromForm(c, "category_id")
if err != nil {
c.AbortWithError(http.StatusNotAcceptable, fmt.Errorf("category_id: %w", err))
return
}
transactionPayeeID, err := getNullUUIDFromForm(c, "payee_id")
if err != nil {
c.AbortWithError(http.StatusNotAcceptable, fmt.Errorf("payee_id: %w", err))
return
}
transactionDate, succ := c.GetPostForm("date")
if !succ {
c.AbortWithError(http.StatusNotAcceptable, fmt.Errorf("date missing"))
return
}
transactionDateValue, err := time.Parse("2006-01-02", transactionDate)
if err != nil {
c.AbortWithError(http.StatusNotAcceptable, fmt.Errorf("date is not a valid date"))
return
}
transactionAmount, succ := c.GetPostForm("amount")
if !succ {
c.AbortWithError(http.StatusNotAcceptable, fmt.Errorf("amount missing"))
return
}
amount := postgres.Numeric{}
amount.Set(transactionAmount)
transactionUUID, err := getNullUUIDFromParam(c, "transactionid")
if err != nil {
c.AbortWithError(http.StatusInternalServerError, fmt.Errorf("parse transaction id: %w", err))
return
}
if !transactionUUID.Valid {
new := postgres.CreateTransactionParams{
Memo: transactionMemo,
Date: transactionDateValue,
Amount: amount,
AccountID: transactionAccountID,
PayeeID: transactionPayeeID,
CategoryID: transactionCategoryID,
}
_, err = h.Service.CreateTransaction(c.Request.Context(), new)
if err != nil {
c.AbortWithError(http.StatusInternalServerError, fmt.Errorf("create transaction: %w", err))
}
return
}
_, delete := c.GetPostForm("delete")
if delete {
err = h.Service.DeleteTransaction(c.Request.Context(), transactionUUID.UUID)
if err != nil {
c.AbortWithError(http.StatusInternalServerError, fmt.Errorf("delete transaction: %w", err))
}
return
}
update := postgres.UpdateTransactionParams{
ID: transactionUUID.UUID,
Memo: transactionMemo,
Date: transactionDateValue,
Amount: amount,
AccountID: transactionAccountID,
PayeeID: transactionPayeeID,
CategoryID: transactionCategoryID,
}
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.GetPostForm("budget_id")
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
}
}

105
jwt/login.go Normal file
View File

@ -0,0 +1,105 @@
package jwt
import (
"fmt"
"time"
"git.javil.eu/jacob1123/budgeteer"
"git.javil.eu/jacob1123/budgeteer/postgres"
"github.com/dgrijalva/jwt-go"
"github.com/google/uuid"
)
// TokenVerifier verifies Tokens
type TokenVerifier struct {
}
// Token contains everything to authenticate a user
type Token struct {
username string
name string
expiry float64
id uuid.UUID
}
const (
expiration = 72
secret = "uditapbzuditagscwxuqdflgzpbu´ßiaefnlmzeßtrubiadern"
)
// CreateToken creates a new token from username and name
func (tv *TokenVerifier) CreateToken(user *postgres.User) (string, error) {
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"usr": user.Email,
"name": user.Name,
"exp": time.Now().Add(time.Hour * expiration).Unix(),
"id": user.ID,
})
// Generate encoded token and send it as response.
t, err := token.SignedString([]byte(secret))
if err != nil {
return "", err
}
return t, nil
}
// VerifyToken verifys a given string-token
func (tv *TokenVerifier) VerifyToken(tokenString string) (budgeteer.Token, error) {
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("Unexpected signing method: %v", token.Header["alg"])
}
return []byte(secret), nil
})
if err != nil {
return nil, fmt.Errorf("parse jwt: %w", err)
}
claims, err := verifyToken(token)
if err != nil {
return nil, fmt.Errorf("verify jwt: %w", err)
}
tkn := &Token{
username: claims["usr"].(string),
name: claims["name"].(string),
expiry: claims["exp"].(float64),
id: uuid.MustParse(claims["id"].(string)),
}
return tkn, nil
}
func verifyToken(token *jwt.Token) (jwt.MapClaims, error) {
if !token.Valid {
return nil, fmt.Errorf("Token is not valid")
}
claims, ok := token.Claims.(jwt.MapClaims)
if !ok {
return nil, fmt.Errorf("Claims are not of Type MapClaims")
}
if !claims.VerifyExpiresAt(time.Now().Unix(), true) {
return nil, fmt.Errorf("Claims have expired")
}
return claims, nil
}
func (t *Token) GetName() string {
return t.name
}
func (t *Token) GetUsername() string {
return t.username
}
func (t *Token) GetExpiry() float64 {
return t.expiry
}
func (t *Token) GetID() uuid.UUID {
return t.id
}

View File

@ -1,89 +0,0 @@
package jwt
import (
"fmt"
"time"
"git.javil.eu/jacob1123/budgeteer"
"git.javil.eu/jacob1123/budgeteer/postgres"
"github.com/dgrijalva/jwt-go"
"github.com/google/uuid"
)
// TokenVerifier verifies Tokens.
type TokenVerifier struct {
Expiration time.Duration
secret string
}
const DefaultExpiration = time.Hour * time.Duration(72)
func NewTokenVerifier(secret string) (*TokenVerifier, error) {
if secret == "" {
return nil, ErrEmptySecret
}
return &TokenVerifier{
Expiration: DefaultExpiration,
secret: secret,
}, nil
}
var (
ErrUnexpectedSigningMethod = fmt.Errorf("unexpected signing method")
ErrInvalidToken = fmt.Errorf("token is invalid")
ErrTokenExpired = fmt.Errorf("token has expired")
ErrEmptySecret = fmt.Errorf("secret is required")
)
// CreateToken creates a new token from username and name.
func (tv *TokenVerifier) CreateToken(user *postgres.User) (string, error) {
if tv.secret == "" {
return "", ErrEmptySecret
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"usr": user.Email,
"name": user.Name,
"exp": time.Now().Add(tv.Expiration).Unix(),
"id": user.ID,
})
// Generate encoded token and send it as response.
t, err := token.SignedString([]byte(tv.secret))
if err != nil {
return "", fmt.Errorf("create token: %w", err)
}
return t, nil
}
// VerifyToken verifies a given string-token.
func (tv *TokenVerifier) VerifyToken(tokenString string) (budgeteer.Token, error) { //nolint:ireturn
if tv.secret == "" {
return nil, ErrEmptySecret
}
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("method '%v': %w", token.Header["alg"], ErrUnexpectedSigningMethod)
}
return []byte(tv.secret), nil
})
if err != nil {
return nil, fmt.Errorf("parse jwt: %w", err)
}
claims, err := verifyToken(token)
if err != nil {
return nil, fmt.Errorf("verify jwt: %w", err)
}
tkn := &Token{ //nolint:forcetypeassert
username: claims["usr"].(string),
name: claims["name"].(string),
expiry: claims["exp"].(float64),
id: uuid.MustParse(claims["id"].(string)),
}
return tkn, nil
}

View File

@ -1,49 +0,0 @@
package jwt
import (
"time"
"github.com/dgrijalva/jwt-go"
"github.com/google/uuid"
)
// Token contains everything to authenticate a user.
type Token struct {
username string
name string
expiry float64
id uuid.UUID
}
func verifyToken(token *jwt.Token) (jwt.MapClaims, error) {
if !token.Valid {
return nil, ErrInvalidToken
}
claims, ok := token.Claims.(jwt.MapClaims)
if !ok {
return nil, ErrInvalidToken
}
if !claims.VerifyExpiresAt(time.Now().Unix(), true) {
return nil, ErrTokenExpired
}
return claims, nil
}
func (t *Token) GetName() string {
return t.name
}
func (t *Token) GetUsername() string {
return t.username
}
func (t *Token) GetExpiry() float64 {
return t.expiry
}
func (t *Token) GetID() uuid.UUID {
return t.id
}

View File

@ -5,9 +5,7 @@ package postgres
import ( import (
"context" "context"
"time"
"git.javil.eu/jacob1123/budgeteer/postgres/numeric"
"github.com/google/uuid" "github.com/google/uuid"
) )
@ -15,7 +13,7 @@ const createAccount = `-- name: CreateAccount :one
INSERT INTO accounts INSERT INTO accounts
(name, budget_id) (name, budget_id)
VALUES ($1, $2) VALUES ($1, $2)
RETURNING id, budget_id, name, on_budget, is_open RETURNING id, budget_id, name, on_budget
` `
type CreateAccountParams struct { type CreateAccountParams struct {
@ -31,13 +29,12 @@ func (q *Queries) CreateAccount(ctx context.Context, arg CreateAccountParams) (A
&i.BudgetID, &i.BudgetID,
&i.Name, &i.Name,
&i.OnBudget, &i.OnBudget,
&i.IsOpen,
) )
return i, err return i, err
} }
const getAccount = `-- name: GetAccount :one const getAccount = `-- name: GetAccount :one
SELECT accounts.id, accounts.budget_id, accounts.name, accounts.on_budget, accounts.is_open FROM accounts SELECT accounts.id, accounts.budget_id, accounts.name, accounts.on_budget FROM accounts
WHERE accounts.id = $1 WHERE accounts.id = $1
` `
@ -49,15 +46,13 @@ func (q *Queries) GetAccount(ctx context.Context, id uuid.UUID) (Account, error)
&i.BudgetID, &i.BudgetID,
&i.Name, &i.Name,
&i.OnBudget, &i.OnBudget,
&i.IsOpen,
) )
return i, err return i, err
} }
const getAccounts = `-- name: GetAccounts :many const getAccounts = `-- name: GetAccounts :many
SELECT accounts.id, accounts.budget_id, accounts.name, accounts.on_budget, accounts.is_open FROM accounts SELECT accounts.id, accounts.budget_id, accounts.name, accounts.on_budget FROM accounts
WHERE accounts.budget_id = $1 WHERE accounts.budget_id = $1
AND accounts.is_open = TRUE
ORDER BY accounts.name ORDER BY accounts.name
` `
@ -75,7 +70,6 @@ func (q *Queries) GetAccounts(ctx context.Context, budgetID uuid.UUID) ([]Accoun
&i.BudgetID, &i.BudgetID,
&i.Name, &i.Name,
&i.OnBudget, &i.OnBudget,
&i.IsOpen,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@ -91,26 +85,20 @@ func (q *Queries) GetAccounts(ctx context.Context, budgetID uuid.UUID) ([]Accoun
} }
const getAccountsWithBalance = `-- name: GetAccountsWithBalance :many const getAccountsWithBalance = `-- name: GetAccountsWithBalance :many
SELECT accounts.id, accounts.name, accounts.on_budget, accounts.is_open, SELECT accounts.id, accounts.name, accounts.on_budget, SUM(transactions.amount)::decimal(12,2) as balance
(SELECT MAX(transactions.date) FROM transactions WHERE transactions.account_id = accounts.id AND transactions.status = 'Reconciled')::date as last_reconciled,
(SELECT SUM(transactions.amount) FROM transactions WHERE transactions.account_id = accounts.id AND transactions.date < NOW())::decimal(12,2) as working_balance,
(SELECT SUM(transactions.amount) FROM transactions WHERE transactions.account_id = accounts.id AND transactions.date < NOW() AND transactions.status IN ('Cleared', 'Reconciled'))::decimal(12,2) as cleared_balance,
(SELECT SUM(transactions.amount) FROM transactions WHERE transactions.account_id = accounts.id AND transactions.date < NOW() AND transactions.status = 'Reconciled')::decimal(12,2) as reconciled_balance
FROM accounts FROM accounts
LEFT JOIN transactions ON transactions.account_id = accounts.id
WHERE accounts.budget_id = $1 WHERE accounts.budget_id = $1
AND accounts.is_open = TRUE AND transactions.date < NOW()
GROUP BY accounts.id, accounts.name
ORDER BY accounts.name ORDER BY accounts.name
` `
type GetAccountsWithBalanceRow struct { type GetAccountsWithBalanceRow struct {
ID uuid.UUID ID uuid.UUID
Name string Name string
OnBudget bool OnBudget bool
IsOpen bool Balance Numeric
LastReconciled time.Time
WorkingBalance numeric.Numeric
ClearedBalance numeric.Numeric
ReconciledBalance numeric.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) {
@ -126,11 +114,7 @@ func (q *Queries) GetAccountsWithBalance(ctx context.Context, budgetID uuid.UUID
&i.ID, &i.ID,
&i.Name, &i.Name,
&i.OnBudget, &i.OnBudget,
&i.IsOpen, &i.Balance,
&i.LastReconciled,
&i.WorkingBalance,
&i.ClearedBalance,
&i.ReconciledBalance,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@ -144,85 +128,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.is_open = TRUE
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,
is_open = $3
WHERE accounts.id = $4
RETURNING id, budget_id, name, on_budget, is_open
`
type UpdateAccountParams struct {
Name string
OnBudget bool
IsOpen 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.IsOpen,
arg.ID,
)
var i Account
err := row.Scan(
&i.ID,
&i.BudgetID,
&i.Name,
&i.OnBudget,
&i.IsOpen,
)
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

@ -34,15 +34,6 @@ func (q *Queries) CreateBudget(ctx context.Context, arg CreateBudgetParams) (Bud
return i, err return i, err
} }
const deleteBudget = `-- name: DeleteBudget :exec
DELETE FROM budgets WHERE id = $1
`
func (q *Queries) DeleteBudget(ctx context.Context, id uuid.UUID) error {
_, err := q.db.ExecContext(ctx, deleteBudget, id)
return err
}
const getBudget = `-- name: GetBudget :one const getBudget = `-- name: GetBudget :one
SELECT id, name, last_modification, income_category_id FROM budgets SELECT id, name, last_modification, income_category_id FROM budgets
WHERE id = $1 WHERE id = $1

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

@ -116,46 +116,3 @@ func (q *Queries) GetCategoryGroups(ctx context.Context, budgetID uuid.UUID) ([]
} }
return items, nil return items, nil
} }
const searchCategories = `-- name: SearchCategories :many
SELECT CONCAT(category_groups.name, ' : ', categories.name) as name, categories.id, 'category' as type
FROM categories
INNER JOIN category_groups ON categories.category_group_id = category_groups.id
WHERE category_groups.budget_id = $1
AND categories.name LIKE $2
ORDER BY category_groups.name, categories.name
`
type SearchCategoriesParams struct {
BudgetID uuid.UUID
Search string
}
type SearchCategoriesRow struct {
Name interface{}
ID uuid.UUID
Type interface{}
}
func (q *Queries) SearchCategories(ctx context.Context, arg SearchCategoriesParams) ([]SearchCategoriesRow, error) {
rows, err := q.db.QueryContext(ctx, searchCategories, arg.BudgetID, arg.Search)
if err != nil {
return nil, err
}
defer rows.Close()
var items []SearchCategoriesRow
for rows.Next() {
var i SearchCategoriesRow
if err := rows.Scan(&i.Name, &i.ID, &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
}

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,9 +17,10 @@ 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(server string, user string, password string, database string) (*Database, error) {
conn, err := sql.Open(typ, connString) connString := fmt.Sprintf("postgres://%s:%s@%s/%s", user, password, server, database)
conn, err := sql.Open("pgx", connString)
if err != nil { if err != nil {
return nil, fmt.Errorf("open connection: %w", err) return nil, fmt.Errorf("open connection: %w", 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"
) )
@ -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

@ -4,39 +4,16 @@ package postgres
import ( import (
"database/sql" "database/sql"
"fmt"
"time" "time"
"git.javil.eu/jacob1123/budgeteer/postgres/numeric"
"github.com/google/uuid" "github.com/google/uuid"
) )
type TransactionStatus string
const (
TransactionStatusReconciled TransactionStatus = "Reconciled"
TransactionStatusCleared TransactionStatus = "Cleared"
TransactionStatusUncleared TransactionStatus = "Uncleared"
)
func (e *TransactionStatus) Scan(src interface{}) error {
switch s := src.(type) {
case []byte:
*e = TransactionStatus(s)
case string:
*e = TransactionStatus(s)
default:
return fmt.Errorf("unsupported scan type for TransactionStatus: %T", src)
}
return nil
}
type Account struct { type Account struct {
ID uuid.UUID ID uuid.UUID
BudgetID uuid.UUID BudgetID uuid.UUID
Name string Name string
OnBudget bool OnBudget bool
IsOpen bool
} }
type Assignment struct { type Assignment struct {
@ -44,7 +21,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 {
@ -73,24 +50,6 @@ type CategoryGroup struct {
Name string Name string
} }
type DisplayTransaction 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
BudgetID uuid.UUID
AccountID uuid.UUID
}
type Payee struct { type Payee struct {
ID uuid.UUID ID uuid.UUID
BudgetID uuid.UUID BudgetID uuid.UUID
@ -101,12 +60,11 @@ 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
GroupID uuid.NullUUID GroupID uuid.NullUUID
Status TransactionStatus
} }
type TransactionsByMonth struct { type TransactionsByMonth struct {

35
postgres/numeric.go Normal file
View File

@ -0,0 +1,35 @@
package postgres
import "github.com/jackc/pgtype"
type Numeric struct {
pgtype.Numeric
}
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
}

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

@ -56,50 +56,3 @@ func (q *Queries) GetPayees(ctx context.Context, budgetID uuid.UUID) ([]Payee, e
} }
return items, nil return items, nil
} }
const searchPayees = `-- name: SearchPayees :many
SELECT payees.id, payees.budget_id, payees.name, 'payee' as type FROM payees
WHERE payees.budget_id = $1
AND payees.name LIKE $2
ORDER BY payees.name
`
type SearchPayeesParams struct {
BudgetID uuid.UUID
Search string
}
type SearchPayeesRow struct {
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)
if err != nil {
return nil, err
}
defer rows.Close()
var items []SearchPayeesRow
for rows.Next() {
var i SearchPayeesRow
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
}

View File

@ -11,31 +11,13 @@ WHERE accounts.id = $1;
-- name: GetAccounts :many -- name: GetAccounts :many
SELECT accounts.* FROM accounts SELECT accounts.* FROM accounts
WHERE accounts.budget_id = $1 WHERE accounts.budget_id = $1
AND accounts.is_open = TRUE
ORDER BY accounts.name; ORDER BY accounts.name;
-- name: GetAccountsWithBalance :many -- name: GetAccountsWithBalance :many
SELECT accounts.id, accounts.name, accounts.on_budget, accounts.is_open, SELECT accounts.id, accounts.name, accounts.on_budget, SUM(transactions.amount)::decimal(12,2) as balance
(SELECT MAX(transactions.date) FROM transactions WHERE transactions.account_id = accounts.id AND transactions.status = 'Reconciled')::date as last_reconciled,
(SELECT SUM(transactions.amount) FROM transactions WHERE transactions.account_id = accounts.id AND transactions.date < NOW())::decimal(12,2) as working_balance,
(SELECT SUM(transactions.amount) FROM transactions WHERE transactions.account_id = accounts.id AND transactions.date < NOW() AND transactions.status IN ('Cleared', 'Reconciled'))::decimal(12,2) as cleared_balance,
(SELECT SUM(transactions.amount) FROM transactions WHERE transactions.account_id = accounts.id AND transactions.date < NOW() AND transactions.status = 'Reconciled')::decimal(12,2) as reconciled_balance
FROM accounts FROM accounts
LEFT JOIN transactions ON transactions.account_id = accounts.id
WHERE accounts.budget_id = $1 WHERE accounts.budget_id = $1
AND accounts.is_open = TRUE AND transactions.date < NOW()
ORDER BY accounts.name; GROUP BY accounts.id, 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.is_open = TRUE
AND accounts.name LIKE @search
ORDER BY accounts.name;
-- name: UpdateAccount :one
UPDATE accounts
SET name = $1,
on_budget = $2,
is_open = $3
WHERE accounts.id = $4
RETURNING *;

View File

@ -15,11 +15,4 @@ WHERE categories.id = assignments.category_id AND category_groups.budget_id = @b
-- name: GetAssignmentsByMonthAndCategory :many -- name: GetAssignmentsByMonthAndCategory :many
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

@ -31,7 +31,4 @@ FROM (
FROM transactions FROM transactions
INNER JOIN accounts ON accounts.id = transactions.account_id INNER JOIN accounts ON accounts.id = transactions.account_id
WHERE accounts.budget_id = @budget_id WHERE accounts.budget_id = @budget_id
) dates; ) dates;
-- name: DeleteBudget :exec
DELETE FROM budgets WHERE id = $1;

View File

@ -18,13 +18,4 @@ RETURNING *;
SELECT categories.*, category_groups.name as group FROM categories SELECT categories.*, category_groups.name as group 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
ORDER BY category_groups.name, categories.name; ORDER BY category_groups.name, categories.name;
-- name: SearchCategories :many
SELECT CONCAT(category_groups.name, ' : ', categories.name) as name, categories.id, 'category' as type
FROM categories
INNER JOIN category_groups ON categories.category_group_id = category_groups.id
WHERE category_groups.budget_id = @budget_id
AND categories.name LIKE @search
ORDER BY category_groups.name, categories.name;
--ORDER BY levenshtein(payees.name, $2);

View File

@ -7,11 +7,4 @@ RETURNING *;
-- name: GetPayees :many -- name: GetPayees :many
SELECT payees.* FROM payees SELECT payees.* FROM payees
WHERE payees.budget_id = $1 WHERE payees.budget_id = $1
ORDER BY name; ORDER BY name;
-- name: SearchPayees :many
SELECT payees.*, 'payee' as type FROM payees
WHERE payees.budget_id = @budget_id
AND payees.name LIKE @search
ORDER BY payees.name;
--ORDER BY levenshtein(payees.name, $2);

View File

@ -1,40 +1,49 @@
-- name: GetTransaction :one -- name: GetTransaction :one
SELECT * FROM display_transactions SELECT * FROM transactions
WHERE id = $1; WHERE id = $1;
-- name: CreateTransaction :one -- name: CreateTransaction :one
INSERT INTO transactions INSERT INTO transactions
(date, memo, amount, account_id, payee_id, category_id, group_id, status) (date, memo, amount, account_id, payee_id, category_id, group_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8) VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id; RETURNING *;
-- name: UpdateTransaction :exec -- 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;
-- name: SetTransactionReconciled :exec
UPDATE transactions
SET status = 'Reconciled'
WHERE id = $1;
-- 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 t.* SELECT transactions.id, transactions.date, transactions.memo, transactions.amount, transactions.group_id,
FROM display_transactions AS t accounts.name as account, COALESCE(payees.name, '') as payee, COALESCE(category_groups.name, '') as category_group, COALESCE(categories.name, '') as category
WHERE t.budget_id = $1; 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;
-- name: GetTransactionsForAccount :many -- name: GetTransactionsForAccount :many
SELECT t.* SELECT transactions.id, transactions.date, transactions.memo, transactions.amount, transactions.group_id,
FROM display_transactions AS t accounts.name as account, COALESCE(payees.name, '') as payee, COALESCE(category_groups.name, '') as category_group, COALESCE(categories.name, '') as category
WHERE t.account_id = $1 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 transactions.account_id = $1
ORDER BY transactions.date DESC
LIMIT 200; LIMIT 200;
-- name: DeleteAllTransactions :execrows -- name: DeleteAllTransactions :execrows

View File

@ -1,12 +0,0 @@
-- +goose Up
CREATE TYPE transaction_status AS ENUM (
'Reconciled',
'Cleared',
'Uncleared'
);
ALTER TABLE transactions ADD COLUMN status transaction_status NOT NULL DEFAULT 'Uncleared';
-- +goose Down
ALTER TABLE transactions DROP COLUMN status;
DROP TYPE transaction_status;

View File

@ -1,5 +0,0 @@
-- +goose Up
CREATE EXTENSION IF NOT EXISTS "fuzzystrmatch";
-- +goose Down
DROP EXTENSION "fuzzystrmatch";

View File

@ -1,25 +0,0 @@
-- +goose Up
CREATE VIEW display_transactions AS
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,
accounts.budget_id, transactions.account_id
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
ORDER BY transactions.date DESC;
-- +goose Down
DROP VIEW display_transactions;

View File

@ -1,5 +0,0 @@
-- +goose Up
ALTER TABLE accounts ADD COLUMN is_open BOOLEAN NOT NULL DEFAULT TRUE;
-- +goose Down
ALTER TABLE accounts DROP COLUMN is_open;

View File

@ -7,29 +7,27 @@ import (
"context" "context"
"time" "time"
"git.javil.eu/jacob1123/budgeteer/postgres/numeric"
"github.com/google/uuid" "github.com/google/uuid"
) )
const createTransaction = `-- name: CreateTransaction :one const createTransaction = `-- name: CreateTransaction :one
INSERT INTO transactions INSERT INTO transactions
(date, memo, amount, account_id, payee_id, category_id, group_id, status) (date, memo, amount, account_id, payee_id, category_id, group_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8) VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id RETURNING id, date, memo, amount, account_id, category_id, payee_id, group_id
` `
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
GroupID uuid.NullUUID GroupID uuid.NullUUID
Status TransactionStatus
} }
func (q *Queries) CreateTransaction(ctx context.Context, arg CreateTransactionParams) (uuid.UUID, error) { func (q *Queries) CreateTransaction(ctx context.Context, arg CreateTransactionParams) (Transaction, error) {
row := q.db.QueryRowContext(ctx, createTransaction, row := q.db.QueryRowContext(ctx, createTransaction,
arg.Date, arg.Date,
arg.Memo, arg.Memo,
@ -38,11 +36,19 @@ func (q *Queries) CreateTransaction(ctx context.Context, arg CreateTransactionPa
arg.PayeeID, arg.PayeeID,
arg.CategoryID, arg.CategoryID,
arg.GroupID, arg.GroupID,
arg.Status,
) )
var id uuid.UUID var i Transaction
err := row.Scan(&id) err := row.Scan(
return id, err &i.ID,
&i.Date,
&i.Memo,
&i.Amount,
&i.AccountID,
&i.CategoryID,
&i.PayeeID,
&i.GroupID,
)
return i, err
} }
const deleteAllTransactions = `-- name: DeleteAllTransactions :execrows const deleteAllTransactions = `-- name: DeleteAllTransactions :execrows
@ -70,75 +76,23 @@ func (q *Queries) DeleteTransaction(ctx context.Context, id uuid.UUID) error {
return err return err
} }
const getAllTransactionsForBudget = `-- name: GetAllTransactionsForBudget :many
SELECT t.id, t.date, t.memo, t.amount, t.group_id, t.status, t.account, t.payee_id, t.category_id, t.payee, t.category_group, t.category, t.transfer_account, t.budget_id, t.account_id
FROM display_transactions AS t
WHERE t.budget_id = $1
`
func (q *Queries) GetAllTransactionsForBudget(ctx context.Context, budgetID uuid.UUID) ([]DisplayTransaction, error) {
rows, err := q.db.QueryContext(ctx, getAllTransactionsForBudget, budgetID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []DisplayTransaction
for rows.Next() {
var i DisplayTransaction
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,
&i.BudgetID,
&i.AccountID,
); 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, group_id, status, account, payee_id, category_id, payee, category_group, category, transfer_account, budget_id, account_id FROM display_transactions SELECT id, date, memo, amount, account_id, category_id, payee_id, group_id FROM transactions
WHERE id = $1 WHERE id = $1
` `
func (q *Queries) GetTransaction(ctx context.Context, id uuid.UUID) (DisplayTransaction, error) { func (q *Queries) GetTransaction(ctx context.Context, id uuid.UUID) (Transaction, error) {
row := q.db.QueryRowContext(ctx, getTransaction, id) row := q.db.QueryRowContext(ctx, getTransaction, id)
var i DisplayTransaction var i Transaction
err := row.Scan( err := row.Scan(
&i.ID, &i.ID,
&i.Date, &i.Date,
&i.Memo, &i.Memo,
&i.Amount, &i.Amount,
&i.GroupID,
&i.Status,
&i.Account,
&i.PayeeID,
&i.CategoryID,
&i.Payee,
&i.CategoryGroup,
&i.Category,
&i.TransferAccount,
&i.BudgetID,
&i.AccountID, &i.AccountID,
&i.CategoryID,
&i.PayeeID,
&i.GroupID,
) )
return i, err return i, err
} }
@ -178,37 +132,49 @@ func (q *Queries) GetTransactionsByMonthAndCategory(ctx context.Context, budgetI
} }
const getTransactionsForAccount = `-- name: GetTransactionsForAccount :many const getTransactionsForAccount = `-- name: GetTransactionsForAccount :many
SELECT t.id, t.date, t.memo, t.amount, t.group_id, t.status, t.account, t.payee_id, t.category_id, t.payee, t.category_group, t.category, t.transfer_account, t.budget_id, t.account_id SELECT transactions.id, transactions.date, transactions.memo, transactions.amount, transactions.group_id,
FROM display_transactions AS t accounts.name as account, COALESCE(payees.name, '') as payee, COALESCE(category_groups.name, '') as category_group, COALESCE(categories.name, '') as category
WHERE t.account_id = $1 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 transactions.account_id = $1
ORDER BY transactions.date DESC
LIMIT 200 LIMIT 200
` `
func (q *Queries) GetTransactionsForAccount(ctx context.Context, accountID uuid.UUID) ([]DisplayTransaction, error) { type GetTransactionsForAccountRow struct {
ID uuid.UUID
Date time.Time
Memo string
Amount Numeric
GroupID uuid.NullUUID
Account string
Payee string
CategoryGroup string
Category string
}
func (q *Queries) GetTransactionsForAccount(ctx context.Context, accountID uuid.UUID) ([]GetTransactionsForAccountRow, error) {
rows, err := q.db.QueryContext(ctx, getTransactionsForAccount, accountID) rows, err := q.db.QueryContext(ctx, getTransactionsForAccount, accountID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer rows.Close() defer rows.Close()
var items []DisplayTransaction var items []GetTransactionsForAccountRow
for rows.Next() { for rows.Next() {
var i DisplayTransaction var i GetTransactionsForAccountRow
if err := rows.Scan( if err := rows.Scan(
&i.ID, &i.ID,
&i.Date, &i.Date,
&i.Memo, &i.Memo,
&i.Amount, &i.Amount,
&i.GroupID, &i.GroupID,
&i.Status,
&i.Account, &i.Account,
&i.PayeeID,
&i.CategoryID,
&i.Payee, &i.Payee,
&i.CategoryGroup, &i.CategoryGroup,
&i.Category, &i.Category,
&i.TransferAccount,
&i.BudgetID,
&i.AccountID,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@ -223,15 +189,62 @@ func (q *Queries) GetTransactionsForAccount(ctx context.Context, accountID uuid.
return items, nil return items, nil
} }
const setTransactionReconciled = `-- name: SetTransactionReconciled :exec const getTransactionsForBudget = `-- name: GetTransactionsForBudget :many
UPDATE transactions SELECT transactions.id, transactions.date, transactions.memo, transactions.amount, transactions.group_id,
SET status = 'Reconciled' accounts.name as account, COALESCE(payees.name, '') as payee, COALESCE(category_groups.name, '') as category_group, COALESCE(categories.name, '') as category
WHERE id = $1 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
` `
func (q *Queries) SetTransactionReconciled(ctx context.Context, id uuid.UUID) error { type GetTransactionsForBudgetRow struct {
_, err := q.db.ExecContext(ctx, setTransactionReconciled, id) ID uuid.UUID
return err Date time.Time
Memo string
Amount Numeric
GroupID uuid.NullUUID
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.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
@ -239,15 +252,17 @@ 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
@ -258,6 +273,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 DisplayTransaction) []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,179 @@ 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 { transaction := CreateTransactionParams{
CreateTransactionParams
Account *Account
}
func (ynab *YNABImport) GetTransaction(context context.Context, record []string) (NewTransaction, error) {
accountName := record[0]
account, err := ynab.GetAccount(context, accountName)
if err != nil {
return NewTransaction{}, fmt.Errorf("get account %s: %w", accountName, err)
}
// flag := record[1]
dateString := record[2]
date, err := time.Parse("02.01.2006", dateString)
if err != nil {
return NewTransaction{}, fmt.Errorf("parse date %s: %w", dateString, err)
}
categoryGroup, categoryName := record[5], record[6] // also in 4 joined by :
category, err := ynab.GetCategory(context, categoryGroup, categoryName)
if err != nil {
return NewTransaction{}, fmt.Errorf("get category %s/%s: %w", categoryGroup, categoryName, err)
}
memo := record[7]
outflow := record[8]
inflow := record[9]
amount, err := GetAmount(inflow, outflow)
if err != nil {
return NewTransaction{}, fmt.Errorf("parse amount from (%s/%s): %w", inflow, outflow, err)
}
statusEnum := TransactionStatusUncleared
status := record[10]
switch status {
case "Cleared":
statusEnum = TransactionStatusCleared
case "Reconciled":
statusEnum = TransactionStatusReconciled
case "Uncleared":
}
return NewTransaction{
CreateTransactionParams: CreateTransactionParams{
Date: date, Date: date,
Memo: memo, Memo: memo,
AccountID: account.ID, AccountID: account.ID,
CategoryID: category, CategoryID: category,
Amount: amount, Amount: amount,
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)
}
}
//status := record[10]
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 +302,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 +313,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 +322,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 +333,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,73 +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) transactionsForAccount(c *gin.Context) {
accountID := c.Param("accountid")
accountUUID, err := uuid.Parse(accountID)
if err != nil {
c.AbortWithError(http.StatusBadRequest, err)
return
}
account, err := h.Service.GetAccount(c.Request.Context(), accountUUID)
if err != nil {
c.AbortWithError(http.StatusNotFound, err)
return
}
transactions, err := h.Service.GetTransactionsForAccount(c.Request.Context(), accountUUID)
if err != nil {
c.AbortWithError(http.StatusNotFound, err)
return
}
c.JSON(http.StatusOK, TransactionsResponse{account, transactions})
}
type TransactionsResponse struct {
Account postgres.Account
Transactions []postgres.DisplayTransaction
}
type EditAccountRequest struct {
Name string `json:"name"`
OnBudget bool `json:"onBudget"`
IsOpen bool `json:"isOpen"`
}
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,
IsOpen: request.IsOpen,
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,87 +0,0 @@
package server
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"git.javil.eu/jacob1123/budgeteer/bcrypt"
"git.javil.eu/jacob1123/budgeteer/jwt"
"git.javil.eu/jacob1123/budgeteer/postgres"
txdb "github.com/DATA-DOG/go-txdb"
"github.com/gin-gonic/gin"
)
func init() { //nolint:gochecknoinits
txdb.Register("pgtx", "pgx", "postgres://budgeteer_test:budgeteer_test@localhost:5432/budgeteer_test")
}
func TestRegisterUser(t *testing.T) { //nolint:funlen
t.Parallel()
database, err := postgres.Connect("pgtx", "example")
if err != nil {
fmt.Printf("could not connect to db: %s\n", err)
t.Skip()
return
}
tokenVerifier, _ := jwt.NewTokenVerifier("this_is_my_demo_secret_for_unit_tests")
h := Handler{
Service: database,
TokenVerifier: tokenVerifier,
CredentialsVerifier: &bcrypt.Verifier{},
}
recorder := httptest.NewRecorder()
context, engine := gin.CreateTestContext(recorder)
h.LoadRoutes(engine)
t.Run("RegisterUser", func(t *testing.T) {
t.Parallel()
context.Request, err = http.NewRequest(
http.MethodPost,
"/api/v1/user/register",
strings.NewReader(`{"password":"pass","email":"info@example.com","name":"Test"}`))
if err != nil {
t.Errorf("error creating request: %s", err)
return
}
h.registerPost(context)
if recorder.Code != http.StatusOK {
t.Errorf("handler returned wrong status code: got %v want %v", recorder.Code, http.StatusOK)
}
var response LoginResponse
err = json.NewDecoder(recorder.Body).Decode(&response)
if err != nil {
t.Error(err.Error())
t.Error("Error registering")
}
if len(response.Token) == 0 {
t.Error("Did not get a token")
}
})
t.Run("GetTransactions", func(t *testing.T) {
t.Parallel()
context.Request, err = http.NewRequest(http.MethodGet, "/account/accountid/transactions", nil)
if recorder.Code != http.StatusOK {
t.Errorf("handler returned wrong status code: got %v want %v", recorder.Code, http.StatusOK)
}
var response TransactionsResponse
err = json.NewDecoder(recorder.Body).Decode(&response)
if err != nil {
t.Error(err.Error())
t.Error("Error retreiving list of transactions.")
}
if len(response.Transactions) == 0 {
t.Error("Did not get any transactions.")
}
})
}

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,33 +0,0 @@
package server
import (
"net/http"
"github.com/gin-gonic/gin"
)
type newBudgetInformation struct {
Name string `json:"name"`
}
func (h *Handler) newBudget(c *gin.Context) {
var newBudget newBudgetInformation
if err := c.BindJSON(&newBudget); err != nil {
c.AbortWithError(http.StatusNotAcceptable, err)
return
}
if newBudget.Name == "" {
c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{"budget name is required"})
return
}
userID := MustGetToken(c).GetID()
budget, err := h.Service.NewBudget(c.Request.Context(), newBudget.Name, userID)
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
c.JSON(http.StatusOK, budget)
}

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,121 +0,0 @@
package server
import (
"errors"
"io"
"io/fs"
"net/http"
"path"
"strings"
"git.javil.eu/jacob1123/budgeteer"
"git.javil.eu/jacob1123/budgeteer/bcrypt"
"git.javil.eu/jacob1123/budgeteer/postgres"
"github.com/gin-gonic/gin"
)
// Handler handles incoming requests.
type Handler struct {
Service *postgres.Database
TokenVerifier budgeteer.TokenVerifier
CredentialsVerifier *bcrypt.Verifier
StaticFS http.FileSystem
}
// Serve starts the http server.
func (h *Handler) Serve() {
router := gin.Default()
h.LoadRoutes(router)
if err := router.Run(":1323"); err != nil {
panic(err)
}
}
type ErrorResponse struct {
Message string
}
type SuccessResponse struct {
Message string
}
// LoadRoutes initializes all the routes.
func (h *Handler) LoadRoutes(router *gin.Engine) {
router.Use(enableCachingForStaticFiles())
router.NoRoute(h.ServeStatic)
api := router.Group("/api/v1")
anonymous := api.Group("/user")
anonymous.GET("/login", func(c *gin.Context) { c.Redirect(http.StatusPermanentRedirect, "/login") })
anonymous.POST("/login", h.loginPost)
anonymous.POST("/register", h.registerPost)
authenticated := api.Group("")
authenticated.Use(h.verifyLoginWithForbidden)
authenticated.GET("/dashboard", h.dashboard)
authenticated.GET("/account/:accountid/transactions", h.transactionsForAccount)
authenticated.POST("/account/:accountid/reconcile", h.reconcileTransactions)
authenticated.POST("/account/:accountid", h.editAccount)
authenticated.GET("/admin/clear-database", h.clearDatabase)
budget := authenticated.Group("/budget")
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.POST("/new", h.newTransaction)
transaction.POST("/:transactionid", h.newTransaction)
}
func (h *Handler) ServeStatic(c *gin.Context) {
h.ServeStaticFile(c, c.Request.URL.Path)
}
func (h *Handler) ServeStaticFile(c *gin.Context, fullPath string) {
file, err := h.StaticFS.Open(fullPath)
if errors.Is(err, fs.ErrNotExist) {
h.ServeStaticFile(c, path.Join("/", "/index.html"))
return
}
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
stat, err := file.Stat()
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
if stat.IsDir() {
h.ServeStaticFile(c, path.Join(fullPath, "index.html"))
return
}
if file, ok := file.(io.ReadSeeker); ok {
http.ServeContent(c.Writer, c.Request, stat.Name(), stat.ModTime(), file)
} else {
panic("File does not implement ReadSeeker")
}
}
func enableCachingForStaticFiles() gin.HandlerFunc {
return func(c *gin.Context) {
if strings.HasPrefix(c.Request.RequestURI, "/static/") {
c.Header("Cache-Control", "max-age=86400")
}
}
}

View File

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

View File

@ -1,103 +0,0 @@
package server
import (
"database/sql"
"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 ReconcileTransactionsRequest struct {
TransactionIDs []uuid.UUID `json:"transactionIds"`
ReconcilationTransactionAmount string `json:"reconciliationTransactionAmount"`
}
type ReconcileTransactionsResponse struct {
Message string
ReconciliationTransaction *postgres.DisplayTransaction
}
func (h *Handler) reconcileTransactions(c *gin.Context) {
accountID := c.Param("accountid")
accountUUID, err := uuid.Parse(accountID)
if err != nil {
c.AbortWithError(http.StatusBadRequest, err)
return
}
var request ReconcileTransactionsRequest
err = c.BindJSON(&request)
if err != nil {
c.AbortWithError(http.StatusBadRequest, fmt.Errorf("parse request: %w", err))
return
}
var amount numeric.Numeric
err = amount.Set(request.ReconcilationTransactionAmount)
if err != nil {
c.AbortWithError(http.StatusBadRequest, fmt.Errorf("parse request: %w", err))
return
}
tx, err := h.Service.BeginTx(c.Request.Context(), &sql.TxOptions{})
if err != nil {
c.AbortWithError(http.StatusInternalServerError, fmt.Errorf("begin tx: %w", err))
return
}
db := h.Service.WithTx(tx)
for _, transactionID := range request.TransactionIDs {
err := db.SetTransactionReconciled(c.Request.Context(), transactionID)
if err != nil {
c.AbortWithError(http.StatusInternalServerError, fmt.Errorf("update transaction: %w", err))
return
}
}
reconciliationTransaction, err := h.CreateReconcilationTransaction(amount, accountUUID, db, c)
if err != nil {
c.AbortWithError(http.StatusInternalServerError, fmt.Errorf("insert new transaction: %w", err))
return
}
err = tx.Commit()
if err != nil {
c.AbortWithError(http.StatusInternalServerError, fmt.Errorf("commit: %w", err))
return
}
c.JSON(http.StatusOK, ReconcileTransactionsResponse{
Message: fmt.Sprintf("Set status for %d transactions", len(request.TransactionIDs)),
ReconciliationTransaction: reconciliationTransaction,
})
}
func (*Handler) CreateReconcilationTransaction(amount numeric.Numeric, accountUUID uuid.UUID, db *postgres.Queries, c *gin.Context) (*postgres.DisplayTransaction, error) {
if amount.IsZero() {
return nil, nil //nolint: nilnil
}
createTransaction := postgres.CreateTransactionParams{
Date: time.Now(),
Memo: "Reconciliation Transaction",
Amount: amount,
AccountID: accountUUID,
Status: "Reconciled",
}
transactionUUID, err := db.CreateTransaction(c.Request.Context(), createTransaction)
if err != nil {
return nil, fmt.Errorf("insert new transaction: %w", err)
}
transaction, err := db.GetTransaction(c.Request.Context(), transactionUUID)
if err != nil {
return nil, fmt.Errorf("get created transaction: %w", err)
}
return &transaction, nil
}

View File

@ -1,161 +0,0 @@
package server
import (
"context"
"fmt"
"net/http"
"git.javil.eu/jacob1123/budgeteer"
"git.javil.eu/jacob1123/budgeteer/postgres"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
const (
HeaderName = "Authorization"
Bearer = "Bearer "
ParamName = "token"
)
func MustGetToken(c *gin.Context) budgeteer.Token { //nolint:ireturn
token := c.MustGet(ParamName)
if token, ok := token.(budgeteer.Token); ok {
return token
}
panic("Token is not a valid Token")
}
func (h *Handler) verifyLogin(c *gin.Context) (budgeteer.Token, *ErrorResponse) { //nolint:ireturn
tokenString := c.GetHeader(HeaderName)
if len(tokenString) <= len(Bearer) {
return nil, &ErrorResponse{"no authorization header supplied"}
}
tokenString = tokenString[7:]
token, err := h.TokenVerifier.VerifyToken(tokenString)
if err != nil {
return nil, &ErrorResponse{fmt.Sprintf("verify token '%s': %s", tokenString, err)}
}
return token, nil
}
func (h *Handler) verifyLoginWithForbidden(c *gin.Context) {
token, err := h.verifyLogin(c)
if err != nil {
// c.Header("WWW-Authenticate", "Bearer")
c.AbortWithStatusJSON(http.StatusForbidden, err)
return
}
c.Set(ParamName, token)
c.Next()
}
type loginInformation struct {
Password string `json:"password"`
User string `json:"user"`
}
func (h *Handler) loginPost(c *gin.Context) {
var login loginInformation
err := c.BindJSON(&login)
if err != nil {
return
}
user, err := h.Service.GetUserByUsername(c.Request.Context(), login.User)
if err != nil {
c.AbortWithError(http.StatusUnauthorized, err)
return
}
if err = h.CredentialsVerifier.Verify(login.Password, user.Password); err != nil {
c.AbortWithError(http.StatusUnauthorized, err)
return
}
token, err := h.TokenVerifier.CreateToken(&user)
if err != nil {
c.AbortWithError(http.StatusUnauthorized, err)
}
go h.UpdateLastLogin(user.ID)
budgets, err := h.Service.GetBudgetsForUser(c.Request.Context(), user.ID)
if err != nil {
return
}
c.JSON(http.StatusOK, LoginResponse{token, user, budgets})
}
type LoginResponse struct {
Token string
User postgres.User
Budgets []postgres.Budget
}
type registerInformation struct {
Password string `json:"password"`
Email string `json:"email"`
Name string `json:"name"`
}
func (h *Handler) registerPost(c *gin.Context) {
var register registerInformation
err := c.BindJSON(&register)
if err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{"error parsing body"})
return
}
if register.Email == "" || register.Password == "" || register.Name == "" {
c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{"e-mail, password and name are required"})
return
}
_, err = h.Service.GetUserByUsername(c.Request.Context(), register.Email)
if err == nil {
c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{"email is already taken"})
return
}
hash, err := h.CredentialsVerifier.Hash(register.Password)
if err != nil {
c.AbortWithError(http.StatusBadRequest, err)
return
}
createUser := postgres.CreateUserParams{
Name: register.Name,
Password: hash,
Email: register.Email,
}
user, err := h.Service.CreateUser(c.Request.Context(), createUser)
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
}
token, err := h.TokenVerifier.CreateToken(&user)
if err != nil {
c.AbortWithError(http.StatusUnauthorized, err)
}
go h.UpdateLastLogin(user.ID)
budgets, err := h.Service.GetBudgetsForUser(c.Request.Context(), user.ID)
if err != nil {
return
}
c.JSON(http.StatusOK, LoginResponse{token, user, budgets})
}
func (h *Handler) UpdateLastLogin(userID uuid.UUID) {
_, err := h.Service.UpdateLastLogin(context.Background(), userID)
if err != nil {
fmt.Printf("Error updating last login: %s", err)
}
}

View File

@ -1,159 +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" {
groupID, err := h.CreateTransferForOtherAccount(newTransaction, amount, payload, c)
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
newTransaction.GroupID = groupID
} 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
}
transactionUUID, err := h.Service.CreateTransaction(c.Request.Context(), newTransaction)
if err != nil {
c.AbortWithError(http.StatusInternalServerError, fmt.Errorf("create transaction: %w", err))
return
}
transaction, err := h.Service.GetTransaction(c.Request.Context(), transactionUUID)
if err != nil {
c.AbortWithError(http.StatusInternalServerError, fmt.Errorf("get 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))
return
}
transaction, err := h.Service.GetTransaction(c.Request.Context(), transactionUUID)
if err != nil {
c.AbortWithError(http.StatusInternalServerError, fmt.Errorf("get transaction: %w", err))
return
}
c.JSON(http.StatusOK, transaction)
}
func (h *Handler) CreateTransferForOtherAccount(newTransaction postgres.CreateTransactionParams, amount numeric.Numeric, payload NewTransactionPayload, c *gin.Context) (uuid.NullUUID, 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 uuid.NullUUID{}, fmt.Errorf("create transfer transaction: %w", err)
}
return newTransaction.GroupID, 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)

39
web/account.html Normal file
View File

@ -0,0 +1,39 @@
{{template "base" .}}
{{define "title"}}{{.Account.Name}}{{end}}
{{define "new"}}
{{template "transaction-new" .}}
{{end}}
{{define "main"}}
<div class="budget-item">
<a href="#newtransactionmodal" data-bs-toggle="modal" data-bs-target="#newtransactionmodal">New Transaction</a>
<span class="time"></span>
</div>
<table class="container col-lg-12" id="content">
{{range .Transactions}}
<tr class="{{if .Date.After now}}future{{end}}">
<td>{{.Date.Format "02.01.2006"}}</td>
<td>
{{.Account}}
</td>
<td>
{{.Payee}}
</td>
<td>
{{if .CategoryGroup}}
{{.CategoryGroup}} : {{.Category}}
{{end}}
</td>
<td>
{{if .GroupID.Valid}}☀{{end}}
</td>
<td>
<a href="/budget/{{$.Budget.ID}}/transaction/{{.ID}}">{{.Memo}}</a>
</td>
{{template "amount-cell" .Amount}}
</tr>
{{end}}
</table>
{{end}}

17
web/accounts.html Normal file
View File

@ -0,0 +1,17 @@
{{define "title"}}
Accounts
{{end}}
{{define "new"}}
{{end}}
{{template "base" .}}
{{define "main"}}
{{range .Accounts}}
<div class="budget-item">
<a href="/budget/{{$.Budget.ID}}/account/{{.ID}}">{{.Name}}</a>
<span class="time">{{printf "%.2f" .Balance.GetFloat64}}</span>
</div>
{{end}}
{{end}}

18
web/admin.html Normal file
View File

@ -0,0 +1,18 @@
{{define "title"}}
Admin
{{end}}
{{define "sidebar"}}
Settings for all Budgets
{{end}}
{{template "base" .}}
{{define "main"}}
<h1>Danger Zone</h1>
<div class="budget-item">
<button>Clear database</button>
<p>This removes all data and starts from scratch. Not undoable!</p>
</div>
{{end}}

23
web/amount.tpl Normal file
View File

@ -0,0 +1,23 @@
{{define "amount"}}
<span class="right {{if .IsZero}}zero{{else if not .IsPositive}}negative{{end}}">
{{printf "%.2f" .GetFloat64}}
</span>
{{end}}
{{define "amount-cell"}}
<td class="right {{if .IsZero}}zero{{else if not .IsPositive}}negative{{end}}">
{{printf "%.2f" .GetFloat64}}
</td>
{{end}}
{{define "amountf64"}}
<span class="right {{if eq . 0.0}}zero{{else if lt . 0.0}}negative{{end}}">
{{printf "%.2f" .}}
</span>
{{end}}
{{define "amountf64-cell"}}
<td class="right {{if eq . 0.0}}zero{{else if lt . 0.0}}negative{{end}}">
{{printf "%.2f" .}}
</td>
{{end}}

39
web/base.tpl Normal file
View File

@ -0,0 +1,39 @@
{{define "base"}}
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<link href="/static/css/bootstrap.min.css" rel="stylesheet" />
<link href="/static/css/main.css" rel="stylesheet" />
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.6.0/jquery.min.js"></script>
<script src="https://malsup.github.io/jquery.form.js"></script>
<script src="/static/js/bootstrap.min.js"></script>
<script src="/static/js/main.js"></script>
<title>{{template "title" .}} - Budgeteer</title>
{{block "more-head" .}}{{end}}
</head>
<body>
<div id="wrapper">
<div id="sidebar">
{{block "sidebar" .}}
{{template "budget-sidebar" .}}
{{end}}
</div>
<div id="content">
<div class="container" id="head">
{{template "title" .}}
</div>
<div class="container col-lg-12" id="content">
{{template "main" .}}
</div>
{{block "new" .}}{{end}}
</div>
</div>
</body>
</html>
{{end}}

40
web/budget-new.tpl Normal file
View File

@ -0,0 +1,40 @@
{{define "budget-new"}}
<div id="newbudgetmodal" class="modal fade" role="dialog">
<div class="modal-dialog" role="document">
<script>
$(document).ready(function () {
$('#errorcreatingbudget').hide();
$('#newbudgetform').ajaxForm({
error: function() {
$('#errorcreatingbudget').show();
}
});
});
</script>
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">New Budget</h5>
<button type="button" class="close" data-bs-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<form id="newbudgetform" action="/api/v1/budget/new" method="POST">
<div class="modal-body">
<div class="form-group">
<label for="name">Name</label>
<input type="text" name="name" class="form-control" placeholder="Name" />
</div>
<div id="errorcreatingbudget">
Error creating budget.
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
<input type="submit" class="btn btn-primary" value="Create" class="form-control" />
</div>
</form>
</div>
</div>
</div>
{{end}}

48
web/budget-sidebar.tpl Normal file
View File

@ -0,0 +1,48 @@
{{define "budget-sidebar"}}
<h1><a href="/dashboard">⌂</a> {{.Budget.Name}}</h1>
<ul>
<li><a href="/budget/{{.Budget.ID}}">Budget</a></li>
<li>Reports (Coming Soon)</li>
<li><a href="/budget/{{.Budget.ID}}/all-accounts">All Accounts</a></li>
<li>
On-Budget Accounts
<ul class="two-valued">
{{- range .OnBudgetAccounts}}
<li>
<a href="/budget/{{$.Budget.ID}}/account/{{.ID}}">{{.Name}}</a>
{{- template "amount" .Balance}}
</li>
{{- end}}
</ul>
</li>
<li>
Off-Budget Accounts
<ul class="two-valued">
{{- range .OffBudgetAccounts}}
<li>
<a href="/budget/{{$.Budget.ID}}/account/{{.ID}}">{{.Name}}</a>
{{template "amount" .Balance -}}
</li>
{{- end}}
</ul>
</li>
<li>
Closed Accounts
</li>
<li>
<a href="/budget/{{$.Budget.ID}}/accounts">Edit accounts</a>
</li>
<li>
+ Add Account
</li>
<li>
<a href="/budget/{{.Budget.ID}}/settings">Budget-Settings</a>
</li>
<li>
<a href="/admin">Admin</a>
</li>
<li>
<a href="/api/v1/user/logout">Logout</a>
</li>
</ul>
{{end}}

50
web/budgeting.html Normal file
View File

@ -0,0 +1,50 @@
{{template "base" .}}
{{define "title"}}
{{printf "Budget for %s %d" .Date.Month .Date.Year}}
{{end}}
{{define "new"}}
{{end}}
{{define "main"}}
<div class="budget-item">
<a href="#newtransactionmodal" data-bs-toggle="modal" data-bs-target="#newtransactionmodal">New Transaction</a>
<span class="time"></span>
</div>
<div>
<a href="{{printf "/budget/%s/%d/%d" .Budget.ID .Previous.Year .Previous.Month}}">Previous Month</a> -
<a href="{{printf "/budget/%s" .Budget.ID}}">Current Month</a> -
<a href="{{printf "/budget/%s/%d/%d" .Budget.ID .Next.Year .Next.Month}}">Next Month</a>
</div>
<div>
<span>Available Balance: </span>{{template "amountf64" .AvailableBalance}}
</div>
<table class="container col-lg-12" id="content">
<tr>
<th>Group</th>
<th>Category</th>
<th></th>
<th></th>
<th>Leftover</th>
<th>Assigned</th>
<th>Activity</th>
<th>Available</th>
</tr>
{{range .Categories}}
<tr>
<td>{{.Group}}</td>
<td>{{.Name}}</td>
<td>
</td>
<td>
</td>
{{template "amountf64-cell" .AvailableLastMonth}}
{{template "amountf64-cell" .Assigned}}
{{template "amountf64-cell" .Activity}}
{{template "amountf64-cell" .Available}}
</tr>
{{end}}
</table>
{{end}}

26
web/dashboard.html Normal file
View File

@ -0,0 +1,26 @@
{{define "title"}}
Budgets
{{end}}
{{define "new"}}
{{template "budget-new"}}
{{end}}
{{define "sidebar"}}
Please select a budget.
{{end}}
{{template "base" .}}
{{define "main"}}
{{range .Budgets}}
<div class="budget-item">
<a href="budget/{{.ID}}">{{.Name}}</a>
<span class="time"></span>
</div>
{{end}}
<div class="budget-item">
<button type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#newbudgetmodal">New Budget</a>
<span class="time"></span>
</div>
{{end}}

View File

@ -1,13 +1,17 @@
<!DOCTYPE html> {{define "title"}}
<html lang="en"> Start
<head> {{end}}
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" /> {{define "new"}}
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> {{end}}
<title>Vite App</title>
</head> {{template "base" .}}
<body class="bg-slate-200 text-slate-800 dark:bg-slate-800 dark:text-slate-200 box-border w-full">
<div id="app"></div> {{define "main"}}
<script type="module" src="/src/main.ts"></script> <div class="container col-md-8 col-ld-8" id="content">
</body> Willkommen bei Budgeteer, der neuen App für's Budget!
</html> </div>
<div class="container col-md-4" id="login">
<a href="/login">Login</a> or <a href="/login">register</a>
</div>
{{end}}

42
web/login.html Normal file
View File

@ -0,0 +1,42 @@
{{template "base" .}}
{{define "title"}}Login{{end}}
{{define "more-head"}}
<script>
$(document).ready(function() {
$('#invalidCredentials').hide();
$('#loginForm').ajaxForm({
success: function() {
window.location.href = "/dashboard";
},
error: function() {
$('#invalidCredentials').show();
}
});
});
</script>
{{end}}
{{define "main"}}
<form id="loginForm" action="/api/v1/user/login" method="POST" class="center-block">
<div class="form-group">
<label for="username">User</label>
<input type="text" name="username" class="form-control" placeholder="User" />
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password" name="password" class="form-control" placeholder="Password" />
<p id="invalidCredentials">
The entered credentials are invalid
</p>
</div>
<input type="submit" value="Login" class="btn btn-default" />
<p>
New user? <a href="/register">Register</a> instead!
</p>
</form>
{{end}}

View File

@ -1,33 +0,0 @@
{
"name": "web",
"version": "0.0.0",
"scripts": {
"serve": "vite preview",
"build": "vite build",
"dev": "vite",
"preview": "vite preview"
},
"dependencies": {
"@mdi/font": "5.9.55",
"@vueuse/core": "^7.6.1",
"autoprefixer": "^10.4.2",
"file-saver": "^2.0.5",
"pinia": "^2.0.11",
"postcss": "^8.4.6",
"tailwindcss": "^3.0.18",
"vue": "^3.2.25",
"vue-router": "^4.0.12"
},
"devDependencies": {
"@types/file-saver": "^2.0.5",
"@vitejs/plugin-vue": "^2.0.0",
"@vue/cli-plugin-babel": "5.0.0-beta.7",
"@vue/cli-plugin-typescript": "~4.5.0",
"@vue/cli-service": "5.0.0-beta.7",
"sass": "^1.38.0",
"sass-loader": "^10.0.0",
"typescript": "^4.5.5",
"vite": "^2.7.2",
"vue-tsc": "^0.32.0"
}
}

View File

@ -1,6 +0,0 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

72
web/register.html Normal file
View File

@ -0,0 +1,72 @@
{{define "title"}}Register{{end}}
{{template "base" .}}
{{define "more-head"}}
<script>
function checkPasswordMatchUi() {
if(checkPasswordMatch())
$("#divCheckPasswordMatch").html("Passwords match.");
else
$("#divCheckPasswordMatch").html("Passwords do not match!");
}
function checkPasswordMatch() {
var password = $("#password").val();
var confirmPassword = $("#password_confirm").val();
return password == confirmPassword;
}
$(document).ready(function () {
$("#password, #password_confirm").keyup(checkPasswordMatchUi);
$('#invalidCredentials').hide();
$('#loginForm').ajaxForm({
beforeSubmit: function(a, b, c) {
var match = checkPasswordMatch();
if(!match){
$("#divCheckPasswordMatch").fadeOut(300).fadeIn(300).fadeOut(300).fadeIn(300);
}
return match;
},
success: function() {
window.location.href = "/dashboard";
},
error: function() {
$('#invalidCredentials').show();
}
});
});
</script>
{{end}}
{{define "main"}}
<form id="loginForm" action="/api/v1/user/register" method="POST" class="center-block">
<div class="form-group">
<label for="email">E-Mail</label>
<input type="text" name="email" class="form-control" placeholder="E-Mail" />
</div>
<div class="form-group">
<label for="name">Name</label>
<input type="text" name="name" class="form-control" placeholder="Name" />
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password" name="password" id="password" class="form-control" placeholder="Password" />
<input type="password" id="password_confirm" class="form-control" placeholder="Verify password" />
</div>
<div id="divCheckPasswordMatch"></div>
<div id="invalidCredentials">
Username already exists
</div>
<input type="submit" value="Login" class="form-control" />
<p>
Existing user? <a href="/login">Login</a> instead!
</p>
</form>
{{end}}

32
web/settings.html Normal file
View File

@ -0,0 +1,32 @@
{{define "title"}}
Settings for Budget "{{.Budget.Name}}"
{{end}}
{{template "base" .}}
{{define "main"}}
<h1>Danger Zone</h1>
<div class="budget-item">
<a href="/budget/{{.Budget.ID}}/settings/clear">Clear budget</a>
<p>This removes transactions and assignments to start from scratch. Accounts and categories are kept. Not undoable!</p>
</div>
<div class="budget-item">
<a href="/budget/{{.Budget.ID}}/settings/clean-negative">Fix all historic negative category-balances</a>
<p>This restores YNABs functionality, that would substract any overspent categories' balances from next months inflows.</p>
</div>
<div class="budget-item">
<form method="POST" action="/api/v1/transaction/import/ynab" enctype="multipart/form-data">
<input type="hidden" name="budget_id" value="{{.Budget.ID}}" />
<label for="transactions_file">
Transaktionen:
<input type="file" name="transactions" accept="text/*" />
</label>
<br />
<label for="assignments_file">
Budget:
<input type="file" name="assignments" accept="text/*" />
</label>
<button type="submit">Importieren</button>
</form>
</div>
{{end}}

View File

@ -1,5 +0,0 @@
declare module "*.vue" {
import { defineComponent } from "vue";
const component: ReturnType<typeof defineComponent>;
export default component;
}

View File

@ -1,60 +0,0 @@
<script lang="ts">
import { mapState } from "pinia";
import { defineComponent } from "vue";
import { useBudgetsStore } from "./stores/budget";
import { useSessionStore } from "./stores/session";
import { useSettingsStore } from "./stores/settings";
export default defineComponent({
computed: {
...mapState(useBudgetsStore, ["CurrentBudgetName"]),
...mapState(useSettingsStore, ["Menu"]),
...mapState(useSessionStore, ["LoggedIn"]),
},
methods: {
logout() {
useSessionStore().logout();
this.$router.push("/login");
},
toggleMenu() {
useSettingsStore().toggleMenu();
},
toggleMenuSize() {
useSettingsStore().toggleMenuSize();
}
},
})
</script>
<template>
<div class="flex flex-col md:flex-row flex-1">
<div
:class="[Menu.Expand ? 'md:w-72' : 'md:w-36', Menu.Show ? '' : 'hidden']"
class="md:block flex-shrink-0 w-full bg-gray-500 border-r-4 border-black"
>
<router-view name="sidebar"></router-view>
</div>
<div class="flex-1">
<div class="flex bg-gray-400 dark:bg-gray-600 p-4 fixed md:static top-0 left-0 w-full h-14">
<span
class="flex-1 font-bold text-5xl -my-3 hidden md:inline"
@click="toggleMenuSize"
></span>
<span class="flex-1 font-bold text-5xl -my-3 md:hidden" @click="toggleMenu"></span>
<span class="flex-1">{{ CurrentBudgetName }}</span>
<div class="flex flex-1 flex-row justify-end -mx-4">
<router-link class="mx-4" v-if="LoggedIn" to="/dashboard">Dashboard</router-link>
<router-link class="mx-4" v-if="!LoggedIn" to="/login">Login</router-link>
<a class="mx-4" v-if="LoggedIn" @click="logout">Logout</a>
</div>
</div>
<div class="p-3 pl-6">
<router-view></router-view>
</div>
</div>
</div>
</template>

View File

@ -1,26 +0,0 @@
import { useSessionStore } from "./stores/session";
export const BASE_URL = "/api/v1"
export function GET(path: string) {
const sessionStore = useSessionStore();
return fetch(BASE_URL + path, {
headers: sessionStore.AuthHeaders,
})
};
export function POST(path: string, body: FormData | string | null) {
const sessionStore = useSessionStore();
return fetch(BASE_URL + path, {
method: "POST",
headers: sessionStore.AuthHeaders,
body: body,
})
}
export function DELETE(path: string) {
const sessionStore = useSessionStore();
return fetch(BASE_URL + path, {
method: "DELETE",
headers: sessionStore.AuthHeaders,
})
}

View File

@ -1,102 +0,0 @@
<script lang="ts" setup>
import { ref, watch } from "vue"
import { GET } from "../api";
import { useBudgetsStore } from "../stores/budget";
import Input from "./Input.vue";
export interface Suggestion {
ID: string
Name: string
Type: string
}
const props = defineProps<{
text: String,
id: String | undefined,
model: String,
type?: string | undefined,
}>();
const SearchQuery = ref(props.text || "");
const Suggestions = ref<Array<Suggestion>>([]);
const emit = defineEmits(["update:id", "update:text", "update:type"]);
watch(SearchQuery, () => {
load(SearchQuery.value);
});
function load(text: String) {
emit('update:id', null);
emit('update:text', text);
emit('update:type', undefined);
if (text == "") {
Suggestions.value = [];
return;
}
const budgetStore = useBudgetsStore();
GET("/budget/" + budgetStore.CurrentBudgetID + "/autocomplete/" + props.model + "?s=" + text)
.then(x => x.json())
.then(x => {
let suggestions = x || [];
if (suggestions.length > 10) {
suggestions = suggestions.slice(0, 10);
}
Suggestions.value = suggestions;
});
};
function keypress(e: KeyboardEvent) {
if (e.key != "Enter")
return;
const selected = Suggestions.value[0];
selectElement(selected);
const el = (<HTMLInputElement>e.target);
const inputElements = Array.from(el.ownerDocument.querySelectorAll('input:not([disabled]):not([readonly])'));
const currentIndex = inputElements.indexOf(el);
const nextElement = inputElements[currentIndex < inputElements.length - 1 ? currentIndex + 1 : 0];
(<HTMLInputElement>nextElement).focus();
};
function selectElement(element: Suggestion) {
emit('update:id', element.ID);
emit('update:text', element.Name);
emit('update:type', element.Type);
Suggestions.value = [];
};
function select(e: MouseEvent) {
const target = (<HTMLInputElement>e.target);
const valueAttribute = target.attributes.getNamedItem("value");
let selectedID = "";
if (valueAttribute != null)
selectedID = valueAttribute.value;
const selected = Suggestions.value.filter(x => x.ID == selectedID)[0];
selectElement(selected);
};
function clear() {
emit('update:id', null);
emit('update:text', SearchQuery.value);
emit('update:type', undefined);
};
</script>
<template>
<div>
<Input
type="text"
class="border-b-2 border-black"
@keypress="keypress"
v-if="id == undefined"
v-model="SearchQuery"
/>
<span @click="clear" v-if="id != undefined" class="bg-gray-300 dark:bg-gray-700">{{ text }}</span>
<div v-if="Suggestions.length > 0" class="absolute bg-gray-400 dark:bg-gray-600 w-64 p-2">
<span
v-for="suggestion in Suggestions"
class="block"
@click="select"
:value="suggestion.ID"
>{{ suggestion.Name }}</span>
</div>
</div>
</template>

View File

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

View File

@ -1,8 +0,0 @@
<script lang="ts" setup>
</script>
<template>
<div class="flex flex-row items-center bg-gray-300 dark:bg-gray-700 rounded-lg">
<slot></slot>
</div>
</template>

View File

@ -1,11 +0,0 @@
<script lang="ts" setup>
const props = defineProps(["modelValue"]);
</script>
<template>
<input
type="checkbox"
:checked="modelValue"
@change="$emit('update:modelValue', ($event.target as HTMLInputElement)?.checked)"
class="dark:bg-slate-900">
</template>

View File

@ -1,15 +0,0 @@
<script lang="ts" setup>
import { computed } from 'vue';
const props = defineProps<{ value: number | undefined }>();
const internalValue = computed(() => Number(props.value ?? 0));
const formattedValue = computed(() => internalValue.value.toLocaleString(undefined, {
minimumFractionDigits: 2,
}));
</script>
<template>
<span class="text-right" :class="internalValue < 0 ? 'negative' : ''">{{ formattedValue }} </span>
</template>

Some files were not shown because too many files have changed in this diff Show More