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 @@
ReportsProjectionsPortfolio
+ GoalsSharing{{.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.