diff --git a/internal/app/app.go b/internal/app/app.go index 68615a5..0329920 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -449,7 +449,7 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor tierProcessor: processor.NewTierProcessor(repos.tierRepo), gameProcessor: processor.NewGameProcessor(repos.gameRepo), gamePrizeProcessor: processor.NewGamePrizeProcessor(repos.gamePrizeRepo), - gamePlayProcessor: processor.NewGamePlayProcessor(repos.gamePlayRepo, repos.gameRepo, repos.gamePrizeRepo, repos.customerTokensRepo, repos.customerPointsRepo), + gamePlayProcessor: processor.NewGamePlayProcessor(repos.gamePlayRepo, repos.gameRepo, repos.gamePrizeRepo, processor.NewWalletProcessor(repos.walletRepo), repos.walletQueryRepo, repos.txManager), omsetTrackerProcessor: processor.NewOmsetTrackerProcessor(repos.omsetTrackerRepo), rewardProcessor: processor.NewRewardProcessor(repos.rewardRepo), campaignProcessor: processor.NewCampaignProcessor(repos.campaignRepo), @@ -556,7 +556,7 @@ func (a *App) initServices(processors *processors, repos *repositories, cfg *con campaignService := service.NewCampaignService(processors.campaignProcessor, processors.campaignRuleProcessor) customerAuthService := service.NewCustomerAuthService(processors.customerAuthProcessor) customerPointsService := service.NewCustomerPointsService(processors.customerPointsProcessor) - spinGameService := service.NewSpinGameService(processors.gamePlayProcessor, repos.txManager) + spinGameService := service.NewSpinGameService(processors.gamePlayProcessor) userDeviceService := service.NewUserDeviceService(processors.userDeviceProcessor) notificationService := service.NewNotificationService(processors.notificationProcessor) diff --git a/internal/contract/game_play_contract.go b/internal/contract/game_play_contract.go index 57ab50d..b32ad4f 100644 --- a/internal/contract/game_play_contract.go +++ b/internal/contract/game_play_contract.go @@ -9,15 +9,17 @@ import ( type CreateGamePlayRequest struct { GameID uuid.UUID `json:"game_id" validate:"required"` CustomerID uuid.UUID `json:"customer_id" validate:"required"` - TokenUsed int `json:"token_used" validate:"min=0"` + CoinsUsed int `json:"coins_used" validate:"min=0"` RandomSeed *string `json:"random_seed,omitempty"` } type GamePlayResponse struct { - ID uuid.UUID `json:"id"` - GameID uuid.UUID `json:"game_id"` - CustomerID uuid.UUID `json:"customer_id"` - PrizeID *uuid.UUID `json:"prize_id,omitempty"` + ID uuid.UUID `json:"id"` + GameID uuid.UUID `json:"game_id"` + CustomerID uuid.UUID `json:"customer_id"` + PrizeID *uuid.UUID `json:"prize_id,omitempty"` + CoinsUsed int `json:"coins_used"` + // Deprecated: the same as CoinsUsed, kept until the apps read coins_used. TokenUsed int `json:"token_used"` RandomSeed *string `json:"random_seed,omitempty"` CreatedAt time.Time `json:"created_at"` @@ -33,7 +35,7 @@ type ListGamePlaysRequest struct { GameID *uuid.UUID `json:"game_id"` CustomerID *uuid.UUID `json:"customer_id"` PrizeID *uuid.UUID `json:"prize_id"` - SortBy string `json:"sort_by" validate:"omitempty,oneof=created_at token_used"` + SortBy string `json:"sort_by" validate:"omitempty,oneof=created_at coins_used token_used"` SortOrder string `json:"sort_order" validate:"omitempty,oneof=asc desc"` } @@ -45,14 +47,17 @@ type PaginatedGamePlaysResponse struct { TotalPages int `json:"total_pages"` } +// PlayGameRequest plays a game for a customer. What it costs is the game's +// metadata.coin_cost (docs/prd-point-coin.md F8), never the caller's choice. type PlayGameRequest struct { GameID uuid.UUID `json:"game_id" validate:"required"` CustomerID uuid.UUID `json:"customer_id" validate:"required"` - TokenUsed int `json:"token_used" validate:"min=0"` } type PlayGameResponse struct { - GamePlay GamePlayResponse `json:"game_play"` - PrizeWon *GamePrizeResponse `json:"prize_won,omitempty"` - TokensRemaining int64 `json:"tokens_remaining"` + GamePlay GamePlayResponse `json:"game_play"` + PrizeWon *GamePrizeResponse `json:"prize_won,omitempty"` + CoinsRemaining int64 `json:"coins_remaining"` + // Deprecated: the same as CoinsRemaining, kept until the apps read coins_remaining. + TokensRemaining int64 `json:"tokens_remaining"` } diff --git a/internal/contract/spin_game_contract.go b/internal/contract/spin_game_contract.go index e41dc41..67b50b7 100644 --- a/internal/contract/spin_game_contract.go +++ b/internal/contract/spin_game_contract.go @@ -14,7 +14,9 @@ type SpinGameResponse struct { // SpinGameResponseData contains the game play result type SpinGameResponseData struct { - GamePlay GamePlayResponse `json:"game_play"` - PrizeWon *CustomerGamePrizeResponse `json:"prize_won,omitempty"` - TokensRemaining int64 `json:"tokens_remaining"` + GamePlay GamePlayResponse `json:"game_play"` + PrizeWon *CustomerGamePrizeResponse `json:"prize_won,omitempty"` + CoinsRemaining int64 `json:"coins_remaining"` + // Deprecated: the same as CoinsRemaining, kept until the apps read coins_remaining. + TokensRemaining int64 `json:"tokens_remaining"` } diff --git a/internal/entities/game_play.go b/internal/entities/game_play.go index 5ef61f6..026ca95 100644 --- a/internal/entities/game_play.go +++ b/internal/entities/game_play.go @@ -12,7 +12,7 @@ type GamePlay struct { GameID uuid.UUID `gorm:"type:uuid;not null;index" json:"game_id" validate:"required"` CustomerID uuid.UUID `gorm:"type:uuid;not null;index" json:"customer_id" validate:"required"` PrizeID *uuid.UUID `gorm:"type:uuid" json:"prize_id,omitempty"` - TokenUsed int `gorm:"default:0" json:"token_used" validate:"min=0"` + CoinsUsed int `gorm:"column:coins_used;default:0" json:"coins_used" validate:"min=0"` RandomSeed *string `gorm:"type:varchar(255)" json:"random_seed,omitempty"` CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` diff --git a/internal/handler/spin_game_handler.go b/internal/handler/spin_game_handler.go index 3b218cb..b7c033d 100644 --- a/internal/handler/spin_game_handler.go +++ b/internal/handler/spin_game_handler.go @@ -1,10 +1,13 @@ package handler import ( + "errors" + "apskel-pos-be/internal/constants" "apskel-pos-be/internal/contract" "apskel-pos-be/internal/mappers" "apskel-pos-be/internal/models" + "apskel-pos-be/internal/processor" "apskel-pos-be/internal/service" "apskel-pos-be/internal/util" @@ -49,8 +52,14 @@ func (h *SpinGameHandler) PlaySpinGame(c *gin.Context) { // Play the spin game response, err := h.spinGameService.PlaySpinGame(c.Request.Context(), customerID.(string), modelReq) if err != nil { + // Not enough EnakCoin, an inactive game or a prize that just ran out is the + // customer's to fix, not a server failure. + code := constants.InternalServerErrorCode + if errors.Is(err, processor.ErrGamePlayRefused) { + code = constants.ValidationErrorCode + } util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{ - contract.NewResponseError(constants.InternalServerErrorCode, constants.SpinGameHandlerEntity, err.Error()), + contract.NewResponseError(code, constants.SpinGameHandlerEntity, err.Error()), }), "SpinGameHandler::PlaySpinGame") return } diff --git a/internal/mappers/game_play_mapper.go b/internal/mappers/game_play_mapper.go index cf88268..659ca51 100644 --- a/internal/mappers/game_play_mapper.go +++ b/internal/mappers/game_play_mapper.go @@ -16,7 +16,7 @@ func ToGamePlayResponse(gamePlay *entities.GamePlay) *models.GamePlayResponse { GameID: gamePlay.GameID, CustomerID: gamePlay.CustomerID, PrizeID: gamePlay.PrizeID, - TokenUsed: gamePlay.TokenUsed, + CoinsUsed: gamePlay.CoinsUsed, RandomSeed: gamePlay.RandomSeed, CreatedAt: gamePlay.CreatedAt, Game: ToGameResponse(&gamePlay.Game), @@ -48,7 +48,7 @@ func ToGamePlayEntity(req *models.CreateGamePlayRequest) *entities.GamePlay { return &entities.GamePlay{ GameID: req.GameID, CustomerID: req.CustomerID, - TokenUsed: req.TokenUsed, + CoinsUsed: req.CoinsUsed, RandomSeed: req.RandomSeed, } } diff --git a/internal/mappers/spin_game_mapper.go b/internal/mappers/spin_game_mapper.go index be5b882..dd4b184 100644 --- a/internal/mappers/spin_game_mapper.go +++ b/internal/mappers/spin_game_mapper.go @@ -38,7 +38,8 @@ func SpinGameResponseDataModelToContract(data *models.SpinGameResponseData) *con return &contract.SpinGameResponseData{ GamePlay: GamePlayResponseModelToContract(&data.GamePlay), PrizeWon: CustomerGamePrizeResponseModelToContract(data.PrizeWon), - TokensRemaining: data.TokensRemaining, + CoinsRemaining: data.CoinsRemaining, + TokensRemaining: data.CoinsRemaining, } } @@ -53,7 +54,8 @@ func GamePlayResponseModelToContract(resp *models.GamePlayResponse) contract.Gam GameID: resp.GameID, CustomerID: resp.CustomerID, PrizeID: resp.PrizeID, - TokenUsed: resp.TokenUsed, + CoinsUsed: resp.CoinsUsed, + TokenUsed: resp.CoinsUsed, RandomSeed: resp.RandomSeed, CreatedAt: resp.CreatedAt, Game: nil, // Optional field - can be populated separately if needed diff --git a/internal/models/game_play.go b/internal/models/game_play.go index 342c2c9..f2378b3 100644 --- a/internal/models/game_play.go +++ b/internal/models/game_play.go @@ -9,7 +9,7 @@ import ( type CreateGamePlayRequest struct { GameID uuid.UUID `json:"game_id" validate:"required"` CustomerID uuid.UUID `json:"customer_id" validate:"required"` - TokenUsed int `json:"token_used" validate:"min=0"` + CoinsUsed int `json:"coins_used" validate:"min=0"` RandomSeed *string `json:"random_seed,omitempty"` } @@ -18,7 +18,7 @@ type GamePlayResponse struct { GameID uuid.UUID `json:"game_id"` CustomerID uuid.UUID `json:"customer_id"` PrizeID *uuid.UUID `json:"prize_id,omitempty"` - TokenUsed int `json:"token_used"` + CoinsUsed int `json:"coins_used"` RandomSeed *string `json:"random_seed,omitempty"` CreatedAt time.Time `json:"created_at"` Game *GameResponse `json:"game,omitempty"` @@ -33,18 +33,18 @@ type ListGamePlaysQuery struct { GameID *uuid.UUID `query:"game_id"` CustomerID *uuid.UUID `query:"customer_id"` PrizeID *uuid.UUID `query:"prize_id"` - SortBy string `query:"sort_by" validate:"omitempty,oneof=created_at token_used"` + SortBy string `query:"sort_by" validate:"omitempty,oneof=created_at coins_used token_used"` SortOrder string `query:"sort_order" validate:"omitempty,oneof=asc desc"` } +// PlayGameRequest plays a game for a customer, at the game's metadata.coin_cost. type PlayGameRequest struct { GameID uuid.UUID `json:"game_id" validate:"required"` CustomerID uuid.UUID `json:"customer_id" validate:"required"` - TokenUsed int `json:"token_used" validate:"min=0"` } type PlayGameResponse struct { - GamePlay GamePlayResponse `json:"game_play"` - PrizeWon *GamePrizeResponse `json:"prize_won,omitempty"` - TokensRemaining int64 `json:"tokens_remaining"` + GamePlay GamePlayResponse `json:"game_play"` + PrizeWon *GamePrizeResponse `json:"prize_won,omitempty"` + CoinsRemaining int64 `json:"coins_remaining"` } diff --git a/internal/models/spin_game.go b/internal/models/spin_game.go index ae66946..a384b7a 100644 --- a/internal/models/spin_game.go +++ b/internal/models/spin_game.go @@ -20,9 +20,9 @@ type SpinGameResponse struct { // SpinGameResponseData contains the game play result type SpinGameResponseData struct { - GamePlay GamePlayResponse `json:"game_play"` - PrizeWon *CustomerGamePrizeResponse `json:"prize_won,omitempty"` - TokensRemaining int64 `json:"tokens_remaining"` + GamePlay GamePlayResponse `json:"game_play"` + PrizeWon *CustomerGamePrizeResponse `json:"prize_won,omitempty"` + CoinsRemaining int64 `json:"coins_remaining"` } // SpinGamePrizeResponse represents a prize won in the spin game diff --git a/internal/processor/game_play_processor.go b/internal/processor/game_play_processor.go index 4a77282..15e3337 100644 --- a/internal/processor/game_play_processor.go +++ b/internal/processor/game_play_processor.go @@ -1,6 +1,7 @@ package processor import ( + "apskel-pos-be/internal/constants" "apskel-pos-be/internal/entities" "apskel-pos-be/internal/mappers" "apskel-pos-be/internal/models" @@ -8,33 +9,55 @@ import ( "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 *repository.GameRepository - gamePrizeRepo *repository.GamePrizeRepository - customerTokensRepo *repository.CustomerTokensRepository - customerPointsRepo repository.CustomerPointsRepository + gamePlayRepo repository.GamePlayRepository + gameRepo gameReader + gamePrizeRepo gamePrizeStock + wallet *WalletProcessor + spendable spendableReader + tx TxRunner } func NewGamePlayProcessor( gamePlayRepo repository.GamePlayRepository, - gameRepo *repository.GameRepository, - gamePrizeRepo *repository.GamePrizeRepository, - customerTokensRepo *repository.CustomerTokensRepository, - customerPointsRepo repository.CustomerPointsRepository, + gameRepo gameReader, + gamePrizeRepo gamePrizeStock, + wallet *WalletProcessor, + spendable spendableReader, + tx TxRunner, ) *GamePlayProcessor { return &GamePlayProcessor{ - gamePlayRepo: gamePlayRepo, - gameRepo: gameRepo, - gamePrizeRepo: gamePrizeRepo, - customerTokensRepo: customerTokensRepo, - customerPointsRepo: customerPointsRepo, + gamePlayRepo: gamePlayRepo, + gameRepo: gameRepo, + gamePrizeRepo: gamePrizeRepo, + wallet: wallet, + spendable: spendable, + tx: tx, } } @@ -76,6 +99,10 @@ func (p *GamePlayProcessor) ListGamePlays(ctx context.Context, query *models.Lis } offset := (query.Page - 1) * query.Limit + // The column was renamed; the old name still sorts the same way. + if query.SortBy == "token_used" { + query.SortBy = "coins_used" + } // Get game plays from repository gamePlays, total, err := p.gamePlayRepo.List( @@ -110,101 +137,140 @@ func (p *GamePlayProcessor) ListGamePlays(ctx context.Context, query *models.Lis }, nil } -// PlayGame handles the game playing logic +// 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) { - // Verify game exists and is active + 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, errors.New("game is not active") + return nil, refuse("game is not active") } - - // Convert GameType to TokenType - tokenType := entities.TokenType(game.Type) - - // Check if customer has enough tokens - customerTokens, err := p.customerTokensRepo.GetByCustomerIDAndType(ctx, req.CustomerID, tokenType) + cost, err := gameCoinCost(game.Metadata) if err != nil { - return nil, fmt.Errorf("customer tokens not found: %w", err) + return nil, refuse("%s", err.Error()) } - if customerTokens.Balance < int64(req.TokenUsed) { - return nil, errors.New("insufficient tokens") - } - - // Deduct tokens - err = p.customerTokensRepo.DeductTokens(ctx, req.CustomerID, tokenType, int64(req.TokenUsed)) - if err != nil { - return nil, fmt.Errorf("failed to deduct tokens: %w", err) - } - - // Get available prizes - availablePrizes, err := p.gamePrizeRepo.GetAvailablePrizes(ctx, req.GameID) - if err != nil { - return nil, fmt.Errorf("failed to get available prizes: %w", err) - } - - if len(availablePrizes) == 0 { - return nil, errors.New("no prizes available") - } - - // Convert entities to models for prize selection - prizeResponses := make([]models.GamePrizeResponse, len(availablePrizes)) - for i, prize := range availablePrizes { - prizeResponses[i] = *mappers.ToGamePrizeResponse(&prize) - } - - // Select prize based on weight - selectedPrize := p.selectPrizeByWeight(prizeResponses) - - // Generate random seed for audit - randomSeed := fmt.Sprintf("%d", time.Now().UnixNano()) - - // Create game play record - gamePlay := &models.CreateGamePlayRequest{ - GameID: req.GameID, - CustomerID: req.CustomerID, - TokenUsed: req.TokenUsed, - RandomSeed: &randomSeed, - } - - gamePlayEntity := mappers.ToGamePlayEntity(gamePlay) - if selectedPrize != nil { - gamePlayEntity.PrizeID = &selectedPrize.ID - } - - err = p.gamePlayRepo.Create(ctx, gamePlayEntity) - if err != nil { - // Rollback token deduction - p.customerTokensRepo.AddTokens(ctx, req.CustomerID, tokenType, int64(req.TokenUsed)) - return nil, fmt.Errorf("failed to create game play: %w", err) - } - - // Decrease prize stock if prize was won - if selectedPrize != nil { - err = p.gamePrizeRepo.DecreaseStock(ctx, selectedPrize.ID, 1) + 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 { - // Log error but don't fail the transaction - fmt.Printf("Warning: failed to decrease prize stock: %v\n", err) + 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) - // Get updated token balance - updatedTokens, err := p.customerTokensRepo.GetByCustomerIDAndType(ctx, req.CustomerID, tokenType) + // 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, fmt.Errorf("failed to get updated token balance: %w", err) + 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(gamePlayEntity), - PrizeWon: selectedPrize, - TokensRemaining: updatedTokens.Balance, + 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 { diff --git a/internal/processor/game_play_processor_db_test.go b/internal/processor/game_play_processor_db_test.go new file mode 100644 index 0000000..ef1cb6d --- /dev/null +++ b/internal/processor/game_play_processor_db_test.go @@ -0,0 +1,69 @@ +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() + require.NoError(t, db.Exec(`INSERT INTO games (id, name, type, metadata) VALUES (?, 'Raffle', 'RAFFLE', '{"coin_cost": 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) +} diff --git a/internal/processor/game_play_processor_test.go b/internal/processor/game_play_processor_test.go new file mode 100644 index 0000000..6a43670 --- /dev/null +++ b/internal/processor/game_play_processor_test.go @@ -0,0 +1,245 @@ +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)) + }) +} diff --git a/internal/repository/game_play_repository.go b/internal/repository/game_play_repository.go index b3ffb2c..48c8d01 100644 --- a/internal/repository/game_play_repository.go +++ b/internal/repository/game_play_repository.go @@ -31,7 +31,7 @@ func NewGamePlayRepository(db *gorm.DB) GamePlayRepository { } func (r *gamePlayRepository) Create(ctx context.Context, gamePlay *entities.GamePlay) error { - if err := r.db.WithContext(ctx).Create(gamePlay).Error; err != nil { + if err := DBFromContext(ctx, r.db).WithContext(ctx).Create(gamePlay).Error; err != nil { return fmt.Errorf("failed to create game play: %w", err) } return nil @@ -39,7 +39,7 @@ func (r *gamePlayRepository) Create(ctx context.Context, gamePlay *entities.Game func (r *gamePlayRepository) GetByID(ctx context.Context, id uuid.UUID) (*entities.GamePlay, error) { var gamePlay entities.GamePlay - err := r.db.WithContext(ctx). + err := DBFromContext(ctx, r.db).WithContext(ctx). Preload("Game"). Preload("Customer"). Preload("Prize"). @@ -57,7 +57,7 @@ func (r *gamePlayRepository) List(ctx context.Context, offset, limit int, search var gamePlays []*entities.GamePlay var total int64 - query := r.db.WithContext(ctx).Model(&entities.GamePlay{}) + query := DBFromContext(ctx, r.db).WithContext(ctx).Model(&entities.GamePlay{}) // Apply filters if search != "" { @@ -107,7 +107,7 @@ func (r *gamePlayRepository) List(ctx context.Context, offset, limit int, search func (r *gamePlayRepository) GetByCustomerID(ctx context.Context, customerID uuid.UUID, limit int) ([]*entities.GamePlay, error) { var gamePlays []*entities.GamePlay - query := r.db.WithContext(ctx). + query := DBFromContext(ctx, r.db).WithContext(ctx). Where("customer_id = ?", customerID). Preload("Game"). Preload("Prize"). @@ -127,7 +127,7 @@ func (r *gamePlayRepository) GetByCustomerID(ctx context.Context, customerID uui func (r *gamePlayRepository) GetByGameID(ctx context.Context, gameID uuid.UUID, limit int) ([]*entities.GamePlay, error) { var gamePlays []*entities.GamePlay - query := r.db.WithContext(ctx). + query := DBFromContext(ctx, r.db).WithContext(ctx). Where("game_id = ?", gameID). Preload("Customer"). Preload("Prize"). @@ -147,7 +147,7 @@ func (r *gamePlayRepository) GetByGameID(ctx context.Context, gameID uuid.UUID, func (r *gamePlayRepository) CountByCustomerID(ctx context.Context, customerID uuid.UUID) (int64, error) { var count int64 - err := r.db.WithContext(ctx). + err := DBFromContext(ctx, r.db).WithContext(ctx). Model(&entities.GamePlay{}). Where("customer_id = ?", customerID). Count(&count).Error @@ -159,7 +159,7 @@ func (r *gamePlayRepository) CountByCustomerID(ctx context.Context, customerID u func (r *gamePlayRepository) CountByGameID(ctx context.Context, gameID uuid.UUID) (int64, error) { var count int64 - err := r.db.WithContext(ctx). + err := DBFromContext(ctx, r.db).WithContext(ctx). Model(&entities.GamePlay{}). Where("game_id = ?", gameID). Count(&count).Error diff --git a/internal/repository/game_prize_repository.go b/internal/repository/game_prize_repository.go index 243bf73..2b2f13c 100644 --- a/internal/repository/game_prize_repository.go +++ b/internal/repository/game_prize_repository.go @@ -3,12 +3,16 @@ package repository import ( "apskel-pos-be/internal/entities" "context" + "errors" "fmt" "github.com/google/uuid" "gorm.io/gorm" ) +// ErrGamePrizeOutOfStock means a prize had less stock than was taken from it. +var ErrGamePrizeOutOfStock = errors.New("game prize is out of stock") + type GamePrizeRepository struct { db *gorm.DB } @@ -18,12 +22,12 @@ func NewGamePrizeRepository(db *gorm.DB) *GamePrizeRepository { } func (r *GamePrizeRepository) Create(ctx context.Context, gamePrize *entities.GamePrize) error { - return r.db.WithContext(ctx).Create(gamePrize).Error + return DBFromContext(ctx, r.db).WithContext(ctx).Create(gamePrize).Error } func (r *GamePrizeRepository) GetByID(ctx context.Context, id uuid.UUID) (*entities.GamePrize, error) { var gamePrize entities.GamePrize - err := r.db.WithContext(ctx).Preload("Game").Preload("FallbackPrize").Where("id = ?", id).First(&gamePrize).Error + err := DBFromContext(ctx, r.db).WithContext(ctx).Preload("Game").Preload("FallbackPrize").Where("id = ?", id).First(&gamePrize).Error if err != nil { return nil, err } @@ -32,7 +36,7 @@ func (r *GamePrizeRepository) GetByID(ctx context.Context, id uuid.UUID) (*entit func (r *GamePrizeRepository) GetByGameID(ctx context.Context, gameID uuid.UUID) ([]entities.GamePrize, error) { var gamePrizes []entities.GamePrize - err := r.db.WithContext(ctx).Preload("Game").Preload("FallbackPrize").Where("game_id = ?", gameID).Find(&gamePrizes).Error + err := DBFromContext(ctx, r.db).WithContext(ctx).Preload("Game").Preload("FallbackPrize").Where("game_id = ?", gameID).Find(&gamePrizes).Error if err != nil { return nil, err } @@ -43,7 +47,7 @@ func (r *GamePrizeRepository) List(ctx context.Context, offset, limit int, searc var gamePrizes []entities.GamePrize var total int64 - query := r.db.WithContext(ctx).Preload("Game").Preload("FallbackPrize") + query := DBFromContext(ctx, r.db).WithContext(ctx).Preload("Game").Preload("FallbackPrize") if search != "" { searchTerm := "%" + search + "%" @@ -76,22 +80,31 @@ func (r *GamePrizeRepository) List(ctx context.Context, offset, limit int, searc } func (r *GamePrizeRepository) Update(ctx context.Context, gamePrize *entities.GamePrize) error { - return r.db.WithContext(ctx).Save(gamePrize).Error + return DBFromContext(ctx, r.db).WithContext(ctx).Save(gamePrize).Error } func (r *GamePrizeRepository) Delete(ctx context.Context, id uuid.UUID) error { - return r.db.WithContext(ctx).Delete(&entities.GamePrize{}, id).Error + return DBFromContext(ctx, r.db).WithContext(ctx).Delete(&entities.GamePrize{}, id).Error } +// DecreaseStock takes amount from a prize's stock. When less than amount is left it +// changes nothing and returns ErrGamePrizeOutOfStock. func (r *GamePrizeRepository) DecreaseStock(ctx context.Context, id uuid.UUID, amount int) error { - return r.db.WithContext(ctx).Model(&entities.GamePrize{}). + res := DBFromContext(ctx, r.db).WithContext(ctx).Model(&entities.GamePrize{}). Where("id = ? AND stock >= ?", id, amount). - Update("stock", gorm.Expr("stock - ?", amount)).Error + Update("stock", gorm.Expr("stock - ?", amount)) + if res.Error != nil { + return fmt.Errorf("failed to decrease prize stock: %w", res.Error) + } + if res.RowsAffected == 0 { + return ErrGamePrizeOutOfStock + } + return nil } func (r *GamePrizeRepository) GetAvailablePrizes(ctx context.Context, gameID uuid.UUID) ([]entities.GamePrize, error) { var gamePrizes []entities.GamePrize - err := r.db.WithContext(ctx).Preload("Game").Preload("FallbackPrize"). + err := DBFromContext(ctx, r.db).WithContext(ctx).Preload("Game").Preload("FallbackPrize"). Where("game_id = ? AND stock > 0", gameID). Order("weight DESC"). Find(&gamePrizes).Error diff --git a/internal/repository/game_repository.go b/internal/repository/game_repository.go index 4d41f18..22d281a 100644 --- a/internal/repository/game_repository.go +++ b/internal/repository/game_repository.go @@ -18,12 +18,12 @@ func NewGameRepository(db *gorm.DB) *GameRepository { } func (r *GameRepository) Create(ctx context.Context, game *entities.Game) error { - return r.db.WithContext(ctx).Create(game).Error + return DBFromContext(ctx, r.db).WithContext(ctx).Create(game).Error } func (r *GameRepository) GetByID(ctx context.Context, id uuid.UUID) (*entities.Game, error) { var game entities.Game - err := r.db.WithContext(ctx).Preload("Prizes").Where("id = ?", id).First(&game).Error + err := DBFromContext(ctx, r.db).WithContext(ctx).Preload("Prizes").Where("id = ?", id).First(&game).Error if err != nil { return nil, err } @@ -34,7 +34,7 @@ func (r *GameRepository) List(ctx context.Context, offset, limit int, search, ga var games []entities.Game var total int64 - query := r.db.WithContext(ctx).Preload("Prizes") + query := DBFromContext(ctx, r.db).WithContext(ctx).Preload("Prizes") if search != "" { searchTerm := "%" + search + "%" @@ -71,16 +71,16 @@ func (r *GameRepository) List(ctx context.Context, offset, limit int, search, ga } func (r *GameRepository) Update(ctx context.Context, game *entities.Game) error { - return r.db.WithContext(ctx).Save(game).Error + return DBFromContext(ctx, r.db).WithContext(ctx).Save(game).Error } func (r *GameRepository) Delete(ctx context.Context, id uuid.UUID) error { - return r.db.WithContext(ctx).Delete(&entities.Game{}, id).Error + return DBFromContext(ctx, r.db).WithContext(ctx).Delete(&entities.Game{}, id).Error } func (r *GameRepository) GetActiveGames(ctx context.Context) ([]entities.Game, error) { var games []entities.Game - err := r.db.WithContext(ctx).Preload("Prizes").Where("is_active = ?", true).Find(&games).Error + err := DBFromContext(ctx, r.db).WithContext(ctx).Preload("Prizes").Where("is_active = ?", true).Find(&games).Error if err != nil { return nil, err } @@ -89,7 +89,7 @@ func (r *GameRepository) GetActiveGames(ctx context.Context) ([]entities.Game, e func (r *GameRepository) GetActiveSpinGames(ctx context.Context) ([]entities.Game, error) { var games []entities.Game - err := r.db.WithContext(ctx). + err := DBFromContext(ctx, r.db).WithContext(ctx). Preload("Prizes"). Where("is_active = ? AND type = ?", true, entities.GameTypeSpin). Find(&games).Error @@ -101,7 +101,7 @@ func (r *GameRepository) GetActiveSpinGames(ctx context.Context) ([]entities.Gam func (r *GameRepository) GetFerrisWheelGame(ctx context.Context) (*entities.Game, error) { var game entities.Game - err := r.db.WithContext(ctx). + err := DBFromContext(ctx, r.db).WithContext(ctx). Preload("Prizes"). Where("is_active = ? AND type = ?", true, "SPIN"). First(&game).Error diff --git a/internal/service/spin_game_service.go b/internal/service/spin_game_service.go index dcfd93e..42390c2 100644 --- a/internal/service/spin_game_service.go +++ b/internal/service/spin_game_service.go @@ -6,7 +6,6 @@ import ( "apskel-pos-be/internal/models" "apskel-pos-be/internal/processor" - "apskel-pos-be/internal/repository" "github.com/google/uuid" ) @@ -17,13 +16,11 @@ type SpinGameService interface { type spinGameService struct { gamePlayProcessor *processor.GamePlayProcessor - txManager *repository.TxManager } -func NewSpinGameService(gamePlayProcessor *processor.GamePlayProcessor, txManager *repository.TxManager) SpinGameService { +func NewSpinGameService(gamePlayProcessor *processor.GamePlayProcessor) SpinGameService { return &spinGameService{ gamePlayProcessor: gamePlayProcessor, - txManager: txManager, } } @@ -45,28 +42,13 @@ func (s *spinGameService) PlaySpinGame(ctx context.Context, customerID string, r return nil, fmt.Errorf("invalid customer ID format: %w", err) } - // Use transaction to ensure consistency - var playResponse *models.PlayGameResponse - err = s.txManager.WithTransaction(ctx, func(txCtx context.Context) error { - // Create play game request - playGameReq := &models.PlayGameRequest{ - GameID: spinID, - CustomerID: customerUUID, - TokenUsed: 1, // Default to 1 token per spin - } - - // Play the game within transaction - var playErr error - playResponse, playErr = s.gamePlayProcessor.PlayGame(txCtx, playGameReq) - if playErr != nil { - return fmt.Errorf("failed to play spin game: %w", playErr) - } - - return nil + // PlayGame runs its own transaction and charges the game's coin_cost in EnakCoin. + playResponse, err := s.gamePlayProcessor.PlayGame(ctx, &models.PlayGameRequest{ + GameID: spinID, + CustomerID: customerUUID, }) - if err != nil { - return nil, err + return nil, fmt.Errorf("failed to play spin game: %w", err) } // Convert prize to customer-facing format @@ -88,9 +70,9 @@ func (s *spinGameService) PlaySpinGame(ctx context.Context, customerID string, r Status: "SUCCESS", Message: "Spin game completed successfully.", Data: &models.SpinGameResponseData{ - GamePlay: playResponse.GamePlay, - PrizeWon: customerPrize, - TokensRemaining: playResponse.TokensRemaining, + GamePlay: playResponse.GamePlay, + PrizeWon: customerPrize, + CoinsRemaining: playResponse.CoinsRemaining, }, } diff --git a/internal/transformer/gamification_transformer.go b/internal/transformer/gamification_transformer.go index b7a2b70..a1a0dcd 100644 --- a/internal/transformer/gamification_transformer.go +++ b/internal/transformer/gamification_transformer.go @@ -350,7 +350,7 @@ func CreateGamePlayRequestToModel(req *contract.CreateGamePlayRequest) *models.C return &models.CreateGamePlayRequest{ GameID: req.GameID, CustomerID: req.CustomerID, - TokenUsed: req.TokenUsed, + CoinsUsed: req.CoinsUsed, RandomSeed: req.RandomSeed, } } @@ -359,7 +359,6 @@ func PlayGameRequestToModel(req *contract.PlayGameRequest) *models.PlayGameReque return &models.PlayGameRequest{ GameID: req.GameID, CustomerID: req.CustomerID, - TokenUsed: req.TokenUsed, } } @@ -401,7 +400,8 @@ func GamePlayModelToResponse(model *models.GamePlayResponse) *contract.GamePlayR GameID: model.GameID, CustomerID: model.CustomerID, PrizeID: model.PrizeID, - TokenUsed: model.TokenUsed, + CoinsUsed: model.CoinsUsed, + TokenUsed: model.CoinsUsed, RandomSeed: model.RandomSeed, CreatedAt: model.CreatedAt, Game: game, @@ -433,7 +433,8 @@ func PlayGameModelToResponse(model *models.PlayGameResponse) *contract.PlayGameR return &contract.PlayGameResponse{ GamePlay: gamePlayValue, PrizeWon: prizeWon, - TokensRemaining: model.TokensRemaining, + CoinsRemaining: model.CoinsRemaining, + TokensRemaining: model.CoinsRemaining, } } diff --git a/internal/validator/gamification_validator.go b/internal/validator/gamification_validator.go index c65a001..2ce5a44 100644 --- a/internal/validator/gamification_validator.go +++ b/internal/validator/gamification_validator.go @@ -436,8 +436,8 @@ func (v *GamificationValidatorImpl) ValidateCreateGamePlayRequest(req *contract. return err, "VALIDATION_ERROR" } - if req.TokenUsed < 0 { - return errors.New("token used cannot be negative"), "INVALID_TOKEN_USED" + if req.CoinsUsed < 0 { + return errors.New("coins used cannot be negative"), "INVALID_COINS_USED" } return nil, "" @@ -466,10 +466,6 @@ func (v *GamificationValidatorImpl) ValidatePlayGameRequest(req *contract.PlayGa return err, "VALIDATION_ERROR" } - if req.TokenUsed < 0 { - return errors.New("token used cannot be negative"), "INVALID_TOKEN_USED" - } - return nil, "" } diff --git a/migrations/000095_rename_game_plays_token_used_to_coins_used.down.sql b/migrations/000095_rename_game_plays_token_used_to_coins_used.down.sql new file mode 100644 index 0000000..fb9fb88 --- /dev/null +++ b/migrations/000095_rename_game_plays_token_used_to_coins_used.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE game_plays RENAME CONSTRAINT chk_game_plays_coins_used_non_negative TO chk_game_plays_token_used_non_negative; +ALTER TABLE game_plays RENAME COLUMN coins_used TO token_used; diff --git a/migrations/000095_rename_game_plays_token_used_to_coins_used.up.sql b/migrations/000095_rename_game_plays_token_used_to_coins_used.up.sql new file mode 100644 index 0000000..d59c7e1 --- /dev/null +++ b/migrations/000095_rename_game_plays_token_used_to_coins_used.up.sql @@ -0,0 +1,4 @@ +-- Every game now costs EnakCoin (docs/prd-point-coin.md F8, K1), so what a play used +-- is a number of EnakCoin. +ALTER TABLE game_plays RENAME COLUMN token_used TO coins_used; +ALTER TABLE game_plays RENAME CONSTRAINT chk_game_plays_token_used_non_negative TO chk_game_plays_coins_used_non_negative;