feat: investment accounts
All checks were successful
Deploy / build-and-deploy (push) Successful in 1m50s

This commit is contained in:
juancwu 2026-05-22 14:49:57 +00:00
commit 7c24a8302d
25 changed files with 2205 additions and 56 deletions

View file

@ -27,6 +27,7 @@ type App struct {
AuditLogService *service.SpaceAuditLogService AuditLogService *service.SpaceAuditLogService
TxAuditLogService *service.TransactionAuditLogService TxAuditLogService *service.TransactionAuditLogService
AccountActivitySvc *service.AccountActivityService AccountActivitySvc *service.AccountActivityService
InvestmentService *service.InvestmentService
AccountDeletionWorker *worker.AccountDeletionWorker AccountDeletionWorker *worker.AccountDeletionWorker
} }
@ -56,6 +57,9 @@ func New(cfg *config.Config) (*App, error) {
txAuditLogRepository := repository.NewTransactionAuditLogRepository(database) txAuditLogRepository := repository.NewTransactionAuditLogRepository(database)
recurringEventRepository := repository.NewRecurringEventRepository(database) recurringEventRepository := repository.NewRecurringEventRepository(database)
accountDeletionRequestRepo := repository.NewAccountDeletionRequestRepository(database) accountDeletionRequestRepo := repository.NewAccountDeletionRequestRepository(database)
contributionRoomRepo := repository.NewInvestmentContributionRoomRepository(database)
holdingRepo := repository.NewInvestmentHoldingRepository(database)
tradeRepo := repository.NewInvestmentTradeRepository(database)
// Services // Services
emailService := service.NewEmailService( emailService := service.NewEmailService(
@ -94,6 +98,7 @@ func New(cfg *config.Config) (*App, error) {
) )
inviteService := service.NewInviteService(invitationRepository, spaceRepository, userRepository, emailService, auditLogService) inviteService := service.NewInviteService(invitationRepository, spaceRepository, userRepository, emailService, auditLogService)
recurringEventService := service.NewRecurringEventService(recurringEventRepository, transactionService, accountService) recurringEventService := service.NewRecurringEventService(recurringEventRepository, transactionService, accountService)
investmentService := service.NewInvestmentService(accountRepository, contributionRoomRepo, holdingRepo, tradeRepo, transactionRepository)
return &App{ return &App{
Cfg: cfg, Cfg: cfg,
@ -110,6 +115,7 @@ func New(cfg *config.Config) (*App, error) {
AuditLogService: auditLogService, AuditLogService: auditLogService,
TxAuditLogService: txAuditLogService, TxAuditLogService: txAuditLogService,
AccountActivitySvc: accountActivityService, AccountActivitySvc: accountActivityService,
InvestmentService: investmentService,
AccountDeletionWorker: accountDeletionWorker, AccountDeletionWorker: accountDeletionWorker,
}, nil }, nil
} }

View file

@ -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

View file

@ -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 = &notes
}
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,
}))
}

View file

@ -30,6 +30,7 @@ type spaceHandler struct {
auditLogService *service.SpaceAuditLogService auditLogService *service.SpaceAuditLogService
txAuditLogService *service.TransactionAuditLogService txAuditLogService *service.TransactionAuditLogService
accountActivitySvc *service.AccountActivityService accountActivitySvc *service.AccountActivityService
investmentService *service.InvestmentService
} }
func NewSpaceHandler( func NewSpaceHandler(
@ -41,6 +42,7 @@ func NewSpaceHandler(
auditLogService *service.SpaceAuditLogService, auditLogService *service.SpaceAuditLogService,
txAuditLogService *service.TransactionAuditLogService, txAuditLogService *service.TransactionAuditLogService,
accountActivitySvc *service.AccountActivityService, accountActivitySvc *service.AccountActivityService,
investmentService *service.InvestmentService,
) *spaceHandler { ) *spaceHandler {
return &spaceHandler{ return &spaceHandler{
spaceService: spaceService, spaceService: spaceService,
@ -51,6 +53,7 @@ func NewSpaceHandler(
auditLogService: auditLogService, auditLogService: auditLogService,
txAuditLogService: txAuditLogService, txAuditLogService: txAuditLogService,
accountActivitySvc: accountActivitySvc, accountActivitySvc: accountActivitySvc,
investmentService: investmentService,
} }
} }
@ -236,11 +239,15 @@ func (h *spaceHandler) HandleCreateAccount(w http.ResponseWriter, r *http.Reques
if currencyInput == "" { if currencyInput == "" {
currencyInput = currency.Default currencyInput = currency.Default
} }
isInvestment := r.FormValue("is_investment") == "1"
subtypeInput := strings.ToLower(strings.TrimSpace(r.FormValue("investment_subtype")))
formProps := forms.CreateAccountProps{ formProps := forms.CreateAccountProps{
SpaceID: spaceID, SpaceID: spaceID,
Name: nameInput, Name: nameInput,
Currency: currencyInput, Currency: currencyInput,
IsInvestment: isInvestment,
InvestmentSubtype: subtypeInput,
} }
hasErr := false hasErr := false
@ -252,6 +259,10 @@ func (h *spaceHandler) HandleCreateAccount(w http.ResponseWriter, r *http.Reques
formProps.CurrencyErr = "Choose a supported currency." formProps.CurrencyErr = "Choose a supported currency."
hasErr = true hasErr = true
} }
if isInvestment && !model.IsValidInvestmentSubtype(subtypeInput) {
formProps.SubtypeErr = "Choose an account type."
hasErr = true
}
if hasErr { if hasErr {
ui.Render(w, r, forms.CreateAccount(formProps)) ui.Render(w, r, forms.CreateAccount(formProps))
return return
@ -277,7 +288,14 @@ func (h *spaceHandler) HandleCreateAccount(w http.ResponseWriter, r *http.Reques
if user != nil { if user != nil {
actorID = user.ID 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 { if err != nil {
slog.Error("failed to create account", "error", err, "space_id", spaceID) slog.Error("failed to create account", "error", err, "space_id", spaceID)
formProps.GeneralErr = "Something went wrong. Please try again." formProps.GeneralErr = "Something went wrong. Please try again."
@ -329,7 +347,7 @@ func (h *spaceHandler) SpaceAccountPage(w http.ResponseWriter, r *http.Request)
allocSummary = nil allocSummary = nil
} }
ui.Render(w, r, pages.SpaceAccountPage(pages.SpaceAccountPageProps{ props := pages.SpaceAccountPageProps{
SpaceID: spaceID, SpaceID: spaceID,
SpaceName: space.Name, SpaceName: space.Name,
AccountID: accountID, AccountID: accountID,
@ -339,7 +357,24 @@ func (h *spaceHandler) SpaceAccountPage(w http.ResponseWriter, r *http.Request)
RecentTransactions: recent, RecentTransactions: recent,
NonEditableTransactionIDs: h.nonEditableTransactionIDs(recent), NonEditableTransactionIDs: h.nonEditableTransactionIDs(recent),
AllocationSummary: allocSummary, 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) { 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 return
} }
subtype := ""
if account.InvestmentSubtype != nil {
subtype = *account.InvestmentSubtype
}
ui.Render(w, r, pages.SpaceAccountSettingsPage(pages.SpaceAccountSettingsPageProps{ ui.Render(w, r, pages.SpaceAccountSettingsPage(pages.SpaceAccountSettingsPageProps{
SpaceID: spaceID, SpaceID: spaceID,
SpaceName: space.Name, SpaceName: space.Name,
AccountID: accountID, AccountID: accountID,
AccountName: account.Name, AccountName: account.Name,
AccountCurrency: account.Currency, AccountCurrency: account.Currency,
IsInvestment: account.IsInvestment,
InvestmentSubtype: subtype,
UpdateForm: forms.UpdateAccountProps{ UpdateForm: forms.UpdateAccountProps{
SpaceID: spaceID, SpaceID: spaceID,
AccountID: accountID, 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) { func (h *spaceHandler) HandleRenameAccount(w http.ResponseWriter, r *http.Request) {
spaceID := r.PathValue("spaceID") spaceID := r.PathValue("spaceID")
accountID := r.PathValue("accountID") accountID := r.PathValue("accountID")

View file

@ -7,13 +7,34 @@ import (
) )
type Account struct { type Account struct {
ID string `db:"id"` ID string `db:"id"`
Name string `db:"name"` Name string `db:"name"`
SpaceID string `db:"space_id"` SpaceID string `db:"space_id"`
Balance decimal.Decimal `db:"balance"` Balance decimal.Decimal `db:"balance"`
Currency string `db:"currency"` Currency string `db:"currency"`
CreatedAt time.Time `db:"created_at"` IsInvestment bool `db:"is_investment"`
UpdatedAt time.Time `db:"updated_at"` 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 type TransactionType string

View file

@ -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
}

View file

@ -15,6 +15,7 @@ const (
SpaceAuditActionAccountRenamed SpaceAuditAction = "account.renamed" SpaceAuditActionAccountRenamed SpaceAuditAction = "account.renamed"
SpaceAuditActionAccountDeleted SpaceAuditAction = "account.deleted" SpaceAuditActionAccountDeleted SpaceAuditAction = "account.deleted"
SpaceAuditActionAccountCurrencyChanged SpaceAuditAction = "account.currency_changed" SpaceAuditActionAccountCurrencyChanged SpaceAuditAction = "account.currency_changed"
SpaceAuditActionAccountInvestmentFlag SpaceAuditAction = "account.investment_flag_changed"
SpaceAuditActionAllocationCreated SpaceAuditAction = "allocation.created" SpaceAuditActionAllocationCreated SpaceAuditAction = "allocation.created"
SpaceAuditActionAllocationUpdated SpaceAuditAction = "allocation.updated" SpaceAuditActionAllocationUpdated SpaceAuditAction = "allocation.updated"
SpaceAuditActionAllocationDeleted SpaceAuditAction = "allocation.deleted" SpaceAuditActionAllocationDeleted SpaceAuditAction = "allocation.deleted"

View file

@ -29,6 +29,12 @@ type AccountRepository interface {
// ChangeCurrency atomically updates an account's currency and balance and // ChangeCurrency atomically updates an account's currency and balance and
// rewrites each provided allocation's amount/target in the new currency. // rewrites each provided allocation's amount/target in the new currency.
ChangeCurrency(accountID, newCurrency string, newBalance decimal.Decimal, allocationConversions []AllocationConversion) error 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 { type accountRepository struct {
@ -40,9 +46,10 @@ func NewAccountRepository(db *sqlx.DB) AccountRepository {
} }
func (r *accountRepository) Create(account *model.Account) error { func (r *accountRepository) Create(account *model.Account) error {
query := `INSERT INTO accounts (id, name, space_id, currency, created_at, updated_at) query := `INSERT INTO accounts (id, name, space_id, currency, is_investment, investment_subtype, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6);` VALUES ($1, $2, $3, $4, $5, $6, $7, $8);`
_, err := r.db.Exec(query, account.ID, account.Name, account.SpaceID, account.Currency, account.CreatedAt, account.UpdatedAt) _, err := r.db.Exec(query, account.ID, account.Name, account.SpaceID, account.Currency,
account.IsInvestment, account.InvestmentSubtype, account.CreatedAt, account.UpdatedAt)
return err return err
} }
@ -78,6 +85,36 @@ func (r *accountRepository) Delete(id string) error {
return err 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 { func (r *accountRepository) ChangeCurrency(accountID, newCurrency string, newBalance decimal.Decimal, allocationConversions []AllocationConversion) error {
return WithTx(r.db, func(tx *sqlx.Tx) error { return WithTx(r.db, func(tx *sqlx.Tx) error {
now := time.Now() now := time.Now()

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}

View file

@ -22,6 +22,12 @@ type TransactionRepository interface {
TransferIDsIn(ids []string) (map[string]bool, error) TransferIDsIn(ids []string) (map[string]bool, error)
ListByAccount(accountID string, limit, offset int) ([]*model.Transaction, error) ListByAccount(accountID string, limit, offset int) ([]*model.Transaction, error)
CountByAccount(accountID string) (int, 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 { type transactionRepository struct {
@ -304,3 +310,25 @@ func (r *transactionRepository) CountByAccount(accountID string) (int, error) {
} }
return count, nil 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
}

View file

@ -19,9 +19,10 @@ 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.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) allocationH := handler.NewAllocationHandler(a.AllocationService, a.AccountService)
recurringH := handler.NewRecurringEventHandler(a.RecurringEventService, a.AccountService, a.SpaceService) recurringH := handler.NewRecurringEventHandler(a.RecurringEventService, a.AccountService, a.SpaceService)
investmentH := handler.NewInvestmentHandler(a.AccountService, a.SpaceService, a.InvestmentService)
redirectH := handler.NewRedirectHandler() redirectH := handler.NewRedirectHandler()
r := router.New() 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("/home", spaceH.HomePage).Name("page.app.home")
g.Get("/investments", investmentH.InvestmentsOverviewPage).Name("page.app.investments")
g.SubGroup("/spaces", func(g *router.Group) { g.SubGroup("/spaces", func(g *router.Group) {
g.Get("", spaceH.SpacesPage).Name("page.app.spaces") g.Get("", spaceH.SpacesPage).Name("page.app.spaces")
g.Get("/create", spaceH.CreateSpacePage).Name("page.app.spaces.create") 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/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/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/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.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.Post("/bills/create", spaceH.HandleCreateBill).Name("action.app.spaces.space.accounts.account.bills.create")
g.Get("/deposits/create", spaceH.SpaceCreateDepositPage).Name("page.app.spaces.space.accounts.account.deposits.create") g.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/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}/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("/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")
}) })
}) })
}) })

View file

@ -34,47 +34,126 @@ func (s *AccountService) SetAuditLogger(audit *SpaceAuditLogService) {
s.auditSvc = audit s.auditSvc = audit
} }
func (s *AccountService) CreateAccount(spaceID, name, currencyCode, actorID string) (*model.Account, error) { // CreateAccountInput captures all the fields the caller can set when creating
if spaceID == "" { // 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") return nil, fmt.Errorf("space id is required")
} }
if name == "" { if input.Name == "" {
return nil, fmt.Errorf("account name cannot be empty") return nil, fmt.Errorf("account name cannot be empty")
} }
code := currency.Normalize(currencyCode) code := currency.Normalize(input.CurrencyCode)
if code == "" { if code == "" {
code = currency.Default code = currency.Default
} }
if !currency.IsValid(code) { 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() now := time.Now()
account := &model.Account{ account := &model.Account{
ID: uuid.NewString(), ID: uuid.NewString(),
Name: name, Name: input.Name,
SpaceID: spaceID, SpaceID: input.SpaceID,
Currency: code, Currency: code,
CreatedAt: now, IsInvestment: input.IsInvestment,
UpdatedAt: now, InvestmentSubtype: subtypePtr,
CreatedAt: now,
UpdatedAt: now,
} }
if err := s.accountRepo.Create(account); err != nil { if err := s.accountRepo.Create(account); err != nil {
return nil, fmt.Errorf("failed to create account: %w", err) 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{ s.auditSvc.Record(RecordOptions{
SpaceID: spaceID, SpaceID: input.SpaceID,
ActorID: actorID, ActorID: input.ActorID,
Action: model.SpaceAuditActionAccountCreated, Action: model.SpaceAuditActionAccountCreated,
Metadata: map[string]any{ Metadata: meta,
"account_id": account.ID,
"account_name": account.Name,
"currency": account.Currency,
},
}) })
return account, nil 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) { func (s *AccountService) GetAccount(id string) (*model.Account, error) {
account, err := s.accountRepo.ByID(id) account, err := s.accountRepo.ByID(id)
if err != nil { if err != nil {

View file

@ -22,7 +22,7 @@ func TestAccountService_CreateAccount_RecordsAudit(t *testing.T) {
user := testutil.CreateTestUser(t, dbi.DB, "acct-create-audit@example.com", nil) user := testutil.CreateTestUser(t, dbi.DB, "acct-create-audit@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "S") 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) require.NoError(t, err)
logs, err := auditRepo.ListAccountEvents(account.ID, 10, 0) 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) user := testutil.CreateTestUser(t, dbi.DB, "no-audit@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "S") 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, err)
require.NoError(t, svc.RenameAccount(account.ID, "y", user.ID)) require.NoError(t, svc.RenameAccount(account.ID, "y", user.ID))
require.NoError(t, svc.DeleteAccount(account.ID, user.ID)) require.NoError(t, svc.DeleteAccount(account.ID, user.ID))

View file

@ -371,7 +371,11 @@ func (s *AuthService) CompleteOnboarding(userID, name string) error {
return fmt.Errorf("failed to create onboarding space: %w", err) 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 { if delErr := s.spaceService.DeleteSpace(space.ID, userID); delErr != nil {
slog.Error("failed to roll back space after account creation error", slog.Error("failed to roll back space after account creation error",
"space_id", space.ID, "error", delErr) "space_id", space.ID, "error", delErr)

View file

@ -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
}

View file

@ -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) {
<div id="investment-section" class="space-y-4">
@card.Card(card.Props{Class: "rounded-sm"}) {
@card.Header() {
<div class="flex items-start justify-between gap-3">
<div>
@card.Title() {
<div class="flex items-center gap-2">
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 }
}
}
</div>
}
@card.Description() {
Track yearly contributions against the room you set.
}
</div>
</div>
}
@card.Content(card.ContentProps{Class: "space-y-4"}) {
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
<div>
<div class="text-muted-foreground">Room</div>
<div class="text-lg font-semibold">
if props.Summary.RoomAmount != nil {
${ fmtMoney(*props.Summary.RoomAmount) }
} else {
<span class="text-muted-foreground italic">Not set</span>
}
</div>
</div>
<div>
<div class="text-muted-foreground">YTD Contributions</div>
<div class="text-lg font-semibold">${ fmtMoney(props.Summary.YTDContributions) }</div>
</div>
<div>
<div class="text-muted-foreground">YTD Withdrawals</div>
<div class="text-lg font-semibold">${ fmtMoney(props.Summary.YTDWithdrawals) }</div>
</div>
<div>
<div class="text-muted-foreground">Remaining</div>
<div class={ "text-lg font-semibold", plClass(remainingValue(props.Summary)) }>
if props.Summary.RoomRemaining != nil {
${ fmtMoney(*props.Summary.RoomRemaining) }
} else {
<span class="text-muted-foreground">—</span>
}
</div>
</div>
</div>
<form
hx-post={ routeurl.URL("action.app.spaces.space.accounts.account.investments.contribution-room", "spaceID", props.SpaceID, "accountID", props.AccountID) }
hx-target="#investment-section"
hx-swap="outerHTML"
class="flex flex-wrap items-end gap-2"
>
<div class="flex flex-col gap-1">
<label class="text-xs text-muted-foreground" for="room-year">Year</label>
@input.Input(input.Props{
ID: "room-year",
Name: "year",
Type: input.TypeNumber,
Value: fmt.Sprintf("%d", props.Summary.Year),
Class: "w-28 rounded-sm",
})
</div>
<div class="flex flex-col gap-1">
<label class="text-xs text-muted-foreground" for="room-amount">Room amount ({ props.Currency })</label>
{{
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,
})
</div>
@button.Button(button.Props{Type: button.TypeSubmit, Class: "rounded-sm"}) {
Save room
}
</form>
}
}
@card.Card(card.Props{Class: "rounded-sm"}) {
@card.Header() {
<div class="flex items-center justify-between">
<div>
@card.Title() {
Holdings
}
@card.Description() {
Track shares and buy/sell prices. Cost basis is computed from your trades.
}
</div>
@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
}
</div>
}
@card.Content() {
if len(props.Positions) == 0 {
<p class="text-sm text-muted-foreground">No holdings yet. Add one to start tracking shares and trades.</p>
} else {
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="text-left text-muted-foreground border-b">
<tr>
<th class="py-2 pr-2">Symbol</th>
<th class="py-2 pr-2">Quantity</th>
<th class="py-2 pr-2">Avg cost</th>
<th class="py-2 pr-2">Cost basis</th>
<th class="py-2 pr-2">Realized P/L</th>
<th class="py-2"></th>
</tr>
</thead>
<tbody>
for _, pos := range props.Positions {
<tr class="border-b last:border-b-0">
<td class="py-2 pr-2">
<a
class="font-medium hover:underline"
href={ templ.SafeURL(routeurl.URL("page.app.spaces.space.accounts.account.investments.holdings.holding", "spaceID", props.SpaceID, "accountID", props.AccountID, "holdingID", pos.Holding.ID)) }
>
{ pos.Holding.Symbol }
</a>
<div class="text-xs text-muted-foreground">{ pos.Holding.DisplayName }</div>
</td>
<td class="py-2 pr-2">{ pos.Quantity.StringFixedBank(4) }</td>
<td class="py-2 pr-2">${ fmtMoney(pos.AvgCost) }</td>
<td class="py-2 pr-2">${ fmtMoney(pos.CostBasis) }</td>
<td class={ "py-2 pr-2", plClass(pos.RealizedPL) }>${ fmtMoney(pos.RealizedPL) }</td>
<td class="py-2 text-right">
@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()
}
</td>
</tr>
}
</tbody>
</table>
</div>
}
}
}
</div>
}
func remainingValue(s *model.InvestmentAccountSummary) decimal.Decimal {
if s.RoomRemaining == nil {
return decimal.Zero
}
return *s.RoomRemaining
}

View file

@ -10,14 +10,28 @@ import "git.juancwu.dev/juancwu/budgit/internal/ui/components/input"
type CreateAccountProps struct { type CreateAccountProps struct {
SpaceID string SpaceID string
Name string Name string
Currency string Currency string
IsInvestment bool
InvestmentSubtype string
NameErr string NameErr string
CurrencyErr string CurrencyErr string
SubtypeErr string
GeneralErr 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) { templ CreateAccount(props CreateAccountProps) {
<form hx-post={ routeurl.URL("action.app.spaces.space.accounts.create", "spaceID", props.SpaceID) }> <form hx-post={ routeurl.URL("action.app.spaces.space.accounts.create", "spaceID", props.SpaceID) }>
@card.Card(card.Props{Class: "rounded-sm"}) { @card.Card(card.Props{Class: "rounded-sm"}) {
@ -82,6 +96,54 @@ templ CreateAccount(props CreateAccountProps) {
} }
} }
} }
{{
selectedSubtype := props.InvestmentSubtype
if selectedSubtype == "" {
selectedSubtype = "tfsa"
}
}}
@form.Item() {
<label class="flex items-center gap-2 text-sm font-medium">
<input
type="checkbox"
name="is_investment"
value="1"
class="h-4 w-4 rounded border-input"
checked?={ props.IsInvestment }
_="on change toggle .hidden on #investment-subtype-wrapper"
/>
Investment account
</label>
@form.Description() {
Tracks contributions, contribution room, and holdings.
}
}
<div
id="investment-subtype-wrapper"
class={ templ.KV("hidden", !props.IsInvestment) }
>
@form.Item() {
@form.Label(form.LabelProps{For: "investment_subtype"}) {
Account type
}
<select
id="investment_subtype"
name="investment_subtype"
class={ "flex h-9 w-full items-center rounded-sm border bg-transparent px-3 py-1 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
templ.KV("border-destructive", props.SubtypeErr != ""),
templ.KV("border-input", props.SubtypeErr == "") }
>
for _, opt := range investmentSubtypes {
<option value={ opt.Value } selected?={ selectedSubtype == opt.Value }>{ opt.Label }</option>
}
</select>
if props.SubtypeErr != "" {
@form.Message(form.MessageProps{Variant: form.MessageVariantError}) {
{ props.SubtypeErr }
}
}
}
</div>
} }
@card.Footer(card.FooterProps{Class: "flex justify-end gap-2"}) { @card.Footer(card.FooterProps{Class: "flex justify-end gap-2"}) {
@button.Button(button.Props{ @button.Button(button.Props{

View file

@ -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),
) {
<div class="container px-6 py-8 mx-auto space-y-8">
<div class="flex items-start justify-between gap-3 flex-wrap">
<div>
<h1 class="text-3xl font-bold flex items-center gap-3">
{ props.Position.Holding.Symbol }
@badge.Badge(badge.Props{Variant: badge.VariantSecondary}) {
{ props.Currency }
}
</h1>
<p class="text-muted-foreground">{ props.Position.Holding.DisplayName }</p>
</div>
<form
hx-post={ routeurl.URL("action.app.spaces.space.accounts.account.investments.holdings.holding.delete", "spaceID", props.SpaceID, "accountID", props.AccountID, "holdingID", props.Position.Holding.ID) }
hx-confirm="Delete this holding and all its trades?"
>
@button.Button(button.Props{
Type: button.TypeSubmit,
Variant: button.VariantDestructive,
Class: "flex items-center gap-2",
}) {
@icon.Trash2()
Delete holding
}
</form>
</div>
@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"}) {
<div>
<div class="text-muted-foreground">Quantity</div>
<div class="text-lg font-semibold">{ props.Position.Quantity.StringFixedBank(4) }</div>
</div>
<div>
<div class="text-muted-foreground">Avg cost</div>
<div class="text-lg font-semibold">${ utils.FormatDecimalWithThousands(props.Position.AvgCost.StringFixedBank(2)) }</div>
</div>
<div>
<div class="text-muted-foreground">Cost basis</div>
<div class="text-lg font-semibold">${ utils.FormatDecimalWithThousands(props.Position.CostBasis.StringFixedBank(2)) }</div>
</div>
<div>
<div class="text-muted-foreground">Realized P/L</div>
<div class={ "text-lg font-semibold", holdingPlClass(props.Position.RealizedPL.StringFixedBank(2)) }>
${ utils.FormatDecimalWithThousands(props.Position.RealizedPL.StringFixedBank(2)) }
</div>
</div>
<div>
<div class="text-muted-foreground">Total fees</div>
<div class="text-lg font-semibold">${ utils.FormatDecimalWithThousands(props.Position.TotalFees.StringFixedBank(2)) }</div>
</div>
}
}
@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
hx-post={ routeurl.URL("action.app.spaces.space.accounts.account.investments.holdings.holding.trades.create", "spaceID", props.SpaceID, "accountID", props.AccountID, "holdingID", props.Position.Holding.ID) }
class="grid grid-cols-1 md:grid-cols-6 gap-3 items-end"
>
@form.Item() {
@form.Label(form.LabelProps{For: "type"}) { Type }
<select id="type" name="type" class="h-9 rounded-sm border bg-transparent px-3 text-sm">
<option value="buy">Buy</option>
<option value="sell">Sell</option>
</select>
}
@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
}
</form>
}
}
@card.Card(card.Props{Class: "rounded-sm"}) {
@card.Header() {
@card.Title() { Trade history }
}
@card.Content() {
if len(props.Trades) == 0 {
<p class="text-sm text-muted-foreground">No trades recorded yet.</p>
} else {
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="text-left text-muted-foreground border-b">
<tr>
<th class="py-2 pr-2">Date</th>
<th class="py-2 pr-2">Type</th>
<th class="py-2 pr-2">Quantity</th>
<th class="py-2 pr-2">Price / unit</th>
<th class="py-2 pr-2">Fees</th>
<th class="py-2 pr-2">Total</th>
<th class="py-2"></th>
</tr>
</thead>
<tbody>
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"
}
}}
<tr class="border-b last:border-b-0">
<td class="py-2 pr-2">{ t.OccurredAt.Format("2006-01-02") }</td>
<td class="py-2 pr-2">
@badge.Badge(badge.Props{
Variant: badge.VariantSecondary,
Class: "text-xs",
}) {
{ typeLabel }
}
</td>
<td class="py-2 pr-2">{ t.Quantity.StringFixedBank(4) }</td>
<td class="py-2 pr-2">${ utils.FormatDecimalWithThousands(t.PricePerUnit.StringFixedBank(2)) }</td>
<td class="py-2 pr-2">{ fees }</td>
<td class="py-2 pr-2">${ utils.FormatDecimalWithThousands(total.StringFixedBank(2)) }</td>
<td class="py-2 text-right">
<form
hx-post={ routeurl.URL("action.app.spaces.space.accounts.account.investments.holdings.holding.trades.trade.delete", "spaceID", props.SpaceID, "accountID", props.AccountID, "holdingID", props.Position.Holding.ID, "tradeID", t.ID) }
hx-confirm="Delete this trade?"
class="inline"
>
@button.Button(button.Props{
Type: button.TypeSubmit,
Variant: button.VariantGhost,
Class: "h-8 px-2",
}) {
@icon.Trash2()
}
</form>
</td>
</tr>
}
</tbody>
</table>
</div>
}
}
}
</div>
}
}
var _ = fmt.Sprintf

View file

@ -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),
) {
<div class="container max-w-3xl px-6 py-8 mx-auto space-y-8">
<div>
<h1 class="text-3xl font-bold">Add holding</h1>
<p class="text-muted-foreground mt-2">
Track a symbol inside { props.AccountName }. You can record buy/sell trades after creating it.
</p>
</div>
<form hx-post={ routeurl.URL("action.app.spaces.space.accounts.account.investments.holdings.create", "spaceID", props.SpaceID, "accountID", props.AccountID) }>
@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
}
}
}
</form>
</div>
}
}

View file

@ -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()) {
<div class="container px-6 py-8 mx-auto space-y-6">
<div class="flex items-start justify-between gap-3 flex-wrap">
<div>
<h1 class="text-3xl font-bold">Investments</h1>
<p class="text-muted-foreground mt-1">
{ fmt.Sprintf("%d contribution rooms, YTD activity, and total cost basis across your investment accounts.", props.Year) }
</p>
</div>
</div>
if len(props.Rows) == 0 {
@card.Card(card.Props{Class: "rounded-sm"}) {
@card.Content(card.ContentProps{Class: "p-6 space-y-3"}) {
<p class="text-sm text-muted-foreground">
You don't have any investment accounts yet. In any space, create a new account and check
<strong>Investment account</strong> to start tracking contributions and holdings.
</p>
}
}
} else {
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
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() {
<div class="flex items-start justify-between gap-2">
<div>
@card.Title() {
<div class="flex items-center gap-2">
{ 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 }
}
</div>
}
@card.Description() {
{ row.SpaceName }
}
</div>
@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()
}
</div>
}
@card.Content(card.ContentProps{Class: "grid grid-cols-2 gap-3 text-sm"}) {
<div>
<div class="text-muted-foreground">YTD Contributions</div>
<div class="font-semibold">${ utils.FormatDecimalWithThousands(row.Summary.YTDContributions.StringFixedBank(2)) }</div>
</div>
<div>
<div class="text-muted-foreground">YTD Withdrawals</div>
<div class="font-semibold">${ utils.FormatDecimalWithThousands(row.Summary.YTDWithdrawals.StringFixedBank(2)) }</div>
</div>
<div>
<div class="text-muted-foreground">Room remaining</div>
<div class="font-semibold">
if row.Summary.RoomRemaining != nil {
${ utils.FormatDecimalWithThousands(row.Summary.RoomRemaining.StringFixedBank(2)) }
} else {
<span class="text-muted-foreground italic">Room not set</span>
}
</div>
</div>
<div>
<div class="text-muted-foreground">Total cost basis</div>
<div class="font-semibold">${ utils.FormatDecimalWithThousands(row.Summary.TotalCostBasis.StringFixedBank(2)) }</div>
</div>
}
}
}
</div>
}
</div>
}
}
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)
}

