diff --git a/postgres/assignments.sql.go b/postgres/assignments.sql.go index 8602e7a..52bd45d 100644 --- a/postgres/assignments.sql.go +++ b/postgres/assignments.sql.go @@ -17,7 +17,7 @@ INSERT INTO assignments ( ) VALUES ( $1, $2, $3 ) -RETURNING id, category_id, date, memo, amount +RETURNING category_id, date, memo, amount ` type CreateAssignmentParams struct { @@ -30,7 +30,6 @@ func (q *Queries) CreateAssignment(ctx context.Context, arg CreateAssignmentPara row := q.db.QueryRowContext(ctx, createAssignment, arg.Date, arg.Amount, arg.CategoryID) var i Assignment err := row.Scan( - &i.ID, &i.CategoryID, &i.Date, &i.Memo, @@ -130,3 +129,22 @@ func (q *Queries) GetAssignmentsByMonthAndCategory(ctx context.Context, budgetID } return items, nil } + +const updateAssignment = `-- name: UpdateAssignment :exec +INSERT INTO assignments (category_id, date, amount) +VALUES($1, $2, $3) +ON CONFLICT (category_id, date) +DO + UPDATE SET amount = $3 +` + +type UpdateAssignmentParams struct { + CategoryID uuid.UUID + Date time.Time + Amount numeric.Numeric +} + +func (q *Queries) UpdateAssignment(ctx context.Context, arg UpdateAssignmentParams) error { + _, err := q.db.ExecContext(ctx, updateAssignment, arg.CategoryID, arg.Date, arg.Amount) + return err +} diff --git a/postgres/models.go b/postgres/models.go index 4e33e88..7ff33e8 100644 --- a/postgres/models.go +++ b/postgres/models.go @@ -40,7 +40,6 @@ type Account struct { } type Assignment struct { - ID uuid.UUID CategoryID uuid.UUID Date time.Time Memo sql.NullString diff --git a/postgres/numeric/numeric.go b/postgres/numeric/numeric.go index 8d0cc5e..4988f9e 100644 --- a/postgres/numeric/numeric.go +++ b/postgres/numeric/numeric.go @@ -53,6 +53,13 @@ func (n Numeric) IsZero() bool { return float == 0 } +func (n *Numeric) MatchExpI(exp int32) { + diffExp := n.Exp - exp + factor := big.NewInt(0).Exp(big.NewInt(10), big.NewInt(int64(diffExp)), nil) //nolint:gomnd + n.Exp = exp + n.Int = big.NewInt(0).Mul(n.Int, factor) +} + func (n Numeric) MatchExp(exp int32) Numeric { diffExp := n.Exp - exp factor := big.NewInt(0).Exp(big.NewInt(10), big.NewInt(int64(diffExp)), nil) //nolint:gomnd @@ -64,6 +71,22 @@ func (n Numeric) MatchExp(exp int32) Numeric { }} } +func (n *Numeric) SubI(other Numeric) *Numeric { + right := other + if n.Exp < other.Exp { + right = other.MatchExp(n.Exp) + } else if n.Exp > other.Exp { + n.MatchExpI(other.Exp) + } + + if n.Exp == right.Exp { + n.Int = big.NewInt(0).Sub(n.Int, right.Int) + return n + } + + panic("Cannot subtract with different exponents") +} + func (n Numeric) Sub(other Numeric) Numeric { left := n right := other @@ -106,6 +129,22 @@ func (n Numeric) Add(other Numeric) Numeric { panic("Cannot add with different exponents") } +func (n *Numeric) AddI(other Numeric) *Numeric { + right := other + if n.Exp < other.Exp { + right = other.MatchExp(n.Exp) + } else if n.Exp > other.Exp { + n.MatchExpI(other.Exp) + } + + if n.Exp == right.Exp { + n.Int = big.NewInt(0).Add(n.Int, right.Int) + return n + } + + panic("Cannot add with different exponents") +} + func (n Numeric) String() string { if n.Int == nil || n.Int.Int64() == 0 { return "0" diff --git a/postgres/queries/assignments.sql b/postgres/queries/assignments.sql index 56975ef..cb0687d 100644 --- a/postgres/queries/assignments.sql +++ b/postgres/queries/assignments.sql @@ -22,4 +22,11 @@ SELECT assignments.date, categories.name as category, category_groups.name as gr FROM assignments INNER JOIN categories ON categories.id = assignments.category_id INNER JOIN category_groups ON categories.category_group_id = category_groups.id -WHERE category_groups.budget_id = @budget_id; \ No newline at end of file +WHERE category_groups.budget_id = @budget_id; + +-- name: UpdateAssignment :exec +INSERT INTO assignments (category_id, date, amount) +VALUES($1, $2, $3) +ON CONFLICT (category_id, date) +DO + UPDATE SET amount = $3; \ No newline at end of file diff --git a/postgres/schema/0017_natural-key-assignments.sql b/postgres/schema/0017_natural-key-assignments.sql new file mode 100644 index 0000000..27c1096 --- /dev/null +++ b/postgres/schema/0017_natural-key-assignments.sql @@ -0,0 +1,6 @@ +-- +goose Up +ALTER TABLE assignments DROP id; +ALTER TABLE assignments ADD PRIMARY KEY (category_id, date); + +-- +goose Down +ALTER TABLE assignments ADD COLUMN id uuid DEFAULT uuid_generate_v4() PRIMARY KEY; \ No newline at end of file diff --git a/server/budgeting.go b/server/budgeting.go index a421dbc..9c65d85 100644 --- a/server/budgeting.go +++ b/server/budgeting.go @@ -3,7 +3,6 @@ package server import ( "fmt" "net/http" - "strconv" "time" "git.javil.eu/jacob1123/budgeteer/postgres" @@ -41,27 +40,6 @@ func NewCategoryWithBalance(category *postgres.GetCategoriesRow) CategoryWithBal } } -func getDate(c *gin.Context) (time.Time, error) { - var year, month int - yearString := c.Param("year") - monthString := c.Param("month") - if yearString == "" && monthString == "" { - return getFirstOfMonthTime(time.Now()), nil - } - - year, err := strconv.Atoi(yearString) - if err != nil { - return time.Time{}, fmt.Errorf("parse year: %w", err) - } - - month, err = strconv.Atoi(monthString) - if err != nil { - return time.Time{}, fmt.Errorf("parse month: %w", err) - } - - return getFirstOfMonth(year, month, time.Now().Location()), nil -} - func (h *Handler) budgetingForMonth(c *gin.Context) { budgetID := c.Param("budgetid") budgetUUID, err := uuid.Parse(budgetID) @@ -97,8 +75,16 @@ func (h *Handler) budgetingForMonth(c *gin.Context) { categoriesWithBalance, moneyUsed := h.calculateBalances( budget, firstOfNextMonth, firstOfMonth, categories, cumultativeBalances) + availableBalance := h.getAvailableBalance(budget, moneyUsed, cumultativeBalances, firstOfNextMonth) + for i := range categoriesWithBalance { + cat := &categoriesWithBalance[i] + if cat.ID != budget.IncomeCategoryID { + continue + } - availableBalance := h.getAvailableBalance(categories, budget, moneyUsed, cumultativeBalances, firstOfNextMonth) + cat.Available = availableBalance + cat.AvailableLastMonth = availableBalance + } data := struct { Categories []CategoryWithBalance @@ -107,27 +93,22 @@ func (h *Handler) budgetingForMonth(c *gin.Context) { c.JSON(http.StatusOK, data) } -func (*Handler) getAvailableBalance(categories []postgres.GetCategoriesRow, budget postgres.Budget, +func (*Handler) getAvailableBalance(budget postgres.Budget, moneyUsed numeric.Numeric, cumultativeBalances []postgres.GetCumultativeBalancesRow, firstOfNextMonth time.Time) numeric.Numeric { - availableBalance := numeric.Zero() - for _, cat := range categories { - if cat.ID != budget.IncomeCategoryID { + availableBalance := moneyUsed + + for _, bal := range cumultativeBalances { + if bal.CategoryID != budget.IncomeCategoryID { continue } - availableBalance = moneyUsed - for _, bal := range cumultativeBalances { - if bal.CategoryID != cat.ID { - continue - } - - if !bal.Date.Before(firstOfNextMonth) { - continue - } - - availableBalance = availableBalance.Add(bal.Transactions) + if !bal.Date.Before(firstOfNextMonth) { + continue } + + availableBalance.AddI(bal.Transactions) + availableBalance.AddI(bal.Assignments) } return availableBalance } @@ -171,21 +152,18 @@ func (h *Handler) calculateBalances(budget postgres.Budget, cumultativeBalances []postgres.GetCumultativeBalancesRow) ([]CategoryWithBalance, numeric.Numeric) { categoriesWithBalance := []CategoryWithBalance{} - moneyUsed := numeric.Zero() + moneyUsed2 := numeric.Zero() + moneyUsed := &moneyUsed2 for i := range categories { cat := &categories[i] // do not show hidden categories categoryWithBalance := h.CalculateCategoryBalances(cat, cumultativeBalances, - firstOfNextMonth, &moneyUsed, firstOfMonth, budget) - - if cat.ID == budget.IncomeCategoryID { - continue - } + firstOfNextMonth, moneyUsed, firstOfMonth, budget) categoriesWithBalance = append(categoriesWithBalance, categoryWithBalance) } - return categoriesWithBalance, moneyUsed + return categoriesWithBalance, *moneyUsed } func (*Handler) CalculateCategoryBalances(cat *postgres.GetCategoriesRow, @@ -202,11 +180,11 @@ func (*Handler) CalculateCategoryBalances(cat *postgres.GetCategoriesRow, continue } - *moneyUsed = moneyUsed.Sub(bal.Assignments) - categoryWithBalance.Available = categoryWithBalance.Available.Add(bal.Assignments) - categoryWithBalance.Available = categoryWithBalance.Available.Add(bal.Transactions) + moneyUsed.SubI(bal.Assignments) + categoryWithBalance.Available.AddI(bal.Assignments) + categoryWithBalance.Available.AddI(bal.Transactions) if !categoryWithBalance.Available.IsPositive() && bal.Date.Before(firstOfMonth) { - *moneyUsed = moneyUsed.Add(categoryWithBalance.Available) + moneyUsed.AddI(categoryWithBalance.Available) categoryWithBalance.Available = numeric.Zero() } diff --git a/server/category.go b/server/category.go new file mode 100644 index 0000000..ab4676c --- /dev/null +++ b/server/category.go @@ -0,0 +1,55 @@ +package server + +import ( + "fmt" + "net/http" + + "git.javil.eu/jacob1123/budgeteer/postgres" + "git.javil.eu/jacob1123/budgeteer/postgres/numeric" + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +type SetCategoryAssignmentRequest struct { + Assigned string +} + +func (h *Handler) setCategoryAssignment(c *gin.Context) { + categoryID := c.Param("categoryid") + categoryUUID, err := uuid.Parse(categoryID) + if err != nil { + c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{"categoryid missing from URL"}) + return + } + + var request SetCategoryAssignmentRequest + err = c.BindJSON(&request) + if err != nil { + c.AbortWithError(http.StatusBadRequest, fmt.Errorf("invalid payload: %w", err)) + return + } + + date, err := getDate(c) + if err != nil { + c.AbortWithError(http.StatusBadRequest, fmt.Errorf("date invalid: %w", err)) + return + } + + var amount numeric.Numeric + err = amount.Set(request.Assigned) + if err != nil { + c.AbortWithError(http.StatusBadRequest, fmt.Errorf("parse amount: %w", err)) + return + } + + updateArgs := postgres.UpdateAssignmentParams{ + CategoryID: categoryUUID, + Date: date, + Amount: amount, + } + err = h.Service.UpdateAssignment(c.Request.Context(), updateArgs) + if err != nil { + c.AbortWithError(http.StatusInternalServerError, fmt.Errorf("update assignment: %w", err)) + return + } +} diff --git a/server/http.go b/server/http.go index e3fcfc9..e958687 100644 --- a/server/http.go +++ b/server/http.go @@ -64,6 +64,7 @@ func (h *Handler) LoadRoutes(router *gin.Engine) { budget.POST("/new", h.newBudget) budget.GET("/:budgetid", h.budgeting) budget.GET("/:budgetid/:year/:month", h.budgetingForMonth) + budget.POST("/:budgetid/category/:categoryid/:year/:month", h.setCategoryAssignment) budget.GET("/:budgetid/autocomplete/payees", h.autocompletePayee) budget.GET("/:budgetid/autocomplete/categories", h.autocompleteCategories) budget.DELETE("/:budgetid", h.deleteBudget) diff --git a/server/util.go b/server/util.go new file mode 100644 index 0000000..ffb63a4 --- /dev/null +++ b/server/util.go @@ -0,0 +1,30 @@ +package server + +import ( + "fmt" + "strconv" + "time" + + "github.com/gin-gonic/gin" +) + +func getDate(c *gin.Context) (time.Time, error) { + var year, month int + yearString := c.Param("year") + monthString := c.Param("month") + if yearString == "" && monthString == "" { + return getFirstOfMonthTime(time.Now()), nil + } + + year, err := strconv.Atoi(yearString) + if err != nil { + return time.Time{}, fmt.Errorf("parse year: %w", err) + } + + month, err = strconv.Atoi(monthString) + if err != nil { + return time.Time{}, fmt.Errorf("parse month: %w", err) + } + + return getFirstOfMonth(year, month, time.Now().Location()), nil +} diff --git a/web/src/App.vue b/web/src/App.vue index 2345236..057c1f2 100644 --- a/web/src/App.vue +++ b/web/src/App.vue @@ -8,7 +8,6 @@ import { useSettingsStore } from "./stores/settings"; export default defineComponent({ computed: { ...mapState(useBudgetsStore, ["CurrentBudgetName"]), - ...mapState(useSettingsStore, ["Menu"]), ...mapState(useSessionStore, ["LoggedIn"]), }, methods: { @@ -27,15 +26,10 @@ export default defineComponent({ diff --git a/web/src/router/index.ts b/web/src/router/index.ts index bfa0501..03b9870 100644 --- a/web/src/router/index.ts +++ b/web/src/router/index.ts @@ -10,15 +10,16 @@ import BudgetSidebar from '../pages/BudgetSidebar.vue'; const routes = [ { path: "/", name: "Index", component: Index }, - { path: "/dashboard", name: "Dashboard", component: Dashboard }, - { path: "/login", name: "Login", component: Login }, - { path: "/register", name: "Register", component: Register }, + { path: "/dashboard", name: "Dashboard", component: Dashboard, meta: { requiresAuth: true } }, + { path: "/login", name: "Login", component: Login, meta: { hideForAuth: true } }, + { path: "/register", name: "Register", component: Register, meta: { hideForAuth: true } }, { path: "/budget/:budgetid/budgeting", name: "Budget", redirect: (to : RouteLocationNormalized) => - '/budget/' + to.params.budgetid + '/budgeting/' + new Date().getFullYear() + '/' + new Date().getMonth() + '/budget/' + to.params.budgetid + '/budgeting/' + new Date().getFullYear() + '/' + new Date().getMonth(), + meta: { requiresAuth: true } }, - { path: "/budget/:budgetid/budgeting/:year/:month", name: "Budget with date", components: { default: Budgeting, sidebar: BudgetSidebar }, props: true }, - { path: "/budget/:budgetid/Settings", name: "Budget Settings", components: { default: Settings, sidebar: BudgetSidebar }, props: true }, - { path: "/budget/:budgetid/account/:accountid", name: "Account", components: { default: Account, sidebar: BudgetSidebar }, props: true }, + { path: "/budget/:budgetid/budgeting/:year/:month", name: "Budget with date", components: { default: Budgeting, sidebar: BudgetSidebar }, props: true, meta: { requiresAuth: true } }, + { path: "/budget/:budgetid/Settings", name: "Budget Settings", components: { default: Settings, sidebar: BudgetSidebar }, props: true, meta: { requiresAuth: true } }, + { path: "/budget/:budgetid/account/:accountid", name: "Account", components: { default: Account, sidebar: BudgetSidebar }, props: true, meta: { requiresAuth: true } }, ] const router = createRouter({ diff --git a/web/src/stores/budget-account.ts b/web/src/stores/budget-account.ts index 4c6d71e..becea06 100644 --- a/web/src/stores/budget-account.ts +++ b/web/src/stores/budget-account.ts @@ -31,7 +31,6 @@ export interface Category { AvailableLastMonth: number Assigned: number Activity: number - Available: number } export const useAccountStore = defineStore("budget/account", { @@ -51,12 +50,37 @@ export const useAccountStore = defineStore("budget/account", { const monthMap = yearMap?.get(month); return [...monthMap?.values() || []]; }, + GetCategoryAvailable(state) { + return (category : Category) : number => { + return category.AvailableLastMonth + Number(category.Assigned) + category.Activity; + } + }, + GetIncomeCategoryID(state) { + const budget = useBudgetsStore(); + return budget.CurrentBudget?.IncomeCategoryID; + }, + GetIncomeAvailable(state) { + return (year: number, month: number) => { + const IncomeCategoryID = this.GetIncomeCategoryID; + if(IncomeCategoryID == null) + return 0; + + const categories = this.AllCategoriesForMonth(year, month); + const category = categories.filter(x => x.ID == IncomeCategoryID)[0]; + if (category == null) + return 0; + return category.AvailableLastMonth; + } + }, CategoryGroupsForMonth(state) { return (year: number, month: number) => { const categories = this.AllCategoriesForMonth(year, month); const categoryGroups = []; let prev = undefined; for (const category of categories) { + if (category.ID == this.GetIncomeCategoryID) + continue; + if (category.Group != prev) categoryGroups.push({ Name: category.Group, diff --git a/web/src/stores/session.ts b/web/src/stores/session.ts index cf61e2f..5b8d690 100644 --- a/web/src/stores/session.ts +++ b/web/src/stores/session.ts @@ -16,6 +16,7 @@ export interface Budget { ID: string Name: string AvailableBalance: number + IncomeCategoryID: string } export const useSessionStore = defineStore('session', { diff --git a/web/src/stores/transactions.ts b/web/src/stores/transactions.ts index 121cebb..e8c9825 100644 --- a/web/src/stores/transactions.ts +++ b/web/src/stores/transactions.ts @@ -39,7 +39,7 @@ export const useTransactionsStore = defineStore("budget/transactions", { return reconciledBalance; }, TransactionsList(state): Transaction[] { - const accountsStore = useAccountStore() + const accountsStore = useAccountStore() return accountsStore.CurrentAccount!.Transactions.map(x => { return this.Transactions.get(x)! });