feat: investment accounts
All checks were successful
Deploy / build-and-deploy (push) Successful in 1m50s
All checks were successful
Deploy / build-and-deploy (push) Successful in 1m50s
This commit is contained in:
parent
f444a074bc
commit
7c24a8302d
25 changed files with 2205 additions and 56 deletions
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
50
internal/db/migrations/00019_add_investment_tracking.sql
Normal file
50
internal/db/migrations/00019_add_investment_tracking.sql
Normal 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
|
||||
348
internal/handler/investment.go
Normal file
348
internal/handler/investment.go
Normal 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 = ¬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,
|
||||
}))
|
||||
}
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
83
internal/model/investment.go
Normal file
83
internal/model/investment.go
Normal 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
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
74
internal/repository/investment_contribution_room.go
Normal file
74
internal/repository/investment_contribution_room.go
Normal 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
|
||||
}
|
||||
|
||||
89
internal/repository/investment_holding.go
Normal file
89
internal/repository/investment_holding.go
Normal 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
|
||||
}
|
||||
91
internal/repository/investment_trade.go
Normal file
91
internal/repository/investment_trade.go
Normal 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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
355
internal/service/investment.go
Normal file
355
internal/service/investment.go
Normal 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
|
||||
}
|
||||
220
internal/ui/blocks/investment_section.templ
Normal file
220
internal/ui/blocks/investment_section.templ
Normal 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
|
||||
}
|
||||
|
|
@ -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) {
|
||||
<form hx-post={ routeurl.URL("action.app.spaces.space.accounts.create", "spaceID", props.SpaceID) }>
|
||||
@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"}) {
|
||||
@button.Button(button.Props{
|
||||
|
|
|
|||
213
internal/ui/pages/investment_holding_detail.templ
Normal file
213
internal/ui/pages/investment_holding_detail.templ
Normal 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
|
||||
88
internal/ui/pages/investment_holding_form.templ
Normal file
88
internal/ui/pages/investment_holding_form.templ
Normal 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>
|
||||
}
|
||||
}
|
||||
131
internal/ui/pages/investments_overview.templ
Normal file
131
internal/ui/pages/investments_overview.templ
Normal 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)
|
||||
}
|
||||
|
|
@ -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) {
|
|||
}
|
||||
}
|
||||
</div>
|
||||
@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,
|
||||
})
|
||||
}
|
||||
<div>
|
||||
@card.Card() {
|
||||
@card.Header() {
|
||||
|
|
|
|||
|
|
@ -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) {
|
|||
</div>
|
||||
@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
|
||||
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.Header() {
|
||||
@card.Title(card.TitleProps{Class: "text-destructive"}) {
|
||||
|
|
|
|||
|
|
@ -99,6 +99,16 @@ templ spaceOverviewSidebarContent() {
|
|||
<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>
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue