diff --git a/apps/finance/services/api/main/handler.go b/apps/finance/services/api/main/handler.go index 6052d95..8e1d94a 100644 --- a/apps/finance/services/api/main/handler.go +++ b/apps/finance/services/api/main/handler.go @@ -277,6 +277,8 @@ type storeIface interface { 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 + getGoalFundedCentsAll(ctx context.Context, userID string) (map[string]int64, error) + getGoalTransactions(ctx context.Context, userID, goalID string) ([]Transaction, 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 @@ -465,7 +467,6 @@ func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) { 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 { @@ -493,9 +494,6 @@ func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) { 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) @@ -504,7 +502,6 @@ func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) { 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 @@ -514,15 +511,6 @@ func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) { 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) } @@ -565,97 +553,42 @@ func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) { 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 + // transaction-backed waterfall: split this month's transactions into buckets + waterfallLiving := int64(0) + waterfallGoals := int64(0) + incomeByCat := make(map[string]int64) + livingByCat := make(map[string]int64) + incomeCatTxns := make(map[string][]Transaction) + livingCatTxns := make(map[string][]Transaction) + goalFundedThisMonth := make(map[string]int64) + for _, t := range txns { + if t.Date.Before(thisStart) { + continue } - 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 + if t.AmountCents > 0 { + incomeByCat[t.Category] += t.AmountCents + incomeCatTxns[t.Category] = append(incomeCatTxns[t.Category], t) + } else { + if t.GoalID != "" { + waterfallGoals += -t.AmountCents + goalFundedThisMonth[t.GoalID] += -t.AmountCents + } else { + waterfallLiving += -t.AmountCents + livingByCat[t.Category] += -t.AmountCents + livingCatTxns[t.Category] = append(livingCatTxns[t.Category], t) } - 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 + waterfallFreeCash := thisMonthIncome - waterfallLiving - waterfallGoals - // 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 - } + // sort category rows by amount descending for the drill-down display + incomeCats := sortWaterfallRows(incomeByCat, catColors) + livingCats := sortWaterfallRows(livingByCat, catColors) // 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 { @@ -712,13 +645,15 @@ func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) { } } - // ── Committed goals for dashboard widget ───────────────────────────── + // ── Committed goals for dashboard widget (tx-backed progress) ──────── var dashGoals []GoalPlan + goalFunds, _ := h.store.getGoalFundedCentsAll(ctx, a.UserID) if allGoals, err2 := h.store.getGoals(ctx, a.UserID); err2 == nil { for _, g := range allGoals { if !g.Committed { continue } + g.SavedCents = goalFunds[g.ID] remaining := g.TargetCents - g.SavedCents if remaining < 0 { remaining = 0 @@ -728,10 +663,6 @@ func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) { ml = 1 } monthly := remaining / ml - var atRate int64 - if avgSavingsForGoals := disposableIncome; avgSavingsForGoals > 0 { - atRate = remaining / avgSavingsForGoals - } pct := int64(0) if g.TargetCents > 0 { pct = g.SavedCents * 100 / g.TargetCents @@ -740,12 +671,11 @@ func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) { } } dashGoals = append(dashGoals, GoalPlan{ - Goal: g, - MonthsLeft: ml, - MonthlyCents: monthly, - MonthsAtCurrentRate: atRate, - Feasible: disposableIncome >= monthly, - ProgressPct: pct, + Goal: g, + MonthsLeft: ml, + MonthlyCents: monthly, + Feasible: waterfallFreeCash >= monthly, + ProgressPct: pct, }) } } @@ -773,56 +703,17 @@ func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) { } } - // 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 - } + // goal deadline risk alerts (tx-backed remaining) + for _, g := range dashGoals { + if !g.Committed || g.SavedCents >= g.TargetCents { + continue } - var totalS int64 - for _, s := range moSavings { - if s > 0 { - totalS += s - } + if waterfallFreeCash < g.MonthlyCents { + alerts = append(alerts, Alert{ + Level: AlertWarn, + Message: fmt.Sprintf("Free cash (€%.0f) is below the monthly need for \"%s\" (€%.0f/mo).", float64(waterfallFreeCash)/100, g.Name, float64(g.MonthlyCents)/100), + }) } - 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{ @@ -840,14 +731,16 @@ func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) { ThisMonthExpense: thisMonthExpense, CategoryBudgets: catBudgets, CategoryColors: catColors, - AvailableToSpend: availableToSpend, - DisposableIncome: disposableIncome, + WaterfallIncome: thisMonthIncome, + WaterfallLiving: waterfallLiving, + WaterfallGoals: waterfallGoals, + WaterfallFreeCash: waterfallFreeCash, + IncomeCats: incomeCats, + LivingCats: livingCats, + IncomeCatTxns: incomeCatTxns, + LivingCatTxns: livingCatTxns, + GoalFundedThisMonth: goalFundedThisMonth, MonthProgressPct: monthProgressPct, - MonthSpentPct: monthSpentPct, - RecurringExpenses: recurringExpenses, - BankShouldBe: bankShouldBe, - SafetyBufferCents: safetyBuffer, - TotalCommittedCents: totalCommittedCents, SavingsRatePct: savingsRatePct, LastMonthSavingsRatePct: lastMonthSavingsRatePct, PortfolioValueCents: portfolioValueCents, @@ -902,6 +795,7 @@ func (h *Handler) Transactions(w http.ResponseWriter, r *http.Request) { cats, _ := h.store.getCategories(ctx, a.UserID) accounts, _ := h.store.getAccounts(ctx, a.UserID) + goals, _ := h.store.getGoals(ctx, a.UserID) accountNames := make(map[string]string) for _, acc := range accounts { @@ -923,6 +817,7 @@ func (h *Handler) Transactions(w http.ResponseWriter, r *http.Request) { "Txns": txns, "Categories": cats, "Accounts": accounts, + "Goals": goals, "AccountNames": accountNames, "CategoryColors": catColors, "Cat": cat, @@ -942,6 +837,7 @@ func (h *Handler) CreateTransaction(w http.ResponseWriter, r *http.Request) { Description string `json:"description"` AmountCents int64 `json:"amount_cents"` Category string `json:"category"` + GoalID string `json:"goal_id"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil { http.Error(w, "bad request", http.StatusBadRequest) @@ -961,6 +857,7 @@ func (h *Handler) CreateTransaction(w http.ResponseWriter, r *http.Request) { Description: body.Description, AmountCents: body.AmountCents, Category: body.Category, + GoalID: body.GoalID, CreatedAt: time.Now(), } @@ -1270,6 +1167,7 @@ func (h *Handler) UpdateTransaction(w http.ResponseWriter, r *http.Request) { var body struct { Category string `json:"category"` Description string `json:"description"` + GoalID string `json:"goal_id"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil { http.Error(w, "bad request", http.StatusBadRequest) @@ -1283,6 +1181,9 @@ func (h *Handler) UpdateTransaction(w http.ResponseWriter, r *http.Request) { if body.Description != "" { update["description"] = body.Description } + if body.GoalID != "" { + update["goal_id"] = body.GoalID + } if err := h.store.updateTransaction(ctx, id, a.UserID, update); err != nil { slog.Error("update transaction", "err", err) @@ -1905,44 +1806,57 @@ func (h *Handler) Goals(w http.ResponseWriter, r *http.Request) { // 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) + thisStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location()) + threeMonthsAgo := thisStart.AddDate(0, -3, 0) + // Use year*12+month as key to avoid cross-year collisions + type monthKey struct{ year, month int } + monthlySavings := make(map[monthKey]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 + if !t.Date.Before(threeMonthsAgo) && t.Date.Before(thisStart) { + k := monthKey{t.Date.Year(), int(t.Date.Month())} + monthlySavings[k] += t.AmountCents } } var totalSavings int64 + var posMonths int64 for _, s := range monthlySavings { if s > 0 { totalSavings += s + posMonths++ } } avgMonthlySavings := int64(0) - if len(monthlySavings) > 0 { - avgMonthlySavings = totalSavings / int64(len(monthlySavings)) + if posMonths > 0 { + avgMonthlySavings = totalSavings / posMonths } - // 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) + // transaction-backed waterfall for this month + waterfallIncome := int64(0) + waterfallLiving := int64(0) + waterfallGoals := 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 + waterfallIncome += t.AmountCents + } else { + if t.GoalID != "" { + waterfallGoals += -t.AmountCents + } else { + waterfallLiving += -t.AmountCents + } } } - disposable := thisMonthIncome - fixedThisMonth + waterfallFreeCash := waterfallIncome - waterfallLiving - waterfallGoals - // build goal plans + // tx-backed funded amounts and recent contributions per goal + goalFunds, _ := h.store.getGoalFundedCentsAll(ctx, a.UserID) + + // build goal plans with tx-backed SavedCents var plans []GoalPlan for _, g := range goals { + g.SavedCents = goalFunds[g.ID] remaining := g.TargetCents - g.SavedCents if remaining < 0 { remaining = 0 @@ -1968,55 +1882,32 @@ func (h *Handler) Goals(w http.ResponseWriter, r *http.Request) { } } + fundingTxns, _ := h.store.getGoalTransactions(ctx, a.UserID, g.ID) plans = append(plans, GoalPlan{ Goal: g, MonthsLeft: monthsLeft, MonthlyCents: monthlyCents, - ImpactOnDisposable: disposable - monthlyCents, + ImpactOnDisposable: waterfallIncome - waterfallLiving - monthlyCents, MonthsAtCurrentRate: monthsAtRate, Feasible: avgMonthlySavings >= monthlyCents, ProgressPct: progressPct, + FundingTxns: fundingTxns, }) } - // 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 - } - data := &GoalsData{ - T: h.t(r), - UserID: a.UserID, - Email: a.Email, - Title: "Goals", - Route: "goals", - Tab: r.URL.Query().Get("tab"), - Goals: plans, - AvgMonthlySavings: avgMonthlySavings, - DisposableIncome: disposable, - CommittedMonthlyCents: committedTotal, - RemainingDisposable: remainingDisposable, - ConflictWarning: conflictWarning, + T: h.t(r), + UserID: a.UserID, + Email: a.Email, + Title: "Goals", + Route: "goals", + Tab: r.URL.Query().Get("tab"), + Goals: plans, + AvgMonthlySavings: avgMonthlySavings, + WaterfallIncome: waterfallIncome, + WaterfallLiving: waterfallLiving, + WaterfallGoals: waterfallGoals, + WaterfallFreeCash: waterfallFreeCash, } if data.Tab == "" { data.Tab = "goals" @@ -2912,6 +2803,16 @@ func sortStrings(s []string) { sort.Strings(s) } +// sortWaterfallRows converts a category→cents map into a slice sorted by amount descending. +func sortWaterfallRows(byCat map[string]int64, colors map[string]string) []WaterfallRow { + rows := make([]WaterfallRow, 0, len(byCat)) + for name, cents := range byCat { + rows = append(rows, WaterfallRow{Name: name, Color: colors[name], Cents: cents}) + } + sort.Slice(rows, func(i, j int) bool { return rows[i].Cents > rows[j].Cents }) + return rows +} + func appendIfMissing(s []string, v string) []string { for _, x := range s { if x == v { diff --git a/apps/finance/services/api/main/locales/en.toml b/apps/finance/services/api/main/locales/en.toml index bf74b3d..7344499 100644 --- a/apps/finance/services/api/main/locales/en.toml +++ b/apps/finance/services/api/main/locales/en.toml @@ -243,6 +243,15 @@ total_committed = "Total committed" vs_last_month_up = "↑ vs last month" vs_last_month_down = "↓ vs last month" +[dashboard.waterfall] +title = "Cash flow this month" +income = "Income" +living = "Living expenses" +goals = "Goal contributions" +goals_link = "manage →" +free_cash = "Free cash" +month_progress = "Month progress" + # ── Transactions ───────────────────────────────────────────────────────────── [transactions] @@ -287,6 +296,8 @@ placeholder_amount = "0.00" label_category = "Category" label_account = "Account" option_no_account = "— none —" +label_goal = "Fund a goal (optional)" +option_no_goal = "— not a goal contribution —" btn_cancel = "Cancel" btn_save = "Save Transaction" error_required = "Please fill in date, description, and a positive amount." @@ -338,11 +349,11 @@ tab_planner = "Goal Planner" [goals.summary_cards] avg_monthly_savings = "Avg monthly savings" last_3_months = "last 3 months" -disposable_income = "Disposable income" -before_goals = "before goals" -reserved_for_goals = "Reserved for goals" -per_month = "per month" -free_to_spend = "Free to spend" +income = "Income" +this_month = "this month" +goal_funded = "Funded to goals" +tagged_transactions = "via tagged transactions" +free_cash = "Free cash" after_goals = "after goals" [goals.goal_card] @@ -362,6 +373,9 @@ btn_committed = "✓ Committed — click to uncommit" btn_commit = "Commit to this goal" btn_remove = "Remove" confirm_remove = "Remove this goal?" +btn_fund = "Fund this goal" +funding_history = "Recent contributions" +no_funding_yet = "No transactions tagged yet. Add a transaction and link it to this goal." [goals.empty] title = "No goals yet" diff --git a/apps/finance/services/api/main/locales/pt.toml b/apps/finance/services/api/main/locales/pt.toml index 0964dfd..8995294 100644 --- a/apps/finance/services/api/main/locales/pt.toml +++ b/apps/finance/services/api/main/locales/pt.toml @@ -243,6 +243,15 @@ total_committed = "Total comprometido" vs_last_month_up = "↑ vs mês anterior" vs_last_month_down = "↓ vs mês anterior" +[dashboard.waterfall] +title = "Fluxo de caixa este mês" +income = "Rendimento" +living = "Despesas de vida" +goals = "Contribuições para objetivos" +goals_link = "gerir →" +free_cash = "Dinheiro livre" +month_progress = "Progresso do mês" + # ── Transações ──────────────────────────────────────────────────────────────── [transactions] @@ -287,6 +296,8 @@ placeholder_amount = "0,00" label_category = "Categoria" label_account = "Conta" option_no_account = "— nenhuma —" +label_goal = "Financiar um objetivo (opcional)" +option_no_goal = "— não é contribuição para objetivo —" btn_cancel = "Cancelar" btn_save = "Guardar Transação" error_required = "Por favor, preencha a data, a descrição e um valor positivo." @@ -338,11 +349,11 @@ tab_planner = "Planeador de Objetivos" [goals.summary_cards] avg_monthly_savings = "Poupança mensal média" last_3_months = "últimos 3 meses" -disposable_income = "Rendimento disponível" -before_goals = "antes dos objetivos" -reserved_for_goals = "Reservado para objetivos" -per_month = "por mês" -free_to_spend = "Livre para gastar" +income = "Rendimento" +this_month = "este mês" +goal_funded = "Financiado para objetivos" +tagged_transactions = "via transações etiquetadas" +free_cash = "Dinheiro livre" after_goals = "após objetivos" [goals.goal_card] @@ -362,6 +373,9 @@ btn_committed = "✓ Comprometido — clique para cancelar compromisso btn_commit = "Comprometer com este objetivo" btn_remove = "Remover" confirm_remove = "Remover este objetivo?" +btn_fund = "Financiar este objetivo" +funding_history = "Contribuições recentes" +no_funding_yet = "Nenhuma transação etiquetada ainda. Adicione uma transação e associe-a a este objetivo." [goals.empty] title = "Sem objetivos ainda" diff --git a/apps/finance/services/api/main/models.go b/apps/finance/services/api/main/models.go index 769209a..bc9b0a5 100644 --- a/apps/finance/services/api/main/models.go +++ b/apps/finance/services/api/main/models.go @@ -49,6 +49,7 @@ type Transaction struct { Description string `bson:"description" json:"description"` AmountCents int64 `bson:"amount_cents" json:"amount_cents"` Category string `bson:"category" json:"category"` + GoalID string `bson:"goal_id,omitempty" json:"goal_id,omitempty"` BankRef string `bson:"bank_ref,omitempty" json:"bank_ref,omitempty"` RawCSV string `bson:"raw_csv,omitempty" json:"raw_csv,omitempty"` CreatedAt time.Time `bson:"created_at" json:"created_at"` @@ -114,10 +115,11 @@ var FixedCategories = map[string]bool{ "Investments": true, } -type RecurringExpense struct { - Category string - MonthlyCents int64 - IsGoal bool // true when this entry comes from a committed goal +// WaterfallRow is one drilldown entry inside the interactive waterfall. +type WaterfallRow struct { + Name string + Color string + Cents int64 // always positive (absolute spend or income amount) } type DashboardData struct { @@ -132,23 +134,26 @@ type DashboardData struct { RecentTxns []Transaction BalanceTrend []BalancePoint - // Phase 1 fields ThisMonthIncome int64 ThisMonthExpense int64 CategoryBudgets map[string]int64 CategoryColors map[string]string + MonthProgressPct int - AvailableToSpend int64 // income − fixed − variable budgets spent so far - DisposableIncome int64 // income − fixed recurring costs - MonthProgressPct int // % of month elapsed - MonthSpentPct int // % of disposable already spent + // Transaction-backed waterfall totals + WaterfallIncome int64 + WaterfallLiving int64 + WaterfallGoals int64 + WaterfallFreeCash int64 - RecurringExpenses []RecurringExpense - BankShouldBe int64 - SafetyBufferCents int64 - TotalCommittedCents int64 // sum of all fixed costs + committed goals + // Drill-down: sorted category rows + pre-grouped transactions + IncomeCats []WaterfallRow + LivingCats []WaterfallRow + IncomeCatTxns map[string][]Transaction // category → this-month income txns + LivingCatTxns map[string][]Transaction // category → this-month living txns + GoalFundedThisMonth map[string]int64 // goalID → amount funded this month - SavingsRatePct int // savings / income * 100 this month + SavingsRatePct int LastMonthSavingsRatePct int PortfolioValueCents int64 @@ -159,7 +164,7 @@ type DashboardData struct { NetWorthCents int64 Alerts []Alert - DashGoals []GoalPlan // committed goals for the dashboard widget + DashGoals []GoalPlan } type PeriodSummary struct { @@ -472,6 +477,7 @@ type GoalPlan struct { MonthsAtCurrentRate int64 Feasible bool ProgressPct int64 + FundingTxns []Transaction // recent transactions tagged to this goal } type GoalsData struct { @@ -481,12 +487,13 @@ type GoalsData struct { Title string Route string Tab string // "goals" or "planner" - 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 + Goals []GoalPlan + AvgMonthlySavings int64 + // Waterfall (transaction-backed) + WaterfallIncome int64 // gross income this month + WaterfallLiving int64 // outflows not tagged to any goal + WaterfallGoals int64 // outflows tagged to goals this month + WaterfallFreeCash int64 // income - living - goals // Planner tab PlannerType string // "purchase" or "transition" PlanProperties []PropertyView diff --git a/apps/finance/services/api/main/seed.go b/apps/finance/services/api/main/seed.go index f54769e..7590be1 100644 --- a/apps/finance/services/api/main/seed.go +++ b/apps/finance/services/api/main/seed.go @@ -84,6 +84,14 @@ func SeedExtras(ctx context.Context, store *Store) { if err := seedGoals(ctx, store, userID); err != nil { slog.Error("seed: goals failed", "err", err) } + goals, _ = store.getGoals(ctx, userID) + } + + // goal-tagged transactions (tx-backed progress — idempotent) + if len(goals) > 0 { + if err := seedGoalTransactions(ctx, store, userID, goals); err != nil { + slog.Error("seed: goal transactions failed", "err", err) + } } // properties & loans @@ -152,6 +160,103 @@ func seedGoals(ctx context.Context, store *Store, userID string) error { return nil } +// seedGoalTransactions back-fills goal-tagged transactions so the tx-backed +// SavedCents and waterfall reflect realistic progress. Idempotent — skips if +// any goal-tagged transactions already exist. +func seedGoalTransactions(ctx context.Context, store *Store, userID string, goals []Goal) error { + existing, _ := store.getTransactions(ctx, userID, bson.M{ + "goal_id": bson.M{"$exists": true, "$ne": ""}, + }) + if len(existing) > 0 { + slog.Info("seed: goal transactions already present, skipping") + return nil + } + + accounts, _ := store.getAccounts(ctx, userID) + savingsID := "" + checkingID := "" + for _, a := range accounts { + switch a.Type { + case "savings": + savingsID = a.ID + case "checking": + if checkingID == "" { + checkingID = a.ID + } + } + } + if savingsID == "" { + savingsID = checkingID + } + if savingsID == "" && len(accounts) > 0 { + savingsID = accounts[0].ID + } + + goalsByName := make(map[string]Goal, len(goals)) + for _, g := range goals { + goalsByName[g.Name] = g + } + + now := time.Now() + monthStart := func(monthsAgo int, day int) time.Time { + t := time.Date(now.Year(), now.Month(), day, 0, 0, 0, 0, now.Location()) + return t.AddDate(0, -monthsAgo, 0) + } + + var txns []Transaction + mkTxn := func(date time.Time, desc string, cents int64, goalID string) { + txns = append(txns, Transaction{ + ID: bson.NewObjectID().Hex(), + UserID: userID, + AccountID: savingsID, + Date: date, + Description: desc, + AmountCents: -cents, // outflow + Category: "Investments", + GoalID: goalID, + CreatedAt: time.Now(), + }) + } + + // Emergency fund (3 months) — 7×€300 past months + €300 this month = €2,400 + if g, ok := goalsByName["Emergency fund (3 months)"]; ok { + for i := 7; i >= 1; i-- { + mkTxn(monthStart(i, 5), "Emergency fund transfer", 30000, g.ID) + } + mkTxn(monthStart(0, 5), "Emergency fund transfer", 30000, g.ID) + } + + // House down payment — 10×€500 past months + €500 this month = €5,500 + if g, ok := goalsByName["House down payment"]; ok { + for i := 10; i >= 1; i-- { + mkTxn(monthStart(i, 10), "House down payment savings", 50000, g.ID) + } + mkTxn(monthStart(0, 10), "House down payment savings", 50000, g.ID) + } + + // Japan trip — 4×€200 past months + €200 this month = €1,000 + if g, ok := goalsByName["Japan trip"]; ok { + for i := 4; i >= 1; i-- { + mkTxn(monthStart(i, 15), "Japan trip savings", 20000, g.ID) + } + mkTxn(monthStart(0, 15), "Japan trip savings", 20000, g.ID) + } + + // MacBook Pro — 2×€200 past months + €200 this month = €600 + if g, ok := goalsByName["MacBook Pro"]; ok { + for i := 2; i >= 1; i-- { + mkTxn(monthStart(i, 20), "MacBook Pro savings", 20000, g.ID) + } + mkTxn(monthStart(0, 20), "MacBook Pro savings", 20000, g.ID) + } + + if len(txns) == 0 { + return nil + } + slog.Info("seed: inserting goal-tagged transactions", "count", len(txns)) + return store.createTransactions(ctx, txns) +} + func seedProperty(ctx context.Context, store *Store, userID string) error { now := time.Now() propID := bson.NewObjectID().Hex() diff --git a/apps/finance/services/api/main/store.go b/apps/finance/services/api/main/store.go index df5fa33..78da5a7 100644 --- a/apps/finance/services/api/main/store.go +++ b/apps/finance/services/api/main/store.go @@ -301,6 +301,62 @@ func (s *Store) deleteGoal(ctx context.Context, id, userID string) error { return err } +// getGoalFundedCentsAll aggregates all transactions tagged with a goal_id and returns +// a map of goalID → total funded cents (absolute value of outflows). +func (s *Store) getGoalFundedCentsAll(ctx context.Context, userID string) (map[string]int64, error) { + ctx, span := mongo.StartSpan(ctx, "Store.getGoalFundedCentsAll") + defer span.End() + pipeline := bson.A{ + bson.M{"$match": bson.M{ + "user_id": userID, + "goal_id": bson.M{"$exists": true, "$ne": ""}, + "amount_cents": bson.M{"$lt": 0}, + }}, + bson.M{"$group": bson.M{ + "_id": "$goal_id", + "total": bson.M{"$sum": bson.M{"$abs": "$amount_cents"}}, + }}, + } + cur, err := s.transactions().Aggregate(ctx, pipeline) + if err != nil { + return nil, err + } + defer cur.Close(ctx) + var rows []struct { + ID string `bson:"_id"` + Total int64 `bson:"total"` + } + if err := cur.All(ctx, &rows); err != nil { + return nil, err + } + result := make(map[string]int64, len(rows)) + for _, r := range rows { + result[r.ID] = r.Total + } + return result, nil +} + +// getGoalTransactions returns the most recent transactions tagged to a specific goal. +func (s *Store) getGoalTransactions(ctx context.Context, userID, goalID string) ([]Transaction, error) { + ctx, span := mongo.StartSpan(ctx, "Store.getGoalTransactions") + defer span.End() + opts := options.Find().SetSort(bson.M{"date": -1}).SetLimit(5) + cur, err := s.transactions().Find(ctx, bson.M{ + "user_id": userID, + "goal_id": goalID, + "amount_cents": bson.M{"$lt": 0}, + }, opts) + if err != nil { + return nil, err + } + defer cur.Close(ctx) + var txns []Transaction + if err := cur.All(ctx, &txns); err != nil { + return nil, err + } + return txns, nil +} + 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/dashboard.html b/apps/finance/services/api/main/templates/dashboard.html index c7138df..26781b9 100644 --- a/apps/finance/services/api/main/templates/dashboard.html +++ b/apps/finance/services/api/main/templates/dashboard.html @@ -20,45 +20,206 @@ {{end}} - +