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
+395
View File
@@ -0,0 +1,395 @@
package service
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"github.com/google/uuid"
"apskel-pos-be/internal/appcontext"
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/contract"
"apskel-pos-be/internal/logger"
"apskel-pos-be/internal/models"
"apskel-pos-be/internal/processor"
"apskel-pos-be/internal/repository"
)
// EnakGameAdminService is the dashboard side of EnakGame: games, reward
// configurations and budgets of the admin's organization (docs/rfc-enakgame.md §11).
// Bodies are read strictly, so a misspelt field is refused instead of ignored.
type EnakGameAdminService interface {
CreateGame(ctx context.Context, apctx *appcontext.ContextInfo, body []byte) *contract.Response
ListGames(ctx context.Context, apctx *appcontext.ContextInfo, q models.EnakGameListQuery) *contract.Response
GetGame(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID) *contract.Response
// UpdateGame changes the fields the body sends and keeps the others.
UpdateGame(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID, body []byte) *contract.Response
SetGameStatus(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID, body []byte) *contract.Response
CreateRewardConfig(ctx context.Context, apctx *appcontext.ContextInfo, gameID uuid.UUID, body []byte) *contract.Response
ListRewardConfigs(ctx context.Context, apctx *appcontext.ContextInfo, gameID uuid.UUID) *contract.Response
ActivateRewardConfig(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID, body []byte) *contract.Response
CreateBudget(ctx context.Context, apctx *appcontext.ContextInfo, body []byte) *contract.Response
ListBudgets(ctx context.Context, apctx *appcontext.ContextInfo, q models.GameBudgetListQuery) *contract.Response
GetBudget(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID) *contract.Response
// UpdateBudget changes the fields the body sends and keeps the others.
UpdateBudget(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID, body []byte) *contract.Response
DeleteBudget(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID) *contract.Response
BudgetMetrics(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID) *contract.Response
CreateEvent(ctx context.Context, apctx *appcontext.ContextInfo, body []byte) *contract.Response
ListEvents(ctx context.Context, apctx *appcontext.ContextInfo, q models.GameEventListQuery) *contract.Response
GetEvent(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID) *contract.Response
// UpdateEvent changes the fields the body sends and keeps the others.
UpdateEvent(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID, body []byte) *contract.Response
SetEventStatus(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID, body []byte) *contract.Response
CreateVoucher(ctx context.Context, apctx *appcontext.ContextInfo, body []byte) *contract.Response
ListVouchers(ctx context.Context, apctx *appcontext.ContextInfo, q models.VoucherListQuery) *contract.Response
GetVoucher(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID) *contract.Response
// UpdateVoucher changes the fields the body sends and keeps the others.
UpdateVoucher(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID, body []byte) *contract.Response
SetVoucherStatus(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID, body []byte) *contract.Response
// ImportVoucherCodes adds the codes of a CSV to a CODE_POOL voucher.
ImportVoucherCodes(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID, csv []byte) *contract.Response
ListVoucherCodes(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID, q models.VoucherCodeListQuery) *contract.Response
}
// EnakGameCustomerService is what the customer app does with EnakGame.
type EnakGameCustomerService interface {
ListGames(ctx context.Context, customerID uuid.UUID) *contract.Response
StartSession(ctx context.Context, customerID uuid.UUID, req *contract.StartGameSessionRequest, idempotencyKey string) *contract.Response
CompleteSession(ctx context.Context, customerID, sessionID uuid.UUID, in models.GameSessionCompleteInput) *contract.Response
ListSessions(ctx context.Context, customerID uuid.UUID, page, limit int) *contract.Response
GetSession(ctx context.Context, customerID, sessionID uuid.UUID) *contract.Response
ListVouchers(ctx context.Context, customerID uuid.UUID) *contract.Response
RedeemVoucher(ctx context.Context, customerID, voucherID uuid.UUID, req *contract.RedeemVoucherRequest, idempotencyKey string, info models.CustomerPinRequestInfo) *contract.Response
ListRedemptions(ctx context.Context, customerID uuid.UUID, page, limit int) *contract.Response
}
type EnakGameAdminServiceImpl struct {
games *processor.EnakGameAdminProcessor
budgets *processor.GameBudgetProcessor
metrics *processor.GameBudgetMetricsProcessor
events *processor.GameEventProcessor
vouchers *processor.VoucherAdminProcessor
}
func NewEnakGameAdminService(games *processor.EnakGameAdminProcessor, budgets *processor.GameBudgetProcessor, metrics *processor.GameBudgetMetricsProcessor,
events *processor.GameEventProcessor, vouchers *processor.VoucherAdminProcessor) *EnakGameAdminServiceImpl {
return &EnakGameAdminServiceImpl{games: games, budgets: budgets, metrics: metrics, events: events, vouchers: vouchers}
}
type EnakGameCustomerServiceImpl struct {
sessions *processor.GameSessionProcessor
redemptions *processor.VoucherRedemptionProcessor
}
func NewEnakGameCustomerService(sessions *processor.GameSessionProcessor, redemptions *processor.VoucherRedemptionProcessor) *EnakGameCustomerServiceImpl {
return &EnakGameCustomerServiceImpl{sessions: sessions, redemptions: redemptions}
}
// respond wraps a processor result, or its error, as a response.
func respond[T any](ctx context.Context, result T, err error) *contract.Response {
if err != nil {
return enakGameErrorResponse(ctx, err)
}
return contract.BuildSuccessResponse(result)
}
func (s *EnakGameAdminServiceImpl) CreateGame(ctx context.Context, apctx *appcontext.ContextInfo, body []byte) *contract.Response {
var in models.EnakGameInput
if resp := decodeStrict(body, &in); resp != nil {
return resp
}
game, err := s.games.CreateGame(ctx, apctx.OrganizationID, apctx.UserID, in)
return respond(ctx, game, err)
}
func (s *EnakGameAdminServiceImpl) ListGames(ctx context.Context, apctx *appcontext.ContextInfo, q models.EnakGameListQuery) *contract.Response {
games, err := s.games.ListGames(ctx, apctx.OrganizationID, q)
return respond(ctx, games, err)
}
func (s *EnakGameAdminServiceImpl) GetGame(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID) *contract.Response {
game, err := s.games.GetGame(ctx, apctx.OrganizationID, id)
return respond(ctx, game, err)
}
func (s *EnakGameAdminServiceImpl) UpdateGame(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID, body []byte) *contract.Response {
current, err := s.games.GetGame(ctx, apctx.OrganizationID, id)
if err != nil {
return enakGameErrorResponse(ctx, err)
}
in := processor.EnakGameInputFrom(current)
if resp := decodeStrict(body, &in); resp != nil {
return resp
}
game, err := s.games.UpdateGame(ctx, apctx.OrganizationID, apctx.UserID, id, in)
return respond(ctx, game, err)
}
func (s *EnakGameAdminServiceImpl) SetGameStatus(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID, body []byte) *contract.Response {
var in models.EnakGameStatusInput
if resp := decodeStrict(body, &in); resp != nil {
return resp
}
game, err := s.games.SetGameStatus(ctx, apctx.OrganizationID, apctx.UserID, id, in)
return respond(ctx, game, err)
}
func (s *EnakGameAdminServiceImpl) CreateRewardConfig(ctx context.Context, apctx *appcontext.ContextInfo, gameID uuid.UUID, body []byte) *contract.Response {
var in models.GameRewardConfigInput
if resp := decodeStrict(body, &in); resp != nil {
return resp
}
config, err := s.games.CreateRewardConfig(ctx, apctx.OrganizationID, apctx.UserID, gameID, in)
return respond(ctx, config, err)
}
func (s *EnakGameAdminServiceImpl) ListRewardConfigs(ctx context.Context, apctx *appcontext.ContextInfo, gameID uuid.UUID) *contract.Response {
configs, err := s.games.ListRewardConfigs(ctx, apctx.OrganizationID, gameID)
return respond(ctx, configs, err)
}
func (s *EnakGameAdminServiceImpl) ActivateRewardConfig(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID, body []byte) *contract.Response {
var in models.GameRewardConfigActivateInput
if len(bytes.TrimSpace(body)) > 0 {
if resp := decodeStrict(body, &in); resp != nil {
return resp
}
}
config, err := s.games.ActivateRewardConfig(ctx, apctx.OrganizationID, apctx.UserID, id, in)
return respond(ctx, config, err)
}
func (s *EnakGameAdminServiceImpl) CreateBudget(ctx context.Context, apctx *appcontext.ContextInfo, body []byte) *contract.Response {
var in models.GameBudgetInput
if resp := decodeStrict(body, &in); resp != nil {
return resp
}
budget, err := s.budgets.CreateBudget(ctx, apctx.OrganizationID, apctx.UserID, in)
return respond(ctx, budget, err)
}
func (s *EnakGameAdminServiceImpl) ListBudgets(ctx context.Context, apctx *appcontext.ContextInfo, q models.GameBudgetListQuery) *contract.Response {
budgets, err := s.budgets.ListBudgets(ctx, apctx.OrganizationID, q)
return respond(ctx, budgets, err)
}
func (s *EnakGameAdminServiceImpl) GetBudget(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID) *contract.Response {
budget, err := s.budgets.GetBudget(ctx, apctx.OrganizationID, id)
return respond(ctx, budget, err)
}
func (s *EnakGameAdminServiceImpl) UpdateBudget(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID, body []byte) *contract.Response {
current, err := s.budgets.GetBudget(ctx, apctx.OrganizationID, id)
if err != nil {
return enakGameErrorResponse(ctx, err)
}
in := processor.GameBudgetInputFrom(current)
if resp := decodeStrict(body, &in); resp != nil {
return resp
}
budget, err := s.budgets.UpdateBudget(ctx, apctx.OrganizationID, apctx.UserID, id, in)
return respond(ctx, budget, err)
}
func (s *EnakGameAdminServiceImpl) DeleteBudget(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID) *contract.Response {
if err := s.budgets.DeleteBudget(ctx, apctx.OrganizationID, apctx.UserID, id); err != nil {
return enakGameErrorResponse(ctx, err)
}
return contract.BuildSuccessResponse(map[string]any{"id": id, "deleted": true})
}
func (s *EnakGameAdminServiceImpl) BudgetMetrics(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID) *contract.Response {
metrics, err := s.metrics.Metrics(ctx, apctx.OrganizationID, id)
return respond(ctx, metrics, err)
}
func (s *EnakGameAdminServiceImpl) CreateEvent(ctx context.Context, apctx *appcontext.ContextInfo, body []byte) *contract.Response {
var in models.GameEventInput
if resp := decodeStrict(body, &in); resp != nil {
return resp
}
event, err := s.events.CreateEvent(ctx, apctx.OrganizationID, apctx.UserID, in)
return respond(ctx, event, err)
}
func (s *EnakGameAdminServiceImpl) ListEvents(ctx context.Context, apctx *appcontext.ContextInfo, q models.GameEventListQuery) *contract.Response {
events, err := s.events.ListEvents(ctx, apctx.OrganizationID, q)
return respond(ctx, events, err)
}
func (s *EnakGameAdminServiceImpl) GetEvent(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID) *contract.Response {
event, err := s.events.GetEvent(ctx, apctx.OrganizationID, id)
return respond(ctx, event, err)
}
func (s *EnakGameAdminServiceImpl) UpdateEvent(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID, body []byte) *contract.Response {
current, err := s.events.GetEvent(ctx, apctx.OrganizationID, id)
if err != nil {
return enakGameErrorResponse(ctx, err)
}
in := processor.GameEventInputFrom(current)
if resp := decodeStrict(body, &in); resp != nil {
return resp
}
event, err := s.events.UpdateEvent(ctx, apctx.OrganizationID, apctx.UserID, id, in)
return respond(ctx, event, err)
}
func (s *EnakGameAdminServiceImpl) SetEventStatus(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID, body []byte) *contract.Response {
var in models.GameEventStatusInput
if resp := decodeStrict(body, &in); resp != nil {
return resp
}
event, err := s.events.SetEventStatus(ctx, apctx.OrganizationID, apctx.UserID, id, in)
return respond(ctx, event, err)
}
func (s *EnakGameAdminServiceImpl) CreateVoucher(ctx context.Context, apctx *appcontext.ContextInfo, body []byte) *contract.Response {
var in models.VoucherInput
if resp := decodeStrict(body, &in); resp != nil {
return resp
}
voucher, err := s.vouchers.CreateVoucher(ctx, apctx.OrganizationID, apctx.UserID, in)
return respond(ctx, voucher, err)
}
func (s *EnakGameAdminServiceImpl) ListVouchers(ctx context.Context, apctx *appcontext.ContextInfo, q models.VoucherListQuery) *contract.Response {
vouchers, err := s.vouchers.ListVouchers(ctx, apctx.OrganizationID, q)
return respond(ctx, vouchers, err)
}
func (s *EnakGameAdminServiceImpl) GetVoucher(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID) *contract.Response {
voucher, err := s.vouchers.GetVoucher(ctx, apctx.OrganizationID, id)
return respond(ctx, voucher, err)
}
func (s *EnakGameAdminServiceImpl) UpdateVoucher(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID, body []byte) *contract.Response {
current, err := s.vouchers.GetVoucher(ctx, apctx.OrganizationID, id)
if err != nil {
return enakGameErrorResponse(ctx, err)
}
in := processor.VoucherInputFrom(current)
if resp := decodeStrict(body, &in); resp != nil {
return resp
}
voucher, err := s.vouchers.UpdateVoucher(ctx, apctx.OrganizationID, apctx.UserID, id, in)
return respond(ctx, voucher, err)
}
func (s *EnakGameAdminServiceImpl) SetVoucherStatus(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID, body []byte) *contract.Response {
var in models.VoucherStatusInput
if resp := decodeStrict(body, &in); resp != nil {
return resp
}
voucher, err := s.vouchers.SetVoucherStatus(ctx, apctx.OrganizationID, apctx.UserID, id, in)
return respond(ctx, voucher, err)
}
func (s *EnakGameAdminServiceImpl) ImportVoucherCodes(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID, csv []byte) *contract.Response {
result, err := s.vouchers.ImportCodes(ctx, apctx.OrganizationID, apctx.UserID, id, csv)
return respond(ctx, result, err)
}
func (s *EnakGameAdminServiceImpl) ListVoucherCodes(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID, q models.VoucherCodeListQuery) *contract.Response {
codes, err := s.vouchers.Codes(ctx, apctx.OrganizationID, id, q)
return respond(ctx, codes, err)
}
func (s *EnakGameCustomerServiceImpl) ListVouchers(ctx context.Context, customerID uuid.UUID) *contract.Response {
vouchers, err := s.redemptions.Catalog(ctx, customerID)
return respond(ctx, vouchers, err)
}
func (s *EnakGameCustomerServiceImpl) RedeemVoucher(ctx context.Context, customerID, voucherID uuid.UUID, req *contract.RedeemVoucherRequest, idempotencyKey string, info models.CustomerPinRequestInfo) *contract.Response {
result, err := s.redemptions.Redeem(ctx, customerID, voucherID, req.Pin, idempotencyKey, info)
return respond(ctx, result, err)
}
func (s *EnakGameCustomerServiceImpl) ListRedemptions(ctx context.Context, customerID uuid.UUID, page, limit int) *contract.Response {
redemptions, err := s.redemptions.ListRedemptions(ctx, customerID, page, limit)
return respond(ctx, redemptions, err)
}
func (s *EnakGameCustomerServiceImpl) ListGames(ctx context.Context, customerID uuid.UUID) *contract.Response {
games, err := s.sessions.ListGames(ctx, customerID)
return respond(ctx, games, err)
}
func (s *EnakGameCustomerServiceImpl) StartSession(ctx context.Context, customerID uuid.UUID, req *contract.StartGameSessionRequest, idempotencyKey string) *contract.Response {
started, err := s.sessions.Start(ctx, customerID, req.GameID, idempotencyKey)
return respond(ctx, started, err)
}
func (s *EnakGameCustomerServiceImpl) CompleteSession(ctx context.Context, customerID, sessionID uuid.UUID, in models.GameSessionCompleteInput) *contract.Response {
completion, err := s.sessions.Complete(ctx, customerID, sessionID, in)
return respond(ctx, completion, err)
}
func (s *EnakGameCustomerServiceImpl) ListSessions(ctx context.Context, customerID uuid.UUID, page, limit int) *contract.Response {
sessions, err := s.sessions.ListSessions(ctx, customerID, page, limit)
return respond(ctx, sessions, err)
}
func (s *EnakGameCustomerServiceImpl) GetSession(ctx context.Context, customerID, sessionID uuid.UUID) *contract.Response {
session, err := s.sessions.GetSession(ctx, customerID, sessionID)
return respond(ctx, session, err)
}
// decodeStrict reads a JSON body into v, refusing unknown fields and trailing data.
// It returns the error response, or nil when the body fits.
func decodeStrict(body []byte, v any) *contract.Response {
dec := json.NewDecoder(bytes.NewReader(body))
dec.DisallowUnknownFields()
err := dec.Decode(v)
if err == nil {
if _, trailing := dec.Token(); trailing != io.EOF {
err = errors.New("unexpected data after the JSON object")
}
}
if err != nil {
return contract.BuildErrorResponse([]*contract.ResponseError{
contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, fmt.Sprintf("invalid request body: %v", err)),
})
}
return nil
}
// enakGameErrorResponse tells a refused or missing thing apart from a server failure,
// whose details stay in the log.
func enakGameErrorResponse(ctx context.Context, err error) *contract.Response {
var pinErr *processor.PinError
if errors.As(err, &pinErr) {
return PinErrorResponse(err)
}
code, cause := constants.InternalServerErrorCode, "internal error"
switch {
case errors.Is(err, repository.ErrEnakGameNotFound),
errors.Is(err, repository.ErrGameRewardConfigNotFound),
errors.Is(err, repository.ErrGameBudgetNotFound),
errors.Is(err, repository.ErrGameSessionNotFound),
errors.Is(err, repository.ErrVoucherNotFound),
errors.Is(err, repository.ErrGameEventNotFound),
errors.Is(err, repository.ErrWalletNotFound):
code, cause = constants.NotFoundErrorCode, err.Error()
case errors.Is(err, processor.ErrEnakGameRejected),
errors.Is(err, processor.ErrGameSessionRejected),
errors.Is(err, processor.ErrVoucherRedemptionRejected),
errors.Is(err, processor.ErrWalletIdempotencyConflict):
code, cause = constants.ValidationErrorCode, err.Error()
default:
logger.FromContext(ctx).WithError(err).Error("EnakGame request failed")
}
return contract.BuildErrorResponse([]*contract.ResponseError{
contract.NewResponseError(code, constants.EnakGameServiceEntity, cause),
})
}