diff --git a/apps/finance/services/api/main/handler.go b/apps/finance/services/api/main/handler.go index b1fa4b2..548d7f0 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 := 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, + }) + } + + 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..7a01138 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 int64 + MonthlyCents int64 + ImpactOnDisposable int64 + MonthsAtCurrentRate int64 + Feasible bool + ProgressPct int64 +} + +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
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..9ee1452 --- /dev/null +++ b/apps/finance/services/api/main/templates/goals.html @@ -0,0 +1,242 @@ +{{define "content"}} +{{$d := .}} + +last 3 months
+this month
+Plan a purchase, a house deposit, or an emergency fund.
See exactly how much you need to save each month to get there.