Compare commits
112 Commits
0.6.0
...
5741236e2c
Author | SHA1 | Date | |
---|---|---|---|
5741236e2c | |||
99549fb441 | |||
67c79e252e | |||
3b1174225a | |||
92f56b1046 | |||
f068dd5009 | |||
ebc34d7031 | |||
03ea4a31ad | |||
8636d04b84 | |||
c63a8bc5d3 | |||
0aae7236ae | |||
91ed57f83d | |||
0817f18e33 | |||
704a520993 | |||
a48509e041 | |||
b812b19b34 | |||
0667e5a779 | |||
f47ede2060 | |||
91ac05b575 | |||
c30b33a070 | |||
4fa227c452 | |||
cfb1dbe276 | |||
8f99499c2f | |||
d22816dfd1 | |||
75a1839456 | |||
9fd70cd773 | |||
3b465f17cf | |||
f56d095016 | |||
741589dc71 | |||
44254ed4d0 | |||
777520e9df | |||
07c0c56d11 | |||
43a4647d74 | |||
c595f016b8 | |||
6702a775fb | |||
e4df2f0de5 | |||
ff06693f60 | |||
5c5429ab05 | |||
65424a2587 | |||
4563fcbbaf | |||
df7b691bdd | |||
f5274f69fc | |||
c50fc63303 | |||
88e1dbdfde | |||
0e84cdfa5f | |||
d29405bef3 | |||
46e665efc5 | |||
f3eb19ef91 | |||
2ca05b5d48 | |||
ed2eb53f93 | |||
8bf4fcfd42 | |||
38222c0777 | |||
f43feb66be | |||
49be1d5be6 | |||
8ec69e4b8f | |||
6b3026dc8e | |||
9b5119d3dd | |||
08f48a63ad | |||
c4fc80e47d | |||
23bd12147c | |||
3ca7bfeade | |||
249aa534c8 | |||
76ee88a1c6 | |||
bb664494f6 | |||
4f681d6d89 | |||
11df0fbff1 | |||
a2280e50ec | |||
ed321d3895 | |||
8fe91293e1 | |||
35690681cf | |||
54e591bc5e | |||
01c407d4f1 | |||
cef62574bb | |||
ae529665a3 | |||
2eba947d9d | |||
4d70f50683 | |||
9b67e700be | |||
cce9f29f17 | |||
eb2b31e622 | |||
967f1c71e5 | |||
585951d5e2 | |||
e7af03b702 | |||
a1ec7e9997 | |||
880ed731f8 | |||
a46ddded67 | |||
56485b8deb | |||
f67f6ff0d8 | |||
150a7d562b | |||
49d96be1e3 | |||
8df7968175 | |||
bd99f58ab4 | |||
9471c5b63b | |||
60d6967728 | |||
1913d9eaf0 | |||
8954cffb7a | |||
95eb302d26 | |||
93679c2932 | |||
cb8ce79107 | |||
80c0f0a231 | |||
d73eeb1b40 | |||
ce466e0031 | |||
684efffbdf | |||
cf39db52fb | |||
f0993fd9c3 | |||
5f6bea4ee2 | |||
4332a1537b | |||
2feefea737 | |||
2d9c380cf4 | |||
4a66d9fdfc | |||
967ea784dc | |||
23b7a4dbdd | |||
4798cb95b0 |
18
.drone.yml
18
.drone.yml
@ -4,6 +4,11 @@ type: docker
|
|||||||
name: budgeteer
|
name: budgeteer
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
|
- name: submodules
|
||||||
|
image: alpine/git
|
||||||
|
commands:
|
||||||
|
- git submodule update --recursive --init
|
||||||
|
|
||||||
- name: Taskfile.dev PR
|
- name: Taskfile.dev PR
|
||||||
image: hub.javil.eu/budgeteer:dev
|
image: hub.javil.eu/budgeteer:dev
|
||||||
commands:
|
commands:
|
||||||
@ -18,9 +23,10 @@ steps:
|
|||||||
commands:
|
commands:
|
||||||
- task ci
|
- task ci
|
||||||
when:
|
when:
|
||||||
|
branch:
|
||||||
|
- master
|
||||||
event:
|
event:
|
||||||
exclude:
|
- push
|
||||||
- pull_request
|
|
||||||
|
|
||||||
- name: docker
|
- name: docker
|
||||||
image: plugins/docker
|
image: plugins/docker
|
||||||
@ -57,5 +63,13 @@ steps:
|
|||||||
event:
|
event:
|
||||||
- tag
|
- tag
|
||||||
|
|
||||||
|
services:
|
||||||
|
- name: db
|
||||||
|
image: postgres:alpine
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: budgeteer
|
||||||
|
POSTGRES_PASSWORD: budgeteer
|
||||||
|
POSTGRES_DB: budgeteer_test
|
||||||
|
|
||||||
image_pull_secrets:
|
image_pull_secrets:
|
||||||
- hub.javil.eu
|
- hub.javil.eu
|
3
.gitmodules
vendored
Normal file
3
.gitmodules
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
[submodule "testdata"]
|
||||||
|
path = testdata
|
||||||
|
url = https://git.javil.eu/jacob1123/budgeteer-testdata.git
|
4
.vscode/settings.json
vendored
4
.vscode/settings.json
vendored
@ -9,5 +9,7 @@
|
|||||||
},
|
},
|
||||||
"gopls": {
|
"gopls": {
|
||||||
"formatting.gofumpt": true,
|
"formatting.gofumpt": true,
|
||||||
}
|
},
|
||||||
|
"editor.detectIndentation": false,
|
||||||
|
"editor.tabSize": 2
|
||||||
}
|
}
|
153
Taskfile.yml
153
Taskfile.yml
@ -5,30 +5,31 @@ vars:
|
|||||||
|
|
||||||
tasks:
|
tasks:
|
||||||
default:
|
default:
|
||||||
|
desc: Build budgeteer in production mode
|
||||||
|
deps: [frontend, go-mod, go-sqlc]
|
||||||
cmds:
|
cmds:
|
||||||
- task: build-prod
|
- task: backend
|
||||||
|
|
||||||
sqlc:
|
run:
|
||||||
desc: sqlc code generation
|
desc: Start budgeteer
|
||||||
sources:
|
deps: [backend, go-mod, go-sqlc]
|
||||||
- ./sqlc.yaml
|
|
||||||
- ./postgres/schema/*
|
|
||||||
- ./postgres/queries/*
|
|
||||||
generates:
|
|
||||||
- ./postgres/*.sql.go
|
|
||||||
cmds:
|
cmds:
|
||||||
- sqlc generate
|
- ./build/budgeteer{{exeExt}}
|
||||||
|
|
||||||
gomod:
|
dev:
|
||||||
desc: Go modules
|
desc: Build budgeteer in dev mode (without frontend)
|
||||||
sources:
|
deps: [go-mod, go-sqlc]
|
||||||
- ./go.mod
|
|
||||||
- ./go.sum
|
|
||||||
method: checksum
|
|
||||||
cmds:
|
cmds:
|
||||||
- go mod download
|
- task: backend
|
||||||
|
|
||||||
build:
|
ci:
|
||||||
|
desc: Run CI build
|
||||||
|
deps: [default, static]
|
||||||
|
|
||||||
|
static:
|
||||||
|
deps: [go-lint, go-vet, go-fmt, js-tsc, js-lint, cover]
|
||||||
|
|
||||||
|
backend:
|
||||||
desc: Build budgeteer
|
desc: Build budgeteer
|
||||||
sources:
|
sources:
|
||||||
- ./go.mod
|
- ./go.mod
|
||||||
@ -43,71 +44,99 @@ tasks:
|
|||||||
cmds:
|
cmds:
|
||||||
- go build -o ./build/budgeteer{{exeExt}} ./cmd/budgeteer
|
- go build -o ./build/budgeteer{{exeExt}} ./cmd/budgeteer
|
||||||
|
|
||||||
build-dev:
|
go-vet:
|
||||||
desc: Build budgeteer in dev mode
|
|
||||||
deps: [gomod, sqlc]
|
|
||||||
cmds:
|
cmds:
|
||||||
- go vet
|
- go vet
|
||||||
- go fmt
|
|
||||||
- golangci-lint run
|
|
||||||
- task: build
|
|
||||||
|
|
||||||
build-prod:
|
go-fmt:
|
||||||
desc: Build budgeteer in prod mode
|
|
||||||
deps: [gomod, sqlc, frontend]
|
|
||||||
cmds:
|
cmds:
|
||||||
- go vet
|
|
||||||
- go fmt
|
- go fmt
|
||||||
- golangci-lint run
|
|
||||||
- task: build
|
|
||||||
|
|
||||||
ci:
|
go-lint:
|
||||||
desc: Run CI build
|
|
||||||
cmds:
|
cmds:
|
||||||
- task: build-prod
|
- golangci-lint run
|
||||||
- go test ./...
|
|
||||||
|
go-sqlc:
|
||||||
|
desc: sqlc code generation
|
||||||
|
sources:
|
||||||
|
- ./sqlc.yaml
|
||||||
|
- ./postgres/schema/*
|
||||||
|
- ./postgres/queries/*
|
||||||
|
generates:
|
||||||
|
- ./postgres/*.sql.go
|
||||||
|
cmds:
|
||||||
|
- sqlc generate
|
||||||
|
|
||||||
|
go-mod:
|
||||||
|
desc: Go modules
|
||||||
|
sources:
|
||||||
|
- ./go.mod
|
||||||
|
- ./go.sum
|
||||||
|
method: checksum
|
||||||
|
cmds:
|
||||||
|
- go mod download
|
||||||
|
|
||||||
|
cover:
|
||||||
|
desc: Run test and analyze coverage
|
||||||
|
cmds:
|
||||||
|
- go test ./... -coverprofile=coverage.out -covermode=atomic
|
||||||
|
- go tool cover -html=coverage.out -o=coverage.html
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
desc: Build vue frontend
|
desc: Build vue frontend
|
||||||
dir: web
|
deps: [js-build]
|
||||||
sources:
|
sources:
|
||||||
- web/src/**/*
|
- web/src/**/*
|
||||||
generates:
|
generates:
|
||||||
- web/dist/**/*
|
- web/dist/**/*
|
||||||
|
|
||||||
|
frontend-dev:
|
||||||
|
desc: Run dev-server for frontend
|
||||||
|
dir: web
|
||||||
|
cmds:
|
||||||
|
- yarn run dev
|
||||||
|
|
||||||
|
js-build:
|
||||||
|
dir: web
|
||||||
|
deps: [js-mod]
|
||||||
|
cmds:
|
||||||
|
- yarn build
|
||||||
|
|
||||||
|
js-mod:
|
||||||
|
run: once
|
||||||
|
sources:
|
||||||
|
- web/src/package.json
|
||||||
|
- web/src/yarn.lock
|
||||||
|
generates:
|
||||||
|
- web/node_modules/**/*
|
||||||
|
dir: web
|
||||||
cmds:
|
cmds:
|
||||||
- yarn
|
- yarn
|
||||||
- yarn build
|
|
||||||
- yarn run vue-tsc --noEmit
|
js-tsc:
|
||||||
- yarn run eslint "./src/**"
|
dir: web
|
||||||
|
deps: [js-mod]
|
||||||
docker:
|
|
||||||
desc: Build budgeeter:latest
|
|
||||||
deps: [build-prod]
|
|
||||||
sources:
|
|
||||||
- ./build/budgeteer{{exeExt}}
|
|
||||||
- ./build/Dockerfile
|
|
||||||
cmds:
|
cmds:
|
||||||
- docker build -t {{.IMAGE_NAME}}:latest ./build
|
- yarn run vue-tsc --noEmit
|
||||||
- docker push {{.IMAGE_NAME}}:latest
|
|
||||||
|
js-lint:
|
||||||
|
dir: web
|
||||||
|
deps: [js-mod]
|
||||||
|
cmds:
|
||||||
|
- yarn run eslint "./src/**"
|
||||||
|
|
||||||
dev-docker:
|
dev-docker:
|
||||||
desc: Build budgeeter:dev
|
desc: Build budgeeter:dev
|
||||||
sources:
|
sources:
|
||||||
- ./docker/Dockerfile
|
- ./docker/Dockerfile.dev
|
||||||
- ./docker/build.sh
|
|
||||||
- ./web/package.json
|
- ./web/package.json
|
||||||
|
- ./web/yarn.lock
|
||||||
cmds:
|
cmds:
|
||||||
- docker build -t {{.IMAGE_NAME}}:dev . -f docker/Dockerfile.dev
|
- docker build -t {{.IMAGE_NAME}}:dev . -f docker/Dockerfile.dev
|
||||||
- docker push {{.IMAGE_NAME}}:dev
|
- docker push {{.IMAGE_NAME}}:dev
|
||||||
|
|
||||||
run:
|
run-dev:
|
||||||
desc: Start budgeteer
|
desc: Run dev environment in docker
|
||||||
deps: [build-dev]
|
deps: [dev-docker]
|
||||||
cmds:
|
cmds:
|
||||||
- ./build/budgeteer{{exeExt}}
|
- docker-compose -f docker/docker-compose.dev.yml -p budgeteer up -d
|
||||||
|
|
||||||
rundocker:
|
|
||||||
desc: Start docker-compose
|
|
||||||
deps: [docker]
|
|
||||||
cmds:
|
|
||||||
- docker-compose up -d
|
|
@ -17,22 +17,22 @@ import (
|
|||||||
func main() {
|
func main() {
|
||||||
cfg, err := config.LoadConfig()
|
cfg, err := config.LoadConfig()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Could not load config: %v", err)
|
log.Fatalf("load config: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
queries, 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("connect to database: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
static, err := fs.Sub(web.Static, "dist")
|
static, err := fs.Sub(web.Static, "dist")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic("couldn't open static files")
|
panic("open static files")
|
||||||
}
|
}
|
||||||
|
|
||||||
tokenVerifier, err := jwt.NewTokenVerifier(cfg.SessionSecret)
|
tokenVerifier, err := jwt.NewTokenVerifier(cfg.SessionSecret)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(fmt.Errorf("couldn't create token verifier: %w", err))
|
panic(fmt.Errorf("create token verifier: %w", err))
|
||||||
}
|
}
|
||||||
|
|
||||||
handler := &server.Handler{
|
handler := &server.Handler{
|
||||||
|
@ -5,13 +5,11 @@ RUN go install github.com/go-task/task/v3/cmd/task@latest
|
|||||||
RUN go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest
|
RUN go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest
|
||||||
|
|
||||||
FROM alpine
|
FROM alpine
|
||||||
RUN apk --no-cache add go nodejs yarn bash curl git git-perl tmux
|
RUN apk --no-cache add go nodejs yarn bash curl git git-perl
|
||||||
ADD docker/dev.sh /
|
|
||||||
RUN yarn global add @vue/cli
|
|
||||||
ENV PATH="/root/.yarn/bin/:${PATH}"
|
ENV PATH="/root/.yarn/bin/:${PATH}"
|
||||||
WORKDIR /src/web
|
WORKDIR /src/web
|
||||||
ADD web/package.json web/yarn.lock /src/web/
|
ADD web/package.json web/yarn.lock /src/web/
|
||||||
RUN yarn
|
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
COPY --from=godeps /root/go/bin/task /root/go/bin/sqlc /root/go/bin/golangci-lint /usr/local/bin/
|
VOLUME /go
|
||||||
CMD /dev.sh
|
VOLUME /.cache
|
||||||
|
COPY --from=godeps /root/go/bin/task /root/go/bin/sqlc /root/go/bin/golangci-lint /usr/local/bin/
|
@ -1,26 +1,31 @@
|
|||||||
version: '3.7'
|
version: '3.7'
|
||||||
|
|
||||||
services:
|
services:
|
||||||
app:
|
backend:
|
||||||
image: hub.javil.eu/budgeteer:dev
|
image: hub.javil.eu/budgeteer:dev
|
||||||
container_name: budgeteer
|
command: task -w run
|
||||||
stdin_open: true # docker run -i
|
|
||||||
tty: true # docker run -t
|
|
||||||
ports:
|
ports:
|
||||||
- 1323:1323
|
- 1323:1323
|
||||||
- 3000:3000
|
|
||||||
user: '1000'
|
|
||||||
volumes:
|
volumes:
|
||||||
- ~/budgeteer:/src
|
- ~/budgeteer:/src
|
||||||
- ~/.gitconfig:/.gitconfig
|
- go-cache:/go
|
||||||
- ~/.go:/go
|
- yarn-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
|
BUDGETEER_SESSION_SECRET: random string for JWT authorization
|
||||||
depends_on:
|
depends_on:
|
||||||
- db
|
- db
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
image: hub.javil.eu/budgeteer:dev
|
||||||
|
command: task frontend-dev
|
||||||
|
ports:
|
||||||
|
- 3000:3000
|
||||||
|
volumes:
|
||||||
|
- ~/budgeteer:/src
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
|
||||||
db:
|
db:
|
||||||
image: postgres:14
|
image: postgres:14
|
||||||
ports:
|
ports:
|
||||||
@ -41,3 +46,5 @@ services:
|
|||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
db:
|
db:
|
||||||
|
go-cache:
|
||||||
|
yarn-cache:
|
||||||
|
@ -1,4 +1,6 @@
|
|||||||
// Code generated by sqlc. DO NOT EDIT.
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// sqlc v1.13.0
|
||||||
// source: accounts.sql
|
// source: accounts.sql
|
||||||
|
|
||||||
package postgres
|
package postgres
|
||||||
|
@ -1,4 +1,6 @@
|
|||||||
// Code generated by sqlc. DO NOT EDIT.
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// sqlc v1.13.0
|
||||||
// source: assignments.sql
|
// source: assignments.sql
|
||||||
|
|
||||||
package postgres
|
package postgres
|
||||||
|
@ -1,4 +1,6 @@
|
|||||||
// Code generated by sqlc. DO NOT EDIT.
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// sqlc v1.13.0
|
||||||
// source: budgets.sql
|
// source: budgets.sql
|
||||||
|
|
||||||
package postgres
|
package postgres
|
||||||
|
@ -59,5 +59,6 @@ func (s *Database) NewBudget(context context.Context, name string, userID uuid.U
|
|||||||
return nil, fmt.Errorf("commit: %w", err)
|
return nil, fmt.Errorf("commit: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
budget.IncomeCategoryID = cat.ID
|
||||||
return &budget, nil
|
return &budget, nil
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,6 @@
|
|||||||
// Code generated by sqlc. DO NOT EDIT.
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// sqlc v1.13.0
|
||||||
// source: categories.sql
|
// source: categories.sql
|
||||||
|
|
||||||
package postgres
|
package postgres
|
||||||
|
@ -1,4 +1,6 @@
|
|||||||
// Code generated by sqlc. DO NOT EDIT.
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// sqlc v1.13.0
|
||||||
// source: cumultative-balances.sql
|
// source: cumultative-balances.sql
|
||||||
|
|
||||||
package postgres
|
package postgres
|
||||||
@ -13,21 +15,19 @@ import (
|
|||||||
|
|
||||||
const getCumultativeBalances = `-- name: GetCumultativeBalances :many
|
const getCumultativeBalances = `-- name: GetCumultativeBalances :many
|
||||||
SELECT COALESCE(ass.date, tra.date), COALESCE(ass.category_id, tra.category_id),
|
SELECT COALESCE(ass.date, tra.date), COALESCE(ass.category_id, tra.category_id),
|
||||||
COALESCE(ass.amount, 0)::decimal(12,2) as assignments, SUM(ass.amount) OVER (PARTITION BY ass.category_id ORDER BY ass.date)::decimal(12,2) as assignments_cum,
|
COALESCE(ass.amount, 0)::decimal(12,2) as assignments,
|
||||||
COALESCE(tra.amount, 0)::decimal(12,2) as transactions, SUM(tra.amount) OVER (PARTITION BY tra.category_id ORDER BY tra.date)::decimal(12,2) as transactions_cum
|
COALESCE(tra.amount, 0)::decimal(12,2) as transactions
|
||||||
FROM assignments_by_month as ass
|
FROM assignments_by_month as ass
|
||||||
FULL OUTER JOIN transactions_by_month as tra ON ass.date = tra.date AND ass.category_id = tra.category_id
|
FULL OUTER JOIN transactions_by_month as tra ON ass.date = tra.date AND ass.category_id = tra.category_id
|
||||||
WHERE (ass.budget_id IS NULL OR ass.budget_id = $1) AND (tra.budget_id IS NULL OR tra.budget_id = $1)
|
WHERE COALESCE(ass.budget_id, tra.budget_id) = $1
|
||||||
ORDER BY COALESCE(ass.date, tra.date), COALESCE(ass.category_id, tra.category_id)
|
ORDER BY COALESCE(ass.date, tra.date), COALESCE(ass.amount, tra.amount)
|
||||||
`
|
`
|
||||||
|
|
||||||
type GetCumultativeBalancesRow struct {
|
type GetCumultativeBalancesRow struct {
|
||||||
Date time.Time
|
Date time.Time
|
||||||
CategoryID uuid.UUID
|
CategoryID uuid.UUID
|
||||||
Assignments numeric.Numeric
|
Assignments numeric.Numeric
|
||||||
AssignmentsCum numeric.Numeric
|
Transactions numeric.Numeric
|
||||||
Transactions numeric.Numeric
|
|
||||||
TransactionsCum numeric.Numeric
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) GetCumultativeBalances(ctx context.Context, budgetID uuid.UUID) ([]GetCumultativeBalancesRow, error) {
|
func (q *Queries) GetCumultativeBalances(ctx context.Context, budgetID uuid.UUID) ([]GetCumultativeBalancesRow, error) {
|
||||||
@ -43,9 +43,7 @@ func (q *Queries) GetCumultativeBalances(ctx context.Context, budgetID uuid.UUID
|
|||||||
&i.Date,
|
&i.Date,
|
||||||
&i.CategoryID,
|
&i.CategoryID,
|
||||||
&i.Assignments,
|
&i.Assignments,
|
||||||
&i.AssignmentsCum,
|
|
||||||
&i.Transactions,
|
&i.Transactions,
|
||||||
&i.TransactionsCum,
|
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,6 @@
|
|||||||
// Code generated by sqlc. DO NOT EDIT.
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// sqlc v1.13.0
|
||||||
|
|
||||||
package postgres
|
package postgres
|
||||||
|
|
||||||
|
@ -1,4 +1,6 @@
|
|||||||
// Code generated by sqlc. DO NOT EDIT.
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// sqlc v1.13.0
|
||||||
|
|
||||||
package postgres
|
package postgres
|
||||||
|
|
||||||
|
@ -25,6 +25,13 @@ func FromInt64WithExp(value int64, exp int32) Numeric {
|
|||||||
return Numeric{Numeric: pgtype.Numeric{Int: big.NewInt(value), Exp: exp, Status: pgtype.Present}}
|
return Numeric{Numeric: pgtype.Numeric{Int: big.NewInt(value), Exp: exp, Status: pgtype.Present}}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (n *Numeric) SetZero() {
|
||||||
|
n.Exp = 0
|
||||||
|
n.Int = big.NewInt(0)
|
||||||
|
n.Status = pgtype.Present
|
||||||
|
n.NaN = false
|
||||||
|
}
|
||||||
|
|
||||||
func (n Numeric) GetFloat64() float64 {
|
func (n Numeric) GetFloat64() float64 {
|
||||||
if n.Status != pgtype.Present {
|
if n.Status != pgtype.Present {
|
||||||
return 0
|
return 0
|
||||||
|
@ -1,4 +1,6 @@
|
|||||||
// Code generated by sqlc. DO NOT EDIT.
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// sqlc v1.13.0
|
||||||
// source: payees.sql
|
// source: payees.sql
|
||||||
|
|
||||||
package postgres
|
package postgres
|
||||||
|
@ -1,8 +1,8 @@
|
|||||||
-- name: GetCumultativeBalances :many
|
-- name: GetCumultativeBalances :many
|
||||||
SELECT COALESCE(ass.date, tra.date), COALESCE(ass.category_id, tra.category_id),
|
SELECT COALESCE(ass.date, tra.date), COALESCE(ass.category_id, tra.category_id),
|
||||||
COALESCE(ass.amount, 0)::decimal(12,2) as assignments, SUM(ass.amount) OVER (PARTITION BY ass.category_id ORDER BY ass.date)::decimal(12,2) as assignments_cum,
|
COALESCE(ass.amount, 0)::decimal(12,2) as assignments,
|
||||||
COALESCE(tra.amount, 0)::decimal(12,2) as transactions, SUM(tra.amount) OVER (PARTITION BY tra.category_id ORDER BY tra.date)::decimal(12,2) as transactions_cum
|
COALESCE(tra.amount, 0)::decimal(12,2) as transactions
|
||||||
FROM assignments_by_month as ass
|
FROM assignments_by_month as ass
|
||||||
FULL OUTER JOIN transactions_by_month as tra ON ass.date = tra.date AND ass.category_id = tra.category_id
|
FULL OUTER JOIN transactions_by_month as tra ON ass.date = tra.date AND ass.category_id = tra.category_id
|
||||||
WHERE (ass.budget_id IS NULL OR ass.budget_id = @budget_id) AND (tra.budget_id IS NULL OR tra.budget_id = @budget_id)
|
WHERE COALESCE(ass.budget_id, tra.budget_id) = @budget_id
|
||||||
ORDER BY COALESCE(ass.date, tra.date), COALESCE(ass.category_id, tra.category_id);
|
ORDER BY COALESCE(ass.date, tra.date), COALESCE(ass.amount, tra.amount);
|
@ -11,10 +11,10 @@ RETURNING id;
|
|||||||
-- name: UpdateTransaction :exec
|
-- name: UpdateTransaction :exec
|
||||||
UPDATE transactions
|
UPDATE transactions
|
||||||
SET date = $1,
|
SET date = $1,
|
||||||
memo = $2,
|
memo = $2,
|
||||||
amount = $3,
|
amount = $3,
|
||||||
payee_id = $4,
|
payee_id = $4,
|
||||||
category_id = $5
|
category_id = $5
|
||||||
WHERE id = $6;
|
WHERE id = $6;
|
||||||
|
|
||||||
-- name: SetTransactionReconciled :exec
|
-- name: SetTransactionReconciled :exec
|
||||||
@ -34,8 +34,7 @@ WHERE t.budget_id = $1;
|
|||||||
-- name: GetTransactionsForAccount :many
|
-- name: GetTransactionsForAccount :many
|
||||||
SELECT t.*
|
SELECT t.*
|
||||||
FROM display_transactions AS t
|
FROM display_transactions AS t
|
||||||
WHERE t.account_id = $1
|
WHERE t.account_id = $1;
|
||||||
LIMIT 200;
|
|
||||||
|
|
||||||
-- name: DeleteAllTransactions :execrows
|
-- name: DeleteAllTransactions :execrows
|
||||||
DELETE FROM transactions
|
DELETE FROM transactions
|
||||||
@ -46,4 +45,29 @@ AND accounts.id = transactions.account_id;
|
|||||||
-- name: GetTransactionsByMonthAndCategory :many
|
-- name: GetTransactionsByMonthAndCategory :many
|
||||||
SELECT *
|
SELECT *
|
||||||
FROM transactions_by_month
|
FROM transactions_by_month
|
||||||
WHERE transactions_by_month.budget_id = @budget_id;
|
WHERE transactions_by_month.budget_id = @budget_id;
|
||||||
|
|
||||||
|
-- name: GetProblematicTransactions :many
|
||||||
|
SELECT transactions.*
|
||||||
|
FROM display_transactions AS transactions
|
||||||
|
LEFT JOIN accounts
|
||||||
|
ON transactions.account_id = accounts.id
|
||||||
|
LEFT JOIN transactions AS otherGroupTransaction
|
||||||
|
ON transactions.group_id = otherGroupTransaction.group_id
|
||||||
|
AND transactions.id != otherGroupTransaction.id
|
||||||
|
AND transactions.account_id != otherGroupTransaction.account_id
|
||||||
|
LEFT JOIn accounts AS otherGroupAccount
|
||||||
|
ON otherGroupTransaction.account_id = otherGroupAccount.id
|
||||||
|
WHERE transactions.category_id IS NULL
|
||||||
|
AND accounts.on_budget
|
||||||
|
AND (otherGroupAccount.id IS NULL OR NOT otherGroupAccount.on_budget)
|
||||||
|
AND accounts.budget_id = $1;
|
||||||
|
|
||||||
|
-- name: GetFilteredTransactions :many
|
||||||
|
SELECT transactions.*
|
||||||
|
FROM display_transactions AS transactions
|
||||||
|
WHERE (NOT @filter_category::boolean OR transactions.category_id = @category_id)
|
||||||
|
AND (NOT @filter_account::boolean OR transactions.account_id = @account_id)
|
||||||
|
AND (NOT @filter_payee::boolean OR transactions.payee_id = @payee_id)
|
||||||
|
AND transactions.date BETWEEN @from_date AND @to_date
|
||||||
|
AND transactions.budget_id = @budget_id;
|
@ -0,0 +1,13 @@
|
|||||||
|
-- +goose Up
|
||||||
|
CREATE OR REPLACE VIEW transactions_by_month AS
|
||||||
|
SELECT date_trunc('month', transactions.date)::date as date, transactions.category_id, accounts.budget_id, SUM(amount) as amount
|
||||||
|
FROM transactions
|
||||||
|
INNER JOIN accounts ON accounts.id = transactions.account_id AND accounts.on_budget
|
||||||
|
GROUP BY date_trunc('month', transactions.date), transactions.category_id, accounts.budget_id;
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
CREATE OR REPLACE VIEW transactions_by_month AS
|
||||||
|
SELECT date_trunc('month', transactions.date)::date as date, transactions.category_id, accounts.budget_id, SUM(amount) as amount
|
||||||
|
FROM transactions
|
||||||
|
INNER JOIN accounts ON accounts.id = transactions.account_id
|
||||||
|
GROUP BY date_trunc('month', transactions.date), transactions.category_id, accounts.budget_id;
|
@ -1,4 +1,6 @@
|
|||||||
// Code generated by sqlc. DO NOT EDIT.
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// sqlc v1.13.0
|
||||||
// source: transactions.sql
|
// source: transactions.sql
|
||||||
|
|
||||||
package postgres
|
package postgres
|
||||||
@ -115,6 +117,133 @@ func (q *Queries) GetAllTransactionsForBudget(ctx context.Context, budgetID uuid
|
|||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getFilteredTransactions = `-- name: GetFilteredTransactions :many
|
||||||
|
SELECT transactions.id, transactions.date, transactions.memo, transactions.amount, transactions.group_id, transactions.status, transactions.account, transactions.payee_id, transactions.category_id, transactions.payee, transactions.category_group, transactions.category, transactions.transfer_account, transactions.budget_id, transactions.account_id
|
||||||
|
FROM display_transactions AS transactions
|
||||||
|
WHERE (NOT $1::boolean OR transactions.category_id = $2)
|
||||||
|
AND (NOT $3::boolean OR transactions.account_id = $4)
|
||||||
|
AND (NOT $5::boolean OR transactions.payee_id = $6)
|
||||||
|
AND transactions.date BETWEEN $7 AND $8
|
||||||
|
AND transactions.budget_id = $9
|
||||||
|
`
|
||||||
|
|
||||||
|
type GetFilteredTransactionsParams struct {
|
||||||
|
FilterCategory bool
|
||||||
|
CategoryID uuid.NullUUID
|
||||||
|
FilterAccount bool
|
||||||
|
AccountID uuid.UUID
|
||||||
|
FilterPayee bool
|
||||||
|
PayeeID uuid.NullUUID
|
||||||
|
FromDate time.Time
|
||||||
|
ToDate time.Time
|
||||||
|
BudgetID uuid.UUID
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) GetFilteredTransactions(ctx context.Context, arg GetFilteredTransactionsParams) ([]DisplayTransaction, error) {
|
||||||
|
rows, err := q.db.QueryContext(ctx, getFilteredTransactions,
|
||||||
|
arg.FilterCategory,
|
||||||
|
arg.CategoryID,
|
||||||
|
arg.FilterAccount,
|
||||||
|
arg.AccountID,
|
||||||
|
arg.FilterPayee,
|
||||||
|
arg.PayeeID,
|
||||||
|
arg.FromDate,
|
||||||
|
arg.ToDate,
|
||||||
|
arg.BudgetID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []DisplayTransaction
|
||||||
|
for rows.Next() {
|
||||||
|
var i DisplayTransaction
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.Date,
|
||||||
|
&i.Memo,
|
||||||
|
&i.Amount,
|
||||||
|
&i.GroupID,
|
||||||
|
&i.Status,
|
||||||
|
&i.Account,
|
||||||
|
&i.PayeeID,
|
||||||
|
&i.CategoryID,
|
||||||
|
&i.Payee,
|
||||||
|
&i.CategoryGroup,
|
||||||
|
&i.Category,
|
||||||
|
&i.TransferAccount,
|
||||||
|
&i.BudgetID,
|
||||||
|
&i.AccountID,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Close(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const getProblematicTransactions = `-- name: GetProblematicTransactions :many
|
||||||
|
SELECT transactions.id, transactions.date, transactions.memo, transactions.amount, transactions.group_id, transactions.status, transactions.account, transactions.payee_id, transactions.category_id, transactions.payee, transactions.category_group, transactions.category, transactions.transfer_account, transactions.budget_id, transactions.account_id
|
||||||
|
FROM display_transactions AS transactions
|
||||||
|
LEFT JOIN accounts
|
||||||
|
ON transactions.account_id = accounts.id
|
||||||
|
LEFT JOIN transactions AS otherGroupTransaction
|
||||||
|
ON transactions.group_id = otherGroupTransaction.group_id
|
||||||
|
AND transactions.id != otherGroupTransaction.id
|
||||||
|
AND transactions.account_id != otherGroupTransaction.account_id
|
||||||
|
LEFT JOIn accounts AS otherGroupAccount
|
||||||
|
ON otherGroupTransaction.account_id = otherGroupAccount.id
|
||||||
|
WHERE transactions.category_id IS NULL
|
||||||
|
AND accounts.on_budget
|
||||||
|
AND (otherGroupAccount.id IS NULL OR NOT otherGroupAccount.on_budget)
|
||||||
|
AND accounts.budget_id = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) GetProblematicTransactions(ctx context.Context, budgetID uuid.UUID) ([]DisplayTransaction, error) {
|
||||||
|
rows, err := q.db.QueryContext(ctx, getProblematicTransactions, budgetID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []DisplayTransaction
|
||||||
|
for rows.Next() {
|
||||||
|
var i DisplayTransaction
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.Date,
|
||||||
|
&i.Memo,
|
||||||
|
&i.Amount,
|
||||||
|
&i.GroupID,
|
||||||
|
&i.Status,
|
||||||
|
&i.Account,
|
||||||
|
&i.PayeeID,
|
||||||
|
&i.CategoryID,
|
||||||
|
&i.Payee,
|
||||||
|
&i.CategoryGroup,
|
||||||
|
&i.Category,
|
||||||
|
&i.TransferAccount,
|
||||||
|
&i.BudgetID,
|
||||||
|
&i.AccountID,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Close(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
const getTransaction = `-- name: GetTransaction :one
|
const getTransaction = `-- name: GetTransaction :one
|
||||||
SELECT id, date, memo, amount, group_id, status, account, payee_id, category_id, payee, category_group, category, transfer_account, budget_id, account_id FROM display_transactions
|
SELECT id, date, memo, amount, group_id, status, account, payee_id, category_id, payee, category_group, category, transfer_account, budget_id, account_id FROM display_transactions
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
@ -181,7 +310,6 @@ const getTransactionsForAccount = `-- name: GetTransactionsForAccount :many
|
|||||||
SELECT t.id, t.date, t.memo, t.amount, t.group_id, t.status, t.account, t.payee_id, t.category_id, t.payee, t.category_group, t.category, t.transfer_account, t.budget_id, t.account_id
|
SELECT t.id, t.date, t.memo, t.amount, t.group_id, t.status, t.account, t.payee_id, t.category_id, t.payee, t.category_group, t.category, t.transfer_account, t.budget_id, t.account_id
|
||||||
FROM display_transactions AS t
|
FROM display_transactions AS t
|
||||||
WHERE t.account_id = $1
|
WHERE t.account_id = $1
|
||||||
LIMIT 200
|
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) GetTransactionsForAccount(ctx context.Context, accountID uuid.UUID) ([]DisplayTransaction, error) {
|
func (q *Queries) GetTransactionsForAccount(ctx context.Context, accountID uuid.UUID) ([]DisplayTransaction, error) {
|
||||||
@ -237,10 +365,10 @@ func (q *Queries) SetTransactionReconciled(ctx context.Context, id uuid.UUID) er
|
|||||||
const updateTransaction = `-- name: UpdateTransaction :exec
|
const updateTransaction = `-- name: UpdateTransaction :exec
|
||||||
UPDATE transactions
|
UPDATE transactions
|
||||||
SET date = $1,
|
SET date = $1,
|
||||||
memo = $2,
|
memo = $2,
|
||||||
amount = $3,
|
amount = $3,
|
||||||
payee_id = $4,
|
payee_id = $4,
|
||||||
category_id = $5
|
category_id = $5
|
||||||
WHERE id = $6
|
WHERE id = $6
|
||||||
`
|
`
|
||||||
|
|
||||||
|
@ -1,4 +1,6 @@
|
|||||||
// Code generated by sqlc. DO NOT EDIT.
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// sqlc v1.13.0
|
||||||
// source: user_budgets.sql
|
// source: user_budgets.sql
|
||||||
|
|
||||||
package postgres
|
package postgres
|
||||||
|
@ -1,4 +1,6 @@
|
|||||||
// Code generated by sqlc. DO NOT EDIT.
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// sqlc v1.13.0
|
||||||
// source: users.sql
|
// source: users.sql
|
||||||
|
|
||||||
package postgres
|
package postgres
|
||||||
|
@ -2,12 +2,85 @@ package server
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
"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"
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type FilterTransactionsRequest struct {
|
||||||
|
CategoryID string `json:"category_id"`
|
||||||
|
PayeeID string `json:"payee_id"`
|
||||||
|
AccountID string `json:"account_id"`
|
||||||
|
FromDate time.Time `json:"from_date"`
|
||||||
|
ToDate time.Time `json:"to_date"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) filteredTransactions(c *gin.Context) {
|
||||||
|
budgetID := c.Param("budgetid")
|
||||||
|
budgetUUID, err := uuid.Parse(budgetID)
|
||||||
|
if err != nil {
|
||||||
|
c.AbortWithError(http.StatusBadRequest, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var request FilterTransactionsRequest
|
||||||
|
err = c.BindJSON(&request)
|
||||||
|
if err != nil {
|
||||||
|
c.AbortWithError(http.StatusBadRequest, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
params := postgres.GetFilteredTransactionsParams{
|
||||||
|
BudgetID: budgetUUID,
|
||||||
|
FromDate: request.FromDate,
|
||||||
|
ToDate: request.ToDate,
|
||||||
|
}
|
||||||
|
params.CategoryID, params.FilterCategory = parseEmptyUUID(request.CategoryID)
|
||||||
|
accountID, filterAccount := parseEmptyUUID(request.AccountID)
|
||||||
|
params.AccountID, params.FilterAccount = accountID.UUID, filterAccount
|
||||||
|
params.PayeeID, params.FilterPayee = parseEmptyUUID(request.PayeeID)
|
||||||
|
|
||||||
|
transactions, err := h.Service.GetFilteredTransactions(c.Request.Context(), params)
|
||||||
|
if err != nil {
|
||||||
|
c.AbortWithError(http.StatusInternalServerError, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, TransactionsResponse{nil, transactions})
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseEmptyUUID(value string) (uuid.NullUUID, bool) {
|
||||||
|
if value == "" {
|
||||||
|
return uuid.NullUUID{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
val, err := uuid.Parse(value)
|
||||||
|
if err != nil {
|
||||||
|
return uuid.NullUUID{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
return uuid.NullUUID{val, true}, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) problematicTransactions(c *gin.Context) {
|
||||||
|
budgetID := c.Param("budgetid")
|
||||||
|
budgetUUID, err := uuid.Parse(budgetID)
|
||||||
|
if err != nil {
|
||||||
|
c.AbortWithError(http.StatusBadRequest, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
transactions, err := h.Service.GetProblematicTransactions(c.Request.Context(), budgetUUID)
|
||||||
|
if err != nil {
|
||||||
|
c.AbortWithError(http.StatusInternalServerError, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, TransactionsResponse{nil, transactions})
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Handler) transactionsForAccount(c *gin.Context) {
|
func (h *Handler) transactionsForAccount(c *gin.Context) {
|
||||||
accountID := c.Param("accountid")
|
accountID := c.Param("accountid")
|
||||||
accountUUID, err := uuid.Parse(accountID)
|
accountUUID, err := uuid.Parse(accountID)
|
||||||
@ -28,11 +101,11 @@ func (h *Handler) transactionsForAccount(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, TransactionsResponse{account, transactions})
|
c.JSON(http.StatusOK, TransactionsResponse{&account, transactions})
|
||||||
}
|
}
|
||||||
|
|
||||||
type TransactionsResponse struct {
|
type TransactionsResponse struct {
|
||||||
Account postgres.Account
|
Account *postgres.Account
|
||||||
Transactions []postgres.DisplayTransaction
|
Transactions []postgres.DisplayTransaction
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -69,5 +142,5 @@ func (h *Handler) editAccount(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
h.returnBudgetingData(c, account.BudgetID)
|
h.getBudget(c, account.BudgetID)
|
||||||
}
|
}
|
||||||
|
@ -2,7 +2,6 @@ package server
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"strings"
|
"strings"
|
||||||
@ -11,24 +10,18 @@ import (
|
|||||||
"git.javil.eu/jacob1123/budgeteer/bcrypt"
|
"git.javil.eu/jacob1123/budgeteer/bcrypt"
|
||||||
"git.javil.eu/jacob1123/budgeteer/jwt"
|
"git.javil.eu/jacob1123/budgeteer/jwt"
|
||||||
"git.javil.eu/jacob1123/budgeteer/postgres"
|
"git.javil.eu/jacob1123/budgeteer/postgres"
|
||||||
txdb "github.com/DATA-DOG/go-txdb"
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() { //nolint:gochecknoinits
|
func TestRegisterUser(t *testing.T) {
|
||||||
txdb.Register("pgtx", "pgx", "postgres://budgeteer_test:budgeteer_test@localhost:5432/budgeteer_test")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRegisterUser(t *testing.T) { //nolint:funlen
|
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
database, err := postgres.Connect("pgtx", "example")
|
database, err := postgres.Connect("pgtx", cfg.DatabaseConnection)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("could not connect to db: %s\n", err)
|
t.Errorf("connect to DB: %v", err)
|
||||||
t.Skip()
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
tokenVerifier, _ := jwt.NewTokenVerifier("this_is_my_demo_secret_for_unit_tests")
|
tokenVerifier, _ := jwt.NewTokenVerifier(cfg.SessionSecret)
|
||||||
h := Handler{
|
h := Handler{
|
||||||
Service: database,
|
Service: database,
|
||||||
TokenVerifier: tokenVerifier,
|
TokenVerifier: tokenVerifier,
|
||||||
@ -66,22 +59,4 @@ func TestRegisterUser(t *testing.T) { //nolint:funlen
|
|||||||
t.Error("Did not get a token")
|
t.Error("Did not get a token")
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("GetTransactions", func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
context.Request, err = http.NewRequest(http.MethodGet, "/account/accountid/transactions", nil)
|
|
||||||
if recorder.Code != http.StatusOK {
|
|
||||||
t.Errorf("handler returned wrong status code: got %v want %v", recorder.Code, http.StatusOK)
|
|
||||||
}
|
|
||||||
|
|
||||||
var response TransactionsResponse
|
|
||||||
err = json.NewDecoder(recorder.Body).Decode(&response)
|
|
||||||
if err != nil {
|
|
||||||
t.Error(err.Error())
|
|
||||||
t.Error("Error retreiving list of transactions.")
|
|
||||||
}
|
|
||||||
if len(response.Transactions) == 0 {
|
|
||||||
t.Error("Did not get any transactions.")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
@ -64,49 +64,3 @@ func (h *Handler) clearBudget(c *gin.Context) {
|
|||||||
|
|
||||||
h.clearBudgetData(c, budgetUUID)
|
h.clearBudgetData(c, budgetUUID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) cleanNegativeBudget(c *gin.Context) {
|
|
||||||
/*budgetID := c.Param("budgetid")
|
|
||||||
budgetUUID, err := uuid.Parse(budgetID)
|
|
||||||
if err != nil {
|
|
||||||
c.Redirect(http.StatusTemporaryRedirect, "/login")
|
|
||||||
return
|
|
||||||
}*/
|
|
||||||
|
|
||||||
/*min_date, err := h.Service.GetFirstActivity(c.Request.Context(), budgetUUID)
|
|
||||||
date := getFirstOfMonthTime(min_date)
|
|
||||||
for {
|
|
||||||
nextDate := date.AddDate(0, 1, 0)
|
|
||||||
params := postgres.GetCategoriesWithBalanceParams{
|
|
||||||
BudgetID: budgetUUID,
|
|
||||||
ToDate: nextDate,
|
|
||||||
FromDate: date,
|
|
||||||
}
|
|
||||||
categories, err := h.Service.GetCategoriesWithBalance(c.Request.Context(), params)
|
|
||||||
if err != nil {
|
|
||||||
c.AbortWithError(http.StatusInternalServerError, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, category := range categories {
|
|
||||||
available := category.Available.GetFloat64()
|
|
||||||
if available >= 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
var negativeAvailable postgres.Numeric
|
|
||||||
negativeAvailable.Set(-available)
|
|
||||||
createAssignment := postgres.CreateAssignmentParams{
|
|
||||||
Date: nextDate.AddDate(0, 0, -1),
|
|
||||||
Amount: negativeAvailable,
|
|
||||||
CategoryID: category.ID,
|
|
||||||
}
|
|
||||||
h.Service.CreateAssignment(c.Request.Context(), createAssignment)
|
|
||||||
}
|
|
||||||
|
|
||||||
if nextDate.Before(time.Now()) {
|
|
||||||
date = nextDate
|
|
||||||
} else {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}*/
|
|
||||||
}
|
|
||||||
|
@ -9,6 +9,28 @@ import (
|
|||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func (h *Handler) autocompleteAccounts(c *gin.Context) {
|
||||||
|
budgetID := c.Param("budgetid")
|
||||||
|
budgetUUID, err := uuid.Parse(budgetID)
|
||||||
|
if err != nil {
|
||||||
|
c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{"budgetid missing from URL"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
query := c.Request.URL.Query().Get("s")
|
||||||
|
searchParams := postgres.SearchAccountsParams{
|
||||||
|
BudgetID: budgetUUID,
|
||||||
|
Search: "%" + query + "%",
|
||||||
|
}
|
||||||
|
categories, err := h.Service.SearchAccounts(c.Request.Context(), searchParams)
|
||||||
|
if err != nil {
|
||||||
|
c.AbortWithError(http.StatusInternalServerError, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, categories)
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Handler) autocompleteCategories(c *gin.Context) {
|
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)
|
||||||
|
@ -1,9 +1,9 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
|
||||||
|
|
||||||
"git.javil.eu/jacob1123/budgeteer/postgres"
|
"git.javil.eu/jacob1123/budgeteer/postgres"
|
||||||
"git.javil.eu/jacob1123/budgeteer/postgres/numeric"
|
"git.javil.eu/jacob1123/budgeteer/postgres/numeric"
|
||||||
@ -11,20 +11,8 @@ import (
|
|||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
func getFirstOfMonth(year, month int, location *time.Location) time.Time {
|
|
||||||
return time.Date(year, time.Month(month), 1, 0, 0, 0, 0, location)
|
|
||||||
}
|
|
||||||
|
|
||||||
func getFirstOfMonthTime(date time.Time) time.Time {
|
|
||||||
var monthM time.Month
|
|
||||||
year, monthM, _ := date.Date()
|
|
||||||
month := int(monthM)
|
|
||||||
return getFirstOfMonth(year, month, date.Location())
|
|
||||||
}
|
|
||||||
|
|
||||||
type CategoryWithBalance struct {
|
type CategoryWithBalance struct {
|
||||||
*postgres.GetCategoriesRow
|
*postgres.GetCategoriesRow
|
||||||
Available numeric.Numeric
|
|
||||||
AvailableLastMonth numeric.Numeric
|
AvailableLastMonth numeric.Numeric
|
||||||
Activity numeric.Numeric
|
Activity numeric.Numeric
|
||||||
Assigned numeric.Numeric
|
Assigned numeric.Numeric
|
||||||
@ -33,7 +21,6 @@ type CategoryWithBalance struct {
|
|||||||
func NewCategoryWithBalance(category *postgres.GetCategoriesRow) CategoryWithBalance {
|
func NewCategoryWithBalance(category *postgres.GetCategoriesRow) CategoryWithBalance {
|
||||||
return CategoryWithBalance{
|
return CategoryWithBalance{
|
||||||
GetCategoriesRow: category,
|
GetCategoriesRow: category,
|
||||||
Available: numeric.Zero(),
|
|
||||||
AvailableLastMonth: numeric.Zero(),
|
AvailableLastMonth: numeric.Zero(),
|
||||||
Activity: numeric.Zero(),
|
Activity: numeric.Zero(),
|
||||||
Assigned: numeric.Zero(),
|
Assigned: numeric.Zero(),
|
||||||
@ -54,48 +41,47 @@ func (h *Handler) budgetingForMonth(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
firstOfMonth, err := getDate(c)
|
month, err := getDate(c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.Redirect(http.StatusTemporaryRedirect, "/budget/"+budgetUUID.String())
|
c.Redirect(http.StatusTemporaryRedirect, "/budget/"+budget.ID.String())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
categories, err := h.Service.GetCategories(c.Request.Context(), budgetUUID)
|
data, err := h.getBudgetingViewForMonth(c.Request.Context(), budget, month)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.AbortWithError(http.StatusInternalServerError, err)
|
c.AbortWithError(http.StatusInternalServerError, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
firstOfNextMonth := firstOfMonth.AddDate(0, 1, 0)
|
|
||||||
cumultativeBalances, err := h.Service.GetCumultativeBalances(c.Request.Context(), budgetUUID)
|
|
||||||
if err != nil {
|
|
||||||
c.AbortWithStatusJSON(http.StatusInternalServerError, ErrorResponse{fmt.Sprintf("error loading balances: %s", err)})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
categoriesWithBalance, moneyUsed := h.calculateBalances(
|
|
||||||
budget, firstOfNextMonth, firstOfMonth, categories, cumultativeBalances)
|
|
||||||
availableBalance := h.getAvailableBalance(budget, moneyUsed, cumultativeBalances, firstOfNextMonth)
|
|
||||||
for i := range categoriesWithBalance {
|
|
||||||
cat := &categoriesWithBalance[i]
|
|
||||||
if cat.ID != budget.IncomeCategoryID {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
cat.Available = availableBalance
|
|
||||||
cat.AvailableLastMonth = availableBalance
|
|
||||||
}
|
|
||||||
|
|
||||||
data := struct {
|
|
||||||
Categories []CategoryWithBalance
|
|
||||||
AvailableBalance numeric.Numeric
|
|
||||||
}{categoriesWithBalance, availableBalance}
|
|
||||||
c.JSON(http.StatusOK, data)
|
c.JSON(http.StatusOK, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (*Handler) getAvailableBalance(budget postgres.Budget,
|
func (h *Handler) getBudgetingViewForMonth(ctx context.Context, budget postgres.Budget, month Month) (BudgetingForMonthResponse, error) {
|
||||||
|
categories, err := h.Service.GetCategories(ctx, budget.ID)
|
||||||
|
if err != nil {
|
||||||
|
return BudgetingForMonthResponse{}, fmt.Errorf("error loading categories: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cumultativeBalances, err := h.Service.GetCumultativeBalances(ctx, budget.ID)
|
||||||
|
if err != nil {
|
||||||
|
return BudgetingForMonthResponse{}, fmt.Errorf("error loading balances: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
categoriesWithBalance, moneyUsed, overspentLastMonth := h.calculateBalances(budget, month, categories, cumultativeBalances)
|
||||||
|
availableBalance := h.getAvailableBalance(budget, month, moneyUsed, cumultativeBalances)
|
||||||
|
|
||||||
|
data := BudgetingForMonthResponse{categoriesWithBalance, availableBalance, overspentLastMonth}
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type BudgetingForMonthResponse struct {
|
||||||
|
Categories []CategoryWithBalance
|
||||||
|
AvailableBalance numeric.Numeric
|
||||||
|
OverspentLastMonth numeric.Numeric
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*Handler) getAvailableBalance(budget postgres.Budget, month Month,
|
||||||
moneyUsed numeric.Numeric, cumultativeBalances []postgres.GetCumultativeBalancesRow,
|
moneyUsed numeric.Numeric, cumultativeBalances []postgres.GetCumultativeBalancesRow,
|
||||||
firstOfNextMonth time.Time) numeric.Numeric {
|
) numeric.Numeric {
|
||||||
availableBalance := moneyUsed
|
availableBalance := moneyUsed
|
||||||
|
|
||||||
for _, bal := range cumultativeBalances {
|
for _, bal := range cumultativeBalances {
|
||||||
@ -103,13 +89,14 @@ func (*Handler) getAvailableBalance(budget postgres.Budget,
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if !bal.Date.Before(firstOfNextMonth) {
|
if month.InFuture(bal.Date) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
availableBalance.AddI(bal.Transactions)
|
availableBalance.AddI(bal.Transactions)
|
||||||
availableBalance.AddI(bal.Assignments)
|
availableBalance.AddI(bal.Assignments) // should be zero, but who knows
|
||||||
}
|
}
|
||||||
|
|
||||||
return availableBalance
|
return availableBalance
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -118,7 +105,7 @@ type BudgetingResponse struct {
|
|||||||
Budget postgres.Budget
|
Budget postgres.Budget
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) budgeting(c *gin.Context) {
|
func (h *Handler) budget(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 {
|
||||||
@ -126,10 +113,10 @@ func (h *Handler) budgeting(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
h.returnBudgetingData(c, budgetUUID)
|
h.getBudget(c, budgetUUID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) returnBudgetingData(c *gin.Context, budgetUUID uuid.UUID) {
|
func (h *Handler) getBudget(c *gin.Context, budgetUUID uuid.UUID) {
|
||||||
budget, err := h.Service.GetBudget(c.Request.Context(), budgetUUID)
|
budget, err := h.Service.GetBudget(c.Request.Context(), budgetUUID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.AbortWithError(http.StatusNotFound, err)
|
c.AbortWithError(http.StatusNotFound, err)
|
||||||
@ -147,54 +134,55 @@ func (h *Handler) returnBudgetingData(c *gin.Context, budgetUUID uuid.UUID) {
|
|||||||
c.JSON(http.StatusOK, data)
|
c.JSON(http.StatusOK, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) calculateBalances(budget postgres.Budget,
|
func (h *Handler) calculateBalances(budget postgres.Budget, month Month,
|
||||||
firstOfNextMonth time.Time, firstOfMonth time.Time, categories []postgres.GetCategoriesRow,
|
categories []postgres.GetCategoriesRow, cumultativeBalances []postgres.GetCumultativeBalancesRow,
|
||||||
cumultativeBalances []postgres.GetCumultativeBalancesRow) ([]CategoryWithBalance, numeric.Numeric) {
|
) ([]CategoryWithBalance, numeric.Numeric, numeric.Numeric) {
|
||||||
categoriesWithBalance := []CategoryWithBalance{}
|
categoriesWithBalance := []CategoryWithBalance{}
|
||||||
|
|
||||||
moneyUsed2 := numeric.Zero()
|
moneyUsed := numeric.Zero()
|
||||||
moneyUsed := &moneyUsed2
|
overspentLastMonth := numeric.Zero()
|
||||||
|
categories = append(categories, postgres.GetCategoriesRow{
|
||||||
|
Group: "Income",
|
||||||
|
Name: "No Category",
|
||||||
|
ID: uuid.UUID{},
|
||||||
|
})
|
||||||
for i := range categories {
|
for i := range categories {
|
||||||
cat := &categories[i]
|
cat := &categories[i]
|
||||||
// do not show hidden categories
|
if cat.ID == budget.IncomeCategoryID {
|
||||||
categoryWithBalance := h.CalculateCategoryBalances(cat, cumultativeBalances,
|
continue
|
||||||
firstOfNextMonth, moneyUsed, firstOfMonth, budget)
|
}
|
||||||
|
|
||||||
|
categoryWithBalance := NewCategoryWithBalance(cat)
|
||||||
|
for _, bal := range cumultativeBalances {
|
||||||
|
if bal.CategoryID != cat.ID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// skip everything in the future
|
||||||
|
if month.InFuture(bal.Date) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
moneyUsed.SubI(bal.Assignments)
|
||||||
|
if month.InPresent(bal.Date) {
|
||||||
|
categoryWithBalance.Activity = bal.Transactions
|
||||||
|
categoryWithBalance.Assigned = bal.Assignments
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
categoryWithBalance.AvailableLastMonth.AddI(bal.Assignments)
|
||||||
|
categoryWithBalance.AvailableLastMonth.AddI(bal.Transactions)
|
||||||
|
if !categoryWithBalance.AvailableLastMonth.IsPositive() {
|
||||||
|
moneyUsed.AddI(categoryWithBalance.AvailableLastMonth)
|
||||||
|
if month.Previous().InPresent(bal.Date) {
|
||||||
|
overspentLastMonth.AddI(categoryWithBalance.AvailableLastMonth)
|
||||||
|
}
|
||||||
|
categoryWithBalance.AvailableLastMonth = numeric.Zero()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
categoriesWithBalance = append(categoriesWithBalance, categoryWithBalance)
|
categoriesWithBalance = append(categoriesWithBalance, categoryWithBalance)
|
||||||
}
|
}
|
||||||
|
|
||||||
return categoriesWithBalance, *moneyUsed
|
return categoriesWithBalance, moneyUsed, overspentLastMonth
|
||||||
}
|
|
||||||
|
|
||||||
func (*Handler) CalculateCategoryBalances(cat *postgres.GetCategoriesRow,
|
|
||||||
cumultativeBalances []postgres.GetCumultativeBalancesRow, firstOfNextMonth time.Time,
|
|
||||||
moneyUsed *numeric.Numeric, firstOfMonth time.Time, budget postgres.Budget) CategoryWithBalance {
|
|
||||||
categoryWithBalance := NewCategoryWithBalance(cat)
|
|
||||||
for _, bal := range cumultativeBalances {
|
|
||||||
if bal.CategoryID != cat.ID {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// skip everything in the future
|
|
||||||
if !bal.Date.Before(firstOfNextMonth) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
moneyUsed.SubI(bal.Assignments)
|
|
||||||
categoryWithBalance.Available.AddI(bal.Assignments)
|
|
||||||
categoryWithBalance.Available.AddI(bal.Transactions)
|
|
||||||
if !categoryWithBalance.Available.IsPositive() && bal.Date.Before(firstOfMonth) {
|
|
||||||
moneyUsed.AddI(categoryWithBalance.Available)
|
|
||||||
categoryWithBalance.Available = numeric.Zero()
|
|
||||||
}
|
|
||||||
|
|
||||||
if bal.Date.Before(firstOfMonth) {
|
|
||||||
categoryWithBalance.AvailableLastMonth = categoryWithBalance.Available
|
|
||||||
} else if bal.Date.Before(firstOfNextMonth) {
|
|
||||||
categoryWithBalance.Activity = bal.Transactions
|
|
||||||
categoryWithBalance.Assigned = bal.Assignments
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return categoryWithBalance
|
|
||||||
}
|
}
|
||||||
|
@ -11,7 +11,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type SetCategoryAssignmentRequest struct {
|
type SetCategoryAssignmentRequest struct {
|
||||||
Assigned string
|
Assigned float64
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) setCategoryAssignment(c *gin.Context) {
|
func (h *Handler) setCategoryAssignment(c *gin.Context) {
|
||||||
@ -44,7 +44,7 @@ func (h *Handler) setCategoryAssignment(c *gin.Context) {
|
|||||||
|
|
||||||
updateArgs := postgres.UpdateAssignmentParams{
|
updateArgs := postgres.UpdateAssignmentParams{
|
||||||
CategoryID: categoryUUID,
|
CategoryID: categoryUUID,
|
||||||
Date: date,
|
Date: date.FirstOfMonth(),
|
||||||
Amount: amount,
|
Amount: amount,
|
||||||
}
|
}
|
||||||
err = h.Service.UpdateAssignment(c.Request.Context(), updateArgs)
|
err = h.Service.UpdateAssignment(c.Request.Context(), updateArgs)
|
||||||
|
@ -1,25 +0,0 @@
|
|||||||
package server
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"git.javil.eu/jacob1123/budgeteer/postgres"
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (h *Handler) dashboard(c *gin.Context) {
|
|
||||||
userID := MustGetToken(c).GetID()
|
|
||||||
budgets, err := h.Service.GetBudgetsForUser(c.Request.Context(), userID)
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
d := DashboardData{
|
|
||||||
Budgets: budgets,
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusOK, d)
|
|
||||||
}
|
|
||||||
|
|
||||||
type DashboardData struct {
|
|
||||||
Budgets []postgres.Budget
|
|
||||||
}
|
|
@ -48,35 +48,37 @@ func (h *Handler) LoadRoutes(router *gin.Engine) {
|
|||||||
api := router.Group("/api/v1")
|
api := router.Group("/api/v1")
|
||||||
|
|
||||||
anonymous := api.Group("/user")
|
anonymous := api.Group("/user")
|
||||||
anonymous.GET("/login", func(c *gin.Context) { c.Redirect(http.StatusPermanentRedirect, "/login") })
|
|
||||||
anonymous.POST("/login", h.loginPost)
|
anonymous.POST("/login", h.loginPost)
|
||||||
anonymous.POST("/register", h.registerPost)
|
anonymous.POST("/register", h.registerPost)
|
||||||
|
|
||||||
authenticated := api.Group("")
|
authenticated := api.Group("")
|
||||||
authenticated.Use(h.verifyLoginWithForbidden)
|
{
|
||||||
authenticated.GET("/dashboard", h.dashboard)
|
authenticated.Use(h.verifyLoginWithForbidden)
|
||||||
authenticated.GET("/account/:accountid/transactions", h.transactionsForAccount)
|
authenticated.GET("/account/:accountid/transactions", h.transactionsForAccount)
|
||||||
authenticated.POST("/account/:accountid/reconcile", h.reconcileTransactions)
|
authenticated.POST("/account/:accountid/reconcile", h.reconcileTransactions)
|
||||||
authenticated.POST("/account/:accountid", h.editAccount)
|
authenticated.POST("/account/:accountid", h.editAccount)
|
||||||
authenticated.GET("/admin/clear-database", h.clearDatabase)
|
authenticated.GET("/admin/clear-database", h.clearDatabase)
|
||||||
|
|
||||||
budget := authenticated.Group("/budget")
|
budget := authenticated.Group("/budget")
|
||||||
budget.POST("/new", h.newBudget)
|
budget.POST("/new", h.newBudget)
|
||||||
budget.GET("/:budgetid", h.budgeting)
|
budget.GET("/:budgetid", h.budget)
|
||||||
budget.GET("/:budgetid/:year/:month", h.budgetingForMonth)
|
budget.GET("/:budgetid/:year/:month", h.budgetingForMonth)
|
||||||
budget.POST("/:budgetid/category/:categoryid/:year/:month", h.setCategoryAssignment)
|
budget.POST("/:budgetid/category/:categoryid/:year/:month", h.setCategoryAssignment)
|
||||||
budget.GET("/:budgetid/autocomplete/payees", h.autocompletePayee)
|
budget.GET("/:budgetid/autocomplete/payees", h.autocompletePayee)
|
||||||
budget.GET("/:budgetid/autocomplete/categories", h.autocompleteCategories)
|
budget.GET("/:budgetid/autocomplete/accounts", h.autocompleteAccounts)
|
||||||
budget.DELETE("/:budgetid", h.deleteBudget)
|
budget.GET("/:budgetid/autocomplete/categories", h.autocompleteCategories)
|
||||||
budget.POST("/:budgetid/import/ynab", h.importYNAB)
|
budget.GET("/:budgetid/problematic-transactions", h.problematicTransactions)
|
||||||
budget.POST("/:budgetid/export/ynab/transactions", h.exportYNABTransactions)
|
budget.POST("/:budgetid/filtered-transactions", h.filteredTransactions)
|
||||||
budget.POST("/:budgetid/export/ynab/assignments", h.exportYNABAssignments)
|
budget.DELETE("/:budgetid", h.deleteBudget)
|
||||||
budget.POST("/:budgetid/settings/clear", h.clearBudget)
|
budget.POST("/:budgetid/import/ynab", h.importYNAB)
|
||||||
budget.POST("/:budgetid/settings/clean-negative", h.cleanNegativeBudget)
|
budget.POST("/:budgetid/export/ynab/transactions", h.exportYNABTransactions)
|
||||||
|
budget.POST("/:budgetid/export/ynab/assignments", h.exportYNABAssignments)
|
||||||
|
budget.POST("/:budgetid/settings/clear", h.clearBudget)
|
||||||
|
|
||||||
transaction := authenticated.Group("/transaction")
|
transaction := authenticated.Group("/transaction")
|
||||||
transaction.POST("/new", h.newTransaction)
|
transaction.POST("/new", h.newTransaction)
|
||||||
transaction.POST("/:transactionid", h.newTransaction)
|
transaction.POST("/:transactionid", h.updateTransaction)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) ServeStatic(c *gin.Context) {
|
func (h *Handler) ServeStatic(c *gin.Context) {
|
||||||
|
305
server/main_test.go
Normal file
305
server/main_test.go
Normal file
@ -0,0 +1,305 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.javil.eu/jacob1123/budgeteer/bcrypt"
|
||||||
|
"git.javil.eu/jacob1123/budgeteer/config"
|
||||||
|
"git.javil.eu/jacob1123/budgeteer/jwt"
|
||||||
|
"git.javil.eu/jacob1123/budgeteer/postgres"
|
||||||
|
"git.javil.eu/jacob1123/budgeteer/web"
|
||||||
|
|
||||||
|
txdb "github.com/DATA-DOG/go-txdb"
|
||||||
|
)
|
||||||
|
|
||||||
|
var cfg = config.Config{ //nolint:gochecknoglobals
|
||||||
|
DatabaseConnection: "postgres://budgeteer:budgeteer@db:5432/budgeteer_test",
|
||||||
|
SessionSecret: "this_is_my_demo_secret_for_unit_tests",
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() { //nolint:gochecknoinits
|
||||||
|
_, err := postgres.Connect("pgx", cfg.DatabaseConnection)
|
||||||
|
if err != nil {
|
||||||
|
panic("failed connecting to DB for migrations: " + err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
txdb.Register("pgtx", "pgx", cfg.DatabaseConnection)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMain(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
queries, err := postgres.Connect("pgtx", cfg.DatabaseConnection)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("connect to DB: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
static, err := fs.Sub(web.Static, "dist")
|
||||||
|
if err != nil {
|
||||||
|
panic("couldn't open static files")
|
||||||
|
}
|
||||||
|
|
||||||
|
tokenVerifier, err := jwt.NewTokenVerifier(cfg.SessionSecret)
|
||||||
|
if err != nil {
|
||||||
|
panic(fmt.Errorf("couldn't create token verifier: %w", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
handler := &Handler{
|
||||||
|
Service: queries,
|
||||||
|
TokenVerifier: tokenVerifier,
|
||||||
|
CredentialsVerifier: &bcrypt.Verifier{},
|
||||||
|
StaticFS: http.FS(static),
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
createUserParams := postgres.CreateUserParams{
|
||||||
|
Email: "test@example.com",
|
||||||
|
Name: "test@example.com",
|
||||||
|
Password: "this is my dumb password",
|
||||||
|
}
|
||||||
|
user, err := handler.Service.CreateUser(ctx, createUserParams)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
t.Fail()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
budget, err := handler.Service.NewBudget(ctx, "My nice Budget", user.ID)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
t.Fail()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
handler.DoYNABImport(ctx, t, budget)
|
||||||
|
|
||||||
|
loc := time.Now().Location()
|
||||||
|
|
||||||
|
AssertCategoriesAndAvailableEqual(ctx, t, loc, handler, budget)
|
||||||
|
|
||||||
|
AssertAccountsEqual(ctx, t, handler, budget)
|
||||||
|
}
|
||||||
|
|
||||||
|
func AssertAccountsEqual(ctx context.Context, t *testing.T, handler *Handler, budget *postgres.Budget) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
t.Run("account balances", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
resultDir := "../testdata/production-export/results"
|
||||||
|
files, err := os.ReadDir(resultDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("could not load results: %s", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, file := range files {
|
||||||
|
if file.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
name := file.Name()[0 : len(file.Name())-5]
|
||||||
|
if name != "accounts" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
testCaseFile := filepath.Join(resultDir, file.Name())
|
||||||
|
handler.CheckAccountBalance(ctx, t, testCaseFile, budget)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h Handler) CheckAccountBalance(ctx context.Context, t *testing.T, testCaseFile string, budget *postgres.Budget) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
accounts, err := h.Service.GetAccountsWithBalance(ctx, budget.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("get accounts: %s", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
testDataFile, err := os.Open(testCaseFile)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("could not load category test data: %s", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var testData map[string]float64
|
||||||
|
dec := json.NewDecoder(testDataFile)
|
||||||
|
err = dec.Decode(&testData)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("could not decode category test data: %s", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for accountName, accountBalance := range testData {
|
||||||
|
found := false
|
||||||
|
for _, account := range accounts {
|
||||||
|
if account.Name == accountName {
|
||||||
|
assertEqual(t, accountBalance, account.WorkingBalance.GetFloat64(), "balance for "+accountName)
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !found {
|
||||||
|
t.Errorf("account " + accountName + " was not found in result")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func AssertCategoriesAndAvailableEqual(ctx context.Context, t *testing.T, loc *time.Location, handler *Handler, budget *postgres.Budget) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
t.Run("Categories and available balance", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
resultDir := "../testdata/production-export/results"
|
||||||
|
files, err := os.ReadDir(resultDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("could not load results: %s", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, file := range files {
|
||||||
|
if file.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
name := file.Name()[0 : len(file.Name())-5]
|
||||||
|
parts := strings.Split(name, "-")
|
||||||
|
if len(parts) != 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
year, _ := strconv.Atoi(parts[0])
|
||||||
|
month, _ := strconv.Atoi(parts[1])
|
||||||
|
testCaseFile := filepath.Join(resultDir, file.Name())
|
||||||
|
handler.CheckAvailableBalance(ctx, t, testCaseFile, budget, Month{year, month})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type TestData struct {
|
||||||
|
AvailableBalance float64
|
||||||
|
Categories map[string]CategoryTestData
|
||||||
|
}
|
||||||
|
|
||||||
|
type CategoryTestData struct {
|
||||||
|
Available float64
|
||||||
|
Activity float64
|
||||||
|
Assigned float64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h Handler) CheckAvailableBalance(ctx context.Context, t *testing.T, testCaseFile string, budget *postgres.Budget, month Month) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
t.Run(month.String(), func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
data, err := h.getBudgetingViewForMonth(ctx, *budget, month)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("prepare budgeting: %s", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
testDataFile, err := os.Open(testCaseFile)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("could not load category test data: %s", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var testData TestData
|
||||||
|
dec := json.NewDecoder(testDataFile)
|
||||||
|
err = dec.Decode(&testData)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("could not decode category test data: %s", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEqual(t, testData.AvailableBalance, data.AvailableBalance.GetFloat64(), "available balance")
|
||||||
|
|
||||||
|
for categoryName, categoryTestData := range testData.Categories {
|
||||||
|
found := false
|
||||||
|
for _, category := range data.Categories {
|
||||||
|
name := category.Group + " : " + category.Name
|
||||||
|
|
||||||
|
if name == categoryName {
|
||||||
|
assertEqual(t, categoryTestData.Activity, category.Activity.GetFloat64(), "activity for "+categoryName)
|
||||||
|
assertEqual(t, categoryTestData.Assigned, category.Assigned.GetFloat64(), "assigned for "+categoryName)
|
||||||
|
available := category.AvailableLastMonth
|
||||||
|
available.AddI(category.Activity).AddI(category.Assigned)
|
||||||
|
assertEqual(t, categoryTestData.Available, available.GetFloat64(), "available for "+categoryName)
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !found {
|
||||||
|
t.Errorf("category " + categoryName + " was not found in result")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func AssertEqualBool(t *testing.T, expected, actual bool, message string) {
|
||||||
|
t.Helper()
|
||||||
|
if expected == actual {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Errorf("%s: expected %v, got %v", message, expected, actual)
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertEqual(t *testing.T, expected, actual float64, message string) {
|
||||||
|
t.Helper()
|
||||||
|
if expected == actual {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Errorf("%s: expected %f, got %f", message, expected, actual)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h Handler) DoYNABImport(ctx context.Context, t *testing.T, budget *postgres.Budget) {
|
||||||
|
t.Helper()
|
||||||
|
budgetID := budget.ID
|
||||||
|
ynab, err := postgres.NewYNABImport(ctx, h.Service.Queries, budgetID)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
t.Fail()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
transactions, err := os.Open("../testdata/production-export/Register.tsv")
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
t.Fail()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
assignments, err := os.Open("../testdata/production-export/Budget.tsv")
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
t.Fail()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = ynab.ImportTransactions(ctx, transactions)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
t.Fail()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = ynab.ImportAssignments(ctx, assignments)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
t.Fail()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
71
server/month.go
Normal file
71
server/month.go
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Month struct {
|
||||||
|
Year int
|
||||||
|
Month int
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewFromTime(date time.Time) Month {
|
||||||
|
return Month{date.Year(), int(date.Month())}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Month) String() string {
|
||||||
|
return fmt.Sprintf("%d-%d", m.Year, m.Month)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Month) FirstOfMonth() time.Time {
|
||||||
|
return time.Date(m.Year, time.Month(m.Month), 1, 0, 0, 0, 0, time.Now().Location())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Month) Previous() Month {
|
||||||
|
if m.Month == int(time.January) {
|
||||||
|
return Month{Year: m.Year - 1, Month: int(time.December)}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Month{Year: m.Year, Month: m.Month - 1}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Month) Next() Month {
|
||||||
|
if m.Month == int(time.December) {
|
||||||
|
return Month{Year: m.Year + 1, Month: int(time.January)}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Month{Year: m.Year, Month: m.Month + 1}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Month) InFuture(date time.Time) bool {
|
||||||
|
if m.Year < date.Year() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if m.Year > date.Year() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return time.Month(m.Month) < date.Month()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Month) InPast(date time.Time) bool {
|
||||||
|
if m.Year > date.Year() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if m.Year < date.Year() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return time.Month(m.Month) > date.Month()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Month) InPresent(date time.Time) bool {
|
||||||
|
if date.Year() != m.Year {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return date.Month() == time.Month(m.Month)
|
||||||
|
}
|
46
server/month_test.go
Normal file
46
server/month_test.go
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
package server_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.javil.eu/jacob1123/budgeteer/server"
|
||||||
|
)
|
||||||
|
|
||||||
|
type TestCaseCompare struct {
|
||||||
|
Value server.Month
|
||||||
|
Date time.Time
|
||||||
|
InPast bool
|
||||||
|
InPresent bool
|
||||||
|
InFuture bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComparisons(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
loc := time.Now().Location()
|
||||||
|
tests := []TestCaseCompare{
|
||||||
|
{server.Month{2022, 2}, time.Date(2022, 3, 1, 0, 0, 0, 0, loc), false, false, true},
|
||||||
|
{server.Month{2022, 3}, time.Date(2022, 3, 1, 0, 0, 0, 0, loc), false, true, false},
|
||||||
|
{server.Month{2022, 4}, time.Date(2022, 3, 1, 0, 0, 0, 0, loc), true, false, false},
|
||||||
|
{server.Month{2022, 2}, time.Date(2022, 3, 31, 0, 0, 0, 0, loc), false, false, true},
|
||||||
|
{server.Month{2022, 3}, time.Date(2022, 3, 31, 0, 0, 0, 0, loc), false, true, false},
|
||||||
|
{server.Month{2022, 4}, time.Date(2022, 3, 31, 0, 0, 0, 0, loc), true, false, false},
|
||||||
|
{server.Month{2021, 2}, time.Date(2022, 3, 31, 0, 0, 0, 0, loc), false, false, true},
|
||||||
|
{server.Month{2021, 3}, time.Date(2022, 3, 31, 0, 0, 0, 0, loc), false, false, true},
|
||||||
|
{server.Month{2021, 4}, time.Date(2022, 3, 31, 0, 0, 0, 0, loc), false, false, true},
|
||||||
|
{server.Month{2023, 2}, time.Date(2022, 3, 31, 0, 0, 0, 0, loc), true, false, false},
|
||||||
|
{server.Month{2023, 3}, time.Date(2022, 3, 31, 0, 0, 0, 0, loc), true, false, false},
|
||||||
|
{server.Month{2023, 4}, time.Date(2022, 3, 31, 0, 0, 0, 0, loc), true, false, false},
|
||||||
|
{server.Month{2021, 11}, time.Date(2021, 12, 1, 0, 0, 0, 0, loc), false, false, true},
|
||||||
|
}
|
||||||
|
for i := range tests { //nolint:paralleltest
|
||||||
|
test := tests[i]
|
||||||
|
t.Run(test.Date.Format("2006-01-02")+" is in of "+test.Value.String(), func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
server.AssertEqualBool(t, test.InPast, test.Value.InPast(test.Date), "in past")
|
||||||
|
server.AssertEqualBool(t, test.InPresent, test.Value.InPresent(test.Date), "in present")
|
||||||
|
server.AssertEqualBool(t, test.InFuture, test.Value.InFuture(test.Date), "in future")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
@ -27,7 +27,7 @@ type NewTransactionPayload struct {
|
|||||||
State string `json:"state"`
|
State string `json:"state"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) newTransaction(c *gin.Context) {
|
func (h *Handler) updateTransaction(c *gin.Context) {
|
||||||
var payload NewTransactionPayload
|
var payload NewTransactionPayload
|
||||||
err := c.BindJSON(&payload)
|
err := c.BindJSON(&payload)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -42,8 +42,26 @@ func (h *Handler) newTransaction(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
transactionID := c.Param("transactionid")
|
transactionID := c.Param("transactionid")
|
||||||
if transactionID != "" {
|
transactionUUID, err := uuid.Parse(transactionID)
|
||||||
h.UpdateTransaction(payload, amount, transactionID, c)
|
if err != nil {
|
||||||
|
c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{"transactionid missing from URL"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.UpdateTransaction(payload, amount, transactionUUID, c)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) newTransaction(c *gin.Context) {
|
||||||
|
var payload NewTransactionPayload
|
||||||
|
err := c.BindJSON(&payload)
|
||||||
|
if err != nil {
|
||||||
|
c.AbortWithError(http.StatusBadRequest, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
amount, err := numeric.Parse(payload.Amount)
|
||||||
|
if err != nil {
|
||||||
|
c.AbortWithError(http.StatusBadRequest, fmt.Errorf("amount: %w", err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -87,8 +105,7 @@ func (h *Handler) newTransaction(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, transaction)
|
c.JSON(http.StatusOK, transaction)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) UpdateTransaction(payload NewTransactionPayload, amount numeric.Numeric, transactionID string, c *gin.Context) {
|
func (h *Handler) UpdateTransaction(payload NewTransactionPayload, amount numeric.Numeric, transactionUUID uuid.UUID, c *gin.Context) {
|
||||||
transactionUUID := uuid.MustParse(transactionID)
|
|
||||||
if amount.IsZero() {
|
if amount.IsZero() {
|
||||||
err := h.Service.DeleteTransaction(c.Request.Context(), transactionUUID)
|
err := h.Service.DeleteTransaction(c.Request.Context(), transactionUUID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -8,7 +8,7 @@ import (
|
|||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
func getDate(c *gin.Context) (time.Time, error) {
|
func getDate(c *gin.Context) (Month, error) {
|
||||||
var year, month int
|
var year, month int
|
||||||
yearString := c.Param("year")
|
yearString := c.Param("year")
|
||||||
monthString := c.Param("month")
|
monthString := c.Param("month")
|
||||||
@ -18,13 +18,20 @@ func getDate(c *gin.Context) (time.Time, error) {
|
|||||||
|
|
||||||
year, err := strconv.Atoi(yearString)
|
year, err := strconv.Atoi(yearString)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return time.Time{}, fmt.Errorf("parse year: %w", err)
|
return Month{}, fmt.Errorf("parse year: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
month, err = strconv.Atoi(monthString)
|
month, err = strconv.Atoi(monthString)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return time.Time{}, fmt.Errorf("parse month: %w", err)
|
return Month{}, fmt.Errorf("parse month: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return getFirstOfMonth(year, month, time.Now().Location()), nil
|
return Month{year, month}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getFirstOfMonthTime(date time.Time) Month {
|
||||||
|
var monthM time.Month
|
||||||
|
year, monthM, _ := date.Date()
|
||||||
|
month := int(monthM)
|
||||||
|
return Month{year, month}
|
||||||
}
|
}
|
||||||
|
1
testdata
Submodule
1
testdata
Submodule
Submodule testdata added at 8de369b17a
@ -7,6 +7,10 @@ module.exports = {
|
|||||||
],
|
],
|
||||||
rules: {
|
rules: {
|
||||||
// override/add rules settings here, such as:
|
// override/add rules settings here, such as:
|
||||||
|
'vue/max-attributes-per-line': 'off',
|
||||||
|
'vue/singleline-html-element-content-newline': 'off',
|
||||||
|
'vue/first-attribute-linebreak': 'off',
|
||||||
|
'vue/html-closing-bracket-newline': 'off',
|
||||||
// 'vue/no-unused-vars': 'error'
|
// 'vue/no-unused-vars': 'error'
|
||||||
},
|
},
|
||||||
parser: "vue-eslint-parser",
|
parser: "vue-eslint-parser",
|
||||||
|
0
web/dist/generate-directory-for-ci
vendored
Normal file
0
web/dist/generate-directory-for-ci
vendored
Normal file
@ -22,9 +22,6 @@
|
|||||||
"@types/file-saver": "^2.0.5",
|
"@types/file-saver": "^2.0.5",
|
||||||
"@typescript-eslint/parser": "^5.13.0",
|
"@typescript-eslint/parser": "^5.13.0",
|
||||||
"@vitejs/plugin-vue": "^2.0.0",
|
"@vitejs/plugin-vue": "^2.0.0",
|
||||||
"@vue/cli-plugin-babel": "5.0.0-beta.7",
|
|
||||||
"@vue/cli-plugin-typescript": "~4.5.0",
|
|
||||||
"@vue/cli-service": "5.0.0-beta.7",
|
|
||||||
"eslint": "^8.10.0",
|
"eslint": "^8.10.0",
|
||||||
"eslint-plugin-vue": "^8.5.0",
|
"eslint-plugin-vue": "^8.5.0",
|
||||||
"prettier": "2.5.1",
|
"prettier": "2.5.1",
|
||||||
|
@ -1,28 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts" setup>
|
||||||
import { mapState } from "pinia";
|
import MainMenu from "./components/MainMenu.vue";
|
||||||
import { defineComponent } from "vue";
|
|
||||||
import { useBudgetsStore } from "./stores/budget";
|
|
||||||
import { useSessionStore } from "./stores/session";
|
|
||||||
import { useSettingsStore } from "./stores/settings";
|
|
||||||
|
|
||||||
export default defineComponent({
|
|
||||||
computed: {
|
|
||||||
...mapState(useBudgetsStore, ["CurrentBudgetName"]),
|
|
||||||
...mapState(useSessionStore, ["LoggedIn"]),
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
logout() {
|
|
||||||
useSessionStore().logout();
|
|
||||||
this.$router.push("/login");
|
|
||||||
},
|
|
||||||
toggleMenu() {
|
|
||||||
useSettingsStore().toggleMenu();
|
|
||||||
},
|
|
||||||
toggleMenuSize() {
|
|
||||||
useSettingsStore().toggleMenuSize();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@ -30,42 +7,7 @@ export default defineComponent({
|
|||||||
<router-view name="sidebar" />
|
<router-view name="sidebar" />
|
||||||
|
|
||||||
<div class="flex-1 overflow-auto">
|
<div class="flex-1 overflow-auto">
|
||||||
<div
|
<MainMenu />
|
||||||
class="flex bg-gray-400 dark:bg-gray-600 p-4 fixed md:static top-0 left-0 w-full h-14"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
class="flex-1 font-bold text-5xl -my-3 hidden md:inline"
|
|
||||||
@click="toggleMenuSize"
|
|
||||||
>≡</span>
|
|
||||||
<span
|
|
||||||
class="flex-1 font-bold text-5xl -my-3 md:hidden"
|
|
||||||
@click="toggleMenu"
|
|
||||||
>≡</span>
|
|
||||||
|
|
||||||
<span class="flex-1">{{ CurrentBudgetName }}</span>
|
|
||||||
|
|
||||||
<div class="flex flex-1 flex-row justify-end -mx-4">
|
|
||||||
<router-link
|
|
||||||
v-if="LoggedIn"
|
|
||||||
class="mx-4"
|
|
||||||
to="/dashboard"
|
|
||||||
>
|
|
||||||
Dashboard
|
|
||||||
</router-link>
|
|
||||||
<router-link
|
|
||||||
v-if="!LoggedIn"
|
|
||||||
class="mx-4"
|
|
||||||
to="/login"
|
|
||||||
>
|
|
||||||
Login
|
|
||||||
</router-link>
|
|
||||||
<a
|
|
||||||
v-if="LoggedIn"
|
|
||||||
class="mx-4"
|
|
||||||
@click="logout"
|
|
||||||
>Logout</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="p-3 pl-6">
|
<div class="p-3 pl-6">
|
||||||
<router-view />
|
<router-view />
|
||||||
|
@ -86,7 +86,7 @@ function clear() {
|
|||||||
v-if="id == undefined"
|
v-if="id == undefined"
|
||||||
v-model="SearchQuery"
|
v-model="SearchQuery"
|
||||||
type="text"
|
type="text"
|
||||||
class="border-b-2 border-black"
|
class="border-b-2 border-black block w-full border-b-2 border-black"
|
||||||
@keypress="keypress"
|
@keypress="keypress"
|
||||||
/>
|
/>
|
||||||
<span
|
<span
|
||||||
|
@ -1,17 +1,26 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
modelValue?: number | string
|
modelValue?: number | string,
|
||||||
|
type: string,
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emits = defineEmits<{
|
const emits = defineEmits<{
|
||||||
(e: "update:modelValue", value: number | string): void
|
(e: "update:modelValue", value: number | string): void
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
function valueChanged(e: Event) {
|
||||||
|
const target = <HTMLInputElement>e.target;
|
||||||
|
switch (props.type) {
|
||||||
|
case "number":
|
||||||
|
emits('update:modelValue', target.valueAsNumber);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
emits('update:modelValue', target.value)
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<input
|
<input :value="modelValue" :type="type" class="dark:bg-slate-900" @input="valueChanged">
|
||||||
:value="modelValue"
|
|
||||||
class="dark:bg-slate-900"
|
|
||||||
@input="emits('update:modelValue', ($event.target as HTMLInputElement)?.value)"
|
|
||||||
>
|
|
||||||
</template>
|
</template>
|
||||||
|
44
web/src/components/MainMenu.vue
Normal file
44
web/src/components/MainMenu.vue
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { computed } from "vue";
|
||||||
|
import { useRouter } from "vue-router";
|
||||||
|
import { useBudgetsStore } from "../stores/budget";
|
||||||
|
import { useSessionStore } from "../stores/session";
|
||||||
|
import { useSettingsStore } from "../stores/settings";
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const CurrentBudgetName = computed(() => useBudgetsStore().CurrentBudgetName);
|
||||||
|
const LoggedIn = computed(() => useSessionStore().LoggedIn);
|
||||||
|
function logout() {
|
||||||
|
useSessionStore().logout();
|
||||||
|
router.push("/login");
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleMenu() {
|
||||||
|
useSettingsStore().toggleMenu();
|
||||||
|
}
|
||||||
|
function toggleMenuSize() {
|
||||||
|
useSettingsStore().toggleMenuSize();
|
||||||
|
}
|
||||||
|
|
||||||
|
router.afterEach(function(to, from) {
|
||||||
|
useSettingsStore().Menu.Show = false;
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex bg-gray-400 dark:bg-gray-600 p-4 static top-0 left-0 w-full h-14">
|
||||||
|
<span class="flex-1 font-bold text-5xl -my-3 hidden md:inline" @click="toggleMenuSize">≡</span>
|
||||||
|
<span class="flex-1 font-bold text-5xl -my-3 md:hidden" @click="toggleMenu">≡</span>
|
||||||
|
<span class="flex-1">{{ CurrentBudgetName }}</span>
|
||||||
|
<div class="flex flex-1 flex-row justify-end -mx-4">
|
||||||
|
<router-link v-if="LoggedIn" class="mx-4" to="/dashboard">
|
||||||
|
Dashboard
|
||||||
|
</router-link>
|
||||||
|
<router-link v-if="!LoggedIn" class="mx-4" to="/login">
|
||||||
|
Login
|
||||||
|
</router-link>
|
||||||
|
<a v-if="LoggedIn" class="mx-4" @click="logout">Logout</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
@ -48,7 +48,7 @@ function submitDialog() {
|
|||||||
class="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full"
|
class="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="relative top-20 mx-auto p-5 w-96 shadow-lg rounded-md bg-white dark:bg-black"
|
class="relative md:top-20 md:mx-auto p-5 md:w-96 shadow-lg md:h-auto h-full rounded-md bg-white dark:bg-black"
|
||||||
>
|
>
|
||||||
<div class="mt-3 text-center">
|
<div class="mt-3 text-center">
|
||||||
<h3
|
<h3
|
||||||
|
@ -8,7 +8,8 @@ import Input from "./Input.vue";
|
|||||||
import Button from "./SimpleButton.vue";
|
import Button from "./SimpleButton.vue";
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
transactionid: string
|
transactionid: string,
|
||||||
|
withAccount: boolean,
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits(["save"]);
|
const emit = defineEmits(["save"]);
|
||||||
@ -18,67 +19,47 @@ const TX = transactionsStore.Transactions.get(props.transactionid)!;
|
|||||||
const payeeType = ref<string|undefined>(undefined);
|
const payeeType = ref<string|undefined>(undefined);
|
||||||
|
|
||||||
const payload = computed(() => JSON.stringify({
|
const payload = computed(() => JSON.stringify({
|
||||||
date: TX.Date.toISOString().split("T")[0],
|
date: TX.Date.toISOString().split("T")[0],
|
||||||
payee: {
|
payee: {
|
||||||
Name: TX.Payee,
|
Name: TX.Payee,
|
||||||
ID: TX.PayeeID,
|
ID: TX.PayeeID,
|
||||||
Type: payeeType.value,
|
Type: payeeType.value,
|
||||||
},
|
},
|
||||||
categoryId: TX.CategoryID,
|
categoryId: TX.CategoryID,
|
||||||
memo: TX.Memo,
|
memo: TX.Memo,
|
||||||
amount: TX.Amount.toString(),
|
amount: TX.Amount.toString(),
|
||||||
state: "Uncleared"
|
state: "Uncleared"
|
||||||
}));
|
}));
|
||||||
|
|
||||||
function saveTransaction(e: MouseEvent) {
|
function saveTransaction(e: MouseEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
transactionsStore.editTransaction(TX.ID, payload.value);
|
transactionsStore.editTransaction(TX.ID, payload.value);
|
||||||
emit('save');
|
emit('save');
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<tr>
|
<tr>
|
||||||
<td class="text-sm">
|
<td class="text-sm">
|
||||||
<DateInput
|
<DateInput v-model="TX.Date" class="border-b-2 border-black" />
|
||||||
v-model="TX.Date"
|
</td>
|
||||||
class="border-b-2 border-black"
|
<td v-if="withAccount">
|
||||||
/>
|
<Autocomplete v-model:text="TX.Account" v-model:id="TX.AccountID" model="accounts" />
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<Autocomplete
|
<Autocomplete v-model:text="TX.Payee" v-model:id="TX.PayeeID" v-model:type="payeeType" model="payees" />
|
||||||
v-model:text="TX.Payee"
|
|
||||||
v-model:id="TX.PayeeID"
|
|
||||||
v-model:type="payeeType"
|
|
||||||
model="payees"
|
|
||||||
/>
|
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<Autocomplete
|
<Autocomplete v-model:text="TX.Category" v-model:id="TX.CategoryID" model="categories" />
|
||||||
v-model:text="TX.Category"
|
|
||||||
v-model:id="TX.CategoryID"
|
|
||||||
model="categories"
|
|
||||||
/>
|
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<Input
|
<Input v-model="TX.Memo" class="block w-full border-b-2 border-black" type="text" />
|
||||||
v-model="TX.Memo"
|
|
||||||
class="block w-full border-b-2 border-black"
|
|
||||||
type="text"
|
|
||||||
/>
|
|
||||||
</td>
|
</td>
|
||||||
<td class="text-right">
|
<td class="text-right">
|
||||||
<Input
|
<Input v-model="TX.Amount" class="text-right block w-full border-b-2 border-black" type="currency" />
|
||||||
v-model="TX.Amount"
|
|
||||||
class="text-right block w-full border-b-2 border-black"
|
|
||||||
type="currency"
|
|
||||||
/>
|
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<Button
|
<Button class="bg-blue-500" @click="saveTransaction">
|
||||||
class="bg-blue-500"
|
|
||||||
@click="saveTransaction"
|
|
||||||
>
|
|
||||||
Save
|
Save
|
||||||
</Button>
|
</Button>
|
||||||
</td>
|
</td>
|
||||||
|
@ -24,7 +24,9 @@ const TX = ref<Transaction>({
|
|||||||
ID: "",
|
ID: "",
|
||||||
Status: "Uncleared",
|
Status: "Uncleared",
|
||||||
TransferAccount: "",
|
TransferAccount: "",
|
||||||
Reconciled: false
|
Reconciled: false,
|
||||||
|
Account: "",
|
||||||
|
AccountID: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
const payeeType = ref<string|undefined>(undefined);
|
const payeeType = ref<string|undefined>(undefined);
|
||||||
@ -63,7 +65,7 @@ defineExpose({Save});
|
|||||||
<td class="text-sm">
|
<td class="text-sm">
|
||||||
<DateInput
|
<DateInput
|
||||||
v-model="TX.Date"
|
v-model="TX.Date"
|
||||||
class="border-b-2 border-black"
|
class="block w-full border-b-2 border-black"
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<label class="md:hidden">Payee</label>
|
<label class="md:hidden">Payee</label>
|
||||||
@ -83,6 +85,7 @@ defineExpose({Save});
|
|||||||
model="categories"
|
model="categories"
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
|
<label class="md:hidden">Memo</label>
|
||||||
<td class="col-span-2">
|
<td class="col-span-2">
|
||||||
<Input
|
<Input
|
||||||
v-model="TX.Memo"
|
v-model="TX.Memo"
|
||||||
|
@ -6,12 +6,12 @@ import Currency from "./Currency.vue";
|
|||||||
import TransactionEditRow from "./TransactionEditRow.vue";
|
import TransactionEditRow from "./TransactionEditRow.vue";
|
||||||
import { formatDate } from "../date";
|
import { formatDate } from "../date";
|
||||||
import { useAccountStore } from "../stores/budget-account";
|
import { useAccountStore } from "../stores/budget-account";
|
||||||
import Input from "./Input.vue";
|
|
||||||
import Checkbox from "./Checkbox.vue";
|
import Checkbox from "./Checkbox.vue";
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
transactionid: string,
|
transactionid: string,
|
||||||
index: number,
|
index: number,
|
||||||
|
withAccount: boolean,
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const edit = ref(false);
|
const edit = ref(false);
|
||||||
@ -22,19 +22,6 @@ const Reconciling = computed(() => useTransactionsStore().Reconciling);
|
|||||||
const transactionsStore = useTransactionsStore();
|
const transactionsStore = useTransactionsStore();
|
||||||
const TX = transactionsStore.Transactions.get(props.transactionid)!;
|
const TX = transactionsStore.Transactions.get(props.transactionid)!;
|
||||||
|
|
||||||
function dateChanged() {
|
|
||||||
const currentAccount = useAccountStore().CurrentAccount;
|
|
||||||
if (currentAccount == null)
|
|
||||||
return true;
|
|
||||||
const transactionIndex = currentAccount.Transactions.indexOf(props.transactionid);
|
|
||||||
if(transactionIndex<=0)
|
|
||||||
return true;
|
|
||||||
|
|
||||||
const previousTransactionId = currentAccount.Transactions[transactionIndex-1];
|
|
||||||
const previousTransaction = transactionsStore.Transactions.get(previousTransactionId);
|
|
||||||
return TX.Date.getTime() != previousTransaction?.Date.getTime();
|
|
||||||
}
|
|
||||||
|
|
||||||
function getStatusSymbol() {
|
function getStatusSymbol() {
|
||||||
if(TX.Status == "Reconciled")
|
if(TX.Status == "Reconciled")
|
||||||
return "✔";
|
return "✔";
|
||||||
@ -47,22 +34,17 @@ function getStatusSymbol() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<tr v-if="dateChanged()" class="table-row md:hidden">
|
|
||||||
<td class="py-2" colspan="5">
|
|
||||||
<span class="bg-gray-400 dark:bg-slate-600 rounded-lg p-1 px-2 w-full block">
|
|
||||||
{{ formatDate(TX.Date) }}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr
|
<tr
|
||||||
v-if="!edit"
|
v-if="!edit"
|
||||||
class="{{new Date(TX.Date) > new Date() ? 'future' : ''}}"
|
class="{{new Date(TX.Date) > new Date() ? 'future' : ''}}"
|
||||||
:class="[index % 6 < 3 ? 'md:bg-gray-300 dark:md:bg-gray-700' : 'md:bg-gray-100 dark:md:bg-gray-900']"
|
:class="[index % 2 < 1 ? 'md:bg-gray-300 dark:md:bg-gray-700' : 'md:bg-gray-100 dark:md:bg-gray-900']"
|
||||||
>
|
>
|
||||||
<!--:class="[index % 6 < 3 ? index % 6 === 1 ? 'bg-gray-400' : 'bg-gray-300' : index % 6 !== 4 ? 'bg-gray-100' : '']">-->
|
|
||||||
<td class="hidden md:block">
|
<td class="hidden md:block">
|
||||||
{{ formatDate(TX.Date) }}
|
{{ formatDate(TX.Date) }}
|
||||||
</td>
|
</td>
|
||||||
|
<td v-if="withAccount" class="pl-2 md:pl-0">
|
||||||
|
{{ TX.Account }}
|
||||||
|
</td>
|
||||||
<td class="pl-2 md:pl-0">
|
<td class="pl-2 md:pl-0">
|
||||||
{{ TX.TransferAccount ? "Transfer : " + TX.TransferAccount : TX.Payee }}
|
{{ TX.TransferAccount ? "Transfer : " + TX.TransferAccount : TX.Payee }}
|
||||||
</td>
|
</td>
|
||||||
@ -93,6 +75,7 @@ function getStatusSymbol() {
|
|||||||
<TransactionEditRow
|
<TransactionEditRow
|
||||||
v-if="edit"
|
v-if="edit"
|
||||||
:transactionid="TX.ID"
|
:transactionid="TX.ID"
|
||||||
|
:with-account="withAccount"
|
||||||
@save="edit = false"
|
@save="edit = false"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
@ -6,3 +6,12 @@ export function formatDate(date: Date): string {
|
|||||||
day: "2-digit",
|
day: "2-digit",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function groupBy<T, K extends keyof any>(list: T[], getKey: (item: T) => K) {
|
||||||
|
return list.reduce((previous, currentItem) => {
|
||||||
|
const group = getKey(currentItem);
|
||||||
|
if (!previous[group]) previous[group] = [];
|
||||||
|
previous[group].push(currentItem);
|
||||||
|
return previous;
|
||||||
|
}, {} as Record<K, T[]>);
|
||||||
|
}
|
@ -10,6 +10,7 @@ import { useTransactionsStore } from "../stores/transactions";
|
|||||||
import Modal from "../components/Modal.vue";
|
import Modal from "../components/Modal.vue";
|
||||||
import Input from "../components/Input.vue";
|
import Input from "../components/Input.vue";
|
||||||
import Checkbox from "../components/Checkbox.vue";
|
import Checkbox from "../components/Checkbox.vue";
|
||||||
|
import { formatDate } from "../date";
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
budgetid: string
|
budgetid: string
|
||||||
@ -116,27 +117,13 @@ function createReconcilationTransaction() {
|
|||||||
|
|
||||||
<table>
|
<table>
|
||||||
<tr class="font-bold">
|
<tr class="font-bold">
|
||||||
<td
|
<td class="hidden md:block" style="width: 90px;">Date</td>
|
||||||
class="hidden md:block"
|
<td style="max-width: 150px;">Payee</td>
|
||||||
style="width: 90px;"
|
<td style="max-width: 200px;">Category</td>
|
||||||
>
|
|
||||||
Date
|
|
||||||
</td>
|
|
||||||
<td style="max-width: 150px;">
|
|
||||||
Payee
|
|
||||||
</td>
|
|
||||||
<td style="max-width: 200px;">
|
|
||||||
Category
|
|
||||||
</td>
|
|
||||||
<td>Memo</td>
|
<td>Memo</td>
|
||||||
<td class="text-right">
|
<td class="text-right">Amount</td>
|
||||||
Amount
|
|
||||||
</td>
|
|
||||||
<td style="width: 80px;">
|
<td style="width: 80px;">
|
||||||
<Checkbox
|
<Checkbox v-if="transactions.Reconciling" @input="setReconciled" />
|
||||||
v-if="transactions.Reconciling"
|
|
||||||
@input="setReconciled"
|
|
||||||
/>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<TransactionInputRow
|
<TransactionInputRow
|
||||||
@ -144,12 +131,22 @@ function createReconcilationTransaction() {
|
|||||||
:budgetid="budgetid"
|
:budgetid="budgetid"
|
||||||
:accountid="accountid"
|
:accountid="accountid"
|
||||||
/>
|
/>
|
||||||
<TransactionRow
|
<template v-for="(dayTransactions, key, index) in transactions.TransactionsByDate" :key="key">
|
||||||
v-for="(transaction, index) in transactions.TransactionsList"
|
<tr class="table-row md:hidden">
|
||||||
:key="transaction.ID"
|
<td class="py-2" colspan="5">
|
||||||
:transactionid="transaction.ID"
|
<span class="bg-gray-400 dark:bg-slate-600 rounded-lg p-1 px-2 w-full block">
|
||||||
:index="index"
|
{{ key }}
|
||||||
/>
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<TransactionRow
|
||||||
|
v-for="transaction in dayTransactions"
|
||||||
|
:key="transaction.ID"
|
||||||
|
:index="index"
|
||||||
|
:transactionid="transaction.ID"
|
||||||
|
:with-account="false"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
</table>
|
</table>
|
||||||
<div class="md:hidden">
|
<div class="md:hidden">
|
||||||
<Modal @submit="submitModal">
|
<Modal @submit="submitModal">
|
||||||
@ -162,7 +159,7 @@ function createReconcilationTransaction() {
|
|||||||
</template>
|
</template>
|
||||||
<TransactionInputRow
|
<TransactionInputRow
|
||||||
ref="modalInputRow"
|
ref="modalInputRow"
|
||||||
class="grid grid-cols-2"
|
class="flex flex-col w-full h-full top-0"
|
||||||
:budgetid="budgetid"
|
:budgetid="budgetid"
|
||||||
:accountid="accountid"
|
:accountid="accountid"
|
||||||
/>
|
/>
|
||||||
|
125
web/src/pages/AllAccounts.vue
Normal file
125
web/src/pages/AllAccounts.vue
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { computed, ref, onMounted, watch } from "vue"
|
||||||
|
import Currency from "../components/Currency.vue";
|
||||||
|
import TransactionRow from "../components/TransactionRow.vue";
|
||||||
|
import TransactionInputRow from "../components/TransactionInputRow.vue";
|
||||||
|
import { useAccountStore } from "../stores/budget-account";
|
||||||
|
import EditAccount from "../dialogs/EditAccount.vue";
|
||||||
|
import Button from "../components/SimpleButton.vue";
|
||||||
|
import { useTransactionsStore } from "../stores/transactions";
|
||||||
|
import Modal from "../components/Modal.vue";
|
||||||
|
import Input from "../components/Input.vue";
|
||||||
|
import Checkbox from "../components/Checkbox.vue";
|
||||||
|
import { formatDate } from "../date";
|
||||||
|
import DateInput from "../components/DateInput.vue";
|
||||||
|
import Autocomplete from '../components/Autocomplete.vue'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
budgetid: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const modalInputRow = ref<typeof TransactionInputRow | null>(null);
|
||||||
|
|
||||||
|
function submitModal() {
|
||||||
|
modalInputRow.value!.Save();
|
||||||
|
}
|
||||||
|
|
||||||
|
const accounts = useAccountStore();
|
||||||
|
const transactions = useTransactionsStore();
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
transactions.GetProblematicTransactions();
|
||||||
|
})
|
||||||
|
|
||||||
|
const filters = ref({
|
||||||
|
Account: null,
|
||||||
|
AccountID: null,
|
||||||
|
Payee: null,
|
||||||
|
PayeeID: null,
|
||||||
|
Category: null,
|
||||||
|
CategoryID: null,
|
||||||
|
FromDate: new Date(1900, 0, 2),
|
||||||
|
ToDate: new Date(2999,11,32),
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(() => filters.value.AccountID
|
||||||
|
+ filters.value.PayeeID
|
||||||
|
+ filters.value.CategoryID
|
||||||
|
+ filters.value.FromDate?.toISOString()
|
||||||
|
+ filters.value.ToDate?.toISOString(), function() {
|
||||||
|
if(!hasFilters.value)
|
||||||
|
return;
|
||||||
|
transactions.GetFilteredTransactions(
|
||||||
|
filters.value.AccountID,
|
||||||
|
filters.value.CategoryID,
|
||||||
|
filters.value.PayeeID,
|
||||||
|
filters.value.FromDate?.toISOString(),
|
||||||
|
filters.value.ToDate?.toISOString(),
|
||||||
|
);
|
||||||
|
})
|
||||||
|
|
||||||
|
const hasFilters = computed(() =>
|
||||||
|
filters.value.AccountID != null
|
||||||
|
|| filters.value.PayeeID != null
|
||||||
|
|| filters.value.CategoryID != null
|
||||||
|
|| (filters.value.FromDate != null && filters.value.FromDate.getFullYear() > 1901)
|
||||||
|
|| (filters.value.ToDate != null && filters.value.ToDate.getFullYear() < 2998))
|
||||||
|
|
||||||
|
const transactionsList = computed(() => {
|
||||||
|
if (hasFilters.value){
|
||||||
|
return transactions.FilteredTransactionsList;
|
||||||
|
}
|
||||||
|
return transactions.ProblematicTransactionsList;
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="grid grid-cols-[1fr_auto]">
|
||||||
|
<div>
|
||||||
|
<h1 class="inline">
|
||||||
|
{{ hasFilters ? "Filtered" : "Problematic" }} Transactions
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 w-96">
|
||||||
|
Account:
|
||||||
|
<Autocomplete v-model:text="filters.Account" v-model:id="filters.AccountID" model="accounts" class="inline-block" /><br>
|
||||||
|
Payee:
|
||||||
|
<Autocomplete v-model:text="filters.Payee" v-model:id="filters.PayeeID" model="payees" class="inline-block" /><br>
|
||||||
|
Category:
|
||||||
|
<Autocomplete v-model:text="filters.Category" v-model:id="filters.CategoryID" model="categories" class="inline-block" /><br>
|
||||||
|
From Date:
|
||||||
|
<DateInput v-model="filters.FromDate" class="inline-block" /><br>
|
||||||
|
To Date:
|
||||||
|
<DateInput v-model="filters.ToDate" class="inline-block" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<tr class="font-bold">
|
||||||
|
<td class="hidden md:block" style="width: 90px;">Date</td>
|
||||||
|
<td style="max-width: 200px;">Account</td>
|
||||||
|
<td style="max-width: 150px;">Payee</td>
|
||||||
|
<td style="max-width: 200px;">Category</td>
|
||||||
|
<td>Memo</td>
|
||||||
|
<td class="text-right">Amount</td>
|
||||||
|
</tr>
|
||||||
|
<TransactionRow
|
||||||
|
v-for="(transaction, index) in transactionsList"
|
||||||
|
:key="transaction.ID"
|
||||||
|
:with-account="true"
|
||||||
|
:transactionid="transaction.ID"
|
||||||
|
:index="index"
|
||||||
|
/>
|
||||||
|
</table>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
table-layout: fixed;
|
||||||
|
}
|
||||||
|
.negative {
|
||||||
|
color: red;
|
||||||
|
}
|
||||||
|
</style>
|
@ -19,68 +19,47 @@ const OnBudgetAccounts = computed(() => accountStore.OnBudgetAccounts);
|
|||||||
const OffBudgetAccounts = computed(() => accountStore.OffBudgetAccounts);
|
const OffBudgetAccounts = computed(() => accountStore.OffBudgetAccounts);
|
||||||
const OnBudgetAccountsBalance = computed(() => accountStore.OnBudgetAccountsBalance);
|
const OnBudgetAccountsBalance = computed(() => accountStore.OnBudgetAccountsBalance);
|
||||||
const OffBudgetAccountsBalance = computed(() => accountStore.OffBudgetAccountsBalance);
|
const OffBudgetAccountsBalance = computed(() => accountStore.OffBudgetAccountsBalance);
|
||||||
|
|
||||||
|
function toggleMenu() {
|
||||||
|
useSettingsStore().toggleMenu();
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div :class="[ExpandMenu ? 'md:w-72' : 'md:w-36', ShowMenu ? '' : 'hidden']"
|
||||||
:class="[ExpandMenu ? 'md:w-72' : 'md:w-36', ShowMenu ? '' : 'hidden']"
|
class="md:block flex-shrink-0 w-full bg-gray-500 border-r-4 border-black">
|
||||||
class="md:block flex-shrink-0 w-full bg-gray-500 border-r-4 border-black"
|
<div class="flex flex-col">
|
||||||
>
|
<div class="m-2 md:px-3 p-1">
|
||||||
<div class="flex flex-col mt-14 md:mt-0">
|
<span class="font-bold-my-3 md:hidden font-bold text-5xl pr-3" @click="toggleMenu">≡</span>
|
||||||
<span
|
<span class="h-10 overflow-hidden" :class="[ExpandMenu ? 'text-2xl' : 'text-md']">
|
||||||
class="m-2 p-1 px-3 h-10 overflow-hidden"
|
<router-link to="/dashboard" class="text-5xl">⌂</router-link>
|
||||||
:class="[ExpandMenu ? 'text-2xl' : 'text-md']"
|
{{ CurrentBudgetName }}
|
||||||
>
|
</span>
|
||||||
<router-link
|
</div>
|
||||||
to="/dashboard"
|
|
||||||
style="font-size: 150%"
|
|
||||||
>⌂</router-link>
|
|
||||||
{{ CurrentBudgetName }}
|
|
||||||
</span>
|
|
||||||
<span class="bg-gray-100 dark:bg-gray-700 p-2 px-3 flex flex-col">
|
<span class="bg-gray-100 dark:bg-gray-700 p-2 px-3 flex flex-col">
|
||||||
<router-link :to="'/budget/' + CurrentBudgetID + '/budgeting'">Budget</router-link>
|
<router-link :to="'/budget/' + CurrentBudgetID + '/budgeting'">Budget</router-link>
|
||||||
<br>
|
<br>
|
||||||
<!--<router-link :to="'/budget/'+CurrentBudgetID+'/reports'">Reports</router-link>-->
|
<!--<router-link :to="'/budget/'+CurrentBudgetID+'/reports'">Reports</router-link>-->
|
||||||
<!--<router-link :to="'/budget/'+CurrentBudgetID+'/all-accounts'">All Accounts</router-link>-->
|
<router-link :to="'/budget/'+CurrentBudgetID+'/problematic-transactions'">Transactions</router-link>
|
||||||
</span>
|
</span>
|
||||||
<li class="bg-slate-200 dark:bg-slate-700 my-2 p-2 px-3">
|
<li class="bg-slate-200 dark:bg-slate-700 my-2 p-2 px-3">
|
||||||
<div class="flex flex-row justify-between font-bold">
|
<div class="flex flex-row justify-between font-bold">
|
||||||
<span>On-Budget Accounts</span>
|
<span>On-Budget Accounts</span>
|
||||||
<Currency
|
<Currency :class="ExpandMenu ? 'md:inline' : 'md:hidden'" class="whitespace-nowrap" :value="OnBudgetAccountsBalance" />
|
||||||
:class="ExpandMenu ? 'md:inline' : 'md:hidden'"
|
|
||||||
:value="OnBudgetAccountsBalance"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div v-for="account in OnBudgetAccounts" :key="account.ID" class="flex flex-row justify-between">
|
||||||
v-for="account in OnBudgetAccounts"
|
<AccountWithReconciled class="whitespace-nowrap overflow-hidden mr-4" :account="account" />
|
||||||
:key="account.ID"
|
<Currency :class="ExpandMenu ? 'md:inline' : 'md:hidden'" class="whitespace-nowrap" :value="account.ClearedBalance" />
|
||||||
class="flex flex-row justify-between"
|
|
||||||
>
|
|
||||||
<AccountWithReconciled :account="account" />
|
|
||||||
<Currency
|
|
||||||
:class="ExpandMenu ? 'md:inline' : 'md:hidden'"
|
|
||||||
:value="account.ClearedBalance"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
<li class="bg-slate-200 dark:bg-slate-700 my-2 p-2 px-3">
|
<li class="bg-slate-200 dark:bg-slate-700 my-2 p-2 px-3">
|
||||||
<div class="flex flex-row justify-between font-bold">
|
<div class="flex flex-row justify-between font-bold">
|
||||||
<span>Off-Budget Accounts</span>
|
<span>Off-Budget Accounts</span>
|
||||||
<Currency
|
<Currency :class="ExpandMenu ? 'md:inline' : 'md:hidden'" class="whitespace-nowrap" :value="OffBudgetAccountsBalance" />
|
||||||
:class="ExpandMenu ? 'md:inline' : 'md:hidden'"
|
|
||||||
:value="OffBudgetAccountsBalance"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div v-for="account in OffBudgetAccounts" :key="account.ID" class="flex flex-row justify-between">
|
||||||
v-for="account in OffBudgetAccounts"
|
<AccountWithReconciled class="whitespace-nowrap overflow-hidden mr-4" :account="account" />
|
||||||
:key="account.ID"
|
<Currency :class="ExpandMenu ? 'md:inline' : 'md:hidden'" class="whitespace-nowrap" :value="account.ClearedBalance" />
|
||||||
class="flex flex-row justify-between"
|
|
||||||
>
|
|
||||||
<AccountWithReconciled :account="account" />
|
|
||||||
<Currency
|
|
||||||
:class="ExpandMenu ? 'md:inline' : 'md:hidden'"
|
|
||||||
:value="account.ClearedBalance"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
<!--
|
<!--
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { computed, defineProps, onMounted, ref, watchEffect } from "vue";
|
import { computed, onMounted, ref, watchEffect } from "vue";
|
||||||
import Currency from "../components/Currency.vue";
|
import Currency from "../components/Currency.vue";
|
||||||
import { useBudgetsStore } from "../stores/budget";
|
import { useBudgetsStore } from "../stores/budget";
|
||||||
import { Category, useAccountStore } from "../stores/budget-account";
|
import { Category, useAccountStore } from "../stores/budget-account";
|
||||||
@ -72,14 +72,60 @@ function assignedChanged(e : Event, category : Category){
|
|||||||
POST("/budget/"+CurrentBudgetID.value+"/category/" + category.ID + "/" + selected.value.Year + "/" + (selected.value.Month+1),
|
POST("/budget/"+CurrentBudgetID.value+"/category/" + category.ID + "/" + selected.value.Year + "/" + (selected.value.Month+1),
|
||||||
JSON.stringify({Assigned: category.Assigned}));
|
JSON.stringify({Assigned: category.Assigned}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const budgeted = computed(() => accountStore.GetBudgeted(selected.value.Year, selected.value.Month))
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<h1>Budget for {{ selected.Month + 1 }}/{{ selected.Year }}</h1>
|
<h1>Budget for {{ selected.Month + 1 }}/{{ selected.Year }}</h1>
|
||||||
<span>Available balance:
|
<table class="inline-block">
|
||||||
<Currency
|
<tr>
|
||||||
:value="accountStore.GetIncomeAvailable(selected.Year, selected.Month)"
|
<td>
|
||||||
/></span>
|
Available last month:
|
||||||
|
</td>
|
||||||
|
<td class="text-right">
|
||||||
|
<Currency :value="accountStore.Available-accountStore.OverspentLastMonth+budgeted.Assigned+budgeted.Deassigned" />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
Overspent last month:
|
||||||
|
</td>
|
||||||
|
<td class="text-right">
|
||||||
|
<Currency :value="accountStore.OverspentLastMonth" />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
Budgeted this month:
|
||||||
|
</td>
|
||||||
|
<td class="text-right">
|
||||||
|
<Currency :value="budgeted.Assigned+budgeted.Deassigned" />
|
||||||
|
</td>
|
||||||
|
<td class="text-sm pl-2">
|
||||||
|
= <Currency :value="budgeted.Assigned" /> - <Currency :value="-budgeted.Deassigned" />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="font-bold">
|
||||||
|
<td class="py-2">
|
||||||
|
Available balance:
|
||||||
|
</td>
|
||||||
|
<td class="text-right">
|
||||||
|
<Currency :value="accountStore.Available" />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
Activity:
|
||||||
|
</td>
|
||||||
|
<td class="text-right">
|
||||||
|
<Currency :value="budgeted.Income + budgeted.Spent" />
|
||||||
|
</td>
|
||||||
|
<td class="text-sm pl-2">
|
||||||
|
= <Currency :value="budgeted.Income" /> - <Currency :value="-1 * budgeted.Spent" />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
<div>
|
<div>
|
||||||
<router-link
|
<router-link
|
||||||
:to="'/budget/' + CurrentBudgetID + '/budgeting/' + previous.Year + '/' + previous.Month"
|
:to="'/budget/' + CurrentBudgetID + '/budgeting/' + previous.Year + '/' + previous.Month"
|
||||||
|
@ -3,10 +3,6 @@ import NewBudget from '../dialogs/NewBudget.vue';
|
|||||||
import RowCard from '../components/RowCard.vue';
|
import RowCard from '../components/RowCard.vue';
|
||||||
import { useSessionStore } from '../stores/session';
|
import { useSessionStore } from '../stores/session';
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
budgetid: string,
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const BudgetsList = useSessionStore().BudgetsList;
|
const BudgetsList = useSessionStore().BudgetsList;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@ -24,7 +20,7 @@ const BudgetsList = useSessionStore().BudgetsList;
|
|||||||
<!--<svg class="w-24"></svg>-->
|
<!--<svg class="w-24"></svg>-->
|
||||||
<p class="w-24 text-center text-6xl" />
|
<p class="w-24 text-center text-6xl" />
|
||||||
<span class="text-lg">{{ budget.Name
|
<span class="text-lg">{{ budget.Name
|
||||||
}}{{ budget.ID == budgetid ? " *" : "" }}</span>
|
}}</span>
|
||||||
</router-link>
|
</router-link>
|
||||||
</RowCard>
|
</RowCard>
|
||||||
<NewBudget />
|
<NewBudget />
|
||||||
|
@ -9,6 +9,8 @@ import Button from "../components/SimpleButton.vue";
|
|||||||
import { saveAs } from 'file-saver';
|
import { saveAs } from 'file-saver';
|
||||||
import Input from "../components/Input.vue";
|
import Input from "../components/Input.vue";
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
const transactionsFile = ref<File | undefined>(undefined);
|
const transactionsFile = ref<File | undefined>(undefined);
|
||||||
const assignmentsFile = ref<File | undefined>(undefined);
|
const assignmentsFile = ref<File | undefined>(undefined);
|
||||||
|
|
||||||
@ -39,14 +41,11 @@ function deleteBudget() {
|
|||||||
|
|
||||||
const budgetStore = useSessionStore();
|
const budgetStore = useSessionStore();
|
||||||
budgetStore.Budgets.delete(CurrentBudgetID.value);
|
budgetStore.Budgets.delete(CurrentBudgetID.value);
|
||||||
useRouter().push("/")
|
router.push("/dashboard")
|
||||||
};
|
};
|
||||||
function clearBudget() {
|
function clearBudget() {
|
||||||
POST("/budget/" + CurrentBudgetID.value + "/settings/clear", null)
|
POST("/budget/" + CurrentBudgetID.value + "/settings/clear", null)
|
||||||
};
|
};
|
||||||
function cleanNegative() {
|
|
||||||
// <a href="/budget/{{.Budget.ID}}/settings/clean-negative">Fix all historic negative category-balances</a>
|
|
||||||
};
|
|
||||||
function ynabImport() {
|
function ynabImport() {
|
||||||
if (transactionsFile.value == undefined || assignmentsFile.value == undefined)
|
if (transactionsFile.value == undefined || assignmentsFile.value == undefined)
|
||||||
return
|
return
|
||||||
@ -109,21 +108,6 @@ function ynabExport() {
|
|||||||
Delete budget
|
Delete budget
|
||||||
</Button>
|
</Button>
|
||||||
</RowCard>
|
</RowCard>
|
||||||
<RowCard class="flex-col p-3">
|
|
||||||
<h2 class="text-lg font-bold">
|
|
||||||
Fix all historic negative category-balances
|
|
||||||
</h2>
|
|
||||||
<p>
|
|
||||||
This restores YNABs functionality, that would substract any
|
|
||||||
overspent categories' balances from next months inflows.
|
|
||||||
</p>
|
|
||||||
<Button
|
|
||||||
class="bg-orange-500 py-2"
|
|
||||||
@click="cleanNegative"
|
|
||||||
>
|
|
||||||
Fix negative
|
|
||||||
</Button>
|
|
||||||
</RowCard>
|
|
||||||
<RowCard class="flex-col p-3">
|
<RowCard class="flex-col p-3">
|
||||||
<h2 class="text-lg font-bold">
|
<h2 class="text-lg font-bold">
|
||||||
Import YNAB Budget
|
Import YNAB Budget
|
||||||
|
@ -3,7 +3,8 @@ import {
|
|||||||
createWebHistory,
|
createWebHistory,
|
||||||
RouteLocationNormalized,
|
RouteLocationNormalized,
|
||||||
} from "vue-router";
|
} from "vue-router";
|
||||||
import Account from "@/pages/Account.vue";
|
import Account from "../pages/Account.vue";
|
||||||
|
import AllAccounts from "../pages/AllAccounts.vue";
|
||||||
import Budgeting from "../pages/Budgeting.vue";
|
import Budgeting from "../pages/Budgeting.vue";
|
||||||
import BudgetSidebar from "../pages/BudgetSidebar.vue";
|
import BudgetSidebar from "../pages/BudgetSidebar.vue";
|
||||||
import Dashboard from "../pages/Dashboard.vue";
|
import Dashboard from "../pages/Dashboard.vue";
|
||||||
@ -13,7 +14,12 @@ import Settings from "../pages/Settings.vue";
|
|||||||
import WelcomePage from "../pages/WelcomePage.vue";
|
import WelcomePage from "../pages/WelcomePage.vue";
|
||||||
|
|
||||||
const routes = [
|
const routes = [
|
||||||
{ path: "/", name: "Index", component: WelcomePage },
|
{
|
||||||
|
path: "/",
|
||||||
|
name: "Index",
|
||||||
|
component: WelcomePage,
|
||||||
|
meta: { hideForAuth: true },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "/dashboard",
|
path: "/dashboard",
|
||||||
name: "Dashboard",
|
name: "Dashboard",
|
||||||
@ -58,6 +64,13 @@ const routes = [
|
|||||||
props: true,
|
props: true,
|
||||||
meta: { requiresAuth: true },
|
meta: { requiresAuth: true },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "/budget/:budgetid/problematic-transactions",
|
||||||
|
name: "Problematic Transactions",
|
||||||
|
components: { default: AllAccounts, sidebar: BudgetSidebar },
|
||||||
|
props: true,
|
||||||
|
meta: { requiresAuth: true },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "/budget/:budgetid/account/:accountid",
|
path: "/budget/:budgetid/account/:accountid",
|
||||||
name: "Account",
|
name: "Account",
|
||||||
|
@ -9,6 +9,8 @@ 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>>>;
|
||||||
|
Available: number,
|
||||||
|
OverspentLastMonth: number,
|
||||||
Assignments: [];
|
Assignments: [];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -38,11 +40,20 @@ export interface Category {
|
|||||||
Activity: number;
|
Activity: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface BudgetedAmounts {
|
||||||
|
Assigned: number,
|
||||||
|
Deassigned: number,
|
||||||
|
Spent: number,
|
||||||
|
Income: number,
|
||||||
|
}
|
||||||
|
|
||||||
export const useAccountStore = defineStore("budget/account", {
|
export const useAccountStore = defineStore("budget/account", {
|
||||||
state: (): State => ({
|
state: (): State => ({
|
||||||
Accounts: new Map<string, Account>(),
|
Accounts: new Map<string, Account>(),
|
||||||
CurrentAccountID: null,
|
CurrentAccountID: null,
|
||||||
Months: new Map<number, Map<number, Map<string, Category>>>(),
|
Months: new Map<number, Map<number, Map<string, Category>>>(),
|
||||||
|
Available: 0,
|
||||||
|
OverspentLastMonth: 0,
|
||||||
Categories: new Map<string, Category>(),
|
Categories: new Map<string, Category>(),
|
||||||
Assignments: [],
|
Assignments: [],
|
||||||
}),
|
}),
|
||||||
@ -59,7 +70,7 @@ export const useAccountStore = defineStore("budget/account", {
|
|||||||
return (category: Category): number => {
|
return (category: Category): number => {
|
||||||
return (
|
return (
|
||||||
category.AvailableLastMonth +
|
category.AvailableLastMonth +
|
||||||
Number(category.Assigned) +
|
category.Assigned +
|
||||||
category.Activity
|
category.Activity
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@ -68,17 +79,34 @@ export const useAccountStore = defineStore("budget/account", {
|
|||||||
const budget = useBudgetsStore();
|
const budget = useBudgetsStore();
|
||||||
return budget.CurrentBudget?.IncomeCategoryID;
|
return budget.CurrentBudget?.IncomeCategoryID;
|
||||||
},
|
},
|
||||||
GetIncomeAvailable(state) {
|
GetBudgeted(state) {
|
||||||
return (year: number, month: number) => {
|
return (year: number, month: number) : BudgetedAmounts => {
|
||||||
const IncomeCategoryID = this.GetIncomeCategoryID;
|
const IncomeCategoryID = this.GetIncomeCategoryID;
|
||||||
if (IncomeCategoryID == null) return 0;
|
if (IncomeCategoryID == null) return {Spent: 0, Income: 0, Assigned: 0, Deassigned: 0};
|
||||||
|
|
||||||
const categories = this.AllCategoriesForMonth(year, month);
|
const categories = this.AllCategoriesForMonth(year, month);
|
||||||
const category = categories.filter(
|
|
||||||
(x) => x.ID == IncomeCategoryID
|
let assigned = 0, deassigned = 0;
|
||||||
)[0];
|
let spent = 0, income = 0;
|
||||||
if (category == null) return 0;
|
for (const category of categories) {
|
||||||
return category.AvailableLastMonth;
|
if (category.ID == IncomeCategoryID)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
if(category.Activity > 0)
|
||||||
|
income += category.Activity;
|
||||||
|
else
|
||||||
|
spent += category.Activity;
|
||||||
|
if(category.Assigned > 0)
|
||||||
|
assigned += category.Assigned;
|
||||||
|
else
|
||||||
|
deassigned += category.Assigned;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
Assigned: assigned,
|
||||||
|
Deassigned: deassigned,
|
||||||
|
Spent: spent,
|
||||||
|
Income: income
|
||||||
|
};
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
CategoryGroupsForMonth(state) {
|
CategoryGroupsForMonth(state) {
|
||||||
@ -87,7 +115,7 @@ export const useAccountStore = defineStore("budget/account", {
|
|||||||
const categoryGroups = [];
|
const categoryGroups = [];
|
||||||
let prev = undefined;
|
let prev = undefined;
|
||||||
for (const category of categories) {
|
for (const category of categories) {
|
||||||
if (category.ID == this.GetIncomeCategoryID) continue;
|
//if (category.ID == this.GetIncomeCategoryID) continue;
|
||||||
|
|
||||||
if (prev == undefined || category.Group != prev.Name) {
|
if (prev == undefined || category.Group != prev.Name) {
|
||||||
prev = {
|
prev = {
|
||||||
@ -169,10 +197,9 @@ export const useAccountStore = defineStore("budget/account", {
|
|||||||
);
|
);
|
||||||
const response = await result.json();
|
const response = await result.json();
|
||||||
const transactionsStore = useTransactionsStore();
|
const transactionsStore = useTransactionsStore();
|
||||||
const transactions = transactionsStore.AddTransactions(
|
transactionsStore.AddTransactions(
|
||||||
response.Transactions
|
response.Transactions
|
||||||
);
|
);
|
||||||
account.Transactions = transactions;
|
|
||||||
},
|
},
|
||||||
async FetchMonthBudget(budgetid: string, year: number, month: number) {
|
async FetchMonthBudget(budgetid: string, year: number, month: number) {
|
||||||
const result = await GET(
|
const result = await GET(
|
||||||
@ -184,7 +211,7 @@ export const useAccountStore = defineStore("budget/account", {
|
|||||||
response.Categories.length <= 0
|
response.Categories.length <= 0
|
||||||
)
|
)
|
||||||
return;
|
return;
|
||||||
this.addCategoriesForMonth(year, month, response.Categories);
|
this.addCategoriesForMonth(year, month, response.Categories, response.AvailableBalance, response.OverspentLastMonth);
|
||||||
},
|
},
|
||||||
async EditAccount(
|
async EditAccount(
|
||||||
accountid: string,
|
accountid: string,
|
||||||
@ -210,7 +237,9 @@ export const useAccountStore = defineStore("budget/account", {
|
|||||||
addCategoriesForMonth(
|
addCategoriesForMonth(
|
||||||
year: number,
|
year: number,
|
||||||
month: number,
|
month: number,
|
||||||
categories: Category[]
|
categories: Category[],
|
||||||
|
available: number,
|
||||||
|
overspentLastMonth: number,
|
||||||
): void {
|
): void {
|
||||||
this.$patch((state) => {
|
this.$patch((state) => {
|
||||||
const yearMap =
|
const yearMap =
|
||||||
@ -224,6 +253,9 @@ export const useAccountStore = defineStore("budget/account", {
|
|||||||
|
|
||||||
yearMap.set(month, monthMap);
|
yearMap.set(month, monthMap);
|
||||||
state.Months.set(year, yearMap);
|
state.Months.set(year, yearMap);
|
||||||
|
|
||||||
|
state.Available = available;
|
||||||
|
state.OverspentLastMonth = overspentLastMonth;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
logout() {
|
logout() {
|
||||||
|
@ -1,10 +1,14 @@
|
|||||||
import { defineStore } from "pinia";
|
import { defineStore } from "pinia";
|
||||||
import { POST } from "../api";
|
import { GET, POST } from "../api";
|
||||||
|
import { formatDate, groupBy } from "../date";
|
||||||
|
import { useBudgetsStore } from "./budget";
|
||||||
import { useAccountStore } from "./budget-account";
|
import { useAccountStore } from "./budget-account";
|
||||||
|
|
||||||
interface State {
|
interface State {
|
||||||
Transactions: Map<string, Transaction>;
|
Transactions: Map<string, Transaction>;
|
||||||
Reconciling: boolean;
|
Reconciling: boolean;
|
||||||
|
ProblematicTransactions: Array<string>;
|
||||||
|
FilteredTransactions: Array<string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Transaction {
|
export interface Transaction {
|
||||||
@ -21,12 +25,16 @@ export interface Transaction {
|
|||||||
PayeeID: string | undefined;
|
PayeeID: string | undefined;
|
||||||
Amount: number;
|
Amount: number;
|
||||||
Reconciled: boolean;
|
Reconciled: boolean;
|
||||||
|
Account: string;
|
||||||
|
AccountID: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useTransactionsStore = defineStore("budget/transactions", {
|
export const useTransactionsStore = defineStore("budget/transactions", {
|
||||||
state: (): State => ({
|
state: (): State => ({
|
||||||
Transactions: new Map<string, Transaction>(),
|
Transactions: new Map<string, Transaction>(),
|
||||||
Reconciling: false,
|
Reconciling: false,
|
||||||
|
ProblematicTransactions: new Array<string>(),
|
||||||
|
FilteredTransactions: new Array<string>(),
|
||||||
}),
|
}),
|
||||||
getters: {
|
getters: {
|
||||||
ReconcilingBalance(state): number {
|
ReconcilingBalance(state): number {
|
||||||
@ -39,24 +47,33 @@ export const useTransactionsStore = defineStore("budget/transactions", {
|
|||||||
}
|
}
|
||||||
return reconciledBalance;
|
return reconciledBalance;
|
||||||
},
|
},
|
||||||
TransactionsList(state): Transaction[] {
|
TransactionsByDate(state) : Record<string, Transaction[]> {
|
||||||
const accountsStore = useAccountStore();
|
const accountsStore = useAccountStore();
|
||||||
return accountsStore.CurrentAccount!.Transactions.map((x) => {
|
const accountID = accountsStore.CurrentAccountID;
|
||||||
return this.Transactions.get(x)!;
|
const allTransactions = [...this.Transactions.values()].filter(x => x.AccountID == accountID);
|
||||||
});
|
return groupBy(allTransactions, x => formatDate(x.Date));
|
||||||
},
|
},
|
||||||
|
TransactionsList(state) : Transaction[] {
|
||||||
|
const accountsStore = useAccountStore();
|
||||||
|
const accountID = accountsStore.CurrentAccountID;
|
||||||
|
const allTransactions = [...this.Transactions.values()];
|
||||||
|
return allTransactions.filter(x => x.AccountID == accountID);
|
||||||
|
},
|
||||||
|
ProblematicTransactionsList(state) : Transaction[] {
|
||||||
|
return [...state.ProblematicTransactions.map(x => state.Transactions!.get(x)!)];
|
||||||
|
},
|
||||||
|
FilteredTransactionsList(state) : Transaction[] {
|
||||||
|
return [...state.FilteredTransactions.map(x => state.Transactions!.get(x)!)];
|
||||||
|
}
|
||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
AddTransactions(transactions: Array<Transaction>) {
|
AddTransactions(transactions: Array<Transaction>) {
|
||||||
const transactionIds = [] as Array<string>;
|
|
||||||
this.$patch(() => {
|
this.$patch(() => {
|
||||||
for (const transaction of transactions) {
|
for (const transaction of transactions) {
|
||||||
transaction.Date = new Date(transaction.Date);
|
transaction.Date = new Date(transaction.Date);
|
||||||
this.Transactions.set(transaction.ID, transaction);
|
this.Transactions.set(transaction.ID, transaction);
|
||||||
transactionIds.push(transaction.ID);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return transactionIds;
|
|
||||||
},
|
},
|
||||||
SetReconciledForAllTransactions(value: boolean) {
|
SetReconciledForAllTransactions(value: boolean) {
|
||||||
for (const transaction of this.TransactionsList) {
|
for (const transaction of this.TransactionsList) {
|
||||||
@ -65,6 +82,29 @@ export const useTransactionsStore = defineStore("budget/transactions", {
|
|||||||
transaction.Reconciled = value;
|
transaction.Reconciled = value;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
async GetProblematicTransactions() {
|
||||||
|
const budgetStore = useBudgetsStore();
|
||||||
|
const result = await GET("/budget/" + budgetStore.CurrentBudgetID + "/problematic-transactions");
|
||||||
|
const response = await result.json();
|
||||||
|
const transactions = response.Transactions ?? [];
|
||||||
|
this.AddTransactions(transactions);
|
||||||
|
this.ProblematicTransactions = [...transactions?.map((x : Transaction) => x.ID)];
|
||||||
|
},
|
||||||
|
async GetFilteredTransactions(accountID : string | null, categoryID : string | null, payeeID : string | null, fromDate : string, toDate : string) {
|
||||||
|
const budgetStore = useBudgetsStore();
|
||||||
|
const payload = JSON.stringify({
|
||||||
|
category_id: categoryID,
|
||||||
|
payee_id: payeeID,
|
||||||
|
account_id: accountID,
|
||||||
|
from_date: fromDate,
|
||||||
|
to_date: toDate,
|
||||||
|
});
|
||||||
|
const result = await POST("/budget/" + budgetStore.CurrentBudgetID + "/filtered-transactions", payload);
|
||||||
|
const response = await result.json();
|
||||||
|
const transactions = response.Transactions ?? [];
|
||||||
|
this.AddTransactions(transactions);
|
||||||
|
this.FilteredTransactions = [...transactions.map((x : Transaction) => x.ID)];
|
||||||
|
},
|
||||||
async SubmitReconcilation(reconciliationTransactionAmount: number) {
|
async SubmitReconcilation(reconciliationTransactionAmount: number) {
|
||||||
const accountsStore = useAccountStore();
|
const accountsStore = useAccountStore();
|
||||||
const account = accountsStore.CurrentAccount!;
|
const account = accountsStore.CurrentAccount!;
|
||||||
|
@ -19,7 +19,7 @@ export default defineConfig({
|
|||||||
host: "0.0.0.0",
|
host: "0.0.0.0",
|
||||||
proxy: {
|
proxy: {
|
||||||
"/api": {
|
"/api": {
|
||||||
target: "http://10.0.0.162:1323/",
|
target: "http://backend:1323/",
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
Reference in New Issue
Block a user