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") simulatorTmpl = parseTmpl("templates/base.html", "templates/simulator.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(`

403 - Access Denied

You do not have permission to view this user's finances.

`), }) }) } 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) Simulator(w http.ResponseWriter, r *http.Request) { ctx := r.Context() a := getAuth(r) txns, _ := h.store.getTransactions(ctx, a.UserID, bson.M{}) goals, _ := h.store.getGoals(ctx, a.UserID) now := time.Now() thisStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location()) // this month income + fixed costs thisMonthIncome := int64(0) totalFixed := int64(0) fixedByMonth := make(map[string]map[int]int64) threeAgo := thisStart.AddDate(0, -3, 0) monthlySavings := make(map[string]struct{ income, saved int64 }) for _, t := range txns { mk := t.Date.Format("2006-01") if t.Date.Before(thisStart) { // savings history: accumulate per month ms := monthlySavings[mk] if t.AmountCents > 0 { ms.income += t.AmountCents } ms.saved += t.AmountCents monthlySavings[mk] = ms // fixed category detection over last 3 months if !t.Date.Before(threeAgo) && 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 } } else { if t.AmountCents > 0 { thisMonthIncome += t.AmountCents } } } // avg monthly fixed from last 3 months for _, byMo := range fixedByMonth { total := int64(0) for _, v := range byMo { total += v } totalFixed += total / int64(len(byMo)) } // committed goal monthly totals goalsCents := int64(0) var simGoals []SimulatorGoal for _, g := range goals { remaining := g.TargetCents - g.SavedCents if remaining <= 0 { continue } ml := int64(monthsBetween(now, g.Deadline)) if ml < 1 { ml = 1 } monthly := remaining / ml if g.Committed { goalsCents += monthly } simGoals = append(simGoals, SimulatorGoal{ Name: g.Name, MonthlyCents: monthly, MonthsLeft: ml, Committed: g.Committed, }) } disposable := thisMonthIncome - totalFixed - goalsCents // average monthly savings over last 3 complete months var avgSavings int64 count := 0 for _, ms := range monthlySavings { if ms.income > 0 && ms.saved > 0 { avgSavings += ms.saved count++ } } if count > 0 { avgSavings /= int64(count) } // savings rate history — sorted months var monthKeys []string for mk := range monthlySavings { monthKeys = append(monthKeys, mk) } sortStrings(monthKeys) var history []SavingsPoint for _, mk := range monthKeys { ms := monthlySavings[mk] if ms.income <= 0 { continue } rate := int(float64(ms.saved) / float64(ms.income) * 100) if rate < -100 { rate = -100 } history = append(history, SavingsPoint{ Month: mk, IncomeCents: ms.income, SavedCents: ms.saved, RatePct: rate, }) } render(w, simulatorTmpl, &SimulatorData{ UserID: a.UserID, Email: a.Email, Title: "What If", Route: "simulator", IncomeCents: thisMonthIncome, FixedCents: totalFixed, GoalsCents: goalsCents, DisposableCents: disposable, AvgSavingsCents: avgSavings, Goals: simGoals, SavingsHistory: history, }) } 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 /simulator", h.Simulator) 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) }