diff --git a/http/budgeting.go b/http/budgeting.go index f12f6d6..9d87085 100644 --- a/http/budgeting.go +++ b/http/budgeting.go @@ -2,6 +2,7 @@ package http import ( "context" + "fmt" "net/http" "strconv" "time" @@ -75,3 +76,28 @@ func (h *Handler) budgeting(c *gin.Context) { c.HTML(http.StatusOK, "budgeting.html", d) } + +func (h *Handler) clearBudget(c *gin.Context) { + budgetID := c.Param("budgetid") + budgetUUID, err := uuid.Parse(budgetID) + if err != nil { + c.Redirect(http.StatusTemporaryRedirect, "/login") + return + } + + rows, err := h.Service.DB.DeleteAllAssignments(c.Request.Context(), budgetUUID) + if err != nil { + c.AbortWithError(http.StatusInternalServerError, err) + return + } + + fmt.Printf("Deleted %d assignments\n", rows) + + rows, err = h.Service.DB.DeleteAllTransactions(c.Request.Context(), budgetUUID) + if err != nil { + c.AbortWithError(http.StatusInternalServerError, err) + return + } + + fmt.Printf("Deleted %d transactions\n", rows) +} diff --git a/http/http.go b/http/http.go index 6c2ebbc..20b88db 100644 --- a/http/http.go +++ b/http/http.go @@ -1,6 +1,7 @@ package http import ( + "fmt" "io/fs" "net/http" "time" @@ -57,6 +58,7 @@ func (h *Handler) Serve() { withBudget.Use(h.verifyLoginWithRedirect) withBudget.Use(h.getImportantData) withBudget.GET("/budget/:budgetid", h.budgeting) + withBudget.GET("/budget/:budgetid/clear", h.clearBudget) withBudget.GET("/budget/:budgetid/:year/:month", h.budgeting) withBudget.GET("/budget/:budgetid/all-accounts", h.budget) withBudget.GET("/budget/:budgetid/accounts", h.accounts) @@ -86,21 +88,9 @@ func (h *Handler) Serve() { } func (h *Handler) importYNAB(c *gin.Context) { - transactionsFile, err := c.FormFile("transactions") - if err != nil { - c.AbortWithError(http.StatusInternalServerError, err) - return - } - - transactions, err := transactionsFile.Open() - if err != nil { - c.AbortWithError(http.StatusInternalServerError, err) - return - } - budgetID, succ := c.GetPostForm("budget_id") if !succ { - c.AbortWithError(http.StatusBadRequest, err) + c.AbortWithError(http.StatusBadRequest, fmt.Errorf("no budget_id specified")) return } @@ -116,7 +106,37 @@ func (h *Handler) importYNAB(c *gin.Context) { return } - err = ynab.Import(transactions) + transactionsFile, err := c.FormFile("transactions") + if err != nil { + c.AbortWithError(http.StatusInternalServerError, err) + return + } + + transactions, err := transactionsFile.Open() + if err != nil { + c.AbortWithError(http.StatusInternalServerError, err) + return + } + + err = ynab.ImportTransactions(transactions) + if err != nil { + c.AbortWithError(http.StatusInternalServerError, err) + return + } + + assignmentsFile, err := c.FormFile("assignments") + if err != nil { + c.AbortWithError(http.StatusInternalServerError, err) + return + } + + assignments, err := assignmentsFile.Open() + if err != nil { + c.AbortWithError(http.StatusInternalServerError, err) + return + } + + err = ynab.ImportAssignments(assignments) if err != nil { c.AbortWithError(http.StatusInternalServerError, err) return diff --git a/http/ynab-import.go b/http/ynab-import.go index 410b248..6eb48a0 100644 --- a/http/ynab-import.go +++ b/http/ynab-import.go @@ -56,7 +56,61 @@ func NewYNABImport(q *postgres.Queries, budgetID uuid.UUID) (*YNABImport, error) } -func (ynab *YNABImport) Import(r io.Reader) error { +func (ynab *YNABImport) ImportAssignments(r io.Reader) error { + csv := csv.NewReader(r) + csv.Comma = '\t' + csv.LazyQuotes = true + + csvData, err := csv.ReadAll() + if err != nil { + return fmt.Errorf("could not read from tsv: %w", err) + } + + count := 0 + for _, record := range csvData[1:] { + //"Month" "Category Group/Category" "Category Group" "Category" "Budgeted" "Activity" "Available" + //"Apr 2019" "Income: Next Month" "Income" "Next Month" 0,00€ 0,00€ 0,00€ + dateString := record[0] + date, err := time.Parse("Jan 2006", dateString) + if err != nil { + return fmt.Errorf("could not parse date %s: %w", dateString, err) + } + + categoryGroup, categoryName := record[2], record[3] //also in 1 joined by : + category, err := ynab.GetCategory(categoryGroup, categoryName) + if err != nil { + return fmt.Errorf("could not get category %s/%s: %w", categoryGroup, categoryName, err) + } + + amountString := record[4] + amount, err := GetAmount(amountString, "0,00€") + if err != nil { + return fmt.Errorf("could not parse amount %s: %w", amountString, err) + } + + if amount.Int.Int64() == 0 { + continue + } + + assignment := postgres.CreateAssignmentParams{ + Date: date, + CategoryID: category.UUID, + Amount: amount, + } + _, err = ynab.queries.CreateAssignment(ynab.Context, assignment) + if err != nil { + return fmt.Errorf("could not save assignment %v: %w", assignment, err) + } + + count++ + } + + fmt.Printf("Imported %d assignments\n", count) + + return nil +} + +func (ynab *YNABImport) ImportTransactions(r io.Reader) error { csv := csv.NewReader(r) csv.Comma = '\t' csv.LazyQuotes = true @@ -88,10 +142,10 @@ func (ynab *YNABImport) Import(r io.Reader) error { return fmt.Errorf("could not get payee %s: %w", payeeName, err) } - categoryGroup, categoryName := record[5], record[6] //also in 5 + 6 split by group/category + categoryGroup, categoryName := record[5], record[6] //also in 4 joined by : category, err := ynab.GetCategory(categoryGroup, categoryName) if err != nil { - return fmt.Errorf("could not get category %s: %w", payeeName, err) + return fmt.Errorf("could not get category %s/%s: %w", categoryGroup, categoryName, err) } memo := record[7] diff --git a/postgres/assignments.sql.go b/postgres/assignments.sql.go new file mode 100644 index 0000000..8302a22 --- /dev/null +++ b/postgres/assignments.sql.go @@ -0,0 +1,54 @@ +// Code generated by sqlc. DO NOT EDIT. +// source: assignments.sql + +package postgres + +import ( + "context" + "time" + + "github.com/google/uuid" +) + +const createAssignment = `-- name: CreateAssignment :one +INSERT INTO assignments ( + date, amount, category_id +) VALUES ( + $1, $2, $3 +) +RETURNING id, category_id, date, memo, amount +` + +type CreateAssignmentParams struct { + Date time.Time + Amount Numeric + CategoryID uuid.UUID +} + +func (q *Queries) CreateAssignment(ctx context.Context, arg CreateAssignmentParams) (Assignment, error) { + 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, + &i.Amount, + ) + return i, err +} + +const deleteAllAssignments = `-- name: DeleteAllAssignments :execrows +DELETE FROM assignments +USING categories +INNER JOIN category_groups ON categories.category_group_id = category_groups.id +WHERE categories.id = assignments.category_id AND category_groups.budget_id = $1 +` + +func (q *Queries) DeleteAllAssignments(ctx context.Context, budgetID uuid.UUID) (int64, error) { + result, err := q.db.ExecContext(ctx, deleteAllAssignments, budgetID) + if err != nil { + return 0, err + } + return result.RowsAffected() +} diff --git a/postgres/categories.sql.go b/postgres/categories.sql.go index 21104f0..ce590e6 100644 --- a/postgres/categories.sql.go +++ b/postgres/categories.sql.go @@ -91,6 +91,14 @@ func (q *Queries) GetCategories(ctx context.Context, budgetID uuid.UUID) ([]GetC const getCategoriesWithBalance = `-- name: GetCategoriesWithBalance :many SELECT categories.id, categories.name, category_groups.name as group, + COALESCE( + ( + SELECT SUM(a_hist.amount) + FROM assignments a_hist + WHERE categories.id = a_hist.category_id + AND a_hist.date < $1 + ) + , 0)::decimal(12,2) as balance_assignments, COALESCE( ( SELECT SUM(t_hist.amount) @@ -121,11 +129,12 @@ type GetCategoriesWithBalanceParams struct { } type GetCategoriesWithBalanceRow struct { - ID uuid.UUID - Name string - Group string - Balance Numeric - Activity Numeric + ID uuid.UUID + Name string + Group string + BalanceAssignments Numeric + Balance Numeric + Activity Numeric } func (q *Queries) GetCategoriesWithBalance(ctx context.Context, arg GetCategoriesWithBalanceParams) ([]GetCategoriesWithBalanceRow, error) { @@ -141,6 +150,7 @@ func (q *Queries) GetCategoriesWithBalance(ctx context.Context, arg GetCategorie &i.ID, &i.Name, &i.Group, + &i.BalanceAssignments, &i.Balance, &i.Activity, ); err != nil { diff --git a/postgres/models.go b/postgres/models.go index 007df77..5e9f22d 100644 --- a/postgres/models.go +++ b/postgres/models.go @@ -15,6 +15,14 @@ type Account struct { Name string } +type Assignment struct { + ID uuid.UUID + CategoryID uuid.UUID + Date time.Time + Memo sql.NullString + Amount Numeric +} + type Budget struct { ID uuid.UUID Name string diff --git a/postgres/queries/assignments.sql b/postgres/queries/assignments.sql new file mode 100644 index 0000000..a0c521e --- /dev/null +++ b/postgres/queries/assignments.sql @@ -0,0 +1,13 @@ +-- name: CreateAssignment :one +INSERT INTO assignments ( + date, amount, category_id +) VALUES ( + $1, $2, $3 +) +RETURNING *; + +-- name: DeleteAllAssignments :execrows +DELETE FROM assignments +USING categories +INNER JOIN category_groups ON categories.category_group_id = category_groups.id +WHERE categories.id = assignments.category_id AND category_groups.budget_id = @budget_id; diff --git a/postgres/queries/categories.sql b/postgres/queries/categories.sql index 22460fb..7a8f9ae 100644 --- a/postgres/queries/categories.sql +++ b/postgres/queries/categories.sql @@ -21,6 +21,14 @@ WHERE category_groups.budget_id = $1; -- name: GetCategoriesWithBalance :many SELECT categories.id, categories.name, category_groups.name as group, + COALESCE( + ( + SELECT SUM(a_hist.amount) + FROM assignments a_hist + WHERE categories.id = a_hist.category_id + AND a_hist.date < @from_date + ) + , 0)::decimal(12,2) as balance_assignments, COALESCE( ( SELECT SUM(t_hist.amount) diff --git a/postgres/queries/transactions.sql b/postgres/queries/transactions.sql index b38e4d6..3dd6f42 100644 --- a/postgres/queries/transactions.sql +++ b/postgres/queries/transactions.sql @@ -26,4 +26,10 @@ LEFT JOIN categories ON categories.id = transactions.category_id LEFT JOIN category_groups ON category_groups.id = categories.category_group_id WHERE transactions.account_id = $1 ORDER BY transactions.date DESC -LIMIT 200; \ No newline at end of file +LIMIT 200; + +-- name: DeleteAllTransactions :execrows +DELETE FROM transactions +USING accounts +WHERE accounts.budget_id = @budget_id +AND accounts.id = transactions.account_id; \ No newline at end of file diff --git a/postgres/schema/202112071547_assignments.sql b/postgres/schema/202112071547_assignments.sql new file mode 100644 index 0000000..bb6ad24 --- /dev/null +++ b/postgres/schema/202112071547_assignments.sql @@ -0,0 +1,11 @@ +-- +goose Up +CREATE TABLE assignments ( + id uuid DEFAULT uuid_generate_v4() PRIMARY KEY, + category_id uuid NOT NULL REFERENCES categories (id), + date date NOT NULL, + memo text, + amount decimal(12,2) NOT NULL +); + +-- +goose Down +DROP TABLE assignments; \ No newline at end of file diff --git a/postgres/transactions.sql.go b/postgres/transactions.sql.go index 832d28d..6de4317 100644 --- a/postgres/transactions.sql.go +++ b/postgres/transactions.sql.go @@ -48,6 +48,21 @@ func (q *Queries) CreateTransaction(ctx context.Context, arg CreateTransactionPa return i, err } +const deleteAllTransactions = `-- name: DeleteAllTransactions :execrows +DELETE FROM transactions +USING accounts +WHERE accounts.budget_id = $1 +AND accounts.id = transactions.account_id +` + +func (q *Queries) DeleteAllTransactions(ctx context.Context, budgetID uuid.UUID) (int64, error) { + result, err := q.db.ExecContext(ctx, deleteAllTransactions, budgetID) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + const getTransactionsForAccount = `-- name: GetTransactionsForAccount :many SELECT transactions.id, transactions.date, transactions.memo, transactions.amount, accounts.name as account, COALESCE(payees.name, '') as payee, COALESCE(category_groups.name, '') as category_group, COALESCE(categories.name, '') as category diff --git a/web/budgeting.html b/web/budgeting.html index a8ac940..ccdb2a1 100644 --- a/web/budgeting.html +++ b/web/budgeting.html @@ -29,6 +29,7 @@ {{template "amount-cell" .Balance}} + {{template "amount-cell" .BalanceAssignments}} {{template "amount-cell" .Activity}} {{end}}