feat: savings allocations
All checks were successful
Deploy / build-and-deploy (push) Successful in 1m31s
All checks were successful
Deploy / build-and-deploy (push) Successful in 1m31s
This commit is contained in:
parent
ff237e2fab
commit
2dac136049
17 changed files with 1140 additions and 4 deletions
|
|
@ -18,6 +18,7 @@ type App struct {
|
||||||
EmailService *service.EmailService
|
EmailService *service.EmailService
|
||||||
SpaceService *service.SpaceService
|
SpaceService *service.SpaceService
|
||||||
AccountService *service.AccountService
|
AccountService *service.AccountService
|
||||||
|
AllocationService *service.AllocationService
|
||||||
TransactionService *service.TransactionService
|
TransactionService *service.TransactionService
|
||||||
InviteService *service.InviteService
|
InviteService *service.InviteService
|
||||||
AuditLogService *service.SpaceAuditLogService
|
AuditLogService *service.SpaceAuditLogService
|
||||||
|
|
@ -43,6 +44,7 @@ func New(cfg *config.Config) (*App, error) {
|
||||||
tokenRepository := repository.NewTokenRepository(database)
|
tokenRepository := repository.NewTokenRepository(database)
|
||||||
spaceRepository := repository.NewSpaceRepository(database)
|
spaceRepository := repository.NewSpaceRepository(database)
|
||||||
accountRepository := repository.NewAccountRepository(database)
|
accountRepository := repository.NewAccountRepository(database)
|
||||||
|
allocationRepository := repository.NewAllocationRepository(database)
|
||||||
transactionRepository := repository.NewTransactionRepository(database)
|
transactionRepository := repository.NewTransactionRepository(database)
|
||||||
categoryRepository := repository.NewCategoryRepository(database)
|
categoryRepository := repository.NewCategoryRepository(database)
|
||||||
invitationRepository := repository.NewInvitationRepository(database)
|
invitationRepository := repository.NewInvitationRepository(database)
|
||||||
|
|
@ -57,6 +59,8 @@ func New(cfg *config.Config) (*App, error) {
|
||||||
spaceService.SetAuditLogger(auditLogService)
|
spaceService.SetAuditLogger(auditLogService)
|
||||||
accountService := service.NewAccountService(accountRepository)
|
accountService := service.NewAccountService(accountRepository)
|
||||||
accountService.SetAuditLogger(auditLogService)
|
accountService.SetAuditLogger(auditLogService)
|
||||||
|
allocationService := service.NewAllocationService(allocationRepository, accountService)
|
||||||
|
allocationService.SetAuditLogger(auditLogService)
|
||||||
transactionService := service.NewTransactionService(transactionRepository, categoryRepository, accountService)
|
transactionService := service.NewTransactionService(transactionRepository, categoryRepository, accountService)
|
||||||
transactionService.SetAuditLogger(txAuditLogService)
|
transactionService.SetAuditLogger(txAuditLogService)
|
||||||
accountActivityService := service.NewAccountActivityService(auditLogService, txAuditLogService)
|
accountActivityService := service.NewAccountActivityService(auditLogService, txAuditLogService)
|
||||||
|
|
@ -88,6 +92,7 @@ func New(cfg *config.Config) (*App, error) {
|
||||||
EmailService: emailService,
|
EmailService: emailService,
|
||||||
SpaceService: spaceService,
|
SpaceService: spaceService,
|
||||||
AccountService: accountService,
|
AccountService: accountService,
|
||||||
|
AllocationService: allocationService,
|
||||||
TransactionService: transactionService,
|
TransactionService: transactionService,
|
||||||
InviteService: inviteService,
|
InviteService: inviteService,
|
||||||
AuditLogService: auditLogService,
|
AuditLogService: auditLogService,
|
||||||
|
|
|
||||||
21
internal/db/migrations/00012_create_allocations_table.sql
Normal file
21
internal/db/migrations/00012_create_allocations_table.sql
Normal file
|
|
@ -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
|
||||||
15
internal/db/migrations/00013_index_allocation_audit_logs.sql
Normal file
15
internal/db/migrations/00013_index_allocation_audit_logs.sql
Normal file
|
|
@ -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
|
||||||
211
internal/handler/allocation.go
Normal file
211
internal/handler/allocation.go
Normal file
|
|
@ -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."
|
||||||
|
}
|
||||||
|
|
@ -24,6 +24,7 @@ type spaceHandler struct {
|
||||||
spaceService *service.SpaceService
|
spaceService *service.SpaceService
|
||||||
accountService *service.AccountService
|
accountService *service.AccountService
|
||||||
transactionService *service.TransactionService
|
transactionService *service.TransactionService
|
||||||
|
allocationService *service.AllocationService
|
||||||
inviteService *service.InviteService
|
inviteService *service.InviteService
|
||||||
auditLogService *service.SpaceAuditLogService
|
auditLogService *service.SpaceAuditLogService
|
||||||
txAuditLogService *service.TransactionAuditLogService
|
txAuditLogService *service.TransactionAuditLogService
|
||||||
|
|
@ -34,6 +35,7 @@ func NewSpaceHandler(
|
||||||
spaceService *service.SpaceService,
|
spaceService *service.SpaceService,
|
||||||
accountService *service.AccountService,
|
accountService *service.AccountService,
|
||||||
transactionService *service.TransactionService,
|
transactionService *service.TransactionService,
|
||||||
|
allocationService *service.AllocationService,
|
||||||
inviteService *service.InviteService,
|
inviteService *service.InviteService,
|
||||||
auditLogService *service.SpaceAuditLogService,
|
auditLogService *service.SpaceAuditLogService,
|
||||||
txAuditLogService *service.TransactionAuditLogService,
|
txAuditLogService *service.TransactionAuditLogService,
|
||||||
|
|
@ -43,6 +45,7 @@ func NewSpaceHandler(
|
||||||
spaceService: spaceService,
|
spaceService: spaceService,
|
||||||
accountService: accountService,
|
accountService: accountService,
|
||||||
transactionService: transactionService,
|
transactionService: transactionService,
|
||||||
|
allocationService: allocationService,
|
||||||
inviteService: inviteService,
|
inviteService: inviteService,
|
||||||
auditLogService: auditLogService,
|
auditLogService: auditLogService,
|
||||||
txAuditLogService: txAuditLogService,
|
txAuditLogService: txAuditLogService,
|
||||||
|
|
@ -328,6 +331,12 @@ func (h *spaceHandler) SpaceAccountPage(w http.ResponseWriter, r *http.Request)
|
||||||
recent = nil
|
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{
|
ui.Render(w, r, pages.SpaceAccountPage(pages.SpaceAccountPageProps{
|
||||||
SpaceID: spaceID,
|
SpaceID: spaceID,
|
||||||
SpaceName: space.Name,
|
SpaceName: space.Name,
|
||||||
|
|
@ -336,6 +345,7 @@ func (h *spaceHandler) SpaceAccountPage(w http.ResponseWriter, r *http.Request)
|
||||||
AccountBalance: account.Balance,
|
AccountBalance: account.Balance,
|
||||||
RecentTransactions: recent,
|
RecentTransactions: recent,
|
||||||
NonEditableTransactionIDs: h.nonEditableTransactionIDs(recent),
|
NonEditableTransactionIDs: h.nonEditableTransactionIDs(recent),
|
||||||
|
AllocationSummary: allocSummary,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,17 @@ type Tag struct {
|
||||||
UpdatedAt time.Time `db:"updated_at"`
|
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 {
|
type Category struct {
|
||||||
ID string `db:"id"`
|
ID string `db:"id"`
|
||||||
Name string `db:"name"`
|
Name string `db:"name"`
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,9 @@ const (
|
||||||
SpaceAuditActionAccountCreated SpaceAuditAction = "account.created"
|
SpaceAuditActionAccountCreated SpaceAuditAction = "account.created"
|
||||||
SpaceAuditActionAccountRenamed SpaceAuditAction = "account.renamed"
|
SpaceAuditActionAccountRenamed SpaceAuditAction = "account.renamed"
|
||||||
SpaceAuditActionAccountDeleted SpaceAuditAction = "account.deleted"
|
SpaceAuditActionAccountDeleted SpaceAuditAction = "account.deleted"
|
||||||
|
SpaceAuditActionAllocationCreated SpaceAuditAction = "allocation.created"
|
||||||
|
SpaceAuditActionAllocationUpdated SpaceAuditAction = "allocation.updated"
|
||||||
|
SpaceAuditActionAllocationDeleted SpaceAuditAction = "allocation.deleted"
|
||||||
)
|
)
|
||||||
|
|
||||||
type SpaceAuditLog struct {
|
type SpaceAuditLog struct {
|
||||||
|
|
|
||||||
95
internal/repository/allocation.go
Normal file
95
internal/repository/allocation.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
107
internal/repository/allocation_test.go
Normal file
107
internal/repository/allocation_test.go
Normal file
|
|
@ -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))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -71,7 +71,7 @@ func (r *spaceAuditLogRepository) ListAccountEvents(accountID string, limit, off
|
||||||
FROM space_audit_logs a
|
FROM space_audit_logs a
|
||||||
LEFT JOIN users actor ON actor.id = a.actor_id
|
LEFT JOIN users actor ON actor.id = a.actor_id
|
||||||
LEFT JOIN users target ON target.id = a.target_user_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
|
AND a.metadata->>'account_id' = $1
|
||||||
ORDER BY a.created_at DESC
|
ORDER BY a.created_at DESC
|
||||||
LIMIT $2 OFFSET $3;`
|
LIMIT $2 OFFSET $3;`
|
||||||
|
|
@ -84,7 +84,8 @@ func (r *spaceAuditLogRepository) CountAccountEvents(accountID string) (int, err
|
||||||
var count int
|
var count int
|
||||||
err := r.db.Get(&count,
|
err := r.db.Get(&count,
|
||||||
`SELECT COUNT(*) FROM space_audit_logs
|
`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)
|
accountID)
|
||||||
return count, err
|
return count, err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,8 @@ func SetupRoutes(a *app.App) http.Handler {
|
||||||
authH := handler.NewAuthHandler(a.AuthService, a.InviteService, a.SpaceService)
|
authH := handler.NewAuthHandler(a.AuthService, a.InviteService, a.SpaceService)
|
||||||
homeH := handler.NewHomeHandler()
|
homeH := handler.NewHomeHandler()
|
||||||
settingsH := handler.NewSettingsHandler(a.AuthService, a.UserService)
|
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()
|
redirectH := handler.NewRedirectHandler()
|
||||||
|
|
||||||
r := router.New()
|
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.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.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("/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")
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
244
internal/service/allocation.go
Normal file
244
internal/service/allocation.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
240
internal/ui/blocks/allocation_card.templ
Normal file
240
internal/ui/blocks/allocation_card.templ
Normal file
|
|
@ -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() + "%;"
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
<div id={ "alloc-" + a.ID } class="border rounded-md p-4 space-y-3">
|
||||||
|
<div id={ viewID }>
|
||||||
|
<div class="flex items-start justify-between gap-3">
|
||||||
|
<div class="space-y-1">
|
||||||
|
<p class="font-semibold">{ a.Name }</p>
|
||||||
|
<p class="text-2xl font-bold">${ utils.FormatDecimalWithThousands(a.Amount.StringFixedBank(2)) }</p>
|
||||||
|
if a.TargetAmount != nil {
|
||||||
|
<p class="text-xs text-muted-foreground">
|
||||||
|
of ${ utils.FormatDecimalWithThousands(a.TargetAmount.StringFixedBank(2)) } goal
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-1">
|
||||||
|
@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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
<form
|
||||||
|
hx-post={ routeurl.URL("action.app.spaces.space.accounts.account.allocations.allocation.delete", "spaceID", spaceID, "accountID", accountID, "allocationID", a.ID) }
|
||||||
|
hx-target="#allocations-section"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
>
|
||||||
|
@button.Button(button.Props{
|
||||||
|
Type: button.TypeSubmit,
|
||||||
|
Variant: button.VariantDestructive,
|
||||||
|
Class: "flex gap-2 items-center",
|
||||||
|
}) {
|
||||||
|
@icon.Trash2()
|
||||||
|
Delete
|
||||||
|
}
|
||||||
|
</form>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
if a.TargetAmount != nil && a.TargetAmount.IsPositive() {
|
||||||
|
<div class="mt-3 space-y-1">
|
||||||
|
<div class="h-2 bg-muted rounded-full overflow-hidden">
|
||||||
|
<div class="h-full bg-primary" style={ barWidth }></div>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-muted-foreground text-right">{ percent }</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div id={ editID } class="hidden">
|
||||||
|
@allocationEditForm(spaceID, accountID, a, AllocationFormState{
|
||||||
|
Name: a.Name,
|
||||||
|
Amount: a.Amount.StringFixedBank(2),
|
||||||
|
TargetAmount: targetDisplay(a.TargetAmount),
|
||||||
|
}, viewID, editID)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
templ allocationCreateForm(spaceID, accountID string, state AllocationFormState) {
|
||||||
|
<form
|
||||||
|
hx-post={ routeurl.URL("action.app.spaces.space.accounts.account.allocations.create", "spaceID", spaceID, "accountID", accountID) }
|
||||||
|
hx-target="#allocations-section"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
class="border rounded-md p-4 space-y-3"
|
||||||
|
>
|
||||||
|
<p class="font-semibold">New savings goal</p>
|
||||||
|
if state.GeneralErr != "" {
|
||||||
|
@form.Message(form.MessageProps{Variant: form.MessageVariantError}) {
|
||||||
|
{ state.GeneralErr }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@allocationFormFields(state)
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
@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
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
}
|
||||||
|
|
||||||
|
templ allocationEditForm(spaceID, accountID string, a *model.Allocation, state AllocationFormState, viewID, editID string) {
|
||||||
|
<form
|
||||||
|
hx-post={ routeurl.URL("action.app.spaces.space.accounts.account.allocations.allocation.edit", "spaceID", spaceID, "accountID", accountID, "allocationID", a.ID) }
|
||||||
|
hx-target="#allocations-section"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
class="space-y-3"
|
||||||
|
>
|
||||||
|
if state.GeneralErr != "" {
|
||||||
|
@form.Message(form.MessageProps{Variant: form.MessageVariantError}) {
|
||||||
|
{ state.GeneralErr }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@allocationFormFields(state)
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
@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
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
}
|
||||||
|
|
||||||
|
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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
<div class="grid grid-cols-2 gap-3">
|
||||||
|
@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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
13
internal/ui/blocks/allocations.go
Normal file
13
internal/ui/blocks/allocations.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
102
internal/ui/blocks/allocations_section.templ
Normal file
102
internal/ui/blocks/allocations_section.templ
Normal file
|
|
@ -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) {
|
||||||
|
<div id="allocations-section" class="space-y-4">
|
||||||
|
@card.Card(card.Props{Class: "rounded-sm"}) {
|
||||||
|
@card.Header() {
|
||||||
|
<div class="flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
@card.Title() {
|
||||||
|
Savings Goals
|
||||||
|
}
|
||||||
|
@card.Description() {
|
||||||
|
Earmark portions of this account for things you're saving for.
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
@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
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@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 = ""
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
<div id="allocation-create-form" class={ createClasses }>
|
||||||
|
@allocationCreateForm(props.SpaceID, props.AccountID, createState)
|
||||||
|
</div>
|
||||||
|
if props.Summary != nil && len(props.Summary.Allocations) > 0 {
|
||||||
|
<div class="grid gap-3 md:grid-cols-2">
|
||||||
|
for _, a := range props.Summary.Allocations {
|
||||||
|
@allocationCard(props.SpaceID, props.AccountID, a)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
} else {
|
||||||
|
<p class="text-sm text-muted-foreground">No savings goals yet. Create one to start earmarking funds.</p>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
templ allocationsAvailableBanner(summary *service.AllocationSummary) {
|
||||||
|
{{
|
||||||
|
availClasses := []string{"text-2xl font-bold"}
|
||||||
|
if summary.Overflow {
|
||||||
|
availClasses = append(availClasses, "text-red-600 dark:text-red-400")
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
<div class="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-2 border rounded-md p-4 bg-muted/30">
|
||||||
|
<div>
|
||||||
|
<p class="text-xs text-muted-foreground uppercase tracking-wide">Available</p>
|
||||||
|
<p class={ utils.TwMerge(availClasses...) }>${ utils.FormatDecimalWithThousands(summary.Available.StringFixedBank(2)) }</p>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-muted-foreground">
|
||||||
|
Allocated: ${ utils.FormatDecimalWithThousands(summary.Allocated.StringFixedBank(2)) }
|
||||||
|
</p>
|
||||||
|
if summary.Overflow {
|
||||||
|
<div class="text-sm text-red-600 dark:text-red-400 font-medium">
|
||||||
|
Over-allocated by ${ utils.FormatDecimalWithThousands(summary.Available.Abs().StringFixedBank(2)) }
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,7 @@ package pages
|
||||||
import "github.com/shopspring/decimal"
|
import "github.com/shopspring/decimal"
|
||||||
import "git.juancwu.dev/juancwu/budgit/internal/model"
|
import "git.juancwu.dev/juancwu/budgit/internal/model"
|
||||||
import "git.juancwu.dev/juancwu/budgit/internal/routeurl"
|
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/blocks"
|
||||||
import "git.juancwu.dev/juancwu/budgit/internal/ui/layouts"
|
import "git.juancwu.dev/juancwu/budgit/internal/ui/layouts"
|
||||||
import "git.juancwu.dev/juancwu/budgit/internal/ui/components/card"
|
import "git.juancwu.dev/juancwu/budgit/internal/ui/components/card"
|
||||||
|
|
@ -18,6 +19,7 @@ type SpaceAccountPageProps struct {
|
||||||
AccountBalance decimal.Decimal
|
AccountBalance decimal.Decimal
|
||||||
RecentTransactions []*model.Transaction
|
RecentTransactions []*model.Transaction
|
||||||
NonEditableTransactionIDs map[string]bool
|
NonEditableTransactionIDs map[string]bool
|
||||||
|
AllocationSummary *service.AllocationSummary
|
||||||
}
|
}
|
||||||
|
|
||||||
templ SpaceAccountPage(props SpaceAccountPageProps) {
|
templ SpaceAccountPage(props SpaceAccountPageProps) {
|
||||||
|
|
@ -42,7 +44,7 @@ templ SpaceAccountPage(props SpaceAccountPageProps) {
|
||||||
}
|
}
|
||||||
@card.Content() {
|
@card.Content() {
|
||||||
<h1 class={ utils.TwMerge(balanceTextClasses...) }>${ utils.FormatDecimalWithThousands(props.AccountBalance.StringFixedBank(2)) }</h1>
|
<h1 class={ utils.TwMerge(balanceTextClasses...) }>${ utils.FormatDecimalWithThousands(props.AccountBalance.StringFixedBank(2)) }</h1>
|
||||||
<p class="text-sm text-muted-foreground">Available Balance</p>
|
<p class="text-sm text-muted-foreground">Account Balance</p>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@card.Card(card.Props{Class: "rounded-sm col-span-full md:col-span-4"}) {
|
@card.Card(card.Props{Class: "rounded-sm col-span-full md:col-span-4"}) {
|
||||||
|
|
@ -79,6 +81,11 @@ templ SpaceAccountPage(props SpaceAccountPageProps) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
@blocks.AllocationsSection(blocks.AllocationsSectionProps{
|
||||||
|
SpaceID: props.SpaceID,
|
||||||
|
AccountID: props.AccountID,
|
||||||
|
Summary: props.AllocationSummary,
|
||||||
|
})
|
||||||
<div>
|
<div>
|
||||||
@card.Card() {
|
@card.Card() {
|
||||||
@card.Header() {
|
@card.Header() {
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ package pages
|
||||||
|
|
||||||
import "encoding/json"
|
import "encoding/json"
|
||||||
import "fmt"
|
import "fmt"
|
||||||
|
import "sort"
|
||||||
import "strings"
|
import "strings"
|
||||||
|
|
||||||
import "git.juancwu.dev/juancwu/budgit/internal/model"
|
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"})
|
@icon.Pencil(icon.Props{Class: "size-4 text-muted-foreground"})
|
||||||
case model.SpaceAuditActionAccountDeleted:
|
case model.SpaceAuditActionAccountDeleted:
|
||||||
@icon.Trash2(icon.Props{Class: "size-4 text-destructive"})
|
@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:
|
default:
|
||||||
@icon.History(icon.Props{Class: "size-4 text-muted-foreground"})
|
@icon.History(icon.Props{Class: "size-4 text-muted-foreground"})
|
||||||
}
|
}
|
||||||
|
|
@ -166,6 +173,45 @@ func activityMessage(log *model.SpaceAuditLogWithActor) string {
|
||||||
name = "an account"
|
name = "an account"
|
||||||
}
|
}
|
||||||
return fmt.Sprintf("%s deleted the account %s.", actor, bold(name))
|
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:
|
default:
|
||||||
return fmt.Sprintf("%s performed %s.", actor, bold(string(log.Action)))
|
return fmt.Sprintf("%s performed %s.", actor, bold(string(log.Action)))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue