diff --git a/internal/handler/space.go b/internal/handler/space.go index 0373a28..7737ce0 100644 --- a/internal/handler/space.go +++ b/internal/handler/space.go @@ -972,6 +972,287 @@ func (h *spaceHandler) HandleCreateDeposit(w http.ResponseWriter, r *http.Reques w.WriteHeader(http.StatusOK) } +func (h *spaceHandler) SpaceTransactionPage(w http.ResponseWriter, r *http.Request) { + spaceID := r.PathValue("spaceID") + accountID := r.PathValue("accountID") + transactionID := r.PathValue("transactionID") + + account, err := h.accountService.GetAccount(accountID) + if err != nil || account.SpaceID != spaceID { + ui.Render(w, r, pages.NotFound()) + return + } + + txn, err := h.transactionService.GetTransaction(transactionID) + if err != nil || txn.AccountID != accountID { + ui.Render(w, r, pages.NotFound()) + return + } + + space, err := h.spaceService.GetSpace(spaceID) + if err != nil { + slog.Error("failed to load space", "error", err, "space_id", spaceID) + ui.RenderError(w, r, "Failed to load page", http.StatusInternalServerError) + return + } + + categoryName := "" + if txn.Type == model.TransactionTypeWithdrawal { + categoryID, err := h.transactionService.GetTransactionCategoryID(transactionID) + if err != nil { + slog.Error("failed to load transaction category", "error", err, "transaction_id", transactionID) + } else if categoryID != "" { + categories, err := h.transactionService.ListCategories() + if err != nil { + slog.Error("failed to load categories", "error", err) + } else { + for _, c := range categories { + if c.ID == categoryID { + categoryName = c.Name + break + } + } + } + } + } + + ui.Render(w, r, pages.SpaceTransactionPage(pages.SpaceTransactionPageProps{ + SpaceID: spaceID, + SpaceName: space.Name, + AccountID: accountID, + AccountName: account.Name, + Transaction: txn, + CategoryName: categoryName, + })) +} + +func (h *spaceHandler) SpaceEditTransactionPage(w http.ResponseWriter, r *http.Request) { + spaceID := r.PathValue("spaceID") + accountID := r.PathValue("accountID") + transactionID := r.PathValue("transactionID") + + account, err := h.accountService.GetAccount(accountID) + if err != nil || account.SpaceID != spaceID { + ui.Render(w, r, pages.NotFound()) + return + } + + txn, err := h.transactionService.GetTransaction(transactionID) + if err != nil || txn.AccountID != accountID { + ui.Render(w, r, pages.NotFound()) + return + } + + space, err := h.spaceService.GetSpace(spaceID) + if err != nil { + slog.Error("failed to load space", "error", err, "space_id", spaceID) + ui.RenderError(w, r, "Failed to load page", http.StatusInternalServerError) + return + } + + description := "" + if txn.Description != nil { + description = *txn.Description + } + + pageProps := pages.SpaceEditTransactionPageProps{ + SpaceID: spaceID, + SpaceName: space.Name, + AccountID: accountID, + AccountName: account.Name, + TransactionType: txn.Type, + } + + if txn.Type == model.TransactionTypeDeposit { + pageProps.DepositForm = forms.EditDepositProps{ + SpaceID: spaceID, + AccountID: accountID, + TransactionID: transactionID, + Title: txn.Title, + Amount: txn.Value.StringFixedBank(2), + Date: txn.OccurredAt.Format("2006-01-02"), + Description: description, + } + } else { + categories, err := h.transactionService.ListCategories() + if err != nil { + slog.Error("failed to load categories", "error", err) + ui.RenderError(w, r, "Failed to load categories", http.StatusInternalServerError) + return + } + categoryID, err := h.transactionService.GetTransactionCategoryID(transactionID) + if err != nil { + slog.Error("failed to load transaction category", "error", err, "transaction_id", transactionID) + categoryID = "" + } + pageProps.BillForm = forms.EditBillProps{ + SpaceID: spaceID, + AccountID: accountID, + TransactionID: transactionID, + Categories: categories, + Title: txn.Title, + Amount: txn.Value.StringFixedBank(2), + Date: txn.OccurredAt.Format("2006-01-02"), + Description: description, + CategoryID: categoryID, + } + } + + ui.Render(w, r, pages.SpaceEditTransactionPage(pageProps)) +} + +func (h *spaceHandler) HandleEditTransaction(w http.ResponseWriter, r *http.Request) { + spaceID := r.PathValue("spaceID") + accountID := r.PathValue("accountID") + transactionID := r.PathValue("transactionID") + + account, err := h.accountService.GetAccount(accountID) + if err != nil || account.SpaceID != spaceID { + ui.RenderError(w, r, "Account not found", http.StatusNotFound) + return + } + + txn, err := h.transactionService.GetTransaction(transactionID) + if err != nil || txn.AccountID != accountID { + ui.RenderError(w, r, "Transaction not found", http.StatusNotFound) + return + } + + titleInput := strings.TrimSpace(r.FormValue("title")) + amountInput := strings.TrimSpace(r.FormValue("amount")) + dateInput := strings.TrimSpace(r.FormValue("date")) + descriptionInput := strings.TrimSpace(r.FormValue("description")) + + titleErr, amountErr, dateErr := "", "", "" + hasErr := false + + if titleInput == "" { + titleErr = "Title is required." + hasErr = true + } + + var amount decimal.Decimal + if amountInput == "" { + amountErr = "Amount is required." + hasErr = true + } else { + amt, err := decimal.NewFromString(amountInput) + if err != nil { + amountErr = "Enter a valid amount (e.g. 12.34)." + hasErr = true + } else if !amt.IsPositive() { + amountErr = "Amount must be greater than zero." + hasErr = true + } else if amt.Exponent() < -2 { + amountErr = "Amount can have at most 2 decimal places." + hasErr = true + } else { + amount = amt + } + } + + var occurredAt time.Time + if dateInput == "" { + dateErr = "Date is required." + hasErr = true + } else { + parsed, err := time.Parse("2006-01-02", dateInput) + if err != nil { + dateErr = "Enter a valid date." + hasErr = true + } else { + occurredAt = parsed + } + } + + if txn.Type == model.TransactionTypeDeposit { + formProps := forms.EditDepositProps{ + SpaceID: spaceID, + AccountID: accountID, + TransactionID: transactionID, + Title: titleInput, + Amount: amountInput, + Date: dateInput, + Description: descriptionInput, + TitleErr: titleErr, + AmountErr: amountErr, + DateErr: dateErr, + } + if hasErr { + ui.Render(w, r, forms.EditDeposit(formProps)) + return + } + if _, err := h.transactionService.UpdateDeposit(service.UpdateDepositInput{ + TransactionID: transactionID, + Title: titleInput, + Amount: amount, + OccurredAt: occurredAt, + Description: descriptionInput, + }); err != nil { + slog.Error("failed to update deposit", "error", err, "transaction_id", transactionID) + formProps.GeneralErr = "Something went wrong. Please try again." + ui.Render(w, r, forms.EditDeposit(formProps)) + return + } + redirectTo := routeurl.URL( + "page.app.spaces.space.accounts.account.transactions.transaction", + "spaceID", spaceID, + "accountID", accountID, + "transactionID", transactionID, + ) + w.Header().Set("HX-Redirect", redirectTo) + w.WriteHeader(http.StatusOK) + return + } + + categoryInput := strings.TrimSpace(r.FormValue("category")) + categories, err := h.transactionService.ListCategories() + if err != nil { + slog.Error("failed to load categories", "error", err) + ui.RenderError(w, r, "Failed to load categories", http.StatusInternalServerError) + return + } + formProps := forms.EditBillProps{ + SpaceID: spaceID, + AccountID: accountID, + TransactionID: transactionID, + Categories: categories, + Title: titleInput, + Amount: amountInput, + Date: dateInput, + Description: descriptionInput, + CategoryID: categoryInput, + TitleErr: titleErr, + AmountErr: amountErr, + DateErr: dateErr, + } + if hasErr { + ui.Render(w, r, forms.EditBill(formProps)) + return + } + if _, err := h.transactionService.UpdateBill(service.UpdateBillInput{ + TransactionID: transactionID, + Title: titleInput, + Amount: amount, + OccurredAt: occurredAt, + Description: descriptionInput, + CategoryID: categoryInput, + }); err != nil { + slog.Error("failed to update bill", "error", err, "transaction_id", transactionID) + formProps.GeneralErr = "Something went wrong. Please try again." + ui.Render(w, r, forms.EditBill(formProps)) + return + } + redirectTo := routeurl.URL( + "page.app.spaces.space.accounts.account.transactions.transaction", + "spaceID", spaceID, + "accountID", accountID, + "transactionID", transactionID, + ) + w.Header().Set("HX-Redirect", redirectTo) + w.WriteHeader(http.StatusOK) +} + func (h *spaceHandler) HandleCreateBill(w http.ResponseWriter, r *http.Request) { spaceID := r.PathValue("spaceID") accountID := r.PathValue("accountID") diff --git a/internal/repository/transaction.go b/internal/repository/transaction.go index 6d50f1e..f3aaee6 100644 --- a/internal/repository/transaction.go +++ b/internal/repository/transaction.go @@ -1,6 +1,7 @@ package repository import ( + "database/sql" "time" "git.juancwu.dev/juancwu/budgit/internal/model" @@ -11,6 +12,10 @@ import ( type TransactionRepository interface { CreateBillAtomic(t *model.Transaction, newBalance decimal.Decimal, categoryID *string) error CreateDepositAtomic(t *model.Transaction, newBalance decimal.Decimal) error + UpdateBillAtomic(t *model.Transaction, newBalance decimal.Decimal, categoryID *string) error + UpdateDepositAtomic(t *model.Transaction, newBalance decimal.Decimal) error + GetByID(id string) (*model.Transaction, error) + GetCategoryID(transactionID string) (*string, error) ListByAccount(accountID string, limit, offset int) ([]*model.Transaction, error) CountByAccount(accountID string) (int, error) } @@ -79,6 +84,85 @@ func (r *transactionRepository) CreateDepositAtomic(t *model.Transaction, newBal }) } +func (r *transactionRepository) UpdateBillAtomic(t *model.Transaction, newBalance decimal.Decimal, categoryID *string) error { + return WithTx(r.db, func(tx *sqlx.Tx) error { + updateTxn := ` + UPDATE transactions + SET value = $1, title = $2, description = $3, occurred_at = $4, updated_at = $5 + WHERE id = $6; + ` + if _, err := tx.Exec( + updateTxn, + t.Value, t.Title, t.Description, t.OccurredAt, t.UpdatedAt, t.ID, + ); err != nil { + return err + } + + updateBalance := `UPDATE accounts SET balance = $1, updated_at = $2 WHERE id = $3;` + if _, err := tx.Exec(updateBalance, newBalance, time.Now(), t.AccountID); err != nil { + return err + } + + if _, err := tx.Exec(`DELETE FROM transaction_categories WHERE transaction_id = $1;`, t.ID); err != nil { + return err + } + if categoryID != nil && *categoryID != "" { + linkCategory := `INSERT INTO transaction_categories (category_id, transaction_id) VALUES ($1, $2);` + if _, err := tx.Exec(linkCategory, *categoryID, t.ID); err != nil { + return err + } + } + return nil + }) +} + +func (r *transactionRepository) UpdateDepositAtomic(t *model.Transaction, newBalance decimal.Decimal) error { + return WithTx(r.db, func(tx *sqlx.Tx) error { + updateTxn := ` + UPDATE transactions + SET value = $1, title = $2, description = $3, occurred_at = $4, updated_at = $5 + WHERE id = $6; + ` + if _, err := tx.Exec( + updateTxn, + t.Value, t.Title, t.Description, t.OccurredAt, t.UpdatedAt, t.ID, + ); err != nil { + return err + } + + updateBalance := `UPDATE accounts SET balance = $1, updated_at = $2 WHERE id = $3;` + if _, err := tx.Exec(updateBalance, newBalance, time.Now(), t.AccountID); err != nil { + return err + } + return nil + }) +} + +func (r *transactionRepository) GetByID(id string) (*model.Transaction, error) { + query := ` + SELECT id, value, type, account_id, title, description, occurred_at, created_at, updated_at + FROM transactions + WHERE id = $1; + ` + t := &model.Transaction{} + if err := r.db.Get(t, query, id); err != nil { + return nil, err + } + return t, nil +} + +func (r *transactionRepository) GetCategoryID(transactionID string) (*string, error) { + var id string + err := r.db.Get(&id, `SELECT category_id FROM transaction_categories WHERE transaction_id = $1 LIMIT 1;`, transactionID) + if err != nil { + if err == sql.ErrNoRows { + return nil, nil + } + return nil, err + } + return &id, nil +} + func (r *transactionRepository) ListByAccount(accountID string, limit, offset int) ([]*model.Transaction, error) { query := ` SELECT id, value, type, account_id, title, description, occurred_at, created_at, updated_at diff --git a/internal/routes/routes.go b/internal/routes/routes.go index b4e9874..e2a2955 100644 --- a/internal/routes/routes.go +++ b/internal/routes/routes.go @@ -109,6 +109,9 @@ func SetupRoutes(a *app.App) http.Handler { g.SubGroup("/accounts/{accountID}", func(g *router.Group) { g.Get("/overview", spaceH.SpaceAccountPage).Name("page.app.spaces.space.accounts.account.overview") g.Get("/transactions", spaceH.SpaceAccountTransactionsPage).Name("page.app.spaces.space.accounts.account.transactions") + g.Get("/transactions/{transactionID}", spaceH.SpaceTransactionPage).Name("page.app.spaces.space.accounts.account.transactions.transaction") + g.Get("/transactions/{transactionID}/edit", spaceH.SpaceEditTransactionPage).Name("page.app.spaces.space.accounts.account.transactions.transaction.edit") + g.Post("/transactions/{transactionID}/edit", spaceH.HandleEditTransaction).Name("action.app.spaces.space.accounts.account.transactions.transaction.edit") g.Get("/settings", spaceH.SpaceAccountSettingsPage).Name("page.app.spaces.space.accounts.account.settings") g.Post("/settings/rename", spaceH.HandleRenameAccount).Name("action.app.spaces.space.accounts.account.settings.rename") g.Post("/settings/delete", spaceH.HandleDeleteAccount).Name("action.app.spaces.space.accounts.account.settings.delete") diff --git a/internal/service/transaction.go b/internal/service/transaction.go index 0083f58..3a7dc76 100644 --- a/internal/service/transaction.go +++ b/internal/service/transaction.go @@ -144,6 +144,140 @@ func (s *TransactionService) Deposit(input DepositInput) (*model.Transaction, er return txn, nil } +type UpdateBillInput struct { + TransactionID string + Title string + Amount decimal.Decimal + OccurredAt time.Time + Description string + CategoryID string +} + +func (s *TransactionService) UpdateBill(input UpdateBillInput) (*model.Transaction, error) { + title := strings.TrimSpace(input.Title) + if title == "" { + return nil, fmt.Errorf("title is required") + } + if input.TransactionID == "" { + return nil, fmt.Errorf("transaction id is required") + } + if !input.Amount.IsPositive() { + return nil, fmt.Errorf("amount must be greater than zero") + } + if input.OccurredAt.IsZero() { + return nil, fmt.Errorf("date is required") + } + + existing, err := s.transactionRepo.GetByID(input.TransactionID) + if err != nil { + return nil, fmt.Errorf("failed to load transaction: %w", err) + } + if existing.Type != model.TransactionTypeWithdrawal { + return nil, fmt.Errorf("transaction is not a bill") + } + + account, err := s.accountService.GetAccount(existing.AccountID) + if err != nil { + return nil, fmt.Errorf("failed to load account: %w", err) + } + + newBalance := account.Balance.Add(existing.Value).Sub(input.Amount) + + var description *string + if d := strings.TrimSpace(input.Description); d != "" { + description = &d + } + var categoryID *string + if c := strings.TrimSpace(input.CategoryID); c != "" { + categoryID = &c + } + + existing.Value = input.Amount + existing.Title = title + existing.Description = description + existing.OccurredAt = input.OccurredAt + existing.UpdatedAt = time.Now() + + if err := s.transactionRepo.UpdateBillAtomic(existing, newBalance, categoryID); err != nil { + return nil, fmt.Errorf("failed to update bill transaction: %w", err) + } + return existing, nil +} + +type UpdateDepositInput struct { + TransactionID string + Title string + Amount decimal.Decimal + OccurredAt time.Time + Description string +} + +func (s *TransactionService) UpdateDeposit(input UpdateDepositInput) (*model.Transaction, error) { + title := strings.TrimSpace(input.Title) + if title == "" { + return nil, fmt.Errorf("title is required") + } + if input.TransactionID == "" { + return nil, fmt.Errorf("transaction id is required") + } + if !input.Amount.IsPositive() { + return nil, fmt.Errorf("amount must be greater than zero") + } + if input.OccurredAt.IsZero() { + return nil, fmt.Errorf("date is required") + } + + existing, err := s.transactionRepo.GetByID(input.TransactionID) + if err != nil { + return nil, fmt.Errorf("failed to load transaction: %w", err) + } + if existing.Type != model.TransactionTypeDeposit { + return nil, fmt.Errorf("transaction is not a deposit") + } + + account, err := s.accountService.GetAccount(existing.AccountID) + if err != nil { + return nil, fmt.Errorf("failed to load account: %w", err) + } + + newBalance := account.Balance.Sub(existing.Value).Add(input.Amount) + + var description *string + if d := strings.TrimSpace(input.Description); d != "" { + description = &d + } + + existing.Value = input.Amount + existing.Title = title + existing.Description = description + existing.OccurredAt = input.OccurredAt + existing.UpdatedAt = time.Now() + + if err := s.transactionRepo.UpdateDepositAtomic(existing, newBalance); err != nil { + return nil, fmt.Errorf("failed to update deposit transaction: %w", err) + } + return existing, nil +} + +func (s *TransactionService) GetTransaction(id string) (*model.Transaction, error) { + txn, err := s.transactionRepo.GetByID(id) + if err != nil { + return nil, fmt.Errorf("failed to load transaction: %w", err) + } + return txn, nil +} + +func (s *TransactionService) GetTransactionCategoryID(transactionID string) (string, error) { + id, err := s.transactionRepo.GetCategoryID(transactionID) + if err != nil { + return "", fmt.Errorf("failed to load transaction category: %w", err) + } + if id == nil { + return "", nil + } + return *id, nil +} + func (s *TransactionService) ListByAccount(accountID string, limit, offset int) ([]*model.Transaction, error) { if limit <= 0 { limit = 25 diff --git a/internal/ui/blocks/transaction_list.templ b/internal/ui/blocks/transaction_list.templ index ef61397..5df15bf 100644 --- a/internal/ui/blocks/transaction_list.templ +++ b/internal/ui/blocks/transaction_list.templ @@ -1,24 +1,32 @@ package blocks import "git.juancwu.dev/juancwu/budgit/internal/model" +import "git.juancwu.dev/juancwu/budgit/internal/routeurl" +import "git.juancwu.dev/juancwu/budgit/internal/ui/components/button" import "git.juancwu.dev/juancwu/budgit/internal/ui/components/icon" import "git.juancwu.dev/juancwu/budgit/internal/ui/utils" -templ TransactionList(txns []*model.Transaction) { - if len(txns) == 0 { +type TransactionListProps struct { + SpaceID string + AccountID string + Transactions []*model.Transaction +} + +templ TransactionList(props TransactionListProps) { + if len(props.Transactions) == 0 {
{ t.Title }
+ if spaceID != "" && accountID != "" { + + { t.Title } + + } else { +{ t.Title }
+ }{ t.OccurredAt.Format("Jan 2, 2006") }
- { sign }${ utils.FormatDecimalWithThousands(t.Value.StringFixedBank(2)) } -
- if t.Description != nil && *t.Description != "" { -{ *t.Description }
++ { sign }${ utils.FormatDecimalWithThousands(t.Value.StringFixedBank(2)) } +
+ if t.Description != nil && *t.Description != "" { +{ *t.Description }
+ } ++ Update the details of this transaction in { props.AccountName }. +
++ { label } in { props.AccountName } +
+Amount
++ { sign }${ utils.FormatDecimalWithThousands(props.Transaction.Value.StringFixedBank(2)) } +
+Date
+{ props.Transaction.OccurredAt.Format("January 2, 2006") }
+Type
+{ label }
+Category
+ if props.CategoryName != "" { +{ props.CategoryName }
+ } else { +Uncategorized
+ } +Last updated
+{ props.Transaction.UpdatedAt.Format("Jan 2, 2006 3:04 PM") }
+Description
+{ *props.Transaction.Description }
+