From 27188e2e276e0a2eb70f57b9c92e117c31cbe9dc Mon Sep 17 00:00:00 2001 From: Jan Bader Date: Wed, 23 Feb 2022 19:32:49 +0000 Subject: [PATCH] Implement ynab-export --- postgres/assignments.sql.go | 43 +++++++++++++ postgres/queries/assignments.sql | 9 ++- postgres/ynab-export.go | 102 +++++++++++++++++++++++++++++++ server/http.go | 1 + server/ynab-import.go | 32 ++++++++++ 5 files changed, 186 insertions(+), 1 deletion(-) create mode 100644 postgres/ynab-export.go diff --git a/postgres/assignments.sql.go b/postgres/assignments.sql.go index 5398652..0689a18 100644 --- a/postgres/assignments.sql.go +++ b/postgres/assignments.sql.go @@ -53,6 +53,49 @@ func (q *Queries) DeleteAllAssignments(ctx context.Context, budgetID uuid.UUID) return result.RowsAffected() } +const getAllAssignments = `-- name: GetAllAssignments :many +SELECT assignments.date, categories.name as category, category_groups.name as group, assignments.amount +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 = $1 +` + +type GetAllAssignmentsRow struct { + Date time.Time + Category string + Group string + Amount Numeric +} + +func (q *Queries) GetAllAssignments(ctx context.Context, budgetID uuid.UUID) ([]GetAllAssignmentsRow, error) { + rows, err := q.db.QueryContext(ctx, getAllAssignments, budgetID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetAllAssignmentsRow + for rows.Next() { + var i GetAllAssignmentsRow + if err := rows.Scan( + &i.Date, + &i.Category, + &i.Group, + &i.Amount, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const getAssignmentsByMonthAndCategory = `-- name: GetAssignmentsByMonthAndCategory :many SELECT date, category_id, budget_id, amount FROM assignments_by_month diff --git a/postgres/queries/assignments.sql b/postgres/queries/assignments.sql index 1cffd54..56975ef 100644 --- a/postgres/queries/assignments.sql +++ b/postgres/queries/assignments.sql @@ -15,4 +15,11 @@ WHERE categories.id = assignments.category_id AND category_groups.budget_id = @b -- name: GetAssignmentsByMonthAndCategory :many SELECT * FROM assignments_by_month -WHERE assignments_by_month.budget_id = @budget_id; \ No newline at end of file +WHERE assignments_by_month.budget_id = @budget_id; + +-- name: GetAllAssignments :many +SELECT assignments.date, categories.name as category, category_groups.name as group, assignments.amount +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 diff --git a/postgres/ynab-export.go b/postgres/ynab-export.go new file mode 100644 index 0000000..0443929 --- /dev/null +++ b/postgres/ynab-export.go @@ -0,0 +1,102 @@ +package postgres + +import ( + "context" + "encoding/csv" + "fmt" + "io" + + "github.com/google/uuid" +) + +type YNABExport struct { + queries *Queries + budgetID uuid.UUID +} + +func NewYNABExport(context context.Context, queries *Queries, budgetID uuid.UUID) (*YNABExport, error) { + return &YNABExport{ + queries: queries, + budgetID: budgetID, + }, nil +} + +// ImportAssignments expects a TSV-file as exported by YNAB in the following format: +// "Month" "Category Group/Category" "Category Group" "Category" "Budgeted" "Activity" "Available" +// "Apr 2019" "Income: Next Month" "Income" "Next Month" 0,00€ 0,00€ 0,00€ +// +// Activity and Available are not imported, since they are determined by the transactions and historic assignments. +func (ynab *YNABExport) ExportAssignments(context context.Context, w io.Writer) error { + csv := csv.NewWriter(w) + csv.Comma = '\t' + + assignments, err := ynab.queries.GetAllAssignments(context, ynab.budgetID) + if err != nil { + return fmt.Errorf("load assignments: %w", err) + } + + count := 0 + for _, assignment := range assignments { + row := []string{ + assignment.Date.Format("Jan 2006"), + assignment.Group + ": " + assignment.Category, + assignment.Group, + assignment.Category, + assignment.Amount.String() + "€", + "0,00€", + "0,00€", + } + + csv.Write(row) + count++ + } + csv.Flush() + + fmt.Printf("Exported %d assignments\n", count) + + return nil +} + +// ImportTransactions expects a TSV-file as exported by YNAB in the following format: +// "Account" "Flag" "Date" "Payee" "Category Group/Category" "Category Group" "Category" "Memo" "Outflow" "Inflow" "Cleared" +// "Cash" "" "11.12.2021" "Transfer : Checking" "" "" "" "Brought to bank" 500,00€ 0,00€ "Cleared" +func (ynab *YNABExport) ExportTransactions(context context.Context, w io.Writer) error { + csv := csv.NewWriter(w) + csv.Comma = '\t' + + transactions, err := ynab.queries.GetTransactionsForBudget(context, ynab.budgetID) + if err != nil { + return fmt.Errorf("load transactions: %w", err) + } + + count := 0 + for _, transaction := range transactions { + row := []string{ + transaction.Account, + "", // Flag + transaction.Date.Format("02.01.2006"), + transaction.Payee, + transaction.CategoryGroup + " : " + transaction.Category, + transaction.CategoryGroup, + transaction.Category, + transaction.Memo, + } + + if transaction.Amount.IsPositive() { + row = append(row, transaction.Amount.String()+"€", "0,00€") + } else { + row = append(row, "0,00€", transaction.Amount.String()[1:]+"€") + } + + row = append(row, string(transaction.Status)) + + csv.Write(row) + count++ + } + + csv.Flush() + + fmt.Printf("Exported %d transactions\n", count) + + return nil +} diff --git a/server/http.go b/server/http.go index 569464b..dbc623f 100644 --- a/server/http.go +++ b/server/http.go @@ -67,6 +67,7 @@ func (h *Handler) LoadRoutes(router *gin.Engine) { authenticated.GET("/budget/:budgetid/autocomplete/categories", h.autocompleteCategories) authenticated.DELETE("/budget/:budgetid", h.deleteBudget) authenticated.POST("/budget/:budgetid/import/ynab", h.importYNAB) + authenticated.POST("/budget/:budgetid/export/ynab", h.exportYNAB) authenticated.POST("/budget/:budgetid/settings/clear", h.clearBudget) budget := authenticated.Group("/budget") diff --git a/server/ynab-import.go b/server/ynab-import.go index 349449e..11d0a4c 100644 --- a/server/ynab-import.go +++ b/server/ynab-import.go @@ -63,3 +63,35 @@ func (h *Handler) importYNAB(c *gin.Context) { return } } + +func (h *Handler) exportYNAB(c *gin.Context) { + budgetID, succ := c.Params.Get("budgetid") + if !succ { + c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{"no budget_id specified"}) + return + } + + budgetUUID, err := uuid.Parse(budgetID) + if !succ { + c.AbortWithError(http.StatusBadRequest, err) + return + } + + ynab, err := postgres.NewYNABExport(c.Request.Context(), h.Service.Queries, budgetUUID) + if err != nil { + c.AbortWithError(http.StatusInternalServerError, err) + return + } + + err = ynab.ExportTransactions(c.Request.Context(), c.Writer) + if err != nil { + c.AbortWithError(http.StatusInternalServerError, err) + return + } + + err = ynab.ExportAssignments(c.Request.Context(), c.Writer) + if err != nil { + c.AbortWithError(http.StatusInternalServerError, err) + return + } +}