Main #33

Merged
aefril merged 57 commits from main into staging 2026-09-30 10:17:39 +02:00
20 changed files with 577 additions and 181 deletions
Showing only changes of commit a18bb072f5 - Show all commits
+2 -2
View File
@@ -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)
+8 -3
View File
@@ -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"`
} }
+2
View File
@@ -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"`
} }
+1 -1
View File
@@ -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"`
+10 -1
View File
@@ -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
} }
+2 -2
View File
@@ -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,
} }
} }
+4 -2
View File
@@ -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
+5 -5
View File
@@ -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"`
} }
+1 -1
View File
@@ -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
+130 -64
View File
@@ -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))
})
}
+7 -7
View File
@@ -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
+22 -9
View File
@@ -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
+8 -8
View File
@@ -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
+5 -23
View File
@@ -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,
} }
} }
+2 -6
View File
@@ -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;