Main #33
+2
-2
@@ -449,7 +449,7 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor
|
|||||||
tierProcessor: processor.NewTierProcessor(repos.tierRepo),
|
tierProcessor: processor.NewTierProcessor(repos.tierRepo),
|
||||||
gameProcessor: processor.NewGameProcessor(repos.gameRepo),
|
gameProcessor: processor.NewGameProcessor(repos.gameRepo),
|
||||||
gamePrizeProcessor: processor.NewGamePrizeProcessor(repos.gamePrizeRepo),
|
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),
|
omsetTrackerProcessor: processor.NewOmsetTrackerProcessor(repos.omsetTrackerRepo),
|
||||||
rewardProcessor: processor.NewRewardProcessor(repos.rewardRepo),
|
rewardProcessor: processor.NewRewardProcessor(repos.rewardRepo),
|
||||||
campaignProcessor: processor.NewCampaignProcessor(repos.campaignRepo),
|
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)
|
campaignService := service.NewCampaignService(processors.campaignProcessor, processors.campaignRuleProcessor)
|
||||||
customerAuthService := service.NewCustomerAuthService(processors.customerAuthProcessor)
|
customerAuthService := service.NewCustomerAuthService(processors.customerAuthProcessor)
|
||||||
customerPointsService := service.NewCustomerPointsService(processors.customerPointsProcessor)
|
customerPointsService := service.NewCustomerPointsService(processors.customerPointsProcessor)
|
||||||
spinGameService := service.NewSpinGameService(processors.gamePlayProcessor, repos.txManager)
|
spinGameService := service.NewSpinGameService(processors.gamePlayProcessor)
|
||||||
userDeviceService := service.NewUserDeviceService(processors.userDeviceProcessor)
|
userDeviceService := service.NewUserDeviceService(processors.userDeviceProcessor)
|
||||||
notificationService := service.NewNotificationService(processors.notificationProcessor)
|
notificationService := service.NewNotificationService(processors.notificationProcessor)
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
type CreateGamePlayRequest struct {
|
type CreateGamePlayRequest struct {
|
||||||
GameID uuid.UUID `json:"game_id" validate:"required"`
|
GameID uuid.UUID `json:"game_id" validate:"required"`
|
||||||
CustomerID uuid.UUID `json:"customer_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"`
|
RandomSeed *string `json:"random_seed,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -18,6 +18,8 @@ type GamePlayResponse struct {
|
|||||||
GameID uuid.UUID `json:"game_id"`
|
GameID uuid.UUID `json:"game_id"`
|
||||||
CustomerID uuid.UUID `json:"customer_id"`
|
CustomerID uuid.UUID `json:"customer_id"`
|
||||||
PrizeID *uuid.UUID `json:"prize_id,omitempty"`
|
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"`
|
TokenUsed int `json:"token_used"`
|
||||||
RandomSeed *string `json:"random_seed,omitempty"`
|
RandomSeed *string `json:"random_seed,omitempty"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
@@ -33,7 +35,7 @@ type ListGamePlaysRequest struct {
|
|||||||
GameID *uuid.UUID `json:"game_id"`
|
GameID *uuid.UUID `json:"game_id"`
|
||||||
CustomerID *uuid.UUID `json:"customer_id"`
|
CustomerID *uuid.UUID `json:"customer_id"`
|
||||||
PrizeID *uuid.UUID `json:"prize_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"`
|
SortOrder string `json:"sort_order" validate:"omitempty,oneof=asc desc"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,14 +47,17 @@ type PaginatedGamePlaysResponse struct {
|
|||||||
TotalPages int `json:"total_pages"`
|
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 {
|
type PlayGameRequest struct {
|
||||||
GameID uuid.UUID `json:"game_id" validate:"required"`
|
GameID uuid.UUID `json:"game_id" validate:"required"`
|
||||||
CustomerID uuid.UUID `json:"customer_id" validate:"required"`
|
CustomerID uuid.UUID `json:"customer_id" validate:"required"`
|
||||||
TokenUsed int `json:"token_used" validate:"min=0"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type PlayGameResponse struct {
|
type PlayGameResponse struct {
|
||||||
GamePlay GamePlayResponse `json:"game_play"`
|
GamePlay GamePlayResponse `json:"game_play"`
|
||||||
PrizeWon *GamePrizeResponse `json:"prize_won,omitempty"`
|
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"`
|
TokensRemaining int64 `json:"tokens_remaining"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,5 +16,7 @@ type SpinGameResponse struct {
|
|||||||
type SpinGameResponseData struct {
|
type SpinGameResponseData struct {
|
||||||
GamePlay GamePlayResponse `json:"game_play"`
|
GamePlay GamePlayResponse `json:"game_play"`
|
||||||
PrizeWon *CustomerGamePrizeResponse `json:"prize_won,omitempty"`
|
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"`
|
TokensRemaining int64 `json:"tokens_remaining"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ type GamePlay struct {
|
|||||||
GameID uuid.UUID `gorm:"type:uuid;not null;index" json:"game_id" validate:"required"`
|
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"`
|
CustomerID uuid.UUID `gorm:"type:uuid;not null;index" json:"customer_id" validate:"required"`
|
||||||
PrizeID *uuid.UUID `gorm:"type:uuid" json:"prize_id,omitempty"`
|
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"`
|
RandomSeed *string `gorm:"type:varchar(255)" json:"random_seed,omitempty"`
|
||||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
|
|
||||||
"apskel-pos-be/internal/constants"
|
"apskel-pos-be/internal/constants"
|
||||||
"apskel-pos-be/internal/contract"
|
"apskel-pos-be/internal/contract"
|
||||||
"apskel-pos-be/internal/mappers"
|
"apskel-pos-be/internal/mappers"
|
||||||
"apskel-pos-be/internal/models"
|
"apskel-pos-be/internal/models"
|
||||||
|
"apskel-pos-be/internal/processor"
|
||||||
"apskel-pos-be/internal/service"
|
"apskel-pos-be/internal/service"
|
||||||
"apskel-pos-be/internal/util"
|
"apskel-pos-be/internal/util"
|
||||||
|
|
||||||
@@ -49,8 +52,14 @@ func (h *SpinGameHandler) PlaySpinGame(c *gin.Context) {
|
|||||||
// Play the spin game
|
// Play the spin game
|
||||||
response, err := h.spinGameService.PlaySpinGame(c.Request.Context(), customerID.(string), modelReq)
|
response, err := h.spinGameService.PlaySpinGame(c.Request.Context(), customerID.(string), modelReq)
|
||||||
if err != nil {
|
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{
|
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")
|
}), "SpinGameHandler::PlaySpinGame")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ func ToGamePlayResponse(gamePlay *entities.GamePlay) *models.GamePlayResponse {
|
|||||||
GameID: gamePlay.GameID,
|
GameID: gamePlay.GameID,
|
||||||
CustomerID: gamePlay.CustomerID,
|
CustomerID: gamePlay.CustomerID,
|
||||||
PrizeID: gamePlay.PrizeID,
|
PrizeID: gamePlay.PrizeID,
|
||||||
TokenUsed: gamePlay.TokenUsed,
|
CoinsUsed: gamePlay.CoinsUsed,
|
||||||
RandomSeed: gamePlay.RandomSeed,
|
RandomSeed: gamePlay.RandomSeed,
|
||||||
CreatedAt: gamePlay.CreatedAt,
|
CreatedAt: gamePlay.CreatedAt,
|
||||||
Game: ToGameResponse(&gamePlay.Game),
|
Game: ToGameResponse(&gamePlay.Game),
|
||||||
@@ -48,7 +48,7 @@ func ToGamePlayEntity(req *models.CreateGamePlayRequest) *entities.GamePlay {
|
|||||||
return &entities.GamePlay{
|
return &entities.GamePlay{
|
||||||
GameID: req.GameID,
|
GameID: req.GameID,
|
||||||
CustomerID: req.CustomerID,
|
CustomerID: req.CustomerID,
|
||||||
TokenUsed: req.TokenUsed,
|
CoinsUsed: req.CoinsUsed,
|
||||||
RandomSeed: req.RandomSeed,
|
RandomSeed: req.RandomSeed,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,7 +38,8 @@ func SpinGameResponseDataModelToContract(data *models.SpinGameResponseData) *con
|
|||||||
return &contract.SpinGameResponseData{
|
return &contract.SpinGameResponseData{
|
||||||
GamePlay: GamePlayResponseModelToContract(&data.GamePlay),
|
GamePlay: GamePlayResponseModelToContract(&data.GamePlay),
|
||||||
PrizeWon: CustomerGamePrizeResponseModelToContract(data.PrizeWon),
|
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,
|
GameID: resp.GameID,
|
||||||
CustomerID: resp.CustomerID,
|
CustomerID: resp.CustomerID,
|
||||||
PrizeID: resp.PrizeID,
|
PrizeID: resp.PrizeID,
|
||||||
TokenUsed: resp.TokenUsed,
|
CoinsUsed: resp.CoinsUsed,
|
||||||
|
TokenUsed: resp.CoinsUsed,
|
||||||
RandomSeed: resp.RandomSeed,
|
RandomSeed: resp.RandomSeed,
|
||||||
CreatedAt: resp.CreatedAt,
|
CreatedAt: resp.CreatedAt,
|
||||||
Game: nil, // Optional field - can be populated separately if needed
|
Game: nil, // Optional field - can be populated separately if needed
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
type CreateGamePlayRequest struct {
|
type CreateGamePlayRequest struct {
|
||||||
GameID uuid.UUID `json:"game_id" validate:"required"`
|
GameID uuid.UUID `json:"game_id" validate:"required"`
|
||||||
CustomerID uuid.UUID `json:"customer_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"`
|
RandomSeed *string `json:"random_seed,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -18,7 +18,7 @@ type GamePlayResponse struct {
|
|||||||
GameID uuid.UUID `json:"game_id"`
|
GameID uuid.UUID `json:"game_id"`
|
||||||
CustomerID uuid.UUID `json:"customer_id"`
|
CustomerID uuid.UUID `json:"customer_id"`
|
||||||
PrizeID *uuid.UUID `json:"prize_id,omitempty"`
|
PrizeID *uuid.UUID `json:"prize_id,omitempty"`
|
||||||
TokenUsed int `json:"token_used"`
|
CoinsUsed int `json:"coins_used"`
|
||||||
RandomSeed *string `json:"random_seed,omitempty"`
|
RandomSeed *string `json:"random_seed,omitempty"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
Game *GameResponse `json:"game,omitempty"`
|
Game *GameResponse `json:"game,omitempty"`
|
||||||
@@ -33,18 +33,18 @@ type ListGamePlaysQuery struct {
|
|||||||
GameID *uuid.UUID `query:"game_id"`
|
GameID *uuid.UUID `query:"game_id"`
|
||||||
CustomerID *uuid.UUID `query:"customer_id"`
|
CustomerID *uuid.UUID `query:"customer_id"`
|
||||||
PrizeID *uuid.UUID `query:"prize_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"`
|
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 {
|
type PlayGameRequest struct {
|
||||||
GameID uuid.UUID `json:"game_id" validate:"required"`
|
GameID uuid.UUID `json:"game_id" validate:"required"`
|
||||||
CustomerID uuid.UUID `json:"customer_id" validate:"required"`
|
CustomerID uuid.UUID `json:"customer_id" validate:"required"`
|
||||||
TokenUsed int `json:"token_used" validate:"min=0"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type PlayGameResponse struct {
|
type PlayGameResponse struct {
|
||||||
GamePlay GamePlayResponse `json:"game_play"`
|
GamePlay GamePlayResponse `json:"game_play"`
|
||||||
PrizeWon *GamePrizeResponse `json:"prize_won,omitempty"`
|
PrizeWon *GamePrizeResponse `json:"prize_won,omitempty"`
|
||||||
TokensRemaining int64 `json:"tokens_remaining"`
|
CoinsRemaining int64 `json:"coins_remaining"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ type SpinGameResponse struct {
|
|||||||
type SpinGameResponseData struct {
|
type SpinGameResponseData struct {
|
||||||
GamePlay GamePlayResponse `json:"game_play"`
|
GamePlay GamePlayResponse `json:"game_play"`
|
||||||
PrizeWon *CustomerGamePrizeResponse `json:"prize_won,omitempty"`
|
PrizeWon *CustomerGamePrizeResponse `json:"prize_won,omitempty"`
|
||||||
TokensRemaining int64 `json:"tokens_remaining"`
|
CoinsRemaining int64 `json:"coins_remaining"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// SpinGamePrizeResponse represents a prize won in the spin game
|
// SpinGamePrizeResponse represents a prize won in the spin game
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package processor
|
package processor
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"apskel-pos-be/internal/constants"
|
||||||
"apskel-pos-be/internal/entities"
|
"apskel-pos-be/internal/entities"
|
||||||
"apskel-pos-be/internal/mappers"
|
"apskel-pos-be/internal/mappers"
|
||||||
"apskel-pos-be/internal/models"
|
"apskel-pos-be/internal/models"
|
||||||
@@ -8,33 +9,55 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"math"
|
||||||
"math/rand"
|
"math/rand"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"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 {
|
type GamePlayProcessor struct {
|
||||||
gamePlayRepo repository.GamePlayRepository
|
gamePlayRepo repository.GamePlayRepository
|
||||||
gameRepo *repository.GameRepository
|
gameRepo gameReader
|
||||||
gamePrizeRepo *repository.GamePrizeRepository
|
gamePrizeRepo gamePrizeStock
|
||||||
customerTokensRepo *repository.CustomerTokensRepository
|
wallet *WalletProcessor
|
||||||
customerPointsRepo repository.CustomerPointsRepository
|
spendable spendableReader
|
||||||
|
tx TxRunner
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewGamePlayProcessor(
|
func NewGamePlayProcessor(
|
||||||
gamePlayRepo repository.GamePlayRepository,
|
gamePlayRepo repository.GamePlayRepository,
|
||||||
gameRepo *repository.GameRepository,
|
gameRepo gameReader,
|
||||||
gamePrizeRepo *repository.GamePrizeRepository,
|
gamePrizeRepo gamePrizeStock,
|
||||||
customerTokensRepo *repository.CustomerTokensRepository,
|
wallet *WalletProcessor,
|
||||||
customerPointsRepo repository.CustomerPointsRepository,
|
spendable spendableReader,
|
||||||
|
tx TxRunner,
|
||||||
) *GamePlayProcessor {
|
) *GamePlayProcessor {
|
||||||
return &GamePlayProcessor{
|
return &GamePlayProcessor{
|
||||||
gamePlayRepo: gamePlayRepo,
|
gamePlayRepo: gamePlayRepo,
|
||||||
gameRepo: gameRepo,
|
gameRepo: gameRepo,
|
||||||
gamePrizeRepo: gamePrizeRepo,
|
gamePrizeRepo: gamePrizeRepo,
|
||||||
customerTokensRepo: customerTokensRepo,
|
wallet: wallet,
|
||||||
customerPointsRepo: customerPointsRepo,
|
spendable: spendable,
|
||||||
|
tx: tx,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,6 +99,10 @@ func (p *GamePlayProcessor) ListGamePlays(ctx context.Context, query *models.Lis
|
|||||||
}
|
}
|
||||||
|
|
||||||
offset := (query.Page - 1) * query.Limit
|
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
|
// Get game plays from repository
|
||||||
gamePlays, total, err := p.gamePlayRepo.List(
|
gamePlays, total, err := p.gamePlayRepo.List(
|
||||||
@@ -110,101 +137,140 @@ func (p *GamePlayProcessor) ListGamePlays(ctx context.Context, query *models.Lis
|
|||||||
}, nil
|
}, 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) {
|
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)
|
game, err := p.gameRepo.GetByID(ctx, req.GameID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("game not found: %w", err)
|
return nil, fmt.Errorf("game not found: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !game.IsActive {
|
if !game.IsActive {
|
||||||
return nil, errors.New("game is not active")
|
return nil, refuse("game is not active")
|
||||||
}
|
}
|
||||||
|
cost, err := gameCoinCost(game.Metadata)
|
||||||
// Convert GameType to TokenType
|
|
||||||
tokenType := entities.TokenType(game.Type)
|
|
||||||
|
|
||||||
// Check if customer has enough tokens
|
|
||||||
customerTokens, err := p.customerTokensRepo.GetByCustomerIDAndType(ctx, req.CustomerID, tokenType)
|
|
||||||
if err != nil {
|
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) {
|
var gamePlay *entities.GamePlay
|
||||||
return nil, errors.New("insufficient tokens")
|
var selectedPrize *models.GamePrizeResponse
|
||||||
}
|
err = p.tx.WithTransaction(ctx, func(ctx context.Context) error {
|
||||||
|
|
||||||
// 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)
|
availablePrizes, err := p.gamePrizeRepo.GetAvailablePrizes(ctx, req.GameID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to get available prizes: %w", err)
|
return fmt.Errorf("failed to get available prizes: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(availablePrizes) == 0 {
|
if len(availablePrizes) == 0 {
|
||||||
return nil, errors.New("no prizes available")
|
return refuse("no prizes available")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert entities to models for prize selection
|
|
||||||
prizeResponses := make([]models.GamePrizeResponse, len(availablePrizes))
|
prizeResponses := make([]models.GamePrizeResponse, len(availablePrizes))
|
||||||
for i, prize := range availablePrizes {
|
for i, prize := range availablePrizes {
|
||||||
prizeResponses[i] = *mappers.ToGamePrizeResponse(&prize)
|
prizeResponses[i] = *mappers.ToGamePrizeResponse(&prize)
|
||||||
}
|
}
|
||||||
|
selectedPrize = p.selectPrizeByWeight(prizeResponses)
|
||||||
|
|
||||||
// Select prize based on weight
|
// Random seed kept for audit.
|
||||||
selectedPrize := p.selectPrizeByWeight(prizeResponses)
|
|
||||||
|
|
||||||
// Generate random seed for audit
|
|
||||||
randomSeed := fmt.Sprintf("%d", time.Now().UnixNano())
|
randomSeed := fmt.Sprintf("%d", time.Now().UnixNano())
|
||||||
|
gamePlay = &entities.GamePlay{
|
||||||
// Create game play record
|
ID: uuid.New(),
|
||||||
gamePlay := &models.CreateGamePlayRequest{
|
|
||||||
GameID: req.GameID,
|
GameID: req.GameID,
|
||||||
CustomerID: req.CustomerID,
|
CustomerID: req.CustomerID,
|
||||||
TokenUsed: req.TokenUsed,
|
CoinsUsed: int(cost),
|
||||||
RandomSeed: &randomSeed,
|
RandomSeed: &randomSeed,
|
||||||
}
|
}
|
||||||
|
|
||||||
gamePlayEntity := mappers.ToGamePlayEntity(gamePlay)
|
|
||||||
if selectedPrize != nil {
|
if selectedPrize != nil {
|
||||||
gamePlayEntity.PrizeID = &selectedPrize.ID
|
gamePlay.PrizeID = &selectedPrize.ID
|
||||||
}
|
}
|
||||||
|
|
||||||
err = p.gamePlayRepo.Create(ctx, gamePlayEntity)
|
// 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 {
|
if err != nil {
|
||||||
// Rollback token deduction
|
return err
|
||||||
p.customerTokensRepo.AddTokens(ctx, req.CustomerID, tokenType, int64(req.TokenUsed))
|
}
|
||||||
return nil, fmt.Errorf("failed to create game play: %w", err)
|
if err := p.gamePlayRepo.Create(ctx, gamePlay); err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Decrease prize stock if prize was won
|
|
||||||
if selectedPrize != nil {
|
if selectedPrize != nil {
|
||||||
err = p.gamePrizeRepo.DecreaseStock(ctx, selectedPrize.ID, 1)
|
err := p.gamePrizeRepo.DecreaseStock(ctx, selectedPrize.ID, 1)
|
||||||
if err != nil {
|
if errors.Is(err, repository.ErrGamePrizeOutOfStock) {
|
||||||
// Log error but don't fail the transaction
|
return refuse("the prize just ran out, please play again")
|
||||||
fmt.Printf("Warning: failed to decrease prize stock: %v\n", err)
|
|
||||||
}
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get updated token balance
|
balances, err := p.spendable.SpendableBalances(ctx, req.CustomerID, time.Now())
|
||||||
updatedTokens, err := p.customerTokensRepo.GetByCustomerIDAndType(ctx, req.CustomerID, tokenType)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to get updated token balance: %w", err)
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return &models.PlayGameResponse{
|
return &models.PlayGameResponse{
|
||||||
GamePlay: *mappers.ToGamePlayResponse(gamePlayEntity),
|
GamePlay: *mappers.ToGamePlayResponse(gamePlay),
|
||||||
PrizeWon: selectedPrize,
|
PrizeWon: selectedPrize,
|
||||||
TokensRemaining: updatedTokens.Balance,
|
CoinsRemaining: balances[constants.WalletCurrencyCoin],
|
||||||
}, nil
|
}, 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
|
// selectPrizeByWeight selects a prize based on weight distribution
|
||||||
func (p *GamePlayProcessor) selectPrizeByWeight(prizes []models.GamePrizeResponse) *models.GamePrizeResponse {
|
func (p *GamePlayProcessor) selectPrizeByWeight(prizes []models.GamePrizeResponse) *models.GamePrizeResponse {
|
||||||
if len(prizes) == 0 {
|
if len(prizes) == 0 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -31,7 +31,7 @@ func NewGamePlayRepository(db *gorm.DB) GamePlayRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r *gamePlayRepository) Create(ctx context.Context, gamePlay *entities.GamePlay) error {
|
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 fmt.Errorf("failed to create game play: %w", err)
|
||||||
}
|
}
|
||||||
return nil
|
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) {
|
func (r *gamePlayRepository) GetByID(ctx context.Context, id uuid.UUID) (*entities.GamePlay, error) {
|
||||||
var gamePlay entities.GamePlay
|
var gamePlay entities.GamePlay
|
||||||
err := r.db.WithContext(ctx).
|
err := DBFromContext(ctx, r.db).WithContext(ctx).
|
||||||
Preload("Game").
|
Preload("Game").
|
||||||
Preload("Customer").
|
Preload("Customer").
|
||||||
Preload("Prize").
|
Preload("Prize").
|
||||||
@@ -57,7 +57,7 @@ func (r *gamePlayRepository) List(ctx context.Context, offset, limit int, search
|
|||||||
var gamePlays []*entities.GamePlay
|
var gamePlays []*entities.GamePlay
|
||||||
var total int64
|
var total int64
|
||||||
|
|
||||||
query := r.db.WithContext(ctx).Model(&entities.GamePlay{})
|
query := DBFromContext(ctx, r.db).WithContext(ctx).Model(&entities.GamePlay{})
|
||||||
|
|
||||||
// Apply filters
|
// Apply filters
|
||||||
if search != "" {
|
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) {
|
func (r *gamePlayRepository) GetByCustomerID(ctx context.Context, customerID uuid.UUID, limit int) ([]*entities.GamePlay, error) {
|
||||||
var gamePlays []*entities.GamePlay
|
var gamePlays []*entities.GamePlay
|
||||||
query := r.db.WithContext(ctx).
|
query := DBFromContext(ctx, r.db).WithContext(ctx).
|
||||||
Where("customer_id = ?", customerID).
|
Where("customer_id = ?", customerID).
|
||||||
Preload("Game").
|
Preload("Game").
|
||||||
Preload("Prize").
|
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) {
|
func (r *gamePlayRepository) GetByGameID(ctx context.Context, gameID uuid.UUID, limit int) ([]*entities.GamePlay, error) {
|
||||||
var gamePlays []*entities.GamePlay
|
var gamePlays []*entities.GamePlay
|
||||||
query := r.db.WithContext(ctx).
|
query := DBFromContext(ctx, r.db).WithContext(ctx).
|
||||||
Where("game_id = ?", gameID).
|
Where("game_id = ?", gameID).
|
||||||
Preload("Customer").
|
Preload("Customer").
|
||||||
Preload("Prize").
|
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) {
|
func (r *gamePlayRepository) CountByCustomerID(ctx context.Context, customerID uuid.UUID) (int64, error) {
|
||||||
var count int64
|
var count int64
|
||||||
err := r.db.WithContext(ctx).
|
err := DBFromContext(ctx, r.db).WithContext(ctx).
|
||||||
Model(&entities.GamePlay{}).
|
Model(&entities.GamePlay{}).
|
||||||
Where("customer_id = ?", customerID).
|
Where("customer_id = ?", customerID).
|
||||||
Count(&count).Error
|
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) {
|
func (r *gamePlayRepository) CountByGameID(ctx context.Context, gameID uuid.UUID) (int64, error) {
|
||||||
var count int64
|
var count int64
|
||||||
err := r.db.WithContext(ctx).
|
err := DBFromContext(ctx, r.db).WithContext(ctx).
|
||||||
Model(&entities.GamePlay{}).
|
Model(&entities.GamePlay{}).
|
||||||
Where("game_id = ?", gameID).
|
Where("game_id = ?", gameID).
|
||||||
Count(&count).Error
|
Count(&count).Error
|
||||||
|
|||||||
@@ -3,12 +3,16 @@ package repository
|
|||||||
import (
|
import (
|
||||||
"apskel-pos-be/internal/entities"
|
"apskel-pos-be/internal/entities"
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"gorm.io/gorm"
|
"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 {
|
type GamePrizeRepository struct {
|
||||||
db *gorm.DB
|
db *gorm.DB
|
||||||
}
|
}
|
||||||
@@ -18,12 +22,12 @@ func NewGamePrizeRepository(db *gorm.DB) *GamePrizeRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r *GamePrizeRepository) Create(ctx context.Context, gamePrize *entities.GamePrize) error {
|
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) {
|
func (r *GamePrizeRepository) GetByID(ctx context.Context, id uuid.UUID) (*entities.GamePrize, error) {
|
||||||
var gamePrize entities.GamePrize
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
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) {
|
func (r *GamePrizeRepository) GetByGameID(ctx context.Context, gameID uuid.UUID) ([]entities.GamePrize, error) {
|
||||||
var gamePrizes []entities.GamePrize
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -43,7 +47,7 @@ func (r *GamePrizeRepository) List(ctx context.Context, offset, limit int, searc
|
|||||||
var gamePrizes []entities.GamePrize
|
var gamePrizes []entities.GamePrize
|
||||||
var total int64
|
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 != "" {
|
if search != "" {
|
||||||
searchTerm := "%" + 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 {
|
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 {
|
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 {
|
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).
|
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) {
|
func (r *GamePrizeRepository) GetAvailablePrizes(ctx context.Context, gameID uuid.UUID) ([]entities.GamePrize, error) {
|
||||||
var gamePrizes []entities.GamePrize
|
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).
|
Where("game_id = ? AND stock > 0", gameID).
|
||||||
Order("weight DESC").
|
Order("weight DESC").
|
||||||
Find(&gamePrizes).Error
|
Find(&gamePrizes).Error
|
||||||
|
|||||||
@@ -18,12 +18,12 @@ func NewGameRepository(db *gorm.DB) *GameRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r *GameRepository) Create(ctx context.Context, game *entities.Game) error {
|
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) {
|
func (r *GameRepository) GetByID(ctx context.Context, id uuid.UUID) (*entities.Game, error) {
|
||||||
var game entities.Game
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -34,7 +34,7 @@ func (r *GameRepository) List(ctx context.Context, offset, limit int, search, ga
|
|||||||
var games []entities.Game
|
var games []entities.Game
|
||||||
var total int64
|
var total int64
|
||||||
|
|
||||||
query := r.db.WithContext(ctx).Preload("Prizes")
|
query := DBFromContext(ctx, r.db).WithContext(ctx).Preload("Prizes")
|
||||||
|
|
||||||
if search != "" {
|
if search != "" {
|
||||||
searchTerm := "%" + 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 {
|
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 {
|
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) {
|
func (r *GameRepository) GetActiveGames(ctx context.Context) ([]entities.Game, error) {
|
||||||
var games []entities.Game
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
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) {
|
func (r *GameRepository) GetActiveSpinGames(ctx context.Context) ([]entities.Game, error) {
|
||||||
var games []entities.Game
|
var games []entities.Game
|
||||||
err := r.db.WithContext(ctx).
|
err := DBFromContext(ctx, r.db).WithContext(ctx).
|
||||||
Preload("Prizes").
|
Preload("Prizes").
|
||||||
Where("is_active = ? AND type = ?", true, entities.GameTypeSpin).
|
Where("is_active = ? AND type = ?", true, entities.GameTypeSpin).
|
||||||
Find(&games).Error
|
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) {
|
func (r *GameRepository) GetFerrisWheelGame(ctx context.Context) (*entities.Game, error) {
|
||||||
var game entities.Game
|
var game entities.Game
|
||||||
err := r.db.WithContext(ctx).
|
err := DBFromContext(ctx, r.db).WithContext(ctx).
|
||||||
Preload("Prizes").
|
Preload("Prizes").
|
||||||
Where("is_active = ? AND type = ?", true, "SPIN").
|
Where("is_active = ? AND type = ?", true, "SPIN").
|
||||||
First(&game).Error
|
First(&game).Error
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import (
|
|||||||
|
|
||||||
"apskel-pos-be/internal/models"
|
"apskel-pos-be/internal/models"
|
||||||
"apskel-pos-be/internal/processor"
|
"apskel-pos-be/internal/processor"
|
||||||
"apskel-pos-be/internal/repository"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
@@ -17,13 +16,11 @@ type SpinGameService interface {
|
|||||||
|
|
||||||
type spinGameService struct {
|
type spinGameService struct {
|
||||||
gamePlayProcessor *processor.GamePlayProcessor
|
gamePlayProcessor *processor.GamePlayProcessor
|
||||||
txManager *repository.TxManager
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewSpinGameService(gamePlayProcessor *processor.GamePlayProcessor, txManager *repository.TxManager) SpinGameService {
|
func NewSpinGameService(gamePlayProcessor *processor.GamePlayProcessor) SpinGameService {
|
||||||
return &spinGameService{
|
return &spinGameService{
|
||||||
gamePlayProcessor: gamePlayProcessor,
|
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)
|
return nil, fmt.Errorf("invalid customer ID format: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use transaction to ensure consistency
|
// PlayGame runs its own transaction and charges the game's coin_cost in EnakCoin.
|
||||||
var playResponse *models.PlayGameResponse
|
playResponse, err := s.gamePlayProcessor.PlayGame(ctx, &models.PlayGameRequest{
|
||||||
err = s.txManager.WithTransaction(ctx, func(txCtx context.Context) error {
|
|
||||||
// Create play game request
|
|
||||||
playGameReq := &models.PlayGameRequest{
|
|
||||||
GameID: spinID,
|
GameID: spinID,
|
||||||
CustomerID: customerUUID,
|
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
|
|
||||||
})
|
})
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, fmt.Errorf("failed to play spin game: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert prize to customer-facing format
|
// Convert prize to customer-facing format
|
||||||
@@ -90,7 +72,7 @@ func (s *spinGameService) PlaySpinGame(ctx context.Context, customerID string, r
|
|||||||
Data: &models.SpinGameResponseData{
|
Data: &models.SpinGameResponseData{
|
||||||
GamePlay: playResponse.GamePlay,
|
GamePlay: playResponse.GamePlay,
|
||||||
PrizeWon: customerPrize,
|
PrizeWon: customerPrize,
|
||||||
TokensRemaining: playResponse.TokensRemaining,
|
CoinsRemaining: playResponse.CoinsRemaining,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -350,7 +350,7 @@ func CreateGamePlayRequestToModel(req *contract.CreateGamePlayRequest) *models.C
|
|||||||
return &models.CreateGamePlayRequest{
|
return &models.CreateGamePlayRequest{
|
||||||
GameID: req.GameID,
|
GameID: req.GameID,
|
||||||
CustomerID: req.CustomerID,
|
CustomerID: req.CustomerID,
|
||||||
TokenUsed: req.TokenUsed,
|
CoinsUsed: req.CoinsUsed,
|
||||||
RandomSeed: req.RandomSeed,
|
RandomSeed: req.RandomSeed,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -359,7 +359,6 @@ func PlayGameRequestToModel(req *contract.PlayGameRequest) *models.PlayGameReque
|
|||||||
return &models.PlayGameRequest{
|
return &models.PlayGameRequest{
|
||||||
GameID: req.GameID,
|
GameID: req.GameID,
|
||||||
CustomerID: req.CustomerID,
|
CustomerID: req.CustomerID,
|
||||||
TokenUsed: req.TokenUsed,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -401,7 +400,8 @@ func GamePlayModelToResponse(model *models.GamePlayResponse) *contract.GamePlayR
|
|||||||
GameID: model.GameID,
|
GameID: model.GameID,
|
||||||
CustomerID: model.CustomerID,
|
CustomerID: model.CustomerID,
|
||||||
PrizeID: model.PrizeID,
|
PrizeID: model.PrizeID,
|
||||||
TokenUsed: model.TokenUsed,
|
CoinsUsed: model.CoinsUsed,
|
||||||
|
TokenUsed: model.CoinsUsed,
|
||||||
RandomSeed: model.RandomSeed,
|
RandomSeed: model.RandomSeed,
|
||||||
CreatedAt: model.CreatedAt,
|
CreatedAt: model.CreatedAt,
|
||||||
Game: game,
|
Game: game,
|
||||||
@@ -433,7 +433,8 @@ func PlayGameModelToResponse(model *models.PlayGameResponse) *contract.PlayGameR
|
|||||||
return &contract.PlayGameResponse{
|
return &contract.PlayGameResponse{
|
||||||
GamePlay: gamePlayValue,
|
GamePlay: gamePlayValue,
|
||||||
PrizeWon: prizeWon,
|
PrizeWon: prizeWon,
|
||||||
TokensRemaining: model.TokensRemaining,
|
CoinsRemaining: model.CoinsRemaining,
|
||||||
|
TokensRemaining: model.CoinsRemaining,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -436,8 +436,8 @@ func (v *GamificationValidatorImpl) ValidateCreateGamePlayRequest(req *contract.
|
|||||||
return err, "VALIDATION_ERROR"
|
return err, "VALIDATION_ERROR"
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.TokenUsed < 0 {
|
if req.CoinsUsed < 0 {
|
||||||
return errors.New("token used cannot be negative"), "INVALID_TOKEN_USED"
|
return errors.New("coins used cannot be negative"), "INVALID_COINS_USED"
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil, ""
|
return nil, ""
|
||||||
@@ -466,10 +466,6 @@ func (v *GamificationValidatorImpl) ValidatePlayGameRequest(req *contract.PlayGa
|
|||||||
return err, "VALIDATION_ERROR"
|
return err, "VALIDATION_ERROR"
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.TokenUsed < 0 {
|
|
||||||
return errors.New("token used cannot be negative"), "INVALID_TOKEN_USED"
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil, ""
|
return nil, ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
Reference in New Issue
Block a user