Revert "feat(loyalty): EnakPoint & EnakCoin" (#32)
This reverts merge commit645da30, returning main tof0ff59f. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
645da3048e
commit
4e24f9bbb0
@@ -1,7 +1,6 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"apskel-pos-be/internal/constants"
|
||||
"apskel-pos-be/internal/entities"
|
||||
"apskel-pos-be/internal/mappers"
|
||||
"apskel-pos-be/internal/models"
|
||||
@@ -9,55 +8,33 @@ 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 gameReader
|
||||
gamePrizeRepo gamePrizeStock
|
||||
wallet *WalletProcessor
|
||||
spendable spendableReader
|
||||
tx TxRunner
|
||||
gamePlayRepo repository.GamePlayRepository
|
||||
gameRepo *repository.GameRepository
|
||||
gamePrizeRepo *repository.GamePrizeRepository
|
||||
customerTokensRepo *repository.CustomerTokensRepository
|
||||
customerPointsRepo repository.CustomerPointsRepository
|
||||
}
|
||||
|
||||
func NewGamePlayProcessor(
|
||||
gamePlayRepo repository.GamePlayRepository,
|
||||
gameRepo gameReader,
|
||||
gamePrizeRepo gamePrizeStock,
|
||||
wallet *WalletProcessor,
|
||||
spendable spendableReader,
|
||||
tx TxRunner,
|
||||
gameRepo *repository.GameRepository,
|
||||
gamePrizeRepo *repository.GamePrizeRepository,
|
||||
customerTokensRepo *repository.CustomerTokensRepository,
|
||||
customerPointsRepo repository.CustomerPointsRepository,
|
||||
) *GamePlayProcessor {
|
||||
return &GamePlayProcessor{
|
||||
gamePlayRepo: gamePlayRepo,
|
||||
gameRepo: gameRepo,
|
||||
gamePrizeRepo: gamePrizeRepo,
|
||||
wallet: wallet,
|
||||
spendable: spendable,
|
||||
tx: tx,
|
||||
gamePlayRepo: gamePlayRepo,
|
||||
gameRepo: gameRepo,
|
||||
gamePrizeRepo: gamePrizeRepo,
|
||||
customerTokensRepo: customerTokensRepo,
|
||||
customerPointsRepo: customerPointsRepo,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,10 +76,6 @@ 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(
|
||||
@@ -137,140 +110,101 @@ func (p *GamePlayProcessor) ListGamePlays(ctx context.Context, query *models.Lis
|
||||
}, nil
|
||||
}
|
||||
|
||||
// PlayGame plays a game for a customer, paid with EnakCoin (docs/prd-point-coin.md
|
||||
// F8, K1). Every kind of game spends the same EnakCoin, at the game's
|
||||
// metadata.coin_cost. Taking the EnakCoin (GAME_SPEND), recording the play and taking
|
||||
// the prize from stock happen in one transaction, so a play that cannot be completed,
|
||||
// for instance because the prize just ran out, takes nothing and records nothing.
|
||||
// PlayGame handles the game playing logic
|
||||
func (p *GamePlayProcessor) PlayGame(ctx context.Context, req *models.PlayGameRequest) (*models.PlayGameResponse, error) {
|
||||
refuse := func(format string, args ...any) error {
|
||||
return fmt.Errorf("%w: %s", ErrGamePlayRefused, fmt.Sprintf(format, args...))
|
||||
}
|
||||
// Verify game exists and is active
|
||||
game, err := p.gameRepo.GetByID(ctx, req.GameID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("game not found: %w", err)
|
||||
}
|
||||
|
||||
if !game.IsActive {
|
||||
return nil, refuse("game is not active")
|
||||
}
|
||||
cost, err := gameCoinCost(game.Metadata)
|
||||
if err != nil {
|
||||
return nil, refuse("%s", err.Error())
|
||||
return nil, errors.New("game is not active")
|
||||
}
|
||||
|
||||
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)
|
||||
// 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 {
|
||||
return nil, fmt.Errorf("customer tokens not found: %w", err)
|
||||
}
|
||||
|
||||
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)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get available prizes: %w", err)
|
||||
// Log error but don't fail the transaction
|
||||
fmt.Printf("Warning: failed to decrease prize stock: %v\n", err)
|
||||
}
|
||||
if len(availablePrizes) == 0 {
|
||||
return refuse("no prizes available")
|
||||
}
|
||||
prizeResponses := make([]models.GamePrizeResponse, len(availablePrizes))
|
||||
for i, prize := range availablePrizes {
|
||||
prizeResponses[i] = *mappers.ToGamePrizeResponse(&prize)
|
||||
}
|
||||
selectedPrize = p.selectPrizeByWeight(prizeResponses)
|
||||
|
||||
// Random seed kept for audit.
|
||||
randomSeed := fmt.Sprintf("%d", time.Now().UnixNano())
|
||||
gamePlay = &entities.GamePlay{
|
||||
ID: uuid.New(),
|
||||
GameID: req.GameID,
|
||||
CustomerID: req.CustomerID,
|
||||
CoinsUsed: int(cost),
|
||||
RandomSeed: &randomSeed,
|
||||
}
|
||||
if selectedPrize != nil {
|
||||
gamePlay.PrizeID = &selectedPrize.ID
|
||||
}
|
||||
|
||||
// The EnakCoin go first: the debit locks the wallet, so plays by the same
|
||||
// customer at the same time queue up instead of spending the same balance.
|
||||
_, err = p.wallet.Debit(ctx, WalletDebitInput{WalletEntry: WalletEntry{
|
||||
CustomerID: req.CustomerID,
|
||||
Currency: constants.WalletCurrencyCoin,
|
||||
Type: constants.WalletTxTypeGameSpend,
|
||||
Amount: cost,
|
||||
ReferenceType: constants.WalletRefTypeGamePlay,
|
||||
ReferenceID: gamePlay.ID,
|
||||
Description: gameSpendDescription(game.Name, selectedPrize),
|
||||
Metadata: entities.Metadata{"game_id": game.ID.String(), "coin_cost": cost},
|
||||
}})
|
||||
if errors.Is(err, repository.ErrWalletInsufficientBalance) {
|
||||
return refuse("not enough EnakCoin: this game costs %d", cost)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := p.gamePlayRepo.Create(ctx, gamePlay); err != nil {
|
||||
return err
|
||||
}
|
||||
if selectedPrize != nil {
|
||||
err := p.gamePrizeRepo.DecreaseStock(ctx, selectedPrize.ID, 1)
|
||||
if errors.Is(err, repository.ErrGamePrizeOutOfStock) {
|
||||
return refuse("the prize just ran out, please play again")
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
balances, err := p.spendable.SpendableBalances(ctx, req.CustomerID, time.Now())
|
||||
// Get updated token balance
|
||||
updatedTokens, err := p.customerTokensRepo.GetByCustomerIDAndType(ctx, req.CustomerID, tokenType)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("failed to get updated token balance: %w", err)
|
||||
}
|
||||
|
||||
return &models.PlayGameResponse{
|
||||
GamePlay: *mappers.ToGamePlayResponse(gamePlay),
|
||||
PrizeWon: selectedPrize,
|
||||
CoinsRemaining: balances[constants.WalletCurrencyCoin],
|
||||
GamePlay: *mappers.ToGamePlayResponse(gamePlayEntity),
|
||||
PrizeWon: selectedPrize,
|
||||
TokensRemaining: updatedTokens.Balance,
|
||||
}, 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 {
|
||||
|
||||
Reference in New Issue
Block a user