diff --git a/apps/api/internal/repository/game.go b/apps/api/internal/repository/game.go index d37d1bf..7cd2928 100644 --- a/apps/api/internal/repository/game.go +++ b/apps/api/internal/repository/game.go @@ -2,6 +2,7 @@ package repository import ( "context" + "fmt" "time" "github.com/jackc/pgx/v5/pgxpool" @@ -187,11 +188,13 @@ func (r *GameRepository) ClaimQuest(ctx context.Context, playerQuestID int) (int func (r *GameRepository) ListAchievements(ctx context.Context) ([]model.Achievement, error) { pid := middleware.ProfileIDFromCtx(ctx) rows, err := r.pool.Query(ctx, ` - SELECT a.id, a.title, a.description, a.icon, a.xp_reward, a.unlock_condition, + SELECT a.id, a.title, a.description, a.icon, a.xp_reward, a.unlock_condition, a.category_id, pa.id IS NOT NULL, COALESCE(pa.earned_at::text, '') FROM achievements a LEFT JOIN player_achievements pa ON pa.achievement_id = a.id AND pa.profile_id = $1 + WHERE a.category_id IS NULL + OR a.category_id IN (SELECT id FROM categories WHERE profile_id = $1) ORDER BY pa.earned_at DESC NULLS LAST, a.id `, pid) if err != nil { @@ -202,7 +205,7 @@ func (r *GameRepository) ListAchievements(ctx context.Context) ([]model.Achievem var out []model.Achievement for rows.Next() { var a model.Achievement - if err := rows.Scan(&a.ID, &a.Title, &a.Description, &a.Icon, &a.XPReward, &a.UnlockCondition, &a.Earned, &a.EarnedAt); err != nil { + if err := rows.Scan(&a.ID, &a.Title, &a.Description, &a.Icon, &a.XPReward, &a.UnlockCondition, &a.CategoryID, &a.Earned, &a.EarnedAt); err != nil { return nil, err } out = append(out, a) @@ -210,6 +213,67 @@ func (r *GameRepository) ListAchievements(ctx context.Context) ([]model.Achievem return out, rows.Err() } +func (r *GameRepository) EnsureCategoryAchievement(ctx context.Context, categoryID int, categoryName string) error { + title := "EconĆ“mico em " + categoryName + description := "Mantenha os gastos dentro da meta em " + categoryName + condition := fmt.Sprintf("budget_cat_%d", categoryID) + _, err := r.pool.Exec(ctx, ` + INSERT INTO achievements (title, description, icon, xp_reward, unlock_condition, category_id) + VALUES ($1, $2, 'šŸ’°', 100, $3, $4) + ON CONFLICT (unlock_condition) DO UPDATE SET title = EXCLUDED.title, description = EXCLUDED.description + `, title, description, condition, categoryID) + return err +} + +func (r *GameRepository) RemoveCategoryAchievementIfNotEarned(ctx context.Context, categoryID int) error { + pid := middleware.ProfileIDFromCtx(ctx) + _, err := r.pool.Exec(ctx, ` + DELETE FROM achievements + WHERE category_id = $1 + AND NOT EXISTS ( + SELECT 1 FROM player_achievements pa + WHERE pa.achievement_id = achievements.id AND pa.profile_id = $2 + ) + `, categoryID, pid) + return err +} + +func (r *GameRepository) ListCategoriesWithBudget(ctx context.Context) ([]model.CategoryBudgetInfo, error) { + pid := middleware.ProfileIDFromCtx(ctx) + rows, err := r.pool.Query(ctx, ` + SELECT id, monthly_budget FROM categories + WHERE profile_id = $1 AND monthly_budget IS NOT NULL + `, pid) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []model.CategoryBudgetInfo + for rows.Next() { + var c model.CategoryBudgetInfo + if err := rows.Scan(&c.CategoryID, &c.MonthlyBudget); err != nil { + return nil, err + } + out = append(out, c) + } + return out, rows.Err() +} + +func (r *GameRepository) GetMonthlySpendByCategory(ctx context.Context, month string, categoryID int) (float64, error) { + pid := middleware.ProfileIDFromCtx(ctx) + var total float64 + err := r.pool.QueryRow(ctx, ` + SELECT COALESCE(SUM(amount), 0) + FROM transactions + WHERE to_char(date, 'YYYY-MM') = $1 + AND category_id = $2 + AND type = 'expense' + AND profile_id = $3 + `, month, categoryID, pid).Scan(&total) + return total, err +} + func (r *GameRepository) IsAchievementUnlocked(ctx context.Context, condition string) (bool, error) { pid := middleware.ProfileIDFromCtx(ctx) var count int diff --git a/apps/api/internal/service/game.go b/apps/api/internal/service/game.go index c5b026b..d870ac8 100644 --- a/apps/api/internal/service/game.go +++ b/apps/api/internal/service/game.go @@ -2,6 +2,7 @@ package service import ( "context" + "fmt" "time" "financeiro-carvalho/internal/model" @@ -21,6 +22,10 @@ type GameRepo interface { ListCosmetics(ctx context.Context) ([]model.Cosmetic, error) EquipCosmetic(ctx context.Context, cosmeticID int) error UnlockCosmeticsForLevel(ctx context.Context, level int) error + EnsureCategoryAchievement(ctx context.Context, categoryID int, categoryName string) error + RemoveCategoryAchievementIfNotEarned(ctx context.Context, categoryID int) error + ListCategoriesWithBudget(ctx context.Context) ([]model.CategoryBudgetInfo, error) + GetMonthlySpendByCategory(ctx context.Context, month string, categoryID int) (float64, error) } type GameService struct { @@ -137,6 +142,33 @@ func (s *GameService) EquipCosmetic(ctx context.Context, cosmeticID int) error { return s.repo.EquipCosmetic(ctx, cosmeticID) } +// OnCategoryBudgetSet gera ou remove a conquista dinĆ¢mica ao salvar/limpar a meta mensal de uma categoria. +func (s *GameService) OnCategoryBudgetSet(ctx context.Context, categoryID int, categoryName string, budget *float64) { + if budget == nil { + _ = s.repo.RemoveCategoryAchievementIfNotEarned(ctx, categoryID) + return + } + _ = s.repo.EnsureCategoryAchievement(ctx, categoryID, categoryName) +} + +// CheckCategoryBudgets verifica todas as categorias com meta do perfil e desbloqueia conquistas quando gasto ≤ meta. +func (s *GameService) CheckCategoryBudgets(ctx context.Context, month string) { + cats, err := s.repo.ListCategoriesWithBudget(ctx) + if err != nil { + return + } + for _, cat := range cats { + total, err := s.repo.GetMonthlySpendByCategory(ctx, month, cat.CategoryID) + if err != nil { + continue + } + if total <= cat.MonthlyBudget { + condition := fmt.Sprintf("budget_cat_%d", cat.CategoryID) + s.tryUnlockAchievement(ctx, condition) + } + } +} + // NotifyAction is called by other handlers after key financial actions to award XP and update quests. func (s *GameService) NotifyAction(ctx context.Context, action string, extra map[string]any) { now := time.Now() @@ -153,6 +185,7 @@ func (s *GameService) NotifyAction(ctx context.Context, action string, extra map _ = s.repo.IncrementQuestCount(ctx, "weekly", weeklyPeriod, 1) } s.tryUnlockAchievement(ctx, "first_transaction") + s.CheckCategoryBudgets(ctx, monthlyPeriod) case "import_confirmed": count, _ := extra["count"].(int) @@ -162,6 +195,7 @@ func (s *GameService) NotifyAction(ctx context.Context, action string, extra map _, _ = s.AwardXP(ctx, "import_confirmed", 50, "Extrato importado") _ = s.repo.IncrementQuestCount(ctx, "weekly", weeklyPeriod, 1) s.tryUnlockAchievement(ctx, "first_import") + s.CheckCategoryBudgets(ctx, monthlyPeriod) case "savings_checked": pct, _ := extra["pct"].(float64) diff --git a/apps/api/internal/service/game_test.go b/apps/api/internal/service/game_test.go index 1449c89..b8a5e0e 100644 --- a/apps/api/internal/service/game_test.go +++ b/apps/api/internal/service/game_test.go @@ -9,14 +9,20 @@ import ( ) type mockGameRepo struct { - profile model.PlayerProfile - achievements map[string]bool + profile model.PlayerProfile + achievements map[string]bool + ensuredCats map[int]string + removedCats []int + catBudgets []model.CategoryBudgetInfo + catSpend map[int]float64 } func newMockGameRepo() *mockGameRepo { return &mockGameRepo{ profile: model.PlayerProfile{ID: 1, Level: 1, XP: 0, XPToNext: 100}, achievements: map[string]bool{}, + ensuredCats: map[int]string{}, + catSpend: map[int]float64{}, } } @@ -52,6 +58,20 @@ func (m *mockGameRepo) UnlockAchievement(_ context.Context, c string) (int, erro func (m *mockGameRepo) ListCosmetics(_ context.Context) ([]model.Cosmetic, error) { return nil, nil } func (m *mockGameRepo) EquipCosmetic(_ context.Context, _ int) error { return nil } func (m *mockGameRepo) UnlockCosmeticsForLevel(_ context.Context, _ int) error { return nil } +func (m *mockGameRepo) EnsureCategoryAchievement(_ context.Context, catID int, name string) error { + m.ensuredCats[catID] = name + return nil +} +func (m *mockGameRepo) RemoveCategoryAchievementIfNotEarned(_ context.Context, catID int) error { + m.removedCats = append(m.removedCats, catID) + return nil +} +func (m *mockGameRepo) ListCategoriesWithBudget(_ context.Context) ([]model.CategoryBudgetInfo, error) { + return m.catBudgets, nil +} +func (m *mockGameRepo) GetMonthlySpendByCategory(_ context.Context, _ string, catID int) (float64, error) { + return m.catSpend[catID], nil +} func TestGame_XPAccumulates(t *testing.T) { repo := newMockGameRepo() @@ -96,3 +116,58 @@ func TestGame_AchievementNotDuplicated(t *testing.T) { t.Error("achievement should be marked unlocked") } } + +func TestGame_EnsureCategoryAchievementOnBudgetSet(t *testing.T) { + repo := newMockGameRepo() + svc := service.NewGameService(repo) + + budget := 800.0 + svc.OnCategoryBudgetSet(context.Background(), 5, "Alimentação", &budget) + + if repo.ensuredCats[5] != "Alimentação" { + t.Errorf("expected EnsureCategoryAchievement called for cat 5, got %v", repo.ensuredCats) + } + if len(repo.removedCats) != 0 { + t.Error("expected no removal when budget is set") + } +} + +func TestGame_RemoveCategoryAchievementOnBudgetNull(t *testing.T) { + repo := newMockGameRepo() + svc := service.NewGameService(repo) + + svc.OnCategoryBudgetSet(context.Background(), 5, "Alimentação", nil) + + if len(repo.removedCats) == 0 || repo.removedCats[0] != 5 { + t.Errorf("expected RemoveCategoryAchievementIfNotEarned for cat 5, got %v", repo.removedCats) + } + if len(repo.ensuredCats) != 0 { + t.Error("expected no ensure when budget is nil") + } +} + +func TestGame_CategoryBudgetUnlock(t *testing.T) { + repo := newMockGameRepo() + repo.catBudgets = []model.CategoryBudgetInfo{{CategoryID: 5, MonthlyBudget: 800}} + repo.catSpend[5] = 750 + svc := service.NewGameService(repo) + + svc.CheckCategoryBudgets(context.Background(), "2026-05") + + if !repo.achievements["budget_cat_5"] { + t.Error("expected budget_cat_5 unlocked when spend 750 <= budget 800") + } +} + +func TestGame_CategoryBudgetNoUnlockWhenOverBudget(t *testing.T) { + repo := newMockGameRepo() + repo.catBudgets = []model.CategoryBudgetInfo{{CategoryID: 5, MonthlyBudget: 800}} + repo.catSpend[5] = 850 + svc := service.NewGameService(repo) + + svc.CheckCategoryBudgets(context.Background(), "2026-05") + + if repo.achievements["budget_cat_5"] { + t.Error("expected budget_cat_5 NOT unlocked when spend 850 > budget 800") + } +}