feat(enakgame): game sessions, rewards, vouchers, budgets and events

EnakGame phases 1-8 of docs/tasks-enakgame.md (EG-101 to EG-803), built on the
existing EnakPoint/EnakCoin wallet (docs/rfc-enakgame.md).

Foundation (phase 1)
- Migrations 000103-000106: games extended with organization, slug, status,
  entry cost and result rules, old games archived (not deleted); budgets,
  versioned reward configs, sessions and session rewards; the ledger types
  GAME_SPEND_REFUND, GAME_REWARD and REWARD_REDEEM_REFUND; audit_logs.
- AuditLogger writes in the caller's transaction only.
- enakgame.limit.user_daily and global_daily organization settings.

Games and sessions (phases 2-4)
- Admin /marketing/enakgame: games, reward config versions (immutable but for
  status, one ACTIVE per game), budgets with non-overlapping global periods and
  a daily job opening the next month.
- Customer /customer/enakgame: start (Idempotency-Key, entry cost and config
  frozen on the session), complete (result validation, reward engine, max_reward
  cap, daily limits via game_reward_counters, one GAME_REWARD per budget),
  automatic refunds for system errors and deactivated games, and a session job.
- Reward engine: FIXED, SCORE_BASED, OUTCOME_BASED, PROBABILITY (crypto/rand),
  rounded down.

Vouchers and budgets (phases 5-6)
- Migration 000108 and 000107: vouchers, codes, redemptions, cost attribution;
  Economy Guard counters.
- STATIC and CODE_POOL redemption in one transaction with the REDEEM PIN action;
  realized cost traced through the lots to the budget that paid the reward.
- Budget metrics: realized cost, forecast, exposure and status. Migrations
  000109-000110 add the wallet_lots indexes they need, built CONCURRENTLY.

Events (phase 7)
- Migration 000111: game events, each with its own EVENT budget. Event extras
  stack per PRD §16 defaults, with event and per-customer limits.

External vouchers (phase 8)
- VoucherProvider contract, two-step PENDING redemption and a recovery job,
  tested with a fake provider. No provider adapter is registered yet, so
  EXTERNAL vouchers stay out of the catalog.

Not yet decided before release: reward rounding, event stacking, budget
exhaustion policy and thresholds (RFC §19.2). Migrations 000103-000111 have
not been run on any shared database.

