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
|
AuditLogService *service.SpaceAuditLogService
|
||||||
TxAuditLogService *service.TransactionAuditLogService
|
TxAuditLogService *service.TransactionAuditLogService
|
||||||
AccountActivitySvc *service.AccountActivityService
|
AccountActivitySvc *service.AccountActivityService
|
||||||
|
InvestmentService *service.InvestmentService
|
||||||
AccountDeletionWorker *worker.AccountDeletionWorker
|
AccountDeletionWorker *worker.AccountDeletionWorker
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -56,6 +57,9 @@ func New(cfg *config.Config) (*App, error) {
|
||||||
txAuditLogRepository := repository.NewTransactionAuditLogRepository(database)
|
txAuditLogRepository := repository.NewTransactionAuditLogRepository(database)
|
||||||
recurringEventRepository := repository.NewRecurringEventRepository(database)
|
recurringEventRepository := repository.NewRecurringEventRepository(database)
|
||||||
accountDeletionRequestRepo := repository.NewAccountDeletionRequestRepository(database)
|
accountDeletionRequestRepo := repository.NewAccountDeletionRequestRepository(database)
|
||||||
|
contributionRoomRepo := repository.NewInvestmentContributionRoomRepository(database)
|
||||||
|
holdingRepo := repository.NewInvestmentHoldingRepository(database)
|
||||||
|
tradeRepo := repository.NewInvestmentTradeRepository(database)
|
||||||
|
|
||||||
// Services
|
// Services
|
||||||
emailService := service.NewEmailService(
|
emailService := service.NewEmailService(
|
||||||
|
|
@ -94,6 +98,7 @@ func New(cfg *config.Config) (*App, error) {
|
||||||
)
|
)
|
||||||
inviteService := service.NewInviteService(invitationRepository, spaceRepository, userRepository, emailService, auditLogService)
|
inviteService := service.NewInviteService(invitationRepository, spaceRepository, userRepository, emailService, auditLogService)
|
||||||
recurringEventService := service.NewRecurringEventService(recurringEventRepository, transactionService, accountService)
|
recurringEventService := service.NewRecurringEventService(recurringEventRepository, transactionService, accountService)
|
||||||
|
investmentService := service.NewInvestmentService(accountRepository, contributionRoomRepo, holdingRepo, tradeRepo, transactionRepository)
|
||||||
|
|
||||||
return &App{
|
return &App{
|
||||||
Cfg: cfg,
|
Cfg: cfg,
|
||||||
|
|
@ -110,6 +115,7 @@ func New(cfg *config.Config) (*App, error) {
|
||||||
AuditLogService: auditLogService,
|
AuditLogService: auditLogService,
|
||||||
TxAuditLogService: txAuditLogService,
|
TxAuditLogService: txAuditLogService,
|
||||||
AccountActivitySvc: accountActivityService,
|
AccountActivitySvc: accountActivityService,
|
||||||
|
InvestmentService: investmentService,
|
||||||
AccountDeletionWorker: accountDeletionWorker,
|
AccountDeletionWorker: accountDeletionWorker,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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
|
auditLogService *service.SpaceAuditLogService
|
||||||
txAuditLogService *service.TransactionAuditLogService
|
txAuditLogService *service.TransactionAuditLogService
|
||||||
accountActivitySvc *service.AccountActivityService
|
accountActivitySvc *service.AccountActivityService
|
||||||
|
investmentService *service.InvestmentService
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewSpaceHandler(
|
func NewSpaceHandler(
|
||||||
|
|
@ -41,6 +42,7 @@ func NewSpaceHandler(
|
||||||
auditLogService *service.SpaceAuditLogService,
|
auditLogService *service.SpaceAuditLogService,
|
||||||
txAuditLogService *service.TransactionAuditLogService,
|
txAuditLogService *service.TransactionAuditLogService,
|
||||||
accountActivitySvc *service.AccountActivityService,
|
accountActivitySvc *service.AccountActivityService,
|
||||||
|
investmentService *service.InvestmentService,
|
||||||
) *spaceHandler {
|
) *spaceHandler {
|
||||||
return &spaceHandler{
|
return &spaceHandler{
|
||||||
spaceService: spaceService,
|
spaceService: spaceService,
|
||||||
|
|
@ -51,6 +53,7 @@ func NewSpaceHandler(
|
||||||
auditLogService: auditLogService,
|
auditLogService: auditLogService,
|
||||||
txAuditLogService: txAuditLogService,
|
txAuditLogService: txAuditLogService,
|
||||||
accountActivitySvc: accountActivitySvc,
|
accountActivitySvc: accountActivitySvc,
|
||||||
|
investmentService: investmentService,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -236,11 +239,15 @@ func (h *spaceHandler) HandleCreateAccount(w http.ResponseWriter, r *http.Reques
|
||||||
if currencyInput == "" {
|
if currencyInput == "" {
|
||||||
currencyInput = currency.Default
|
currencyInput = currency.Default
|
||||||
}
|
}
|
||||||
|
isInvestment := r.FormValue("is_investment") == "1"
|
||||||
|
subtypeInput := strings.ToLower(strings.TrimSpace(r.FormValue("investment_subtype")))
|
||||||
|
|
||||||
formProps := forms.CreateAccountProps{
|
formProps := forms.CreateAccountProps{
|
||||||
SpaceID: spaceID,
|
SpaceID: spaceID,
|
||||||
Name: nameInput,
|
Name: nameInput,
|
||||||
Currency: currencyInput,
|
Currency: currencyInput,
|
||||||
|
IsInvestment: isInvestment,
|
||||||
|
InvestmentSubtype: subtypeInput,
|
||||||
}
|
}
|
||||||
|
|
||||||
hasErr := false
|
hasErr := false
|
||||||
|
|
@ -252,6 +259,10 @@ func (h *spaceHandler) HandleCreateAccount(w http.ResponseWriter, r *http.Reques
|
||||||
formProps.CurrencyErr = "Choose a supported currency."
|
formProps.CurrencyErr = "Choose a supported currency."
|
||||||
hasErr = true
|
hasErr = true
|
||||||
}
|
}
|
||||||
|
if isInvestment && !model.IsValidInvestmentSubtype(subtypeInput) {
|
||||||
|
formProps.SubtypeErr = "Choose an account type."
|
||||||
|
hasErr = true
|
||||||
|
}
|
||||||
if hasErr {
|
if hasErr {
|
||||||
ui.Render(w, r, forms.CreateAccount(formProps))
|
ui.Render(w, r, forms.CreateAccount(formProps))
|
||||||
return
|
return
|
||||||
|
|
@ -277,7 +288,14 @@ func (h *spaceHandler) HandleCreateAccount(w http.ResponseWriter, r *http.Reques
|
||||||
if user != nil {
|
if user != nil {
|
||||||
actorID = user.ID
|
actorID = user.ID
|
||||||
}
|
}
|
||||||
account, err := h.accountService.CreateAccount(spaceID, nameInput, currencyInput, actorID)
|
account, err := h.accountService.CreateAccount(service.CreateAccountInput{
|
||||||
|
SpaceID: spaceID,
|
||||||
|
Name: nameInput,
|
||||||
|
CurrencyCode: currencyInput,
|
||||||
|
IsInvestment: isInvestment,
|
||||||
|
InvestmentSubtype: subtypeInput,
|
||||||
|
ActorID: actorID,
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("failed to create account", "error", err, "space_id", spaceID)
|
slog.Error("failed to create account", "error", err, "space_id", spaceID)
|
||||||
formProps.GeneralErr = "Something went wrong. Please try again."
|
formProps.GeneralErr = "Something went wrong. Please try again."
|
||||||
|
|
@ -329,7 +347,7 @@ func (h *spaceHandler) SpaceAccountPage(w http.ResponseWriter, r *http.Request)
|
||||||
allocSummary = nil
|
allocSummary = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
ui.Render(w, r, pages.SpaceAccountPage(pages.SpaceAccountPageProps{
|
props := pages.SpaceAccountPageProps{
|
||||||
SpaceID: spaceID,
|
SpaceID: spaceID,
|
||||||
SpaceName: space.Name,
|
SpaceName: space.Name,
|
||||||
AccountID: accountID,
|
AccountID: accountID,
|
||||||
|
|
@ -339,7 +357,24 @@ func (h *spaceHandler) SpaceAccountPage(w http.ResponseWriter, r *http.Request)
|
||||||
RecentTransactions: recent,
|
RecentTransactions: recent,
|
||||||
NonEditableTransactionIDs: h.nonEditableTransactionIDs(recent),
|
NonEditableTransactionIDs: h.nonEditableTransactionIDs(recent),
|
||||||
AllocationSummary: allocSummary,
|
AllocationSummary: allocSummary,
|
||||||
}))
|
}
|
||||||
|
if account.IsInvestment {
|
||||||
|
year := time.Now().Year()
|
||||||
|
summary, err := h.investmentService.SummarizeAccount(accountID, year)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("failed to summarize investment account", "error", err, "account_id", accountID)
|
||||||
|
} else {
|
||||||
|
props.InvestmentSummary = summary
|
||||||
|
}
|
||||||
|
positions, err := h.investmentService.HoldingPositions(accountID)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("failed to load holding positions", "error", err, "account_id", accountID)
|
||||||
|
} else {
|
||||||
|
props.InvestmentPositions = positions
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ui.Render(w, r, pages.SpaceAccountPage(props))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *spaceHandler) SpaceAccountTransactionsPage(w http.ResponseWriter, r *http.Request) {
|
func (h *spaceHandler) SpaceAccountTransactionsPage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
@ -757,12 +792,18 @@ func (h *spaceHandler) SpaceAccountSettingsPage(w http.ResponseWriter, r *http.R
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
subtype := ""
|
||||||
|
if account.InvestmentSubtype != nil {
|
||||||
|
subtype = *account.InvestmentSubtype
|
||||||
|
}
|
||||||
ui.Render(w, r, pages.SpaceAccountSettingsPage(pages.SpaceAccountSettingsPageProps{
|
ui.Render(w, r, pages.SpaceAccountSettingsPage(pages.SpaceAccountSettingsPageProps{
|
||||||
SpaceID: spaceID,
|
SpaceID: spaceID,
|
||||||
SpaceName: space.Name,
|
SpaceName: space.Name,
|
||||||
AccountID: accountID,
|
AccountID: accountID,
|
||||||
AccountName: account.Name,
|
AccountName: account.Name,
|
||||||
AccountCurrency: account.Currency,
|
AccountCurrency: account.Currency,
|
||||||
|
IsInvestment: account.IsInvestment,
|
||||||
|
InvestmentSubtype: subtype,
|
||||||
UpdateForm: forms.UpdateAccountProps{
|
UpdateForm: forms.UpdateAccountProps{
|
||||||
SpaceID: spaceID,
|
SpaceID: spaceID,
|
||||||
AccountID: accountID,
|
AccountID: accountID,
|
||||||
|
|
@ -776,6 +817,35 @@ func (h *spaceHandler) SpaceAccountSettingsPage(w http.ResponseWriter, r *http.R
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *spaceHandler) HandleSetInvestmentFlag(w http.ResponseWriter, r *http.Request) {
|
||||||
|
spaceID := r.PathValue("spaceID")
|
||||||
|
accountID := r.PathValue("accountID")
|
||||||
|
account, err := h.accountService.GetAccount(accountID)
|
||||||
|
if err != nil || account.SpaceID != spaceID {
|
||||||
|
ui.Render(w, r, pages.NotFound())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isInvestment := r.FormValue("is_investment") == "1"
|
||||||
|
subtype := strings.ToLower(strings.TrimSpace(r.FormValue("investment_subtype")))
|
||||||
|
|
||||||
|
user := ctxkeys.User(r.Context())
|
||||||
|
actorID := ""
|
||||||
|
if user != nil {
|
||||||
|
actorID = user.ID
|
||||||
|
}
|
||||||
|
if err := h.accountService.SetInvestmentFlag(accountID, isInvestment, subtype, actorID); err != nil {
|
||||||
|
slog.Error("failed to update investment flag", "error", err, "account_id", accountID)
|
||||||
|
http.Error(w, "could not update", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("HX-Redirect", routeurl.URL(
|
||||||
|
"page.app.spaces.space.accounts.account.settings",
|
||||||
|
"spaceID", spaceID, "accountID", accountID,
|
||||||
|
))
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
func (h *spaceHandler) HandleRenameAccount(w http.ResponseWriter, r *http.Request) {
|
func (h *spaceHandler) HandleRenameAccount(w http.ResponseWriter, r *http.Request) {
|
||||||
spaceID := r.PathValue("spaceID")
|
spaceID := r.PathValue("spaceID")
|
||||||
accountID := r.PathValue("accountID")
|
accountID := r.PathValue("accountID")
|
||||||
|
|
|
||||||
|
|
@ -12,10 +12,31 @@ type Account struct {
|
||||||
SpaceID string `db:"space_id"`
|
SpaceID string `db:"space_id"`
|
||||||
Balance decimal.Decimal `db:"balance"`
|
Balance decimal.Decimal `db:"balance"`
|
||||||
Currency string `db:"currency"`
|
Currency string `db:"currency"`
|
||||||
|
IsInvestment bool `db:"is_investment"`
|
||||||
|
InvestmentSubtype *string `db:"investment_subtype"`
|
||||||
CreatedAt time.Time `db:"created_at"`
|
CreatedAt time.Time `db:"created_at"`
|
||||||
UpdatedAt time.Time `db:"updated_at"`
|
UpdatedAt time.Time `db:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type InvestmentSubtype string
|
||||||
|
|
||||||
|
const (
|
||||||
|
InvestmentSubtypeTFSA InvestmentSubtype = "tfsa"
|
||||||
|
InvestmentSubtypeRRSP InvestmentSubtype = "rrsp"
|
||||||
|
InvestmentSubtypeFHSA InvestmentSubtype = "fhsa"
|
||||||
|
InvestmentSubtypePersonal InvestmentSubtype = "personal"
|
||||||
|
InvestmentSubtypeOther InvestmentSubtype = "other"
|
||||||
|
)
|
||||||
|
|
||||||
|
func IsValidInvestmentSubtype(s string) bool {
|
||||||
|
switch InvestmentSubtype(s) {
|
||||||
|
case InvestmentSubtypeTFSA, InvestmentSubtypeRRSP, InvestmentSubtypeFHSA,
|
||||||
|
InvestmentSubtypePersonal, InvestmentSubtypeOther:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
type TransactionType string
|
type TransactionType string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|
|
||||||
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"
|
SpaceAuditActionAccountRenamed SpaceAuditAction = "account.renamed"
|
||||||
SpaceAuditActionAccountDeleted SpaceAuditAction = "account.deleted"
|
SpaceAuditActionAccountDeleted SpaceAuditAction = "account.deleted"
|
||||||
SpaceAuditActionAccountCurrencyChanged SpaceAuditAction = "account.currency_changed"
|
SpaceAuditActionAccountCurrencyChanged SpaceAuditAction = "account.currency_changed"
|
||||||
|
SpaceAuditActionAccountInvestmentFlag SpaceAuditAction = "account.investment_flag_changed"
|
||||||
SpaceAuditActionAllocationCreated SpaceAuditAction = "allocation.created"
|
SpaceAuditActionAllocationCreated SpaceAuditAction = "allocation.created"
|
||||||
SpaceAuditActionAllocationUpdated SpaceAuditAction = "allocation.updated"
|
SpaceAuditActionAllocationUpdated SpaceAuditAction = "allocation.updated"
|
||||||
SpaceAuditActionAllocationDeleted SpaceAuditAction = "allocation.deleted"
|
SpaceAuditActionAllocationDeleted SpaceAuditAction = "allocation.deleted"
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,12 @@ type AccountRepository interface {
|
||||||
// ChangeCurrency atomically updates an account's currency and balance and
|
// ChangeCurrency atomically updates an account's currency and balance and
|
||||||
// rewrites each provided allocation's amount/target in the new currency.
|
// rewrites each provided allocation's amount/target in the new currency.
|
||||||
ChangeCurrency(accountID, newCurrency string, newBalance decimal.Decimal, allocationConversions []AllocationConversion) error
|
ChangeCurrency(accountID, newCurrency string, newBalance decimal.Decimal, allocationConversions []AllocationConversion) error
|
||||||
|
// SetInvestment toggles the investment flag and subtype for an account.
|
||||||
|
// subtype is the canonical lowercase string (e.g. "tfsa"); pass nil to clear.
|
||||||
|
SetInvestment(id string, isInvestment bool, subtype *string) error
|
||||||
|
// InvestmentAccountsByUserID returns all investment-flagged accounts the
|
||||||
|
// user owns, across every space the user owns.
|
||||||
|
InvestmentAccountsByUserID(userID string) ([]*model.Account, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type accountRepository struct {
|
type accountRepository struct {
|
||||||
|
|
@ -40,9 +46,10 @@ func NewAccountRepository(db *sqlx.DB) AccountRepository {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *accountRepository) Create(account *model.Account) error {
|
func (r *accountRepository) Create(account *model.Account) error {
|
||||||
query := `INSERT INTO accounts (id, name, space_id, currency, created_at, updated_at)
|
query := `INSERT INTO accounts (id, name, space_id, currency, is_investment, investment_subtype, created_at, updated_at)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6);`
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8);`
|
||||||
_, err := r.db.Exec(query, account.ID, account.Name, account.SpaceID, account.Currency, account.CreatedAt, account.UpdatedAt)
|
_, err := r.db.Exec(query, account.ID, account.Name, account.SpaceID, account.Currency,
|
||||||
|
account.IsInvestment, account.InvestmentSubtype, account.CreatedAt, account.UpdatedAt)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -78,6 +85,36 @@ func (r *accountRepository) Delete(id string) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *accountRepository) SetInvestment(id string, isInvestment bool, subtype *string) error {
|
||||||
|
query := `UPDATE accounts
|
||||||
|
SET is_investment = $1, investment_subtype = $2, updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = $3;`
|
||||||
|
res, err := r.db.Exec(query, isInvestment, subtype, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
n, err := res.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if n == 0 {
|
||||||
|
return ErrAccountNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *accountRepository) InvestmentAccountsByUserID(userID string) ([]*model.Account, error) {
|
||||||
|
var accounts []*model.Account
|
||||||
|
query := `SELECT a.* FROM accounts a
|
||||||
|
JOIN space_members sm ON sm.space_id = a.space_id
|
||||||
|
WHERE sm.user_id = $1 AND a.is_investment = TRUE
|
||||||
|
ORDER BY a.created_at ASC;`
|
||||||
|
if err := r.db.Select(&accounts, query, userID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return accounts, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (r *accountRepository) ChangeCurrency(accountID, newCurrency string, newBalance decimal.Decimal, allocationConversions []AllocationConversion) error {
|
func (r *accountRepository) ChangeCurrency(accountID, newCurrency string, newBalance decimal.Decimal, allocationConversions []AllocationConversion) error {
|
||||||
return WithTx(r.db, func(tx *sqlx.Tx) error {
|
return WithTx(r.db, func(tx *sqlx.Tx) error {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
|
|
|
||||||
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)
|
TransferIDsIn(ids []string) (map[string]bool, error)
|
||||||
ListByAccount(accountID string, limit, offset int) ([]*model.Transaction, error)
|
ListByAccount(accountID string, limit, offset int) ([]*model.Transaction, error)
|
||||||
CountByAccount(accountID string) (int, error)
|
CountByAccount(accountID string) (int, error)
|
||||||
|
// SumByAccountYearType totals transaction values for an account, year,
|
||||||
|
// and type (deposit or withdrawal). Returns zero when no rows match.
|
||||||
|
SumByAccountYearType(accountID string, year int, txType model.TransactionType) (decimal.Decimal, error)
|
||||||
|
// SumLifetimeByAccountType totals transaction values for an account over
|
||||||
|
// its full history, restricted to one type.
|
||||||
|
SumLifetimeByAccountType(accountID string, txType model.TransactionType) (decimal.Decimal, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type transactionRepository struct {
|
type transactionRepository struct {
|
||||||
|
|
@ -304,3 +310,25 @@ func (r *transactionRepository) CountByAccount(accountID string) (int, error) {
|
||||||
}
|
}
|
||||||
return count, nil
|
return count, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *transactionRepository) SumByAccountYearType(accountID string, year int, txType model.TransactionType) (decimal.Decimal, error) {
|
||||||
|
var sum decimal.Decimal
|
||||||
|
query := `SELECT COALESCE(SUM(value::numeric), 0)::text FROM transactions
|
||||||
|
WHERE account_id = $1
|
||||||
|
AND type = $2
|
||||||
|
AND EXTRACT(YEAR FROM occurred_at) = $3;`
|
||||||
|
if err := r.db.Get(&sum, query, accountID, txType, year); err != nil {
|
||||||
|
return decimal.Zero, err
|
||||||
|
}
|
||||||
|
return sum, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *transactionRepository) SumLifetimeByAccountType(accountID string, txType model.TransactionType) (decimal.Decimal, error) {
|
||||||
|
var sum decimal.Decimal
|
||||||
|
query := `SELECT COALESCE(SUM(value::numeric), 0)::text FROM transactions
|
||||||
|
WHERE account_id = $1 AND type = $2;`
|
||||||
|
if err := r.db.Get(&sum, query, accountID, txType); err != nil {
|
||||||
|
return decimal.Zero, err
|
||||||
|
}
|
||||||
|
return sum, nil
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,9 +19,10 @@ func SetupRoutes(a *app.App) http.Handler {
|
||||||
authH := handler.NewAuthHandler(a.AuthService, a.InviteService, a.SpaceService)
|
authH := handler.NewAuthHandler(a.AuthService, a.InviteService, a.SpaceService)
|
||||||
homeH := handler.NewHomeHandler()
|
homeH := handler.NewHomeHandler()
|
||||||
settingsH := handler.NewSettingsHandler(a.AuthService, a.UserService)
|
settingsH := handler.NewSettingsHandler(a.AuthService, a.UserService)
|
||||||
spaceH := handler.NewSpaceHandler(a.SpaceService, a.AccountService, a.TransactionService, a.AllocationService, a.InviteService, a.AuditLogService, a.TxAuditLogService, a.AccountActivitySvc)
|
spaceH := handler.NewSpaceHandler(a.SpaceService, a.AccountService, a.TransactionService, a.AllocationService, a.InviteService, a.AuditLogService, a.TxAuditLogService, a.AccountActivitySvc, a.InvestmentService)
|
||||||
allocationH := handler.NewAllocationHandler(a.AllocationService, a.AccountService)
|
allocationH := handler.NewAllocationHandler(a.AllocationService, a.AccountService)
|
||||||
recurringH := handler.NewRecurringEventHandler(a.RecurringEventService, a.AccountService, a.SpaceService)
|
recurringH := handler.NewRecurringEventHandler(a.RecurringEventService, a.AccountService, a.SpaceService)
|
||||||
|
investmentH := handler.NewInvestmentHandler(a.AccountService, a.SpaceService, a.InvestmentService)
|
||||||
redirectH := handler.NewRedirectHandler()
|
redirectH := handler.NewRedirectHandler()
|
||||||
|
|
||||||
r := router.New()
|
r := router.New()
|
||||||
|
|
@ -95,6 +96,8 @@ func SetupRoutes(a *app.App) http.Handler {
|
||||||
|
|
||||||
g.Get("/home", spaceH.HomePage).Name("page.app.home")
|
g.Get("/home", spaceH.HomePage).Name("page.app.home")
|
||||||
|
|
||||||
|
g.Get("/investments", investmentH.InvestmentsOverviewPage).Name("page.app.investments")
|
||||||
|
|
||||||
g.SubGroup("/spaces", func(g *router.Group) {
|
g.SubGroup("/spaces", func(g *router.Group) {
|
||||||
g.Get("", spaceH.SpacesPage).Name("page.app.spaces")
|
g.Get("", spaceH.SpacesPage).Name("page.app.spaces")
|
||||||
g.Get("/create", spaceH.CreateSpacePage).Name("page.app.spaces.create")
|
g.Get("/create", spaceH.CreateSpacePage).Name("page.app.spaces.create")
|
||||||
|
|
@ -136,6 +139,7 @@ func SetupRoutes(a *app.App) http.Handler {
|
||||||
g.Post("/settings/rename", spaceH.HandleRenameAccount).Name("action.app.spaces.space.accounts.account.settings.rename")
|
g.Post("/settings/rename", spaceH.HandleRenameAccount).Name("action.app.spaces.space.accounts.account.settings.rename")
|
||||||
g.Post("/settings/currency", spaceH.HandleChangeAccountCurrency).Name("action.app.spaces.space.accounts.account.settings.currency")
|
g.Post("/settings/currency", spaceH.HandleChangeAccountCurrency).Name("action.app.spaces.space.accounts.account.settings.currency")
|
||||||
g.Post("/settings/delete", spaceH.HandleDeleteAccount).Name("action.app.spaces.space.accounts.account.settings.delete")
|
g.Post("/settings/delete", spaceH.HandleDeleteAccount).Name("action.app.spaces.space.accounts.account.settings.delete")
|
||||||
|
g.Post("/settings/investment", spaceH.HandleSetInvestmentFlag).Name("action.app.spaces.space.accounts.account.settings.investment")
|
||||||
g.Get("/bills/create", spaceH.SpaceCreateBillPage).Name("page.app.spaces.space.accounts.account.bills.create")
|
g.Get("/bills/create", spaceH.SpaceCreateBillPage).Name("page.app.spaces.space.accounts.account.bills.create")
|
||||||
g.Post("/bills/create", spaceH.HandleCreateBill).Name("action.app.spaces.space.accounts.account.bills.create")
|
g.Post("/bills/create", spaceH.HandleCreateBill).Name("action.app.spaces.space.accounts.account.bills.create")
|
||||||
g.Get("/deposits/create", spaceH.SpaceCreateDepositPage).Name("page.app.spaces.space.accounts.account.deposits.create")
|
g.Get("/deposits/create", spaceH.SpaceCreateDepositPage).Name("page.app.spaces.space.accounts.account.deposits.create")
|
||||||
|
|
@ -146,6 +150,14 @@ func SetupRoutes(a *app.App) http.Handler {
|
||||||
g.Post("/allocations/create", allocationH.HandleCreate).Name("action.app.spaces.space.accounts.account.allocations.create")
|
g.Post("/allocations/create", allocationH.HandleCreate).Name("action.app.spaces.space.accounts.account.allocations.create")
|
||||||
g.Post("/allocations/{allocationID}/edit", allocationH.HandleEdit).Name("action.app.spaces.space.accounts.account.allocations.allocation.edit")
|
g.Post("/allocations/{allocationID}/edit", allocationH.HandleEdit).Name("action.app.spaces.space.accounts.account.allocations.allocation.edit")
|
||||||
g.Post("/allocations/{allocationID}/delete", allocationH.HandleDelete).Name("action.app.spaces.space.accounts.account.allocations.allocation.delete")
|
g.Post("/allocations/{allocationID}/delete", allocationH.HandleDelete).Name("action.app.spaces.space.accounts.account.allocations.allocation.delete")
|
||||||
|
|
||||||
|
g.Post("/investments/contribution-room", investmentH.HandleSetContributionRoom).Name("action.app.spaces.space.accounts.account.investments.contribution-room")
|
||||||
|
g.Get("/investments/holdings/create", investmentH.CreateHoldingPage).Name("page.app.spaces.space.accounts.account.investments.holdings.create")
|
||||||
|
g.Post("/investments/holdings/create", investmentH.HandleCreateHolding).Name("action.app.spaces.space.accounts.account.investments.holdings.create")
|
||||||
|
g.Get("/investments/holdings/{holdingID}", investmentH.HoldingDetailPage).Name("page.app.spaces.space.accounts.account.investments.holdings.holding")
|
||||||
|
g.Post("/investments/holdings/{holdingID}/delete", investmentH.HandleDeleteHolding).Name("action.app.spaces.space.accounts.account.investments.holdings.holding.delete")
|
||||||
|
g.Post("/investments/holdings/{holdingID}/trades/create", investmentH.HandleCreateTrade).Name("action.app.spaces.space.accounts.account.investments.holdings.holding.trades.create")
|
||||||
|
g.Post("/investments/holdings/{holdingID}/trades/{tradeID}/delete", investmentH.HandleDeleteTrade).Name("action.app.spaces.space.accounts.account.investments.holdings.holding.trades.trade.delete")
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -34,47 +34,126 @@ func (s *AccountService) SetAuditLogger(audit *SpaceAuditLogService) {
|
||||||
s.auditSvc = audit
|
s.auditSvc = audit
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *AccountService) CreateAccount(spaceID, name, currencyCode, actorID string) (*model.Account, error) {
|
// CreateAccountInput captures all the fields the caller can set when creating
|
||||||
if spaceID == "" {
|
// an account. isInvestment + investmentSubtype are optional; if isInvestment is
|
||||||
|
// false the subtype is forced to nil.
|
||||||
|
type CreateAccountInput struct {
|
||||||
|
SpaceID string
|
||||||
|
Name string
|
||||||
|
CurrencyCode string
|
||||||
|
IsInvestment bool
|
||||||
|
InvestmentSubtype string // canonical lowercase string; ignored if IsInvestment is false
|
||||||
|
ActorID string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AccountService) CreateAccount(input CreateAccountInput) (*model.Account, error) {
|
||||||
|
if input.SpaceID == "" {
|
||||||
return nil, fmt.Errorf("space id is required")
|
return nil, fmt.Errorf("space id is required")
|
||||||
}
|
}
|
||||||
if name == "" {
|
if input.Name == "" {
|
||||||
return nil, fmt.Errorf("account name cannot be empty")
|
return nil, fmt.Errorf("account name cannot be empty")
|
||||||
}
|
}
|
||||||
|
|
||||||
code := currency.Normalize(currencyCode)
|
code := currency.Normalize(input.CurrencyCode)
|
||||||
if code == "" {
|
if code == "" {
|
||||||
code = currency.Default
|
code = currency.Default
|
||||||
}
|
}
|
||||||
if !currency.IsValid(code) {
|
if !currency.IsValid(code) {
|
||||||
return nil, fmt.Errorf("unsupported currency code: %s", currencyCode)
|
return nil, fmt.Errorf("unsupported currency code: %s", input.CurrencyCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
var subtypePtr *string
|
||||||
|
if input.IsInvestment {
|
||||||
|
sub := input.InvestmentSubtype
|
||||||
|
if !model.IsValidInvestmentSubtype(sub) {
|
||||||
|
return nil, fmt.Errorf("invalid investment subtype: %s", sub)
|
||||||
|
}
|
||||||
|
subtypePtr = &sub
|
||||||
}
|
}
|
||||||
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
account := &model.Account{
|
account := &model.Account{
|
||||||
ID: uuid.NewString(),
|
ID: uuid.NewString(),
|
||||||
Name: name,
|
Name: input.Name,
|
||||||
SpaceID: spaceID,
|
SpaceID: input.SpaceID,
|
||||||
Currency: code,
|
Currency: code,
|
||||||
|
IsInvestment: input.IsInvestment,
|
||||||
|
InvestmentSubtype: subtypePtr,
|
||||||
CreatedAt: now,
|
CreatedAt: now,
|
||||||
UpdatedAt: now,
|
UpdatedAt: now,
|
||||||
}
|
}
|
||||||
if err := s.accountRepo.Create(account); err != nil {
|
if err := s.accountRepo.Create(account); err != nil {
|
||||||
return nil, fmt.Errorf("failed to create account: %w", err)
|
return nil, fmt.Errorf("failed to create account: %w", err)
|
||||||
}
|
}
|
||||||
s.auditSvc.Record(RecordOptions{
|
meta := map[string]any{
|
||||||
SpaceID: spaceID,
|
|
||||||
ActorID: actorID,
|
|
||||||
Action: model.SpaceAuditActionAccountCreated,
|
|
||||||
Metadata: map[string]any{
|
|
||||||
"account_id": account.ID,
|
"account_id": account.ID,
|
||||||
"account_name": account.Name,
|
"account_name": account.Name,
|
||||||
"currency": account.Currency,
|
"currency": account.Currency,
|
||||||
},
|
}
|
||||||
|
if account.IsInvestment {
|
||||||
|
meta["is_investment"] = true
|
||||||
|
if subtypePtr != nil {
|
||||||
|
meta["investment_subtype"] = *subtypePtr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.auditSvc.Record(RecordOptions{
|
||||||
|
SpaceID: input.SpaceID,
|
||||||
|
ActorID: input.ActorID,
|
||||||
|
Action: model.SpaceAuditActionAccountCreated,
|
||||||
|
Metadata: meta,
|
||||||
})
|
})
|
||||||
return account, nil
|
return account, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetInvestmentFlag toggles the investment flag on an existing account. When
|
||||||
|
// turning the flag off, the subtype is cleared.
|
||||||
|
func (s *AccountService) SetInvestmentFlag(accountID string, isInvestment bool, subtype string, actorID string) error {
|
||||||
|
if accountID == "" {
|
||||||
|
return fmt.Errorf("account id is required")
|
||||||
|
}
|
||||||
|
account, err := s.accountRepo.ByID(accountID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to load account: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var subtypePtr *string
|
||||||
|
if isInvestment {
|
||||||
|
if !model.IsValidInvestmentSubtype(subtype) {
|
||||||
|
return fmt.Errorf("invalid investment subtype: %s", subtype)
|
||||||
|
}
|
||||||
|
s := subtype
|
||||||
|
subtypePtr = &s
|
||||||
|
}
|
||||||
|
if err := s.accountRepo.SetInvestment(accountID, isInvestment, subtypePtr); err != nil {
|
||||||
|
return fmt.Errorf("failed to update investment flag: %w", err)
|
||||||
|
}
|
||||||
|
s.auditSvc.Record(RecordOptions{
|
||||||
|
SpaceID: account.SpaceID,
|
||||||
|
ActorID: actorID,
|
||||||
|
Action: model.SpaceAuditActionAccountInvestmentFlag,
|
||||||
|
Metadata: map[string]any{
|
||||||
|
"account_id": accountID,
|
||||||
|
"account_name": account.Name,
|
||||||
|
"is_investment": isInvestment,
|
||||||
|
"investment_subtype": subtypePtr,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// InvestmentAccountsForUser lists every investment-flagged account in spaces
|
||||||
|
// the user is a member of (including spaces they own).
|
||||||
|
func (s *AccountService) InvestmentAccountsForUser(userID string) ([]*model.Account, error) {
|
||||||
|
if userID == "" {
|
||||||
|
return nil, fmt.Errorf("user id is required")
|
||||||
|
}
|
||||||
|
accounts, err := s.accountRepo.InvestmentAccountsByUserID(userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to list investment accounts: %w", err)
|
||||||
|
}
|
||||||
|
return accounts, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *AccountService) GetAccount(id string) (*model.Account, error) {
|
func (s *AccountService) GetAccount(id string) (*model.Account, error) {
|
||||||
account, err := s.accountRepo.ByID(id)
|
account, err := s.accountRepo.ByID(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ func TestAccountService_CreateAccount_RecordsAudit(t *testing.T) {
|
||||||
user := testutil.CreateTestUser(t, dbi.DB, "acct-create-audit@example.com", nil)
|
user := testutil.CreateTestUser(t, dbi.DB, "acct-create-audit@example.com", nil)
|
||||||
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "S")
|
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "S")
|
||||||
|
|
||||||
account, err := svc.CreateAccount(space.ID, "Checking", "CAD", user.ID)
|
account, err := svc.CreateAccount(CreateAccountInput{SpaceID: space.ID, Name: "Checking", CurrencyCode: "CAD", ActorID: user.ID})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
logs, err := auditRepo.ListAccountEvents(account.ID, 10, 0)
|
logs, err := auditRepo.ListAccountEvents(account.ID, 10, 0)
|
||||||
|
|
@ -104,7 +104,7 @@ func TestAccountService_NoAuditLoggerSet_DoesNotPanic(t *testing.T) {
|
||||||
user := testutil.CreateTestUser(t, dbi.DB, "no-audit@example.com", nil)
|
user := testutil.CreateTestUser(t, dbi.DB, "no-audit@example.com", nil)
|
||||||
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "S")
|
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "S")
|
||||||
|
|
||||||
account, err := svc.CreateAccount(space.ID, "x", "", user.ID)
|
account, err := svc.CreateAccount(CreateAccountInput{SpaceID: space.ID, Name: "x", ActorID: user.ID})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NoError(t, svc.RenameAccount(account.ID, "y", user.ID))
|
require.NoError(t, svc.RenameAccount(account.ID, "y", user.ID))
|
||||||
require.NoError(t, svc.DeleteAccount(account.ID, user.ID))
|
require.NoError(t, svc.DeleteAccount(account.ID, user.ID))
|
||||||
|
|
|
||||||
|
|
@ -371,7 +371,11 @@ func (s *AuthService) CompleteOnboarding(userID, name string) error {
|
||||||
return fmt.Errorf("failed to create onboarding space: %w", err)
|
return fmt.Errorf("failed to create onboarding space: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := s.accountService.CreateAccount(space.ID, DefaultAccountName, "", userID); err != nil {
|
if _, err := s.accountService.CreateAccount(CreateAccountInput{
|
||||||
|
SpaceID: space.ID,
|
||||||
|
Name: DefaultAccountName,
|
||||||
|
ActorID: userID,
|
||||||
|
}); err != nil {
|
||||||
if delErr := s.spaceService.DeleteSpace(space.ID, userID); delErr != nil {
|
if delErr := s.spaceService.DeleteSpace(space.ID, userID); delErr != nil {
|
||||||
slog.Error("failed to roll back space after account creation error",
|
slog.Error("failed to roll back space after account creation error",
|
||||||
"space_id", space.ID, "error", delErr)
|
"space_id", space.ID, "error", delErr)
|
||||||
|
|
|
||||||
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
|
||||||
|
}
|
||||||
|
|
@ -12,12 +12,26 @@ type CreateAccountProps struct {
|
||||||
|
|
||||||
Name string
|
Name string
|
||||||
Currency string
|
Currency string
|
||||||
|
IsInvestment bool
|
||||||
|
InvestmentSubtype string
|
||||||
|
|
||||||
NameErr string
|
NameErr string
|
||||||
CurrencyErr string
|
CurrencyErr string
|
||||||
|
SubtypeErr string
|
||||||
GeneralErr string
|
GeneralErr string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var investmentSubtypes = []struct {
|
||||||
|
Value string
|
||||||
|
Label string
|
||||||
|
}{
|
||||||
|
{"tfsa", "TFSA"},
|
||||||
|
{"rrsp", "RRSP"},
|
||||||
|
{"fhsa", "FHSA"},
|
||||||
|
{"personal", "Personal / Non-registered"},
|
||||||
|
{"other", "Other"},
|
||||||
|
}
|
||||||
|
|
||||||
templ CreateAccount(props CreateAccountProps) {
|
templ CreateAccount(props CreateAccountProps) {
|
||||||
<form hx-post={ routeurl.URL("action.app.spaces.space.accounts.create", "spaceID", props.SpaceID) }>
|
<form hx-post={ routeurl.URL("action.app.spaces.space.accounts.create", "spaceID", props.SpaceID) }>
|
||||||
@card.Card(card.Props{Class: "rounded-sm"}) {
|
@card.Card(card.Props{Class: "rounded-sm"}) {
|
||||||
|
|
@ -82,6 +96,54 @@ templ CreateAccount(props CreateAccountProps) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
{{
|
||||||
|
selectedSubtype := props.InvestmentSubtype
|
||||||
|
if selectedSubtype == "" {
|
||||||
|
selectedSubtype = "tfsa"
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
@form.Item() {
|
||||||
|
<label class="flex items-center gap-2 text-sm font-medium">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name="is_investment"
|
||||||
|
value="1"
|
||||||
|
class="h-4 w-4 rounded border-input"
|
||||||
|
checked?={ props.IsInvestment }
|
||||||
|
_="on change toggle .hidden on #investment-subtype-wrapper"
|
||||||
|
/>
|
||||||
|
Investment account
|
||||||
|
</label>
|
||||||
|
@form.Description() {
|
||||||
|
Tracks contributions, contribution room, and holdings.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
<div
|
||||||
|
id="investment-subtype-wrapper"
|
||||||
|
class={ templ.KV("hidden", !props.IsInvestment) }
|
||||||
|
>
|
||||||
|
@form.Item() {
|
||||||
|
@form.Label(form.LabelProps{For: "investment_subtype"}) {
|
||||||
|
Account type
|
||||||
|
}
|
||||||
|
<select
|
||||||
|
id="investment_subtype"
|
||||||
|
name="investment_subtype"
|
||||||
|
class={ "flex h-9 w-full items-center rounded-sm border bg-transparent px-3 py-1 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
|
||||||
|
templ.KV("border-destructive", props.SubtypeErr != ""),
|
||||||
|
templ.KV("border-input", props.SubtypeErr == "") }
|
||||||
|
>
|
||||||
|
for _, opt := range investmentSubtypes {
|
||||||
|
<option value={ opt.Value } selected?={ selectedSubtype == opt.Value }>{ opt.Label }</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
if props.SubtypeErr != "" {
|
||||||
|
@form.Message(form.MessageProps{Variant: form.MessageVariantError}) {
|
||||||
|
{ props.SubtypeErr }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
@card.Footer(card.FooterProps{Class: "flex justify-end gap-2"}) {
|
@card.Footer(card.FooterProps{Class: "flex justify-end gap-2"}) {
|
||||||
@button.Button(button.Props{
|
@button.Button(button.Props{
|
||||||
|
|
|
||||||
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
|
RecentTransactions []*model.Transaction
|
||||||
NonEditableTransactionIDs map[string]bool
|
NonEditableTransactionIDs map[string]bool
|
||||||
AllocationSummary *service.AllocationSummary
|
AllocationSummary *service.AllocationSummary
|
||||||
|
InvestmentSummary *model.InvestmentAccountSummary
|
||||||
|
InvestmentPositions []model.HoldingPosition
|
||||||
}
|
}
|
||||||
|
|
||||||
templ SpaceAccountPage(props SpaceAccountPageProps) {
|
templ SpaceAccountPage(props SpaceAccountPageProps) {
|
||||||
|
|
@ -91,11 +93,21 @@ templ SpaceAccountPage(props SpaceAccountPageProps) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
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{
|
@blocks.AllocationsSection(blocks.AllocationsSectionProps{
|
||||||
SpaceID: props.SpaceID,
|
SpaceID: props.SpaceID,
|
||||||
AccountID: props.AccountID,
|
AccountID: props.AccountID,
|
||||||
Summary: props.AllocationSummary,
|
Summary: props.AllocationSummary,
|
||||||
})
|
})
|
||||||
|
}
|
||||||
<div>
|
<div>
|
||||||
@card.Card() {
|
@card.Card() {
|
||||||
@card.Header() {
|
@card.Header() {
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import "git.juancwu.dev/juancwu/budgit/internal/ui/layouts"
|
||||||
import "git.juancwu.dev/juancwu/budgit/internal/ui/components/button"
|
import "git.juancwu.dev/juancwu/budgit/internal/ui/components/button"
|
||||||
import "git.juancwu.dev/juancwu/budgit/internal/ui/components/card"
|
import "git.juancwu.dev/juancwu/budgit/internal/ui/components/card"
|
||||||
import "git.juancwu.dev/juancwu/budgit/internal/ui/components/dialog"
|
import "git.juancwu.dev/juancwu/budgit/internal/ui/components/dialog"
|
||||||
|
import "git.juancwu.dev/juancwu/budgit/internal/ui/components/form"
|
||||||
import "git.juancwu.dev/juancwu/budgit/internal/ui/components/icon"
|
import "git.juancwu.dev/juancwu/budgit/internal/ui/components/icon"
|
||||||
|
|
||||||
type SpaceAccountSettingsPageProps struct {
|
type SpaceAccountSettingsPageProps struct {
|
||||||
|
|
@ -14,6 +15,8 @@ type SpaceAccountSettingsPageProps struct {
|
||||||
AccountID string
|
AccountID string
|
||||||
AccountName string
|
AccountName string
|
||||||
AccountCurrency string
|
AccountCurrency string
|
||||||
|
IsInvestment bool
|
||||||
|
InvestmentSubtype string
|
||||||
UpdateForm forms.UpdateAccountProps
|
UpdateForm forms.UpdateAccountProps
|
||||||
CurrencyForm forms.ChangeAccountCurrencyProps
|
CurrencyForm forms.ChangeAccountCurrencyProps
|
||||||
}
|
}
|
||||||
|
|
@ -35,6 +38,68 @@ templ SpaceAccountSettingsPage(props SpaceAccountSettingsPageProps) {
|
||||||
</div>
|
</div>
|
||||||
@forms.UpdateAccount(props.UpdateForm)
|
@forms.UpdateAccount(props.UpdateForm)
|
||||||
@forms.ChangeAccountCurrency(props.CurrencyForm)
|
@forms.ChangeAccountCurrency(props.CurrencyForm)
|
||||||
|
@card.Card(card.Props{Class: "rounded-sm"}) {
|
||||||
|
@card.Header() {
|
||||||
|
@card.Title() {
|
||||||
|
Investment account
|
||||||
|
}
|
||||||
|
@card.Description() {
|
||||||
|
Flag this account to track contribution room, holdings, and trades.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@card.Content() {
|
||||||
|
{{
|
||||||
|
selectedSubtype := props.InvestmentSubtype
|
||||||
|
if selectedSubtype == "" {
|
||||||
|
selectedSubtype = "tfsa"
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
<form
|
||||||
|
hx-post={ routeurl.URL("action.app.spaces.space.accounts.account.settings.investment", "spaceID", props.SpaceID, "accountID", props.AccountID) }
|
||||||
|
class="space-y-4"
|
||||||
|
>
|
||||||
|
@form.Item() {
|
||||||
|
<label class="flex items-center gap-2 text-sm font-medium">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name="is_investment"
|
||||||
|
value="1"
|
||||||
|
class="h-4 w-4 rounded border-input"
|
||||||
|
checked?={ props.IsInvestment }
|
||||||
|
_="on change toggle .hidden on #settings-subtype-wrapper"
|
||||||
|
/>
|
||||||
|
Track as an investment account
|
||||||
|
</label>
|
||||||
|
}
|
||||||
|
<div
|
||||||
|
id="settings-subtype-wrapper"
|
||||||
|
class={ templ.KV("hidden", !props.IsInvestment) }
|
||||||
|
>
|
||||||
|
@form.Item() {
|
||||||
|
@form.Label(form.LabelProps{For: "settings-subtype"}) {
|
||||||
|
Account type
|
||||||
|
}
|
||||||
|
<select
|
||||||
|
id="settings-subtype"
|
||||||
|
name="investment_subtype"
|
||||||
|
class="flex h-9 w-full items-center rounded-sm border bg-transparent px-3 py-1 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring border-input"
|
||||||
|
>
|
||||||
|
<option value="tfsa" selected?={ selectedSubtype == "tfsa" }>TFSA</option>
|
||||||
|
<option value="rrsp" selected?={ selectedSubtype == "rrsp" }>RRSP</option>
|
||||||
|
<option value="fhsa" selected?={ selectedSubtype == "fhsa" }>FHSA</option>
|
||||||
|
<option value="personal" selected?={ selectedSubtype == "personal" }>Personal / Non-registered</option>
|
||||||
|
<option value="other" selected?={ selectedSubtype == "other" }>Other</option>
|
||||||
|
</select>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end">
|
||||||
|
@button.Button(button.Props{Type: button.TypeSubmit}) {
|
||||||
|
Save investment settings
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
}
|
||||||
|
}
|
||||||
@card.Card(card.Props{Class: "rounded-sm border-destructive"}) {
|
@card.Card(card.Props{Class: "rounded-sm border-destructive"}) {
|
||||||
@card.Header() {
|
@card.Header() {
|
||||||
@card.Title(card.TitleProps{Class: "text-destructive"}) {
|
@card.Title(card.TitleProps{Class: "text-destructive"}) {
|
||||||
|
|
|
||||||
|
|
@ -99,6 +99,16 @@ templ spaceOverviewSidebarContent() {
|
||||||
<span>Shared with me</span>
|
<span>Shared with me</span>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@sidebar.MenuItem() {
|
||||||
|
@sidebar.MenuButton(sidebar.MenuButtonProps{
|
||||||
|
Href: routeurl.URL("page.app.investments"),
|
||||||
|
IsActive: ctxkeys.URLPath(ctx) == routeurl.URL("page.app.investments"),
|
||||||
|
Tooltip: "Investments",
|
||||||
|
}) {
|
||||||
|
@icon.TrendingUp()
|
||||||
|
<span>Investments</span>
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue