From 7c24a8302dbe482b70aab1686453b6b163a7f557 Mon Sep 17 00:00:00 2001 From: juancwu Date: Fri, 22 May 2026 14:49:57 +0000 Subject: [PATCH] feat: investment accounts --- internal/app/app.go | 6 + .../00019_add_investment_tracking.sql | 50 +++ internal/handler/investment.go | 348 +++++++++++++++++ internal/handler/space.go | 92 ++++- internal/model/financial_management.go | 35 +- internal/model/investment.go | 83 ++++ internal/model/space_audit_log.go | 1 + internal/repository/account.go | 43 ++- .../investment_contribution_room.go | 74 ++++ internal/repository/investment_holding.go | 89 +++++ internal/repository/investment_trade.go | 91 +++++ internal/repository/transaction.go | 28 ++ internal/routes/routes.go | 14 +- internal/service/account.go | 117 +++++- internal/service/account_test.go | 4 +- internal/service/auth.go | 6 +- internal/service/investment.go | 355 ++++++++++++++++++ internal/ui/blocks/investment_section.templ | 220 +++++++++++ internal/ui/forms/create_account.templ | 66 +++- .../ui/pages/investment_holding_detail.templ | 213 +++++++++++ .../ui/pages/investment_holding_form.templ | 88 +++++ internal/ui/pages/investments_overview.templ | 131 +++++++ internal/ui/pages/space_account.templ | 22 +- .../ui/pages/space_account_settings.templ | 79 +++- internal/ui/pages/spaces.templ | 10 + 25 files changed, 2207 insertions(+), 58 deletions(-) create mode 100644 internal/db/migrations/00019_add_investment_tracking.sql create mode 100644 internal/handler/investment.go create mode 100644 internal/model/investment.go create mode 100644 internal/repository/investment_contribution_room.go create mode 100644 internal/repository/investment_holding.go create mode 100644 internal/repository/investment_trade.go create mode 100644 internal/service/investment.go create mode 100644 internal/ui/blocks/investment_section.templ create mode 100644 internal/ui/pages/investment_holding_detail.templ create mode 100644 internal/ui/pages/investment_holding_form.templ create mode 100644 internal/ui/pages/investments_overview.templ diff --git a/internal/app/app.go b/internal/app/app.go index 3215ada..12c8e00 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -27,6 +27,7 @@ type App struct { AuditLogService *service.SpaceAuditLogService TxAuditLogService *service.TransactionAuditLogService AccountActivitySvc *service.AccountActivityService + InvestmentService *service.InvestmentService AccountDeletionWorker *worker.AccountDeletionWorker } @@ -56,6 +57,9 @@ func New(cfg *config.Config) (*App, error) { txAuditLogRepository := repository.NewTransactionAuditLogRepository(database) recurringEventRepository := repository.NewRecurringEventRepository(database) accountDeletionRequestRepo := repository.NewAccountDeletionRequestRepository(database) + contributionRoomRepo := repository.NewInvestmentContributionRoomRepository(database) + holdingRepo := repository.NewInvestmentHoldingRepository(database) + tradeRepo := repository.NewInvestmentTradeRepository(database) // Services emailService := service.NewEmailService( @@ -94,6 +98,7 @@ func New(cfg *config.Config) (*App, error) { ) inviteService := service.NewInviteService(invitationRepository, spaceRepository, userRepository, emailService, auditLogService) recurringEventService := service.NewRecurringEventService(recurringEventRepository, transactionService, accountService) + investmentService := service.NewInvestmentService(accountRepository, contributionRoomRepo, holdingRepo, tradeRepo, transactionRepository) return &App{ Cfg: cfg, @@ -110,6 +115,7 @@ func New(cfg *config.Config) (*App, error) { AuditLogService: auditLogService, TxAuditLogService: txAuditLogService, AccountActivitySvc: accountActivityService, + InvestmentService: investmentService, AccountDeletionWorker: accountDeletionWorker, }, nil } diff --git a/internal/db/migrations/00019_add_investment_tracking.sql b/internal/db/migrations/00019_add_investment_tracking.sql new file mode 100644 index 0000000..7a6a656 --- /dev/null +++ b/internal/db/migrations/00019_add_investment_tracking.sql @@ -0,0 +1,50 @@ +-- +goose Up +-- +goose StatementBegin +ALTER TABLE accounts + ADD COLUMN is_investment BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN investment_subtype TEXT NULL; + +CREATE TABLE investment_contribution_rooms ( + account_id TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, + year INTEGER NOT NULL, + room_amount TEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (account_id, year) +); + +CREATE TABLE investment_holdings ( + id TEXT NOT NULL PRIMARY KEY, + account_id TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, + symbol TEXT NOT NULL, + display_name TEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE (account_id, symbol) +); + +CREATE TABLE investment_trades ( + id TEXT NOT NULL PRIMARY KEY, + holding_id TEXT NOT NULL REFERENCES investment_holdings(id) ON DELETE CASCADE, + type TEXT NOT NULL, + quantity TEXT NOT NULL, + price_per_unit TEXT NOT NULL, + fees TEXT NULL, + occurred_at TIMESTAMP NOT NULL, + notes TEXT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_investment_trades_holding_id_occurred_at + ON investment_trades (holding_id, occurred_at); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TABLE investment_trades; +DROP TABLE investment_holdings; +DROP TABLE investment_contribution_rooms; +ALTER TABLE accounts + DROP COLUMN investment_subtype, + DROP COLUMN is_investment; +-- +goose StatementEnd diff --git a/internal/handler/investment.go b/internal/handler/investment.go new file mode 100644 index 0000000..11b7a7c --- /dev/null +++ b/internal/handler/investment.go @@ -0,0 +1,348 @@ +package handler + +import ( + "log/slog" + "net/http" + "strconv" + "strings" + "time" + + "github.com/shopspring/decimal" + + "git.juancwu.dev/juancwu/budgit/internal/ctxkeys" + "git.juancwu.dev/juancwu/budgit/internal/model" + "git.juancwu.dev/juancwu/budgit/internal/routeurl" + "git.juancwu.dev/juancwu/budgit/internal/service" + "git.juancwu.dev/juancwu/budgit/internal/ui" + "git.juancwu.dev/juancwu/budgit/internal/ui/blocks" + "git.juancwu.dev/juancwu/budgit/internal/ui/pages" +) + +type investmentHandler struct { + accountService *service.AccountService + spaceService *service.SpaceService + investmentService *service.InvestmentService +} + +func NewInvestmentHandler( + accountService *service.AccountService, + spaceService *service.SpaceService, + investmentService *service.InvestmentService, +) *investmentHandler { + return &investmentHandler{ + accountService: accountService, + spaceService: spaceService, + investmentService: investmentService, + } +} + +func (h *investmentHandler) loadInvestmentAccount(w http.ResponseWriter, r *http.Request) (*model.Account, bool) { + spaceID := r.PathValue("spaceID") + accountID := r.PathValue("accountID") + account, err := h.accountService.GetAccount(accountID) + if err != nil || account.SpaceID != spaceID { + ui.Render(w, r, pages.NotFound()) + return nil, false + } + if !account.IsInvestment { + ui.Render(w, r, pages.NotFound()) + return nil, false + } + return account, true +} + +// HandleSetContributionRoom upserts the room amount for a year, then returns +// the refreshed investment section so the page can swap it in place. +func (h *investmentHandler) HandleSetContributionRoom(w http.ResponseWriter, r *http.Request) { + account, ok := h.loadInvestmentAccount(w, r) + if !ok { + return + } + + year, err := strconv.Atoi(strings.TrimSpace(r.FormValue("year"))) + if err != nil || year < 1900 || year > 9999 { + http.Error(w, "invalid year", http.StatusBadRequest) + return + } + roomStr := strings.TrimSpace(r.FormValue("room")) + room, err := decimal.NewFromString(roomStr) + if err != nil || room.IsNegative() { + http.Error(w, "invalid room amount", http.StatusBadRequest) + return + } + if err := h.investmentService.SetContributionRoom(account.ID, year, room); err != nil { + slog.Error("failed to set contribution room", "error", err, "account_id", account.ID) + http.Error(w, "could not save room", http.StatusInternalServerError) + return + } + h.renderSection(w, r, account, year) +} + +func (h *investmentHandler) renderSection(w http.ResponseWriter, r *http.Request, account *model.Account, year int) { + summary, err := h.investmentService.SummarizeAccount(account.ID, year) + if err != nil { + slog.Error("failed to summarize investment account", "error", err, "account_id", account.ID) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + positions, err := h.investmentService.HoldingPositions(account.ID) + if err != nil { + slog.Error("failed to load positions", "error", err, "account_id", account.ID) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + ui.Render(w, r, blocks.InvestmentSection(blocks.InvestmentSectionProps{ + SpaceID: account.SpaceID, + AccountID: account.ID, + Currency: account.Currency, + Summary: summary, + Positions: positions, + })) +} + +// ---------- Holdings ---------- + +func (h *investmentHandler) CreateHoldingPage(w http.ResponseWriter, r *http.Request) { + account, ok := h.loadInvestmentAccount(w, r) + if !ok { + return + } + space, err := h.spaceService.GetSpace(account.SpaceID) + if err != nil { + ui.Render(w, r, pages.NotFound()) + return + } + ui.Render(w, r, pages.InvestmentHoldingFormPage(pages.InvestmentHoldingFormPageProps{ + SpaceID: space.ID, + SpaceName: space.Name, + AccountID: account.ID, + AccountName: account.Name, + })) +} + +func (h *investmentHandler) HandleCreateHolding(w http.ResponseWriter, r *http.Request) { + account, ok := h.loadInvestmentAccount(w, r) + if !ok { + return + } + symbol := strings.ToUpper(strings.TrimSpace(r.FormValue("symbol"))) + displayName := strings.TrimSpace(r.FormValue("display_name")) + if symbol == "" { + http.Error(w, "symbol required", http.StatusBadRequest) + return + } + if _, err := h.investmentService.CreateHolding(account.ID, symbol, displayName); err != nil { + slog.Error("failed to create holding", "error", err) + http.Error(w, "could not create holding", http.StatusInternalServerError) + return + } + w.Header().Set("HX-Redirect", routeurl.URL( + "page.app.spaces.space.accounts.account.overview", + "spaceID", account.SpaceID, "accountID", account.ID, + )) + w.WriteHeader(http.StatusOK) +} + +func (h *investmentHandler) HoldingDetailPage(w http.ResponseWriter, r *http.Request) { + account, ok := h.loadInvestmentAccount(w, r) + if !ok { + return + } + holdingID := r.PathValue("holdingID") + holding, err := h.investmentService.GetHolding(holdingID) + if err != nil || holding.AccountID != account.ID { + ui.Render(w, r, pages.NotFound()) + return + } + pos, err := h.investmentService.HoldingPosition(holdingID) + if err != nil { + slog.Error("failed to load holding position", "error", err) + ui.RenderError(w, r, "Failed to load holding", http.StatusInternalServerError) + return + } + trades, err := h.investmentService.ListTrades(holdingID) + if err != nil { + slog.Error("failed to load trades", "error", err) + trades = nil + } + space, err := h.spaceService.GetSpace(account.SpaceID) + if err != nil { + ui.Render(w, r, pages.NotFound()) + return + } + ui.Render(w, r, pages.InvestmentHoldingDetailPage(pages.InvestmentHoldingDetailProps{ + SpaceID: space.ID, + SpaceName: space.Name, + AccountID: account.ID, + AccountName: account.Name, + Currency: account.Currency, + Position: *pos, + Trades: trades, + })) +} + +func (h *investmentHandler) HandleDeleteHolding(w http.ResponseWriter, r *http.Request) { + account, ok := h.loadInvestmentAccount(w, r) + if !ok { + return + } + holdingID := r.PathValue("holdingID") + holding, err := h.investmentService.GetHolding(holdingID) + if err != nil || holding.AccountID != account.ID { + http.Error(w, "not found", http.StatusNotFound) + return + } + if err := h.investmentService.DeleteHolding(holdingID); err != nil { + slog.Error("failed to delete holding", "error", err) + http.Error(w, "could not delete holding", http.StatusInternalServerError) + return + } + w.Header().Set("HX-Redirect", routeurl.URL( + "page.app.spaces.space.accounts.account.overview", + "spaceID", account.SpaceID, "accountID", account.ID, + )) + w.WriteHeader(http.StatusOK) +} + +// ---------- Trades ---------- + +func (h *investmentHandler) HandleCreateTrade(w http.ResponseWriter, r *http.Request) { + account, ok := h.loadInvestmentAccount(w, r) + if !ok { + return + } + holdingID := r.PathValue("holdingID") + holding, err := h.investmentService.GetHolding(holdingID) + if err != nil || holding.AccountID != account.ID { + http.Error(w, "not found", http.StatusNotFound) + return + } + + tradeType := strings.ToLower(strings.TrimSpace(r.FormValue("type"))) + if !model.IsValidInvestmentTradeType(tradeType) { + http.Error(w, "invalid trade type", http.StatusBadRequest) + return + } + qty, err := decimal.NewFromString(strings.TrimSpace(r.FormValue("quantity"))) + if err != nil || !qty.IsPositive() { + http.Error(w, "invalid quantity", http.StatusBadRequest) + return + } + price, err := decimal.NewFromString(strings.TrimSpace(r.FormValue("price"))) + if err != nil || price.IsNegative() { + http.Error(w, "invalid price", http.StatusBadRequest) + return + } + var feesPtr *decimal.Decimal + if feesStr := strings.TrimSpace(r.FormValue("fees")); feesStr != "" { + fees, err := decimal.NewFromString(feesStr) + if err != nil || fees.IsNegative() { + http.Error(w, "invalid fees", http.StatusBadRequest) + return + } + feesPtr = &fees + } + occurredAt := time.Now() + if dateStr := strings.TrimSpace(r.FormValue("occurred_at")); dateStr != "" { + t, err := time.Parse("2006-01-02", dateStr) + if err != nil { + http.Error(w, "invalid date", http.StatusBadRequest) + return + } + occurredAt = t + } + var notesPtr *string + if notes := strings.TrimSpace(r.FormValue("notes")); notes != "" { + notesPtr = ¬es + } + + if _, err := h.investmentService.RecordTrade(service.RecordTradeInput{ + HoldingID: holdingID, + Type: model.InvestmentTradeType(tradeType), + Quantity: qty, + PricePerUnit: price, + Fees: feesPtr, + OccurredAt: occurredAt, + Notes: notesPtr, + }); err != nil { + slog.Error("failed to record trade", "error", err) + http.Error(w, "could not record trade", http.StatusInternalServerError) + return + } + w.Header().Set("HX-Redirect", routeurl.URL( + "page.app.spaces.space.accounts.account.investments.holdings.holding", + "spaceID", account.SpaceID, "accountID", account.ID, "holdingID", holdingID, + )) + w.WriteHeader(http.StatusOK) +} + +func (h *investmentHandler) HandleDeleteTrade(w http.ResponseWriter, r *http.Request) { + account, ok := h.loadInvestmentAccount(w, r) + if !ok { + return + } + holdingID := r.PathValue("holdingID") + tradeID := r.PathValue("tradeID") + trade, err := h.investmentService.GetTrade(tradeID) + if err != nil || trade.HoldingID != holdingID { + http.Error(w, "not found", http.StatusNotFound) + return + } + holding, err := h.investmentService.GetHolding(holdingID) + if err != nil || holding.AccountID != account.ID { + http.Error(w, "not found", http.StatusNotFound) + return + } + if err := h.investmentService.DeleteTrade(tradeID); err != nil { + slog.Error("failed to delete trade", "error", err) + http.Error(w, "could not delete trade", http.StatusInternalServerError) + return + } + w.Header().Set("HX-Redirect", routeurl.URL( + "page.app.spaces.space.accounts.account.investments.holdings.holding", + "spaceID", account.SpaceID, "accountID", account.ID, "holdingID", holdingID, + )) + w.WriteHeader(http.StatusOK) +} + +// ---------- Top-level /app/investments page ---------- + +func (h *investmentHandler) InvestmentsOverviewPage(w http.ResponseWriter, r *http.Request) { + user := ctxkeys.User(r.Context()) + if user == nil { + ui.Render(w, r, pages.NotFound()) + return + } + accounts, err := h.accountService.InvestmentAccountsForUser(user.ID) + if err != nil { + slog.Error("failed to list investment accounts", "error", err) + ui.RenderError(w, r, "Failed to load investments", http.StatusInternalServerError) + return + } + year := time.Now().Year() + rows := make([]pages.InvestmentOverviewRow, 0, len(accounts)) + for _, acc := range accounts { + summary, err := h.investmentService.SummarizeAccount(acc.ID, year) + if err != nil { + slog.Error("failed to summarize account", "error", err, "account_id", acc.ID) + continue + } + space, err := h.spaceService.GetSpace(acc.SpaceID) + spaceName := acc.SpaceID + if err == nil { + spaceName = space.Name + } + rows = append(rows, pages.InvestmentOverviewRow{ + SpaceID: acc.SpaceID, + SpaceName: spaceName, + AccountID: acc.ID, + AccountName: acc.Name, + Currency: acc.Currency, + Summary: summary, + }) + } + ui.Render(w, r, pages.InvestmentsOverviewPage(pages.InvestmentsOverviewProps{ + Year: year, + Rows: rows, + })) +} diff --git a/internal/handler/space.go b/internal/handler/space.go index 62ccfff..ca3bbaf 100644 --- a/internal/handler/space.go +++ b/internal/handler/space.go @@ -30,6 +30,7 @@ type spaceHandler struct { auditLogService *service.SpaceAuditLogService txAuditLogService *service.TransactionAuditLogService accountActivitySvc *service.AccountActivityService + investmentService *service.InvestmentService } func NewSpaceHandler( @@ -41,6 +42,7 @@ func NewSpaceHandler( auditLogService *service.SpaceAuditLogService, txAuditLogService *service.TransactionAuditLogService, accountActivitySvc *service.AccountActivityService, + investmentService *service.InvestmentService, ) *spaceHandler { return &spaceHandler{ spaceService: spaceService, @@ -51,6 +53,7 @@ func NewSpaceHandler( auditLogService: auditLogService, txAuditLogService: txAuditLogService, accountActivitySvc: accountActivitySvc, + investmentService: investmentService, } } @@ -236,11 +239,15 @@ func (h *spaceHandler) HandleCreateAccount(w http.ResponseWriter, r *http.Reques if currencyInput == "" { currencyInput = currency.Default } + isInvestment := r.FormValue("is_investment") == "1" + subtypeInput := strings.ToLower(strings.TrimSpace(r.FormValue("investment_subtype"))) formProps := forms.CreateAccountProps{ - SpaceID: spaceID, - Name: nameInput, - Currency: currencyInput, + SpaceID: spaceID, + Name: nameInput, + Currency: currencyInput, + IsInvestment: isInvestment, + InvestmentSubtype: subtypeInput, } hasErr := false @@ -252,6 +259,10 @@ func (h *spaceHandler) HandleCreateAccount(w http.ResponseWriter, r *http.Reques formProps.CurrencyErr = "Choose a supported currency." hasErr = true } + if isInvestment && !model.IsValidInvestmentSubtype(subtypeInput) { + formProps.SubtypeErr = "Choose an account type." + hasErr = true + } if hasErr { ui.Render(w, r, forms.CreateAccount(formProps)) return @@ -277,7 +288,14 @@ func (h *spaceHandler) HandleCreateAccount(w http.ResponseWriter, r *http.Reques if user != nil { actorID = user.ID } - account, err := h.accountService.CreateAccount(spaceID, nameInput, currencyInput, actorID) + account, err := h.accountService.CreateAccount(service.CreateAccountInput{ + SpaceID: spaceID, + Name: nameInput, + CurrencyCode: currencyInput, + IsInvestment: isInvestment, + InvestmentSubtype: subtypeInput, + ActorID: actorID, + }) if err != nil { slog.Error("failed to create account", "error", err, "space_id", spaceID) formProps.GeneralErr = "Something went wrong. Please try again." @@ -329,7 +347,7 @@ func (h *spaceHandler) SpaceAccountPage(w http.ResponseWriter, r *http.Request) allocSummary = nil } - ui.Render(w, r, pages.SpaceAccountPage(pages.SpaceAccountPageProps{ + props := pages.SpaceAccountPageProps{ SpaceID: spaceID, SpaceName: space.Name, AccountID: accountID, @@ -339,7 +357,24 @@ func (h *spaceHandler) SpaceAccountPage(w http.ResponseWriter, r *http.Request) RecentTransactions: recent, NonEditableTransactionIDs: h.nonEditableTransactionIDs(recent), AllocationSummary: allocSummary, - })) + } + if account.IsInvestment { + year := time.Now().Year() + summary, err := h.investmentService.SummarizeAccount(accountID, year) + if err != nil { + slog.Error("failed to summarize investment account", "error", err, "account_id", accountID) + } else { + props.InvestmentSummary = summary + } + positions, err := h.investmentService.HoldingPositions(accountID) + if err != nil { + slog.Error("failed to load holding positions", "error", err, "account_id", accountID) + } else { + props.InvestmentPositions = positions + } + } + + ui.Render(w, r, pages.SpaceAccountPage(props)) } func (h *spaceHandler) SpaceAccountTransactionsPage(w http.ResponseWriter, r *http.Request) { @@ -757,12 +792,18 @@ func (h *spaceHandler) SpaceAccountSettingsPage(w http.ResponseWriter, r *http.R return } + subtype := "" + if account.InvestmentSubtype != nil { + subtype = *account.InvestmentSubtype + } ui.Render(w, r, pages.SpaceAccountSettingsPage(pages.SpaceAccountSettingsPageProps{ - SpaceID: spaceID, - SpaceName: space.Name, - AccountID: accountID, - AccountName: account.Name, - AccountCurrency: account.Currency, + SpaceID: spaceID, + SpaceName: space.Name, + AccountID: accountID, + AccountName: account.Name, + AccountCurrency: account.Currency, + IsInvestment: account.IsInvestment, + InvestmentSubtype: subtype, UpdateForm: forms.UpdateAccountProps{ SpaceID: spaceID, AccountID: accountID, @@ -776,6 +817,35 @@ func (h *spaceHandler) SpaceAccountSettingsPage(w http.ResponseWriter, r *http.R })) } +func (h *spaceHandler) HandleSetInvestmentFlag(w http.ResponseWriter, r *http.Request) { + spaceID := r.PathValue("spaceID") + accountID := r.PathValue("accountID") + account, err := h.accountService.GetAccount(accountID) + if err != nil || account.SpaceID != spaceID { + ui.Render(w, r, pages.NotFound()) + return + } + + isInvestment := r.FormValue("is_investment") == "1" + subtype := strings.ToLower(strings.TrimSpace(r.FormValue("investment_subtype"))) + + user := ctxkeys.User(r.Context()) + actorID := "" + if user != nil { + actorID = user.ID + } + if err := h.accountService.SetInvestmentFlag(accountID, isInvestment, subtype, actorID); err != nil { + slog.Error("failed to update investment flag", "error", err, "account_id", accountID) + http.Error(w, "could not update", http.StatusBadRequest) + return + } + w.Header().Set("HX-Redirect", routeurl.URL( + "page.app.spaces.space.accounts.account.settings", + "spaceID", spaceID, "accountID", accountID, + )) + w.WriteHeader(http.StatusOK) +} + func (h *spaceHandler) HandleRenameAccount(w http.ResponseWriter, r *http.Request) { spaceID := r.PathValue("spaceID") accountID := r.PathValue("accountID") diff --git a/internal/model/financial_management.go b/internal/model/financial_management.go index 9647326..6928a13 100644 --- a/internal/model/financial_management.go +++ b/internal/model/financial_management.go @@ -7,13 +7,34 @@ import ( ) type Account struct { - ID string `db:"id"` - Name string `db:"name"` - SpaceID string `db:"space_id"` - Balance decimal.Decimal `db:"balance"` - Currency string `db:"currency"` - CreatedAt time.Time `db:"created_at"` - UpdatedAt time.Time `db:"updated_at"` + ID string `db:"id"` + Name string `db:"name"` + SpaceID string `db:"space_id"` + Balance decimal.Decimal `db:"balance"` + Currency string `db:"currency"` + IsInvestment bool `db:"is_investment"` + InvestmentSubtype *string `db:"investment_subtype"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` +} + +type InvestmentSubtype string + +const ( + InvestmentSubtypeTFSA InvestmentSubtype = "tfsa" + InvestmentSubtypeRRSP InvestmentSubtype = "rrsp" + InvestmentSubtypeFHSA InvestmentSubtype = "fhsa" + InvestmentSubtypePersonal InvestmentSubtype = "personal" + InvestmentSubtypeOther InvestmentSubtype = "other" +) + +func IsValidInvestmentSubtype(s string) bool { + switch InvestmentSubtype(s) { + case InvestmentSubtypeTFSA, InvestmentSubtypeRRSP, InvestmentSubtypeFHSA, + InvestmentSubtypePersonal, InvestmentSubtypeOther: + return true + } + return false } type TransactionType string diff --git a/internal/model/investment.go b/internal/model/investment.go new file mode 100644 index 0000000..2cd227e --- /dev/null +++ b/internal/model/investment.go @@ -0,0 +1,83 @@ +package model + +import ( + "time" + + "github.com/shopspring/decimal" +) + +type InvestmentContributionRoom struct { + AccountID string `db:"account_id"` + Year int `db:"year"` + RoomAmount decimal.Decimal `db:"room_amount"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` +} + +type InvestmentHolding struct { + ID string `db:"id"` + AccountID string `db:"account_id"` + Symbol string `db:"symbol"` + DisplayName string `db:"display_name"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` +} + +type InvestmentTradeType string + +const ( + InvestmentTradeTypeBuy InvestmentTradeType = "buy" + InvestmentTradeTypeSell InvestmentTradeType = "sell" +) + +func IsValidInvestmentTradeType(t string) bool { + switch InvestmentTradeType(t) { + case InvestmentTradeTypeBuy, InvestmentTradeTypeSell: + return true + } + return false +} + +type InvestmentTrade struct { + ID string `db:"id"` + HoldingID string `db:"holding_id"` + Type InvestmentTradeType `db:"type"` + Quantity decimal.Decimal `db:"quantity"` + PricePerUnit decimal.Decimal `db:"price_per_unit"` + Fees *decimal.Decimal `db:"fees"` + OccurredAt time.Time `db:"occurred_at"` + Notes *string `db:"notes"` + CreatedAt time.Time `db:"created_at"` +} + +// HoldingPosition aggregates a holding with its derived figures across all +// trades. Quantity is the net of buys minus sells. AvgCost is the weighted +// average per-unit cost of remaining shares (reduced proportionally on sells). +// RealizedPL is the cumulative realized profit/loss from sells. +type HoldingPosition struct { + Holding InvestmentHolding + Quantity decimal.Decimal + AvgCost decimal.Decimal + CostBasis decimal.Decimal + LastBuyPrice *decimal.Decimal + LastSellPrice *decimal.Decimal + RealizedPL decimal.Decimal + TotalBuyQty decimal.Decimal + TotalSellQty decimal.Decimal + TotalFees decimal.Decimal +} + +// InvestmentAccountSummary is the rolled-up view for an investment-flagged +// account: contribution room and YTD cash flow plus aggregate cost basis across +// holdings. +type InvestmentAccountSummary struct { + Account *Account + Year int + RoomAmount *decimal.Decimal // nil if room not yet set for the year + YTDContributions decimal.Decimal + YTDWithdrawals decimal.Decimal + RoomRemaining *decimal.Decimal // nil if RoomAmount is nil + NetContributions decimal.Decimal // lifetime: all deposits minus all withdrawals + TotalCostBasis decimal.Decimal + HoldingCount int +} diff --git a/internal/model/space_audit_log.go b/internal/model/space_audit_log.go index f4fd3d4..6629a8f 100644 --- a/internal/model/space_audit_log.go +++ b/internal/model/space_audit_log.go @@ -15,6 +15,7 @@ const ( SpaceAuditActionAccountRenamed SpaceAuditAction = "account.renamed" SpaceAuditActionAccountDeleted SpaceAuditAction = "account.deleted" SpaceAuditActionAccountCurrencyChanged SpaceAuditAction = "account.currency_changed" + SpaceAuditActionAccountInvestmentFlag SpaceAuditAction = "account.investment_flag_changed" SpaceAuditActionAllocationCreated SpaceAuditAction = "allocation.created" SpaceAuditActionAllocationUpdated SpaceAuditAction = "allocation.updated" SpaceAuditActionAllocationDeleted SpaceAuditAction = "allocation.deleted" diff --git a/internal/repository/account.go b/internal/repository/account.go index bb1fd68..1a01890 100644 --- a/internal/repository/account.go +++ b/internal/repository/account.go @@ -29,6 +29,12 @@ type AccountRepository interface { // ChangeCurrency atomically updates an account's currency and balance and // rewrites each provided allocation's amount/target in the new currency. ChangeCurrency(accountID, newCurrency string, newBalance decimal.Decimal, allocationConversions []AllocationConversion) error + // SetInvestment toggles the investment flag and subtype for an account. + // subtype is the canonical lowercase string (e.g. "tfsa"); pass nil to clear. + SetInvestment(id string, isInvestment bool, subtype *string) error + // InvestmentAccountsByUserID returns all investment-flagged accounts the + // user owns, across every space the user owns. + InvestmentAccountsByUserID(userID string) ([]*model.Account, error) } type accountRepository struct { @@ -40,9 +46,10 @@ func NewAccountRepository(db *sqlx.DB) AccountRepository { } func (r *accountRepository) Create(account *model.Account) error { - query := `INSERT INTO accounts (id, name, space_id, currency, created_at, updated_at) - VALUES ($1, $2, $3, $4, $5, $6);` - _, err := r.db.Exec(query, account.ID, account.Name, account.SpaceID, account.Currency, account.CreatedAt, account.UpdatedAt) + query := `INSERT INTO accounts (id, name, space_id, currency, is_investment, investment_subtype, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8);` + _, err := r.db.Exec(query, account.ID, account.Name, account.SpaceID, account.Currency, + account.IsInvestment, account.InvestmentSubtype, account.CreatedAt, account.UpdatedAt) return err } @@ -78,6 +85,36 @@ func (r *accountRepository) Delete(id string) error { return err } +func (r *accountRepository) SetInvestment(id string, isInvestment bool, subtype *string) error { + query := `UPDATE accounts + SET is_investment = $1, investment_subtype = $2, updated_at = CURRENT_TIMESTAMP + WHERE id = $3;` + res, err := r.db.Exec(query, isInvestment, subtype, id) + if err != nil { + return err + } + n, err := res.RowsAffected() + if err != nil { + return err + } + if n == 0 { + return ErrAccountNotFound + } + return nil +} + +func (r *accountRepository) InvestmentAccountsByUserID(userID string) ([]*model.Account, error) { + var accounts []*model.Account + query := `SELECT a.* FROM accounts a + JOIN space_members sm ON sm.space_id = a.space_id + WHERE sm.user_id = $1 AND a.is_investment = TRUE + ORDER BY a.created_at ASC;` + if err := r.db.Select(&accounts, query, userID); err != nil { + return nil, err + } + return accounts, nil +} + func (r *accountRepository) ChangeCurrency(accountID, newCurrency string, newBalance decimal.Decimal, allocationConversions []AllocationConversion) error { return WithTx(r.db, func(tx *sqlx.Tx) error { now := time.Now() diff --git a/internal/repository/investment_contribution_room.go b/internal/repository/investment_contribution_room.go new file mode 100644 index 0000000..420a318 --- /dev/null +++ b/internal/repository/investment_contribution_room.go @@ -0,0 +1,74 @@ +package repository + +import ( + "database/sql" + "errors" + + "git.juancwu.dev/juancwu/budgit/internal/model" + "github.com/jmoiron/sqlx" +) + +var ErrContributionRoomNotFound = errors.New("contribution room not found") + +type InvestmentContributionRoomRepository interface { + Upsert(room *model.InvestmentContributionRoom) error + ByAccountAndYear(accountID string, year int) (*model.InvestmentContributionRoom, error) + ByAccountID(accountID string) ([]*model.InvestmentContributionRoom, error) + Delete(accountID string, year int) error +} + +type investmentContributionRoomRepository struct { + db *sqlx.DB +} + +func NewInvestmentContributionRoomRepository(db *sqlx.DB) InvestmentContributionRoomRepository { + return &investmentContributionRoomRepository{db: db} +} + +func (r *investmentContributionRoomRepository) Upsert(room *model.InvestmentContributionRoom) error { + query := `INSERT INTO investment_contribution_rooms (account_id, year, room_amount, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (account_id, year) DO UPDATE + SET room_amount = EXCLUDED.room_amount, + updated_at = EXCLUDED.updated_at;` + _, err := r.db.Exec(query, room.AccountID, room.Year, room.RoomAmount, room.CreatedAt, room.UpdatedAt) + return err +} + +func (r *investmentContributionRoomRepository) ByAccountAndYear(accountID string, year int) (*model.InvestmentContributionRoom, error) { + room := &model.InvestmentContributionRoom{} + query := `SELECT * FROM investment_contribution_rooms WHERE account_id = $1 AND year = $2;` + err := r.db.Get(room, query, accountID, year) + if err == sql.ErrNoRows { + return nil, ErrContributionRoomNotFound + } + if err != nil { + return nil, err + } + return room, nil +} + +func (r *investmentContributionRoomRepository) ByAccountID(accountID string) ([]*model.InvestmentContributionRoom, error) { + var rooms []*model.InvestmentContributionRoom + query := `SELECT * FROM investment_contribution_rooms WHERE account_id = $1 ORDER BY year DESC;` + if err := r.db.Select(&rooms, query, accountID); err != nil { + return nil, err + } + return rooms, nil +} + +func (r *investmentContributionRoomRepository) Delete(accountID string, year int) error { + res, err := r.db.Exec(`DELETE FROM investment_contribution_rooms WHERE account_id = $1 AND year = $2;`, accountID, year) + if err != nil { + return err + } + n, err := res.RowsAffected() + if err != nil { + return err + } + if n == 0 { + return ErrContributionRoomNotFound + } + return nil +} + diff --git a/internal/repository/investment_holding.go b/internal/repository/investment_holding.go new file mode 100644 index 0000000..ee4a48e --- /dev/null +++ b/internal/repository/investment_holding.go @@ -0,0 +1,89 @@ +package repository + +import ( + "database/sql" + "errors" + + "git.juancwu.dev/juancwu/budgit/internal/model" + "github.com/jmoiron/sqlx" +) + +var ErrHoldingNotFound = errors.New("investment holding not found") + +type InvestmentHoldingRepository interface { + Create(h *model.InvestmentHolding) error + ByID(id string) (*model.InvestmentHolding, error) + ByAccountID(accountID string) ([]*model.InvestmentHolding, error) + Update(id, symbol, displayName string) error + Delete(id string) error +} + +type investmentHoldingRepository struct { + db *sqlx.DB +} + +func NewInvestmentHoldingRepository(db *sqlx.DB) InvestmentHoldingRepository { + return &investmentHoldingRepository{db: db} +} + +func (r *investmentHoldingRepository) Create(h *model.InvestmentHolding) error { + query := `INSERT INTO investment_holdings (id, account_id, symbol, display_name, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6);` + _, err := r.db.Exec(query, h.ID, h.AccountID, h.Symbol, h.DisplayName, h.CreatedAt, h.UpdatedAt) + return err +} + +func (r *investmentHoldingRepository) ByID(id string) (*model.InvestmentHolding, error) { + h := &model.InvestmentHolding{} + query := `SELECT * FROM investment_holdings WHERE id = $1;` + err := r.db.Get(h, query, id) + if err == sql.ErrNoRows { + return nil, ErrHoldingNotFound + } + if err != nil { + return nil, err + } + return h, nil +} + +func (r *investmentHoldingRepository) ByAccountID(accountID string) ([]*model.InvestmentHolding, error) { + var holdings []*model.InvestmentHolding + query := `SELECT * FROM investment_holdings WHERE account_id = $1 ORDER BY symbol ASC;` + if err := r.db.Select(&holdings, query, accountID); err != nil { + return nil, err + } + return holdings, nil +} + +func (r *investmentHoldingRepository) Update(id, symbol, displayName string) error { + query := `UPDATE investment_holdings + SET symbol = $1, display_name = $2, updated_at = CURRENT_TIMESTAMP + WHERE id = $3;` + res, err := r.db.Exec(query, symbol, displayName, id) + if err != nil { + return err + } + n, err := res.RowsAffected() + if err != nil { + return err + } + if n == 0 { + return ErrHoldingNotFound + } + return nil +} + +func (r *investmentHoldingRepository) Delete(id string) error { + res, err := r.db.Exec(`DELETE FROM investment_holdings WHERE id = $1;`, id) + if err != nil { + return err + } + n, err := res.RowsAffected() + if err != nil { + return err + } + if n == 0 { + return ErrHoldingNotFound + } + return nil +} diff --git a/internal/repository/investment_trade.go b/internal/repository/investment_trade.go new file mode 100644 index 0000000..ff17f4f --- /dev/null +++ b/internal/repository/investment_trade.go @@ -0,0 +1,91 @@ +package repository + +import ( + "database/sql" + "errors" + "time" + + "git.juancwu.dev/juancwu/budgit/internal/model" + "github.com/jmoiron/sqlx" + "github.com/shopspring/decimal" +) + +var ErrTradeNotFound = errors.New("investment trade not found") + +type InvestmentTradeRepository interface { + Create(t *model.InvestmentTrade) error + ByID(id string) (*model.InvestmentTrade, error) + ByHoldingID(holdingID string) ([]*model.InvestmentTrade, error) + Update(id string, quantity, pricePerUnit decimal.Decimal, fees *decimal.Decimal, occurredAt time.Time, notes *string) error + Delete(id string) error +} + +type investmentTradeRepository struct { + db *sqlx.DB +} + +func NewInvestmentTradeRepository(db *sqlx.DB) InvestmentTradeRepository { + return &investmentTradeRepository{db: db} +} + +func (r *investmentTradeRepository) Create(t *model.InvestmentTrade) error { + query := `INSERT INTO investment_trades (id, holding_id, type, quantity, price_per_unit, fees, occurred_at, notes, created_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9);` + _, err := r.db.Exec(query, t.ID, t.HoldingID, t.Type, t.Quantity, t.PricePerUnit, t.Fees, t.OccurredAt, t.Notes, t.CreatedAt) + return err +} + +func (r *investmentTradeRepository) ByID(id string) (*model.InvestmentTrade, error) { + t := &model.InvestmentTrade{} + query := `SELECT * FROM investment_trades WHERE id = $1;` + err := r.db.Get(t, query, id) + if err == sql.ErrNoRows { + return nil, ErrTradeNotFound + } + if err != nil { + return nil, err + } + return t, nil +} + +func (r *investmentTradeRepository) ByHoldingID(holdingID string) ([]*model.InvestmentTrade, error) { + var trades []*model.InvestmentTrade + query := `SELECT * FROM investment_trades WHERE holding_id = $1 ORDER BY occurred_at ASC, created_at ASC;` + if err := r.db.Select(&trades, query, holdingID); err != nil { + return nil, err + } + return trades, nil +} + +func (r *investmentTradeRepository) Update(id string, quantity, pricePerUnit decimal.Decimal, fees *decimal.Decimal, occurredAt time.Time, notes *string) error { + query := `UPDATE investment_trades + SET quantity = $1, price_per_unit = $2, fees = $3, occurred_at = $4, notes = $5 + WHERE id = $6;` + res, err := r.db.Exec(query, quantity, pricePerUnit, fees, occurredAt, notes, id) + if err != nil { + return err + } + n, err := res.RowsAffected() + if err != nil { + return err + } + if n == 0 { + return ErrTradeNotFound + } + return nil +} + +func (r *investmentTradeRepository) Delete(id string) error { + res, err := r.db.Exec(`DELETE FROM investment_trades WHERE id = $1;`, id) + if err != nil { + return err + } + n, err := res.RowsAffected() + if err != nil { + return err + } + if n == 0 { + return ErrTradeNotFound + } + return nil +} diff --git a/internal/repository/transaction.go b/internal/repository/transaction.go index fb1d63c..72bb9fb 100644 --- a/internal/repository/transaction.go +++ b/internal/repository/transaction.go @@ -22,6 +22,12 @@ type TransactionRepository interface { TransferIDsIn(ids []string) (map[string]bool, error) ListByAccount(accountID string, limit, offset int) ([]*model.Transaction, error) CountByAccount(accountID string) (int, error) + // SumByAccountYearType totals transaction values for an account, year, + // and type (deposit or withdrawal). Returns zero when no rows match. + SumByAccountYearType(accountID string, year int, txType model.TransactionType) (decimal.Decimal, error) + // SumLifetimeByAccountType totals transaction values for an account over + // its full history, restricted to one type. + SumLifetimeByAccountType(accountID string, txType model.TransactionType) (decimal.Decimal, error) } type transactionRepository struct { @@ -304,3 +310,25 @@ func (r *transactionRepository) CountByAccount(accountID string) (int, error) { } return count, nil } + +func (r *transactionRepository) SumByAccountYearType(accountID string, year int, txType model.TransactionType) (decimal.Decimal, error) { + var sum decimal.Decimal + query := `SELECT COALESCE(SUM(value::numeric), 0)::text FROM transactions + WHERE account_id = $1 + AND type = $2 + AND EXTRACT(YEAR FROM occurred_at) = $3;` + if err := r.db.Get(&sum, query, accountID, txType, year); err != nil { + return decimal.Zero, err + } + return sum, nil +} + +func (r *transactionRepository) SumLifetimeByAccountType(accountID string, txType model.TransactionType) (decimal.Decimal, error) { + var sum decimal.Decimal + query := `SELECT COALESCE(SUM(value::numeric), 0)::text FROM transactions + WHERE account_id = $1 AND type = $2;` + if err := r.db.Get(&sum, query, accountID, txType); err != nil { + return decimal.Zero, err + } + return sum, nil +} diff --git a/internal/routes/routes.go b/internal/routes/routes.go index bbb4879..543cb1e 100644 --- a/internal/routes/routes.go +++ b/internal/routes/routes.go @@ -19,9 +19,10 @@ 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.AllocationService, 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, a.InvestmentService) allocationH := handler.NewAllocationHandler(a.AllocationService, a.AccountService) recurringH := handler.NewRecurringEventHandler(a.RecurringEventService, a.AccountService, a.SpaceService) + investmentH := handler.NewInvestmentHandler(a.AccountService, a.SpaceService, a.InvestmentService) redirectH := handler.NewRedirectHandler() r := router.New() @@ -95,6 +96,8 @@ func SetupRoutes(a *app.App) http.Handler { g.Get("/home", spaceH.HomePage).Name("page.app.home") + g.Get("/investments", investmentH.InvestmentsOverviewPage).Name("page.app.investments") + g.SubGroup("/spaces", func(g *router.Group) { g.Get("", spaceH.SpacesPage).Name("page.app.spaces") g.Get("/create", spaceH.CreateSpacePage).Name("page.app.spaces.create") @@ -136,6 +139,7 @@ func SetupRoutes(a *app.App) http.Handler { g.Post("/settings/rename", spaceH.HandleRenameAccount).Name("action.app.spaces.space.accounts.account.settings.rename") g.Post("/settings/currency", spaceH.HandleChangeAccountCurrency).Name("action.app.spaces.space.accounts.account.settings.currency") g.Post("/settings/delete", spaceH.HandleDeleteAccount).Name("action.app.spaces.space.accounts.account.settings.delete") + g.Post("/settings/investment", spaceH.HandleSetInvestmentFlag).Name("action.app.spaces.space.accounts.account.settings.investment") g.Get("/bills/create", spaceH.SpaceCreateBillPage).Name("page.app.spaces.space.accounts.account.bills.create") g.Post("/bills/create", spaceH.HandleCreateBill).Name("action.app.spaces.space.accounts.account.bills.create") g.Get("/deposits/create", spaceH.SpaceCreateDepositPage).Name("page.app.spaces.space.accounts.account.deposits.create") @@ -146,6 +150,14 @@ func SetupRoutes(a *app.App) http.Handler { 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") + + g.Post("/investments/contribution-room", investmentH.HandleSetContributionRoom).Name("action.app.spaces.space.accounts.account.investments.contribution-room") + g.Get("/investments/holdings/create", investmentH.CreateHoldingPage).Name("page.app.spaces.space.accounts.account.investments.holdings.create") + g.Post("/investments/holdings/create", investmentH.HandleCreateHolding).Name("action.app.spaces.space.accounts.account.investments.holdings.create") + g.Get("/investments/holdings/{holdingID}", investmentH.HoldingDetailPage).Name("page.app.spaces.space.accounts.account.investments.holdings.holding") + g.Post("/investments/holdings/{holdingID}/delete", investmentH.HandleDeleteHolding).Name("action.app.spaces.space.accounts.account.investments.holdings.holding.delete") + g.Post("/investments/holdings/{holdingID}/trades/create", investmentH.HandleCreateTrade).Name("action.app.spaces.space.accounts.account.investments.holdings.holding.trades.create") + g.Post("/investments/holdings/{holdingID}/trades/{tradeID}/delete", investmentH.HandleDeleteTrade).Name("action.app.spaces.space.accounts.account.investments.holdings.holding.trades.trade.delete") }) }) }) diff --git a/internal/service/account.go b/internal/service/account.go index e18072f..0097096 100644 --- a/internal/service/account.go +++ b/internal/service/account.go @@ -34,47 +34,126 @@ func (s *AccountService) SetAuditLogger(audit *SpaceAuditLogService) { s.auditSvc = audit } -func (s *AccountService) CreateAccount(spaceID, name, currencyCode, actorID string) (*model.Account, error) { - if spaceID == "" { +// CreateAccountInput captures all the fields the caller can set when creating +// an account. isInvestment + investmentSubtype are optional; if isInvestment is +// false the subtype is forced to nil. +type CreateAccountInput struct { + SpaceID string + Name string + CurrencyCode string + IsInvestment bool + InvestmentSubtype string // canonical lowercase string; ignored if IsInvestment is false + ActorID string +} + +func (s *AccountService) CreateAccount(input CreateAccountInput) (*model.Account, error) { + if input.SpaceID == "" { return nil, fmt.Errorf("space id is required") } - if name == "" { + if input.Name == "" { return nil, fmt.Errorf("account name cannot be empty") } - code := currency.Normalize(currencyCode) + code := currency.Normalize(input.CurrencyCode) if code == "" { code = currency.Default } if !currency.IsValid(code) { - return nil, fmt.Errorf("unsupported currency code: %s", currencyCode) + return nil, fmt.Errorf("unsupported currency code: %s", input.CurrencyCode) + } + + var subtypePtr *string + if input.IsInvestment { + sub := input.InvestmentSubtype + if !model.IsValidInvestmentSubtype(sub) { + return nil, fmt.Errorf("invalid investment subtype: %s", sub) + } + subtypePtr = &sub } now := time.Now() account := &model.Account{ - ID: uuid.NewString(), - Name: name, - SpaceID: spaceID, - Currency: code, - CreatedAt: now, - UpdatedAt: now, + ID: uuid.NewString(), + Name: input.Name, + SpaceID: input.SpaceID, + Currency: code, + IsInvestment: input.IsInvestment, + InvestmentSubtype: subtypePtr, + CreatedAt: now, + UpdatedAt: now, } if err := s.accountRepo.Create(account); err != nil { return nil, fmt.Errorf("failed to create account: %w", err) } + meta := map[string]any{ + "account_id": account.ID, + "account_name": account.Name, + "currency": account.Currency, + } + if account.IsInvestment { + meta["is_investment"] = true + if subtypePtr != nil { + meta["investment_subtype"] = *subtypePtr + } + } s.auditSvc.Record(RecordOptions{ - SpaceID: spaceID, - ActorID: actorID, - Action: model.SpaceAuditActionAccountCreated, - Metadata: map[string]any{ - "account_id": account.ID, - "account_name": account.Name, - "currency": account.Currency, - }, + SpaceID: input.SpaceID, + ActorID: input.ActorID, + Action: model.SpaceAuditActionAccountCreated, + Metadata: meta, }) return account, nil } +// SetInvestmentFlag toggles the investment flag on an existing account. When +// turning the flag off, the subtype is cleared. +func (s *AccountService) SetInvestmentFlag(accountID string, isInvestment bool, subtype string, actorID string) error { + if accountID == "" { + return fmt.Errorf("account id is required") + } + account, err := s.accountRepo.ByID(accountID) + if err != nil { + return fmt.Errorf("failed to load account: %w", err) + } + + var subtypePtr *string + if isInvestment { + if !model.IsValidInvestmentSubtype(subtype) { + return fmt.Errorf("invalid investment subtype: %s", subtype) + } + s := subtype + subtypePtr = &s + } + if err := s.accountRepo.SetInvestment(accountID, isInvestment, subtypePtr); err != nil { + return fmt.Errorf("failed to update investment flag: %w", err) + } + s.auditSvc.Record(RecordOptions{ + SpaceID: account.SpaceID, + ActorID: actorID, + Action: model.SpaceAuditActionAccountInvestmentFlag, + Metadata: map[string]any{ + "account_id": accountID, + "account_name": account.Name, + "is_investment": isInvestment, + "investment_subtype": subtypePtr, + }, + }) + return nil +} + +// InvestmentAccountsForUser lists every investment-flagged account in spaces +// the user is a member of (including spaces they own). +func (s *AccountService) InvestmentAccountsForUser(userID string) ([]*model.Account, error) { + if userID == "" { + return nil, fmt.Errorf("user id is required") + } + accounts, err := s.accountRepo.InvestmentAccountsByUserID(userID) + if err != nil { + return nil, fmt.Errorf("failed to list investment accounts: %w", err) + } + return accounts, nil +} + func (s *AccountService) GetAccount(id string) (*model.Account, error) { account, err := s.accountRepo.ByID(id) if err != nil { diff --git a/internal/service/account_test.go b/internal/service/account_test.go index 1c8e437..9d12aff 100644 --- a/internal/service/account_test.go +++ b/internal/service/account_test.go @@ -22,7 +22,7 @@ func TestAccountService_CreateAccount_RecordsAudit(t *testing.T) { user := testutil.CreateTestUser(t, dbi.DB, "acct-create-audit@example.com", nil) space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "S") - account, err := svc.CreateAccount(space.ID, "Checking", "CAD", user.ID) + account, err := svc.CreateAccount(CreateAccountInput{SpaceID: space.ID, Name: "Checking", CurrencyCode: "CAD", ActorID: user.ID}) require.NoError(t, err) logs, err := auditRepo.ListAccountEvents(account.ID, 10, 0) @@ -104,7 +104,7 @@ func TestAccountService_NoAuditLoggerSet_DoesNotPanic(t *testing.T) { user := testutil.CreateTestUser(t, dbi.DB, "no-audit@example.com", nil) space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "S") - account, err := svc.CreateAccount(space.ID, "x", "", user.ID) + account, err := svc.CreateAccount(CreateAccountInput{SpaceID: space.ID, Name: "x", ActorID: user.ID}) require.NoError(t, err) require.NoError(t, svc.RenameAccount(account.ID, "y", user.ID)) require.NoError(t, svc.DeleteAccount(account.ID, user.ID)) diff --git a/internal/service/auth.go b/internal/service/auth.go index d9f7f46..6054d32 100644 --- a/internal/service/auth.go +++ b/internal/service/auth.go @@ -371,7 +371,11 @@ func (s *AuthService) CompleteOnboarding(userID, name string) error { return fmt.Errorf("failed to create onboarding space: %w", err) } - if _, err := s.accountService.CreateAccount(space.ID, DefaultAccountName, "", userID); err != nil { + if _, err := s.accountService.CreateAccount(CreateAccountInput{ + SpaceID: space.ID, + Name: DefaultAccountName, + ActorID: userID, + }); err != nil { if delErr := s.spaceService.DeleteSpace(space.ID, userID); delErr != nil { slog.Error("failed to roll back space after account creation error", "space_id", space.ID, "error", delErr) diff --git a/internal/service/investment.go b/internal/service/investment.go new file mode 100644 index 0000000..8ba4590 --- /dev/null +++ b/internal/service/investment.go @@ -0,0 +1,355 @@ +package service + +import ( + "fmt" + "time" + + "git.juancwu.dev/juancwu/budgit/internal/model" + "git.juancwu.dev/juancwu/budgit/internal/repository" + "github.com/google/uuid" + "github.com/shopspring/decimal" +) + +// InvestmentService handles contribution rooms, holdings, trades, and the +// summary view for investment-flagged accounts. Cash movement (contributions +// and withdrawals) still goes through TransactionService / TransferService; +// this service reads from those tables but never mutates them directly. +type InvestmentService struct { + accountRepo repository.AccountRepository + roomRepo repository.InvestmentContributionRoomRepository + holdingRepo repository.InvestmentHoldingRepository + tradeRepo repository.InvestmentTradeRepository + txRepo repository.TransactionRepository +} + +func NewInvestmentService( + accountRepo repository.AccountRepository, + roomRepo repository.InvestmentContributionRoomRepository, + holdingRepo repository.InvestmentHoldingRepository, + tradeRepo repository.InvestmentTradeRepository, + txRepo repository.TransactionRepository, +) *InvestmentService { + return &InvestmentService{ + accountRepo: accountRepo, + roomRepo: roomRepo, + holdingRepo: holdingRepo, + tradeRepo: tradeRepo, + txRepo: txRepo, + } +} + +// ---------- Contribution room ---------- + +func (s *InvestmentService) SetContributionRoom(accountID string, year int, room decimal.Decimal) error { + if accountID == "" { + return fmt.Errorf("account id is required") + } + if year < 1900 || year > 9999 { + return fmt.Errorf("year out of range") + } + if room.IsNegative() { + return fmt.Errorf("contribution room cannot be negative") + } + account, err := s.accountRepo.ByID(accountID) + if err != nil { + return fmt.Errorf("failed to load account: %w", err) + } + if !account.IsInvestment { + return fmt.Errorf("account is not an investment account") + } + now := time.Now() + return s.roomRepo.Upsert(&model.InvestmentContributionRoom{ + AccountID: accountID, + Year: year, + RoomAmount: room, + CreatedAt: now, + UpdatedAt: now, + }) +} + +func (s *InvestmentService) GetContributionRoom(accountID string, year int) (*model.InvestmentContributionRoom, error) { + room, err := s.roomRepo.ByAccountAndYear(accountID, year) + if err != nil { + if err == repository.ErrContributionRoomNotFound { + return nil, nil + } + return nil, fmt.Errorf("failed to load contribution room: %w", err) + } + return room, nil +} + +func (s *InvestmentService) ListContributionRooms(accountID string) ([]*model.InvestmentContributionRoom, error) { + rooms, err := s.roomRepo.ByAccountID(accountID) + if err != nil { + return nil, fmt.Errorf("failed to list contribution rooms: %w", err) + } + return rooms, nil +} + +// ---------- Summary ---------- + +// SummarizeAccount produces the rollup view for an investment account in the +// given calendar year: contribution room, YTD cash flow, lifetime net +// contributions, and total cost basis across all holdings. +func (s *InvestmentService) SummarizeAccount(accountID string, year int) (*model.InvestmentAccountSummary, error) { + account, err := s.accountRepo.ByID(accountID) + if err != nil { + return nil, fmt.Errorf("failed to load account: %w", err) + } + if !account.IsInvestment { + return nil, fmt.Errorf("account is not an investment account") + } + + ytdContrib, err := s.txRepo.SumByAccountYearType(accountID, year, model.TransactionTypeDeposit) + if err != nil { + return nil, fmt.Errorf("failed to sum ytd contributions: %w", err) + } + ytdWithdraw, err := s.txRepo.SumByAccountYearType(accountID, year, model.TransactionTypeWithdrawal) + if err != nil { + return nil, fmt.Errorf("failed to sum ytd withdrawals: %w", err) + } + lifeContrib, err := s.txRepo.SumLifetimeByAccountType(accountID, model.TransactionTypeDeposit) + if err != nil { + return nil, fmt.Errorf("failed to sum lifetime contributions: %w", err) + } + lifeWithdraw, err := s.txRepo.SumLifetimeByAccountType(accountID, model.TransactionTypeWithdrawal) + if err != nil { + return nil, fmt.Errorf("failed to sum lifetime withdrawals: %w", err) + } + + summary := &model.InvestmentAccountSummary{ + Account: account, + Year: year, + YTDContributions: ytdContrib, + YTDWithdrawals: ytdWithdraw, + NetContributions: lifeContrib.Sub(lifeWithdraw), + } + + room, err := s.roomRepo.ByAccountAndYear(accountID, year) + if err == nil { + amt := room.RoomAmount + summary.RoomAmount = &amt + rem := amt.Sub(ytdContrib) + summary.RoomRemaining = &rem + } else if err != repository.ErrContributionRoomNotFound { + return nil, fmt.Errorf("failed to load contribution room: %w", err) + } + + positions, err := s.HoldingPositions(accountID) + if err != nil { + return nil, err + } + summary.HoldingCount = len(positions) + for _, p := range positions { + summary.TotalCostBasis = summary.TotalCostBasis.Add(p.CostBasis) + } + return summary, nil +} + +// ---------- Holdings ---------- + +func (s *InvestmentService) CreateHolding(accountID, symbol, displayName string) (*model.InvestmentHolding, error) { + if accountID == "" { + return nil, fmt.Errorf("account id is required") + } + if symbol == "" { + return nil, fmt.Errorf("symbol is required") + } + if displayName == "" { + displayName = symbol + } + account, err := s.accountRepo.ByID(accountID) + if err != nil { + return nil, fmt.Errorf("failed to load account: %w", err) + } + if !account.IsInvestment { + return nil, fmt.Errorf("account is not an investment account") + } + now := time.Now() + holding := &model.InvestmentHolding{ + ID: uuid.NewString(), + AccountID: accountID, + Symbol: symbol, + DisplayName: displayName, + CreatedAt: now, + UpdatedAt: now, + } + if err := s.holdingRepo.Create(holding); err != nil { + return nil, fmt.Errorf("failed to create holding: %w", err) + } + return holding, nil +} + +func (s *InvestmentService) GetHolding(id string) (*model.InvestmentHolding, error) { + h, err := s.holdingRepo.ByID(id) + if err != nil { + return nil, fmt.Errorf("failed to load holding: %w", err) + } + return h, nil +} + +func (s *InvestmentService) UpdateHolding(id, symbol, displayName string) error { + if symbol == "" { + return fmt.Errorf("symbol is required") + } + if displayName == "" { + displayName = symbol + } + if err := s.holdingRepo.Update(id, symbol, displayName); err != nil { + return fmt.Errorf("failed to update holding: %w", err) + } + return nil +} + +func (s *InvestmentService) DeleteHolding(id string) error { + return s.holdingRepo.Delete(id) +} + +func (s *InvestmentService) ListHoldings(accountID string) ([]*model.InvestmentHolding, error) { + return s.holdingRepo.ByAccountID(accountID) +} + +// ---------- Trades ---------- + +type RecordTradeInput struct { + HoldingID string + Type model.InvestmentTradeType + Quantity decimal.Decimal + PricePerUnit decimal.Decimal + Fees *decimal.Decimal + OccurredAt time.Time + Notes *string +} + +func (s *InvestmentService) RecordTrade(input RecordTradeInput) (*model.InvestmentTrade, error) { + if input.HoldingID == "" { + return nil, fmt.Errorf("holding id is required") + } + if !model.IsValidInvestmentTradeType(string(input.Type)) { + return nil, fmt.Errorf("invalid trade type: %s", input.Type) + } + if !input.Quantity.IsPositive() { + return nil, fmt.Errorf("quantity must be greater than zero") + } + if input.PricePerUnit.IsNegative() { + return nil, fmt.Errorf("price per unit cannot be negative") + } + if input.OccurredAt.IsZero() { + input.OccurredAt = time.Now() + } + trade := &model.InvestmentTrade{ + ID: uuid.NewString(), + HoldingID: input.HoldingID, + Type: input.Type, + Quantity: input.Quantity, + PricePerUnit: input.PricePerUnit, + Fees: input.Fees, + OccurredAt: input.OccurredAt, + Notes: input.Notes, + CreatedAt: time.Now(), + } + if err := s.tradeRepo.Create(trade); err != nil { + return nil, fmt.Errorf("failed to record trade: %w", err) + } + return trade, nil +} + +func (s *InvestmentService) UpdateTrade(id string, qty, price decimal.Decimal, fees *decimal.Decimal, occurredAt time.Time, notes *string) error { + if !qty.IsPositive() { + return fmt.Errorf("quantity must be greater than zero") + } + if price.IsNegative() { + return fmt.Errorf("price per unit cannot be negative") + } + return s.tradeRepo.Update(id, qty, price, fees, occurredAt, notes) +} + +func (s *InvestmentService) DeleteTrade(id string) error { + return s.tradeRepo.Delete(id) +} + +func (s *InvestmentService) GetTrade(id string) (*model.InvestmentTrade, error) { + return s.tradeRepo.ByID(id) +} + +func (s *InvestmentService) ListTrades(holdingID string) ([]*model.InvestmentTrade, error) { + return s.tradeRepo.ByHoldingID(holdingID) +} + +// HoldingPositions returns the derived position for every holding in the +// account. Positions are computed by replaying each trade in chronological +// order, maintaining a running weighted-average cost basis. Each sell reduces +// the remaining quantity at the current avg cost; realized P/L accumulates on +// each sell as (sell.price − avg cost) × qty − fees. +func (s *InvestmentService) HoldingPositions(accountID string) ([]model.HoldingPosition, error) { + holdings, err := s.holdingRepo.ByAccountID(accountID) + if err != nil { + return nil, fmt.Errorf("failed to load holdings: %w", err) + } + out := make([]model.HoldingPosition, 0, len(holdings)) + for _, h := range holdings { + pos, err := s.holdingPosition(*h) + if err != nil { + return nil, err + } + out = append(out, pos) + } + return out, nil +} + +func (s *InvestmentService) HoldingPosition(holdingID string) (*model.HoldingPosition, error) { + h, err := s.holdingRepo.ByID(holdingID) + if err != nil { + return nil, fmt.Errorf("failed to load holding: %w", err) + } + pos, err := s.holdingPosition(*h) + if err != nil { + return nil, err + } + return &pos, nil +} + +func (s *InvestmentService) holdingPosition(h model.InvestmentHolding) (model.HoldingPosition, error) { + trades, err := s.tradeRepo.ByHoldingID(h.ID) + if err != nil { + return model.HoldingPosition{}, fmt.Errorf("failed to load trades: %w", err) + } + pos := model.HoldingPosition{Holding: h} + qty := decimal.Zero + avgCost := decimal.Zero + for _, t := range trades { + fees := decimal.Zero + if t.Fees != nil { + fees = *t.Fees + } + pos.TotalFees = pos.TotalFees.Add(fees) + switch t.Type { + case model.InvestmentTradeTypeBuy: + newQty := qty.Add(t.Quantity) + if newQty.IsPositive() { + // weighted average including fees in cost basis + newCost := qty.Mul(avgCost).Add(t.Quantity.Mul(t.PricePerUnit)).Add(fees) + avgCost = newCost.Div(newQty) + } + qty = newQty + pos.TotalBuyQty = pos.TotalBuyQty.Add(t.Quantity) + price := t.PricePerUnit + pos.LastBuyPrice = &price + case model.InvestmentTradeTypeSell: + realized := t.PricePerUnit.Sub(avgCost).Mul(t.Quantity).Sub(fees) + pos.RealizedPL = pos.RealizedPL.Add(realized) + qty = qty.Sub(t.Quantity) + pos.TotalSellQty = pos.TotalSellQty.Add(t.Quantity) + price := t.PricePerUnit + pos.LastSellPrice = &price + if !qty.IsPositive() { + qty = decimal.Zero + avgCost = decimal.Zero + } + } + } + pos.Quantity = qty + pos.AvgCost = avgCost + pos.CostBasis = qty.Mul(avgCost) + return pos, nil +} diff --git a/internal/ui/blocks/investment_section.templ b/internal/ui/blocks/investment_section.templ new file mode 100644 index 0000000..747c1e6 --- /dev/null +++ b/internal/ui/blocks/investment_section.templ @@ -0,0 +1,220 @@ +package blocks + +import ( + "fmt" + "strings" + + "github.com/shopspring/decimal" + + "git.juancwu.dev/juancwu/budgit/internal/model" + "git.juancwu.dev/juancwu/budgit/internal/routeurl" + "git.juancwu.dev/juancwu/budgit/internal/ui/components/badge" + "git.juancwu.dev/juancwu/budgit/internal/ui/components/button" + "git.juancwu.dev/juancwu/budgit/internal/ui/components/card" + "git.juancwu.dev/juancwu/budgit/internal/ui/components/icon" + "git.juancwu.dev/juancwu/budgit/internal/ui/components/input" + "git.juancwu.dev/juancwu/budgit/internal/ui/utils" +) + +type InvestmentSectionProps struct { + SpaceID string + AccountID string + Currency string + Summary *model.InvestmentAccountSummary + Positions []model.HoldingPosition +} + +func fmtMoney(d decimal.Decimal) string { + s, _ := utils.FormatDecimalWithThousands(d.StringFixedBank(2)) + return s +} + +func subtypeLabel(s *string) string { + if s == nil { + return "" + } + return strings.ToUpper(*s) +} + +func plClass(d decimal.Decimal) string { + if d.IsNegative() { + return "text-red-600 dark:text-red-400" + } + if d.IsPositive() { + return "text-green-600 dark:text-green-400" + } + return "" +} + +templ InvestmentSection(props InvestmentSectionProps) { +
+ @card.Card(card.Props{Class: "rounded-sm"}) { + @card.Header() { +
+
+ @card.Title() { +
+ Contribution Room ({ fmt.Sprintf("%d", props.Summary.Year) }) + if label := subtypeLabel(props.Summary.Account.InvestmentSubtype); label != "" { + @badge.Badge(badge.Props{Variant: badge.VariantSecondary, Class: "text-xs"}) { + { label } + } + } +
+ } + @card.Description() { + Track yearly contributions against the room you set. + } +
+
+ } + @card.Content(card.ContentProps{Class: "space-y-4"}) { +
+
+
Room
+
+ if props.Summary.RoomAmount != nil { + ${ fmtMoney(*props.Summary.RoomAmount) } + } else { + Not set + } +
+
+
+
YTD Contributions
+
${ fmtMoney(props.Summary.YTDContributions) }
+
+
+
YTD Withdrawals
+
${ fmtMoney(props.Summary.YTDWithdrawals) }
+
+
+
Remaining
+
+ if props.Summary.RoomRemaining != nil { + ${ fmtMoney(*props.Summary.RoomRemaining) } + } else { + + } +
+
+
+
+
+ + @input.Input(input.Props{ + ID: "room-year", + Name: "year", + Type: input.TypeNumber, + Value: fmt.Sprintf("%d", props.Summary.Year), + Class: "w-28 rounded-sm", + }) +
+
+ + {{ + currentRoom := "" + if props.Summary.RoomAmount != nil { + currentRoom = props.Summary.RoomAmount.StringFixedBank(2) + } + }} + @input.Input(input.Props{ + ID: "room-amount", + Name: "room", + Type: input.TypeText, + Value: currentRoom, + Placeholder: "0.00", + Class: "w-40 rounded-sm", + Required: true, + }) +
+ @button.Button(button.Props{Type: button.TypeSubmit, Class: "rounded-sm"}) { + Save room + } +
+ } + } + @card.Card(card.Props{Class: "rounded-sm"}) { + @card.Header() { +
+
+ @card.Title() { + Holdings + } + @card.Description() { + Track shares and buy/sell prices. Cost basis is computed from your trades. + } +
+ @button.Button(button.Props{ + Variant: button.VariantSecondary, + Class: "flex gap-2 items-center rounded-sm", + Href: routeurl.URL("page.app.spaces.space.accounts.account.investments.holdings.create", "spaceID", props.SpaceID, "accountID", props.AccountID), + }) { + @icon.Plus() + Add holding + } +
+ } + @card.Content() { + if len(props.Positions) == 0 { +

No holdings yet. Add one to start tracking shares and trades.

+ } else { +
+ + + + + + + + + + + + + for _, pos := range props.Positions { + + + + + + + + + } + +
SymbolQuantityAvg costCost basisRealized P/L
+ + { pos.Holding.Symbol } + +
{ pos.Holding.DisplayName }
+
{ pos.Quantity.StringFixedBank(4) }${ fmtMoney(pos.AvgCost) }${ fmtMoney(pos.CostBasis) }${ fmtMoney(pos.RealizedPL) } + @button.Button(button.Props{ + Variant: button.VariantGhost, + Class: "h-8 px-2", + Href: routeurl.URL("page.app.spaces.space.accounts.account.investments.holdings.holding", "spaceID", props.SpaceID, "accountID", props.AccountID, "holdingID", pos.Holding.ID), + }) { + @icon.ChevronRight() + } +
+
+ } + } + } +
+} + +func remainingValue(s *model.InvestmentAccountSummary) decimal.Decimal { + if s.RoomRemaining == nil { + return decimal.Zero + } + return *s.RoomRemaining +} diff --git a/internal/ui/forms/create_account.templ b/internal/ui/forms/create_account.templ index 00c2965..079c8a5 100644 --- a/internal/ui/forms/create_account.templ +++ b/internal/ui/forms/create_account.templ @@ -10,14 +10,28 @@ import "git.juancwu.dev/juancwu/budgit/internal/ui/components/input" type CreateAccountProps struct { SpaceID string - Name string - Currency string + Name string + Currency string + IsInvestment bool + InvestmentSubtype string NameErr string CurrencyErr string + SubtypeErr string GeneralErr string } +var investmentSubtypes = []struct { + Value string + Label string +}{ + {"tfsa", "TFSA"}, + {"rrsp", "RRSP"}, + {"fhsa", "FHSA"}, + {"personal", "Personal / Non-registered"}, + {"other", "Other"}, +} + templ CreateAccount(props CreateAccountProps) {
@card.Card(card.Props{Class: "rounded-sm"}) { @@ -82,6 +96,54 @@ templ CreateAccount(props CreateAccountProps) { } } } + {{ + selectedSubtype := props.InvestmentSubtype + if selectedSubtype == "" { + selectedSubtype = "tfsa" + } + }} + @form.Item() { + + @form.Description() { + Tracks contributions, contribution room, and holdings. + } + } +
+ @form.Item() { + @form.Label(form.LabelProps{For: "investment_subtype"}) { + Account type + } + + if props.SubtypeErr != "" { + @form.Message(form.MessageProps{Variant: form.MessageVariantError}) { + { props.SubtypeErr } + } + } + } +
} @card.Footer(card.FooterProps{Class: "flex justify-end gap-2"}) { @button.Button(button.Props{ diff --git a/internal/ui/pages/investment_holding_detail.templ b/internal/ui/pages/investment_holding_detail.templ new file mode 100644 index 0000000..14bed8f --- /dev/null +++ b/internal/ui/pages/investment_holding_detail.templ @@ -0,0 +1,213 @@ +package pages + +import ( + "fmt" + "time" + + "git.juancwu.dev/juancwu/budgit/internal/model" + "git.juancwu.dev/juancwu/budgit/internal/routeurl" + "git.juancwu.dev/juancwu/budgit/internal/ui/components/badge" + "git.juancwu.dev/juancwu/budgit/internal/ui/components/button" + "git.juancwu.dev/juancwu/budgit/internal/ui/components/card" + "git.juancwu.dev/juancwu/budgit/internal/ui/components/form" + "git.juancwu.dev/juancwu/budgit/internal/ui/components/icon" + "git.juancwu.dev/juancwu/budgit/internal/ui/components/input" + "git.juancwu.dev/juancwu/budgit/internal/ui/layouts" + "git.juancwu.dev/juancwu/budgit/internal/ui/utils" +) + +type InvestmentHoldingDetailProps struct { + SpaceID string + SpaceName string + AccountID string + AccountName string + Currency string + Position model.HoldingPosition + Trades []*model.InvestmentTrade +} + +func holdingPlClass(d string) string { + if len(d) > 0 && d[0] == '-' { + return "text-red-600 dark:text-red-400" + } + return "text-green-600 dark:text-green-400" +} + +templ InvestmentHoldingDetailPage(props InvestmentHoldingDetailProps) { + @layouts.AppWithBreadcrumb( + props.Position.Holding.Symbol, + accountChildBreadcrumb(props.SpaceID, props.SpaceName, props.AccountID, props.AccountName, props.Position.Holding.Symbol), + spaceOverviewSidebarContent(), + spaceSpecificSidebarContent(props.SpaceID), + spaceAccountSidebarContent(props.SpaceID, props.AccountID), + ) { +
+
+
+

+ { props.Position.Holding.Symbol } + @badge.Badge(badge.Props{Variant: badge.VariantSecondary}) { + { props.Currency } + } +

+

{ props.Position.Holding.DisplayName }

+
+ + @button.Button(button.Props{ + Type: button.TypeSubmit, + Variant: button.VariantDestructive, + Class: "flex items-center gap-2", + }) { + @icon.Trash2() + Delete holding + } + +
+ @card.Card(card.Props{Class: "rounded-sm"}) { + @card.Content(card.ContentProps{Class: "grid grid-cols-2 md:grid-cols-5 gap-4 text-sm p-4"}) { +
+
Quantity
+
{ props.Position.Quantity.StringFixedBank(4) }
+
+
+
Avg cost
+
${ utils.FormatDecimalWithThousands(props.Position.AvgCost.StringFixedBank(2)) }
+
+
+
Cost basis
+
${ utils.FormatDecimalWithThousands(props.Position.CostBasis.StringFixedBank(2)) }
+
+
+
Realized P/L
+
+ ${ utils.FormatDecimalWithThousands(props.Position.RealizedPL.StringFixedBank(2)) } +
+
+
+
Total fees
+
${ utils.FormatDecimalWithThousands(props.Position.TotalFees.StringFixedBank(2)) }
+
+ } + } + @card.Card(card.Props{Class: "rounded-sm"}) { + @card.Header() { + @card.Title() { + Record a trade + } + @card.Description() { + Manually enter a buy or sell. Quantities and prices recompute the position. + } + } + @card.Content() { +
+ @form.Item() { + @form.Label(form.LabelProps{For: "type"}) { Type } + + } + @form.Item() { + @form.Label(form.LabelProps{For: "quantity"}) { Quantity } + @input.Input(input.Props{ID: "quantity", Name: "quantity", Type: input.TypeText, Placeholder: "0", Class: "rounded-sm", Required: true}) + } + @form.Item() { + @form.Label(form.LabelProps{For: "price"}) { Price / unit } + @input.Input(input.Props{ID: "price", Name: "price", Type: input.TypeText, Placeholder: "0.00", Class: "rounded-sm", Required: true}) + } + @form.Item() { + @form.Label(form.LabelProps{For: "fees"}) { Fees } + @input.Input(input.Props{ID: "fees", Name: "fees", Type: input.TypeText, Placeholder: "0.00", Class: "rounded-sm"}) + } + @form.Item() { + @form.Label(form.LabelProps{For: "occurred_at"}) { Date } + @input.Input(input.Props{ID: "occurred_at", Name: "occurred_at", Type: input.TypeDate, Value: time.Now().Format("2006-01-02"), Class: "rounded-sm"}) + } + @button.Button(button.Props{Type: button.TypeSubmit, Class: "rounded-sm"}) { + Record + } +
+ } + } + @card.Card(card.Props{Class: "rounded-sm"}) { + @card.Header() { + @card.Title() { Trade history } + } + @card.Content() { + if len(props.Trades) == 0 { +

No trades recorded yet.

+ } else { +
+ + + + + + + + + + + + + + for _, t := range props.Trades { + {{ + fees := "—" + if t.Fees != nil { + fees = t.Fees.StringFixedBank(2) + } + total := t.Quantity.Mul(t.PricePerUnit) + typeLabel := "Buy" + if string(t.Type) == "sell" { + typeLabel = "Sell" + } + }} + + + + + + + + + + } + +
DateTypeQuantityPrice / unitFeesTotal
{ t.OccurredAt.Format("2006-01-02") } + @badge.Badge(badge.Props{ + Variant: badge.VariantSecondary, + Class: "text-xs", + }) { + { typeLabel } + } + { t.Quantity.StringFixedBank(4) }${ utils.FormatDecimalWithThousands(t.PricePerUnit.StringFixedBank(2)) }{ fees }${ utils.FormatDecimalWithThousands(total.StringFixedBank(2)) } +
+ @button.Button(button.Props{ + Type: button.TypeSubmit, + Variant: button.VariantGhost, + Class: "h-8 px-2", + }) { + @icon.Trash2() + } +
+
+
+ } + } + } +
+ } +} + +var _ = fmt.Sprintf diff --git a/internal/ui/pages/investment_holding_form.templ b/internal/ui/pages/investment_holding_form.templ new file mode 100644 index 0000000..23f7a5c --- /dev/null +++ b/internal/ui/pages/investment_holding_form.templ @@ -0,0 +1,88 @@ +package pages + +import ( + "git.juancwu.dev/juancwu/budgit/internal/routeurl" + "git.juancwu.dev/juancwu/budgit/internal/ui/components/button" + "git.juancwu.dev/juancwu/budgit/internal/ui/components/card" + "git.juancwu.dev/juancwu/budgit/internal/ui/components/form" + "git.juancwu.dev/juancwu/budgit/internal/ui/components/input" + "git.juancwu.dev/juancwu/budgit/internal/ui/layouts" +) + +type InvestmentHoldingFormPageProps struct { + SpaceID string + SpaceName string + AccountID string + AccountName string +} + +templ InvestmentHoldingFormPage(props InvestmentHoldingFormPageProps) { + @layouts.AppWithBreadcrumb( + "Add holding", + accountChildBreadcrumb(props.SpaceID, props.SpaceName, props.AccountID, props.AccountName, "Add holding"), + spaceOverviewSidebarContent(), + spaceSpecificSidebarContent(props.SpaceID), + spaceAccountSidebarContent(props.SpaceID, props.AccountID), + ) { +
+
+

Add holding

+

+ Track a symbol inside { props.AccountName }. You can record buy/sell trades after creating it. +

+
+
+ @card.Card(card.Props{Class: "rounded-sm"}) { + @card.Content(card.ContentProps{Class: "p-4 space-y-4"}) { + @form.Item() { + @form.Label(form.LabelProps{For: "symbol"}) { + Symbol + } + @input.Input(input.Props{ + ID: "symbol", + Name: "symbol", + Type: input.TypeText, + Placeholder: "e.g. VFV.TO", + Class: "rounded-sm uppercase", + Required: true, + Attributes: templ.Attributes{ + "autocomplete": "off", + "autofocus": "", + }, + }) + @form.Description() { + Use the broker's symbol exactly (e.g. VFV.TO for TSX, AAPL for NASDAQ). + } + } + @form.Item() { + @form.Label(form.LabelProps{For: "display_name"}) { + Display name + } + @input.Input(input.Props{ + ID: "display_name", + Name: "display_name", + Type: input.TypeText, + Placeholder: "e.g. Vanguard S&P 500 Index ETF", + Class: "rounded-sm", + }) + @form.Description() { + Optional. Falls back to the symbol if left empty. + } + } + } + @card.Footer(card.FooterProps{Class: "flex justify-end gap-2"}) { + @button.Button(button.Props{ + Variant: button.VariantGhost, + Href: routeurl.URL("page.app.spaces.space.accounts.account.overview", "spaceID", props.SpaceID, "accountID", props.AccountID), + }) { + Cancel + } + @button.Button(button.Props{Type: button.TypeSubmit}) { + Add holding + } + } + } +
+
+ } +} diff --git a/internal/ui/pages/investments_overview.templ b/internal/ui/pages/investments_overview.templ new file mode 100644 index 0000000..59c567e --- /dev/null +++ b/internal/ui/pages/investments_overview.templ @@ -0,0 +1,131 @@ +package pages + +import ( + "fmt" + + "git.juancwu.dev/juancwu/budgit/internal/model" + "git.juancwu.dev/juancwu/budgit/internal/routeurl" + "git.juancwu.dev/juancwu/budgit/internal/ui/components/badge" + "git.juancwu.dev/juancwu/budgit/internal/ui/components/button" + "git.juancwu.dev/juancwu/budgit/internal/ui/components/card" + "git.juancwu.dev/juancwu/budgit/internal/ui/components/icon" + "git.juancwu.dev/juancwu/budgit/internal/ui/layouts" + "git.juancwu.dev/juancwu/budgit/internal/ui/utils" +) + +type InvestmentOverviewRow struct { + SpaceID string + SpaceName string + AccountID string + AccountName string + Currency string + Summary *model.InvestmentAccountSummary +} + +type InvestmentsOverviewProps struct { + Year int + Rows []InvestmentOverviewRow +} + +templ InvestmentsOverviewPage(props InvestmentsOverviewProps) { + @layouts.App("Investments", spaceOverviewSidebarContent()) { +
+
+
+

Investments

+

+ { fmt.Sprintf("%d contribution rooms, YTD activity, and total cost basis across your investment accounts.", props.Year) } +

+
+
+ if len(props.Rows) == 0 { + @card.Card(card.Props{Class: "rounded-sm"}) { + @card.Content(card.ContentProps{Class: "p-6 space-y-3"}) { +

+ You don't have any investment accounts yet. In any space, create a new account and check + Investment account to start tracking contributions and holdings. +

+ } + } + } else { +
+ for _, row := range props.Rows { + {{ + subtypeLabel := "" + if row.Summary != nil && row.Summary.Account != nil && row.Summary.Account.InvestmentSubtype != nil { + subtypeLabel = upperString(*row.Summary.Account.InvestmentSubtype) + } + }} + @card.Card(card.Props{Class: "rounded-sm"}) { + @card.Header() { +
+
+ @card.Title() { +
+ { row.AccountName } + if subtypeLabel != "" { + @badge.Badge(badge.Props{Variant: badge.VariantSecondary, Class: "text-xs"}) { + { subtypeLabel } + } + } + @badge.Badge(badge.Props{Variant: badge.VariantOutline, Class: "text-xs"}) { + { row.Currency } + } +
+ } + @card.Description() { + { row.SpaceName } + } +
+ @button.Button(button.Props{ + Variant: button.VariantGhost, + Class: "h-8 px-2", + Href: routeurl.URL("page.app.spaces.space.accounts.account.overview", "spaceID", row.SpaceID, "accountID", row.AccountID), + }) { + @icon.ChevronRight() + } +
+ } + @card.Content(card.ContentProps{Class: "grid grid-cols-2 gap-3 text-sm"}) { +
+
YTD Contributions
+
${ utils.FormatDecimalWithThousands(row.Summary.YTDContributions.StringFixedBank(2)) }
+
+
+
YTD Withdrawals
+
${ utils.FormatDecimalWithThousands(row.Summary.YTDWithdrawals.StringFixedBank(2)) }
+
+
+
Room remaining
+
+ if row.Summary.RoomRemaining != nil { + ${ utils.FormatDecimalWithThousands(row.Summary.RoomRemaining.StringFixedBank(2)) } + } else { + Room not set + } +
+
+
+
Total cost basis
+
${ utils.FormatDecimalWithThousands(row.Summary.TotalCostBasis.StringFixedBank(2)) }
+
+ } + } + } +
+ } +
+ } +} + +func upperString(s string) string { + out := make([]byte, len(s)) + for i := 0; i < len(s); i++ { + c := s[i] + if c >= 'a' && c <= 'z' { + c -= 32 + } + out[i] = c + } + return string(out) +} diff --git a/internal/ui/pages/space_account.templ b/internal/ui/pages/space_account.templ index f80c152..55f262a 100644 --- a/internal/ui/pages/space_account.templ +++ b/internal/ui/pages/space_account.templ @@ -22,6 +22,8 @@ type SpaceAccountPageProps struct { RecentTransactions []*model.Transaction NonEditableTransactionIDs map[string]bool AllocationSummary *service.AllocationSummary + InvestmentSummary *model.InvestmentAccountSummary + InvestmentPositions []model.HoldingPosition } templ SpaceAccountPage(props SpaceAccountPageProps) { @@ -91,11 +93,21 @@ templ SpaceAccountPage(props SpaceAccountPageProps) { } } - @blocks.AllocationsSection(blocks.AllocationsSectionProps{ - SpaceID: props.SpaceID, - AccountID: props.AccountID, - Summary: props.AllocationSummary, - }) + if props.InvestmentSummary != nil { + @blocks.InvestmentSection(blocks.InvestmentSectionProps{ + SpaceID: props.SpaceID, + AccountID: props.AccountID, + Currency: props.AccountCurrency, + Summary: props.InvestmentSummary, + Positions: props.InvestmentPositions, + }) + } else { + @blocks.AllocationsSection(blocks.AllocationsSectionProps{ + SpaceID: props.SpaceID, + AccountID: props.AccountID, + Summary: props.AllocationSummary, + }) + }
@card.Card() { @card.Header() { diff --git a/internal/ui/pages/space_account_settings.templ b/internal/ui/pages/space_account_settings.templ index eeed24a..50c0dd6 100644 --- a/internal/ui/pages/space_account_settings.templ +++ b/internal/ui/pages/space_account_settings.templ @@ -6,16 +6,19 @@ import "git.juancwu.dev/juancwu/budgit/internal/ui/layouts" 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/dialog" +import "git.juancwu.dev/juancwu/budgit/internal/ui/components/form" import "git.juancwu.dev/juancwu/budgit/internal/ui/components/icon" type SpaceAccountSettingsPageProps struct { - SpaceID string - SpaceName string - AccountID string - AccountName string - AccountCurrency string - UpdateForm forms.UpdateAccountProps - CurrencyForm forms.ChangeAccountCurrencyProps + SpaceID string + SpaceName string + AccountID string + AccountName string + AccountCurrency string + IsInvestment bool + InvestmentSubtype string + UpdateForm forms.UpdateAccountProps + CurrencyForm forms.ChangeAccountCurrencyProps } templ SpaceAccountSettingsPage(props SpaceAccountSettingsPageProps) { @@ -35,6 +38,68 @@ templ SpaceAccountSettingsPage(props SpaceAccountSettingsPageProps) {
@forms.UpdateAccount(props.UpdateForm) @forms.ChangeAccountCurrency(props.CurrencyForm) + @card.Card(card.Props{Class: "rounded-sm"}) { + @card.Header() { + @card.Title() { + Investment account + } + @card.Description() { + Flag this account to track contribution room, holdings, and trades. + } + } + @card.Content() { + {{ + selectedSubtype := props.InvestmentSubtype + if selectedSubtype == "" { + selectedSubtype = "tfsa" + } + }} +
+ @form.Item() { + + } +
+ @form.Item() { + @form.Label(form.LabelProps{For: "settings-subtype"}) { + Account type + } + + } +
+
+ @button.Button(button.Props{Type: button.TypeSubmit}) { + Save investment settings + } +
+
+ } + } @card.Card(card.Props{Class: "rounded-sm border-destructive"}) { @card.Header() { @card.Title(card.TitleProps{Class: "text-destructive"}) { diff --git a/internal/ui/pages/spaces.templ b/internal/ui/pages/spaces.templ index 58b1c97..d797ed3 100644 --- a/internal/ui/pages/spaces.templ +++ b/internal/ui/pages/spaces.templ @@ -99,6 +99,16 @@ templ spaceOverviewSidebarContent() { Shared with me } } + @sidebar.MenuItem() { + @sidebar.MenuButton(sidebar.MenuButtonProps{ + Href: routeurl.URL("page.app.investments"), + IsActive: ctxkeys.URLPath(ctx) == routeurl.URL("page.app.investments"), + Tooltip: "Investments", + }) { + @icon.TrendingUp() + Investments + } + } } } }