feat: #45 meta de poupança configurável + conquistas genéricas
- Nova tabela user_settings com savings_goal_pct (default 40%) - API GET /api/settings, PUT /api/settings - DashboardService inclui savings_goal_pct no payload - DashboardHandler dispara savings_checked com goal configurado - GameService.NotifyAction: checa pct >= goal (sem hardcode de 40%) - SettingsView: painel META DE POUPANÇA com input configurável - HomeView: widget mostra meta dinâmica (META: X%) e usa savingsGoal - stores/settings.ts: store com fetch e update Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
This commit is contained in:
@@ -86,9 +86,13 @@ func main() {
|
||||
accountHandler := handler.NewAccountHandler(accountSvc)
|
||||
creditBillHandler := handler.NewCreditBillHandler(creditBillSvc)
|
||||
|
||||
settingsRepo := repository.NewSettingsRepository(pool)
|
||||
settingsSvc := service.NewSettingsService(settingsRepo)
|
||||
settingsHandler := handler.NewSettingsHandler(settingsSvc)
|
||||
|
||||
dashboardRepo := repository.NewDashboardRepository(pool)
|
||||
dashboardSvc := service.NewDashboardService(dashboardRepo, recurringSvc, accountRepo, creditBillSvc, pendingBillSvc)
|
||||
dashboardHandler := handler.NewDashboardHandler(dashboardSvc)
|
||||
dashboardSvc := service.NewDashboardService(dashboardRepo, recurringSvc, accountRepo, creditBillSvc, pendingBillSvc, settingsSvc)
|
||||
dashboardHandler := handler.NewDashboardHandler(dashboardSvc, gameSvc)
|
||||
|
||||
r.Get("/health", handler.Health)
|
||||
|
||||
@@ -142,6 +146,9 @@ func main() {
|
||||
|
||||
r.Get("/dashboard", dashboardHandler.Get)
|
||||
|
||||
r.Get("/settings", settingsHandler.Get)
|
||||
r.Put("/settings", settingsHandler.Update)
|
||||
|
||||
r.Get("/game/summary", gameHandler.Summary)
|
||||
r.Post("/game/xp", gameHandler.AwardXP)
|
||||
r.Post("/game/quests/{id}/claim", gameHandler.ClaimQuest)
|
||||
|
||||
@@ -8,11 +8,12 @@ import (
|
||||
)
|
||||
|
||||
type DashboardHandler struct {
|
||||
svc *service.DashboardService
|
||||
svc *service.DashboardService
|
||||
gameSvc *service.GameService
|
||||
}
|
||||
|
||||
func NewDashboardHandler(svc *service.DashboardService) *DashboardHandler {
|
||||
return &DashboardHandler{svc: svc}
|
||||
func NewDashboardHandler(svc *service.DashboardService, gameSvc *service.GameService) *DashboardHandler {
|
||||
return &DashboardHandler{svc: svc, gameSvc: gameSvc}
|
||||
}
|
||||
|
||||
func (h *DashboardHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -26,5 +27,14 @@ func (h *DashboardHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load dashboard")
|
||||
return
|
||||
}
|
||||
|
||||
// Notify game engine about savings percentage for the current month only
|
||||
if month == time.Now().Format("2006-01") {
|
||||
h.gameSvc.NotifyAction(r.Context(), "savings_checked", map[string]any{
|
||||
"pct": data.SavingsPct,
|
||||
"goal": data.SavingsGoalPct,
|
||||
})
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, data)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"financeiro-carvalho/internal/model"
|
||||
"financeiro-carvalho/internal/service"
|
||||
)
|
||||
|
||||
type SettingsHandler struct {
|
||||
svc *service.SettingsService
|
||||
}
|
||||
|
||||
func NewSettingsHandler(svc *service.SettingsService) *SettingsHandler {
|
||||
return &SettingsHandler{svc: svc}
|
||||
}
|
||||
|
||||
func (h *SettingsHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
s, err := h.svc.Get(r.Context())
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to get settings")
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, s)
|
||||
}
|
||||
|
||||
func (h *SettingsHandler) Update(w http.ResponseWriter, r *http.Request) {
|
||||
var in model.UserSettings
|
||||
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
||||
respondError(w, http.StatusBadRequest, "invalid JSON")
|
||||
return
|
||||
}
|
||||
out, err := h.svc.Update(r.Context(), in)
|
||||
if err != nil {
|
||||
if err == service.ErrInvalidSavingsGoal {
|
||||
respondError(w, http.StatusUnprocessableEntity, err.Error())
|
||||
return
|
||||
}
|
||||
respondError(w, http.StatusInternalServerError, "failed to update settings")
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, out)
|
||||
}
|
||||
@@ -56,6 +56,9 @@ var m015 string
|
||||
//go:embed sql/016_pending_bill_imports.sql
|
||||
var m016 string
|
||||
|
||||
//go:embed sql/017_user_settings.sql
|
||||
var m017 string
|
||||
|
||||
func Run(ctx context.Context, pool *pgxpool.Pool) error {
|
||||
// Bootstrap: ensure schema_migrations table exists before checking versions.
|
||||
if _, err := pool.Exec(ctx, `
|
||||
@@ -67,7 +70,7 @@ func Run(ctx context.Context, pool *pgxpool.Pool) error {
|
||||
return fmt.Errorf("bootstrap schema_migrations: %w", err)
|
||||
}
|
||||
|
||||
migrations := []string{m001, m002, m003, m004, m005, m006, m007, m008, m009, m010, m011, m012, m013, m014, m015, m016}
|
||||
migrations := []string{m001, m002, m003, m004, m005, m006, m007, m008, m009, m010, m011, m012, m013, m014, m015, m016, m017}
|
||||
for i, sql := range migrations {
|
||||
version := i + 1
|
||||
var applied bool
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
CREATE TABLE IF NOT EXISTS user_settings (
|
||||
profile_id INTEGER PRIMARY KEY REFERENCES profiles(id) ON DELETE CASCADE,
|
||||
savings_goal_pct NUMERIC(5,2) NOT NULL DEFAULT 40,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
@@ -28,6 +28,7 @@ type DashboardData struct {
|
||||
TotalIncome float64 `json:"total_income"`
|
||||
TotalExpenses float64 `json:"total_expenses"`
|
||||
SavingsPct float64 `json:"savings_pct"`
|
||||
SavingsGoalPct float64 `json:"savings_goal_pct"`
|
||||
TotalPatrimony float64 `json:"total_patrimony"`
|
||||
ByCategory []CategoryTotal `json:"by_category"`
|
||||
MonthlyEvolution []MonthEvolution `json:"monthly_evolution"`
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
package model
|
||||
|
||||
type UserSettings struct {
|
||||
SavingsGoalPct float64 `json:"savings_goal_pct"`
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"financeiro-carvalho/internal/middleware"
|
||||
"financeiro-carvalho/internal/model"
|
||||
)
|
||||
|
||||
type SettingsRepository interface {
|
||||
Get(ctx context.Context) (*model.UserSettings, error)
|
||||
Upsert(ctx context.Context, s model.UserSettings) (*model.UserSettings, error)
|
||||
}
|
||||
|
||||
type settingsRepo struct{ pool *pgxpool.Pool }
|
||||
|
||||
func NewSettingsRepository(pool *pgxpool.Pool) SettingsRepository {
|
||||
return &settingsRepo{pool: pool}
|
||||
}
|
||||
|
||||
func (r *settingsRepo) Get(ctx context.Context) (*model.UserSettings, error) {
|
||||
pid := middleware.ProfileIDFromCtx(ctx)
|
||||
var s model.UserSettings
|
||||
err := r.pool.QueryRow(ctx, `
|
||||
SELECT savings_goal_pct FROM user_settings WHERE profile_id = $1
|
||||
`, pid).Scan(&s.SavingsGoalPct)
|
||||
if err != nil {
|
||||
// Return defaults if not yet configured
|
||||
return &model.UserSettings{SavingsGoalPct: 40}, nil
|
||||
}
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
func (r *settingsRepo) Upsert(ctx context.Context, s model.UserSettings) (*model.UserSettings, error) {
|
||||
pid := middleware.ProfileIDFromCtx(ctx)
|
||||
var out model.UserSettings
|
||||
err := r.pool.QueryRow(ctx, `
|
||||
INSERT INTO user_settings (profile_id, savings_goal_pct)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT (profile_id) DO UPDATE
|
||||
SET savings_goal_pct = EXCLUDED.savings_goal_pct, updated_at = NOW()
|
||||
RETURNING savings_goal_pct
|
||||
`, pid, s.SavingsGoalPct).Scan(&out.SavingsGoalPct)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
@@ -24,10 +24,11 @@ type DashboardService struct {
|
||||
patrimony PatrimonySource
|
||||
billSvc *CreditBillService
|
||||
pendingBillSvc *PendingBillService
|
||||
settingsSvc *SettingsService
|
||||
}
|
||||
|
||||
func NewDashboardService(repo DashboardRepo, recurrSvc *RecurringService, patrimony PatrimonySource, billSvc *CreditBillService, pendingBillSvc *PendingBillService) *DashboardService {
|
||||
return &DashboardService{repo: repo, recurrSvc: recurrSvc, patrimony: patrimony, billSvc: billSvc, pendingBillSvc: pendingBillSvc}
|
||||
func NewDashboardService(repo DashboardRepo, recurrSvc *RecurringService, patrimony PatrimonySource, billSvc *CreditBillService, pendingBillSvc *PendingBillService, settingsSvc *SettingsService) *DashboardService {
|
||||
return &DashboardService{repo: repo, recurrSvc: recurrSvc, patrimony: patrimony, billSvc: billSvc, pendingBillSvc: pendingBillSvc, settingsSvc: settingsSvc}
|
||||
}
|
||||
|
||||
func (s *DashboardService) Get(ctx context.Context, month string) (*model.DashboardData, error) {
|
||||
@@ -115,11 +116,18 @@ func (s *DashboardService) Get(ctx context.Context, month string) (*model.Dashbo
|
||||
pendingBillImports = []model.PendingBillImport{}
|
||||
}
|
||||
|
||||
settings, _ := s.settingsSvc.Get(ctx)
|
||||
goalPct := 40.0
|
||||
if settings != nil {
|
||||
goalPct = settings.SavingsGoalPct
|
||||
}
|
||||
|
||||
return &model.DashboardData{
|
||||
Month: month,
|
||||
TotalIncome: income,
|
||||
TotalExpenses: expenses,
|
||||
SavingsPct: savingsPct,
|
||||
SavingsGoalPct: goalPct,
|
||||
TotalPatrimony: patrimony,
|
||||
ByCategory: byCategory,
|
||||
MonthlyEvolution: evolution,
|
||||
|
||||
@@ -61,12 +61,22 @@ func (m *mockPendingBillRepo) GetByID(_ context.Context, _ int) (*model.PendingB
|
||||
}
|
||||
func (m *mockPendingBillRepo) Delete(_ context.Context, _ int) error { return nil }
|
||||
|
||||
type mockSettingsRepo struct{}
|
||||
|
||||
func (m *mockSettingsRepo) Get(_ context.Context) (*model.UserSettings, error) {
|
||||
return &model.UserSettings{SavingsGoalPct: 40}, nil
|
||||
}
|
||||
func (m *mockSettingsRepo) Upsert(_ context.Context, s model.UserSettings) (*model.UserSettings, error) {
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
func newDashboardSvc(income, expenses float64) *service.DashboardService {
|
||||
repo := &mockAccountRepo{}
|
||||
recurrSvc := service.NewRecurringService(newMockRecurring(nil), &mockTxRepo{})
|
||||
billSvc := service.NewCreditBillService(&mockCreditBillRepo{}, repo)
|
||||
pendingBillSvc := service.NewPendingBillService(&mockPendingBillRepo{}, &mockImportTxRepo{})
|
||||
return service.NewDashboardService(&mockDashboardRepo{income: income, expenses: expenses}, recurrSvc, &mockPatrimony{}, billSvc, pendingBillSvc)
|
||||
settingsSvc := service.NewSettingsService(&mockSettingsRepo{})
|
||||
return service.NewDashboardService(&mockDashboardRepo{income: income, expenses: expenses}, recurrSvc, &mockPatrimony{}, billSvc, pendingBillSvc, settingsSvc)
|
||||
}
|
||||
|
||||
func TestDashboard_SavingsPct_40(t *testing.T) {
|
||||
|
||||
@@ -165,9 +165,13 @@ func (s *GameService) NotifyAction(ctx context.Context, action string, extra map
|
||||
|
||||
case "savings_checked":
|
||||
pct, _ := extra["pct"].(float64)
|
||||
goal, _ := extra["goal"].(float64)
|
||||
if goal <= 0 {
|
||||
goal = 40
|
||||
}
|
||||
_ = s.repo.UpdateQuestPct(ctx, monthlyPeriod, pct)
|
||||
if pct >= 40 {
|
||||
_, _ = s.AwardXP(ctx, "savings_goal_met", 200, "Meta de 40% atingida!")
|
||||
if pct >= goal {
|
||||
_, _ = s.AwardXP(ctx, "savings_goal_met", 200, "Meta de poupança atingida!")
|
||||
s.tryUnlockAchievement(ctx, "first_40pct")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"financeiro-carvalho/internal/model"
|
||||
"financeiro-carvalho/internal/repository"
|
||||
)
|
||||
|
||||
var ErrInvalidSavingsGoal = errors.New("savings_goal_pct must be between 1 and 100")
|
||||
|
||||
type SettingsService struct {
|
||||
repo repository.SettingsRepository
|
||||
}
|
||||
|
||||
func NewSettingsService(repo repository.SettingsRepository) *SettingsService {
|
||||
return &SettingsService{repo: repo}
|
||||
}
|
||||
|
||||
func (s *SettingsService) Get(ctx context.Context) (*model.UserSettings, error) {
|
||||
return s.repo.Get(ctx)
|
||||
}
|
||||
|
||||
func (s *SettingsService) Update(ctx context.Context, in model.UserSettings) (*model.UserSettings, error) {
|
||||
if in.SavingsGoalPct < 1 || in.SavingsGoalPct > 100 {
|
||||
return nil, ErrInvalidSavingsGoal
|
||||
}
|
||||
return s.repo.Upsert(ctx, in)
|
||||
}
|
||||
Reference in New Issue
Block a user