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
+165
View File
@@ -0,0 +1,165 @@
package service
import (
"context"
"sync"
"time"
"apskel-pos-be/internal/logger"
)
const (
defaultGameSessionJobInterval = time.Minute
defaultGameBudgetPeriodJobInterval = 24 * time.Hour
)
// tickerJob runs a function now and then on every tick until stopped. It keeps no
// state in memory: the work it runs is safe on several instances at once.
type tickerJob struct {
name string
run func(ctx context.Context)
stopCh chan struct{}
stopOnce sync.Once
}
func (j *tickerJob) start(interval, fallback time.Duration) {
if interval <= 0 {
interval = fallback
}
go func() {
j.run(context.Background())
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
j.run(context.Background())
case <-j.stopCh:
return
}
}
}()
logger.NonContext.Infof("%s started (interval: %s)", j.name, interval)
}
func (j *tickerJob) stop() {
j.stopOnce.Do(func() { close(j.stopCh) })
}
type gameSessionWork interface {
ProcessDueSessions(ctx context.Context) (refunded, expired int, err error)
}
// GameSessionJob refunds or expires the EnakGame sessions left STARTED
// (docs/rfc-enakgame.md §7.3). Every session is handled under its customer's wallet
// lock with a conditional update, and the refund carries an idempotency key, so
// several instances and repeated runs refund nothing twice.
type GameSessionJob struct{ job tickerJob }
func NewGameSessionJob(work gameSessionWork) *GameSessionJob {
j := &GameSessionJob{}
j.job = tickerJob{name: "Game session job", stopCh: make(chan struct{}), run: func(ctx context.Context) {
refunded, expired, err := work.ProcessDueSessions(ctx)
if err != nil {
logger.NonContext.Error("Game session job failed", err)
}
if refunded > 0 || expired > 0 {
logger.NonContext.Infof("Game session job refunded %d and expired %d sessions", refunded, expired)
}
}}
return j
}
func (j *GameSessionJob) Start(interval time.Duration) {
j.job.start(interval, defaultGameSessionJobInterval)
}
func (j *GameSessionJob) Stop() { j.job.stop() }
type gameBudgetPeriodWork interface {
CreateNextPeriods(ctx context.Context) (int, error)
}
// GameBudgetPeriodJob gives each running global EnakGame budget its successor for the
// following month, when there is none yet (§12). The unique index on the period start
// keeps repeated runs and several instances from creating it twice.
type GameBudgetPeriodJob struct{ job tickerJob }
func NewGameBudgetPeriodJob(work gameBudgetPeriodWork) *GameBudgetPeriodJob {
j := &GameBudgetPeriodJob{}
j.job = tickerJob{name: "Game budget period job", stopCh: make(chan struct{}), run: func(ctx context.Context) {
created, err := work.CreateNextPeriods(ctx)
if err != nil {
logger.NonContext.Error("Game budget period job failed", err)
}
if created > 0 {
logger.NonContext.Infof("Game budget period job created %d budgets", created)
}
}}
return j
}
func (j *GameBudgetPeriodJob) Start(interval time.Duration) {
j.job.start(interval, defaultGameBudgetPeriodJobInterval)
}
func (j *GameBudgetPeriodJob) Stop() { j.job.stop() }
const defaultVoucherCodeExpiryJobInterval = time.Hour
type voucherCodeExpiryWork interface {
ExpireCodes(ctx context.Context, now time.Time) (int64, error)
}
// VoucherCodeExpiryJob moves the voucher codes past their expiry from AVAILABLE to
// EXPIRED (docs/rfc-enakgame.md §12). Redemption already skips them; this keeps the
// counts honest. Rows are taken with SKIP LOCKED, so instances share the work.
type VoucherCodeExpiryJob struct{ job tickerJob }
func NewVoucherCodeExpiryJob(work voucherCodeExpiryWork) *VoucherCodeExpiryJob {
j := &VoucherCodeExpiryJob{}
j.job = tickerJob{name: "Voucher code expiry job", stopCh: make(chan struct{}), run: func(ctx context.Context) {
expired, err := work.ExpireCodes(ctx, time.Now())
if err != nil {
logger.NonContext.Error("Voucher code expiry job failed", err)
}
if expired > 0 {
logger.NonContext.Infof("Voucher code expiry job expired %d codes", expired)
}
}}
return j
}
func (j *VoucherCodeExpiryJob) Start(interval time.Duration) {
j.job.start(interval, defaultVoucherCodeExpiryJobInterval)
}
func (j *VoucherCodeExpiryJob) Stop() { j.job.stop() }
const defaultVoucherRecoveryJobInterval = time.Minute
type voucherRecoveryWork interface {
RecoverPending(ctx context.Context) (completed, failed int, err error)
}
// VoucherRedemptionRecoveryJob settles the EXTERNAL voucher redemptions their provider
// left without an answer (docs/rfc-enakgame.md §7.5 step 4). Redemptions are claimed
// with SKIP LOCKED and settled with conditional updates, so several instances and
// repeated runs never refund one twice.
type VoucherRedemptionRecoveryJob struct{ job tickerJob }
func NewVoucherRedemptionRecoveryJob(work voucherRecoveryWork) *VoucherRedemptionRecoveryJob {
j := &VoucherRedemptionRecoveryJob{}
j.job = tickerJob{name: "Voucher redemption recovery job", stopCh: make(chan struct{}), run: func(ctx context.Context) {
completed, failed, err := work.RecoverPending(ctx)
if err != nil {
logger.NonContext.Error("Voucher redemption recovery job failed", err)
}
if completed > 0 || failed > 0 {
logger.NonContext.Infof("Voucher redemption recovery job completed %d and failed %d redemptions", completed, failed)
}
}}
return j
}
func (j *VoucherRedemptionRecoveryJob) Start(interval time.Duration) {
j.job.start(interval, defaultVoucherRecoveryJobInterval)
}
func (j *VoucherRedemptionRecoveryJob) Stop() { j.job.stop() }
+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),
})
}