Fix amount for available balance #50

Merged
jacob1123 merged 28 commits from available-balance into master 2022-04-08 22:43:22 +02:00
33 changed files with 388 additions and 170 deletions

View File

@ -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,29 +44,37 @@ 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
- task: cover
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: cover:
desc: Run test and analyze coverage desc: Run test and analyze coverage
@ -75,26 +84,46 @@ tasks:
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
@ -103,17 +132,11 @@ tasks:
- ./web/package.json - ./web/package.json
- ./web/yarn.lock - ./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

View File

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

View File

@ -6,10 +6,10 @@ 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 RUN apk --no-cache add go nodejs yarn bash curl git git-perl
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
VOLUME /go
VOLUME /.cache
COPY --from=godeps /root/go/bin/task /root/go/bin/sqlc /root/go/bin/golangci-lint /usr/local/bin/ COPY --from=godeps /root/go/bin/task /root/go/bin/sqlc /root/go/bin/golangci-lint /usr/local/bin/

View File

@ -6,11 +6,10 @@ services:
command: task -w run command: task -w run
ports: ports:
- 1323:1323 - 1323:1323
user: '1000'
volumes: volumes:
- ~/budgeteer:/src - ~/budgeteer:/src
- ~/.go:/go - go-cache:/go
- ~/.cache:/.cache - yarn-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
@ -19,13 +18,11 @@ services:
frontend: frontend:
image: hub.javil.eu/budgeteer:dev image: hub.javil.eu/budgeteer:dev
command: bash -c "cd web; yarn run dev" command: task frontend-dev
ports: ports:
- 3000:3000 - 3000:3000
user: '1000'
volumes: volumes:
- ~/budgeteer:/src - ~/budgeteer:/src
- ~/.cache:/.cache
depends_on: depends_on:
- backend - backend
@ -49,3 +46,5 @@ services:
volumes: volumes:
db: db:
go-cache:
yarn-cache:

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 (ass.budget_id IS NULL OR ass.budget_id = $1) AND (tra.budget_id IS NULL OR 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
} }

View File

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

View File

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

View File

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

View File

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

View File

@ -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 (ass.budget_id IS NULL OR ass.budget_id = @budget_id) AND (tra.budget_id IS NULL OR 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);

View File

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

View File

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

View File

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

View File

