From 1d59cb2a0e45dee874e474f21bef8cc0dd2f9ee4 Mon Sep 17 00:00:00 2001 From: Mlcarvalho1 Date: Thu, 28 May 2026 22:32:08 -0300 Subject: [PATCH] =?UTF-8?q?feat:=20#45=20meta=20de=20poupan=C3=A7a=20confi?= =?UTF-8?q?gur=C3=A1vel=20+=20conquistas=20gen=C3=A9ricas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- apps/api/cmd/server/main.go | 11 +++- apps/api/internal/handler/dashboard.go | 16 ++++- apps/api/internal/handler/settings.go | 44 +++++++++++++ apps/api/internal/migration/migration.go | 5 +- .../migration/sql/017_user_settings.sql | 5 ++ apps/api/internal/model/dashboard.go | 1 + apps/api/internal/model/settings.go | 5 ++ apps/api/internal/repository/settings.go | 50 ++++++++++++++ apps/api/internal/service/dashboard.go | 12 +++- apps/api/internal/service/dashboard_test.go | 12 +++- apps/api/internal/service/game.go | 8 ++- apps/api/internal/service/settings.go | 30 +++++++++ apps/web/src/stores/settings.ts | 40 ++++++++++++ apps/web/src/views/HomeView.vue | 7 +- apps/web/src/views/SettingsView.vue | 65 ++++++++++++++++++- 15 files changed, 296 insertions(+), 15 deletions(-) create mode 100644 apps/api/internal/handler/settings.go create mode 100644 apps/api/internal/migration/sql/017_user_settings.sql create mode 100644 apps/api/internal/model/settings.go create mode 100644 apps/api/internal/repository/settings.go create mode 100644 apps/api/internal/service/settings.go create mode 100644 apps/web/src/stores/settings.ts diff --git a/apps/api/cmd/server/main.go b/apps/api/cmd/server/main.go index 0c62810..5a22400 100644 --- a/apps/api/cmd/server/main.go +++ b/apps/api/cmd/server/main.go @@ -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) diff --git a/apps/api/internal/handler/dashboard.go b/apps/api/internal/handler/dashboard.go index 745b01d..e4a2e99 100644 --- a/apps/api/internal/handler/dashboard.go +++ b/apps/api/internal/handler/dashboard.go @@ -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) } diff --git a/apps/api/internal/handler/settings.go b/apps/api/internal/handler/settings.go new file mode 100644 index 0000000..89cb217 --- /dev/null +++ b/apps/api/internal/handler/settings.go @@ -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) +} diff --git a/apps/api/internal/migration/migration.go b/apps/api/internal/migration/migration.go index 708fbc4..d78c611 100644 --- a/apps/api/internal/migration/migration.go +++ b/apps/api/internal/migration/migration.go @@ -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 diff --git a/apps/api/internal/migration/sql/017_user_settings.sql b/apps/api/internal/migration/sql/017_user_settings.sql new file mode 100644 index 0000000..1b758af --- /dev/null +++ b/apps/api/internal/migration/sql/017_user_settings.sql @@ -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() +); diff --git a/apps/api/internal/model/dashboard.go b/apps/api/internal/model/dashboard.go index 3e790a7..5f801a5 100644 --- a/apps/api/internal/model/dashboard.go +++ b/apps/api/internal/model/dashboard.go @@ -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"` diff --git a/apps/api/internal/model/settings.go b/apps/api/internal/model/settings.go new file mode 100644 index 0000000..91c836f --- /dev/null +++ b/apps/api/internal/model/settings.go @@ -0,0 +1,5 @@ +package model + +type UserSettings struct { + SavingsGoalPct float64 `json:"savings_goal_pct"` +} diff --git a/apps/api/internal/repository/settings.go b/apps/api/internal/repository/settings.go new file mode 100644 index 0000000..9c3ee29 --- /dev/null +++ b/apps/api/internal/repository/settings.go @@ -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 +} diff --git a/apps/api/internal/service/dashboard.go b/apps/api/internal/service/dashboard.go index 493eb7b..1f8542a 100644 --- a/apps/api/internal/service/dashboard.go +++ b/apps/api/internal/service/dashboard.go @@ -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, diff --git a/apps/api/internal/service/dashboard_test.go b/apps/api/internal/service/dashboard_test.go index de6eb28..e204263 100644 --- a/apps/api/internal/service/dashboard_test.go +++ b/apps/api/internal/service/dashboard_test.go @@ -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) { diff --git a/apps/api/internal/service/game.go b/apps/api/internal/service/game.go index 58f7c7f..c5b026b 100644 --- a/apps/api/internal/service/game.go +++ b/apps/api/internal/service/game.go @@ -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") } } diff --git a/apps/api/internal/service/settings.go b/apps/api/internal/service/settings.go new file mode 100644 index 0000000..e6351f6 --- /dev/null +++ b/apps/api/internal/service/settings.go @@ -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) +} diff --git a/apps/web/src/stores/settings.ts b/apps/web/src/stores/settings.ts new file mode 100644 index 0000000..623a24d --- /dev/null +++ b/apps/web/src/stores/settings.ts @@ -0,0 +1,40 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' +import { api } from '@/services/api' + +export interface UserSettings { + savings_goal_pct: number +} + +export const useSettingsStore = defineStore('settings', () => { + const settings = ref({ savings_goal_pct: 40 }) + const loading = ref(false) + const error = ref(null) + + async function fetch() { + loading.value = true + error.value = null + try { + settings.value = await api.get('/settings') + } catch (e: any) { + error.value = e.message + } finally { + loading.value = false + } + } + + async function update(input: UserSettings) { + loading.value = true + error.value = null + try { + settings.value = await api.put('/settings', input) + } catch (e: any) { + error.value = e.message + throw e + } finally { + loading.value = false + } + } + + return { settings, loading, error, fetch, update } +}) diff --git a/apps/web/src/views/HomeView.vue b/apps/web/src/views/HomeView.vue index b21ff22..10003cf 100644 --- a/apps/web/src/views/HomeView.vue +++ b/apps/web/src/views/HomeView.vue @@ -53,8 +53,9 @@ const xpPct = computed(() => { if (!p) return 0 return Math.round((p.xp / p.xp_to_next) * 100) }) -const savingsPct = computed(() => dash.data?.savings_pct ?? 0) -const savingsOk = computed(() => savingsPct.value >= 40) +const savingsPct = computed(() => dash.data?.savings_pct ?? 0) +const savingsGoal = computed(() => dash.data?.savings_goal_pct ?? 40) +const savingsOk = computed(() => savingsPct.value >= savingsGoal.value) const today = new Date() const isCurrentMonth = computed(() => currentMonth.value === today.toISOString().slice(0, 7)) @@ -142,7 +143,7 @@ async function markLate(id: number) {
- META: 40%  ·  + META: {{ savingsGoal.toFixed(0) }}%  ·  {{ savingsOk ? 'META BATIDA ✓' : 'ABAIXO DA META' }} diff --git a/apps/web/src/views/SettingsView.vue b/apps/web/src/views/SettingsView.vue index ad07f29..3440479 100644 --- a/apps/web/src/views/SettingsView.vue +++ b/apps/web/src/views/SettingsView.vue @@ -1,11 +1,41 @@