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
},
"dateInput": func(t time.Time) string {
if t.IsZero() {
return ""
}
return t.Format("2006-01-02")
},
"statusColor": func(s string) string {
switch s {
case "approved", "paid", "delivered", "settled", "received", "reconciled", "done":
return "rgba(74,222,128,0.12); color:var(--green)"
case "submitted", "under_review", "review", "ordered", "disbursed", "pending_payment":
return "rgba(99,179,237,0.12); color:#63b3ed"
case "rejected", "cancelled", "disputed":
return "rgba(239,68,68,0.12); color:var(--red)"
case "info_requested", "settlement_due", "partial_settlement":
return "rgba(251,191,36,0.1); color:#fbbf24"
default:
return "var(--bg3); color:var(--text3)"
}
},
"varColor": func(planned, actual int64) string {
if planned == 0 {
return "var(--text2)"
}
if actual > planned {
return "var(--red)"
}
return "var(--green)"
},
"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")
// Org
orgListTmpl = parseTmpl("templates/base.html", "templates/org_list.html")
orgCreateTmpl = parseTmpl("templates/base.html", "templates/org_create.html")
orgHomeTmpl = parseTmpl("templates/base.html", "templates/org_home.html")
orgTeamsTmpl = parseTmpl("templates/base.html", "templates/org_teams.html")
orgMembersTmpl = parseTmpl("templates/base.html", "templates/org_members.html")
orgInviteTmpl = parseTmpl("templates/base.html", "templates/org_invite.html")
orgJoinTmpl = parseTmpl("templates/base.html", "templates/org_join.html")
orgEventsTmpl = parseTmpl("templates/base.html", "templates/org_events.html")
orgEventDetailTmpl = parseTmpl("templates/base.html", "templates/org_event_detail.html")
orgRequestsTmpl = parseTmpl("templates/base.html", "templates/org_requests.html")
orgRequestDetailTmpl = parseTmpl("templates/base.html", "templates/org_request_detail.html")
orgLedgerTmpl = parseTmpl("templates/base.html", "templates/org_ledger.html")
orgBankImportTmpl = parseTmpl("templates/base.html", "templates/org_bank_import.html")
orgAnalysisTmpl = parseTmpl("templates/base.html", "templates/org_analysis.html")
orgReportTmpl = parseTmpl("templates/base.html", "templates/org_report.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
// Org
getOrgsForUser(ctx context.Context, userID string) ([]OrgWithRole, error)
getOrg(ctx context.Context, orgID string) (*Org, error)
getOrgBySlug(ctx context.Context, slug string) (*Org, error)
createOrg(ctx context.Context, o *Org) error
slugExists(ctx context.Context, slug string) (bool, error)
getTeams(ctx context.Context, orgID string) ([]OrgTeam, error)
getTeam(ctx context.Context, teamID, orgID string) (*OrgTeam, error)
createTeam(ctx context.Context, t *OrgTeam) error
deleteTeam(ctx context.Context, teamID, orgID string) error
getMembers(ctx context.Context, orgID string) ([]OrgMember, error)
getMember(ctx context.Context, orgID, userID string) (*OrgMember, error)
createMember(ctx context.Context, m *OrgMember) error
updateMemberRole(ctx context.Context, memberID, orgID string, role OrgRole) error
removeMember(ctx context.Context, memberID, orgID string) error
getInvites(ctx context.Context, orgID string) ([]OrgInvite, error)
getInviteByToken(ctx context.Context, token string) (*OrgInvite, error)
createInvite(ctx context.Context, inv *OrgInvite) error
consumeInvite(ctx context.Context, inviteID string) error
revokeInvite(ctx context.Context, inviteID, orgID string) error
getFiscalYears(ctx context.Context, orgID string) ([]FiscalYear, error)
getFiscalYear(ctx context.Context, yearID, orgID string) (*FiscalYear, error)
getActiveFiscalYear(ctx context.Context, orgID string) (*FiscalYear, error)
createFiscalYear(ctx context.Context, y *FiscalYear) error
updateFiscalYearStatus(ctx context.Context, yearID, orgID string, status FiscalYearStatus, extraSet bson.M) error
getEvents(ctx context.Context, orgID, fiscalYearID string) ([]OrgEvent, error)
getEvent(ctx context.Context, eventID, orgID string) (*OrgEvent, error)
createEvent(ctx context.Context, e *OrgEvent) error
updateEvent(ctx context.Context, eventID, orgID string, update bson.M) error
deleteEvent(ctx context.Context, eventID, orgID string) error
getBudgetLines(ctx context.Context, eventID, orgID string) ([]BudgetLine, error)
createBudgetLine(ctx context.Context, l *BudgetLine) error
deleteBudgetLine(ctx context.Context, lineID, orgID string) error
getEventComments(ctx context.Context, eventID, orgID string) ([]EventComment, error)
createEventComment(ctx context.Context, c *EventComment) error
getTxRequests(ctx context.Context, orgID string, filter bson.M) ([]TxRequest, error)
getTxRequest(ctx context.Context, reqID, orgID string) (*TxRequest, error)
createTxRequest(ctx context.Context, r *TxRequest) error
appendStatusLog(ctx context.Context, reqID, orgID string, entry StatusLogEntry) error
updateTxRequest(ctx context.Context, reqID, orgID string, update bson.M) error
getLedgerEntries(ctx context.Context, orgID, fiscalYearID string, extra bson.M) ([]OrgLedgerEntry, error)
createLedgerEntry(ctx context.Context, e *OrgLedgerEntry) error
updateLedgerEntry(ctx context.Context, id, orgID string, update bson.M) error
getAttachments(ctx context.Context, requestID, orgID string) ([]OrgAttachment, error)
createAttachment(ctx context.Context, a *OrgAttachment) 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)
h.RegisterOrgRoutes(mux)
}
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)
}