@ -4,7 +4,6 @@ import (
"context" "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"
@ -12,30 +11,19 @@ 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
Activity numeric.Numeric Activity numeric.Numeric
Assigned numeric.Numeric Assigned numeric.Numeric
} }
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(),
Activity: numeric.Zero(), Activity: numeric.Zero(),
Assigned: numeric.Zero(), Assigned: numeric.Zero(),
} }
} }
@ -53,13 +41,13 @@ 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/"+budget.ID.String()) c.Redirect(http.StatusTemporaryRedirect, "/budget/"+budget.ID.String())
return return
} }
data, err := h.prepareBudgeting(c.Request.Context(), budget, firstOfMonth) 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
@ -67,8 +55,7 @@ func (h *Handler) budgetingForMonth(c *gin.Context) {
c.JSON(http.StatusOK, data) c.JSON(http.StatusOK, data)
} }
func (h *Handler) prepareBudgeting(ctx context.Context, budget postgres.Budget, firstOfMonth time.Time) (BudgetingForMonthResponse, error) { func (h *Handler) getBudgetingViewForMonth(ctx context.Context, budget postgres.Budget, month Month) (BudgetingForMonthResponse, error) {
firstOfNextMonth := firstOfMonth.AddDate(0, 1, 0)
categories, err := h.Service.GetCategories(ctx, budget.ID) categories, err := h.Service.GetCategories(ctx, budget.ID)
if err != nil { if err != nil {
return BudgetingForMonthResponse{}, fmt.Errorf("error loading categories: %w", err) return BudgetingForMonthResponse{}, fmt.Errorf("error loading categories: %w", err)
@ -79,8 +66,8 @@ func (h *Handler) prepareBudgeting(ctx context.Context, budget postgres.Budget,
return BudgetingForMonthResponse{}, fmt.Errorf("error loading balances: %w", err) return BudgetingForMonthResponse{}, fmt.Errorf("error loading balances: %w", err)
} }
categoriesWithBalance, moneyUsed := h.calculateBalances(firstOfNextMonth, firstOfMonth, categories, cumultativeBalances) categoriesWithBalance, moneyUsed := h.calculateBalances(budget, month, categories, cumultativeBalances)
availableBalance := h.getAvailableBalance(budget, moneyUsed, cumultativeBalances, categoriesWithBalance, firstOfNextMonth) availableBalance := h.getAvailableBalance(budget, month, moneyUsed, cumultativeBalances)
data := BudgetingForMonthResponse{categoriesWithBalance, availableBalance} data := BudgetingForMonthResponse{categoriesWithBalance, availableBalance}
return data, nil return data, nil
@ -91,9 +78,8 @@ type BudgetingForMonthResponse struct {
AvailableBalance numeric.Numeric AvailableBalance numeric.Numeric
} }
func (*Handler) getAvailableBalance(budget postgres.Budget, func (*Handler) getAvailableBalance(budget postgres.Budget, month Month,
moneyUsed numeric.Numeric, cumultativeBalances []postgres.GetCumultativeBalancesRow, moneyUsed numeric.Numeric, cumultativeBalances []postgres.GetCumultativeBalancesRow,
categoriesWithBalance []CategoryWithBalance, firstOfNextMonth time.Time,
) numeric.Numeric { ) numeric.Numeric {
availableBalance := moneyUsed availableBalance := moneyUsed
@ -102,22 +88,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
} }
for i := range categoriesWithBalance {
cat := &categoriesWithBalance[i]
if cat.ID != budget.IncomeCategoryID {
continue
}
cat.Available = availableBalance
}
return availableBalance return availableBalance
} }
@ -155,7 +133,7 @@ func (h *Handler) getBudget(c *gin.Context, budgetUUID uuid.UUID) {
c.JSON(http.StatusOK, data) c.JSON(http.StatusOK, data)
} }
func (h *Handler) calculateBalances(firstOfNextMonth time.Time, firstOfMonth time.Time, func (h *Handler) calculateBalances(budget postgres.Budget, month Month,
categories []postgres.GetCategoriesRow, cumultativeBalances []postgres.GetCumultativeBalancesRow, categories []postgres.GetCategoriesRow, cumultativeBalances []postgres.GetCumultativeBalancesRow,
) ([]CategoryWithBalance, numeric.Numeric) { ) ([]CategoryWithBalance, numeric.Numeric) {
categoriesWithBalance := []CategoryWithBalance{} categoriesWithBalance := []CategoryWithBalance{}
@ -163,6 +141,10 @@ func (h *Handler) calculateBalances(firstOfNextMonth time.Time, firstOfMonth tim
moneyUsed := numeric.Zero() moneyUsed := numeric.Zero()
for i := range categories { for i := range categories {
cat := &categories[i] cat := &categories[i]
if cat.ID == budget.IncomeCategoryID {
continue
}
categoryWithBalance := NewCategoryWithBalance(cat) categoryWithBalance := NewCategoryWithBalance(cat)
for _, bal := range cumultativeBalances { for _, bal := range cumultativeBalances {
if bal.CategoryID != cat.ID { if bal.CategoryID != cat.ID {
@ -170,21 +152,22 @@ func (h *Handler) calculateBalances(firstOfNextMonth time.Time, firstOfMonth tim
} }
// skip everything in the future // skip everything in the future
if !bal.Date.Before(firstOfNextMonth) { if month.InFuture(bal.Date) {
continue continue
} }
moneyUsed.SubI(bal.Assignments) moneyUsed.SubI(bal.Assignments)
categoryWithBalance.Available.AddI(bal.Assignments) if month.InPresent(bal.Date) {
categoryWithBalance.Available.AddI(bal.Transactions)
if !categoryWithBalance.Available.IsPositive() && bal.Date.Before(firstOfMonth) {
moneyUsed.AddI(categoryWithBalance.Available)
categoryWithBalance.Available = numeric.Zero()
}
if bal.Date.Year() == firstOfMonth.Year() && bal.Date.Month() == firstOfMonth.Month() {
categoryWithBalance.Activity = bal.Transactions categoryWithBalance.Activity = bal.Transactions
categoryWithBalance.Assigned = bal.Assignments categoryWithBalance.Assigned = bal.Assignments
continue
}
categoryWithBalance.AvailableLastMonth.AddI(bal.Assignments)
categoryWithBalance.AvailableLastMonth.AddI(bal.Transactions)
if !categoryWithBalance.AvailableLastMonth.IsPositive() {
moneyUsed.AddI(categoryWithBalance.AvailableLastMonth)
categoryWithBalance.AvailableLastMonth = numeric.Zero()
} }
} }

View File

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

View File

@ -181,9 +181,8 @@ func AssertCategoriesAndAvailableEqual(ctx context.Context, t *testing.T, loc *t
} }
year, _ := strconv.Atoi(parts[0]) year, _ := strconv.Atoi(parts[0])
month, _ := strconv.Atoi(parts[1]) month, _ := strconv.Atoi(parts[1])
first := time.Date(year, time.Month(month), 1, 0, 0, 0, 0, loc)
testCaseFile := filepath.Join(resultDir, file.Name()) testCaseFile := filepath.Join(resultDir, file.Name())
handler.CheckAvailableBalance(ctx, t, testCaseFile, budget, first) handler.CheckAvailableBalance(ctx, t, testCaseFile, budget, Month{year, month})
} }
}) })
} }
@ -199,12 +198,12 @@ type CategoryTestData struct {
Assigned float64 Assigned float64
} }
func (h Handler) CheckAvailableBalance(ctx context.Context, t *testing.T, testCaseFile string, budget *postgres.Budget, first time.Time) { func (h Handler) CheckAvailableBalance(ctx context.Context, t *testing.T, testCaseFile string, budget *postgres.Budget, month Month) {
t.Helper() t.Helper()
t.Run(first.Format("2006-01"), func(t *testing.T) { t.Run(month.String(), func(t *testing.T) {
t.Parallel() t.Parallel()
data, err := h.prepareBudgeting(ctx, *budget, first) data, err := h.getBudgetingViewForMonth(ctx, *budget, month)
if err != nil { if err != nil {
t.Errorf("prepare budgeting: %s", err) t.Errorf("prepare budgeting: %s", err)
return return
@ -232,9 +231,11 @@ func (h Handler) CheckAvailableBalance(ctx context.Context, t *testing.T, testCa
name := category.Group + " : " + category.Name name := category.Group + " : " + category.Name
if name == categoryName { if name == categoryName {
assertEqual(t, categoryTestData.Available, category.Available.GetFloat64(), "available for "+categoryName)
assertEqual(t, categoryTestData.Activity, category.Activity.GetFloat64(), "activity for "+categoryName) assertEqual(t, categoryTestData.Activity, category.Activity.GetFloat64(), "activity for "+categoryName)
assertEqual(t, categoryTestData.Assigned, category.Assigned.GetFloat64(), "assigned 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 found = true
} }
} }
@ -246,6 +247,15 @@ func (h Handler) CheckAvailableBalance(ctx context.Context, t *testing.T, testCa
}) })
} }
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) { func assertEqual(t *testing.T, expected, actual float64, message string) {
t.Helper() t.Helper()
if expected == actual { if expected == actual {

55
server/month.go Normal file
View File

@ -0,0 +1,55 @@
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) 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
View 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")
})
}
}

View File

@ -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 +1 @@
Subproject commit 6ca3adcee2713e8205133bec6c24b45aa8d730d9 Subproject commit 8de369b17a81f2e6ed079374ab35f868f259f9c1

View File

@ -7,6 +7,7 @@ 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/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
View File

View 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",

View File

@ -1,17 +1,27 @@
<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:
console.log("STR-INPUT", props.type)
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>

View File

@ -6,7 +6,6 @@ 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<{

View File

@ -72,14 +72,35 @@ 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 last month:
<Currency
:value="accountStore.GetIncomeAvailable(previous.Year, previous.Month)"
/>
</span><br>
<span>Available balance: <span>Available balance:
<Currency <Currency
:value="accountStore.GetIncomeAvailable(selected.Year, selected.Month)" :value="accountStore.GetIncomeAvailable(selected.Year, selected.Month)"
/></span> />
</span><br>
<span>Budgeted this month:
<Currency :value="budgeted.Assigned" /> - <Currency :value="-budgeted.Deassigned" /> = <Currency :value="budgeted.Assigned+budgeted.Deassigned" />
</span><br>
<span>Income:
<Currency
:value="budgeted.Income"
/> <Currency
:value="budgeted.Spent"
/> = <Currency
:value="budgeted.Income + budgeted.Spent"
/>
</span><br>
<div> <div>
<router-link <router-link
:to="'/budget/' + CurrentBudgetID + '/budgeting/' + previous.Year + '/' + previous.Month" :to="'/budget/' + CurrentBudgetID + '/budgeting/' + previous.Year + '/' + previous.Month"

View File

@ -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,7 +41,7 @@ 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)

View File

@ -9,6 +9,7 @@ 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: Map<number, Map<number, number>>;
Assignments: []; Assignments: [];
} }
@ -38,11 +39,19 @@ 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: new Map<number, Map<number, number>>(),
Categories: new Map<string, Category>(), Categories: new Map<string, Category>(),
Assignments: [], Assignments: [],
}), }),
@ -59,7 +68,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 +77,40 @@ 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
};
};
},
GetIncomeAvailable(state) {
return (year: number, month: number) => {
const yearMapAv = this.Available.get(year);
return yearMapAv?.get(month);
}; };
}, },
CategoryGroupsForMonth(state) { CategoryGroupsForMonth(state) {
@ -87,7 +119,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 = {
@ -184,7 +216,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);
}, },
async EditAccount( async EditAccount(
accountid: string, accountid: string,
@ -210,7 +242,8 @@ export const useAccountStore = defineStore("budget/account", {
addCategoriesForMonth( addCategoriesForMonth(
year: number, year: number,
month: number, month: number,
categories: Category[] categories: Category[],
available: number
): void { ): void {
this.$patch((state) => { this.$patch((state) => {
const yearMap = const yearMap =
@ -224,6 +257,12 @@ export const useAccountStore = defineStore("budget/account", {
yearMap.set(month, monthMap); yearMap.set(month, monthMap);
state.Months.set(year, yearMap); state.Months.set(year, yearMap);
const yearMapAv =
state.Available.get(year) ||
new Map<number, number>();
yearMapAv.set(month, available);
state.Available.set(year, yearMapAv);
}); });
}, },
logout() { logout() {