diff --git a/internal/handler/space.go b/internal/handler/space.go index f5ed049..c81a196 100644 --- a/internal/handler/space.go +++ b/internal/handler/space.go @@ -302,6 +302,119 @@ func (h *spaceHandler) SpaceCreateBillPage(w http.ResponseWriter, r *http.Reques })) } +func (h *spaceHandler) SpaceCreateDepositPage(w http.ResponseWriter, r *http.Request) { + spaceID := r.PathValue("spaceID") + accountID := r.PathValue("accountID") + + account, err := h.accountService.GetAccount(accountID) + if err != nil { + slog.Error("failed to load account", "error", err, "account_id", accountID) + ui.Render(w, r, pages.NotFound()) + return + } + if account.SpaceID != spaceID { + ui.Render(w, r, pages.NotFound()) + return + } + + ui.Render(w, r, pages.SpaceCreateDepositPage(pages.SpaceCreateDepositPageProps{ + SpaceID: spaceID, + AccountID: accountID, + AccountName: account.Name, + Form: forms.CreateDepositProps{ + SpaceID: spaceID, + AccountID: accountID, + Date: time.Now().Format("2006-01-02"), + }, + })) +} + +func (h *spaceHandler) HandleCreateDeposit(w http.ResponseWriter, r *http.Request) { + spaceID := r.PathValue("spaceID") + accountID := r.PathValue("accountID") + + titleInput := strings.TrimSpace(r.FormValue("title")) + amountInput := strings.TrimSpace(r.FormValue("amount")) + dateInput := strings.TrimSpace(r.FormValue("date")) + descriptionInput := strings.TrimSpace(r.FormValue("description")) + + formProps := forms.CreateDepositProps{ + SpaceID: spaceID, + AccountID: accountID, + Title: titleInput, + Amount: amountInput, + Date: dateInput, + Description: descriptionInput, + } + + hasErr := false + if titleInput == "" { + formProps.TitleErr = "Title is required." + hasErr = true + } + + var amount decimal.Decimal + if amountInput == "" { + formProps.AmountErr = "Amount is required." + hasErr = true + } else { + amt, err := decimal.NewFromString(amountInput) + if err != nil { + formProps.AmountErr = "Enter a valid amount (e.g. 12.34)." + hasErr = true + } else if !amt.IsPositive() { + formProps.AmountErr = "Amount must be greater than zero." + hasErr = true + } else if amt.Exponent() < -2 { + formProps.AmountErr = "Amount can have at most 2 decimal places." + hasErr = true + } else { + amount = amt + } + } + + var occurredAt time.Time + if dateInput == "" { + formProps.DateErr = "Date is required." + hasErr = true + } else { + parsed, err := time.Parse("2006-01-02", dateInput) + if err != nil { + formProps.DateErr = "Enter a valid date." + hasErr = true + } else { + occurredAt = parsed + } + } + + if hasErr { + ui.Render(w, r, forms.CreateDeposit(formProps)) + return + } + + _, err := h.transactionService.Deposit(service.DepositInput{ + AccountID: accountID, + Title: titleInput, + Amount: amount, + OccurredAt: occurredAt, + Description: descriptionInput, + }) + if err != nil { + slog.Error("failed to create deposit", "error", err, "account_id", accountID) + formProps.GeneralErr = "Something went wrong. Please try again." + ui.Render(w, r, forms.CreateDeposit(formProps)) + return + } + + redirectTo := routeurl.URL( + "page.app.spaces.space.accounts.account.overview", + "spaceID", spaceID, + "accountID", accountID, + ) + 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 e8bdfe6..6d50f1e 100644 --- a/internal/repository/transaction.go +++ b/internal/repository/transaction.go @@ -10,6 +10,7 @@ import ( type TransactionRepository interface { CreateBillAtomic(t *model.Transaction, newBalance decimal.Decimal, categoryID *string) error + CreateDepositAtomic(t *model.Transaction, newBalance decimal.Decimal) error ListByAccount(accountID string, limit, offset int) ([]*model.Transaction, error) CountByAccount(accountID string) (int, error) } @@ -54,6 +55,30 @@ func (r *transactionRepository) CreateBillAtomic(t *model.Transaction, newBalanc }) } +func (r *transactionRepository) CreateDepositAtomic(t *model.Transaction, newBalance decimal.Decimal) error { + return WithTx(r.db, func(tx *sqlx.Tx) error { + insertTxn := ` + INSERT INTO transactions + (id, value, type, account_id, title, description, occurred_at, created_at, updated_at) + VALUES + ($1, $2, $3, $4, $5, $6, $7, $8, $9); + ` + if _, err := tx.Exec( + insertTxn, + t.ID, t.Value, t.Type, t.AccountID, t.Title, t.Description, + t.OccurredAt, t.CreatedAt, t.UpdatedAt, + ); 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) 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 ae86ccc..8c36e82 100644 --- a/internal/routes/routes.go +++ b/internal/routes/routes.go @@ -98,6 +98,8 @@ func SetupRoutes(a *app.App) http.Handler { g.Get("/transactions", spaceH.SpaceAccountTransactionsPage).Name("page.app.spaces.space.accounts.account.transactions") g.Get("/bills/create", spaceH.SpaceCreateBillPage).Name("page.app.spaces.space.accounts.account.bills.create") g.Post("/bills/create", spaceH.HandleCreateBill).Name("action.app.spaces.space.accounts.account.bills.create") + g.Get("/deposits/create", spaceH.SpaceCreateDepositPage).Name("page.app.spaces.space.accounts.account.deposits.create") + g.Post("/deposits/create", spaceH.HandleCreateDeposit).Name("action.app.spaces.space.accounts.account.deposits.create") }) }) }) diff --git a/internal/service/transaction.go b/internal/service/transaction.go index fd2caab..0083f58 100644 --- a/internal/service/transaction.go +++ b/internal/service/transaction.go @@ -89,6 +89,61 @@ func (s *TransactionService) PayBill(input PayBillInput) (*model.Transaction, er return txn, nil } +type DepositInput struct { + AccountID string + Title string + Amount decimal.Decimal + OccurredAt time.Time + Description string +} + +func (s *TransactionService) Deposit(input DepositInput) (*model.Transaction, error) { + title := strings.TrimSpace(input.Title) + if title == "" { + return nil, fmt.Errorf("title is required") + } + if input.AccountID == "" { + return nil, fmt.Errorf("account 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") + } + + account, err := s.accountService.GetAccount(input.AccountID) + if err != nil { + return nil, fmt.Errorf("failed to load account: %w", err) + } + + newBalance := account.Balance.Add(input.Amount) + + now := time.Now() + var description *string + if d := strings.TrimSpace(input.Description); d != "" { + description = &d + } + + txn := &model.Transaction{ + ID: uuid.NewString(), + Value: input.Amount, + Type: model.TransactionTypeDeposit, + AccountID: input.AccountID, + Title: title, + Description: description, + OccurredAt: input.OccurredAt, + CreatedAt: now, + UpdatedAt: now, + } + + if err := s.transactionRepo.CreateDepositAtomic(txn, newBalance); err != nil { + return nil, fmt.Errorf("failed to create deposit transaction: %w", err) + } + + return txn, nil +} + func (s *TransactionService) ListByAccount(accountID string, limit, offset int) ([]*model.Transaction, error) { if limit <= 0 { limit = 25 diff --git a/internal/ui/forms/create_deposit.templ b/internal/ui/forms/create_deposit.templ new file mode 100644 index 0000000..49a53f5 --- /dev/null +++ b/internal/ui/forms/create_deposit.templ @@ -0,0 +1,134 @@ +package forms + +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/card" +import "git.juancwu.dev/juancwu/budgit/internal/ui/components/form" +import "git.juancwu.dev/juancwu/budgit/internal/ui/components/input" +import "git.juancwu.dev/juancwu/budgit/internal/ui/components/textarea" + +type CreateDepositProps struct { + SpaceID string + AccountID string + + Title string + Amount string + Date string + Description string + + TitleErr string + AmountErr string + DateErr string + GeneralErr string +} + +templ CreateDeposit(props CreateDepositProps) { +
+} diff --git a/internal/ui/pages/space_account.templ b/internal/ui/pages/space_account.templ index 0fa3d6e..feaf30c 100644 --- a/internal/ui/pages/space_account.templ +++ b/internal/ui/pages/space_account.templ @@ -61,6 +61,7 @@ templ SpaceAccountPage(props SpaceAccountPageProps) { @button.Button(button.Props{ Class: "w-full flex gap-2 md:gap-4 items-center", Variant: button.VariantSecondary, + Href: routeurl.URL("page.app.spaces.space.accounts.account.deposits.create", "spaceID", props.SpaceID, "accountID", props.AccountID), }) { Deposit Funds @icon.BanknoteArrowDown() diff --git a/internal/ui/pages/space_account_transactions.templ b/internal/ui/pages/space_account_transactions.templ index adcda0f..5489f6b 100644 --- a/internal/ui/pages/space_account_transactions.templ +++ b/internal/ui/pages/space_account_transactions.templ @@ -42,6 +42,7 @@ templ SpaceAccountTransactionsPage(props SpaceAccountTransactionsPageProps) { } @button.Button(button.Props{ Variant: button.VariantSecondary, + Href: routeurl.URL("page.app.spaces.space.accounts.account.deposits.create", "spaceID", props.SpaceID, "accountID", props.AccountID), Class: "flex gap-2 items-center", }) { @icon.BanknoteArrowDown() diff --git a/internal/ui/pages/space_create_deposit.templ b/internal/ui/pages/space_create_deposit.templ index a4d8ce0..24eb76c 100644 --- a/internal/ui/pages/space_create_deposit.templ +++ b/internal/ui/pages/space_create_deposit.templ @@ -1,4 +1,25 @@ package pages -templ SpaceCreateDeposit() { +import "git.juancwu.dev/juancwu/budgit/internal/ui/forms" +import "git.juancwu.dev/juancwu/budgit/internal/ui/layouts" + +type SpaceCreateDepositPageProps struct { + SpaceID string + AccountID string + AccountName string + Form forms.CreateDepositProps +} + +templ SpaceCreateDepositPage(props SpaceCreateDepositPageProps) { + @layouts.App("Deposit Funds", spaceOverviewSidebarContent(), spaceSpecificSidebarContent(props.SpaceID)) { ++ Record a deposit into { props.AccountName }. +
+