Gonçalo Rodrigues 42f5b0df4d feat: phase 4 — net worth page + dashboard card
Adds /networth page with hero total, cash/portfolio breakdown cards,
and a month-by-month historical chart. Also shows net worth as a
value card on the dashboard with a link to the full breakdown.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 16:52:57 +01:00

1677 lines
44 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package main
import (
"embed"
"encoding/json"
"fmt"
"html/template"
"io"
"log/slog"
"math"
"net/http"
"sort"
"strings"
"time"
"go.mongodb.org/mongo-driver/v2/bson"
)
//go:embed templates/*.html
var templateFS embed.FS
func parseTmpl(files ...string) *template.Template {
return template.Must(template.New("").Funcs(template.FuncMap{
"cents": func(c int64) string {
sign := ""
val := c
if val < 0 {
sign = "-"
val = -val
}
eur := val / 100
cent := val % 100
return fmt.Sprintf("%s%d.%02d", sign, eur, cent)
},
"centsAbs": func(c int64) int64 {
if c < 0 {
return -c
}
return c
},
"pctSign": func(f float64) string {
if f >= 0 {
return "+"
}
return ""
},
"dateShort": func(t time.Time) string {
return t.Format("02 Jan 2006")
},
"sub": func(a, b int64) int64 {
return a - b
},
"div": func(a, b int64) float64 {
if b == 0 {
return 0
}
return float64(a) / float64(b)
},
"jsonKeys": func(m map[string]int64) string {
var keys []string
for k := range m {
keys = append(keys, fmt.Sprintf("%q", k))
}
return "[" + strings.Join(keys, ",") + "]"
},
"abs": func(v int64) int64 {
if v < 0 {
return -v
}
return v
},
"add": func(a, b int64) int64 {
return a + b
},
"mul": func(a, b float64) float64 {
return a * b
},
"round": func(f float64) float64 {
return math.Round(f)
},
"clampPct": func(spent, budget int64) int64 {
if budget <= 0 {
return 0
}
pct := int64(float64(spent) / float64(budget) * 100)
if pct > 100 {
return 100
}
if pct < 0 {
return 0
}
return pct
},
"isOver": func(spent, budget int64) bool {
return budget > 0 && spent > budget
},
"jsonVals": func(m map[string]int64) template.JS {
var vals []string
for _, v := range m {
vals = append(vals, fmt.Sprintf("%d", v))
}
return template.JS("[" + strings.Join(vals, ",") + "]")
},
}).ParseFS(templateFS, files...))
}
var (
baseTmpl = parseTmpl("templates/base.html")
dashboardTmpl = parseTmpl("templates/base.html", "templates/dashboard.html")
txnsTmpl = parseTmpl("templates/base.html", "templates/transactions.html")
importTmpl = parseTmpl("templates/base.html", "templates/import.html")
accountsTmpl = parseTmpl("templates/base.html", "templates/accounts.html")
categoriesTmpl = parseTmpl("templates/base.html", "templates/categories.html")
reportsTmpl = parseTmpl("templates/base.html", "templates/reports.html")
projectionsTmpl = parseTmpl("templates/base.html", "templates/projections.html")
portfolioTmpl = parseTmpl("templates/base.html", "templates/portfolio.html")
sharingTmpl = parseTmpl("templates/base.html", "templates/sharing.html")
goalsTmpl = parseTmpl("templates/base.html", "templates/goals.html")
networthTmpl = parseTmpl("templates/base.html", "templates/networth.html")
)
type authInfo struct {
UserID string
Email string
Roles string
}
func getAuth(r *http.Request) authInfo {
return authInfo{
UserID: r.Header.Get("X-Auth-User-Id"),
Email: r.Header.Get("X-Auth-Email"),
Roles: r.Header.Get("X-Auth-Roles"),
}
}
type userError struct {
Msg string
Status int
}
func (e *userError) Error() string {
return e.Msg
}
func render(w http.ResponseWriter, tmpl *template.Template, data interface{}) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := tmpl.ExecuteTemplate(w, "base.html", data); err != nil {
slog.Error("template error", "err", err)
}
}
type Handler struct {
store *Store
}
func NewHandler(store *Store) *Handler {
return &Handler{store: store}
}
func (h *Handler) authMW(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
a := getAuth(r)
if a.UserID == "" {
http.Redirect(w, r, "https://auth.homelab.local/login", http.StatusFound)
return
}
next(w, r)
}
}
func (h *Handler) ownerOrViewerMW(next http.HandlerFunc) http.HandlerFunc {
return h.authMW(func(w http.ResponseWriter, r *http.Request) {
a := getAuth(r)
ownerID := r.PathValue("user_id")
if ownerID == "" {
ownerID = a.UserID
}
if ownerID == a.UserID {
next(w, r)
return
}
perms, err := h.store.getPermissions(r.Context(), ownerID)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
for _, p := range perms {
if p.ViewerID == a.UserID {
next(w, r)
return
}
}
render(w, baseTmpl, map[string]interface{}{
"UserID": a.UserID,
"Email": a.Email,
"Title": "Access Denied",
"Content": template.HTML(`<div class="error-page"><h1>403 - Access Denied</h1><p>You do not have permission to view this user's finances.</p></div>`),
})
})
}
func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
a := getAuth(r)
now := time.Now()
thisStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location())
lastStart := thisStart.AddDate(0, -1, 0)
threeMonthsAgo := thisStart.AddDate(0, -3, 0)
txns, err := h.store.getTransactions(ctx, a.UserID, bson.M{})
if err != nil {
slog.Error("get transactions", "err", err)
render(w, dashboardTmpl, &DashboardData{UserID: a.UserID, Email: a.Email, Title: "Dashboard", Route: "dashboard", IsOwner: true})
return
}
cats, err := h.store.getCategories(ctx, a.UserID)
if err != nil {
slog.Error("get categories", "err", err)
}
catColors := make(map[string]string)
catBudgets := make(map[string]int64)
catNames := make(map[string]string)
for _, c := range cats {
catNames[c.Name] = c.Name
catColors[c.Name] = c.Color
// exclude fixed categories from budget health — they're committed costs, not variable spend
if c.BudgetCents > 0 && !FixedCategories[c.Name] {
catBudgets[c.Name] = c.BudgetCents
}
}
thisMonth := &PeriodSummary{ByCategory: make(map[string]int64), CategoryNames: catNames}
lastMonth := &PeriodSummary{ByCategory: make(map[string]int64), CategoryNames: catNames}
// fixed spending by category over the last 3 months (for recurring detection)
fixedByMonth := make(map[string]map[int]int64) // category -> month-offset -> total
var recent []Transaction
var balPoints []BalancePoint
balByDate := make(map[string]int64)
var balDates []string
for _, t := range txns {
isThisMonth := !t.Date.Before(thisStart)
isLastMonth := !t.Date.Before(lastStart) && t.Date.Before(thisStart)
isRecent3 := !t.Date.Before(threeMonthsAgo) && t.Date.Before(thisStart)
if isThisMonth {
thisMonth.TotalCents += t.AmountCents
thisMonth.ByCategory[t.Category] += t.AmountCents
} else if isLastMonth {
lastMonth.TotalCents += t.AmountCents
lastMonth.ByCategory[t.Category] += t.AmountCents
}
// accumulate fixed category spending over last 3 months
if isRecent3 && FixedCategories[t.Category] && t.AmountCents < 0 {
mo := int(t.Date.Month())
if fixedByMonth[t.Category] == nil {
fixedByMonth[t.Category] = make(map[int]int64)
}
fixedByMonth[t.Category][mo] += -t.AmountCents
}
if len(recent) < 5 {
recent = append(recent, t)
}
day := t.Date.Format("2006-01-02")
balByDate[day] += t.AmountCents
balDates = appendIfMissing(balDates, day)
}
sortStrings(balDates)
running := int64(0)
for _, d := range balDates {
running += balByDate[d]
parsed, _ := time.Parse("2006-01-02", d)
balPoints = append(balPoints, BalancePoint{Date: parsed, Cents: running})
}
if len(balPoints) > 90 {
balPoints = balPoints[len(balPoints)-90:]
}
// income / expense split
thisMonthIncome := int64(0)
thisMonthExpense := int64(0)
for _, amt := range thisMonth.ByCategory {
if amt > 0 {
thisMonthIncome += amt
} else {
thisMonthExpense += -amt
}
}
lastMonthIncome := int64(0)
lastMonthSavings := int64(0)
for _, amt := range lastMonth.ByCategory {
if amt > 0 {
lastMonthIncome += amt
}
}
lastMonthSavings = lastMonth.TotalCents
if lastMonthSavings < 0 {
lastMonthSavings = 0
}
// detect recurring fixed expenses (average over last 3 months)
var recurringExpenses []RecurringExpense
totalFixedCents := int64(0)
for cat, byMonth := range fixedByMonth {
total := int64(0)
for _, v := range byMonth {
total += v
}
avg := total / int64(len(byMonth))
recurringExpenses = append(recurringExpenses, RecurringExpense{Category: cat, MonthlyCents: avg})
totalFixedCents += avg
}
sort.Slice(recurringExpenses, func(i, j int) bool {
return recurringExpenses[i].MonthlyCents > recurringExpenses[j].MonthlyCents
})
// disposable income = income - fixed recurring
disposableIncome := thisMonthIncome - totalFixedCents
// deduct committed goal contributions from disposable and add to fixed costs list
committedGoalsCents := int64(0)
if goals, err := h.store.getGoals(ctx, a.UserID); err == nil {
now2 := time.Now()
for _, g := range goals {
if !g.Committed {
continue
}
remaining := g.TargetCents - g.SavedCents
if remaining <= 0 {
continue
}
ml := int64(monthsBetween(now2, g.Deadline))
if ml < 1 {
ml = 1
}
monthly := remaining / ml
committedGoalsCents += monthly
recurringExpenses = append(recurringExpenses, RecurringExpense{
Category: g.Name,
MonthlyCents: monthly,
IsGoal: true,
})
}
}
disposableIncome -= committedGoalsCents
totalCommittedCents := totalFixedCents + committedGoalsCents
// variable spend so far this month (non-fixed categories, expenses only)
variableSpent := int64(0)
for cat, amt := range thisMonth.ByCategory {
if !FixedCategories[cat] && amt < 0 {
variableSpent += -amt
}
}
availableToSpend := disposableIncome - variableSpent
if availableToSpend < 0 {
availableToSpend = 0
}
// month progress
daysInMonth := time.Date(now.Year(), now.Month()+1, 0, 0, 0, 0, 0, now.Location()).Day()
monthProgressPct := int(float64(now.Day()) / float64(daysInMonth) * 100)
// % of disposable already spent
monthSpentPct := 0
if disposableIncome > 0 {
monthSpentPct = int(float64(variableSpent) / float64(disposableIncome) * 100)
if monthSpentPct > 100 {
monthSpentPct = 100
}
}
// safety buffer = 2 weeks of average daily variable spend over last month
lastMonthVariableSpent := int64(0)
for cat, amt := range lastMonth.ByCategory {
if !FixedCategories[cat] && amt < 0 {
lastMonthVariableSpent += -amt
}
}
safetyBuffer := lastMonthVariableSpent / 2
// bank should be = upcoming fixed costs (not yet paid this month) + safety buffer
fixedPaidThisMonth := int64(0)
for cat, amt := range thisMonth.ByCategory {
if FixedCategories[cat] && amt < 0 {
fixedPaidThisMonth += -amt
}
}
bankShouldBe := (totalFixedCents - fixedPaidThisMonth) + safetyBuffer
// savings rate
savingsRatePct := 0
if thisMonthIncome > 0 {
saved := thisMonthIncome - thisMonthExpense
if saved > 0 {
savingsRatePct = int(float64(saved) / float64(thisMonthIncome) * 100)
}
}
lastMonthSavingsRatePct := 0
if lastMonthIncome > 0 && lastMonthSavings > 0 {
lastMonthSavingsRatePct = int(float64(lastMonthSavings) / float64(lastMonthIncome) * 100)
}
// portfolio snapshot — degrade to cost basis if live prices are unavailable
var portfolioValueCents, portfolioPCLCents int64
var portfolioHoldings []Holding
var portfolioPricesAvailable bool
if trades, err := h.store.getTrades(ctx, a.UserID); err == nil && len(trades) > 0 {
prices, _ := fetchPricesByISIN(uniqueISINs(trades))
holdings := computeHoldings(trades, prices)
pr := aggregatePortfolio(holdings)
portfolioHoldings = pr.Holdings
// check whether any prices came back
for _, p := range prices {
if p > 0 {
portfolioPricesAvailable = true
break
}
}
if portfolioPricesAvailable {
portfolioValueCents = pr.TotalVal
portfolioPCLCents = pr.TotalPCL
} else {
// fall back to cost basis so the card is still useful
portfolioValueCents = pr.TotalCost
}
}
render(w, dashboardTmpl, &DashboardData{
UserID: a.UserID,
Email: a.Email,
Title: "Dashboard",
Route: "dashboard",
IsOwner: true,
ThisMonth: thisMonth,
LastMonth: lastMonth,
RecentTxns: recent,
BalanceTrend: balPoints,
ThisMonthIncome: thisMonthIncome,
ThisMonthExpense: thisMonthExpense,
CategoryBudgets: catBudgets,
CategoryColors: catColors,
AvailableToSpend: availableToSpend,
DisposableIncome: disposableIncome,
MonthProgressPct: monthProgressPct,
MonthSpentPct: monthSpentPct,
RecurringExpenses: recurringExpenses,
BankShouldBe: bankShouldBe,
SafetyBufferCents: safetyBuffer,
TotalCommittedCents: totalCommittedCents,
SavingsRatePct: savingsRatePct,
LastMonthSavingsRatePct: lastMonthSavingsRatePct,
PortfolioValueCents: portfolioValueCents,
PortfolioPCLCents: portfolioPCLCents,
PortfolioHoldings: portfolioHoldings,
PortfolioPricesAvailable: portfolioPricesAvailable,
NetWorthCents: portfolioValueCents + running,
})
}
func (h *Handler) Transactions(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
a := getAuth(r)
var filter bson.M
cat := r.URL.Query().Get("category")
search := r.URL.Query().Get("search")
daysStr := r.URL.Query().Get("days")
if cat != "" {
filter = bson.M{"category": cat}
}
if daysStr != "" {
days := 30
fmt.Sscanf(daysStr, "%d", &days)
since := time.Now().AddDate(0, 0, -days)
if filter == nil {
filter = bson.M{}
}
filter["date"] = bson.M{"$gte": since}
}
txns, err := h.store.getTransactions(ctx, a.UserID, filter)
if err != nil {
slog.Error("get transactions", "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if search != "" {
search = strings.ToLower(search)
var filtered []Transaction
for _, t := range txns {
if strings.Contains(strings.ToLower(t.Description), search) {
filtered = append(filtered, t)
}
}
txns = filtered
}
cats, _ := h.store.getCategories(ctx, a.UserID)
accounts, _ := h.store.getAccounts(ctx, a.UserID)
accountNames := make(map[string]string)
for _, acc := range accounts {
accountNames[acc.ID] = acc.Name
}
catColors := make(map[string]string)
for _, c := range cats {
catColors[c.Name] = c.Color
}
render(w, txnsTmpl, map[string]interface{}{
"UserID": a.UserID,
"Email": a.Email,
"Title": "Transactions",
"Route": "transactions",
"IsOwner": true,
"Txns": txns,
"Categories": cats,
"Accounts": accounts,
"AccountNames": accountNames,
"CategoryColors": catColors,
"Cat": cat,
"Search": search,
"Days": daysStr,
})
}
func (h *Handler) CreateTransaction(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
a := getAuth(r)
var body struct {
AccountID string `json:"account_id"`
Date string `json:"date"`
Description string `json:"description"`
AmountCents int64 `json:"amount_cents"`
Category string `json:"category"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
date, err := time.Parse("2006-01-02", body.Date)
if err != nil {
date = time.Now()
}
txn := Transaction{
ID: bson.NewObjectID().Hex(),
UserID: a.UserID,
AccountID: body.AccountID,
Date: date,
Description: body.Description,
AmountCents: body.AmountCents,
Category: body.Category,
CreatedAt: time.Now(),
}
if err := h.store.createTransactions(ctx, []Transaction{txn}); err != nil {
slog.Error("create transaction", "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(txn)
}
func (h *Handler) ImportPage(w http.ResponseWriter, r *http.Request) {
a := getAuth(r)
accounts, _ := h.store.getAccounts(r.Context(), a.UserID)
render(w, importTmpl, map[string]interface{}{
"UserID": a.UserID,
"Email": a.Email,
"Title": "Import",
"Route": "import",
"IsOwner": true,
"Accounts": accounts,
"Preview": nil,
})
}
func (h *Handler) ImportPreview(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
a := getAuth(r)
if err := r.ParseMultipartForm(32 << 20); err != nil {
slog.Error("import preview multipart",
"err", err,
"content-type", r.Header.Get("Content-Type"),
"content-length", r.ContentLength,
)
http.Error(w, "bad request", http.StatusBadRequest)
return
}
accountID := r.FormValue("account_id")
format := CSVFormat(r.FormValue("format"))
file, _, err := r.FormFile("file")
if err != nil {
http.Error(w, "missing file", http.StatusBadRequest)
return
}
defer file.Close()
data, err := io.ReadAll(file)
if err != nil {
http.Error(w, "read file error", http.StatusInternalServerError)
return
}
var mapping CSVColumnMapping
switch format {
case FormatCGD:
mapping = CGDMapping
case FormatTradeRepublic:
mapping = TradeRepublicMapping
default:
mapping = GenericMapping(data)
}
rows, err := parseCSV(strings.NewReader(string(data)), mapping)
if err != nil {
accounts, _ := h.store.getAccounts(ctx, a.UserID)
render(w, importTmpl, map[string]interface{}{
"UserID": a.UserID,
"Email": a.Email,
"Title": "Import",
"Route": "import",
"IsOwner": true,
"Accounts": accounts,
"Error": err.Error(),
})
return
}
accounts, _ := h.store.getAccounts(ctx, a.UserID)
cats, _ := h.store.getCategories(ctx, a.UserID)
var catList []string
catMap := make(map[string]string)
catColors := make(map[string]string)
if len(cats) == 0 {
catList = DefaultCategories
for _, name := range DefaultCategories {
catMap[strings.ToLower(name)] = name
if c, ok := DefaultCategoryColors[name]; ok {
catColors[name] = c
}
}
} else {
for _, c := range cats {
catMap[strings.ToLower(c.Name)] = c.Name
catList = append(catList, c.Name)
if c.Color != "" {
catColors[c.Name] = c.Color
}
}
}
for i := range rows {
rows[i].Category = autoCategorize(rows[i].Description, catMap)
}
importPreview := &CSVImportPreview{
AccountID: accountID,
Rows: rows,
Total: len(rows),
}
render(w, importTmpl, map[string]interface{}{
"UserID": a.UserID,
"Email": a.Email,
"Title": "Import",
"Route": "import",
"IsOwner": true,
"Accounts": accounts,
"Preview": importPreview,
"Categories": catList,
"RawData": string(data),
"SelectedFormat": string(format),
"SelectedAccount": accountID,
"CategoryColors": catColors,
})
}
func GenericMapping(data []byte) CSVColumnMapping {
return CSVColumnMapping{
DateCol: 0,
DescriptionCol: 1,
AmountCol: 2,
TypeCol: -1,
HasHeader: true,
DateFormat: "2006-01-02",
DecimalSep: ".",
}
}
func (h *Handler) ImportConfirm(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
a := getAuth(r)
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
accountID := r.FormValue("account_id")
format := CSVFormat(r.FormValue("format"))
rawData := r.FormValue("raw_data")
var mapping CSVColumnMapping
switch format {
case FormatCGD:
mapping = CGDMapping
case FormatTradeRepublic:
mapping = TradeRepublicMapping
default:
mapping = GenericMapping([]byte(rawData))
}
rows, err := parseCSV(strings.NewReader(rawData), mapping)
if err != nil {
http.Error(w, "parse error: "+err.Error(), http.StatusBadRequest)
return
}
userCats := r.Form["categories"]
now := time.Now()
var txns []Transaction
for i, row := range rows {
date, _ := time.Parse("2006-01-02", row.Date)
cat := "Others"
if i < len(userCats) && userCats[i] != "" {
cat = userCats[i]
}
txns = append(txns, Transaction{
ID: bson.NewObjectID().Hex(),
UserID: a.UserID,
AccountID: accountID,
Date: date,
Description: row.Description,
AmountCents: row.AmountCents,
Category: cat,
CreatedAt: now,
})
}
if err := h.store.createTransactions(ctx, txns); err != nil {
slog.Error("create transactions", "err", err)
http.Error(w, "save error", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/transactions", http.StatusSeeOther)
}
func autoCategorize(desc string, catMap map[string]string) string {
desc = strings.ToLower(desc)
keywords := map[string]string{
"supermercado": "Groceries", "mercado": "Groceries", "pingo": "Groceries",
"continente": "Groceries", "lidl": "Groceries", "aldi": "Groceries",
"auchan": "Groceries", "el corte": "Groceries", "jumbo": "Groceries",
"restaurante": "Food", "restaurant": "Food", "cafetaria": "Food",
"padaria": "Food", "pastelaria": "Food", "pizza": "Food",
"mcdonald": "Food", "burger": "Food", "kfc": "Food",
"steam": "Games", "playstation": "Games", "nintendo": "Games",
"xbox": "Games", "epic games": "Games", "gog": "Games",
"uber": "Transport", "bolt": "Transport", "metro": "Transport",
"cp -": "Transport", "combust": "Transport", "gasolina": "Transport",
"electric": "Transport", "parking": "Transport", "portagens": "Transport",
"via verde": "Transport",
"renda": "Housing", "condom": "Housing", "agua": "Housing",
"edp": "Utilities", "meo": "Utilities", "vodafone": "Utilities",
"nos": "Utilities", "internet": "Utilities", "telecom": "Utilities",
"farmacia": "Health", "hospital": "Health", "medico": "Health",
"dentista": "Health", "seguro": "Health",
"zara": "Clothing", "hm": "Clothing", "nike": "Clothing",
"adidas": "Clothing", "primark": "Clothing",
"salario": "Income", "wage": "Income", "salary": "Income",
"pension": "Income", "rendimento": "Income",
"trade republic": "Investments", "etf": "Investments", "degiro": "Investments",
"xbox game pass": "Games",
}
for kw, cat := range keywords {
if strings.Contains(desc, kw) {
if name, ok := catMap[strings.ToLower(cat)]; ok {
return name
}
return cat
}
}
for name := range catMap {
if strings.Contains(desc, name) {
return catMap[name]
}
}
if name, ok := catMap["other"]; ok {
return name
}
return "Others"
}
func (h *Handler) UpdateTransaction(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
a := getAuth(r)
id := r.PathValue("id")
var body struct {
Category string `json:"category"`
Description string `json:"description"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
update := bson.M{}
if body.Category != "" {
update["category"] = body.Category
}
if body.Description != "" {
update["description"] = body.Description
}
if err := h.store.updateTransaction(ctx, id, a.UserID, update); err != nil {
slog.Error("update transaction", "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
}
func (h *Handler) DeleteTransaction(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
a := getAuth(r)
id := r.PathValue("id")
if err := h.store.deleteTransaction(ctx, id, a.UserID); err != nil {
slog.Error("delete transaction", "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *Handler) Accounts(w http.ResponseWriter, r *http.Request) {
a := getAuth(r)
switch r.Method {
case http.MethodGet:
accounts, err := h.store.getAccounts(r.Context(), a.UserID)
if err != nil {
slog.Error("get accounts", "err", err)
}
render(w, accountsTmpl, map[string]interface{}{
"UserID": a.UserID,
"Email": a.Email,
"Title": "Accounts",
"Route": "accounts",
"IsOwner": true,
"Accounts": accounts,
})
case http.MethodPost:
name := r.FormValue("name")
acctType := r.FormValue("type")
if name == "" || acctType == "" {
http.Error(w, "name and type required", http.StatusBadRequest)
return
}
acct := &Account{
ID: bson.NewObjectID().Hex(),
UserID: a.UserID,
Name: name,
Type: acctType,
}
if err := h.store.createAccount(r.Context(), acct); err != nil {
slog.Error("create account", "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/accounts", http.StatusSeeOther)
case http.MethodDelete:
id := r.PathValue("id")
if err := h.store.deleteAccount(r.Context(), id, a.UserID); err != nil {
slog.Error("delete account", "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
}
func (h *Handler) Categories(w http.ResponseWriter, r *http.Request) {
a := getAuth(r)
switch r.Method {
case http.MethodGet:
cats, err := h.store.getCategories(r.Context(), a.UserID)
if err != nil {
slog.Error("get categories", "err", err)
}
render(w, categoriesTmpl, map[string]interface{}{
"UserID": a.UserID,
"Email": a.Email,
"Title": "Categories",
"Route": "categories",
"IsOwner": true,
"Categories": cats,
})
case http.MethodPost:
name := r.FormValue("name")
color := r.FormValue("color")
if name == "" || color == "" {
http.Error(w, "name and color required", http.StatusBadRequest)
return
}
cat := &Category{
ID: bson.NewObjectID().Hex(),
UserID: a.UserID,
Name: name,
Color: color,
}
if err := h.store.createCategory(r.Context(), cat); err != nil {
slog.Error("create category", "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/categories", http.StatusSeeOther)
case http.MethodPut:
id := r.PathValue("id")
var body struct {
Name string `json:"name"`
Color string `json:"color"`
BudgetCents int64 `json:"budget_cents"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
cat := &Category{
ID: id,
UserID: a.UserID,
Name: body.Name,
Color: body.Color,
BudgetCents: body.BudgetCents,
}
if err := h.store.updateCategory(r.Context(), cat); err != nil {
slog.Error("update category", "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
case http.MethodDelete:
id := r.PathValue("id")
if err := h.store.deleteCategory(r.Context(), id, a.UserID); err != nil {
slog.Error("delete category", "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
}
func (h *Handler) Reports(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
a := getAuth(r)
txns, err := h.store.getTransactions(ctx, a.UserID, bson.M{})
if err != nil {
slog.Error("get transactions", "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
cats, _ := h.store.getCategories(ctx, a.UserID)
catNames := make(map[string]string)
catColors := make(map[string]string)
for _, c := range cats {
catNames[c.Name] = c.Name
catColors[c.Name] = c.Color
}
monthly := make(map[string]map[string]int64)
for _, t := range txns {
key := t.Date.Format("2006-01")
if monthly[key] == nil {
monthly[key] = make(map[string]int64)
}
monthly[key][t.Category] += t.AmountCents
}
now := time.Now()
var monthlyData []MonthlyCategorySummary
for i := 11; i >= 0; i-- {
m := now.AddDate(0, -i, 0)
key := m.Format("2006-01")
data := MonthlyCategorySummary{
Month: m.Format("Jan 2006"),
Totals: monthly[key],
}
if data.Totals == nil {
data.Totals = make(map[string]int64)
}
monthlyData = append(monthlyData, data)
}
render(w, reportsTmpl, map[string]interface{}{
"UserID": a.UserID,
"Email": a.Email,
"Title": "Reports",
"Route": "reports",
"IsOwner": true,
"MonthlyData": monthlyData,
"CategoryNames": catNames,
"CategoryColors": catColors,
"Year": now.Year(),
})
}
func (h *Handler) Projections(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
a := getAuth(r)
txns, err := h.store.getTransactions(ctx, a.UserID, bson.M{})
if err != nil {
slog.Error("get transactions", "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
cats, _ := h.store.getCategories(ctx, a.UserID)
catNames := make(map[string]string)
for _, c := range cats {
catNames[c.Name] = c.Name
}
now := time.Now()
sixMonthsAgo := now.AddDate(0, -6, 0)
spendByCat := make(map[string]int64)
totalSpend := int64(0)
monthCount := 0
currentMonth := ""
for _, t := range txns {
if t.Date.Before(sixMonthsAgo) || t.AmountCents >= 0 {
continue
}
m := t.Date.Format("2006-01")
if m != currentMonth {
monthCount++
currentMonth = m
}
spendByCat[t.Category] += t.AmountCents
totalSpend += t.AmountCents
}
if monthCount == 0 {
monthCount = 1
}
monthlyAvg := make(map[string]float64)
for cat, total := range spendByCat {
avg := math.Round(float64(-total)/float64(monthCount)*100) / 100
if avg > 0 {
monthlyAvg[cat] = avg
}
}
annualTotal := int64(math.Round(float64(-totalSpend) / float64(monthCount) * 12))
monthlyTotal := float64(annualTotal) / 12
// pre-compute pace percentage per category for the template (avoids float/int type issues)
type catProjection struct {
Name string
MonthlyAvg float64
AnnualTotal float64
PacePct int
}
cats2, _ := h.store.getCategories(ctx, a.UserID)
catColors2 := make(map[string]string)
for _, c := range cats2 {
catColors2[c.Name] = c.Color
}
var projections []catProjection
for cat, avg := range monthlyAvg {
pct := 0
if monthlyTotal > 0 {
pct = int(math.Round(avg / monthlyTotal * 100))
if pct > 100 {
pct = 100
}
}
projections = append(projections, catProjection{
Name: cat,
MonthlyAvg: avg,
AnnualTotal: avg * 12,
PacePct: pct,
})
}
// sort by monthly avg descending
sort.Slice(projections, func(i, j int) bool {
return projections[i].MonthlyAvg > projections[j].MonthlyAvg
})
render(w, projectionsTmpl, map[string]interface{}{
"UserID": a.UserID,
"Email": a.Email,
"Title": "Projections",
"Route": "projections",
"IsOwner": true,
"Projections": projections,
"AnnualTotal": annualTotal,
"CategoryColors": catColors2,
})
}
func (h *Handler) Portfolio(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
a := getAuth(r)
trades, err := h.store.getTrades(ctx, a.UserID)
if err != nil {
slog.Error("get trades", "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
isins := uniqueISINs(trades)
if len(isins) == 0 {
render(w, portfolioTmpl, &PortfolioData{
UserID: a.UserID,
Email: a.Email,
Title: "Portfolio",
Route: "portfolio",
})
return
}
prices, err := fetchPricesByISIN(isins)
if err != nil {
slog.Error("fetch prices", "err", err)
}
holdings := computeHoldings(trades, prices)
pr := aggregatePortfolio(holdings)
render(w, portfolioTmpl, &PortfolioData{
UserID: a.UserID,
Email: a.Email,
Title: "Portfolio",
Route: "portfolio",
Holdings: pr.Holdings,
TotalValueCents: pr.TotalVal,
TotalCostCents: pr.TotalCost,
TotalPCLCents: pr.TotalPCL,
TotalPCLPct: pr.PCLPct,
})
}
func (h *Handler) ImportSecurities(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
a := getAuth(r)
if err := r.ParseMultipartForm(32 << 20); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
file, _, err := r.FormFile("file")
if err != nil {
http.Error(w, "missing file", http.StatusBadRequest)
return
}
defer file.Close()
data, err := io.ReadAll(file)
if err != nil {
http.Error(w, "read error", http.StatusInternalServerError)
return
}
rows, err := parseSecuritiesCSV(strings.NewReader(string(data)))
if err != nil {
http.Error(w, "parse error: "+err.Error(), http.StatusBadRequest)
return
}
now := time.Now()
var trades []Trade
for _, row := range rows {
date, _ := time.Parse("2006-01-02", row.Date)
trades = append(trades, Trade{
ID: bson.NewObjectID().Hex(),
UserID: a.UserID,
ISIN: row.ISIN,
Name: row.Name,
Type: row.Type,
Quantity: row.Quantity,
PriceCents: row.PriceCents,
TotalCents: row.TotalCents,
Date: date,
CreatedAt: now,
})
}
if err := h.store.createTrades(ctx, trades); err != nil {
slog.Error("create trades", "err", err)
http.Error(w, "save error", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/portfolio", http.StatusSeeOther)
}
func (h *Handler) Sharing(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
a := getAuth(r)
switch r.Method {
case http.MethodGet:
perms, err := h.store.getPermissions(ctx, a.UserID)
if err != nil {
slog.Error("get permissions", "err", err)
}
granted, err := h.store.getGrantedViewers(ctx, a.UserID)
if err != nil {
slog.Error("get granted", "err", err)
}
viewerIDs := make(map[string]bool)
for _, p := range perms {
viewerIDs[p.ViewerID] = true
}
type userInfo struct {
ID string `bson:"_id" json:"id"`
Email string `bson:"email" json:"email"`
}
var viewers []SharingUser
for viewerID := range viewerIDs {
viewers = append(viewers, SharingUser{ID: viewerID, Email: viewerID})
}
render(w, sharingTmpl, map[string]interface{}{
"UserID": a.UserID,
"Email": a.Email,
"Title": "Sharing",
"Route": "sharing",
"IsOwner": true,
"Grants": perms,
"Viewers": viewers,
"Granted": granted,
})
case http.MethodPost:
viewerID := r.FormValue("viewer_id")
if viewerID == "" || viewerID == a.UserID {
http.Error(w, "invalid viewer", http.StatusBadRequest)
return
}
existing, _ := h.store.getPermissions(ctx, a.UserID)
for _, p := range existing {
if p.ViewerID == viewerID {
http.Redirect(w, r, "/sharing", http.StatusSeeOther)
return
}
}
perm := &Permission{
ID: bson.NewObjectID().Hex(),
OwnerID: a.UserID,
ViewerID: viewerID,
CreatedAt: time.Now(),
}
if err := h.store.createPermission(ctx, perm); err != nil {
slog.Error("create permission", "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/sharing", http.StatusSeeOther)
case http.MethodDelete:
viewerID := r.PathValue("viewer_id")
if err := h.store.deletePermission(ctx, a.UserID, viewerID); err != nil {
slog.Error("delete permission", "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
}
func (h *Handler) SearchUsers(w http.ResponseWriter, r *http.Request) {
a := getAuth(r)
q := r.URL.Query().Get("q")
if q == "" || len(q) < 2 {
json.NewEncoder(w).Encode([]map[string]string{})
return
}
resp, err := http.Get(fmt.Sprintf("http://users/admin/users?search=%s", q))
if err != nil {
json.NewEncoder(w).Encode([]map[string]string{})
return
}
defer resp.Body.Close()
var users []map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&users); err != nil {
json.NewEncoder(w).Encode([]map[string]string{})
return
}
var results []map[string]string
for _, u := range users {
id, _ := u["id"].(string)
email, _ := u["email"].(string)
if id != a.UserID {
results = append(results, map[string]string{"id": id, "email": email})
}
}
json.NewEncoder(w).Encode(results)
}
func (h *Handler) healthz(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
}
func (h *Handler) Goals(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
a := getAuth(r)
if r.Method == http.MethodPost {
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
action := r.FormValue("action")
if action == "delete" {
id := r.FormValue("id")
h.store.deleteGoal(ctx, id, a.UserID)
http.Redirect(w, r, "/goals", http.StatusSeeOther)
return
}
if action == "commit" || action == "uncommit" {
id := r.FormValue("id")
h.store.updateGoal(ctx, id, a.UserID, bson.M{"committed": action == "commit"})
http.Redirect(w, r, "/goals", http.StatusSeeOther)
return
}
// create goal
name := r.FormValue("name")
goalType := GoalType(r.FormValue("type"))
targetStr := r.FormValue("target_euros")
deadlineStr := r.FormValue("deadline")
targetEuros := parseFloat(targetStr)
deadline, _ := time.Parse("2006-01", deadlineStr)
g := &Goal{
ID: bson.NewObjectID().Hex(),
UserID: a.UserID,
Name: name,
Type: goalType,
TargetCents: int64(targetEuros * 100),
Deadline: deadline,
CreatedAt: time.Now(),
}
if err := h.store.createGoal(ctx, g); err != nil {
slog.Error("create goal", "err", err)
}
http.Redirect(w, r, "/goals", http.StatusSeeOther)
return
}
goals, err := h.store.getGoals(ctx, a.UserID)
if err != nil {
slog.Error("get goals", "err", err)
}
// compute average monthly savings over last 3 months
txns, _ := h.store.getTransactions(ctx, a.UserID, bson.M{})
now := time.Now()
threeMonthsAgo := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location()).AddDate(0, -3, 0)
monthlySavings := make(map[int]int64)
for _, t := range txns {
if !t.Date.Before(threeMonthsAgo) && t.Date.Before(time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location())) {
monthlySavings[int(t.Date.Month())] += t.AmountCents
}
}
var totalSavings int64
for _, s := range monthlySavings {
if s > 0 {
totalSavings += s
}
}
avgMonthlySavings := int64(0)
if len(monthlySavings) > 0 {
avgMonthlySavings = totalSavings / int64(len(monthlySavings))
}
// compute disposable income from this month's transactions
thisStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location())
thisMonthIncome := int64(0)
fixedThisMonth := int64(0)
for _, t := range txns {
if t.Date.Before(thisStart) {
continue
}
if t.AmountCents > 0 {
thisMonthIncome += t.AmountCents
}
if FixedCategories[t.Category] && t.AmountCents < 0 {
fixedThisMonth += -t.AmountCents
}
}
disposable := thisMonthIncome - fixedThisMonth
// build goal plans
var plans []GoalPlan
for _, g := range goals {
remaining := g.TargetCents - g.SavedCents
if remaining < 0 {
remaining = 0
}
monthsLeft := int64(monthsBetween(now, g.Deadline))
if monthsLeft < 1 {
monthsLeft = 1
}
monthlyCents := remaining / monthsLeft
monthsAtRate := int64(0)
if avgMonthlySavings > 0 {
monthsAtRate = remaining / avgMonthlySavings
}
progressPct := int64(0)
if g.TargetCents > 0 {
progressPct = int64(float64(g.SavedCents) / float64(g.TargetCents) * 100)
if progressPct > 100 {
progressPct = 100
}
}
plans = append(plans, GoalPlan{
Goal: g,
MonthsLeft: monthsLeft,
MonthlyCents: monthlyCents,
ImpactOnDisposable: disposable - monthlyCents,
MonthsAtCurrentRate: monthsAtRate,
Feasible: avgMonthlySavings >= monthlyCents,
ProgressPct: progressPct,
})
}
// sum committed goal contributions and detect conflicts
committedTotal := int64(0)
for _, p := range plans {
if p.Committed {
committedTotal += p.MonthlyCents
}
}
remainingDisposable := disposable - committedTotal
conflictWarning := ""
if committedTotal > disposable {
// find which committed goals are in conflict
var conflictNames []string
for _, p := range plans {
if p.Committed {
conflictNames = append(conflictNames, p.Name)
}
}
conflictWarning = fmt.Sprintf(
"Your committed goals require €%.0f/month but your disposable income is €%.0f/month. Consider pushing back a deadline or removing a goal.",
float64(committedTotal)/100, float64(disposable)/100,
)
_ = conflictNames
}
render(w, goalsTmpl, &GoalsData{
UserID: a.UserID,
Email: a.Email,
Title: "Goals",
Route: "goals",
Goals: plans,
AvgMonthlySavings: avgMonthlySavings,
DisposableIncome: disposable,
CommittedMonthlyCents: committedTotal,
RemainingDisposable: remainingDisposable,
ConflictWarning: conflictWarning,
})
}
func monthsBetween(from, to time.Time) int {
months := (to.Year()-from.Year())*12 + int(to.Month()) - int(from.Month())
if months < 0 {
return 0
}
return months
}
func parseFloat(s string) float64 {
var f float64
fmt.Sscanf(s, "%f", &f)
return f
}
func (h *Handler) NetWorth(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
a := getAuth(r)
txns, err := h.store.getTransactions(ctx, a.UserID, bson.M{})
if err != nil {
slog.Error("networth get transactions", "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
// build a running total per month for cash (non-credit accounts treated as assets,
// credit accounts as liabilities)
// We don't have account-type info on transactions, so we use signing convention:
// Income category = income (+), everything else = expense ().
// For a simple net-worth history: sum all transaction amounts cumulatively per month.
type monthKey = string
monthCash := make(map[monthKey]int64)
var months []string
seen := make(map[monthKey]bool)
// cumulative running balance across all txns sorted by date (store returns desc; reverse)
// running cumulative balance — reset is not possible so we track running sum
runningBalance := int64(0)
// txns are sorted desc by date; reverse to process oldest first
for i := len(txns) - 1; i >= 0; i-- {
t := txns[i]
runningBalance += t.AmountCents
mk := t.Date.Format("2006-01")
monthCash[mk] = runningBalance
if !seen[mk] {
seen[mk] = true
months = append(months, mk)
}
}
sortStrings(months)
// current cash = running balance at end of all transactions
cashCents := runningBalance
// portfolio
var portfolioCents int64
var pricesAvailable bool
if trades, err2 := h.store.getTrades(ctx, a.UserID); err2 == nil && len(trades) > 0 {
prices, _ := fetchPricesByISIN(uniqueISINs(trades))
holdings := computeHoldings(trades, prices)
pr := aggregatePortfolio(holdings)
for _, p := range prices {
if p > 0 {
pricesAvailable = true
break
}
}
if pricesAvailable {
portfolioCents = pr.TotalVal
} else {
portfolioCents = pr.TotalCost
}
}
netWorthCents := cashCents + portfolioCents
// build history points — each month: cash snapshot + portfolio (we only have current portfolio)
var history []NetWorthPoint
for _, m := range months {
cash := monthCash[m]
history = append(history, NetWorthPoint{
Month: m,
AssetCents: cash + portfolioCents,
LiabCents: 0,
NetCents: cash + portfolioCents,
})
}
render(w, networthTmpl, &NetWorthData{
UserID: a.UserID,
Email: a.Email,
Title: "Net Worth",
Route: "networth",
CashCents: cashCents,
PortfolioCents: portfolioCents,
CreditCents: 0,
NetWorthCents: netWorthCents,
PortfolioPricesAvailable: pricesAvailable,
History: history,
})
}
func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /{$}", h.Dashboard)
mux.HandleFunc("GET /transactions", h.Transactions)
mux.HandleFunc("GET /import", h.ImportPage)
mux.HandleFunc("POST /import/preview", h.ImportPreview)
mux.HandleFunc("POST /import/confirm", h.ImportConfirm)
mux.HandleFunc("POST /import/securities", h.ImportSecurities)
mux.HandleFunc("GET /accounts", h.Accounts)
mux.HandleFunc("POST /accounts", h.Accounts)
mux.HandleFunc("DELETE /accounts/{id}", h.Accounts)
mux.HandleFunc("GET /categories", h.Categories)
mux.HandleFunc("POST /categories", h.Categories)
mux.HandleFunc("PUT /categories/{id}", h.Categories)
mux.HandleFunc("DELETE /categories/{id}", h.Categories)
mux.HandleFunc("GET /reports", h.Reports)
mux.HandleFunc("GET /projections", h.Projections)
mux.HandleFunc("GET /portfolio", h.Portfolio)
mux.HandleFunc("GET /goals", h.Goals)
mux.HandleFunc("POST /goals", h.Goals)
mux.HandleFunc("GET /networth", h.NetWorth)
mux.HandleFunc("GET /sharing", h.Sharing)
mux.HandleFunc("POST /sharing", h.Sharing)
mux.HandleFunc("DELETE /sharing/{viewer_id}", h.Sharing)
mux.HandleFunc("GET /api/users/search", h.SearchUsers)
mux.HandleFunc("POST /api/transactions", h.CreateTransaction)
mux.HandleFunc("PUT /api/transactions/{id}", h.UpdateTransaction)
mux.HandleFunc("DELETE /api/transactions/{id}", h.DeleteTransaction)
}
func sortStrings(s []string) {
sort.Strings(s)
}
func appendIfMissing(s []string, v string) []string {
for _, x := range s {
if x == v {
return s
}
}
return append(s, v)
}