feat(enakgame): spin as an EnakGame game; remove the old game flow
EnakGame phase 10 of docs/tasks-enakgame.md (EG-1001 to EG-1003). Spin (EG-1001) - PROBABILITY entries take an optional label (a wheel segment). The customer game list shows a PROBABILITY game's prizes (entry, label, amount, never weights), and completing returns the drawn prize, so the client can draw the wheel and stop it on the server's draw. - docs/enakgame-spin.md: the admin steps to set up spin per organization (no seeder) and the customer app flow. An HTTP test plays it end to end. Old game flow removed (EG-1002) - Routes POST /customer/spin, GET /customer/games, GET /customer/ferris-wheel, and admin /marketing/games, /marketing/game-prizes, /marketing/rewards, with their handlers, services, processors, repositories, validators, models, contracts, mappers and tests (GamePlayProcessor, SpinGameService, rewards, ...). This also closes RFC §15 findings 1 and 2 (double charge, spinning another org's game). - Tables games, game_prizes, game_plays and rewards stay for ledger history. entities.StringSlice moves to its own file; the omset tracker (unrouted) keeps game_id but no longer embeds the old game response. games.is_active dropped (EG-1003) - Migration 000115; nothing reads metadata.coin_cost any more. The EnakPoint integration docs now point at /customer/enakgame. The Postgres tests were not run: no test database here. Migration 000115 has not been run anywhere. The customer app must stop calling the removed endpoints before this is deployed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
296708244e
commit
18e87398bb
@@ -5,20 +5,17 @@ import (
|
||||
"fmt"
|
||||
|
||||
"apskel-pos-be/internal/models"
|
||||
"apskel-pos-be/internal/repository"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type CustomerPointsProcessor struct {
|
||||
walletQuery *WalletQueryProcessor
|
||||
gameRepo *repository.GameRepository
|
||||
}
|
||||
|
||||
func NewCustomerPointsProcessor(walletQuery *WalletQueryProcessor, gameRepo *repository.GameRepository) *CustomerPointsProcessor {
|
||||
func NewCustomerPointsProcessor(walletQuery *WalletQueryProcessor) *CustomerPointsProcessor {
|
||||
return &CustomerPointsProcessor{
|
||||
walletQuery: walletQuery,
|
||||
gameRepo: gameRepo,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,94 +71,6 @@ func parseWalletCustomerID(customerID string) (uuid.UUID, error) {
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// GetCustomerGamesAPI gets active SPIN games for customers
|
||||
func (p *CustomerPointsProcessor) GetCustomerGamesAPI(ctx context.Context) (*models.GetCustomerGamesResponse, error) {
|
||||
// Get active SPIN games
|
||||
games, err := p.gameRepo.GetActiveSpinGames(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get active SPIN games: %w", err)
|
||||
}
|
||||
|
||||
// Convert to response format
|
||||
var gameResponses []models.CustomerGameResponse
|
||||
for _, game := range games {
|
||||
var prizeResponses []models.CustomerGamePrizeResponse
|
||||
for _, prize := range game.Prizes {
|
||||
prizeResponses = append(prizeResponses, models.CustomerGamePrizeResponse{
|
||||
ID: prize.ID,
|
||||
GameID: prize.GameID,
|
||||
Name: prize.Name,
|
||||
Image: prize.Image,
|
||||
Metadata: (*map[string]interface{})(&prize.Metadata),
|
||||
CreatedAt: prize.CreatedAt,
|
||||
UpdatedAt: prize.UpdatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
gameResponses = append(gameResponses, models.CustomerGameResponse{
|
||||
ID: game.ID,
|
||||
Name: game.Name,
|
||||
Type: string(game.Type),
|
||||
IsActive: game.IsActive,
|
||||
Metadata: (*map[string]interface{})(&game.Metadata),
|
||||
Prizes: prizeResponses,
|
||||
CreatedAt: game.CreatedAt,
|
||||
UpdatedAt: game.UpdatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
return &models.GetCustomerGamesResponse{
|
||||
Status: "SUCCESS",
|
||||
Message: "Customer games retrieved successfully.",
|
||||
Data: &models.GetCustomerGamesResponseData{
|
||||
Games: gameResponses,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetFerrisWheelGameAPI gets the Ferris Wheel game for customers
|
||||
func (p *CustomerPointsProcessor) GetFerrisWheelGameAPI(ctx context.Context) (*models.GetFerrisWheelGameResponse, error) {
|
||||
// Get Ferris Wheel game
|
||||
game, err := p.gameRepo.GetFerrisWheelGame(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get Ferris Wheel game: %w", err)
|
||||
}
|
||||
|
||||
// Convert prizes to response format
|
||||
var prizeResponses []models.CustomerGamePrizeResponse
|
||||
for _, prize := range game.Prizes {
|
||||
prizeResponses = append(prizeResponses, models.CustomerGamePrizeResponse{
|
||||
ID: prize.ID,
|
||||
GameID: prize.GameID,
|
||||
Name: prize.Name,
|
||||
Image: prize.Image,
|
||||
Metadata: (*map[string]interface{})(&prize.Metadata),
|
||||
CreatedAt: prize.CreatedAt,
|
||||
UpdatedAt: prize.UpdatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
// Convert game to response format
|
||||
gameResponse := models.CustomerGameResponse{
|
||||
ID: game.ID,
|
||||
Name: game.Name,
|
||||
Type: string(game.Type),
|
||||
IsActive: game.IsActive,
|
||||
Metadata: (*map[string]interface{})(&game.Metadata),
|
||||
Prizes: prizeResponses,
|
||||
CreatedAt: game.CreatedAt,
|
||||
UpdatedAt: game.UpdatedAt,
|
||||
}
|
||||
|
||||
return &models.GetFerrisWheelGameResponse{
|
||||
Status: "SUCCESS",
|
||||
Message: "Ferris Wheel game retrieved successfully.",
|
||||
Data: &models.GetFerrisWheelGameResponseData{
|
||||
Game: gameResponse,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *CustomerPointsProcessor) GetCustomerWalletExpiringAPI(ctx context.Context, customerID string) (*models.CustomerWalletExpiringList, error) {
|
||||
id, err := parseWalletCustomerID(customerID)
|
||||
if err != nil {
|
||||
|
||||
@@ -1,301 +0,0 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"apskel-pos-be/internal/constants"
|
||||
"apskel-pos-be/internal/entities"
|
||||
"apskel-pos-be/internal/mappers"
|
||||
"apskel-pos-be/internal/models"
|
||||
"apskel-pos-be/internal/repository"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"math/rand"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// ErrGamePlayRefused wraps every reason a customer cannot play right now: the game,
|
||||
// its prizes or their EnakCoin. The message says which.
|
||||
var ErrGamePlayRefused = errors.New("game cannot be played")
|
||||
|
||||
const (
|
||||
gameCoinCostKey = "coin_cost"
|
||||
defaultGameCoinCost = 1
|
||||
)
|
||||
|
||||
type gameReader interface {
|
||||
GetByID(ctx context.Context, id uuid.UUID) (*entities.Game, error)
|
||||
}
|
||||
|
||||
type gamePrizeStock interface {
|
||||
GetAvailablePrizes(ctx context.Context, gameID uuid.UUID) ([]entities.GamePrize, error)
|
||||
DecreaseStock(ctx context.Context, id uuid.UUID, amount int) error
|
||||
}
|
||||
|
||||
type GamePlayProcessor struct {
|
||||
gamePlayRepo repository.GamePlayRepository
|
||||
gameRepo gameReader
|
||||
gamePrizeRepo gamePrizeStock
|
||||
wallet *WalletProcessor
|
||||
spendable spendableReader
|
||||
tx TxRunner
|
||||
}
|
||||
|
||||
func NewGamePlayProcessor(
|
||||
gamePlayRepo repository.GamePlayRepository,
|
||||
gameRepo gameReader,
|
||||
gamePrizeRepo gamePrizeStock,
|
||||
wallet *WalletProcessor,
|
||||
spendable spendableReader,
|
||||
tx TxRunner,
|
||||
) *GamePlayProcessor {
|
||||
return &GamePlayProcessor{
|
||||
gamePlayRepo: gamePlayRepo,
|
||||
gameRepo: gameRepo,
|
||||
gamePrizeRepo: gamePrizeRepo,
|
||||
wallet: wallet,
|
||||
spendable: spendable,
|
||||
tx: tx,
|
||||
}
|
||||
}
|
||||
|
||||
// CreateGamePlay creates a new game play record
|
||||
func (p *GamePlayProcessor) CreateGamePlay(ctx context.Context, req *models.CreateGamePlayRequest) (*models.GamePlayResponse, error) {
|
||||
// Convert request to entity
|
||||
gamePlay := mappers.ToGamePlayEntity(req)
|
||||
|
||||
// Create game play
|
||||
err := p.gamePlayRepo.Create(ctx, gamePlay)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create game play: %w", err)
|
||||
}
|
||||
|
||||
return mappers.ToGamePlayResponse(gamePlay), nil
|
||||
}
|
||||
|
||||
// GetGamePlay retrieves a game play by ID
|
||||
func (p *GamePlayProcessor) GetGamePlay(ctx context.Context, id uuid.UUID) (*models.GamePlayResponse, error) {
|
||||
gamePlay, err := p.gamePlayRepo.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("game play not found: %w", err)
|
||||
}
|
||||
|
||||
return mappers.ToGamePlayResponse(gamePlay), nil
|
||||
}
|
||||
|
||||
// ListGamePlays retrieves game plays with pagination and filtering
|
||||
func (p *GamePlayProcessor) ListGamePlays(ctx context.Context, query *models.ListGamePlaysQuery) (*models.PaginatedResponse[models.GamePlayResponse], error) {
|
||||
// Set default values
|
||||
if query.Page <= 0 {
|
||||
query.Page = 1
|
||||
}
|
||||
if query.Limit <= 0 {
|
||||
query.Limit = 10
|
||||
}
|
||||
if query.Limit > 100 {
|
||||
query.Limit = 100
|
||||
}
|
||||
|
||||
offset := (query.Page - 1) * query.Limit
|
||||
|
||||
// Get game plays from repository
|
||||
gamePlays, total, err := p.gamePlayRepo.List(
|
||||
ctx,
|
||||
offset,
|
||||
query.Limit,
|
||||
query.Search,
|
||||
query.GameID,
|
||||
query.CustomerID,
|
||||
query.PrizeID,
|
||||
query.SortBy,
|
||||
query.SortOrder,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list game plays: %w", err)
|
||||
}
|
||||
|
||||
// Convert to responses
|
||||
responses := mappers.ToGamePlayResponsesFromPointers(gamePlays)
|
||||
|
||||
// Calculate pagination info
|
||||
totalPages := int((total + int64(query.Limit) - 1) / int64(query.Limit))
|
||||
|
||||
return &models.PaginatedResponse[models.GamePlayResponse]{
|
||||
Data: responses,
|
||||
Pagination: models.Pagination{
|
||||
Page: query.Page,
|
||||
Limit: query.Limit,
|
||||
Total: total,
|
||||
TotalPages: totalPages,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// PlayGame plays a game for a customer, paid with EnakCoin (docs/prd-point-coin.md
|
||||
// F8, K1). Every kind of game spends the same EnakCoin, at the game's
|
||||
// metadata.coin_cost. Taking the EnakCoin (GAME_SPEND), recording the play and taking
|
||||
// the prize from stock happen in one transaction, so a play that cannot be completed,
|
||||
// for instance because the prize just ran out, takes nothing and records nothing.
|
||||
func (p *GamePlayProcessor) PlayGame(ctx context.Context, req *models.PlayGameRequest) (*models.PlayGameResponse, error) {
|
||||
refuse := func(format string, args ...any) error {
|
||||
return fmt.Errorf("%w: %s", ErrGamePlayRefused, fmt.Sprintf(format, args...))
|
||||
}
|
||||
game, err := p.gameRepo.GetByID(ctx, req.GameID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("game not found: %w", err)
|
||||
}
|
||||
if !game.IsActive {
|
||||
return nil, refuse("game is not active")
|
||||
}
|
||||
cost, err := gameCoinCost(game.Metadata)
|
||||
if err != nil {
|
||||
return nil, refuse("%s", err.Error())
|
||||
}
|
||||
|
||||
var gamePlay *entities.GamePlay
|
||||
var selectedPrize *models.GamePrizeResponse
|
||||
err = p.tx.WithTransaction(ctx, func(ctx context.Context) error {
|
||||
availablePrizes, err := p.gamePrizeRepo.GetAvailablePrizes(ctx, req.GameID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get available prizes: %w", err)
|
||||
}
|
||||
if len(availablePrizes) == 0 {
|
||||
return refuse("no prizes available")
|
||||
}
|
||||
prizeResponses := make([]models.GamePrizeResponse, len(availablePrizes))
|
||||
for i, prize := range availablePrizes {
|
||||
prizeResponses[i] = *mappers.ToGamePrizeResponse(&prize)
|
||||
}
|
||||
selectedPrize = p.selectPrizeByWeight(prizeResponses)
|
||||
|
||||
// Random seed kept for audit.
|
||||
randomSeed := fmt.Sprintf("%d", time.Now().UnixNano())
|
||||
gamePlay = &entities.GamePlay{
|
||||
ID: uuid.New(),
|
||||
GameID: req.GameID,
|
||||
CustomerID: req.CustomerID,
|
||||
CoinsUsed: int(cost),
|
||||
RandomSeed: &randomSeed,
|
||||
}
|
||||
if selectedPrize != nil {
|
||||
gamePlay.PrizeID = &selectedPrize.ID
|
||||
}
|
||||
|
||||
// The EnakCoin go first: the debit locks the wallet, so plays by the same
|
||||
// customer at the same time queue up instead of spending the same balance.
|
||||
_, err = p.wallet.Debit(ctx, WalletDebitInput{WalletEntry: WalletEntry{
|
||||
CustomerID: req.CustomerID,
|
||||
Currency: constants.WalletCurrencyCoin,
|
||||
Type: constants.WalletTxTypeGameSpend,
|
||||
Amount: cost,
|
||||
ReferenceType: constants.WalletRefTypeGamePlay,
|
||||
ReferenceID: gamePlay.ID,
|
||||
Description: gameSpendDescription(game.Name, selectedPrize),
|
||||
Metadata: entities.Metadata{"game_id": game.ID.String(), "coin_cost": cost},
|
||||
}})
|
||||
if errors.Is(err, repository.ErrWalletInsufficientBalance) {
|
||||
return refuse("not enough EnakCoin: this game costs %d", cost)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := p.gamePlayRepo.Create(ctx, gamePlay); err != nil {
|
||||
return err
|
||||
}
|
||||
if selectedPrize != nil {
|
||||
err := p.gamePrizeRepo.DecreaseStock(ctx, selectedPrize.ID, 1)
|
||||
if errors.Is(err, repository.ErrGamePrizeOutOfStock) {
|
||||
return refuse("the prize just ran out, please play again")
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
balances, err := p.spendable.SpendableBalances(ctx, req.CustomerID, time.Now())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &models.PlayGameResponse{
|
||||
GamePlay: *mappers.ToGamePlayResponse(gamePlay),
|
||||
PrizeWon: selectedPrize,
|
||||
CoinsRemaining: balances[constants.WalletCurrencyCoin],
|
||||
}, nil
|
||||
}
|
||||
|
||||
// gameCoinCost is what one play of a game costs, from games.metadata.coin_cost: a
|
||||
// whole number of EnakCoin, 1 when it is not set (F8).
|
||||
func gameCoinCost(metadata entities.Metadata) (int64, error) {
|
||||
raw, ok := metadata[gameCoinCostKey]
|
||||
if !ok || raw == nil {
|
||||
return defaultGameCoinCost, nil
|
||||
}
|
||||
var cost int64
|
||||
switch v := raw.(type) {
|
||||
case float64:
|
||||
if v != math.Trunc(v) {
|
||||
return 0, fmt.Errorf("the game's coin_cost %v is not a whole number", v)
|
||||
}
|
||||
cost = int64(v)
|
||||
case int:
|
||||
cost = int64(v)
|
||||
case int64:
|
||||
cost = v
|
||||
default:
|
||||
return 0, fmt.Errorf("the game's coin_cost %v is not a number", raw)
|
||||
}
|
||||
if cost < 1 {
|
||||
return 0, fmt.Errorf("the game's coin_cost must be at least 1, got %d", cost)
|
||||
}
|
||||
return cost, nil
|
||||
}
|
||||
|
||||
// gameSpendDescription is what the customer's history shows for a play, frozen when it
|
||||
// happens (§8.1): "Main Spin Wheel: dapat Voucher 10rb".
|
||||
func gameSpendDescription(gameName string, prize *models.GamePrizeResponse) string {
|
||||
description := "Main " + gameName
|
||||
if prize != nil {
|
||||
description += ": dapat " + prize.Name
|
||||
}
|
||||
return truncateRunes(description, walletDescriptionLimit)
|
||||
}
|
||||
|
||||
// selectPrizeByWeight selects a prize based on weight distribution
|
||||
func (p *GamePlayProcessor) selectPrizeByWeight(prizes []models.GamePrizeResponse) *models.GamePrizeResponse {
|
||||
if len(prizes) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Calculate total weight
|
||||
totalWeight := 0
|
||||
for _, prize := range prizes {
|
||||
totalWeight += prize.Weight
|
||||
}
|
||||
|
||||
if totalWeight == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Generate random number
|
||||
rand.Seed(time.Now().UnixNano())
|
||||
randomNumber := rand.Intn(totalWeight)
|
||||
|
||||
// Select prize based on cumulative weight
|
||||
currentWeight := 0
|
||||
for _, prize := range prizes {
|
||||
currentWeight += prize.Weight
|
||||
if randomNumber < currentWeight {
|
||||
return &prize
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to last prize
|
||||
return &prizes[len(prizes)-1]
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"apskel-pos-be/internal/constants"
|
||||
"apskel-pos-be/internal/models"
|
||||
"apskel-pos-be/internal/repository"
|
||||
)
|
||||
|
||||
// Plays against Postgres, so the game repositories really join the transaction: a
|
||||
// refused play leaves no game_plays row and no GAME_SPEND.
|
||||
func TestPlayGame_AgainstPostgres(t *testing.T) {
|
||||
db, _, player, _ := walletMoveDB(t)
|
||||
gameID, prizeID := uuid.New(), uuid.New()
|
||||
// An old game: archived for EnakGame (000103) but still active for the old spin.
|
||||
require.NoError(t, db.Exec(`INSERT INTO games (id, name, type, metadata, status, entry_cost) VALUES (?, 'Raffle', 'RAFFLE', '{"coin_cost": 2}', 'ARCHIVED', 2)`, gameID).Error)
|
||||
require.NoError(t, db.Exec(`INSERT INTO game_prizes (id, game_id, name, weight, stock) VALUES (?, ?, 'Tumbler', 1, 1)`, prizeID, gameID).Error)
|
||||
t.Cleanup(func() {
|
||||
db.Exec(`DELETE FROM wallet_lot_allocations WHERE transaction_id IN (SELECT id FROM wallet_transactions WHERE customer_id = ?)`, player)
|
||||
db.Exec(`DELETE FROM game_plays WHERE game_id = ?`, gameID)
|
||||
db.Exec(`DELETE FROM game_prizes WHERE game_id = ?`, gameID)
|
||||
db.Exec(`DELETE FROM games WHERE id = ?`, gameID)
|
||||
})
|
||||
|
||||
wallet := NewWalletProcessor(repository.NewWalletRepository(db))
|
||||
txm := repository.NewTxManager(db)
|
||||
require.NoError(t, txm.WithTransaction(context.Background(), func(ctx context.Context) error {
|
||||
in := earn(player, 3, nil)
|
||||
in.Currency = constants.WalletCurrencyCoin
|
||||
_, err := wallet.Credit(ctx, in)
|
||||
return err
|
||||
}))
|
||||
p := NewGamePlayProcessor(repository.NewGamePlayRepository(db), repository.NewGameRepository(db), repository.NewGamePrizeRepository(db),
|
||||
wallet, repository.NewWalletQueryRepository(db), txm)
|
||||
play := func() (*models.PlayGameResponse, error) {
|
||||
return p.PlayGame(context.Background(), &models.PlayGameRequest{GameID: gameID, CustomerID: player})
|
||||
}
|
||||
|
||||
res, err := play()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(1), res.CoinsRemaining)
|
||||
assert.Equal(t, 2, res.GamePlay.CoinsUsed)
|
||||
|
||||
var spend struct {
|
||||
ReferenceID uuid.UUID
|
||||
Amount int64
|
||||
}
|
||||
require.NoError(t, db.Raw(`SELECT reference_id, amount FROM wallet_transactions WHERE customer_id = ? AND type = 'GAME_SPEND'`, player).Scan(&spend).Error)
|
||||
assert.Equal(t, res.GamePlay.ID, spend.ReferenceID)
|
||||
assert.Equal(t, int64(-2), spend.Amount)
|
||||
|
||||
// The only prize is gone, then the EnakCoin are too short: neither play leaves a row.
|
||||
_, err = play()
|
||||
assert.ErrorIs(t, err, ErrGamePlayRefused)
|
||||
require.NoError(t, db.Exec(`UPDATE game_prizes SET stock = 5 WHERE id = ?`, prizeID).Error)
|
||||
_, err = play()
|
||||
assert.ErrorIs(t, err, ErrGamePlayRefused)
|
||||
|
||||
var plays, spends int64
|
||||
require.NoError(t, db.Raw(`SELECT COUNT(*) FROM game_plays WHERE game_id = ? AND coins_used = 2`, gameID).Scan(&plays).Error)
|
||||
require.NoError(t, db.Raw(`SELECT COUNT(*) FROM wallet_transactions WHERE customer_id = ? AND type = 'GAME_SPEND'`, player).Scan(&spends).Error)
|
||||
assert.Equal(t, int64(1), plays)
|
||||
assert.Equal(t, int64(1), spends)
|
||||
}
|
||||
@@ -1,245 +0,0 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
// snapshot saves everything the fake wallet holds and returns what puts it back, so
|
||||
// a test transaction can roll back like a real one.
|
||||
func (f *walletRepoFake) snapshot() func() {
|
||||
wallets := make(map[uuid.UUID]*entities.CustomerWallet, len(f.wallets))
|
||||
for id, w := range f.wallets {
|
||||
c := *w
|
||||
wallets[id] = &c
|
||||
}
|
||||
transactions := make([]*entities.WalletTransaction, len(f.transactions))
|
||||
for i, t := range f.transactions {
|
||||
c := *t
|
||||
transactions[i] = &c
|
||||
}
|
||||
lots := make([]*entities.WalletLot, len(f.lots))
|
||||
for i, l := range f.lots {
|
||||
c := *l
|
||||
lots[i] = &c
|
||||
}
|
||||
allocations := append([]entities.WalletLotAllocation(nil), f.allocations...)
|
||||
return func() {
|
||||
f.wallets, f.transactions, f.lots, f.allocations = wallets, transactions, lots, allocations
|
||||
}
|
||||
}
|
||||
|
||||
// rollbackTx runs fn in place and, when it fails, puts back what each save saved.
|
||||
type rollbackTx struct{ saves []func() func() }
|
||||
|
||||
func (r rollbackTx) WithTransaction(ctx context.Context, fn func(ctx context.Context) error) error {
|
||||
restores := make([]func(), 0, len(r.saves))
|
||||
for _, save := range r.saves {
|
||||
restores = append(restores, save())
|
||||
}
|
||||
err := fn(ctx)
|
||||
if err != nil {
|
||||
for _, restore := range restores {
|
||||
restore()
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
type gameFake struct{ games map[uuid.UUID]*entities.Game }
|
||||
|
||||
func (f gameFake) GetByID(_ context.Context, id uuid.UUID) (*entities.Game, error) {
|
||||
g, ok := f.games[id]
|
||||
if !ok {
|
||||
return nil, errors.New("record not found")
|
||||
}
|
||||
return g, nil
|
||||
}
|
||||
|
||||
type prizeStockFake struct {
|
||||
prizes []entities.GamePrize
|
||||
stockErr error
|
||||
}
|
||||
|
||||
func (f *prizeStockFake) GetAvailablePrizes(_ context.Context, gameID uuid.UUID) ([]entities.GamePrize, error) {
|
||||
var out []entities.GamePrize
|
||||
for _, p := range f.prizes {
|
||||
if p.GameID == gameID && p.Stock > 0 {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (f *prizeStockFake) DecreaseStock(_ context.Context, id uuid.UUID, amount int) error {
|
||||
if f.stockErr != nil {
|
||||
return f.stockErr
|
||||
}
|
||||
for i := range f.prizes {
|
||||
if f.prizes[i].ID == id {
|
||||
if f.prizes[i].Stock < amount {
|
||||
return repository.ErrGamePrizeOutOfStock
|
||||
}
|
||||
f.prizes[i].Stock -= amount
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return repository.ErrGamePrizeOutOfStock
|
||||
}
|
||||
|
||||
func (f *prizeStockFake) snapshot() func() {
|
||||
prizes := append([]entities.GamePrize(nil), f.prizes...)
|
||||
return func() { f.prizes = prizes }
|
||||
}
|
||||
|
||||
// gamePlayRepoFake records plays; the other methods of the interface are not used.
|
||||
type gamePlayRepoFake struct {
|
||||
repository.GamePlayRepository
|
||||
plays []entities.GamePlay
|
||||
}
|
||||
|
||||
func (f *gamePlayRepoFake) Create(_ context.Context, gp *entities.GamePlay) error {
|
||||
f.plays = append(f.plays, *gp)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *gamePlayRepoFake) snapshot() func() {
|
||||
plays := append([]entities.GamePlay(nil), f.plays...)
|
||||
return func() { f.plays = plays }
|
||||
}
|
||||
|
||||
type gameTestEnv struct {
|
||||
*walletMoveEnv
|
||||
game *entities.Game
|
||||
prize entities.GamePrize
|
||||
stock *prizeStockFake
|
||||
plays *gamePlayRepoFake
|
||||
player uuid.UUID
|
||||
p *GamePlayProcessor
|
||||
}
|
||||
|
||||
func newGameTestEnv(t *testing.T, metadata entities.Metadata) *gameTestEnv {
|
||||
e := &gameTestEnv{walletMoveEnv: newWalletMoveEnv(t)}
|
||||
e.game = &entities.Game{ID: uuid.New(), Name: "Spin Wheel", Type: entities.GameTypeSpin, IsActive: true, Metadata: metadata}
|
||||
e.prize = entities.GamePrize{ID: uuid.New(), GameID: e.game.ID, Name: "Voucher 10rb", Weight: 1, Stock: 5}
|
||||
e.stock = &prizeStockFake{prizes: []entities.GamePrize{e.prize}}
|
||||
e.plays = &gamePlayRepoFake{}
|
||||
e.player = e.member("Budi", "081234561234")
|
||||
tx := rollbackTx{saves: []func() func(){e.repo.snapshot, e.stock.snapshot, e.plays.snapshot}}
|
||||
e.p = NewGamePlayProcessor(e.plays, gameFake{games: map[uuid.UUID]*entities.Game{e.game.ID: e.game}}, e.stock, e.walletTestEnv.p, e, tx)
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *gameTestEnv) play() (*models.PlayGameResponse, error) {
|
||||
return e.p.PlayGame(e.ctx, &models.PlayGameRequest{GameID: e.game.ID, CustomerID: e.player})
|
||||
}
|
||||
|
||||
func TestPlayGame_SpendsTheGamesCoinCost(t *testing.T) {
|
||||
// coin_cost comes back from JSONB as a float64.
|
||||
e := newGameTestEnv(t, entities.Metadata{"coin_cost": float64(3)})
|
||||
e.earnCoins(t, e.player, 10, nil)
|
||||
|
||||
res, err := e.play()
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, int64(7), res.CoinsRemaining)
|
||||
require.NotNil(t, res.PrizeWon)
|
||||
assert.Equal(t, e.prize.ID, res.PrizeWon.ID)
|
||||
require.Len(t, e.plays.plays, 1)
|
||||
play := e.plays.plays[0]
|
||||
assert.Equal(t, 3, play.CoinsUsed)
|
||||
assert.Equal(t, e.prize.ID, *play.PrizeID)
|
||||
assert.Equal(t, 4, e.stock.prizes[0].Stock)
|
||||
|
||||
spend := e.repo.transactions[len(e.repo.transactions)-1]
|
||||
assert.Equal(t, constants.WalletTxTypeGameSpend, spend.Type)
|
||||
assert.Equal(t, constants.WalletCurrencyCoin, spend.Currency)
|
||||
assert.Equal(t, int64(-3), spend.Amount)
|
||||
assert.Equal(t, constants.WalletRefTypeGamePlay, spend.ReferenceType)
|
||||
assert.Equal(t, play.ID, spend.ReferenceID, "GAME_SPEND points at the game play")
|
||||
assert.Equal(t, "Main Spin Wheel: dapat Voucher 10rb", spend.Description)
|
||||
}
|
||||
|
||||
func TestPlayGame_CostsOneCoinByDefault(t *testing.T) {
|
||||
e := newGameTestEnv(t, entities.Metadata{})
|
||||
e.earnCoins(t, e.player, 2, nil)
|
||||
|
||||
res, err := e.play()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(1), res.CoinsRemaining)
|
||||
assert.Equal(t, 1, e.plays.plays[0].CoinsUsed)
|
||||
}
|
||||
|
||||
func TestPlayGame_NotEnoughCoinsRecordsNothing(t *testing.T) {
|
||||
e := newGameTestEnv(t, entities.Metadata{"coin_cost": float64(3)})
|
||||
e.earnCoins(t, e.player, 2, nil)
|
||||
|
||||
_, err := e.play()
|
||||
assert.ErrorIs(t, err, ErrGamePlayRefused)
|
||||
assert.Empty(t, e.plays.plays)
|
||||
assert.Equal(t, 5, e.stock.prizes[0].Stock)
|
||||
assert.Equal(t, int64(2), e.coinBalance(t, e.player))
|
||||
}
|
||||
|
||||
func TestPlayGame_PrizeStockFailureCancelsThePlay(t *testing.T) {
|
||||
e := newGameTestEnv(t, entities.Metadata{})
|
||||
e.earnCoins(t, e.player, 5, nil)
|
||||
rows := len(e.repo.transactions)
|
||||
|
||||
// Another play took the last one between reading the prizes and taking it.
|
||||
e.stock.stockErr = repository.ErrGamePrizeOutOfStock
|
||||
_, err := e.play()
|
||||
assert.ErrorIs(t, err, ErrGamePlayRefused)
|
||||
|
||||
// Anything else going wrong with the stock cancels it too.
|
||||
e.stock.stockErr = errors.New("connection reset")
|
||||
_, err = e.play()
|
||||
assert.Error(t, err)
|
||||
|
||||
assert.Empty(t, e.plays.plays)
|
||||
assert.Len(t, e.repo.transactions, rows, "no GAME_SPEND is left behind")
|
||||
assert.Equal(t, int64(5), e.coinBalance(t, e.player))
|
||||
}
|
||||
|
||||
func TestPlayGame_RefusesWhatCannotBePlayed(t *testing.T) {
|
||||
for name, metadata := range map[string]entities.Metadata{
|
||||
"zero": {"coin_cost": float64(0)},
|
||||
"fraction": {"coin_cost": 1.5},
|
||||
"not number": {"coin_cost": "2"},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
e := newGameTestEnv(t, metadata)
|
||||
e.earnCoins(t, e.player, 5, nil)
|
||||
_, err := e.play()
|
||||
assert.ErrorIs(t, err, ErrGamePlayRefused)
|
||||
assert.Equal(t, int64(5), e.coinBalance(t, e.player))
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("inactive", func(t *testing.T) {
|
||||
e := newGameTestEnv(t, entities.Metadata{})
|
||||
e.earnCoins(t, e.player, 5, nil)
|
||||
e.game.IsActive = false
|
||||
_, err := e.play()
|
||||
assert.ErrorIs(t, err, ErrGamePlayRefused)
|
||||
})
|
||||
|
||||
t.Run("no prizes left", func(t *testing.T) {
|
||||
e := newGameTestEnv(t, entities.Metadata{})
|
||||
e.earnCoins(t, e.player, 5, nil)
|
||||
e.stock.prizes[0].Stock = 0
|
||||
_, err := e.play()
|
||||
assert.ErrorIs(t, err, ErrGamePlayRefused)
|
||||
assert.Equal(t, int64(5), e.coinBalance(t, e.player))
|
||||
})
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"apskel-pos-be/internal/mappers"
|
||||
"apskel-pos-be/internal/models"
|
||||
"apskel-pos-be/internal/repository"
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type GamePrizeProcessor struct {
|
||||
gamePrizeRepo *repository.GamePrizeRepository
|
||||
}
|
||||
|
||||
func NewGamePrizeProcessor(gamePrizeRepo *repository.GamePrizeRepository) *GamePrizeProcessor {
|
||||
return &GamePrizeProcessor{
|
||||
gamePrizeRepo: gamePrizeRepo,
|
||||
}
|
||||
}
|
||||
|
||||
// CreateGamePrize creates a new game prize
|
||||
func (p *GamePrizeProcessor) CreateGamePrize(ctx context.Context, req *models.CreateGamePrizeRequest) (*models.GamePrizeResponse, error) {
|
||||
// Convert request to entity
|
||||
gamePrize := mappers.ToGamePrizeEntity(req)
|
||||
|
||||
// Create game prize
|
||||
err := p.gamePrizeRepo.Create(ctx, gamePrize)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create game prize: %w", err)
|
||||
}
|
||||
|
||||
return mappers.ToGamePrizeResponse(gamePrize), nil
|
||||
}
|
||||
|
||||
// GetGamePrize retrieves a game prize by ID
|
||||
func (p *GamePrizeProcessor) GetGamePrize(ctx context.Context, id uuid.UUID) (*models.GamePrizeResponse, error) {
|
||||
gamePrize, err := p.gamePrizeRepo.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("game prize not found: %w", err)
|
||||
}
|
||||
|
||||
return mappers.ToGamePrizeResponse(gamePrize), nil
|
||||
}
|
||||
|
||||
// GetGamePrizesByGameID retrieves all prizes for a specific game
|
||||
func (p *GamePrizeProcessor) GetGamePrizesByGameID(ctx context.Context, gameID uuid.UUID) ([]models.GamePrizeResponse, error) {
|
||||
gamePrizes, err := p.gamePrizeRepo.GetByGameID(ctx, gameID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get game prizes: %w", err)
|
||||
}
|
||||
|
||||
return mappers.ToGamePrizeResponses(gamePrizes), nil
|
||||
}
|
||||
|
||||
// ListGamePrizes retrieves game prizes with pagination and filtering
|
||||
func (p *GamePrizeProcessor) ListGamePrizes(ctx context.Context, query *models.ListGamePrizesQuery) (*models.PaginatedResponse[models.GamePrizeResponse], error) {
|
||||
// Set default values
|
||||
if query.Page <= 0 {
|
||||
query.Page = 1
|
||||
}
|
||||
if query.Limit <= 0 {
|
||||
query.Limit = 10
|
||||
}
|
||||
if query.Limit > 100 {
|
||||
query.Limit = 100
|
||||
}
|
||||
|
||||
offset := (query.Page - 1) * query.Limit
|
||||
|
||||
// Get game prizes from repository
|
||||
gamePrizes, total, err := p.gamePrizeRepo.List(
|
||||
ctx,
|
||||
offset,
|
||||
query.Limit,
|
||||
query.Search,
|
||||
query.GameID,
|
||||
query.SortBy,
|
||||
query.SortOrder,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list game prizes: %w", err)
|
||||
}
|
||||
|
||||
// Convert to responses
|
||||
responses := mappers.ToGamePrizeResponses(gamePrizes)
|
||||
|
||||
// Calculate pagination info
|
||||
totalPages := int((total + int64(query.Limit) - 1) / int64(query.Limit))
|
||||
|
||||
return &models.PaginatedResponse[models.GamePrizeResponse]{
|
||||
Data: responses,
|
||||
Pagination: models.Pagination{
|
||||
Page: query.Page,
|
||||
Limit: query.Limit,
|
||||
Total: total,
|
||||
TotalPages: totalPages,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// UpdateGamePrize updates an existing game prize
|
||||
func (p *GamePrizeProcessor) UpdateGamePrize(ctx context.Context, id uuid.UUID, req *models.UpdateGamePrizeRequest) (*models.GamePrizeResponse, error) {
|
||||
// Get existing game prize
|
||||
gamePrize, err := p.gamePrizeRepo.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("game prize not found: %w", err)
|
||||
}
|
||||
|
||||
// Update game prize fields
|
||||
mappers.UpdateGamePrizeEntity(gamePrize, req)
|
||||
|
||||
// Save updated game prize
|
||||
err = p.gamePrizeRepo.Update(ctx, gamePrize)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to update game prize: %w", err)
|
||||
}
|
||||
|
||||
return mappers.ToGamePrizeResponse(gamePrize), nil
|
||||
}
|
||||
|
||||
// DeleteGamePrize deletes a game prize
|
||||
func (p *GamePrizeProcessor) DeleteGamePrize(ctx context.Context, id uuid.UUID) error {
|
||||
// Get existing game prize
|
||||
_, err := p.gamePrizeRepo.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("game prize not found: %w", err)
|
||||
}
|
||||
|
||||
// Delete game prize
|
||||
err = p.gamePrizeRepo.Delete(ctx, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete game prize: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetAvailablePrizes gets all available prizes for a game (with stock > 0)
|
||||
func (p *GamePrizeProcessor) GetAvailablePrizes(ctx context.Context, gameID uuid.UUID) ([]models.GamePrizeResponse, error) {
|
||||
gamePrizes, err := p.gamePrizeRepo.GetAvailablePrizes(ctx, gameID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get available prizes: %w", err)
|
||||
}
|
||||
|
||||
return mappers.ToGamePrizeResponses(gamePrizes), nil
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"apskel-pos-be/internal/mappers"
|
||||
"apskel-pos-be/internal/models"
|
||||
"apskel-pos-be/internal/repository"
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type GameProcessor struct {
|
||||
gameRepo *repository.GameRepository
|
||||
}
|
||||
|
||||
func NewGameProcessor(gameRepo *repository.GameRepository) *GameProcessor {
|
||||
return &GameProcessor{
|
||||
gameRepo: gameRepo,
|
||||
}
|
||||
}
|
||||
|
||||
// CreateGame creates a new game
|
||||
func (p *GameProcessor) CreateGame(ctx context.Context, req *models.CreateGameRequest) (*models.GameResponse, error) {
|
||||
// Convert request to entity
|
||||
game := mappers.ToGameEntity(req)
|
||||
|
||||
// Create game
|
||||
err := p.gameRepo.Create(ctx, game)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create game: %w", err)
|
||||
}
|
||||
|
||||
return mappers.ToGameResponse(game), nil
|
||||
}
|
||||
|
||||
// GetGame retrieves a game by ID
|
||||
func (p *GameProcessor) GetGame(ctx context.Context, id uuid.UUID) (*models.GameResponse, error) {
|
||||
game, err := p.gameRepo.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("game not found: %w", err)
|
||||
}
|
||||
|
||||
return mappers.ToGameResponse(game), nil
|
||||
}
|
||||
|
||||
// ListGames retrieves games with pagination and filtering
|
||||
func (p *GameProcessor) ListGames(ctx context.Context, query *models.ListGamesQuery) (*models.PaginatedResponse[models.GameResponse], error) {
|
||||
// Set default values
|
||||
if query.Page <= 0 {
|
||||
query.Page = 1
|
||||
}
|
||||
if query.Limit <= 0 {
|
||||
query.Limit = 10
|
||||
}
|
||||
if query.Limit > 100 {
|
||||
query.Limit = 100
|
||||
}
|
||||
|
||||
offset := (query.Page - 1) * query.Limit
|
||||
|
||||
// Get games from repository
|
||||
games, total, err := p.gameRepo.List(
|
||||
ctx,
|
||||
offset,
|
||||
query.Limit,
|
||||
query.Search,
|
||||
query.Type,
|
||||
query.IsActive,
|
||||
query.SortBy,
|
||||
query.SortOrder,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list games: %w", err)
|
||||
}
|
||||
|
||||
// Convert to responses
|
||||
responses := mappers.ToGameResponses(games)
|
||||
|
||||
// Calculate pagination info
|
||||
totalPages := int((total + int64(query.Limit) - 1) / int64(query.Limit))
|
||||
|
||||
return &models.PaginatedResponse[models.GameResponse]{
|
||||
Data: responses,
|
||||
Pagination: models.Pagination{
|
||||
Page: query.Page,
|
||||
Limit: query.Limit,
|
||||
Total: total,
|
||||
TotalPages: totalPages,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// UpdateGame updates an existing game
|
||||
func (p *GameProcessor) UpdateGame(ctx context.Context, id uuid.UUID, req *models.UpdateGameRequest) (*models.GameResponse, error) {
|
||||
// Get existing game
|
||||
game, err := p.gameRepo.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("game not found: %w", err)
|
||||
}
|
||||
|
||||
// Update game fields
|
||||
mappers.UpdateGameEntity(game, req)
|
||||
|
||||
// Save updated game
|
||||
err = p.gameRepo.Update(ctx, game)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to update game: %w", err)
|
||||
}
|
||||
|
||||
return mappers.ToGameResponse(game), nil
|
||||
}
|
||||
|
||||
// DeleteGame deletes a game
|
||||
func (p *GameProcessor) DeleteGame(ctx context.Context, id uuid.UUID) error {
|
||||
// Get existing game
|
||||
_, err := p.gameRepo.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("game not found: %w", err)
|
||||
}
|
||||
|
||||
// Delete game
|
||||
err = p.gameRepo.Delete(ctx, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete game: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetActiveGames gets all active games
|
||||
func (p *GameProcessor) GetActiveGames(ctx context.Context) ([]models.GameResponse, error) {
|
||||
games, err := p.gameRepo.GetActiveGames(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get active games: %w", err)
|
||||
}
|
||||
|
||||
return mappers.ToGameResponses(games), nil
|
||||
}
|
||||
@@ -123,6 +123,7 @@ func (p *GameSessionProcessor) Complete(ctx context.Context, customerID, session
|
||||
if err := json.Unmarshal(session.RewardBreakdown, &stored); err == nil {
|
||||
out.LimitedBy = stored.LimitedBy
|
||||
out.Reward = rewardParts(stored)
|
||||
out.Prize = drawnPrize(stored.RewardType, stored.Base, stored.Detail)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
|
||||
@@ -2,6 +2,7 @@ package processor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
@@ -384,11 +385,26 @@ func (p *GameSessionProcessor) ListGames(ctx context.Context, customerID uuid.UU
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
configs, err := p.games.ListActiveRewardConfigs(ctx, customer.OrganizationID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
prizes := make(map[uuid.UUID][]models.GamePrize, len(configs))
|
||||
for _, c := range configs {
|
||||
if c.RewardType != constants.GameRewardTypeProbability {
|
||||
continue
|
||||
}
|
||||
// Rules passed Validate when the configuration was made.
|
||||
if list, err := probabilityPrizes(json.RawMessage(c.Rules)); err == nil {
|
||||
prizes[c.GameID] = list
|
||||
}
|
||||
}
|
||||
out := make([]models.CustomerEnakGame, 0, len(games))
|
||||
for _, g := range games {
|
||||
item := models.CustomerEnakGame{
|
||||
ID: g.ID, Name: g.Name, Description: g.Description, ThumbnailURL: g.ThumbnailURL, GameURL: g.GameURL,
|
||||
Version: g.Version, EntryCost: g.EntryCost, SessionTTLSeconds: g.SessionTTLSeconds, Events: []models.CustomerGameEvent{},
|
||||
Prizes: prizes[g.ID],
|
||||
}
|
||||
for _, e := range events[g.ID] {
|
||||
item.Events = append(item.Events, models.CustomerGameEvent{
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"apskel-pos-be/internal/constants"
|
||||
"apskel-pos-be/internal/models"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -332,12 +333,18 @@ func (o outcomeBasedReward) Scale(rules json.RawMessage, multiplier int64) (json
|
||||
|
||||
// PROBABILITY: {"table": [{"weight": 1, "amount": 1000}, {"weight": 999, "amount": 0}]}.
|
||||
// Weights are whole numbers, so no check depends on floating point (§8). The draw is
|
||||
// kept in the detail for audit.
|
||||
// kept in the detail for audit. An entry may carry a label, such as a spin wheel's
|
||||
// segment ("Zonk", "Jackpot"); the customer sees the entries and the one drawn, never
|
||||
// the weights.
|
||||
type probabilityReward struct{}
|
||||
|
||||
// probabilityLabelLimit bounds a label's length.
|
||||
const probabilityLabelLimit = 100
|
||||
|
||||
type probabilityEntry struct {
|
||||
Weight *int64 `json:"weight"`
|
||||
Amount *int64 `json:"amount"`
|
||||
Weight *int64 `json:"weight"`
|
||||
Amount *int64 `json:"amount"`
|
||||
Label *string `json:"label,omitempty"`
|
||||
}
|
||||
|
||||
type probabilityRules struct {
|
||||
@@ -363,6 +370,9 @@ func (probabilityReward) parse(rules json.RawMessage) (probabilityRules, int64,
|
||||
if *e.Amount < 0 {
|
||||
return r, 0, invalidRules("entry %d amount must be 0 or more", i+1)
|
||||
}
|
||||
if e.Label != nil && (strings.TrimSpace(*e.Label) == "" || len(*e.Label) > probabilityLabelLimit) {
|
||||
return r, 0, invalidRules("entry %d label must not be empty, at most %d characters", i+1, probabilityLabelLimit)
|
||||
}
|
||||
total += *e.Weight
|
||||
if total > rewardWeightLimit {
|
||||
return r, 0, invalidRules("the weights add up to more than %d", rewardWeightLimit)
|
||||
@@ -389,9 +399,13 @@ func (p probabilityReward) Calculate(rules json.RawMessage, _ SessionResult, rng
|
||||
for i, e := range r.Table {
|
||||
cumulative += *e.Weight
|
||||
if roll < cumulative {
|
||||
return *e.Amount, map[string]any{
|
||||
detail := map[string]any{
|
||||
"reward_type": constants.GameRewardTypeProbability, "roll": roll, "total_weight": total, "entry": i + 1,
|
||||
}, nil
|
||||
}
|
||||
if e.Label != nil {
|
||||
detail["label"] = *e.Label
|
||||
}
|
||||
return *e.Amount, detail, nil
|
||||
}
|
||||
}
|
||||
return 0, nil, fmt.Errorf("draw %d is outside the total weight %d", roll, total)
|
||||
@@ -410,3 +424,40 @@ func (p probabilityReward) Scale(rules json.RawMessage, multiplier int64) (json.
|
||||
}
|
||||
return marshalRules(r)
|
||||
}
|
||||
|
||||
// probabilityPrizes lists the entries of PROBABILITY rules as the customer sees them:
|
||||
// in order, without their weights.
|
||||
func probabilityPrizes(rules json.RawMessage) ([]models.GamePrize, error) {
|
||||
r, _, err := probabilityReward{}.parse(rules)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
prizes := make([]models.GamePrize, 0, len(r.Table))
|
||||
for i, e := range r.Table {
|
||||
prizes = append(prizes, models.GamePrize{Entry: i + 1, Label: e.Label, Amount: *e.Amount})
|
||||
}
|
||||
return prizes, nil
|
||||
}
|
||||
|
||||
// drawnPrize is the entry a PROBABILITY reward drew, read back from its detail; nil
|
||||
// for another type or when nothing was drawn.
|
||||
func drawnPrize(rewardType string, base int64, detail map[string]any) *models.GamePrize {
|
||||
if rewardType != constants.GameRewardTypeProbability {
|
||||
return nil
|
||||
}
|
||||
// A number read back from JSON is a float64; one just drawn is an int.
|
||||
var entry int
|
||||
switch v := detail["entry"].(type) {
|
||||
case int:
|
||||
entry = v
|
||||
case float64:
|
||||
entry = int(v)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
prize := &models.GamePrize{Entry: entry, Amount: base}
|
||||
if label, ok := detail["label"].(string); ok {
|
||||
prize.Label = &label
|
||||
}
|
||||
return prize
|
||||
}
|
||||
|
||||
@@ -3,12 +3,14 @@ package processor
|
||||
import (
|
||||
"encoding/json"
|
||||
"math"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"apskel-pos-be/internal/constants"
|
||||
"apskel-pos-be/internal/models"
|
||||
)
|
||||
|
||||
// fixedRNG returns the given draws in turn.
|
||||
@@ -179,3 +181,49 @@ func TestRewardCalculator_Scale(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
assert.EqualValues(t, math.MaxInt64/15_000*15_000/10_000, v)
|
||||
}
|
||||
|
||||
// EG-1001: a spin wheel is a PROBABILITY game whose entries carry labels.
|
||||
func TestRewardCalculator_SpinPrizes(t *testing.T) {
|
||||
rules := json.RawMessage(`{"table": [
|
||||
{"weight": 50, "amount": 0, "label": "Zonk"},
|
||||
{"weight": 45, "amount": 5, "label": "5 Coin"},
|
||||
{"weight": 5, "amount": 50}]}`)
|
||||
c, err := RewardCalculatorFor(constants.GameRewardTypeProbability)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, c.Validate(rules))
|
||||
|
||||
prizes, err := probabilityPrizes(rules)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []models.GamePrize{
|
||||
{Entry: 1, Label: ptr("Zonk"), Amount: 0}, {Entry: 2, Label: ptr("5 Coin"), Amount: 5}, {Entry: 3, Amount: 50},
|
||||
}, prizes, "no weights")
|
||||
|
||||
base, detail, err := c.Calculate(rules, SessionResult{}, &fixedRNG{draws: []int64{60}})
|
||||
require.NoError(t, err)
|
||||
assert.EqualValues(t, 5, base)
|
||||
assert.Equal(t, &models.GamePrize{Entry: 2, Label: ptr("5 Coin"), Amount: 5}, drawnPrize(constants.GameRewardTypeProbability, base, detail))
|
||||
|
||||
// As read back from reward_breakdown.
|
||||
stored, err := json.Marshal(detail)
|
||||
require.NoError(t, err)
|
||||
var back map[string]any
|
||||
require.NoError(t, json.Unmarshal(stored, &back))
|
||||
assert.Equal(t, &models.GamePrize{Entry: 2, Label: ptr("5 Coin"), Amount: 5}, drawnPrize(constants.GameRewardTypeProbability, base, back))
|
||||
|
||||
_, detail, err = c.Calculate(rules, SessionResult{}, &fixedRNG{draws: []int64{99}})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, &models.GamePrize{Entry: 3, Amount: 50}, drawnPrize(constants.GameRewardTypeProbability, 50, detail), "no label")
|
||||
assert.Nil(t, drawnPrize(constants.GameRewardTypeFixed, 5, map[string]any{"reward_type": "FIXED"}))
|
||||
assert.Nil(t, drawnPrize(constants.GameRewardTypeProbability, 0, nil), "rejected: nothing drawn")
|
||||
|
||||
scaled, err := c.Scale(rules, 9_000)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, string(scaled), `"label":"Zonk"`, "scaling keeps the labels")
|
||||
|
||||
for name, bad := range map[string]string{
|
||||
"empty label": `{"table": [{"weight": 1, "amount": 1, "label": " "}]}`,
|
||||
"label too long": `{"table": [{"weight": 1, "amount": 1, "label": "` + strings.Repeat("x", 101) + `"}]}`,
|
||||
} {
|
||||
assert.ErrorIs(t, c.Validate(json.RawMessage(bad)), ErrInvalidRewardRules, name)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,149 +0,0 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"apskel-pos-be/internal/contract"
|
||||
"apskel-pos-be/internal/entities"
|
||||
"apskel-pos-be/internal/mappers"
|
||||
"apskel-pos-be/internal/models"
|
||||
"apskel-pos-be/internal/repository"
|
||||
)
|
||||
|
||||
type RewardProcessor interface {
|
||||
CreateReward(ctx context.Context, req *contract.CreateRewardRequest) (*models.RewardResponse, error)
|
||||
GetReward(ctx context.Context, id string) (*models.RewardResponse, error)
|
||||
ListRewards(ctx context.Context, req *contract.ListRewardsRequest) (*models.ListRewardsResponse, error)
|
||||
UpdateReward(ctx context.Context, req *contract.UpdateRewardRequest) (*models.RewardResponse, error)
|
||||
DeleteReward(ctx context.Context, id string) error
|
||||
UpdateRewardStock(ctx context.Context, id string, newStock int) error
|
||||
GetRewardsByType(ctx context.Context, rewardType string) ([]models.RewardResponse, error)
|
||||
}
|
||||
|
||||
type rewardProcessor struct {
|
||||
rewardRepo repository.RewardRepository
|
||||
}
|
||||
|
||||
func NewRewardProcessor(rewardRepo repository.RewardRepository) RewardProcessor {
|
||||
return &rewardProcessor{
|
||||
rewardRepo: rewardRepo,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *rewardProcessor) CreateReward(ctx context.Context, req *contract.CreateRewardRequest) (*models.RewardResponse, error) {
|
||||
// Convert request to entity
|
||||
entity := mappers.ToRewardEntity(req)
|
||||
if entity == nil {
|
||||
return nil, fmt.Errorf("invalid request data")
|
||||
}
|
||||
|
||||
// Create in repository
|
||||
if err := p.rewardRepo.Create(ctx, entity); err != nil {
|
||||
return nil, fmt.Errorf("failed to create reward: %w", err)
|
||||
}
|
||||
|
||||
// Convert entity to response
|
||||
response := mappers.ToRewardResponse(entity)
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (p *rewardProcessor) GetReward(ctx context.Context, id string) (*models.RewardResponse, error) {
|
||||
// Get from repository
|
||||
entity, err := p.rewardRepo.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get reward: %w", err)
|
||||
}
|
||||
|
||||
if entity == nil {
|
||||
return nil, fmt.Errorf("reward not found")
|
||||
}
|
||||
|
||||
// Convert entity to response
|
||||
response := mappers.ToRewardResponse(entity)
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (p *rewardProcessor) ListRewards(ctx context.Context, req *contract.ListRewardsRequest) (*models.ListRewardsResponse, error) {
|
||||
// Convert request to entity request
|
||||
entityReq := &entities.ListRewardsRequest{
|
||||
Page: req.Page,
|
||||
Limit: req.Limit,
|
||||
Search: req.Search,
|
||||
RewardType: req.RewardType,
|
||||
MinPoints: req.MinPoints,
|
||||
MaxPoints: req.MaxPoints,
|
||||
HasStock: req.HasStock,
|
||||
}
|
||||
|
||||
// Get from repository
|
||||
entities, total, err := p.rewardRepo.List(ctx, entityReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list rewards: %w", err)
|
||||
}
|
||||
|
||||
// Convert entities to response
|
||||
rewards := mappers.ToRewardResponseSlice(entities)
|
||||
|
||||
response := &models.ListRewardsResponse{
|
||||
Rewards: rewards,
|
||||
Total: total,
|
||||
Page: req.Page,
|
||||
Limit: req.Limit,
|
||||
}
|
||||
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (p *rewardProcessor) UpdateReward(ctx context.Context, req *contract.UpdateRewardRequest) (*models.RewardResponse, error) {
|
||||
// Convert request to entity
|
||||
entity := mappers.ToRewardEntityFromUpdate(req)
|
||||
if entity == nil {
|
||||
return nil, fmt.Errorf("invalid request data")
|
||||
}
|
||||
|
||||
// Update in repository
|
||||
if err := p.rewardRepo.Update(ctx, entity); err != nil {
|
||||
return nil, fmt.Errorf("failed to update reward: %w", err)
|
||||
}
|
||||
|
||||
// Get updated entity
|
||||
updatedEntity, err := p.rewardRepo.GetByID(ctx, req.ID.String())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get updated reward: %w", err)
|
||||
}
|
||||
|
||||
// Convert entity to response
|
||||
response := mappers.ToRewardResponse(updatedEntity)
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (p *rewardProcessor) DeleteReward(ctx context.Context, id string) error {
|
||||
// Delete from repository
|
||||
if err := p.rewardRepo.Delete(ctx, id); err != nil {
|
||||
return fmt.Errorf("failed to delete reward: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *rewardProcessor) UpdateRewardStock(ctx context.Context, id string, newStock int) error {
|
||||
// Update stock in repository
|
||||
if err := p.rewardRepo.UpdateStock(ctx, id, newStock); err != nil {
|
||||
return fmt.Errorf("failed to update reward stock: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *rewardProcessor) GetRewardsByType(ctx context.Context, rewardType string) ([]models.RewardResponse, error) {
|
||||
// Get from repository
|
||||
entities, err := p.rewardRepo.GetByRewardType(ctx, entities.RewardType(rewardType))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get rewards by type: %w", err)
|
||||
}
|
||||
|
||||
// Convert entities to response
|
||||
rewards := mappers.ToRewardResponseSlice(entities)
|
||||
return rewards, nil
|
||||
}
|
||||
Reference in New Issue
Block a user