Files
apskel-pos-backend/internal/validator/gamification_validator.go
T
efrilmandClaude Opus 5.5 a18bb072f5 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>
2026-09-30 12:20:42 +07:00

553 lines
15 KiB
Go

package validator
import (
"apskel-pos-be/internal/contract"
"errors"
"strings"
"github.com/go-playground/validator/v10"
)
type GamificationValidator interface {
// Customer Points
ValidateCreateCustomerPointsRequest(req *contract.CreateCustomerPointsRequest) (error, string)
ValidateUpdateCustomerPointsRequest(req *contract.UpdateCustomerPointsRequest) (error, string)
ValidateListCustomerPointsRequest(req *contract.ListCustomerPointsRequest) (error, string)
ValidateAddCustomerPointsRequest(req *contract.AddCustomerPointsRequest) (error, string)
ValidateDeductCustomerPointsRequest(req *contract.DeductCustomerPointsRequest) (error, string)
// Customer Tokens
ValidateCreateCustomerTokensRequest(req *contract.CreateCustomerTokensRequest) (error, string)
ValidateUpdateCustomerTokensRequest(req *contract.UpdateCustomerTokensRequest) (error, string)
ValidateListCustomerTokensRequest(req *contract.ListCustomerTokensRequest) (error, string)
ValidateAddCustomerTokensRequest(req *contract.AddCustomerTokensRequest) (error, string)
ValidateDeductCustomerTokensRequest(req *contract.DeductCustomerTokensRequest) (error, string)
// Tiers
ValidateCreateTierRequest(req *contract.CreateTierRequest) (error, string)
ValidateUpdateTierRequest(req *contract.UpdateTierRequest) (error, string)
ValidateListTiersRequest(req *contract.ListTiersRequest) (error, string)
// Games
ValidateCreateGameRequest(req *contract.CreateGameRequest) (error, string)
ValidateUpdateGameRequest(req *contract.UpdateGameRequest) (error, string)
ValidateListGamesRequest(req *contract.ListGamesRequest) (error, string)
// Game Prizes
ValidateCreateGamePrizeRequest(req *contract.CreateGamePrizeRequest) (error, string)
ValidateUpdateGamePrizeRequest(req *contract.UpdateGamePrizeRequest) (error, string)
ValidateListGamePrizesRequest(req *contract.ListGamePrizesRequest) (error, string)
// Game Plays
ValidateCreateGamePlayRequest(req *contract.CreateGamePlayRequest) (error, string)
ValidateListGamePlaysRequest(req *contract.ListGamePlaysRequest) (error, string)
ValidatePlayGameRequest(req *contract.PlayGameRequest) (error, string)
// Omset Tracker
ValidateCreateOmsetTrackerRequest(req *contract.CreateOmsetTrackerRequest) (error, string)
ValidateUpdateOmsetTrackerRequest(req *contract.UpdateOmsetTrackerRequest) (error, string)
ValidateListOmsetTrackerRequest(req *contract.ListOmsetTrackerRequest) (error, string)
ValidateAddOmsetRequest(req *contract.AddOmsetRequest) (error, string)
}
type GamificationValidatorImpl struct {
validate *validator.Validate
}
func NewGamificationValidator() *GamificationValidatorImpl {
return &GamificationValidatorImpl{
validate: validator.New(),
}
}
// Customer Points Validators
func (v *GamificationValidatorImpl) ValidateCreateCustomerPointsRequest(req *contract.CreateCustomerPointsRequest) (error, string) {
if err := v.validate.Struct(req); err != nil {
return err, "VALIDATION_ERROR"
}
if req.Balance < 0 {
return errors.New("balance cannot be negative"), "INVALID_BALANCE"
}
return nil, ""
}
func (v *GamificationValidatorImpl) ValidateUpdateCustomerPointsRequest(req *contract.UpdateCustomerPointsRequest) (error, string) {
if err := v.validate.Struct(req); err != nil {
return err, "VALIDATION_ERROR"
}
if req.Balance < 0 {
return errors.New("balance cannot be negative"), "INVALID_BALANCE"
}
return nil, ""
}
func (v *GamificationValidatorImpl) ValidateListCustomerPointsRequest(req *contract.ListCustomerPointsRequest) (error, string) {
if err := v.validate.Struct(req); err != nil {
return err, "VALIDATION_ERROR"
}
if req.Page <= 0 {
req.Page = 1
}
if req.Limit <= 0 {
req.Limit = 10
}
if req.Limit > 100 {
req.Limit = 100
}
return nil, ""
}
func (v *GamificationValidatorImpl) ValidateAddCustomerPointsRequest(req *contract.AddCustomerPointsRequest) (error, string) {
if err := v.validate.Struct(req); err != nil {
return err, "VALIDATION_ERROR"
}
if req.Points <= 0 {
return errors.New("points must be greater than 0"), "INVALID_POINTS"
}
return nil, ""
}
func (v *GamificationValidatorImpl) ValidateDeductCustomerPointsRequest(req *contract.DeductCustomerPointsRequest) (error, string) {
if err := v.validate.Struct(req); err != nil {
return err, "VALIDATION_ERROR"
}
if req.Points <= 0 {
return errors.New("points must be greater than 0"), "INVALID_POINTS"
}
return nil, ""
}
// Customer Tokens Validators
func (v *GamificationValidatorImpl) ValidateCreateCustomerTokensRequest(req *contract.CreateCustomerTokensRequest) (error, string) {
if err := v.validate.Struct(req); err != nil {
return err, "VALIDATION_ERROR"
}
if req.Balance < 0 {
return errors.New("balance cannot be negative"), "INVALID_BALANCE"
}
validTokenTypes := []string{"SPIN", "RAFFLE", "MINIGAME"}
if !contains(validTokenTypes, req.TokenType) {
return errors.New("invalid token type"), "INVALID_TOKEN_TYPE"
}
return nil, ""
}
func (v *GamificationValidatorImpl) ValidateUpdateCustomerTokensRequest(req *contract.UpdateCustomerTokensRequest) (error, string) {
if err := v.validate.Struct(req); err != nil {
return err, "VALIDATION_ERROR"
}
if req.Balance < 0 {
return errors.New("balance cannot be negative"), "INVALID_BALANCE"
}
return nil, ""
}
func (v *GamificationValidatorImpl) ValidateListCustomerTokensRequest(req *contract.ListCustomerTokensRequest) (error, string) {
if err := v.validate.Struct(req); err != nil {
return err, "VALIDATION_ERROR"
}
if req.Page <= 0 {
req.Page = 1
}
if req.Limit <= 0 {
req.Limit = 10
}
if req.Limit > 100 {
req.Limit = 100
}
if req.TokenType != "" {
validTokenTypes := []string{"SPIN", "RAFFLE", "MINIGAME"}
if !contains(validTokenTypes, req.TokenType) {
return errors.New("invalid token type"), "INVALID_TOKEN_TYPE"
}
}
return nil, ""
}
func (v *GamificationValidatorImpl) ValidateAddCustomerTokensRequest(req *contract.AddCustomerTokensRequest) (error, string) {
if err := v.validate.Struct(req); err != nil {
return err, "VALIDATION_ERROR"
}
if req.Tokens <= 0 {
return errors.New("tokens must be greater than 0"), "INVALID_TOKENS"
}
return nil, ""
}
func (v *GamificationValidatorImpl) ValidateDeductCustomerTokensRequest(req *contract.DeductCustomerTokensRequest) (error, string) {
if err := v.validate.Struct(req); err != nil {
return err, "VALIDATION_ERROR"
}
if req.Tokens <= 0 {
return errors.New("tokens must be greater than 0"), "INVALID_TOKENS"
}
return nil, ""
}
// Tier Validators
func (v *GamificationValidatorImpl) ValidateCreateTierRequest(req *contract.CreateTierRequest) (error, string) {
if err := v.validate.Struct(req); err != nil {
return err, "VALIDATION_ERROR"
}
if req.Name != "" {
req.Name = strings.TrimSpace(req.Name)
if req.Name == "" {
return errors.New("name cannot be empty or whitespace only"), "INVALID_NAME"
}
if len(req.Name) > 100 {
return errors.New("name cannot exceed 100 characters"), "INVALID_NAME"
}
}
if req.MinPoints < 0 {
return errors.New("min points cannot be negative"), "INVALID_MIN_POINTS"
}
return nil, ""
}
func (v *GamificationValidatorImpl) ValidateUpdateTierRequest(req *contract.UpdateTierRequest) (error, string) {
if err := v.validate.Struct(req); err != nil {
return err, "VALIDATION_ERROR"
}
if req.Name != nil && *req.Name != "" {
*req.Name = strings.TrimSpace(*req.Name)
if *req.Name == "" {
return errors.New("name cannot be empty or whitespace only"), "INVALID_NAME"
}
if len(*req.Name) > 100 {
return errors.New("name cannot exceed 100 characters"), "INVALID_NAME"
}
}
if req.MinPoints != nil && *req.MinPoints < 0 {
return errors.New("min points cannot be negative"), "INVALID_MIN_POINTS"
}
return nil, ""
}
func (v *GamificationValidatorImpl) ValidateListTiersRequest(req *contract.ListTiersRequest) (error, string) {
if err := v.validate.Struct(req); err != nil {
return err, "VALIDATION_ERROR"
}
if req.Page <= 0 {
req.Page = 1
}
if req.Limit <= 0 {
req.Limit = 10
}
if req.Limit > 100 {
req.Limit = 100
}
return nil, ""
}
// Game Validators
func (v *GamificationValidatorImpl) ValidateCreateGameRequest(req *contract.CreateGameRequest) (error, string) {
if err := v.validate.Struct(req); err != nil {
return err, "VALIDATION_ERROR"
}
if req.Name != "" {
req.Name = strings.TrimSpace(req.Name)
if req.Name == "" {
return errors.New("name cannot be empty or whitespace only"), "INVALID_NAME"
}
if len(req.Name) > 255 {
return errors.New("name cannot exceed 255 characters"), "INVALID_NAME"
}
}
validGameTypes := []string{"SPIN", "RAFFLE", "MINIGAME"}
if !contains(validGameTypes, req.Type) {
return errors.New("invalid game type"), "INVALID_GAME_TYPE"
}
return nil, ""
}
func (v *GamificationValidatorImpl) ValidateUpdateGameRequest(req *contract.UpdateGameRequest) (error, string) {
if err := v.validate.Struct(req); err != nil {
return err, "VALIDATION_ERROR"
}
if req.Name != nil && *req.Name != "" {
*req.Name = strings.TrimSpace(*req.Name)
if *req.Name == "" {
return errors.New("name cannot be empty or whitespace only"), "INVALID_NAME"
}
if len(*req.Name) > 255 {
return errors.New("name cannot exceed 255 characters"), "INVALID_NAME"
}
}
if req.Type != nil {
validGameTypes := []string{"SPIN", "RAFFLE", "MINIGAME"}
if !contains(validGameTypes, *req.Type) {
return errors.New("invalid game type"), "INVALID_GAME_TYPE"
}
}
return nil, ""
}
func (v *GamificationValidatorImpl) ValidateListGamesRequest(req *contract.ListGamesRequest) (error, string) {
if err := v.validate.Struct(req); err != nil {
return err, "VALIDATION_ERROR"
}
if req.Page <= 0 {
req.Page = 1
}
if req.Limit <= 0 {
req.Limit = 10
}
if req.Limit > 100 {
req.Limit = 100
}
if req.Type != "" {
validGameTypes := []string{"SPIN", "RAFFLE", "MINIGAME"}
if !contains(validGameTypes, req.Type) {
return errors.New("invalid game type"), "INVALID_GAME_TYPE"
}
}
return nil, ""
}
// Game Prize Validators
func (v *GamificationValidatorImpl) ValidateCreateGamePrizeRequest(req *contract.CreateGamePrizeRequest) (error, string) {
if err := v.validate.Struct(req); err != nil {
return err, "VALIDATION_ERROR"
}
if req.Name != "" {
req.Name = strings.TrimSpace(req.Name)
if req.Name == "" {
return errors.New("name cannot be empty or whitespace only"), "INVALID_NAME"
}
if len(req.Name) > 255 {
return errors.New("name cannot exceed 255 characters"), "INVALID_NAME"
}
}
if req.Weight <= 0 {
return errors.New("weight must be greater than 0"), "INVALID_WEIGHT"
}
if req.Stock < 0 {
return errors.New("stock cannot be negative"), "INVALID_STOCK"
}
if req.MaxStock != nil && *req.MaxStock <= 0 {
return errors.New("max stock must be greater than 0"), "INVALID_MAX_STOCK"
}
if req.Threshold != nil && *req.Threshold < 0 {
return errors.New("threshold cannot be negative"), "INVALID_THRESHOLD"
}
return nil, ""
}
func (v *GamificationValidatorImpl) ValidateUpdateGamePrizeRequest(req *contract.UpdateGamePrizeRequest) (error, string) {
if err := v.validate.Struct(req); err != nil {
return err, "VALIDATION_ERROR"
}
if req.Name != nil && *req.Name != "" {
*req.Name = strings.TrimSpace(*req.Name)
if *req.Name == "" {
return errors.New("name cannot be empty or whitespace only"), "INVALID_NAME"
}
if len(*req.Name) > 255 {
return errors.New("name cannot exceed 255 characters"), "INVALID_NAME"
}
}
if req.Weight != nil && *req.Weight <= 0 {
return errors.New("weight must be greater than 0"), "INVALID_WEIGHT"
}
if req.Stock != nil && *req.Stock < 0 {
return errors.New("stock cannot be negative"), "INVALID_STOCK"
}
if req.MaxStock != nil && *req.MaxStock <= 0 {
return errors.New("max stock must be greater than 0"), "INVALID_MAX_STOCK"
}
if req.Threshold != nil && *req.Threshold < 0 {
return errors.New("threshold cannot be negative"), "INVALID_THRESHOLD"
}
return nil, ""
}
func (v *GamificationValidatorImpl) ValidateListGamePrizesRequest(req *contract.ListGamePrizesRequest) (error, string) {
if err := v.validate.Struct(req); err != nil {
return err, "VALIDATION_ERROR"
}
if req.Page <= 0 {
req.Page = 1
}
if req.Limit <= 0 {
req.Limit = 10
}
if req.Limit > 100 {
req.Limit = 100
}
return nil, ""
}
// Game Play Validators
func (v *GamificationValidatorImpl) ValidateCreateGamePlayRequest(req *contract.CreateGamePlayRequest) (error, string) {
if err := v.validate.Struct(req); err != nil {
return err, "VALIDATION_ERROR"
}
if req.CoinsUsed < 0 {
return errors.New("coins used cannot be negative"), "INVALID_COINS_USED"
}
return nil, ""
}
func (v *GamificationValidatorImpl) ValidateListGamePlaysRequest(req *contract.ListGamePlaysRequest) (error, string) {
if err := v.validate.Struct(req); err != nil {
return err, "VALIDATION_ERROR"
}
if req.Page <= 0 {
req.Page = 1
}
if req.Limit <= 0 {
req.Limit = 10
}
if req.Limit > 100 {
req.Limit = 100
}
return nil, ""
}
func (v *GamificationValidatorImpl) ValidatePlayGameRequest(req *contract.PlayGameRequest) (error, string) {
if err := v.validate.Struct(req); err != nil {
return err, "VALIDATION_ERROR"
}
return nil, ""
}
// Omset Tracker Validators
func (v *GamificationValidatorImpl) ValidateCreateOmsetTrackerRequest(req *contract.CreateOmsetTrackerRequest) (error, string) {
if err := v.validate.Struct(req); err != nil {
return err, "VALIDATION_ERROR"
}
validPeriodTypes := []string{"DAILY", "WEEKLY", "MONTHLY", "TOTAL"}
if !contains(validPeriodTypes, req.PeriodType) {
return errors.New("invalid period type"), "INVALID_PERIOD_TYPE"
}
if req.Total < 0 {
return errors.New("total cannot be negative"), "INVALID_TOTAL"
}
if req.PeriodEnd.Before(req.PeriodStart) {
return errors.New("period end must be after period start"), "INVALID_PERIOD"
}
return nil, ""
}
func (v *GamificationValidatorImpl) ValidateUpdateOmsetTrackerRequest(req *contract.UpdateOmsetTrackerRequest) (error, string) {
if err := v.validate.Struct(req); err != nil {
return err, "VALIDATION_ERROR"
}
if req.PeriodType != nil {
validPeriodTypes := []string{"DAILY", "WEEKLY", "MONTHLY", "TOTAL"}
if !contains(validPeriodTypes, *req.PeriodType) {
return errors.New("invalid period type"), "INVALID_PERIOD_TYPE"
}
}
if req.Total != nil && *req.Total < 0 {
return errors.New("total cannot be negative"), "INVALID_TOTAL"
}
if req.PeriodStart != nil && req.PeriodEnd != nil && req.PeriodEnd.Before(*req.PeriodStart) {
return errors.New("period end must be after period start"), "INVALID_PERIOD"
}
return nil, ""
}
func (v *GamificationValidatorImpl) ValidateListOmsetTrackerRequest(req *contract.ListOmsetTrackerRequest) (error, string) {
if err := v.validate.Struct(req); err != nil {
return err, "VALIDATION_ERROR"
}
if req.Page <= 0 {
req.Page = 1
}
if req.Limit <= 0 {
req.Limit = 10
}
if req.Limit > 100 {
req.Limit = 100
}
if req.PeriodType != "" {
validPeriodTypes := []string{"DAILY", "WEEKLY", "MONTHLY", "TOTAL"}
if !contains(validPeriodTypes, req.PeriodType) {
return errors.New("invalid period type"), "INVALID_PERIOD_TYPE"
}
}
return nil, ""
}
func (v *GamificationValidatorImpl) ValidateAddOmsetRequest(req *contract.AddOmsetRequest) (error, string) {
if err := v.validate.Struct(req); err != nil {
return err, "VALIDATION_ERROR"
}
if req.Amount <= 0 {
return errors.New("amount must be greater than 0"), "INVALID_AMOUNT"
}
return nil, ""
}