diff --git a/apps/finance/services/api/main/handler.go b/apps/finance/services/api/main/handler.go index 548d7f0..b513101 100644 --- a/apps/finance/services/api/main/handler.go +++ b/apps/finance/services/api/main/handler.go @@ -324,6 +324,27 @@ func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) { // disposable income = income - fixed recurring disposableIncome := thisMonthIncome - totalFixedCents + // deduct committed goal contributions from disposable + 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 + } + committedGoalsCents += remaining / ml + } + } + disposableIncome -= committedGoalsCents + // variable spend so far this month (non-fixed categories, expenses only) variableSpent := int64(0) for cat, amt := range thisMonth.ByCategory { @@ -1347,6 +1368,13 @@ func (h *Handler) Goals(w http.ResponseWriter, r *http.Request) { 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")) @@ -1454,14 +1482,42 @@ func (h *Handler) Goals(w http.ResponseWriter, r *http.Request) { }) } + // 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, + UserID: a.UserID, + Email: a.Email, + Title: "Goals", + Route: "goals", + Goals: plans, + AvgMonthlySavings: avgMonthlySavings, + DisposableIncome: disposable, + CommittedMonthlyCents: committedTotal, + RemainingDisposable: remainingDisposable, + ConflictWarning: conflictWarning, }) } diff --git a/apps/finance/services/api/main/models.go b/apps/finance/services/api/main/models.go index 7a01138..6000ee7 100644 --- a/apps/finance/services/api/main/models.go +++ b/apps/finance/services/api/main/models.go @@ -249,11 +249,14 @@ type GoalPlan struct { } 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 + UserID string + Email string + Title string + Route string + Goals []GoalPlan + AvgMonthlySavings int64 + DisposableIncome int64 + CommittedMonthlyCents int64 // sum of all committed goal contributions + RemainingDisposable int64 // disposable after committed goals + ConflictWarning string // set when committing would exceed disposable } diff --git a/apps/finance/services/api/main/store.go b/apps/finance/services/api/main/store.go index 729ad0e..3c0fc28 100644 --- a/apps/finance/services/api/main/store.go +++ b/apps/finance/services/api/main/store.go @@ -287,6 +287,13 @@ func (s *Store) createGoal(ctx context.Context, g *Goal) error { return err } +func (s *Store) updateGoal(ctx context.Context, id, userID string, update bson.M) error { + ctx, span := mongo.StartSpan(ctx, "Store.updateGoal") + defer span.End() + _, err := s.goals().UpdateOne(ctx, bson.M{"_id": id, "user_id": userID}, bson.M{"$set": update}) + return err +} + func (s *Store) deleteGoal(ctx context.Context, id, userID string) error { ctx, span := mongo.StartSpan(ctx, "Store.deleteGoal") defer span.End() diff --git a/apps/finance/services/api/main/templates/dashboard.html b/apps/finance/services/api/main/templates/dashboard.html index 02b62a7..48fc4de 100644 --- a/apps/finance/services/api/main/templates/dashboard.html +++ b/apps/finance/services/api/main/templates/dashboard.html @@ -224,4 +224,35 @@ + +{{if $d.RecurringExpenses}} +
this month
+before goals
per month
+after goals
+