Compare commits
16 Commits
create-cat
...
4ee82dce26
Author | SHA1 | Date | |
---|---|---|---|
4ee82dce26 | |||
1da86e30c3 | |||
51902cd65d | |||
014c3818ba | |||
210ddd65a5 | |||
5741236e2c | |||
99549fb441 | |||
67c79e252e | |||
3b1174225a | |||
92f56b1046 | |||
f068dd5009 | |||
ebc34d7031 | |||
03ea4a31ad | |||
8636d04b84 | |||
c63a8bc5d3 | |||
0aae7236ae |
@ -1,5 +1,5 @@
|
|||||||
build/
|
build/
|
||||||
docker/
|
.git/
|
||||||
docker-compose.yml
|
docker-compose.yml
|
||||||
README.md
|
README.md
|
||||||
Earthfile
|
Earthfile
|
||||||
|
@ -14,7 +14,6 @@ linters:
|
|||||||
- gci # not working, shows errors on freshly formatted file
|
- gci # not working, shows errors on freshly formatted file
|
||||||
- varnamelen
|
- varnamelen
|
||||||
- lll
|
- lll
|
||||||
|
|
||||||
linters-settings:
|
linters-settings:
|
||||||
errcheck:
|
errcheck:
|
||||||
exclude-functions:
|
exclude-functions:
|
||||||
@ -26,18 +25,3 @@ linters-settings:
|
|||||||
varnamelen:
|
varnamelen:
|
||||||
ignore-decls:
|
ignore-decls:
|
||||||
- c *gin.Context
|
- c *gin.Context
|
||||||
wrapcheck:
|
|
||||||
ignoreSigs:
|
|
||||||
- .JSON(
|
|
||||||
- .Redirect(
|
|
||||||
- .String(
|
|
||||||
- .Errorf(
|
|
||||||
- errors.New(
|
|
||||||
- errors.Unwrap(
|
|
||||||
- .Wrap(
|
|
||||||
- .Wrapf(
|
|
||||||
- .WithMessage(
|
|
||||||
- .WithMessagef(
|
|
||||||
- .WithStack(
|
|
||||||
ignorePackageGlobs:
|
|
||||||
- git.javil.eu/jacob1123/budgeteer/postgres
|
|
26
README.md
26
README.md
@ -1,18 +1,20 @@
|
|||||||
# Budgeteer
|
# Budgeteer
|
||||||
|
|
||||||
Budgeting Web-Application written in Go and inspired by [YNAB](https://youneedabudget.com).
|
Budgeting Web-Application
|
||||||
|
|
||||||
## Getting started
|
## Data structure
|
||||||
|
|
||||||
The fastest way to get up and running quickly, is using docker-compose. Just download the [docker-compose.yml](https://git.javil.eu/jacob1123/budgeteer/src/branch/master/docker/docker-compose.yml) to some empty directory and run `docker-compose up -d`. This starts budgeteer, a postgres database and an adminer instance. The latter is optional and can be removed from the docker-compose.yml.
|
1 User
|
||||||
|
N Budgets
|
||||||
|
AccountID[]
|
||||||
|
CategoryID[]
|
||||||
|
PayeeID[]
|
||||||
|
|
||||||
## Known issues
|
N Accounts
|
||||||
|
TransactionID[]
|
||||||
|
N Categories
|
||||||
|
AssignmentID[]
|
||||||
|
N Payees
|
||||||
|
|
||||||
Currently the application is usable when importing from YNAB via their CSV export. All balances should match the balances from YNAB. There are even unit-tests that confirm that using my personal budget.
|
N Transactions
|
||||||
|
N Assignments
|
||||||
For people wishing to start fresh in Budgeteer, there currently are some blockers though:
|
|
||||||
- The ability to create new accounts and categories is missing (#59)
|
|
||||||
|
|
||||||
## Contributing
|
|
||||||
|
|
||||||
If you're willing to help, please check the issues for [help-wanted labels](https://git.javil.eu/jacob1123/budgeteer/issues?labels=4). Just using Budgeteer and reporting any issues is although very helpful.
|
|
@ -139,4 +139,4 @@ tasks:
|
|||||||
desc: Run dev environment in docker
|
desc: Run dev environment in docker
|
||||||
deps: [dev-docker]
|
deps: [dev-docker]
|
||||||
cmds:
|
cmds:
|
||||||
- docker-compose -f docker/docker-compose.dev.yml up -d
|
- docker-compose -f docker/docker-compose.dev.yml -p budgeteer up -d
|
@ -1,9 +1,15 @@
|
|||||||
FROM nixos/nix
|
FROM alpine as godeps
|
||||||
|
RUN apk --no-cache add go
|
||||||
|
RUN go install github.com/kyleconroy/sqlc/cmd/sqlc@latest
|
||||||
|
RUN go install github.com/go-task/task/v3/cmd/task@latest
|
||||||
|
RUN go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest
|
||||||
|
|
||||||
|
FROM alpine
|
||||||
|
RUN apk --no-cache add go nodejs yarn bash curl git git-perl
|
||||||
ENV PATH="/root/.yarn/bin/:${PATH}"
|
ENV PATH="/root/.yarn/bin/:${PATH}"
|
||||||
RUN nix-env --install go go-task sqlc nodejs yarn git
|
|
||||||
ADD web/package.json web/yarn.lock /src/web/
|
|
||||||
WORKDIR /src/web
|
WORKDIR /src/web
|
||||||
RUN yarn
|
ADD web/package.json web/yarn.lock /src/web/
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
VOLUME /go
|
VOLUME /go
|
||||||
VOLUME /.cache
|
VOLUME /.cache
|
||||||
|
COPY --from=godeps /root/go/bin/task /root/go/bin/sqlc /root/go/bin/golangci-lint /usr/local/bin/
|
@ -2,6 +2,6 @@
|
|||||||
|
|
||||||
tmux new-session -d -s watch 'cd web; yarn dev'
|
tmux new-session -d -s watch 'cd web; yarn dev'
|
||||||
tmux split-window;
|
tmux split-window;
|
||||||
tmux send 'go-task -w run' ENTER;
|
tmux send 'task -w run' ENTER;
|
||||||
tmux split-window;
|
tmux split-window;
|
||||||
tmux a;
|
tmux a;
|
@ -1,7 +1,5 @@
|
|||||||
version: '3.7'
|
version: '3.7'
|
||||||
|
|
||||||
name: "budgeteer"
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
backend:
|
backend:
|
||||||
image: hub.javil.eu/budgeteer:dev
|
image: hub.javil.eu/budgeteer:dev
|
||||||
@ -9,7 +7,7 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- 1323:1323
|
- 1323:1323
|
||||||
volumes:
|
volumes:
|
||||||
- ../:/src
|
- ~/budgeteer:/src
|
||||||
- go-cache:/go
|
- go-cache:/go
|
||||||
- yarn-cache:/.cache
|
- yarn-cache:/.cache
|
||||||
environment:
|
environment:
|
||||||
@ -24,7 +22,7 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- 3000:3000
|
- 3000:3000
|
||||||
volumes:
|
volumes:
|
||||||
- ../:/src
|
- ~/budgeteer:/src
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
- backend
|
||||||
|
|
||||||
|
@ -1,7 +1,5 @@
|
|||||||
version: '3.7'
|
version: '3.7'
|
||||||
|
|
||||||
name: "budgeteer"
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
app:
|
app:
|
||||||
image: hub.javil.eu/budgeteer:latest
|
image: hub.javil.eu/budgeteer:latest
|
||||||
|
@ -91,24 +91,6 @@ func (q *Queries) GetCategories(ctx context.Context, budgetID uuid.UUID) ([]GetC
|
|||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
const getCategoryGroupByName = `-- name: GetCategoryGroupByName :one
|
|
||||||
SELECT category_groups.id, category_groups.budget_id, category_groups.name FROM category_groups
|
|
||||||
WHERE category_groups.budget_id = $1
|
|
||||||
AND category_groups.name = $2
|
|
||||||
`
|
|
||||||
|
|
||||||
type GetCategoryGroupByNameParams struct {
|
|
||||||
BudgetID uuid.UUID
|
|
||||||
Name string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (q *Queries) GetCategoryGroupByName(ctx context.Context, arg GetCategoryGroupByNameParams) (CategoryGroup, error) {
|
|
||||||
row := q.db.QueryRowContext(ctx, getCategoryGroupByName, arg.BudgetID, arg.Name)
|
|
||||||
var i CategoryGroup
|
|
||||||
err := row.Scan(&i.ID, &i.BudgetID, &i.Name)
|
|
||||||
return i, err
|
|
||||||
}
|
|
||||||
|
|
||||||
const getCategoryGroups = `-- name: GetCategoryGroups :many
|
const getCategoryGroups = `-- name: GetCategoryGroups :many
|
||||||
SELECT category_groups.id, category_groups.budget_id, category_groups.name FROM category_groups
|
SELECT category_groups.id, category_groups.budget_id, category_groups.name FROM category_groups
|
||||||
WHERE category_groups.budget_id = $1
|
WHERE category_groups.budget_id = $1
|
||||||
|
@ -24,9 +24,6 @@ type CreatePayeeParams struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) CreatePayee(ctx context.Context, arg CreatePayeeParams) (Payee, error) {
|
func (q *Queries) CreatePayee(ctx context.Context, arg CreatePayeeParams) (Payee, error) {
|
||||||
if len(arg.Name) > 50 {
|
|
||||||
arg.Name = arg.Name[:50]
|
|
||||||
}
|
|
||||||
row := q.db.QueryRowContext(ctx, createPayee, arg.Name, arg.BudgetID)
|
row := q.db.QueryRowContext(ctx, createPayee, arg.Name, arg.BudgetID)
|
||||||
var i Payee
|
var i Payee
|
||||||
err := row.Scan(&i.ID, &i.BudgetID, &i.Name)
|
err := row.Scan(&i.ID, &i.BudgetID, &i.Name)
|
||||||
|
@ -8,11 +8,6 @@ RETURNING *;
|
|||||||
SELECT category_groups.* FROM category_groups
|
SELECT category_groups.* FROM category_groups
|
||||||
WHERE category_groups.budget_id = $1;
|
WHERE category_groups.budget_id = $1;
|
||||||
|
|
||||||
-- name: GetCategoryGroupByName :one
|
|
||||||
SELECT category_groups.* FROM category_groups
|
|
||||||
WHERE category_groups.budget_id = $1
|
|
||||||
AND category_groups.name = $2;
|
|
||||||
|
|
||||||
-- name: CreateCategory :one
|
-- name: CreateCategory :one
|
||||||
INSERT INTO categories
|
INSERT INTO categories
|
||||||
(name, category_group_id)
|
(name, category_group_id)
|
||||||
|
@ -10,11 +10,11 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type FilterTransactionsRequest struct {
|
type FilterTransactionsRequest struct {
|
||||||
CategoryID string `json:"categoryId"`
|
CategoryID string `json:"category_id"`
|
||||||
PayeeID string `json:"payeeId"`
|
PayeeID string `json:"payee_id"`
|
||||||
AccountID string `json:"accountId"`
|
AccountID string `json:"account_id"`
|
||||||
FromDate time.Time `json:"fromDate"`
|
FromDate time.Time `json:"from_date"`
|
||||||
ToDate time.Time `json:"toDate"`
|
ToDate time.Time `json:"to_date"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) filteredTransactions(c echo.Context) error {
|
func (h *Handler) filteredTransactions(c echo.Context) error {
|
||||||
@ -58,7 +58,7 @@ func parseEmptyUUID(value string) (uuid.NullUUID, bool) {
|
|||||||
return uuid.NullUUID{}, false
|
return uuid.NullUUID{}, false
|
||||||
}
|
}
|
||||||
|
|
||||||
return uuid.NullUUID{UUID: val, Valid: true}, true
|
return uuid.NullUUID{val, true}, true
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) problematicTransactions(c echo.Context) error {
|
func (h *Handler) problematicTransactions(c echo.Context) error {
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
@ -36,23 +35,17 @@ func TestRegisterUser(t *testing.T) {
|
|||||||
|
|
||||||
t.Run("RegisterUser", func(t *testing.T) {
|
t.Run("RegisterUser", func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
request, err := http.NewRequestWithContext(context.Background(),
|
request, err := http.NewRequest(
|
||||||
http.MethodPost,
|
http.MethodPost,
|
||||||
"/api/v1/user/register",
|
"/api/v1/user/register",
|
||||||
strings.NewReader(`{"password":"pass","email":"info@example.com","name":"Test"}`))
|
strings.NewReader(`{"password":"pass","email":"info@example.com","name":"Test"}`))
|
||||||
request.Header.Set("Content-Type", "application/json")
|
|
||||||
context := engine.NewContext(request, recorder)
|
context := engine.NewContext(request, recorder)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Errorf("error creating request: %s", err)
|
t.Errorf("error creating request: %s", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
err = h.registerPost(context)
|
h.registerPost(context)
|
||||||
if err != nil {
|
|
||||||
t.Error(err.Error())
|
|
||||||
t.Error("Error registering")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if recorder.Code != http.StatusOK {
|
if recorder.Code != http.StatusOK {
|
||||||
t.Errorf("handler returned wrong status code: got %v want %v", recorder.Code, http.StatusOK)
|
t.Errorf("handler returned wrong status code: got %v want %v", recorder.Code, http.StatusOK)
|
||||||
|
@ -41,7 +41,7 @@ func (h *Handler) budgetingForMonth(c echo.Context) error {
|
|||||||
|
|
||||||
month, err := getDate(c)
|
month, err := getDate(c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.Redirect(http.StatusTemporaryRedirect, "/budget/"+budget.ID.String())
|
c.Redirect(http.StatusTemporaryRedirect, "/budget/"+budget.ID.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
data, err := h.getBudgetingViewForMonth(c.Request().Context(), budget, month)
|
data, err := h.getBudgetingViewForMonth(c.Request().Context(), budget, month)
|
||||||
|
@ -1,35 +0,0 @@
|
|||||||
package server
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"git.javil.eu/jacob1123/budgeteer/postgres"
|
|
||||||
"github.com/google/uuid"
|
|
||||||
"github.com/labstack/echo/v4"
|
|
||||||
)
|
|
||||||
|
|
||||||
type newCategoryGroupInformation struct {
|
|
||||||
BudgetID uuid.UUID `json:"budgetId"`
|
|
||||||
Group string `json:"group"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handler) newCategoryGroup(c echo.Context) error {
|
|
||||||
var newCategory newCategoryGroupInformation
|
|
||||||
if err := c.Bind(&newCategory); err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusNotAcceptable, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if newCategory.Group == "" {
|
|
||||||
return echo.NewHTTPError(http.StatusBadRequest, "category group is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
categoryGroup, err := h.Service.CreateCategoryGroup(c.Request().Context(), postgres.CreateCategoryGroupParams{
|
|
||||||
BudgetID: newCategory.BudgetID,
|
|
||||||
Name: newCategory.Group,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.JSON(http.StatusOK, categoryGroup)
|
|
||||||
}
|
|
@ -1,48 +0,0 @@
|
|||||||
package server
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"git.javil.eu/jacob1123/budgeteer/postgres"
|
|
||||||
"github.com/google/uuid"
|
|
||||||
"github.com/labstack/echo/v4"
|
|
||||||
)
|
|
||||||
|
|
||||||
type newCategoryInformation struct {
|
|
||||||
BudgetID uuid.UUID `json:"budgetId"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
Group string `json:"group"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handler) newCategory(c echo.Context) error {
|
|
||||||
var newCategory newCategoryInformation
|
|
||||||
if err := c.Bind(&newCategory); err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusNotAcceptable, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if newCategory.Name == "" {
|
|
||||||
return echo.NewHTTPError(http.StatusBadRequest, "category name is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
if newCategory.Group == "" {
|
|
||||||
return echo.NewHTTPError(http.StatusBadRequest, "category group is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
categoryGroup, err := h.Service.GetCategoryGroupByName(c.Request().Context(), postgres.GetCategoryGroupByNameParams{
|
|
||||||
BudgetID: newCategory.BudgetID,
|
|
||||||
Name: newCategory.Group,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
category, err := h.Service.CreateCategory(c.Request().Context(), postgres.CreateCategoryParams{
|
|
||||||
CategoryGroupID: categoryGroup.ID,
|
|
||||||
Name: newCategory.Name,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.JSON(http.StatusOK, category)
|
|
||||||
}
|
|
@ -1,8 +1,11 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"errors"
|
||||||
|
"io"
|
||||||
|
"io/fs"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"path"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"git.javil.eu/jacob1123/budgeteer"
|
"git.javil.eu/jacob1123/budgeteer"
|
||||||
@ -33,7 +36,6 @@ func (h *Handler) Serve() {
|
|||||||
|
|
||||||
// LoadRoutes initializes all the routes.
|
// LoadRoutes initializes all the routes.
|
||||||
func (h *Handler) LoadRoutes(router *echo.Echo) {
|
func (h *Handler) LoadRoutes(router *echo.Echo) {
|
||||||
router.Use(middleware.Logger())
|
|
||||||
router.Use(enableCachingForStaticFiles())
|
router.Use(enableCachingForStaticFiles())
|
||||||
router.Use(middleware.StaticWithConfig(middleware.StaticConfig{
|
router.Use(middleware.StaticWithConfig(middleware.StaticConfig{
|
||||||
Filesystem: h.StaticFS,
|
Filesystem: h.StaticFS,
|
||||||
@ -49,16 +51,10 @@ func (h *Handler) LoadRoutes(router *echo.Echo) {
|
|||||||
authenticated := api.Group("")
|
authenticated := api.Group("")
|
||||||
{
|
{
|
||||||
authenticated.Use(h.verifyLoginWithForbidden)
|
authenticated.Use(h.verifyLoginWithForbidden)
|
||||||
account := authenticated.Group("/account")
|
authenticated.GET("/account/:accountid/transactions", h.transactionsForAccount)
|
||||||
account.GET("/:accountid/transactions", h.transactionsForAccount)
|
authenticated.POST("/account/:accountid/reconcile", h.reconcileTransactions)
|
||||||
account.POST("/:accountid/reconcile", h.reconcileTransactions)
|
authenticated.POST("/account/:accountid", h.editAccount)
|
||||||
account.POST("/:accountid", h.editAccount)
|
authenticated.GET("/admin/clear-database", h.clearDatabase)
|
||||||
|
|
||||||
category := authenticated.Group("/category")
|
|
||||||
category.POST("/new", h.newCategory)
|
|
||||||
|
|
||||||
categoryGroup := authenticated.Group("/category-group")
|
|
||||||
categoryGroup.POST("/new", h.newCategoryGroup)
|
|
||||||
|
|
||||||
budget := authenticated.Group("/budget")
|
budget := authenticated.Group("/budget")
|
||||||
budget.POST("/new", h.newBudget)
|
budget.POST("/new", h.newBudget)
|
||||||
@ -79,16 +75,44 @@ func (h *Handler) LoadRoutes(router *echo.Echo) {
|
|||||||
transaction := authenticated.Group("/transaction")
|
transaction := authenticated.Group("/transaction")
|
||||||
transaction.POST("/new", h.newTransaction)
|
transaction.POST("/new", h.newTransaction)
|
||||||
transaction.POST("/:transactionid", h.updateTransaction)
|
transaction.POST("/:transactionid", h.updateTransaction)
|
||||||
|
}
|
||||||
authenticated.GET("/admin/clear-database", h.clearDatabase)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
api.Any("/*", h.notFound)
|
func (h *Handler) ServeStatic(next echo.HandlerFunc) echo.HandlerFunc {
|
||||||
|
return func(c echo.Context) error {
|
||||||
|
h.ServeStaticFile(c, c.Path())
|
||||||
|
return nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) notFound(c echo.Context) error {
|
func (h *Handler) ServeStaticFile(c echo.Context, fullPath string) {
|
||||||
fmt.Println("not found?")
|
file, err := h.StaticFS.Open(fullPath)
|
||||||
return echo.NewHTTPError(http.StatusNotImplemented, "not found")
|
if errors.Is(err, fs.ErrNotExist) {
|
||||||
|
h.ServeStaticFile(c, path.Join("/", "/index.html"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
stat, err := file.Stat()
|
||||||
|
if err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if stat.IsDir() {
|
||||||
|
h.ServeStaticFile(c, path.Join(fullPath, "index.html"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if file, ok := file.(io.ReadSeeker); ok {
|
||||||
|
http.ServeContent(c.Response().Writer, c.Request(), stat.Name(), stat.ModTime(), file)
|
||||||
|
} else {
|
||||||
|
panic("File does not implement ReadSeeker")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func enableCachingForStaticFiles() echo.MiddlewareFunc {
|
func enableCachingForStaticFiles() echo.MiddlewareFunc {
|
||||||
|
@ -35,7 +35,7 @@ func (h *Handler) verifyLogin(c echo.Context) (budgeteer.Token, error) { //nolin
|
|||||||
tokenString = tokenString[7:]
|
tokenString = tokenString[7:]
|
||||||
token, err := h.TokenVerifier.VerifyToken(tokenString)
|
token, err := h.TokenVerifier.VerifyToken(tokenString)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("verify token '%s': %w", tokenString, err)
|
return nil, fmt.Errorf("verify token '%s': %s", tokenString, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return token, nil
|
return token, nil
|
||||||
@ -63,7 +63,7 @@ func (h *Handler) loginPost(c echo.Context) error {
|
|||||||
var login loginInformation
|
var login loginInformation
|
||||||
err := c.Bind(&login)
|
err := c.Bind(&login)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("parse payload: %w", err)
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
user, err := h.Service.GetUserByUsername(c.Request().Context(), login.User)
|
user, err := h.Service.GetUserByUsername(c.Request().Context(), login.User)
|
||||||
@ -72,12 +72,12 @@ func (h *Handler) loginPost(c echo.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err = h.CredentialsVerifier.Verify(login.Password, user.Password); err != nil {
|
if err = h.CredentialsVerifier.Verify(login.Password, user.Password); err != nil {
|
||||||
return fmt.Errorf("verify password: %w", err)
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
token, err := h.TokenVerifier.CreateToken(&user)
|
token, err := h.TokenVerifier.CreateToken(&user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("create token: %w", err)
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
go h.UpdateLastLogin(user.ID)
|
go h.UpdateLastLogin(user.ID)
|
||||||
@ -120,7 +120,7 @@ func (h *Handler) registerPost(c echo.Context) error {
|
|||||||
|
|
||||||
hash, err := h.CredentialsVerifier.Hash(register.Password)
|
hash, err := h.CredentialsVerifier.Hash(register.Password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("hash password: %w", err)
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
createUser := postgres.CreateUserParams{
|
createUser := postgres.CreateUserParams{
|
||||||
@ -135,7 +135,7 @@ func (h *Handler) registerPost(c echo.Context) error {
|
|||||||
|
|
||||||
token, err := h.TokenVerifier.CreateToken(&user)
|
token, err := h.TokenVerifier.CreateToken(&user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("create token: %w", err)
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
go h.UpdateLastLogin(user.ID)
|
go h.UpdateLastLogin(user.ID)
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"git.javil.eu/jacob1123/budgeteer/postgres"
|
"git.javil.eu/jacob1123/budgeteer/postgres"
|
||||||
@ -27,12 +26,12 @@ func (h *Handler) importYNAB(c echo.Context) error {
|
|||||||
|
|
||||||
transactionsFile, err := c.FormFile("transactions")
|
transactionsFile, err := c.FormFile("transactions")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("get transactions: %w", err)
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
transactions, err := transactionsFile.Open()
|
transactions, err := transactionsFile.Open()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("open transactions: %w", err)
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
err = ynab.ImportTransactions(c.Request().Context(), transactions)
|
err = ynab.ImportTransactions(c.Request().Context(), transactions)
|
||||||
@ -42,12 +41,12 @@ func (h *Handler) importYNAB(c echo.Context) error {
|
|||||||
|
|
||||||
assignmentsFile, err := c.FormFile("assignments")
|
assignmentsFile, err := c.FormFile("assignments")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("get assignments: %w", err)
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
assignments, err := assignmentsFile.Open()
|
assignments, err := assignmentsFile.Open()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("open assignments: %w", err)
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
err = ynab.ImportAssignments(c.Request().Context(), assignments)
|
err = ynab.ImportAssignments(c.Request().Context(), assignments)
|
||||||
|
@ -9,18 +9,7 @@ export function GET(path: string) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FORM(path: string, body: FormData) {
|
export function POST(path: string, body: FormData | string | null) {
|
||||||
const sessionStore = useSessionStore();
|
|
||||||
return fetch(BASE_URL + path, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
...sessionStore.AuthHeaders,
|
|
||||||
},
|
|
||||||
body: body,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function POST(path: string, body: string | null) {
|
|
||||||
const sessionStore = useSessionStore();
|
const sessionStore = useSessionStore();
|
||||||
return fetch(BASE_URL + path, {
|
return fetch(BASE_URL + path, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
@ -11,10 +11,10 @@ export interface Suggestion {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
text: string | null,
|
text: string,
|
||||||
id: string | null,
|
id: string | undefined,
|
||||||
model: string,
|
model: string,
|
||||||
type?: string | null,
|
type?: string | undefined,
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const SearchQuery = ref(props.text || "");
|
const SearchQuery = ref(props.text || "");
|
||||||
|
@ -1,49 +0,0 @@
|
|||||||
<script lang="ts" setup>
|
|
||||||
import { computed, ref, watch } from 'vue';
|
|
||||||
import { useAccountStore } from '../stores/budget-account';
|
|
||||||
import { Category, useCategoryStore } from '../stores/category';
|
|
||||||
import Currency from './Currency.vue'
|
|
||||||
import Input from './Input.vue';
|
|
||||||
|
|
||||||
const props = defineProps<{category:Category, year: number, month: number}>()
|
|
||||||
|
|
||||||
const assigned = ref(props.category.Assigned);
|
|
||||||
watch(() => props.category.Assigned, () =>{ assigned.value = props.category.Assigned});
|
|
||||||
|
|
||||||
const accountStore = useAccountStore();
|
|
||||||
const categoryStore = useCategoryStore();
|
|
||||||
|
|
||||||
function assignedChanged(_e : Event, category : Category){
|
|
||||||
categoryStore.SetAssigned(category, props.year, props.month, assigned.value);
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template
|
|
||||||
v-for="category in getCategoriesForGroup(group)"
|
|
||||||
:key="category.ID"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="contents"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
class="whitespace-nowrap overflow-hidden"
|
|
||||||
>{{ category.Name }}</span>
|
|
||||||
<Currency
|
|
||||||
:value="category.AvailableLastMonth"
|
|
||||||
class="hidden lg:block"
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
v-model="assigned"
|
|
||||||
type="number"
|
|
||||||
class="hidden sm:block mx-2 text-right"
|
|
||||||
@input="(evt : Event) => assignedChanged(evt, category)"
|
|
||||||
/>
|
|
||||||
<Currency
|
|
||||||
:value="category.Activity"
|
|
||||||
class="hidden sm:block"
|
|
||||||
/>
|
|
||||||
<Currency
|
|
||||||
:value="accountStore.GetCategoryAvailable(category)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
@ -1,66 +0,0 @@
|
|||||||
<script lang="ts" setup>
|
|
||||||
import { computed, ref } from 'vue';
|
|
||||||
import { useCategoryStore } from '../stores/category';
|
|
||||||
import { CategoryGroup } from '../stores/category-group';
|
|
||||||
import BudgetingCategory from './BudgetingCategory.vue';
|
|
||||||
import Currency from './Currency.vue'
|
|
||||||
import CreateCategory from '../dialogs/CreateCategory.vue';
|
|
||||||
|
|
||||||
const props = defineProps<{group: CategoryGroup, year: number, month: number}>();
|
|
||||||
|
|
||||||
const categoryStore = useCategoryStore();
|
|
||||||
const categoriesForGroup = computed(() => categoryStore.GetCategoriesForGroup(props.group));
|
|
||||||
|
|
||||||
const expanded = ref(true)
|
|
||||||
function toggleGroup() {
|
|
||||||
expanded.value = !expanded.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
const availableLastMonth = computed(() => categoriesForGroup.value.reduce((prev, current) => prev + current.AvailableLastMonth, 0))
|
|
||||||
const assigned = computed(() => categoriesForGroup.value.reduce((prev, current) => prev + current.Assigned, 0))
|
|
||||||
const activity = computed(() => categoriesForGroup.value.reduce((prev, current) => prev + current.Activity, 0))
|
|
||||||
const available = computed(() => activity.value+assigned.value+availableLastMonth.value);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<span
|
|
||||||
class="text-lg font-bold mt-2"
|
|
||||||
@click="toggleGroup()"
|
|
||||||
>{{ (expanded ? "−" : "+") + " " + group.Name }}
|
|
||||||
<CreateCategory :category-group="group.Name" />
|
|
||||||
</span>
|
|
||||||
<Currency
|
|
||||||
:value="availableLastMonth"
|
|
||||||
class="hidden lg:block mt-2"
|
|
||||||
positive-class="text-slate-500"
|
|
||||||
negative-class="text-red-700 dark:text-red-400"
|
|
||||||
/>
|
|
||||||
<Currency
|
|
||||||
:value="assigned"
|
|
||||||
class="hidden sm:block mx-2 mt-2 text-right"
|
|
||||||
positive-class="text-slate-500"
|
|
||||||
negative-class="text-red-700 dark:text-red-400"
|
|
||||||
/>
|
|
||||||
<Currency
|
|
||||||
:value="activity"
|
|
||||||
class="hidden sm:block mt-2"
|
|
||||||
positive-class="text-slate-500"
|
|
||||||
negative-class="text-red-700 dark:text-red-400"
|
|
||||||
/>
|
|
||||||
<Currency
|
|
||||||
:value="available"
|
|
||||||
class="mt-2"
|
|
||||||
positive-class="text-slate-500"
|
|
||||||
negative-class="text-red-700 dark:text-red-400"
|
|
||||||
/>
|
|
||||||
<template
|
|
||||||
v-if="expanded">
|
|
||||||
<BudgetingCategory
|
|
||||||
v-for="category in categoriesForGroup"
|
|
||||||
:key="category.ID"
|
|
||||||
:category="category"
|
|
||||||
:year="year"
|
|
||||||
:month="month"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
</template>
|
|
@ -1,62 +0,0 @@
|
|||||||
<script lang="ts" setup>
|
|
||||||
import { computed } from 'vue';
|
|
||||||
import { useAccountStore } from '../stores/budget-account';
|
|
||||||
import Currency from './Currency.vue';
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
year:number,
|
|
||||||
month:number
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const accountStore = useAccountStore();
|
|
||||||
const budgeted = computed(() => accountStore.GetBudgeted(props.year, props.month))
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<h1>Budget for {{ month + 1 }}/{{ year }}</h1>
|
|
||||||
<table class="inline-block">
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
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.Assigned+budgeted.Deassigned)>=0?"Budgeted":"Freed" }} this month:
|
|
||||||
</td>
|
|
||||||
<td class="text-right">
|
|
||||||
<Currency :value="-1*(budgeted.Assigned+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>
|
|
||||||
</template>
|
|
@ -15,13 +15,10 @@ const visible = ref(false);
|
|||||||
function closeDialog() {
|
function closeDialog() {
|
||||||
visible.value = false;
|
visible.value = false;
|
||||||
};
|
};
|
||||||
|
function openDialog() {
|
||||||
function openDialog(e : MouseEvent) {
|
|
||||||
e.stopPropagation();
|
|
||||||
emit("open");
|
emit("open");
|
||||||
visible.value = true;
|
visible.value = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
function submitDialog() {
|
function submitDialog() {
|
||||||
const e = {cancel: false};
|
const e = {cancel: false};
|
||||||
emit("submit", e);
|
emit("submit", e);
|
||||||
|
@ -16,9 +16,9 @@ const TX = ref<Transaction>({
|
|||||||
Memo: "",
|
Memo: "",
|
||||||
Amount: 0,
|
Amount: 0,
|
||||||
Payee: "",
|
Payee: "",
|
||||||
PayeeID: null,
|
PayeeID: undefined,
|
||||||
Category: "",
|
Category: "",
|
||||||
CategoryID: null,
|
CategoryID: undefined,
|
||||||
CategoryGroup: "",
|
CategoryGroup: "",
|
||||||
GroupID: "",
|
GroupID: "",
|
||||||
ID: "",
|
ID: "",
|
||||||
|
@ -1,53 +0,0 @@
|
|||||||
<script lang="ts" setup>
|
|
||||||
import { computed, ref } from 'vue';
|
|
||||||
import Modal from '../components/Modal.vue';
|
|
||||||
import { useAccountStore } from '../stores/budget-account';
|
|
||||||
import Input from '../components/Input.vue';
|
|
||||||
import { useCategoryStore } from '../stores/category';
|
|
||||||
|
|
||||||
const categoryStore = useCategoryStore();
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
categoryGroup: string
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const categoryName = ref("");
|
|
||||||
const error = ref("");
|
|
||||||
|
|
||||||
function createCategory(e : {cancel:boolean}) : boolean {
|
|
||||||
error.value = "";
|
|
||||||
categoryStore.CreateCategory(props.categoryGroup, categoryName.value);
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<Modal
|
|
||||||
button-text="Create Category"
|
|
||||||
@submit="createCategory"
|
|
||||||
>
|
|
||||||
<template #placeholder>
|
|
||||||
<span class="ml-2">+</span>
|
|
||||||
</template>
|
|
||||||
<div class="mt-2 px-7 py-3">
|
|
||||||
Parent: {{ categoryGroup }}
|
|
||||||
</div>
|
|
||||||
<div class="mt-2 px-7 py-3">
|
|
||||||
<Input
|
|
||||||
v-model="categoryName"
|
|
||||||
class="border-2 dark:border-gray-700"
|
|
||||||
type="text"
|
|
||||||
placeholder="Category name"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-if="error != ''"
|
|
||||||
class="dark:text-red-300 text-red-700"
|
|
||||||
>
|
|
||||||
{{ error }}
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
</template>
|
|
@ -1,44 +0,0 @@
|
|||||||
<script lang="ts" setup>
|
|
||||||
import { ref } from 'vue';
|
|
||||||
import Modal from '../components/Modal.vue';
|
|
||||||
import Input from '../components/Input.vue';
|
|
||||||
import { useCategoryGroupStore } from '../stores/category-group';
|
|
||||||
|
|
||||||
const categoryGroupStore = useCategoryGroupStore();
|
|
||||||
|
|
||||||
const categoryGroupName = ref("");
|
|
||||||
const error = ref("");
|
|
||||||
|
|
||||||
function createCategoryGroup(e : {cancel:boolean}) : boolean {
|
|
||||||
error.value = "";
|
|
||||||
categoryGroupStore.CreateCategoryGroup(categoryGroupName.value);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<Modal
|
|
||||||
button-text="Create Category Group"
|
|
||||||
@submit="createCategoryGroup"
|
|
||||||
>
|
|
||||||
<template #placeholder>
|
|
||||||
<button class="px-2 py-0 w-full bg-slate-400 rounded-md mt-4">Add Group</button>
|
|
||||||
</template>
|
|
||||||
<div class="mt-2 px-7 py-3">
|
|
||||||
<Input
|
|
||||||
v-model="categoryGroupName"
|
|
||||||
class="border-2 dark:border-gray-700"
|
|
||||||
type="text"
|
|
||||||
placeholder="Category name"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-if="error != ''"
|
|
||||||
class="dark:text-red-300 text-red-700"
|
|
||||||
>
|
|
||||||
{{ error }}
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
</template>
|
|
@ -42,7 +42,7 @@ const filters = ref({
|
|||||||
ToDate: new Date(2999,11,32),
|
ToDate: new Date(2999,11,32),
|
||||||
});
|
});
|
||||||
|
|
||||||
watch(() => (filters.value.AccountID ?? "")
|
watch(() => filters.value.AccountID
|
||||||
+ filters.value.PayeeID
|
+ filters.value.PayeeID
|
||||||
+ filters.value.CategoryID
|
+ filters.value.CategoryID
|
||||||
+ filters.value.FromDate?.toISOString()
|
+ filters.value.FromDate?.toISOString()
|
||||||
|
@ -1,14 +1,11 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { computed, onMounted, watchEffect } from "vue";
|
import { computed, onMounted, ref, watchEffect } from "vue";
|
||||||
|
import Currency from "../components/Currency.vue";
|
||||||
import { useBudgetsStore } from "../stores/budget";
|
import { useBudgetsStore } from "../stores/budget";
|
||||||
import { useCategoryGroupStore } from "../stores/category-group";
|
import { Category, useAccountStore } from "../stores/budget-account";
|
||||||
import { useAccountStore } from "../stores/budget-account";
|
|
||||||
import { useSessionStore } from "../stores/session";
|
import { useSessionStore } from "../stores/session";
|
||||||
|
import Input from "../components/Input.vue";
|
||||||
import { POST } from "../api";
|
import { POST } from "../api";
|
||||||
import BudgetingSummary from "../components/BudgetingSummary.vue";
|
|
||||||
import { Category } from "../stores/category";
|
|
||||||
import CreateCategoryGroup from "../dialogs/CreateCategoryGroup.vue";
|
|
||||||
import BudgetingCategoryGroup from "../components/BudgetingCategoryGroup.vue";
|
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
budgetid: string,
|
budgetid: string,
|
||||||
@ -16,12 +13,22 @@ const props = defineProps<{
|
|||||||
month: string,
|
month: string,
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const categoryGroupStore = useCategoryGroupStore()
|
|
||||||
const categoryGroups = computed(() => [...categoryGroupStore.CategoryGroups.values()]);
|
|
||||||
|
|
||||||
const budgetsStore = useBudgetsStore();
|
const budgetsStore = useBudgetsStore();
|
||||||
const CurrentBudgetID = computed(() => budgetsStore.CurrentBudgetID);
|
const CurrentBudgetID = computed(() => budgetsStore.CurrentBudgetID);
|
||||||
|
|
||||||
|
const accountStore = useAccountStore();
|
||||||
|
const categoriesForMonth = accountStore.CategoriesForMonthAndGroup;
|
||||||
|
|
||||||
|
function GetCategories(group: string) {
|
||||||
|
return [...categoriesForMonth(selected.value.Year, selected.value.Month, group)];
|
||||||
|
};
|
||||||
|
|
||||||
|
const groupsForMonth = accountStore.CategoryGroupsForMonth;
|
||||||
|
const GroupsForMonth = computed(() => {
|
||||||
|
return [...groupsForMonth(selected.value.Year, selected.value.Month)];
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
const previous = computed(() => ({
|
const previous = computed(() => ({
|
||||||
Year: new Date(selected.value.Year, selected.value.Month - 1, 1).getFullYear(),
|
Year: new Date(selected.value.Year, selected.value.Month - 1, 1).getFullYear(),
|
||||||
Month: new Date(selected.value.Year, selected.value.Month - 1, 1).getMonth(),
|
Month: new Date(selected.value.Year, selected.value.Month - 1, 1).getMonth(),
|
||||||
@ -48,10 +55,77 @@ onMounted(() => {
|
|||||||
useSessionStore().setTitle("Budget for " + selected.value.Month + "/" + selected.value.Year);
|
useSessionStore().setTitle("Budget for " + selected.value.Month + "/" + selected.value.Year);
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
const expandedGroups = ref<Map<string, boolean>>(new Map<string, boolean>())
|
||||||
|
|
||||||
|
function toggleGroup(group: { Name: string, Expand: boolean }) {
|
||||||
|
expandedGroups.value.set(group.Name, !(expandedGroups.value.get(group.Name) ?? group.Expand))
|
||||||
|
}
|
||||||
|
|
||||||
|
function getGroupState(group: { Name: string, Expand: boolean }): boolean {
|
||||||
|
return expandedGroups.value.get(group.Name) ?? group.Expand;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assignedChanged(e : Event, category : Category){
|
||||||
|
const target = e.target as HTMLInputElement;
|
||||||
|
const value = target.valueAsNumber;
|
||||||
|
POST("/budget/"+CurrentBudgetID.value+"/category/" + category.ID + "/" + selected.value.Year + "/" + (selected.value.Month+1),
|
||||||
|
JSON.stringify({Assigned: category.Assigned}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const budgeted = computed(() => accountStore.GetBudgeted(selected.value.Year, selected.value.Month))
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<BudgetingSummary :year="selected.Year" :month="selected.Month" />
|
<h1>Budget for {{ selected.Month + 1 }}/{{ selected.Year }}</h1>
|
||||||
|
<table class="inline-block">
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
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"
|
||||||
@ -78,13 +152,68 @@ onMounted(() => {
|
|||||||
<span class="hidden sm:block text-right">Assigned</span>
|
<span class="hidden sm:block text-right">Assigned</span>
|
||||||
<span class="hidden sm:block text-right">Activity</span>
|
<span class="hidden sm:block text-right">Activity</span>
|
||||||
<span class="hidden sm:block text-right">Available</span>
|
<span class="hidden sm:block text-right">Available</span>
|
||||||
<BudgetingCategoryGroup
|
<template
|
||||||
v-for="group in categoryGroups"
|
v-for="group in GroupsForMonth"
|
||||||
:key="group.Name"
|
:key="group.Name"
|
||||||
:group="group"
|
>
|
||||||
:year="selected.Year"
|
<span
|
||||||
:month="selected.Month"
|
class="text-lg font-bold mt-2"
|
||||||
|
@click="toggleGroup(group)"
|
||||||
|
>{{ (getGroupState(group) ? "−" : "+") + " " + group.Name }}</span>
|
||||||
|
<Currency
|
||||||
|
:value="group.AvailableLastMonth"
|
||||||
|
class="hidden lg:block mt-2"
|
||||||
|
positive-class="text-slate-500"
|
||||||
|
negative-class="text-red-700 dark:text-red-400"
|
||||||
|
/>
|
||||||
|
<Currency
|
||||||
|
:value="group.Assigned"
|
||||||
|
class="hidden sm:block mx-2 mt-2 text-right"
|
||||||
|
positive-class="text-slate-500"
|
||||||
|
negative-class="text-red-700 dark:text-red-400"
|
||||||
|
/>
|
||||||
|
<Currency
|
||||||
|
:value="group.Activity"
|
||||||
|
class="hidden sm:block mt-2"
|
||||||
|
positive-class="text-slate-500"
|
||||||
|
negative-class="text-red-700 dark:text-red-400"
|
||||||
|
/>
|
||||||
|
<Currency
|
||||||
|
:value="group.Available"
|
||||||
|
class="mt-2"
|
||||||
|
positive-class="text-slate-500"
|
||||||
|
negative-class="text-red-700 dark:text-red-400"
|
||||||
|
/>
|
||||||
|
<template
|
||||||
|
v-for="category in GetCategories(group.Name)"
|
||||||
|
:key="category.ID"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="getGroupState(group)"
|
||||||
|
class="contents"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="whitespace-nowrap overflow-hidden"
|
||||||
|
>{{ category.Name }}</span>
|
||||||
|
<Currency
|
||||||
|
:value="category.AvailableLastMonth"
|
||||||
|
class="hidden lg:block"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
v-model="category.Assigned"
|
||||||
|
type="number"
|
||||||
|
class="hidden sm:block mx-2 text-right"
|
||||||
|
@input="(evt) => assignedChanged(evt, category)"
|
||||||
|
/>
|
||||||
|
<Currency
|
||||||
|
:value="category.Activity"
|
||||||
|
class="hidden sm:block"
|
||||||
|
/>
|
||||||
|
<Currency
|
||||||
|
:value="accountStore.GetCategoryAvailable(category)"
|
||||||
/>
|
/>
|
||||||
<CreateCategoryGroup />
|
</div>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
@ -1,14 +1,13 @@
|
|||||||
import { defineStore } from "pinia";
|
import { defineStore } from "pinia";
|
||||||
import { GET, POST } from "../api";
|
import { GET, POST } from "../api";
|
||||||
import { useBudgetsStore } from "./budget";
|
import { useBudgetsStore } from "./budget";
|
||||||
import { Category, useCategoryStore } from "./category";
|
|
||||||
import { useCategoryGroupStore } from "./category-group";
|
|
||||||
import { useSessionStore } from "./session";
|
import { useSessionStore } from "./session";
|
||||||
import { Transaction, useTransactionsStore } from "./transactions";
|
import { useTransactionsStore } from "./transactions";
|
||||||
|
|
||||||
interface State {
|
interface State {
|
||||||
Accounts: Map<string, Account>;
|
Accounts: Map<string, Account>;
|
||||||
CurrentAccountID: string | null;
|
CurrentAccountID: string | null;
|
||||||
|
Categories: Map<string, Category>;
|
||||||
Months: Map<number, Map<number, Map<string, Category>>>;
|
Months: Map<number, Map<number, Map<string, Category>>>;
|
||||||
Available: number,
|
Available: number,
|
||||||
OverspentLastMonth: number,
|
OverspentLastMonth: number,
|
||||||
@ -23,6 +22,7 @@ export interface Account {
|
|||||||
ClearedBalance: number;
|
ClearedBalance: number;
|
||||||
WorkingBalance: number;
|
WorkingBalance: number;
|
||||||
ReconciledBalance: number;
|
ReconciledBalance: number;
|
||||||
|
Transactions: string[];
|
||||||
LastReconciled: NullDate;
|
LastReconciled: NullDate;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -31,6 +31,15 @@ interface NullDate {
|
|||||||
Time: Date;
|
Time: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Category {
|
||||||
|
ID: string;
|
||||||
|
Group: string;
|
||||||
|
Name: string;
|
||||||
|
AvailableLastMonth: number;
|
||||||
|
Assigned: number;
|
||||||
|
Activity: number;
|
||||||
|
}
|
||||||
|
|
||||||
interface BudgetedAmounts {
|
interface BudgetedAmounts {
|
||||||
Assigned: number,
|
Assigned: number,
|
||||||
Deassigned: number,
|
Deassigned: number,
|
||||||
@ -45,6 +54,7 @@ export const useAccountStore = defineStore("budget/account", {
|
|||||||
Months: new Map<number, Map<number, Map<string, Category>>>(),
|
Months: new Map<number, Map<number, Map<string, Category>>>(),
|
||||||
Available: 0,
|
Available: 0,
|
||||||
OverspentLastMonth: 0,
|
OverspentLastMonth: 0,
|
||||||
|
Categories: new Map<string, Category>(),
|
||||||
Assignments: [],
|
Assignments: [],
|
||||||
}),
|
}),
|
||||||
getters: {
|
getters: {
|
||||||
@ -120,10 +130,15 @@ export const useAccountStore = defineStore("budget/account", {
|
|||||||
Expand: prev.Name != "Hidden Categories",
|
Expand: prev.Name != "Hidden Categories",
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
categoryGroups[categoryGroups.length - 1].Available += this.GetCategoryAvailable(category);
|
categoryGroups[categoryGroups.length - 1].Available +=
|
||||||
categoryGroups[categoryGroups.length - 1].AvailableLastMonth += category.AvailableLastMonth;
|
this.GetCategoryAvailable(category);
|
||||||
categoryGroups[categoryGroups.length - 1].Activity += category.Activity;
|
categoryGroups[
|
||||||
categoryGroups[categoryGroups.length - 1].Assigned += category.Assigned;
|
categoryGroups.length - 1
|
||||||
|
].AvailableLastMonth += category.AvailableLastMonth;
|
||||||
|
categoryGroups[categoryGroups.length - 1].Activity +=
|
||||||
|
category.Activity;
|
||||||
|
categoryGroups[categoryGroups.length - 1].Assigned +=
|
||||||
|
category.Assigned;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -226,9 +241,6 @@ export const useAccountStore = defineStore("budget/account", {
|
|||||||
available: number,
|
available: number,
|
||||||
overspentLastMonth: number,
|
overspentLastMonth: number,
|
||||||
): void {
|
): void {
|
||||||
useCategoryStore().AddCategory(...categories)
|
|
||||||
useCategoryGroupStore().AddCategoryGroup(...categories.map(x => ({ID: x.CategoryGroupID, Name: x.Group})));
|
|
||||||
|
|
||||||
this.$patch((state) => {
|
this.$patch((state) => {
|
||||||
const yearMap =
|
const yearMap =
|
||||||
state.Months.get(year) ||
|
state.Months.get(year) ||
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
import { defineStore } from "pinia";
|
import { defineStore } from "pinia";
|
||||||
import { GET, POST, FORM } from "../api";
|
import { GET, POST } from "../api";
|
||||||
import { useAccountStore } from "./budget-account";
|
import { useAccountStore } from "./budget-account";
|
||||||
import { useCategoryStore } from "./category";
|
|
||||||
import { Budget, useSessionStore } from "./session";
|
import { Budget, useSessionStore } from "./session";
|
||||||
|
|
||||||
interface State {
|
interface State {
|
||||||
@ -25,9 +24,9 @@ export const useBudgetsStore = defineStore("budget", {
|
|||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
ImportYNAB(formData: FormData) {
|
ImportYNAB(formData: FormData) {
|
||||||
return FORM(
|
return POST(
|
||||||
"/budget/" + this.CurrentBudgetID + "/import/ynab",
|
"/budget/" + this.CurrentBudgetID + "/import/ynab",
|
||||||
formData,
|
formData
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
async NewBudget(budgetName: string): Promise<void> {
|
async NewBudget(budgetName: string): Promise<void> {
|
||||||
@ -41,13 +40,9 @@ export const useBudgetsStore = defineStore("budget", {
|
|||||||
sessionStore.Budgets.set(response.ID, response);
|
sessionStore.Budgets.set(response.ID, response);
|
||||||
},
|
},
|
||||||
async SetCurrentBudget(budgetid: string): Promise<void> {
|
async SetCurrentBudget(budgetid: string): Promise<void> {
|
||||||
if(this.CurrentBudgetID == budgetid)
|
|
||||||
return;
|
|
||||||
|
|
||||||
this.CurrentBudgetID = budgetid;
|
this.CurrentBudgetID = budgetid;
|
||||||
|
|
||||||
if (budgetid == null)
|
if (budgetid == null) return;
|
||||||
return;
|
|
||||||
|
|
||||||
await this.FetchBudget(budgetid);
|
await this.FetchBudget(budgetid);
|
||||||
},
|
},
|
||||||
@ -59,16 +54,16 @@ export const useBudgetsStore = defineStore("budget", {
|
|||||||
MergeBudgetingData(response: any) {
|
MergeBudgetingData(response: any) {
|
||||||
const accounts = useAccountStore();
|
const accounts = useAccountStore();
|
||||||
for (const account of response.Accounts || []) {
|
for (const account of response.Accounts || []) {
|
||||||
|
const existingAccount = accounts.Accounts.get(account.ID);
|
||||||
|
account.Transactions = existingAccount?.Transactions ?? [];
|
||||||
if (account.LastReconciled.Valid)
|
if (account.LastReconciled.Valid)
|
||||||
account.LastReconciled.Time = new Date(
|
account.LastReconciled.Time = new Date(
|
||||||
account.LastReconciled.Time
|
account.LastReconciled.Time
|
||||||
);
|
);
|
||||||
accounts.Accounts.set(account.ID, account);
|
accounts.Accounts.set(account.ID, account);
|
||||||
}
|
}
|
||||||
|
|
||||||
const categories = useCategoryStore();
|
|
||||||
for (const category of response.Categories || []) {
|
for (const category of response.Categories || []) {
|
||||||
categories.Categories.set(category.ID, category);
|
accounts.Categories.set(category.ID, category);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
@ -1,40 +0,0 @@
|
|||||||
import { defineStore } from "pinia";
|
|
||||||
import { POST } from "../api";
|
|
||||||
import { useBudgetsStore } from "./budget";
|
|
||||||
|
|
||||||
interface State {
|
|
||||||
CategoryGroups: Map<string, CategoryGroup>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CategoryGroup {
|
|
||||||
ID: string;
|
|
||||||
Name: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useCategoryGroupStore = defineStore("category-group", {
|
|
||||||
state: (): State => ({
|
|
||||||
CategoryGroups: new Map<string, CategoryGroup>(),
|
|
||||||
}),
|
|
||||||
getters: {
|
|
||||||
},
|
|
||||||
actions: {
|
|
||||||
async CreateCategoryGroup(
|
|
||||||
group: string,
|
|
||||||
) {
|
|
||||||
const result = await POST(
|
|
||||||
"/category-group/new",
|
|
||||||
JSON.stringify({
|
|
||||||
budgetId: useBudgetsStore().CurrentBudgetID,
|
|
||||||
group: group,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
const response = await result.json();
|
|
||||||
this.AddCategoryGroup(response);
|
|
||||||
},
|
|
||||||
async AddCategoryGroup(...categoryGroups : CategoryGroup[]){
|
|
||||||
for (const categoryGroup of categoryGroups) {
|
|
||||||
this.CategoryGroups.set(categoryGroup.ID, categoryGroup);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
@ -1,59 +0,0 @@
|
|||||||
import { defineStore } from "pinia";
|
|
||||||
import { POST } from "../api";
|
|
||||||
import { useBudgetsStore } from "./budget";
|
|
||||||
import { CategoryGroup } from "./category-group";
|
|
||||||
|
|
||||||
interface State {
|
|
||||||
Categories: Map<string, Category>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Category {
|
|
||||||
ID: string;
|
|
||||||
CategoryGroupID: string;
|
|
||||||
Group: string;
|
|
||||||
Name: string;
|
|
||||||
AvailableLastMonth: number;
|
|
||||||
Assigned: number;
|
|
||||||
Activity: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useCategoryStore = defineStore("category", {
|
|
||||||
state: (): State => ({
|
|
||||||
Categories: new Map<string, Category>(),
|
|
||||||
}),
|
|
||||||
getters: {
|
|
||||||
GetCategoriesForGroup(state) {
|
|
||||||
return (group : CategoryGroup) : Category[] => {
|
|
||||||
return [...state.Categories.values()].filter(x => x.CategoryGroupID == group.ID);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
actions: {
|
|
||||||
async CreateCategory(
|
|
||||||
group: string,
|
|
||||||
name: string,
|
|
||||||
) {
|
|
||||||
const result = await POST(
|
|
||||||
"/category/new",
|
|
||||||
JSON.stringify({
|
|
||||||
budgetId: useBudgetsStore().CurrentBudgetID,
|
|
||||||
name: name,
|
|
||||||
group: group,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
const response = await result.json();
|
|
||||||
this.AddCategory(response);
|
|
||||||
},
|
|
||||||
async AddCategory(...categories : Category[]){
|
|
||||||
for (const category of categories) {
|
|
||||||
this.Categories.set(category.ID, category);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async SetAssigned(category : Category, year : number, month : number, assigned : number){
|
|
||||||
this.Categories.get(category.ID)!.Assigned = assigned;
|
|
||||||
const currentBudgetID = useBudgetsStore().CurrentBudgetID;
|
|
||||||
await POST("/budget/"+currentBudgetID+"/category/" + category.ID + "/" + year + "/" + (month+1),
|
|
||||||
JSON.stringify({Assigned: assigned}));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
@ -17,12 +17,12 @@ export interface Transaction {
|
|||||||
TransferAccount: string;
|
TransferAccount: string;
|
||||||
CategoryGroup: string;
|
CategoryGroup: string;
|
||||||
Category: string;
|
Category: string;
|
||||||
CategoryID: string | null;
|
CategoryID: string | undefined;
|
||||||
Memo: string;
|
Memo: string;
|
||||||
Status: string;
|
Status: string;
|
||||||
GroupID: string;
|
GroupID: string;
|
||||||
Payee: string;
|
Payee: string;
|
||||||
PayeeID: string | null;
|
PayeeID: string | undefined;
|
||||||
Amount: number;
|
Amount: number;
|
||||||
Reconciled: boolean;
|
Reconciled: boolean;
|
||||||
Account: string;
|
Account: string;
|
||||||
@ -47,12 +47,10 @@ export const useTransactionsStore = defineStore("budget/transactions", {
|
|||||||
}
|
}
|
||||||
return reconciledBalance;
|
return reconciledBalance;
|
||||||
},
|
},
|
||||||
TransactionsByDate(state) : Record<string, Transaction[]>|undefined{
|
TransactionsByDate(state) : Record<string, Transaction[]> {
|
||||||
const accountsStore = useAccountStore();
|
const accountsStore = useAccountStore();
|
||||||
const account = accountsStore.CurrentAccount;
|
const accountID = accountsStore.CurrentAccountID;
|
||||||
if(account === undefined)
|
const allTransactions = [...this.Transactions.values()].filter(x => x.AccountID == accountID);
|
||||||
return undefined;
|
|
||||||
const allTransactions = [...this.Transactions.values()].filter(x => x.AccountID == account.ID);
|
|
||||||
return groupBy(allTransactions, x => formatDate(x.Date));
|
return groupBy(allTransactions, x => formatDate(x.Date));
|
||||||
},
|
},
|
||||||
TransactionsList(state) : Transaction[] {
|
TransactionsList(state) : Transaction[] {
|
||||||
@ -95,11 +93,11 @@ export const useTransactionsStore = defineStore("budget/transactions", {
|
|||||||
async GetFilteredTransactions(accountID : string | null, categoryID : string | null, payeeID : string | null, fromDate : string, toDate : string) {
|
async GetFilteredTransactions(accountID : string | null, categoryID : string | null, payeeID : string | null, fromDate : string, toDate : string) {
|
||||||
const budgetStore = useBudgetsStore();
|
const budgetStore = useBudgetsStore();
|
||||||
const payload = JSON.stringify({
|
const payload = JSON.stringify({
|
||||||
categoryId: categoryID,
|
category_id: categoryID,
|
||||||
payeeId: payeeID,
|
payee_id: payeeID,
|
||||||
accountId: accountID,
|
account_id: accountID,
|
||||||
fromDate: fromDate,
|
from_date: fromDate,
|
||||||
toDate: toDate,
|
to_date: toDate,
|
||||||
});
|
});
|
||||||
const result = await POST("/budget/" + budgetStore.CurrentBudgetID + "/filtered-transactions", payload);
|
const result = await POST("/budget/" + budgetStore.CurrentBudgetID + "/filtered-transactions", payload);
|
||||||
const response = await result.json();
|
const response = await result.json();
|
||||||
@ -130,15 +128,18 @@ export const useTransactionsStore = defineStore("budget/transactions", {
|
|||||||
const recTrans = response.ReconciliationTransaction;
|
const recTrans = response.ReconciliationTransaction;
|
||||||
if (recTrans) {
|
if (recTrans) {
|
||||||
this.AddTransactions([recTrans]);
|
this.AddTransactions([recTrans]);
|
||||||
|
account.Transactions.unshift(recTrans.ID);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
logout() {
|
logout() {
|
||||||
this.$reset();
|
this.$reset();
|
||||||
},
|
},
|
||||||
async saveTransaction(payload: string) {
|
async saveTransaction(payload: string) {
|
||||||
|
const accountsStore = useAccountStore();
|
||||||
const result = await POST("/transaction/new", payload);
|
const result = await POST("/transaction/new", payload);
|
||||||
const response = (await result.json()) as Transaction;
|
const response = (await result.json()) as Transaction;
|
||||||
this.AddTransactions([response]);
|
this.AddTransactions([response]);
|
||||||
|
accountsStore.CurrentAccount?.Transactions.unshift(response.ID);
|
||||||
},
|
},
|
||||||
async editTransaction(transactionid: string, payload: string) {
|
async editTransaction(transactionid: string, payload: string) {
|
||||||
const result = await POST("/transaction/" + transactionid, payload);
|
const result = await POST("/transaction/" + transactionid, payload);
|
||||||
|
7272
web/yarn.lock
7272
web/yarn.lock
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user