From be0c2bd89e9a11757cf12c1290d9b357dea143b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gon=C3=A7alo=20Rodrigues?= Date: Sat, 13 Jun 2026 16:20:46 +0100 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20phase=202=20=E2=80=94=20goals=20exp?= =?UTF-8?q?lore=20mode?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a Goals page where users can plan financial goals before committing to them. Each goal shows: - Required monthly contribution to hit the deadline - Months remaining vs months at current savings rate - Disposable income impact (what's left after the contribution) - Feasibility banner (green if on track, red with month delta if not) - Progress bar once savings are tracked Goal types: one-off purchase, deposit/down-payment, emergency fund, recurring investment — each with a description hint in the creation modal. Data: Goal model + store CRUD in finance_goals collection. Nav: Goals tab added between Portfolio and Sharing. Co-Authored-By: Claude Sonnet 4.6 --- apps/finance/services/api/main/handler.go | 153 ++++++++++++++ apps/finance/services/api/main/models.go | 43 ++++ apps/finance/services/api/main/store.go | 34 +++ .../services/api/main/templates/base.html | 1 + .../services/api/main/templates/goals.html | 198 ++++++++++++++++++ 5 files changed, 429 insertions(+) create mode 100644 apps/finance/services/api/main/templates/goals.html diff --git a/apps/finance/services/api/main/handler.go b/apps/finance/services/api/main/handler.go index b1fa4b2..8884713 100644 --- a/apps/finance/services/api/main/handler.go +++ b/apps/finance/services/api/main/handler.go @@ -115,6 +115,7 @@ var ( 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") ) type authInfo struct { @@ -1328,6 +1329,156 @@ func (h *Handler) healthz(w http.ResponseWriter, r *http.Request) { 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 + } + + // 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 := monthsBetween(now, g.Deadline) + if monthsLeft < 1 { + monthsLeft = 1 + } + + monthlyCents := remaining / int64(monthsLeft) + + monthsAtRate := 0 + if avgMonthlySavings > 0 { + monthsAtRate = int(remaining / avgMonthlySavings) + } + + progressPct := 0 + if g.TargetCents > 0 { + progressPct = int(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, + }) + } + + render(w, goalsTmpl, &GoalsData{ + UserID: a.UserID, + Email: a.Email, + Title: "Goals", + Route: "goals", + Goals: plans, + AvgMonthlySavings: avgMonthlySavings, + DisposableIncome: disposable, + }) +} + +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) RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("GET /{$}", h.Dashboard) mux.HandleFunc("GET /transactions", h.Transactions) @@ -1345,6 +1496,8 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) { 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 /sharing", h.Sharing) mux.HandleFunc("POST /sharing", h.Sharing) mux.HandleFunc("DELETE /sharing/{viewer_id}", h.Sharing) diff --git a/apps/finance/services/api/main/models.go b/apps/finance/services/api/main/models.go index d7bd27d..9275e8f 100644 --- a/apps/finance/services/api/main/models.go +++ b/apps/finance/services/api/main/models.go @@ -214,3 +214,46 @@ type SharingUser struct { ID string Email string } + +// GoalType classifies a financial goal for display and calculation purposes. +type GoalType string + +const ( + GoalTypeOnce GoalType = "once" // one-off purchase (Switch, holiday) + GoalTypeDeposit GoalType = "deposit" // house deposit / down-payment + GoalTypeEmergency GoalType = "emergency" // emergency fund (N months of expenses) + GoalTypeInvestment GoalType = "investment" // recurring investment target +) + +type Goal struct { + ID string `bson:"_id" json:"id"` + UserID string `bson:"user_id" json:"user_id"` + Name string `bson:"name" json:"name"` + Type GoalType `bson:"type" json:"type"` + TargetCents int64 `bson:"target_cents" json:"target_cents"` + SavedCents int64 `bson:"saved_cents" json:"saved_cents"` + Deadline time.Time `bson:"deadline" json:"deadline"` + Committed bool `bson:"committed" json:"committed"` // Phase 3: false until user commits + CreatedAt time.Time `bson:"created_at" json:"created_at"` +} + +// GoalPlan is computed at request time — never stored. +type GoalPlan struct { + Goal + MonthsLeft int + MonthlyCents int64 // required monthly contribution + ImpactOnDisposable int64 // how much disposable income this eats + MonthsAtCurrentRate int // months to reach goal at current savings rate + Feasible bool // can reach it by deadline at required monthly + ProgressPct int +} + +type GoalsData struct { + UserID string + Email string + Title string + Route string + Goals []GoalPlan + AvgMonthlySavings int64 // 3-month average savings for projection + DisposableIncome int64 // from current month dashboard calc +} diff --git a/apps/finance/services/api/main/store.go b/apps/finance/services/api/main/store.go index 3e443fb..729ad0e 100644 --- a/apps/finance/services/api/main/store.go +++ b/apps/finance/services/api/main/store.go @@ -260,6 +260,40 @@ func (s *Store) createPermission(ctx context.Context, p *Permission) error { return err } +func (s *Store) goals() *mgmongo.Collection { + return s.db.Collection("finance_goals") +} + +func (s *Store) getGoals(ctx context.Context, userID string) ([]Goal, error) { + ctx, span := mongo.StartSpan(ctx, "Store.getGoals") + defer span.End() + opts := options.Find().SetSort(bson.M{"created_at": 1}) + cur, err := s.goals().Find(ctx, bson.M{"user_id": userID}, opts) + if err != nil { + return nil, fmt.Errorf("find goals: %w", err) + } + defer cur.Close(ctx) + var goals []Goal + if err := cur.All(ctx, &goals); err != nil { + return nil, fmt.Errorf("decode goals: %w", err) + } + return goals, nil +} + +func (s *Store) createGoal(ctx context.Context, g *Goal) error { + ctx, span := mongo.StartSpan(ctx, "Store.createGoal") + defer span.End() + _, err := s.goals().InsertOne(ctx, g) + return err +} + +func (s *Store) deleteGoal(ctx context.Context, id, userID string) error { + ctx, span := mongo.StartSpan(ctx, "Store.deleteGoal") + defer span.End() + _, err := s.goals().DeleteOne(ctx, bson.M{"_id": id, "user_id": userID}) + return err +} + func (s *Store) deletePermission(ctx context.Context, ownerID, viewerID string) error { ctx, span := mongo.StartSpan(ctx, "Store.deletePermission") defer span.End() diff --git a/apps/finance/services/api/main/templates/base.html b/apps/finance/services/api/main/templates/base.html index 110e52d..4f78d27 100644 --- a/apps/finance/services/api/main/templates/base.html +++ b/apps/finance/services/api/main/templates/base.html @@ -399,6 +399,7 @@ Reports Projections Portfolio + Goals Sharing {{.Email}} diff --git a/apps/finance/services/api/main/templates/goals.html b/apps/finance/services/api/main/templates/goals.html new file mode 100644 index 0000000..b54a601 --- /dev/null +++ b/apps/finance/services/api/main/templates/goals.html @@ -0,0 +1,198 @@ +{{define "content"}} +{{$d := .}} + +
+

Goals

+ +
+ +{{if $d.AvgMonthlySavings}} +
+
+

Avg monthly savings

+
€0
+

last 3 months

+
+
+

Disposable income

+
€0
+

this month

+
+
+{{end}} + +{{if $d.Goals}} +
+ {{range $d.Goals}} +
+
+ + +
+
+ {{if eq .Type "once"}}🎯{{else if eq .Type "deposit"}}🏠{{else if eq .Type "emergency"}}🛡️{{else}}📈{{end}} +
+
{{.Name}}
+
+ {{if eq .Type "once"}}One-off purchase{{else if eq .Type "deposit"}}Deposit / down-payment{{else if eq .Type "emergency"}}Emergency fund{{else}}Recurring investment{{end}} +
+
+
+ +
+
+ €{{cents .SavedCents}} saved of €{{cents .TargetCents}} + {{.ProgressPct}}% +
+
+
+
+
+
+ + +
+ +
+
Need per month
+
€0
+
+ +
+
Months left
+
{{.MonthsLeft}}
+
+ +
+
At current rate
+ {{if gt .MonthsAtCurrentRate 0}} +
+ {{.MonthsAtCurrentRate}}mo +
+ {{else}} +
+ {{end}} +
+ +
+
Disposable after
+
€0
+
+ +
+
+ + +
+ {{if .Feasible}} + ✓ On track — your current savings rate covers the required €{{cents .MonthlyCents}}/month with {{.MonthsLeft}} months to go. + {{else}} + ⚠ At your current savings rate (€{{cents $d.AvgMonthlySavings}}/mo) you'd reach this in {{.MonthsAtCurrentRate}} months, missing the deadline by {{sub .MonthsAtCurrentRate .MonthsLeft}} months. + {{end}} +
+ + +
+ + + +
+
+ {{end}} +
+ +{{else}} +
+
🎯
+

No goals yet

+

Plan a purchase, a house deposit, or an emergency fund.
See exactly how much you need to save each month to get there.

+ +
+{{end}} + + + + + +{{end}} From 99be71be8a3a1f7f4ff461dfda4410238fb4405d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gon=C3=A7alo=20Rodrigues?= Date: Sat, 13 Jun 2026 16:28:58 +0100 Subject: [PATCH 2/3] fix: replace month input with month/year dropdowns in goal modal Co-Authored-By: Claude Sonnet 4.6 --- .../services/api/main/templates/goals.html | 68 +++++++++++++++---- 1 file changed, 56 insertions(+), 12 deletions(-) diff --git a/apps/finance/services/api/main/templates/goals.html b/apps/finance/services/api/main/templates/goals.html index b54a601..9ee1452 100644 --- a/apps/finance/services/api/main/templates/goals.html +++ b/apps/finance/services/api/main/templates/goals.html @@ -155,19 +155,39 @@

-
-
- - -
-
- - +
+ + +
+ +
+ +
+ +
+ +
@@ -190,6 +210,30 @@ function updateTypeHint(v) { document.getElementById('type-hint').textContent = typeHints[v] || ''; } +// populate year dropdown: current year + 10 years ahead +(function() { + const now = new Date(); + const yearSel = document.getElementById('deadline-year'); + const monthSel = document.getElementById('deadline-month'); + for (let y = now.getFullYear(); y <= now.getFullYear() + 10; y++) { + const opt = document.createElement('option'); + opt.value = String(y); + opt.textContent = String(y); + yearSel.appendChild(opt); + } + // default month to next month + const nextMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1); + monthSel.value = String(nextMonth.getMonth() + 1).padStart(2, '0'); + yearSel.value = String(nextMonth.getFullYear()); +})(); + +// wire hidden field before submit +document.querySelector('#new-goal-modal form').addEventListener('submit', function() { + const m = document.getElementById('deadline-month').value; + const y = document.getElementById('deadline-year').value; + document.getElementById('deadline-value').value = y + '-' + m; +}); + // close modal on backdrop click document.getElementById('new-goal-modal').addEventListener('click', function(e) { if (e.target === this) this.style.display = 'none'; From 35156e001de6943e6854dde6c11bdec38d3ad1bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gon=C3=A7alo=20Rodrigues?= Date: Sat, 13 Jun 2026 16:33:15 +0100 Subject: [PATCH 3/3] fix: change GoalPlan int fields to int64 to match sub template func Co-Authored-By: Claude Sonnet 4.6 --- apps/finance/services/api/main/handler.go | 12 ++++++------ apps/finance/services/api/main/models.go | 12 ++++++------ 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/apps/finance/services/api/main/handler.go b/apps/finance/services/api/main/handler.go index 8884713..548d7f0 100644 --- a/apps/finance/services/api/main/handler.go +++ b/apps/finance/services/api/main/handler.go @@ -1423,21 +1423,21 @@ func (h *Handler) Goals(w http.ResponseWriter, r *http.Request) { remaining = 0 } - monthsLeft := monthsBetween(now, g.Deadline) + monthsLeft := int64(monthsBetween(now, g.Deadline)) if monthsLeft < 1 { monthsLeft = 1 } - monthlyCents := remaining / int64(monthsLeft) + monthlyCents := remaining / monthsLeft - monthsAtRate := 0 + monthsAtRate := int64(0) if avgMonthlySavings > 0 { - monthsAtRate = int(remaining / avgMonthlySavings) + monthsAtRate = remaining / avgMonthlySavings } - progressPct := 0 + progressPct := int64(0) if g.TargetCents > 0 { - progressPct = int(float64(g.SavedCents) / float64(g.TargetCents) * 100) + progressPct = int64(float64(g.SavedCents) / float64(g.TargetCents) * 100) if progressPct > 100 { progressPct = 100 } diff --git a/apps/finance/services/api/main/models.go b/apps/finance/services/api/main/models.go index 9275e8f..7a01138 100644 --- a/apps/finance/services/api/main/models.go +++ b/apps/finance/services/api/main/models.go @@ -240,12 +240,12 @@ type Goal struct { // GoalPlan is computed at request time — never stored. type GoalPlan struct { Goal - MonthsLeft int - MonthlyCents int64 // required monthly contribution - ImpactOnDisposable int64 // how much disposable income this eats - MonthsAtCurrentRate int // months to reach goal at current savings rate - Feasible bool // can reach it by deadline at required monthly - ProgressPct int + MonthsLeft int64 + MonthlyCents int64 + ImpactOnDisposable int64 + MonthsAtCurrentRate int64 + Feasible bool + ProgressPct int64 } type GoalsData struct {