diff --git a/apps/finance/services/api/main/handler.go b/apps/finance/services/api/main/handler.go index 8e1d94a..e720644 100644 --- a/apps/finance/services/api/main/handler.go +++ b/apps/finance/services/api/main/handler.go @@ -849,6 +849,19 @@ func (h *Handler) CreateTransaction(w http.ResponseWriter, r *http.Request) { date = time.Now() } + // Auto-tag: if no explicit goal_id but the category has one linked, inherit it. + goalID := body.GoalID + if goalID == "" && body.Category != "" && body.AmountCents < 0 { + if cats, err2 := h.store.getCategories(ctx, a.UserID); err2 == nil { + for _, c := range cats { + if c.Name == body.Category && c.GoalID != "" { + goalID = c.GoalID + break + } + } + } + } + txn := Transaction{ ID: bson.NewObjectID().Hex(), UserID: a.UserID, @@ -857,7 +870,7 @@ func (h *Handler) CreateTransaction(w http.ResponseWriter, r *http.Request) { Description: body.Description, AmountCents: body.AmountCents, Category: body.Category, - GoalID: body.GoalID, + GoalID: goalID, CreatedAt: time.Now(), } @@ -1300,6 +1313,7 @@ func (h *Handler) Categories(w http.ResponseWriter, r *http.Request) { Name string `json:"name"` Color string `json:"color"` BudgetCents int64 `json:"budget_cents"` + GoalID string `json:"goal_id"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil { http.Error(w, "bad request", http.StatusBadRequest) @@ -1311,6 +1325,7 @@ func (h *Handler) Categories(w http.ResponseWriter, r *http.Request) { Name: body.Name, Color: body.Color, BudgetCents: body.BudgetCents, + GoalID: body.GoalID, } if err := h.store.updateCategory(r.Context(), cat); err != nil { slog.Error("update category", "err", err) @@ -2710,16 +2725,24 @@ func (h *Handler) Settings(w http.ResponseWriter, r *http.Request) { accounts, _ := h.store.getAccounts(ctx, a.UserID) categories, _ := h.store.getCategories(ctx, a.UserID) + goals, _ := h.store.getGoals(ctx, a.UserID) + + goalNameByID := make(map[string]string, len(goals)) + for _, g := range goals { + goalNameByID[g.ID] = g.Name + } render(w, settingsTmpl, &SettingsData{ - T: h.t(r), - UserID: a.UserID, - Email: a.Email, - Title: "Settings", - Route: "settings", - Tab: tab, - Accounts: accounts, - Categories: categories, + T: h.t(r), + UserID: a.UserID, + Email: a.Email, + Title: "Settings", + Route: "settings", + Tab: tab, + Accounts: accounts, + Categories: categories, + Goals: goals, + GoalNameByID: goalNameByID, }) } diff --git a/apps/finance/services/api/main/locales/en.toml b/apps/finance/services/api/main/locales/en.toml index f173852..2db5719 100644 --- a/apps/finance/services/api/main/locales/en.toml +++ b/apps/finance/services/api/main/locales/en.toml @@ -258,6 +258,9 @@ goals = "Goal contributions" goals_link = "manage →" free_cash = "Free cash" month_progress = "Month progress" +what_now = "What now?" +fund_link = "Fund" +all_funded = "All committed goals funded this month ✓" # ── Transactions ───────────────────────────────────────────────────────────── @@ -830,6 +833,7 @@ placeholder_budget = "Monthly budget (cents)" btn_add = "Add" col_category = "Category" col_budget = "Monthly Budget" +col_goal = "Auto-tags goal" btn_delete = "Delete" empty_msg = "No categories yet — add one above." confirm_delete = "Delete this category?" @@ -838,6 +842,9 @@ confirm_delete = "Delete this category?" title = "Edit Category" placeholder_name = "Name" placeholder_budget = "Budget (cents)" +label_goal = "Auto-tag goal" +option_no_goal = "— none —" +goal_hint = "Expenses in this category will automatically fund the selected goal." btn_cancel = "Cancel" btn_save = "Save" diff --git a/apps/finance/services/api/main/locales/pt.toml b/apps/finance/services/api/main/locales/pt.toml index ddd101c..044dc51 100644 --- a/apps/finance/services/api/main/locales/pt.toml +++ b/apps/finance/services/api/main/locales/pt.toml @@ -258,6 +258,9 @@ goals = "Contribuições para objetivos" goals_link = "gerir →" free_cash = "Dinheiro livre" month_progress = "Progresso do mês" +what_now = "E agora?" +fund_link = "Financiar" +all_funded = "Todos os objetivos comprometidos financiados este mês ✓" # ── Transações ──────────────────────────────────────────────────────────────── @@ -830,6 +833,7 @@ placeholder_budget = "Orçamento mensal (cêntimos)" btn_add = "Adicionar" col_category = "Categoria" col_budget = "Orçamento Mensal" +col_goal = "Etiqueta automática" btn_delete = "Eliminar" empty_msg = "Sem categorias ainda — adicione uma acima." confirm_delete = "Eliminar esta categoria?" @@ -838,6 +842,9 @@ confirm_delete = "Eliminar esta categoria?" title = "Editar Categoria" placeholder_name = "Nome" placeholder_budget = "Orçamento (cêntimos)" +label_goal = "Etiquetar objetivo automaticamente" +option_no_goal = "— nenhum —" +goal_hint = "As despesas nesta categoria irão financiar automaticamente o objetivo selecionado." btn_cancel = "Cancelar" btn_save = "Guardar" diff --git a/apps/finance/services/api/main/models.go b/apps/finance/services/api/main/models.go index bc9b0a5..976d1c7 100644 --- a/apps/finance/services/api/main/models.go +++ b/apps/finance/services/api/main/models.go @@ -34,11 +34,13 @@ type Account struct { } type Category struct { - ID string `bson:"_id" json:"id"` - UserID string `bson:"user_id" json:"user_id"` - Name string `bson:"name" json:"name"` - Color string `bson:"color" json:"color"` - BudgetCents int64 `bson:"budget_cents" json:"budget_cents"` + ID string `bson:"_id" json:"id"` + UserID string `bson:"user_id" json:"user_id"` + Name string `bson:"name" json:"name"` + Color string `bson:"color" json:"color"` + BudgetCents int64 `bson:"budget_cents" json:"budget_cents"` + // GoalID, when set, auto-tags transactions in this category to the linked goal. + GoalID string `bson:"goal_id,omitempty" json:"goal_id,omitempty"` } type Transaction struct { @@ -322,6 +324,8 @@ type SettingsData struct { Tab string // "accounts" | "categories" Accounts []Account Categories []Category + Goals []Goal // for category → goal linking dropdown + GoalNameByID map[string]string // goalID → name, for table display } type HouseholdData struct { diff --git a/apps/finance/services/api/main/store.go b/apps/finance/services/api/main/store.go index 78da5a7..a434568 100644 --- a/apps/finance/services/api/main/store.go +++ b/apps/finance/services/api/main/store.go @@ -103,11 +103,13 @@ func (s *Store) createCategory(ctx context.Context, c *Category) error { func (s *Store) updateCategory(ctx context.Context, c *Category) error { ctx, span := mongo.StartSpan(ctx, "Store.updateCategory") defer span.End() - _, err := s.categories().UpdateOne(ctx, bson.M{"_id": c.ID, "user_id": c.UserID}, bson.M{"$set": bson.M{ - "name": c.Name, - "color": c.Color, + update := bson.M{ + "name": c.Name, + "color": c.Color, "budget_cents": c.BudgetCents, - }}) + "goal_id": c.GoalID, // "" clears the link + } + _, err := s.categories().UpdateOne(ctx, bson.M{"_id": c.ID, "user_id": c.UserID}, bson.M{"$set": update}) return err } diff --git a/apps/finance/services/api/main/templates/dashboard.html b/apps/finance/services/api/main/templates/dashboard.html index c29838d..d64f56c 100644 --- a/apps/finance/services/api/main/templates/dashboard.html +++ b/apps/finance/services/api/main/templates/dashboard.html @@ -153,6 +153,29 @@ background:var(--text3); transition:width 1s ease;"> + + + {{if $d.DashGoals}} +
+
{{$d.T.Get "dashboard.waterfall.what_now"}}
+
+ {{range $d.DashGoals}} + {{if .Committed}} + {{$funded := index $d.GoalFundedThisMonth .ID}} + {{$needed := .MonthlyCents}} + {{if lt $funded $needed}} + {{$shortfall := sub $needed $funded}} +
+ {{.Name}} −€{{cents $shortfall}} + {{$d.T.Get "dashboard.waterfall.fund_link"}} → +
+ {{end}} + {{end}} + {{end}} +
+ +
+ {{end}}