feat(loyalty): pay every game with EnakCoin
Games now spend the wallet's EnakCoin instead of the per-type tokens (docs/prd-point-coin.md F8, K1, PC-403). GamePlayProcessor.PlayGame charges the game's metadata.coin_cost, 1 when it is not set; a cost that is not a whole number of at least 1 refuses the game. In one transaction it picks the prize, takes the EnakCoin with a GAME_SPEND row pointing at the new game_plays.id (which locks the wallet, so a customer's plays at the same time queue up), records the play and takes the prize from stock. The play owns its transaction, so the spin service no longer wraps it, and the admin play endpoint is now atomic too. The game, game prize and game play repositories go through DBFromContext so they join that transaction. DecreaseStock now reports a prize that ran out (ErrGamePrizeOutOfStock) instead of silently updating nothing; that, or any other stock failure, cancels the whole play, where it used to be only printed. The manual AddTokens rollback is gone. Not enough EnakCoin, an inactive game or a prize that ran out answer 400 on /customer/spin instead of 500. game_plays.token_used is renamed coins_used (migration 000095). What a play costs is no longer the caller's choice, so PlayGameRequest loses token_used. Responses carry coins_used and coins_remaining; token_used and tokens_remaining stay as deprecated copies until the apps move over, and sort_by=token_used still sorts by coins_used. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
694d65b6d8
commit
a18bb072f5
+2
-2
@@ -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)
|
||||
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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, ""
|
||||
}
|
||||
|
||||
|
||||
@@ -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