diff --git a/postgres/models.go b/postgres/models.go index ad4bf6b..bacb571 100644 --- a/postgres/models.go +++ b/postgres/models.go @@ -72,6 +72,24 @@ type CategoryGroup struct { Name string } +type DisplayTransaction struct { + ID uuid.UUID + Date time.Time + Memo string + Amount numeric.Numeric + GroupID uuid.NullUUID + Status TransactionStatus + Account string + PayeeID uuid.NullUUID + CategoryID uuid.NullUUID + Payee string + CategoryGroup string + Category string + TransferAccount string + BudgetID uuid.UUID + AccountID uuid.UUID +} + type Payee struct { ID uuid.UUID BudgetID uuid.UUID diff --git a/postgres/queries/transactions.sql b/postgres/queries/transactions.sql index df751e7..562b391 100644 --- a/postgres/queries/transactions.sql +++ b/postgres/queries/transactions.sql @@ -1,12 +1,12 @@ -- name: GetTransaction :one -SELECT * FROM transactions +SELECT * FROM display_transactions WHERE id = $1; -- name: CreateTransaction :one INSERT INTO transactions (date, memo, amount, account_id, payee_id, category_id, group_id, status) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) -RETURNING *; +RETURNING id; -- name: UpdateTransaction :exec UPDATE transactions @@ -17,53 +17,24 @@ SET date = $1, category_id = $5 WHERE id = $6; +-- name: SetTransactionReconciled :exec +UPDATE transactions +SET status = 'Reconciled' +WHERE id = $1; + -- name: DeleteTransaction :exec DELETE FROM transactions WHERE id = $1; -- name: GetAllTransactionsForBudget :many -SELECT transactions.id, transactions.date, transactions.memo, - transactions.amount, transactions.group_id, transactions.status, - accounts.name as account, transactions.payee_id, transactions.category_id, - COALESCE(payees.name, '') as payee, - COALESCE(category_groups.name, '') as category_group, - COALESCE(categories.name, '') as category, - COALESCE(( - SELECT CONCAT(otherAccounts.name) - FROM transactions otherTransactions - LEFT JOIN accounts otherAccounts ON otherAccounts.id = otherTransactions.account_id - WHERE otherTransactions.group_id = transactions.group_id - AND otherTransactions.id != transactions.id - ), '')::text as transfer_account -FROM transactions -INNER JOIN accounts ON accounts.id = transactions.account_id -LEFT JOIN payees ON payees.id = transactions.payee_id -LEFT JOIN categories ON categories.id = transactions.category_id -LEFT JOIN category_groups ON category_groups.id = categories.category_group_id -WHERE accounts.budget_id = $1 -ORDER BY transactions.date DESC; +SELECT t.* +FROM display_transactions AS t +WHERE t.budget_id = $1; -- name: GetTransactionsForAccount :many -SELECT transactions.id, transactions.date, transactions.memo, - transactions.amount, transactions.group_id, transactions.status, - accounts.name as account, transactions.payee_id, transactions.category_id, - COALESCE(payees.name, '') as payee, - COALESCE(category_groups.name, '') as category_group, - COALESCE(categories.name, '') as category, - COALESCE(( - SELECT CONCAT(otherAccounts.name) - FROM transactions otherTransactions - LEFT JOIN accounts otherAccounts ON otherAccounts.id = otherTransactions.account_id - WHERE otherTransactions.group_id = transactions.group_id - AND otherTransactions.id != transactions.id - ), '')::text as transfer_account -FROM transactions -INNER JOIN accounts ON accounts.id = transactions.account_id -LEFT JOIN payees ON payees.id = transactions.payee_id -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 +SELECT t.* +FROM display_transactions AS t +WHERE t.account_id = $1 LIMIT 200; -- name: DeleteAllTransactions :execrows diff --git a/postgres/schema/0015_transactions-view.sql b/postgres/schema/0015_transactions-view.sql new file mode 100644 index 0000000..dd961eb --- /dev/null +++ b/postgres/schema/0015_transactions-view.sql @@ -0,0 +1,25 @@ +-- +goose Up +CREATE VIEW display_transactions AS + SELECT transactions.id, transactions.date, transactions.memo, + transactions.amount, transactions.group_id, transactions.status, + accounts.name as account, transactions.payee_id, transactions.category_id, + COALESCE(payees.name, '') as payee, + COALESCE(category_groups.name, '') as category_group, + COALESCE(categories.name, '') as category, + COALESCE(( + SELECT CONCAT(otherAccounts.name) + FROM transactions otherTransactions + LEFT JOIN accounts otherAccounts ON otherAccounts.id = otherTransactions.account_id + WHERE otherTransactions.group_id = transactions.group_id + AND otherTransactions.id != transactions.id + ), '')::text as transfer_account, + accounts.budget_id, transactions.account_id + FROM transactions + INNER JOIN accounts ON accounts.id = transactions.account_id + LEFT JOIN payees ON payees.id = transactions.payee_id + LEFT JOIN categories ON categories.id = transactions.category_id + LEFT JOIN category_groups ON category_groups.id = categories.category_group_id + ORDER BY transactions.date DESC; + +-- +goose Down +DROP VIEW display_transactions; \ No newline at end of file diff --git a/postgres/transactions.sql.go b/postgres/transactions.sql.go index 1f02819..d238098 100644 --- a/postgres/transactions.sql.go +++ b/postgres/transactions.sql.go @@ -15,7 +15,7 @@ const createTransaction = `-- name: CreateTransaction :one INSERT INTO transactions (date, memo, amount, account_id, payee_id, category_id, group_id, status) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) -RETURNING id, date, memo, amount, account_id, category_id, payee_id, group_id, status +RETURNING id ` type CreateTransactionParams struct { @@ -29,7 +29,7 @@ type CreateTransactionParams struct { Status TransactionStatus } -func (q *Queries) CreateTransaction(ctx context.Context, arg CreateTransactionParams) (Transaction, error) { +func (q *Queries) CreateTransaction(ctx context.Context, arg CreateTransactionParams) (uuid.UUID, error) { row := q.db.QueryRowContext(ctx, createTransaction, arg.Date, arg.Memo, @@ -40,19 +40,9 @@ func (q *Queries) CreateTransaction(ctx context.Context, arg CreateTransactionPa arg.GroupID, arg.Status, ) - var i Transaction - err := row.Scan( - &i.ID, - &i.Date, - &i.Memo, - &i.Amount, - &i.AccountID, - &i.CategoryID, - &i.PayeeID, - &i.GroupID, - &i.Status, - ) - return i, err + var id uuid.UUID + err := row.Scan(&id) + return id, err } const deleteAllTransactions = `-- name: DeleteAllTransactions :execrows @@ -81,53 +71,20 @@ func (q *Queries) DeleteTransaction(ctx context.Context, id uuid.UUID) error { } const getAllTransactionsForBudget = `-- name: GetAllTransactionsForBudget :many -SELECT transactions.id, transactions.date, transactions.memo, - transactions.amount, transactions.group_id, transactions.status, - accounts.name as account, transactions.payee_id, transactions.category_id, - COALESCE(payees.name, '') as payee, - COALESCE(category_groups.name, '') as category_group, - COALESCE(categories.name, '') as category, - COALESCE(( - SELECT CONCAT(otherAccounts.name) - FROM transactions otherTransactions - LEFT JOIN accounts otherAccounts ON otherAccounts.id = otherTransactions.account_id - WHERE otherTransactions.group_id = transactions.group_id - AND otherTransactions.id != transactions.id - ), '')::text as transfer_account -FROM transactions -INNER JOIN accounts ON accounts.id = transactions.account_id -LEFT JOIN payees ON payees.id = transactions.payee_id -LEFT JOIN categories ON categories.id = transactions.category_id -LEFT JOIN category_groups ON category_groups.id = categories.category_group_id -WHERE accounts.budget_id = $1 -ORDER BY transactions.date DESC +SELECT t.id, t.date, t.memo, t.amount, t.group_id, t.status, t.account, t.payee_id, t.category_id, t.payee, t.category_group, t.category, t.transfer_account, t.budget_id, t.account_id +FROM display_transactions AS t +WHERE t.budget_id = $1 ` -type GetAllTransactionsForBudgetRow struct { - ID uuid.UUID - Date time.Time - Memo string - Amount numeric.Numeric - GroupID uuid.NullUUID - Status TransactionStatus - Account string - PayeeID uuid.NullUUID - CategoryID uuid.NullUUID - Payee string - CategoryGroup string - Category string - TransferAccount string -} - -func (q *Queries) GetAllTransactionsForBudget(ctx context.Context, budgetID uuid.UUID) ([]GetAllTransactionsForBudgetRow, error) { +func (q *Queries) GetAllTransactionsForBudget(ctx context.Context, budgetID uuid.UUID) ([]DisplayTransaction, error) { rows, err := q.db.QueryContext(ctx, getAllTransactionsForBudget, budgetID) if err != nil { return nil, err } defer rows.Close() - var items []GetAllTransactionsForBudgetRow + var items []DisplayTransaction for rows.Next() { - var i GetAllTransactionsForBudgetRow + var i DisplayTransaction if err := rows.Scan( &i.ID, &i.Date, @@ -142,6 +99,8 @@ func (q *Queries) GetAllTransactionsForBudget(ctx context.Context, budgetID uuid &i.CategoryGroup, &i.Category, &i.TransferAccount, + &i.BudgetID, + &i.AccountID, ); err != nil { return nil, err } @@ -157,23 +116,29 @@ func (q *Queries) GetAllTransactionsForBudget(ctx context.Context, budgetID uuid } const getTransaction = `-- name: GetTransaction :one -SELECT id, date, memo, amount, account_id, category_id, payee_id, group_id, status FROM transactions +SELECT id, date, memo, amount, group_id, status, account, payee_id, category_id, payee, category_group, category, transfer_account, budget_id, account_id FROM display_transactions WHERE id = $1 ` -func (q *Queries) GetTransaction(ctx context.Context, id uuid.UUID) (Transaction, error) { +func (q *Queries) GetTransaction(ctx context.Context, id uuid.UUID) (DisplayTransaction, error) { row := q.db.QueryRowContext(ctx, getTransaction, id) - var i Transaction + var i DisplayTransaction err := row.Scan( &i.ID, &i.Date, &i.Memo, &i.Amount, - &i.AccountID, - &i.CategoryID, - &i.PayeeID, &i.GroupID, &i.Status, + &i.Account, + &i.PayeeID, + &i.CategoryID, + &i.Payee, + &i.CategoryGroup, + &i.Category, + &i.TransferAccount, + &i.BudgetID, + &i.AccountID, ) return i, err } @@ -213,54 +178,21 @@ func (q *Queries) GetTransactionsByMonthAndCategory(ctx context.Context, budgetI } const getTransactionsForAccount = `-- name: GetTransactionsForAccount :many -SELECT transactions.id, transactions.date, transactions.memo, - transactions.amount, transactions.group_id, transactions.status, - accounts.name as account, transactions.payee_id, transactions.category_id, - COALESCE(payees.name, '') as payee, - COALESCE(category_groups.name, '') as category_group, - COALESCE(categories.name, '') as category, - COALESCE(( - SELECT CONCAT(otherAccounts.name) - FROM transactions otherTransactions - LEFT JOIN accounts otherAccounts ON otherAccounts.id = otherTransactions.account_id - WHERE otherTransactions.group_id = transactions.group_id - AND otherTransactions.id != transactions.id - ), '')::text as transfer_account -FROM transactions -INNER JOIN accounts ON accounts.id = transactions.account_id -LEFT JOIN payees ON payees.id = transactions.payee_id -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 +SELECT t.id, t.date, t.memo, t.amount, t.group_id, t.status, t.account, t.payee_id, t.category_id, t.payee, t.category_group, t.category, t.transfer_account, t.budget_id, t.account_id +FROM display_transactions AS t +WHERE t.account_id = $1 LIMIT 200 ` -type GetTransactionsForAccountRow struct { - ID uuid.UUID - Date time.Time - Memo string - Amount numeric.Numeric - GroupID uuid.NullUUID - Status TransactionStatus - Account string - PayeeID uuid.NullUUID - CategoryID uuid.NullUUID - Payee string - CategoryGroup string - Category string - TransferAccount string -} - -func (q *Queries) GetTransactionsForAccount(ctx context.Context, accountID uuid.UUID) ([]GetTransactionsForAccountRow, error) { +func (q *Queries) GetTransactionsForAccount(ctx context.Context, accountID uuid.UUID) ([]DisplayTransaction, error) { rows, err := q.db.QueryContext(ctx, getTransactionsForAccount, accountID) if err != nil { return nil, err } defer rows.Close() - var items []GetTransactionsForAccountRow + var items []DisplayTransaction for rows.Next() { - var i GetTransactionsForAccountRow + var i DisplayTransaction if err := rows.Scan( &i.ID, &i.Date, @@ -275,6 +207,8 @@ func (q *Queries) GetTransactionsForAccount(ctx context.Context, accountID uuid. &i.CategoryGroup, &i.Category, &i.TransferAccount, + &i.BudgetID, + &i.AccountID, ); err != nil { return nil, err } @@ -289,6 +223,17 @@ func (q *Queries) GetTransactionsForAccount(ctx context.Context, accountID uuid. return items, nil } +const setTransactionReconciled = `-- name: SetTransactionReconciled :exec +UPDATE transactions +SET status = 'Reconciled' +WHERE id = $1 +` + +func (q *Queries) SetTransactionReconciled(ctx context.Context, id uuid.UUID) error { + _, err := q.db.ExecContext(ctx, setTransactionReconciled, id) + return err +} + const updateTransaction = `-- name: UpdateTransaction :exec UPDATE transactions SET date = $1, diff --git a/postgres/ynab-export.go b/postgres/ynab-export.go index 35d822b..7b52f2c 100644 --- a/postgres/ynab-export.go +++ b/postgres/ynab-export.go @@ -110,7 +110,7 @@ func (ynab *YNABExport) ExportTransactions(context context.Context, w io.Writer) return nil } -func GetTransactionRow(transaction GetAllTransactionsForBudgetRow) []string { +func GetTransactionRow(transaction DisplayTransaction) []string { row := []string{ transaction.Account, "", // Flag diff --git a/server/account.go b/server/account.go index 59eab6a..0c057af 100644 --- a/server/account.go +++ b/server/account.go @@ -33,7 +33,7 @@ func (h *Handler) transactionsForAccount(c *gin.Context) { type TransactionsResponse struct { Account postgres.Account - Transactions []postgres.GetTransactionsForAccountRow + Transactions []postgres.DisplayTransaction } type EditAccountRequest struct { diff --git a/server/http.go b/server/http.go index 80e9bd4..e3fcfc9 100644 --- a/server/http.go +++ b/server/http.go @@ -36,6 +36,10 @@ type ErrorResponse struct { Message string } +type SuccessResponse struct { + Message string +} + // LoadRoutes initializes all the routes. func (h *Handler) LoadRoutes(router *gin.Engine) { router.Use(enableCachingForStaticFiles()) @@ -52,6 +56,7 @@ func (h *Handler) LoadRoutes(router *gin.Engine) { authenticated.Use(h.verifyLoginWithForbidden) authenticated.GET("/dashboard", h.dashboard) authenticated.GET("/account/:accountid/transactions", h.transactionsForAccount) + authenticated.POST("/account/:accountid/reconcile", h.reconcileTransactions) authenticated.POST("/account/:accountid", h.editAccount) authenticated.GET("/admin/clear-database", h.clearDatabase) diff --git a/server/reconcile.go b/server/reconcile.go new file mode 100644 index 0000000..7b9cd5a --- /dev/null +++ b/server/reconcile.go @@ -0,0 +1,103 @@ +package server + +import ( + "database/sql" + "fmt" + "net/http" + "time" + + "git.javil.eu/jacob1123/budgeteer/postgres" + "git.javil.eu/jacob1123/budgeteer/postgres/numeric" + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +type ReconcileTransactionsRequest struct { + TransactionIDs []uuid.UUID `json:"transactionIds"` + ReconcilationTransactionAmount string `json:"reconciliationTransactionAmount"` +} + +type ReconcileTransactionsResponse struct { + Message string + ReconciliationTransaction *postgres.DisplayTransaction +} + +func (h *Handler) reconcileTransactions(c *gin.Context) { + accountID := c.Param("accountid") + accountUUID, err := uuid.Parse(accountID) + if err != nil { + c.AbortWithError(http.StatusBadRequest, err) + return + } + + var request ReconcileTransactionsRequest + err = c.BindJSON(&request) + if err != nil { + c.AbortWithError(http.StatusBadRequest, fmt.Errorf("parse request: %w", err)) + return + } + + var amount numeric.Numeric + err = amount.Set(request.ReconcilationTransactionAmount) + if err != nil { + c.AbortWithError(http.StatusBadRequest, fmt.Errorf("parse request: %w", err)) + return + } + + tx, err := h.Service.BeginTx(c.Request.Context(), &sql.TxOptions{}) + if err != nil { + c.AbortWithError(http.StatusInternalServerError, fmt.Errorf("begin tx: %w", err)) + return + } + + db := h.Service.WithTx(tx) + for _, transactionID := range request.TransactionIDs { + err := db.SetTransactionReconciled(c.Request.Context(), transactionID) + if err != nil { + c.AbortWithError(http.StatusInternalServerError, fmt.Errorf("update transaction: %w", err)) + return + } + } + + reconciliationTransaction, err := h.CreateReconcilationTransaction(amount, accountUUID, db, c) + if err != nil { + c.AbortWithError(http.StatusInternalServerError, fmt.Errorf("insert new transaction: %w", err)) + return + } + + err = tx.Commit() + if err != nil { + c.AbortWithError(http.StatusInternalServerError, fmt.Errorf("commit: %w", err)) + return + } + + c.JSON(http.StatusOK, ReconcileTransactionsResponse{ + Message: fmt.Sprintf("Set status for %d transactions", len(request.TransactionIDs)), + ReconciliationTransaction: reconciliationTransaction, + }) +} + +func (*Handler) CreateReconcilationTransaction(amount numeric.Numeric, accountUUID uuid.UUID, db *postgres.Queries, c *gin.Context) (*postgres.DisplayTransaction, error) { + if amount.IsZero() { + return nil, nil //nolint: nilnil + } + + createTransaction := postgres.CreateTransactionParams{ + Date: time.Now(), + Memo: "Reconciliation Transaction", + Amount: amount, + AccountID: accountUUID, + Status: "Reconciled", + } + transactionUUID, err := db.CreateTransaction(c.Request.Context(), createTransaction) + if err != nil { + return nil, fmt.Errorf("insert new transaction: %w", err) + } + + transaction, err := db.GetTransaction(c.Request.Context(), transactionUUID) + if err != nil { + return nil, fmt.Errorf("get created transaction: %w", err) + } + + return &transaction, nil +} diff --git a/server/transaction.go b/server/transaction.go index 08917d7..6dc632f 100644 --- a/server/transaction.go +++ b/server/transaction.go @@ -70,11 +70,18 @@ func (h *Handler) newTransaction(c *gin.Context) { newTransaction.PayeeID = payeeID } - transaction, err := h.Service.CreateTransaction(c.Request.Context(), newTransaction) + transactionUUID, err := h.Service.CreateTransaction(c.Request.Context(), newTransaction) if err != nil { c.AbortWithError(http.StatusInternalServerError, fmt.Errorf("create transaction: %w", err)) return } + + transaction, err := h.Service.GetTransaction(c.Request.Context(), transactionUUID) + if err != nil { + c.AbortWithError(http.StatusInternalServerError, fmt.Errorf("get transaction: %w", err)) + return + } + c.JSON(http.StatusOK, transaction) } @@ -100,7 +107,16 @@ func (h *Handler) UpdateTransaction(payload NewTransactionPayload, amount numeri err := h.Service.UpdateTransaction(c.Request.Context(), editTransaction) if err != nil { c.AbortWithError(http.StatusInternalServerError, fmt.Errorf("edit transaction: %w", err)) + return } + + transaction, err := h.Service.GetTransaction(c.Request.Context(), transactionUUID) + if err != nil { + c.AbortWithError(http.StatusInternalServerError, fmt.Errorf("get transaction: %w", err)) + return + } + + c.JSON(http.StatusOK, transaction) } func (h *Handler) CreateTransferForOtherAccount(newTransaction postgres.CreateTransactionParams, amount numeric.Numeric, payload NewTransactionPayload, c *gin.Context) error { diff --git a/web/src/components/TransactionEditRow.vue b/web/src/components/TransactionEditRow.vue index b0bce81..e66a913 100644 --- a/web/src/components/TransactionEditRow.vue +++ b/web/src/components/TransactionEditRow.vue @@ -8,6 +8,8 @@ const props = defineProps<{ transactionid: string }>() +const emit = defineEmits(["save"]); + const accountStore = useAccountStore(); const TX = accountStore.Transactions.get(props.transactionid)!; const payeeType = ref(undefined); @@ -28,6 +30,7 @@ const payload = computed(() => JSON.stringify({ function saveTransaction(e: MouseEvent) { e.preventDefault(); accountStore.editTransaction(TX.ID, payload.value); + emit('save'); } diff --git a/web/src/components/TransactionRow.vue b/web/src/components/TransactionRow.vue index f209b22..a022774 100644 --- a/web/src/components/TransactionRow.vue +++ b/web/src/components/TransactionRow.vue @@ -1,7 +1,7 @@