View file

@ -22,6 +22,8 @@ type SpaceAccountPageProps struct {
RecentTransactions []*model.Transaction RecentTransactions []*model.Transaction
NonEditableTransactionIDs map[string]bool NonEditableTransactionIDs map[string]bool
AllocationSummary *service.AllocationSummary AllocationSummary *service.AllocationSummary
InvestmentSummary *model.InvestmentAccountSummary
InvestmentPositions []model.HoldingPosition
} }
templ SpaceAccountPage(props SpaceAccountPageProps) { templ SpaceAccountPage(props SpaceAccountPageProps) {
@ -91,11 +93,21 @@ templ SpaceAccountPage(props SpaceAccountPageProps) {
} }
} }
</div> </div>
@blocks.AllocationsSection(blocks.AllocationsSectionProps{ if props.InvestmentSummary != nil {
SpaceID: props.SpaceID, @blocks.InvestmentSection(blocks.InvestmentSectionProps{
AccountID: props.AccountID, SpaceID: props.SpaceID,
Summary: props.AllocationSummary, 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,
})
}
<div> <div>
@card.Card() { @card.Card() {
@card.Header() { @card.Header() {

View file

@ -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/button"
import "git.juancwu.dev/juancwu/budgit/internal/ui/components/card" 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/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/icon"
type SpaceAccountSettingsPageProps struct { type SpaceAccountSettingsPageProps struct {
SpaceID string SpaceID string
SpaceName string SpaceName string
AccountID string AccountID string
AccountName string AccountName string
AccountCurrency string AccountCurrency string
UpdateForm forms.UpdateAccountProps IsInvestment bool
CurrencyForm forms.ChangeAccountCurrencyProps InvestmentSubtype string
UpdateForm forms.UpdateAccountProps
CurrencyForm forms.ChangeAccountCurrencyProps
} }
templ SpaceAccountSettingsPage(props SpaceAccountSettingsPageProps) { templ SpaceAccountSettingsPage(props SpaceAccountSettingsPageProps) {
@ -35,6 +38,68 @@ templ SpaceAccountSettingsPage(props SpaceAccountSettingsPageProps) {
</div> </div>
@forms.UpdateAccount(props.UpdateForm) @forms.UpdateAccount(props.UpdateForm)
@forms.ChangeAccountCurrency(props.CurrencyForm) @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
hx-post={ routeurl.URL("action.app.spaces.space.accounts.account.settings.investment", "spaceID", props.SpaceID, "accountID", props.AccountID) }
class="space-y-4"
>
@form.Item() {
<label class="flex items-center gap-2 text-sm font-medium">
<input
type="checkbox"
name="is_investment"
value="1"
class="h-4 w-4 rounded border-input"
checked?={ props.IsInvestment }
_="on change toggle .hidden on #settings-subtype-wrapper"
/>
Track as an investment account
</label>
}
<div
id="settings-subtype-wrapper"
class={ templ.KV("hidden", !props.IsInvestment) }
>
@form.Item() {
@form.Label(form.LabelProps{For: "settings-subtype"}) {
Account type
}
<select
id="settings-subtype"
name="investment_subtype"
class="flex h-9 w-full items-center rounded-sm border bg-transparent px-3 py-1 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring border-input"
>
<option value="tfsa" selected?={ selectedSubtype == "tfsa" }>TFSA</option>
<option value="rrsp" selected?={ selectedSubtype == "rrsp" }>RRSP</option>
<option value="fhsa" selected?={ selectedSubtype == "fhsa" }>FHSA</option>
<option value="personal" selected?={ selectedSubtype == "personal" }>Personal / Non-registered</option>
<option value="other" selected?={ selectedSubtype == "other" }>Other</option>
</select>
}
</div>
<div class="flex justify-end">
@button.Button(button.Props{Type: button.TypeSubmit}) {
Save investment settings
}
</div>
</form>
}
}
@card.Card(card.Props{Class: "rounded-sm border-destructive"}) { @card.Card(card.Props{Class: "rounded-sm border-destructive"}) {
@card.Header() { @card.Header() {
@card.Title(card.TitleProps{Class: "text-destructive"}) { @card.Title(card.TitleProps{Class: "text-destructive"}) {

View file

@ -99,6 +99,16 @@ templ spaceOverviewSidebarContent() {
<span>Shared with me</span> <span>Shared with me</span>
} }
} }
@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()
<span>Investments</span>
}
}
} }
} }
} }