Split routes into own files
This commit is contained in:
45
http/account.go
Normal file
45
http/account.go
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
package http
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"git.javil.eu/jacob1123/budgeteer/postgres"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AccountData struct {
|
||||||
|
AlwaysNeededData
|
||||||
|
Account *postgres.Account
|
||||||
|
Transactions []postgres.GetTransactionsForAccountRow
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) account(c *gin.Context) {
|
||||||
|
|
||||||
|
accountID := c.Param("accountid")
|
||||||
|
accountUUID, err := uuid.Parse(accountID)
|
||||||
|
if err != nil {
|
||||||
|
c.Redirect(http.StatusTemporaryRedirect, "/login")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
account, err := h.Service.DB.GetAccount(c.Request.Context(), accountUUID)
|
||||||
|
if err != nil {
|
||||||
|
c.AbortWithError(http.StatusNotFound, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
transactions, err := h.Service.DB.GetTransactionsForAccount(c.Request.Context(), accountUUID)
|
||||||
|
if err != nil {
|
||||||
|
c.AbortWithError(http.StatusNotFound, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
d := AccountData{
|
||||||
|
c.MustGet("data").(AlwaysNeededData),
|
||||||
|
&account,
|
||||||
|
transactions,
|
||||||
|
}
|
||||||
|
|
||||||
|
c.HTML(http.StatusOK, "account.html", d)
|
||||||
|
}
|
@ -3,9 +3,7 @@ package http
|
|||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"git.javil.eu/jacob1123/budgeteer/postgres"
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/google/uuid"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type AccountsData struct {
|
type AccountsData struct {
|
||||||
@ -19,39 +17,3 @@ func (h *Handler) accounts(c *gin.Context) {
|
|||||||
|
|
||||||
c.HTML(http.StatusOK, "accounts.html", d)
|
c.HTML(http.StatusOK, "accounts.html", d)
|
||||||
}
|
}
|
||||||
|
|
||||||
type AccountData struct {
|
|
||||||
AlwaysNeededData
|
|
||||||
Account *postgres.Account
|
|
||||||
Transactions []postgres.GetTransactionsForAccountRow
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handler) account(c *gin.Context) {
|
|
||||||
|
|
||||||
accountID := c.Param("accountid")
|
|
||||||
accountUUID, err := uuid.Parse(accountID)
|
|
||||||
if err != nil {
|
|
||||||
c.Redirect(http.StatusTemporaryRedirect, "/login")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
account, err := h.Service.DB.GetAccount(c.Request.Context(), accountUUID)
|
|
||||||
if err != nil {
|
|
||||||
c.AbortWithError(http.StatusNotFound, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
transactions, err := h.Service.DB.GetTransactionsForAccount(c.Request.Context(), accountUUID)
|
|
||||||
if err != nil {
|
|
||||||
c.AbortWithError(http.StatusNotFound, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
d := AccountData{
|
|
||||||
c.MustGet("data").(AlwaysNeededData),
|
|
||||||
&account,
|
|
||||||
transactions,
|
|
||||||
}
|
|
||||||
|
|
||||||
c.HTML(http.StatusOK, "account.html", d)
|
|
||||||
}
|
|
||||||
|
@ -1,9 +1,11 @@
|
|||||||
package http
|
package http
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/google/uuid"
|
||||||
"github.com/pressly/goose/v3"
|
"github.com/pressly/goose/v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -29,3 +31,28 @@ func (h *Handler) clearDatabase(c *gin.Context) {
|
|||||||
|
|
||||||
c.HTML(http.StatusOK, "admin.html", d)
|
c.HTML(http.StatusOK, "admin.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)
|
||||||
|
}
|
||||||
|
@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
"git.javil.eu/jacob1123/budgeteer"
|
||||||
"git.javil.eu/jacob1123/budgeteer/postgres"
|
"git.javil.eu/jacob1123/budgeteer/postgres"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
@ -35,3 +36,18 @@ func (h *Handler) budget(c *gin.Context) {
|
|||||||
|
|
||||||
c.HTML(http.StatusOK, "budget.html", d)
|
c.HTML(http.StatusOK, "budget.html", d)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *Handler) newBudget(c *gin.Context) {
|
||||||
|
budgetName, succ := c.GetPostForm("name")
|
||||||
|
if !succ {
|
||||||
|
c.AbortWithStatus(http.StatusNotAcceptable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userID := c.MustGet("token").(budgeteer.Token).GetID()
|
||||||
|
_, err := h.Service.NewBudget(budgetName, userID)
|
||||||
|
if err != nil {
|
||||||
|
c.AbortWithError(http.StatusInternalServerError, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -2,7 +2,6 @@ package http
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
@ -76,28 +75,3 @@ func (h *Handler) budgeting(c *gin.Context) {
|
|||||||
|
|
||||||
c.HTML(http.StatusOK, "budgeting.html", d)
|
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)
|
|
||||||
}
|
|
||||||
|
120
http/http.go
120
http/http.go
@ -1,10 +1,8 @@
|
|||||||
package http
|
package http
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
|
||||||
|
|
||||||
"git.javil.eu/jacob1123/budgeteer"
|
"git.javil.eu/jacob1123/budgeteer"
|
||||||
"git.javil.eu/jacob1123/budgeteer/bcrypt"
|
"git.javil.eu/jacob1123/budgeteer/bcrypt"
|
||||||
@ -12,7 +10,6 @@ import (
|
|||||||
"git.javil.eu/jacob1123/budgeteer/web"
|
"git.javil.eu/jacob1123/budgeteer/web"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/google/uuid"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Handler handles incoming requests
|
// Handler handles incoming requests
|
||||||
@ -58,11 +55,11 @@ func (h *Handler) Serve() {
|
|||||||
withBudget.Use(h.verifyLoginWithRedirect)
|
withBudget.Use(h.verifyLoginWithRedirect)
|
||||||
withBudget.Use(h.getImportantData)
|
withBudget.Use(h.getImportantData)
|
||||||
withBudget.GET("/budget/:budgetid", h.budgeting)
|
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/:year/:month", h.budgeting)
|
||||||
withBudget.GET("/budget/:budgetid/all-accounts", h.budget)
|
withBudget.GET("/budget/:budgetid/all-accounts", h.budget)
|
||||||
withBudget.GET("/budget/:budgetid/accounts", h.accounts)
|
withBudget.GET("/budget/:budgetid/accounts", h.accounts)
|
||||||
withBudget.GET("/budget/:budgetid/account/:accountid", h.account)
|
withBudget.GET("/budget/:budgetid/account/:accountid", h.account)
|
||||||
|
withBudget.GET("/budget/:budgetid/clear", h.clearBudget)
|
||||||
|
|
||||||
api := router.Group("/api/v1")
|
api := router.Group("/api/v1")
|
||||||
|
|
||||||
@ -86,118 +83,3 @@ func (h *Handler) Serve() {
|
|||||||
|
|
||||||
router.Run(":1323")
|
router.Run(":1323")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) importYNAB(c *gin.Context) {
|
|
||||||
budgetID, succ := c.GetPostForm("budget_id")
|
|
||||||
if !succ {
|
|
||||||
c.AbortWithError(http.StatusBadRequest, fmt.Errorf("no budget_id specified"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
budgetUUID, err := uuid.Parse(budgetID)
|
|
||||||
if !succ {
|
|
||||||
c.AbortWithError(http.StatusBadRequest, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ynab, err := NewYNABImport(h.Service.DB, budgetUUID)
|
|
||||||
if err != nil {
|
|
||||||
c.AbortWithError(http.StatusInternalServerError, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handler) newTransaction(c *gin.Context) {
|
|
||||||
transactionMemo, succ := c.GetPostForm("memo")
|
|
||||||
if !succ {
|
|
||||||
c.AbortWithStatus(http.StatusNotAcceptable)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
transactionAccount, succ := c.GetPostForm("account_id")
|
|
||||||
if !succ {
|
|
||||||
c.AbortWithStatus(http.StatusNotAcceptable)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
transactionAccountID, err := uuid.Parse(transactionAccount)
|
|
||||||
if !succ {
|
|
||||||
c.AbortWithStatus(http.StatusNotAcceptable)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
transactionDate, succ := c.GetPostForm("date")
|
|
||||||
if !succ {
|
|
||||||
c.AbortWithStatus(http.StatusNotAcceptable)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
transactionDateValue, err := time.Parse("2006-01-02", transactionDate)
|
|
||||||
if err != nil {
|
|
||||||
c.AbortWithStatus(http.StatusNotAcceptable)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
new := postgres.CreateTransactionParams{
|
|
||||||
Memo: transactionMemo,
|
|
||||||
Date: transactionDateValue,
|
|
||||||
Amount: postgres.Numeric{},
|
|
||||||
AccountID: transactionAccountID,
|
|
||||||
}
|
|
||||||
_, err = h.Service.DB.CreateTransaction(c.Request.Context(), new)
|
|
||||||
if err != nil {
|
|
||||||
c.AbortWithError(http.StatusInternalServerError, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handler) newBudget(c *gin.Context) {
|
|
||||||
budgetName, succ := c.GetPostForm("name")
|
|
||||||
if !succ {
|
|
||||||
c.AbortWithStatus(http.StatusNotAcceptable)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
userID := c.MustGet("token").(budgeteer.Token).GetID()
|
|
||||||
_, err := h.Service.NewBudget(budgetName, userID)
|
|
||||||
if err != nil {
|
|
||||||
c.AbortWithError(http.StatusInternalServerError, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
54
http/transaction.go
Normal file
54
http/transaction.go
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
package http
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.javil.eu/jacob1123/budgeteer/postgres"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (h *Handler) newTransaction(c *gin.Context) {
|
||||||
|
transactionMemo, succ := c.GetPostForm("memo")
|
||||||
|
if !succ {
|
||||||
|
c.AbortWithStatus(http.StatusNotAcceptable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
transactionAccount, succ := c.GetPostForm("account_id")
|
||||||
|
if !succ {
|
||||||
|
c.AbortWithStatus(http.StatusNotAcceptable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
transactionAccountID, err := uuid.Parse(transactionAccount)
|
||||||
|
if !succ {
|
||||||
|
c.AbortWithStatus(http.StatusNotAcceptable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
transactionDate, succ := c.GetPostForm("date")
|
||||||
|
if !succ {
|
||||||
|
c.AbortWithStatus(http.StatusNotAcceptable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
transactionDateValue, err := time.Parse("2006-01-02", transactionDate)
|
||||||
|
if err != nil {
|
||||||
|
c.AbortWithStatus(http.StatusNotAcceptable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
new := postgres.CreateTransactionParams{
|
||||||
|
Memo: transactionMemo,
|
||||||
|
Date: transactionDateValue,
|
||||||
|
Amount: postgres.Numeric{},
|
||||||
|
AccountID: transactionAccountID,
|
||||||
|
}
|
||||||
|
_, err = h.Service.DB.CreateTransaction(c.Request.Context(), new)
|
||||||
|
if err != nil {
|
||||||
|
c.AbortWithError(http.StatusInternalServerError, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
@ -1,299 +1,66 @@
|
|||||||
package http
|
package http
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"encoding/csv"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"net/http"
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
"unicode/utf8"
|
|
||||||
|
|
||||||
"git.javil.eu/jacob1123/budgeteer/postgres"
|
"git.javil.eu/jacob1123/budgeteer/postgres"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
type YNABImport struct {
|
func (h *Handler) importYNAB(c *gin.Context) {
|
||||||
Context context.Context
|
budgetID, succ := c.GetPostForm("budget_id")
|
||||||
accounts []postgres.Account
|
if !succ {
|
||||||
payees []postgres.Payee
|
c.AbortWithError(http.StatusBadRequest, fmt.Errorf("no budget_id specified"))
|
||||||
categories []postgres.GetCategoriesRow
|
return
|
||||||
categoryGroups []postgres.CategoryGroup
|
}
|
||||||
queries *postgres.Queries
|
|
||||||
budgetID uuid.UUID
|
budgetUUID, err := uuid.Parse(budgetID)
|
||||||
}
|
if !succ {
|
||||||
|
c.AbortWithError(http.StatusBadRequest, err)
|
||||||
func NewYNABImport(q *postgres.Queries, budgetID uuid.UUID) (*YNABImport, error) {
|
return
|
||||||
accounts, err := q.GetAccounts(context.Background(), budgetID)
|
}
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
ynab, err := postgres.NewYNABImport(h.Service.DB, budgetUUID)
|
||||||
}
|
if err != nil {
|
||||||
|
c.AbortWithError(http.StatusInternalServerError, err)
|
||||||
payees, err := q.GetPayees(context.Background(), budgetID)
|
return
|
||||||
if err != nil {
|
}
|
||||||
return nil, err
|
|
||||||
}
|
transactionsFile, err := c.FormFile("transactions")
|
||||||
|
if err != nil {
|
||||||
categories, err := q.GetCategories(context.Background(), budgetID)
|
c.AbortWithError(http.StatusInternalServerError, err)
|
||||||
if err != nil {
|
return
|
||||||
return nil, err
|
}
|
||||||
}
|
|
||||||
|
transactions, err := transactionsFile.Open()
|
||||||
categoryGroups, err := q.GetCategoryGroups(context.Background(), budgetID)
|
if err != nil {
|
||||||
if err != nil {
|
c.AbortWithError(http.StatusInternalServerError, err)
|
||||||
return nil, err
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
return &YNABImport{
|
err = ynab.ImportTransactions(transactions)
|
||||||
Context: context.Background(),
|
if err != nil {
|
||||||
accounts: accounts,
|
c.AbortWithError(http.StatusInternalServerError, err)
|
||||||
payees: payees,
|
return
|
||||||
categories: categories,
|
}
|
||||||
categoryGroups: categoryGroups,
|
|
||||||
queries: q,
|
assignmentsFile, err := c.FormFile("assignments")
|
||||||
budgetID: budgetID,
|
if err != nil {
|
||||||
}, nil
|
c.AbortWithError(http.StatusInternalServerError, err)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ynab *YNABImport) ImportAssignments(r io.Reader) error {
|
assignments, err := assignmentsFile.Open()
|
||||||
csv := csv.NewReader(r)
|
if err != nil {
|
||||||
csv.Comma = '\t'
|
c.AbortWithError(http.StatusInternalServerError, err)
|
||||||
csv.LazyQuotes = true
|
return
|
||||||
|
}
|
||||||
csvData, err := csv.ReadAll()
|
|
||||||
if err != nil {
|
err = ynab.ImportAssignments(assignments)
|
||||||
return fmt.Errorf("could not read from tsv: %w", err)
|
if err != nil {
|
||||||
}
|
c.AbortWithError(http.StatusInternalServerError, err)
|
||||||
|
return
|
||||||
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
|
|
||||||
|
|
||||||
csvData, err := csv.ReadAll()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("could not read from tsv: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
count := 0
|
|
||||||
for _, record := range csvData[1:] {
|
|
||||||
accountName := record[0]
|
|
||||||
account, err := ynab.GetAccount(accountName)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("could not get account %s: %w", accountName, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
//flag := record[1]
|
|
||||||
|
|
||||||
dateString := record[2]
|
|
||||||
date, err := time.Parse("02.01.2006", dateString)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("could not parse date %s: %w", dateString, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
payeeName := record[3]
|
|
||||||
payeeID, err := ynab.GetPayee(payeeName)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("could not get payee %s: %w", payeeName, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
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/%s: %w", categoryGroup, categoryName, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
memo := record[7]
|
|
||||||
|
|
||||||
outflow := record[8]
|
|
||||||
inflow := record[9]
|
|
||||||
amount, err := GetAmount(inflow, outflow)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("could not parse amount from (%s/%s): %w", inflow, outflow, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
//cleared := record[10]
|
|
||||||
|
|
||||||
transaction := postgres.CreateTransactionParams{
|
|
||||||
Date: date,
|
|
||||||
Memo: memo,
|
|
||||||
AccountID: account.ID,
|
|
||||||
PayeeID: payeeID,
|
|
||||||
CategoryID: category,
|
|
||||||
Amount: amount,
|
|
||||||
}
|
|
||||||
_, err = ynab.queries.CreateTransaction(ynab.Context, transaction)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("could not save transaction %v: %w", transaction, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
count++
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("Imported %d transactions\n", count)
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func trimLastChar(s string) string {
|
|
||||||
r, size := utf8.DecodeLastRuneInString(s)
|
|
||||||
if r == utf8.RuneError && (size == 0 || size == 1) {
|
|
||||||
size = 0
|
|
||||||
}
|
|
||||||
return s[:len(s)-size]
|
|
||||||
}
|
|
||||||
|
|
||||||
func GetAmount(inflow string, outflow string) (postgres.Numeric, error) {
|
|
||||||
// Remove trailing currency
|
|
||||||
inflow = strings.Replace(trimLastChar(inflow), ",", ".", 1)
|
|
||||||
outflow = strings.Replace(trimLastChar(outflow), ",", ".", 1)
|
|
||||||
|
|
||||||
num := postgres.Numeric{}
|
|
||||||
err := num.Set(inflow)
|
|
||||||
if err != nil {
|
|
||||||
return num, fmt.Errorf("Could not parse inflow %s: %w", inflow, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// if inflow is zero, use outflow
|
|
||||||
if num.Int.Int64() != 0 {
|
|
||||||
return num, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
err = num.Set("-" + outflow)
|
|
||||||
if err != nil {
|
|
||||||
return num, fmt.Errorf("Could not parse outflow %s: %w", inflow, err)
|
|
||||||
}
|
|
||||||
return num, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ynab *YNABImport) GetAccount(name string) (*postgres.Account, error) {
|
|
||||||
for _, acc := range ynab.accounts {
|
|
||||||
if acc.Name == name {
|
|
||||||
return &acc, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
account, err := ynab.queries.CreateAccount(ynab.Context, postgres.CreateAccountParams{Name: name, BudgetID: ynab.budgetID})
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
ynab.accounts = append(ynab.accounts, account)
|
|
||||||
return &account, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ynab *YNABImport) GetPayee(name string) (uuid.NullUUID, error) {
|
|
||||||
if name == "" {
|
|
||||||
return uuid.NullUUID{}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, pay := range ynab.payees {
|
|
||||||
if pay.Name == name {
|
|
||||||
return uuid.NullUUID{UUID: pay.ID, Valid: true}, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
payee, err := ynab.queries.CreatePayee(ynab.Context, postgres.CreatePayeeParams{Name: name, BudgetID: ynab.budgetID})
|
|
||||||
if err != nil {
|
|
||||||
return uuid.NullUUID{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
ynab.payees = append(ynab.payees, payee)
|
|
||||||
return uuid.NullUUID{UUID: payee.ID, Valid: true}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ynab *YNABImport) GetCategory(group string, name string) (uuid.NullUUID, error) {
|
|
||||||
if group == "" || name == "" {
|
|
||||||
return uuid.NullUUID{}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, category := range ynab.categories {
|
|
||||||
if category.Name == name && category.Group == group {
|
|
||||||
return uuid.NullUUID{UUID: category.ID, Valid: true}, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, categoryGroup := range ynab.categoryGroups {
|
|
||||||
if categoryGroup.Name == group {
|
|
||||||
createCategory := postgres.CreateCategoryParams{Name: name, CategoryGroupID: categoryGroup.ID}
|
|
||||||
category, err := ynab.queries.CreateCategory(ynab.Context, createCategory)
|
|
||||||
if err != nil {
|
|
||||||
return uuid.NullUUID{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
getCategory := postgres.GetCategoriesRow{
|
|
||||||
ID: category.ID,
|
|
||||||
CategoryGroupID: category.CategoryGroupID,
|
|
||||||
Name: category.Name,
|
|
||||||
Group: categoryGroup.Name,
|
|
||||||
}
|
|
||||||
ynab.categories = append(ynab.categories, getCategory)
|
|
||||||
return uuid.NullUUID{UUID: category.ID, Valid: true}, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
categoryGroup, err := ynab.queries.CreateCategoryGroup(ynab.Context, postgres.CreateCategoryGroupParams{Name: group, BudgetID: ynab.budgetID})
|
|
||||||
if err != nil {
|
|
||||||
return uuid.NullUUID{}, err
|
|
||||||
}
|
|
||||||
ynab.categoryGroups = append(ynab.categoryGroups, categoryGroup)
|
|
||||||
|
|
||||||
category, err := ynab.queries.CreateCategory(ynab.Context, postgres.CreateCategoryParams{Name: name, CategoryGroupID: categoryGroup.ID})
|
|
||||||
if err != nil {
|
|
||||||
return uuid.NullUUID{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
getCategory := postgres.GetCategoriesRow{
|
|
||||||
ID: category.ID,
|
|
||||||
CategoryGroupID: category.CategoryGroupID,
|
|
||||||
Name: category.Name,
|
|
||||||
Group: categoryGroup.Name,
|
|
||||||
}
|
|
||||||
ynab.categories = append(ynab.categories, getCategory)
|
|
||||||
return uuid.NullUUID{UUID: category.ID, Valid: true}, nil
|
|
||||||
}
|
}
|
||||||
|
298
postgres/ynab-import.go
Normal file
298
postgres/ynab-import.go
Normal file
@ -0,0 +1,298 @@
|
|||||||
|
package postgres
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/csv"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
type YNABImport struct {
|
||||||
|
Context context.Context
|
||||||
|
accounts []Account
|
||||||
|
payees []Payee
|
||||||
|
categories []GetCategoriesRow
|
||||||
|
categoryGroups []CategoryGroup
|
||||||
|
queries *Queries
|
||||||
|
budgetID uuid.UUID
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewYNABImport(q *Queries, budgetID uuid.UUID) (*YNABImport, error) {
|
||||||
|
accounts, err := q.GetAccounts(context.Background(), budgetID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
payees, err := q.GetPayees(context.Background(), budgetID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
categories, err := q.GetCategories(context.Background(), budgetID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
categoryGroups, err := q.GetCategoryGroups(context.Background(), budgetID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &YNABImport{
|
||||||
|
Context: context.Background(),
|
||||||
|
accounts: accounts,
|
||||||
|
payees: payees,
|
||||||
|
categories: categories,
|
||||||
|
categoryGroups: categoryGroups,
|
||||||
|
queries: q,
|
||||||
|
budgetID: budgetID,
|
||||||
|
}, nil
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
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 := 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
|
||||||
|
|
||||||
|
csvData, err := csv.ReadAll()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("could not read from tsv: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
count := 0
|
||||||
|
for _, record := range csvData[1:] {
|
||||||
|
accountName := record[0]
|
||||||
|
account, err := ynab.GetAccount(accountName)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("could not get account %s: %w", accountName, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
//flag := record[1]
|
||||||
|
|
||||||
|
dateString := record[2]
|
||||||
|
date, err := time.Parse("02.01.2006", dateString)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("could not parse date %s: %w", dateString, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
payeeName := record[3]
|
||||||
|
payeeID, err := ynab.GetPayee(payeeName)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("could not get payee %s: %w", payeeName, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
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/%s: %w", categoryGroup, categoryName, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
memo := record[7]
|
||||||
|
|
||||||
|
outflow := record[8]
|
||||||
|
inflow := record[9]
|
||||||
|
amount, err := GetAmount(inflow, outflow)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("could not parse amount from (%s/%s): %w", inflow, outflow, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
//cleared := record[10]
|
||||||
|
|
||||||
|
transaction := CreateTransactionParams{
|
||||||
|
Date: date,
|
||||||
|
Memo: memo,
|
||||||
|
AccountID: account.ID,
|
||||||
|
PayeeID: payeeID,
|
||||||
|
CategoryID: category,
|
||||||
|
Amount: amount,
|
||||||
|
}
|
||||||
|
_, err = ynab.queries.CreateTransaction(ynab.Context, transaction)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("could not save transaction %v: %w", transaction, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Imported %d transactions\n", count)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func trimLastChar(s string) string {
|
||||||
|
r, size := utf8.DecodeLastRuneInString(s)
|
||||||
|
if r == utf8.RuneError && (size == 0 || size == 1) {
|
||||||
|
size = 0
|
||||||
|
}
|
||||||
|
return s[:len(s)-size]
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetAmount(inflow string, outflow string) (Numeric, error) {
|
||||||
|
// Remove trailing currency
|
||||||
|
inflow = strings.Replace(trimLastChar(inflow), ",", ".", 1)
|
||||||
|
outflow = strings.Replace(trimLastChar(outflow), ",", ".", 1)
|
||||||
|
|
||||||
|
num := Numeric{}
|
||||||
|
err := num.Set(inflow)
|
||||||
|
if err != nil {
|
||||||
|
return num, fmt.Errorf("Could not parse inflow %s: %w", inflow, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// if inflow is zero, use outflow
|
||||||
|
if num.Int.Int64() != 0 {
|
||||||
|
return num, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
err = num.Set("-" + outflow)
|
||||||
|
if err != nil {
|
||||||
|
return num, fmt.Errorf("Could not parse outflow %s: %w", inflow, err)
|
||||||
|
}
|
||||||
|
return num, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ynab *YNABImport) GetAccount(name string) (*Account, error) {
|
||||||
|
for _, acc := range ynab.accounts {
|
||||||
|
if acc.Name == name {
|
||||||
|
return &acc, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
account, err := ynab.queries.CreateAccount(ynab.Context, CreateAccountParams{Name: name, BudgetID: ynab.budgetID})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
ynab.accounts = append(ynab.accounts, account)
|
||||||
|
return &account, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ynab *YNABImport) GetPayee(name string) (uuid.NullUUID, error) {
|
||||||
|
if name == "" {
|
||||||
|
return uuid.NullUUID{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, pay := range ynab.payees {
|
||||||
|
if pay.Name == name {
|
||||||
|
return uuid.NullUUID{UUID: pay.ID, Valid: true}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
payee, err := ynab.queries.CreatePayee(ynab.Context, CreatePayeeParams{Name: name, BudgetID: ynab.budgetID})
|
||||||
|
if err != nil {
|
||||||
|
return uuid.NullUUID{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
ynab.payees = append(ynab.payees, payee)
|
||||||
|
return uuid.NullUUID{UUID: payee.ID, Valid: true}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ynab *YNABImport) GetCategory(group string, name string) (uuid.NullUUID, error) {
|
||||||
|
if group == "" || name == "" {
|
||||||
|
return uuid.NullUUID{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, category := range ynab.categories {
|
||||||
|
if category.Name == name && category.Group == group {
|
||||||
|
return uuid.NullUUID{UUID: category.ID, Valid: true}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, categoryGroup := range ynab.categoryGroups {
|
||||||
|
if categoryGroup.Name == group {
|
||||||
|
createCategory := CreateCategoryParams{Name: name, CategoryGroupID: categoryGroup.ID}
|
||||||
|
category, err := ynab.queries.CreateCategory(ynab.Context, createCategory)
|
||||||
|
if err != nil {
|
||||||
|
return uuid.NullUUID{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
getCategory := GetCategoriesRow{
|
||||||
|
ID: category.ID,
|
||||||
|
CategoryGroupID: category.CategoryGroupID,
|
||||||
|
Name: category.Name,
|
||||||
|
Group: categoryGroup.Name,
|
||||||
|
}
|
||||||
|
ynab.categories = append(ynab.categories, getCategory)
|
||||||
|
return uuid.NullUUID{UUID: category.ID, Valid: true}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
categoryGroup, err := ynab.queries.CreateCategoryGroup(ynab.Context, CreateCategoryGroupParams{Name: group, BudgetID: ynab.budgetID})
|
||||||
|
if err != nil {
|
||||||
|
return uuid.NullUUID{}, err
|
||||||
|
}
|
||||||
|
ynab.categoryGroups = append(ynab.categoryGroups, categoryGroup)
|
||||||
|
|
||||||
|
category, err := ynab.queries.CreateCategory(ynab.Context, CreateCategoryParams{Name: name, CategoryGroupID: categoryGroup.ID})
|
||||||
|
if err != nil {
|
||||||
|
return uuid.NullUUID{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
getCategory := GetCategoriesRow{
|
||||||
|
ID: category.ID,
|
||||||
|
CategoryGroupID: category.CategoryGroupID,
|
||||||
|
Name: category.Name,
|
||||||
|
Group: categoryGroup.Name,
|
||||||
|
}
|
||||||
|
ynab.categories = append(ynab.categories, getCategory)
|
||||||
|
return uuid.NullUUID{UUID: category.ID, Valid: true}, nil
|
||||||
|
}
|
Reference in New Issue
Block a user