diff --git a/internal/app/app.go b/internal/app/app.go index 297072f..1014e9a 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -18,6 +18,7 @@ type App struct { EmailService *service.EmailService SpaceService *service.SpaceService AccountService *service.AccountService + AllocationService *service.AllocationService TransactionService *service.TransactionService InviteService *service.InviteService AuditLogService *service.SpaceAuditLogService @@ -43,6 +44,7 @@ func New(cfg *config.Config) (*App, error) { tokenRepository := repository.NewTokenRepository(database) spaceRepository := repository.NewSpaceRepository(database) accountRepository := repository.NewAccountRepository(database) + allocationRepository := repository.NewAllocationRepository(database) transactionRepository := repository.NewTransactionRepository(database) categoryRepository := repository.NewCategoryRepository(database) invitationRepository := repository.NewInvitationRepository(database) @@ -57,6 +59,8 @@ func New(cfg *config.Config) (*App, error) { spaceService.SetAuditLogger(auditLogService) accountService := service.NewAccountService(accountRepository) accountService.SetAuditLogger(auditLogService) + allocationService := service.NewAllocationService(allocationRepository, accountService) + allocationService.SetAuditLogger(auditLogService) transactionService := service.NewTransactionService(transactionRepository, categoryRepository, accountService) transactionService.SetAuditLogger(txAuditLogService) accountActivityService := service.NewAccountActivityService(auditLogService, txAuditLogService) @@ -88,6 +92,7 @@ func New(cfg *config.Config) (*App, error) { EmailService: emailService, SpaceService: spaceService, AccountService: accountService, + AllocationService: allocationService, TransactionService: transactionService, InviteService: inviteService, AuditLogService: auditLogService, diff --git a/internal/db/migrations/00012_create_allocations_table.sql b/internal/db/migrations/00012_create_allocations_table.sql new file mode 100644 index 0000000..6ebb095 --- /dev/null +++ b/internal/db/migrations/00012_create_allocations_table.sql @@ -0,0 +1,21 @@ +-- +goose Up +-- +goose StatementBegin +CREATE TABLE allocations ( + id TEXT PRIMARY KEY NOT NULL, + account_id TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, + name TEXT NOT NULL, + amount TEXT NOT NULL, + target_amount TEXT, + sort_order INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE (account_id, name) +); + +CREATE INDEX idx_allocations_account_id ON allocations (account_id, sort_order); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TABLE allocations; +-- +goose StatementEnd diff --git a/internal/db/migrations/00013_index_allocation_audit_logs.sql b/internal/db/migrations/00013_index_allocation_audit_logs.sql new file mode 100644 index 0000000..2e5370a --- /dev/null +++ b/internal/db/migrations/00013_index_allocation_audit_logs.sql @@ -0,0 +1,15 @@ +-- +goose Up +-- +goose StatementBegin +-- Mirror of idx_space_audit_logs_account_id but for allocation.* actions, so +-- the account-scoped activity feed can OR-merge the two prefixes without a +-- sequential scan over space_audit_logs. + +CREATE INDEX idx_space_audit_logs_allocation_account_id + ON space_audit_logs ((metadata->>'account_id'), created_at DESC) + WHERE action LIKE 'allocation.%'; +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP INDEX IF EXISTS idx_space_audit_logs_allocation_account_id; +-- +goose StatementEnd diff --git a/internal/handler/allocation.go b/internal/handler/allocation.go new file mode 100644 index 0000000..e879db6 --- /dev/null +++ b/internal/handler/allocation.go @@ -0,0 +1,211 @@ +package handler + +import ( + "errors" + "log/slog" + "net/http" + "strings" + + "git.juancwu.dev/juancwu/budgit/internal/ctxkeys" + "git.juancwu.dev/juancwu/budgit/internal/repository" + "git.juancwu.dev/juancwu/budgit/internal/service" + "git.juancwu.dev/juancwu/budgit/internal/ui" + "git.juancwu.dev/juancwu/budgit/internal/ui/blocks" + "github.com/shopspring/decimal" +) + +type allocationHandler struct { + allocationService *service.AllocationService + accountService *service.AccountService +} + +func NewAllocationHandler(allocation *service.AllocationService, account *service.AccountService) *allocationHandler { + return &allocationHandler{allocationService: allocation, accountService: account} +} + +// ensureAccess validates that the account exists and lives in the requested +// space. Returns false (and writes a 404) when access should be denied. +func (h *allocationHandler) ensureAccess(w http.ResponseWriter, r *http.Request, spaceID, accountID string) bool { + account, err := h.accountService.GetAccount(accountID) + if err != nil || account.SpaceID != spaceID { + ui.RenderError(w, r, "Account not found", http.StatusNotFound) + return false + } + return true +} + +func (h *allocationHandler) renderSection(w http.ResponseWriter, r *http.Request, spaceID, accountID string) { + summary, err := h.allocationService.SummaryForAccount(accountID) + if err != nil { + slog.Error("failed to load allocation summary", "error", err, "account_id", accountID) + ui.RenderError(w, r, "Failed to load savings goals", http.StatusInternalServerError) + return + } + ui.Render(w, r, blocks.AllocationsSection(blocks.AllocationsSectionProps{ + SpaceID: spaceID, AccountID: accountID, Summary: summary, + })) +} + +func parseAllocationForm(r *http.Request) (name string, amount decimal.Decimal, target *decimal.Decimal, state blocks.AllocationFormState) { + name = strings.TrimSpace(r.FormValue("name")) + amountInput := strings.TrimSpace(r.FormValue("amount")) + targetInput := strings.TrimSpace(r.FormValue("target_amount")) + + state = blocks.AllocationFormState{ + Name: name, Amount: amountInput, TargetAmount: targetInput, + } + + if name == "" { + state.NameErr = "Name is required." + } + if amountInput == "" { + state.AmountErr = "Amount is required." + } else { + parsed, err := decimal.NewFromString(amountInput) + if err != nil { + state.AmountErr = "Enter a valid number." + } else if parsed.IsNegative() { + state.AmountErr = "Amount cannot be negative." + } else { + amount = parsed + } + } + if targetInput != "" { + parsed, err := decimal.NewFromString(targetInput) + if err != nil { + state.TargetErr = "Enter a valid number." + } else if parsed.IsNegative() { + state.TargetErr = "Goal cannot be negative." + } else { + target = &parsed + } + } + return +} + +func (h *allocationHandler) HandleCreate(w http.ResponseWriter, r *http.Request) { + spaceID := r.PathValue("spaceID") + accountID := r.PathValue("accountID") + if !h.ensureAccess(w, r, spaceID, accountID) { + return + } + + name, amount, target, state := parseAllocationForm(r) + if state.NameErr != "" || state.AmountErr != "" || state.TargetErr != "" { + // Re-render the section with the create form expanded and errors shown. + h.renderSectionWithCreateError(w, r, spaceID, accountID, state) + return + } + + user := ctxkeys.User(r.Context()) + actorID := "" + if user != nil { + actorID = user.ID + } + if _, err := h.allocationService.Create(service.CreateAllocationInput{ + AccountID: accountID, Name: name, Amount: amount, TargetAmount: target, ActorID: actorID, + }); err != nil { + slog.Error("failed to create allocation", "error", err, "account_id", accountID) + state.GeneralErr = friendlyAllocationError(err) + h.renderSectionWithCreateError(w, r, spaceID, accountID, state) + return + } + + h.renderSection(w, r, spaceID, accountID) +} + +func (h *allocationHandler) HandleEdit(w http.ResponseWriter, r *http.Request) { + spaceID := r.PathValue("spaceID") + accountID := r.PathValue("accountID") + allocationID := r.PathValue("allocationID") + if !h.ensureAccess(w, r, spaceID, accountID) { + return + } + + existing, err := h.allocationService.Get(allocationID) + if err != nil || existing.AccountID != accountID { + ui.RenderError(w, r, "Savings goal not found", http.StatusNotFound) + return + } + + name, amount, target, state := parseAllocationForm(r) + if state.NameErr != "" || state.AmountErr != "" || state.TargetErr != "" { + h.renderSection(w, r, spaceID, accountID) // simplest: re-render fresh; inline edit errors require richer state + return + } + + user := ctxkeys.User(r.Context()) + actorID := "" + if user != nil { + actorID = user.ID + } + if _, err := h.allocationService.Update(service.UpdateAllocationInput{ + AllocationID: allocationID, Name: name, Amount: amount, TargetAmount: target, ActorID: actorID, + }); err != nil { + slog.Error("failed to update allocation", "error", err, "allocation_id", allocationID) + ui.RenderError(w, r, friendlyAllocationError(err), http.StatusBadRequest) + return + } + + h.renderSection(w, r, spaceID, accountID) +} + +func (h *allocationHandler) HandleDelete(w http.ResponseWriter, r *http.Request) { + spaceID := r.PathValue("spaceID") + accountID := r.PathValue("accountID") + allocationID := r.PathValue("allocationID") + if !h.ensureAccess(w, r, spaceID, accountID) { + return + } + + existing, err := h.allocationService.Get(allocationID) + if err != nil || existing.AccountID != accountID { + ui.RenderError(w, r, "Savings goal not found", http.StatusNotFound) + return + } + + user := ctxkeys.User(r.Context()) + actorID := "" + if user != nil { + actorID = user.ID + } + if err := h.allocationService.Delete(allocationID, actorID); err != nil { + slog.Error("failed to delete allocation", "error", err, "allocation_id", allocationID) + ui.RenderError(w, r, "Failed to delete savings goal", http.StatusInternalServerError) + return + } + + h.renderSection(w, r, spaceID, accountID) +} + +// renderSectionWithCreateError re-renders the section but injects the partially- +// filled form state so the user doesn't lose their input when validation fails. +// Implemented by rendering the section then... actually we need a richer block +// for this, so for now just render a fresh section — Phase 5 polishes UX. +func (h *allocationHandler) renderSectionWithCreateError(w http.ResponseWriter, r *http.Request, spaceID, accountID string, state blocks.AllocationFormState) { + summary, err := h.allocationService.SummaryForAccount(accountID) + if err != nil { + slog.Error("failed to load allocation summary", "error", err, "account_id", accountID) + ui.RenderError(w, r, "Failed to load savings goals", http.StatusInternalServerError) + return + } + ui.Render(w, r, blocks.AllocationsSection(blocks.AllocationsSectionProps{ + SpaceID: spaceID, AccountID: accountID, Summary: summary, + CreateForm: &state, + ShowCreateForm: true, + })) +} + +func friendlyAllocationError(err error) string { + if err == nil { + return "" + } + if errors.Is(err, repository.ErrAllocationNotFound) { + return "Savings goal not found." + } + msg := err.Error() + if strings.Contains(msg, "duplicate key") || strings.Contains(msg, "unique") { + return "A savings goal with this name already exists for this account." + } + return "Something went wrong. Please try again." +} diff --git a/internal/handler/space.go b/internal/handler/space.go index 00b83dd..d1abaa8 100644 --- a/internal/handler/space.go +++ b/internal/handler/space.go @@ -24,6 +24,7 @@ type spaceHandler struct { spaceService *service.SpaceService accountService *service.AccountService transactionService *service.TransactionService + allocationService *service.AllocationService inviteService *service.InviteService auditLogService *service.SpaceAuditLogService txAuditLogService *service.TransactionAuditLogService @@ -34,6 +35,7 @@ func NewSpaceHandler( spaceService *service.SpaceService, accountService *service.AccountService, transactionService *service.TransactionService, + allocationService *service.AllocationService, inviteService *service.InviteService, auditLogService *service.SpaceAuditLogService, txAuditLogService *service.TransactionAuditLogService, @@ -43,6 +45,7 @@ func NewSpaceHandler( spaceService: spaceService, accountService: accountService, transactionService: transactionService, + allocationService: allocationService, inviteService: inviteService, auditLogService: auditLogService, txAuditLogService: txAuditLogService, @@ -328,6 +331,12 @@ func (h *spaceHandler) SpaceAccountPage(w http.ResponseWriter, r *http.Request) recent = nil } + allocSummary, err := h.allocationService.SummaryForAccount(accountID) + if err != nil { + slog.Error("failed to load allocation summary", "error", err, "account_id", accountID) + allocSummary = nil + } + ui.Render(w, r, pages.SpaceAccountPage(pages.SpaceAccountPageProps{ SpaceID: spaceID, SpaceName: space.Name, @@ -336,6 +345,7 @@ func (h *spaceHandler) SpaceAccountPage(w http.ResponseWriter, r *http.Request) AccountBalance: account.Balance, RecentTransactions: recent, NonEditableTransactionIDs: h.nonEditableTransactionIDs(recent), + AllocationSummary: allocSummary, })) } diff --git a/internal/model/financial_management.go b/internal/model/financial_management.go index c8b534b..5f875d2 100644 --- a/internal/model/financial_management.go +++ b/internal/model/financial_management.go @@ -42,6 +42,17 @@ type Tag struct { UpdatedAt time.Time `db:"updated_at"` } +type Allocation struct { + ID string `db:"id"` + AccountID string `db:"account_id"` + Name string `db:"name"` + Amount decimal.Decimal `db:"amount"` + TargetAmount *decimal.Decimal `db:"target_amount"` + SortOrder int `db:"sort_order"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` +} + type Category struct { ID string `db:"id"` Name string `db:"name"` diff --git a/internal/model/space_audit_log.go b/internal/model/space_audit_log.go index 3eb45f6..90b2236 100644 --- a/internal/model/space_audit_log.go +++ b/internal/model/space_audit_log.go @@ -14,6 +14,9 @@ const ( SpaceAuditActionAccountCreated SpaceAuditAction = "account.created" SpaceAuditActionAccountRenamed SpaceAuditAction = "account.renamed" SpaceAuditActionAccountDeleted SpaceAuditAction = "account.deleted" + SpaceAuditActionAllocationCreated SpaceAuditAction = "allocation.created" + SpaceAuditActionAllocationUpdated SpaceAuditAction = "allocation.updated" + SpaceAuditActionAllocationDeleted SpaceAuditAction = "allocation.deleted" ) type SpaceAuditLog struct { diff --git a/internal/repository/allocation.go b/internal/repository/allocation.go new file mode 100644 index 0000000..8e83e52 --- /dev/null +++ b/internal/repository/allocation.go @@ -0,0 +1,95 @@ +package repository + +import ( + "database/sql" + "errors" + + "git.juancwu.dev/juancwu/budgit/internal/model" + "github.com/jmoiron/sqlx" + "github.com/shopspring/decimal" +) + +var ErrAllocationNotFound = errors.New("allocation not found") + +type AllocationRepository interface { + Create(allocation *model.Allocation) error + ByID(id string) (*model.Allocation, error) + ByAccountID(accountID string) ([]*model.Allocation, error) + SumByAccountID(accountID string) (decimal.Decimal, error) + Update(id, name string, amount decimal.Decimal, target *decimal.Decimal) error + Delete(id string) error +} + +type allocationRepository struct { + db *sqlx.DB +} + +func NewAllocationRepository(db *sqlx.DB) AllocationRepository { + return &allocationRepository{db: db} +} + +func (r *allocationRepository) Create(a *model.Allocation) error { + query := `INSERT INTO allocations (id, account_id, name, amount, target_amount, sort_order, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8);` + _, err := r.db.Exec(query, a.ID, a.AccountID, a.Name, a.Amount, a.TargetAmount, a.SortOrder, a.CreatedAt, a.UpdatedAt) + return err +} + +func (r *allocationRepository) ByID(id string) (*model.Allocation, error) { + a := &model.Allocation{} + query := `SELECT * FROM allocations WHERE id = $1;` + err := r.db.Get(a, query, id) + if err == sql.ErrNoRows { + return nil, ErrAllocationNotFound + } + return a, err +} + +func (r *allocationRepository) ByAccountID(accountID string) ([]*model.Allocation, error) { + var out []*model.Allocation + query := `SELECT * FROM allocations WHERE account_id = $1 ORDER BY sort_order ASC, created_at ASC;` + err := r.db.Select(&out, query, accountID) + return out, err +} + +func (r *allocationRepository) SumByAccountID(accountID string) (decimal.Decimal, error) { + var sum decimal.Decimal + query := `SELECT COALESCE(SUM(amount::numeric), 0)::text FROM allocations WHERE account_id = $1;` + if err := r.db.Get(&sum, query, accountID); err != nil { + return decimal.Zero, err + } + return sum, nil +} + +func (r *allocationRepository) Update(id, name string, amount decimal.Decimal, target *decimal.Decimal) error { + query := `UPDATE allocations + SET name = $1, amount = $2, target_amount = $3, updated_at = CURRENT_TIMESTAMP + WHERE id = $4;` + res, err := r.db.Exec(query, name, amount, target, id) + if err != nil { + return err + } + n, err := res.RowsAffected() + if err != nil { + return err + } + if n == 0 { + return ErrAllocationNotFound + } + return nil +} + +func (r *allocationRepository) Delete(id string) error { + res, err := r.db.Exec(`DELETE FROM allocations WHERE id = $1;`, id) + if err != nil { + return err + } + n, err := res.RowsAffected() + if err != nil { + return err + } + if n == 0 { + return ErrAllocationNotFound + } + return nil +} diff --git a/internal/repository/allocation_test.go b/internal/repository/allocation_test.go new file mode 100644 index 0000000..2822d61 --- /dev/null +++ b/internal/repository/allocation_test.go @@ -0,0 +1,107 @@ +package repository + +import ( + "testing" + "time" + + "git.juancwu.dev/juancwu/budgit/internal/model" + "git.juancwu.dev/juancwu/budgit/internal/testutil" + "github.com/google/uuid" + "github.com/shopspring/decimal" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAllocationRepository_CRUD(t *testing.T) { + testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) { + accountRepo := NewAccountRepository(dbi.DB) + repo := NewAllocationRepository(dbi.DB) + + user := testutil.CreateTestUser(t, dbi.DB, "alloc-crud@example.com", nil) + space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Alloc Space") + + now := time.Now() + account := &model.Account{ + ID: uuid.NewString(), + Name: "Savings", + SpaceID: space.ID, + CreatedAt: now, + UpdatedAt: now, + } + require.NoError(t, accountRepo.Create(account)) + + target := decimal.NewFromInt(3000) + alloc := &model.Allocation{ + ID: uuid.NewString(), + AccountID: account.ID, + Name: "Emergency Fund", + Amount: decimal.NewFromInt(500), + TargetAmount: &target, + SortOrder: 0, + CreatedAt: now, + UpdatedAt: now, + } + require.NoError(t, repo.Create(alloc)) + + fetched, err := repo.ByID(alloc.ID) + require.NoError(t, err) + assert.Equal(t, "Emergency Fund", fetched.Name) + assert.True(t, fetched.Amount.Equal(decimal.NewFromInt(500))) + require.NotNil(t, fetched.TargetAmount) + assert.True(t, fetched.TargetAmount.Equal(target)) + + alloc2 := &model.Allocation{ + ID: uuid.NewString(), + AccountID: account.ID, + Name: "Trip", + Amount: decimal.NewFromInt(250), + SortOrder: 1, + CreatedAt: now, + UpdatedAt: now, + } + require.NoError(t, repo.Create(alloc2)) + + list, err := repo.ByAccountID(account.ID) + require.NoError(t, err) + require.Len(t, list, 2) + assert.Equal(t, "Emergency Fund", list[0].Name) + assert.Equal(t, "Trip", list[1].Name) + + sum, err := repo.SumByAccountID(account.ID) + require.NoError(t, err) + assert.True(t, sum.Equal(decimal.NewFromInt(750))) + + require.NoError(t, repo.Update(alloc.ID, "Rainy Day", decimal.NewFromInt(800), nil)) + fetched, err = repo.ByID(alloc.ID) + require.NoError(t, err) + assert.Equal(t, "Rainy Day", fetched.Name) + assert.True(t, fetched.Amount.Equal(decimal.NewFromInt(800))) + assert.Nil(t, fetched.TargetAmount) + + require.NoError(t, repo.Delete(alloc2.ID)) + _, err = repo.ByID(alloc2.ID) + assert.ErrorIs(t, err, ErrAllocationNotFound) + + assert.ErrorIs(t, repo.Delete(uuid.NewString()), ErrAllocationNotFound) + }) +} + +func TestAllocationRepository_UniqueNamePerAccount(t *testing.T) { + testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) { + accountRepo := NewAccountRepository(dbi.DB) + repo := NewAllocationRepository(dbi.DB) + + user := testutil.CreateTestUser(t, dbi.DB, "alloc-unique@example.com", nil) + space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Unique Space") + + now := time.Now() + account := &model.Account{ID: uuid.NewString(), Name: "A", SpaceID: space.ID, CreatedAt: now, UpdatedAt: now} + require.NoError(t, accountRepo.Create(account)) + + a := &model.Allocation{ID: uuid.NewString(), AccountID: account.ID, Name: "Goal", Amount: decimal.Zero, CreatedAt: now, UpdatedAt: now} + require.NoError(t, repo.Create(a)) + + dup := &model.Allocation{ID: uuid.NewString(), AccountID: account.ID, Name: "Goal", Amount: decimal.Zero, CreatedAt: now, UpdatedAt: now} + assert.Error(t, repo.Create(dup)) + }) +} diff --git a/internal/repository/space_audit_log.go b/internal/repository/space_audit_log.go index 1941b08..eddc741 100644 --- a/internal/repository/space_audit_log.go +++ b/internal/repository/space_audit_log.go @@ -71,7 +71,7 @@ func (r *spaceAuditLogRepository) ListAccountEvents(accountID string, limit, off FROM space_audit_logs a LEFT JOIN users actor ON actor.id = a.actor_id LEFT JOIN users target ON target.id = a.target_user_id - WHERE a.action LIKE 'account.%' + WHERE (a.action LIKE 'account.%' OR a.action LIKE 'allocation.%') AND a.metadata->>'account_id' = $1 ORDER BY a.created_at DESC LIMIT $2 OFFSET $3;` @@ -84,7 +84,8 @@ func (r *spaceAuditLogRepository) CountAccountEvents(accountID string) (int, err var count int err := r.db.Get(&count, `SELECT COUNT(*) FROM space_audit_logs - WHERE action LIKE 'account.%' AND metadata->>'account_id' = $1;`, + WHERE (action LIKE 'account.%' OR action LIKE 'allocation.%') + AND metadata->>'account_id' = $1;`, accountID) return count, err } diff --git a/internal/routes/routes.go b/internal/routes/routes.go index f3797b5..b9db49a 100644 --- a/internal/routes/routes.go +++ b/internal/routes/routes.go @@ -19,7 +19,8 @@ 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, a.TransactionService, a.InviteService, a.AuditLogService, a.TxAuditLogService, a.AccountActivitySvc) + spaceH := handler.NewSpaceHandler(a.SpaceService, a.AccountService, a.TransactionService, a.AllocationService, a.InviteService, a.AuditLogService, a.TxAuditLogService, a.AccountActivitySvc) + allocationH := handler.NewAllocationHandler(a.AllocationService, a.AccountService) redirectH := handler.NewRedirectHandler() r := router.New() @@ -123,6 +124,10 @@ func SetupRoutes(a *app.App) http.Handler { g.Post("/deposits/create", spaceH.HandleCreateDeposit).Name("action.app.spaces.space.accounts.account.deposits.create") g.Get("/transfers/create", spaceH.SpaceCreateTransferPage).Name("page.app.spaces.space.accounts.account.transfers.create") g.Post("/transfers/create", spaceH.HandleCreateTransfer).Name("action.app.spaces.space.accounts.account.transfers.create") + + g.Post("/allocations/create", allocationH.HandleCreate).Name("action.app.spaces.space.accounts.account.allocations.create") + g.Post("/allocations/{allocationID}/edit", allocationH.HandleEdit).Name("action.app.spaces.space.accounts.account.allocations.allocation.edit") + g.Post("/allocations/{allocationID}/delete", allocationH.HandleDelete).Name("action.app.spaces.space.accounts.account.allocations.allocation.delete") }) }) }) diff --git a/internal/service/allocation.go b/internal/service/allocation.go new file mode 100644 index 0000000..10ee1bf --- /dev/null +++ b/internal/service/allocation.go @@ -0,0 +1,244 @@ +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 AllocationService struct { + repo repository.AllocationRepository + accountService *AccountService + auditSvc *SpaceAuditLogService +} + +func NewAllocationService(repo repository.AllocationRepository, accountService *AccountService) *AllocationService { + return &AllocationService{repo: repo, accountService: accountService} +} + +func (s *AllocationService) SetAuditLogger(audit *SpaceAuditLogService) { + s.auditSvc = audit +} + +// AllocationSummary bundles the allocations for an account with derived totals +// the UI cares about (Available cash, over-allocation flag). +type AllocationSummary struct { + Allocations []*model.Allocation + Allocated decimal.Decimal + Available decimal.Decimal + Overflow bool // true when sum(allocations) > account.balance +} + +type CreateAllocationInput struct { + AccountID string + Name string + Amount decimal.Decimal + TargetAmount *decimal.Decimal + ActorID string +} + +func (s *AllocationService) Create(input CreateAllocationInput) (*model.Allocation, error) { + name := strings.TrimSpace(input.Name) + if name == "" { + return nil, fmt.Errorf("name is required") + } + if input.AccountID == "" { + return nil, fmt.Errorf("account id is required") + } + if input.Amount.IsNegative() { + return nil, fmt.Errorf("amount cannot be negative") + } + if input.TargetAmount != nil && input.TargetAmount.IsNegative() { + return nil, fmt.Errorf("target cannot be negative") + } + + account, err := s.accountService.GetAccount(input.AccountID) + if err != nil { + return nil, fmt.Errorf("failed to load account: %w", err) + } + + now := time.Now() + a := &model.Allocation{ + ID: uuid.NewString(), + AccountID: input.AccountID, + Name: name, + Amount: input.Amount, + TargetAmount: input.TargetAmount, + CreatedAt: now, + UpdatedAt: now, + } + if err := s.repo.Create(a); err != nil { + return nil, fmt.Errorf("failed to create allocation: %w", err) + } + + s.auditSvc.Record(RecordOptions{ + SpaceID: account.SpaceID, + ActorID: input.ActorID, + Action: model.SpaceAuditActionAllocationCreated, + Metadata: map[string]any{ + "account_id": a.AccountID, + "allocation_id": a.ID, + "name": a.Name, + "amount": a.Amount.StringFixedBank(2), + "target": targetString(a.TargetAmount), + }, + }) + return a, nil +} + +type UpdateAllocationInput struct { + AllocationID string + Name string + Amount decimal.Decimal + TargetAmount *decimal.Decimal + ActorID string +} + +func (s *AllocationService) Update(input UpdateAllocationInput) (*model.Allocation, error) { + name := strings.TrimSpace(input.Name) + if name == "" { + return nil, fmt.Errorf("name is required") + } + if input.AllocationID == "" { + return nil, fmt.Errorf("allocation id is required") + } + if input.Amount.IsNegative() { + return nil, fmt.Errorf("amount cannot be negative") + } + if input.TargetAmount != nil && input.TargetAmount.IsNegative() { + return nil, fmt.Errorf("target cannot be negative") + } + + existing, err := s.repo.ByID(input.AllocationID) + if err != nil { + return nil, fmt.Errorf("failed to load allocation: %w", err) + } + account, err := s.accountService.GetAccount(existing.AccountID) + if err != nil { + return nil, fmt.Errorf("failed to load account: %w", err) + } + + changes := map[string]any{} + if existing.Name != name { + changes["name"] = map[string]any{"old": existing.Name, "new": name} + } + if !existing.Amount.Equal(input.Amount) { + changes["amount"] = map[string]any{ + "old": existing.Amount.StringFixedBank(2), + "new": input.Amount.StringFixedBank(2), + } + } + if !decimalPtrEq(existing.TargetAmount, input.TargetAmount) { + changes["target"] = map[string]any{ + "old": targetString(existing.TargetAmount), + "new": targetString(input.TargetAmount), + } + } + + if err := s.repo.Update(input.AllocationID, name, input.Amount, input.TargetAmount); err != nil { + return nil, fmt.Errorf("failed to update allocation: %w", err) + } + + existing.Name = name + existing.Amount = input.Amount + existing.TargetAmount = input.TargetAmount + existing.UpdatedAt = time.Now() + + if len(changes) > 0 { + s.auditSvc.Record(RecordOptions{ + SpaceID: account.SpaceID, + ActorID: input.ActorID, + Action: model.SpaceAuditActionAllocationUpdated, + Metadata: map[string]any{ + "account_id": existing.AccountID, + "allocation_id": existing.ID, + "changes": changes, + }, + }) + } + return existing, nil +} + +func (s *AllocationService) Delete(allocationID, actorID string) error { + if allocationID == "" { + return fmt.Errorf("allocation id is required") + } + existing, err := s.repo.ByID(allocationID) + if err != nil { + return fmt.Errorf("failed to load allocation: %w", err) + } + account, err := s.accountService.GetAccount(existing.AccountID) + if err != nil { + return fmt.Errorf("failed to load account: %w", err) + } + // Record before delete so the row references pre-delete state. + s.auditSvc.Record(RecordOptions{ + SpaceID: account.SpaceID, + ActorID: actorID, + Action: model.SpaceAuditActionAllocationDeleted, + Metadata: map[string]any{ + "account_id": existing.AccountID, + "allocation_id": existing.ID, + "name": existing.Name, + "amount": existing.Amount.StringFixedBank(2), + }, + }) + if err := s.repo.Delete(allocationID); err != nil { + return fmt.Errorf("failed to delete allocation: %w", err) + } + return nil +} + +func (s *AllocationService) Get(id string) (*model.Allocation, error) { + a, err := s.repo.ByID(id) + if err != nil { + return nil, fmt.Errorf("failed to load allocation: %w", err) + } + return a, nil +} + +// SummaryForAccount returns the allocations for an account along with the +// derived Allocated/Available figures used by the UI banner. +func (s *AllocationService) SummaryForAccount(accountID string) (*AllocationSummary, error) { + account, err := s.accountService.GetAccount(accountID) + if err != nil { + return nil, fmt.Errorf("failed to load account: %w", err) + } + allocs, err := s.repo.ByAccountID(accountID) + if err != nil { + return nil, fmt.Errorf("failed to load allocations: %w", err) + } + allocated := decimal.Zero + for _, a := range allocs { + allocated = allocated.Add(a.Amount) + } + available := account.Balance.Sub(allocated) + return &AllocationSummary{ + Allocations: allocs, + Allocated: allocated, + Available: available, + Overflow: available.IsNegative(), + }, nil +} + +func targetString(t *decimal.Decimal) string { + if t == nil { + return "" + } + return t.StringFixedBank(2) +} + +func decimalPtrEq(a, b *decimal.Decimal) bool { + if a == nil && b == nil { + return true + } + if a == nil || b == nil { + return false + } + return a.Equal(*b) +} diff --git a/internal/ui/blocks/allocation_card.templ b/internal/ui/blocks/allocation_card.templ new file mode 100644 index 0000000..244a7f9 --- /dev/null +++ b/internal/ui/blocks/allocation_card.templ @@ -0,0 +1,240 @@ +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/dialog" +import "git.juancwu.dev/juancwu/budgit/internal/ui/components/form" +import "git.juancwu.dev/juancwu/budgit/internal/ui/components/icon" +import "git.juancwu.dev/juancwu/budgit/internal/ui/components/input" +import "git.juancwu.dev/juancwu/budgit/internal/ui/utils" + +// AllocationFormState carries the previously-submitted values + per-field +// errors so handler responses can re-render forms with messages. +type AllocationFormState struct { + Name string + Amount string + TargetAmount string + + NameErr string + AmountErr string + TargetErr string + GeneralErr string +} + +templ allocationCard(spaceID, accountID string, a *model.Allocation) { + {{ + editID := "alloc-edit-" + a.ID + viewID := "alloc-view-" + a.ID + percent := "" + barWidth := "" + if a.TargetAmount != nil && a.TargetAmount.IsPositive() { + ratio := a.Amount.Div(*a.TargetAmount) + pct := ratio.Mul(decimalHundred()).Truncate(0) + if pct.GreaterThan(decimalHundred()) { + pct = decimalHundred() + } + if pct.IsNegative() { + pct = decimalZero() + } + percent = pct.String() + "%" + barWidth = "width: " + pct.String() + "%;" + } + }} +
+
+
+
+

{ a.Name }

+

${ utils.FormatDecimalWithThousands(a.Amount.StringFixedBank(2)) }

+ if a.TargetAmount != nil { +

+ of ${ utils.FormatDecimalWithThousands(a.TargetAmount.StringFixedBank(2)) } goal +

+ } +
+
+ @button.Button(button.Props{ + Variant: button.VariantGhost, + Size: button.SizeIcon, + Attributes: templ.Attributes{ + "_": "on click add .hidden to #" + viewID + " then remove .hidden from #" + editID, + }, + }) { + @icon.Pencil() + } + @dialog.Dialog(dialog.Props{ID: "alloc-delete-" + a.ID}) { + @dialog.Trigger(dialog.TriggerProps{For: "alloc-delete-" + a.ID}) { + @button.Button(button.Props{ + Variant: button.VariantGhost, + Size: button.SizeIcon, + }) { + @icon.Trash2() + } + } + @dialog.Content() { + @dialog.Header() { + @dialog.Title() { + Delete { a.Name }? + } + @dialog.Description() { + This removes the savings goal. Funds in this account are unaffected; they return to Available. + } + } + @dialog.Footer(dialog.FooterProps{Class: "mt-2"}) { + @dialog.Close(dialog.CloseProps{For: "alloc-delete-" + a.ID}) { + @button.Button(button.Props{Variant: button.VariantOutline}) { + Cancel + } + } +
+ @button.Button(button.Props{ + Type: button.TypeSubmit, + Variant: button.VariantDestructive, + Class: "flex gap-2 items-center", + }) { + @icon.Trash2() + Delete + } +
+ } + } + } +
+
+ if a.TargetAmount != nil && a.TargetAmount.IsPositive() { +
+
+
+
+

{ percent }

+
+ } +
+ +
+} + +templ allocationCreateForm(spaceID, accountID string, state AllocationFormState) { +
+

New savings goal

+ if state.GeneralErr != "" { + @form.Message(form.MessageProps{Variant: form.MessageVariantError}) { + { state.GeneralErr } + } + } + @allocationFormFields(state) +
+ @button.Button(button.Props{ + Variant: button.VariantGhost, + Attributes: templ.Attributes{ + "type": "button", + "_": "on click add .hidden to #allocation-create-form", + }, + }) { + Cancel + } + @button.Button(button.Props{Type: button.TypeSubmit}) { + Create + } +
+
+} + +templ allocationEditForm(spaceID, accountID string, a *model.Allocation, state AllocationFormState, viewID, editID string) { +
+ if state.GeneralErr != "" { + @form.Message(form.MessageProps{Variant: form.MessageVariantError}) { + { state.GeneralErr } + } + } + @allocationFormFields(state) +
+ @button.Button(button.Props{ + Variant: button.VariantGhost, + Attributes: templ.Attributes{ + "type": "button", + "_": "on click add .hidden to #" + editID + " then remove .hidden from #" + viewID, + }, + }) { + Cancel + } + @button.Button(button.Props{Type: button.TypeSubmit}) { + Save + } +
+
+} + +templ allocationFormFields(state AllocationFormState) { + @form.Item() { + @form.Label(form.LabelProps{For: "name"}) { + Name + } + @input.Input(input.Props{ + ID: "name", Name: "name", Type: input.TypeText, Class: "rounded-sm", + Value: state.Name, HasError: state.NameErr != "", Required: true, + Placeholder: "e.g. Emergency Fund", + Attributes: templ.Attributes{"autocomplete": "off"}, + }) + if state.NameErr != "" { + @form.Message(form.MessageProps{Variant: form.MessageVariantError}) { + { state.NameErr } + } + } + } +
+ @form.Item() { + @form.Label(form.LabelProps{For: "amount"}) { + Amount + } + @input.Input(input.Props{ + ID: "amount", Name: "amount", Type: input.TypeText, Class: "rounded-sm", + Value: state.Amount, HasError: state.AmountErr != "", Required: true, + Placeholder: "0.00", + Attributes: templ.Attributes{"inputmode": "decimal"}, + }) + if state.AmountErr != "" { + @form.Message(form.MessageProps{Variant: form.MessageVariantError}) { + { state.AmountErr } + } + } + } + @form.Item() { + @form.Label(form.LabelProps{For: "target_amount"}) { + Goal (optional) + } + @input.Input(input.Props{ + ID: "target_amount", Name: "target_amount", Type: input.TypeText, Class: "rounded-sm", + Value: state.TargetAmount, HasError: state.TargetErr != "", + Placeholder: "0.00", + Attributes: templ.Attributes{"inputmode": "decimal"}, + }) + if state.TargetErr != "" { + @form.Message(form.MessageProps{Variant: form.MessageVariantError}) { + { state.TargetErr } + } + } + } +
+} diff --git a/internal/ui/blocks/allocations.go b/internal/ui/blocks/allocations.go new file mode 100644 index 0000000..167b08e --- /dev/null +++ b/internal/ui/blocks/allocations.go @@ -0,0 +1,13 @@ +package blocks + +import "github.com/shopspring/decimal" + +func decimalHundred() decimal.Decimal { return decimal.NewFromInt(100) } +func decimalZero() decimal.Decimal { return decimal.Zero } + +func targetDisplay(t *decimal.Decimal) string { + if t == nil { + return "" + } + return t.StringFixedBank(2) +} diff --git a/internal/ui/blocks/allocations_section.templ b/internal/ui/blocks/allocations_section.templ new file mode 100644 index 0000000..d5f1482 --- /dev/null +++ b/internal/ui/blocks/allocations_section.templ @@ -0,0 +1,102 @@ +package blocks + +import "git.juancwu.dev/juancwu/budgit/internal/service" +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/icon" +import "git.juancwu.dev/juancwu/budgit/internal/ui/utils" + +type AllocationsSectionProps struct { + SpaceID string + AccountID string + Summary *service.AllocationSummary + + // CreateForm preserves user input + errors when re-rendering after a + // failed create submission. Nil for fresh renders. + CreateForm *AllocationFormState + // ShowCreateForm forces the create form to be visible (used after a + // validation error so the user sees what went wrong). + ShowCreateForm bool +} + +// AllocationsSection renders the savings-goals card on the account overview. +// The whole card is the HTMX swap target so create/edit/delete handlers can +// return a fresh copy and the Available banner stays in sync. +templ AllocationsSection(props AllocationsSectionProps) { +
+ @card.Card(card.Props{Class: "rounded-sm"}) { + @card.Header() { +
+
+ @card.Title() { + Savings Goals + } + @card.Description() { + Earmark portions of this account for things you're saving for. + } +
+ @button.Button(button.Props{ + Variant: button.VariantDefault, + Class: "flex items-center gap-2", + Attributes: templ.Attributes{ + "_": "on click toggle .hidden on #allocation-create-form", + }, + }) { + @icon.Plus() + New goal + } +
+ } + @card.Content(card.ContentProps{Class: "space-y-4"}) { + if props.Summary != nil { + @allocationsAvailableBanner(props.Summary) + } + {{ + createState := AllocationFormState{} + if props.CreateForm != nil { + createState = *props.CreateForm + } + createClasses := "hidden" + if props.ShowCreateForm { + createClasses = "" + } + }} +
+ @allocationCreateForm(props.SpaceID, props.AccountID, createState) +
+ if props.Summary != nil && len(props.Summary.Allocations) > 0 { +
+ for _, a := range props.Summary.Allocations { + @allocationCard(props.SpaceID, props.AccountID, a) + } +
+ } else { +

No savings goals yet. Create one to start earmarking funds.

+ } + } + } +
+} + +templ allocationsAvailableBanner(summary *service.AllocationSummary) { + {{ + availClasses := []string{"text-2xl font-bold"} + if summary.Overflow { + availClasses = append(availClasses, "text-red-600 dark:text-red-400") + } + }} +
+
+

Available

+

${ utils.FormatDecimalWithThousands(summary.Available.StringFixedBank(2)) }

+
+

+ Allocated: ${ utils.FormatDecimalWithThousands(summary.Allocated.StringFixedBank(2)) } +

+ if summary.Overflow { +
+ Over-allocated by ${ utils.FormatDecimalWithThousands(summary.Available.Abs().StringFixedBank(2)) } +
+ } +
+} diff --git a/internal/ui/pages/space_account.templ b/internal/ui/pages/space_account.templ index 8e3fc74..7c499a1 100644 --- a/internal/ui/pages/space_account.templ +++ b/internal/ui/pages/space_account.templ @@ -3,6 +3,7 @@ package pages import "github.com/shopspring/decimal" import "git.juancwu.dev/juancwu/budgit/internal/model" import "git.juancwu.dev/juancwu/budgit/internal/routeurl" +import "git.juancwu.dev/juancwu/budgit/internal/service" import "git.juancwu.dev/juancwu/budgit/internal/ui/blocks" import "git.juancwu.dev/juancwu/budgit/internal/ui/layouts" import "git.juancwu.dev/juancwu/budgit/internal/ui/components/card" @@ -18,6 +19,7 @@ type SpaceAccountPageProps struct { AccountBalance decimal.Decimal RecentTransactions []*model.Transaction NonEditableTransactionIDs map[string]bool + AllocationSummary *service.AllocationSummary } templ SpaceAccountPage(props SpaceAccountPageProps) { @@ -42,7 +44,7 @@ templ SpaceAccountPage(props SpaceAccountPageProps) { } @card.Content() {

${ utils.FormatDecimalWithThousands(props.AccountBalance.StringFixedBank(2)) }

-

Available Balance

+

Account Balance

} } @card.Card(card.Props{Class: "rounded-sm col-span-full md:col-span-4"}) { @@ -79,6 +81,11 @@ templ SpaceAccountPage(props SpaceAccountPageProps) { } } + @blocks.AllocationsSection(blocks.AllocationsSectionProps{ + SpaceID: props.SpaceID, + AccountID: props.AccountID, + Summary: props.AllocationSummary, + })
@card.Card() { @card.Header() { diff --git a/internal/ui/pages/space_activity.templ b/internal/ui/pages/space_activity.templ index 98f89d4..6177400 100644 --- a/internal/ui/pages/space_activity.templ +++ b/internal/ui/pages/space_activity.templ @@ -2,6 +2,7 @@ package pages import "encoding/json" import "fmt" +import "sort" import "strings" import "git.juancwu.dev/juancwu/budgit/internal/model" @@ -77,6 +78,12 @@ templ activityIcon(action model.SpaceAuditAction) { @icon.Pencil(icon.Props{Class: "size-4 text-muted-foreground"}) case model.SpaceAuditActionAccountDeleted: @icon.Trash2(icon.Props{Class: "size-4 text-destructive"}) + case model.SpaceAuditActionAllocationCreated: + @icon.Plus(icon.Props{Class: "size-4 text-muted-foreground"}) + case model.SpaceAuditActionAllocationUpdated: + @icon.Pencil(icon.Props{Class: "size-4 text-muted-foreground"}) + case model.SpaceAuditActionAllocationDeleted: + @icon.Trash2(icon.Props{Class: "size-4 text-destructive"}) default: @icon.History(icon.Props{Class: "size-4 text-muted-foreground"}) } @@ -166,6 +173,45 @@ func activityMessage(log *model.SpaceAuditLogWithActor) string { name = "an account" } return fmt.Sprintf("%s deleted the account %s.", actor, bold(name)) + case model.SpaceAuditActionAllocationCreated: + var meta struct { + Name string `json:"name"` + Amount string `json:"amount"` + } + _ = json.Unmarshal(log.Metadata, &meta) + name := meta.Name + if name == "" { + name = "a savings goal" + } + if meta.Amount != "" { + return fmt.Sprintf("%s created savings goal %s with $%s.", actor, bold(name), bold(meta.Amount)) + } + return fmt.Sprintf("%s created savings goal %s.", actor, bold(name)) + case model.SpaceAuditActionAllocationUpdated: + var meta struct { + Changes map[string]map[string]any `json:"changes"` + } + _ = json.Unmarshal(log.Metadata, &meta) + fields := make([]string, 0, len(meta.Changes)) + for k := range meta.Changes { + fields = append(fields, k) + } + sort.Strings(fields) + if len(fields) == 0 { + return fmt.Sprintf("%s updated a savings goal.", actor) + } + return fmt.Sprintf("%s updated savings goal (%s).", actor, bold(strings.Join(fields, ", "))) + case model.SpaceAuditActionAllocationDeleted: + var meta struct { + Name string `json:"name"` + Amount string `json:"amount"` + } + _ = json.Unmarshal(log.Metadata, &meta) + name := meta.Name + if name == "" { + name = "a savings goal" + } + return fmt.Sprintf("%s deleted savings goal %s.", actor, bold(name)) default: return fmt.Sprintf("%s performed %s.", actor, bold(string(log.Action))) }