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

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

View file

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

View file

@ -0,0 +1,50 @@
-- +goose Up
-- +goose StatementBegin
ALTER TABLE accounts
ADD COLUMN is_investment BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN investment_subtype TEXT NULL;
CREATE TABLE investment_contribution_rooms (
account_id TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
year INTEGER NOT NULL,
room_amount TEXT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (account_id, year)
);
CREATE TABLE investment_holdings (
id TEXT NOT NULL PRIMARY KEY,
account_id TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
symbol TEXT NOT NULL,
display_name TEXT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE (account_id, symbol)
);
CREATE TABLE investment_trades (
id TEXT NOT NULL PRIMARY KEY,
holding_id TEXT NOT NULL REFERENCES investment_holdings(id) ON DELETE CASCADE,
type TEXT NOT NULL,
quantity TEXT NOT NULL,
price_per_unit TEXT NOT NULL,
fees TEXT NULL,
occurred_at TIMESTAMP NOT NULL,
notes TEXT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_investment_trades_holding_id_occurred_at
ON investment_trades (holding_id, occurred_at);
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
DROP TABLE investment_trades;
DROP TABLE investment_holdings;
DROP TABLE investment_contribution_rooms;
ALTER TABLE accounts
DROP COLUMN investment_subtype,
DROP COLUMN is_investment;
-- +goose StatementEnd

View file

@ -0,0 +1,348 @@
package handler
import (
"log/slog"
"net/http"
"strconv"
"strings"
"time"
"github.com/shopspring/decimal"
"git.juancwu.dev/juancwu/budgit/internal/ctxkeys"
"git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/routeurl"
"git.juancwu.dev/juancwu/budgit/internal/service"
"git.juancwu.dev/juancwu/budgit/internal/ui"
"git.juancwu.dev/juancwu/budgit/internal/ui/blocks"
"git.juancwu.dev/juancwu/budgit/internal/ui/pages"
)
type investmentHandler struct {
accountService *service.AccountService
spaceService *service.SpaceService
investmentService *service.InvestmentService
}
func NewInvestmentHandler(
accountService *service.AccountService,
spaceService *service.SpaceService,
investmentService *service.InvestmentService,
) *investmentHandler {
return &investmentHandler{
accountService: accountService,
spaceService: spaceService,
investmentService: investmentService,
}
}
func (h *investmentHandler) loadInvestmentAccount(w http.ResponseWriter, r *http.Request) (*model.Account, bool) {
spaceID := r.PathValue("spaceID")
accountID := r.PathValue("accountID")
account, err := h.accountService.GetAccount(accountID)
if err != nil || account.SpaceID != spaceID {
ui.Render(w, r, pages.NotFound())
return nil, false
}
if !account.IsInvestment {
ui.Render(w, r, pages.NotFound())
return nil, false
}
return account, true
}
// HandleSetContributionRoom upserts the room amount for a year, then returns
// the refreshed investment section so the page can swap it in place.
func (h *investmentHandler) HandleSetContributionRoom(w http.ResponseWriter, r *http.Request) {
account, ok := h.loadInvestmentAccount(w, r)
if !ok {
return
}
year, err := strconv.Atoi(strings.TrimSpace(r.FormValue("year")))
if err != nil || year < 1900 || year > 9999 {
http.Error(w, "invalid year", http.StatusBadRequest)
return
}
roomStr := strings.TrimSpace(r.FormValue("room"))
room, err := decimal.NewFromString(roomStr)
if err != nil || room.IsNegative() {
http.Error(w, "invalid room amount", http.StatusBadRequest)
return
}
if err := h.investmentService.SetContributionRoom(account.ID, year, room); err != nil {
slog.Error("failed to set contribution room", "error", err, "account_id", account.ID)
http.Error(w, "could not save room", http.StatusInternalServerError)
return
}
h.renderSection(w, r, account, year)
}
func (h *investmentHandler) renderSection(w http.ResponseWriter, r *http.Request, account *model.Account, year int) {
summary, err := h.investmentService.SummarizeAccount(account.ID, year)
if err != nil {
slog.Error("failed to summarize investment account", "error", err, "account_id", account.ID)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
positions, err := h.investmentService.HoldingPositions(account.ID)
if err != nil {
slog.Error("failed to load positions", "error", err, "account_id", account.ID)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
ui.Render(w, r, blocks.InvestmentSection(blocks.InvestmentSectionProps{
SpaceID: account.SpaceID,
AccountID: account.ID,
Currency: account.Currency,
Summary: summary,
Positions: positions,
}))
}
// ---------- Holdings ----------
func (h *investmentHandler) CreateHoldingPage(w http.ResponseWriter, r *http.Request) {
account, ok := h.loadInvestmentAccount(w, r)
if !ok {
return
}
space, err := h.spaceService.GetSpace(account.SpaceID)
if err != nil {
ui.Render(w, r, pages.NotFound())
return
}
ui.Render(w, r, pages.InvestmentHoldingFormPage(pages.InvestmentHoldingFormPageProps{
SpaceID: space.ID,
SpaceName: space.Name,
AccountID: account.ID,
AccountName: account.Name,
}))
}
func (h *investmentHandler) HandleCreateHolding(w http.ResponseWriter, r *http.Request) {
account, ok := h.loadInvestmentAccount(w, r)
if !ok {
return
}
symbol := strings.ToUpper(strings.TrimSpace(r.FormValue("symbol")))
displayName := strings.TrimSpace(r.FormValue("display_name"))
if symbol == "" {
http.Error(w, "symbol required", http.StatusBadRequest)
return
}
if _, err := h.investmentService.CreateHolding(account.ID, symbol, displayName); err != nil {
slog.Error("failed to create holding", "error", err)
http.Error(w, "could not create holding", http.StatusInternalServerError)
return
}
w.Header().Set("HX-Redirect", routeurl.URL(
"page.app.spaces.space.accounts.account.overview",
"spaceID", account.SpaceID, "accountID", account.ID,
))
w.WriteHeader(http.StatusOK)
}
func (h *investmentHandler) HoldingDetailPage(w http.ResponseWriter, r *http.Request) {
account, ok := h.loadInvestmentAccount(w, r)
if !ok {
return
}
holdingID := r.PathValue("holdingID")
holding, err := h.investmentService.GetHolding(holdingID)
if err != nil || holding.AccountID != account.ID {
ui.Render(w, r, pages.NotFound())
return
}
pos, err := h.investmentService.HoldingPosition(holdingID)
if err != nil {
slog.Error("failed to load holding position", "error", err)
ui.RenderError(w, r, "Failed to load holding", http.StatusInternalServerError)
return
}
trades, err := h.investmentService.ListTrades(holdingID)
if err != nil {
slog.Error("failed to load trades", "error", err)
trades = nil
}
space, err := h.spaceService.GetSpace(account.SpaceID)
if err != nil {
ui.Render(w, r, pages.NotFound())
return
}
ui.Render(w, r, pages.InvestmentHoldingDetailPage(pages.InvestmentHoldingDetailProps{
SpaceID: space.ID,
SpaceName: space.Name,
AccountID: account.ID,
AccountName: account.Name,
Currency: account.Currency,
Position: *pos,
Trades: trades,
}))
}
func (h *investmentHandler) HandleDeleteHolding(w http.ResponseWriter, r *http.Request) {
account, ok := h.loadInvestmentAccount(w, r)
if !ok {
return
}
holdingID := r.PathValue("holdingID")
holding, err := h.investmentService.GetHolding(holdingID)
if err != nil || holding.AccountID != account.ID {
http.Error(w, "not found", http.StatusNotFound)
return
}
if err := h.investmentService.DeleteHolding(holdingID); err != nil {
slog.Error("failed to delete holding", "error", err)
http.Error(w, "could not delete holding", http.StatusInternalServerError)
return
}
w.Header().Set("HX-Redirect", routeurl.URL(
"page.app.spaces.space.accounts.account.overview",
"spaceID", account.SpaceID, "accountID", account.ID,
))
w.WriteHeader(http.StatusOK)
}
// ---------- Trades ----------
func (h *investmentHandler) HandleCreateTrade(w http.ResponseWriter, r *http.Request) {
account, ok := h.loadInvestmentAccount(w, r)
if !ok {
return
}
holdingID := r.PathValue("holdingID")
holding, err := h.investmentService.GetHolding(holdingID)
if err != nil || holding.AccountID != account.ID {
http.Error(w, "not found", http.StatusNotFound)
return
}
tradeType := strings.ToLower(strings.TrimSpace(r.FormValue("type")))
if !model.IsValidInvestmentTradeType(tradeType) {
http.Error(w, "invalid trade type", http.StatusBadRequest)
return
}
qty, err := decimal.NewFromString(strings.TrimSpace(r.FormValue("quantity")))
if err != nil || !qty.IsPositive() {
http.Error(w, "invalid quantity", http.StatusBadRequest)
return
}
price, err := decimal.NewFromString(strings.TrimSpace(r.FormValue("price")))
if err != nil || price.IsNegative() {
http.Error(w, "invalid price", http.StatusBadRequest)
return
}
var feesPtr *decimal.Decimal
if feesStr := strings.TrimSpace(r.FormValue("fees")); feesStr != "" {
fees, err := decimal.NewFromString(feesStr)
if err != nil || fees.IsNegative() {
http.Error(w, "invalid fees", http.StatusBadRequest)
return
}
feesPtr = &fees
}
occurredAt := time.Now()
if dateStr := strings.TrimSpace(r.FormValue("occurred_at")); dateStr != "" {
t, err := time.Parse("2006-01-02", dateStr)
if err != nil {
http.Error(w, "invalid date", http.StatusBadRequest)
return
}
occurredAt = t
}
var notesPtr *string
if notes := strings.TrimSpace(r.FormValue("notes")); notes != "" {
notesPtr = &notes
}
if _, err := h.investmentService.RecordTrade(service.RecordTradeInput{
HoldingID: holdingID,
Type: model.InvestmentTradeType(tradeType),
Quantity: qty,
PricePerUnit: price,
Fees: feesPtr,
OccurredAt: occurredAt,
Notes: notesPtr,
}); err != nil {
slog.Error("failed to record trade", "error", err)
http.Error(w, "could not record trade", http.StatusInternalServerError)
return
}
w.Header().Set("HX-Redirect", routeurl.URL(
"page.app.spaces.space.accounts.account.investments.holdings.holding",
"spaceID", account.SpaceID, "accountID", account.ID, "holdingID", holdingID,
))
w.WriteHeader(http.StatusOK)
}
func (h *investmentHandler) HandleDeleteTrade(w http.ResponseWriter, r *http.Request) {
account, ok := h.loadInvestmentAccount(w, r)
if !ok {
return
}
holdingID := r.PathValue("holdingID")
tradeID := r.PathValue("tradeID")
trade, err := h.investmentService.GetTrade(tradeID)
if err != nil || trade.HoldingID != holdingID {
http.Error(w, "not found", http.StatusNotFound)
return
}
holding, err := h.investmentService.GetHolding(holdingID)
if err != nil || holding.AccountID != account.ID {
http.Error(w, "not found", http.StatusNotFound)
return
}
if err := h.investmentService.DeleteTrade(tradeID); err != nil {
slog.Error("failed to delete trade", "error", err)
http.Error(w, "could not delete trade", http.StatusInternalServerError)
return
}
w.Header().Set("HX-Redirect", routeurl.URL(
"page.app.spaces.space.accounts.account.investments.holdings.holding",
"spaceID", account.SpaceID, "accountID", account.ID, "holdingID", holdingID,
))
w.WriteHeader(http.StatusOK)
}
// ---------- Top-level /app/investments page ----------
func (h *investmentHandler) InvestmentsOverviewPage(w http.ResponseWriter, r *http.Request) {
user := ctxkeys.User(r.Context())
if user == nil {
ui.Render(w, r, pages.NotFound())
return
}
accounts, err := h.accountService.InvestmentAccountsForUser(user.ID)
if err != nil {
slog.Error("failed to list investment accounts", "error", err)
ui.RenderError(w, r, "Failed to load investments", http.StatusInternalServerError)
return
}
year := time.Now().Year()
rows := make([]pages.InvestmentOverviewRow, 0, len(accounts))
for _, acc := range accounts {
summary, err := h.investmentService.SummarizeAccount(acc.ID, year)
if err != nil {
slog.Error("failed to summarize account", "error", err, "account_id", acc.ID)
continue
}
space, err := h.spaceService.GetSpace(acc.SpaceID)
spaceName := acc.SpaceID
if err == nil {
spaceName = space.Name
}
rows = append(rows, pages.InvestmentOverviewRow{
SpaceID: acc.SpaceID,
SpaceName: spaceName,
AccountID: acc.ID,
AccountName: acc.Name,
Currency: acc.Currency,
Summary: summary,
})
}
ui.Render(w, r, pages.InvestmentsOverviewPage(pages.InvestmentsOverviewProps{
Year: year,
Rows: rows,
}))
}

View file

@ -30,6 +30,7 @@ type spaceHandler struct {
auditLogService *service.SpaceAuditLogService
txAuditLogService *service.TransactionAuditLogService
accountActivitySvc *service.AccountActivityService
investmentService *service.InvestmentService
}
func NewSpaceHandler(
@ -41,6 +42,7 @@ func NewSpaceHandler(
auditLogService *service.SpaceAuditLogService,
txAuditLogService *service.TransactionAuditLogService,
accountActivitySvc *service.AccountActivityService,
investmentService *service.InvestmentService,
) *spaceHandler {
return &spaceHandler{
spaceService: spaceService,
@ -51,6 +53,7 @@ func NewSpaceHandler(
auditLogService: auditLogService,
txAuditLogService: txAuditLogService,
accountActivitySvc: accountActivitySvc,
investmentService: investmentService,
}
}
@ -236,11 +239,15 @@ func (h *spaceHandler) HandleCreateAccount(w http.ResponseWriter, r *http.Reques
if currencyInput == "" {
currencyInput = currency.Default
}
isInvestment := r.FormValue("is_investment") == "1"
subtypeInput := strings.ToLower(strings.TrimSpace(r.FormValue("investment_subtype")))
formProps := forms.CreateAccountProps{
SpaceID: spaceID,
Name: nameInput,
Currency: currencyInput,
IsInvestment: isInvestment,
InvestmentSubtype: subtypeInput,
}
hasErr := false
@ -252,6 +259,10 @@ func (h *spaceHandler) HandleCreateAccount(w http.ResponseWriter, r *http.Reques
formProps.CurrencyErr = "Choose a supported currency."
hasErr = true
}
if isInvestment && !model.IsValidInvestmentSubtype(subtypeInput) {
formProps.SubtypeErr = "Choose an account type."
hasErr = true
}
if hasErr {
ui.Render(w, r, forms.CreateAccount(formProps))
return
@ -277,7 +288,14 @@ func (h *spaceHandler) HandleCreateAccount(w http.ResponseWriter, r *http.Reques
if user != nil {
actorID = user.ID
}
account, err := h.accountService.CreateAccount(spaceID, nameInput, currencyInput, actorID)
account, err := h.accountService.CreateAccount(service.CreateAccountInput{
SpaceID: spaceID,
Name: nameInput,
CurrencyCode: currencyInput,
IsInvestment: isInvestment,
InvestmentSubtype: subtypeInput,
ActorID: actorID,
})
if err != nil {
slog.Error("failed to create account", "error", err, "space_id", spaceID)
formProps.GeneralErr = "Something went wrong. Please try again."
@ -329,7 +347,7 @@ func (h *spaceHandler) SpaceAccountPage(w http.ResponseWriter, r *http.Request)
allocSummary = nil
}
ui.Render(w, r, pages.SpaceAccountPage(pages.SpaceAccountPageProps{
props := pages.SpaceAccountPageProps{
SpaceID: spaceID,
SpaceName: space.Name,
AccountID: accountID,
@ -339,7 +357,24 @@ func (h *spaceHandler) SpaceAccountPage(w http.ResponseWriter, r *http.Request)
RecentTransactions: recent,
NonEditableTransactionIDs: h.nonEditableTransactionIDs(recent),
AllocationSummary: allocSummary,
}))
}
if account.IsInvestment {
year := time.Now().Year()
summary, err := h.investmentService.SummarizeAccount(accountID, year)
if err != nil {
slog.Error("failed to summarize investment account", "error", err, "account_id", accountID)
} else {
props.InvestmentSummary = summary
}
positions, err := h.investmentService.HoldingPositions(accountID)
if err != nil {
slog.Error("failed to load holding positions", "error", err, "account_id", accountID)
} else {
props.InvestmentPositions = positions
}
}
ui.Render(w, r, pages.SpaceAccountPage(props))
}
func (h *spaceHandler) SpaceAccountTransactionsPage(w http.ResponseWriter, r *http.Request) {
@ -757,12 +792,18 @@ func (h *spaceHandler) SpaceAccountSettingsPage(w http.ResponseWriter, r *http.R
return
}
subtype := ""
if account.InvestmentSubtype != nil {
subtype = *account.InvestmentSubtype
}
ui.Render(w, r, pages.SpaceAccountSettingsPage(pages.SpaceAccountSettingsPageProps{
SpaceID: spaceID,
SpaceName: space.Name,
AccountID: accountID,
AccountName: account.Name,
AccountCurrency: account.Currency,
IsInvestment: account.IsInvestment,
InvestmentSubtype: subtype,
UpdateForm: forms.UpdateAccountProps{
SpaceID: spaceID,
AccountID: accountID,
@ -776,6 +817,35 @@ func (h *spaceHandler) SpaceAccountSettingsPage(w http.ResponseWriter, r *http.R
}))
}
func (h *spaceHandler) HandleSetInvestmentFlag(w http.ResponseWriter, r *http.Request) {
spaceID := r.PathValue("spaceID")
accountID := r.PathValue("accountID")
account, err := h.accountService.GetAccount(accountID)
if err != nil || account.SpaceID != spaceID {
ui.Render(w, r, pages.NotFound())
return
}
isInvestment := r.FormValue("is_investment") == "1"
subtype := strings.ToLower(strings.TrimSpace(r.FormValue("investment_subtype")))
user := ctxkeys.User(r.Context())
actorID := ""
if user != nil {
actorID = user.ID
}
if err := h.accountService.SetInvestmentFlag(accountID, isInvestment, subtype, actorID); err != nil {
slog.Error("failed to update investment flag", "error", err, "account_id", accountID)
http.Error(w, "could not update", http.StatusBadRequest)
return
}
w.Header().Set("HX-Redirect", routeurl.URL(
"page.app.spaces.space.accounts.account.settings",
"spaceID", spaceID, "accountID", accountID,
))
w.WriteHeader(http.StatusOK)
}
func (h *spaceHandler) HandleRenameAccount(w http.ResponseWriter, r *http.Request) {
spaceID := r.PathValue("spaceID")
accountID := r.PathValue("accountID")

View file

@ -12,10 +12,31 @@ type Account struct {
SpaceID string `db:"space_id"`
Balance decimal.Decimal `db:"balance"`
Currency string `db:"currency"`
IsInvestment bool `db:"is_investment"`
InvestmentSubtype *string `db:"investment_subtype"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}
type InvestmentSubtype string
const (
InvestmentSubtypeTFSA InvestmentSubtype = "tfsa"
InvestmentSubtypeRRSP InvestmentSubtype = "rrsp"
InvestmentSubtypeFHSA InvestmentSubtype = "fhsa"
InvestmentSubtypePersonal InvestmentSubtype = "personal"
InvestmentSubtypeOther InvestmentSubtype = "other"
)
func IsValidInvestmentSubtype(s string) bool {
switch InvestmentSubtype(s) {
case InvestmentSubtypeTFSA, InvestmentSubtypeRRSP, InvestmentSubtypeFHSA,
InvestmentSubtypePersonal, InvestmentSubtypeOther:
return true
}
return false
}
type TransactionType string
const (

View file

@ -0,0 +1,83 @@
package model
import (
"time"
"github.com/shopspring/decimal"
)
type InvestmentContributionRoom struct {
AccountID string `db:"account_id"`
Year int `db:"year"`
RoomAmount decimal.Decimal `db:"room_amount"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}
type InvestmentHolding struct {
ID string `db:"id"`
AccountID string `db:"account_id"`
Symbol string `db:"symbol"`
DisplayName string `db:"display_name"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}
type InvestmentTradeType string
const (
InvestmentTradeTypeBuy InvestmentTradeType = "buy"
InvestmentTradeTypeSell InvestmentTradeType = "sell"
)
func IsValidInvestmentTradeType(t string) bool {
switch InvestmentTradeType(t) {
case InvestmentTradeTypeBuy, InvestmentTradeTypeSell:
return true
}
return false
}
type InvestmentTrade struct {
ID string `db:"id"`
HoldingID string `db:"holding_id"`
Type InvestmentTradeType `db:"type"`
Quantity decimal.Decimal `db:"quantity"`
PricePerUnit decimal.Decimal `db:"price_per_unit"`
Fees *decimal.Decimal `db:"fees"`
OccurredAt time.Time `db:"occurred_at"`
Notes *string `db:"notes"`
CreatedAt time.Time `db:"created_at"`
}
// HoldingPosition aggregates a holding with its derived figures across all
// trades. Quantity is the net of buys minus sells. AvgCost is the weighted
// average per-unit cost of remaining shares (reduced proportionally on sells).
// RealizedPL is the cumulative realized profit/loss from sells.
type HoldingPosition struct {
Holding InvestmentHolding
Quantity decimal.Decimal
AvgCost decimal.Decimal
CostBasis decimal.Decimal
LastBuyPrice *decimal.Decimal
LastSellPrice *decimal.Decimal
RealizedPL decimal.Decimal
TotalBuyQty decimal.Decimal
TotalSellQty decimal.Decimal
TotalFees decimal.Decimal
}
// InvestmentAccountSummary is the rolled-up view for an investment-flagged
// account: contribution room and YTD cash flow plus aggregate cost basis across
// holdings.
type InvestmentAccountSummary struct {
Account *Account
Year int
RoomAmount *decimal.Decimal // nil if room not yet set for the year
YTDContributions decimal.Decimal
YTDWithdrawals decimal.Decimal
RoomRemaining *decimal.Decimal // nil if RoomAmount is nil
NetContributions decimal.Decimal // lifetime: all deposits minus all withdrawals
TotalCostBasis decimal.Decimal
HoldingCount int
}

View file

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

View file

@ -29,6 +29,12 @@ type AccountRepository interface {
// ChangeCurrency atomically updates an account's currency and balance and
// rewrites each provided allocation's amount/target in the new currency.
ChangeCurrency(accountID, newCurrency string, newBalance decimal.Decimal, allocationConversions []AllocationConversion) error
// SetInvestment toggles the investment flag and subtype for an account.
// subtype is the canonical lowercase string (e.g. "tfsa"); pass nil to clear.
SetInvestment(id string, isInvestment bool, subtype *string) error
// InvestmentAccountsByUserID returns all investment-flagged accounts the
// user owns, across every space the user owns.
InvestmentAccountsByUserID(userID string) ([]*model.Account, error)
}
type accountRepository struct {
@ -40,9 +46,10 @@ func NewAccountRepository(db *sqlx.DB) AccountRepository {
}
func (r *accountRepository) Create(account *model.Account) error {
query := `INSERT INTO accounts (id, name, space_id, currency, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6);`
_, err := r.db.Exec(query, account.ID, account.Name, account.SpaceID, account.Currency, account.CreatedAt, account.UpdatedAt)
query := `INSERT INTO accounts (id, name, space_id, currency, is_investment, investment_subtype, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8);`
_, err := r.db.Exec(query, account.ID, account.Name, account.SpaceID, account.Currency,
account.IsInvestment, account.InvestmentSubtype, account.CreatedAt, account.UpdatedAt)
return err
}
@ -78,6 +85,36 @@ func (r *accountRepository) Delete(id string) error {
return err
}
func (r *accountRepository) SetInvestment(id string, isInvestment bool, subtype *string) error {
query := `UPDATE accounts
SET is_investment = $1, investment_subtype = $2, updated_at = CURRENT_TIMESTAMP
WHERE id = $3;`
res, err := r.db.Exec(query, isInvestment, subtype, id)
if err != nil {
return err
}
n, err := res.RowsAffected()
if err != nil {
return err
}
if n == 0 {
return ErrAccountNotFound
}
return nil
}
func (r *accountRepository) InvestmentAccountsByUserID(userID string) ([]*model.Account, error) {
var accounts []*model.Account
query := `SELECT a.* FROM accounts a
JOIN space_members sm ON sm.space_id = a.space_id
WHERE sm.user_id = $1 AND a.is_investment = TRUE
ORDER BY a.created_at ASC;`
if err := r.db.Select(&accounts, query, userID); err != nil {
return nil, err
}
return accounts, nil
}
func (r *accountRepository) ChangeCurrency(accountID, newCurrency string, newBalance decimal.Decimal, allocationConversions []AllocationConversion) error {
return WithTx(r.db, func(tx *sqlx.Tx) error {
now := time.Now()

View file

@ -0,0 +1,74 @@
package repository
import (
"database/sql"
"errors"
"git.juancwu.dev/juancwu/budgit/internal/model"
"github.com/jmoiron/sqlx"
)
var ErrContributionRoomNotFound = errors.New("contribution room not found")
type InvestmentContributionRoomRepository interface {
Upsert(room *model.InvestmentContributionRoom) error
ByAccountAndYear(accountID string, year int) (*model.InvestmentContributionRoom, error)
ByAccountID(accountID string) ([]*model.InvestmentContributionRoom, error)
Delete(accountID string, year int) error
}
type investmentContributionRoomRepository struct {
db *sqlx.DB
}
func NewInvestmentContributionRoomRepository(db *sqlx.DB) InvestmentContributionRoomRepository {
return &investmentContributionRoomRepository{db: db}
}
func (r *investmentContributionRoomRepository) Upsert(room *model.InvestmentContributionRoom) error {
query := `INSERT INTO investment_contribution_rooms (account_id, year, room_amount, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (account_id, year) DO UPDATE
SET room_amount = EXCLUDED.room_amount,
updated_at = EXCLUDED.updated_at;`
_, err := r.db.Exec(query, room.AccountID, room.Year, room.RoomAmount, room.CreatedAt, room.UpdatedAt)
return err
}
func (r *investmentContributionRoomRepository) ByAccountAndYear(accountID string, year int) (*model.InvestmentContributionRoom, error) {
room := &model.InvestmentContributionRoom{}
query := `SELECT * FROM investment_contribution_rooms WHERE account_id = $1 AND year = $2;`
err := r.db.Get(room, query, accountID, year)
if err == sql.ErrNoRows {
return nil, ErrContributionRoomNotFound
}
if err != nil {
return nil, err
}
return room, nil
}
func (r *investmentContributionRoomRepository) ByAccountID(accountID string) ([]*model.InvestmentContributionRoom, error) {
var rooms []*model.InvestmentContributionRoom
query := `SELECT * FROM investment_contribution_rooms WHERE account_id = $1 ORDER BY year DESC;`
if err := r.db.Select(&rooms, query, accountID); err != nil {
return nil, err
}
return rooms, nil
}
func (r *investmentContributionRoomRepository) Delete(accountID string, year int) error {
res, err := r.db.Exec(`DELETE FROM investment_contribution_rooms WHERE account_id = $1 AND year = $2;`, accountID, year)
if err != nil {
return err
}
n, err := res.RowsAffected()
if err != nil {
return err
}
if n == 0 {
return ErrContributionRoomNotFound
}
return nil
}

View file

@ -0,0 +1,89 @@
package repository
import (
"database/sql"
"errors"
"git.juancwu.dev/juancwu/budgit/internal/model"
"github.com/jmoiron/sqlx"
)
var ErrHoldingNotFound = errors.New("investment holding not found")
type InvestmentHoldingRepository interface {
Create(h *model.InvestmentHolding) error
ByID(id string) (*model.InvestmentHolding, error)
ByAccountID(accountID string) ([]*model.InvestmentHolding, error)
Update(id, symbol, displayName string) error
Delete(id string) error
}
type investmentHoldingRepository struct {
db *sqlx.DB
}
func NewInvestmentHoldingRepository(db *sqlx.DB) InvestmentHoldingRepository {
return &investmentHoldingRepository{db: db}
}
func (r *investmentHoldingRepository) Create(h *model.InvestmentHolding) error {
query := `INSERT INTO investment_holdings (id, account_id, symbol, display_name, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6);`
_, err := r.db.Exec(query, h.ID, h.AccountID, h.Symbol, h.DisplayName, h.CreatedAt, h.UpdatedAt)
return err
}
func (r *investmentHoldingRepository) ByID(id string) (*model.InvestmentHolding, error) {
h := &model.InvestmentHolding{}
query := `SELECT * FROM investment_holdings WHERE id = $1;`
err := r.db.Get(h, query, id)
if err == sql.ErrNoRows {
return nil, ErrHoldingNotFound
}
if err != nil {
return nil, err
}
return h, nil
}
func (r *investmentHoldingRepository) ByAccountID(accountID string) ([]*model.InvestmentHolding, error) {
var holdings []*model.InvestmentHolding
query := `SELECT * FROM investment_holdings WHERE account_id = $1 ORDER BY symbol ASC;`
if err := r.db.Select(&holdings, query, accountID); err != nil {
return nil, err
}
return holdings, nil
}
func (r *investmentHoldingRepository) Update(id, symbol, displayName string) error {
query := `UPDATE investment_holdings
SET symbol = $1, display_name = $2, updated_at = CURRENT_TIMESTAMP
WHERE id = $3;`
res, err := r.db.Exec(query, symbol, displayName, id)
if err != nil {
return err
}
n, err := res.RowsAffected()
if err != nil {
return err
}
if n == 0 {
return ErrHoldingNotFound
}
return nil
}
func (r *investmentHoldingRepository) Delete(id string) error {
res, err := r.db.Exec(`DELETE FROM investment_holdings WHERE id = $1;`, id)
if err != nil {
return err
}
n, err := res.RowsAffected()
if err != nil {
return err
}
if n == 0 {
return ErrHoldingNotFound
}
return nil
}

View file

@ -0,0 +1,91 @@
package repository
import (
"database/sql"
"errors"
"time"
"git.juancwu.dev/juancwu/budgit/internal/model"
"github.com/jmoiron/sqlx"
"github.com/shopspring/decimal"
)
var ErrTradeNotFound = errors.New("investment trade not found")
type InvestmentTradeRepository interface {
Create(t *model.InvestmentTrade) error
ByID(id string) (*model.InvestmentTrade, error)
ByHoldingID(holdingID string) ([]*model.InvestmentTrade, error)
Update(id string, quantity, pricePerUnit decimal.Decimal, fees *decimal.Decimal, occurredAt time.Time, notes *string) error
Delete(id string) error
}
type investmentTradeRepository struct {
db *sqlx.DB
}
func NewInvestmentTradeRepository(db *sqlx.DB) InvestmentTradeRepository {
return &investmentTradeRepository{db: db}
}
func (r *investmentTradeRepository) Create(t *model.InvestmentTrade) error {
query := `INSERT INTO investment_trades (id, holding_id, type, quantity, price_per_unit, fees, occurred_at, notes, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9);`
_, err := r.db.Exec(query, t.ID, t.HoldingID, t.Type, t.Quantity, t.PricePerUnit, t.Fees, t.OccurredAt, t.Notes, t.CreatedAt)
return err
}
func (r *investmentTradeRepository) ByID(id string) (*model.InvestmentTrade, error) {
t := &model.InvestmentTrade{}
query := `SELECT * FROM investment_trades WHERE id = $1;`
err := r.db.Get(t, query, id)
if err == sql.ErrNoRows {
return nil, ErrTradeNotFound
}
if err != nil {
return nil, err
}
return t, nil
}
func (r *investmentTradeRepository) ByHoldingID(holdingID string) ([]*model.InvestmentTrade, error) {
var trades []*model.InvestmentTrade
query := `SELECT * FROM investment_trades WHERE holding_id = $1 ORDER BY occurred_at ASC, created_at ASC;`
if err := r.db.Select(&trades, query, holdingID); err != nil {
return nil, err
}
return trades, nil
}
func (r *investmentTradeRepository) Update(id string, quantity, pricePerUnit decimal.Decimal, fees *decimal.Decimal, occurredAt time.Time, notes *string) error {
query := `UPDATE investment_trades
SET quantity = $1, price_per_unit = $2, fees = $3, occurred_at = $4, notes = $5
WHERE id = $6;`
res, err := r.db.Exec(query, quantity, pricePerUnit, fees, occurredAt, notes, id)
if err != nil {
return err
}
n, err := res.RowsAffected()
if err != nil {
return err
}
if n == 0 {
return ErrTradeNotFound
}
return nil
}
func (r *investmentTradeRepository) Delete(id string) error {
res, err := r.db.Exec(`DELETE FROM investment_trades WHERE id = $1;`, id)
if err != nil {
return err
}
n, err := res.RowsAffected()
if err != nil {
return err
}
if n == 0 {
return ErrTradeNotFound
}
return nil
}

View file

@ -22,6 +22,12 @@ type TransactionRepository interface {
TransferIDsIn(ids []string) (map[string]bool, error)
ListByAccount(accountID string, limit, offset int) ([]*model.Transaction, error)
CountByAccount(accountID string) (int, error)
// SumByAccountYearType totals transaction values for an account, year,
// and type (deposit or withdrawal). Returns zero when no rows match.
SumByAccountYearType(accountID string, year int, txType model.TransactionType) (decimal.Decimal, error)
// SumLifetimeByAccountType totals transaction values for an account over
// its full history, restricted to one type.
SumLifetimeByAccountType(accountID string, txType model.TransactionType) (decimal.Decimal, error)
}
type transactionRepository struct {
@ -304,3 +310,25 @@ func (r *transactionRepository) CountByAccount(accountID string) (int, error) {
}
return count, nil
}
func (r *transactionRepository) SumByAccountYearType(accountID string, year int, txType model.TransactionType) (decimal.Decimal, error) {
var sum decimal.Decimal
query := `SELECT COALESCE(SUM(value::numeric), 0)::text FROM transactions
WHERE account_id = $1
AND type = $2
AND EXTRACT(YEAR FROM occurred_at) = $3;`
if err := r.db.Get(&sum, query, accountID, txType, year); err != nil {
return decimal.Zero, err
}
return sum, nil
}
func (r *transactionRepository) SumLifetimeByAccountType(accountID string, txType model.TransactionType) (decimal.Decimal, error) {
var sum decimal.Decimal
query := `SELECT COALESCE(SUM(value::numeric), 0)::text FROM transactions
WHERE account_id = $1 AND type = $2;`
if err := r.db.Get(&sum, query, accountID, txType); err != nil {
return decimal.Zero, err
}
return sum, nil
}

View file

@ -19,9 +19,10 @@ func SetupRoutes(a *app.App) http.Handler {
authH := handler.NewAuthHandler(a.AuthService, a.InviteService, a.SpaceService)
homeH := handler.NewHomeHandler()
settingsH := handler.NewSettingsHandler(a.AuthService, a.UserService)
spaceH := handler.NewSpaceHandler(a.SpaceService, a.AccountService, a.TransactionService, a.AllocationService, a.InviteService, a.AuditLogService, a.TxAuditLogService, a.AccountActivitySvc)
spaceH := handler.NewSpaceHandler(a.SpaceService, a.AccountService, a.TransactionService, a.AllocationService, a.InviteService, a.AuditLogService, a.TxAuditLogService, a.AccountActivitySvc, a.InvestmentService)
allocationH := handler.NewAllocationHandler(a.AllocationService, a.AccountService)
recurringH := handler.NewRecurringEventHandler(a.RecurringEventService, a.AccountService, a.SpaceService)
investmentH := handler.NewInvestmentHandler(a.AccountService, a.SpaceService, a.InvestmentService)
redirectH := handler.NewRedirectHandler()
r := router.New()
@ -95,6 +96,8 @@ func SetupRoutes(a *app.App) http.Handler {
g.Get("/home", spaceH.HomePage).Name("page.app.home")
g.Get("/investments", investmentH.InvestmentsOverviewPage).Name("page.app.investments")
g.SubGroup("/spaces", func(g *router.Group) {
g.Get("", spaceH.SpacesPage).Name("page.app.spaces")
g.Get("/create", spaceH.CreateSpacePage).Name("page.app.spaces.create")
@ -136,6 +139,7 @@ func SetupRoutes(a *app.App) http.Handler {
g.Post("/settings/rename", spaceH.HandleRenameAccount).Name("action.app.spaces.space.accounts.account.settings.rename")
g.Post("/settings/currency", spaceH.HandleChangeAccountCurrency).Name("action.app.spaces.space.accounts.account.settings.currency")
g.Post("/settings/delete", spaceH.HandleDeleteAccount).Name("action.app.spaces.space.accounts.account.settings.delete")
g.Post("/settings/investment", spaceH.HandleSetInvestmentFlag).Name("action.app.spaces.space.accounts.account.settings.investment")
g.Get("/bills/create", spaceH.SpaceCreateBillPage).Name("page.app.spaces.space.accounts.account.bills.create")
g.Post("/bills/create", spaceH.HandleCreateBill).Name("action.app.spaces.space.accounts.account.bills.create")
g.Get("/deposits/create", spaceH.SpaceCreateDepositPage).Name("page.app.spaces.space.accounts.account.deposits.create")
@ -146,6 +150,14 @@ func SetupRoutes(a *app.App) http.Handler {
g.Post("/allocations/create", allocationH.HandleCreate).Name("action.app.spaces.space.accounts.account.allocations.create")
g.Post("/allocations/{allocationID}/edit", allocationH.HandleEdit).Name("action.app.spaces.space.accounts.account.allocations.allocation.edit")
g.Post("/allocations/{allocationID}/delete", allocationH.HandleDelete).Name("action.app.spaces.space.accounts.account.allocations.allocation.delete")
g.Post("/investments/contribution-room", investmentH.HandleSetContributionRoom).Name("action.app.spaces.space.accounts.account.investments.contribution-room")
g.Get("/investments/holdings/create", investmentH.CreateHoldingPage).Name("page.app.spaces.space.accounts.account.investments.holdings.create")
g.Post("/investments/holdings/create", investmentH.HandleCreateHolding).Name("action.app.spaces.space.accounts.account.investments.holdings.create")
g.Get("/investments/holdings/{holdingID}", investmentH.HoldingDetailPage).Name("page.app.spaces.space.accounts.account.investments.holdings.holding")
g.Post("/investments/holdings/{holdingID}/delete", investmentH.HandleDeleteHolding).Name("action.app.spaces.space.accounts.account.investments.holdings.holding.delete")
g.Post("/investments/holdings/{holdingID}/trades/create", investmentH.HandleCreateTrade).Name("action.app.spaces.space.accounts.account.investments.holdings.holding.trades.create")
g.Post("/investments/holdings/{holdingID}/trades/{tradeID}/delete", investmentH.HandleDeleteTrade).Name("action.app.spaces.space.accounts.account.investments.holdings.holding.trades.trade.delete")
})
})
})

View file

@ -34,47 +34,126 @@ func (s *AccountService) SetAuditLogger(audit *SpaceAuditLogService) {
s.auditSvc = audit
}
func (s *AccountService) CreateAccount(spaceID, name, currencyCode, actorID string) (*model.Account, error) {
if spaceID == "" {
// CreateAccountInput captures all the fields the caller can set when creating
// an account. isInvestment + investmentSubtype are optional; if isInvestment is
// false the subtype is forced to nil.
type CreateAccountInput struct {
SpaceID string
Name string
CurrencyCode string
IsInvestment bool
InvestmentSubtype string // canonical lowercase string; ignored if IsInvestment is false
ActorID string
}
func (s *AccountService) CreateAccount(input CreateAccountInput) (*model.Account, error) {
if input.SpaceID == "" {
return nil, fmt.Errorf("space id is required")
}
if name == "" {
if input.Name == "" {
return nil, fmt.Errorf("account name cannot be empty")
}
code := currency.Normalize(currencyCode)
code := currency.Normalize(input.CurrencyCode)
if code == "" {
code = currency.Default
}
if !currency.IsValid(code) {
return nil, fmt.Errorf("unsupported currency code: %s", currencyCode)
return nil, fmt.Errorf("unsupported currency code: %s", input.CurrencyCode)
}
var subtypePtr *string
if input.IsInvestment {
sub := input.InvestmentSubtype
if !model.IsValidInvestmentSubtype(sub) {
return nil, fmt.Errorf("invalid investment subtype: %s", sub)
}
subtypePtr = &sub
}
now := time.Now()
account := &model.Account{
ID: uuid.NewString(),
Name: name,
SpaceID: spaceID,
Name: input.Name,
SpaceID: input.SpaceID,
Currency: code,
IsInvestment: input.IsInvestment,
InvestmentSubtype: subtypePtr,
CreatedAt: now,
UpdatedAt: now,
}
if err := s.accountRepo.Create(account); err != nil {
return nil, fmt.Errorf("failed to create account: %w", err)
}
s.auditSvc.Record(RecordOptions{
SpaceID: spaceID,
ActorID: actorID,
Action: model.SpaceAuditActionAccountCreated,
Metadata: map[string]any{
meta := map[string]any{
"account_id": account.ID,
"account_name": account.Name,
"currency": account.Currency,
},
}
if account.IsInvestment {
meta["is_investment"] = true
if subtypePtr != nil {
meta["investment_subtype"] = *subtypePtr
}
}
s.auditSvc.Record(RecordOptions{
SpaceID: input.SpaceID,
ActorID: input.ActorID,
Action: model.SpaceAuditActionAccountCreated,
Metadata: meta,
})
return account, nil
}
// SetInvestmentFlag toggles the investment flag on an existing account. When
// turning the flag off, the subtype is cleared.
func (s *AccountService) SetInvestmentFlag(accountID string, isInvestment bool, subtype string, actorID string) error {
if accountID == "" {
return fmt.Errorf("account id is required")
}
account, err := s.accountRepo.ByID(accountID)
if err != nil {
return fmt.Errorf("failed to load account: %w", err)
}
var subtypePtr *string
if isInvestment {
if !model.IsValidInvestmentSubtype(subtype) {
return fmt.Errorf("invalid investment subtype: %s", subtype)
}
s := subtype
subtypePtr = &s
}
if err := s.accountRepo.SetInvestment(accountID, isInvestment, subtypePtr); err != nil {
return fmt.Errorf("failed to update investment flag: %w", err)
}
s.auditSvc.Record(RecordOptions{
SpaceID: account.SpaceID,
ActorID: actorID,
Action: model.SpaceAuditActionAccountInvestmentFlag,
Metadata: map[string]any{
"account_id": accountID,
"account_name": account.Name,
"is_investment": isInvestment,
"investment_subtype": subtypePtr,
},
})
return nil
}
// InvestmentAccountsForUser lists every investment-flagged account in spaces
// the user is a member of (including spaces they own).
func (s *AccountService) InvestmentAccountsForUser(userID string) ([]*model.Account, error) {
if userID == "" {
return nil, fmt.Errorf("user id is required")
}
accounts, err := s.accountRepo.InvestmentAccountsByUserID(userID)
if err != nil {
return nil, fmt.Errorf("failed to list investment accounts: %w", err)
}
return accounts, nil
}
func (s *AccountService) GetAccount(id string) (*model.Account, error) {
account, err := s.accountRepo.ByID(id)
if err != nil {

View file

@ -22,7 +22,7 @@ func TestAccountService_CreateAccount_RecordsAudit(t *testing.T) {
user := testutil.CreateTestUser(t, dbi.DB, "acct-create-audit@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "S")
account, err := svc.CreateAccount(space.ID, "Checking", "CAD", user.ID)
account, err := svc.CreateAccount(CreateAccountInput{SpaceID: space.ID, Name: "Checking", CurrencyCode: "CAD", ActorID: user.ID})
require.NoError(t, err)
logs, err := auditRepo.ListAccountEvents(account.ID, 10, 0)
@ -104,7 +104,7 @@ func TestAccountService_NoAuditLoggerSet_DoesNotPanic(t *testing.T) {
user := testutil.CreateTestUser(t, dbi.DB, "no-audit@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "S")
account, err := svc.CreateAccount(space.ID, "x", "", user.ID)
account, err := svc.CreateAccount(CreateAccountInput{SpaceID: space.ID, Name: "x", ActorID: user.ID})
require.NoError(t, err)
require.NoError(t, svc.RenameAccount(account.ID, "y", user.ID))
require.NoError(t, svc.DeleteAccount(account.ID, user.ID))

View file

@ -371,7 +371,11 @@ func (s *AuthService) CompleteOnboarding(userID, name string) error {
return fmt.Errorf("failed to create onboarding space: %w", err)
}
if _, err := s.accountService.CreateAccount(space.ID, DefaultAccountName, "", userID); err != nil {
if _, err := s.accountService.CreateAccount(CreateAccountInput{
SpaceID: space.ID,
Name: DefaultAccountName,
ActorID: userID,
}); err != nil {
if delErr := s.spaceService.DeleteSpace(space.ID, userID); delErr != nil {
slog.Error("failed to roll back space after account creation error",
"space_id", space.ID, "error", delErr)

View file

@ -0,0 +1,355 @@
package service
import (
"fmt"
"time"
"git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/repository"
"github.com/google/uuid"
"github.com/shopspring/decimal"
)
// InvestmentService handles contribution rooms, holdings, trades, and the
// summary view for investment-flagged accounts. Cash movement (contributions
// and withdrawals) still goes through TransactionService / TransferService;
// this service reads from those tables but never mutates them directly.
type InvestmentService struct {
accountRepo repository.AccountRepository
roomRepo repository.InvestmentContributionRoomRepository
holdingRepo repository.InvestmentHoldingRepository
tradeRepo repository.InvestmentTradeRepository
txRepo repository.TransactionRepository
}
func NewInvestmentService(
accountRepo repository.AccountRepository,
roomRepo repository.InvestmentContributionRoomRepository,
holdingRepo repository.InvestmentHoldingRepository,
tradeRepo repository.InvestmentTradeRepository,
txRepo repository.TransactionRepository,
) *InvestmentService {
return &InvestmentService{
accountRepo: accountRepo,
roomRepo: roomRepo,
holdingRepo: holdingRepo,
tradeRepo: tradeRepo,
txRepo: txRepo,
}
}
// ---------- Contribution room ----------
func (s *InvestmentService) SetContributionRoom(accountID string, year int, room decimal.Decimal) error {
if accountID == "" {
return fmt.Errorf("account id is required")
}
if year < 1900 || year > 9999 {
return fmt.Errorf("year out of range")
}
if room.IsNegative() {
return fmt.Errorf("contribution room cannot be negative")
}
account, err := s.accountRepo.ByID(accountID)
if err != nil {
return fmt.Errorf("failed to load account: %w", err)
}
if !account.IsInvestment {
return fmt.Errorf("account is not an investment account")
}
now := time.Now()
return s.roomRepo.Upsert(&model.InvestmentContributionRoom{
AccountID: accountID,
Year: year,
RoomAmount: room,
CreatedAt: now,
UpdatedAt: now,
})
}
func (s *InvestmentService) GetContributionRoom(accountID string, year int) (*model.InvestmentContributionRoom, error) {
room, err := s.roomRepo.ByAccountAndYear(accountID, year)
if err != nil {
if err == repository.ErrContributionRoomNotFound {
return nil, nil
}
return nil, fmt.Errorf("failed to load contribution room: %w", err)
}
return room, nil
}
func (s *InvestmentService) ListContributionRooms(accountID string) ([]*model.InvestmentContributionRoom, error) {
rooms, err := s.roomRepo.ByAccountID(accountID)
if err != nil {
return nil, fmt.Errorf("failed to list contribution rooms: %w", err)
}
return rooms, nil
}
// ---------- Summary ----------
// SummarizeAccount produces the rollup view for an investment account in the
// given calendar year: contribution room, YTD cash flow, lifetime net
// contributions, and total cost basis across all holdings.
func (s *InvestmentService) SummarizeAccount(accountID string, year int) (*model.InvestmentAccountSummary, error) {
account, err := s.accountRepo.ByID(accountID)
if err != nil {
return nil, fmt.Errorf("failed to load account: %w", err)
}
if !account.IsInvestment {
return nil, fmt.Errorf("account is not an investment account")
}
ytdContrib, err := s.txRepo.SumByAccountYearType(accountID, year, model.TransactionTypeDeposit)
if err != nil {
return nil, fmt.Errorf("failed to sum ytd contributions: %w", err)
}
ytdWithdraw, err := s.txRepo.SumByAccountYearType(accountID, year, model.TransactionTypeWithdrawal)
if err != nil {
return nil, fmt.Errorf("failed to sum ytd withdrawals: %w", err)
}
lifeContrib, err := s.txRepo.SumLifetimeByAccountType(accountID, model.TransactionTypeDeposit)
if err != nil {
return nil, fmt.Errorf("failed to sum lifetime contributions: %w", err)
}
lifeWithdraw, err := s.txRepo.SumLifetimeByAccountType(accountID, model.TransactionTypeWithdrawal)
if err != nil {
return nil, fmt.Errorf("failed to sum lifetime withdrawals: %w", err)
}
summary := &model.InvestmentAccountSummary{
Account: account,
Year: year,
YTDContributions: ytdContrib,
YTDWithdrawals: ytdWithdraw,
NetContributions: lifeContrib.Sub(lifeWithdraw),
}
room, err := s.roomRepo.ByAccountAndYear(accountID, year)
if err == nil {
amt := room.RoomAmount
summary.RoomAmount = &amt
rem := amt.Sub(ytdContrib)
summary.RoomRemaining = &rem
} else if err != repository.ErrContributionRoomNotFound {
return nil, fmt.Errorf("failed to load contribution room: %w", err)
}
positions, err := s.HoldingPositions(accountID)
if err != nil {
return nil, err
}
summary.HoldingCount = len(positions)
for _, p := range positions {
summary.TotalCostBasis = summary.TotalCostBasis.Add(p.CostBasis)
}
return summary, nil
}
// ---------- Holdings ----------
func (s *InvestmentService) CreateHolding(accountID, symbol, displayName string) (*model.InvestmentHolding, error) {
if accountID == "" {
return nil, fmt.Errorf("account id is required")
}
if symbol == "" {
return nil, fmt.Errorf("symbol is required")
}
if displayName == "" {
displayName = symbol
}
account, err := s.accountRepo.ByID(accountID)
if err != nil {
return nil, fmt.Errorf("failed to load account: %w", err)
}
if !account.IsInvestment {
return nil, fmt.Errorf("account is not an investment account")
}
now := time.Now()
holding := &model.InvestmentHolding{
ID: uuid.NewString(),
AccountID: accountID,
Symbol: symbol,
DisplayName: displayName,
CreatedAt: now,
UpdatedAt: now,
}
if err := s.holdingRepo.Create(holding); err != nil {
return nil, fmt.Errorf("failed to create holding: %w", err)
}
return holding, nil
}
func (s *InvestmentService) GetHolding(id string) (*model.InvestmentHolding, error) {
h, err := s.holdingRepo.ByID(id)
if err != nil {
return nil, fmt.Errorf("failed to load holding: %w", err)
}
return h, nil
}
func (s *InvestmentService) UpdateHolding(id, symbol, displayName string) error {
if symbol == "" {
return fmt.Errorf("symbol is required")
}
if displayName == "" {
displayName = symbol
}
if err := s.holdingRepo.Update(id, symbol, displayName); err != nil {
return fmt.Errorf("failed to update holding: %w", err)
}
return nil
}
func (s *InvestmentService) DeleteHolding(id string) error {
return s.holdingRepo.Delete(id)
}
func (s *InvestmentService) ListHoldings(accountID string) ([]*model.InvestmentHolding, error) {
return s.holdingRepo.ByAccountID(accountID)
}
// ---------- Trades ----------
type RecordTradeInput struct {
HoldingID string
Type model.InvestmentTradeType
Quantity decimal.Decimal
PricePerUnit decimal.Decimal
Fees *decimal.Decimal
OccurredAt time.Time
Notes *string
}
func (s *InvestmentService) RecordTrade(input RecordTradeInput) (*model.InvestmentTrade, error) {
if input.HoldingID == "" {
return nil, fmt.Errorf("holding id is required")
}
if !model.IsValidInvestmentTradeType(string(input.Type)) {
return nil, fmt.Errorf("invalid trade type: %s", input.Type)
}
if !input.Quantity.IsPositive() {
return nil, fmt.Errorf("quantity must be greater than zero")
}
if input.PricePerUnit.IsNegative() {
return nil, fmt.Errorf("price per unit cannot be negative")
}
if input.OccurredAt.IsZero() {
input.OccurredAt = time.Now()
}
trade := &model.InvestmentTrade{
ID: uuid.NewString(),
HoldingID: input.HoldingID,
Type: input.Type,
Quantity: input.Quantity,
PricePerUnit: input.PricePerUnit,
Fees: input.Fees,
OccurredAt: input.OccurredAt,
Notes: input.Notes,
CreatedAt: time.Now(),
}
if err := s.tradeRepo.Create(trade); err != nil {
return nil, fmt.Errorf("failed to record trade: %w", err)
}
return trade, nil
}
func (s *InvestmentService) UpdateTrade(id string, qty, price decimal.Decimal, fees *decimal.Decimal, occurredAt time.Time, notes *string) error {
if !qty.IsPositive() {
return fmt.Errorf("quantity must be greater than zero")
}
if price.IsNegative() {
return fmt.Errorf("price per unit cannot be negative")
}
return s.tradeRepo.Update(id, qty, price, fees, occurredAt, notes)
}
func (s *InvestmentService) DeleteTrade(id string) error {
return s.tradeRepo.Delete(id)
}
func (s *InvestmentService) GetTrade(id string) (*model.InvestmentTrade, error) {
return s.tradeRepo.ByID(id)
}
func (s *InvestmentService) ListTrades(holdingID string) ([]*model.InvestmentTrade, error) {
return s.tradeRepo.ByHoldingID(holdingID)
}
// HoldingPositions returns the derived position for every holding in the
// account. Positions are computed by replaying each trade in chronological
// order, maintaining a running weighted-average cost basis. Each sell reduces
// the remaining quantity at the current avg cost; realized P/L accumulates on
// each sell as (sell.price avg cost) × qty fees.
func (s *InvestmentService) HoldingPositions(accountID string) ([]model.HoldingPosition, error) {
holdings, err := s.holdingRepo.ByAccountID(accountID)
if err != nil {
return nil, fmt.Errorf("failed to load holdings: %w", err)
}
out := make([]model.HoldingPosition, 0, len(holdings))
for _, h := range holdings {
pos, err := s.holdingPosition(*h)
if err != nil {
return nil, err
}
out = append(out, pos)
}
return out, nil
}
func (s *InvestmentService) HoldingPosition(holdingID string) (*model.HoldingPosition, error) {
h, err := s.holdingRepo.ByID(holdingID)
if err != nil {
return nil, fmt.Errorf("failed to load holding: %w", err)
}
pos, err := s.holdingPosition(*h)
if err != nil {
return nil, err
}
return &pos, nil
}
func (s *InvestmentService) holdingPosition(h model.InvestmentHolding) (model.HoldingPosition, error) {
trades, err := s.tradeRepo.ByHoldingID(h.ID)
if err != nil {
return model.HoldingPosition{}, fmt.Errorf("failed to load trades: %w", err)
}
pos := model.HoldingPosition{Holding: h}
qty := decimal.Zero
avgCost := decimal.Zero
for _, t := range trades {
fees := decimal.Zero
if t.Fees != nil {
fees = *t.Fees
}
pos.TotalFees = pos.TotalFees.Add(fees)
switch t.Type {
case model.InvestmentTradeTypeBuy:
newQty := qty.Add(t.Quantity)
if newQty.IsPositive() {
// weighted average including fees in cost basis
newCost := qty.Mul(avgCost).Add(t.Quantity.Mul(t.PricePerUnit)).Add(fees)
avgCost = newCost.Div(newQty)
}
qty = newQty
pos.TotalBuyQty = pos.TotalBuyQty.Add(t.Quantity)
price := t.PricePerUnit
pos.LastBuyPrice = &price
case model.InvestmentTradeTypeSell:
realized := t.PricePerUnit.Sub(avgCost).Mul(t.Quantity).Sub(fees)
pos.RealizedPL = pos.RealizedPL.Add(realized)
qty = qty.Sub(t.Quantity)
pos.TotalSellQty = pos.TotalSellQty.Add(t.Quantity)
price := t.PricePerUnit
pos.LastSellPrice = &price
if !qty.IsPositive() {
qty = decimal.Zero
avgCost = decimal.Zero
}
}
}
pos.Quantity = qty
pos.AvgCost = avgCost
pos.CostBasis = qty.Mul(avgCost)
return pos, nil
}

View file

@ -0,0 +1,220 @@
package blocks
import (
"fmt"
"strings"
"github.com/shopspring/decimal"
"git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/routeurl"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/badge"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/button"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/card"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/icon"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/input"
"git.juancwu.dev/juancwu/budgit/internal/ui/utils"
)
type InvestmentSectionProps struct {
SpaceID string
AccountID string
Currency string
Summary *model.InvestmentAccountSummary
Positions []model.HoldingPosition
}
func fmtMoney(d decimal.Decimal) string {
s, _ := utils.FormatDecimalWithThousands(d.StringFixedBank(2))
return s
}
func subtypeLabel(s *string) string {
if s == nil {
return ""
}
return strings.ToUpper(*s)
}
func plClass(d decimal.Decimal) string {
if d.IsNegative() {
return "text-red-600 dark:text-red-400"
}
if d.IsPositive() {
return "text-green-600 dark:text-green-400"
}
return ""
}
templ InvestmentSection(props InvestmentSectionProps) {
<div id="investment-section" class="space-y-4">
@card.Card(card.Props{Class: "rounded-sm"}) {
@card.Header() {
<div class="flex items-start justify-between gap-3">
<div>
@card.Title() {
<div class="flex items-center gap-2">
Contribution Room ({ fmt.Sprintf("%d", props.Summary.Year) })
if label := subtypeLabel(props.Summary.Account.InvestmentSubtype); label != "" {
@badge.Badge(badge.Props{Variant: badge.VariantSecondary, Class: "text-xs"}) {
{ label }
}
}
</div>
}
@card.Description() {
Track yearly contributions against the room you set.
}
</div>
</div>
}
@card.Content(card.ContentProps{Class: "space-y-4"}) {
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
<div>
<div class="text-muted-foreground">Room</div>
<div class="text-lg font-semibold">
if props.Summary.RoomAmount != nil {
${ fmtMoney(*props.Summary.RoomAmount) }
} else {
<span class="text-muted-foreground italic">Not set</span>
}
</div>
</div>
<div>
<div class="text-muted-foreground">YTD Contributions</div>
<div class="text-lg font-semibold">${ fmtMoney(props.Summary.YTDContributions) }</div>
</div>
<div>
<div class="text-muted-foreground">YTD Withdrawals</div>
<div class="text-lg font-semibold">${ fmtMoney(props.Summary.YTDWithdrawals) }</div>
</div>
<div>
<div class="text-muted-foreground">Remaining</div>
<div class={ "text-lg font-semibold", plClass(remainingValue(props.Summary)) }>
if props.Summary.RoomRemaining != nil {
${ fmtMoney(*props.Summary.RoomRemaining) }
} else {
<span class="text-muted-foreground">—</span>
}
</div>
</div>
</div>
<form
hx-post={ routeurl.URL("action.app.spaces.space.accounts.account.investments.contribution-room", "spaceID", props.SpaceID, "accountID", props.AccountID) }
hx-target="#investment-section"
hx-swap="outerHTML"
class="flex flex-wrap items-end gap-2"
>
<div class="flex flex-col gap-1">
<label class="text-xs text-muted-foreground" for="room-year">Year</label>
@input.Input(input.Props{
ID: "room-year",
Name: "year",
Type: input.TypeNumber,
Value: fmt.Sprintf("%d", props.Summary.Year),
Class: "w-28 rounded-sm",
})
</div>
<div class="flex flex-col gap-1">
<label class="text-xs text-muted-foreground" for="room-amount">Room amount ({ props.Currency })</label>
{{
currentRoom := ""
if props.Summary.RoomAmount != nil {
currentRoom = props.Summary.RoomAmount.StringFixedBank(2)
}
}}
@input.Input(input.Props{
ID: "room-amount",
Name: "room",
Type: input.TypeText,
Value: currentRoom,
Placeholder: "0.00",
Class: "w-40 rounded-sm",
Required: true,
})
</div>
@button.Button(button.Props{Type: button.TypeSubmit, Class: "rounded-sm"}) {
Save room
}
</form>
}
}
@card.Card(card.Props{Class: "rounded-sm"}) {
@card.Header() {
<div class="flex items-center justify-between">
<div>
@card.Title() {
Holdings
}
@card.Description() {
Track shares and buy/sell prices. Cost basis is computed from your trades.
}
</div>
@button.Button(button.Props{
Variant: button.VariantSecondary,
Class: "flex gap-2 items-center rounded-sm",
Href: routeurl.URL("page.app.spaces.space.accounts.account.investments.holdings.create", "spaceID", props.SpaceID, "accountID", props.AccountID),
}) {
@icon.Plus()
Add holding
}
</div>
}
@card.Content() {
if len(props.Positions) == 0 {
<p class="text-sm text-muted-foreground">No holdings yet. Add one to start tracking shares and trades.</p>
} else {
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="text-left text-muted-foreground border-b">
<tr>
<th class="py-2 pr-2">Symbol</th>
<th class="py-2 pr-2">Quantity</th>
<th class="py-2 pr-2">Avg cost</th>
<th class="py-2 pr-2">Cost basis</th>
<th class="py-2 pr-2">Realized P/L</th>
<th class="py-2"></th>
</tr>
</thead>
<tbody>
for _, pos := range props.Positions {
<tr class="border-b last:border-b-0">
<td class="py-2 pr-2">
<a
class="font-medium hover:underline"
href={ templ.SafeURL(routeurl.URL("page.app.spaces.space.accounts.account.investments.holdings.holding", "spaceID", props.SpaceID, "accountID", props.AccountID, "holdingID", pos.Holding.ID)) }
>
{ pos.Holding.Symbol }
</a>
<div class="text-xs text-muted-foreground">{ pos.Holding.DisplayName }</div>
</td>
<td class="py-2 pr-2">{ pos.Quantity.StringFixedBank(4) }</td>
<td class="py-2 pr-2">${ fmtMoney(pos.AvgCost) }</td>
<td class="py-2 pr-2">${ fmtMoney(pos.CostBasis) }</td>
<td class={ "py-2 pr-2", plClass(pos.RealizedPL) }>${ fmtMoney(pos.RealizedPL) }</td>
<td class="py-2 text-right">
@button.Button(button.Props{
Variant: button.VariantGhost,
Class: "h-8 px-2",
Href: routeurl.URL("page.app.spaces.space.accounts.account.investments.holdings.holding", "spaceID", props.SpaceID, "accountID", props.AccountID, "holdingID", pos.Holding.ID),
}) {
@icon.ChevronRight()
}
</td>
</tr>
}
</tbody>
</table>
</div>
}
}
}
</div>
}
func remainingValue(s *model.InvestmentAccountSummary) decimal.Decimal {
if s.RoomRemaining == nil {
return decimal.Zero
}
return *s.RoomRemaining
}

View file

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

View file

@ -0,0 +1,213 @@
package pages
import (
"fmt"
"time"
"git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/routeurl"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/badge"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/button"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/card"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/form"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/icon"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/input"
"git.juancwu.dev/juancwu/budgit/internal/ui/layouts"
"git.juancwu.dev/juancwu/budgit/internal/ui/utils"
)
type InvestmentHoldingDetailProps struct {
SpaceID string
SpaceName string
AccountID string
AccountName string
Currency string
Position model.HoldingPosition
Trades []*model.InvestmentTrade
}
func holdingPlClass(d string) string {
if len(d) > 0 && d[0] == '-' {
return "text-red-600 dark:text-red-400"
}
return "text-green-600 dark:text-green-400"
}
templ InvestmentHoldingDetailPage(props InvestmentHoldingDetailProps) {
@layouts.AppWithBreadcrumb(
props.Position.Holding.Symbol,
accountChildBreadcrumb(props.SpaceID, props.SpaceName, props.AccountID, props.AccountName, props.Position.Holding.Symbol),
spaceOverviewSidebarContent(),
spaceSpecificSidebarContent(props.SpaceID),
spaceAccountSidebarContent(props.SpaceID, props.AccountID),
) {
<div class="container px-6 py-8 mx-auto space-y-8">
<div class="flex items-start justify-between gap-3 flex-wrap">
<div>
<h1 class="text-3xl font-bold flex items-center gap-3">
{ props.Position.Holding.Symbol }
@badge.Badge(badge.Props{Variant: badge.VariantSecondary}) {
{ props.Currency }
}
</h1>
<p class="text-muted-foreground">{ props.Position.Holding.DisplayName }</p>
</div>
<form
hx-post={ routeurl.URL("action.app.spaces.space.accounts.account.investments.holdings.holding.delete", "spaceID", props.SpaceID, "accountID", props.AccountID, "holdingID", props.Position.Holding.ID) }
hx-confirm="Delete this holding and all its trades?"
>
@button.Button(button.Props{
Type: button.TypeSubmit,
Variant: button.VariantDestructive,
Class: "flex items-center gap-2",
}) {
@icon.Trash2()
Delete holding
}
</form>
</div>
@card.Card(card.Props{Class: "rounded-sm"}) {
@card.Content(card.ContentProps{Class: "grid grid-cols-2 md:grid-cols-5 gap-4 text-sm p-4"}) {
<div>
<div class="text-muted-foreground">Quantity</div>
<div class="text-lg font-semibold">{ props.Position.Quantity.StringFixedBank(4) }</div>
</div>
<div>
<div class="text-muted-foreground">Avg cost</div>
<div class="text-lg font-semibold">${ utils.FormatDecimalWithThousands(props.Position.AvgCost.StringFixedBank(2)) }</div>
</div>
<div>
<div class="text-muted-foreground">Cost basis</div>
<div class="text-lg font-semibold">${ utils.FormatDecimalWithThousands(props.Position.CostBasis.StringFixedBank(2)) }</div>
</div>
<div>
<div class="text-muted-foreground">Realized P/L</div>
<div class={ "text-lg font-semibold", holdingPlClass(props.Position.RealizedPL.StringFixedBank(2)) }>
${ utils.FormatDecimalWithThousands(props.Position.RealizedPL.StringFixedBank(2)) }
</div>
</div>
<div>
<div class="text-muted-foreground">Total fees</div>
<div class="text-lg font-semibold">${ utils.FormatDecimalWithThousands(props.Position.TotalFees.StringFixedBank(2)) }</div>
</div>
}
}
@card.Card(card.Props{Class: "rounded-sm"}) {
@card.Header() {
@card.Title() {
Record a trade
}
@card.Description() {
Manually enter a buy or sell. Quantities and prices recompute the position.
}
}
@card.Content() {
<form
hx-post={ routeurl.URL("action.app.spaces.space.accounts.account.investments.holdings.holding.trades.create", "spaceID", props.SpaceID, "accountID", props.AccountID, "holdingID", props.Position.Holding.ID) }
class="grid grid-cols-1 md:grid-cols-6 gap-3 items-end"
>
@form.Item() {
@form.Label(form.LabelProps{For: "type"}) { Type }
<select id="type" name="type" class="h-9 rounded-sm border bg-transparent px-3 text-sm">
<option value="buy">Buy</option>
<option value="sell">Sell</option>
</select>
}
@form.Item() {
@form.Label(form.LabelProps{For: "quantity"}) { Quantity }
@input.Input(input.Props{ID: "quantity", Name: "quantity", Type: input.TypeText, Placeholder: "0", Class: "rounded-sm", Required: true})
}
@form.Item() {
@form.Label(form.LabelProps{For: "price"}) { Price / unit }
@input.Input(input.Props{ID: "price", Name: "price", Type: input.TypeText, Placeholder: "0.00", Class: "rounded-sm", Required: true})
}
@form.Item() {
@form.Label(form.LabelProps{For: "fees"}) { Fees }
@input.Input(input.Props{ID: "fees", Name: "fees", Type: input.TypeText, Placeholder: "0.00", Class: "rounded-sm"})
}
@form.Item() {
@form.Label(form.LabelProps{For: "occurred_at"}) { Date }
@input.Input(input.Props{ID: "occurred_at", Name: "occurred_at", Type: input.TypeDate, Value: time.Now().Format("2006-01-02"), Class: "rounded-sm"})
}
@button.Button(button.Props{Type: button.TypeSubmit, Class: "rounded-sm"}) {
Record
}
</form>
}
}
@card.Card(card.Props{Class: "rounded-sm"}) {
@card.Header() {
@card.Title() { Trade history }
}
@card.Content() {
if len(props.Trades) == 0 {
<p class="text-sm text-muted-foreground">No trades recorded yet.</p>
} else {
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="text-left text-muted-foreground border-b">
<tr>
<th class="py-2 pr-2">Date</th>
<th class="py-2 pr-2">Type</th>
<th class="py-2 pr-2">Quantity</th>
<th class="py-2 pr-2">Price / unit</th>
<th class="py-2 pr-2">Fees</th>
<th class="py-2 pr-2">Total</th>
<th class="py-2"></th>
</tr>
</thead>
<tbody>
for _, t := range props.Trades {
{{
fees := "—"
if t.Fees != nil {
fees = t.Fees.StringFixedBank(2)
}
total := t.Quantity.Mul(t.PricePerUnit)
typeLabel := "Buy"
if string(t.Type) == "sell" {
typeLabel = "Sell"
}
}}
<tr class="border-b last:border-b-0">
<td class="py-2 pr-2">{ t.OccurredAt.Format("2006-01-02") }</td>
<td class="py-2 pr-2">
@badge.Badge(badge.Props{
Variant: badge.VariantSecondary,
Class: "text-xs",
}) {
{ typeLabel }
}
</td>
<td class="py-2 pr-2">{ t.Quantity.StringFixedBank(4) }</td>
<td class="py-2 pr-2">${ utils.FormatDecimalWithThousands(t.PricePerUnit.StringFixedBank(2)) }</td>
<td class="py-2 pr-2">{ fees }</td>
<td class="py-2 pr-2">${ utils.FormatDecimalWithThousands(total.StringFixedBank(2)) }</td>
<td class="py-2 text-right">
<form
hx-post={ routeurl.URL("action.app.spaces.space.accounts.account.investments.holdings.holding.trades.trade.delete", "spaceID", props.SpaceID, "accountID", props.AccountID, "holdingID", props.Position.Holding.ID, "tradeID", t.ID) }
hx-confirm="Delete this trade?"
class="inline"
>
@button.Button(button.Props{
Type: button.TypeSubmit,
Variant: button.VariantGhost,
Class: "h-8 px-2",
}) {
@icon.Trash2()
}
</form>
</td>
</tr>
}
</tbody>
</table>
</div>
}
}
}
</div>
}
}
var _ = fmt.Sprintf

View file

@ -0,0 +1,88 @@
package pages
import (
"git.juancwu.dev/juancwu/budgit/internal/routeurl"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/button"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/card"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/form"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/input"
"git.juancwu.dev/juancwu/budgit/internal/ui/layouts"
)
type InvestmentHoldingFormPageProps struct {
SpaceID string
SpaceName string
AccountID string
AccountName string
}
templ InvestmentHoldingFormPage(props InvestmentHoldingFormPageProps) {
@layouts.AppWithBreadcrumb(
"Add holding",
accountChildBreadcrumb(props.SpaceID, props.SpaceName, props.AccountID, props.AccountName, "Add holding"),
spaceOverviewSidebarContent(),
spaceSpecificSidebarContent(props.SpaceID),
spaceAccountSidebarContent(props.SpaceID, props.AccountID),
) {
<div class="container max-w-3xl px-6 py-8 mx-auto space-y-8">
<div>
<h1 class="text-3xl font-bold">Add holding</h1>
<p class="text-muted-foreground mt-2">
Track a symbol inside { props.AccountName }. You can record buy/sell trades after creating it.
</p>
</div>
<form hx-post={ routeurl.URL("action.app.spaces.space.accounts.account.investments.holdings.create", "spaceID", props.SpaceID, "accountID", props.AccountID) }>
@card.Card(card.Props{Class: "rounded-sm"}) {
@card.Content(card.ContentProps{Class: "p-4 space-y-4"}) {
@form.Item() {
@form.Label(form.LabelProps{For: "symbol"}) {
Symbol
}
@input.Input(input.Props{
ID: "symbol",
Name: "symbol",
Type: input.TypeText,
Placeholder: "e.g. VFV.TO",
Class: "rounded-sm uppercase",
Required: true,
Attributes: templ.Attributes{
"autocomplete": "off",
"autofocus": "",
},
})
@form.Description() {
Use the broker's symbol exactly (e.g. VFV.TO for TSX, AAPL for NASDAQ).
}
}
@form.Item() {
@form.Label(form.LabelProps{For: "display_name"}) {
Display name
}
@input.Input(input.Props{
ID: "display_name",
Name: "display_name",
Type: input.TypeText,
Placeholder: "e.g. Vanguard S&P 500 Index ETF",
Class: "rounded-sm",
})
@form.Description() {
Optional. Falls back to the symbol if left empty.
}
}
}
@card.Footer(card.FooterProps{Class: "flex justify-end gap-2"}) {
@button.Button(button.Props{
Variant: button.VariantGhost,
Href: routeurl.URL("page.app.spaces.space.accounts.account.overview", "spaceID", props.SpaceID, "accountID", props.AccountID),
}) {
Cancel
}
@button.Button(button.Props{Type: button.TypeSubmit}) {
Add holding
}
}
}
</form>
</div>
}
}

View file

@ -0,0 +1,131 @@
package pages
import (
"fmt"
"git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/routeurl"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/badge"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/button"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/card"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/icon"
"git.juancwu.dev/juancwu/budgit/internal/ui/layouts"
"git.juancwu.dev/juancwu/budgit/internal/ui/utils"
)
type InvestmentOverviewRow struct {
SpaceID string
SpaceName string
AccountID string
AccountName string
Currency string
Summary *model.InvestmentAccountSummary
}
type InvestmentsOverviewProps struct {
Year int
Rows []InvestmentOverviewRow
}
templ InvestmentsOverviewPage(props InvestmentsOverviewProps) {
@layouts.App("Investments", spaceOverviewSidebarContent()) {
<div class="container px-6 py-8 mx-auto space-y-6">
<div class="flex items-start justify-between gap-3 flex-wrap">
<div>
<h1 class="text-3xl font-bold">Investments</h1>
<p class="text-muted-foreground mt-1">
{ fmt.Sprintf("%d contribution rooms, YTD activity, and total cost basis across your investment accounts.", props.Year) }
</p>
</div>
</div>
if len(props.Rows) == 0 {
@card.Card(card.Props{Class: "rounded-sm"}) {
@card.Content(card.ContentProps{Class: "p-6 space-y-3"}) {
<p class="text-sm text-muted-foreground">
You don't have any investment accounts yet. In any space, create a new account and check
<strong>Investment account</strong> to start tracking contributions and holdings.
</p>
}
}
} else {
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
for _, row := range props.Rows {
{{
subtypeLabel := ""
if row.Summary != nil && row.Summary.Account != nil && row.Summary.Account.InvestmentSubtype != nil {
subtypeLabel = upperString(*row.Summary.Account.InvestmentSubtype)
}
}}
@card.Card(card.Props{Class: "rounded-sm"}) {
@card.Header() {
<div class="flex items-start justify-between gap-2">
<div>
@card.Title() {
<div class="flex items-center gap-2">
{ row.AccountName }
if subtypeLabel != "" {
@badge.Badge(badge.Props{Variant: badge.VariantSecondary, Class: "text-xs"}) {
{ subtypeLabel }
}
}
@badge.Badge(badge.Props{Variant: badge.VariantOutline, Class: "text-xs"}) {
{ row.Currency }
}
</div>
}
@card.Description() {
{ row.SpaceName }
}
</div>
@button.Button(button.Props{
Variant: button.VariantGhost,
Class: "h-8 px-2",
Href: routeurl.URL("page.app.spaces.space.accounts.account.overview", "spaceID", row.SpaceID, "accountID", row.AccountID),
}) {
@icon.ChevronRight()
}
</div>
}
@card.Content(card.ContentProps{Class: "grid grid-cols-2 gap-3 text-sm"}) {
<div>
<div class="text-muted-foreground">YTD Contributions</div>
<div class="font-semibold">${ utils.FormatDecimalWithThousands(row.Summary.YTDContributions.StringFixedBank(2)) }</div>
</div>
<div>
<div class="text-muted-foreground">YTD Withdrawals</div>
<div class="font-semibold">${ utils.FormatDecimalWithThousands(row.Summary.YTDWithdrawals.StringFixedBank(2)) }</div>
</div>
<div>
<div class="text-muted-foreground">Room remaining</div>
<div class="font-semibold">
if row.Summary.RoomRemaining != nil {
${ utils.FormatDecimalWithThousands(row.Summary.RoomRemaining.StringFixedBank(2)) }
} else {
<span class="text-muted-foreground italic">Room not set</span>
}
</div>
</div>
<div>
<div class="text-muted-foreground">Total cost basis</div>
<div class="font-semibold">${ utils.FormatDecimalWithThousands(row.Summary.TotalCostBasis.StringFixedBank(2)) }</div>
</div>
}
}
}
</div>
}
</div>
}
}
func upperString(s string) string {
out := make([]byte, len(s))
for i := 0; i < len(s); i++ {
c := s[i]
if c >= 'a' && c <= 'z' {
c -= 32
}
out[i] = c
}
return string(out)
}

View file

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

View file

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

View file

@ -99,6 +99,16 @@ templ spaceOverviewSidebarContent() {
<span>Shared with me</span>
}
}
@sidebar.MenuItem() {
@sidebar.MenuButton(sidebar.MenuButtonProps{
Href: routeurl.URL("page.app.investments"),
IsActive: ctxkeys.URLPath(ctx) == routeurl.URL("page.app.investments"),
Tooltip: "Investments",
}) {
@icon.TrendingUp()
<span>Investments</span>
}
}
}
}
}