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

@ -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>
@blocks.AllocationsSection(blocks.AllocationsSectionProps{
SpaceID: props.SpaceID,
AccountID: props.AccountID,
Summary: props.AllocationSummary,
})
if props.InvestmentSummary != nil {
@blocks.InvestmentSection(blocks.InvestmentSectionProps{
SpaceID: props.SpaceID,
AccountID: props.AccountID,
Currency: props.AccountCurrency,
Summary: props.InvestmentSummary,
Positions: props.InvestmentPositions,
})
} else {
@blocks.AllocationsSection(blocks.AllocationsSectionProps{
SpaceID: props.SpaceID,
AccountID: props.AccountID,
Summary: props.AllocationSummary,
})
}
<div>
@card.Card() {
@card.Header() {

View file

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

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