Also fixes a leftover PAYMENT filter in a wallet test and a data race in a
test PIN fake.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
efrilm
2026-10-07 20:53:14 +07:00
co-authored by Claude Opus 5.5
parent 2c9753fae7
commit 798a36bd6c
92 changed files with 12392 additions and 23 deletions
+410
View File
@@ -0,0 +1,410 @@
package handler
import (
"io"
"mime/multipart"
"strings"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"apskel-pos-be/internal/appcontext"
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/contract"
"apskel-pos-be/internal/models"
"apskel-pos-be/internal/service"
"apskel-pos-be/internal/util"
)
// EnakGameAdminHandler serves /marketing/enakgame (docs/rfc-enakgame.md §11). Every
// request works on the admin's own organization.
type EnakGameAdminHandler struct {
service service.EnakGameAdminService
}
func NewEnakGameAdminHandler(s service.EnakGameAdminService) *EnakGameAdminHandler {
return &EnakGameAdminHandler{service: s}
}
// voucherCodeUploadLimit caps a CSV of codes: 50 000 lines of up to 255 characters.
const voucherCodeUploadLimit = 16 << 20
// pathID reads a UUID path parameter, answering 400 when it is not one.
func pathID(c *gin.Context, name, method string) (uuid.UUID, bool) {
id, err := uuid.Parse(c.Param(name))
if err != nil {
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "invalid "+name),
}), method)
return uuid.Nil, false
}
return id, true
}
// rawBody reads the request body, answering 400 when it cannot.
func rawBody(c *gin.Context, method string) ([]byte, bool) {
body, err := c.GetRawData()
if err != nil {
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "cannot read the request body"),
}), method)
return nil, false
}
return body, true
}
func bindQuery(c *gin.Context, q any, method string) bool {
if err := c.ShouldBindQuery(q); err != nil {
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, err.Error()),
}), method)
return false
}
return true
}
// CreateGame is POST /marketing/enakgame/games.
func (h *EnakGameAdminHandler) CreateGame(c *gin.Context) {
const method = "EnakGameAdminHandler::CreateGame"
body, ok := rawBody(c, method)
if !ok {
return
}
ctx := c.Request.Context()
util.HandleResponse(c.Writer, c.Request, h.service.CreateGame(ctx, appcontext.FromGinContext(ctx), body), method)
}
// ListGames is GET /marketing/enakgame/games?status=&search=&page=&limit=.
func (h *EnakGameAdminHandler) ListGames(c *gin.Context) {
const method = "EnakGameAdminHandler::ListGames"
var q models.EnakGameListQuery
if !bindQuery(c, &q, method) {
return
}
ctx := c.Request.Context()
util.HandleResponse(c.Writer, c.Request, h.service.ListGames(ctx, appcontext.FromGinContext(ctx), q), method)
}
// GetGame is GET /marketing/enakgame/games/:id.
func (h *EnakGameAdminHandler) GetGame(c *gin.Context) {
const method = "EnakGameAdminHandler::GetGame"
id, ok := pathID(c, "id", method)
if !ok {
return
}
ctx := c.Request.Context()
util.HandleResponse(c.Writer, c.Request, h.service.GetGame(ctx, appcontext.FromGinContext(ctx), id), method)
}
// UpdateGame is PUT /marketing/enakgame/games/:id.
func (h *EnakGameAdminHandler) UpdateGame(c *gin.Context) {
const method = "EnakGameAdminHandler::UpdateGame"
id, ok := pathID(c, "id", method)
if !ok {
return
}
body, ok := rawBody(c, method)
if !ok {
return
}
ctx := c.Request.Context()
util.HandleResponse(c.Writer, c.Request, h.service.UpdateGame(ctx, appcontext.FromGinContext(ctx), id, body), method)
}
// SetGameStatus is PUT /marketing/enakgame/games/:id/status.
func (h *EnakGameAdminHandler) SetGameStatus(c *gin.Context) {
const method = "EnakGameAdminHandler::SetGameStatus"
id, ok := pathID(c, "id", method)
if !ok {
return
}
body, ok := rawBody(c, method)
if !ok {
return
}
ctx := c.Request.Context()
util.HandleResponse(c.Writer, c.Request, h.service.SetGameStatus(ctx, appcontext.FromGinContext(ctx), id, body), method)
}
// CreateRewardConfig is POST /marketing/enakgame/games/:id/reward-configs.
func (h *EnakGameAdminHandler) CreateRewardConfig(c *gin.Context) {
const method = "EnakGameAdminHandler::CreateRewardConfig"
id, ok := pathID(c, "id", method)
if !ok {
return
}
body, ok := rawBody(c, method)
if !ok {
return
}
ctx := c.Request.Context()
util.HandleResponse(c.Writer, c.Request, h.service.CreateRewardConfig(ctx, appcontext.FromGinContext(ctx), id, body), method)
}
// ListRewardConfigs is GET /marketing/enakgame/games/:id/reward-configs.
func (h *EnakGameAdminHandler) ListRewardConfigs(c *gin.Context) {
const method = "EnakGameAdminHandler::ListRewardConfigs"
id, ok := pathID(c, "id", method)
if !ok {
return
}
ctx := c.Request.Context()
util.HandleResponse(c.Writer, c.Request, h.service.ListRewardConfigs(ctx, appcontext.FromGinContext(ctx), id), method)
}
// ActivateRewardConfig is POST /marketing/enakgame/reward-configs/:id/activate.
func (h *EnakGameAdminHandler) ActivateRewardConfig(c *gin.Context) {
const method = "EnakGameAdminHandler::ActivateRewardConfig"
id, ok := pathID(c, "id", method)
if !ok {
return
}
body, ok := rawBody(c, method)
if !ok {
return
}
ctx := c.Request.Context()
util.HandleResponse(c.Writer, c.Request, h.service.ActivateRewardConfig(ctx, appcontext.FromGinContext(ctx), id, body), method)
}
// CreateBudget is POST /marketing/enakgame/budgets.
func (h *EnakGameAdminHandler) CreateBudget(c *gin.Context) {
const method = "EnakGameAdminHandler::CreateBudget"
body, ok := rawBody(c, method)
if !ok {
return
}
ctx := c.Request.Context()
util.HandleResponse(c.Writer, c.Request, h.service.CreateBudget(ctx, appcontext.FromGinContext(ctx), body), method)
}
// ListBudgets is GET /marketing/enakgame/budgets?scope=&page=&limit=.
func (h *EnakGameAdminHandler) ListBudgets(c *gin.Context) {
const method = "EnakGameAdminHandler::ListBudgets"
var q models.GameBudgetListQuery
if !bindQuery(c, &q, method) {
return
}
ctx := c.Request.Context()
util.HandleResponse(c.Writer, c.Request, h.service.ListBudgets(ctx, appcontext.FromGinContext(ctx), q), method)
}
// GetBudget is GET /marketing/enakgame/budgets/:id.
func (h *EnakGameAdminHandler) GetBudget(c *gin.Context) {
const method = "EnakGameAdminHandler::GetBudget"
id, ok := pathID(c, "id", method)
if !ok {
return
}
ctx := c.Request.Context()
util.HandleResponse(c.Writer, c.Request, h.service.GetBudget(ctx, appcontext.FromGinContext(ctx), id), method)
}
// UpdateBudget is PUT /marketing/enakgame/budgets/:id.
func (h *EnakGameAdminHandler) UpdateBudget(c *gin.Context) {
const method = "EnakGameAdminHandler::UpdateBudget"
id, ok := pathID(c, "id", method)
if !ok {
return
}
body, ok := rawBody(c, method)
if !ok {
return
}
ctx := c.Request.Context()
util.HandleResponse(c.Writer, c.Request, h.service.UpdateBudget(ctx, appcontext.FromGinContext(ctx), id, body), method)
}
// DeleteBudget is DELETE /marketing/enakgame/budgets/:id.
func (h *EnakGameAdminHandler) DeleteBudget(c *gin.Context) {
const method = "EnakGameAdminHandler::DeleteBudget"
id, ok := pathID(c, "id", method)
if !ok {
return
}
ctx := c.Request.Context()
util.HandleResponse(c.Writer, c.Request, h.service.DeleteBudget(ctx, appcontext.FromGinContext(ctx), id), method)
}
// BudgetMetrics is GET /marketing/enakgame/budgets/:id/metrics.
func (h *EnakGameAdminHandler) BudgetMetrics(c *gin.Context) {
const method = "EnakGameAdminHandler::BudgetMetrics"
id, ok := pathID(c, "id", method)
if !ok {
return
}
ctx := c.Request.Context()
util.HandleResponse(c.Writer, c.Request, h.service.BudgetMetrics(ctx, appcontext.FromGinContext(ctx), id), method)
}
// CreateVoucher is POST /marketing/enakgame/vouchers.
func (h *EnakGameAdminHandler) CreateVoucher(c *gin.Context) {
const method = "EnakGameAdminHandler::CreateVoucher"
body, ok := rawBody(c, method)
if !ok {
return
}
ctx := c.Request.Context()
util.HandleResponse(c.Writer, c.Request, h.service.CreateVoucher(ctx, appcontext.FromGinContext(ctx), body), method)
}
// ListVouchers is GET /marketing/enakgame/vouchers?status=&search=&page=&limit=.
func (h *EnakGameAdminHandler) ListVouchers(c *gin.Context) {
const method = "EnakGameAdminHandler::ListVouchers"
var q models.VoucherListQuery
if !bindQuery(c, &q, method) {
return
}
ctx := c.Request.Context()
util.HandleResponse(c.Writer, c.Request, h.service.ListVouchers(ctx, appcontext.FromGinContext(ctx), q), method)
}
// GetVoucher is GET /marketing/enakgame/vouchers/:id.
func (h *EnakGameAdminHandler) GetVoucher(c *gin.Context) {
const method = "EnakGameAdminHandler::GetVoucher"
id, ok := pathID(c, "id", method)
if !ok {
return
}
ctx := c.Request.Context()
util.HandleResponse(c.Writer, c.Request, h.service.GetVoucher(ctx, appcontext.FromGinContext(ctx), id), method)
}
// UpdateVoucher is PUT /marketing/enakgame/vouchers/:id.
func (h *EnakGameAdminHandler) UpdateVoucher(c *gin.Context) {
const method = "EnakGameAdminHandler::UpdateVoucher"
id, ok := pathID(c, "id", method)
if !ok {
return
}
body, ok := rawBody(c, method)
if !ok {
return
}
ctx := c.Request.Context()
util.HandleResponse(c.Writer, c.Request, h.service.UpdateVoucher(ctx, appcontext.FromGinContext(ctx), id, body), method)
}
// SetVoucherStatus is PUT /marketing/enakgame/vouchers/:id/status.
func (h *EnakGameAdminHandler) SetVoucherStatus(c *gin.Context) {
const method = "EnakGameAdminHandler::SetVoucherStatus"
id, ok := pathID(c, "id", method)
if !ok {
return
}
body, ok := rawBody(c, method)
if !ok {
return
}
ctx := c.Request.Context()
util.HandleResponse(c.Writer, c.Request, h.service.SetVoucherStatus(ctx, appcontext.FromGinContext(ctx), id, body), method)
}
// ImportVoucherCodes is POST /marketing/enakgame/vouchers/:id/codes, with the CSV as
// a multipart file named "file" or as the request body.
func (h *EnakGameAdminHandler) ImportVoucherCodes(c *gin.Context) {
const method = "EnakGameAdminHandler::ImportVoucherCodes"
id, ok := pathID(c, "id", method)
if !ok {
return
}
var data []byte
if strings.HasPrefix(c.ContentType(), "multipart/") {
file, err := c.FormFile("file")
if err == nil {
var f multipart.File
if f, err = file.Open(); err == nil {
data, err = io.ReadAll(io.LimitReader(f, voucherCodeUploadLimit))
f.Close()
}
}
if err != nil {
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "send the CSV as a file named \"file\""),
}), method)
return
}
} else if data, ok = rawBody(c, method); !ok {
return
}
ctx := c.Request.Context()
util.HandleResponse(c.Writer, c.Request, h.service.ImportVoucherCodes(ctx, appcontext.FromGinContext(ctx), id, data), method)
}
// ListVoucherCodes is GET /marketing/enakgame/vouchers/:id/codes?status=&page=&limit=.
func (h *EnakGameAdminHandler) ListVoucherCodes(c *gin.Context) {
const method = "EnakGameAdminHandler::ListVoucherCodes"
id, ok := pathID(c, "id", method)
if !ok {
return
}
var q models.VoucherCodeListQuery
if !bindQuery(c, &q, method) {
return
}
ctx := c.Request.Context()
util.HandleResponse(c.Writer, c.Request, h.service.ListVoucherCodes(ctx, appcontext.FromGinContext(ctx), id, q), method)
}
// CreateEvent is POST /marketing/enakgame/events.
func (h *EnakGameAdminHandler) CreateEvent(c *gin.Context) {
const method = "EnakGameAdminHandler::CreateEvent"
body, ok := rawBody(c, method)
if !ok {
return
}
ctx := c.Request.Context()
util.HandleResponse(c.Writer, c.Request, h.service.CreateEvent(ctx, appcontext.FromGinContext(ctx), body), method)
}
// ListEvents is GET /marketing/enakgame/events?status=&page=&limit=.
func (h *EnakGameAdminHandler) ListEvents(c *gin.Context) {
const method = "EnakGameAdminHandler::ListEvents"
var q models.GameEventListQuery
if !bindQuery(c, &q, method) {
return
}
ctx := c.Request.Context()
util.HandleResponse(c.Writer, c.Request, h.service.ListEvents(ctx, appcontext.FromGinContext(ctx), q), method)
}
// GetEvent is GET /marketing/enakgame/events/:id.
func (h *EnakGameAdminHandler) GetEvent(c *gin.Context) {
const method = "EnakGameAdminHandler::GetEvent"
id, ok := pathID(c, "id", method)
if !ok {
return
}
ctx := c.Request.Context()
util.HandleResponse(c.Writer, c.Request, h.service.GetEvent(ctx, appcontext.FromGinContext(ctx), id), method)
}
// UpdateEvent is PUT /marketing/enakgame/events/:id.
func (h *EnakGameAdminHandler) UpdateEvent(c *gin.Context) {
const method = "EnakGameAdminHandler::UpdateEvent"
id, ok := pathID(c, "id", method)
if !ok {
return
}
body, ok := rawBody(c, method)
if !ok {
return
}
ctx := c.Request.Context()
util.HandleResponse(c.Writer, c.Request, h.service.UpdateEvent(ctx, appcontext.FromGinContext(ctx), id, body), method)
}
// SetEventStatus is PUT /marketing/enakgame/events/:id/status.
func (h *EnakGameAdminHandler) SetEventStatus(c *gin.Context) {
const method = "EnakGameAdminHandler::SetEventStatus"
id, ok := pathID(c, "id", method)
if !ok {
return
}
body, ok := rawBody(c, method)
if !ok {
return
}
ctx := c.Request.Context()
util.HandleResponse(c.Writer, c.Request, h.service.SetEventStatus(ctx, appcontext.FromGinContext(ctx), id, body), method)
}