package processor import ( "encoding/json" "testing" "time" "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "apskel-pos-be/internal/constants" "apskel-pos-be/internal/entities" "apskel-pos-be/internal/models" "apskel-pos-be/internal/repository" ) // realizedCost records a completed redemption whose face value the budget paid, // recognized at at. func (e *enakGameEnv) realizedCost(voucherID, budgetID uuid.UUID, amount int64, at time.Time) { e.t.Helper() debit, redemption := uuid.New(), uuid.New() require.NoError(e.t, e.db.Exec(`INSERT INTO wallet_transactions (id, organization_id, customer_id, currency, type, amount, balance_after, reference_type, reference_id, description) VALUES (?, ?, ?, 'POINT', 'REWARD_REDEEM', -1, 0, 'REWARD_REDEMPTION', ?, 'x')`, debit, e.orgA, e.bob, redemption).Error) require.NoError(e.t, e.db.Exec(`INSERT INTO voucher_redemptions (id, organization_id, customer_id, voucher_id, idempotency_key, status, face_value, point_cost, debit_transaction_id, completed_at) VALUES (?, ?, ?, ?, ?, 'COMPLETED', ?, 1, ?, ?)`, redemption, e.orgA, e.bob, voucherID, redemption.String(), amount, debit, at).Error) require.NoError(e.t, e.db.Exec(`INSERT INTO voucher_redemption_costs (redemption_id, budget_id, source_type, points, cost, recognized_at) VALUES (?, ?, 'GAME_REWARD', 1, ?, ?)`, redemption, budgetID, amount, at).Error) } func (e *enakGameEnv) activeConfig(gameID uuid.UUID) *entities.GameRewardConfig { e.t.Helper() c, err := repository.NewEnakGameRepository(e.db).GetActiveRewardConfig(e.ctx(), e.orgA, gameID) require.NoError(e.t, err) return c } // EG-901 and EG-902 against Postgres, from the PRD §30 numbers. Needs // TEST_DATABASE_URL; see internal/repository/wallet_repository_test.go. func TestGameBudgetController_AgainstPostgres(t *testing.T) { e := newEnakGameEnv(t) ctx := e.ctx() e.now = time.Date(2026, 10, 21, 12, 0, 0, 0, walletDisplayLocation) budgets := repository.NewGameBudgetRepository(e.db) metrics := NewGameBudgetMetricsProcessor(budgets, repository.NewGameBudgetMetricsRepository(e.db)) controller := NewGameBudgetControllerProcessor(budgets, metrics, repository.NewEnakGameRepository(e.db), NewAuditLogger(repository.NewAuditLogRepository(e.db)), e.txm) controller.now = func() time.Time { return e.now } october := e.globalBudget(e.orgA, e.now) tap := e.gameWith("tap", entities.GameResultRules{}, constants.GameRewardTypeFixed, `{"amount": 10}`, 10) run := e.gameWith("run", entities.GameResultRules{}, constants.GameRewardTypeScoreBased, `{"bands": [{"min": 0, "max": 100, "amount": 5}, {"min": 101, "amount": 20}]}`, 20) tapV1, runV1 := e.activeConfig(tap.ID), e.activeConfig(run.ID) // Rp60M realized, Rp5,5M a day over the last week, 10 days left → Rp115M. voucher := e.staticVoucher(1_000, 1, 1) e.realizedCost(voucher.ID, october.ID, 21_500_000, time.Date(2026, 10, 5, 10, 0, 0, 0, walletDisplayLocation)) for day := 15; day <= 21; day++ { e.realizedCost(voucher.ID, october.ID, 5_500_000, time.Date(2026, 10, day, 0, 0, 0, 0, walletDisplayLocation)) } rec, err := controller.Recommendation(ctx, e.orgA, october.ID) require.NoError(t, err) assert.Equal(t, constants.GameBudgetRecommended, rec.State) assert.EqualValues(t, 115_000_000, rec.Metrics.ForecastCost) assert.InDelta(t, 0.7272, *rec.TargetMultiplier, 1e-9) assert.EqualValues(t, 0.9, rec.Multiplier, "one step of 10%") assert.EqualValues(t, 10, *rec.Guardrails.MaxStepPercent) require.Len(t, rec.Games, 2) assert.Equal(t, run.ID, rec.Games[0].GameID, "by game name") assert.JSONEq(t, `{"bands":[{"min":0,"max":100,"amount":4},{"min":101,"amount":18}]}`, string(rec.Games[0].NewRules)) assert.EqualValues(t, 18, rec.Games[0].NewMaxReward) assert.JSONEq(t, `{"amount":9}`, string(rec.Games[1].NewRules)) assert.Zero(t, e.count(`SELECT COUNT(*) FROM game_reward_configs WHERE organization_id = ? AND budget_id IS NOT NULL`, e.orgA), "showing changes nothing") // Only what the admin saw is applied. _, err = controller.Accept(ctx, e.orgA, e.manager, october.ID, models.GameBudgetRecommendationAcceptInput{}) assert.ErrorIs(t, err, ErrEnakGameRejected) _, err = controller.Accept(ctx, e.orgA, e.manager, october.ID, models.GameBudgetRecommendationAcceptInput{Multiplier: ptr(0.85)}) assert.ErrorIs(t, err, ErrEnakGameRejected) assert.Zero(t, e.count(`SELECT COUNT(*) FROM game_reward_configs WHERE organization_id = ? AND budget_id IS NOT NULL`, e.orgA)) accepted, err := controller.Accept(ctx, e.orgA, e.manager, october.ID, models.GameBudgetRecommendationAcceptInput{ Multiplier: ptr(0.9), Reason: ptr("Burn rate terlalu tinggi"), }) require.NoError(t, err) require.Len(t, accepted.RewardConfigs, 2) tapV2 := e.activeConfig(tap.ID) assert.Equal(t, 2, tapV2.Version) assert.JSONEq(t, `{"amount":9}`, string(tapV2.Rules)) assert.EqualValues(t, 9, tapV2.MaxReward) assert.EqualValues(t, 0.9, *tapV2.Multiplier) assert.Equal(t, tapV1.ID, *tapV2.BaseConfigID) assert.Equal(t, october.ID, *tapV2.BudgetID) assert.Equal(t, "Burn rate terlalu tinggi", *tapV2.Reason) assert.EqualValues(t, 1, e.count(`SELECT COUNT(*) FROM game_reward_configs WHERE id = ? AND status = 'RETIRED'`, tapV1.ID)) assert.EqualValues(t, 1, e.count(`SELECT COUNT(*) FROM game_reward_configs WHERE id = ? AND status = 'RETIRED'`, runV1.ID)) assert.Equal(t, []string{"CREATED", constants.AuditActionRecommendationAccepted}, e.auditActions(constants.AuditEntityGameBudget, october.ID)) assert.Equal(t, []string{"ACTIVATED", "CREATED"}, e.auditActions(constants.AuditEntityGameRewardConfig, tapV2.ID)) assert.EqualValues(t, 7, e.count(`SELECT COUNT(*) FROM audit_logs WHERE organization_id = ? AND source = ?`, e.orgA, constants.AuditSourceBudgetController), "per game: retired, created, activated; and the budget") // The game pays the new amount. e.coins(e.alice, 2, nil) started, err := e.sessions.Start(ctx, e.alice, tap.ID, "after") require.NoError(t, err) done, err := e.sessions.Complete(ctx, e.alice, started.SessionID, models.GameSessionCompleteInput{}) require.NoError(t, err) assert.EqualValues(t, 9, done.RewardTotal) // Not again within the cooldown, though the forecast is still over. rec, err = controller.Recommendation(ctx, e.orgA, october.ID) require.NoError(t, err) assert.Equal(t, constants.GameBudgetRecommendationCooldown, rec.State) require.NotNil(t, rec.CooldownUntil) assert.WithinDuration(t, e.now.AddDate(0, 0, 7), *rec.CooldownUntil, time.Second) assert.EqualValues(t, 0.81, rec.Games[1].NewMultiplier, "what it would be") _, err = controller.Accept(ctx, e.orgA, e.manager, october.ID, models.GameBudgetRecommendationAcceptInput{Multiplier: ptr(0.9)}) assert.ErrorIs(t, err, ErrEnakGameRejected) // No cooldown and a floor of 85%: 0.81 stops at 0.85, scaled from version 1. current, err := e.budgets.GetBudget(ctx, e.orgA, october.ID) require.NoError(t, err) in := GameBudgetInputFrom(current) in.Thresholds.CooldownDays, in.Thresholds.MinMultiplierPercent = ptr(int64(0)), ptr(int64(85)) _, err = e.budgets.UpdateBudget(ctx, e.orgA, e.manager, october.ID, in) require.NoError(t, err) rec, err = controller.Recommendation(ctx, e.orgA, october.ID) require.NoError(t, err) require.Equal(t, constants.GameBudgetRecommended, rec.State) _, err = controller.Accept(ctx, e.orgA, e.manager, october.ID, models.GameBudgetRecommendationAcceptInput{Multiplier: ptr(0.9)}) require.NoError(t, err) tapV3 := e.activeConfig(tap.ID) assert.EqualValues(t, 0.85, *tapV3.Multiplier) assert.JSONEq(t, `{"amount":8}`, string(tapV3.Rules), "10 × 0.85, rounded down") assert.Equal(t, tapV1.ID, *tapV3.BaseConfigID, "always from the admin's version") rec, err = controller.Recommendation(ctx, e.orgA, october.ID) require.NoError(t, err) assert.Equal(t, constants.GameBudgetRecommendationAtLimit, rec.State) assert.Empty(t, rec.Games) // An admin's new version starts over from 1. config, err := e.admin.CreateRewardConfig(ctx, e.orgA, e.manager, tap.ID, models.GameRewardConfigInput{ RewardType: constants.GameRewardTypeFixed, Rules: json.RawMessage(`{"amount": 12}`), MaxReward: 12, }) require.NoError(t, err) _, err = e.admin.ActivateRewardConfig(ctx, e.orgA, e.manager, config.ID, models.GameRewardConfigActivateInput{}) require.NoError(t, err) rec, err = controller.Recommendation(ctx, e.orgA, october.ID) require.NoError(t, err) require.Equal(t, constants.GameBudgetRecommended, rec.State) require.Len(t, rec.Games, 1) assert.Equal(t, config.ID, rec.Games[0].BaseConfigID) assert.JSONEq(t, `{"amount":10}`, string(rec.Games[0].NewRules), "12 × 0.9, rounded down") // Event budgets pay for what events add, which is set on the event. event := e.eventBudget(e.orgA, "Ramadan") _, err = controller.Recommendation(ctx, e.orgA, event.ID) assert.ErrorIs(t, err, ErrEnakGameRejected) _, err = controller.Recommendation(ctx, e.orgB, october.ID) assert.ErrorIs(t, err, repository.ErrGameBudgetNotFound) }