EnakGame phase 9 of docs/tasks-enakgame.md (EG-901 to EG-903). Budget Controller (EG-901, EG-902) - GET /marketing/enakgame/budgets/:id/recommendation, GLOBAL budgets only: the multiplier (budget − realized) / (forecast − realized), within one step of 1, rounded down to two decimals, either way. Shows each game's new rules. - POST .../recommendation/accept with the multiplier the admin saw: recomputed in the transaction, then one new ACTIVE version per game, the old one RETIRED, audited with source budget_controller and RECOMMENDATION_ACCEPTED on the budget. - Migration 000112: base_config_id, multiplier and budget_id on game_reward_configs. Rules are always scaled from the admin's last version, so rounding does not compound and min/max are against what the admin set. - Guardrails in game_budgets.thresholds: max_step_percent 10, min/max multiplier 50-150%, cooldown_days 7 per organization. Provisional pending RFC §19.2 #4. - RewardCalculator.Scale for the four reward types: amounts only, rounded down. Analytics (EG-903) - GET /marketing/enakgame/analytics/games and /analytics/economy over a range of Asia/Jakarta days (at most 366), from game_sessions and the wallet ledger. - Migrations 000113 (game_sessions by organization and start) and 000114 (wallet_transactions by organization and time, CONCURRENTLY). The Postgres tests for accepting and analytics were not run: no test database here. Migrations 000112-000114 have not been run anywhere. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
165 lines
8.8 KiB
Go
165 lines
8.8 KiB
Go
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)
|
||
}
|