package main import ( "context" "crypto/sha256" "embed" "encoding/hex" "encoding/json" "fmt" "html/template" "io" "log/slog" "math" "net/http" "sort" "strconv" "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") taxTmpl = parseTmpl("templates/base.html", "templates/tax.html") householdTmpl = parseTmpl("templates/base.html", "templates/household.html") autoImportTmpl = parseTmpl("templates/base.html", "templates/auto_import.html") peopleTmpl = parseTmpl("templates/base.html", "templates/people.html") settingsTmpl = parseTmpl("templates/base.html", "templates/settings.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 storeIface interface { getAccounts(ctx context.Context, userID string) ([]Account, error) getAccount(ctx context.Context, id string) (*Account, error) createAccount(ctx context.Context, a *Account) error deleteAccount(ctx context.Context, id, userID string) error getCategories(ctx context.Context, userID string) ([]Category, error) createCategory(ctx context.Context, c *Category) error updateCategory(ctx context.Context, c *Category) error deleteCategory(ctx context.Context, id, userID string) error getTransactions(ctx context.Context, userID string, filter bson.M) ([]Transaction, error) getTransaction(ctx context.Context, id, userID string) (*Transaction, error) createTransactions(ctx context.Context, txns []Transaction) error updateTransaction(ctx context.Context, id, userID string, update bson.M) error deleteTransaction(ctx context.Context, id, userID string) error aggregateTransactions(ctx context.Context, userID string, pipeline bson.A) ([]bson.M, error) getTrades(ctx context.Context, userID string) ([]Trade, error) createTrades(ctx context.Context, trades []Trade) error deleteTrade(ctx context.Context, id, userID string) error getPermissions(ctx context.Context, ownerID string) ([]Permission, error) getGrantedViewers(ctx context.Context, viewerID string) ([]Permission, error) createPermission(ctx context.Context, p *Permission) error deletePermission(ctx context.Context, ownerID, viewerID string) error getGoals(ctx context.Context, userID string) ([]Goal, error) createGoal(ctx context.Context, g *Goal) error updateGoal(ctx context.Context, id, userID string, update bson.M) error deleteGoal(ctx context.Context, id, userID string) error seedCategories(ctx context.Context, userID string) error getTickerMappings(ctx context.Context, userID string) ([]TickerMapping, error) saveTickerMapping(ctx context.Context, userID, isin, ticker string) error getHousehold(ctx context.Context, userID string) (*Household, error) createHousehold(ctx context.Context, h *Household) error deleteHousehold(ctx context.Context, userID string) error getImportSchedules(ctx context.Context, userID string) ([]ImportSchedule, error) createImportSchedule(ctx context.Context, sched *ImportSchedule) error deleteImportSchedule(ctx context.Context, id, userID string) error } type Handler struct { store storeIface } 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), nil) 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 } } // ── Alerts ────────────────────────────────────────────────────────── var alerts []Alert // budget overspend alerts — compare per-category spend vs budget for cat, budget := range catBudgets { spent := -thisMonth.ByCategory[cat] // expenses are negative if spent <= 0 || budget <= 0 { continue } pct := int(float64(spent) / float64(budget) * 100) if pct >= 100 { alerts = append(alerts, Alert{ Level: AlertDanger, Message: fmt.Sprintf("You've exceeded your %s budget (€%.0f of €%.0f — %d%%).", cat, float64(spent)/100, float64(budget)/100, pct), }) } else if pct >= 80 && monthProgressPct < 80 { alerts = append(alerts, Alert{ Level: AlertWarn, Message: fmt.Sprintf("You've used %d%% of your %s budget but only %d%% of the month has passed.", pct, cat, monthProgressPct), }) } } // goal deadline risk alerts if goalList, err := h.store.getGoals(ctx, a.UserID); err == nil { threeAgo := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location()).AddDate(0, -3, 0) moSavings := make(map[int]int64) for _, t := range txns { if !t.Date.Before(threeAgo) && t.Date.Before(thisStart) { moSavings[int(t.Date.Month())] += t.AmountCents } } var totalS int64 for _, s := range moSavings { if s > 0 { totalS += s } } avgS := int64(0) if len(moSavings) > 0 { avgS = totalS / int64(len(moSavings)) } for _, g := range goalList { remaining := g.TargetCents - g.SavedCents if remaining <= 0 { continue } ml := int64(monthsBetween(now, g.Deadline)) if ml < 1 { ml = 1 } needed := remaining / ml if avgS < needed { monthsOff := int64(0) if avgS > 0 { monthsOff = remaining/avgS - ml } msg := fmt.Sprintf("You're on track to miss your \"%s\" goal", g.Name) if monthsOff > 0 { msg += fmt.Sprintf(" by %d month(s)", monthsOff) } msg += fmt.Sprintf(" — need €%.0f/mo but saving ~€%.0f/mo.", float64(needed)/100, float64(avgS)/100) alerts = append(alerts, Alert{Level: AlertWarn, Message: msg}) } } } // overall spend pace alert if monthProgressPct > 0 && monthSpentPct > monthProgressPct+20 { alerts = append(alerts, Alert{ Level: AlertWarn, Message: fmt.Sprintf("You've spent %d%% of your disposable income but only %d%% of the month has passed — you're ahead of pace.", monthSpentPct, monthProgressPct), }) } 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, Alerts: alerts, }) } 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, "Notice": r.URL.Query().Get("notice"), }) } 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 } } } // compute fingerprints and detect duplicates var fingerprints []string for i := range rows { rows[i].Category = autoCategorize(rows[i].Description, catMap) rows[i].Fingerprint = txnFingerprint(rows[i].Date, rows[i].Description, rows[i].AmountCents, accountID) fingerprints = append(fingerprints, rows[i].Fingerprint) } existing, _ := h.store.getTransactions(ctx, a.UserID, bson.M{"bank_ref": bson.M{"$in": fingerprints}}) existingRefs := map[string]bool{} for _, t := range existing { existingRefs[t.BankRef] = true } duplicateCount := 0 for i := range rows { if existingRefs[rows[i].Fingerprint] { rows[i].Duplicate = true duplicateCount++ } } 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, "DuplicateCount": duplicateCount, }) } 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"] // compute fingerprints and skip duplicates var fingerprints []string for _, row := range rows { fingerprints = append(fingerprints, txnFingerprint(row.Date, row.Description, row.AmountCents, accountID)) } existing, _ := h.store.getTransactions(ctx, a.UserID, bson.M{"bank_ref": bson.M{"$in": fingerprints}}) existingRefs := map[string]bool{} for _, t := range existing { existingRefs[t.BankRef] = true } now := time.Now() var txns []Transaction for i, row := range rows { fp := fingerprints[i] if existingRefs[fp] { continue } 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, BankRef: fp, CreatedAt: now, }) } if len(txns) == 0 { http.Redirect(w, r, "/transactions?notice=all_duplicates", http.StatusSeeOther) return } 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 txnFingerprint(date, description string, amountCents int64, accountID string) string { h := sha256.Sum256([]byte(fmt.Sprintf("%s|%s|%d|%s", date, description, amountCents, accountID))) return hex.EncodeToString(h[:])[:16] } 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 } // load user-saved ISIN→ticker overrides customMappings, _ := h.store.getTickerMappings(ctx, a.UserID) custom := make(map[string]string, len(customMappings)) for _, m := range customMappings { custom[m.ISIN] = m.Ticker } prices, err := fetchPricesByISIN(isins, custom) if err != nil { slog.Error("fetch prices", "err", err) } // collect ISINs for which we got no price var missingPrices []string for _, isin := range isins { if prices[isin] == 0 { missingPrices = append(missingPrices, isin) } } 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, MissingPrices: missingPrices, }) } func (h *Handler) SaveTickerMapping(w http.ResponseWriter, r *http.Request) { ctx := r.Context() a := getAuth(r) isin := strings.TrimSpace(r.FormValue("isin")) ticker := strings.TrimSpace(r.FormValue("ticker")) if isin == "" || ticker == "" { http.Error(w, "isin and ticker required", http.StatusBadRequest) return } if err := h.store.saveTickerMapping(ctx, a.UserID, isin, ticker); err != nil { slog.Error("save ticker mapping", "err", err) http.Error(w, "save error", http.StatusInternalServerError) return } http.Redirect(w, r, "/portfolio", http.StatusSeeOther) } 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), nil) 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, }) } // ── Tax Summary ─────────────────────────────────────────────────────────────── func (h *Handler) Tax(w http.ResponseWriter, r *http.Request) { auth := getAuth(r) ctx := r.Context() // year selector yearStr := r.URL.Query().Get("year") now := time.Now() year := now.Year() if yearStr != "" { if y, err := strconv.Atoi(yearStr); err == nil && y >= 2000 && y <= now.Year() { year = y } } start := time.Date(year, 1, 1, 0, 0, 0, 0, time.UTC) end := time.Date(year+1, 1, 1, 0, 0, 0, 0, time.UTC) // income transactions incomeTxns, err := h.store.getTransactions(ctx, auth.UserID, bson.M{ "category": "Income", "date": bson.M{"$gte": start, "$lt": end}, }) if err != nil { http.Error(w, "failed to load income", http.StatusInternalServerError) return } var grossIncome int64 for _, t := range incomeTxns { grossIncome += t.AmountCents } // expense transactions by category (deductible candidates = all expenses) expTxns, err := h.store.getTransactions(ctx, auth.UserID, bson.M{ "amount_cents": bson.M{"$lt": 0}, "date": bson.M{"$gte": start, "$lt": end}, }) if err != nil { http.Error(w, "failed to load expenses", http.StatusInternalServerError) return } deductMap := map[string]int64{} for _, t := range expTxns { deductMap[t.Category] += -t.AmountCents } var deductibles []TaxDeductible var totalDeduct int64 // order by category name for stable output catNames := make([]string, 0, len(deductMap)) for c := range deductMap { catNames = append(catNames, c) } sort.Strings(catNames) for _, cat := range catNames { amt := deductMap[cat] deductibles = append(deductibles, TaxDeductible{Category: cat, TotalCents: amt}) totalDeduct += amt } // capital gains from trades in the selected year trades, err := h.store.getTrades(ctx, auth.UserID) if err != nil { http.Error(w, "failed to load trades", http.StatusInternalServerError) return } // FIFO matching: for each ISIN track buy queue, match sells type buyLot struct { qty float64 priceCents int64 } buyQueues := map[string][]buyLot{} nameByISIN := map[string]string{} // process buys in date order first (already sorted by date from store, but sort to be safe) sort.Slice(trades, func(i, j int) bool { return trades[i].Date.Before(trades[j].Date) }) var capEntries []CapitalGainEntry var capGains, capLosses int64 for _, t := range trades { if t.Date.Before(start) { // still build the buy queue from prior years so we can match sells if t.Type == "buy" { buyQueues[t.ISIN] = append(buyQueues[t.ISIN], buyLot{t.Quantity, t.PriceCents}) nameByISIN[t.ISIN] = t.Name } else if t.Type == "sell" { // consume from queue silently q := t.Quantity for q > 0 && len(buyQueues[t.ISIN]) > 0 { lot := &buyQueues[t.ISIN][0] if lot.qty <= q { q -= lot.qty buyQueues[t.ISIN] = buyQueues[t.ISIN][1:] } else { lot.qty -= q q = 0 } } } continue } if t.Date.After(end) { continue } nameByISIN[t.ISIN] = t.Name if t.Type == "buy" { buyQueues[t.ISIN] = append(buyQueues[t.ISIN], buyLot{t.Quantity, t.PriceCents}) } else if t.Type == "sell" { // FIFO match remaining := t.Quantity var costCents int64 for remaining > 0 && len(buyQueues[t.ISIN]) > 0 { lot := &buyQueues[t.ISIN][0] matched := lot.qty if matched > remaining { matched = remaining } costCents += int64(matched * float64(lot.priceCents)) lot.qty -= matched remaining -= matched if lot.qty == 0 { buyQueues[t.ISIN] = buyQueues[t.ISIN][1:] } } gainCents := t.TotalCents - costCents gainPct := 0.0 if costCents > 0 { gainPct = float64(gainCents) / float64(costCents) * 100 } entry := CapitalGainEntry{ ISIN: t.ISIN, Name: nameByISIN[t.ISIN], BuyCents: costCents, SellCents: t.TotalCents, GainCents: gainCents, GainPct: math.Round(gainPct*100) / 100, } capEntries = append(capEntries, entry) if gainCents > 0 { capGains += gainCents } else { capLosses += -gainCents } } } // available years: from first transaction year to current year allTxns, _ := h.store.getTransactions(ctx, auth.UserID, bson.M{}) availYears := []int{} minYear := now.Year() for _, t := range allTxns { if t.Date.Year() < minYear { minYear = t.Date.Year() } } for y := minYear; y <= now.Year(); y++ { availYears = append(availYears, y) } if len(availYears) == 0 { availYears = []int{now.Year()} } render(w, taxTmpl, &TaxData{ UserID: auth.UserID, Email: auth.Email, Title: "Tax Summary", Route: "/tax", Year: year, GrossIncomeCents: grossIncome, CapitalGainsCents: capGains, CapitalLossesCents: capLosses, NetCapitalCents: capGains - capLosses, Deductibles: deductibles, TotalDeductCents: totalDeduct, CapitalEntries: capEntries, AvailableYears: availYears, }) } func (h *Handler) TaxExport(w http.ResponseWriter, r *http.Request) { // Reuse Tax logic output as CSV — redirect with same year param auth := getAuth(r) ctx := r.Context() yearStr := r.URL.Query().Get("year") now := time.Now() year := now.Year() if yearStr != "" { if y, err := strconv.Atoi(yearStr); err == nil { year = y } } start := time.Date(year, 1, 1, 0, 0, 0, 0, time.UTC) end := time.Date(year+1, 1, 1, 0, 0, 0, 0, time.UTC) expTxns, _ := h.store.getTransactions(ctx, auth.UserID, bson.M{ "amount_cents": bson.M{"$lt": 0}, "date": bson.M{"$gte": start, "$lt": end}, }) w.Header().Set("Content-Type", "text/csv") w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="tax_%d.csv"`, year)) fmt.Fprintf(w, "Date,Description,Category,Amount\n") for _, t := range expTxns { fmt.Fprintf(w, "%s,%q,%s,%.2f\n", t.Date.Format("2006-01-02"), t.Description, t.Category, float64(-t.AmountCents)/100, ) } } // ── Household ───────────────────────────────────────────────────────────────── func (h *Handler) Household(w http.ResponseWriter, r *http.Request) { auth := getAuth(r) ctx := r.Context() now := time.Now() data := &HouseholdData{ UserID: auth.UserID, Email: auth.Email, Title: "Household", Route: "/household", } if r.Method == http.MethodPost { partnerEmail := strings.TrimSpace(r.FormValue("partner_email")) if partnerEmail == "" { http.Error(w, "partner email required", http.StatusBadRequest) return } // resolve partner user ID via permissions search (reuse SearchUsers pattern) // For now store by email as ID placeholder — real lookup needs identity service hh := &Household{ ID: bson.NewObjectID().Hex(), OwnerID: auth.UserID, PartnerID: partnerEmail, // stored as email; resolved on read CreatedAt: now, } if err := h.store.createHousehold(ctx, hh); err != nil { http.Error(w, "failed to create household", http.StatusInternalServerError) return } http.Redirect(w, r, "/household", http.StatusSeeOther) return } if r.Method == http.MethodDelete { _ = h.store.deleteHousehold(ctx, auth.UserID) w.WriteHeader(http.StatusNoContent) return } hh, err := h.store.getHousehold(ctx, auth.UserID) if err == nil && hh != nil { data.HasHousehold = true data.IsOwner = hh.OwnerID == auth.UserID partnerID := hh.PartnerID if hh.OwnerID == auth.UserID { partnerID = hh.PartnerID } else { partnerID = hh.OwnerID } data.PartnerID = partnerID data.PartnerEmail = partnerID // email stored as ID for now // compute combined view for current month monthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC) nextMonth := monthStart.AddDate(0, 1, 0) myTxns, _ := h.store.getTransactions(ctx, auth.UserID, bson.M{ "date": bson.M{"$gte": monthStart, "$lt": nextMonth}, }) partnerTxns, _ := h.store.getTransactions(ctx, partnerID, bson.M{ "date": bson.M{"$gte": monthStart, "$lt": nextMonth}, }) for _, t := range myTxns { if t.AmountCents > 0 { data.MyIncomeCents += t.AmountCents } else { data.CombinedExpenseCents += -t.AmountCents } } for _, t := range partnerTxns { if t.AmountCents > 0 { data.PartnerIncomeCents += t.AmountCents } else { data.CombinedExpenseCents += -t.AmountCents } } data.CombinedIncomeCents = data.MyIncomeCents + data.PartnerIncomeCents data.CombinedDisposable = data.CombinedIncomeCents - data.CombinedExpenseCents myGoals, _ := h.store.getGoals(ctx, auth.UserID) partnerGoals, _ := h.store.getGoals(ctx, partnerID) for _, g := range myGoals { data.MyGoals = append(data.MyGoals, GoalPlan{Goal: g}) } for _, g := range partnerGoals { data.PartnerGoals = append(data.PartnerGoals, GoalPlan{Goal: g}) } data.SharedGoals = append(data.MyGoals, data.PartnerGoals...) } render(w, householdTmpl, data) } // ── Auto Import ─────────────────────────────────────────────────────────────── func (h *Handler) AutoImport(w http.ResponseWriter, r *http.Request) { auth := getAuth(r) accounts, _ := h.store.getAccounts(r.Context(), auth.UserID) render(w, autoImportTmpl, &AutoImportData{ UserID: auth.UserID, Email: auth.Email, Title: "Import Guide", Route: "/auto-import", Accounts: accounts, }) } // ── People (Sharing + Household merged) ─────────────────────────────────────── func (h *Handler) People(w http.ResponseWriter, r *http.Request) { ctx := r.Context() a := getAuth(r) tab := r.URL.Query().Get("tab") if tab == "" { tab = "sharing" } // Handle mutations — redirect back preserving tab if r.Method == http.MethodPost { _ = r.ParseForm() switch r.FormValue("_action") { case "share": viewerID := r.FormValue("viewer_id") if viewerID != "" && viewerID != a.UserID { existing, _ := h.store.getPermissions(ctx, a.UserID) already := false for _, p := range existing { if p.ViewerID == viewerID { already = true break } } if !already { _ = h.store.createPermission(ctx, &Permission{ ID: bson.NewObjectID().Hex(), OwnerID: a.UserID, ViewerID: viewerID, CreatedAt: time.Now(), }) } } http.Redirect(w, r, "/people?tab=sharing", http.StatusSeeOther) return case "household": partnerEmail := strings.TrimSpace(r.FormValue("partner_email")) if partnerEmail != "" { _ = h.store.createHousehold(ctx, &Household{ ID: bson.NewObjectID().Hex(), OwnerID: a.UserID, PartnerID: partnerEmail, CreatedAt: time.Now(), }) } http.Redirect(w, r, "/people?tab=household", http.StatusSeeOther) return } } if r.Method == http.MethodDelete { switch r.URL.Query().Get("kind") { case "share": _ = h.store.deletePermission(ctx, a.UserID, r.PathValue("id")) case "household": _ = h.store.deleteHousehold(ctx, a.UserID) } w.WriteHeader(http.StatusNoContent) return } data := &PeopleData{ UserID: a.UserID, Email: a.Email, Title: "People", Route: "people", Tab: tab, } // Sharing data perms, _ := h.store.getPermissions(ctx, a.UserID) granted, _ := h.store.getGrantedViewers(ctx, a.UserID) viewerIDs := map[string]bool{} for _, p := range perms { viewerIDs[p.ViewerID] = true } for id := range viewerIDs { data.Viewers = append(data.Viewers, SharingUser{ID: id, Email: id}) } data.Grants = perms data.Granted = granted // Household data now := time.Now() hh, err := h.store.getHousehold(ctx, a.UserID) if err == nil && hh != nil { data.HasHousehold = true data.IsOwner = hh.OwnerID == a.UserID partnerID := hh.PartnerID if hh.OwnerID != a.UserID { partnerID = hh.OwnerID } data.PartnerID = partnerID data.PartnerEmail = partnerID monthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC) nextMonth := monthStart.AddDate(0, 1, 0) myTxns, _ := h.store.getTransactions(ctx, a.UserID, bson.M{"date": bson.M{"$gte": monthStart, "$lt": nextMonth}}) partnerTxns, _ := h.store.getTransactions(ctx, partnerID, bson.M{"date": bson.M{"$gte": monthStart, "$lt": nextMonth}}) for _, t := range myTxns { if t.AmountCents > 0 { data.MyIncomeCents += t.AmountCents } else { data.CombinedExpenseCents += -t.AmountCents } } for _, t := range partnerTxns { if t.AmountCents > 0 { data.PartnerIncomeCents += t.AmountCents } else { data.CombinedExpenseCents += -t.AmountCents } } data.CombinedIncomeCents = data.MyIncomeCents + data.PartnerIncomeCents data.CombinedDisposable = data.CombinedIncomeCents - data.CombinedExpenseCents myGoals, _ := h.store.getGoals(ctx, a.UserID) partnerGoals, _ := h.store.getGoals(ctx, partnerID) for _, g := range myGoals { data.MyGoals = append(data.MyGoals, GoalPlan{Goal: g}) } for _, g := range partnerGoals { data.PartnerGoals = append(data.PartnerGoals, GoalPlan{Goal: g}) } } render(w, peopleTmpl, data) } // ── Settings (Accounts + Categories merged) ──────────────────────────────────── func (h *Handler) Settings(w http.ResponseWriter, r *http.Request) { ctx := r.Context() a := getAuth(r) tab := r.URL.Query().Get("tab") if tab == "" { tab = "accounts" } accounts, _ := h.store.getAccounts(ctx, a.UserID) categories, _ := h.store.getCategories(ctx, a.UserID) render(w, settingsTmpl, &SettingsData{ UserID: a.UserID, Email: a.Email, Title: "Settings", Route: "settings", Tab: tab, Accounts: accounts, Categories: categories, }) } 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("POST /portfolio/ticker", h.SaveTickerMapping) mux.HandleFunc("POST /accounts", h.Accounts) mux.HandleFunc("DELETE /accounts/{id}", h.Accounts) 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) // legacy redirects so old bookmarks / links keep working mux.HandleFunc("GET /sharing", func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/people?tab=sharing", http.StatusMovedPermanently) }) mux.HandleFunc("GET /household", func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/people?tab=household", http.StatusMovedPermanently) }) mux.HandleFunc("GET /accounts", func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/settings?tab=accounts", http.StatusMovedPermanently) }) mux.HandleFunc("GET /categories", func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/settings?tab=categories", http.StatusMovedPermanently) }) // people page mux.HandleFunc("GET /people", h.People) mux.HandleFunc("POST /people", h.People) mux.HandleFunc("DELETE /people/{id}", h.People) // settings page mux.HandleFunc("GET /settings", h.Settings) 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) mux.HandleFunc("GET /tax", h.Tax) mux.HandleFunc("GET /tax/export.csv", h.TaxExport) mux.HandleFunc("GET /auto-import", h.AutoImport) } 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) }