Compare commits
102 Commits
Author | SHA1 | Date | |
---|---|---|---|
4019656e4d | |||
16b7049438 | |||
73e3b49b40 | |||
953d348bed | |||
a6eb2a2253 | |||
578e7d071c | |||
4688d2d94d | |||
cfda327a5d | |||
1bd38bb367 | |||
19d2ddbd65 | |||
3cb39d978a | |||
e08a21b750 | |||
6929c940c4 | |||
2423bdd3ee | |||
22ec0433bf | |||
c03d16878a | |||
545f223a97 | |||
260ac2d4ad | |||
d815e8c3cd | |||
91b8cc06b2 | |||
75a6ce1577 | |||
a0d89ee93a | |||
e39d1dc6e3 | |||
1dcd0d2f6d | |||
62085cb694 | |||
ca51ac5e27 | |||
36b2f12183 | |||
96b514ccf8 | |||
b52ed21d1d | |||
787165b7f1 | |||
8035403416 | |||
1a1971246d | |||
9b92e2b551 | |||
c5be03ab6b | |||
77afe700ae | |||
f0961ccc3c | |||
1d2ae0e394 | |||
b0175542f1 | |||
558fddc139 | |||
02ba80a555 | |||
1a19d3a197 | |||
052a2628ab | |||
46b9b82f30 | |||
c5a0f49719 | |||
649f937254 | |||
0f2501dcbd | |||
daadfd45bc | |||
72b5bdde4f | |||
737d5fb101 | |||
bb4548c50d | |||
7b20bc9822 | |||
2f45c415e0 | |||
74a53954de | |||
7a0c4a17a2 | |||
71c54c9373 | |||
584e7ef393 | |||
aaf16dbe92 | |||
d8e0f5a160 | |||
38dfa540b4 | |||
835a15ec08 | |||
4bbbc0be13 | |||
8f6974e151 | |||
368ac7f15d | |||
0d20d9bfb8 | |||
4276c51268 | |||
57930d0e5d | |||
fe018e1953 | |||
e7a085273b | |||
5bbd096fc8 | |||
452d63c329 | |||
d28c894d21 | |||
1a79177422 | |||
0aa877d7d4 | |||
87a70ee5fa | |||
0a030eaee1 | |||
d11c0036b5 | |||
ca93e9cd55 | |||
a061ffd350 | |||
5633c029ac | |||
a97d050ead | |||
958929fd16 | |||
a61d80ee1f | |||
41c5095b8b | |||
c074dfe865 | |||
fa8a2854f2 | |||
15bb73de30 | |||
e506510fde | |||
11ac8758da | |||
3db5e1e72c | |||
4e2a783b2e | |||
bb83563bc6 | |||
0a21c59eff | |||
3308b58524 | |||
941b642f39 | |||
6a77c71df4 | |||
bf20914c1c | |||
7874ef69a2 | |||
2e719b590e | |||
95d8e4fccc | |||
7cf106eb85 | |||
148fc18cd8 | |||
47095ae6ec |
@ -8,3 +8,4 @@ config.example.json
|
|||||||
.vscode/
|
.vscode/
|
||||||
budgeteer
|
budgeteer
|
||||||
budgeteer.exe
|
budgeteer.exe
|
||||||
|
**/node_modules/
|
||||||
|
@ -6,8 +6,9 @@ name: budgeteer
|
|||||||
steps:
|
steps:
|
||||||
- name: Taskfile.dev
|
- name: Taskfile.dev
|
||||||
image: hub.javil.eu/budgeteer:dev
|
image: hub.javil.eu/budgeteer:dev
|
||||||
|
pull: true
|
||||||
commands:
|
commands:
|
||||||
- task build
|
- task ci
|
||||||
|
|
||||||
- name: docker
|
- name: docker
|
||||||
image: plugins/docker
|
image: plugins/docker
|
||||||
|
26
.golangci.yml
Normal file
26
.golangci.yml
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
linters:
|
||||||
|
enable-all: true
|
||||||
|
disable:
|
||||||
|
- golint
|
||||||
|
- scopelint
|
||||||
|
- maligned
|
||||||
|
- interfacer
|
||||||
|
- wsl
|
||||||
|
- forbidigo
|
||||||
|
- nlreturn
|
||||||
|
- testpackage
|
||||||
|
- ifshort
|
||||||
|
- exhaustivestruct
|
||||||
|
- gci # not working, shows errors on freshly formatted file
|
||||||
|
- varnamelen
|
||||||
|
linters-settings:
|
||||||
|
errcheck:
|
||||||
|
exclude-functions:
|
||||||
|
- io/ioutil.ReadFile
|
||||||
|
- io.Copy(*bytes.Buffer)
|
||||||
|
- (*github.com/gin-gonic/gin.Context).AbortWithError
|
||||||
|
- (*github.com/gin-gonic/gin.Context).AbortWithError
|
||||||
|
- io.Copy(os.Stdout)
|
||||||
|
varnamelen:
|
||||||
|
ignore-decls:
|
||||||
|
- c *gin.Context
|
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@ -2,5 +2,8 @@
|
|||||||
"files.exclude": {
|
"files.exclude": {
|
||||||
"**/node_modules": true,
|
"**/node_modules": true,
|
||||||
"**/vendor": true
|
"**/vendor": true
|
||||||
|
},
|
||||||
|
"gopls": {
|
||||||
|
"formatting.gofumpt": true,
|
||||||
}
|
}
|
||||||
}
|
}
|
23
.woodpecker.yml
Normal file
23
.woodpecker.yml
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
pipeline:
|
||||||
|
build:
|
||||||
|
name: Taskfile.dev
|
||||||
|
image: hub.javil.eu/budgeteer:dev
|
||||||
|
pull: true
|
||||||
|
commands:
|
||||||
|
- task ci
|
||||||
|
|
||||||
|
docker:
|
||||||
|
image: plugins/docker
|
||||||
|
secrets: [ docker_username, docker_password ]
|
||||||
|
settings:
|
||||||
|
registry: hub.javil.eu
|
||||||
|
repo: hub.javil.eu/budgeteer
|
||||||
|
context: build
|
||||||
|
dockerfile: build/Dockerfile
|
||||||
|
tags:
|
||||||
|
- latest
|
||||||
|
when:
|
||||||
|
event: [push, tag, deployment]
|
||||||
|
|
||||||
|
image_pull_secrets:
|
||||||
|
- hub.javil.eu
|
@ -12,7 +12,7 @@ docker:
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY +build/budgeteer .
|
COPY +build/budgeteer .
|
||||||
ENTRYPOINT ["/app/budgeteer"]
|
ENTRYPOINT ["/app/budgeteer"]
|
||||||
SAVE IMAGE budgeteer:latest
|
SAVE IMAGE hub.javil.eu/budgeteer:latest
|
||||||
|
|
||||||
run:
|
run:
|
||||||
LOCALLY
|
LOCALLY
|
||||||
|
40
Taskfile.yml
40
Taskfile.yml
@ -1,9 +1,12 @@
|
|||||||
version: '3'
|
version: '3'
|
||||||
|
|
||||||
|
vars:
|
||||||
|
IMAGE_NAME: hub.javil.eu/budgeteer
|
||||||
|
|
||||||
tasks:
|
tasks:
|
||||||
default:
|
default:
|
||||||
cmds:
|
cmds:
|
||||||
- task: build
|
- task: build-prod
|
||||||
|
|
||||||
sqlc:
|
sqlc:
|
||||||
desc: sqlc code generation
|
desc: sqlc code generation
|
||||||
@ -30,12 +33,7 @@ tasks:
|
|||||||
sources:
|
sources:
|
||||||
- ./go.mod
|
- ./go.mod
|
||||||
- ./go.sum
|
- ./go.sum
|
||||||
- ./cmd/budgeteer/*.go
|
- ./**/*.go
|
||||||
- ./*.go
|
|
||||||
- ./config/*.go
|
|
||||||
- ./http/*.go
|
|
||||||
- ./jwt/*.go
|
|
||||||
- ./postgres/*.go
|
|
||||||
- ./web/dist/**/*
|
- ./web/dist/**/*
|
||||||
- ./postgres/schema/*
|
- ./postgres/schema/*
|
||||||
generates:
|
generates:
|
||||||
@ -49,22 +47,34 @@ tasks:
|
|||||||
desc: Build budgeteer in dev mode
|
desc: Build budgeteer in dev mode
|
||||||
deps: [gomod, sqlc]
|
deps: [gomod, sqlc]
|
||||||
cmds:
|
cmds:
|
||||||
|
- go vet
|
||||||
|
- go fmt
|
||||||
|
- golangci-lint run
|
||||||
- task: build
|
- task: build
|
||||||
|
|
||||||
build-prod:
|
build-prod:
|
||||||
desc: Build budgeteer in prod mode
|
desc: Build budgeteer in prod mode
|
||||||
deps: [gomod, sqlc, frontend]
|
deps: [gomod, sqlc, frontend]
|
||||||
cmds:
|
cmds:
|
||||||
|
- go vet
|
||||||
|
- go fmt
|
||||||
|
- golangci-lint run
|
||||||
- task: build
|
- task: build
|
||||||
|
|
||||||
|
ci:
|
||||||
|
desc: Run CI build
|
||||||
|
cmds:
|
||||||
|
- task: build-prod
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
desc: Build vue frontend
|
desc: Build vue frontend
|
||||||
|
dir: web
|
||||||
sources:
|
sources:
|
||||||
- web/src/**/*
|
- web/src/**/*
|
||||||
generates:
|
generates:
|
||||||
- web/dist/**/*
|
- web/dist/**/*
|
||||||
cmds:
|
cmds:
|
||||||
- cd web
|
- yarn
|
||||||
- yarn build
|
- yarn build
|
||||||
|
|
||||||
docker:
|
docker:
|
||||||
@ -72,8 +82,20 @@ tasks:
|
|||||||
deps: [build-prod]
|
deps: [build-prod]
|
||||||
sources:
|
sources:
|
||||||
- ./build/budgeteer{{exeExt}}
|
- ./build/budgeteer{{exeExt}}
|
||||||
|
- ./build/Dockerfile
|
||||||
cmds:
|
cmds:
|
||||||
- docker build -t budgeteer:latest -t hub.javil.eu/budgeteer:latest ./build
|
- docker build -t {{.IMAGE_NAME}}: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
|
||||||
|
- docker push {{.IMAGE_NAME}}:dev
|
||||||
|
|
||||||
run:
|
run:
|
||||||
desc: Start budgeteer
|
desc: Start budgeteer
|
||||||
|
@ -1,23 +1,30 @@
|
|||||||
package bcrypt
|
package bcrypt
|
||||||
|
|
||||||
import "golang.org/x/crypto/bcrypt"
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
// Verifier verifys passwords using Bcrypt
|
"golang.org/x/crypto/bcrypt"
|
||||||
type Verifier struct {
|
)
|
||||||
cost int
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify verifys a Password
|
// Verifier verifys passwords using Bcrypt.
|
||||||
func (bv *Verifier) Verify(password string, hashOnDb string) error {
|
type Verifier struct{}
|
||||||
return bcrypt.CompareHashAndPassword([]byte(hashOnDb), []byte(password))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hash calculates a hash to be stored on the database
|
// Verify verifys a Password.
|
||||||
func (bv *Verifier) Hash(password string) (string, error) {
|
func (bv *Verifier) Verify(password string, hashOnDB string) error {
|
||||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bv.cost)
|
err := bcrypt.CompareHashAndPassword([]byte(hashOnDB), []byte(password))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return fmt.Errorf("verify password: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return string(hash[:]), nil
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash calculates a hash to be stored on the database.
|
||||||
|
func (bv *Verifier) Hash(password string) (string, error) {
|
||||||
|
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("hash password: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(hash), nil
|
||||||
}
|
}
|
||||||
|
@ -1,13 +1,16 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"io/fs"
|
||||||
"log"
|
"log"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
"git.javil.eu/jacob1123/budgeteer/bcrypt"
|
"git.javil.eu/jacob1123/budgeteer/bcrypt"
|
||||||
"git.javil.eu/jacob1123/budgeteer/config"
|
"git.javil.eu/jacob1123/budgeteer/config"
|
||||||
"git.javil.eu/jacob1123/budgeteer/http"
|
|
||||||
"git.javil.eu/jacob1123/budgeteer/jwt"
|
"git.javil.eu/jacob1123/budgeteer/jwt"
|
||||||
"git.javil.eu/jacob1123/budgeteer/postgres"
|
"git.javil.eu/jacob1123/budgeteer/postgres"
|
||||||
|
"git.javil.eu/jacob1123/budgeteer/server"
|
||||||
|
"git.javil.eu/jacob1123/budgeteer/web"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@ -16,16 +19,24 @@ func main() {
|
|||||||
log.Fatalf("Could not load config: %v", err)
|
log.Fatalf("Could not load config: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
q, err := postgres.Connect("pgx", cfg.DatabaseConnection)
|
queries, err := postgres.Connect("pgx", cfg.DatabaseConnection)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Failed connecting to DB: %v", err)
|
log.Fatalf("Failed connecting to DB: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
h := &http.Handler{
|
static, err := fs.Sub(web.Static, "dist")
|
||||||
Service: q,
|
if err != nil {
|
||||||
TokenVerifier: &jwt.TokenVerifier{},
|
panic("couldn't open static files")
|
||||||
CredentialsVerifier: &bcrypt.Verifier{},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
h.Serve()
|
handler := &server.Handler{
|
||||||
|
Service: queries,
|
||||||
|
TokenVerifier: &jwt.TokenVerifier{
|
||||||
|
Secret: cfg.SessionSecret,
|
||||||
|
},
|
||||||
|
CredentialsVerifier: &bcrypt.Verifier{},
|
||||||
|
StaticFS: http.FS(static),
|
||||||
|
}
|
||||||
|
|
||||||
|
handler.Serve()
|
||||||
}
|
}
|
||||||
|
@ -4,15 +4,17 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Config contains all needed configurations
|
// Config contains all needed configurations.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
DatabaseConnection string
|
DatabaseConnection string
|
||||||
|
SessionSecret string
|
||||||
}
|
}
|
||||||
|
|
||||||
// LoadConfig from path
|
// LoadConfig from path.
|
||||||
func LoadConfig() (*Config, error) {
|
func LoadConfig() (*Config, error) {
|
||||||
configuration := Config{
|
configuration := Config{
|
||||||
DatabaseConnection: os.Getenv("BUDGETEER_DB"),
|
DatabaseConnection: os.Getenv("BUDGETEER_DB"),
|
||||||
|
SessionSecret: os.Getenv("BUDGETEER_SESSION_SECRET"),
|
||||||
}
|
}
|
||||||
|
|
||||||
return &configuration, nil
|
return &configuration, nil
|
||||||
|
@ -2,9 +2,7 @@ version: '3.7'
|
|||||||
|
|
||||||
services:
|
services:
|
||||||
app:
|
app:
|
||||||
image: budgeteer:dev
|
image: hub.javil.eu/budgeteer:dev
|
||||||
build:
|
|
||||||
context: ./docker/
|
|
||||||
container_name: budgeteer
|
container_name: budgeteer
|
||||||
stdin_open: true # docker run -i
|
stdin_open: true # docker run -i
|
||||||
tty: true # docker run -t
|
tty: true # docker run -t
|
||||||
@ -19,6 +17,7 @@ services:
|
|||||||
- ~/.cache:/.cache
|
- ~/.cache:/.cache
|
||||||
environment:
|
environment:
|
||||||
BUDGETEER_DB: postgres://budgeteer:budgeteer@db:5432/budgeteer
|
BUDGETEER_DB: postgres://budgeteer:budgeteer@db:5432/budgeteer
|
||||||
|
BUDGETEER_SESSION_SECRET: random string for JWT authorization
|
||||||
depends_on:
|
depends_on:
|
||||||
- db
|
- db
|
||||||
|
|
||||||
|
@ -2,7 +2,7 @@ version: '3.7'
|
|||||||
|
|
||||||
services:
|
services:
|
||||||
app:
|
app:
|
||||||
image: budgeteer:latest
|
image: hub.javil.eu/budgeteer:latest
|
||||||
container_name: budgeteer
|
container_name: budgeteer
|
||||||
ports:
|
ports:
|
||||||
- 1323:1323
|
- 1323:1323
|
||||||
|
@ -1,13 +1,17 @@
|
|||||||
|
FROM alpine as godeps
|
||||||
|
RUN apk add go
|
||||||
|
RUN go install github.com/kyleconroy/sqlc/cmd/sqlc@latest
|
||||||
|
RUN go install github.com/go-task/task/v3/cmd/task@latest
|
||||||
|
RUN go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest
|
||||||
|
|
||||||
FROM alpine
|
FROM alpine
|
||||||
RUN apk add go
|
RUN apk add go
|
||||||
RUN apk add nodejs yarn bash curl git git-perl tmux
|
RUN apk add nodejs yarn bash curl git git-perl tmux
|
||||||
RUN bash -c "$(curl --location https://taskfile.dev/install.sh)" -- -d -b /usr/local/bin
|
ADD docker/build.sh /
|
||||||
ADD build.sh /
|
|
||||||
RUN addgroup -S dev && adduser -S dev -G dev
|
|
||||||
USER dev
|
|
||||||
RUN go install github.com/kyleconroy/sqlc/cmd/sqlc@latest
|
|
||||||
RUN go install github.com/go-task/task/v3/cmd/task@latest
|
|
||||||
RUN yarn global add @vue/cli
|
RUN yarn global add @vue/cli
|
||||||
ENV PATH="/home/dev/go/bin:/home/dev/.yarn/bin/:${PATH}"
|
ENV PATH="/root/.yarn/bin/:${PATH}"
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
|
ADD web/package.json /src/web/
|
||||||
|
RUN yarn
|
||||||
|
COPY --from=godeps /root/go/bin/task /root/go/bin/sqlc /root/go/bin/golangci-lint /usr/local/bin/
|
||||||
CMD /build.sh
|
CMD /build.sh
|
||||||
|
@ -1,89 +0,0 @@
|
|||||||
package http
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"git.javil.eu/jacob1123/budgeteer/postgres"
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
"github.com/google/uuid"
|
|
||||||
)
|
|
||||||
|
|
||||||
type NewTransactionPayload struct {
|
|
||||||
Date JSONDate `json:"date"`
|
|
||||||
Payee struct {
|
|
||||||
ID uuid.NullUUID
|
|
||||||
Name string
|
|
||||||
} `json:"payee"`
|
|
||||||
Category struct {
|
|
||||||
ID uuid.NullUUID
|
|
||||||
Name string
|
|
||||||
} `json:"category"`
|
|
||||||
Memo string `json:"memo"`
|
|
||||||
Amount string `json:"amount"`
|
|
||||||
BudgetID uuid.UUID `json:"budget_id"`
|
|
||||||
AccountID uuid.UUID `json:"account_id"`
|
|
||||||
State string `json:"state"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handler) newTransaction(c *gin.Context) {
|
|
||||||
var payload NewTransactionPayload
|
|
||||||
err := c.BindJSON(&payload)
|
|
||||||
if err != nil {
|
|
||||||
c.AbortWithError(http.StatusInternalServerError, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("%v\n", payload)
|
|
||||||
|
|
||||||
amount := postgres.Numeric{}
|
|
||||||
amount.Set(payload.Amount)
|
|
||||||
|
|
||||||
/*transactionUUID, err := getNullUUIDFromParam(c, "transactionid")
|
|
||||||
if err != nil {
|
|
||||||
c.AbortWithError(http.StatusInternalServerError, fmt.Errorf("parse transaction id: %w", err))
|
|
||||||
return
|
|
||||||
}*/
|
|
||||||
|
|
||||||
//if !transactionUUID.Valid {
|
|
||||||
new := postgres.CreateTransactionParams{
|
|
||||||
Memo: payload.Memo,
|
|
||||||
Date: time.Time(payload.Date),
|
|
||||||
Amount: amount,
|
|
||||||
AccountID: payload.AccountID,
|
|
||||||
PayeeID: payload.Payee.ID, //TODO handle new payee
|
|
||||||
CategoryID: payload.Category.ID, //TODO handle new category
|
|
||||||
Status: postgres.TransactionStatus(payload.State),
|
|
||||||
}
|
|
||||||
_, err = h.Service.CreateTransaction(c.Request.Context(), new)
|
|
||||||
if err != nil {
|
|
||||||
c.AbortWithError(http.StatusInternalServerError, fmt.Errorf("create transaction: %w", err))
|
|
||||||
}
|
|
||||||
|
|
||||||
return
|
|
||||||
// }
|
|
||||||
/*
|
|
||||||
_, delete := c.GetPostForm("delete")
|
|
||||||
if delete {
|
|
||||||
err = h.Service.DeleteTransaction(c.Request.Context(), transactionUUID.UUID)
|
|
||||||
if err != nil {
|
|
||||||
c.AbortWithError(http.StatusInternalServerError, fmt.Errorf("delete transaction: %w", err))
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
update := postgres.UpdateTransactionParams{
|
|
||||||
ID: transactionUUID.UUID,
|
|
||||||
Memo: payload.Memo,
|
|
||||||
Date: time.Time(payload.Date),
|
|
||||||
Amount: amount,
|
|
||||||
AccountID: transactionAccountID,
|
|
||||||
PayeeID: payload.Payee.ID, //TODO handle new payee
|
|
||||||
CategoryID: payload.Category.ID, //TODO handle new category
|
|
||||||
}
|
|
||||||
err = h.Service.UpdateTransaction(c.Request.Context(), update)
|
|
||||||
if err != nil {
|
|
||||||
c.AbortWithError(http.StatusInternalServerError, fmt.Errorf("update transaction: %w", err))
|
|
||||||
}*/
|
|
||||||
}
|
|
56
http/util.go
56
http/util.go
@ -1,56 +0,0 @@
|
|||||||
package http
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
"github.com/google/uuid"
|
|
||||||
)
|
|
||||||
|
|
||||||
func getUUID(c *gin.Context, name string) (uuid.UUID, error) {
|
|
||||||
value, succ := c.GetPostForm(name)
|
|
||||||
if !succ {
|
|
||||||
return uuid.UUID{}, fmt.Errorf("not set")
|
|
||||||
}
|
|
||||||
|
|
||||||
id, err := uuid.Parse(value)
|
|
||||||
if err != nil {
|
|
||||||
return uuid.UUID{}, fmt.Errorf("not a valid uuid: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return id, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func getNullUUIDFromParam(c *gin.Context, name string) (uuid.NullUUID, error) {
|
|
||||||
value := c.Param(name)
|
|
||||||
if value == "" {
|
|
||||||
return uuid.NullUUID{}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
id, err := uuid.Parse(value)
|
|
||||||
if err != nil {
|
|
||||||
return uuid.NullUUID{}, fmt.Errorf("not a valid uuid: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return uuid.NullUUID{
|
|
||||||
UUID: id,
|
|
||||||
Valid: true,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func getNullUUIDFromForm(c *gin.Context, name string) (uuid.NullUUID, error) {
|
|
||||||
value, succ := c.GetPostForm(name)
|
|
||||||
if !succ || value == "" {
|
|
||||||
return uuid.NullUUID{}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
id, err := uuid.Parse(value)
|
|
||||||
if err != nil {
|
|
||||||
return uuid.NullUUID{}, fmt.Errorf("not a valid uuid: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return uuid.NullUUID{
|
|
||||||
UUID: id,
|
|
||||||
Valid: true,
|
|
||||||
}, nil
|
|
||||||
}
|
|
34
jwt/login.go
34
jwt/login.go
@ -10,11 +10,12 @@ import (
|
|||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TokenVerifier verifies Tokens
|
// TokenVerifier verifies Tokens.
|
||||||
type TokenVerifier struct {
|
type TokenVerifier struct {
|
||||||
|
Secret string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Token contains everything to authenticate a user
|
// Token contains everything to authenticate a user.
|
||||||
type Token struct {
|
type Token struct {
|
||||||
username string
|
username string
|
||||||
name string
|
name string
|
||||||
@ -24,10 +25,9 @@ type Token struct {
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
expiration = 72
|
expiration = 72
|
||||||
secret = "uditapbzuditagscwxuqdflgzpbu´ßiaefnlmzeßtrubiadern"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// CreateToken creates a new token from username and name
|
// CreateToken creates a new token from username and name.
|
||||||
func (tv *TokenVerifier) CreateToken(user *postgres.User) (string, error) {
|
func (tv *TokenVerifier) CreateToken(user *postgres.User) (string, error) {
|
||||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
|
||||||
"usr": user.Email,
|
"usr": user.Email,
|
||||||
@ -37,21 +37,27 @@ func (tv *TokenVerifier) CreateToken(user *postgres.User) (string, error) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Generate encoded token and send it as response.
|
// Generate encoded token and send it as response.
|
||||||
t, err := token.SignedString([]byte(secret))
|
t, err := token.SignedString([]byte(tv.Secret))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", fmt.Errorf("create token: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return t, nil
|
return t, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// VerifyToken verifys a given string-token
|
var (
|
||||||
func (tv *TokenVerifier) VerifyToken(tokenString string) (budgeteer.Token, error) {
|
ErrUnexpectedSigningMethod = fmt.Errorf("unexpected signing method")
|
||||||
|
ErrInvalidToken = fmt.Errorf("token is invalid")
|
||||||
|
ErrTokenExpired = fmt.Errorf("token has expired")
|
||||||
|
)
|
||||||
|
|
||||||
|
// VerifyToken verifys a given string-token.
|
||||||
|
func (tv *TokenVerifier) VerifyToken(tokenString string) (budgeteer.Token, error) { //nolint:ireturn
|
||||||
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
|
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
|
||||||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||||
return nil, fmt.Errorf("Unexpected signing method: %v", token.Header["alg"])
|
return nil, fmt.Errorf("method '%v': %w", token.Header["alg"], ErrUnexpectedSigningMethod)
|
||||||
}
|
}
|
||||||
return []byte(secret), nil
|
return []byte(tv.Secret), nil
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("parse jwt: %w", err)
|
return nil, fmt.Errorf("parse jwt: %w", err)
|
||||||
@ -62,7 +68,7 @@ func (tv *TokenVerifier) VerifyToken(tokenString string) (budgeteer.Token, error
|
|||||||
return nil, fmt.Errorf("verify jwt: %w", err)
|
return nil, fmt.Errorf("verify jwt: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
tkn := &Token{
|
tkn := &Token{ //nolint:forcetypeassert
|
||||||
username: claims["usr"].(string),
|
username: claims["usr"].(string),
|
||||||
name: claims["name"].(string),
|
name: claims["name"].(string),
|
||||||
expiry: claims["exp"].(float64),
|
expiry: claims["exp"].(float64),
|
||||||
@ -73,16 +79,16 @@ func (tv *TokenVerifier) VerifyToken(tokenString string) (budgeteer.Token, error
|
|||||||
|
|
||||||
func verifyToken(token *jwt.Token) (jwt.MapClaims, error) {
|
func verifyToken(token *jwt.Token) (jwt.MapClaims, error) {
|
||||||
if !token.Valid {
|
if !token.Valid {
|
||||||
return nil, fmt.Errorf("Token is not valid")
|
return nil, ErrInvalidToken
|
||||||
}
|
}
|
||||||
|
|
||||||
claims, ok := token.Claims.(jwt.MapClaims)
|
claims, ok := token.Claims.(jwt.MapClaims)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, fmt.Errorf("Claims are not of Type MapClaims")
|
return nil, ErrInvalidToken
|
||||||
}
|
}
|
||||||
|
|
||||||
if !claims.VerifyExpiresAt(time.Now().Unix(), true) {
|
if !claims.VerifyExpiresAt(time.Now().Unix(), true) {
|
||||||
return nil, fmt.Errorf("Claims have expired")
|
return nil, ErrTokenExpired
|
||||||
}
|
}
|
||||||
|
|
||||||
return claims, nil
|
return claims, nil
|
||||||
|
@ -8,11 +8,15 @@ import (
|
|||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
// NewBudget creates a budget and adds it to the current user
|
// NewBudget creates a budget and adds it to the current user.
|
||||||
func (s *Database) NewBudget(context context.Context, name string, userID uuid.UUID) (*Budget, error) {
|
func (s *Database) NewBudget(context context.Context, name string, userID uuid.UUID) (*Budget, error) {
|
||||||
tx, err := s.BeginTx(context, &sql.TxOptions{})
|
tx, err := s.BeginTx(context, &sql.TxOptions{})
|
||||||
q := s.WithTx(tx)
|
if err != nil {
|
||||||
budget, err := q.CreateBudget(context, CreateBudgetParams{
|
return nil, fmt.Errorf("begin transaction: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
transaction := s.WithTx(tx)
|
||||||
|
budget, err := transaction.CreateBudget(context, CreateBudgetParams{
|
||||||
Name: name,
|
Name: name,
|
||||||
IncomeCategoryID: uuid.New(),
|
IncomeCategoryID: uuid.New(),
|
||||||
})
|
})
|
||||||
@ -21,12 +25,12 @@ func (s *Database) NewBudget(context context.Context, name string, userID uuid.U
|
|||||||
}
|
}
|
||||||
|
|
||||||
ub := LinkBudgetToUserParams{UserID: userID, BudgetID: budget.ID}
|
ub := LinkBudgetToUserParams{UserID: userID, BudgetID: budget.ID}
|
||||||
_, err = q.LinkBudgetToUser(context, ub)
|
_, err = transaction.LinkBudgetToUser(context, ub)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("link budget to user: %w", err)
|
return nil, fmt.Errorf("link budget to user: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
group, err := q.CreateCategoryGroup(context, CreateCategoryGroupParams{
|
group, err := transaction.CreateCategoryGroup(context, CreateCategoryGroupParams{
|
||||||
Name: "Inflow",
|
Name: "Inflow",
|
||||||
BudgetID: budget.ID,
|
BudgetID: budget.ID,
|
||||||
})
|
})
|
||||||
@ -34,7 +38,7 @@ func (s *Database) NewBudget(context context.Context, name string, userID uuid.U
|
|||||||
return nil, fmt.Errorf("create inflow category_group: %w", err)
|
return nil, fmt.Errorf("create inflow category_group: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
cat, err := q.CreateCategory(context, CreateCategoryParams{
|
cat, err := transaction.CreateCategory(context, CreateCategoryParams{
|
||||||
Name: "Ready to Assign",
|
Name: "Ready to Assign",
|
||||||
CategoryGroupID: group.ID,
|
CategoryGroupID: group.ID,
|
||||||
})
|
})
|
||||||
@ -42,7 +46,7 @@ func (s *Database) NewBudget(context context.Context, name string, userID uuid.U
|
|||||||
return nil, fmt.Errorf("create ready to assign category: %w", err)
|
return nil, fmt.Errorf("create ready to assign category: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = q.SetInflowCategory(context, SetInflowCategoryParams{
|
err = transaction.SetInflowCategory(context, SetInflowCategoryParams{
|
||||||
IncomeCategoryID: cat.ID,
|
IncomeCategoryID: cat.ID,
|
||||||
ID: budget.ID,
|
ID: budget.ID,
|
||||||
})
|
})
|
||||||
@ -50,7 +54,10 @@ func (s *Database) NewBudget(context context.Context, name string, userID uuid.U
|
|||||||
return nil, fmt.Errorf("set inflow category: %w", err)
|
return nil, fmt.Errorf("set inflow category: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
tx.Commit()
|
err = tx.Commit()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("commit: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
return &budget, nil
|
return &budget, nil
|
||||||
}
|
}
|
||||||
|
@ -5,7 +5,7 @@ import (
|
|||||||
"embed"
|
"embed"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
_ "github.com/jackc/pgx/v4/stdlib"
|
_ "github.com/jackc/pgx/v4/stdlib" // needed for pg connection
|
||||||
"github.com/pressly/goose/v3"
|
"github.com/pressly/goose/v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -17,7 +17,7 @@ type Database struct {
|
|||||||
*sql.DB
|
*sql.DB
|
||||||
}
|
}
|
||||||
|
|
||||||
// Connect to a database
|
// Connect connects to a database.
|
||||||
func Connect(typ string, connString string) (*Database, error) {
|
func Connect(typ string, connString string) (*Database, error) {
|
||||||
conn, err := sql.Open(typ, connString)
|
conn, err := sql.Open(typ, connString)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -45,7 +45,7 @@ func (n Numeric) IsZero() bool {
|
|||||||
|
|
||||||
func (n Numeric) MatchExp(exp int32) Numeric {
|
func (n Numeric) MatchExp(exp int32) Numeric {
|
||||||
diffExp := n.Exp - exp
|
diffExp := n.Exp - exp
|
||||||
factor := big.NewInt(0).Exp(big.NewInt(10), big.NewInt(int64(diffExp)), nil)
|
factor := big.NewInt(0).Exp(big.NewInt(10), big.NewInt(int64(diffExp)), nil) //nolint:gomnd
|
||||||
return Numeric{pgtype.Numeric{
|
return Numeric{pgtype.Numeric{
|
||||||
Exp: exp,
|
Exp: exp,
|
||||||
Int: big.NewInt(0).Mul(n.Int, factor),
|
Int: big.NewInt(0).Mul(n.Int, factor),
|
||||||
@ -54,13 +54,13 @@ func (n Numeric) MatchExp(exp int32) Numeric {
|
|||||||
}}
|
}}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (n Numeric) Sub(o Numeric) Numeric {
|
func (n Numeric) Sub(other Numeric) Numeric {
|
||||||
left := n
|
left := n
|
||||||
right := o
|
right := other
|
||||||
if n.Exp < o.Exp {
|
if n.Exp < other.Exp {
|
||||||
right = o.MatchExp(n.Exp)
|
right = other.MatchExp(n.Exp)
|
||||||
} else if n.Exp > o.Exp {
|
} else if n.Exp > other.Exp {
|
||||||
left = n.MatchExp(o.Exp)
|
left = n.MatchExp(other.Exp)
|
||||||
}
|
}
|
||||||
|
|
||||||
if left.Exp == right.Exp {
|
if left.Exp == right.Exp {
|
||||||
@ -72,13 +72,14 @@ func (n Numeric) Sub(o Numeric) Numeric {
|
|||||||
|
|
||||||
panic("Cannot subtract with different exponents")
|
panic("Cannot subtract with different exponents")
|
||||||
}
|
}
|
||||||
func (n Numeric) Add(o Numeric) Numeric {
|
|
||||||
|
func (n Numeric) Add(other Numeric) Numeric {
|
||||||
left := n
|
left := n
|
||||||
right := o
|
right := other
|
||||||
if n.Exp < o.Exp {
|
if n.Exp < other.Exp {
|
||||||
right = o.MatchExp(n.Exp)
|
right = other.MatchExp(n.Exp)
|
||||||
} else if n.Exp > o.Exp {
|
} else if n.Exp > other.Exp {
|
||||||
left = n.MatchExp(o.Exp)
|
left = n.MatchExp(other.Exp)
|
||||||
}
|
}
|
||||||
|
|
||||||
if left.Exp == right.Exp {
|
if left.Exp == right.Exp {
|
||||||
|
@ -13,7 +13,6 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type YNABImport struct {
|
type YNABImport struct {
|
||||||
Context context.Context
|
|
||||||
accounts []Account
|
accounts []Account
|
||||||
payees []Payee
|
payees []Payee
|
||||||
categories []GetCategoriesRow
|
categories []GetCategoriesRow
|
||||||
@ -22,73 +21,70 @@ type YNABImport struct {
|
|||||||
budgetID uuid.UUID
|
budgetID uuid.UUID
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewYNABImport(context context.Context, q *Queries, budgetID uuid.UUID) (*YNABImport, error) {
|
func NewYNABImport(context context.Context, queries *Queries, budgetID uuid.UUID) (*YNABImport, error) {
|
||||||
accounts, err := q.GetAccounts(context, budgetID)
|
accounts, err := queries.GetAccounts(context, budgetID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
payees, err := q.GetPayees(context, budgetID)
|
payees, err := queries.GetPayees(context, budgetID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
categories, err := q.GetCategories(context, budgetID)
|
categories, err := queries.GetCategories(context, budgetID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
categoryGroups, err := q.GetCategoryGroups(context, budgetID)
|
categoryGroups, err := queries.GetCategoryGroups(context, budgetID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return &YNABImport{
|
return &YNABImport{
|
||||||
Context: context,
|
|
||||||
accounts: accounts,
|
accounts: accounts,
|
||||||
payees: payees,
|
payees: payees,
|
||||||
categories: categories,
|
categories: categories,
|
||||||
categoryGroups: categoryGroups,
|
categoryGroups: categoryGroups,
|
||||||
queries: q,
|
queries: queries,
|
||||||
budgetID: budgetID,
|
budgetID: budgetID,
|
||||||
}, nil
|
}, nil
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ImportAssignments expects a TSV-file as exported by YNAB in the following format:
|
// ImportAssignments expects a TSV-file as exported by YNAB in the following format:
|
||||||
//"Month" "Category Group/Category" "Category Group" "Category" "Budgeted" "Activity" "Available"
|
// "Month" "Category Group/Category" "Category Group" "Category" "Budgeted" "Activity" "Available"
|
||||||
//"Apr 2019" "Income: Next Month" "Income" "Next Month" 0,00€ 0,00€ 0,00€
|
// "Apr 2019" "Income: Next Month" "Income" "Next Month" 0,00€ 0,00€ 0,00€
|
||||||
//
|
//
|
||||||
// Activity and Available are not imported, since they are determined by the transactions and historic assignments
|
// Activity and Available are not imported, since they are determined by the transactions and historic assignments.
|
||||||
func (ynab *YNABImport) ImportAssignments(r io.Reader) error {
|
func (ynab *YNABImport) ImportAssignments(context context.Context, r io.Reader) error {
|
||||||
csv := csv.NewReader(r)
|
csv := csv.NewReader(r)
|
||||||
csv.Comma = '\t'
|
csv.Comma = '\t'
|
||||||
csv.LazyQuotes = true
|
csv.LazyQuotes = true
|
||||||
|
|
||||||
csvData, err := csv.ReadAll()
|
csvData, err := csv.ReadAll()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("could not read from tsv: %w", err)
|
return fmt.Errorf("read from tsv: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
count := 0
|
count := 0
|
||||||
for _, record := range csvData[1:] {
|
for _, record := range csvData[1:] {
|
||||||
|
|
||||||
dateString := record[0]
|
dateString := record[0]
|
||||||
date, err := time.Parse("Jan 2006", dateString)
|
date, err := time.Parse("Jan 2006", dateString)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("could not parse date %s: %w", dateString, err)
|
return fmt.Errorf("parse date %s: %w", dateString, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
categoryGroup, categoryName := record[2], record[3] //also in 1 joined by :
|
categoryGroup, categoryName := record[2], record[3] // also in 1 joined by :
|
||||||
category, err := ynab.GetCategory(categoryGroup, categoryName)
|
category, err := ynab.GetCategory(context, categoryGroup, categoryName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("could not get category %s/%s: %w", categoryGroup, categoryName, err)
|
return fmt.Errorf("get category %s/%s: %w", categoryGroup, categoryName, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
amountString := record[4]
|
amountString := record[4]
|
||||||
amount, err := GetAmount(amountString, "0,00€")
|
amount, err := GetAmount(amountString, "0,00€")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("could not parse amount %s: %w", amountString, err)
|
return fmt.Errorf("parse amount %s: %w", amountString, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if amount.Int.Int64() == 0 {
|
if amount.Int.Int64() == 0 {
|
||||||
@ -100,9 +96,9 @@ func (ynab *YNABImport) ImportAssignments(r io.Reader) error {
|
|||||||
CategoryID: category.UUID,
|
CategoryID: category.UUID,
|
||||||
Amount: amount,
|
Amount: amount,
|
||||||
}
|
}
|
||||||
_, err = ynab.queries.CreateAssignment(ynab.Context, assignment)
|
_, err = ynab.queries.CreateAssignment(context, assignment)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("could not save assignment %v: %w", assignment, err)
|
return fmt.Errorf("save assignment %v: %w", assignment, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
count++
|
count++
|
||||||
@ -120,150 +116,183 @@ type Transfer struct {
|
|||||||
ToAccount string
|
ToAccount string
|
||||||
}
|
}
|
||||||
|
|
||||||
// ImportTransactions expects a TSV-file as exported by YNAB in the following format:
|
// ImportTransactions expects a TSV-file as exported by YNAB.
|
||||||
|
func (ynab *YNABImport) ImportTransactions(context context.Context, r io.Reader) error {
|
||||||
func (ynab *YNABImport) ImportTransactions(r io.Reader) error {
|
|
||||||
csv := csv.NewReader(r)
|
csv := csv.NewReader(r)
|
||||||
csv.Comma = '\t'
|
csv.Comma = '\t'
|
||||||
csv.LazyQuotes = true
|
csv.LazyQuotes = true
|
||||||
|
|
||||||
csvData, err := csv.ReadAll()
|
csvData, err := csv.ReadAll()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("could not read from tsv: %w", err)
|
return fmt.Errorf("read from tsv: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var openTransfers []Transfer
|
var openTransfers []Transfer
|
||||||
|
|
||||||
count := 0
|
count := 0
|
||||||
for _, record := range csvData[1:] {
|
for _, record := range csvData[1:] {
|
||||||
accountName := record[0]
|
transaction, err := ynab.GetTransaction(context, record)
|
||||||
account, err := ynab.GetAccount(accountName)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("could not get account %s: %w", accountName, err)
|
return err
|
||||||
}
|
|
||||||
|
|
||||||
//flag := record[1]
|
|
||||||
|
|
||||||
dateString := record[2]
|
|
||||||
date, err := time.Parse("02.01.2006", dateString)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("could not parse date %s: %w", dateString, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
categoryGroup, categoryName := record[5], record[6] //also in 4 joined by :
|
|
||||||
category, err := ynab.GetCategory(categoryGroup, categoryName)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("could not get category %s/%s: %w", categoryGroup, categoryName, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
memo := record[7]
|
|
||||||
|
|
||||||
outflow := record[8]
|
|
||||||
inflow := record[9]
|
|
||||||
amount, err := GetAmount(inflow, outflow)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("could not parse amount from (%s/%s): %w", inflow, outflow, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
statusEnum := TransactionStatusUncleared
|
|
||||||
status := record[10]
|
|
||||||
switch status {
|
|
||||||
case "Cleared":
|
|
||||||
statusEnum = TransactionStatusCleared
|
|
||||||
case "Reconciled":
|
|
||||||
statusEnum = TransactionStatusReconciled
|
|
||||||
case "Uncleared":
|
|
||||||
}
|
|
||||||
|
|
||||||
transaction := CreateTransactionParams{
|
|
||||||
Date: date,
|
|
||||||
Memo: memo,
|
|
||||||
AccountID: account.ID,
|
|
||||||
CategoryID: category,
|
|
||||||
Amount: amount,
|
|
||||||
Status: statusEnum,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
payeeName := record[3]
|
payeeName := record[3]
|
||||||
|
// Transaction is a transfer
|
||||||
if strings.HasPrefix(payeeName, "Transfer : ") {
|
if strings.HasPrefix(payeeName, "Transfer : ") {
|
||||||
// Transaction is a transfer to
|
err = ynab.ImportTransferTransaction(context, payeeName, transaction.CreateTransactionParams,
|
||||||
transferToAccountName := payeeName[11:]
|
&openTransfers, transaction.Account, transaction.Amount)
|
||||||
transferToAccount, err := ynab.GetAccount(transferToAccountName)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("Could not get transfer account %s: %w", transferToAccountName, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
transfer := Transfer{
|
|
||||||
transaction,
|
|
||||||
transferToAccount,
|
|
||||||
accountName,
|
|
||||||
transferToAccountName,
|
|
||||||
}
|
|
||||||
|
|
||||||
found := false
|
|
||||||
for i, openTransfer := range openTransfers {
|
|
||||||
if openTransfer.TransferToAccount.ID != transfer.AccountID {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if openTransfer.AccountID != transfer.TransferToAccount.ID {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if openTransfer.Amount.GetFloat64() != -1*transfer.Amount.GetFloat64() {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("Matched transfers from %s to %s over %f\n", account.Name, transferToAccount.Name, amount.GetFloat64())
|
|
||||||
openTransfers[i] = openTransfers[len(openTransfers)-1]
|
|
||||||
openTransfers = openTransfers[:len(openTransfers)-1]
|
|
||||||
found = true
|
|
||||||
|
|
||||||
groupID := uuid.New()
|
|
||||||
transfer.GroupID = uuid.NullUUID{UUID: groupID, Valid: true}
|
|
||||||
openTransfer.GroupID = uuid.NullUUID{UUID: groupID, Valid: true}
|
|
||||||
|
|
||||||
_, err = ynab.queries.CreateTransaction(ynab.Context, transfer.CreateTransactionParams)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("could not save transaction %v: %w", transfer.CreateTransactionParams, err)
|
|
||||||
}
|
|
||||||
_, err = ynab.queries.CreateTransaction(ynab.Context, openTransfer.CreateTransactionParams)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("could not save transaction %v: %w", openTransfer.CreateTransactionParams, err)
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
if !found {
|
|
||||||
openTransfers = append(openTransfers, transfer)
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
payeeID, err := ynab.GetPayee(payeeName)
|
err = ynab.ImportRegularTransaction(context, payeeName, transaction.CreateTransactionParams)
|
||||||
if err != nil {
|
}
|
||||||
return fmt.Errorf("could not get payee %s: %w", payeeName, err)
|
if err != nil {
|
||||||
}
|
return err
|
||||||
transaction.PayeeID = payeeID
|
|
||||||
|
|
||||||
_, err = ynab.queries.CreateTransaction(ynab.Context, transaction)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("could not save transaction %v: %w", transaction, err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
count++
|
count++
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, openTransfer := range openTransfers {
|
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())
|
fmt.Printf("Saving unmatched transfer from %s to %s on %s over %f as regular transaction\n",
|
||||||
_, err = ynab.queries.CreateTransaction(ynab.Context, openTransfer.CreateTransactionParams)
|
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("could not save transaction %v: %w", openTransfer.CreateTransactionParams, err)
|
return fmt.Errorf("save transaction %v: %w", openTransfer.CreateTransactionParams, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("Imported %d transactions\n", count)
|
fmt.Printf("Imported %d transactions\n", count)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type NewTransaction struct {
|
||||||
|
CreateTransactionParams
|
||||||
|
Account *Account
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ynab *YNABImport) GetTransaction(context context.Context, record []string) (NewTransaction, error) {
|
||||||
|
accountName := record[0]
|
||||||
|
account, err := ynab.GetAccount(context, accountName)
|
||||||
|
if err != nil {
|
||||||
|
return NewTransaction{}, fmt.Errorf("get account %s: %w", accountName, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// flag := record[1]
|
||||||
|
|
||||||
|
dateString := record[2]
|
||||||
|
date, err := time.Parse("02.01.2006", dateString)
|
||||||
|
if err != nil {
|
||||||
|
return NewTransaction{}, fmt.Errorf("parse date %s: %w", dateString, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
categoryGroup, categoryName := record[5], record[6] // also in 4 joined by :
|
||||||
|
category, err := ynab.GetCategory(context, categoryGroup, categoryName)
|
||||||
|
if err != nil {
|
||||||
|
return NewTransaction{}, fmt.Errorf("get category %s/%s: %w", categoryGroup, categoryName, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
memo := record[7]
|
||||||
|
|
||||||
|
outflow := record[8]
|
||||||
|
inflow := record[9]
|
||||||
|
amount, err := GetAmount(inflow, outflow)
|
||||||
|
if err != nil {
|
||||||
|
return NewTransaction{}, fmt.Errorf("parse amount from (%s/%s): %w", inflow, outflow, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
statusEnum := TransactionStatusUncleared
|
||||||
|
status := record[10]
|
||||||
|
switch status {
|
||||||
|
case "Cleared":
|
||||||
|
statusEnum = TransactionStatusCleared
|
||||||
|
case "Reconciled":
|
||||||
|
statusEnum = TransactionStatusReconciled
|
||||||
|
case "Uncleared":
|
||||||
|
}
|
||||||
|
|
||||||
|
return NewTransaction{
|
||||||
|
CreateTransactionParams: CreateTransactionParams{
|
||||||
|
Date: date,
|
||||||
|
Memo: memo,
|
||||||
|
AccountID: account.ID,
|
||||||
|
CategoryID: category,
|
||||||
|
Amount: amount,
|
||||||
|
Status: statusEnum,
|
||||||
|
},
|
||||||
|
Account: account,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ynab *YNABImport) ImportRegularTransaction(context context.Context, payeeName string,
|
||||||
|
transaction CreateTransactionParams) error {
|
||||||
|
payeeID, err := ynab.GetPayee(context, payeeName)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("get payee %s: %w", payeeName, err)
|
||||||
|
}
|
||||||
|
transaction.PayeeID = payeeID
|
||||||
|
|
||||||
|
_, err = ynab.queries.CreateTransaction(context, transaction)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("save transaction %v: %w", transaction, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ynab *YNABImport) ImportTransferTransaction(context context.Context, payeeName string,
|
||||||
|
transaction CreateTransactionParams, openTransfers *[]Transfer,
|
||||||
|
account *Account, amount Numeric) error {
|
||||||
|
transferToAccountName := payeeName[11:]
|
||||||
|
transferToAccount, err := ynab.GetAccount(context, transferToAccountName)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("get transfer account %s: %w", transferToAccountName, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
transfer := Transfer{
|
||||||
|
transaction,
|
||||||
|
transferToAccount,
|
||||||
|
account.Name,
|
||||||
|
transferToAccountName,
|
||||||
|
}
|
||||||
|
|
||||||
|
found := false
|
||||||
|
for i, openTransfer := range *openTransfers {
|
||||||
|
if openTransfer.TransferToAccount.ID != transfer.AccountID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if openTransfer.AccountID != transfer.TransferToAccount.ID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if openTransfer.Amount.GetFloat64() != -1*transfer.Amount.GetFloat64() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Matched transfers from %s to %s over %f\n", account.Name, transferToAccount.Name, amount.GetFloat64())
|
||||||
|
transfers := *openTransfers
|
||||||
|
transfers[i] = transfers[len(transfers)-1]
|
||||||
|
*openTransfers = transfers[:len(transfers)-1]
|
||||||
|
found = true
|
||||||
|
|
||||||
|
groupID := uuid.New()
|
||||||
|
transfer.GroupID = uuid.NullUUID{UUID: groupID, Valid: true}
|
||||||
|
openTransfer.GroupID = uuid.NullUUID{UUID: groupID, Valid: true}
|
||||||
|
|
||||||
|
_, err = ynab.queries.CreateTransaction(context, transfer.CreateTransactionParams)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("save transaction %v: %w", transfer.CreateTransactionParams, err)
|
||||||
|
}
|
||||||
|
_, err = ynab.queries.CreateTransaction(context, openTransfer.CreateTransactionParams)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("save transaction %v: %w", openTransfer.CreateTransactionParams, err)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if !found {
|
||||||
|
*openTransfers = append(*openTransfers, transfer)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func trimLastChar(s string) string {
|
func trimLastChar(s string) string {
|
||||||
r, size := utf8.DecodeLastRuneInString(s)
|
r, size := utf8.DecodeLastRuneInString(s)
|
||||||
if r == utf8.RuneError && (size == 0 || size == 1) {
|
if r == utf8.RuneError && (size == 0 || size == 1) {
|
||||||
@ -280,7 +309,7 @@ func GetAmount(inflow string, outflow string) (Numeric, error) {
|
|||||||
num := Numeric{}
|
num := Numeric{}
|
||||||
err := num.Set(inflow)
|
err := num.Set(inflow)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return num, fmt.Errorf("Could not parse inflow %s: %w", inflow, err)
|
return num, fmt.Errorf("parse inflow %s: %w", inflow, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// if inflow is zero, use outflow
|
// if inflow is zero, use outflow
|
||||||
@ -290,19 +319,19 @@ func GetAmount(inflow string, outflow string) (Numeric, error) {
|
|||||||
|
|
||||||
err = num.Set("-" + outflow)
|
err = num.Set("-" + outflow)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return num, fmt.Errorf("Could not parse outflow %s: %w", inflow, err)
|
return num, fmt.Errorf("parse outflow %s: %w", inflow, err)
|
||||||
}
|
}
|
||||||
return num, nil
|
return num, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ynab *YNABImport) GetAccount(name string) (*Account, error) {
|
func (ynab *YNABImport) GetAccount(context context.Context, name string) (*Account, error) {
|
||||||
for _, acc := range ynab.accounts {
|
for _, acc := range ynab.accounts {
|
||||||
if acc.Name == name {
|
if acc.Name == name {
|
||||||
return &acc, nil
|
return &acc, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
account, err := ynab.queries.CreateAccount(ynab.Context, CreateAccountParams{Name: name, BudgetID: ynab.budgetID})
|
account, err := ynab.queries.CreateAccount(context, CreateAccountParams{Name: name, BudgetID: ynab.budgetID})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@ -311,7 +340,7 @@ func (ynab *YNABImport) GetAccount(name string) (*Account, error) {
|
|||||||
return &account, nil
|
return &account, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ynab *YNABImport) GetPayee(name string) (uuid.NullUUID, error) {
|
func (ynab *YNABImport) GetPayee(context context.Context, name string) (uuid.NullUUID, error) {
|
||||||
if name == "" {
|
if name == "" {
|
||||||
return uuid.NullUUID{}, nil
|
return uuid.NullUUID{}, nil
|
||||||
}
|
}
|
||||||
@ -322,7 +351,7 @@ func (ynab *YNABImport) GetPayee(name string) (uuid.NullUUID, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
payee, err := ynab.queries.CreatePayee(ynab.Context, CreatePayeeParams{Name: name, BudgetID: ynab.budgetID})
|
payee, err := ynab.queries.CreatePayee(context, CreatePayeeParams{Name: name, BudgetID: ynab.budgetID})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return uuid.NullUUID{}, err
|
return uuid.NullUUID{}, err
|
||||||
}
|
}
|
||||||
@ -331,7 +360,7 @@ func (ynab *YNABImport) GetPayee(name string) (uuid.NullUUID, error) {
|
|||||||
return uuid.NullUUID{UUID: payee.ID, Valid: true}, nil
|
return uuid.NullUUID{UUID: payee.ID, Valid: true}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ynab *YNABImport) GetCategory(group string, name string) (uuid.NullUUID, error) {
|
func (ynab *YNABImport) GetCategory(context context.Context, group string, name string) (uuid.NullUUID, error) { //nolint
|
||||||
if group == "" || name == "" {
|
if group == "" || name == "" {
|
||||||
return uuid.NullUUID{}, nil
|
return uuid.NullUUID{}, nil
|
||||||
}
|
}
|
||||||
@ -342,32 +371,25 @@ func (ynab *YNABImport) GetCategory(group string, name string) (uuid.NullUUID, e
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, categoryGroup := range ynab.categoryGroups {
|
var categoryGroup CategoryGroup
|
||||||
if categoryGroup.Name == group {
|
for _, existingGroup := range ynab.categoryGroups {
|
||||||
createCategory := CreateCategoryParams{Name: name, CategoryGroupID: categoryGroup.ID}
|
if existingGroup.Name == group {
|
||||||
category, err := ynab.queries.CreateCategory(ynab.Context, createCategory)
|
categoryGroup = existingGroup
|
||||||
if err != nil {
|
|
||||||
return uuid.NullUUID{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
getCategory := GetCategoriesRow{
|
|
||||||
ID: category.ID,
|
|
||||||
CategoryGroupID: category.CategoryGroupID,
|
|
||||||
Name: category.Name,
|
|
||||||
Group: categoryGroup.Name,
|
|
||||||
}
|
|
||||||
ynab.categories = append(ynab.categories, getCategory)
|
|
||||||
return uuid.NullUUID{UUID: category.ID, Valid: true}, nil
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
categoryGroup, err := ynab.queries.CreateCategoryGroup(ynab.Context, CreateCategoryGroupParams{Name: group, BudgetID: ynab.budgetID})
|
if categoryGroup.Name == "" {
|
||||||
if err != nil {
|
newGroup := CreateCategoryGroupParams{Name: group, BudgetID: ynab.budgetID}
|
||||||
return uuid.NullUUID{}, err
|
var err error
|
||||||
|
categoryGroup, err = ynab.queries.CreateCategoryGroup(context, newGroup)
|
||||||
|
if err != nil {
|
||||||
|
return uuid.NullUUID{}, err
|
||||||
|
}
|
||||||
|
ynab.categoryGroups = append(ynab.categoryGroups, categoryGroup)
|
||||||
}
|
}
|
||||||
ynab.categoryGroups = append(ynab.categoryGroups, categoryGroup)
|
|
||||||
|
|
||||||
category, err := ynab.queries.CreateCategory(ynab.Context, CreateCategoryParams{Name: name, CategoryGroupID: categoryGroup.ID})
|
newCategory := CreateCategoryParams{Name: name, CategoryGroupID: categoryGroup.ID}
|
||||||
|
category, err := ynab.queries.CreateCategory(context, newCategory)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return uuid.NullUUID{}, err
|
return uuid.NullUUID{}, err
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
package http
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
@ -1,4 +1,4 @@
|
|||||||
package http
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
@ -10,47 +10,53 @@ import (
|
|||||||
"git.javil.eu/jacob1123/budgeteer/bcrypt"
|
"git.javil.eu/jacob1123/budgeteer/bcrypt"
|
||||||
"git.javil.eu/jacob1123/budgeteer/jwt"
|
"git.javil.eu/jacob1123/budgeteer/jwt"
|
||||||
"git.javil.eu/jacob1123/budgeteer/postgres"
|
"git.javil.eu/jacob1123/budgeteer/postgres"
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
|
|
||||||
txdb "github.com/DATA-DOG/go-txdb"
|
txdb "github.com/DATA-DOG/go-txdb"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() { //nolint:gochecknoinits
|
||||||
txdb.Register("pgtx", "pgx", "postgres://budgeteer_test:budgeteer_test@localhost:5432/budgeteer_test")
|
txdb.Register("pgtx", "pgx", "postgres://budgeteer_test:budgeteer_test@localhost:5432/budgeteer_test")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestListTimezonesHandler(t *testing.T) {
|
func TestRegisterUser(t *testing.T) { //nolint:funlen
|
||||||
db, err := postgres.Connect("pgtx", "example")
|
t.Parallel()
|
||||||
|
database, err := postgres.Connect("pgtx", "example")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Errorf("could not connect to db: %s", err)
|
t.Errorf("could not connect to db: %s", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
h := Handler{
|
h := Handler{
|
||||||
Service: db,
|
Service: database,
|
||||||
TokenVerifier: &jwt.TokenVerifier{},
|
TokenVerifier: &jwt.TokenVerifier{
|
||||||
|
Secret: "this_is_my_demo_secret_for_unit_tests",
|
||||||
|
},
|
||||||
CredentialsVerifier: &bcrypt.Verifier{},
|
CredentialsVerifier: &bcrypt.Verifier{},
|
||||||
}
|
}
|
||||||
|
|
||||||
rr := httptest.NewRecorder()
|
recorder := httptest.NewRecorder()
|
||||||
c, engine := gin.CreateTestContext(rr)
|
context, engine := gin.CreateTestContext(recorder)
|
||||||
h.LoadRoutes(engine)
|
h.LoadRoutes(engine)
|
||||||
|
|
||||||
t.Run("RegisterUser", func(t *testing.T) {
|
t.Run("RegisterUser", func(t *testing.T) {
|
||||||
c.Request, err = http.NewRequest(http.MethodPost, "/api/v1/user/register", strings.NewReader(`{"password":"pass","email":"info@example.com","name":"Test"}`))
|
t.Parallel()
|
||||||
|
context.Request, err = http.NewRequest(
|
||||||
|
http.MethodPost,
|
||||||
|
"/api/v1/user/register",
|
||||||
|
strings.NewReader(`{"password":"pass","email":"info@example.com","name":"Test"}`))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Errorf("error creating request: %s", err)
|
t.Errorf("error creating request: %s", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
h.registerPost(c)
|
h.registerPost(context)
|
||||||
|
|
||||||
if rr.Code != http.StatusOK {
|
if recorder.Code != http.StatusOK {
|
||||||
t.Errorf("handler returned wrong status code: got %v want %v", rr.Code, http.StatusOK)
|
t.Errorf("handler returned wrong status code: got %v want %v", recorder.Code, http.StatusOK)
|
||||||
}
|
}
|
||||||
|
|
||||||
var response LoginResponse
|
var response LoginResponse
|
||||||
err = json.NewDecoder(rr.Body).Decode(&response)
|
err = json.NewDecoder(recorder.Body).Decode(&response)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Error(err.Error())
|
t.Error(err.Error())
|
||||||
t.Error("Error registering")
|
t.Error("Error registering")
|
||||||
@ -61,13 +67,14 @@ func TestListTimezonesHandler(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
t.Run("GetTransactions", func(t *testing.T) {
|
t.Run("GetTransactions", func(t *testing.T) {
|
||||||
c.Request, err = http.NewRequest(http.MethodGet, "/account/accountid/transactions", nil)
|
t.Parallel()
|
||||||
if rr.Code != http.StatusOK {
|
context.Request, err = http.NewRequest(http.MethodGet, "/account/accountid/transactions", nil)
|
||||||
t.Errorf("handler returned wrong status code: got %v want %v", rr.Code, http.StatusOK)
|
if recorder.Code != http.StatusOK {
|
||||||
|
t.Errorf("handler returned wrong status code: got %v want %v", recorder.Code, http.StatusOK)
|
||||||
}
|
}
|
||||||
|
|
||||||
var response TransactionsResponse
|
var response TransactionsResponse
|
||||||
err = json.NewDecoder(rr.Body).Decode(&response)
|
err = json.NewDecoder(recorder.Body).Decode(&response)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Error(err.Error())
|
t.Error(err.Error())
|
||||||
t.Error("Error retreiving list of transactions.")
|
t.Error("Error retreiving list of transactions.")
|
@ -1,4 +1,4 @@
|
|||||||
package http
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
@ -1,7 +1,6 @@
|
|||||||
package http
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"git.javil.eu/jacob1123/budgeteer/postgres"
|
"git.javil.eu/jacob1123/budgeteer/postgres"
|
||||||
@ -13,7 +12,7 @@ func (h *Handler) autocompleteCategories(c *gin.Context) {
|
|||||||
budgetID := c.Param("budgetid")
|
budgetID := c.Param("budgetid")
|
||||||
budgetUUID, err := uuid.Parse(budgetID)
|
budgetUUID, err := uuid.Parse(budgetID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.AbortWithError(http.StatusBadRequest, fmt.Errorf("budgetid missing from URL"))
|
c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{"budgetid missing from URL"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -35,7 +34,7 @@ func (h *Handler) autocompletePayee(c *gin.Context) {
|
|||||||
budgetID := c.Param("budgetid")
|
budgetID := c.Param("budgetid")
|
||||||
budgetUUID, err := uuid.Parse(budgetID)
|
budgetUUID, err := uuid.Parse(budgetID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.AbortWithError(http.StatusBadRequest, fmt.Errorf("budgetid missing from URL"))
|
c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{"budgetid missing from URL"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -1,10 +1,8 @@
|
|||||||
package http
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"git.javil.eu/jacob1123/budgeteer"
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -14,18 +12,17 @@ type newBudgetInformation struct {
|
|||||||
|
|
||||||
func (h *Handler) newBudget(c *gin.Context) {
|
func (h *Handler) newBudget(c *gin.Context) {
|
||||||
var newBudget newBudgetInformation
|
var newBudget newBudgetInformation
|
||||||
err := c.BindJSON(&newBudget)
|
if err := c.BindJSON(&newBudget); err != nil {
|
||||||
if err != nil {
|
|
||||||
c.AbortWithError(http.StatusNotAcceptable, err)
|
c.AbortWithError(http.StatusNotAcceptable, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if newBudget.Name == "" {
|
if newBudget.Name == "" {
|
||||||
c.AbortWithError(http.StatusNotAcceptable, fmt.Errorf("Budget name is needed"))
|
c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{"budget name is required"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
userID := c.MustGet("token").(budgeteer.Token).GetID()
|
userID := MustGetToken(c).GetID()
|
||||||
budget, err := h.Service.NewBudget(c.Request.Context(), newBudget.Name, userID)
|
budget, err := h.Service.NewBudget(c.Request.Context(), newBudget.Name, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.AbortWithError(http.StatusInternalServerError, err)
|
c.AbortWithError(http.StatusInternalServerError, err)
|
@ -1,4 +1,4 @@
|
|||||||
package http
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
@ -55,7 +55,7 @@ func (h *Handler) budgetingForMonth(c *gin.Context) {
|
|||||||
budgetID := c.Param("budgetid")
|
budgetID := c.Param("budgetid")
|
||||||
budgetUUID, err := uuid.Parse(budgetID)
|
budgetUUID, err := uuid.Parse(budgetID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.AbortWithError(http.StatusBadRequest, fmt.Errorf("budgetid missing from URL"))
|
c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{"budgetid missing from URL"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -80,16 +80,25 @@ func (h *Handler) budgetingForMonth(c *gin.Context) {
|
|||||||
firstOfNextMonth := firstOfMonth.AddDate(0, 1, 0)
|
firstOfNextMonth := firstOfMonth.AddDate(0, 1, 0)
|
||||||
cumultativeBalances, err := h.Service.GetCumultativeBalances(c.Request.Context(), budgetUUID)
|
cumultativeBalances, err := h.Service.GetCumultativeBalances(c.Request.Context(), budgetUUID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.AbortWithError(http.StatusInternalServerError, fmt.Errorf("load balances: %w", err))
|
c.AbortWithStatusJSON(http.StatusInternalServerError, ErrorResponse{fmt.Sprintf("error loading balances: %s", err)})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// skip everything in the future
|
categoriesWithBalance, moneyUsed := h.calculateBalances(
|
||||||
categoriesWithBalance, moneyUsed, err := h.calculateBalances(c, budget, firstOfNextMonth, firstOfMonth, categories, cumultativeBalances)
|
budget, firstOfNextMonth, firstOfMonth, categories, cumultativeBalances)
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
|
availableBalance := h.getAvailableBalance(categories, budget, moneyUsed, cumultativeBalances, firstOfNextMonth)
|
||||||
|
|
||||||
|
data := struct {
|
||||||
|
Categories []CategoryWithBalance
|
||||||
|
AvailableBalance postgres.Numeric
|
||||||
|
}{categoriesWithBalance, availableBalance}
|
||||||
|
c.JSON(http.StatusOK, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*Handler) getAvailableBalance(categories []postgres.GetCategoriesRow, budget postgres.Budget,
|
||||||
|
moneyUsed postgres.Numeric, cumultativeBalances []postgres.GetCumultativeBalancesRow,
|
||||||
|
firstOfNextMonth time.Time) postgres.Numeric {
|
||||||
availableBalance := postgres.NewZeroNumeric()
|
availableBalance := postgres.NewZeroNumeric()
|
||||||
for _, cat := range categories {
|
for _, cat := range categories {
|
||||||
if cat.ID != budget.IncomeCategoryID {
|
if cat.ID != budget.IncomeCategoryID {
|
||||||
@ -109,20 +118,14 @@ func (h *Handler) budgetingForMonth(c *gin.Context) {
|
|||||||
availableBalance = availableBalance.Add(bal.Transactions)
|
availableBalance = availableBalance.Add(bal.Transactions)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return availableBalance
|
||||||
data := struct {
|
|
||||||
Categories []CategoryWithBalance
|
|
||||||
AvailableBalance postgres.Numeric
|
|
||||||
}{categoriesWithBalance, availableBalance}
|
|
||||||
c.JSON(http.StatusOK, data)
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) budgeting(c *gin.Context) {
|
func (h *Handler) budgeting(c *gin.Context) {
|
||||||
budgetID := c.Param("budgetid")
|
budgetID := c.Param("budgetid")
|
||||||
budgetUUID, err := uuid.Parse(budgetID)
|
budgetUUID, err := uuid.Parse(budgetID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.AbortWithError(http.StatusBadRequest, fmt.Errorf("budgetid missing from URL"))
|
c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{"budgetid missing from URL"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -146,7 +149,9 @@ func (h *Handler) budgeting(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, data)
|
c.JSON(http.StatusOK, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) calculateBalances(c *gin.Context, budget postgres.Budget, firstOfNextMonth time.Time, firstOfMonth time.Time, categories []postgres.GetCategoriesRow, cumultativeBalances []postgres.GetCumultativeBalancesRow) ([]CategoryWithBalance, postgres.Numeric, error) {
|
func (h *Handler) calculateBalances(budget postgres.Budget,
|
||||||
|
firstOfNextMonth time.Time, firstOfMonth time.Time, categories []postgres.GetCategoriesRow,
|
||||||
|
cumultativeBalances []postgres.GetCumultativeBalancesRow) ([]CategoryWithBalance, postgres.Numeric) {
|
||||||
categoriesWithBalance := []CategoryWithBalance{}
|
categoriesWithBalance := []CategoryWithBalance{}
|
||||||
hiddenCategory := CategoryWithBalance{
|
hiddenCategory := CategoryWithBalance{
|
||||||
GetCategoriesRow: &postgres.GetCategoriesRow{
|
GetCategoriesRow: &postgres.GetCategoriesRow{
|
||||||
@ -162,39 +167,9 @@ func (h *Handler) calculateBalances(c *gin.Context, budget postgres.Budget, firs
|
|||||||
moneyUsed := postgres.NewZeroNumeric()
|
moneyUsed := postgres.NewZeroNumeric()
|
||||||
for i := range categories {
|
for i := range categories {
|
||||||
cat := &categories[i]
|
cat := &categories[i]
|
||||||
categoryWithBalance := CategoryWithBalance{
|
|
||||||
GetCategoriesRow: cat,
|
|
||||||
Available: postgres.NewZeroNumeric(),
|
|
||||||
AvailableLastMonth: postgres.NewZeroNumeric(),
|
|
||||||
Activity: postgres.NewZeroNumeric(),
|
|
||||||
Assigned: postgres.NewZeroNumeric(),
|
|
||||||
}
|
|
||||||
for _, bal := range cumultativeBalances {
|
|
||||||
if bal.CategoryID != cat.ID {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if !bal.Date.Before(firstOfNextMonth) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
moneyUsed = moneyUsed.Sub(bal.Assignments)
|
|
||||||
categoryWithBalance.Available = categoryWithBalance.Available.Add(bal.Assignments)
|
|
||||||
categoryWithBalance.Available = categoryWithBalance.Available.Add(bal.Transactions)
|
|
||||||
if !categoryWithBalance.Available.IsPositive() && bal.Date.Before(firstOfMonth) {
|
|
||||||
moneyUsed = moneyUsed.Add(categoryWithBalance.Available)
|
|
||||||
categoryWithBalance.Available = postgres.NewZeroNumeric()
|
|
||||||
}
|
|
||||||
|
|
||||||
if bal.Date.Before(firstOfMonth) {
|
|
||||||
categoryWithBalance.AvailableLastMonth = categoryWithBalance.Available
|
|
||||||
} else if bal.Date.Before(firstOfNextMonth) {
|
|
||||||
categoryWithBalance.Activity = bal.Transactions
|
|
||||||
categoryWithBalance.Assigned = bal.Assignments
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// do not show hidden categories
|
// do not show hidden categories
|
||||||
|
categoryWithBalance := h.CalculateCategoryBalances(cat, cumultativeBalances,
|
||||||
|
firstOfNextMonth, &moneyUsed, firstOfMonth, hiddenCategory, budget)
|
||||||
if cat.Group == "Hidden Categories" {
|
if cat.Group == "Hidden Categories" {
|
||||||
hiddenCategory.Available = hiddenCategory.Available.Add(categoryWithBalance.Available)
|
hiddenCategory.Available = hiddenCategory.Available.Add(categoryWithBalance.Available)
|
||||||
hiddenCategory.AvailableLastMonth = hiddenCategory.AvailableLastMonth.Add(categoryWithBalance.AvailableLastMonth)
|
hiddenCategory.AvailableLastMonth = hiddenCategory.AvailableLastMonth.Add(categoryWithBalance.AvailableLastMonth)
|
||||||
@ -212,5 +187,45 @@ func (h *Handler) calculateBalances(c *gin.Context, budget postgres.Budget, firs
|
|||||||
|
|
||||||
categoriesWithBalance = append(categoriesWithBalance, hiddenCategory)
|
categoriesWithBalance = append(categoriesWithBalance, hiddenCategory)
|
||||||
|
|
||||||
return categoriesWithBalance, moneyUsed, nil
|
return categoriesWithBalance, moneyUsed
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*Handler) CalculateCategoryBalances(cat *postgres.GetCategoriesRow,
|
||||||
|
cumultativeBalances []postgres.GetCumultativeBalancesRow, firstOfNextMonth time.Time,
|
||||||
|
moneyUsed *postgres.Numeric, firstOfMonth time.Time, hiddenCategory CategoryWithBalance,
|
||||||
|
budget postgres.Budget) CategoryWithBalance {
|
||||||
|
categoryWithBalance := CategoryWithBalance{
|
||||||
|
GetCategoriesRow: cat,
|
||||||
|
Available: postgres.NewZeroNumeric(),
|
||||||
|
AvailableLastMonth: postgres.NewZeroNumeric(),
|
||||||
|
Activity: postgres.NewZeroNumeric(),
|
||||||
|
Assigned: postgres.NewZeroNumeric(),
|
||||||
|
}
|
||||||
|
for _, bal := range cumultativeBalances {
|
||||||
|
if bal.CategoryID != cat.ID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// skip everything in the future
|
||||||
|
if !bal.Date.Before(firstOfNextMonth) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
*moneyUsed = moneyUsed.Sub(bal.Assignments)
|
||||||
|
categoryWithBalance.Available = categoryWithBalance.Available.Add(bal.Assignments)
|
||||||
|
categoryWithBalance.Available = categoryWithBalance.Available.Add(bal.Transactions)
|
||||||
|
if !categoryWithBalance.Available.IsPositive() && bal.Date.Before(firstOfMonth) {
|
||||||
|
*moneyUsed = moneyUsed.Add(categoryWithBalance.Available)
|
||||||
|
categoryWithBalance.Available = postgres.NewZeroNumeric()
|
||||||
|
}
|
||||||
|
|
||||||
|
if bal.Date.Before(firstOfMonth) {
|
||||||
|
categoryWithBalance.AvailableLastMonth = categoryWithBalance.Available
|
||||||
|
} else if bal.Date.Before(firstOfNextMonth) {
|
||||||
|
categoryWithBalance.Activity = bal.Transactions
|
||||||
|
categoryWithBalance.Assigned = bal.Assignments
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return categoryWithBalance
|
||||||
}
|
}
|
@ -1,15 +1,14 @@
|
|||||||
package http
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"git.javil.eu/jacob1123/budgeteer"
|
|
||||||
"git.javil.eu/jacob1123/budgeteer/postgres"
|
"git.javil.eu/jacob1123/budgeteer/postgres"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (h *Handler) dashboard(c *gin.Context) {
|
func (h *Handler) dashboard(c *gin.Context) {
|
||||||
userID := c.MustGet("token").(budgeteer.Token).GetID()
|
userID := MustGetToken(c).GetID()
|
||||||
budgets, err := h.Service.GetBudgetsForUser(c.Request.Context(), userID)
|
budgets, err := h.Service.GetBudgetsForUser(c.Request.Context(), userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
@ -1,50 +1,45 @@
|
|||||||
package http
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"path"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"git.javil.eu/jacob1123/budgeteer"
|
"git.javil.eu/jacob1123/budgeteer"
|
||||||
"git.javil.eu/jacob1123/budgeteer/bcrypt"
|
"git.javil.eu/jacob1123/budgeteer/bcrypt"
|
||||||
"git.javil.eu/jacob1123/budgeteer/postgres"
|
"git.javil.eu/jacob1123/budgeteer/postgres"
|
||||||
"git.javil.eu/jacob1123/budgeteer/web"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Handler handles incoming requests
|
// Handler handles incoming requests.
|
||||||
type Handler struct {
|
type Handler struct {
|
||||||
Service *postgres.Database
|
Service *postgres.Database
|
||||||
TokenVerifier budgeteer.TokenVerifier
|
TokenVerifier budgeteer.TokenVerifier
|
||||||
CredentialsVerifier *bcrypt.Verifier
|
CredentialsVerifier *bcrypt.Verifier
|
||||||
|
StaticFS http.FileSystem
|
||||||
}
|
}
|
||||||
|
|
||||||
const (
|
// Serve starts the http server.
|
||||||
expiration = 72
|
|
||||||
)
|
|
||||||
|
|
||||||
// Serve starts the http server
|
|
||||||
func (h *Handler) Serve() {
|
func (h *Handler) Serve() {
|
||||||
router := gin.Default()
|
router := gin.Default()
|
||||||
h.LoadRoutes(router)
|
h.LoadRoutes(router)
|
||||||
router.Run(":1323")
|
|
||||||
|
if err := router.Run(":1323"); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// LoadRoutes initializes all the routes
|
type ErrorResponse struct {
|
||||||
func (h *Handler) LoadRoutes(router *gin.Engine) {
|
Message string
|
||||||
static, err := fs.Sub(web.Static, "dist")
|
}
|
||||||
if err != nil {
|
|
||||||
panic("couldn't open static files")
|
|
||||||
}
|
|
||||||
staticFS := http.FS(static)
|
|
||||||
|
|
||||||
|
// LoadRoutes initializes all the routes.
|
||||||
|
func (h *Handler) LoadRoutes(router *gin.Engine) {
|
||||||
router.Use(enableCachingForStaticFiles())
|
router.Use(enableCachingForStaticFiles())
|
||||||
router.NoRoute(
|
router.NoRoute(h.ServeStatic)
|
||||||
func(c *gin.Context) {
|
|
||||||
c.FileFromFS(c.Request.URL.Path, staticFS)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
withLogin := router.Group("")
|
withLogin := router.Group("")
|
||||||
withLogin.Use(h.verifyLoginWithRedirect)
|
withLogin.Use(h.verifyLoginWithRedirect)
|
||||||
@ -82,6 +77,40 @@ func (h *Handler) LoadRoutes(router *gin.Engine) {
|
|||||||
transaction.POST("/:transactionid", 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 {
|
func enableCachingForStaticFiles() gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
if strings.HasPrefix(c.Request.RequestURI, "/static/") {
|
if strings.HasPrefix(c.Request.RequestURI, "/static/") {
|
@ -1,29 +1,36 @@
|
|||||||
package http
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
type JSONDate time.Time
|
type JSONDate time.Time
|
||||||
|
|
||||||
// Implement Marshaler and Unmarshaler interface
|
// UnmarshalJSON parses the JSONDate from a JSON input.
|
||||||
func (j *JSONDate) UnmarshalJSON(b []byte) error {
|
func (j *JSONDate) UnmarshalJSON(b []byte) error {
|
||||||
s := strings.Trim(string(b), "\"")
|
s := strings.Trim(string(b), "\"")
|
||||||
t, err := time.Parse("2006-01-02", s)
|
t, err := time.Parse("2006-01-02", s)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return fmt.Errorf("parse date: %w", err)
|
||||||
}
|
}
|
||||||
*j = JSONDate(t)
|
*j = JSONDate(t)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MarshalJSON converts the JSONDate to a JSON in ISO format.
|
||||||
func (j JSONDate) MarshalJSON() ([]byte, error) {
|
func (j JSONDate) MarshalJSON() ([]byte, error) {
|
||||||
return json.Marshal(time.Time(j))
|
result, err := json.Marshal(time.Time(j))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("marshal date: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Maybe a Format function for printing your date
|
// Format formats the time using the regular time.Time mechanics..
|
||||||
func (j JSONDate) Format(s string) string {
|
func (j JSONDate) Format(s string) string {
|
||||||
t := time.Time(j)
|
t := time.Time(j)
|
||||||
return t.Format(s)
|
return t.Format(s)
|
@ -1,4 +1,4 @@
|
|||||||
package http
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
@ -8,18 +8,34 @@ import (
|
|||||||
"git.javil.eu/jacob1123/budgeteer"
|
"git.javil.eu/jacob1123/budgeteer"
|
||||||
"git.javil.eu/jacob1123/budgeteer/postgres"
|
"git.javil.eu/jacob1123/budgeteer/postgres"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (h *Handler) verifyLogin(c *gin.Context) (budgeteer.Token, error) {
|
const (
|
||||||
tokenString := c.GetHeader("Authorization")
|
HeaderName = "Authorization"
|
||||||
if len(tokenString) < 8 {
|
Bearer = "Bearer "
|
||||||
return nil, fmt.Errorf("no authorization header supplied")
|
ParamName = "token"
|
||||||
|
)
|
||||||
|
|
||||||
|
func MustGetToken(c *gin.Context) budgeteer.Token { //nolint:ireturn
|
||||||
|
token := c.MustGet(ParamName)
|
||||||
|
if token, ok := token.(budgeteer.Token); !ok {
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
|
||||||
|
panic("Token is not a valid Token")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) verifyLogin(c *gin.Context) (budgeteer.Token, *ErrorResponse) { //nolint:ireturn
|
||||||
|
tokenString := c.GetHeader(HeaderName)
|
||||||
|
if len(tokenString) <= len(Bearer) {
|
||||||
|
return nil, &ErrorResponse{"no authorization header supplied"}
|
||||||
}
|
}
|
||||||
|
|
||||||
tokenString = tokenString[7:]
|
tokenString = tokenString[7:]
|
||||||
token, err := h.TokenVerifier.VerifyToken(tokenString)
|
token, err := h.TokenVerifier.VerifyToken(tokenString)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("verify token '%s': %w", tokenString, err)
|
return nil, &ErrorResponse{fmt.Sprintf("verify token '%s': %s", tokenString, err)}
|
||||||
}
|
}
|
||||||
|
|
||||||
return token, nil
|
return token, nil
|
||||||
@ -28,12 +44,12 @@ func (h *Handler) verifyLogin(c *gin.Context) (budgeteer.Token, error) {
|
|||||||
func (h *Handler) verifyLoginWithForbidden(c *gin.Context) {
|
func (h *Handler) verifyLoginWithForbidden(c *gin.Context) {
|
||||||
token, err := h.verifyLogin(c)
|
token, err := h.verifyLogin(c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
//c.Header("WWW-Authenticate", "Bearer")
|
// c.Header("WWW-Authenticate", "Bearer")
|
||||||
c.AbortWithError(http.StatusForbidden, err)
|
c.AbortWithStatusJSON(http.StatusForbidden, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c.Set("token", token)
|
c.Set(ParamName, token)
|
||||||
c.Next()
|
c.Next()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -45,7 +61,7 @@ func (h *Handler) verifyLoginWithRedirect(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c.Set("token", token)
|
c.Set(ParamName, token)
|
||||||
c.Next()
|
c.Next()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -72,19 +88,19 @@ func (h *Handler) loginPost(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
t, err := h.TokenVerifier.CreateToken(&user)
|
token, err := h.TokenVerifier.CreateToken(&user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.AbortWithError(http.StatusUnauthorized, err)
|
c.AbortWithError(http.StatusUnauthorized, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
go h.Service.UpdateLastLogin(context.Background(), user.ID)
|
go h.UpdateLastLogin(user.ID)
|
||||||
|
|
||||||
budgets, err := h.Service.GetBudgetsForUser(c.Request.Context(), user.ID)
|
budgets, err := h.Service.GetBudgetsForUser(c.Request.Context(), user.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, LoginResponse{t, user, budgets})
|
c.JSON(http.StatusOK, LoginResponse{token, user, budgets})
|
||||||
}
|
}
|
||||||
|
|
||||||
type LoginResponse struct {
|
type LoginResponse struct {
|
||||||
@ -101,16 +117,20 @@ type registerInformation struct {
|
|||||||
|
|
||||||
func (h *Handler) registerPost(c *gin.Context) {
|
func (h *Handler) registerPost(c *gin.Context) {
|
||||||
var register registerInformation
|
var register registerInformation
|
||||||
c.BindJSON(®ister)
|
err := c.BindJSON(®ister)
|
||||||
|
if err != nil {
|
||||||
if register.Email == "" || register.Password == "" || register.Name == "" {
|
c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{"error parsing body"})
|
||||||
c.AbortWithError(http.StatusBadRequest, fmt.Errorf("e-mail, password and name are required"))
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err := h.Service.GetUserByUsername(c.Request.Context(), register.Email)
|
if register.Email == "" || register.Password == "" || register.Name == "" {
|
||||||
|
c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{"e-mail, password and name are required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = h.Service.GetUserByUsername(c.Request.Context(), register.Email)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
c.AbortWithError(http.StatusBadRequest, fmt.Errorf("email is already taken"))
|
c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{"email is already taken"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -130,17 +150,24 @@ func (h *Handler) registerPost(c *gin.Context) {
|
|||||||
c.AbortWithError(http.StatusInternalServerError, err)
|
c.AbortWithError(http.StatusInternalServerError, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
t, err := h.TokenVerifier.CreateToken(&user)
|
token, err := h.TokenVerifier.CreateToken(&user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.AbortWithError(http.StatusUnauthorized, err)
|
c.AbortWithError(http.StatusUnauthorized, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
go h.Service.UpdateLastLogin(context.Background(), user.ID)
|
go h.UpdateLastLogin(user.ID)
|
||||||
|
|
||||||
budgets, err := h.Service.GetBudgetsForUser(c.Request.Context(), user.ID)
|
budgets, err := h.Service.GetBudgetsForUser(c.Request.Context(), user.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, LoginResponse{t, user, budgets})
|
c.JSON(http.StatusOK, LoginResponse{token, user, budgets})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) UpdateLastLogin(userID uuid.UUID) {
|
||||||
|
_, err := h.Service.UpdateLastLogin(context.Background(), userID)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Error updating last login: %s", err)
|
||||||
|
}
|
||||||
}
|
}
|
78
server/transaction.go
Normal file
78
server/transaction.go
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.javil.eu/jacob1123/budgeteer/postgres"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
type NewTransactionPayload struct {
|
||||||
|
Date JSONDate `json:"date"`
|
||||||
|
Payee struct {
|
||||||
|
ID uuid.NullUUID
|
||||||
|
Name string
|
||||||
|
} `json:"payee"`
|
||||||
|
Category struct {
|
||||||
|
ID uuid.NullUUID
|
||||||
|
Name string
|
||||||
|
} `json:"category"`
|
||||||
|
Memo string `json:"memo"`
|
||||||
|
Amount string `json:"amount"`
|
||||||
|
BudgetID uuid.UUID `json:"budgetId"`
|
||||||
|
AccountID uuid.UUID `json:"accountId"`
|
||||||
|
State string `json:"state"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) newTransaction(c *gin.Context) {
|
||||||
|
var payload NewTransactionPayload
|
||||||
|
err := c.BindJSON(&payload)
|
||||||
|
if err != nil {
|
||||||
|
c.AbortWithError(http.StatusBadRequest, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
amount := postgres.Numeric{}
|
||||||
|
err = amount.Set(payload.Amount)
|
||||||
|
if err != nil {
|
||||||
|
c.AbortWithError(http.StatusBadRequest, fmt.Errorf("amount: %w", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
payeeID := payload.Payee.ID
|
||||||
|
if !payeeID.Valid && payload.Payee.Name != "" {
|
||||||
|
newPayee := postgres.CreatePayeeParams{
|
||||||
|
Name: payload.Payee.Name,
|
||||||
|
BudgetID: payload.BudgetID,
|
||||||
|
}
|
||||||
|
payee, err := h.Service.CreatePayee(c.Request.Context(), newPayee)
|
||||||
|
if err != nil {
|
||||||
|
c.AbortWithError(http.StatusInternalServerError, fmt.Errorf("create payee: %w", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
payeeID = uuid.NullUUID{
|
||||||
|
UUID: payee.ID,
|
||||||
|
Valid: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
newTransaction := postgres.CreateTransactionParams{
|
||||||
|
Memo: payload.Memo,
|
||||||
|
Date: time.Time(payload.Date),
|
||||||
|
Amount: amount,
|
||||||
|
AccountID: payload.AccountID,
|
||||||
|
PayeeID: payeeID,
|
||||||
|
CategoryID: payload.Category.ID,
|
||||||
|
Status: postgres.TransactionStatus(payload.State),
|
||||||
|
}
|
||||||
|
transaction, err := h.Service.CreateTransaction(c.Request.Context(), newTransaction)
|
||||||
|
if err != nil {
|
||||||
|
c.AbortWithError(http.StatusInternalServerError, fmt.Errorf("create transaction: %w", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, transaction)
|
||||||
|
}
|
@ -1,7 +1,6 @@
|
|||||||
package http
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"git.javil.eu/jacob1123/budgeteer/postgres"
|
"git.javil.eu/jacob1123/budgeteer/postgres"
|
||||||
@ -12,7 +11,7 @@ import (
|
|||||||
func (h *Handler) importYNAB(c *gin.Context) {
|
func (h *Handler) importYNAB(c *gin.Context) {
|
||||||
budgetID, succ := c.Params.Get("budgetid")
|
budgetID, succ := c.Params.Get("budgetid")
|
||||||
if !succ {
|
if !succ {
|
||||||
c.AbortWithError(http.StatusBadRequest, fmt.Errorf("no budget_id specified"))
|
c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{"no budget_id specified"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -40,7 +39,7 @@ func (h *Handler) importYNAB(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
err = ynab.ImportTransactions(transactions)
|
err = ynab.ImportTransactions(c.Request.Context(), transactions)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.AbortWithError(http.StatusInternalServerError, err)
|
c.AbortWithError(http.StatusInternalServerError, err)
|
||||||
return
|
return
|
||||||
@ -58,7 +57,7 @@ func (h *Handler) importYNAB(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
err = ynab.ImportAssignments(assignments)
|
err = ynab.ImportAssignments(c.Request.Context(), assignments)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.AbortWithError(http.StatusInternalServerError, err)
|
c.AbortWithError(http.StatusInternalServerError, err)
|
||||||
return
|
return
|
4
token.go
4
token.go
@ -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)
|
||||||
|
26
web/src/api.ts
Normal file
26
web/src/api.ts
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
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,
|
||||||
|
})
|
||||||
|
}
|
@ -1,11 +1,11 @@
|
|||||||
<script lang="ts">
|
<script lang="ts" setup>
|
||||||
import { defineComponent, PropType } from "vue"
|
import { defineComponent, PropType, ref, watch } from "vue"
|
||||||
import { useAPI } from "../stores/api";
|
import { GET } from "../api";
|
||||||
import { useBudgetsStore } from "../stores/budget";
|
import { useBudgetsStore } from "../stores/budget";
|
||||||
|
|
||||||
export interface Suggestion {
|
export interface Suggestion {
|
||||||
ID : string
|
ID: string
|
||||||
Name : string
|
Name: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Data {
|
interface Data {
|
||||||
@ -14,89 +14,88 @@ interface Data {
|
|||||||
Suggestions: Suggestion[]
|
Suggestions: Suggestion[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export default defineComponent({
|
const props = defineProps<{
|
||||||
data() {
|
modelValue: Suggestion | undefined,
|
||||||
return {
|
type: String
|
||||||
Selected: undefined,
|
}>();
|
||||||
SearchQuery: this.modelValue || "",
|
|
||||||
Suggestions: new Array<Suggestion>(),
|
|
||||||
} as Data
|
|
||||||
},
|
|
||||||
props: {
|
|
||||||
modelValue: Object as PropType<Suggestion>,
|
|
||||||
type: String
|
|
||||||
},
|
|
||||||
watch: {
|
|
||||||
SearchQuery() {
|
|
||||||
this.load(this.$data.SearchQuery);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
saveTransaction(e : MouseEvent) {
|
|
||||||
e.preventDefault();
|
|
||||||
},
|
|
||||||
load(text : String) {
|
|
||||||
this.$emit('update:modelValue', {ID: null, Name: text});
|
|
||||||
if (text == ""){
|
|
||||||
this.$data.Suggestions = [];
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const api = useAPI();
|
const Selected = ref<Suggestion | undefined>(props.modelValue || undefined);
|
||||||
const budgetStore = useBudgetsStore();
|
const SearchQuery = ref(props.modelValue?.Name || "");
|
||||||
api.GET("/budget/" + budgetStore.CurrentBudgetID + "/autocomplete/" + this.type + "?s=" + text)
|
const Suggestions = ref<Array<Suggestion>>([]);
|
||||||
.then(x=>x.json())
|
const emit = defineEmits(["update:modelValue"]);
|
||||||
.then(x => {
|
watch(SearchQuery, () => {
|
||||||
let suggestions = x || [];
|
load(SearchQuery.value);
|
||||||
if(suggestions.length > 10){
|
});
|
||||||
suggestions = suggestions.slice(0, 10);
|
function saveTransaction(e: MouseEvent) {
|
||||||
}
|
e.preventDefault();
|
||||||
this.$data.Suggestions = suggestions;
|
};
|
||||||
});
|
function load(text: String) {
|
||||||
},
|
emit('update:modelValue', { ID: null, Name: text });
|
||||||
keypress(e : KeyboardEvent) {
|
if (text == "") {
|
||||||
console.log(e.key);
|
Suggestions.value = [];
|
||||||
if(e.key == "Enter") {
|
return;
|
||||||
const selected = this.$data.Suggestions[0];
|
|
||||||
this.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();
|
|
||||||
|
|
||||||
}
|
|
||||||
},
|
|
||||||
selectElement(element : Suggestion) {
|
|
||||||
this.$data.Selected = element;
|
|
||||||
this.$data.Suggestions = [];
|
|
||||||
this.$emit('update:modelValue', element);
|
|
||||||
},
|
|
||||||
select(e : MouseEvent) {
|
|
||||||
const target = (<HTMLInputElement>e.target);
|
|
||||||
const valueAttribute = target.attributes.getNamedItem("value");
|
|
||||||
let selectedID = "";
|
|
||||||
if(valueAttribute != null)
|
|
||||||
selectedID = valueAttribute.value;
|
|
||||||
const selected = this.$data.Suggestions.filter(x => x.ID == selectedID)[0];
|
|
||||||
this.selectElement(selected);
|
|
||||||
},
|
|
||||||
clear() {
|
|
||||||
this.$data.Selected = undefined;
|
|
||||||
this.$emit('update:modelValue', {ID: null, Name: this.$data.SearchQuery});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
})
|
|
||||||
|
const budgetStore = useBudgetsStore();
|
||||||
|
GET("/budget/" + budgetStore.CurrentBudgetID + "/autocomplete/" + props.type + "?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) {
|
||||||
|
console.log(e.key);
|
||||||
|
if (e.key == "Enter") {
|
||||||
|
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) {
|
||||||
|
Selected.value = element;
|
||||||
|
Suggestions.value = [];
|
||||||
|
emit('update:modelValue', element);
|
||||||
|
};
|
||||||
|
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() {
|
||||||
|
Selected.value = undefined;
|
||||||
|
emit('update:modelValue', { ID: null, Name: SearchQuery.value });
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<input class="border-b-2 border-black" @keypress="keypress" v-if="Selected == undefined" v-model="SearchQuery" />
|
<input
|
||||||
<span @click="clear" v-if="Selected != undefined" class="bg-gray-300">{{Selected.Name}}</span>
|
class="border-b-2 border-black"
|
||||||
|
@keypress="keypress"
|
||||||
|
v-if="Selected == undefined"
|
||||||
|
v-model="SearchQuery"
|
||||||
|
/>
|
||||||
|
<span @click="clear" v-if="Selected != undefined" class="bg-gray-300">{{ Selected.Name }}</span>
|
||||||
<div v-if="Suggestions.length > 0" class="absolute bg-gray-400 w-64 p-2">
|
<div v-if="Suggestions.length > 0" class="absolute bg-gray-400 w-64 p-2">
|
||||||
<span v-for="suggestion in Suggestions" class="block" @click="select" :value="suggestion.ID">
|
<span
|
||||||
{{suggestion.Name}}
|
v-for="suggestion in Suggestions"
|
||||||
</span>
|
class="block"
|
||||||
|
@click="select"
|
||||||
|
:value="suggestion.ID"
|
||||||
|
>{{ suggestion.Name }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
@ -1,9 +1,4 @@
|
|||||||
<script lang="ts">
|
<script lang="ts" setup>
|
||||||
import { defineComponent } from "vue";
|
|
||||||
|
|
||||||
export default defineComponent({
|
|
||||||
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
@ -1,19 +1,15 @@
|
|||||||
<script lang="ts">
|
<script lang="ts" setup>
|
||||||
import { defineComponent } from "vue";
|
import { computed } from 'vue';
|
||||||
|
|
||||||
export default defineComponent({
|
const props = defineProps<{ value: number | undefined }>();
|
||||||
props: ["value"],
|
|
||||||
computed: {
|
|
||||||
formattedValue() {
|
|
||||||
return Number(this.value).toLocaleString(undefined, {
|
|
||||||
minimumFractionDigits: 2,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
|
const internalValue = computed(() => Number(props.value ?? 0));
|
||||||
|
|
||||||
|
const formattedValue = computed(() => internalValue.value.toLocaleString(undefined, {
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
}));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<span class="text-right" :class="value < 0 ? 'negative' : ''">{{formattedValue}} €</span>
|
<span class="text-right" :class="internalValue < 0 ? 'negative' : ''">{{ formattedValue }} €</span>
|
||||||
</template>
|
</template>
|
61
web/src/components/TransactionInputRow.vue
Normal file
61
web/src/components/TransactionInputRow.vue
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { computed, ref } from "vue";
|
||||||
|
import Autocomplete, { Suggestion } from '../components/Autocomplete.vue'
|
||||||
|
import { useAccountStore } from '../stores/budget-account'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
budgetid: string
|
||||||
|
accountid: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const TransactionDate = ref(new Date().toISOString().substring(0, 10));
|
||||||
|
const Payee = ref<Suggestion | undefined>(undefined);
|
||||||
|
const Category = ref<Suggestion | undefined>(undefined);
|
||||||
|
const Memo = ref("");
|
||||||
|
const Amount = ref("0");
|
||||||
|
|
||||||
|
const payload = computed(() => JSON.stringify({
|
||||||
|
budget_id: props.budgetid,
|
||||||
|
account_id: props.accountid,
|
||||||
|
date: TransactionDate.value,
|
||||||
|
payee: Payee.value,
|
||||||
|
category: Category.value,
|
||||||
|
memo: Memo.value,
|
||||||
|
amount: Amount.value,
|
||||||
|
state: "Uncleared"
|
||||||
|
}));
|
||||||
|
|
||||||
|
const accountStore = useAccountStore();
|
||||||
|
function saveTransaction(e: MouseEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
accountStore.saveTransaction(payload.value);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<tr>
|
||||||
|
<td style="width: 90px;" class="text-sm">
|
||||||
|
<input class="border-b-2 border-black" type="date" v-model="TransactionDate" />
|
||||||
|
</td>
|
||||||
|
<td style="max-width: 150px;">
|
||||||
|
<Autocomplete v-model="Payee" type="payees" />
|
||||||
|
</td>
|
||||||
|
<td style="max-width: 200px;">
|
||||||
|
<Autocomplete v-model="Category" type="categories" />
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<input class="block w-full border-b-2 border-black" type="text" v-model="Memo" />
|
||||||
|
</td>
|
||||||
|
<td style="width: 80px;" class="text-right">
|
||||||
|
<input
|
||||||
|
class="text-right block w-full border-b-2 border-black"
|
||||||
|
type="currency"
|
||||||
|
v-model="Amount"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td style="width: 20px;">
|
||||||
|
<input type="submit" @click="saveTransaction" value="Save" />
|
||||||
|
</td>
|
||||||
|
<td style="width: 20px;"></td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
@ -1,16 +1,15 @@
|
|||||||
<script lang="ts">
|
<script lang="ts" setup>
|
||||||
import { mapState } from "pinia";
|
import { computed } from "vue";
|
||||||
import { defineComponent } from "vue";
|
|
||||||
import { useBudgetsStore } from "../stores/budget";
|
import { useBudgetsStore } from "../stores/budget";
|
||||||
|
import { Transaction } from "../stores/budget-account";
|
||||||
import Currency from "./Currency.vue";
|
import Currency from "./Currency.vue";
|
||||||
|
|
||||||
export default defineComponent({
|
const props = defineProps<{
|
||||||
props: [ "transaction", "index" ],
|
transaction: Transaction,
|
||||||
components: { Currency },
|
index: number,
|
||||||
computed: {
|
}>();
|
||||||
...mapState(useBudgetsStore, ["CurrentBudgetID"])
|
|
||||||
}
|
const CurrentBudgetID = computed(()=> useBudgetsStore().CurrentBudgetID);
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
@ -1,26 +1,17 @@
|
|||||||
<script lang="ts">
|
<script lang="ts" setup>
|
||||||
import Card from '../components/Card.vue';
|
import Card from '../components/Card.vue';
|
||||||
import { defineComponent } from "vue";
|
import { ref } from "vue";
|
||||||
import { useBudgetsStore } from '../stores/budget';
|
import { useBudgetsStore } from '../stores/budget';
|
||||||
|
|
||||||
export default defineComponent({
|
const dialog = ref(false);
|
||||||
data() {
|
const budgetName = ref("");
|
||||||
return {
|
function saveBudget() {
|
||||||
dialog: false,
|
useBudgetsStore().NewBudget(budgetName.value);
|
||||||
budgetName: ""
|
dialog.value = false;
|
||||||
}
|
};
|
||||||
},
|
function newBudget() {
|
||||||
components: { Card },
|
dialog.value = true;
|
||||||
methods: {
|
};
|
||||||
saveBudget() {
|
|
||||||
useBudgetsStore().NewBudget(this.$data.budgetName);
|
|
||||||
this.$data.dialog = false;
|
|
||||||
},
|
|
||||||
newBudget() {
|
|
||||||
this.$data.dialog = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
@ -1,89 +1,43 @@
|
|||||||
<script lang="ts">
|
<script lang="ts" setup>
|
||||||
import { mapState } from "pinia";
|
import { computed, ref } from "vue"
|
||||||
import { defineComponent } from "vue"
|
|
||||||
import Autocomplete, { Suggestion } from '../components/Autocomplete.vue'
|
|
||||||
import Currency from "../components/Currency.vue";
|
import Currency from "../components/Currency.vue";
|
||||||
import TransactionRow from "../components/TransactionRow.vue";
|
import TransactionRow from "../components/TransactionRow.vue";
|
||||||
import { useAPI } from "../stores/api";
|
import TransactionInputRow from "../components/TransactionInputRow.vue";
|
||||||
import { useAccountStore } from "../stores/budget-account";
|
import { useAccountStore } from "../stores/budget-account";
|
||||||
import { useSessionStore } from "../stores/session";
|
|
||||||
|
|
||||||
export default defineComponent({
|
const props = defineProps<{
|
||||||
data() {
|
budgetid: string
|
||||||
return {
|
accountid: string
|
||||||
TransactionDate: new Date().toISOString().substring(0, 10),
|
}>()
|
||||||
Payee: undefined as Suggestion | undefined,
|
|
||||||
Category: undefined as Suggestion | undefined,
|
const accountStore = useAccountStore();
|
||||||
Memo: "",
|
const CurrentAccount = computed(() => accountStore.CurrentAccount);
|
||||||
Amount: 0
|
const TransactionsList = computed(() => accountStore.TransactionsList);
|
||||||
}
|
|
||||||
},
|
|
||||||
components: { Autocomplete, Currency, TransactionRow },
|
|
||||||
props: ["budgetid", "accountid"],
|
|
||||||
computed: {
|
|
||||||
...mapState(useAccountStore, ["CurrentAccount", "TransactionsList"]),
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
saveTransaction(e : MouseEvent) {
|
|
||||||
e.preventDefault();
|
|
||||||
const api = useAPI();
|
|
||||||
api.POST("/transaction/new", JSON.stringify({
|
|
||||||
budget_id: this.budgetid,
|
|
||||||
account_id: this.accountid,
|
|
||||||
date: this.$data.TransactionDate,
|
|
||||||
payee: this.$data.Payee,
|
|
||||||
category: this.$data.Category,
|
|
||||||
memo: this.$data.Memo,
|
|
||||||
amount: this.$data.Amount,
|
|
||||||
state: "Uncleared"
|
|
||||||
}))
|
|
||||||
.then(x => x.json());
|
|
||||||
},
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<h1>{{ CurrentAccount?.Name }}</h1>
|
<h1>{{ CurrentAccount?.Name }}</h1>
|
||||||
<p>
|
<p>
|
||||||
Current Balance:
|
Current Balance:
|
||||||
<Currency :value="CurrentAccount?.Balance" />
|
<Currency :value="CurrentAccount?.Balance" />
|
||||||
</p>
|
</p>
|
||||||
<table>
|
<table>
|
||||||
<tr class="font-bold">
|
<tr class="font-bold">
|
||||||
<td style="width: 90px;">Date</td>
|
<td style="width: 90px;">Date</td>
|
||||||
<td style="max-width: 150px;">Payee</td>
|
<td style="max-width: 150px;">Payee</td>
|
||||||
<td style="max-width: 200px;">Category</td>
|
<td style="max-width: 200px;">Category</td>
|
||||||
<td>Memo</td>
|
<td>Memo</td>
|
||||||
<td class="text-right">Amount</td>
|
<td class="text-right">Amount</td>
|
||||||
<td style="width: 20px;"></td>
|
<td style="width: 20px;"></td>
|
||||||
<td style="width: 20px;"></td>
|
<td style="width: 20px;"></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<TransactionInputRow :budgetid="budgetid" :accountid="accountid" />
|
||||||
<td style="width: 90px;" class="text-sm">
|
<TransactionRow
|
||||||
<input class="border-b-2 border-black" type="date" v-model="TransactionDate" />
|
v-for="(transaction, index) in TransactionsList"
|
||||||
</td>
|
:transaction="transaction"
|
||||||
<td style="max-width: 150px;">
|
:index="index"
|
||||||
<Autocomplete v-model="Payee" type="payees" />
|
/>
|
||||||
</td>
|
</table>
|
||||||
<td style="max-width: 200px;">
|
|
||||||
<Autocomplete v-model="Category" type="categories" />
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<input class="block w-full border-b-2 border-black" type="text" v-model="Memo" />
|
|
||||||
</td>
|
|
||||||
<td style="width: 80px;" class="text-right">
|
|
||||||
<input class="text-right block w-full border-b-2 border-black" type="currency" v-model="Amount" />
|
|
||||||
</td>
|
|
||||||
<td style="width: 20px;">
|
|
||||||
<input type="submit" @click="saveTransaction" value="Save" />
|
|
||||||
</td>
|
|
||||||
<td style="width: 20px;"></td>
|
|
||||||
</tr>
|
|
||||||
<TransactionRow v-for="(transaction, index) in TransactionsList"
|
|
||||||
:transaction="transaction"
|
|
||||||
:index="index" />
|
|
||||||
</table>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
16
web/src/pages/Admin.vue
Normal file
16
web/src/pages/Admin.vue
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { onMounted } from 'vue';
|
||||||
|
import { useSessionStore } from '../stores/session';
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
useSessionStore().setTitle("Admin");
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<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>
|
||||||
|
</template>
|
@ -1,20 +1,26 @@
|
|||||||
<script lang="ts">
|
<script lang="ts" setup>
|
||||||
import { mapState } from "pinia"
|
import { computed } from "vue";
|
||||||
import { defineComponent } from "vue"
|
|
||||||
import Currency from "../components/Currency.vue"
|
import Currency from "../components/Currency.vue"
|
||||||
import { useBudgetsStore } from "../stores/budget"
|
import { useBudgetsStore } from "../stores/budget"
|
||||||
import { useAccountStore } from "../stores/budget-account"
|
import { useAccountStore } from "../stores/budget-account"
|
||||||
import { useSettingsStore } from "../stores/settings"
|
import { useSettingsStore } from "../stores/settings"
|
||||||
|
|
||||||
export default defineComponent({
|
const props = defineProps<{
|
||||||
props: ["budgetid", "accountid"],
|
budgetid: string,
|
||||||
components: { Currency },
|
accountid: string,
|
||||||
computed: {
|
}>();
|
||||||
...mapState(useSettingsStore, ["ExpandMenu"]),
|
|
||||||
...mapState(useBudgetsStore, ["CurrentBudgetName", "CurrentBudgetID"]),
|
const ExpandMenu = computed(() => useSettingsStore().Menu.Expand);
|
||||||
...mapState(useAccountStore, ["OnBudgetAccounts", "OnBudgetAccountsBalance", "OffBudgetAccounts", "OffBudgetAccountsBalance"])
|
|
||||||
}
|
const budgetStore = useBudgetsStore();
|
||||||
})
|
const CurrentBudgetName = computed(() => budgetStore.CurrentBudgetName);
|
||||||
|
const CurrentBudgetID = computed(() => budgetStore.CurrentBudgetID);
|
||||||
|
|
||||||
|
const accountStore = useAccountStore();
|
||||||
|
const OnBudgetAccounts = computed(() => accountStore.OnBudgetAccounts);
|
||||||
|
const OffBudgetAccounts = computed(() => accountStore.OffBudgetAccounts);
|
||||||
|
const OnBudgetAccountsBalance = computed(() => accountStore.OnBudgetAccountsBalance);
|
||||||
|
const OffBudgetAccountsBalance = computed(() => accountStore.OffBudgetAccountsBalance);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
@ -1,76 +1,53 @@
|
|||||||
<script lang="ts">
|
<script lang="ts" setup>
|
||||||
import { mapState } from "pinia";
|
import { computed, defineProps, onMounted, watchEffect } from "vue";
|
||||||
import { defineComponent, PropType } from "vue";
|
|
||||||
import Currency from "../components/Currency.vue";
|
import Currency from "../components/Currency.vue";
|
||||||
import { useBudgetsStore } from "../stores/budget";
|
import { useBudgetsStore } from "../stores/budget";
|
||||||
import { Category, useAccountStore } from "../stores/budget-account";
|
import { useAccountStore } from "../stores/budget-account";
|
||||||
|
import { useSessionStore } from "../stores/session";
|
||||||
|
|
||||||
interface Date {
|
const props = defineProps<{
|
||||||
Year: number,
|
budgetid: string,
|
||||||
Month: number,
|
year: string,
|
||||||
}
|
month: string,
|
||||||
|
}>()
|
||||||
|
|
||||||
export default defineComponent({
|
const budgetsStore = useBudgetsStore();
|
||||||
props: {
|
const CurrentBudgetID = computed(() => budgetsStore.CurrentBudgetID);
|
||||||
budgetid: {} as PropType<string>,
|
|
||||||
year: {} as PropType<number>,
|
const categoriesForMonth = useAccountStore().CategoriesForMonth;
|
||||||
month: {} as PropType<number>,
|
const Categories = computed(() => {
|
||||||
},
|
return [...categoriesForMonth(selected.value.Year, selected.value.Month)];
|
||||||
computed: {
|
});
|
||||||
...mapState(useBudgetsStore, ["CurrentBudgetID"]),
|
|
||||||
Categories() : Category[] {
|
const previous = computed(() => ({
|
||||||
const accountStore = useAccountStore();
|
Year: new Date(selected.value.Year, selected.value.Month - 1, 1).getFullYear(),
|
||||||
return [...accountStore.CategoriesForMonth(this.selected.Year, this.selected.Month)];
|
Month: new Date(selected.value.Year, selected.value.Month - 1, 1).getMonth(),
|
||||||
},
|
}));
|
||||||
previous() : Date {
|
const current = computed(() => ({
|
||||||
return {
|
Year: new Date().getFullYear(),
|
||||||
Year: new Date(this.selected.Year, this.selected.Month - 1, 1).getFullYear(),
|
Month: new Date().getMonth(),
|
||||||
Month: new Date(this.selected.Year, this.selected.Month - 1, 1).getMonth(),
|
}));
|
||||||
};
|
const selected = computed(() => ({
|
||||||
},
|
Year: Number(props.year) ?? current.value.Year,
|
||||||
current() : Date {
|
Month: Number(props.month ?? current.value.Month)
|
||||||
return {
|
}));
|
||||||
Year: new Date().getFullYear(),
|
const next = computed(() => ({
|
||||||
Month: new Date().getMonth(),
|
Year: new Date(selected.value.Year, Number(props.month) + 1, 1).getFullYear(),
|
||||||
};
|
Month: new Date(selected.value.Year, Number(props.month) + 1, 1).getMonth(),
|
||||||
},
|
}));
|
||||||
selected() : Date {
|
|
||||||
return {
|
watchEffect(() => {
|
||||||
Year: this.year ?? this.current.Year,
|
if (props.year != undefined && props.month != undefined)
|
||||||
Month: Number(this.month ?? this.current.Month) + 1
|
return useAccountStore().FetchMonthBudget(props.budgetid ?? "", Number(props.year), Number(props.month));
|
||||||
}
|
});
|
||||||
},
|
|
||||||
next() : Date {
|
onMounted(() => {
|
||||||
return {
|
useSessionStore().setTitle("Budget for " + selected.value.Month + "/" + selected.value.Year);
|
||||||
Year: new Date(this.selected.Year, Number(this.month) + 1, 1).getFullYear(),
|
|
||||||
Month: new Date(this.selected.Year, Number(this.month) + 1, 1).getMonth(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
},
|
|
||||||
mounted() : Promise<void> {
|
|
||||||
document.title = "Budgeteer - Budget for " + this.selected.Month + "/" + this.selected.Year;
|
|
||||||
return useAccountStore().FetchMonthBudget(this.budgetid ?? "", this.selected.Year, this.selected.Month);
|
|
||||||
},
|
|
||||||
watch: {
|
|
||||||
year() {
|
|
||||||
if (this.year != undefined && this.month != undefined)
|
|
||||||
return useAccountStore().FetchMonthBudget(this.budgetid ?? "", this.year, this.month);
|
|
||||||
},
|
|
||||||
month() {
|
|
||||||
if (this.year != undefined && this.month != undefined)
|
|
||||||
return useAccountStore().FetchMonthBudget(this.budgetid ?? "", this.year, this.month);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
components: { Currency }
|
|
||||||
})
|
})
|
||||||
|
|
||||||
/*{{define "title"}}
|
|
||||||
{{printf "Budget for %s %d" .Date.Month .Date.Year}}
|
|
||||||
{{end}}*/
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<h1>Budget for {{ selected.Month }}/{{ selected.Year }}</h1>
|
<h1>Budget for {{ selected.Month + 1 }}/{{ selected.Year }}</h1>
|
||||||
<div>
|
<div>
|
||||||
<router-link
|
<router-link
|
||||||
:to="'/budget/' + CurrentBudgetID + '/budgeting/' + previous.Year + '/' + previous.Month"
|
:to="'/budget/' + CurrentBudgetID + '/budgeting/' + previous.Year + '/' + previous.Month"
|
||||||
|
@ -1,17 +1,13 @@
|
|||||||
<script lang="ts">
|
<script lang="ts" setup>
|
||||||
import NewBudget from '../dialogs/NewBudget.vue';
|
import NewBudget from '../dialogs/NewBudget.vue';
|
||||||
import Card from '../components/Card.vue';
|
import Card from '../components/Card.vue';
|
||||||
import { defineComponent } from 'vue';
|
|
||||||
import { mapState } from 'pinia';
|
|
||||||
import { useSessionStore } from '../stores/session';
|
import { useSessionStore } from '../stores/session';
|
||||||
|
|
||||||
export default defineComponent({
|
const props = defineProps<{
|
||||||
props: ["budgetid"],
|
budgetid: string,
|
||||||
components: { NewBudget, Card },
|
}>();
|
||||||
computed: {
|
|
||||||
...mapState(useSessionStore, ["BudgetsList"]),
|
const BudgetsList = useSessionStore().BudgetsList;
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
@ -1,9 +1,4 @@
|
|||||||
<script lang="ts">
|
<script lang="ts" setup>
|
||||||
import { defineComponent } from 'vue';
|
|
||||||
|
|
||||||
export default defineComponent({
|
|
||||||
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
@ -1,46 +1,48 @@
|
|||||||
<script lang="ts">
|
<script lang="ts" setup>
|
||||||
import { defineComponent } from "vue";
|
import { onMounted, ref } from "vue";
|
||||||
|
import { useRouter } from "vue-router";
|
||||||
import { useSessionStore } from "../stores/session";
|
import { useSessionStore } from "../stores/session";
|
||||||
|
|
||||||
export default defineComponent({
|
const error = ref("");
|
||||||
data() {
|
const login = ref({ user: "", password: "" });
|
||||||
return {
|
|
||||||
error: "",
|
|
||||||
login: {
|
|
||||||
user: "",
|
|
||||||
password: ""
|
|
||||||
},
|
|
||||||
showPassword: false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
mounted() {
|
|
||||||
document.title = "Budgeteer - Login";
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
formSubmit(e : MouseEvent) {
|
|
||||||
e.preventDefault();
|
|
||||||
useSessionStore().login(this.$data.login)
|
|
||||||
.then(x => {
|
|
||||||
this.$data.error = "";
|
|
||||||
this.$router.replace("/dashboard");
|
|
||||||
})
|
|
||||||
.catch(x => this.$data.error = "The entered credentials are invalid!");
|
|
||||||
|
|
||||||
// TODO display invalidCredentials
|
onMounted(() => {
|
||||||
// TODO redirect to dashboard on success
|
useSessionStore().setTitle("Login");
|
||||||
}
|
});
|
||||||
}
|
|
||||||
})
|
function formSubmit(e: MouseEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
useSessionStore().login(login.value)
|
||||||
|
.then(x => {
|
||||||
|
error.value = "";
|
||||||
|
useRouter().replace("/dashboard");
|
||||||
|
})
|
||||||
|
.catch(x => error.value = "The entered credentials are invalid!");
|
||||||
|
|
||||||
|
// TODO display invalidCredentials
|
||||||
|
// TODO redirect to dashboard on success
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<input type="text" v-model="login.user" placeholder="Username" class="border-2 border-black rounded-lg block px-2 my-2 w-48" />
|
<input
|
||||||
<input type="password" v-model="login.password" placeholder="Password" class="border-2 border-black rounded-lg block px-2 my-2 w-48" />
|
type="text"
|
||||||
</div>
|
v-model="login.user"
|
||||||
<div>{{ error }}</div>
|
placeholder="Username"
|
||||||
<button type="submit" @click="formSubmit" class="bg-blue-300 rounded-lg p-2 w-48">Login</button>
|
class="border-2 border-black rounded-lg block px-2 my-2 w-48"
|
||||||
<p>
|
/>
|
||||||
New user? <router-link to="/register">Register</router-link> instead!
|
<input
|
||||||
</p>
|
type="password"
|
||||||
|
v-model="login.password"
|
||||||
|
placeholder="Password"
|
||||||
|
class="border-2 border-black rounded-lg block px-2 my-2 w-48"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>{{ error }}</div>
|
||||||
|
<button type="submit" @click="formSubmit" class="bg-blue-300 rounded-lg p-2 w-48">Login</button>
|
||||||
|
<p>
|
||||||
|
New user?
|
||||||
|
<router-link to="/register">Register</router-link>instead!
|
||||||
|
</p>
|
||||||
</template>
|
</template>
|
@ -1,31 +1,20 @@
|
|||||||
<script lang="ts">
|
<script lang="ts" setup>
|
||||||
import { defineComponent } from 'vue';
|
import { ref } from 'vue';
|
||||||
import { useSessionStore } from '../stores/session';
|
import { useSessionStore } from '../stores/session';
|
||||||
|
|
||||||
export default defineComponent({
|
const error = ref("");
|
||||||
data() {
|
const login = ref({ email: "", password: "", name: "" });
|
||||||
return {
|
const showPassword = ref(false);
|
||||||
showPassword: false,
|
|
||||||
error: "",
|
|
||||||
login: {
|
|
||||||
email: "",
|
|
||||||
password: "",
|
|
||||||
name: "",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
formSubmit (e : FormDataEvent) {
|
|
||||||
e.preventDefault();
|
|
||||||
useSessionStore().register(this.$data.login)
|
|
||||||
.then(() => this.$data.error = "")
|
|
||||||
.catch(() => this.$data.error = "Something went wrong!");
|
|
||||||
|
|
||||||
// TODO display invalidCredentials
|
function formSubmit(e: FormDataEvent) {
|
||||||
// TODO redirect to dashboard on success
|
e.preventDefault();
|
||||||
}
|
useSessionStore().register(login)
|
||||||
}
|
.then(() => error.value = "")
|
||||||
})
|
.catch(() => error.value = "Something went wrong!");
|
||||||
|
|
||||||
|
// TODO display invalidCredentials
|
||||||
|
// TODO redirect to dashboard on success
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@ -38,30 +27,35 @@ export default defineComponent({
|
|||||||
<v-text-field v-model="login.name" type="text" label="Name" />
|
<v-text-field v-model="login.name" type="text" label="Name" />
|
||||||
</v-col>
|
</v-col>
|
||||||
<v-col cols="6">
|
<v-col cols="6">
|
||||||
<v-text-field v-model="login.password" label="Password"
|
<v-text-field
|
||||||
|
v-model="login.password"
|
||||||
|
label="Password"
|
||||||
:append-icon="showPassword ? 'mdi-eye' : 'mdi-eye-off'"
|
:append-icon="showPassword ? 'mdi-eye' : 'mdi-eye-off'"
|
||||||
:type="showPassword ? 'text' : 'password'"
|
:type="showPassword ? 'text' : 'password'"
|
||||||
@click:append="showPassword = showPassword"
|
@click:append="showPassword = showPassword"
|
||||||
:error-message="error"
|
:error-message="error"
|
||||||
error-count="2"
|
error-count="2"
|
||||||
error />
|
error
|
||||||
|
/>
|
||||||
</v-col>
|
</v-col>
|
||||||
<v-col cols="6">
|
<v-col cols="6">
|
||||||
<v-text-field v-model="login.password" label="Repeat password"
|
<v-text-field
|
||||||
|
v-model="login.password"
|
||||||
|
label="Repeat password"
|
||||||
:append-icon="showPassword ? 'mdi-eye' : 'mdi-eye-off'"
|
:append-icon="showPassword ? 'mdi-eye' : 'mdi-eye-off'"
|
||||||
:type="showPassword ? 'text' : 'password'"
|
:type="showPassword ? 'text' : 'password'"
|
||||||
@click:append="showPassword = showPassword"
|
@click:append="showPassword = showPassword"
|
||||||
:error-message="error"
|
:error-message="error"
|
||||||
error-count="2"
|
error-count="2"
|
||||||
error />
|
error
|
||||||
|
/>
|
||||||
</v-col>
|
</v-col>
|
||||||
</v-row>
|
</v-row>
|
||||||
<div class="form-group">
|
<div class="form-group">{{ error }}</div>
|
||||||
{{ error }}
|
|
||||||
</div>
|
|
||||||
<v-btn type="submit" @click="formSubmit">Register</v-btn>
|
<v-btn type="submit" @click="formSubmit">Register</v-btn>
|
||||||
<p>
|
<p>
|
||||||
Existing user? <router-link to="/login">Login</router-link> instead!
|
Existing user?
|
||||||
</p>
|
<router-link to="/login">Login</router-link>instead!
|
||||||
|
</p>
|
||||||
</v-container>
|
</v-container>
|
||||||
</template>
|
</template>
|
||||||
|
@ -1,67 +1,56 @@
|
|||||||
<script lang="ts">
|
<script lang="ts" setup>
|
||||||
import { defineComponent } from "vue"
|
import { computed, defineComponent, onMounted, ref } from "vue"
|
||||||
import { useAPI } from "../stores/api";
|
import { useRouter } from "vue-router";
|
||||||
|
import { DELETE, POST } from "../api";
|
||||||
import { useBudgetsStore } from "../stores/budget";
|
import { useBudgetsStore } from "../stores/budget";
|
||||||
import { useSessionStore } from "../stores/session";
|
import { useSessionStore } from "../stores/session";
|
||||||
|
|
||||||
export default defineComponent({
|
const transactionsFile = ref<File | undefined>(undefined);
|
||||||
data() {
|
const assignmentsFile = ref<File | undefined>(undefined);
|
||||||
return {
|
|
||||||
transactionsFile: undefined as File | undefined,
|
|
||||||
assignmentsFile: undefined as File | undefined
|
|
||||||
}
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
filesIncomplete() : boolean {
|
|
||||||
return this.$data.transactionsFile == undefined || this.$data.assignmentsFile == undefined;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
mounted() {
|
|
||||||
document.title = "Budgeteer - Settings";
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
gotAssignments(e : Event) {
|
|
||||||
const input = (<HTMLInputElement>e.target);
|
|
||||||
if(input.files != null)
|
|
||||||
this.$data.assignmentsFile = input.files[0];
|
|
||||||
},
|
|
||||||
gotTransactions(e : Event) {
|
|
||||||
const input = (<HTMLInputElement>e.target);
|
|
||||||
if(input.files != null)
|
|
||||||
this.$data.transactionsFile = input.files[0];
|
|
||||||
},
|
|
||||||
deleteBudget() {
|
|
||||||
const currentBudgetID = useBudgetsStore().CurrentBudgetID;
|
|
||||||
if (currentBudgetID == null)
|
|
||||||
return;
|
|
||||||
|
|
||||||
const api = useAPI();
|
const filesIncomplete = computed(() => transactionsFile.value == undefined || assignmentsFile.value == undefined);
|
||||||
api.DELETE("/budget/" + currentBudgetID);
|
onMounted(() => {
|
||||||
|
useSessionStore().setTitle("Settings");
|
||||||
|
});
|
||||||
|
|
||||||
const budgetStore = useSessionStore();
|
function gotAssignments(e: Event) {
|
||||||
budgetStore.Budgets.delete(currentBudgetID);
|
const input = (<HTMLInputElement>e.target);
|
||||||
this.$router.push("/")
|
if (input.files != null)
|
||||||
},
|
assignmentsFile.value = input.files[0];
|
||||||
clearBudget() {
|
}
|
||||||
const currentBudgetID = useBudgetsStore().CurrentBudgetID;
|
function gotTransactions(e: Event) {
|
||||||
const api = useAPI();
|
const input = (<HTMLInputElement>e.target);
|
||||||
api.POST("/budget/" + currentBudgetID + "/settings/clear", null)
|
if (input.files != null)
|
||||||
},
|
transactionsFile.value = input.files[0];
|
||||||
cleanNegative() {
|
};
|
||||||
// <a href="/budget/{{.Budget.ID}}/settings/clean-negative">Fix all historic negative category-balances</a>
|
function deleteBudget() {
|
||||||
},
|
const currentBudgetID = useBudgetsStore().CurrentBudgetID;
|
||||||
ynabImport() {
|
if (currentBudgetID == null)
|
||||||
if (this.$data.transactionsFile == undefined || this.$data.assignmentsFile == undefined)
|
return;
|
||||||
return
|
|
||||||
|
|
||||||
let formData = new FormData();
|
DELETE("/budget/" + currentBudgetID);
|
||||||
formData.append("transactions", this.$data.transactionsFile);
|
|
||||||
formData.append("assignments", this.$data.assignmentsFile);
|
const budgetStore = useSessionStore();
|
||||||
const budgetStore = useBudgetsStore();
|
budgetStore.Budgets.delete(currentBudgetID);
|
||||||
budgetStore.ImportYNAB(formData);
|
useRouter().push("/")
|
||||||
}
|
};
|
||||||
}
|
function clearBudget() {
|
||||||
})
|
const currentBudgetID = useBudgetsStore().CurrentBudgetID;
|
||||||
|
POST("/budget/" + currentBudgetID + "/settings/clear", null)
|
||||||
|
};
|
||||||
|
function cleanNegative() {
|
||||||
|
// <a href="/budget/{{.Budget.ID}}/settings/clean-negative">Fix all historic negative category-balances</a>
|
||||||
|
};
|
||||||
|
function ynabImport() {
|
||||||
|
if (transactionsFile.value == undefined || assignmentsFile.value == undefined)
|
||||||
|
return
|
||||||
|
|
||||||
|
let formData = new FormData();
|
||||||
|
formData.append("transactions", transactionsFile.value);
|
||||||
|
formData.append("assignments", assignmentsFile.value);
|
||||||
|
const budgetStore = useBudgetsStore();
|
||||||
|
budgetStore.ImportYNAB(formData);
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@ -126,10 +115,7 @@ export default defineComponent({
|
|||||||
</label>
|
</label>
|
||||||
|
|
||||||
<v-card-actions class="justify-center">
|
<v-card-actions class="justify-center">
|
||||||
<v-btn
|
<v-btn :disabled="filesIncomplete" @click="ynabImport">Importieren</v-btn>
|
||||||
:disabled="filesIncomplete"
|
|
||||||
@click="ynabImport"
|
|
||||||
>Importieren</v-btn>
|
|
||||||
</v-card-actions>
|
</v-card-actions>
|
||||||
</v-card>
|
</v-card>
|
||||||
</v-col>
|
</v-col>
|
||||||
|
@ -1,28 +0,0 @@
|
|||||||
import { defineStore } from "pinia";
|
|
||||||
import { useSessionStore } from "./session";
|
|
||||||
|
|
||||||
export const useAPI = defineStore("api", {
|
|
||||||
actions: {
|
|
||||||
GET(path : string) {
|
|
||||||
const sessionStore = useSessionStore();
|
|
||||||
return fetch("/api/v1" + path, {
|
|
||||||
headers: sessionStore.AuthHeaders,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
POST(path : string, body : FormData | string | null) {
|
|
||||||
const sessionStore = useSessionStore();
|
|
||||||
return fetch("/api/v1" + path, {
|
|
||||||
method: "POST",
|
|
||||||
headers: sessionStore.AuthHeaders,
|
|
||||||
body: body,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
DELETE(path : string) {
|
|
||||||
const sessionStore = useSessionStore();
|
|
||||||
return fetch("/api/v1" + path, {
|
|
||||||
method: "DELETE",
|
|
||||||
headers: sessionStore.AuthHeaders,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
}
|
|
||||||
});
|
|
@ -1,5 +1,5 @@
|
|||||||
import { defineStore } from "pinia"
|
import { defineStore } from "pinia"
|
||||||
import { useAPI } from "./api";
|
import { GET, POST } from "../api";
|
||||||
import { useSessionStore } from "./session";
|
import { useSessionStore } from "./session";
|
||||||
|
|
||||||
interface State {
|
interface State {
|
||||||
@ -7,15 +7,28 @@ interface State {
|
|||||||
CurrentAccountID: string | null,
|
CurrentAccountID: string | null,
|
||||||
Categories: Map<string, Category>,
|
Categories: Map<string, Category>,
|
||||||
Months: Map<number, Map<number, Map<string, Category>>>,
|
Months: Map<number, Map<number, Map<string, Category>>>,
|
||||||
Transactions: [],
|
Transactions: any[],
|
||||||
Assignments: []
|
Assignments: []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Transaction {
|
||||||
|
ID: string,
|
||||||
|
Date: string,
|
||||||
|
TransferAccount: string,
|
||||||
|
CategoryGroup: string,
|
||||||
|
Category: string,
|
||||||
|
Memo: string,
|
||||||
|
Status: string,
|
||||||
|
GroupID: string,
|
||||||
|
Payee: string,
|
||||||
|
Amount: number,
|
||||||
|
}
|
||||||
|
|
||||||
export interface Account {
|
export interface Account {
|
||||||
ID: string
|
ID: string
|
||||||
Name: string
|
Name: string
|
||||||
OnBudget: boolean
|
OnBudget: boolean
|
||||||
Balance: Number
|
Balance: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Category {
|
export interface Category {
|
||||||
@ -39,29 +52,30 @@ export const useAccountStore = defineStore("budget/account", {
|
|||||||
}),
|
}),
|
||||||
getters: {
|
getters: {
|
||||||
AccountsList(state) {
|
AccountsList(state) {
|
||||||
return [ ...state.Accounts.values() ];
|
return [...state.Accounts.values()];
|
||||||
},
|
},
|
||||||
CategoriesForMonth: (state) => (year : number, month : number) => {
|
CategoriesForMonth: (state) => (year: number, month: number) => {
|
||||||
console.log("MTH", state.Months)
|
|
||||||
const yearMap = state.Months.get(year);
|
const yearMap = state.Months.get(year);
|
||||||
return [ ...yearMap?.get(month)?.values() || [] ];
|
const monthMap = yearMap?.get(month);
|
||||||
|
console.log("MTH", monthMap)
|
||||||
|
return [...monthMap?.values() || []];
|
||||||
},
|
},
|
||||||
CurrentAccount(state) : Account | undefined {
|
CurrentAccount(state): Account | undefined {
|
||||||
if (state.CurrentAccountID == null)
|
if (state.CurrentAccountID == null)
|
||||||
return undefined;
|
return undefined;
|
||||||
|
|
||||||
return state.Accounts.get(state.CurrentAccountID);
|
return state.Accounts.get(state.CurrentAccountID);
|
||||||
},
|
},
|
||||||
OnBudgetAccounts(state) {
|
OnBudgetAccounts(state) {
|
||||||
return [ ...state.Accounts.values() ].filter(x => x.OnBudget);
|
return [...state.Accounts.values()].filter(x => x.OnBudget);
|
||||||
},
|
},
|
||||||
OnBudgetAccountsBalance(state) : Number {
|
OnBudgetAccountsBalance(state): number {
|
||||||
return this.OnBudgetAccounts.reduce((prev, curr) => prev + Number(curr.Balance), 0);
|
return this.OnBudgetAccounts.reduce((prev, curr) => prev + Number(curr.Balance), 0);
|
||||||
},
|
},
|
||||||
OffBudgetAccounts(state) {
|
OffBudgetAccounts(state) {
|
||||||
return [ ...state.Accounts.values() ].filter(x => !x.OnBudget);
|
return [...state.Accounts.values()].filter(x => !x.OnBudget);
|
||||||
},
|
},
|
||||||
OffBudgetAccountsBalance(state) : Number {
|
OffBudgetAccountsBalance(state): number {
|
||||||
return this.OffBudgetAccounts.reduce((prev, curr) => prev + Number(curr.Balance), 0);
|
return this.OffBudgetAccounts.reduce((prev, curr) => prev + Number(curr.Balance), 0);
|
||||||
},
|
},
|
||||||
TransactionsList(state) {
|
TransactionsList(state) {
|
||||||
@ -69,7 +83,7 @@ export const useAccountStore = defineStore("budget/account", {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
async SetCurrentAccount(budgetid : string, accountid : string) {
|
async SetCurrentAccount(budgetid: string, accountid: string) {
|
||||||
if (budgetid == null)
|
if (budgetid == null)
|
||||||
return
|
return
|
||||||
|
|
||||||
@ -80,32 +94,36 @@ export const useAccountStore = defineStore("budget/account", {
|
|||||||
useSessionStore().setTitle(this.CurrentAccount.Name);
|
useSessionStore().setTitle(this.CurrentAccount.Name);
|
||||||
await this.FetchAccount(accountid);
|
await this.FetchAccount(accountid);
|
||||||
},
|
},
|
||||||
async FetchAccount(accountid : string) {
|
async FetchAccount(accountid: string) {
|
||||||
const api = useAPI();
|
const result = await GET("/account/" + accountid + "/transactions");
|
||||||
const result = await api.GET("/account/" + accountid + "/transactions");
|
|
||||||
const response = await result.json();
|
const response = await result.json();
|
||||||
this.Transactions = response.Transactions;
|
this.Transactions = response.Transactions;
|
||||||
},
|
},
|
||||||
async FetchMonthBudget(budgetid : string, year : number, month : number) {
|
async FetchMonthBudget(budgetid: string, year: number, month: number) {
|
||||||
const api = useAPI();
|
const result = await GET("/budget/" + budgetid + "/" + year + "/" + month);
|
||||||
const result = await api.GET("/budget/" + budgetid + "/" + year + "/" + month);
|
|
||||||
const response = await result.json();
|
const response = await result.json();
|
||||||
this.addCategoriesForMonth(year, month, response.Categories);
|
this.addCategoriesForMonth(year, month, response.Categories);
|
||||||
},
|
},
|
||||||
addCategoriesForMonth(year : number, month : number, categories : Category[]) : void {
|
addCategoriesForMonth(year: number, month: number, categories: Category[]): void {
|
||||||
const yearMap = this.Months.get(year) || new Map<number, Map<string, Category>>();
|
this.$patch((state) => {
|
||||||
this.Months.set(year, yearMap);
|
const yearMap = state.Months.get(year) || new Map<number, Map<string, Category>>();
|
||||||
|
const monthMap = yearMap.get(month) || new Map<string, Category>();
|
||||||
|
for (const category of categories) {
|
||||||
|
monthMap.set(category.ID, category);
|
||||||
|
}
|
||||||
|
|
||||||
const monthMap = yearMap.get(month) || new Map<string, Category>();
|
yearMap.set(month, monthMap);
|
||||||
yearMap.set(month, monthMap);
|
state.Months.set(year, yearMap);
|
||||||
|
});
|
||||||
for (const category of categories){
|
|
||||||
monthMap.set(category.ID, category);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
logout() {
|
logout() {
|
||||||
this.$reset()
|
this.$reset()
|
||||||
},
|
},
|
||||||
|
async saveTransaction(payload: string) {
|
||||||
|
const result = await POST("/transaction/new", payload);
|
||||||
|
const response = await result.json();
|
||||||
|
this.Transactions.unshift(response);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
})
|
})
|
@ -1,5 +1,5 @@
|
|||||||
import { defineStore } from "pinia";
|
import { defineStore } from "pinia";
|
||||||
import { useAPI } from "./api";
|
import { GET, POST } from "../api";
|
||||||
import { useAccountStore } from "./budget-account";
|
import { useAccountStore } from "./budget-account";
|
||||||
import { Budget, useSessionStore } from "./session";
|
import { Budget, useSessionStore } from "./session";
|
||||||
|
|
||||||
@ -25,15 +25,13 @@ export const useBudgetsStore = defineStore('budget', {
|
|||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
ImportYNAB(formData: FormData) {
|
ImportYNAB(formData: FormData) {
|
||||||
const api = useAPI();
|
return POST(
|
||||||
return api.POST(
|
|
||||||
"/budget/" + this.CurrentBudgetID + "/import/ynab",
|
"/budget/" + this.CurrentBudgetID + "/import/ynab",
|
||||||
formData,
|
formData,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
async NewBudget(budgetName: string): Promise<void> {
|
async NewBudget(budgetName: string): Promise<void> {
|
||||||
const api = useAPI();
|
const result = await POST(
|
||||||
const result = await api.POST(
|
|
||||||
"/budget/new",
|
"/budget/new",
|
||||||
JSON.stringify({ name: budgetName })
|
JSON.stringify({ name: budgetName })
|
||||||
);
|
);
|
||||||
@ -51,8 +49,7 @@ export const useBudgetsStore = defineStore('budget', {
|
|||||||
await this.FetchBudget(budgetid);
|
await this.FetchBudget(budgetid);
|
||||||
},
|
},
|
||||||
async FetchBudget(budgetid: string) {
|
async FetchBudget(budgetid: string) {
|
||||||
const api = useAPI();
|
const result = await GET("/budget/" + budgetid);
|
||||||
const result = await api.GET("/budget/" + budgetid);
|
|
||||||
const response = await result.json();
|
const response = await result.json();
|
||||||
for (const account of response.Accounts || []) {
|
for (const account of response.Accounts || []) {
|
||||||
useAccountStore().Accounts.set(account.ID, account);
|
useAccountStore().Accounts.set(account.ID, account);
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { StorageSerializers, useStorage } from '@vueuse/core';
|
import { StorageSerializers, useStorage } from '@vueuse/core';
|
||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { useAPI } from './api';
|
import { POST } from '../api';
|
||||||
|
|
||||||
interface State {
|
interface State {
|
||||||
Session: Session | null
|
Session: Session | null
|
||||||
@ -21,7 +21,7 @@ export interface Budget {
|
|||||||
export const useSessionStore = defineStore('session', {
|
export const useSessionStore = defineStore('session', {
|
||||||
state: () => ({
|
state: () => ({
|
||||||
Session: useStorage<Session>('session', null, undefined, { serializer: StorageSerializers.object }),
|
Session: useStorage<Session>('session', null, undefined, { serializer: StorageSerializers.object }),
|
||||||
Budgets: useStorage<Map<string, Budget>>('budgets', new Map<string, Budget>()),
|
Budgets: useStorage<Map<string, Budget>>('budgets', new Map<string, Budget>(), undefined, { serializer: StorageSerializers.map }),
|
||||||
}),
|
}),
|
||||||
getters: {
|
getters: {
|
||||||
BudgetsList: (state) => [ ...state.Budgets.values() ],
|
BudgetsList: (state) => [ ...state.Budgets.values() ],
|
||||||
@ -40,14 +40,12 @@ export const useSessionStore = defineStore('session', {
|
|||||||
this.Budgets = x.Budgets;
|
this.Budgets = x.Budgets;
|
||||||
},
|
},
|
||||||
async login(login: any) {
|
async login(login: any) {
|
||||||
const api = useAPI();
|
const response = await POST("/user/login", JSON.stringify(login));
|
||||||
const response = await api.POST("/user/login", JSON.stringify(login));
|
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
return this.loginSuccess(result);
|
return this.loginSuccess(result);
|
||||||
},
|
},
|
||||||
async register(login : any) {
|
async register(login : any) {
|
||||||
const api = useAPI();
|
const response = await POST("/user/register", JSON.stringify(login));
|
||||||
const response = await api.POST("/user/register", JSON.stringify(login));
|
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
return this.loginSuccess(result);
|
return this.loginSuccess(result);
|
||||||
},
|
},
|
||||||
|
Reference in New Issue
Block a user