diff --git a/internal/app/app.go b/internal/app/app.go
index 6c823bd..db346ca 100644
--- a/internal/app/app.go
+++ b/internal/app/app.go
@@ -11,14 +11,15 @@ import (
)
type App struct {
- Cfg *config.Config
- DB *sqlx.DB
- UserService *service.UserService
- AuthService *service.AuthService
- EmailService *service.EmailService
- SpaceService *service.SpaceService
- AccountService *service.AccountService
- InviteService *service.InviteService
+ Cfg *config.Config
+ DB *sqlx.DB
+ UserService *service.UserService
+ AuthService *service.AuthService
+ EmailService *service.EmailService
+ SpaceService *service.SpaceService
+ AccountService *service.AccountService
+ TransactionService *service.TransactionService
+ InviteService *service.InviteService
}
func New(cfg *config.Config) (*App, error) {
@@ -39,12 +40,15 @@ func New(cfg *config.Config) (*App, error) {
tokenRepository := repository.NewTokenRepository(database)
spaceRepository := repository.NewSpaceRepository(database)
accountRepository := repository.NewAccountRepository(database)
+ transactionRepository := repository.NewTransactionRepository(database)
+ categoryRepository := repository.NewCategoryRepository(database)
invitationRepository := repository.NewInvitationRepository(database)
// Services
userService := service.NewUserService(userRepository)
spaceService := service.NewSpaceService(spaceRepository)
accountService := service.NewAccountService(accountRepository)
+ transactionService := service.NewTransactionService(transactionRepository, categoryRepository, accountService)
emailService := service.NewEmailService(
emailClient,
cfg.MailerEmailFrom,
@@ -66,14 +70,15 @@ func New(cfg *config.Config) (*App, error) {
inviteService := service.NewInviteService(invitationRepository, spaceRepository, userRepository, emailService)
return &App{
- Cfg: cfg,
- DB: database,
- UserService: userService,
- AuthService: authService,
- EmailService: emailService,
- SpaceService: spaceService,
- AccountService: accountService,
- InviteService: inviteService,
+ Cfg: cfg,
+ DB: database,
+ UserService: userService,
+ AuthService: authService,
+ EmailService: emailService,
+ SpaceService: spaceService,
+ AccountService: accountService,
+ TransactionService: transactionService,
+ InviteService: inviteService,
}, nil
}
diff --git a/internal/ctxkeys/ctx.go b/internal/ctxkeys/ctx.go
index ee98d82..653d0bc 100644
--- a/internal/ctxkeys/ctx.go
+++ b/internal/ctxkeys/ctx.go
@@ -8,13 +8,13 @@ import (
)
const (
- UserKey string = "user"
+ UserKey string = "user"
- URLPathKey string = "url_path"
- ConfigKey string = "config"
- CSRFTokenKey string = "csrf_token"
- AppVersionKey string = "app_version"
- SidebarCollapsedKey string = "sidebar_collapsed"
+ URLPathKey string = "url_path"
+ ConfigKey string = "config"
+ CSRFTokenKey string = "csrf_token"
+ AppVersionKey string = "app_version"
+ SidebarCollapsedKey string = "sidebar_collapsed"
)
func User(ctx context.Context) *model.User {
@@ -26,7 +26,6 @@ func WithUser(ctx context.Context, user *model.User) context.Context {
return context.WithValue(ctx, UserKey, user)
}
-
func URLPath(ctx context.Context) string {
path, _ := ctx.Value(URLPathKey).(string)
return path
diff --git a/internal/db/migrations/00007_add_transaction_title_and_occurred_at.sql b/internal/db/migrations/00007_add_transaction_title_and_occurred_at.sql
new file mode 100644
index 0000000..a4c2270
--- /dev/null
+++ b/internal/db/migrations/00007_add_transaction_title_and_occurred_at.sql
@@ -0,0 +1,11 @@
+-- +goose Up
+-- +goose StatementBegin
+ALTER TABLE transactions ADD COLUMN title TEXT NOT NULL DEFAULT '';
+ALTER TABLE transactions ADD COLUMN occurred_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP;
+-- +goose StatementEnd
+
+-- +goose Down
+-- +goose StatementBegin
+ALTER TABLE transactions DROP COLUMN occurred_at;
+ALTER TABLE transactions DROP COLUMN title;
+-- +goose StatementEnd
diff --git a/internal/db/migrations/00008_replace_related_transaction_id_with_table.sql b/internal/db/migrations/00008_replace_related_transaction_id_with_table.sql
new file mode 100644
index 0000000..d2484d2
--- /dev/null
+++ b/internal/db/migrations/00008_replace_related_transaction_id_with_table.sql
@@ -0,0 +1,22 @@
+-- +goose Up
+-- +goose StatementBegin
+CREATE TABLE related_transactions (
+ transaction_one_id TEXT NOT NULL REFERENCES transactions(id) ON DELETE CASCADE,
+ transaction_two_id TEXT NOT NULL REFERENCES transactions(id) ON DELETE CASCADE,
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (transaction_one_id, transaction_two_id),
+ UNIQUE (transaction_one_id),
+ UNIQUE (transaction_two_id),
+ CHECK (transaction_one_id < transaction_two_id)
+);
+
+ALTER TABLE transactions DROP COLUMN related_transaction_id;
+-- +goose StatementEnd
+
+-- +goose Down
+-- +goose StatementBegin
+ALTER TABLE transactions ADD COLUMN related_transaction_id TEXT REFERENCES transactions(id) ON DELETE SET NULL;
+
+DROP TABLE related_transactions;
+-- +goose StatementEnd
diff --git a/internal/handler/space.go b/internal/handler/space.go
index b0b519a..c314e85 100644
--- a/internal/handler/space.go
+++ b/internal/handler/space.go
@@ -4,9 +4,11 @@ import (
"log/slog"
"net/http"
"strings"
+ "time"
"git.juancwu.dev/juancwu/budgit/internal/ctxkeys"
"git.juancwu.dev/juancwu/budgit/internal/model"
+ "git.juancwu.dev/juancwu/budgit/internal/routeurl"
"git.juancwu.dev/juancwu/budgit/internal/service"
"git.juancwu.dev/juancwu/budgit/internal/ui"
"git.juancwu.dev/juancwu/budgit/internal/ui/blocks"
@@ -16,12 +18,21 @@ import (
)
type spaceHandler struct {
- spaceService *service.SpaceService
- accountService *service.AccountService
+ spaceService *service.SpaceService
+ accountService *service.AccountService
+ transactionService *service.TransactionService
}
-func NewSpaceHandler(spaceService *service.SpaceService, accountService *service.AccountService) *spaceHandler {
- return &spaceHandler{spaceService: spaceService, accountService: accountService}
+func NewSpaceHandler(
+ spaceService *service.SpaceService,
+ accountService *service.AccountService,
+ transactionService *service.TransactionService,
+) *spaceHandler {
+ return &spaceHandler{
+ spaceService: spaceService,
+ accountService: accountService,
+ transactionService: transactionService,
+ }
}
func (h *spaceHandler) SpacesPage(w http.ResponseWriter, r *http.Request) {
@@ -164,12 +175,154 @@ func (h *spaceHandler) SpaceAccountPage(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.SpaceAccountPage(pages.SpaceAccountPageProps{
- SpaceID: spaceID,
- AccountID: accountID,
- AccountName: "Money Account",
- AccountDescription: "Vault Infinite Priority",
- AccountNumber: "4492",
- AccountBalance: decimal.NewFromFloat(32093.11),
+ SpaceID: spaceID,
+ AccountID: accountID,
+ AccountName: account.Name,
+ AccountBalance: account.Balance,
}))
}
+
+func (h *spaceHandler) SpaceCreateBillPage(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
+ }
+
+ 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
+ }
+
+ ui.Render(w, r, pages.SpaceCreateBillPage(pages.SpaceCreateBillPageProps{
+ SpaceID: spaceID,
+ AccountID: accountID,
+ AccountName: account.Name,
+ Form: forms.CreateBillProps{
+ SpaceID: spaceID,
+ AccountID: accountID,
+ Categories: categories,
+ Date: time.Now().Format("2006-01-02"),
+ },
+ }))
+}
+
+func (h *spaceHandler) HandleCreateBill(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"))
+ 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.CreateBillProps{
+ SpaceID: spaceID,
+ AccountID: accountID,
+ Categories: categories,
+ Title: titleInput,
+ Amount: amountInput,
+ Date: dateInput,
+ Description: descriptionInput,
+ CategoryID: categoryInput,
+ }
+
+ 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.CreateBill(formProps))
+ return
+ }
+
+ _, err = h.transactionService.PayBill(service.PayBillInput{
+ AccountID: accountID,
+ Title: titleInput,
+ Amount: amount,
+ OccurredAt: occurredAt,
+ Description: descriptionInput,
+ CategoryID: categoryInput,
+ })
+ if err != nil {
+ slog.Error("failed to create bill", "error", err, "account_id", accountID)
+ formProps.GeneralErr = "Something went wrong. Please try again."
+ ui.Render(w, r, forms.CreateBill(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)
+}
diff --git a/internal/model/financial_management.go b/internal/model/financial_management.go
index b48bf7c..c8b534b 100644
--- a/internal/model/financial_management.go
+++ b/internal/model/financial_management.go
@@ -23,14 +23,15 @@ const (
)
type Transaction struct {
- ID string `db:"id"`
- Value decimal.Decimal `db:"value"`
- Type TransactionType `db:"type"`
- AccountID string `db:"account_id"`
- Description *string `db:"description"`
- RelatedTransactionID *string `db:"related_transaction_id"`
- CreatedAt time.Time `db:"created_at"`
- UpdatedAt time.Time `db:"updated_at"`
+ ID string `db:"id"`
+ Value decimal.Decimal `db:"value"`
+ Type TransactionType `db:"type"`
+ AccountID string `db:"account_id"`
+ Title string `db:"title"`
+ Description *string `db:"description"`
+ OccurredAt time.Time `db:"occurred_at"`
+ CreatedAt time.Time `db:"created_at"`
+ UpdatedAt time.Time `db:"updated_at"`
}
type Tag struct {
diff --git a/internal/repository/category.go b/internal/repository/category.go
new file mode 100644
index 0000000..8eb92d3
--- /dev/null
+++ b/internal/repository/category.go
@@ -0,0 +1,27 @@
+package repository
+
+import (
+ "git.juancwu.dev/juancwu/budgit/internal/model"
+ "github.com/jmoiron/sqlx"
+)
+
+type CategoryRepository interface {
+ All() ([]*model.Category, error)
+}
+
+type categoryRepository struct {
+ db *sqlx.DB
+}
+
+func NewCategoryRepository(db *sqlx.DB) CategoryRepository {
+ return &categoryRepository{db: db}
+}
+
+func (r *categoryRepository) All() ([]*model.Category, error) {
+ var categories []*model.Category
+ query := `SELECT * FROM categories ORDER BY name ASC;`
+ if err := r.db.Select(&categories, query); err != nil {
+ return nil, err
+ }
+ return categories, nil
+}
diff --git a/internal/repository/transaction.go b/internal/repository/transaction.go
new file mode 100644
index 0000000..11c8ab4
--- /dev/null
+++ b/internal/repository/transaction.go
@@ -0,0 +1,53 @@
+package repository
+
+import (
+ "time"
+
+ "git.juancwu.dev/juancwu/budgit/internal/model"
+ "github.com/jmoiron/sqlx"
+ "github.com/shopspring/decimal"
+)
+
+type TransactionRepository interface {
+ CreateBillAtomic(t *model.Transaction, newBalance decimal.Decimal, categoryID *string) error
+}
+
+type transactionRepository struct {
+ db *sqlx.DB
+}
+
+func NewTransactionRepository(db *sqlx.DB) TransactionRepository {
+ return &transactionRepository{db: db}
+}
+
+func (r *transactionRepository) CreateBillAtomic(t *model.Transaction, newBalance decimal.Decimal, categoryID *string) 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
+ }
+
+ 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
+ })
+}
diff --git a/internal/routes/routes.go b/internal/routes/routes.go
index 4855051..dc7b4c3 100644
--- a/internal/routes/routes.go
+++ b/internal/routes/routes.go
@@ -19,7 +19,7 @@ func SetupRoutes(a *app.App) http.Handler {
authH := handler.NewAuthHandler(a.AuthService, a.InviteService, a.SpaceService)
homeH := handler.NewHomeHandler()
settingsH := handler.NewSettingsHandler(a.AuthService, a.UserService)
- spaceH := handler.NewSpaceHandler(a.SpaceService, a.AccountService)
+ spaceH := handler.NewSpaceHandler(a.SpaceService, a.AccountService, a.TransactionService)
redirectH := handler.NewRedirectHandler()
r := router.New()
@@ -95,6 +95,8 @@ 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("/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")
})
})
})
diff --git a/internal/service/transaction.go b/internal/service/transaction.go
new file mode 100644
index 0000000..f69572b
--- /dev/null
+++ b/internal/service/transaction.go
@@ -0,0 +1,98 @@
+package service
+
+import (
+ "fmt"
+ "strings"
+ "time"
+
+ "git.juancwu.dev/juancwu/budgit/internal/model"
+ "git.juancwu.dev/juancwu/budgit/internal/repository"
+ "github.com/google/uuid"
+ "github.com/shopspring/decimal"
+)
+
+type TransactionService struct {
+ transactionRepo repository.TransactionRepository
+ categoryRepo repository.CategoryRepository
+ accountService *AccountService
+}
+
+func NewTransactionService(
+ transactionRepo repository.TransactionRepository,
+ categoryRepo repository.CategoryRepository,
+ accountService *AccountService,
+) *TransactionService {
+ return &TransactionService{
+ transactionRepo: transactionRepo,
+ categoryRepo: categoryRepo,
+ accountService: accountService,
+ }
+}
+
+type PayBillInput struct {
+ AccountID string
+ Title string
+ Amount decimal.Decimal
+ OccurredAt time.Time
+ Description string
+ CategoryID string
+}
+
+func (s *TransactionService) PayBill(input PayBillInput) (*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.Sub(input.Amount)
+
+ now := time.Now()
+ var description *string
+ if d := strings.TrimSpace(input.Description); d != "" {
+ description = &d
+ }
+ var categoryID *string
+ if c := strings.TrimSpace(input.CategoryID); c != "" {
+ categoryID = &c
+ }
+
+ txn := &model.Transaction{
+ ID: uuid.NewString(),
+ Value: input.Amount,
+ Type: model.TransactionTypeWithdrawal,
+ AccountID: input.AccountID,
+ Title: title,
+ Description: description,
+ OccurredAt: input.OccurredAt,
+ CreatedAt: now,
+ UpdatedAt: now,
+ }
+
+ if err := s.transactionRepo.CreateBillAtomic(txn, newBalance, categoryID); err != nil {
+ return nil, fmt.Errorf("failed to create bill transaction: %w", err)
+ }
+
+ return txn, nil
+}
+
+func (s *TransactionService) ListCategories() ([]*model.Category, error) {
+ categories, err := s.categoryRepo.All()
+ if err != nil {
+ return nil, fmt.Errorf("failed to list categories: %w", err)
+ }
+ return categories, nil
+}
diff --git a/internal/ui/components/icon/icon_data.go b/internal/ui/components/icon/icon_data.go
index 833e6fa..a49dc74 100644
--- a/internal/ui/components/icon/icon_data.go
+++ b/internal/ui/components/icon/icon_data.go
@@ -1415,9 +1415,9 @@ var internalSvgData = map[string]string{
Available Balance
-- Account •••• { props.AccountNumber } -
} } @card.Card(card.Props{Class: "rounded-sm col-span-full md:col-span-4"}) { @@ -57,6 +50,7 @@ templ SpaceAccountPage(props SpaceAccountPageProps) { @button.Button(button.Props{ Class: "w-full flex gap-2 md:gap-4 items-center", Variant: button.VariantDefault, + Href: routeurl.URL("page.app.spaces.space.accounts.account.bills.create", "spaceID", props.SpaceID, "accountID", props.AccountID), }) { Pay Bills @icon.HandCoins() diff --git a/internal/ui/pages/space_create_bill.templ b/internal/ui/pages/space_create_bill.templ index ef342fc..d412ea2 100644 --- a/internal/ui/pages/space_create_bill.templ +++ b/internal/ui/pages/space_create_bill.templ @@ -1,4 +1,25 @@ package pages -templ SpaceCreateBill() { +import "git.juancwu.dev/juancwu/budgit/internal/ui/forms" +import "git.juancwu.dev/juancwu/budgit/internal/ui/layouts" + +type SpaceCreateBillPageProps struct { + SpaceID string + AccountID string + AccountName string + Form forms.CreateBillProps +} + +templ SpaceCreateBillPage(props SpaceCreateBillPageProps) { + @layouts.App("Pay Bills", spaceOverviewSidebarContent(), spaceSpecificSidebarContent(props.SpaceID)) { ++ Record a bill paid from { props.AccountName }. +
+